diff --git a/.agents/skills/agent-core-dev/SKILL.md b/.agents/skills/agent-core-dev/SKILL.md new file mode 100644 index 000000000..d0f24b29c --- /dev/null +++ b/.agents/skills/agent-core-dev/SKILL.md @@ -0,0 +1,70 @@ +--- +name: agent-core-dev +description: Use when developing in packages/agent-core-v2 (the DI × Scope agent engine) — adding or modifying a domain Service, choosing a LifecycleScope, wiring DI dependencies, splitting a domain across scopes, owning or migrating a config section, gating behavior behind an experimental flag, raising coded errors, working on the permission system, writing DI/Scope tests, porting business logic from agent-core (v1) to v2, triaging a main-branch commit against v2, or exposing a v2 domain over server-v2 while keeping the /api/v1 wire contract compatible with released clients. Self-contained guide organized by development stage (orient → design → implement → test → verify) plus align workflows for v1→v2 migration, main-branch commit triage, and server-v2 wire exposure; each file carries the rules, examples, and red lines for its step. +--- + +# agent-core-dev + +> Develop `packages/agent-core-v2` by lifecycle stage. This skill is **self-contained**: every rule, recipe, and red line lives in the stage files below — it does not delegate to `packages/agent-core-v2/docs/`. + +`agent-core-v2` is the new agent engine built on the **DI × Scope** architecture (a port of `packages/agent-core`). Everything resolves through the container: a service declares an **identity**, its **dependencies**, and a **lifetime**; the container decides construction, singleton-per-scope, ordering, and disposal. The stage files restate the rules in imperative form so you can work without reading the source docs. + +## Lifecycle at a glance + +```text +Orient → Design → Implement → Test → Verify + │ │ │ │ │ + │ │ │ │ └─ lint:domain · typecheck · test · dep graph · red lines + │ │ │ └─ test.md + │ │ └─ implement.md (+ errors.md · flags.md · permission.md) + │ └─ design.md + └─ orient.md +``` + +Stages are ordered but not strictly linear: a test failure (stage 4) that reveals a wrong scope sends you back to design (stage 2); a `CyclicDependencyError` sends you to `design.md` §dependency-direction and `implement.md` §cycles. + +## Workflows + +End-to-end procedures that span the stages. Reach for these before reading the stage files individually. + +- [Align (port `agent-core` → `agent-core-v2`)](align.md): split a v1 class into semantic units, fix each unit's domain / scope / Service / dependencies, then migrate the logic and tests. Use when the task is "move feature X from v1 to v2" or "port `IXxxService` to v2". +- [Commit align (triage a `main` commit against v2)](commit-align.md): given one `main` commit hash + a short note, find the v1 logic it changed, check whether v2 already has the corresponding implementation, bucket it (aligned / partial / missing / not-applicable), and recommend a minimal fix. Use in the `kimi-code-v2`-catching-up-to-`main` phase, for one commit at a time; escalate to [align.md](align.md) if the gap is a whole domain. +- [Server align (expose `agent-core-v2` over `server-v2`)](server-align.md): wire a v2 domain into `packages/kap-server` over `/api/v2` (native) and `/api/v1` (v1-compatible mirror), keep the wire schema byte-compatible with the established v1 contract by sharing the `@moonshot-ai/protocol` schema, and isolate v1-only behavior in a `Legacy` edge adapter instead of distorting the native v2 Service. Use when the task is "expose the new v2 Service on the server", "add a route to the `/api/v1` surface", or "keep server-v2 wire-compatible with released v1 clients". + +## Stages + +- [Stage 1 — Orient](orient.md): the DI black box (identity / dependencies / lifetime), the four `LifecycleScope` tiers and visibility, and the file-header comment convention. Read before touching business code. +- [Stage 2 — Design a service](design.md): pick a scope, split a domain across scopes, choose a calling style (direct call vs event vs hook), and direct dependencies. Decide *where things live and who knows whom* before coding. + - Topic: [Domain boundaries vs Scope](domain-boundaries.md) — keep `session` / `agent` / `turn` from becoming god objects; data-ownership test and their split conclusions. + - Topic: [Persistence layering](persistence.md) — the three-layer `Store → Storage → backend` model, naming Stores by access pattern, and which layer business code should depend on. + - Topic: [Edge exposure — `resource:action` + WS events](edge-exposure.md) — which Services are exposed over `/api/v2` (per-scope action map) and which events stream over WS; what to wrap in a facade. +- [Stage 3 — Implement](implement.md): the standard Service recipe and the DI building blocks — interface + identity, constructor injection, scoped registration, `Disposable`, eager vs delayed, `invokeFunction`, `createInstance`, child scopes, and the cycle-refactor playbook. + - Topic: [Service authoring](service-authoring.md) — file layout, naming, contract vs impl contents, interface style, constructor/field conventions, events, multi-Service domains, comment rules. + - Topic: [Config](config.md) — the section-registry model, App vs Session split, owning a config section, the TOML format, and the env overlay. + - Topic: [Errors](errors.md) — co-located `XxxError`, the central code registry, wire serialization, boundary translation. + - Topic: [Flags](flags.md) — `registerFlagDefinition`, `IFlagService.enabled(id)`, the `[experimental]` config section, resolution precedence. + - Topic: [Permission](permission.md) — composable chain-of-responsibility kernel, policy registry + composer, `modes`/`agentTypes` metadata, `resolveExecution`/`accesses`. + - Topic: [Telemetry](telemetry.md) — emitting events via `ITelemetryService`, context propagation, and appender destinations (`ConsoleAppender` / `CloudAppender`). +- [Stage 4 — Test](test.md): resolve the system under test by interface, pick `TestInstantiationService` vs `createScopedTestHost`, shared stubs, service groups, teardown. +- [Stage 5 — Verify & submit](verify.md): `lint:domain`, `typecheck`, `test`, and the pre-submit checklist. + +## How to use this skill + +Jump to the stage you are in and read that one file; each is self-contained and ends with its own red lines. Skim the global red lines below before submitting — they catch most mistakes across every stage. The repo's source of truth remains the code in `packages/agent-core-v2/src/`; this skill codifies the same rules so you do not have to re-derive them. + +## Global red lines + +Invariants that hold across every stage. Each is expanded in the stage file noted. + +1. No `new` on a class whose constructor carries `@IService` deps — inject with `@IX` or `accessor.get(IX)`. (implement.md) +2. `@IX` decorates constructor parameters only; parameter order depends on construction (static-first for `createInstance`, `@IX`-first for scoped services). (service-authoring.md) +3. Both interface and impl carry `_serviceBrand`; the `createDecorator` name is globally unique. (implement.md) +4. Parent scope never depends on child scope — short-lived may inject long-lived, never the reverse. (orient.md) +5. No cyclic dependencies — refactor (extract a third Service / use an event / re-scope); do not break the cycle with `Delayed`. (design.md, implement.md) +6. `ServicesAccessor` is valid only during `invokeFunction` — never stash it for async use. (implement.md) +7. Scope follows state identity — no `Map` at `App` to fake per-session state. (design.md) +8. Foundational layers never know upstream ones; business code never depends on the edge layer (`gateway`/`rpc`). (design.md) +9. Throw coded errors; register codes centrally; branch on `code` across the wire, never `instanceof`. (errors.md) +10. Gate unreleased behavior behind a flag contributed via `registerFlagDefinition` and resolved through `IFlagService.enabled(id)`; no ad-hoc env toggles. (flags.md) +11. Tests resolve the SUT by interface; shared stubs live under `test/`, never `src/`. (test.md) +12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService` (kept domain-agnostic — never add cron/flags/model state); session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md) diff --git a/.agents/skills/agent-core-dev/align.md b/.agents/skills/agent-core-dev/align.md new file mode 100644 index 000000000..50c1c4936 --- /dev/null +++ b/.agents/skills/agent-core-dev/align.md @@ -0,0 +1,235 @@ +# Subskill — Align (port `agent-core` → `agent-core-v2`) + +Port business logic from `packages/agent-core` (v1) into `packages/agent-core-v2` (v2) by **splitting semantics, then fixing the domain, scope, Service, and dependency relationships**, and finally migrating the logic and tests. + +Use this when the task is "move feature X from v1 to v2", "port `IXxxService` to v2", or "align a v1 domain with the v2 architecture". It complements the stage files: orient / design / implement / test explain the *target* architecture; this file explains how to get there *from v1*. + +## The one-paragraph mental model + +v1 is a **VSCode-style singleton container**: services self-register with `registerSingleton`, resolve as singleton-per-container, and have no explicit lifetime tier — so a single `ISessionService` / `IToolService` tends to accumulate global, per-session, and per-agent state in one class. v2 is a **DI × Scope tree**: every service binds to one of `App` / `Session` / `Agent`, and a domain with state at several lifetimes is split into several Services. Porting is therefore **not** a file copy — it is "find each lifetime of state hiding in the v1 class, give each its own v2 Service at the right scope, then re-wire the dependencies". + +## v1 → v2 at a glance + +| Concern | v1 (`agent-core`) | v2 (`agent-core-v2`) | +|---|---|---| +| Registration | `registerSingleton(IX, X, InstantiationType.Delayed)` | `registerScopedService(LifecycleScope.X, IX, X, InstantiationType.Delayed, 'domain')` | +| DI import | `from '../../di'` | `from '#/_base/di/scope'` / `'#/_base/di/instantiation'` / `'#/_base/di/extensions'` / `'#/_base/di/lifecycle'` | +| Lifetime | implicit singleton-per-container | explicit `LifecycleScope` (App/Session/Agent) — see orient.md | +| Domain granularity | coarse (`session`, `tool`, `loop`) | fine, split by scope + responsibility | +| Test import | `from '@moonshot-ai/agent-core/di/test'` | `from '#/_base/di/test'` | +| Resolve SUT in tests | `ix.createInstance(Impl)` (common) | `ix.get(IX)` by interface — see test.md | +| Scope tests | none | `createScopedTestHost` — see test.md | +| Errors | `from '../../errors'` (central `KimiError`, `ErrorCodes`) | `from '#/_base/errors'` + domain co-located `XxxError` — see errors.md | +| Flags | `flags/` (process-global `FlagResolver`) | `flag/` (App-scope `IFlagService`) — see flags.md | +| Permission | `agent/permission/` (hardcoded chain) | `permission*` (registry + composer) — see permission.md | + +## The align workflow + +```text +Read v1 → Semantic split → Map domain → Assign scope → Shape Services + → Direct dependencies → Port logic → Port tests → Verify +``` + +Each step below states the goal and the concrete action, then points to the stage file that goes deeper. Do them in order; a later step often sends you back to an earlier one (a scope that does not fit means the semantic split was wrong). + +### 1. Read v1 + +**Goal:** build an accurate inventory of what the v1 code actually owns. Read the v1 *source*, not v1 docs. + +Actions: + +- Locate the v1 entry: contract (`/.ts`) + impl (`/Service.ts`), plus any helpers under the same folder. +- Inventory three things from the impl: + - **State** — every field / `Map` / cache the class holds. For each, note its *identity* (global? keyed by `sessionId`? by `agentId`?). + - **Behavior** — every public method; group them by which state they touch. + - **Dependencies** — every `@IFoo` constructor injection and every cross-domain relative import (`from '..//...'`). +- Note the v1 registration line (`registerSingleton(...)`) and any `services.set(IX, ...)` overrides at bootstrap (these reveal runtime static args or prebuilt instances the port must preserve). + +Do not start splitting yet — an accurate inventory prevents the common mistake of porting the class shape instead of the semantics. + +### 2. Semantic split + +**Goal:** break one v1 class into independent semantic units, each owning state at exactly one lifetime. This is the heart of the port. + +Method — for each piece of state from the inventory, ask: + +1. **What is it keyed by?** nothing → a global unit; `sessionId` → a per-session unit; `agentId` → a per-agent unit. +2. **When should it die?** with the process / the session / the agent. State that must outlive its neighbors is a different unit. +3. **Which methods touch only this state?** they travel with the unit. + +Worked example — v1 `ISessionService` (one class, ~600 lines) holds: + +- a global index of all sessions → **global** unit → v2 `sessionStore` (`ISessionStore`, App); +- this session's metadata → **per-session** unit → v2 `sessionMetaStore` (`ISessionMetaStore`, Session); +- this session's activity / status → **per-session** unit → v2 `sessionActivity`; +- this session's context projection → **per-session** unit → v2 `sessionContext`; +- child-agent lifecycle driven by a session → **per-session** unit → v2 `agentLifecycle`; create/close/archive/fork of the session itself → **global** unit → v2 `sessionLifecycle` (App). + +A v1 class that maps cleanly to one v1 decorator often becomes **three to five** v2 Services. That is expected and correct — do not try to keep the v1 class shape. + +Red lines: + +- If two pieces of state have different identities, they belong in different units — do not keep them together "because v1 did". +- Do not split by method count or file aesthetics; split by state identity (design.md §3). +- If a unit has no mutable state (pure behavior), defer its scope decision to step 4 (it is pulled down by its shortest-lived dependency). + +### 3. Map to v2 domain + +**Goal:** assign each semantic unit to a v2 domain — an existing one if it fits, a new one only if none does. + +Actions: + +- Search v2 `src/` for an existing domain that owns the same responsibility. Prefer joining an existing domain over creating a new one. +- If creating a domain, name it after the responsibility (camelCase folder, e.g. `sessionActivity`), not after the v1 file. +- Keep a domain's public surface to one contract file (`.ts`) plus its impl(s). + +Reference mapping (a **starting point**, not gospel — verify against the current v2 `src/`, which is the source of truth): + +| v1 location | v2 domain(s) | +|---|---| +| `services/session/`, `session/` | `session`, `sessionStore`, `sessionMetaStore`, `sessionActivity`, `sessionContext`, `agentLifecycle` | +| `services/tool/`, `tools/`, `agent/tool/` | `toolRegistry`, `toolStore`, `toolExecutor`, `tooldedup`, `userTool` | +| `loop/`, `agent/` (turn loop) | `loop`, `llmRequester`, `llmRequestLog`, `turn` | +| `agent/context/`, `agent/compaction/` | `contextMemory`, `contextProjector`, `contextSize`, `fullCompaction`, `dynamicInjector` | +| `agent/permission/` | `permission`, `permissionMode`, `permissionPolicy`, `permissionRules`, `approval`, `externalHooks` | +| `agent/goal/`, `agent/plan/`, `agent/swarm/`, `agent/cron/`, `agent/background/` | `goal`, `plan`, `swarm`, `cron`, `background`, `subagentHost` | +| `services/config/`, `agent/config/` | `config` | +| `services/event/`, `base/common/event` | `event`, `eventBus` | +| `services/logger/`, `logging/` | `log` | +| `services/fileStore/` | `filestore`, `blobStore` | +| `services/fs/`, `services/workspace/` | `fs`, `workspace` | +| `services/auth/`, `services/oauth/` | `auth` | +| `services/environment/` | `environment` | +| `services/terminal/` | `terminal` | +| `services/question/`, `services/approval/` | `question`, `approval` | +| `services/prompt/`, `agent/injection/` | `prompt`, `dynamicInjector` | +| `services/mcp/`, `mcp/` | `mcp` | +| `plugin/`, `profile/`, `skill/` | `plugin`, `profile`, `skill` | +| `rpc/`, `services/coreProcess/` | `rpc`, `gateway` | +| `di/` | `_base/di` | +| `errors/`, `errors.ts` | `_base/errors` + co-located domain errors | +| `flags/` | `flag` | +| `telemetry.ts` | `telemetry` | +| `agent/records/` | (records split) — verify in v2 `src/` | + +When the table says "verify", or when v1 and v2 have diverged, **read the v2 `src/` tree and decide from the code** — do not invent a mapping. + +### 4. Assign scope + +For each semantic unit, fix its `LifecycleScope` from the identity you found in step 2. Follow design.md §2 verbatim: + +- global → `App`; per `sessionId` → `Session`; per `agentId` → `Agent`. +- Stateless unit → default to `App`, pulled down only by a shorter-lived dependency. +- Self-check: "when this scope is disposed, should this state disappear with it?" + +This is the decision v1 never had to make — get it right before writing any v2 code, because the scope is fixed at registration and changing it later ripples through every consumer. + +### 5. Shape Services + +Decide the Service shape per unit, following design.md §3: + +- A unit that owns **one instance's** state → a single per-instance Service (`ISessionXxx` / `IAgentXxx`). +- A unit that owns a **global view plus per-instance** state → split into an `App` registry/factory (`XxxStore` / `XxxRegistry` / `XxxCatalog`) **and** a per-instance Service. The `App` half creates or locates the per-instance half. +- Do not pre-split a unit that has state at only one lifetime. + +Most consumers inject the per-instance Service; inject the `App` factory only for genuine cross-instance management. + +### 6. Direct dependencies + +Re-wire the dependencies you inventoried in step 1, now across the new v2 Services. Follow design.md §4–§5: + +- **Calling style** — need a result / I orchestrate → direct call (`@IX` injection); stating a fact → event; ordered participation that may veto → hook. +- **Scope direction** — a Service may inject only its own scope or an ancestor. If an `App` Service needs something from a `Session` Service, the dependency is backwards: re-scope or invert into an event. +- **Domain direction** — foundational layers must not know upstream ones. A cycle means a v1 relative import is now pointing the wrong way; extract a third Service or invert the notification into an event. +- **Durable facts** — state changes that must be recorded / replayed / projected across agents go on the wire (`wireRecord`), not a direct call alone. + +Run `lint:domain` (verify.md) as soon as the dependencies compile — it catches direction violations early. + +### 7. Port the business logic + +Move the behavior into the shaped v2 Services, applying the mechanical conversions below. Follow implement.md for the recipe. + +**Registration:** + +```ts +// v1 +import { InstantiationType, registerSingleton } from '../../di'; +registerSingleton(IXxxService, XxxService, InstantiationType.Delayed); + +// v2 +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +registerScopedService(LifecycleScope.Session, IXxxService, XxxService, InstantiationType.Delayed, 'xxx'); +``` + +**Imports:** + +```ts +// v1 +import { createDecorator, Disposable, IInstantiationService } from '../../di'; +import { KimiError, ErrorCodes } from '../../errors'; + +// v2 +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; +import { IInstantiationService } from '#/_base/di/instantiation'; +import { KimiError, type ErrorCode } from '#/_base/errors'; +``` + +**Constructor injection** — unchanged in shape (`@IX` on constructor params, service params after static params). Verify each dependency is resolvable from the new scope (step 6). + +**Errors** — move any shared error into a co-located `XxxError extends KimiError` with a registered `code` (errors.md). Do not keep throwing v1's central error codes from a v2 domain. + +**Flags** — replace any `FlagResolver` / env check with `IFlagService.enabled(id)`; contribute new flags from the owning domain's `flag.ts` via `registerFlagDefinition` (flags.md). + +**Events** — v1's `Emitter` / `Event` from `base/common/event` maps to v2's `event` / `eventBus` domains. Read existing v2 usage in neighboring domains and match it; do not import v1's `Emitter`. + +**Runtime static args / prebuilt instances** — if v1 bootstrap did `services.set(IX, new SyncDescriptor(C, [bag]))` or set a prebuilt instance, preserve that behavior at the v2 composition root (the scope that owns the Service). Do not silently drop it. + +Red lines: + +- Do not copy a v1 file and "fix imports". Re-split first (steps 2–6); a straight copy carries v1's implicit-singleton assumptions into v2 and creates the `Map`-at-`App` anti-pattern. +- Do not leave v1 relative imports (`from '../x/...'`) in v2 — use the `#/...` alias and respect the domain layers. +- Do not preserve a v1 behavior just because it exists; if the split reveals it was a workaround for the missing scope tree, drop it. + +### 8. Port the tests + +Convert v1 tests to the v2 harness, following test.md: + +```ts +// v1 +import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test'; +const svc = ix.createInstance(XxxService, 'static-arg'); + +// v2 +import { createServices } from '#/_base/di/test'; +// in additionalServices: +reg.define(IXxxService, XxxService); +// in the test body: +const svc = ix.get(IXxxService); +``` + +- Resolve the SUT by interface (`ix.get(IX)`), never `new` a `@IService`-carrying impl, and prefer `ix.get(IX)` over `ix.createInstance(Impl)`. +- Move shared stubs into `test//stubs.ts`; import by relative path, never `#/...`. +- If the port introduced scope-layer behavior, add a `createScopedTestHost` test that asserts resolution from the correct scope (with `_clearScopedRegistryForTests()` + explicit re-registration in `beforeEach`). +- Keep v1's behavioral assertions where they still describe observable behavior; delete assertions that only checked v1's internal class shape. + +## Migration checklist + +Before submitting a port: + +- [ ] Every piece of v1 state landed in a v2 Service whose scope matches its identity (no `Map` at `App`). +- [ ] Each v1 dependency now points in the right scope and domain direction; `lint:domain` passes. +- [ ] Registrations use `registerScopedService` with an explicit scope and domain name; no `registerSingleton` remains. +- [ ] Imports use the `#/...` alias; no v1 relative (`../../di`, `../../errors`) imports remain. +- [ ] Errors are co-located coded errors; flags go through `IFlagService`. +- [ ] Tests resolve the SUT by interface; scope behavior is asserted via `createScopedTestHost`; teardown goes through one `DisposableStore`. +- [ ] v1 bootstrap overrides (`services.set(...)`) are preserved at the v2 composition root. + +## Red lines (this subskill) + +- Porting is semantic splitting, not file copying — never preserve a v1 class shape in v2. +- Decide scope from state identity before writing v2 code; the scope is fixed at registration. +- Verify the domain mapping against current v2 `src/`; the table here is a starting point, not authority. +- One Service owns state at exactly one lifetime; split global-view + per-instance into registry + per-instance. +- A dependency cycle introduced by the port means a v1 import is now backwards — refactor, do not route around it with `Delayed`. diff --git a/.agents/skills/agent-core-dev/close-vs-dispose.md b/.agents/skills/agent-core-dev/close-vs-dispose.md new file mode 100644 index 000000000..05251e31f --- /dev/null +++ b/.agents/skills/agent-core-dev/close-vs-dispose.md @@ -0,0 +1,155 @@ +# Topic — Close vs Dispose + +How to shut down a scoped service in `agent-core-v2`: when `dispose()` is enough, when to add an async `close()`, and where cancellation / abort belongs. Read this before putting business shutdown logic into a `Disposable`. + +## The one-sentence rule + +> **`close()` is async business shutdown; `dispose()` is synchronous resource cleanup.** + +`close()` finishes a domain's work: stop in-flight operations, apply shutdown policy, flush persistence, release async resources. `dispose()` releases object resources: event subscriptions, timers, hook registrations, and child disposables. + +## Why they must stay separate + +`IDisposable.dispose()` is synchronous: + +```ts +export interface IDisposable { + dispose(): void; +} +``` + +The container calls it during scope teardown. Disposal order is deterministic (orient.md): child scopes first, then reverse construction order within a scope. Nothing awaits a Promise returned from `dispose()`. + +Business shutdown is usually async. It may need to: + +- stop in-flight tasks and wait for settlement; +- decide policy (`kill` vs `keepAliveOnExit` vs `markLost`); +- flush write queues and persistence; +- emit final records / events / telemetry; +- close sockets, child processes, or external clients. + +If that logic lives in `dispose()`, it becomes fire-and-forget: the scope keeps tearing down, dependencies may be disposed immediately afterward, and the async continuation can run against a half-dead object graph. + +## What `close()` owns + +Add `close(): Promise` when a service owns async shutdown work: + +```ts +export interface IXxxService { + readonly _serviceBrand: undefined; + close(reason?: string): Promise; +} +``` + +A good `close()`: + +- is idempotent — repeated calls return the same Promise or no-op; +- is called by lifecycle code **before** `scope.dispose()`; +- rejects new work after it starts; +- applies shutdown policy explicitly; +- awaits the work it starts; +- leaves `dispose()` with only synchronous cleanup. + +Sketch: + +```ts +class XxxService extends Disposable implements IXxxService { + declare readonly _serviceBrand: undefined; + private closed = false; + + async close(reason = 'scope closed'): Promise { + if (this.closed) return; + this.closed = true; + + await this.stopInFlightWork(reason); + await this.flushPersistence(); + } + + override dispose(): void { + this.closed = true; + // synchronous cleanup only: clear timers, remove listeners, release handles. + super.dispose(); + } +} +``` + +`flush()` is different from `close()`: `flush()` persists buffered state while the service stays open; `close()` is terminal. + +## What `dispose()` owns + +`dispose()` releases resources owned by the object instance: + +```ts +class WSBroadcastService extends Disposable implements IWSBroadcastService { + declare readonly _serviceBrand: undefined; + + constructor(@IEventService event: IEventService) { + super(); + this._register(event.subscribe(() => { /* … */ })); + } +} +``` + +Use `dispose()` to: + +- `_register(...)` event subscriptions and hook registrations; +- clear timers; +- remove signal listeners; +- dispose child `IDisposable`s; +- detach from synchronous handles. + +`dispose()` must be idempotent and should avoid throwing. If `close()` was already called, `dispose()` should be a no-op for business work and only clean resources. + +## Where abort / cancellation belongs + +Cancellation is not the same thing as graceful shutdown. + +For an operation-scoped object, a cancellation trigger can be disposed: + +```ts +const tokenSource = new CancellationTokenSource(); +store.add(toDisposable(() => tokenSource.cancel())); +``` + +This is fine when the contract is **fire-and-forget cancel**: the operation observes the token and settles asynchronously; disposal does not wait for completion. + +For a manager/service that owns many tasks and their state, do not use `dispose()` as the graceful abort path. Expose `stop()` / `stopAll()` / `close()` and let lifecycle code await the one it needs. + +Background-specific rule: a `background`-style service may use `AbortController` internally to propagate cancellation to process / agent / question tasks, but manager shutdown belongs in `close()` or explicit `stopAll()`. `dispose()` may best-effort abort controllers only as a safety net; it must not be the mechanism that decides terminal status, persistence, or notifications. + +## Decision tree + +```text +What does the service own? + │ + ├─ only event subscriptions / timers / disposable handles? + │ └─ extend Disposable; no close() needed. + │ + ├─ async work, in-flight tasks, persistence buffers, sockets, child processes? + │ └─ add close(): Promise; call it before scope.dispose(). + │ + ├─ a single operation that callers may cancel? + │ └─ expose an AbortSignal / CancellationToken or a fire-and-forget cancel handle. + │ + └─ both async shutdown and disposable resources? + └─ close() for business shutdown; dispose() for resource cleanup. +``` + +## VSCode parallel + +VSCode uses the same split: + +- `src/vs/base/common/lifecycle.ts` — `IDisposable.dispose(): void` for synchronous cleanup. +- `src/vs/base/parts/storage/common/storage.ts` — `close(): Promise` flushes and closes the database. +- `src/vs/base/common/cancellation.ts` — `CancellationTokenSource.dispose(true)` / `cancelOnDispose()` cancels operation-scoped work without awaiting it. + +The lesson is not "never cancel in dispose". It is: **disposal may trigger cancellation for a scoped operation, but service shutdown policy stays in an explicit async close path.** + +## Red lines (this topic) + +- Do not put business shutdown in `dispose()` — `dispose()` is synchronous and is not awaited. +- Do not `await` inside `dispose()`. +- Do not rely on `dispose()` to flush persistence, emit final events, wait for tasks, or send notifications. +- Add `close(): Promise` for async shutdown and call it before `scope.dispose()`. +- Keep `close()` and `dispose()` idempotent; `dispose()` after `close()` must be safe. +- Use disposal as a cancellation trigger only for operation-scoped work, not as a manager/service shutdown policy. diff --git a/.agents/skills/agent-core-dev/commit-align.md b/.agents/skills/agent-core-dev/commit-align.md new file mode 100644 index 000000000..d0fde63d9 --- /dev/null +++ b/.agents/skills/agent-core-dev/commit-align.md @@ -0,0 +1,78 @@ +# Subskill — Commit align (triage a `main` commit against v2) + +Context: you are on the `kimi-code-v2` branch, in the phase of catching it up to **new commits that landed on `main`**. Those commits change `packages/agent-core` (v1); the job is to decide, for one commit at a time, whether v2 (`packages/agent-core-v2`) already has the corresponding logic — and if not, what the minimal fix is. + +Use this when the user hands you **one commit hash plus a short description** ("look at `` — it fixed the steering race"). It is the small, per-commit sibling of [align.md](align.md): `align.md` ports a whole v1 domain into v2; this file triages a single `main` commit and says *port / adapt / skip*. If the triage reveals a whole missing domain, stop and switch to [align.md](align.md). + +## The one-paragraph mental model + +A `main` commit edits v1's singleton-container code. The same behavior in v2 lives behind a scoped Service, so a commit lands in one of four buckets: **already-aligned** (v2 has it, possibly by construction), **partial** (v2 has a nearby version whose semantics drift), **missing** (v2 has nothing), or **not-applicable** (the v2 architecture removed the very problem the commit fixes). Your output is a bucket assignment plus evidence, then a fix sized to that bucket — never a blind port of the diff. + +## The workflow + +```text +Read the commit + the user's note → Locate the v1 logic → Map to a v2 domain +→ Check v2 for a corresponding implementation → Bucket it → Recommend a fix → Verify +``` + +### 1. Read the commit and the note + +**Goal:** know exactly what changed in v1 and *why*. The user's one-liner gives the intent; the diff gives the facts. + +Actions: + +- Inspect the change scoped to v1: `git show -- packages/agent-core` (and `--stat` first to see the blast radius). +- From the diff, list: touched files, changed functions/methods, and the observable behavior delta (before → after). +- Reconcile with the user's note: is this a bugfix, a semantic correction, new behavior, or a refactor? The *why* decides whether v2 even needs the change. + +Do not skim the user's sentence and guess — the diff is the spec for what "aligned" means here. + +### 2. Locate the v1 logic + +Pin the change to a v1 place: the contract (`/.ts`) + impl (`/Service.ts`), or the helper/handler the commit touched. Note which state it reads/writes and which other v1 services it calls — this is the same inventory as [align.md](align.md) §1, scoped to the commit's footprint. + +### 3. Map to a v2 domain + +Use the v1 → v2 domain table in [align.md](align.md) §3 as a starting point, then **verify against the current `packages/agent-core-v2/src/` tree** — it is the source of truth. Identify the candidate v2 Service(s) that would own this behavior, and their `LifecycleScope`. + +### 4. Check v2 and assign a bucket + +Search the candidate domain in v2 (Grep the method name, the state field, the error code). For each piece of the commit's behavior delta, decide: + +- **Already-aligned** — v2 produces the same observable result (sometimes for free, because the v2 design never had the bug). Cite the v2 file:line. +- **Partial** — v2 has a near miss: same method, different guard/ordering/error; or the state lives at a different scope. Name the exact drift. +- **Missing** — no v2 Service owns this behavior. Confirm it is a single-Service gap, not a whole-domain gap (latter → [align.md](align.md)). +- **Not-applicable** — the v2 architecture removed the condition the commit fixes (e.g. the scope tree already serializes what v1 patched with a lock). Explain why, so a reviewer trusts the skip. + +Every claim needs a citation (`path:line`) on both sides; "I couldn't find it" is a finding only after you name where you looked. + +### 5. Recommend a fix (sized to the bucket) + +- **Already-aligned** — say so and stop; reference the v2 location. No code change. +- **Partial** — propose the smallest edit that closes the drift: which Service, which method, which guard. Stay inside v2 rules — scope/domain direction, no `Map` at `App` (see [align.md](align.md) §6–§7 red lines). +- **Missing** — sketch the port at commit granularity: target domain + scope, the Service/method to add or extend, the dependency direction, and which [align.md](align.md) §7 conversions apply (registration, `#/…` imports, co-located coded error, `IFlagService` for any gate). If it needs a new scope or a wire change, flag it. +- **Not-applicable** — recommend no v2 change, but call out any test worth adding so the gap stays closed. + +Keep the recommendation to the commit's footprint. If it keeps growing, that is the signal to hand off to [align.md](align.md) for a full domain port. + +### 6. Verify + +Point at the checks that cover the fix, per [verify.md](verify.md): `lint:domain`, `typecheck`, and the relevant `test`. Note the expected outcome rather than asserting you ran it if you did not. + +## Output shape + +When triaging, answer in this order so the user can act on it directly: + +1. **Commit + intent** — one line restating what the commit changed and why (from the note + diff). +2. **v1 location** — file(s) and the behavior delta. +3. **v2 status** — one of the four buckets, with `path:line` evidence on both sides. +4. **Recommendation** — the concrete fix (or the justified skip), scoped to the commit; name the target Service / scope / dependency direction. +5. **Verify** — which checks should pass, and whether to escalate to [align.md](align.md). + +## Red lines (this subskill) + +- Read the diff and the note before judging v2; never infer "aligned" from the description alone. +- Do not copy a v1 diff into v2. Decide the bucket first; a bugfix commit often maps to **not-applicable** because the v2 design already removed the defect. +- Cite `path:line` on both sides. A recommendation without evidence is a guess. +- Stay in the commit's footprint. Growing scope means "switch to [align.md](align.md)", not "keep porting here". +- Do not break v2 invariants to chase v1 parity — scope direction, domain direction, and no `Map` at `App` still hold ([align.md](align.md) red lines). diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md new file mode 100644 index 000000000..477fd05ec --- /dev/null +++ b/.agents/skills/agent-core-dev/config.md @@ -0,0 +1,269 @@ +# Topic — Config + +How the `config` domain works and how a domain owns its configuration section. Covers the section-registry model, the App vs Session split, the TOML on-disk format, and the recipe for adding or migrating a config section. + +The `config` domain is a thin registry + loader: it does **not** know the shape of any individual section. Each domain owns the schema (and, where needed, the TOML transform) for the config it consumes, registers the section into `IConfigRegistry`, and reads it through `IConfigService`. There is no whole-config object passed around. + +## What belongs in Config + +`IConfigService` is the **preference registry**: it holds values a user or +operator *chooses*, each with a schema and a default, that *can* be persisted to +`config.toml`. It is not a grab-bag for every value a domain needs. Before +registering a section, classify the value along three axes — **decision-maker**, +**preference vs fact**, **mutability / persistence**: + +| Type | Decision-maker | Preference/Fact | Persisted? | Examples | Home | +|---|---|---|---|---|---| +| User preference | user | preference | ✅ config.toml | model, theme, log level | **Config** | +| Operational override | operator/deployer | preference | ❌ env / flag | `KIMI_MODEL_*`, `KIMI_LOG_*` | **Config** (env overlay) | +| Per-run intent | invoker | preference | ❌ ephemeral | CLI `--model`, `--config` | **Config** (Memory layer) | +| Host fact | host | fact | ❌ | platform, CI, proxy, home dir | **Bootstrap** | +| Derived convention | code | fact (derived) | ❌ | `configPath`, `logsDir` | **Bootstrap / code** | +| Session runtime state | session/agent | state | ✅ session meta | active model, plan mode | **Session scope** | +| Tuning constant | developer | preference | ❌ compile-time | retry backoffs, buffer sizes | **code** | + +A value belongs in Config **iff** it satisfies all of: + +1. **Preference** — a choice among valid values, not an observed fact. +2. **Persistable** — it *can* be written to `config.toml`, even when a given + value arrives via env or CLI. +3. **Schema + default** — registerable as a section with validation. +4. **User- or operator-facing** — meaningful to set as a preference. + +If it fails any rule, it is not Config: + +- **Fact** (CI, platform, proxy, `HOME`) → a structured fact on + `IBootstrapService` (the L1 startup snapshot), not Config. +- **Derived convention** (`configPath`, `logsDir`) → `IBootstrapService` / code. +- **Session runtime state** (active model, plan mode) → a Session-scoped + service in the owning domain (e.g. `IProfileService`), not `config`. +- **Tuning constant** (retry config, buffer sizes) → domain code; promote to + Config only when it becomes user-tunable. + +**`IBootstrapService` is domain-agnostic.** It holds only generic facts shared by +all domains — the env bag, resolved paths, and host facts (`platform`, `arch`, +`cwd`, `osHomeDir`, `isCI`, …). It must **never** hold state tied to a specific +upper domain (no `cron`, no `flags`, no feature-specific fields): that couples +the foundational layer to an upstream one. + +Any value that belongs to a specific domain — including env-only operational +toggles (`KIMI_CRON_*`, `KIMI_CODE_EXPERIMENTAL_*`), model parameters, or feature +flags — goes through **Config registration**: the owning domain registers a +section with a declarative `envBindings` map (and a `stripEnv` when the value must +not be persisted) and reads it via `config.get(...)`. Each config value declares +an optional env binding (`{ field: 'ENV_VAR' }`, with optional `parse`/`default`); +IConfig resolves each field by `env > config.toml > default` automatically. This +keeps every domain's config in one registry and keeps Bootstrap free of upstream +knowledge. + +Operational env overrides and per-run intent live *inside* Config as layers over +the same persistable key: `model` can be set in `config.toml`, via `KIMI_MODEL_*`, +or via CLI `--model`. They are not separate abstractions — see "Reads vs writes" +and "Layered resolution" below. + +Env access is encapsulated: business domains read `config.get(...)` or structured +`IBootstrapService` facts; only the `config` domain reads the raw env bag (from +`IBootstrapService`) to build its overlays. Business domains must not call +`IBootstrapService.getEnv()` directly. + +## Layered resolution + +`IConfigService` resolves a key by precedence across layers, lowest to highest: + +```text +Default registered defaultValue (and code constants promoted to a section) + ↓ +User config.toml (persisted user preferences) + ↓ +Operational env overlay (e.g. KIMI_MODEL_*, KIMI_CODE_EXPERIMENTAL_*) + ↓ +Memory per-run intent (CLI flags); never persisted; highest +``` + +`set(domain, patch, target?)` writes the `User` layer (persisted) by default; +pass `ConfigTarget.Memory` for a per-run override that is never written to disk. +`inspect(domain)` reports the value at each layer. + +## Layout + +- `src/config/config.ts` — `IConfigRegistry` / `IConfigService` tokens, `ConfigSection`, `ConfigEffectiveOverlay`, event types. +- `src/config/configService.ts` — `ConfigRegistry` + `ConfigService` impl; self-registers at App scope. +- `src/config/toml.ts` — generic snake_case ↔ camelCase machinery plus the registry-aware `transformTomlData` / `applySectionToToml` entry points. Per-domain normalization lives in the section owner's `configSection.ts` (registered as `fromToml` / `toToml`); this module stays free of any other domain's semantics. +- `src/profile/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper; uses the authoritative `ThinkingConfig` from `configSection.ts`. +- `src/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`. + +A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/profile/configSection.ts` for `thinking`, `src/loop/configSection.ts` for `loopControl`). A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the owning domain too (`src/provider/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`. + +## Scope + +- `IConfigRegistry` / `IConfigService` — **App** scope, process-global. One registry of sections; one loader reading `~/.kimi-code/config.toml` (path from `IBootstrapService.configPath`). + +All config reads go through `IConfigService` (global config). Per-session runtime state (active model, thinking level, etc.) lives in the owning Session-scoped service (e.g. `IProfileService`), not in `config`. + +## The section-registry model + +A config section is identified by a camelCase domain key (`'providers'`, `'thinking'`, `'loopControl'`). Each section has: + +- `schema?: ConfigSchema` — zod schema used to validate the value (absent ⇒ passthrough). +- `defaultValue?: T` — filled when the file has no value for the domain. +- `merge?: ConfigMerge` — how `set(domain, patch)` combines base + patch (default `deepMerge`). +- `fromToml?: ConfigFromToml` — read-path transform (snake_case file value → in-memory shape). Defaults to a plain key-casing pass; owners register one when the on-disk shape needs custom normalization (record key preservation, nested object conversion, array entries, key renames, reshapes). +- `toToml?: ConfigToToml` — write-path transform (in-memory value → snake_case file value). Defaults to a plain camelCase→snake_case key mapping. + +Ownership rules: + +- **One owner per section.** `registerSection` throws if a domain is registered twice. +- **The domain that consumes a config owns its schema.** This is what keeps `config` (L2) from importing higher domains: `config` must not import `externalHooks` / `permissionRules` / `provider` / `kosong` / etc. for a section's schema. If a schema needs a domain's types, the schema lives in that domain. +- **Demand-driven.** Do not register sections for config that no domain reads yet; a section appears (with its schema in the owning domain) only when a consumer appears. + +## Env bindings + +A section can declare how its fields are read from environment variables, so the +value resolves through `config.get(...)` rather than ad-hoc `process.env` reads. +Declare the bindings with `envBindings(schema, { … })` — the field names are +type-checked against the schema (no magic strings), and nested schemas recurse: + +```ts +registerSection('thinking', ThinkingConfigSchema, { + env: envBindings(ThinkingConfigSchema, { + effort: 'KIMI_MODEL_THINKING_EFFORT', + }), +}); + +// nested / record section — outer key is a runtime constant, inner fields are +// checked against the value schema: +registerSection('providers', ProvidersSectionSchema, { + env: envBindings(ProvidersSectionSchema, { + [ENV_MODEL_PROVIDER_KEY]: envBindings(ProviderConfigSchema, { + apiKey: 'KIMI_MODEL_API_KEY', + type: 'KIMI_MODEL_PROVIDER_TYPE', + baseUrl:'KIMI_MODEL_BASE_URL', + }), + }), + stripEnv: stripProvidersEnv, +}); +``` + +Each field is an `EnvBinding` — a string (env var name) or +`{ env, parse?, default? }`. IConfig resolves every field by +`env > config.toml > default`, sets it on the effective value, and validates the +section. Empty nested entries (no field resolved) are omitted, so a synthetic +entry like `__kimi_env__` only appears when at least one of its env vars is set. + +`stripEnv(value, rawSnake?)` removes env-derived fields before `set`/`replace` +persists, so env overrides never leak into `config.toml`. + +Business domains read `config.get('section')`; they never read env directly, and +never write their own env-merge logic. + +## Add a config section (recipe) + +1. Define the schema in the owning domain, e.g. `src//configSection.ts`: + ```ts + export const MY_SECTION = 'mySection'; + export const MySectionSchema = z.object({ /* ... */ }); + export type MySection = z.infer; + ``` +2. In the domain's service constructor, inject `IConfigRegistry` and register: + ```ts + constructor(@IConfigRegistry registry: IConfigRegistry) { + registry.registerSection(MY_SECTION, MySectionSchema, { defaultValue: {} }); + } + ``` + Pick a service whose scope matches when the config is first needed. Registering from an Agent-scope service is fine — see "Late registration". +3. Read it anywhere via `IConfigService`: + ```ts + constructor(@IConfigService private readonly config: IConfigService) {} + // ... + const value = this.config.get(MY_SECTION); + ``` +4. React to edits by subscribing `IConfigService.onDidChange` and filtering on `e.domain === MY_SECTION` (see `FlagService`). +5. Write it only through `IConfigService.set(domain, patch)` (merge) or `.replace(domain, value)` (wholesale). Never write `config.toml` directly. + +## Reads vs writes + +Data flow is one-way by default — reading config never touches the file: + +```text +config.toml ──load──▶ IConfigService.effective ──get──▶ services read + ▲ │ + └──────── IConfigService.set/replace ◀──── only on explicit writes +``` + +- **Read path** (startup, every service): `config.toml` is loaded into `IConfigService` once; services read via `get()`. This path **never writes the file**. +- **Write path** (rare): `config.toml` is rewritten only when something explicitly calls `IConfigService.set/replace`. The only production writers today are provider CRUD (`ProviderService.set/delete`, e.g. provisioning a provider after OAuth login). + +**Runtime service state is not config.** Mutating a service at runtime does **not** rewrite `config.toml`: + +- `ProfileService.configure(...)` / `update(...)` / `setModel(...)` / `setThinking(...)` only change **in-memory** fields and append to the session **wireRecord** (for replay). They never call `IConfigService.set`. +- Switching model or thinking level mid-session is session runtime state, not a config edit — the user's `config.toml` is left untouched. + +So `configure(...)` never overwrites the local file. Treat `config.toml` as the user's static config; runtime overrides live in memory and the session record. + +## Late registration + +`ConfigService` loads in its constructor (first `get(IConfigService)`). Domain services that register sections may be constructed later (especially Agent-scope services). To keep validation and defaults correct: + +- `IConfigRegistry` emits `onDidRegisterSection` whenever a section is registered. +- `ConfigService` subscribes and, on registration, re-validates the already-loaded raw value for that domain, applies the default if the raw value is absent, re-runs the env overlay, and fires `onDidChange` if the effective value changed. +- Before a section is registered, `get(domain)` returns the raw (transformed, unvalidated) value; consumers that need validated values should read after the owning service is constructed, or react to `onDidChange`. + +This means registration order is never a correctness concern — you do not need an eager bootstrap. + +## TOML on-disk format + +`config.toml` stores keys in **snake_case**; in-memory values are **camelCase**. `ConfigService` converts both ways by dispatching to each section's registered transform: + +- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask` → `rules`, `loop_control.max_steps_per_run` → `maxStepsPerTurn`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern. +- **Write**: `applySectionToToml(rawSnake, domain, value, registry)` applies the domain's `toToml` hook (or a plain camelCase→snake_case mapping) into a raw clone of the file, preserving unknown top-level keys and unknown sub-fields (lossless round-trip). + +`ConfigService` keeps three views: + +- `rawSnake` — snake_case clone of the file; the write base, never carries the env overlay. +- `raw` — camelCase, env-free; the read/set/replace base. +- `effective` — validated `raw` plus the env overlay; what `get()` returns. + +### `KIMI_MODEL_*` env overlay + +When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`src/provider/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics. + +## Owner-owned sections + +`config` holds no monolithic config schema and no whole-config object. Every section is owned by the domain that consumes it: the schema (and any `fromToml` / `toToml` normalization and `stripEnv`) lives in that domain's `configSection.ts`, and the domain registers it via `IConfigRegistry.registerSection`. Cross-section env behavior (e.g. `KIMI_MODEL_*`) lives in an owner-registered `ConfigEffectiveOverlay`. To add a section, follow "Add a config section" above in the owning domain — never add schema or normalization to `config` itself. + +## Ownership map (current) + +| Section | Owner | Layer | Status | +|---|---|---|---| +| `providers` | `provider` | L2 | owner-owned (`IProviderService` CRUD) | +| `experimental` | `flag` | L3 | owner-owned | +| `thinking` | `profile` | L4 | owner-owned | +| `loopControl` | `loop` | L4 | owner-owned (read by `loop` + `profile`) | +| `McpServerConfig` (type) | `mcp` | L5 | owner-owned (type only; not a registered section) | +| `session` | `config` | L2 | in config | +| `models` / `defaultModel` / `defaultProvider` | `kosong` | L1 | owner-owned (read by `ProviderManager`) | +| `hooks` | `externalHooks` | L4 | owner-owned | +| `permission` | `permissionRules` | L3 | owner-owned | +| `background` | `background` | L5 | owner-owned | + +`config` must not import from any of these owner domains; that is the whole reason the schemas, TOML normalization, and env overlays live with their owners. + +## Layering & scope + +- `config` is **L2**. Domains that own sections import `config` (for `IConfigRegistry` / `IConfigService`) and must be at L2 or higher; lower layers need an entry in `ALLOWED_EXCEPTIONS` (e.g. `kosong>config`, `kosong>provider`). +- Cross-domain type sharing for a config type may need an exception too (e.g. `plugin>mcp` for `McpServerConfig`). Prefer importing the type from the owning domain over re-declaring it. +- `IConfigRegistry` / `IConfigService` are **App**. Agent scope services may inject App services via ancestor lookup. +- `config` never imports a higher domain and holds no section schemas of its own; if a section needs a type from another domain, that schema lives in that domain. + +## Red lines (this topic) + +- One owner per section; `registerSection` throws on duplicate domains. +- `config` (L2) never imports a higher domain — keep section schemas in the owning domain. +- Config is the **preference registry**: register only values that are preferences, persistable, schema'd, and user/operator-facing. Facts → `IBootstrapService`; session state → Session scope; constants → code. +- Business domains read `config.get(...)` or structured `IBootstrapService` facts; never call `IBootstrapService.getEnv()` directly — only `config` reads the raw env bag to build overlays. +- Keep `IBootstrapService` domain-agnostic: never add state tied to a specific upper domain (cron, flags, model params, …). Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`. +- Do not pass a whole config bag via options; read each section through `IConfigService`. There is no `KimiConfig` object — config is a registry of owner-owned sections. +- `config.toml` is snake_case on disk, camelCase in memory — never write camelCase keys to disk, and never write to `config.toml` except through `IConfigService.set/replace`. +- Reading config / calling `configure(...)` / switching model at runtime must not rewrite `config.toml`; runtime state lives in memory and the session wireRecord, not the file. +- Never persist env overlays (`__kimi_env__` / `__kimi_env_model__` / shell API key / experimental env); overlays live only in `effective` / `Memory`. +- Registering from an Agent-scope service is fine — the late-registration mechanism keeps validation correct; do not add an eager bootstrap. diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md new file mode 100644 index 000000000..8bdb21762 --- /dev/null +++ b/.agents/skills/agent-core-dev/design.md @@ -0,0 +1,285 @@ +# Stage 2 — Design a service + +Decide *where things live and who knows whom* before writing code. Every rule here derives from two questions: + +1. **What is the identity of the state it owns?** → decides the **Scope**. +2. **Who owns the decision, and who needs the result?** → decides the **calling style** and **dependency direction**. + +## 1. What a Service is + +A Service = a bundle of **state** + a set of **behaviors**, bound to a **lifetime**. + +- **Behavior** is almost free — the same logic runs anywhere, so it does not by itself decide a scope. +- **State** pins a Service to a scope. State has an **identity** (what it is keyed by) and a **lifetime** (when it is born, when it dies). +- **Dependencies / calling style** answer a different question: who controls whom, and who knows whom. + +## 2. Choosing a scope + +> Scope = the identity + lifetime of the owned state. + +| Scope | State identity (keyed by) | Lifetime | +|---|---|---| +| `App` | none (single global instance) | the process | +| `Session` | `sessionId` | one session | +| `Agent` | `agentId` | one agent | + +### Decision tree + +**Q1. Does it own mutable state?** + +- No (pure behavior) → jump to Q3. +- Yes → Q2. + +**Q2. What is the identity of that state?** + +- one global instance → **`App`** +- one per session → **`Session`** +- one per agent → **`Agent`** +- a mix (a global registry *and* per-instance state) → **split it** (see §3). + +**Q3 (stateless). What is the shortest-lived dependency it must inject?** + +A stateless Service is pulled *down* by its shortest-lived dependency: if it injects an `Agent`-scoped Service, it cannot be `App`. Among the scopes that still satisfy every dependency, **default to the longest-lived one** (usually `App`) to maximize reuse. Push it down only when it must inject a shorter-lived Service, or when you want to limit its visibility. + +### The core anti-pattern (a litmus test) + +> **Do not store per-session state in a `Map` inside an `App` Service.** + +This is the tell-tale sign of "should have been `Session`-scoped but was parked at `App`". Consequences: nobody cleans the entry up when the session ends (leak); every consumer threads `sessionId` around (loss of type safety); it cannot inject `Session`/`Agent`-scoped collaborators. + +### One-sentence self-check + +> "When this scope is disposed, should this state disappear with it?" +> +> - Yes → the scope is right. +> - It must outlive the scope → too short; move up one tier. +> - It should be one-per-unit but is shared → too long; move down one tier. + +## Scope is not a domain + +Scope answers **lifetime and visibility**. Domain answers **responsibility and data ownership**. A Service registered at `Session` or `Agent` scope is not automatically part of the `session` or `agent` domain, and an entity Service must not be named `I{Scope}EntityService` just because its data is scoped that way. + +Use the data-ownership test and the `session` / `agent` / `turn` split conclusions in [domain-boundaries.md](domain-boundaries.md) before naming a Service or adding `I{Domain}EntityService`. + +## 3. Multi-Scope splitting + +> One Service owns state at exactly one identity / lifetime. If a domain owns state at several lifetimes, split it along those boundaries — one Service per lifetime. + +The standard split is "global registry / factory" + "per-instance": + +| Tier | Role | Naming tends to | +|---|---|---| +| `App` | global registry / catalog / factory — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` | +| `Session` / `Agent` | one instance — only the state of "this one" | `XxxService` / `ISessionXxx` / `IAgentXxx` | + +Canonical splits in the codebase: + +- **`records`** — `ISessionStore` (`App`) + `ISessionMetaStore` (`Session`) + `IAgentRecords` (`Agent`). +- **`config`** — `IConfigRegistry` / `IConfigService` (`App`). +- **`kosong`** — `IProtocolHandlerRegistry` (`App`) + `IProviderManager` (`Session`). Generation is driven by `ILLMRequester` (`Agent`) in the `llmRequester` domain. +- **`tool`** — `IToolDefinitionRegistry` (`App`) + `IToolService` (`Agent`). + +Split when the domain genuinely has both a global view and per-instance state. Do **not** split when state lives at only one lifetime (e.g. purely `App` like `log`; purely `Agent` like `prompt`). Do not pre-split for symmetry. + +After the split, the `App` Service usually plays the **factory**; most consumers inject the **per-instance** Service. Inject the `App` factory only when you genuinely need cross-instance management. + +## 4. Choosing a calling style + +Three mechanisms answer three different questions: + +| Mechanism | Nature | Coupling | Returns a value? | Consumers | +|---|---|---|---|---| +| **Direct call** | command: A tells B to do | A → B | yes | one (known) | +| **Event** | fact: A announces "X happened" | both depend only on the bus | no | zero / one / many (unknown) | +| **Hook** (`onWill` / `onDid`, `OrderedHookSlot`) | participation: observers step into an operation, in order | both depend only on the bus | can observe / veto | many, but ordered | + +### Decision tree + +**Q1. Does A need a return value from B?** → Yes: **direct call**. Events cannot return a value (request/reply over events is an anti-pattern). + +**Q2. Is B's reaction part of A's responsibility, or B's own concern?** + +- A's responsibility *includes* B's behavior (A orchestrates B) → **direct call**. E.g. `session` drives `agentLifecycle`; `loop` drives `llmRequester` / `toolExecutor`. +- B's reaction is B's own concern, A merely states a fact → **event**. E.g. `flag` reacts to `config.onDidChange`. + +**Q3. How many consumers?** + +- exactly one, known → **direct call**. +- zero / one / many, producer should not know → **event**. + +**Q4. Would a direct A→B call create a cycle or violate scope direction?** → A *consequence check*, not a primary reason. Decide by Q1–Q3 first; do not turn a genuine direct call into an event just to break a cycle. + +**Q5. Is this fact part of the durable record / replay / cross-agent projection?** → Yes: **emit it on the wire** (`wireRecord`). State changes that must be recorded, replayed, or synchronized across agents are projected onto the wire, not handled by a direct call alone (`permission.set_mode`, `goal.create/update/clear`, `plan_mode.enter/exit`). The wire is the *durable record*, not the live notification channel. + +### One-sentence rule + +> "I am telling you to do this, and I may need the result" → **direct call.** +> "I am announcing that something happened; react if you care" → **event.** +> "I am announcing something, and you may step in, in order, possibly to veto" → **hook.** + +### As extension points (open-closed) + +The three mechanisms above are also where a domain accepts new behavior without being edited. When adding a scenario would otherwise require changing this domain's `if/else`, expose the right extension point instead: + +| Need | Extension point | Typical scope | +|---|---|---| +| Register a new implementation / definition | a **registry / catalog** the domain queries | `App` | +| React to a fact the domain announces | an **event** on the bus | the announcing scope | +| Step into an operation in order / veto | a **hook** (`onWill`/`onDid`, `OrderedHookSlot`) | the owning scope | +| Swap a backend (File ↔ DB ↔ S3) | a **Store / Storage token** at the byte layer (see persistence.md) | `App` (composition root) | + +Closed-for-modification means: the domain's own file is not where new scenarios branch. If a new scenario forces an edit here, an extension point is missing or misplaced. + +## 5. Dependency direction + +Two layers are involved: + +- **Scope direction**: short-lived → long-lived, **enforced by the container** (see orient.md). +- **Domain direction**: which domain may depend on which — **a matter of judgment**, not enforced by the container. + +> **A depends on B iff A needs B's data or behavior to do its own job.** + +Add one anti-rot heuristic to keep the graph from collapsing into a clique: + +> **Do not let a more foundational / more-reused Service come to know a more specific / more-upstream one.** + +Once a foundational component knows about an upstream scenario, it can no longer be reused by other scenarios and will almost always create a cycle. + +### The natural layers of this repo + +`agent-core-v2` is stratified into eight dependency layers, **L0–L7** (the `Ln` number in file headers — see orient.md for the full table and the representative domains). A domain at layer `L` may import only domains at layer `<= L`; lower layers never reach upward. `lint:domain` enforces this from the `DOMAIN_LAYER` map in `scripts/check-domain-layers.mjs`. + +The tiers, from lowest to highest: + +- **L0 — base infrastructure** (`_base`, errors, wire types). +- **L1 — bridges & low-level capabilities** (logging, telemetry, event bus, environment, storage). +- **L2 — data & cross-cutting capabilities** (records, config, providers, auth, workspace registry). +- **L3 — registries & capabilities** (tools, permissions, flags, skills, plugins). +- **L4 — agent behaviour** (turn, loop, prompt, profile, context, goal, plan, swarm). +- **L5 — async lifecycle** (background, MCP, cron, sub-agent tools). +- **L6 — coordination** (session, agent/session lifecycle, interactions, terminal). +- **L7 — boundary / edge** (`gateway`, `rpc`, approval/question, the `*Legacy` v1 adapters). + +Red lines: + +- The **L0/L1 substrate** never imports a higher business layer. +- Business logic never depends on the **L7 edge** layer — business code should not know REST / WebSocket exist. +- A cycle means knowledge was placed backwards: extract a third, more foundational Service, or invert the "notification" half into an event. + +> Capability → orchestrator (e.g. `prompt → turn`) is allowed and present in this repo; the real red line is *inverted reuse* — a foundational / lower Service depending on a specific / upper one. + +> When a Service is meant to be reached over the wire (`/api/v2`, WS), see [edge-exposure.md](edge-exposure.md) for the per-scope `resource:action` map, which Services may be exposed directly vs wrapped in a facade, and how events stream. + +## 6. New-Service checklist + +1. **What does it remember, and what is the state's identity?** → pick the scope (§2). +2. **What is the shortest-lived dependency it must inject?** → the scope cannot be longer than that. +3. **Does it own state at both a global and a per-instance lifetime?** → if yes, split Multi-Scope (§3). +4. **For each collaborator: am I commanding it, notifying it, or letting it participate?** → pick the calling style (§4). +5. **Does each dependency arrow make a more foundational thing know a more specific thing?** → if yes, invert it (§5). + +## 7. Render the placement tree + +After the checklist, render the result as a plaintext tree — the deliverable reviewers read. Keep it in the design doc or PR description. + +```text +domain: `` (owning scope: ) +├─ serves (who uses me) tag = HOW they reach me +│ ├─ (inject) @ +│ └─ (accessor) @ +├─ exposes (interfaces I provide, by scope) +│ ├─ App : +│ ├─ Session : +│ └─ Agent : +└─ depends (what I inject) tag = calling style + └─ @ direct/event/hook — +``` + +Conventions: + +- List **only real interfaces**; write `—` for a scope with no exposed interface. Most domains are single-scope — do not invent symmetry. +- On `depends`, tag each arrow with its calling style: `direct`, `event`, or `hook`. +- On `serves`, tag each consumer with its **access mechanism**, grouped `inject` first then `accessor`: + - `inject` — a descendant or peer scope DI-injects me. Resolved by the container; lifetime-safe. + - `accessor` — an ancestor or edge scope borrows me through `IScopeHandle.accessor.get(...)`. Valid only while this scope lives; never cache the result; must run before the child scope is disposed. See the cross-scope borrow diagram below. +- An empty `(inject)` group with a non-empty `(accessor)` group is a signal: the interface is currently an edge / lifecycle command surface — check it is not leaking internals. +- A consumer is upstream of you. If you cannot name one business consumer, the domain may be dead or mis-scoped. + +### Cross-scope borrow diagram + +When a domain has `accessor` consumers, draw the reverse-direction borrow next to the tree so it is never mistaken for injection: + +```text +App scope + ──holds──► IScopeHandle() + │ + │ accessor.get() + │ └── resolve runs inside the child scope + ▼ + scope () + ← the interface lives here +``` + +Read it as: + +- `──holds──►` = the ancestor owns a handle to the child scope (it stores the key, not the service). DI allows this. +- `accessor.get(...)` = a **runtime borrow**, not a dependency edge. It must cross an `IScopeHandle`, run on demand, never be cached, and finish before the child scope is disposed. + +Worked example — `sessionLifecycle`: + +```text +domain: `sessionLifecycle` (owning scope: App) +├─ serves (who uses me) +│ ├─ (inject) — (none yet) +│ └─ (accessor) +│ ├─ sessionLegacy @App(edge) — v1-compatible create/fork/archive/… +│ └─ gateway / rpc @App(edge) — native v2 session lifecycle actions +├─ exposes (interfaces I provide, by scope) +│ ├─ App : ISessionLifecycleService — owns the live session scope tree +│ ├─ Session : — — (per-session state lives in sessionMetadata / agentLifecycle / …) +│ └─ Agent : — — (per-agent state lives in agentLifecycle) +└─ depends (what I inject) + ├─ bootstrap @App direct — addresses session storage + ├─ hostEnvironment @App direct — gates scope creation on the probe + ├─ sessionIndex @App direct — persisted read model for cold resumes + ├─ storage @App direct — atomic docs + append logs + ├─ workspaceRegistry @App direct — resolves a session's workspace + └─ event @App direct — broadcasts session-level facts (e.g. archived) +``` + +Cross-scope borrow for `sessionLifecycle`: + +```text +App scope + SessionLifecycleService ──holds──┐ + GatewayService ───────────holds──┼──► IScopeHandle(sessionId) + │ + │ accessor.get(ISessionMetadata) … + │ └── resolve runs inside the Session scope + ▼ + Session scope (sessionId) + sessionMetadata / agentLifecycle / … ← per-session services live here +``` + +How the three lenses shaped it: + +- **Scope (§2)** → the live registry of session scopes is process-wide, so it is App-scoped; per-session data stays in Session-scoped services, reached through the handle's `accessor`. +- **Dependency direction (§5)** → `sessionLifecycle` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service. +- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `sessionLifecycle`. + +For a multi-scope split, the `exposes` block fills more than one scope — see the `records` pattern in §3. + +## Red lines (this stage) + +- Scope is not a domain; ownership follows write authority and invariants, not read consumption. +- Do not create `I{Scope}EntityService` bundles (`IAgentEntityService`, `ISessionEntityService`) that re-merge multiple domains. +- No `Map` at `App` to fake per-session state. +- Scope follows state identity; stateless Services are pulled down by their shortest-lived dependency, otherwise default to `App`. +- Do not pre-split a domain that has state at only one lifetime. +- Need a result / I orchestrate → direct call; stating a fact → event; ordered participation / may veto → hook. +- Foundational layers never know upstream ones; business code never depends on the edge layer. +- A cycle means knowledge is placed backwards — refactor, do not route around it. +- Render the placement tree with real interfaces only — never pad an empty scope for symmetry. +- Tag `serves` consumers with `inject` / `accessor`; an empty `inject` group is a signal to check the interface is not leaking internals. +- An `accessor` consumer is a runtime borrow across a scope boundary, not DI injection — never cache the result and finish before the child scope disposes. +- A `serves` list with no business consumer (or only edge consumers) signals a dead or leaking interface. diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md new file mode 100644 index 000000000..0f7236e7a --- /dev/null +++ b/.agents/skills/agent-core-dev/domain-boundaries.md @@ -0,0 +1,203 @@ +# Topic — Domain boundaries vs Scope + +How to keep `agent-core-v2` from recreating a god object after splitting one. Read this before naming a Service, adding an `I{Domain}EntityService`, or deciding whether data belongs to `session`, `agent`, or `turn`. + +## The one-sentence rule + +> **Scope is a lifetime and visibility boundary; a domain is a responsibility and data-ownership boundary.** + +A Service registered at `LifecycleScope.Session` or `LifecycleScope.Agent` is **not automatically in the `session` or `agent` domain**. Scope says when an instance is born, when it dies, and who can see it. Domain says which business responsibility it owns and which data it is allowed to mutate. + +## Definitions + +| Term | Meaning | +|---|---| +| **Scope** | Lifetime / visibility tier. Current code registers Services at `App`, `Session`, or `Agent`. | +| **Domain** | A cohesive business responsibility with its own model, invariants, and write authority. | +| **Entity** | Data with identity and lifecycle, usually suitable for `get/list/create/update/delete` semantics. | +| **Aggregate** | A consistency boundary: the owner that enforces invariants over a cluster of data. | +| **Read model / projection** | Derived data built for queries; it may be shaped like a domain, but it is not the write authority. | +| **Runtime state** | Ephemeral data that dies with its scope; it should not be forced into an entity store. | + +## The data-ownership test + +Do not ask "does Session / Agent / Turn use this data?". Most data is used by several of them. Ask these instead: + +1. **What is the data's identity?** `sessionId`, `agentId`, `turnId`, `taskId`, `workspaceId`, `providerName`, or something else? +2. **Who is the only writer?** The writer is usually the owner. Readers and projectors are not owners. +3. **Who enforces the invariants?** The domain that decides valid transitions owns the model. +4. **What is the authoritative source?** Atomic document, append-log / event stream, blob, query projection, config, or runtime memory? +5. **Can it be named without `Session` / `Agent` / `Turn`?** If yes, it probably deserves its own domain. + +Examples: + +- `PermissionRules` are Agent-scoped, but `permission` owns rule changes and evaluation. +- `BackgroundTask` is spawned by an Agent, but `background` owns task state and output. +- `ContextMessage` is consumed by the Agent loop, but `contextMemory` / `wireRecord` owns history and replay. +- `SessionMeta` is about a Session, but it is owned by `sessionMetadata`, not by a broad `session` data bag. + +## Persistence models are not all entity CRUD + +Before introducing `I{Domain}EntityService`, classify the persistence model: + +| Persistence model | Use when | Examples | +|---|---|---| +| **Atomic document** | One typed document per key | `SessionMeta`, `config.toml` | +| **Append-log / event-sourced** | The authoritative record is "what happened" | `wireRecord`, `contextMemory`, `goal`, `plan`, `permission` transitions | +| **Blob / key-value** | Large or content-addressed bytes | media offload, blob store | +| **Indexed query / read model** | Derived, queryable view | `sessionIndex`, future `IQueryStore` projections | +| **Registry / catalog** | Global or scoped known items | `workspaceRegistry`, `toolRegistry` | +| **Ephemeral runtime state** | No durable entity | active turn handle, pending interactions, terminal handles | + +See [persistence.md](persistence.md) for the `Store → Storage → backend` rules. A domain EntityService is a business facade over those stores; it is not a replacement for the store layer. + +## Naming consequence + +Do not name Services after a scope or a god-object-shaped concept: + +- ❌ `IAgentEntityService` +- ❌ `IAgentDataService` +- ❌ `ISessionEntityService` +- ❌ `ITurnEntityService` that bundles context, tools, permissions, and telemetry + +Name Services after the real owning domain: + +- ✅ `ISessionMetadata` +- ✅ `ISessionIndex` +- ✅ `IAgentLifecycleService` +- ✅ `ITurnService` +- ✅ `IBackgroundTaskEntityService` +- ✅ `ICronTaskEntityService` +- ✅ `IPermissionRulesService` + +`Session` and `Agent` are valid scope names. They are usually **not** good data-owner names. + +## Split conclusion — `session` + +`session` is both a Scope and a narrow Domain. Keep the Domain small. + +The `session` domain owns only Session-level identity, metadata, lifecycle commands, and Session-level read views: + +| Concern | Owner | Notes | +|---|---|---| +| `sessionId`, `workspaceId`, `sessionDir`, `metaScope` | `sessionContext` | Seeded facts; no IO | +| `SessionMeta` | `sessionMetadata` | Durable atomic document; entity-like | +| Open session scope registry | `sessionLifecycle` | App-scope live handles; not the persisted entity table | +| Session commands such as `archive()` | `session` | Orchestrates metadata, agent teardown, and events | +| Persisted session list / get / count | `sessionIndex` | Backend-neutral read model | +| Running / idle / awaiting status | `sessionActivity` | Derived from interactions and active turns; owns no state | + +`session` must not reabsorb these: + +| Data | Real owner | +|---|---| +| Agent instances / handles | `agentLifecycle` | +| Turns | `turn` | +| Context messages | `contextMemory` / `wireRecord` | +| Tool state | `toolStore` / `tool` | +| Permission rules / mode | `permission` | +| Profile / model | `profile` | +| Goal / Plan | `goal` / `plan` | +| Background tasks | `background` | +| Cron tasks | `cron` | +| Pending approvals / questions | `interaction` / `approval` / `question` | +| Workspace | `workspaceRegistry` | +| Provider / config | `provider` / `config` | + +Entity-service conclusion for `session`: + +- ✅ `ISessionMetadata` is already an entity-document Service. +- ✅ `ISessionIndex` is a query/read-model Service. +- ❌ Do not create a broad `ISessionEntityService` that owns agents, turns, records, interactions, logs, workspace, and config. + +## Split conclusion — `agent` + +`agent` is primarily a Scope and composition boundary, not a large data Domain. + +Strictly, the `agent` domain owns only Agent-instance concerns: + +| Concern | Owner | Notes | +|---|---|---| +| Agent instance identity / handle | `agentLifecycle` | Owns live Agent scope handles | +| Agent creation / removal | `agentLifecycle` | Lifecycle, not a data bag | +| Parent / child relationship | `session` / `agentLifecycle` depending on current code | Do not duplicate it into a new Agent data service | +| Active turn reference | `turn` | Turn is its own domain even though it is Agent-scoped | + +Many Agent-scoped Services are **not** in the `agent` domain: + +| Data / capability | Real owner | Persistence model | +|---|---|---| +| Wire records | `wireRecord` | Append-log | +| Context messages | `contextMemory` | Event-sourced through `wireRecord` | +| Profile / model config | `profile` | Config + wire records | +| Tool definitions / registry | `toolRegistry` | Runtime registry | +| Tool mutable state | `toolStore` | Wire records | +| Permission mode / rules | `permissionMode` / `permissionRules` | Wire records + config | +| Goal | `goal` | Wire records | +| Plan | `plan` | Wire records + plan file | +| Skill activation | `skill` | Wire records | +| Background tasks | `background` | Task records / output logs, candidate for entity service | +| Cron tasks | `cron` | Task records, candidate for entity service | + +Entity-service conclusion for `agent`: + +- ✅ Keep `IAgentLifecycleService` for Agent instance lifecycle. +- ✅ If a persisted Agent identity registry is ever needed, name it after that narrow concern, e.g. `IAgentInstanceRegistry`. +- ❌ Do not create `IAgentEntityService` or `IAgentDataService` that bundles profile, records, tools, permission, goal, plan, background, cron, and turn. + +## Split conclusion — `turn` + +`turn` is a Domain, but it is **not** currently a separate `LifecycleScope` in code; `ITurnService` is registered at `Agent` scope. + +`turn` owns one execution round's runtime state and turn-level facts: + +| Concern | Owner | Notes | +|---|---|---| +| Active `Turn` handle | `turn` | `id`, `abortController`, `ready`, `result` | +| Turn id allocation | `turn` | Restored from `turn.prompt` records and `context.append_loop_event` turn ids | +| Turn lifecycle hooks | `turn` | `onLaunched`, `onEnded`, `beforeStep`, `afterStep` | +| `turn.started` / `turn.ended` live events | `turn` | Live event stream | + +`turn` must not own these: + +| Data / capability | Real owner | +|---|---| +| Prompt and context messages | `contextMemory` | +| Append-only record log mechanics | `wireRecord` | +| Step loop | `loop` | +| Tool execution | `toolExecutor` / `tool` | +| Permission decisions | `permission` | +| External hook policy | `externalHooks` | +| Telemetry pipeline | `telemetry` | +| Event transport | `eventSink` | + +Entity-service conclusion for `turn`: + +- ✅ Keep `ITurnService` as a runtime orchestrator. +- ✅ Add a Turn read model / projection only if history queries are needed. +- ❌ Do not create `ITurnEntityService` with `create/update/delete/list` over a turn table as the authoritative model. + +## Migration recipe + +When moving data out of a v1 god object or reviewing a proposed EntityService: + +1. **Name the data without using `Session`, `Agent`, or `Turn`.** If you cannot, the domain is probably unclear. +2. **Find the writer.** The exclusive writer is the likely owner. +3. **Find the invariant.** The Service that rejects invalid transitions owns the model. +4. **Classify the persistence model.** Atomic document, append-log, blob, query projection, registry, or runtime-only. +5. **Pick the Service shape.** + - Entity document / record → `I{Domain}EntityService` or domain-specific CRUD Service. + - Event-sourced → behavior Service + `wireRecord` record types + optional projection. + - Derived query → read-model Service, not a write authority. + - Runtime-only → scoped Service with no entity store. +6. **Choose the Scope by state identity.** Scope follows what the state is keyed by; it does not decide the domain name. +7. **Render the placement tree** from [design.md §7](design.md#7-render-the-placement-tree). + +## Red lines (this topic) + +- Scope is not a domain. `Session` / `Agent` scopes do not make data `session` / `agent` owned. +- Ownership follows write authority and invariants, not read consumption. +- Do not create `I{Scope}EntityService` bundles (`IAgentEntityService`, `ISessionEntityService`, `ITurnEntityService`) that re-merge multiple domains. +- Event-sourced domains keep behavior Services and append-log records; do not replace them with arbitrary CRUD. +- Read models may be shaped like a domain, but they are projections, not write authorities. +- A dependency is not ownership. A Service may inject another domain without owning that domain's data. diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md new file mode 100644 index 000000000..1cc6b25be --- /dev/null +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -0,0 +1,181 @@ +# Edge exposure — `resource:action` + WS events + +How a domain's Services become the wire surface (`/api/v2`) and WebSocket events. This is a **design-time** decision: which Services are exposed, under what public `resource:action` name, and which events stream. + +The transport (`/api/v2` over HTTP + WS) lives in the **edge** layer (`gateway`/`rpc`/`transport`). It borrows business Services by interface; business code never imports it. + +## 1. The edge model + +Three scopes, three URL shapes, one dispatcher: + +```text +GET|POST /api/v2/:sa Core +GET|POST /api/v2/session/:session_id/:sa Session +GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa Agent +``` + +`:sa` is a single path segment of the form `:` (e.g. +`sessions:list`, `session:read`, `profile:getModel`). + +- `:resource` is a **public** name (`sessions`, `session`, `profile`), never an internal domain token (`ISessionMetadata`). +- `:action` is the method. `GET` for reads, `POST` for writes. +- Body = the method's single argument (JSON), omitted for no-arg. +- Response = the project envelope `{ code, msg, data, request_id, details? }`. +- The dispatcher resolves the **scope** from the URL, the **Service** from an `actionMap`, calls the method, wraps the result. + +```ts +// actionMap — the allowlist; hides internal domain names. +const actionMap = { + core: { 'sessions:list': { service: ISessionIndex, method: 'list' }, ... }, + session: { 'session:read': { service: ISessionMetadata, method: 'read' }, ... }, + agent: { 'profile:getModel': { service: IProfileService, method: 'getModel' }, ... }, +}; +``` + +The `actionMap` is the single allowlist: only mapped `resource:action` pairs are callable; unknown → `40001`. + +## 2. What may be exposed directly + +A Service method is directly exposable iff **all** hold: + +1. Args are JSON-serializable (no live objects, `AbortSignal`, callbacks, resumer fns). +2. Return is JSON-serializable data or `void` (no `IScopeHandle`, `Turn`, `IProcess`, `AsyncIterable`, `IDisposable`, `Event`). +3. Errors are `KimiError` (coded). +4. It is a command/query, not a factory, stream, byte-store, or sink. + +If any fail → wrap in a **facade** (a Service that takes ids, returns data, throws `KimiError`) and expose the facade. The repo already ships a wire-shaped facade in `rpc/core-api.ts` (`CoreAPI` / `SessionAPI` / `AgentAPI`) behind `IAgentRPCService` / `ISessionRPCService` — prefer building the HTTP edge on top of it rather than re-deriving a new one. + +## 3. Per-scope `resource:action` map + +Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. + +### Core (`/api/v2/:resource:action`) + +| resource | action | Service.method | verb | +|---|---|---|---| +| `sessions` | `list` | ISessionIndex.list | GET | +| `sessions` | `get` | ISessionIndex.get | GET | +| `sessions` | `countActive` | ISessionIndex.countActive | GET | +| `workspaces` | `list` | IWorkspaceRegistry.list | GET | +| `workspaces` | `get` | IWorkspaceRegistry.get | GET | +| `workspaces` | `createOrTouch` | IWorkspaceRegistry.createOrTouch | POST | +| `workspaces` | `update` | IWorkspaceRegistry.update | POST | +| `workspaces` | `delete` | IWorkspaceRegistry.delete | POST | +| `config` | `get` / `getAll` / `inspect` | IConfigService.* | GET | +| `config` | `set` / `replace` / `reload` | IConfigService.* | POST | +| `providers` | `list` / `get` | IProviderService.* | GET | +| `providers` | `set` / `delete` | IProviderService.* | POST | +| `oauth` | `startLogin` / `cancelLogin` / `logout` | IOAuthService.* | POST | +| `oauth` | `getFlow` / `status` | IOAuthService.* | GET | +| `auth` | `summarize` | IAuthSummaryService.summarize | GET | +| `auth` | `ensureReady` | IAuthSummaryService.ensureReady | POST | +| `flags` | `snapshot` / `enabled` / `explain` / `explainAll` | IFlagService.* | GET | +| `fs` | `browse` / `home` | IHostFolderBrowser.* | GET | +| `meta` | `getEnv` / `detect` | IBootstrapService.* | GET | + +### Session (`/api/v2/session/:sid/:resource:action`) + +| resource | action | Service.method | verb | +|---|---|---|---| +| `session` | `read` | ISessionMetadata.read | GET | +| `session` | `update` | ISessionMetadata.update | POST | +| `session` | `setTitle` | ISessionMetadata.setTitle | POST | +| `session` | `setArchived` | ISessionMetadata.setArchived | POST | +| `session` | `status` | ISessionActivity.status | GET | +| `session` | `isIdle` | ISessionActivity.isIdle | GET | +| `session` | `archive` | ISessionLifecycleService.archive | POST | +| `approvals` | `listPending` | IApprovalService.listPending | GET | +| `approvals` | `decide` | IApprovalService.decide | POST | +| `questions` | `listPending` | IQuestionService.listPending | GET | +| `questions` | `answer` | IQuestionService.answer | POST | +| `interactions` | `listPending` | IInteractionService.listPending | GET | +| `interactions` | `respond` | IInteractionService.respond | POST | +| `workspace` | `setWorkDir` / `addAdditionalDir` / `removeAdditionalDir` / `resolve` | IWorkspaceContext.* | GET/POST | + +### Agent (`/api/v2/session/:sid/agent/:aid/:resource:action`) + +| resource | action | Service.method | verb | +|---|---|---|---| +| `goal` | `get` | IGoalService.getGoal | GET | +| `goal` | `create` / `pause` / `resume` / `cancel` | IGoalService.* | POST | +| `plan` | `status` | IPlanService.status | GET | +| `plan` | `enter` / `exit` / `cancel` / `clear` | IPlanService.* | POST | +| `tasks` | `list` / `get` / `readOutput` | IBackgroundService.* | GET | +| `tasks` | `stop` / `detach` | IBackgroundService.* | POST | +| `usage` | `status` | IUsageService.status | GET | +| `context` | `status` | IAgentContextSizeService.get | GET | +| `swarm` | `isActive` | ISwarmService.isActive | GET | +| `swarm` | `enter` / `exit` | ISwarmService.* | POST | +| `permission` | `getMode` | IPermissionModeService.mode | GET | +| `permission` | `setMode` | IPermissionModeService.setMode | POST | +| `permissionRules` | `list` | IPermissionRulesService.rules | GET | +| `permissionRules` | `addRules` | IPermissionRulesService.addRules | POST | +| `profile` | `get` / `getModel` / `getSystemPrompt` / `getActiveToolNames` | IProfileService.* | GET | +| `profile` | `setModel` / `setThinking` | IProfileService.* | POST | +| `messages` | `list` | IContextMemory.get | GET | +| `messages` | `splice` | IContextMemory.splice | POST | +| `toolStore` | `get` / `data` | IToolStoreService.* | GET | +| `toolStore` | `set` | IToolStoreService.set | POST | +| `mcp` | `list` | IMcpService.list | GET | +| `mcp` | `reconnect` | IMcpService.reconnect | POST | +| `tools` | `list` | IToolRegistry.list | GET | + +## 4. Facade-needed (wrap before exposing) + +These fail §2 and must be wrapped in a facade that takes ids and returns data: + +| Service | Why not direct | Facade shape | +|---|---|---| +| ISessionLifecycleService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session | +| IAgentPromptService / IAgentTurnService | returns `Turn` handle | `prompts.submit` / `steer` / `abort` / `undo` | +| ILLMRequester | `AsyncIterable` stream | stream over WS, not RPC | +| ISubagentHost | `SubagentHandle` | `subagents.spawn` / `resume` → info | +| IProcessRunner | `IProcess` streams | terminal (separate WS protocol) | +| Storage / Store (IFileSystemStorageService / IAppendLogStore / IAtomicDocumentStore / IBlobStore) | bytes / streams | not for RPC | +| IAgentFileSystem | `withCwd` handle | `fs.read` / `write` → text/bytes | +| IExternalHooksService | server-side outbound | not exposed | +| IWireRecord | write-ahead log | internal | + +## 5. WS events + +A single WebSocket endpoint multiplexes RPC `call`s and event `listen`s over a JSON protocol (the lean counterpart of VSCode's `IMessagePassingProtocol`, carrying the same safety features — see §6): + +```text +WS /api/v2/ws +``` + +Client → server: `hello` (auth), `call` (scope + `resource:action` + arg), `cancel`, `listen` (scope + event), `unlisten`, `pong`. +Server → client: `ready`, `result`, `error`, `event`, `ping`. + +`call` reuses the same dispatcher as the HTTP routes (scope + `actionMap`). `listen` subscribes to an `Event` source and forwards each emission as an `event` message, keyed by the client-chosen `id`. + +The `eventMap` binds a public event name to the scope's `Event` source (analogous to the `actionMap`): + +| Scope | event | Source | +|---|---|---| +| Core | `events` | `IEventService.subscribe` (process-wide `DomainEvent` bus) | +| Agent | `events` | `IEventSink.on` (per-agent `AgentEvent` stream) | + +Session-level `onDidChange` sources (metadata / interactions) carry no payload today, so they are not exposed until there is a concrete consumer. + +Safety / reliability (carried over from `packages/server/src/ws/connection.ts` and VSCode's `ChannelServer`): + +- request ids + active-request table — `cancel` / `unlisten` disposes them; +- heartbeat — `ping` every 30s, `pong` timeout 10s → `terminate`; +- schema validation — invalid frames are dropped, not fatal; +- graceful close — dispose listeners, cancel pending, reject in-flight calls; +- no stack traces over the wire; +- non-serializable event payloads are dropped, never fatal. + +Cursor / replay / resync for events is a future addition (a separate `call` before `listen`); the raw stream is the foundation. + +## 6. Red lines (edge exposure) + +- Never expose an internal domain token (`ISessionMetadata`) as a URL segment — use a public `resource` name + `action`. +- Never expose a method that returns a handle / stream / bytes / disposable — wrap in a facade. +- Never expose a method that takes a live object / `AbortSignal` / callback / resumer fn — wrap in a facade. +- Session / Agent Services are reached by `accessor.get` with the id from the URL — never cache the result; finish before the scope disposes. +- The `actionMap` is the allowlist — only mapped `resource:action` pairs are callable; unknown → `40001`. +- Events stream over WS (`listen`), never RPC (`call`). +- Business code never imports the edge (`gateway` / `rpc` / `transport`) — the edge borrows business Services by interface. +- Read = `GET`, write = `POST`; do not overload `POST` for reads when caching / browser-friendliness matters. diff --git a/.agents/skills/agent-core-dev/errors.md b/.agents/skills/agent-core-dev/errors.md new file mode 100644 index 000000000..254cec98d --- /dev/null +++ b/.agents/skills/agent-core-dev/errors.md @@ -0,0 +1,40 @@ +# Topic — Errors + +Error infrastructure for agent-core-v2: base classes, the per-domain code contract, wire serialization, and the conventions domains follow when raising errors. The package-level reference is `packages/agent-core-v2/docs/errors.md`; this topic summarizes the hot-path rules. + +Base classes and serialization are **centralized** in `_base/errors`; error **codes** are **decentralized** — each domain owns an `errors.ts` that self-registers its codes and metadata, and the `src/errors.ts` facade aggregates them into the unified `ErrorCodes` const. + +## Where things live + +- `src/_base/errors/errors.ts`: base classes — `Error2`, `ExpectedError`, `ErrorNoTelemetry`, `BugIndicatingError`, `NotImplementedError`, plus `isError2` and `unwrapErrorCause`. +- `src/_base/errors/codes.ts`: the `ErrorDomain` contract, the `ErrorCode` type (aliased to the protocol's `KimiErrorCode`), the registry (`registerErrorDomain` / `errorInfo` / `isErrorCode`), and `CoreErrors` (`internal`, `not_implemented`). +- `src/_base/errors/serialize.ts`: `ErrorPayload`, `isCodedError`, `toErrorPayload`, `fromErrorPayload`. Wire-facing names (`KimiErrorPayload`, `toKimiErrorPayload`) mirror the protocol and are kept as-is. +- `src/_base/errors/unexpectedError.ts`: `onUnexpectedError` / `setUnexpectedErrorHandler` (global handler). +- `src//errors.ts`: the domain's `XxxErrors` descriptor (codes + retryable list + per-code info overrides), self-registered on import. +- `src/errors.ts`: the **facade** — imports every domain's `errors.ts`, builds `ErrorCodes`, re-exports the primitives. Throw sites import from here. + +## Conventions (hard rules) + +- **Throw a coded error, not a bare string.** `throw new Error2(ErrorCodes.X, …)`. Bare `new Error` only for unreachable guards; `BugIndicatingError` for caller bugs; `NotImplementedError('feature')` for stubs. +- **Define codes in the owning domain**, in `/errors.ts` as an `XxxErrors` descriptor (`satisfies ErrorDomain` + `registerErrorDomain`), then wire it into the facade. Never add domain codes to `_base/errors`. +- **One `code` per failure mode.** Codes read `domain.reason`. The valid code strings are fixed by the protocol (`KimiErrorCode` in `packages/protocol/src/events.ts`): **add new codes to the protocol first**. Renaming/removing a code is a major. +- **Translate foreign errors at the boundary.** Provider/HTTP, fs, MCP errors are re-thrown as the owning domain's coded error. `_base/errors` never imports a business domain. +- **Translation is idempotent and cause-preserving.** Translators (`toHostFsError`, `toStorageIoError`) pass through an already-translated error and always keep the original as `cause`. +- **`details` is structured and JSON-serializable; `message` is a short human sentence.** Paths/errnos/scope/key go into `details`, not the message. +- **Cancellation passes through untranslated** (`UserCancellationError` from `_base/utils/abort`) — apply only at boundaries that can actually see cancellation; do not sprinkle the check everywhere. +- **Classify wrapped errors via `unwrapErrorCause`** — errno/status predicates test the unwrapped cause, not the coded wrapper. +- **Branch on `code`, never `instanceof`, across the wire.** In-process, `instanceof Error2` / `isCodedError` are fine. + +## Reference tiers + +- `os.fs` — `HostFsError` via `toHostFsError` (`os/interface/hostFsErrors.ts`): errno → `os.fs.*`, details `{ path, op, errno?, syscall? }`. +- `os.process` — `HostProcessError`: `spawn_failed` / `kill_failed`, raw error as `cause`. +- `storage` — `StorageError` (`persistence/interface/storage.ts`): `not_found` / `decode_failed` / `corrupted` / `io_failed` (retryable) / `locked` (retryable). ENOENT keeps absence semantics, never an error. A locked query store throws `storage.locked`; consumers catch it explicitly and fall back — no silent no-op degradation. +- `wire` — `WireError` (`wire/errors.ts`): `DuplicateOpError`, `CycleError`, and `wire.unknown_record` (replay skips unknown records, reports via `onUnexpectedError`, returns `{ unknownRecords }`). + +## Red lines (this topic) + +- Throw a coded error with a `code`, not a bare string (except unreachable guards / `BugIndicatingError` / `NotImplementedError`). +- Codes live in the owning domain's `errors.ts` and self-register; new codes land in the protocol first. +- Translate foreign errors at the owning domain's boundary, idempotently, with `cause` and structured `details`; `_base/errors` never imports a business domain. +- Branch on `code` across the wire, never `instanceof`. diff --git a/.agents/skills/agent-core-dev/flags.md b/.agents/skills/agent-core-dev/flags.md new file mode 100644 index 000000000..abae89ac3 --- /dev/null +++ b/.agents/skills/agent-core-dev/flags.md @@ -0,0 +1,108 @@ +# Topic — Flags + +Experimental feature-flag gating for agent-core-v2 — an App-scope `IFlagService` resolver plus a writable `IFlagRegistry` catalog that domains contribute their flags to, backed by the `[experimental]` config section. + +Gate not-yet-public features behind `IFlagService.enabled(id)`, per the repository hard rule that unreleased behavior must be flag-gated. v1 was a process-global `FlagResolver` singleton over a central `FLAG_DEFINITIONS` array; v2 is a scoped DI service whose flag definitions are registered **decentrally** by each owning domain — there is no central catalog to edit. + +## Layout + +- `src/flag/flagRegistry.ts` — `IFlagRegistry` token + `FlagDefinitionInput` / `FlagId` / `FlagSurface` types + `registerFlagDefinition` / `getContributedFlags` (import-time contribution queue). +- `src/flag/flagRegistryService.ts` — `FlagRegistryService` impl; in-memory catalog seeded from import-time contributions; App scope. +- `src/flag/flag.ts` — `IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod). +- `src/flag/flagService.ts` — `FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope. +- `src/flag/index.ts` — **removed (no barrel)**; `src/index.ts` imports the `flag` leafs precisely instead (e.g. `import './flag/flagService'`). +- `src//flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/multiServer/flag.ts`). The directory already names the domain, so the file is just `flag.ts`. + +## Public surface + +- `IFlagService` (DI token, App scope): `enabled(id)`, `explain(id)`, `snapshot()`, `enabledIds()`, `explainAll()`, `setConfigOverrides(overrides)`, `registry`. +- `IFlagRegistry` (DI token, App scope): `register(definition)`, `get(id)`, `list()` — writable catalog. `register` is the **runtime** path (tests, dynamic registration); `IFlagService.registry` exposes the same instance for hosts/UI to enumerate flags without resolving them. +- `registerFlagDefinition(definition)` — the **import-time** path. Domains call this from their `flag.ts` top level; contributions are queued and drained by `FlagRegistryService` when it is instantiated. +- `FlagService` / `FlagRegistryService`: exported for tests and hosts that construct them directly. + +## Resolution precedence + +Highest wins; env is read live on every call (nothing cached): + +1. Master env `KIMI_CODE_EXPERIMENTAL_FLAG` truthy → every flag on. +2. Per-feature `def.env` (e.g. `KIMI_CODE_EXPERIMENTAL_MY_FEATURE`) → forces on/off. +3. `[experimental]` config section per-flag override. +4. Registry `default`. + +`explain(id)` returns the winning `source` (`master-env` | `env` | `config` | `default`) plus the effective `configValue`. `explain(id)` returns `undefined` (and `enabled(id)` returns `false`) for an id that no domain has registered. + +## Config integration + +- `FlagService` registers the `[experimental]` section into `IConfigRegistry` at construction (`registerSection('experimental', ExperimentalConfigSchema)`) and reads overrides from `IConfigService`. +- It subscribes `IConfigService.onDidChange` and refreshes overrides whenever the `experimental` domain changes, so config edits apply live. +- `IConfigRegistry.registerSection` throws if a domain is registered twice — `experimental` is owned exclusively by `FlagService`. +- `setConfigOverrides(overrides)` is an imperative escape hatch for tests and hosts without an `IConfigService`; hosts on `IConfigService` should set the `[experimental]` section instead. + +Config shape: + +```toml +[experimental] +my_feature = false +``` + +Keys are intentionally loose (`z.record(z.string(), z.boolean())`), so obsolete flags stay inert config. + +## Add a flag + +Declare the definition in the owning domain's `flag.ts` and call `registerFlagDefinition` at the module top level. There is no central catalog to edit. + +`src//flag.ts`: + +```ts +import { type FlagDefinitionInput, registerFlagDefinition } from '#/flag'; + +export const myFeatureFlag: FlagDefinitionInput = { + id: 'my_feature', + title: 'My feature', + description: '...', + env: 'KIMI_CODE_EXPERIMENTAL_MY_FEATURE', + default: false, + surface: 'both', +}; + +registerFlagDefinition(myFeatureFlag); +``` + +Then ensure the package entry `src/index.ts` imports the flag leaf precisely so the top-level call runs at import time — there is no `src//index.ts` barrel: + +```ts +// src/index.ts +import './/flag'; +``` + +`src/index.ts` imports every domain's leaf files precisely (one line per leaf), so the contribution runs during bootstrap, before any scope is created — and therefore before any consumer resolves `IFlagService`. + +- `env` must start with `KIMI_CODE_EXPERIMENTAL_`, be unique, and not equal `KIMI_CODE_EXPERIMENTAL_FLAG`. +- `id` must not be `flag`. A duplicate `id` throws when `FlagRegistryService` drains the contributions. +- `FlagId` is `string`, not a literal union: with no central catalog there is nothing to derive it from, so `enabled()` has no compile-time typo-checking. Cover gated behavior with tests instead. +- `surface`: `core` | `tui` | `both` (documentation/grouping only; not used in resolution). + +## Consume a flag + +Inject `IFlagService` and gate on it. It is resolvable from any scope (App ancestor): + +```ts +constructor(@IFlagService private readonly flags: IFlagService) {} +// ... +if (!this.flags.enabled('my_feature')) return; +``` + +## Layering & scope + +- Domain `flag` is registered at **L3**. It imports only `config` (L2) downward. +- It cannot live in `_base` (L0): registering/reading the config section requires importing `config`, and L0 must not import L2. +- Scope: `IFlagRegistry` and `IFlagService` are both `App`. Env + config are process-global inputs, so there is no per-session/agent state. Flag definitions are contributed at **import time** (top-level `registerFlagDefinition` calls), so they are queued before any scope is created and drained when `FlagRegistryService` is first instantiated — before `IFlagService` is first resolved. +- Tests build `FlagService` + `FlagRegistryService` directly with a real `ConfigRegistry`/`ConfigService` and an injected env map, then `register` the flags they exercise. + +## Red lines (this topic) + +- Gate unreleased behavior behind a registered flag; no ad-hoc env toggles. +- Contribute each flag from the **owning domain's** `flag.ts` (`src//flag.ts`) via a top-level `registerFlagDefinition` call; there is no central catalog to edit. The directory names the domain, so the file is just `flag.ts`. +- `env` must start with `KIMI_CODE_EXPERIMENTAL_`, be unique, and not equal `KIMI_CODE_EXPERIMENTAL_FLAG`; `id` must not be `flag`. +- `FlagId` is `string` (decentralized registration) — do not reintroduce a central `FLAG_DEFINITIONS` array or a derived literal union. +- `flag` lives at L3 and `App` scope — never in `_base`, never per-session. diff --git a/.agents/skills/agent-core-dev/implement.md b/.agents/skills/agent-core-dev/implement.md new file mode 100644 index 000000000..77a845773 --- /dev/null +++ b/.agents/skills/agent-core-dev/implement.md @@ -0,0 +1,258 @@ +# Stage 3 — Implement + +Write the contract leaf, implementation leaf (with its registration), and the package-entry lines that load them. Each section below introduces one DI building block as you need it. Source lives in `src/_base/di/`. + +## Standard recipe for a new `IXxxService` + +1. **Contract leaf** — `src//.ts`: interface (with `_serviceBrand`) + `createDecorator` identity. +2. **Impl leaf** — `src//Service.ts`: class with `@IX` constructor deps; top-level `registerScopedService(scope, IX, Impl, type, '')`. +3. **Entry** — `src/index.ts`: load each leaf precisely — `export * from './/';` for the contract and `import './/Service';` for the impl (importing the impl runs the registration). **No `src//index.ts` barrel.** +4. **Tests** — see test.md. + +There is **no central wiring file**: bindings live in each domain's impl file and are collected through import side effects. + +## §1 Interface + identity (a global service, no deps) + +```ts +// greet/greet.ts +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IGreeter { + readonly _serviceBrand: undefined; // type marker: tells DI "this is a service" + hello(): string; +} + +export const IGreeter: ServiceIdentifier = createDecorator('greeter'); +``` + +`createDecorator(name)` produces a `ServiceIdentifier` that is three things at once: a runtime key, a parameter decorator, and a compile-time carrier of the `IGreeter` type. + +> **The identity name is globally unique.** `createDecorator` caches by `name`; two domains using the same string collide and share one identity. + +```ts +// greet/greetService.ts +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IGreeter } from './greet'; + +export class Greeter implements IGreeter { + declare readonly _serviceBrand: undefined; // mirrors the interface marker + hello(): string { return 'hi'; } +} + +registerScopedService( + LifecycleScope.App, // lifetime: process-wide + IGreeter, // identity + Greeter, // implementation + InstantiationType.Eager, // when to construct: immediately + 'greet', // domain name (for diagnostics) +); +``` + +The scope a class binds to is an **intrinsic property of the class**, decided at the registration point, not the call site. + +The impl's top-level `registerScopedService` runs as soon as the module is imported. There is no `greet/index.ts` barrel — instead, add the leafs to the package entry `src/index.ts`, one line per leaf: + +```ts +// src/index.ts +export * from './greet/greet'; +import './greet/greetService'; // this import runs registerScopedService +``` + +Anyone can now `accessor.get(IGreeter)` the single global instance. + +## §2 Constructor injection (your service uses others) + +```ts +export class SessionMetadata extends Disposable implements ISessionMetadata { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionContext private readonly ctx: ISessionContext, + @IAtomicDocumentStore private readonly store: IAtomicDocumentStore, + @ILogService private readonly log: ILogService, + ) { + super(); + } +} +``` + +`@ISessionContext` records "parameter 0 needs `ISessionContext`" on the class metadata; the container fills it when constructing. + +Three inviolable constraints: + +1. **Do not `new` a class with `@IService` deps** — `new` bypasses registration, scope, and the singleton cache. Inject with `@IX` or `accessor.get(IX)`. +2. **`@IX` decorates constructor parameters only.** Decorating a field/method throws at runtime. +3. **Parameter order depends on how the object is built** — for `createInstance` non-singletons, static params come first (see §7); for scoped services, `@IX` params are conventionally first and any static params need defaults. See service-authoring.md §constructor-conventions. + +Consumers resolve by interface and never import the impl class: + +```ts +const meta = accessor.get(ISessionMetadata); // type is ISessionMetadata +``` + +> If you need "a config" rather than "a service", model it as a service (e.g. `IConfigService`) and inject it. If you need a per-turn, parameterized, non-singleton object, see §7. + +## §3 Scoped registration (not global) + +Swap the `scope` argument to bind to a different tier: + +```ts +registerScopedService(LifecycleScope.Session, ISessionMetadata, SessionMetadata, InstantiationType.Delayed, 'sessionMetadata'); +``` + +Remember the visibility rule from orient.md: a service may inject services from its own scope or any ancestor; never from a descendant. + +## §4 Releasing resources (`Disposable`) + +For a service that subscribes to events, starts timers, or holds handles: + +```ts +import { Disposable } from '#/_base/di/lifecycle'; + +export class WSBroadcastService extends Disposable implements IWSBroadcastService { + declare readonly _serviceBrand: undefined; + + constructor(@IEventService event: IEventService) { + super(); + this._register(event.subscribe(() => { /* … */ })); // collect child resources + } +} +``` + +- Extend `Disposable`, collect any `IDisposable` with `this._register(d)` (event subscriptions, `toDisposable(fn)`, etc.). +- The container calls `dispose()` automatically when the service is torn down; child resources release in turn. +- Disposal order is deterministic (orient.md): child scopes first, then reverse construction order within a scope. + +## §5 Eager vs delayed instantiation + +```ts +// Eager: constructed when the scope is created +registerScopedService(LifecycleScope.App, ILogService, LogService, InstantiationType.Eager, 'log'); + +// Delayed: constructed on first get +registerScopedService(LifecycleScope.App, IScopeRegistry, ScopeRegistry, InstantiationType.Delayed, 'gateway'); +``` + +A `Delayed` service returns a **Proxy** that constructs the real instance on first property access. Listeners registered on its `onDid…` / `onWill…` events before construction are not lost — the container records them and replays the subscriptions once the instance exists. + +> Rule of thumb: `Eager` for dependency-free, frequently-used, or "early side effect" services (e.g. `ILogService`); default to `Delayed` otherwise. + +## §6 Using a service inside a plain function (`invokeFunction`) + +When you do not want a new class and just need a service once, or when you expose a `ServicesAccessor` to the outside: + +```ts +const accessor: ServicesAccessor = { + get: (id: ServiceIdentifier): T => instantiation.invokeFunction((a) => a.get(id)), +}; +``` + +`invokeFunction(fn)` hands `fn` a `ServicesAccessor` valid **only during that call**. + +> **The accessor is valid only during the invocation.** Calling `accessor.get()` after `invokeFunction` returns throws `"service accessor is only valid during the invocation"`. Do not stash it for async use — inject the service in the constructor (§2) if you need it long-term. + +## §7 Creating a non-singleton object with deps (`createInstance`) + +For a per-turn executor that also has `@IService` deps: + +```ts +class TurnRunner { + constructor( + private readonly input: string, // static param: passed by caller + private readonly turn: number, // static param: passed by caller + @ILogService private readonly log: ILogService, // service param: injected by container + ) {} +} + +const runner = instantiation.createInstance(TurnRunner, 'hello', 1); +``` + +Static params come first (you pass them), service params follow (the container fills them), then `Reflect.construct` builds the instance. This object is **not** placed in any scope's singleton cache — every call is a fresh instance. + +> This is why service params must follow static params **for `createInstance`**: the container sorts by the parameter positions recorded via `@IX`. `_serviceBrand` lets the compiler tell the two kinds apart. Scoped services built by `registerScopedService` follow a different convention (`@IX` params first, optional static params after) — see service-authoring.md §constructor-conventions. + +## §8 Spawning a child scope / child container + +For a service that "starts a new session / agent" and needs a child scope, inject `IInstantiationService` itself (every container binds itself as `IInstantiationService`): + +```ts +export class ScopeRegistry implements IScopeRegistry { + declare readonly _serviceBrand: undefined; + + constructor(@IInstantiationService private readonly instantiation: IInstantiationService) {} + + createSession(opts: CreateSessionOptions): Promise { + const collection = new ServiceCollection(); + for (const entry of getScopedServiceDescriptors(LifecycleScope.Session)) { + collection.set(entry.id, entry.descriptor); // collect Session-tier descriptors + } + const child = this.instantiation.createChild(collection); // spawn child container + const accessor: ServicesAccessor = { + get: (id: ServiceIdentifier): T => child.invokeFunction((a) => a.get(id)), + }; + const handle: IScopeHandle = { id: opts.sessionId, kind: LifecycleScope.Session, accessor }; + this.sessions.set(opts.sessionId, handle); + return Promise.resolve(handle); + } +} +``` + +Key points: + +- `getScopedServiceDescriptors(scope)` returns every descriptor registered at that tier; load them into a `ServiceCollection`. +- `instantiation.createChild(collection)` builds a child container whose parent pointer is the current container — so the child resolves upward to `App` services (the visibility rule). +- Expose the child to the outside by wrapping it in a `ServicesAccessor` via `invokeFunction` (§6). + +> Higher-level code usually calls `Scope.createChild(kind, id)` (it does the "filter descriptors + build child" for you). Drop to the manual `ServiceCollection` form only when you need explicit control. + +## §9 Cyclic dependencies (forbidden — refactor) + +Business rule: **no cyclic dependencies.** The container rejects them; the correct response is to refactor, not to make it run. + +### The container rejects synchronous cycles + +If A needs B while being created and B needs A while being created, the container throws `CyclicDependencyError` with a `path` like `['A', 'B', 'A']`. Self-cycles (A depends on itself) are also rejected. This is a protection mechanism telling you the two services' responsibilities are mis-drawn. + +### Why cycles are disallowed + +- Scope layering makes normal dependencies a DAG (Agent → Session → App, resolving upward); a cycle is almost always a design smell. +- "Making the cycle happen to work" turns construction order into an implicit contract — hard to debug. + +v2's stance: **the dependency graph must be acyclic.** + +### How to refactor (in priority order) + +1. **Extract a third service C.** Move the part A and B both need into C; let A and B both depend on C instead of each other. The most common fix. +2. **Decouple with an event.** If A only needs to know about a change in B, have B emit via `IEventService` and A subscribe, rather than A holding a reference to B. +3. **Re-partition scope.** One of them may belong at a different tier — moving it makes the cycle disappear. + +### Delayed as a cycle-breaker (legacy escape hatch — forbidden) + +A legacy mechanism lets a `Delayed` edge turn a "soft cycle" into a non-synchronous Proxy. **Do not use it to bypass cyclic dependencies** — it exists for historical compatibility, not to paper over your design. On `CyclicDependencyError`, refactor per the above. + +## Interface cheat sheet + +| Interface | Section | Role | +|---|---|---| +| `createDecorator(name)` → `ServiceIdentifier` | §1 | identity (runtime key + compile-time type + param decorator) | +| `@IService` | §2, §7 | declare a dependency on a constructor param | +| `registerScopedService(scope, id, ctor, type, domain)` | §1, §3, §5 | bind an impl to a lifetime tier | +| `ServicesAccessor.get(IX)` | §2, §6 | resolve an instance by interface | +| `IInstantiationService.invokeFunction(fn, …)` | §6, §8 | obtain a temporary accessor inside a function | +| `IInstantiationService.createInstance(ctor, …args)` | §7 | build a non-singleton object with deps injected | +| `IInstantiationService.createChild(collection)` | §8 | spawn a child container | +| `getScopedServiceDescriptors(scope)` | §8 | retrieve all descriptors registered at a tier | +| `Disposable` / `DisposableStore` / `IDisposable` | §4 | resource management and disposal | +| `Scope` / `LifecycleScope` | §3, §8 | the lifetime tree | +| `SyncDescriptor` | (tests / low-level) | package a constructor + static args into a pending descriptor | + +> Legacy export (not used in v2, just recognize it): `refineServiceDecorator` is a VS Code leftover DI helper. v2 src/test has zero references; always use `registerScopedService`. + +## Red lines (this stage) + +- No `new` on a class whose constructor carries `@IService` deps — inject or `accessor.get(IX)`. +- `@IX` decorates constructor params only; parameter order depends on construction (static-first for `createInstance`, `@IX`-first for scoped services — see service-authoring.md). +- Both interface and impl carry `_serviceBrand`; the `createDecorator` name is globally unique. +- `ServicesAccessor` is valid only during `invokeFunction` — never stash it for async use. +- No cyclic dependencies — refactor (extract / event / re-scope); do not break the cycle with `Delayed`. diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md new file mode 100644 index 000000000..3047c43d4 --- /dev/null +++ b/.agents/skills/agent-core-dev/orient.md @@ -0,0 +1,111 @@ +# Stage 1 — Orient + +Understand the DI × Scope black box and the file conventions before touching business code. + +## The DI black box + +When writing business code you declare three things; the container handles the rest (when to construct, whether it is the same instance, ordering, disposal): + +- **Who am I** — an identity that is both a runtime key and a compile-time type. +- **Whom do I need** — the dependencies that provide my capabilities. +- **How long do I live** — which lifetime tier I belong to. + +Classes talk only to interfaces and never care how an implementation is constructed. + +## The three `LifecycleScope` tiers + +Lifetimes form a tree, from longest to shortest: + +```text +App (0) process-wide, single global instance + └── Session (1) one session + └── Agent (2) one agent +``` + +```ts +export enum LifecycleScope { + App = 0, + Session = 1, + Agent = 2, +} +``` + +- A larger number = shorter life = closer to a leaf. +- "Singleton" means **one per scope**: `ILogService` is global once; each `Session` scope has its own `ISessionMetadata`. +- `kind` strictly increases along the parent→child direction. + +### Visibility rule + +A child scope sees its ancestors; a parent never sees its children. Resolution walks *up* the tree: + +- ✅ An `Agent` service injects a `Session` or `App` service (found upward). +- ❌ An `App` service injects a `Session` service (the parent does not look down, and the child may not exist yet). + +> **Short-lived may inject long-lived; never the reverse.** The tree structure enforces this — it is not a matter of discipline. + +### Disposal order + +Deterministic: **child scopes die first; within one scope, instances dispose in reverse construction order** (last constructed, first disposed). Business code declares which tier it lives in and never disposes by hand. + +## The `(Ln)` layer number in headers + +The `Ln` in a file-header identity line is the domain's **dependency layer** (L0–L7), **not** its `LifecycleScope`. They are easy to confuse because both are small integers, but they answer different questions: + +- `LifecycleScope` (App=0 / Session=1 / Agent=2) — **lifetime & visibility** (this stage). +- Dependency layer `Ln` (L0–L7) — **who may import whom**: a domain at layer `L` may import only domains at layer `<= L`. Enforced by `lint:domain` from the authoritative `DOMAIN_LAYER` map in `scripts/check-domain-layers.mjs`. + +So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but lives at **L6**. When you write the header, read the number from the layer map, not from the scope. + +| Layer | Role | Representative domains | +|---|---|---| +| L0 | base infrastructure | `_base`, `errors`, `llmProtocol` | +| L1 | bridges & low-level capabilities | `log`, `telemetry`, `event`, `environment`, `bootstrap`, `storage` | +| L2 | data & cross-cutting capabilities | `records`, `wireRecord`, `config`, `provider`, `auth`, `workspaceRegistry` | +| L3 | registries & capabilities | `tool`, `toolRegistry`, `permission*`, `flag`, `skill`, `plugin` | +| L4 | agent behaviour | `turn`, `loop`, `prompt`, `profile`, `contextMemory`, `goal`, `plan`, `swarm` | +| L5 | async lifecycle | `background`, `mcp`, `cron`, `agentTool` | +| L6 | coordination | `session`, `agentLifecycle`, `sessionMetadata`, `interaction`, `terminal` | +| L7 | boundary / edge | `gateway`, `rpc`, `approval`, `question`, `*Legacy` | + +## File-header comment convention + +`packages/agent-core-v2/AGENTS.md` mandates a header-only comment style: + +- **Header only.** Comments live solely in the top-of-file `/** */` block — never beside functions, methods, or statements. The code is the source of truth for *how*; the header states *what the module exposes and the responsibility it owns*. +- **Identity line first.** Start with `` `` domain (Ln) — . `` Keep an existing `(cross-cutting)` label as-is. Write the role as a responsibility ("drives the turn lifecycle"), not a symbol list. +- **Scope is in the filename.** `session*.ts` = Session, `agent*.ts` = Agent, no prefix = App (see service-authoring.md). State the same scope in the header so the two never drift. +- **Interface files** (`.ts`) state the public contract + scope: which `IXxx` they define and what it is for. +- **Impl files** (`Service.ts`) add collaborators + scope: list every imported cross-domain collaborator as a role ("persists records through `records`"); read scope from `registerScopedService(LifecycleScope.X, …)`. +- **Contribution files** (`.ts` / `.contrib.ts`) state what they register into the target domain (e.g. "registers the `log` config section into `config`"). +- **Pure-function / `.types` / `.errors` files** state the responsibility only — they own no scoped state, so no scope line. + +Impl file example (`sessionMetadataService.ts`): + +```ts +/** + * `sessionMetadata` domain (L6) — `ISessionMetadata` implementation. + * + * Persists the session metadata document (`state.json`) through the `storage` + * access-pattern store (`IAtomicDocumentStore`), rooted at the `metaScope` + * namespace from `sessionContext`. Loads the existing document on + * construction (creating it on first run), and logs through `log`. Bound at + * Session scope. + */ +``` + +Contribution file example (`config.ts` inside `log/`): + +```ts +/** + * `log` domain — registers the `log` config section into `config`. + * + * Owns the `log` section schema and its env overlay; imported for the + * registration side effect. Bound at App scope. + */ +``` + +## Red lines (this stage) + +- Import via the `#/...` alias (mapped to `src/`); never reach into another domain's internals by relative path. +- Short-lived may inject long-lived; never the reverse. +- File-header comments describe role and scope only; never narrate implementation beside statements. diff --git a/.agents/skills/agent-core-dev/permission.md b/.agents/skills/agent-core-dev/permission.md new file mode 100644 index 000000000..7db146b9c --- /dev/null +++ b/.agents/skills/agent-core-dev/permission.md @@ -0,0 +1,206 @@ +# Topic — Permission + +The target design for the agent-core permission system. Read this when touching `permission`, `permissionMode`, `permissionRules`, or when adding a new permission dimension. + +> **The permission system should be a composable, registrable chain of responsibility (a microkernel).** The kernel only runs the chain in order, first hit wins; concrete permission dimensions (policies) are contributed by their owning Domain Services through a registry; tools only declare standardized resource access (`accesses`) in `resolveExecution`, and generic dimensions consume that metadata. +> +> **Do not introduce Casbin** — the hard part here is *decision behavior* (continuations, side effects, RPC, state machines), not "match + scalar decision". + +## 1. Problem definition + +The permission system answers one question: **for each tool call, in the current agent and current mode — allow / deny / ask the user?** Three traits shape the architecture: + +1. **Decisions carry behavior.** Returning `ask` is not an enum value — it is a workflow with an RPC round-trip, hooks, telemetry, state writes, and a continuation; returning `deny` may be the result of running an external hook. +2. **Heterogeneous policies.** Some check a tool-name set, some count same-batch `AgentSwarm` calls, some run a hook, some inspect the plan state machine — no uniform `(sub, obj, act)` shape. +3. **Multi-agent × multi-mode × external extension.** Different agents / modes need different permissions, and outsiders (org admins, plugins) must contribute rules or behavior in a decoupled way. + +## 2. Current state (v1) at a glance + +Code lives in `packages/agent-core/src/agent/permission/`. + +- **Architecture: ordered chain of responsibility, first hit wins.** `PermissionManager` holds `PermissionPolicy[]`; evaluation iterates in order, the first non-`undefined` result wins. +- **`PermissionPolicyResult` is a behavior bundle, not a scalar:** `approve` (with `executionMetadata`), `deny` (with `message`), or `ask` (with `resolveApproval` / `resolveError` continuations). +- **11 dimensions, 19 policies**, hardcoded in `policies/index.ts#createPermissionDecisionPolicies()`. Order is a high-to-low safety cascade: external force → structural deny → state-machine deny → static deny → mode allow → session-memory allow → static ask → static allow → flow allow → sensitive-path ask → default allow → fallback ask. +- **Resource-access declaration:** tools declare accessed resources in `resolveExecution(input)` via `accesses` (`ToolAccesses`, currently `file` and `all`); generic dimensions read `context.execution.accesses`. + +### v1 pain points the target design fixes + +1. The chain is hardcoded — outsiders cannot contribute. +2. `mode` is an `if` inside each policy (`YoloModeApprove` / `AutoModeApprove` self-guard). +3. No per-agent chain entry point (only scattered `agent.type === 'sub'` checks). +4. No external extension point beyond the single `PreToolUse` hook slot. + +## 3. Why not Casbin + +- **`policy_effect` is unusable** — composition here is a fixed, intentionally hardcoded safety cascade; the real complexity lives in each policy's `evaluate` behavior, which a Casbin expression cannot absorb. Externally tunable safety knobs are already exposed via `mode` + allow/deny/ask rules. +- **Flexible priority is unusable** — there is no plugin injection point, no multi-subject/RBAC, and a fixed subject (agent/user), so priority collisions do not arise. Casbin's `(sub, obj, act)`, `g()`, and domains would idle. +- **Fundamental mismatch: decisions are not scalars.** `enforce()` maps a request to an effect; agent-core decisions are behavior bundles (continuations, side effects, synthesized results). Even if Casbin computed `ask`, the surrounding behavior would still need to be rewritten — Casbin would degrade to an enum generator. +- **When Casbin becomes worth it:** when the hard part is matching semantics itself — role inheritance, domain isolation, ABAC expressions, policies loaded from a DB. Not before. + +## 4. Design-pattern placement + +Permission orchestration is a layered combination, not a single pattern: + +| Layer | Pattern | Role | +|---|---|---| +| Runtime decision | **Chain of Responsibility** | multiple candidates in order; first hit wins, rest short-circuit | +| Single handler | **Strategy** | each policy is an interchangeable "permission adjudication" algorithm | +| Assembly / external extension | **Plugin / Microkernel** | minimal kernel + explicit extension points + pluggable policies | +| Landing support | **Registry + Factory** | collect plugins; assemble the chain per `(agent, mode)` on demand | + +Casbin = single Strategy + data-driven. This design = multiple Strategies + chain-of-responsibility composition. Behavior-heavy systems must choose the latter — behavior cannot be flattened into data rows. + +## 5. Target design + +### 5.1 Core principles + +1. **The chain encodes "permission dimensions", not "tools".** Adding a tool does not lengthen the chain; only adding a dimension adds a node. +2. **Two contribution paths:** high-frequency trivial specifics go through the **data path** (rules); low-frequency new dimensions with behavior go through the **code path** (policies). +3. **Domain self-registration:** a domain that owns a dimension (plan/goal/swarm) registers its policy in DI, mirroring v2's existing "domain self-registers tools". +4. **Tools declare resources; generic dimensions consume them:** bash/write/read only declare `accesses`; file/security dimensions judge centrally. + +### 5.2 Core abstractions + +```ts +type Phase = + | 'guard' | 'user-deny' | 'mode' | 'session' + | 'user-ask' | 'default' | 'fallback'; + +interface PermissionPolicyEntry { + name: string; + phase: Phase; + modes?: PermissionMode[]; // declare which modes this applies in (no more in-evaluate if) + agentTypes?: AgentType[]; + factory: (accessor: ServicesAccessor) => PermissionPolicy; +} + +// App scope — collects every domain's registration +interface IPermissionPolicyRegistry { + register(entry: PermissionPolicyEntry): IDisposable; + list(): readonly PermissionPolicyEntry[]; +} +``` + +`PermissionPolicyService` (Agent scope) changes from a hardcoded list to "assemble by `(agent, mode)`": + +```ts +this.policies = registry.list() + .filter(e => !e.modes || e.modes.includes(mode)) + .filter(e => !e.agentTypes || e.agentTypes.includes(agentType)) + .sort(byPhaseThenRegistrationOrder) + .map(e => e.factory(accessor)); +``` + +Key points: + +- `modes` / `agentTypes` are **declarations** — they lift the `if (mode !== 'yolo') return` out of `YoloModeApprove` into metadata. +- `factory`, not `instance`: a node may depend on agent-scoped services (mode, rules) and must be instantiated in the Agent scope — symmetric to `IToolDefinitionRegistry` (App) storing factories and `IToolService` (Agent) instantiating tools. +- **Different `(agent, mode)` produce differently-shaped chains** — under yolo the ask/fallback phases are physically filtered out. + +### 5.3 Two contribution paths + +| What is being added | Path | Chain length | +|---|---|---| +| New tool, new org rule, new user preference ("deny `Bash(curl *)`") | **Data path**: add a `PermissionRule` to an existing node | unchanged | +| New cross-cutting behavior (custom approval UI, audit log, new mode) | **Code path**: register a new policy node | +1 | + +Most growth goes through the data path — node count is bounded by "kinds of behavior"; rule count grows with specifics (rule matching is a cheap Set/glob). + +### 5.4 Domain self-registration + +Mirrors v2's "domain registers tools in its constructor". `PlanService` self-registers its dimensions: + +```ts +// src/plan/planService.ts +constructor(@IPermissionPolicyRegistry registry: IPermissionPolicyRegistry) { + registry.register({ name: 'plan-mode-guard-deny', phase: 'guard', + factory: a => new PlanModeGuardDenyPolicy(a.get(IPlanService)) }); + registry.register({ name: 'plan-mode-tool-approve', phase: 'mode', + factory: a => new PlanModeToolApprovePolicy(a.get(IPlanService)) }); + registry.register({ name: 'exit-plan-mode-review-ask', phase: 'user-ask', + factory: a => new ExitPlanModeReviewAskPolicy(a.get(IPlanService), a.get(IPermissionModeService)) }); +} +``` + +A complex domain may register a single **composite** node externally and run a small internal chain, hiding its internal order from the global chain. + +### 5.5 Tools declare resources at runtime (`resolveExecution` / `accesses`) + +In `resolveExecution(input)`, before execution, declare accessed resources with the `ToolAccesses.*` builders: + +```ts +resolveExecution(args: WriteInput): ToolExecution { + const path = resolvePathAccessPath(args.path, { kaos, workspace, operation: 'write' }); + return { + accesses: ToolAccesses.writeFile(path), // declares: write this file + approvalRule: literalRulePattern(this.name, path), + matchesRule: (ruleArgs) => matchesPathRuleSubject(ruleArgs, path, ...), + execute: () => this.execution(args, path), + }; +} +``` + +Current resource types: + +```ts +type ToolResourceAccess = + | { kind: 'file'; operation: 'read'|'write'|'readwrite'|'search'; path: string; recursive?: boolean } + | { kind: 'all' }; // non-enumerable side effects (pessimistic, globally exclusive) +``` + +Two complementary channels: + +- **Enumerable resources** (write/read/edit/grep/glob) → use `accesses`; generic file dimensions cover them automatically. +- **Non-enumerable resources** (bash running arbitrary commands) → do not declare `accesses`; use the `matchesRule` DSL (e.g. `Bash(rm *)` globs by command string). + +**kaos's role:** kaos is the execution-environment abstraction (fs/process/pathClass) used by the file dimension for path normalization and judgment — it is **not** the permission-dimension abstraction itself. Permission semantics live one layer above kaos, at "file access". + +**v2 evolution:** extend the `ToolResourceAccess` union so non-file resources can be declared structurally: + +```ts +type ToolResourceAccess = + | { kind: 'file'; operation: FileOp; path: string; recursive?: boolean } + | { kind: 'network'; operation: 'connect'; host: string } + | { kind: 'shell'; command: string } + | { kind: 'datastore'; operation: 'read'|'write'; table: string } + | { kind: 'all' }; +``` + +Each new resource kind can pair with a generic dimension that consumes it; tools always only **declare**. + +### 5.6 Dimension ownership + +| Dimension | Owner (who registers) | Type | +|---|---|---| +| external hook veto | `externalHooks` domain | generic | +| tool-batch exclusivity | `swarm` domain | domain-specific (ships with the AgentSwarm tool) | +| runtime-mode posture | `permissionMode` domain | generic | +| plan-mode constraints | `plan` domain | domain-specific | +| goal-start approval | `goal` domain | domain-specific | +| static config rules | `permissionRules` domain | generic (data path) | +| session approval memory | `permissionRules` domain | generic | +| sensitive / special paths | generic "file-access/security" dimension | generic (consumes `accesses`) | +| tool intrinsic risk | core permission | generic (consumes tool declarations) | +| workspace write trust | generic "file-access/security" dimension | generic (consumes `accesses`) | +| fallback | core permission | generic | + +Pattern: **specific dimensions ship with their owning domain + tool; generic dimensions register centrally and apply across tools via the declared `accesses`.** + +## 6. Evolution path + +Incremental, not big-bang: + +1. **Registry + Composer (zero behavior change).** Replace the 19 hardcoded `new`s in v2 `PermissionPolicyService` with reads from `IPermissionPolicyRegistry`; register existing policies as-is. Immediately gain multi-agent/mode selectable chains and an external registration entry. +2. **Declarative modes.** Lift the mode guards in `YoloModeApprove` / `AutoModeApprove` into `modes` metadata. +3. **Sink domain dimensions.** Move registration of plan/goal/swarm policies into their owning domain service constructors. +4. **(On demand) extend resource types.** When non-file resources (network/DB/shell) need structural dimensions, extend the `ToolResourceAccess` union. +5. **(On demand) swap the matching kernel for Casbin.** Only when external rules genuinely need RBAC/ABAC semantics, swap the data-path rule-matching kernel for Casbin. Not before. + +## Red lines (this topic) + +- Do not introduce Casbin — decisions are behavior bundles, not scalar effects. +- The chain encodes dimensions, not tools: a new tool must not lengthen the chain. +- New specifics go through the data path (rules); only new behavior goes through the code path (a policy node). +- A domain that owns a dimension self-registers its policy in DI; do not centralize domain policies in core. +- Tools only declare `accesses`; generic dimensions consume them. kaos is the execution environment, not the permission abstraction. +- Use `factory` (Agent-scope instantiation), not `instance`, for registered policies. diff --git a/.agents/skills/agent-core-dev/persistence.md b/.agents/skills/agent-core-dev/persistence.md new file mode 100644 index 000000000..832a2fe80 --- /dev/null +++ b/.agents/skills/agent-core-dev/persistence.md @@ -0,0 +1,204 @@ +# Topic — Persistence layering + +How business code persists data in `agent-core-v2`: the three-layer model (`Store → Storage → backend`), the naming rules for each layer, and how to decide which layer a domain should depend on. Read this before adding any persistence to a domain. + +A domain `I{Domain}EntityService` is a business facade over these layers, not a replacement for them. Before naming or bundling EntityServices by `session` / `agent` / `turn`, read [domain-boundaries.md](domain-boundaries.md). + +## The three-layer model + +Persistence is split into three layers, each hiding one kind of change: + +```text +Business Service + │ inject + ▼ +┌────────────────────────────────────────┐ +│ Store (semantic layer) │ ← access-pattern facade +│ IAppendLogStore / IAtomicDocumentStore│ append-log / atomic-doc / blob +└────────────────────────────────────────┘ + │ inject + ▼ +┌────────────────────────────────────────┐ +│ Storage (byte layer) │ ← byte primitives +│ IFileSystemStorageService │ read/write/append/list/delete +└────────────────────────────────────────┘ + │ implements + ▼ +┌────────────────────────────────────────┐ +│ Backend (deployment-specific) │ ← File / Postgres / Redis / S3 +│ FileStorageService / PostgresStorage │ +└────────────────────────────────────────┘ + │ uses + ▼ +┌────────────────────────────────────────┐ +│ Platform primitives │ ← hostFs / dbClient / redisClient +└────────────────────────────────────────┘ +``` + +Each layer hides exactly one concern: + +| Layer | Hides | Business code sees | +|---|---|---| +| **Store** | how an access pattern works (append-log reads, atomic-doc serialization) | "append this record" / "save this document" | +| **Storage** | byte primitives (atomic write, ordered append, prefix list) | `read/write/append/list/delete` over `(scope, key)` | +| **Backend** | deployment environment (file vs DB vs Redis vs S3) | nothing — chosen at the composition root | + +## The one-sentence rule + +> **Business code expresses *what* to store or fetch, never *how* to store it.** + +If business code contains any "how to persist" detail, it has punched through the layer it should depend on: + +| Business code contains | It has punched through | Depend on instead | +|---|---|---| +| `INSERT INTO …` / `SELECT …` | Storage + backend | a Store | +| file paths / `rename` / `fsync` | Storage | Storage or a Store | +| `JSON.parse` / `JSON.stringify` | Store (serialization) | `IAtomicDocumentStore` | +| append offsets / sequential cursors | Store (log semantics) | `IAppendLogStore` | +| `hash(data)` used as a key | Store (blob semantics) | `IBlobStore` | +| `pathe.join / relative / basename` on `homeDir` etc. | Bootstrap (path layout) | `IBootstrapService.scope(...)` / scope contexts | +| only `read/write/list/delete` on bytes | nothing — this is the byte layer | `IFileSystemStorageService` directly ✅ | + +## Where scopes come from — `IBootstrapService` and scope contexts + +Business code **never assembles scope strings from paths**. Scope strings come from three places: + +1. **`IBootstrapService.scope(name)`** — well-known top-level scopes (`'config' | 'sessions' | 'blobs' | 'store' | 'logs' | 'cache' | 'credentials'`). App-scope, deployment-agnostic contract. +2. **`ISessionContext.scope(subKey?)`** — persistence scope rooted at the current session; `scope('agents/main')` etc. +3. **`IAgentScopeContext.scope(subKey?)`** — persistence scope rooted at the current agent; `scope('cron')`, `scope('blobs')` etc. + +The bootstrap layer decides how each semantic scope maps to concrete addressing. In the file deployment, `FileBootstrapService` reads a `ResolvedEnvironment` (the paths bag) and returns homeDir-relative scopes; a server deployment could bind a different `IBootstrapService` implementation that maps `'sessions'` to a DB table without any business change. + +```ts +// ❌ Wrong — path arithmetic on homeDir/sessionDir leaks the file layout +const scope = relative(bootstrap.homeDir, join(session.sessionDir, 'agents', agentId, 'cron')); + +// ✅ Right — the agent already knows its own scope root +const scope = agentCtx.scope('cron'); +``` + +Absolute paths (`sessionDir`, `agentHomedir`) are still available on `IBootstrapService` for the very small number of legacy APIs that expose on-disk paths (session log rotation, background task tail file). Prefer scope strings; ask before adding a new absolute-path caller. + +## Which layer to depend on — decision tree + +```text +Need to persist + │ + ├─ read-whole / write-whole, JSON-serializable? + │ └─ IAtomicDocumentStore + │ + ├─ append-only writes / sequential reads, independent records? + │ └─ IAppendLogStore + │ + ├─ large object, addressed by content hash? + │ └─ IBlobStore + │ + ├─ custom byte layout (index / cache / binary) that read/write/list cover? + │ └─ IFileSystemStorageService directly + │ + ├─ new, reusable access semantics (multi-field query / time-range / graph)? + │ └─ add a new Store; business depends on the Store + │ + └─ business-specific, trivial, one or two lines? + └─ IFileSystemStorageService directly; if it grows, extract a private Store +``` + +## Naming — Store by access pattern, not by business + +A Store abstracts an **access pattern**, not a business data type. Name it after the pattern so its reusability is obvious from the name. + +| Access pattern | Store name | Backend examples | +|---|---|---| +| append-log (append / sequential read) | `IAppendLogStore` | `FileAppendLogStore` / `PostgresAppendLogStore` | +| atomic-document (read/write whole) | `IAtomicDocumentStore` | `FileDocumentStore` / `RedisDocumentStore` | +| blob (hash-addressed large object) | `IBlobStore` | `FileBlobStore` / `S3BlobStore` | + +**Do not name a generic Store after a business concept.** `IRecordStore` / `IConfigStore` make a reusable access pattern look like a private store for one feature. Any domain that needs an append-log uses `IAppendLogStore`; any domain that needs an atomic document uses `IAtomicDocumentStore`. + +**Exception — business-specific Stores are named after the business.** When a Store captures one domain's unique query semantics (not a generic access pattern), name it after the domain: + +```text +ISessionIndex query / enumerate sessions by workspace ← business-specific +``` + +Test: is the Store's semantics a *generic access pattern* (append-log / atomic-doc / blob) or *one domain's unique query*? Generic → name by pattern; unique → name by domain. + +## Storage — a filesystem-specific byte layer + +The byte layer is a single `IFileSystemStorageService` interface (read / readStream / write / append / list / delete / watch / flush / close). As the name says, it is **filesystem-specific**: it exposes the two irreducible durable primitives a local filesystem implements optimally — atomic whole-value replacement (`write`, via tmp + rename) and ordered durable extension (`append`, via `open('a')`). The node-fs Store backends (`AppendLogStore`, `JsonAtomicDocumentStore`, `BlobStoreService`) are built on it. + +```ts +export interface IFileSystemStorageService { + read(scope: string, key: string): Promise; + readStream(scope: string, key: string): AsyncIterable; + write(scope: string, key: string, data: Uint8Array, options?: { atomic?: boolean }): Promise; + append(scope: string, key: string, data: Uint8Array, options?: { durable?: boolean }): Promise; + list(scope: string, prefix?: string): Promise; + delete(scope: string, key: string): Promise; + watch?(scope: string, key: string): Event; + flush(): Promise; + close(): Promise; +} +``` + +Two backends implement it today, both bound at the composition root: + +```ts +// Production — local filesystem rooted at homeDir +collection.set(IFileSystemStorageService, new FileStorageService(homeDir)); + +// Tests — in-memory backend seeded by the test harness +collection.set(IFileSystemStorageService, new InMemoryStorageService()); +``` + +**Non-filesystem backends (Postgres, S3, Redis) do not implement this interface.** Atomic-rename and byte-append have no native equivalent in those stores, so they implement the **Store** interfaces directly via their own clients instead: + +```ts +// Server profile — append-logs on Postgres, atomic documents on Redis. +// Each Store is backed by a native client; IFileSystemStorageService is not involved. +collection.set(IAppendLogStore, new PostgresAppendLogStore(db, 'records')); +collection.set(IAtomicDocumentStore, new RedisDocumentStore(redis, 'config')); +``` + +Use the `scope` parameter to express **business namespace** within a backend. Do not overload `scope` to route backends — bind a different Store implementation at the composition root instead. + +## Store `acquire(scope, key)` — flush-on-dispose handle + +Stores that buffer writes expose an `acquire(scope, key)` handle so a business can flush them on disposal: + +```ts +export interface IAppendLogStore { + // … + /** + * Acquire a disposable handle for `(scope, key)`. Register it with your + * `Disposable` (via `this._register(...)`); when you are disposed, pending + * appends for that log are flushed. The shared store itself is not disposed. + */ + acquire(scope: string, key: string): IDisposable; +} +``` + +`IAppendLogStore.acquire` flushes the log's pending appends on dispose — it exists because `append` is fire-and-forget. `IAtomicDocumentStore.acquire` is a no-op today (atomic documents are durable on write) and exists for interface symmetry. Businesses that do not need flush-on-dispose simply do not call `acquire`. + +## When the byte layer does not apply + +`IFileSystemStorageService` covers only the local-filesystem byte primitives. It is not a universal storage abstraction: + +- **Non-filesystem backends** (Postgres / S3 / Redis) implement the **Store** interfaces directly via native clients — they never implement `IFileSystemStorageService`. +- **Blobs** are a Store-level interface (`IBlobStore`) with their own backends; the node-fs `BlobStoreService` sits on `IFileSystemStorageService`, but an `S3BlobStore` would not. +- **A backend has a fast primitive the Store interface cannot express** (e.g. Postgres `COPY`) → as an exception, extend that backend's Store implementation directly. This is an exception, not the default. + +## Platform primitives are deployment-coupled, not core abstractions + +`hostFs` (local filesystem) is a **platform primitive** used only by local backends (`FileStorageService`, `LocalFileSystemBackend`, `LocalSkillCatalog`, `HostFolderBrowser`). It is **not** a core abstraction and must not appear in L2/L3 dependency graphs. A server deployment swaps those backends for DB / S3 implementations and never registers `hostFs`. + +## Red lines (this topic) + +- Business code never contains "how to persist" details (serialization / paths / SQL / append offsets) — if it does, drop a layer. +- Business code never assembles scope strings from paths (`pathe.join / relative / basename` on `homeDir` / `sessionDir` / …). Use `IBootstrapService.scope(name)` for well-known scopes, `ISessionContext.scope(subKey?)` for session-rooted scopes, and `IAgentScopeContext.scope(subKey?)` for agent-rooted scopes. +- Name generic Stores by access pattern (`IAppendLogStore` / `IAtomicDocumentStore` / `IBlobStore`), never by business concept (`IRecordStore` / `IConfigStore`). +- Business-specific Stores (unique query semantics) are named after the domain (`ISessionIndex`). +- `IFileSystemStorageService` is the filesystem byte-layer interface; non-filesystem backends implement the **Store** interfaces directly. Route backends by binding a different Store implementation at the composition root, not by overloading `scope`. +- `hostFs` is a local-only platform primitive; L2/L3 domains must not import `node:fs` or `hostFs` directly. +- Only the file-backed bootstrap (`FileBootstrapService`) and file backends import `pathe`; business domains do not. +- Do not create a pass-through `Store` that only forwards `read/write` — a Store must hide a real access-pattern concern, or it is noise; use `IFileSystemStorageService` directly instead. diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md new file mode 100644 index 000000000..f347005b4 --- /dev/null +++ b/.agents/skills/agent-core-dev/server-align.md @@ -0,0 +1,250 @@ +# Subskill — Server align (expose `agent-core-v2` over `server-v2`) + +Wire a v2 domain into `packages/kap-server`, and — when the endpoint is part of the established `/api/v1` wire contract — keep the wire shape **byte-for-byte compatible** with what released v1 clients expect. This is the server-side counterpart of [align.md](align.md): `align.md` ports v1 *business logic* into v2; this file exposes the v2 result over HTTP / WS, reusing the v1 wire contract where it already exists. + +Use this when the task is "expose the new v2 Service on the server", "add a `/sessions/:sid/...` route to the `/api/v1` surface", or "keep server-v2 speaking the same `/api/v1` contract released clients rely on". + +## The one-paragraph mental model + +`server-v2` serves **two HTTP surfaces** off the same `agent-core-v2` scope tree: + +- **`/api/v2/:sa`** — the native v2 RPC surface, driven by the `actionMap` allowlist (`packages/kap-server/src/transport/actionMap.ts`). One `resource:action` segment maps to one `Service.method`. New v2-native capabilities land here. See [edge-exposure.md](edge-exposure.md). +- **`/api/v1/...`** — the v1-compatible surface, hand-written routes in `packages/kap-server/src/routes/*.ts` that **implement the established v1 wire contract path-for-path and schema-for-schema**, mounted by `registerApiV1Routes.ts`. This surface IS the v1 contract now (the legacy v1 server is gone); it exists so existing v1 clients keep working against server-v2 unchanged. + +The two surfaces can point at **different Services** for the same feature. v2's native `IAgentPromptService` serves `/api/v2`; a v1-shaped `IAgentPromptService` serves `/api/v1`. Keeping them separate is what lets v2's domain design stay clean while the wire stays compatible. + +## Decision: which surface? + +```text +Is the endpoint part of the established /api/v1 wire contract (protocol schema ++ released-client expectation)? +├─ YES → /api/v1 mirror route (this file, §schema-fidelity + §legacy-service). +│ Reuse the protocol schema; add a LegacyService if v2 semantics diverge. +└─ NO → /api/v2 native action (edge-exposure.md). + Add to actionMap, wrapping in a facade if the method fails §2 there. +``` + +A feature often needs **both**: the v1 mirror so old clients keep working, and the v2 action so new clients get the cleaner shape. Do them as two routes / two action-map entries over the same scope tree. + +## The server-align workflow + +```text +Pick surface → Read the v1 route (if any) → Reuse / add the protocol schema +→ Choose native Service vs LegacyService → Wire the route / actionMap entry +→ Map errors → Test against the v1 wire shape → Verify +``` + +### 1. Pick the surface + +Apply the decision above. For a v1-matched endpoint, the **spec** is the protocol schema plus the existing mirror routes: + +- `packages/protocol/src/rest/.ts` — the wire schema you must match. +- `packages/kap-server/src/routes/.ts` — the file you are writing (create it if missing); sibling route files show the conventions. + +The protocol schema is the source of truth. Do not re-derive the wire shape from memory or from the v2 domain model. + +### 2. Reuse (or add) the protocol schema + +The wire schema lives in **`@moonshot-ai/protocol`** under `packages/protocol/src/rest/.ts` (e.g. `promptSubmissionSchema`, `promptListResponseSchema`, `configResponseSchema`). Every `/api/v1` route in `packages/kap-server` imports from it — that single import is what guarantees the server speaks the same shape released clients expect. + +Actions: + +- **Schema already in protocol** → import it in the server-v2 route and use it in `defineRoute` (`body`, `success.data`, error `dataSchema` / `detailsSchema`). Do **not** re-declare the schema inline in server-v2. +- **Schema missing in protocol** → add it to `packages/protocol/src/rest/.ts` first, with a `rest-.test.ts`, then consume it from the route. The protocol package is the source of truth; server-v2 never owns a v1 wire schema locally. +- **Schema exists but only v1 uses it** → move/keep it in protocol and import it into server-v2; do not fork a copy. + +#### Schema-fidelity rule (the hard rule) + +For a `/api/v1` endpoint, the request and response schemas **must be the established protocol schema** (or a strict superset): + +- ✅ **Adding** an optional field is allowed (`field: z.string().optional()`). Old clients ignore it; new clients may send it. +- ❌ **Renaming** a field, **changing** its type, **tightening** its validation, or **changing its meaning** is a wire break — do not do it in a mirror route. If the v2 domain genuinely needs a different shape, that shape belongs on `/api/v2`, not on the `/api/v1` mirror. +- ❌ Re-declaring the schema inline in server-v2 (even if it "looks identical") is forbidden — it drifts. One schema, one home: `packages/protocol`. + +Self-check: "would a released v1 client get a byte-identical envelope from `packages/kap-server` for this request?" If you cannot answer yes from the shared schema, the route is wrong. + +### 3. Choose native Service vs LegacyService + +Resolve the v2 Service that will back the route. Two cases: + +**Case A — the v2 native Service already matches the v1 contract.** Use it directly. Most data/command Services (`IConfigService`, `IWorkspaceRegistry`, `IApprovalService`, `IQuestionService`, `IFileStore`, …) land here: the route is a thin adapter that resolves the scope, calls the method, and wraps the result. Examples: `routes/config.ts`, `routes/messages.ts`, `routes/questions.ts`, `routes/files.ts`. + +**Case B — the v1 contract needs behavior that would distort the v2 domain.** Introduce a **`*LegacyService`** — an L7 edge adapter that implements the v1 contract **on top of** the v2 native Service, leaving the native Service untouched. The v2 native Service keeps serving `/api/v2`; the LegacyService serves `/api/v1`. + +Reach for a LegacyService when **any** hold: + +- The v1 endpoint carries state the v2 domain deliberately dropped (e.g. a FIFO queue, a `prompt_id`, idempotent `abort`/`steer`, auto-start-next). +- The v1 method returns a handle/stream that v2 wraps differently, and the v1 clients expect the old envelope shape. +- Matching v1 would force a `Map`-at-`App` anti-pattern or a scope/domain-direction violation into the native Service (see [align.md](align.md) red lines). +- The native Service's error set / return type would have to grow v1-only branches. + +Do **not** put v1 quirks into the native v2 Service "to keep the route simple". That is the conflict this rule exists to prevent: the native Service serves the v2 architecture; the LegacyService serves the wire contract. + +#### LegacyService recipe + +A LegacyService is a normal v2 Service (service-authoring.md) with one extra convention: its contract is shaped by the **protocol** types, not by the v2 domain model. + +```text +packages/agent-core-v2/src/Legacy/ +├── Legacy.ts ← contract: protocol-typed interface + decorator +├── LegacyService.ts ← impl: delegates to the native v2 Service(s) +└── errors.ts ← v1-compatible error codes (KimiError codes) +``` + +Skeleton (matches `prompt/`): + +```ts +// prompt.ts — contract shaped by @moonshot-ai/protocol +import type { PromptSubmitResult, PromptSubmission } from '@moonshot-ai/protocol'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentPromptService { + readonly _serviceBrand: undefined; + submit(body: PromptSubmission): Promise; + // ...the rest of the v1 contract, typed by protocol +} +export const IAgentPromptService: ServiceIdentifier = + createDecorator('agentPromptLegacyService'); +``` + +```ts +// promptService.ts — impl delegates to the native v2 Service +constructor(@IAgentPromptService private readonly prompt: IAgentPromptService /*, ... */) {} +// submit() builds v2-native input, calls the native Service, projects the result +// back into the protocol PromptSubmitResult. + +registerScopedService( + LifecycleScope.Agent, // scope = the lifetime of the legacy state + IAgentPromptService, + AgentPromptLegacyService, + InstantiationType.Delayed, + 'prompt', +); +``` + +Conventions: + +- **Name** the domain `Legacy` and the interface with the scope prefix, `ILegacyService` (e.g. `prompt` / `IAgentPromptService`), per service-authoring.md. +- **Header comment** must say it is an `L7 edge adapter` and name both the v1 contract it implements and the native v2 Service it leaves untouched (see `prompt.ts`). +- **Scope** = the lifetime of the *legacy* state it holds (the `prompt` queue is per-agent → `LifecycleScope.Agent`). Apply [orient.md](orient.md) / [design.md](design.md) normally — a LegacyService is not exempt from scope rules. +- **Delegate, do not duplicate** business logic. The LegacyService translates the v1 contract into native-Service calls and translates results back; the real work stays in the native Service. +- **Contract types come from `@moonshot-ai/protocol`**, so the interface cannot drift from the wire shape. + +### 4. Wire the route / actionMap entry + +**For `/api/v1` (mirror):** add a route file under `packages/kap-server/src/routes/.ts` using `defineRoute`, then register it in `registerApiV1Routes.ts`. Resolve the scope from the URL (`session_id` → Session scope, agent → Agent scope via `IAgentLifecycleService.getHandle`), then `accessor.get(IX)` the native or Legacy Service. Match the established verbs, paths (`:sid` / `{session_id}`), and `parseActionSuffix` actions (`:steer`, `:abort`) exactly — sibling routes under `packages/kap-server/src/routes/` are the reference. + +```ts +const route = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/prompts', + body: promptSubmissionSchema, // ← from @moonshot-ai/protocol + params: sessionIdParamSchema, + success: { data: promptSubmitResultSchema }, // ← from @moonshot-ai/protocol + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.SESSION_BUSY]: {}, + [ErrorCode.PROMPT_ALREADY_COMPLETED]: { dataSchema: z.object({ aborted: z.literal(false) }) }, + }, + operationId: 'submitPrompt', + tags: ['prompts'], + }, + async (req, reply) => { + try { + const result = await resolveLegacy(core, req.params.session_id).submit(req.body); + reply.send(okEnvelope(result, req.id)); + } catch (error) { + sendMappedError(reply, req.id, error); + } + }, +); +app.post(route.path, route.options, route.handler); +``` + +**For `/api/v2` (native):** add a `resource:action` entry to `actionMap` ([edge-exposure.md](edge-exposure.md) §3). If the method fails the direct-exposure rules (returns a handle / stream / bytes, takes a live object), wrap it in a wire-shaped facade first (`IAgentRPCService` / `ISessionRPCService`) and map to the facade — as `prompts:*` does via `IAgentRPCService`. + +### 5. Map errors + +The route translates domain `KimiError` codes into protocol `ErrorCode` numbers. Two registries must stay in sync: + +- **Domain code** — register in `agent-core-v2/src/errors.ts` (`ErrorCodes`) and throw from the Service (errors.md). Co-located domain errors go in `Legacy/errors.ts` (e.g. `prompt.not_found`, `session.busy`). +- **Wire code** — register the matching number in `packages/protocol/src/error-codes.ts` and reference it in the route's `errors` map and `sendMappedError`. + +```ts +function sendMappedError(reply, requestId, err) { + if (isKimiError(err)) { + switch (err.code) { + case 'session.not_found': + case 'agent.not_found': + return reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); + case 'prompt.not_found': + return reply.send(errEnvelope(ErrorCode.PROMPT_NOT_FOUND, err.message, requestId)); + // ... + } + } + return reply.send(errEnvelope(ErrorCode.INTERNAL_ERROR, String(err), requestId)); +} +``` + +Match the v1 route's status codes and idempotent-conflict envelopes (e.g. `prompt.already_completed` → `40903` with `{ data: { aborted: false } }`). The error envelope is part of the wire contract — it is covered by the same schema-fidelity rule. + +### 6. Test against the v1 wire shape + +Add a `packages/kap-server/test/.test.ts` that boots the server and hits the route. Assert on the **envelope + protocol shape**, not on the v2 domain internals: + +- success envelope `{ code: 0, data: , request_id }`; +- each declared error envelope `{ code: , msg, data, request_id }`; +- the fields v1 clients read are present with the same names/types. + +Where the route mirrors v1, the test is the regression guard for the schema-fidelity rule: if someone drifts the protocol schema or the projection, this test breaks. + +### 7. Verify + +- `pnpm -C packages/kap-server test` — server routes green. +- `pnpm -C packages/protocol test` — schema tests green (incl. any new `rest-*.test.ts`). +- `pnpm -C packages/agent-core-v2 test` — native + Legacy Service tests green. +- `pnpm -C packages/agent-core-v2 run lint:domain` — a LegacyService is still inside the domain layers (edge adapter, L7); it must not pull business code into the edge or invert scope direction. +- `pnpm -C packages/server-e2e ...` when a v1 parity scenario exists. + +## Worked example — porting v1 `/sessions/:sid/prompts` + +This is the reference alignment (commits `feat(server-v2): port v1 /sessions/:sid/prompts routes`, `feat(server-v2): return turn ids for prompt actions`). It shows all three decisions at once. + +**The mismatch.** v1 `IPromptService` is a per-agent *scheduler*: it owns a FIFO queue, assigns `prompt_id`s, supports `steer`/`abort`, and auto-starts the next queued prompt when a turn settles. v2's native `IAgentPromptService` is a *turn driver*: a submission *is* a turn, there is no queue and no `prompt_id`. Forcing the queue into the v2 native Service would distort the v2 domain. + +**The split.** + +- `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to `IAgentRPCService` (a wire facade over the v2 turn driver) in `actionMap`. The native `IAgentPromptService` is untouched. +- `/api/v1` gets an `AgentPromptLegacyService` (`prompt/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IAgentPromptService`. The `/api/v1` routes consume the LegacyService. + +**The schema.** Both surfaces import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from `@moonshot-ai/protocol`. The `/api/v1` and `/api/v2` routes are therefore compatible with released clients by construction; the LegacyService projects v2 turn results back into those protocol shapes. + +**The errors.** v1 codes (`prompt.not_found`, `session.busy`, `prompt.already_completed`) are registered in `agent-core-v2` (`prompt/errors.ts`) and in `packages/protocol` (`error-codes.ts`), then mapped in the route's `sendMappedError` — including the idempotent `prompt.already_completed` → `40903 { data: { aborted: false } }`. + +**The lesson.** When the v1 contract and the v2 domain disagree, add an adapter (LegacyService) at the edge; do not let the wire contract leak into the native domain. The two surfaces share the protocol schema but not the Service. + +## Migration checklist + +Before submitting a server-align change: + +- [ ] Surface chosen deliberately: `/api/v1` mirror for a v1-matched endpoint, `/api/v2` for a new native capability (both if needed). +- [ ] For a `/api/v1` mirror, the route matches the established v1 contract (protocol schema + sibling routes) path-for-path, verb-for-verb, action-for-action. +- [ ] Request and response schemas come from `@moonshot-ai/protocol` (`packages/protocol/src/rest/.ts`); no inline re-declaration in server-v2. +- [ ] Existing schema fields are unchanged in name, type, and semantics; only optional fields added (if any). +- [ ] Native v2 Service left clean; v1-only behavior isolated in a `Legacy` / `ILegacyService` edge adapter when the semantics diverge. +- [ ] LegacyService registered with the correct `LifecycleScope` and a header comment naming it an L7 edge adapter + the native Service it preserves. +- [ ] Domain error codes registered in `agent-core-v2`; wire codes registered in `packages/protocol`; route maps them in `sendMappedError`, matching v1's status codes and idempotent envelopes. +- [ ] Route resolves the scope from the URL by `accessor.get(IX)`; no cached scope; finishes before disposal. +- [ ] Tests assert the wire envelope + protocol shape; schema tests in `packages/protocol` added/updated. +- [ ] `lint:domain` passes; the LegacyService did not invert scope or domain direction. + +## Red lines (this subskill) + +- One wire schema, one home: `packages/protocol`. Never re-declare a v1 wire schema inline in server-v2. +- A `/api/v1` mirror route must keep every existing schema field's name, type, and semantics; only optional additions are allowed. A different shape belongs on `/api/v2`, not on the mirror. +- Do not distort the native v2 Service to satisfy a v1 quirk — add a `Legacy` edge adapter instead. The native Service serves the v2 architecture; the LegacyService serves the wire contract. +- A LegacyService is still a v2 Service: it follows scope, domain-direction, and DI rules. "Edge adapter" describes its role, not an exemption. +- The protocol schema (`packages/protocol/src/rest/.ts`) plus the existing mirror routes are the spec for a `/api/v1` route — match them; do not re-derive the wire shape from the v2 domain model or from memory. +- Register every new error code in **both** `agent-core-v2` and `packages/protocol`; an unmapped code is a wire break. +- Events stream over WS (`listen`), never over the REST mirror; do not invent REST polling for something v1 pushed as an event. diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md new file mode 100644 index 000000000..d05e1fc0a --- /dev/null +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -0,0 +1,341 @@ +# Topic — Service authoring + +How to write a Service in `packages/agent-core-v2`: file layout, naming, what goes in the contract vs the impl, interface style, constructor / field conventions, events, multi-Service domains, and the comment rules. This is the day-to-day reference for stage 3 (implement.md covers the DI *mechanics*; this file covers the *authoring details*). + +## File layout + +One folder per domain, **camelCase**: `session/`, `sessionActivity/`, `contextMemory/`, `toolDedup/`. Inside, six kinds of files: + +```text +/ +├── .ts ← interface file: exactly one IXxx + its createDecorator + the types it owns +├── Service.ts ← impl file: exactly one class + exactly one registerScopedService(...) +├── .ts ← pure function(s): no Service suffix, no class, no registration +├── .ts ← contribution file (common): registers into another domain's extension point +├── .contrib.ts ← contribution file (uncommon / ad-hoc) +└── .types.ts ← shared types that no single interface owns +``` + +- **Strictly one service per file.** An interface file holds exactly one injectable interface and exactly one `createDecorator(...)`; an impl file holds exactly one service implementation class and exactly one `registerScopedService(...)`. No exceptions for "tightly-coupled" groups: even same-scope collaborators each get their own `.ts` + `Service.ts` pair. +- **Scope is in the filename.** `session*.ts` = Session, `agent*.ts` = Agent, no scope prefix = App (see [Naming](#naming)). The header comment restates the same scope. +- A domain therefore has as many impl files as it has services (e.g. `logService.ts` for the App `ILogService`, `sessionLogService.ts` for the Session `ISessionLogService`). See [Multi-Service domains](#multi-service-domains). + +The package entry `src/index.ts` imports and `export *`s every domain's leaf files precisely (one line per leaf), so importing the package still runs every `registerScopedService(...)` side effect — exactly as the old per-domain barrels did. + +## Naming + +### Interfaces and classes + +| Artifact | Rule | Example | +|---|---|---| +| Interface | `I` + scope prefix + PascalCase domain + role suffix. Scope prefix: `Session` / `Agent` / none (= App). Role suffix is usually `Service`. | `ISessionLogService`, `IAgentLoopService`, `ILogService` (App) | +| Class | the interface name minus the leading `I`, plus `Service` if it does not already end in `Service`; `implements` the interface | `SessionLogService implements ISessionLogService`, `AppendLogStoreService implements IAppendLogStore` | +| Decorator string | lowerCamelCase of the interface name minus the leading `I`; **globally unique and stable** (it surfaces in `CyclicDependencyError.path` and "no service registered" errors) | `createDecorator('sessionLogService')` | +| Model / non-service types | PascalCase, no `I` prefix | `SessionMeta`, `LogEntry`, `ConfigSection` | + +The scope prefix makes a service's lifetime readable from its name. App services carry **no** prefix (App is the default, longest-lived tier); Session and Agent services always carry `Session` / `Agent`. The prefix applies to the interface, the class, and therefore the file names. + +> Do **not** use the scope prefix to re-merge domains by lifetime. `IAgentEntityService`, `IAgentDataService`, and `ISessionEntityService` are still banned — the prefix marks lifetime, the rest of the name must still be the real owning domain (`IBackgroundTaskEntityService`, `ISessionMetadata`, `IPermissionRulesService`). See [domain-boundaries.md](domain-boundaries.md). + +### File names + +File names derive from the interface / class names so that scope and role are visible in the tree: + +| File kind | Rule | Example (interface → file) | +|---|---|---| +| Interface file | interface name minus leading `I`, minus trailing `Service` if present; acronym-aware lowerCamelCase | `ISessionLogService` → `sessionLog.ts`; `IAppendLogStore` → `appendLogStore.ts`; `ILogService` → `log.ts` | +| Impl file | the class name; acronym-aware lowerCamelCase | `SessionLogService` → `sessionLogService.ts`; `AppendLogStoreService` → `appendLogStoreService.ts` | +| Pure-function file | the function / concern name; no `Service` suffix | `formatLogEntry.ts`, `levelEnabled.ts` | +| Contribution file (common) | the **target** domain name | `config.ts` (registers a config section), `tool.ts`, `flag.ts` | +| Contribution file (uncommon) | `.contrib.ts` | `slackWebhook.contrib.ts` | +| Shared-types file | `.types.ts` | `log.types.ts` | +| Errors file | `.errors.ts` | `appendLogStore.errors.ts` | + +Acronym-aware lowerCamelCase lowercases a leading acronym as a group: `ILLMRequester` → `llmRequester.ts`, `IWSGateway` → `wsGateway.ts`, `IOAuthToolkit` → `oauthToolkit.ts`, `IAgentRPCService` → `agentRpcService.ts`. + +Because the impl class always ends in `Service` and the interface file never does, the two files of one service never collide — even for `Store` / `Registry` / `Resolver` interfaces (`IAppendLogStore` → `appendLogStore.ts` + `appendLogStoreService.ts`). + +## The contract file (`.ts`) + +Holds the public surface of the domain. A typical contract: + +```ts +/** + * `greet` domain (Ln) — one-line role. + * + * Defines the `Greeting` model and the `IGreeter` used by … Bound at … scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface Greeting { // model — no _serviceBrand + readonly message: string; +} + +export interface IGreeter { // injectable service — carries _serviceBrand + readonly _serviceBrand: undefined; + hello(): Greeting; +} + +export const IGreeter: ServiceIdentifier = + createDecorator('greeter'); +``` + +What belongs here: + +- **Model types** (`type` / `interface`) the domain exposes — `SessionMeta`, `LogEntry`, `ConfigSection`. +- **Service interface(s)** — the contract consumers depend on. +- **Decorator(s)** — one `createDecorator` per injectable service. +- **Helper types and pure functions** tightly bound to the contract — e.g. option bags, `satisfies`-checked seeds, predicate functions like `levelEnabled`. + +### Which interfaces carry `_serviceBrand` + +Only interfaces used as a **DI token** carry `readonly _serviceBrand: undefined`. Everything else does not: + +- ✅ Service interface resolved via `@IX` / `accessor.get(IX)` → carries `_serviceBrand`. +- ❌ Base interface extended by a service (e.g. `ILogger` extended by `ILogService`) → no `_serviceBrand`. +- ❌ Plain model / data interface (`LogEntry`, `SessionMeta`) → no `_serviceBrand`. + +```ts +export interface ILogger { // base interface — no brand + info(message: string): void; +} +export interface ILogService extends ILogger { // DI token — branded + readonly _serviceBrand: undefined; + setLevel(level: LogLevel): void; +} +``` + +## Interface style + +- **Sync methods** return a concrete type; **async methods** return `Promise`. Do not wrap a sync return in `Promise`. +- **Readonly fields** for immutable exposed state: `readonly ready: Promise`, `readonly modelAlias: string | undefined`. +- **Optional members** with `?`: `flush?(): Promise`, `close?(): Promise`. +- **Generics** where the caller supplies the shape: `get(domain: string): T`. +- **Extend** a base interface to share method groups: `interface ILogService extends ILogger`. +- **Events** as `readonly onDid…` / `onWill…` properties typed `Event` — see [Events](#events). + +```ts +export interface IConfigService { + readonly _serviceBrand: undefined; + readonly ready: Promise; + readonly onDidChange: Event; + get(domain: string): T; + set(domain: string, patch: unknown): Promise; + reload(): Promise; +} +``` + +## The impl file (`Service.ts`) + +Holds the concrete class(es) and the top-level registration. A typical impl: + +```ts +/** + * `greet` domain (Ln) — `IGreeter` implementation. + * + * … collaborators as roles ("logs through `log`") … Bound at App scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/log'; + +import { type Greeting, IGreeter } from './greet'; + +export class Greeter implements IGreeter { + declare readonly _serviceBrand: undefined; + + constructor(@ILogService private readonly log: ILogService) {} + + hello(): Greeting { + this.log.info('hello'); + return { message: 'hi' }; + } +} + +registerScopedService(LifecycleScope.App, IGreeter, Greeter, InstantiationType.Eager, 'greet'); +``` + +What belongs here: + +- **Imports** — `InstantiationType` from `'#/_base/di/extensions'`; `LifecycleScope` + `registerScopedService` from `'#/_base/di/scope'`; collaborators via the `#/` alias; the contract's types + decorator via a relative `./` import. +- **Class** — `XxxService implements IXxxService`, with `declare readonly _serviceBrand: undefined`. +- **Helper classes / functions** used only by this impl (e.g. a built-in writer, an `extractError` helper) — co-located in the same file. +- **Top-level `registerScopedService(...)`** — one per Service the file owns; importing the impl file runs the registration. + +## Constructor conventions + +- Declare every dependency with `@IX` on a constructor parameter. +- Use `private readonly` (or `protected readonly`) to store a used dependency as a field. +- For an injected dependency the class does **not** directly use (e.g. passed through, or only needed to force construction order), drop the visibility modifier and prefix with `_`: `@IEventService _event: IEventService`. +- Service parameters and static parameters may both appear; the ordering rule depends on how the object is created — see below. + +### Parameter order: scoped service vs `createInstance` + +- **`registerScopedService` services** — the container injects only the `@IX` parameters; any static parameters must have defaults and are left at their default when the container builds the instance. Order is therefore not enforced by the container, but the common style is **`@IX` parameters first, optional static parameters after**: + + ```ts + constructor( + @ILogWriterService protected readonly writer: ILogWriterService, + private readonly bound: LogContext = {}, + level: LogLevel = 'info', + ) {} + ``` + +- **`createInstance` objects** (non-singletons built with `instantiation.createInstance(Ctor, …staticArgs)`) — static parameters **must come first**, service parameters after, because the caller passes the static prefix positionally: + + ```ts + constructor( + private readonly input: string, // static — passed by caller + @ILogService private readonly log: ILogService, // service — injected + ) {} + ``` + +### Factory methods + +A scoped Service may expose a factory method that returns a **new** instance of itself (or a related class) with extra context bound — e.g. `ILogger.child(ctx)` returns `new LogService(this.writer, { …this.bound, …ctx }, this._level)`. This is not a DI violation: it is an explicit factory, not a request for the container to build a Service. Do not use it to circumvent scope or singleton semantics. + +## Fields and state + +- `private readonly` for fields set once at construction (injected deps, derived config). +- `private _name` (underscore prefix) for mutable private state: `private _level: LogLevel`. +- `readonly` public fields only for immutable exposed state; prefer a getter (`get level()`) when the value can change. +- Keep state minimal — a Service owns only the state that matches its scope's identity (design.md §2). Anything else belongs in a different Service. + +## Events + +v2 has two distinct event mechanisms. Pick by audience: + +### `Event` / `Emitter` — typed property on a Service + +Use when a Service exposes a typed event its consumers subscribe to. Lives in `'#/_base/event'`. + +```ts +// contract +import type { Event } from '#/_base/event'; +export interface IConfigService { + readonly onDidChange: Event; +} + +// impl +import { Emitter, type Event } from '#/_base/event'; +export class ConfigService extends Disposable implements IConfigService { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private notify(changed: ConfigChangedEvent): void { + this._onDidChange.fire(changed); + } +} +``` + +Conventions: + +- Back the public `Event` with a private `Emitter`, registered with `this._register(...)` so it disposes with the Service. +- Naming: `onDid…` for "happened" (past tense, after the fact); `onWill…` for "about to happen" (may allow `waitUntil` participation / veto — see `AsyncEmitter` / `IWaitUntil` in `'#/_base/event'`). +- The Delayed-instantiation Proxy preserves early `onDid…` / `onWill…` subscriptions (implement.md §5). + +### `IEventService` — global pub-sub bus + +Use to broadcast protocol events across domains. Lives in `'#/event'`. + +```ts +export interface IEventService { + readonly _serviceBrand: undefined; + publish(event: ProtocolEvent): void; + subscribe(handler: (event: ProtocolEvent) => void): IDisposable; +} +``` + +Inject `@IEventService` and `publish(...)`; `subscribe(...)` returns an `IDisposable` to register with `this._register(...)`. This is the bus for "a fact happened, react if you care" (design.md §4) — not for typed per-Service events. + +## Multi-Service domains + +A domain may define several Services. Each Service gets its own pair of files regardless of scope or coupling: + +- **One pair per Service** → `.ts` for the contract + `Service.ts` for the implementation. +- **Different scopes** → the scope prefix in the Service name makes this obvious (`logService.ts` for App `ILogService`, `sessionLogService.ts` for Session `ISessionLogService`). +- **Same interface, multiple role tokens** (e.g. `IAtomicDocumentStore` and `IAtomicTomlDocumentStore` share one interface type but are distinct DI tokens) → each token is its own Service identity and must be registered and resolved independently. + +There is no `index.ts` barrel: consumers import each contract/impl from its precise leaf path (e.g. `import { ILogService } from '#/log/log'`), never the domain directory. + +## No barrel — the package entry loads leafs precisely + +A domain has **no `index.ts` barrel**. Its files are the contract leaf (`.ts`) and the impl leaf (`Service.ts`), and consumers import the precise file — never the directory: + +```ts +import { IGreeter, type Greeting } from '#/greet/greet'; +``` + +Self-registration is unchanged: `greetService.ts` keeps its top-level `registerScopedService(...)`. The package entry `src/index.ts` loads the domain's leafs precisely — `export *` for the contract, a side-effect `import` for the impl — one line per leaf: + +```ts +// src/index.ts +export * from './greet/greet'; +import './greet/greetService'; +``` + +Importing the package therefore fires every `register*` side effect, exactly as the old per-domain barrels did. When you add a new domain, write the contract + impl leafs (with their top-level `register*`), then add the leaf path(s) to `src/index.ts`. **Do not create an `index.ts`.** + +- Load the impl file too — its top-level `registerScopedService(...)` only runs when the module is imported. +- `export *` helper modules only if they are part of the domain's public surface. +- Each leaf's file-header comment still names the domain, scope, and (for impls) the `register*` binding it owns. + +## Comments + +- **File-header comment is mandatory** and the only place comments live (orient.md). State the identity line, the role, collaborators (impls), and scope. +- **Methods and fields carry no comments by default.** Well-named identifiers and types say *what*; the code is the source of truth for *how*. +- Write an inline comment only when the *why* is non-obvious (a hidden constraint, a subtle invariant, a workaround). One short line. +- For unimplemented stubs, throw `NotImplementedError('feature')` rather than `throw new Error('TODO: …')` (errors.md). + +## Complete minimal example + +```ts +// greet/greet.ts +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface Greeting { readonly message: string; } + +export interface IGreeter { + readonly _serviceBrand: undefined; + hello(): Greeting; +} + +export const IGreeter: ServiceIdentifier = createDecorator('greeter'); +``` + +```ts +// greet/greetService.ts +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { type Greeting, IGreeter } from './greet'; + +export class Greeter implements IGreeter { + declare readonly _serviceBrand: undefined; + hello(): Greeting { return { message: 'hi' }; } +} + +registerScopedService(LifecycleScope.App, IGreeter, Greeter, InstantiationType.Eager, 'greet'); +``` + +```ts +// src/index.ts +export * from './greet/greet'; +import './greet/greetService'; +``` + +## Red lines (this topic) + +- One folder per domain, camelCase; one service per file pair: contract `.ts` + impl `Service.ts`; **no `index.ts` barrel** — `src/index.ts` loads each leaf file precisely. +- Exactly one injectable interface and one `createDecorator(...)` per contract file. +- Exactly one service implementation class and one `registerScopedService(...)` per impl file. +- `IXxxService` / `XxxService` naming; decorator string is lowerCamelCase, globally unique, and stable. +- Name Services by owning domain, never by scope (`IAgentEntityService`, `ISessionEntityService`). +- `_serviceBrand` only on interfaces used as a DI token — never on base interfaces or plain models. +- Sync methods return concrete types, async return `Promise`; do not `Promise`-wrap sync work. +- `createInstance` objects put static parameters before service parameters; scoped services put `@IX` parameters first (static params need defaults). +- Never `new` a `@IService`-carrying Service — except inside an explicit factory method, which is not a DI request. +- Events: typed per-Service event → `Event`/`Emitter` from `'#/_base/event'`; cross-domain broadcast → `IEventService` from `'#/event'`. +- `src/index.ts` must import/export every leaf file (including the impl) so each `register*` side effect runs. +- File-header comment only; methods/fields carry no comments by default; stubs throw `NotImplementedError`. diff --git a/.agents/skills/agent-core-dev/telemetry.md b/.agents/skills/agent-core-dev/telemetry.md new file mode 100644 index 000000000..5eaa27413 --- /dev/null +++ b/.agents/skills/agent-core-dev/telemetry.md @@ -0,0 +1,95 @@ +# Topic — Telemetry + +Telemetry infrastructure for agent-core-v2: how business services emit events, how context propagates, and how events reach a destination through appenders. + +Telemetry is a **layer-1 root** domain (alongside `log`): pure `App` scope, stateless, no business-domain dependencies. It is a thin facade — enrichment, batching, and transport belong to the appenders, not to this layer. + +## Where things live + +- `src/app/telemetry/telemetry.ts`: contract — `ITelemetryService` (facade), `ITelemetryAppender` (destination), `TelemetryProperties`, `nullTelemetryAppender`, and `TelemetryServiceOptions`. +- `src/app/telemetry/events.ts`: event registry — `telemetryEventDefinitions` pairs every business event's property type with review metadata (owner / purpose / per-property comment); the single source of truth for `track2`. +- `src/app/telemetry/telemetryService.ts`: `TelemetryService` impl + `registerScopedService(LifecycleScope.App, …)`. +- `src/app/telemetry/consoleAppender.ts`: `ConsoleAppender` — echoes events to a log function (dev / debug). +- `src/app/telemetry/cloudAppender.ts`: `CloudAppender` — sanitizes + PII-cleans properties, batches + enriches + posts to the telemetry endpoint. +- `src/app/telemetry/cloudTransport.ts`: `CloudTransport` — HTTP transport behind `CloudAppender`. +- `src/app/telemetry/privacy.ts`: outbound PII redaction (`cleanTelemetryProperties`) — URLs, emails, tokens, and absolute file paths become `` labels; `node_modules/` tails are kept. + +## Emitting events (business services) + +Inject `ITelemetryService` and call `track2` with a registered event: + +```ts +import { ITelemetryService } from '#/app/telemetry/telemetry'; + +constructor(@ITelemetryService private readonly telemetry: ITelemetryService) {} + +this.telemetry.track2('cron_fired', { task_id: taskId, coalesced_count: 0, stale: false, buffered: false, recurring: true }); +``` + +`track2` is checked against the registry in `events.ts` at compile time: the event name must be a key of `telemetryEventDefinitions`, and the properties must match the registered interface exactly (extra or missing keys are compile errors). **New events must be registered first** — add a properties interface and a `defineTelemetryEvent

({ owner, comment, properties })` entry documenting every property. Naming: snake_case for events and properties, unit suffixes (`_ms` / `_count` / `_bytes`), no user content or file paths; `test/app/telemetry/events.test.ts` enforces the conventions. The low-level `track` remains for appender plumbing and tests only. + +`TelemetryService.track` merges the bound context into the properties and fans the event out to every registered appender. A single throwing appender is isolated via `onUnexpectedError` and never blocks the rest. + +### Context (sessionId / agentId / turnId) + +The service carries a bound context (`sessionId` / `agentId` / `turnId`) that is merged into every event. Bind it at construction or derive a scoped view: + +```ts +const child = telemetry.withContext({ agentId: 'main', turnId: 't1' }); +child.track2('tool_call', { turn_id: 1, tool_call_id: 'c1', tool_name: 'bash', outcome: 'success', duration_ms: 12 }); // carries sessionId + agentId + turnId +``` + +`withContext(patch)` returns a new service sharing the same appenders; per-call properties override bound context on key collision. `setContext(patch)` mutates the bound context in place and propagates to appenders that implement `setContext`. + +## Appenders (destinations) + +An appender is the destination an event is fanned out to. It is **not a DI Service** — it is a plain object implementing `ITelemetryAppender`, held by `TelemetryService`. + +```ts +export interface ITelemetryAppender { + track(event: string, properties?: TelemetryProperties): void; + withContext?(patch: TelemetryContextPatch): ITelemetryAppender; + setContext?(patch: TelemetryContextPatch): void; + flush?(): Promise | void; + shutdown?(): Promise | void; +} +``` + +Built-in appenders: + +- `ConsoleAppender` — `[telemetry] ` to a log function (default `console.log`); options `prefix` / `pretty` / `log`. +- `CloudAppender` — batches events, enriches with common context (`app_name` / `version` / `platform` / …), and posts to `https://telemetry-logs.kimi.com/v1/event` through `CloudTransport` (Bearer auth, retry, on-disk fallback). Options: `homeDir` / `deviceId` / `sessionId?` / `appName` / `version` / `uiMode?` / `model?` / `getAccessToken?` / `endpoint?` / `flushThreshold?` / `flushIntervalMs?`. + +### Registering appenders (bootstrap) + +Appenders are added after the App scope exists, by resolving the service and calling `addAppender`: + +```ts +const app = createAppScope(); +const telemetry = app.accessor.get(ITelemetryService); + +telemetry.addAppender(new ConsoleAppender({ prefix: '[dev]' })); // dev echo +telemetry.addAppender(new CloudAppender({ // production + homeDir, deviceId, sessionId, + appName: 'kimi-code', version, uiMode: 'shell', model, + getAccessToken: () => auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME), +})); +``` + +`addAppender` returns an `IDisposable` that removes the appender when disposed. `setAppender(appender)` resets to a single appender (mainly for tests). `removeAppender(appender)` drops one. + +> There is no production bootstrap wired yet — `TelemetryService` defaults to `[nullTelemetryAppender]`, so `track(...)` is a no-op until `addAppender` is called at startup. + +## Lifecycle + +- `setEnabled(false)` drops `track` (service-level switch); `setEnabled(true)` resumes. `flush` / `shutdown` are unaffected by the switch. +- `flush()` / `shutdown()` fan out to all appenders concurrently; a single rejecting appender is swallowed. Await `shutdown()` before process exit so buffered events (e.g. in `CloudAppender`) are sent. + +## Red lines (this topic) + +- Business services depend only on `ITelemetryService` — never import an appender class. +- Telemetry is layer-1 root: do not inject any business-domain service into it, and do not move it off `App`. +- Appenders are plain `ITelemetryAppender` objects, not DI Services — register them with `addAppender`, never via `registerScopedService`. +- `track` is fire-and-forget and must not throw; appender `track` must be synchronous — buffer and send asynchronously via `flush` / `shutdown`. +- Await `telemetry.shutdown()` before process exit when a buffering appender is registered. +- Keep event names stable; register every business event in `events.ts` and emit via `track2` — properties must be JSON-serializable primitives (non-primitives are dropped with a warning by `CloudAppender`). diff --git a/.agents/skills/agent-core-dev/test.md b/.agents/skills/agent-core-dev/test.md new file mode 100644 index 000000000..37f931984 --- /dev/null +++ b/.agents/skills/agent-core-dev/test.md @@ -0,0 +1,262 @@ +# Stage 4 — Test + +Exercise the **same path production uses**: a service is reached by its interface through the container, its `@IService` dependencies are resolved from the container, and — where the scope layer matters — through the scope tree. Tests that `new` a service and paper over its constructor with hand-rolled objects bypass that path and let the `registerScopedService(IX → Impl)` binding rot untested. + +`@IService` parameter decorators run under vitest (the build uses `experimentalDecorators`), so fixtures declare dependencies exactly like production code. There is **no** `param()` helper, no manual `(Id as …)(Ctor, '', 0)`, and no capturing `accessor` inside a constructor to synchronously `.get()` a peer. + +## The one rule + +**Resolve the system under test by its interface, through the container. Never call `new` on a production service whose constructor carries `@IService` dependencies.** + +```ts +// ✅ resolve by interface — the IX → Sut binding is exercised +ix.set(IMessageService, new SyncDescriptor(MessageService)); +const svc = ix.get(IMessageService); + +// ❌ construct the implementation directly — the registration is never run +const svc = new MessageService(stubContext); +``` + +Resolving by interface is what makes `registerScopedService(ISut, Sut, …)` part of the test. Constructing the class directly (or via `ix.createInstance(Sut)`) tests the class in isolation but leaves the binding, the scope layer, and the delayed/eager flag unverified. + +Pure functions, value objects, and services with **no** `@IService` dependencies may be constructed directly. + +The only other exception is a test that genuinely needs **two independent instances** of the same service with different dependencies (e.g. constructing two `TurnService`s with different `ILoopRunner`s). A singleton-per-container resolution cannot produce both, so `ix.createInstance(Impl)` is acceptable there — annotate it with a comment explaining why. + +## Two harnesses + +Pick the harness by *whether the scope layer is part of what you are testing*. + +| Under test | Harness | Resolve the SUT with | +|---|---|---| +| A single service's behavior (unit) | `TestInstantiationService` (flat) | `ix.get(ISut)` after `ix.set(ISut, new SyncDescriptor(Sut))` | +| Cross-scope wiring, or which layer a service lives in | `createScopedTestHost` (scope tree) | `host..accessor.get(ISut)` | + +### Unit harness — `TestInstantiationService` + +Default for domain service unit tests. It is an `InstantiationService` that also implements `ServicesAccessor` (so you can `ix.get(...)` directly) and owns sinon (so `dispose()` restores stubs). + +```ts +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import type { TestInstantiationService } from '#/_base/di/test'; +import { registerRecordsServices } from '../records/stubs'; + +describe('XxxService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = createServices(disposables, { + base: [registerRecordsServices], + additionalServices: (reg) => { + reg.define(IContextService, ContextService); // 1. real collaborator, by interface + reg.define(IXxxService, XxxService); // 2. system under test, by interface + }, + }); + }); + afterEach(() => disposables.dispose()); + + it('does the thing', () => { + const svc = ix.get(IXxxService); // 3. resolve by interface + expect(svc.thing()).toBe('…'); + }); +}); +``` + +`createServices` builds the container from domain **service groups** plus per-test overrides (see Service groups). Reach for `ix.stub(...)` / `ix.set(...)` directly only inside an `it` when a single test needs to swap a registration: + +- whole service, partial object: `ix.stub(IId, { method() { return … } })`; +- single method: `ix.stub(IId, 'method', value)` returns a sinon stub; `ix.spy(IId, 'method')` returns a spy; +- a prebuilt instance or descriptor: `ix.set(IId, instance)` / `ix.set(IId, new SyncDescriptor(Impl))`; +- when a collaborator's behavior must vary per test, model it as a `Test*Service` subclass whose methods read suite-scoped `let` variables rather than rebuilding the container each test. + +### Scope harness — `createScopedTestHost` + +Reach for this only when *which layer a service lives in* is itself the thing being asserted, or when the SUT reads from parent/child scopes. It builds the real `Scope` tree and resolves through it. + +```ts +import { beforeEach, describe, expect, it } from 'vitest'; +import { InstantiationType } from '#/_base/di/extensions'; +import { + LifecycleScope, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; + +describe('XxxService (scoped)', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Agent, + IXxxService, + XxxService, + InstantiationType.Delayed, + 'xxx', + ); + }); + + it('resolves from the Agent scope with ancestor deps injected', () => { + const host = createScopedTestHost([stubPair(ILogService, stubLog())]); + const agent = host.child(LifecycleScope.Agent, 'main'); + const svc = agent.accessor.get(IXxxService); // by interface + expect(svc.thing()).toBe('…'); + host.dispose(); + }); +}); +``` + +Always `_clearScopedRegistryForTests()` and re-register explicitly in `beforeEach`. Do not rely on a production module's top-level `registerScopedService(...)` side effect: import order then becomes part of the test, and another suite's `_clearScopedRegistryForTests()` can wipe it. + +## Register the SUT by interface + +Whichever harness you use, the SUT is registered under its interface (`ix.set(IX, new SyncDescriptor(Impl))` or `registerScopedService(scope, IX, Impl, …)`) and resolved by that interface. This is non-negotiable: it is the only thing that keeps the production registration honest. + +A test that does `ix.createInstance(Impl)` is testing the class, not the service. Convert those (see Migration). + +## Shared stubs + +Hand-rolled stubs (`noopLog`, `noneEvent`, `unusedRecords`, …) must not be copied between test files. Each domain that owns a frequently-stubbed interface exports a stub from a `stubs.ts` **in the `test/` tree**, never from `src/`: + +```text +test/log/stubs.ts → stubLog() / stubLogger() +test/turn/stubs.ts → stubTurn() +test/records/stubs.ts → stubAgentRecords() +test/environment/stubs.ts → stubEnvironment() +``` + +All test support lives under `test/` so test-only code stays out of the production source tree. Because `tsdown` builds from `src/index.ts`, anything under `test/` is unreachable from the entry and is never bundled into `dist/`. + +Conventions: + +- export a **factory** (`stubXxx()`), not a shared singleton, so tests cannot leak state through a stub; +- name it `stub` — e.g. `stubAgentRecords`; +- the stub satisfies the full interface so the compiler, not a cast, guarantees it stays in sync; +- import it with a **relative path** — `./stubs` from the same domain's tests, `..//stubs` from another domain. Never import stubs from `#/…` (that alias is for production `src/`) and never import one test file from another; +- a `stubs.ts` may import its domain's production types via `#//…`. + +If a stub is needed by two test files, it belongs in that domain's `test//stubs.ts`. + +## Service groups + +Most unit tests stub the same handful of collaborators (`ILogService`, `IAgentRecords`, `IConfigService`, `ITelemetryService`, …). Rather than repeat `ix.stub(...)` lines in every `beforeEach`, each domain exports a `register*Services` function from its `stubs.ts` that registers the default test doubles for that domain: + +```ts +// test/log/stubs.ts +export function registerLogServices(reg: ServiceRegistration): void { + reg.defineInstance(ILogService, stubLog()); +} +``` + +`createServices(disposables, { base, additionalServices })` composes them: + +- `base` — an ordered list of service groups. Each group's registrations are deduped (first writer wins), so groups supply safe defaults without clobbering each other. +- `additionalServices` — applied after `base`. Registrations here **overwrite** any base default, so a test can swap a stub for a spy, register the system under test, or supply a one-off collaborator. + +```ts +ix = createServices(disposables, { + base: [registerLogServices, registerConfigServices, registerRecordsServices], + additionalServices: (reg) => { + reg.definePartialInstance(IAgentKaos, {}); // one-off collaborator + reg.define(IAgentRecords, spyRecords); // override a base default + reg.define(IXxxService, XxxService); // system under test + }, +}); +``` + +`ServiceRegistration` offers three verbs: + +- `define(id, Ctor)` — lazy `SyncDescriptor`; the service is instantiated on first resolve. Use for real collaborators and the system under test. +- `defineInstance(id, instance)` — a fully-built instance (a fake such as `stubLog()`, or `new ConfigRegistry()`). +- `definePartialInstance(id, { ... })` — a partial mock; only the supplied members are provided. Use for collaborators the test does not exercise. + +Conventions: + +- a group registers the domain's services **as dependencies** (a fake, or a `{}` partial when no fake exists yet). When a service is the system under test, the test registers the real implementation via `additionalServices` and does not rely on the group's default for it; +- keep groups small and domain-local. A service that is almost always the system under test, or that every consumer configures differently, should not have a group — register it inline via `additionalServices`; +- import groups with a **relative path** (`..//stubs`), never from `#/…`. + +`createServices` defaults to `strict: false` (missing dependencies warn rather than throw), matching `new TestInstantiationService()`. Pass `strict: true` to surface unregistered `@IService` dependencies. + +## Declaring dependencies + +Always use `@IService` constructor decorators — in fixtures and in production services alike. + +```ts +// ✅ +class Consumer { + constructor(@IGreeter private readonly greeter: IGreeter) {} +} + +// ❌ no param() helper, no inline cast +class Consumer { + constructor(private readonly greeter: IGreeter) {} +} +param(IGreeter, Consumer, 0); +``` + +Because the decorator runs when the class is defined, the `createDecorator` identifier must be initialized **before** the class that uses it. Declare the identifier, then the class: + +```ts +const IDep = createDecorator('dep'); +class Consumer { + constructor(@IDep private readonly dep: IDep) {} +} +``` + +For two services that depend on each other (a cycle), declare both identifiers first, then both classes, so neither class references an uninitialized binding. + +Declare fixtures at module top, interface + decorator + implementation co-located, and keep `_serviceBrand` on the interface when it represents a real service — `GetLeadingNonServiceArgs` relies on the brand to tell service parameters apart from static ones. Pure throwaway fixtures may omit `_serviceBrand`. + +## Lifecycle / teardown + +One `DisposableStore` per suite. Add the **container** and any event subscriptions to it; dispose in `afterEach`. + +```ts +beforeEach(() => { disposables = new DisposableStore(); /* … */ }); +afterEach(() => disposables.dispose()); +``` + +Do **not** add the system-under-test itself to the store. `TestInstantiationService` disposes every service it creates when the container is disposed, so `ix.get(IX)` instances are cleaned up automatically via `disposables.add(ix)`. Wrapping the SUT in `disposables.add(...)` would double-dispose it. For the same reason, do not call `svc.dispose()` at the end of a test unless you are asserting something about disposal itself. + +Scope-host tests call `host.dispose()` in `afterEach` (or at the end of the `it`). Route teardown through the store so ordering is deterministic and nothing leaks when a test fails mid-way. + +## Assertions and naming + +- One behavior per `it`; describe observable behavior (`child shadows parent registration`), not implementation (`calls _getOrCreateServiceInstance`). +- For cycles, assert `CyclicDependencyError` and its `path` array (e.g. `['A', 'B', 'A']`), not merely `toThrow`. +- For disposal order, capture events in an array and assert the sequence (`['C', 'B', 'A']` — children before parents). + +## Migrating existing tests + +Most legacy tests build the SUT with `ix.createInstance(Impl)`. Converting one is mechanical: + +1. import the interface (`IX`) and the descriptor; +2. register the SUT by interface — `reg.define(IX, Impl)` inside `additionalServices` (or `ix.set(IX, new SyncDescriptor(Impl))`); +3. replace `ix.createInstance(Impl)` with `ix.get(IX)`; +4. drop the `disposables.add(...)` wrapper around the SUT and any trailing `svc.dispose()` — the container disposes it; +5. replace any hand-rolled collaborator object with the domain's shared stub or service group (or add one to `test//stubs.ts` if it does not exist); +6. delete now-unused imports. + +Before / after: + +```ts +// before +const svc = ix.createInstance(MessageService); + +// after — registration in beforeEach additionalServices +reg.define(IMessageService, MessageService); +// after — resolution in the test body +const svc = ix.get(IMessageService); +``` + +## Red lines (this stage) + +- Resolve the SUT by interface — never `new` a production service with `@IService` deps; prefer `ix.get(IX)` over `ix.createInstance(Impl)`. +- Shared stubs live in `test//stubs.ts` (never `src/`); import by relative path, never `#/...`. +- Scope tests call `_clearScopedRegistryForTests()` and re-register explicitly in `beforeEach`; do not rely on production import-order side effects. +- One `DisposableStore` per suite; add the container, dispose in `afterEach`; do not add the SUT itself. +- Declare fixture dependencies with `@IService`; initialize `createDecorator` identifiers before the classes that use them. diff --git a/.agents/skills/agent-core-dev/verify.md b/.agents/skills/agent-core-dev/verify.md new file mode 100644 index 000000000..88d091700 --- /dev/null +++ b/.agents/skills/agent-core-dev/verify.md @@ -0,0 +1,32 @@ +# Stage 5 — Verify & submit + +Run the guards and re-scan the red lines before submitting. + +## Commands + +Run from the package (or with `--filter @moonshot-ai/agent-core-v2`): + +- `pnpm --filter @moonshot-ai/agent-core-v2 lint:domain` — domain-layer / dependency-direction guard (`scripts/check-domain-layers.mjs`). Catches a domain importing a layer it must not. +- `pnpm --filter @moonshot-ai/agent-core-v2 typecheck` — `tsc -p tsconfig.json --noEmit`. +- `pnpm --filter @moonshot-ai/agent-core-v2 test` — `vitest run`. + +## Changesets (when the change ships through the CLI) + +If the change is user-facing and ships through the CLI, generate a changeset with the repository's `gen-changesets` skill (root `AGENTS.md` workflow). `agent-core-v2` is an internal package; if its change enters the CLI bundle, the changeset lists `@moonshot-ai/kimi-code` and describes the real change — do not present an internal-only change as a user-facing feature. Never write a `major` bump without explicit user confirmation. + +## Pre-submit checklist + +Walk the stages you touched and confirm: + +- **Design** — scope follows state identity; no `Map` at `App`; dependency arrows do not make a foundational layer know an upstream one; no cycle was routed around. +- **Implement** — no `new` on `@IService`-carrying classes; `@IX` on constructor params only (service params after static params); interface + impl carry `_serviceBrand`; decorator names unique; coded errors only; flags for unreleased behavior. +- **Test** — SUT resolved by interface; stubs under `test/`; scope tests re-register after `_clearScopedRegistryForTests()`; teardown through one `DisposableStore`. +- **Files** — header comments describe role + scope only; registration runs from the impl file's top level; the new domain is exported from `src/index.ts`. + +Then re-read the [global red lines](SKILL.md#global-red-lines) once — they catch most cross-stage mistakes in a single scan. + +## Red lines (this stage) + +- Do not skip `lint:domain` — it is the only automated check for the dependency-direction rules. +- Do not list internal packages in a changeset when the change enters the CLI bundle — list `@moonshot-ai/kimi-code` and describe the real change. +- Never write a `major` changeset without explicit user confirmation. diff --git a/.agents/skills/agent-core-review/SKILL.md b/.agents/skills/agent-core-review/SKILL.md new file mode 100644 index 000000000..64df636e0 --- /dev/null +++ b/.agents/skills/agent-core-review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: agent-core-review +description: Use ONLY for code review and test write/review guidance in `packages/agent-core-v2` (the DI × Scope agent engine). Does NOT apply to the legacy `packages/agent-core` or to any other package — for those, do not load this skill. Groups the review and testing lenses used for agent-core-v2 — `slop` (single-level-of-abstraction / layered error-handling review, invoked only on explicit request) and `test` (contract-driven per-test rules for both authoring and reviewing tests). Apply the sub-skill that matches the task; do not apply `slop` unprompted. +has-sub-skill: true +--- + +# kc-review + +> **Scope: `packages/agent-core-v2` only.** These lenses are calibrated for the v2 engine (DI × Scope). Do not apply them to the legacy `packages/agent-core` or to other packages. + +A bundle of the lenses used when reviewing or testing `packages/agent-core-v2`. Each sub-skill is self-contained; invoke the one that matches the task. + +## Sub-skills + +- **`slop/`** — Single Level of Abstraction & layered error handling. A *review dimension*: a function should read as a straight-line description of its own layer, with errors handled above or below. The agent reports detections and measurements, not severity grades. **Invoke only when the user explicitly asks for this lens** — do not apply it unprompted to general reviews or refactors. +- **`test/`** — Per-test rules behind "test the contract / responsibility, not the implementation," serving two modes. **Write mode:** author a test — one behavior per `it`, drive through the public surface, stub only the true external boundary, control time/config via documented knobs, keep tests clear, isolated, and refactor-resilient (CCCR). **Review mode:** audit existing tests against the same rules and report findings with `file:line`. Use when writing, modifying, or reviewing tests, or when asked how to write a good single test. + +## Routing + +- Reviewing code structure / abstraction layers / where error handling belongs → `slop` (only on explicit request). +- Writing or modifying tests, reviewing test quality, or advising on a single test → `test`. diff --git a/.agents/skills/agent-core-review/slop/SKILL.md b/.agents/skills/agent-core-review/slop/SKILL.md new file mode 100644 index 000000000..7e97d4a6d --- /dev/null +++ b/.agents/skills/agent-core-review/slop/SKILL.md @@ -0,0 +1,133 @@ +--- +name: slop +description: Invoke only when the user explicitly asks to review code through the "single level of abstraction / layered error handling" lens — a function does only its own layer's business logic while errors are handled above or below. The agent reports detections, raw-count measurements, and move directions. Apply only when the user explicitly requests this lens. +--- + +# Single Level of Abstraction & Layered Error Handling + +North star: **a function should read as a straight-line description of what its own layer does. Anything that is not that — input validation, error handling, error-to-response translation, logging, retries, low-level mechanics — belongs to a layer above or below, not inline.** + +This is a review dimension, not a hard rule. See "Exemption checklist" at the end. + +## Scope of this skill — detect and measure + +The agent applying this lens is a **sensor**. Its one job is to report *whether* a function mixes levels and *by how much*; deciding *how serious* it is belongs downstream. Severity labels (`Block` / `Request changes` / `Nit`) compress a continuous quantity into an uncalibrated three-point scale and are the main source of review-to-review variance, so they are produced downstream — by a deterministic rubric, anchored examples, or a human — from the facts the agent reports. + +The agent's output is exactly these four things: + +- **Detection (yes/no):** does this statement / block / function violate a rule of the lens? +- **Measurement (raw factual counts only):** mechanically countable quantities — body size, control-flow keywords, named syntactic shapes (see "Quantify"). Anything that first requires classifying a line (core/foreign, happy/error, high/low level) is recorded under detection, not here. +- **Direction (where it moves):** for each foreign concern, the destination layer — push **down** into a value / parser / infra helper, or push **up** into the edge handler. +- **Exemption flags:** which items, if any, hit the exemption checklist — recorded, not weighed. + +Severity grades, merge/block verdicts, and "is splitting worth it" calls live downstream, derived from the four items above. + +## When to use + +Apply this lens only when the user asks for it explicitly (for example "用单一抽象层次审视一下", "check whether this function does too much", "errors should be handled above/below, right?"). Leave general reviews and refactors to other lenses unless the user names this one. + +## The principle + +One function, one level of abstraction, one responsibility. Three mutually reinforcing rules: + +1. **Single Level of Abstraction (SLAP).** Every statement inside a function sits at the same conceptual level. High-level intent ("reserve inventory, charge payment, create the order") must not be interleaved with low-level mechanics (building headers, escaping strings, opening sockets, parsing bytes). If some lines read as "what" and others as "how", they belong in different functions. +2. **Error handling is its own concern (Clean Code).** A function either does the work or handles the error — not both. Business logic describes the happy path and *signals* failure (throw or return a result); the catch, mapping, logging, and recovery live in a dedicated handler, usually one layer up. Prefer exceptions / result types over threaded check-and-return ladders that interrupt the main flow. +3. **Separation of concerns by layer.** Each layer owns exactly one kind of knowledge: low-level code knows formats and protocols; mid-level code knows business rules; edge code knows the outside world (HTTP / CLI / UI). A function that knows two of these at once is leaking a layer. + +The combined test: **could you explain this function to someone without using the word "and"?** If the explanation is "it reserves stock AND validates the email format AND maps the error to a status code AND logs to metrics", it is doing more than its layer's job. + +Concerns that usually do **not** belong in a business function: + +- Format / range / null validation that a lower value or parser could guarantee once. +- Mapping domain failures to an external protocol (status code, exit code, UI message) — that is the edge layer's job. +- Catch-and-swallow, retry loops, backoff, timeout, circuit breaking around a single call — infrastructure, push down. +- Cross-cutting telemetry / log / metric noise woven through every step — extract or push to a wrapper. +- Check-and-return ladders that occupy more space than the business core — replace with signal + a handler above. + +## Methodology — fixing a function that violates it + +Work top-down. Never start by shuffling lines. + +1. **Name the level.** In one sentence, write what this function is for at its own layer. If you cannot, the function has no clear level — split before polishing. +2. **Classify every statement.** Tag each line or block as: **core** (this layer's business), **down** (a detail a lower abstraction should own), **up** (a concern an upper / edge layer should own), or **cross-cutting** (log / metric / retry). Unlabeled lines are where the mess hides — do not "just leave them". +3. **Decide down vs. up for each foreign item.** + - Push **down** when it is a guarantee a lower building block can provide: a value that can only be constructed valid, a parser that returns a typed result, an infra helper that already retries. The business function then assumes validity and stays clean. + - Push **up** when it is about translating or reacting to failure for the outside world: status codes, messages, exit codes, aggregation of many errors. The edge layer catches once and maps; business code just signals. + - Rule of thumb: if removing it would change what the business rule says, it is core and stays; if removing it only changes how a failure is reported or a detail is computed, it moves. +4. **Extract, do not interleave.** Pull each foreign concern into its own named function or layer. Keep the original function as a readable sequence of same-level calls. For error handling specifically, separate the work body from the recovery body into distinct functions so neither clutters the other. +5. **Signal, do not handle, in the middle.** Mid-layer business functions throw / return and let the right layer react. Do not catch-and-log-and-continue in business code unless continuing is itself the business rule. +6. **Re-read for level.** After the moves, every remaining line should be explainable at the same altitude. If not, repeat from step 1. + +Keep the change minimal: move the smallest thing that restores the level. Do not invent abstractions, frameworks, or generic "handler" machinery beyond what the function actually needs. Three straight-line, same-level calls beat a premature pipeline. + +## Review method — applying the lens to a diff + +Read each changed or touched function and, for each check, record only: **the hit (yes/no) plus evidence (`file:line`)**, and — where the check points at a construct — a raw factual count from "Quantify". + +1. **Altitude check.** Are all lines at the same level of abstraction? Record each place where a "what" line is immediately followed by a "how" block (or vice versa) inside the same function, with `file:line`. +2. **Happy-path check.** Can you read the business intent top to bottom without stepping through error branches? Record whether error handling sits inline between business steps (yes/no + `file:line`), supported by raw counts from "Quantify" (e.g. number of `catch` clauses, `continue` statements). +3. **Ownership check.** For each validation, catch, mapping, log, retry: is this layer the rightful owner, or is it borrowed from above / below? Record each borrowed item with `file:line` and its destination (down / up), using the rules from the methodology. +4. **Layer-leak check.** Does a business function mention an external protocol (status code, exit code, UI text, wire field)? Does an edge function contain a business rule? Record each leak candidate with `file:line` and whether it names an *external* protocol or an *internal* domain shape. +5. **Explanation test.** Describe the function in one sentence with no "and". Record whether "and" was needed; if so, list the proposed split as candidate moves (down / up). + +### Quantify — report only raw factual counts + +Report only quantities that can be counted **mechanically from the text**. Anything that first requires classifying a line (core vs foreign, happy-path vs error-handling, high-level vs low-level) is recorded under detection (the five checks above) as evidence, not as a number here. + +Report, per function: + +- **Body size** — lines and/or statements of the function body; state the basis (e.g. "statements, excluding lone braces"). +- **Control-flow keywords (raw counts)** — `if`, `continue`, early `return`, `throw`, `try` / `catch` / `finally`, `await`, loops (`for` / `while` / `.forEach`). +- **Named syntactic shapes a check points at** — when a check cites a construct, count it verbatim and name the exact token: e.g. number of object literals, string literals, `.trim()` calls, `.length` reads, `origin.` property reads, spread `[...x]` operations. +- **Recovery presence (raw)** — number of `catch` clauses, and number of log / metric calls inside them. + +Quantities that embed a prior classification — out-of-level vs core counts, guard-to-core ratios, happy-path vs error-handling volume, "repeated boundary checks a lower layer could guarantee once", "low-level literals in a high-level flow" — are captured as evidence under the relevant check (`file:line` + the verbatim tokens). A downstream rubric derives any ratio from those raw facts. + +### Red flags + +Record each as evidence (yes/no + `file:line`); these are candidates, not verdicts: + +- A body that is mostly check-and-return / check-and-throw ladders around a thin core. +- A recovery block that logs, maps, and returns inline, sitting next to business steps. +- A function that both computes a value and decides how that value's failure is shown to the user. +- Low-level literals (byte offsets, header strings, format codes) inside a high-level workflow. +- A name that needs "And" / "Or" / "With" to be honest, or a name so vague ("handle", "process", "do") that it hides multiple levels. +- Catch-and-swallow that hides a failure the caller needed to see. +- Defensive null / format checks repeated at every call site instead of guaranteed once at the boundary. + +### Severity grading belongs downstream + +The agent's facts (detections, raw counts, directions, exemptions) feed a downstream grade; the agent reports those facts and stops there. Grades compress a continuous quantity into an uncalibrated three-point scale and are exactly where identical evidence gets labeled differently across runs. Grading happens above the agent: + +- A **deterministic rubric** — a versioned threshold table over the raw counts from "Quantify"; or +- **Anchored examples** — the reviewer judges relative to repo-known reference functions rather than against an absolute adjective like "materially"; or +- A **human**, for items that land near a threshold boundary. + +If a downstream consumer still asks the agent for a grade, the agent returns the underlying facts and the threshold band it would fall under, with `confidence: low` on boundary cases; the grade itself is produced downstream. + +### How to report findings + +Report **evidence + direction**. Lead with the location and the level, then the proposed move. Prefer "this block is one level lower than the rest of the function (`file:line`) — move it **down** into X" over "this is ugly" or "this is a request-changes". The destination layer (down into a value / parser / infra helper, or up into the edge handler) is the actionable output and the deliverable. Attach the "Quantify" numbers and any exemption flags to each finding. + +## Exemption checklist + +This is a lens, not a law. For each foreign concern, check whether any exemption below applies and **record the hit (yes/no) plus the reason**. The agent records exemptions as facts; a recorded exemption is then used downstream to cap the grade (e.g. to `Nit`) deterministically. + +- **Tiny function:** the function is small enough that splitting would add indirection with no reader benefit. +- **Foreign concern is the single job:** the "foreign" concern is in fact the function's one purpose — a dedicated error mapper, a validator, an infra wrapper, or an index-bookkeeping helper whose low-level arithmetic *is* its level. +- **Atomicity / correctness / performance:** the steps genuinely must stay together (e.g. a re-check after an `await` to guard state that may have changed). +- **Edge-translator role:** an edge / handler function whose job is to translate an external event into internal indices; naming the wire fields is its job. + +Keep a split that would make the code harder to read as a recorded candidate for downstream review. When the evidence lands on an exemption boundary, record both sides and set `confidence: low`. + +## Output contract + +Return, per function, items 1–5 only: + +1. **Level statement** — one sentence: what the function is for at its own layer. +2. **Per-check results** — for each of the five review checks: `hit: yes/no`, evidence `file:line`, and (only where the check points at a construct) a raw factual count. +3. **Measurements** — the raw factual counts from "Quantify". +4. **Exemptions** — checklist hits (yes/no + reason). +5. **Proposed moves** — for each foreign concern: `file:line` → destination (down into X / up into Y). This is the actionable deliverable. + +Severity grades, block/merge verdicts, and "worth splitting" calls live downstream, derived from items 1–4. When a consumer asks for a label, hand back items 1–4 and the threshold band, with `confidence: low` on boundary cases. diff --git a/.agents/skills/agent-core-review/test/SKILL.md b/.agents/skills/agent-core-review/test/SKILL.md new file mode 100644 index 000000000..28ac09e5e --- /dev/null +++ b/.agents/skills/agent-core-review/test/SKILL.md @@ -0,0 +1,115 @@ +--- +name: test +description: Use when writing or reviewing tests, or when asked how to write a good single test. Encodes the per-test rules behind the "test the contract / responsibility, not the implementation" principle — name and structure one behavior per `it`, drive through the public surface, stub only true external boundaries, control time and config via documented knobs, and keep tests clear, isolated, and refactor-resilient. The same rules drive both authoring (write mode) and auditing existing tests (review mode). +--- + +# Tests — write & review + +Per-test rules that operationalize one principle: **test the contract / responsibility, not the implementation**. This is the how-to for a single `it`, and the lens for reviewing one. + +## Two modes, one rule set + +- **Write mode** — authoring a test. Apply the rules below to produce it. +- **Review mode** — auditing an existing test or test diff. Apply the same rules as a checklist; report each violation with `file:line`, the rule it breaks, and the fix. See "Review mode" near the end. + +The rules are identical in both modes — only the posture changes (produce vs. audit). + +## Test contract, not implementation + +- Drive the system through its **public control plane** and assert on **observable effects** (returned values, persisted state, emitted events, injected messages), never on source details. +- Resolve collaborators through their contract — the interface plus its identifier — not the module that binds a concrete implementation. +- Do not reach into private fields or add backdoors "for testing". If you feel the need, the seam is wrong — fix the design, not the test. + +## One behavior per `it` + +Each `it` covers exactly one responsibility / scenario. If the name needs "and", split it. + +```ts +it('returns 401 when the caller is unauthorized', ...); +it('does not double-fire when the same tick repeats', ...); +``` + +## Name and structure + +- `describe(' ()'` — name the **responsibility**, not the class. +- An `it(...)` reads as a sentence, but it must still encode three things — the **behavior / method**, the **state or condition**, and the **expected outcome**: `it(' when , ')`. A name like `does X when Y` with no result is too vague to fail usefully. + - Use spaces, not the Java-style `method_state_outcome` underscores — that convention exists only because Java test methods cannot contain spaces. A string-named test reads fine as a sentence. + - Good: `it('returns 401 when the caller is unauthorized')` · `it('advances the cursor and does not double-fire on a repeat tick')` + - Bad: `it('works')` · `it('handles auth correctly')` — no condition, no outcome +- Arrange / Act / Assert. A short `// Given` `// When` `// Then` is fine when it aids reading; do not paste it mechanically on trivial tests. + +## Build a small rig + +When several tests share setup, write a factory (`rig()`, `createHost()`, whatever fits the codebase) that returns the **smallest surface the test needs**. Tests reach into the rig; they do not rebuild the world each time. Keep the rig dumb: wiring only, no assertions. + +## Stub only the real external boundary + +Default to real collaborators wired the way production wires them. Stub the **minimum seam** that is genuinely external: + +- A remote / model / service boundary — spy on the contract method (the interface), and capture what the system sends across it. Do not stand up the real external thing. +- Network / other-process boundaries — stub at the boundary, not the internals. +- Time, timers, jitter — use the documented control knobs the system exposes (env, an injected clock, a manual tick). Do **not** use fake timers or real `setTimeout` to drive time. +- Env / config knobs are usually snapshotted at bootstrap — set them **before** building the system under test, and restore them in `afterEach`. + +## Keep tests DAMP and keep cause next to effect + +- DAMP over DRY: use **literal expected values** in assertions; do not compute the expectation with the same logic as the code under test. +- Keep the key preconditions inside the `it` (or its rig), where the reader can see cause next to effect. Reserve `beforeEach` for cross-cutting plumbing (env snapshot, cleanup), not for hiding the scenario's setup. + +```ts +// Good — the expected value is a literal the reader can check. +expect(discount).toBe(15); +// Bad — re-derives the expectation; mirrors the implementation. +expect(discount).toBe(price * rate); +``` + +## Assert only what is relevant + +Assert the effect that proves the contract. Use matchers / partial-object matching to ignore incidental fields. Do not assert internal counters, call orders, or shapes the user cannot rely on. + +## Isolate and clean up (no flakes) + +Every test must be hermetic and order-independent. In `afterEach`: + +- restore every mock / spy +- restore every env var you touched (snapshot in `beforeEach`) +- dispose the host / container and reset its reference + +No dependence on wall-clock time, run order, or leftover on-disk state — give each scenario its own isolated identity / workspace when state persists. + +## Quality bar: CCCR + +Before finishing, check each test against: + +- **Clarity** — a stranger can tell what broke from the failure message alone. +- **Completeness** — covers the responsibility's success, error, and boundary paths. +- **Conciseness** — no duplicate or speculative cases; one scenario per `it`. +- **Resilience** — survives an internal refactor with no test change (because it asserts contract, not implementation). + +## Per-file scenario header + +Start each test file with a short header comment: the **scenario**, the **responsibilities** asserted, the **wiring** (which collaborators are real vs. the single stubbed boundary), and how to run it. + +## Review mode — auditing existing tests + +Apply the rules above as a checklist against each test in scope (a file, a diff, or a named `it`). For every hit, report `file:line` + the rule it breaks + the fix; do not rewrite unless asked. Lead with the contract question: *what observable behavior does this test prove, and would it survive a refactor?* + +Check, in order: + +1. **Contract, not implementation** — asserts observable effects, not private fields, call order, or internal shapes the user cannot rely on. +2. **One behavior per `it`** — the name carries behavior + condition + outcome; "and" in the name means a split is owed. +3. **Boundary discipline** — only the true external seam is stubbed; time is driven by documented knobs, not fake timers / real `setTimeout`. +4. **DAMP expectations** — expected values are literals, not re-derived by the code under test's logic. +5. **Isolation** — mocks / spies / env / host restored in `afterEach`; no wall-clock, run-order, or leftover on-disk dependence. +6. **CCCR read-through** — Clarity, Completeness (success / error / boundary), Conciseness, Resilience. + +Report findings as evidence + fix, e.g. "`foo.test.ts:42` asserts on `service.internalMap` (contract) — assert the returned value instead." If a test passes the lens, say so briefly; silence on a rule means it held. + +## Quick checklist (write & review) + +- Resolved through the contract; no concrete-impl import +- One behavior per `it`; name carries behavior + condition + outcome; AAA +- Stubbed only the true external seam; time via knobs, not fake timers +- Literal expectations; relevant assertions only +- Mocks / env / host restored in `afterEach`; hermetic, no flakes +- CCCR read-through done diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 9b7b5796a..b5fb0fd50 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -11,20 +11,23 @@ description: Use when generating changesets in the kimi-code repository, includi All other `@moonshot-ai/*` packages are treated as internal packages, including `@moonshot-ai/kimi-code-sdk`, `agent-core`, `kosong`, `kaos`, `kimi-code-oauth`, `kimi-telemetry`, and `migration-legacy`. +`@moonshot-ai/pi-tui` is a special internal package: it is a private fork (`private: true`) that is never published, but it keeps its own changelog through changesets. It is an exception to Core Rule 4 — see the dedicated section below. + ## Core Rules 1. **Inspect the actual changes first.** Use `git status` / `git diff --name-only` to identify which packages were actually changed. -2. **List packages that were actually changed.** Source code, build config, package metadata, and other changes that affect a package's output or behavior need a changeset entry for that package. -3. **Do not list unchanged internal packages.** For example, if `packages/node-sdk` was not changed, do not list `@moonshot-ai/kimi-code-sdk` just because another internal package changed. The SDK follows the same rule as other internal packages: list it only when it was actually changed. -4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, also list `@moonshot-ai/kimi-code`. +2. **List packages that changesets can release.** If a changed package is ignored in `.changeset/config.json`, do not put that ignored package in frontmatter together with a non-ignored package; changesets rejects mixed ignored/non-ignored frontmatter. +3. **Map ignored internal changes to the affected released package.** If an ignored internal package changes CLI output or behavior, list `@moonshot-ai/kimi-code` and describe the actual user-visible or release-artifact change in the changelog text. +4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, list `@moonshot-ai/kimi-code`. + - **Web app (`@moonshot-ai/kimi-web`) changes always enter the CLI bundle.** `@moonshot-ai/kimi-web` is ignored by changesets (see `.changeset/config.json`) and cannot be mixed with `@moonshot-ai/kimi-code` in one changeset frontmatter. Describe the web change in the changelog text, but list `@moonshot-ai/kimi-code` so the CLI release carries the bundled `dist-web` output. 5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump. 6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. ## Workflow -1. List the packages that were actually changed. +1. List the changed packages and check whether each one is ignored by `.changeset/config.json`. 2. Choose a bump level for each package. -3. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`. +3. If an ignored internal package change enters the CLI bundle, put `@moonshot-ai/kimi-code` in frontmatter instead of mixing the ignored package into the same changeset. 4. Create a short kebab-case file under `.changeset/`. 5. Split unrelated changes into separate changesets; keep one logical change in one file. @@ -43,10 +46,12 @@ Format: | Level | When to use | |---|---| -| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades | -| `minor` | New backwards-compatible features or capabilities | +| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades; small improvements to existing features with limited user-facing impact (e.g. a new keyboard shortcut, a flag alias, a minor UX tweak) | +| `minor` | A substantial new user-facing feature, such as a new slash command, a new built-in tool, or a new mode | | `major` | Breaking changes: incompatible config changes, renamed or removed commands/arguments, behavior semantics changes, and similar | +When in doubt between `patch` and `minor`: if the change improves an existing feature and the user-facing impact is small, choose `patch` even when the change is technically "new". Reserve `minor` for a substantial new capability that introduces something users could not do before. + ### Major Rule Never write `major` on your own. @@ -56,30 +61,72 @@ If you believe a change qualifies as major, stop first, explain why, and ask the ## Wording Rules - Changelog entries **must be written in English**. -- **Keep it short — ideally a single sentence that states what was done.** Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change. +- **Keep the whole entry concise.** Aim for one short sentence that states what was done; at most a short sentence plus a one-line usage hint. Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change. +- **For new user-facing features, append a brief usage hint** so users know how to try it. Keep it to a single short line — a command name, a subcommand, a flag, or a one-line "how to use". Do not explain design rationale or list edge cases. Skip the hint for bug fixes, internal changes, and refactors. + - Slash command: `Add the /foo slash command to list active sessions. Run /foo to see them.` + - CLI subcommand: `Add the kimi web subcommand to open the web UI. Run kimi web to launch it.` + - Flag: `Add a --bar flag to skip confirmation prompts. Pass --bar to skip.` + - Too long: `Add the /foo command to list active sessions. It accepts an optional --all flag to include background sessions, supports filtering by name with /foo , and writes the result to the transcript...` - User-facing CLI wording should only be used when CLI users can perceive the change. - Internal changes that do not affect CLI users can still share a changeset with the CLI, but the wording must describe the real change honestly and must not present it as a user-facing feature. - Do not mention file names, class names, function names, PR numbers, or commit hashes. +- Do not include real internal endpoints, key names, account names, or service names. If an example is needed, use neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY`. - Avoid vague words such as `refactor`, `optimize`, and `improve`. Describe the actual change, or use more specific wording. +## When You Are Unsure About a Change + +Generate the changeset from what the diff clearly shows. If part of a change is unclear and you cannot confidently describe what it does for users, do not guess or pad the entry with vague wording. + +1. Finish the changeset for the parts that are clear. +2. Then ask the user once, in a short list: name the specific change(s) you do not understand, and ask whether you may dig into the repository (read related source, tests, or call sites) to describe it more accurately. +3. Only read more code after the user agrees. If the user says no or does not reply, keep the concise wording you already have and do not invent detail. + ## Common Examples An internal package fixes a bug visible to CLI users: ```markdown --- -"@moonshot-ai/agent-core": patch "@moonshot-ai/kimi-code": patch --- Fix occasional loss of tool call results in long conversations. ``` +A new user-facing slash command (note the short usage hint): + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /foo slash command to list active sessions. Run /foo to see them. +``` + +A new CLI subcommand: + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the kimi web subcommand to open the web UI. Run kimi web to launch it. +``` + +A new flag on an existing command: + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +Add a --bar flag to skip confirmation prompts. Pass --bar to skip. +``` + An internal package has an internal-only change, but it enters the CLI bundle: ```markdown --- -"@moonshot-ai/agent-core": patch "@moonshot-ai/kimi-code": patch --- @@ -96,11 +143,83 @@ Only SDK source changed, and the CLI does not use it: Clarify session status typing for internal SDK callers. ``` +## Web app changes + +`@moonshot-ai/kimi-web` is ignored by changesets and must **never** appear in a changeset frontmatter. Because the web app is bundled into the CLI release artifact, any web change that ships must list `@moonshot-ai/kimi-code` instead and describe the actual web-facing change in the text. + +- Prefix the changelog entry text with `web: ` (for example `web: Fix the chat not scrolling to the bottom after sending a message.`) so the synced docs changelog can mark web UI entries. Apply this whenever the change is to the web project (`@moonshot-ai/kimi-web`). +- If a PR ships a web UI feature backed by server API changes that exist solely to power that feature, prefer a single `web:` entry describing what the web user gets. Do not add a separate server-API changeset unless the API has independent user value (a public endpoint that SDK or server consumers call directly). The docs changelog sync also deduplicates this pattern, but catching it here avoids duplicate changesets. +- Do not enumerate every micro-tweak; keep it to one sentence that captures what the web user gets. + +Web-only fix: + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix the chat not scrolling to the bottom after sending a message. +``` + +Web UI plus backing server APIs in the same PR (prefer a single `web:` entry; the API is plumbing): + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +web: Add the server-hosted web UI, including chat layout and session list behaviors. +``` + +Split into two changesets only when the API has independent user value on its own (for example, a public endpoint SDK consumers call directly). In that case add the web entry above plus a separate one such as `Add a public REST API to list archived sessions for SDK consumers.` + +## `@moonshot-ai/pi-tui` changes + +`@moonshot-ai/pi-tui` is a vendored fork that lives in `packages/pi-tui`. It is `private: true` and is never published, but it is **not** ignored by changesets: changesets versions it and writes `packages/pi-tui/CHANGELOG.md` so the fork keeps its own history. Because it is bundled into the CLI like other internal packages, it is an exception to Core Rule 4 — do **not** list `@moonshot-ai/kimi-code` for a change that only touches pi-tui. + +- Changes that only affect pi-tui (build, package, strict-mode cleanup, renderer fixes): list `@moonshot-ai/pi-tui` only. No CLI changeset. +- If the same change is also user-visible in the CLI (for example a terminal rendering fix that CLI users can see), add a **separate** changeset that lists `@moonshot-ai/kimi-code` with CLI-focused wording, in addition to the pi-tui changeset. Do not mix both packages in one frontmatter — the two changelogs need different wording. + +pi-tui-only change: + +```markdown +--- +"@moonshot-ai/pi-tui": patch +--- + +Export the package manifest so the bundled binary can locate its native assets. +``` + +pi-tui change that is also visible in the CLI (two separate changesets): + +```markdown +--- +"@moonshot-ai/pi-tui": patch +--- + +Clamp the differential render to the visible viewport so scrolling up during streaming no longer jumps to the top. +``` + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the transcript jumping to the top when scrolling up through history during streaming output. +``` + ## Red Flags - You are about to write `major` without asking the user. +- A new user-facing feature entry has no usage hint, or the hint runs to multiple lines and explains design rationale. +- You guessed wording for a change you do not understand instead of asking the user whether you may dig into the repo. - Internal package source enters the CLI bundle, but `@moonshot-ai/kimi-code` is missing. +- A changeset frontmatter mixes ignored internal packages with non-ignored packages. - `packages/node-sdk` was not changed, but `@moonshot-ai/kimi-code-sdk` was listed for "internal package sync". - The changelog entry is in Chinese. - The wording claims more than the diff actually did. - The CLI wording mentions internal package names, class names, or PR numbers. +- The entry includes real internal identifiers instead of neutral placeholders. +- A change that only touches `@moonshot-ai/pi-tui` lists `@moonshot-ai/kimi-code` instead of `@moonshot-ai/pi-tui`, or mixes both packages in one frontmatter. +- A web app change entry is missing the `web: ` prefix. +- A server/API changeset exists only to back a web feature that a `web:` changeset already describes (use one `web:` entry instead, unless the API has independent user value). diff --git a/.agents/skills/gen-docs/SKILL.md b/.agents/skills/gen-docs/SKILL.md index 62830a652..f205ad00d 100644 --- a/.agents/skills/gen-docs/SKILL.md +++ b/.agents/skills/gen-docs/SKILL.md @@ -75,6 +75,7 @@ This skill depends on the following being in place. If any are missing, stop and - **Locale sync**: Non-changelog pages stay mirrored between `docs/en/` and `docs/zh/`. Changelog flows English → Chinese. - **Terminology**: Use the term table in `docs/AGENTS.md` exactly. Do not invent new translations or use synonyms. - **Scope discipline**: Only update sections affected by the recent changes. Do not opportunistically rewrite unrelated docs. +- **Public examples**: Never write real internal endpoints, key names, account names, or service names into docs. Use neutral placeholders such as `https://api.example.com/v1`, `https://registry.example.com/v1/models/api.json`, `example.test`, and `YOUR_API_KEY`. - **Breaking changes**: If any change is breaking, also update `docs/en/release-notes/breaking-changes.md` (under `## Unreleased`) with `**Affected**` + `**Migration**` subsections, and mirror it in `docs/zh/release-notes/breaking-changes.md`. - **Do not edit auto-synced files**: `docs/en/release-notes/changelog.md` is regenerated by the sync script; any manual edit will be overwritten. @@ -85,3 +86,4 @@ This skill depends on the following being in place. If any are missing, stop and - Updating only one locale and leaving its mirror stale. - Editing only the mirror to fix wording that should be corrected in the locale you changed first. - Inventing new terminology that drifts from the `docs/AGENTS.md` term table. +- Using real internal values in examples instead of neutral `example` placeholders. diff --git a/.agents/skills/pre-changelog/SKILL.md b/.agents/skills/pre-changelog/SKILL.md new file mode 100644 index 000000000..adde7c1b3 --- /dev/null +++ b/.agents/skills/pre-changelog/SKILL.md @@ -0,0 +1,67 @@ +--- +name: pre-changelog +description: Use before merging a kimi-code release PR to preview the user-facing CLI changelog in Chinese. Reads the changelog that changesets pre-generated in the release PR, then reuses sync-changelog's strip / classify / translate logic to render a Chinese preview. Writes no files. +--- + +# Pre-Changelog + +Preview the user-facing **Chinese** changelog of an open `kimi-code` release PR **before** it is merged. Read-only: this skill writes no files and commits nothing. + +This skill reuses `sync-changelog`'s strip / classify / translate rules. Read `sync-changelog` first; only the data source (release PR diff instead of a published `CHANGELOG.md`) and the output (preview instead of docs files) differ. + +## Workflow + +### 1. Locate the release PR + +```bash +gh pr list --state open --search "ci: release packages in:title" \ + --json number,title,url,headRefName,baseRefName +``` + +Pick the one with `headRefName: changeset-release/main`; record `number`, `url` as ``. If none is open, nothing to preview — stop. + +### 2. Read the pre-generated CLI changelog block + +changesets already pre-generates `apps/kimi-code/CHANGELOG.md` inside the release PR. Extract the new version block from the diff: + +```bash +gh api repos/MoonshotAI/kimi-code/pulls//files \ + --jq '.[] | select(.filename=="apps/kimi-code/CHANGELOG.md") | .patch' +``` + +Take the added lines (`+`) from the top `## ` down to (but not including) the next `## `. That is the version block to preview. + +If the CLI changelog is not in the diff (for example an SDK-only release), stop and tell the user — there is no user-facing CLI changelog to preview. + +### 3. Render the Chinese preview (reuse `sync-changelog`) + +Process the version block exactly as `sync-changelog` does for the docs site, but only in memory: + +- **Strip** (`sync-changelog` step 3): drop the H1, the `### Patch Changes` / `### Minor Changes` / `### Major Changes` subheadings, PR links, and commit-hash links; keep only each entry's body text. The `Thanks [@user](...)!` credit (including the multi-author form) must be removed every time. Within each entry, drop SDK-only and provider-internal sentences (SDK capability mapping / API exposure, provider wire-format mechanics, internal XML markers) and keep only the user-facing effect and required constraints. +- **Merge and deduplicate** (`sync-changelog` step 4): merge micro-tweaks to the same surface into one higher-level entry; when three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry (do not merge broad or genuinely distinct fixes); and drop a server/API entry that only backs a web feature already listed. +- **Classify** (`sync-changelog` step 4): bucket into Features / Bug Fixes / Polish / Refactors / Other; order within each section by reader value (in Polish, user-visible improvements before protocol/internal adjustments). +- **Translate** (`sync-changelog` step 6): translate entry bodies to Chinese; keep one sentence per entry with a parallel rhythm within a section; section headings become 新功能 / 修复 / 优化 / 重构 / 其他. + +If an upstream entry is not in English, flag it and stop (changeset entries must be English). + +### 4. Output + +Print the preview directly. Use `(预览)` as the heading because the version is not released yet. Write `无` for empty sections. Do not write any file. + +``` +发版 PR: + +## (预览) + +### 新功能 +- ... + +### 修复 +- ... +``` + +## Rules + +- Read-only. Never write `CHANGELOG.md`, docs files, or commit anything. +- Classification, ordering, and translation follow `sync-changelog` exactly — do not reword or reclassify beyond what it specifies. +- If the release PR has no CLI changelog diff, report it and stop. diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md index 20430bb4b..25e542d4b 100644 --- a/.agents/skills/sync-changelog/SKILL.md +++ b/.agents/skills/sync-changelog/SKILL.md @@ -1,6 +1,6 @@ --- name: sync-changelog -description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md. +description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md, then open a PR on a dedicated branch. --- # Sync Changelog @@ -15,7 +15,7 @@ apps/kimi-code/CHANGELOG.md This file is the **only upstream source** for the documentation-site changelog. Internal package changelogs such as `packages/*/CHANGELOG.md` do not go into the documentation site. -After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site and translate the English increment into Chinese. +After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site, translate the English increment into Chinese, wait for an optional human review, then commit on a dedicated branch and open a PR. ## When To Use @@ -41,39 +41,65 @@ Before editing, confirm: - The released version exists on npm (`npm view @moonshot-ai/kimi-code versions --json`) or has a matching GitHub Release tag. - The top of `apps/kimi-code/CHANGELOG.md` is that new version. -- The current branch is clean, or you are on a dedicated docs-sync branch. If any condition is not true, stop and confirm with the user. +Do **not** edit or commit directly on `main`. All sync work happens on a dedicated branch created in step 1. + ## Workflow -### 1. Find The Version Range +### 1. Prepare Branch + +Start from an up-to-date default branch: ```bash -# Upstream versions -rg '^## ' apps/kimi-code/CHANGELOG.md | head -20 +git fetch origin +git checkout main +git pull --ff-only origin main +``` -# Latest version already synced into the English docs page +Before creating the branch, peek at the version range so the branch name matches the newest version being synced: + +```bash +rg '^## ' apps/kimi-code/CHANGELOG.md | head -5 rg '^## ' docs/en/release-notes/changelog.md | head -5 ``` +Name the branch after the newest upstream version that is not yet in the English docs page: + +```text +docs/changelog-sync- +``` + +Example: syncing `0.2.1` only → `docs/changelog-sync-0.2.1`. + +```bash +git checkout -b docs/changelog-sync- +``` + +If the branch already exists locally or on the remote, stop and confirm with the user instead of reusing it. + +### 2. Find The Version Range + +Use the same version lists from step 1. Confirm: + - First sync: copy all upstream version blocks into the English page. - Incremental sync: copy every upstream version block above the latest version already present in the English page. Use upstream order: newest version first. -### 2. Strip Decorations And Extract Entry Text +### 3. Strip Decorations And Extract Entry Text Upstream entries look like this: ```markdown -- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) - Clean up lint warnings ... +- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) Thanks [@user](https://github.com/...)! - Clean up lint warnings ... ``` -Keep: +Changesets may add a `Thanks ...!` credit, but it must be removed every time. Keep: - Version headings such as `## 0.2.0`. -- Only the body text of each entry, after the PR/hash decoration. +- Only the body text of each entry, after the PR/hash decoration and any `Thanks ...!` credit have been removed. Remove: @@ -81,24 +107,49 @@ Remove: - Changesets subheadings such as `### Patch Changes`, `### Minor Changes`, and `### Major Changes`. - PR links such as `[#317](...)`. - Commit hash links such as ``[`2f51db4`](...)``. +- The `Thanks [@user](...)!` credit, including the multi-author form `Thanks [@a](...), [@b](...)!`. Drop the whole `Thanks ...!` segment every time, regardless of whether the feature is enabled. -After stripping, each entry should be only: +After stripping, each entry is `- `. + +Drop SDK-only and provider-internal detail. This changelog serves `@moonshot-ai/kimi-code` CLI and web users. Within an entry, keep only what CLI/web users can perceive, and remove sentences that document internals instead of user-visible behavior. Apply this on both the English and Chinese pages: + +- Drop sentences about how the SDK maps a capability, builds model aliases, or exposes a flag through an API such as `getExperimentalFeatures()` — that belongs in the SDK changelog, not here. +- Drop provider / wire-format implementation mechanics (XML markers like ``, protocol field explanations, "the wire protocol is unchanged", cache-hit mechanics) unless they are the behavior a user perceives. +- Keep the user-facing effect and any constraints users must follow (for example "question texts must be unique"). + +Do not change facts or drop a real user-facing behavior — only trim the internal-only scaffolding. For over-long, internal-heavy entries, this trim applies on the English page too, not only in translation. + +Web UI prefix: if the entry is a web UI change, prefix the body text with `web: ` so readers can tell it affects the web UI: ```markdown -- +- web: ``` +An entry counts as a web UI change when its upstream commit touches `apps/kimi-web/`. Check with `git show --name-only ` (the commit hash is the one stripped above). `gen-changesets` writes this prefix for web changes, so it is usually already present in upstream — preserve it when it is there, and add it when a web entry lacks it. When a commit touches both web and non-web code, use `web:` only if the user-facing change described by the entry is in the web UI. Keep the `web:` prefix on the Chinese page too — it is a scope marker, not translated text. + Upstream language rule: `gen-changesets` requires changelog entries to be English. If the upstream CLI changelog contains a non-English entry, stop and report it to the user. Do not silently rewrite it while syncing docs. -### 3. Classify Entries +Public-text rule: do not copy real internal endpoints, key names, account names, or service names into docs changelogs. Replace examples with neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY` while preserving the user-visible meaning. + +### 4. Merge, Deduplicate, And Classify Entries + +Before classifying, merge related entries and drop redundant ones from the user-facing changelog: + +- **Merge micro-tweaks to the same surface.** Collapse several small tweaks to the same UI area or feature into one concise entry at the higher level. For example, "change the composer's default height" and "change the composer's default font" merge into "Polish the composer's default styling." Use the most specific common ancestor (composer, settings page, tool card, and so on). Classify the merged entry by its combined effect, and keep the `web:` prefix if the combined change is still web-facing. +- **Merge same-surface or same-kind fixes when you have three or more.** The `Bug Fixes` section tends to accumulate many narrow UI/polish fixes that read as noise when listed one by one. When three or more fixes target the same area (for example several tool cards in the TUI, or the web session/conversation surface) or the same class of problem (for example several "jumping/flickering/collapsing during streaming" fixes), merge them into one higher-level entry. Examples: + - "Fix the Bash tool card collapsing...", "Fix the Edit tool card jumping in height...", "Fix the Edit tool card flickering while its result streams in" → "Fix several TUI tool cards jumping, flickering, or collapsing in height when results stream in or end with short output." + - "Fix the collapsed sidebar not hiding...", "Stop the chat history from replaying its entrance animation...", "Fix tool components jumping the conversation when expanded/collapsed" → "web: Fix several layout and display glitches when switching sessions, including the collapsed sidebar not hiding, the chat history replaying its entrance animation, and tool components jumping the conversation." + - Keep `web:` if the merged fixes are all web-facing. Classify as `Bug Fixes`. + - **Do not over-merge.** Leave a fix standalone when it is broad, high-value, or genuinely distinct (for example model/provider tool-calling bugs, session-list corruption, file-completion gaps). Merging is for low-reader-value, similar-shape fixes that read as a wall of similar bullets. +- **Drop server/API plumbing covered by a web entry.** If one entry adds a web UI feature (for example, an Archived sessions page) and another entry only adds the server or REST/WebSocket endpoints that exist solely to power that web feature, keep the `web:` entry and drop the API entry. CLI and web users perceive the web page; the backing API is implementation detail with no independent user value on this changelog. Keep the API entry only when it has independent user value — a new public endpoint that SDK or server consumers call directly, or a capability usable outside the web feature. When unsure, keep both and let the reviewer decide. The docs changelog uses five section types: | English section | Chinese section | Meaning | |---|---|---| | `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before | -| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | | `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities | +| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | | `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames | | `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts | @@ -112,6 +163,8 @@ Classification process: Features vs. Polish: ask whether the entry introduces something the user could not do before. If yes (new command, flag, mode, viewer, or capability), use `Features`. If it only improves an existing surface (a UI panel that already existed, an existing prompt, an existing tool card, an existing payload pipeline), use `Polish`. Verbs like `Add` do not automatically mean `Features` — a small visual addition to an existing UI is still polish. +Default-behavior changes: changing the default value of an existing capability (for example flipping a feature on by default) is usually `Polish`, because the capability already existed. Use `Features` only when the new default materially changes the out-of-box experience for most users in a way they could not get before. When genuinely ambiguous, flag it and confirm with the reviewer rather than guessing. + Keyword hints: - **Features**: `Add ... command/flag/option/mode/viewer`, `Introduce`, `Support`, `Allow`, `Enable`, `Implement`, `New ... command/flag/option` @@ -123,18 +176,19 @@ Keyword hints: Within each version, section order is: ```text -Features → Bug Fixes → Polish → Refactors → Other +Features → Polish → Bug Fixes → Refactors → Other ``` Omit empty sections. Within each section, order entries by reader value, not upstream order: 1. Put the most valuable, obvious, and larger changes first. 2. Prefer broad user-visible features, workflow-changing fixes, high-frequency bugs, and large cross-cutting improvements over small polish, narrow edge cases, and internal cleanup. -3. If entries have similar value, preserve upstream order. +3. Within `Polish`, put directly user-visible UX or performance improvements (something users can see or feel) before protocol or internal-behavior adjustments (something that makes the model or pipeline behave more reliably but is invisible to users). +4. If entries have similar value, preserve upstream order. Do not reword or exaggerate entries just to make them look more important; only reorder existing entries. -### 4. Write The English Page +### 5. Write The English Page Never change the English page header: @@ -146,10 +200,24 @@ This page documents the changes in each Kimi Code CLI release. Insert new version blocks immediately after the header paragraph and before the previous latest version. +Every version heading must carry its release date in parentheses: + +```text +## (YYYY-MM-DD) +``` + +Take the date from the version's published GitHub Release tag, not from when you run the sync: + +```bash +git log -1 --format=%cs "@moonshot-ai/kimi-code@" +``` + +Use the half-width parenthesis form ` (YYYY-MM-DD)` on the English page. Never invent or guess a date; if the tag is missing, stop and confirm with the user. + Example: ```markdown -## 0.2.0 +## 0.2.0 (2026-05-26) ### Bug Fixes @@ -161,7 +229,7 @@ Example: - Update the native release workflow to use current GitHub artifact actions. ``` -### 5. Translate The Increment Into Chinese +### 6. Translate The Increment Into Chinese After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`. @@ -177,7 +245,7 @@ Chinese page requirements: 本页记录 Kimi Code CLI 每个版本的变更内容。 ``` -- Preserve version headings exactly, such as `## 0.2.0`. +- Preserve version headings including the release date, but use full-width parentheses on the Chinese page, such as `## 0.2.0(2026-05-26)`. The date must match the English page; only the parenthesis style differs (half-width `()` in English, full-width `()` in Chinese). - Translate section headings exactly: - `### Features` → `### 新功能` - `### Bug Fixes` → `### 修复` @@ -189,7 +257,54 @@ Chinese page requirements: - Translate only entry body text. Do not add entries that are not present in English. - Follow `docs/AGENTS.md` for Chinese typography: full-width punctuation, spaces between Chinese and English, and the glossary. -### 6. Verify +#### Chinese wording style + +Structural fidelity does not mean literal translation. The Chinese entries should read like a concise, idiomatic Chinese changelog. Keep the same facts as the English entry, but rephrase for natural Chinese prose. + +Guidelines: + +- **One entry, one sentence.** Avoid chaining multiple effects with commas or semicolons. If the English entry is long, split it into shorter sentences or keep only the most important effect. +- **Drop SDK-only and provider-internal detail.** Apply the trim from step 3 while translating: keep the user-facing effect and required constraints, drop SDK-mapping sentences, provider / wire-format mechanics, and internal XML markers. A long internal entry should collapse to one short Chinese sentence about what the user gets. +- **Prefer common changelog verbs**: 新增、支持、修复、优化、改进、调整. +- **Avoid indirect "through... make..." structures**. Do not write "通过 X,使 Y"; prefer direct cause-effect or just state the result. + - Bad: `通过缓存已渲染消息行,使终端在长篇对话中保持响应。` + - Better: `缓存已渲染消息行,提升长对话下终端的响应速度。` +- **Be specific, not vague**. Prefer concrete actions over abstract quality words. + - Bad: `加固默认系统提示词和内置工具描述。` + - Better: `优化默认系统提示词与内置工具描述,避免 Agent 阻塞后台任务。` +- **Name concrete files or config keys when it helps clarity**. + - Bad: `插件现在可以在其清单中声明 hooks。` + - Better: `插件现支持在 kimi.plugin.json 中声明生命周期 hooks。` +- **Include required argument placeholders in CLI options**. + - Bad: `--allowed-host` + - Better: `--allowed-host ` +- **Keep usage hints to one short clause**. + - Bad: `传入 --allowed-host 以允许额外的 host。例如 ... (多句展开)` + - Better: `例如 kimi web --allowed-host example.com。` +- **Do not translate technical identifiers**: keep command names, flag names, file names, env vars, config keys, and the `web:` scope prefix as-is. +- **Keep parallel rhythm within a section.** When several entries fix similar web surfaces (layout, animation, sizing), phrase them with a consistent structure (for example 修复 <问题>,现 <行为>) so the section reads as a tidy list rather than a mix of shapes. + +Example — translating a feature entry: + +English source: + +```markdown +- Add a --allowed-host flag to kimi web that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host to allow an extra host. +``` + +Before (literal, wordy): + +```markdown +- 为 `kimi web` 新增 `--allowed-host` 标志,允许额外的 Host 请求头值通过 DNS 重绑定检查,并在 403 错误消息中包含允许指引。传入 `--allowed-host ` 以允许额外的 host。例如 `kimi web --allowed-host example.com`。 +``` + +After (concise, idiomatic): + +```markdown +- `kimi web` 新增 `--allowed-host ` 选项,可将指定 Host 加入 DNS 重绑定白名单;403 错误会提示如何通过 `--allowed-host` 或 `KIMI_CODE_ALLOWED_HOSTS` 放行,例如 `kimi web --allowed-host example.com`。 +``` + +### 7. Verify Review: @@ -200,10 +315,13 @@ git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md Check: - Versions and version counts match between English and Chinese. +- Every version heading carries its release date from the published tag, with half-width parentheses in English and full-width in Chinese. - Each version has the same section set and order on both pages. - Each section has the same number of entries on both pages. - Within each section, the most valuable, obvious, and larger entries appear before smaller or narrower entries. - PR links and commit hashes were stripped. +- No `Thanks ...!` credit remains (remove it every time). +- Real internal identifiers were replaced with neutral placeholders. - There are no empty sections. - Markdown indentation and blank lines are intact. @@ -213,7 +331,36 @@ Then run the docs build: pnpm --filter docs run build ``` -### 7. Commit +### 8. Human Review Checkpoint + +After verification passes, **before committing**, ask the user whether they want to review the sync result. Use `AskQuestion` with options such as: + +- **Review first** — show the diff and wait for the user to finish checking. +- **Skip review, commit and open PR** — proceed directly to steps 9 and 10. + +If the user chooses review: + +1. Show the uncommitted diff: + + ```bash + git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md + ``` + +2. Summarize synced versions, section counts, and anything that needed manual classification. +3. Tell the user to reply when they are done reviewing, or to ask for edits. +4. Do **not** commit, push, or open a PR until the user explicitly says review is complete, or asks to proceed. + +If the user requests edits during review, make the changes, re-run verification from step 7, and return to this checkpoint. + +### 9. Commit + +Only run this step when the user skipped review or confirmed review is complete. + +Stage only the changelog docs files: + +```bash +git add docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md +``` Use a neutral docs-sync commit message: @@ -223,12 +370,66 @@ docs(changelog): sync from apps/kimi-code/CHANGELOG.md Do **not** create a changeset for changelog docs sync. Docs sync does not enter the bundle. +### 10. Push And Open PR + +Run immediately after step 9. + +Push the branch: + +```bash +git push -u origin HEAD +``` + +Create the PR with `gh pr create`. Title follows Conventional Commits: + +```text +docs(changelog): sync from apps/kimi-code/CHANGELOG.md +``` + +Fill in `.github/pull_request_template.md`. For changelog sync PRs: + +- **Related Issue**: write `N/A — post-release docs maintenance` (no issue required). +- **Problem**: the docs-site changelog is behind the published CLI release(s). +- **What changed**: list synced version(s), note English source + Chinese translation, and mention verification (`pnpm --filter docs run build`). +- **Checklist**: check CONTRIBUTING; explain no issue, no tests, no changeset, and that `gen-docs` is not needed because this is the dedicated changelog sync flow. + +Example body: + +```markdown +## Related Issue + +N/A — post-release docs maintenance + +## Problem + +The docs-site changelog has not yet been synced for `` after the npm release. + +## What changed + +- Synced `` from `apps/kimi-code/CHANGELOG.md` into `docs/en/release-notes/changelog.md` +- Translated the new English increment into `docs/zh/release-notes/changelog.md` +- Verified with `pnpm --filter docs run build` + +## Checklist + +- [x] I have read the CONTRIBUTING document. +- [x] I have linked a related issue, or explained the problem above. +- [ ] I have added tests that prove my feature works. (N/A — docs-only sync) +- [x] Ran `gen-changesets` skill, or this PR needs no changeset. (No changeset — docs sync is out of bundle) +- [x] Ran `gen-docs` skill, or this PR needs no doc update. (This PR is the dedicated changelog sync) +``` + +Return the PR URL to the user when done. + ## Rules - The English docs changelog is the source of truth. - Never edit upstream `apps/kimi-code/CHANGELOG.md`. - Do not backfill unreleased `.changeset/*.md` drafts into the docs site. +- Prefix web UI entries with `web: ` (when the upstream commit touches `apps/kimi-web/`), and keep the prefix on both the English and Chinese pages. - If upstream wording is wrong, leave upstream alone and fix it in a future changeset. +- Always sync on a `docs/changelog-sync-*` branch and open a PR; never push changelog docs sync directly to `main`. +- Wait for the human review checkpoint before committing, pushing, or opening a PR. ## Common Mistakes @@ -236,6 +437,10 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter |---|---| | Adding entries directly to the English docs page without reading upstream | Use `apps/kimi-code/CHANGELOG.md` as the source | | Copying PR links or commit hashes into docs | Strip them; keep only body text | +| Leaving the `Thanks ...!` credit in docs | Remove it every time, including the multi-author form | +| Leaving near-duplicate micro-tweaks as separate bullets | Merge small tweaks to the same surface into one higher-level entry (e.g. composer height + font → composer's default styling) | +| Listing many narrow fixes to the same surface as separate bullets | When three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry; keep genuinely distinct or high-value fixes standalone | +| Listing a server/API entry that only backs a web feature already listed | Drop the API entry and keep the `web:` entry, unless the API has independent user value | | Rewording upstream English entries | Upstream is frozen; copy the body text unless the user explicitly asks otherwise | | Leaving English text untranslated in the Chinese page | The Chinese page must be fully Chinese except preserved technical terms | | Editing upstream changelog text | Do not edit upstream | @@ -250,7 +455,11 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter | Putting everything under Other for convenience | Classify what can be classified first | | Translating tool names, command names, or config keys | Keep them as written | | Creating a changeset for docs sync | Do not create one | +| Committing or pushing directly on `main` | Create `docs/changelog-sync-`, commit there, then open a PR | +| Committing or opening a PR before the user skips review or confirms review is done | Wait at the human review checkpoint | | Using curly quotes or half-width Chinese punctuation | Follow `docs/AGENTS.md` | +| Omitting the release date from a version heading, or guessing it | Add ` (YYYY-MM-DD)` (full-width `()` in Chinese) taken from the published tag | +| Forgetting or translating the `web:` prefix on web UI entries | Prefix web UI entries (commit touches `apps/kimi-web/`) with `web: ` on both pages; keep the prefix as-is when translating | ## Stop Signals @@ -260,3 +469,5 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter - English and Chinese versions, entry counts, or section sets do not match. - A section is empty. - A Chinese term is uncertain and `docs/AGENTS.md` does not answer it. +- A `docs/changelog-sync-*` branch already exists for the same version and you cannot confirm whether it is stale. +- The user asked to review but has not yet confirmed review is complete. diff --git a/.agents/skills/translate-docs/SKILL.md b/.agents/skills/translate-docs/SKILL.md index d34370225..e081f1a09 100644 --- a/.agents/skills/translate-docs/SKILL.md +++ b/.agents/skills/translate-docs/SKILL.md @@ -55,6 +55,7 @@ When non-changelog pages change in either locale, sync the mirror before release - **Do not one-sided fixes**: if the changed locale has an unclear or incorrect statement, fix it there first; do not patch only the mirror. - **Match style, not just words**: Chinese docs use a narrative tone (see `docs/AGENTS.md` writing-style examples); preserve that tone in Chinese; preserve sentence-case headings and concise English style in English. - **Code blocks and identifiers stay as-is**: do not translate code, command names, flag names, or file paths. +- **Public examples**: Do not introduce real internal endpoints, key names, account names, or service names while translating. Keep or replace them with neutral placeholders such as `example.com`, `example.test`, and `YOUR_API_KEY` in both locales. ## Common mistakes @@ -63,3 +64,4 @@ When non-changelog pages change in either locale, sync the mirror before release - Forgetting to add spaces between Chinese characters and inline code or English words. - Translating proper nouns listed in the term table (`Wire`, `MCP`, `ACP`, `JSON`, `OAuth`, `macOS`, `uv`, etc.). - Updating only one direction and leaving the other locale stale — always finish all pages flagged by the diff. +- Copying real internal values into the mirror instead of using neutral `example` placeholders. diff --git a/.agents/skills/write-tui/DESIGN.md b/.agents/skills/write-tui/DESIGN.md new file mode 100644 index 000000000..61c16615d --- /dev/null +++ b/.agents/skills/write-tui/DESIGN.md @@ -0,0 +1,178 @@ +# TUI 设计规范(Design Spec) + +> 本目录所有 dialog / selector / 输入框的**单一真值源**。新增或改造交互组件前先读本文件,提交前对照文末「自查清单」。 +> 基准组件:`components/dialogs/model-selector.ts`(`/model`)。所有列表型 dialog 的头部、hint、搜索、选中/当前态都以它为准对齐。 + +--- + +## 1. 视觉状态 + +| 语义 | 规范 | 常量 / token | +|---|---|---| +| 选中项指针 | `❯ `(`primary`) | `constant/symbols.ts` → `SELECT_POINTER` | +| 选中项文字 | `primary` + bold | `chalk.hex(colors.primary).bold` | +| 当前 / 激活项 | 行尾 ` ← current`(`success`) | `constant/symbols.ts` → `CURRENT_MARK` | +| 危险项 / 操作 | `error`(选中再加 bold) | `chalk.hex(colors.error)` | +| 危险确认 `[y/N]` | `warning` + bold | `chalk.hex(colors.warning)` | +| 开关项状态:开 | 名称后 ` enabled`(`success`) | `chalk.hex(colors.success)` | +| 开关项状态:关 | 名称后 ` disabled`(`textDim`) | `chalk.hex(colors.textDim)` | +| 列表 / 选择器边框 | 平直 `─`(`primary`),仅顶/底各一条 | — | +| 输入框边框 | 圆角 `╭ ╮ ╰ ╯`(`primary`) | — | + +- **不要**自造选中指针(`>` / `▶` / `→` 等);统一用 `SELECT_POINTER`。 +- **不要**用 `● ` / `(current)` 表示当前项;统一用 `CURRENT_MARK`(行尾、`success`、前置一个空格)。 +- 当前项与选中项**互相独立**:当前项是「现在生效的值」(行尾 marker),选中项是「光标所在行」(指针 + 高亮);两者可同时落在同一行。 + +## 2. 颜色 + +- 一律使用**语义 token**:`chalk.hex(colors.)`。仓库 `chalk-named-color-guard` 已强制此约定,**禁止** `chalk.red` / `chalk.gray` 等 named color。 +- `ThemeStyles`(`state.theme.styles.*()`)是可选的便捷封装;用与不用都可,但颜色必须来自 `ColorPalette` token。 +- 可用语义 token 见 `theme/colors.ts`:`primary` `accent` `text` `textStrong` `textDim` `textMuted` `border` `borderFocus` `success` `warning` `error` `status` … +- **hint 行不做键位高亮**:整行 `textMuted`,不给 `Enter` / `Esc` / `D` 等键位单独上色。 + +## 3. 列表 dialog 标准布局 + +以 `model-selector` 为准,自上而下逐行固定为: + +``` +───────────────────────────────────────── ① 顶部边框(primary,整宽 ─) + Select a model (type to search) ② 标题(primary+bold)+ 可搜索且无 query 时的后缀(textMuted) + ↑↓ navigate · Enter select · Esc cancel ③ hint(textMuted,紧贴标题,无键位高亮) + ④ 空行 + Search: gpt ⑤ 搜索行:仅在有 query 时出现(` Search: ` primary + query text) + ❯ GPT-5 openai ⑥ 列表项:指针 + 名称(左)+ 次要列(右,textMuted) + Kimi K2 Kimi Code ← current 当前项行尾 ` ← current`(success) + ⑦ 空行 + ▼ 3 more ⑧ 滚动 / 匹配指示:无 query 时 `▼ N more`,有 query 时 `x / y` +───────────────────────────────────────── ⑨ 底部边框(primary,整宽 ─) +``` + +硬性约定: + +- **头部只有顶部一条 `─`**。标题下方紧跟 hint,**不得**再插一条 `─`。整个 dialog 全宽 `─` 仅 2 条(顶 + 底)。 +- **`(type to search)` 只出现在标题后缀**(可搜索且 query 为空时);hint 行**不再**重复出现「type to search」。 +- **`Search:` 行在空行之下、列表之上**,只在有 query 时渲染。 +- hint 紧贴标题(中间无空行);hint 与正文之间有 1 空行。 +- 每行最终经 `truncateToWidth(line, width)`,CJK / 窄终端不超宽。 + +## 4. hint 行与文案词汇(英文 UI) + +每段 hint 形如「**键位 + 描述**」,段间用 ` · `(单空格中点)分隔。 + +| 动作 | 键位 token | 描述词 | 完整片段 | +|---|---|---|---| +| 移动 | `↑↓` | navigate | `↑↓ navigate` | +| 翻页 | `←→` 或 `PgUp/PgDn` | page | `←→ page` | +| 确认 / 选中 | `Enter` | select | `Enter select` | +| 取消 / 关闭 | `Esc` | cancel | `Esc cancel` | +| 删除 | `D` | delete | `D delete` | +| 清空搜索 | `Backspace` | clear | `Backspace clear` | +| 切 provider | `Tab` | toggle provider | `Tab toggle provider` | +| 搜索(标题后缀) | 打字 | — | `(type to search)` | + +- **键位 token 首字母大写**(`Enter` / `Esc` / `Tab` / `Backspace` / `D`),**描述词全小写**(navigate / select / cancel / page / delete / clear);方向符 `↑↓` / `←→` 原样。 +- 方向符统一 `↑↓`(不用 `▲/▼`)。 +- 「离开对话框」统一只说 `cancel`(不混用 close / back / exit / dismiss)。业务语义(如审批的 reject)例外。 +- hint 随状态精简:可搜索列表无 query 时,「type to search」在标题后缀已出现,hint 不重复;有 query 时 hint 追加 `Backspace clear`。 + +## 5. Tab 条(`/model` 的 provider 切换) + +`tabbed-model-selector` 在 flat `model-selector` 外包一层 provider tab,样式对齐 **AskUserQuestion** 的 tab: + +``` + Select a model (type to search) + Tab toggle provider · ↑↓ navigate · Enter select · Esc cancel ← hint 首项即 Tab 切换 + ← 空行 + All Kimi Code openai ← tab 条:激活项填充背景(primary 底 + text 字 + bold),其余 textMuted + ← 空行 + ❯ ... +``` + +- tab 条位置:**在 hint 行下方**,且**上下各一空行**(与 hint、与列表都隔开)。 +- 激活 tab:`chalk.bgHex(colors.primary).hex(colors.text).bold(\` ${label} \`)`;非激活:`chalk.hex(colors.textMuted)`。两者可见宽度一致,切换不抖动。 +- 第一个 tab 恒为 `All`(聚合所有 provider);**默认停在 `All`**。仅当显式传 `initialTabId`(如 `/provider` 新增完跳转)才停在指定 provider tab。 +- `Tab` / `Shift+Tab` 循环切换;hint 行首项即 `Tab toggle provider`。 +- 当前模型在所在 tab 内仍以 `❯` + ` ← current` 标记,切 tab 不丢失定位。 + +## 6. 键位 + +| 动作 | 键 | 判定方式 | +|---|---|---| +| 移动 | `↑` / `↓` | `matchesKey(data, Key.up/down)` | +| 翻页 | `PgUp` / `PgDn` | `matchesKey(data, Key.pageUp/pageDown)` | +| 确认 / 选中 | `Enter` | `matchesKey(data, Key.enter)` | +| 取消 / 关闭 | `Esc` | `matchesKey(data, Key.escape)` | +| 删除 | `D` | `printableChar(data) === 'D'`(也接受 `'d'`) | +| 搜索 | 打字 | `printableChar(data)` | + +- **字符比较必须经 `printableChar()`**(Kitty 协议),由 `printable-key-guard` 强制;功能键用 `matchesKey(data, Key.*)`。 +- **`Esc` 两段式**:有 query 时先清空 query(`list.clearQuery()`),无 query 时才 `onCancel()`。 +- `←` / `→` 不固定语义:无翻页结构的组件里承担「值切换」(如 `/model` 的 thinking on/off);`choice-picker` 这类无横向值的列表里用作翻页。**不要**在有 thinking 切换的组件里又拿 `←→` 翻页。 +- **删除键统一用字母 `D`**(`/provider`、`/plugins` 一致)。字母键要求该列表**不可 type-to-search**(否则会打进搜索框)——当前所有带删除动作的列表都不可搜索;若某列表既要搜索又要删除,删除须改用非打印键。 + +## 7. 开关列表与多选(toggle / multi-select) + +适用于「每行可独立开 / 关」的列表(如 `/plugins` 的已装插件、MCP server 列表)。区别于单选(`Enter` 选中即提交并关闭),开关列表用 `Space` 就地切换每行状态,dialog 不关闭。 + +``` + Plugins + ↑↓ navigate · Space toggle · Enter details · Esc cancel + ← 空行 + Installed plugins (2) ← 分区标题(textStrong / 加粗) + ❯ Kimi Datasource enabled ← 选中行(❯ + primary+bold 名称)+ 状态标签(success) + id kimi-datasource · 1 skill · MCP 1/1 · via code.kimi.com · official ← 次要信息行(textMuted,` · ` 分隔) + Superpowers disabled ← 未选中行(text 名称)+ 关态标签(textDim) + id superpowers · 14 skills · via code.kimi.com · curated +``` + +约定: + +- **`Space` 切换当前行状态**(开 ↔ 关),即时生效、dialog 保持打开;hint 含 `Space toggle`。 +- **状态标签**紧跟名称、空 2 格:开 ` enabled`(`success`)、关 ` disabled`(`textDim`)。其它语义(如 `installed`=success、`install…`=primary)按 `statusStyle` 同源处理。 +- `Enter` 在开关列表里另作他用(如「查看详情」`Enter details`),不承担 toggle。 +- 多套独立动作时(toggle / 详情 / 删除 / 进子菜单),hint 逐项列全,键位首字母大写:`Space toggle · Enter details · D remove`(参照第 4 节大小写规则)。 +- 行下可附 1 行次要信息(id / 数量 / 来源 / 信任级),`textMuted`、` · ` 分隔。 + +## 8. Thinking 控件(`/model` 专属) + +列表下方展示当前选中模型的 thinking 三态,外观固定 `[ On ] Off` 段式: + +- 标题:`Thinking (←→ to switch)`(仅 `toggle` 态显示括号提示);其余态只显示 `Thinking`。 +- `toggle`:`[ On ] Off` / `On [ Off ]`,激活段 `primary+bold`。 +- `always-on`:`[ Always on ]`。 +- `unsupported`:`[ Off ]` + `unsupported`(textMuted)。 +- `←` / `→` 翻转草稿;提交时经 `effectiveThinking()` 归一(always-on→true、unsupported→false)。 + +## 9. 输入框(多字段) + +- 圆角盒 `╭ ╮ ╰ ╯`(`primary`)。 +- 字段切换:`Tab` / `Shift+Tab` / `↑` / `↓`。 +- `Enter`:非末段→推进到下一字段;末段→提交。 +- 取消:`Esc` / `Ctrl+C` / `Ctrl+D`。 +- footer 随焦点动态:非末段显示 `Enter next`,末段显示 `Enter submit`。 +- 必填校验按字段顺序定位(如 custom-registry:URL 空→定位 URL,token 空→定位 token),错误用对应的子提示态。 + +## 10. 共享组件(优先复用,不另起炉灶) + +| 形态 | 组件 | +|---|---| +| 列表光标 / 搜索 / 翻页状态机 | `utils/searchable-list.ts` → `SearchableList` | +| 分页视图 | `utils/paging.ts` → `pageView` | +| Kitty 可打印字符 | `utils/printable-key.ts` → `printableChar` / `isPrintableChar`(含 guard) | +| 选中指针 / 当前项标记 | `constant/symbols.ts` → `SELECT_POINTER` / `CURRENT_MARK` | + +新列表组件**必须复用 `SearchableList`**(光标 / 搜索 / 翻页),并手工对齐本文件第 3–8 节的布局、键位、文案。 + +## 11. 新增 / 改造 dialog 自查清单 + +- [ ] 头部按第 3 节:顶部一条 `─`、标题(+`(type to search)` 后缀)、hint、空行、`Search:` 行、列表、底部一条 `─`;标题下**无**内层 `─`。 +- [ ] hint 整行 `textMuted`,**不**做键位高亮;键位首字母大写、描述词小写、` · ` 分隔。 +- [ ] 选中指针用 `SELECT_POINTER`,当前项用 `CURRENT_MARK`,未自造 `>` / `▶` / `→` / `● ` / `(current)`。 +- [ ] 颜色全部来自 `colors.`,无 named color。 +- [ ] 键位:`↑↓` 移动、`PgUp/PgDn` 翻页、`Enter` 确认、`Esc` 取消(可搜索列表 `Esc` 两段式:先清 query 再关闭)、`D` 删除;字符比较经 `printableChar()`。 +- [ ] 「离开对话框」只说 `cancel`,不混用 close / back / exit / dismiss。 +- [ ] 开关列表用 `Space toggle` 就地切换、不关闭;状态标签 ` enabled`(`success`) / ` disabled`(`textDim`) 紧跟名称空 2 格(见第 7 节)。 +- [ ] 长列表有滚动 / 翻页指示(`▼ N more` 或 `x / y`),空态文案明确(`No matches` 等)。 +- [ ] 每行经 `truncateToWidth(line, width)`,CJK / 窄终端下不超宽。 +- [ ] 复用 `SearchableList`;输入框圆角盒,多字段支持 `Tab/↑↓` 切换、Enter 推进 / 末段提交。 +- [ ] 有对应的组件测试(render 快照 + handleInput 键行为)。 diff --git a/.agents/skills/write-tui/SKILL.md b/.agents/skills/write-tui/SKILL.md new file mode 100644 index 000000000..3952b84b5 --- /dev/null +++ b/.agents/skills/write-tui/SKILL.md @@ -0,0 +1,85 @@ +--- +name: write-tui +description: Use when writing or modifying the kimi-code terminal UI in apps/kimi-code/src/tui — components, dialogs/selectors, slash commands, themes, streaming render, or the KimiTUI controllers. Covers the architecture, where new features go, test placement, the theme system mechanics, and the dialog interaction/visual spec (DESIGN.md). +--- + +# Write TUI (apps/kimi-code) + +The terminal UI lives in `apps/kimi-code/src/tui`. Before writing TUI code, read `apps/kimi-code/AGENTS.md` for the always-on **map, module boundaries, and hard constraints** (printable-key decoding, no chalk named colors, etc.). This skill is the **how-to**: architecture orientation, feature routing, test placement, theme mechanics, and the dialog spec. + +For any list dialog, selector, input box, or status/toggle list, the interaction and visual rules are normative — see **[DESIGN.md](./DESIGN.md)** in this folder and follow its self-check list before submitting. + +## Architecture + +`KimiTUI` is a **coordinator** that wires state, layout, session, and dialogs together and delegates heavy logic to controllers. + +- `src/tui/kimi-tui.ts` — the `KimiTUI` coordinator. Holds `state`, owns startup/shutdown order, layout/editor wiring, user-input entry, sending/queueing, session lifecycle, and the slash-command handler dispatch. It should **not** accumulate event-routing or rendering logic — those live in controllers. +- `src/tui/tui-state.ts` — `TUIState`, `createTUIState`, `createInitialAppState`. The single global UI state shape. Before adding a new global field, decide whether it truly belongs here vs. local component state. +- `src/tui/controllers/` — the independently-testable responsibilities. Each controller owns one slice: + - `session-event-handler.ts` — routes SDK session events (`handleEvent` dispatch + the per-event `handleXxx`). Concrete event handling goes here, not in `KimiTUI`. + - `streaming-ui.ts` — streaming render: assistant delta, thinking, tool call / result, compaction, subagent, background agent, transcript aggregation. + - `session-replay.ts` — resume/replay orchestration; drives replay records through the same live render hooks. Stateless replay parsing/limiting/projection helpers belong in `src/tui/utils/message-replay.ts`. + - `tasks-browser.ts` — the tasks browser controller. + - `editor-keyboard.ts` — editor keyboard handling, exit shortcuts, external editor, clipboard image. + - `auth-flow.ts` — login/auth orchestration (`refreshConfigAfterLogin`, etc.). +- `src/tui/commands/` — slash-command declaration, parsing, ordering, and dynamic skill-command generation. Parsing and types only; execution is dispatched from `KimiTUI`'s slash-command handler section, and complex execution sinks into `utils` or focused components. +- `src/tui/components/` — pi-tui components by UI type: `chrome/` (footer, todo, welcome, loader, device code), `dialogs/` (selectors, approval/question panels, settings popups that replace the editor), `editor/` (input box + mention provider), `media/` (image, diff, code highlight), `messages/` (transcript blocks + tool-renderers), `panes/` (activity, queue). +- `src/tui/reverse-rpc/` — adapts SDK approval/question callbacks into UI panel data and the user's choice back into an SDK response. +- `src/tui/theme/` — themes, color tokens, style helpers, pi-tui markdown theme, terminal-background detection. The single source of truth for color. +- `src/tui/utils/` — TUI-only utilities (need `TUIState` or a component). App-wide, UI-independent helpers go in `src/utils/`. + +When a controller or `KimiTUI` section keeps growing, split pure functions, state projections, and presentation components into the matching directory rather than expanding the file. + +## Where new features go + +The feature type decides the landing spot: + +- **CLI arguments** → `src/cli/commands.ts` / `src/cli/options.ts`, passed into the TUI via `src/cli/run-shell.ts`. The CLI never operates on the session directly. +- **CLI subcommands** → `src/cli/sub/`, non-interactive only; reach core via `@moonshot-ai/kimi-code-sdk`. +- **Slash commands** → declare/parse/type under `src/tui/commands/`; add the execution entry in `KimiTUI`'s slash-command handler section; sink complex logic into `utils` or a focused component. +- **Skill-derived commands** → hook into `buildSkillSlashCommands` / the skill command map; do not hard-code a single skill. +- **Transcript message types** → define the shape in `src/tui/types.ts`, add/extend a `components/messages/` component, register the renderer in the transcript builder. +- **Tool-result display** → extend `components/messages/tool-renderers/registry.ts` and the renderer; do not stack branches inside `ToolCallComponent`. +- **Popup / selector** → `components/dialogs/`, mounted via `mountEditorReplacement`; follow [DESIGN.md](./DESIGN.md). If triggered by an SDK callback, check whether `reverse-rpc/` needs an adapter/controller/handler. +- **SDK event handling** → add the dispatch in `session-event-handler.ts`'s `handleEvent`, then the matching `handleXxx`. +- **Streaming render** → `controllers/streaming-ui.ts`. +- **Session start / resume behavior** → the session-management section of `KimiTUI`; replay behavior → `controllers/session-replay.ts`, reusing live render paths. +- **Status bar / activity / queue** → `chrome/footer`, `panes/activity`, `panes/queue`, and the matching `updateXxx`. +- **Configuration option** → read/write + schema in `src/tui/config.ts`, then the settings UI; persist through `saveTuiConfig` (a component never writes the config file itself). +- **Constants** → shared CLI/TUI non-copy constants in `src/constant/`; TUI-only non-copy constants in `src/tui/constant/`. Component-local copy, option labels, help text, dialog titles/footers stay next to their component — do not centralize copy into a global module. +- **General capability** → no TUI-state dependency → `src/utils/`; depends on TUI state or a component → `src/tui/utils/`. + +## Test placement + +- Component behavior tests sit next to the component's existing tests (`test/tui/components/...`). +- Command parsing tests → `test/tui/commands/`. +- reverse-rpc tests → `test/tui/reverse-rpc/`. +- Pure utility tests → next to the corresponding utils tests. +- Do not create a generic `some-feature.test.ts` just to land a small feature; extend the nearest existing test file. + +## Theme system mechanics + +Themes are managed centrally under `src/tui/theme/`: + +- `colors.ts` — semantic tokens: `ColorPalette`, `darkColors`, `lightColors`. +- `styles.ts` — common chalk helpers built on top of `ColorPalette`. +- `pi-tui-theme.ts` — the markdown/pi-tui theme config. +- `terminal-background.ts` — terminal background detection used by auto resolution. +- `bundle.ts` — packs `colors`, `styles`, `markdownTheme` into a `KimiTUIThemeBundle`. +- `index.ts` / `detect.ts` — theme type and auto/dark/light resolution. + +> **Keep the color-token set in sync.** `ColorPalette` in `colors.ts` is the source of truth for color tokens. When you add, rename, or remove one, update its mirrors in the same change: the custom-theme JSON schema (`apps/kimi-code/src/tui/theme/theme-schema.json`), the token tables in the custom-theme docs (`docs/en/customization/themes.md` and `docs/zh/customization/themes.md`), and the token table in the `custom-theme` built-in skill (`packages/agent-core/src/skill/builtin/custom-theme.md`). + +Apply / switch flow: + +- UI entry: `ThemeSelectorComponent` → `handleThemeCommand` → `applyThemeChoice`. +- The real apply step is `KimiTUI.applyTheme`: it updates `state.theme`, `state.appState.theme`, and notifies components to refresh their palette. +- Persist the choice through `saveTuiConfig` — a component must not write the config file itself. + +> The **hard color rules** (no chalk named colors, contrast ratios, no module-top-level cached styled functions, add a `ColorPalette` token before inventing a color) are normative and guard-enforced — they live in `apps/kimi-code/AGENTS.md`. This skill only covers the mechanics. + +## Before you submit + +- Run lint / format / test on the files you changed. +- For any dialog/selector/input/toggle list, walk the self-check list at the end of [DESIGN.md](./DESIGN.md). +- Keep `printableChar()` for printable-key comparisons (CI guard) and `chalk.hex(colors.)` for color (CI guard). diff --git a/.changeset/README.md b/.changeset/README.md index aed0865b2..d20552ded 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -15,11 +15,16 @@ Current publishable packages: All other workspace packages are private internal packages, are not published to npm, and are excluded via `ignore` in `.changeset/config.json`: +- `@moonshot-ai/acp-adapter` - `@moonshot-ai/agent-core` +- `@moonshot-ai/kaos` - `@moonshot-ai/kimi-code-oauth` - `@moonshot-ai/kimi-telemetry` -- `@moonshot-ai/kaos` +- `@moonshot-ai/kimi-web` - `@moonshot-ai/kosong` +- `@moonshot-ai/migration-legacy` +- `@moonshot-ai/protocol` +- `@moonshot-ai/server-e2e` - `@moonshot-ai/vis` - `@moonshot-ai/vis-server` - `@moonshot-ai/vis-web` @@ -39,6 +44,7 @@ Example scenarios: | SDK behavior change affects CLI user experience | Add changesets to both `@moonshot-ai/kimi-code-sdk` and `@moonshot-ai/kimi-code` | | Provider abstraction change affects SDK / CLI | Add changesets to the affected `@moonshot-ai/kimi-code-sdk` and/or `@moonshot-ai/kimi-code` | | Test-only, internal refactor, docs, or private debug tooling changes | Usually no changeset needed | +| Bundled official plugin change under `plugins/` (e.g. `kimi-datasource`) | No changeset — the plugin is versioned via its own `kimi.plugin.json` / `plugins/marketplace.json` and shipped through the marketplace CDN, not the npm package | ## Prerequisite: NPM Trusted Publishing (OIDC) @@ -138,6 +144,7 @@ The root-level `pnpm run publish` first runs typecheck, lint, sherif, test, buil ## Notes - Every PR that affects publishable-package behavior or public API should include a corresponding changeset. +- Changes under `plugins/` (the bundled official plugins such as `kimi-datasource`) do **not** need a changeset: each plugin carries its own version in `kimi.plugin.json` and `plugins/marketplace.json` and is distributed via the marketplace CDN, separately from the `@moonshot-ai/kimi-code` npm package. - Changeset files must be committed to the repository — release PRs are only triggered after they're merged. - Release PRs require human review and merge; they will not publish automatically. - Do not add release changesets for private internal packages; only select `@moonshot-ai/kimi-code` and `@moonshot-ai/kimi-code-sdk`. diff --git a/.changeset/align-print-background-policy.md b/.changeset/align-print-background-policy.md new file mode 100644 index 000000000..dad316049 --- /dev/null +++ b/.changeset/align-print-background-policy.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Align the print-mode background-task policy across engines: `print_background_mode` and `print_max_turns` now take effect for `kimi -p` on the experimental engine, with the same exit / drain / steer semantics and defaults as the default engine. diff --git a/.changeset/align-subagent-timeout.md b/.changeset/align-subagent-timeout.md new file mode 100644 index 000000000..41920aa5a --- /dev/null +++ b/.changeset/align-subagent-timeout.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Align the subagent timeout across engines: a fixed 2-hour default, overridable with `[subagent] timeout_ms` in config.toml or the KIMI_SUBAGENT_TIMEOUT_MS environment variable. diff --git a/.changeset/compaction-dropped-count-wire-trace.md b/.changeset/compaction-dropped-count-wire-trace.md new file mode 100644 index 000000000..6215dbd12 --- /dev/null +++ b/.changeset/compaction-dropped-count-wire-trace.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add the number of messages dropped during compaction retries to the session wire log's LLM request traces. diff --git a/.changeset/config.json b/.changeset/config.json index af6143de9..2320f45e3 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,5 +1,5 @@ { - "changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code", "disableThanks": true }], + "changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code" }], "commit": false, "fixed": [], "linked": [], @@ -7,6 +7,7 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ + "@moonshot-ai/server-e2e", "@moonshot-ai/vis", "@moonshot-ai/vis-server", "@moonshot-ai/vis-web" diff --git a/.changeset/dynamically-loaded-tools-capability-rename.md b/.changeset/dynamically-loaded-tools-capability-rename.md new file mode 100644 index 000000000..c2ec3927e --- /dev/null +++ b/.changeset/dynamically-loaded-tools-capability-rename.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`, matching the model catalog vocabulary; the `select_tools` tool and the `tool-select` flag are unchanged. diff --git a/.changeset/fix-session-format-backward-compat.md b/.changeset/fix-session-format-backward-compat.md new file mode 100644 index 000000000..274ebd21f --- /dev/null +++ b/.changeset/fix-session-format-backward-compat.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sessions created by newer builds failing to open in older CLI builds on the same machine; new sessions are written in a compatible layout, and existing sessions are healed on first open. diff --git a/.changeset/fix-subagent-turn-status-notifications.md b/.changeset/fix-subagent-turn-status-notifications.md new file mode 100644 index 000000000..f2818c8dc --- /dev/null +++ b/.changeset/fix-subagent-turn-status-notifications.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sub-agent completions being signaled as session turn completions, which fired premature completion notifications, sounds, and unread markers while the main turn was still running. diff --git a/.changeset/server-error-and-operation-logging.md b/.changeset/server-error-and-operation-logging.md new file mode 100644 index 000000000..892c10282 --- /dev/null +++ b/.changeset/server-error-and-operation-logging.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Log failed requests, WebSocket auth rejections, shutdowns, and key operations (abort, cancel, approvals, config changes) in the web UI server so daemon problems can be diagnosed from its logs. diff --git a/.changeset/server-version-from-cli.md b/.changeset/server-version-from-cli.md new file mode 100644 index 000000000..17e7f6ff5 --- /dev/null +++ b/.changeset/server-version-from-cli.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix `kimi server` reporting the internal server package version instead of the CLI version in its metadata; the web UI settings now show the CLI version. diff --git a/.changeset/web-message-history-real-times.md b/.changeset/web-message-history-real-times.md new file mode 100644 index 000000000..5c7b78cb6 --- /dev/null +++ b/.changeset/web-message-history-real-times.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Show each message's actual send time in chat history after reloading a session, instead of the session creation time. diff --git a/.changeset/web-operation-error-feedback.md b/.changeset/web-operation-error-feedback.md new file mode 100644 index 000000000..9763b29bf --- /dev/null +++ b/.changeset/web-operation-error-feedback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Surface server error details when actions such as stopping a session, archiving, or toggling modes fail, instead of failing silently, and log every operation failure to the console and the exported web log. diff --git a/.changeset/workspace-catalog-sync.md b/.changeset/workspace-catalog-sync.md new file mode 100644 index 000000000..eac3819a8 --- /dev/null +++ b/.changeset/workspace-catalog-sync.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep the workspace catalog complete and durable: creating a session registers its directory as a workspace, the server backfills missing workspaces from session history at startup, and a removed workspace no longer reappears after a restart. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..b3726523e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Enforce LF line endings in the working tree on every platform so that +# raw-imported text (e.g. `*.md?raw` templates) is byte-identical on Windows +# and POSIX. Without this, Git for Windows' default `core.autocrlf=true` +# checks text files out as CRLF, which shifts token-count snapshots. +* text=auto eol=lf + +# Binary assets — never normalize line endings. +*.gif binary +*.ico binary +*.png binary diff --git a/.github/workflows/_native-build.yml b/.github/workflows/_native-build.yml index bc190ed89..7d0983503 100644 --- a/.github/workflows/_native-build.yml +++ b/.github/workflows/_native-build.yml @@ -85,6 +85,13 @@ jobs: node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE" echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV" + - name: Build Kimi web assets + # The SEA blob step embeds apps/kimi-code/dist-web; build the web app + # and stage its assets before producing the native executable. + run: | + pnpm --filter @moonshot-ai/kimi-web run build + node apps/kimi-code/scripts/copy-web-assets.mjs + - name: Build native executable (release profile, macOS signed) if: runner.os == 'macOS' && inputs.sign-macos run: pnpm --filter @moonshot-ai/kimi-code run build:native:release diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7ceac2ad..99d74a539 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,10 @@ jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5] steps: - uses: actions/checkout@v4 @@ -42,7 +46,46 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm run test + - run: pnpm run test --shard=${{ matrix.shard }}/5 + + # pi-tui's suite runs on node:test (not vitest), so the root `pnpm run test` + # does not execute it; it needs its own job. + test-pi-tui: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @moonshot-ai/pi-tui test + + test-windows: + runs-on: windows-latest + # Temporarily disabled while Windows tests are being stabilized. + if: false + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + # Windows runners are slower and run the whole suite (including + # in-process e2e tests) under more contention, so the default 5s test + # timeout causes flaky failures. Give it more headroom. + - run: pnpm run test -- --testTimeout=30000 lint: runs-on: ubuntu-latest @@ -82,3 +125,9 @@ jobs: echo "Typechecking ${config}" pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit done + - name: Typecheck kimi-web (vue-tsc) + run: pnpm --filter @moonshot-ai/kimi-web run typecheck + - name: Typecheck vis-server + run: pnpm --filter @moonshot-ai/vis-server run typecheck + - name: Typecheck vis-web + run: pnpm --filter @moonshot-ai/vis-web run typecheck diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml new file mode 100644 index 000000000..6c993662b --- /dev/null +++ b/.github/workflows/desktop-build.yml @@ -0,0 +1,170 @@ +name: desktop-build + +# Builds the Kimi Desktop (Electron) installers for macOS, Windows and Linux. +# Each runner builds the matching-platform SEA backend first, then packages it +# with electron-builder. +# +# macOS is signed with a Developer ID certificate + notarized (so it opens on +# any Mac without the "app is damaged" Gatekeeper block) when `sign-macos` is +# true and the Apple secrets are configured. Windows/Linux ship unsigned in v1. +# +# Triggered two ways: +# - workflow_dispatch: manual ad-hoc builds from the Actions tab. +# - workflow_call: called by release.yml to attach installers to a release. +on: + workflow_dispatch: + inputs: + sign-macos: + description: 'Sign + notarize macOS (needs Apple secrets)' + required: false + type: boolean + default: true + retention-days: + description: 'Artifact retention in days' + required: false + type: number + default: 5 + upload-artifact-prefix: + description: 'Prefix for uploaded artifact name' + required: false + type: string + default: 'kimi-desktop' + workflow_call: + inputs: + sign-macos: + description: 'Sign + notarize macOS (needs Apple secrets)' + required: false + type: boolean + default: false + retention-days: + description: 'Artifact retention in days' + required: false + type: number + default: 7 + upload-artifact-prefix: + description: 'Prefix for uploaded artifact name' + required: false + type: string + default: 'kimi-desktop' + secrets: + APPLE_CERTIFICATE_P12: + required: false + APPLE_CERTIFICATE_PASSWORD: + required: false + APPLE_NOTARIZATION_KEY_P8: + required: false + APPLE_NOTARIZATION_KEY_ID: + required: false + APPLE_NOTARIZATION_ISSUER_ID: + required: false + +permissions: + contents: read + +jobs: + desktop: + name: Desktop installer (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + target: darwin-arm64 + - os: macos-15-intel + target: darwin-x64 + - os: windows-2025-vs2026 + target: win32-x64 + - os: ubuntu-24.04 + target: linux-x64 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Kimi web assets + # The SEA blob embeds apps/kimi-code/dist-web; build the web app and + # stage its assets before producing the native executable. + # KIMI_WEB_DESKTOP=1 bakes the internal-build banner into the web bundle + # (see apps/kimi-web/src/components/InternalBuildBanner.vue); only the + # desktop sets this flag, so the CLI `kimi web` stays banner-free. + env: + KIMI_WEB_DESKTOP: '1' + run: | + pnpm --filter @moonshot-ai/kimi-web run build + node apps/kimi-code/scripts/copy-web-assets.mjs + + - name: Build native executable (local profile) + # The Electron app signs the SEA itself (electron-builder, inside-out), + # so the native build stays unsigned here. + run: pnpm --filter @moonshot-ai/kimi-code run build:native:sea + + - name: Setup macOS keychain (Developer ID) + if: runner.os == 'macOS' && inputs.sign-macos + uses: ./.github/actions/macos-keychain-setup + with: + certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + - name: Prepare CSC_NAME for electron-builder (macOS) + if: runner.os == 'macOS' && inputs.sign-macos + shell: bash + run: | + # electron-builder rejects the "Developer ID Application: " prefix in + # CSC_NAME; strip it so the certificate matches by team name + ID. + name="${APPLE_SIGNING_IDENTITY}" + name="${name#Developer ID Application: }" + echo "CSC_NAME=$name" >> "$GITHUB_ENV" + + - name: Prepare notarization API key (macOS) + if: runner.os == 'macOS' && inputs.sign-macos + shell: bash + env: + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + run: | + set -euo pipefail + key_path="$RUNNER_TEMP/notary-AuthKey.p8" + printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path" + echo "APPLE_API_KEY=$key_path" >> "$GITHUB_ENV" + + - name: Build & package desktop app + shell: bash + env: + # macOS signing is driven by env: when sign-macos, electron-builder + # signs with the keychain's Developer ID and notarizes via the notary + # API key; otherwise it builds unsigned. + CSC_IDENTITY_AUTO_DISCOVERY: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }} + CSC_KEYCHAIN: ${{ env.APPLE_KEYCHAIN_PATH }} + KIMI_DESKTOP_NOTARIZE: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: pnpm --filter @moonshot-ai/kimi-desktop run dist + + - name: Cleanup macOS keychain + if: always() && runner.os == 'macOS' && inputs.sign-macos + uses: ./.github/actions/macos-keychain-cleanup + + - name: Upload installers + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }} + retention-days: ${{ inputs.retention-days }} + path: | + apps/kimi-desktop/dist-app/*.dmg + apps/kimi-desktop/dist-app/*.zip + apps/kimi-desktop/dist-app/*.exe + apps/kimi-desktop/dist-app/*.AppImage + apps/kimi-desktop/dist-app/*.deb + if-no-files-found: ignore diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 1412d1d0f..21cd768cb 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -1,13 +1,7 @@ name: Deploy Docs to GitHub Pages on: - push: - branches: - - main - paths: - - 'docs/**' - - '.github/workflows/docs-deploy.yml' - + workflow_call: workflow_dispatch: permissions: diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 86de76975..fcce360b5 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -36,6 +36,9 @@ jobs: - name: Build package dependencies run: pnpm run build:packages + - name: Build Kimi web assets + run: pnpm --filter @moonshot-ai/kimi-web run build + - name: Generate Kimi Code built-in catalog shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 140fc761e..e5c4c6d37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ jobs: runs-on: ubuntu-latest if: github.repository_owner == 'MoonshotAI' outputs: + packages_published: ${{ steps.changesets.outputs.published }} kimi_native_release: ${{ steps.kimi-release.outputs.should_publish }} kimi_release_tag: ${{ steps.kimi-release.outputs.tag }} permissions: @@ -36,7 +37,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Upgrade npm for Trusted Publishing - run: npm install -g npm@latest + run: npm install -g npm@11 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -70,6 +71,16 @@ jobs: env: CHANGESETS_PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} + deploy-docs: + name: Deploy docs + needs: release + if: needs.release.outputs.packages_published == 'true' + uses: ./.github/workflows/docs-deploy.yml + permissions: + contents: read + pages: write + id-token: write + native-artifacts: name: Native release artifact needs: release @@ -86,6 +97,22 @@ jobs: APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + desktop-artifacts: + name: Desktop release artifact + needs: release + if: needs.release.outputs.kimi_native_release == 'true' + uses: ./.github/workflows/desktop-build.yml + with: + upload-artifact-prefix: kimi-desktop + retention-days: 7 + sign-macos: true + secrets: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + publish-native-assets: name: Publish native release assets needs: diff --git a/.gitignore b/.gitignore index 3fdbd2d60..4bd389b82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,43 @@ node_modules/ dist/ +dist-web/ +dist-single/ dist-native/ .tmp-api-extractor/ +.contract-types-tmp/ +.local/ coverage/ *.tsbuildinfo .vitest-results/ +.vite/ .DS_Store .playwright-mcp/ .claude .conductor .kimi-stash-dir plugins/cdn/ -superpowers +.worktrees/ +.kimi-code/local.toml +.kimi-sandbox/ +.vscode/ + +Dockerfile +docker-compose.yml +.dockerignore + +docs/superpowers/ +reports/ +.superpowers/ +/plan/ + +# Agent scratch / throwaway files - do not commit +.tmp/ +HANDOVER*.md +HANDOFF*.md +handoff.md +handover.md +*-designs.html +*-design.html +*-mockup.html +*-demo.html +*-demos.html diff --git a/.oxlintrc.json b/.oxlintrc.json index 77a86ac9a..003359f31 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,7 +150,7 @@ "node_modules/", "apps/*/scripts/", "docs/smoke-archive/", - "plugins/curated/superpowers/", + "packages/pi-tui/", "*.generated.ts" ] } diff --git a/AGENTS.md b/AGENTS.md index 484722aca..6891b73fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,14 +14,17 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Project Map -- `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. +- `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. -- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, and other core capabilities. +- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. +- `packages/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` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. +- `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. ## Environment Requirements @@ -32,9 +35,11 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Monorepo Workspace Maintenance - `pnpm-workspace.yaml` is the source of truth for workspace membership, but `flake.nix` also contains **hardcoded** `workspacePaths` and `workspaceNames` lists. -- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix`.** +- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix` — for every package, including leaf / test / e2e packages that nothing depends on.** + - `pnpm-workspace.yaml` uses globs (`packages/*`, `apps/*`), so most packages land there automatically; `flake.nix` is fully manual and is where omissions happen. - Missing a path in `flake.nix`'s `workspacePaths` will silently drop files from the Nix build's `src` fileset. - Missing a name in `flake.nix`'s `workspaceNames` will break `pnpmConfigHook` because dependencies for that workspace will not be fetched. +- The automated "Check flake.nix workspace sync" (`scripts/check-nix-workspace.mjs`) only validates the transitive dependency **closure of `@moonshot-ai/kimi-code`**. A leaf package outside that closure (e.g. an e2e package nobody imports) slips through even when it is missing from `flake.nix`. A green check is therefore NOT proof that `flake.nix` is fully in sync — keep it updated by hand on every add/remove, do not rely on the check to catch omissions. ## General Coding Rules @@ -65,9 +70,14 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - Prefer `rg` / `rg --files` when reading code. - When designing changes, follow existing boundaries and local patterns first. +- In public text and test data, replace real internal identifiers with neutral placeholders such as `example.com`, `example.test`, and `YOUR_API_KEY`. Before opening a PR, ask a read-only agent to audit the diff for context-specific internal identifiers. - When creating a PR, the PR title must follow Conventional Commit style, e.g. `chore: remove legacy format commands`. - When an AI agent opens or updates a PR, fill in `.github/pull_request_template.md` — link the related issue or explain the problem, then describe what changed. Do not leave placeholder text or submit a generic summary of the diff. - Do not submit vague AI-generated PR text. The human author must understand the change well enough to explain the code, edge cases, and why the approach fits this repository. - After finishing a task and before submitting a PR, you must run the `gen-changesets` skill (see `.agents/skills/gen-changesets/SKILL.md`) and generate a changeset under `.changeset/` according to its rules. - When generating a changeset, **never** decide on a `major` bump on your own. When you judge a change to meet the major criteria (breaking changes, incompatible user configuration, renamed or removed commands/arguments, changed behavior semantics, etc.), you must stop and explain it to the user and ask for confirmation. **Only write `major` after the user has explicitly agreed.** Otherwise default to `minor` (and fall back to `patch` if `minor` is unclear). See the "Hard rule: confirm with the user before writing `major`" section in `.agents/skills/gen-changesets/SKILL.md` for details. - Prefer importing via `import ... from '#/...'`, which serves the same purpose as `import ... from '@/...'`. +- Do not commit throwaway scratch or exploratory files. Never stage: + - Agent working notes or handoff/summary documents (e.g. `HANDOVER-*.md`, `HANDOFF-*.md`, `handoff.md`). + - Throwaway UI/UX prototypes or design mockups (e.g. `*-designs.html`, `*-mockup.html`, `*-demo(s).html`) at the repo root or under a `design/` folder. The only tracked `.html` files should be Vite `index.html` entrypoints. + Before committing or opening a PR, run `git status` and `git diff --staged --stat` and remove anything matching these patterns. Put scratch work under `.tmp/` (gitignored) instead of the repo root or the source tree. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 000000000..c0fdc2a36 --- /dev/null +++ b/GOAL.md @@ -0,0 +1,231 @@ +# Goal 功能拆分 + +本文把 agent-core 中 goal mode 的能力拆成三部分: + +1. 核心工作流:没有它就不能运行 goal。 +2. 统计 / token 数限制:让 goal 可度量、可限额、可审计。 +3. 用户交互相关:让用户可以安全启动、理解、控制和恢复 goal。 + +## 1. 核心工作流 + +核心工作流是 goal mode 的运行骨架。它负责创建结构化目标、维护状态机、把普通 turn 串成自治多轮执行,并让模型用机器可读状态结束或停放目标。 + +### 目标状态 + +同一个 main agent 同时最多只有一个当前 goal。goal 不是普通聊天文本,而是 runtime 持有的结构化状态,至少包含目标、可选完成标准、当前状态、停止原因和运行统计。 + +状态分为四类: + +- `active`:正在被 goal driver 推进。只有这个状态会自动运行下一轮。 +- `paused`:暂停但保留目标。通常来自用户暂停、中断、进程恢复后降级、provider 或 runtime 错误。可以恢复。 +- `blocked`:目标遇到真实阻塞但保留目标。通常来自模型判断需要外部输入、目标无法按当前表述完成、预算达到、prompt hook 阻止。可以恢复。 +- `complete`:瞬时完成状态。runtime 发出完成事件后立即清除 goal,不长期持久化。 + +没有 `cancelled` 状态。取消就是清除 goal,并提醒模型忽略之前关于该目标的 active reminder。 + +### 创建和替换 + +创建 goal 时,runtime 需要校验目标不能为空、不能过长。已有 active、paused 或 blocked goal 时,默认拒绝创建新 goal,防止静默覆盖。只有用户或调用方明确要求替换时,才先清除旧 goal,再创建新 goal。 + +新 goal 创建后进入 `active`,写入持久记录,并发出 goal 更新事件。 + +### 多轮驱动 + +goal driver 的职责是把一个 active goal 推进成连续的普通 turn: + +- turn 开始时如果 goal 已经是 `active`,进入 goal driver。 +- 普通 turn 中如果模型创建了 goal,或把 paused/blocked goal 恢复成 active,当前 turn 结束后 goal driver 接管继续执行。 +- driver 每次只运行一个普通 turn。 +- 每个 turn 结束后读取 goal 状态。 +- goal 仍是 `active` 时,runtime 自动追加 continuation prompt 并启动下一轮。 +- goal 变成 `paused`、`blocked` 或被清除时,driver 停止。 + +模型如果不调用状态更新工具,且 goal 仍是 active,runtime 会继续下一轮。模型不能只靠自然语言说“完成了”来结束 goal,必须给出结构化状态信号。 + +### Goal 注入 + +每个 goal turn 的边界,runtime 会把当前 goal 状态注入上下文。注入内容包括: + +- 当前正在 goal mode。 +- 目标和完成标准是什么。 +- 目标文本是用户提供的数据,不能覆盖 system/developer 指令、工具 schema、权限规则或 host 控制。 +- 当前状态和进度。 +- 模型应该做简短自审,然后推进一个连贯工作切片。 +- 简单、已完成、不可能、不安全、矛盾的目标,应在同一轮内直接标记 complete 或 blocked。 +- 只有全部要求完成、验证通过、没有下一步有用动作时,才能标记 complete。 +- 外部条件或用户输入阻塞时,应标记 blocked。 +- 不要只做了计划、总结、第一版或部分结果就标记 complete。 + +goal 注入只在 turn / continuation 边界做,不在每个 model step 都做,避免上下文重复膨胀,也有利于 prompt cache。 + +paused 和 blocked goal 的注入更轻: + +- paused:提醒模型目标存在但当前不应自治推进,除非用户明确要求继续。 +- blocked:提醒模型目标被阻塞且当前不自治推进,除非用户要求处理或恢复。 + +### Continuation prompt + +当 goal 仍是 active,runtime 会追加一个系统触发输入,含义相当于“继续朝当前 active goal 工作”。它不只是简单续跑,还要求模型每轮重新判断: + +- 是否已经完成。 +- 是否遇到真实阻塞。 +- 是否应该只推进一个合理切片后继续下一轮。 +- 是否应该避免发散或启动无关工作。 +- 除非真实阻塞,否则不要向用户要输入。 + +### 完成、阻塞和暂停 + +模型通过结构化状态更新控制 goal 生命周期: + +- `complete`:目标已满足,runtime 发出完成事件并清除 goal。 +- `blocked`:遇到真实阻塞,runtime 保留 goal 并停止自治推进。 +- `paused`:暂时放下 goal,runtime 保留 goal 并停止自治推进。 +- `active`:恢复 paused 或 blocked goal。 + +状态更新工具的输入应保持窄,只表达机器状态。完成总结或阻塞原因由模型随后给用户说明。 + +当模型标记 complete 后,runtime 应再给模型一次收尾机会,生成简短最终回复,说明 goal 已完成、主要做了什么、跑了什么验证。 + +当模型标记 blocked 后,runtime 应再给模型一次收尾机会,说明具体阻塞、需要什么输入或变化才能继续。 + +如果当前 turn 已经没有 step 预算,不应为了收尾总结强行再跑一步,避免把“没法写总结”变成 turn 失败。 + +### 错误停车 + +goal mode 把技术运行失败视为可恢复停车: + +- 用户中断当前 turn:goal 变 paused。 +- provider rate limit:goal 变 paused。 +- provider 连接错误、认证错误、API 错误:goal 变 paused。 +- 模型配置错误:goal 变 paused。 +- runtime 异常:goal 变 paused。 +- provider safety filter:goal 变 paused。 + +业务、规则或外部条件阻塞则变 blocked: + +- prompt hook 阻止目标。 +- 模型判断无法继续。 +- 预算达到。 +- 需要用户或外部系统提供新条件。 + +### 持久化和恢复 + +goal 的创建、更新、完成、阻塞、清除应写入可恢复记录。session 恢复时,runtime 用记录重建 goal。 + +恢复时如果发现 goal 原来是 active,不应自动继续跑,而是降级为 paused。因为旧进程中的 active turn 不可能还活着,自动继续会造成重启后偷偷消耗资源。 + +paused 和 blocked 原样保留。complete 理论上不长期存在,因为完成后会清除。 + +fork session 时不继承源 session 的 goal,并提醒模型不要继续源 session 的旧目标。 + +## 2. 统计 / token 数限制 + +这一部分让 goal 可度量、可限额、可审计。没有它,goal 仍然可以运行,但不可控。 + +### 运行统计 + +goal 统计包括: + +- continuation turn 数。 +- token 数。 +- active wall-clock 时间。 + +统计只在 goal 是 `active` 时增长。paused 和 blocked 期间不继续计数。 + +turn 统计在每个 goal turn 准备运行时增加,因此模型在某一轮里标记 complete 时,这一轮也计入最终统计。 + +token 统计在 model step 结束后累计。没有 active goal 时,不记入 goal。token 统计应以静默更新为主,不应每一步都刷 UI。 + +时间统计只计算 active pursuit 时间。进入 active 时开启计时区间,离开 active 时折算进累计时间;pause/resume 会形成新的 active 区间。 + +### 预算 + +goal 预算包括: + +- turn budget。 +- token budget。 +- wall-clock budget。 + +默认没有预算。只有用户明确给出硬限制时才设置,例如“最多 20 轮”“不超过 500k token”“30 分钟内”。模糊表达如“尽快”“别花太久”不能设置预算,模型也不能自行发明预算。 + +时间预算需要合理范围。过短或过长应拒绝。turn 和 token 预算应规范化为正整数。 + +### 预算硬停 + +预算检查应发生在 goal turn 开始前和结束后。token budget 还应在 model step 后触发停止,避免超额后继续下一步。 + +一旦达到预算,runtime 应直接把 goal 标记为 blocked,原因是配置预算已达到。这个 blocked 仍可恢复,但如果预算不变,恢复后可能立刻再次 blocked。 + +### 预算引导和最终统计 + +当预算未接近时,模型提示应鼓励稳定推进。当任一预算达到 75% 以上时,提示应转为收敛,避免启动新的可选工作。 + +complete 和 blocked 的最终回复提示应包含 worked turns、elapsed time、tokens used 等统计信息。UI 事件也应带当前 snapshot 和变化类型。 + +telemetry 可以记录 goal 创建、预算设置、continuation、状态变化、清除等事件,但不应包含目标文本、停止原因等敏感内容。 + +## 3. 用户交互相关 + +这一部分让用户可以安全启动、理解、控制和恢复 goal。没有它,runtime 仍可能运行,但交互体验和安全边界不足。 + +### 生命周期控制 + +用户可以直接控制 goal: + +- 创建。 +- 查看。 +- 暂停。 +- 恢复。 +- 取消。 + +这些操作可以不经过模型 turn。pause 把 active goal 变 paused;resume 把 paused 或 blocked goal 变 active;cancel 直接清除当前 goal。 + +resume 会清除旧停止原因,表示开始新的尝试。paused/blocked goal 不会因为用户发普通消息就自动继续。 + +### 模型发起 goal 的确认 + +模型可以代表用户创建 goal,但只有在用户明确要求启动 goal、自治工作,或宿主 goal-intake 提示要求时才应该这样做。普通请求不能被模型擅自升级成 goal。 + +模型发起 CreateGoal 时,非 auto 权限模式下应触发用户确认。确认菜单允许用户选择本次 goal 的运行权限模式。用户拒绝则 goal 不创建。 + +`GetGoal`、`SetGoalBudget`、`UpdateGoal` 只改 goal runtime 状态,默认可以更容易批准。真正写文件、跑 shell、访问敏感路径等仍走普通权限系统。 + +### 暂停、阻塞和取消后的提示 + +paused goal 的上下文提示应说明目标存在但当前不应继续做,除非用户明确要求继续。 + +blocked goal 的上下文提示应说明目标被阻塞且当前不自治推进,可以在用户要求时帮助解阻,否则正常处理当前请求。 + +cancel 后应追加提醒,让模型忽略旧 goal 的 active reminder,避免旧上下文诱导模型继续已经取消的目标。 + +### 完成和阻塞的用户回复 + +complete 后,goal 被清除,模型应给用户一条简短完成总结,说明完成了什么、做了什么验证。 + +blocked 后,goal 保留,模型应给用户一条简短阻塞说明,说明具体阻塞和继续所需输入、权限、外部条件或变更。 + +### Tool 暴露和隔离 + +goal 工具只给 main agent。subagent 不应直接创建、恢复、结束主 goal。 + +没有 goal 时,模型不应看到 `UpdateGoal` 和 `SetGoalBudget`。有 goal 时才暴露这些控制工具。 + +goal ID 不应暴露给模型,因为它只是 runtime/UI 内部标识,没有用户语义。 + +### 辅助写 goal + +`write-goal` 类能力用于帮助用户把粗糙意图整理成适合 goal mode 的完成契约。好的 goal 应明确: + +- end state:什么条件必须变成真。 +- proof:用什么可观察证据证明完成。 +- boundaries:工作范围和禁止触碰的内容。 +- loop:如何迭代推进。 +- stop rule:什么情况下停止并报告,而不是强行继续。 + +预算是 opt-in,不应默认加入,也不应把 turn cap 写进目标文本。 + +### UI 和会话语义 + +goal 创建、暂停、恢复、阻塞、完成、清除都应发出 goal updated 事件。lifecycle 变化和 completion 变化应区分。completion 是一次终局事件,然后 snapshot 变 null。blocked/paused 保留 snapshot,UI 可以继续展示可恢复 goal。 + +session 恢复时,active goal 会变 paused,避免重启后自动继续。fork session 时不继承 goal,并提醒模型不要继续源 session 的目标。 diff --git a/README.md b/README.md index 501bf8199..d17d143e3 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,20 @@ Install with the official script. No Node.js required. curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash ``` +- **Homebrew (macOS/Linux)**: + +```sh +brew install kimi-code +``` + - **Windows (PowerShell)**: ```powershell irm https://code.kimi.com/kimi-code/install.ps1 | iex ``` +> On Windows, install [Git for Windows](https://gitforwindows.org/) before first launch because Kimi Code CLI uses the bundled Git Bash as its shell environment. If Git Bash is installed in a custom location, set `KIMI_SHELL_PATH` to the absolute path of `bash.exe`. + Then, run it with a new shell session: ```sh @@ -52,17 +60,41 @@ Take a look at this project and explain its main directories. - **Single-binary distribution.** Install with one command: no Node.js setup, PATH gymnastics, or global module conflicts. - **Blazing-fast startup.** The TUI is ready in milliseconds, so starting a session never feels heavy. -- **Purpose-built TUI.** A carefully tuned interface for long, focused agent sessions. -- **Video input.** Drop a screen recording or demo clip into the chat, and let the agent watch what is hard to describe in words. +- **Purpose-built TUI.** A carefully tuned interface, optimized end to end for long, focused agent sessions. +- **Video input.** Drop a screen recording or demo clip into the chat and let the agent watch what is hard to describe in words — turn a reference clip into a LUT, a long video into a short, a screen recording into working code, and more. - **AI-native MCP configuration.** Add, edit, and authenticate Model Context Protocol servers conversationally with `/mcp-config`, without hand-editing JSON. +- **Rich plugin ecosystem.** Install skills, MCP servers, and data sources from the marketplace or any GitHub repo, with each install's trust level surfaced up front. - **Subagents for focused, parallel work.** Dispatch built-in `coder`, `explore`, and `plan` subagents in isolated contexts while keeping the main conversation clean. - **Lifecycle hooks.** Run local commands at key points to gate risky tool calls, audit decisions, trigger desktop notifications, or connect to your own automation. +- **Editor & IDE integration (ACP).** Drive a Kimi Code CLI session straight from Zed, JetBrains, or any [Agent Client Protocol](https://agentclientprotocol.com/) client with `kimi acp`. + +## Use it in your editor (ACP) + +Kimi Code CLI speaks the [Agent Client Protocol](https://agentclientprotocol.com/), so ACP-compatible editors and IDEs (Zed, JetBrains, …) can drive a session over stdio. Log in once, then point your editor at the `kimi acp` subcommand — no extra login needed. + +For Zed, add this to `~/.config/zed/settings.json`: + +```json +{ + "agent_servers": { + "Kimi Code CLI": { + "type": "custom", + "command": "kimi", + "args": ["acp"], + "env": {} + } + } +} +``` + +Then open a new conversation in Zed's Agent panel. See [Using in IDEs](https://moonshotai.github.io/kimi-code/en/guides/ides) for JetBrains setup and troubleshooting, and the [`kimi acp` reference](https://moonshotai.github.io/kimi-code/en/reference/kimi-acp) for the full capability matrix. ## Docs - [Getting Started](https://moonshotai.github.io/kimi-code/en/guides/getting-started) - [Interaction and approvals](https://moonshotai.github.io/kimi-code/en/guides/interaction) - [Sessions](https://moonshotai.github.io/kimi-code/en/guides/sessions) +- [Using in IDEs (ACP)](https://moonshotai.github.io/kimi-code/en/guides/ides) - [Configuration](https://moonshotai.github.io/kimi-code/en/configuration/config-files) - [Command reference](https://moonshotai.github.io/kimi-code/en/reference/kimi-command) diff --git a/README.zh-CN.md b/README.zh-CN.md index 8e9146cb0..e60fd158e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,12 +22,20 @@ Kimi Code CLI 是一个运行在终端里的 AI 编程 agent,可以帮你读 curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash ``` +- **Homebrew(macOS / Linux)**: + +```sh +brew install kimi-code +``` + - **Windows(PowerShell)**: ```powershell irm https://code.kimi.com/kimi-code/install.ps1 | iex ``` +> Windows 用户首次启动前还需要安装 [Git for Windows](https://gitforwindows.org/),Kimi Code CLI 会使用其中的 Git Bash 作为 Shell 环境。如果 Git Bash 安装在非标准路径,请把 `KIMI_SHELL_PATH` 设为 `bash.exe` 的绝对路径。 + 随后在新的终端会话中运行: ```sh @@ -55,18 +63,42 @@ kimi - **二进制发行,零环境依赖** 一行命令安装,不需要预装 Node.js,不用折腾 PATH,也不会和全局模块冲突。 - **极速启动** TUI 在毫秒级就绪,开一个新会话没有任何心智负担。 -- **精致的 TUI 体验** 为长时间、专注的 Agent 会话精心打磨的交互界面。 -- **视频也能输入** 屏幕录像、演示视频也能拖进对话。 +- **精致的 TUI 体验** 端到端打磨的交互界面,专为长时间、专注的 Agent 会话优化。 +- **视频也能输入** 把屏幕录像、演示视频拖进对话,让 Agent 看那些难以用文字描述的东西——把参考片段做成 LUT、把长视频剪成短视频、把录屏变成代码,等等。 - **AI-native 的 MCP 配置** 通过 `/mcp-config` 对话式添加、编辑、认证 MCP 服务器,无需手写 JSON。 +- **丰富的插件生态** 从插件市场或任意 GitHub 仓库安装 skills、MCP 服务器和数据源,每次安装都会标明来源的信任级别。 - **子 Agent 聚焦并行工作** 内置 `coder`、`explore`、`plan` 子 Agent 在隔离上下文中处理子任务,主对话保持清爽。 - **生命周期 hooks** 在关键节点执行本地命令:拦截高风险工具调用、审计决策、发送桌面通知,或对接你自己的自动化脚本。 +- **编辑器 / IDE 集成(ACP)** 用 `kimi acp` 让 Zed、JetBrains 等任意 [Agent Client Protocol](https://agentclientprotocol.com/) 客户端直接驱动会话。 +## 在编辑器里使用(ACP) + +Kimi Code CLI 支持 [Agent Client Protocol](https://agentclientprotocol.com/),ACP 兼容的编辑器 / IDE(Zed、JetBrains……)可以通过 stdio 直接驱动会话。登录一次后,把编辑器指向 `kimi acp` 子命令即可,无需重复登录。 + +以 Zed 为例,在 `~/.config/zed/settings.json` 中加入: + +```json +{ + "agent_servers": { + "Kimi Code CLI": { + "type": "custom", + "command": "kimi", + "args": ["acp"], + "env": {} + } + } +} +``` + +随后在 Zed 的 Agent 面板新建对话即可。JetBrains 配置与排障见[在 IDE 中使用](https://moonshotai.github.io/kimi-code/zh/guides/ides),完整能力矩阵见 [`kimi acp` 参考](https://moonshotai.github.io/kimi-code/zh/reference/kimi-acp)。 + ## 文档 - [快速上手](https://moonshotai.github.io/kimi-code/zh/guides/getting-started) - [交互与审批](https://moonshotai.github.io/kimi-code/zh/guides/interaction) - [会话](https://moonshotai.github.io/kimi-code/zh/guides/sessions) +- [在 IDE 中使用(ACP)](https://moonshotai.github.io/kimi-code/zh/guides/ides) - [配置](https://moonshotai.github.io/kimi-code/zh/configuration/config-files) - [命令参考](https://moonshotai.github.io/kimi-code/zh/reference/kimi-command) diff --git a/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore index 66d6a21a6..901b7a6d2 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -1,2 +1,11 @@ # Copied from packages/kimi-core at build time agents/ + +# Generated at build time by scripts/build-vis-asset.mjs. +# Only the ~150KB base64 VALUE file is ignored; the committed `.d.ts` stub +# next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh +# clone (before any build has produced the `.ts`). +src/generated/vis-web-asset.ts + +# Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs +native/ diff --git a/apps/kimi-code/AGENTS.md b/apps/kimi-code/AGENTS.md index c9ec362bd..11184d958 100644 --- a/apps/kimi-code/AGENTS.md +++ b/apps/kimi-code/AGENTS.md @@ -2,6 +2,8 @@ This file only contains rules local to `apps/kimi-code`. For cross-repo rules, see the root `AGENTS.md`. +> **Writing or modifying the TUI?** Use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). It covers the architecture orientation, where new features go, test placement, theme mechanics, and the dialog interaction/visual spec (`DESIGN.md`). This file keeps only the map, boundaries, and hard constraints. + ## TUI File Layout `apps/kimi-code` is the terminal UI / CLI app. The entry chain is: @@ -13,7 +15,9 @@ Main directories: - `src/constant/`: non-copy constants shared by CLI/TUI — product, protocol, paths, terminal control, updates, and so on. - `src/cli/`: command-line arguments, subcommands, and CLI startup. - `src/tui/`: the interactive terminal UI. -- `src/tui/kimi-tui.ts`: the TUI master assembler, responsible for wiring state, layout, editor, session, SDK events, and dialogs together. +- `src/tui/kimi-tui.ts`: the `KimiTUI` coordinator — wires state, layout, editor, session, SDK events, and dialogs together, and dispatches slash-command handlers. Heavy logic is delegated to `controllers/`, not accumulated here. +- `src/tui/tui-state.ts`: `TUIState`, `createTUIState`, `createInitialAppState` — the single global UI-state shape. +- `src/tui/controllers/`: independently-testable responsibilities — `session-event-handler` (SDK event routing), `streaming-ui` (streaming render), `session-replay` (resume/replay), `tasks-browser`, `editor-keyboard`, `auth-flow`. - `src/tui/commands/`: slash command definitions, parsing, ordering, and dynamic skill command generation. - `src/tui/components/`: pi-tui components, organized by UI type. - `src/tui/constant/`: non-copy constants reused across TUI modules — symbols, terminal sequences, render sizing, streaming-arg match rules, and so on. @@ -24,69 +28,22 @@ Main directories: - `src/tui/components/messages/`: message blocks in the transcript — assistant, user, tool call, thinking, usage, subagent, and so on. - `src/tui/components/panes/`: right-side / activity-area panes such as the activity pane and queue pane. - `src/tui/reverse-rpc/`: the adapter layer that bridges SDK approval/question callbacks to the UI. -- `src/tui/theme/`: themes, color tokens, style helpers, and the pi-tui markdown theme. +- `src/tui/theme/`: themes, color tokens, style helpers, terminal-background detection, and the pi-tui markdown theme. - `src/tui/utils/`: TUI-only utility functions. - `src/utils/`: app-wide utilities — clipboard, git, history, image, process, usage, and so on. ## Module Responsibilities - `cli` only interprets command-line input, assembles startup arguments, and invokes the TUI. Do not put TUI interaction logic into the CLI. -- `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `commands`, `components`, `reverse-rpc`, or `utils` first. +- `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `controllers`, `commands`, `components`, `reverse-rpc`, or `utils` first. +- `controllers` own the heavy, independently-testable slices (event routing, streaming render, session replay, tasks browser, editor keyboard, auth). Event-routing and rendering logic belong here, not on the `KimiTUI` class. - `commands` only owns slash-command declaration, parsing, and the parsed-result types. The actual execution can be dispatched from `KimiTUI`, but complex logic should continue to sink downward. - `components` only handle presentation and local interaction; they must not call the SDK directly, and must not read or write session state directly. - `reverse-rpc` converts SDK approval/question requests into the data shape a UI panel/dialog needs, and converts the user's choice back into an SDK response. - `theme` is the single source of truth for colors and styles. Components must not bypass the theme system and use chalk named colors directly. - `utils` holds utility functions with no UI-state dependency. Logic that needs `TUIState` or a component instance must not live under app-level `src/utils`. -- Resume replay orchestration lives in the `Session Replay` section of `KimiTUI`, because it intentionally drives the same stateful render hooks as live events. Stateless replay parsing, limiting, and projection helpers belong in `src/tui/utils/message-replay.ts`. - `apps/kimi-code` may only use core capabilities through `@moonshot-ai/kimi-code-sdk`. Do not import `@moonshot-ai/agent-core` directly in app code. -## KimiTUI Internal Sections - -`src/tui/kimi-tui.ts` is large. When you modify it, place code into the existing responsibility section — do not just drop it where it happens to be convenient. - -- Types and state creation: `KimiTUIStartupInput`, `TUIState`, `createInitialAppState`, `createTUIState`. Before adding new global UI state, decide whether it really belongs in `TUIState`. -- Startup helpers: slash commands, autocomplete, skill commands, input history. -- Lifecycle: `start`, `init`, `stop`. They only handle startup/shutdown order — do not stuff feature implementations into them. -- Layout and editor: `buildLayout`, `setupEditorHandlers`, external editor, clipboard image, exit shortcuts. -- User input: `handleUserInput`, `executeSlashCommand`, `handleBuiltInSlashCommand`, `sendNormalUserInput`. -- Sending and queueing: `enqueueMessage`, `sendMessageInternal`, `sendMessage`, `steerMessage`, `finalizeTurn`. -- Session management: create, restore, switch, close, sync runtime state, subscribe to session events. -- Session replay: hydrate resume snapshots, drive replay records through live render hooks, and clean up transient replay state. -- Event routing: `handleEvent` only dispatches; concrete events go into the corresponding `handleXxx`. -- Streaming rendering: assistant delta, thinking, tool call, tool result, compaction, subagent, background agent. -- Transcript: `createTranscriptComponent`, `appendTranscriptEntry`, read/tool/agent group aggregation. -- Activity / queue / footer: `updateActivityPane`, `resolveActivityPaneMode`, `updateQueueDisplay`, terminal progress. -- Dialogs / selectors: help, session picker, editor/model/thinking/theme/permission/settings selectors, approval / question panels. -- Slash command handlers: `handleThemeCommand`, `handleModelCommand`, `handlePlanCommand`, `handleCompactCommand`, `handleLoginCommand`, and so on. - -If a section keeps growing, split pure functions, state projections, presentation components, and handler logic into the corresponding directories rather than continuing to expand `KimiTUI`. - -## Where New Features Go - -The feature type decides where it lands: - -- New CLI arguments: change `src/cli/commands.ts` / `src/cli/options.ts`, then pass them into the TUI via `src/cli/run-shell.ts`. Do not let the CLI operate on the session directly. -- New CLI subcommands: put them under `src/cli/sub/`, with non-interactive command logic only; when SDK access is needed, go through `@moonshot-ai/kimi-code-sdk`. -- New slash commands: first change definition, parsing, and types under `src/tui/commands/`; put the execution entry into the slash-command handler section of `KimiTUI`; split complex execution logic into `utils` or focused components when it has no reason to stay in `KimiTUI`. -- New skill-derived commands: hook into `buildSkillSlashCommands` / the skill command map — do not hard-code a single skill. -- New transcript message types: define the data shape in `src/tui/types.ts`, add or extend a component under `components/messages/`, and register the renderer in `createTranscriptComponent`. -- New tool-result display: prefer extending `components/messages/tool-renderers/registry.ts` and the corresponding renderer; do not stack branches inside `ToolCallComponent`. -- New popup / selector: put it under `components/dialogs/` and mount it via `mountEditorReplacement`; if the trigger comes from an SDK callback, also check whether `reverse-rpc/` needs an adapter/controller/handler. -- New SDK event handling: add the dispatch in `handleEvent`, then add the corresponding `handleXxx`. If the event simply maps to a transcript entry. -- New session start / resume behavior: put it in the session management section, keeping `init` focused only on startup orchestration. New resume replay behavior belongs in the `Session Replay` section and should reuse live rendering paths where possible. -- New status bar, activity area, or queue display: change `chrome/footer`, `panes/activity`, `panes/queue`, and the corresponding `updateXxx` method. -- New configuration option: first change the read/write and schema in `src/tui/config.ts`, then wire the settings UI; when persistence is needed, go through `saveTuiConfig`. -- New constants: constants shared by CLI/TUI and not copy belong in `src/constant/`; non-copy constants reused only within the TUI belong in `src/tui/constant/`. Component-local copy, option labels, help descriptions, dialog title/footer text — keep these next to the corresponding component or command, do not centralize them into a global copy constants module. -- New general-purpose capability: if it does not depend on TUI state, put it under `src/utils/`; if it depends on TUI state or a component, put it under `src/tui/utils/`. - -Test placement rules: - -- Component behavior tests live next to the corresponding component's tests. -- Command parsing tests go under `test/tui/commands/`. -- reverse-rpc tests go under `test/tui/reverse-rpc/`. -- Pure utility tests go next to the corresponding utils tests. -- Do not create a generic `some-feature.test.ts` just to land a small feature. - ## TUI Coding Conventions - Do not over-encapsulate, especially for one- or two-line functions — do not introduce a two-layer wrapper, just inline. @@ -94,23 +51,9 @@ Test placement rules: - Constants must live in the corresponding `constant` directory; they must not be scattered through component or logic code. - Inside `handleInput(data)`, when comparing a printable character (letter, digit, space, punctuation), it is **forbidden** to write literal comparisons such as `data === 'q'`. With the Kitty keyboard protocol enabled in terminals like VSCode, these keys are sent as CSI-u sequences (e.g. `\x1b[113u`), and a bare comparison will never match. Decode with `printableChar(data)` from `src/tui/utils/printable-key.ts` first, then compare; function keys continue to use `matchesKey(data, Key.*)`; control characters (codepoint < 32) may still be compared against the raw `data`. `test/tui/printable-key-guard.test.ts` enforces this in CI. -## How to Set Themes +## Color Rules (normative) -Themes are managed centrally under `src/tui/theme/`: - -- `colors.ts` defines semantic tokens: `ColorPalette`, `darkColors`, `lightColors`. -- `styles.ts` builds common chalk helpers on top of `ColorPalette`. -- `pi-tui-theme.ts` produces the theme configuration markdown / pi-tui requires. -- `bundle.ts` packs `colors`, `styles`, and `markdownTheme` into a `KimiTUIThemeBundle`. -- `index.ts` / `detect.ts` handle the theme type and auto/dark/light resolution. - -When setting or switching themes: - -- The UI entry goes through `ThemeSelectorComponent`, `handleThemeCommand`, and `applyThemeChoice`. -- The real apply step goes through `KimiTUI.applyTheme`, which should update `state.theme`, `state.appState.theme`, and notify the relevant components to refresh their palette. -- Persisting the user's choice goes through `saveTuiConfig`. Do not let a component write the config file itself. - -When writing color: +The theme apply/switch mechanics live in the `write-tui` skill. The following rules are hard and guard-enforced: - Do not use chalk named colors such as `chalk.red`, `chalk.cyan`, `chalk.white`, `chalk.gray`, `chalk.dim`, or `chalk.yellow` directly. - If a component already has `colors`, use `chalk.hex(colors.)(text)`. @@ -118,8 +61,7 @@ When writing color: - When new visual semantics have no token, first add a semantic field to `ColorPalette`, and fill in both `darkColors` and `lightColors`. - In light themes, text tokens against a white background must be at least 4.5:1; borders and large chrome must be at least 3:1. - Do not cache styled chalk functions at module top level. Theme switching must take effect within a single render, so styles must be generated on the render path from the current palette. - -After a theme change, non-comment code must not contain chalk named colors such as `chalk.white`, `chalk.cyan`, `chalk.red`, `chalk.green`, `chalk.gray`, `chalk.yellow`, `chalk.blue`, `chalk.magenta`, `chalk.whiteBright`, or `chalk.blackBright`. +- Non-comment code must not contain chalk named colors such as `chalk.white`, `chalk.cyan`, `chalk.red`, `chalk.green`, `chalk.gray`, `chalk.yellow`, `chalk.blue`, `chalk.magenta`, `chalk.whiteBright`, or `chalk.blackBright`. `test/tui/chalk-named-color-guard.test.ts` enforces this in CI. ## General Coding Requirements diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index fc08df0b9..8361900a6 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,10 +1,1321 @@ # @moonshot-ai/kimi-code +## 0.24.1 + +### Patch Changes + +- [#1678](https://github.com/MoonshotAI/kimi-code/pull/1678) [`ec1c974`](https://github.com/MoonshotAI/kimi-code/commit/ec1c9748c816d152bf06af2456e82ac35786bba9) Thanks [@chengluyu](https://github.com/chengluyu)! - Preserve goal completion summaries and show untyped LLM errors without an internal error-code prefix in step interruption events. + +- [#1688](https://github.com/MoonshotAI/kimi-code/pull/1688) [`94c0ef8`](https://github.com/MoonshotAI/kimi-code/commit/94c0ef89d29ea8532be02828201328fa1281273c) Thanks [@sailist](https://github.com/sailist)! - Fix built-in tools being unavailable when the model provider becomes ready after the session starts. + +- [#1684](https://github.com/MoonshotAI/kimi-code/pull/1684) [`e417ee7`](https://github.com/MoonshotAI/kimi-code/commit/e417ee7c2c282f00113dc0e4f4514ca5018b76c9) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix Kimi sessions getting stuck when preserved-thinking history contains an empty reasoning step. + +- [#1673](https://github.com/MoonshotAI/kimi-code/pull/1673) [`0f64b4d`](https://github.com/MoonshotAI/kimi-code/commit/0f64b4dcc4f2d295d0039b176d96d8003cb49991) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Align thinking-level handling with the CLI: submit the selected level verbatim instead of silently downgrading it, pin the model's catalog default when nothing was chosen, pre-select the target model's default on model switches, and persist explicit picks as the daemon-wide default so new sessions inherit them. + +- [#1689](https://github.com/MoonshotAI/kimi-code/pull/1689) [`ab22a2a`](https://github.com/MoonshotAI/kimi-code/commit/ab22a2adf0ca17cbb94f1abdab334ebc58814e8d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show just the level name (e.g. Max) in the model pill instead of "thinking: max". + +- [#1625](https://github.com/MoonshotAI/kimi-code/pull/1625) [`d158e0a`](https://github.com/MoonshotAI/kimi-code/commit/d158e0a7ac4e432046d56787263dd2dbac40285e) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix Thinking effort routing so non-Kimi providers preserve configured values for upstream validation, while Kimi models validate runtime selections, fall back safely during model resolution, and synchronize the effective effort back to clients. + +## 0.24.0 + +### Minor Changes + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Add v2 session export support for packaging diagnostic zip archives. + +- [#1591](https://github.com/MoonshotAI/kimi-code/pull/1591) [`83e1753`](https://github.com/MoonshotAI/kimi-code/commit/83e175399f4dc3dfc3bb478543ff5897a24dfa3d) Thanks [@liruifengv](https://github.com/liruifengv)! - Move foreground Bash commands that hit their timeout to the background instead of killing them, so long-running commands survive the timeout and report back on completion. Set `bash_auto_background_on_timeout = false` under `[background]` in config.toml to restore the kill-on-timeout behavior. + +- [#1617](https://github.com/MoonshotAI/kimi-code/pull/1617) [`4ec2e7f`](https://github.com/MoonshotAI/kimi-code/commit/4ec2e7fab14ab89cddf77821082c3ff4911f737b) Thanks [@sailist](https://github.com/sailist)! - Run the local server (`kimi server run` / `kimi web`) on the agent-core-v2 engine by default — the `KIMI_CODE_EXPERIMENTAL_FLAG` opt-in is no longer needed, and the legacy v1 server package has been removed. + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Port progressive tool disclosure to the new agent engine: MCP tool schemas stay out of the top-level tool list, and the model loads them by name on demand through the announcements plus the select_tools tool, keeping the prompt cache stable. Off by default; set KIMI_CODE_EXPERIMENTAL_TOOL_SELECT=1 to enable. + +- [#1646](https://github.com/MoonshotAI/kimi-code/pull/1646) [`5eb6217`](https://github.com/MoonshotAI/kimi-code/commit/5eb62178b3b67d8659788bdf91132469f6588653) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add session diagnostic export to download a session and bounded metadata-only troubleshooting logs as a ZIP. Run `/export` or pick Export session from a session's more menu. Web downloads are limited to 64 MiB. + +### Patch Changes + +- [#1638](https://github.com/MoonshotAI/kimi-code/pull/1638) [`7c889f3`](https://github.com/MoonshotAI/kimi-code/commit/7c889f3a960482cc9382203bda55d972b6fb6acd) Thanks [@RealKai42](https://github.com/RealKai42)! - In auto permission mode, plan exits are now marked as auto-approved (not user-reviewed) in both the tool result and the transcript, so the agent no longer treats automatic plan approval as a user signal to start executing. + +- [#1598](https://github.com/MoonshotAI/kimi-code/pull/1598) [`4feca6b`](https://github.com/MoonshotAI/kimi-code/commit/4feca6b0738ee0120ab8bea04604b8f467a72e48) Thanks [@kermanx](https://github.com/kermanx)! - web: Recover transient subagent rate limits without surfacing them as session errors. + +- [#1635](https://github.com/MoonshotAI/kimi-code/pull/1635) [`e49b3b8`](https://github.com/MoonshotAI/kimi-code/commit/e49b3b877750ba5ca0ea80e154549d5b53455575) Thanks [@sailist](https://github.com/sailist)! - Request task-owned work to stop on session close, honoring `background.keep_alive_on_exit` for independent processes and `background.kill_grace_period_ms` before attempting force-stop. + +- [#1629](https://github.com/MoonshotAI/kimi-code/pull/1629) [`0527ca2`](https://github.com/MoonshotAI/kimi-code/commit/0527ca2267f8cf355d0c158953f3dbfc0c9692ac) Thanks [@sailist](https://github.com/sailist)! - Fix session fork losing everything except the conversation log: forked sessions now carry over media attachments, plan files, background task output, and cron tasks, and a failed fork no longer leaves a broken half-copy behind. + +- [#1627](https://github.com/MoonshotAI/kimi-code/pull/1627) [`28e9dd4`](https://github.com/MoonshotAI/kimi-code/commit/28e9dd4d627f01143b715976cb071e7d16cd2001) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Continue blocked goals after the user resumes them from the goal controls. + +- [#1631](https://github.com/MoonshotAI/kimi-code/pull/1631) [`2d874fb`](https://github.com/MoonshotAI/kimi-code/commit/2d874fbd73eb511e4ef4c8d4c88bd47e429580b2) Thanks [@sailist](https://github.com/sailist)! - Fix a race where a heartbeat write in flight during server shutdown could recreate the instance file right after it was removed. + +- [#1663](https://github.com/MoonshotAI/kimi-code/pull/1663) [`1294a0e`](https://github.com/MoonshotAI/kimi-code/commit/1294a0e1ad739151573163505f9c58afb2d543e4) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix OAuth login hanging after browser authorization when the provider configuration changes during sign-in. + +- [#1657](https://github.com/MoonshotAI/kimi-code/pull/1657) [`32a89c3`](https://github.com/MoonshotAI/kimi-code/commit/32a89c36432f9aea452a697734102a7956e42e92) Thanks [@RealKai42](https://github.com/RealKai42)! - Prevent oversized image reads from poisoning sessions and recover existing request-too-large failures by removing unsafe media from provider requests. + +- [#1635](https://github.com/MoonshotAI/kimi-code/pull/1635) [`e49b3b8`](https://github.com/MoonshotAI/kimi-code/commit/e49b3b877750ba5ca0ea80e154549d5b53455575) Thanks [@sailist](https://github.com/sailist)! - Store background task records per agent again, so tasks written by older versions are found on resume and one agent's restore no longer marks another agent's tasks as lost. + +- [#1632](https://github.com/MoonshotAI/kimi-code/pull/1632) [`a4aae87`](https://github.com/MoonshotAI/kimi-code/commit/a4aae87cd9a240d3567601ed1a9aefaab540b075) Thanks [@sailist](https://github.com/sailist)! - Fix providers without a configured base_url being rejected: anthropic/openai and other protocol providers now fall back to their official default endpoints again, as before. + +- [#1588](https://github.com/MoonshotAI/kimi-code/pull/1588) [`2061590`](https://github.com/MoonshotAI/kimi-code/commit/20615902c2c3776d17c6c334cedec1c8723222b1) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix pasted media being dropped from /skill and plugin command arguments. + +- [#1588](https://github.com/MoonshotAI/kimi-code/pull/1588) [`2061590`](https://github.com/MoonshotAI/kimi-code/commit/20615902c2c3776d17c6c334cedec1c8723222b1) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix pasted images being dropped when steering with Ctrl-S. + +- [#1629](https://github.com/MoonshotAI/kimi-code/pull/1629) [`0527ca2`](https://github.com/MoonshotAI/kimi-code/commit/0527ca2267f8cf355d0c158953f3dbfc0c9692ac) Thanks [@sailist](https://github.com/sailist)! - Fix the v2 engine never activating tool-call deduplication: identical tool calls issued in the same step no longer execute multiple times, and repeated identical calls across steps receive escalating reminders again. + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix a race in the experimental v2 config service that could drop a just-written setting from the config response. + +- [#1614](https://github.com/MoonshotAI/kimi-code/pull/1614) [`3c0e368`](https://github.com/MoonshotAI/kimi-code/commit/3c0e368cbdfebff9632cffca3b18365615a146b8) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix a server crash when the first goal-mode prompt is submitted while the v2 agent is still starting. + +- [#1631](https://github.com/MoonshotAI/kimi-code/pull/1631) [`2d874fb`](https://github.com/MoonshotAI/kimi-code/commit/2d874fbd73eb511e4ef4c8d4c88bd47e429580b2) Thanks [@sailist](https://github.com/sailist)! - Surface the provider's actual rejection message instead of a misleading re-login prompt when an OAuth-managed model keeps returning 401 after a token refresh. + +- [#1631](https://github.com/MoonshotAI/kimi-code/pull/1631) [`2d874fb`](https://github.com/MoonshotAI/kimi-code/commit/2d874fbd73eb511e4ef4c8d4c88bd47e429580b2) Thanks [@sailist](https://github.com/sailist)! - Rewrite repeated-tool-call reminders to redirect the agent toward a different action instead of prohibiting the call, and treat a dismissed question prompt as no answer rather than the recommended option. + +- [#1636](https://github.com/MoonshotAI/kimi-code/pull/1636) [`8027fe2`](https://github.com/MoonshotAI/kimi-code/commit/8027fe291b03fbfce6dc60aa06f8699ad0976ec5) Thanks [@sailist](https://github.com/sailist)! - Make file tools able to reach skill directories outside the working directory in the v2 engine (experimental), and honor --skillsDir in v2 print mode and the server's skillDirs option. + +- [#1630](https://github.com/MoonshotAI/kimi-code/pull/1630) [`0303b82`](https://github.com/MoonshotAI/kimi-code/commit/0303b82c3e691836163ecf906febfb6324c81d74) Thanks [@sailist](https://github.com/sailist)! - Fix ReadMediaFile results losing their image rendering after a session reload or resume on the v2 server backend. + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix a storage race in the experimental v2 engine that could fail value reads when writes overlap with compaction. + +- [#1601](https://github.com/MoonshotAI/kimi-code/pull/1601) [`dc309a7`](https://github.com/MoonshotAI/kimi-code/commit/dc309a7dfb38b6ef885b8ae80be51b49f8486207) Thanks [@kermanx](https://github.com/kermanx)! - web: Fix the context usage indicator dropping to 0 when a session is reopened or the session list reloads (e.g. after a sidebar search) — the cached live usage is now kept instead of the session record's all-zero placeholder. + +- [#1620](https://github.com/MoonshotAI/kimi-code/pull/1620) [`e91a616`](https://github.com/MoonshotAI/kimi-code/commit/e91a616f2196ab9ffc69b3fcc0f2015398d86bd4) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix duplicate user message bubbles after a session snapshot resync. + +- [#1672](https://github.com/MoonshotAI/kimi-code/pull/1672) [`88629ba`](https://github.com/MoonshotAI/kimi-code/commit/88629bac3add2a8a17ae8288ee4edbdc9313d55a) Thanks [@yicun](https://github.com/yicun)! - web: Fix uploaded and persisted images failing to display on non-loopback server connections. + +- [#1609](https://github.com/MoonshotAI/kimi-code/pull/1609) [`e223549`](https://github.com/MoonshotAI/kimi-code/commit/e223549a79c80e442850947c0cf60d58b2d18667) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix a running multi-step turn rendering a duplicated wall of text after the page reconnects or refreshes mid-turn. + +- [#1611](https://github.com/MoonshotAI/kimi-code/pull/1611) [`32cbd0c`](https://github.com/MoonshotAI/kimi-code/commit/32cbd0cf6109f4f3f124e6e4ee7c4c87fa344247) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Fix the workspace picker menu sizing too narrowly for its content. + +- [#1635](https://github.com/MoonshotAI/kimi-code/pull/1635) [`e49b3b8`](https://github.com/MoonshotAI/kimi-code/commit/e49b3b877750ba5ca0ea80e154549d5b53455575) Thanks [@sailist](https://github.com/sailist)! - Fix possible record loss when resuming sessions whose wire log needs migration, and reject session logs missing the version envelope instead of silently misreading them. + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix MCP tools being unavailable on the first turn after session startup. + +- [#1580](https://github.com/MoonshotAI/kimi-code/pull/1580) [`83370f1`](https://github.com/MoonshotAI/kimi-code/commit/83370f17ef38770561a421e3b3a15f6244219aa5) Thanks [@wszqkzqk](https://github.com/wszqkzqk)! - Fix bash auto-detection on Windows failing when git comes from a native MSYS2 toolchain (ucrt64/clang64/clangarm64). + +- [#1676](https://github.com/MoonshotAI/kimi-code/pull/1676) [`d1820ff`](https://github.com/MoonshotAI/kimi-code/commit/d1820ff0f853689e84b3e9d4c482532c481eb9bd) Thanks [@RealKai42](https://github.com/RealKai42)! - Preserve empty model reasoning blocks across providers so multi-step tool calls can continue. + +- [#1669](https://github.com/MoonshotAI/kimi-code/pull/1669) [`490303d`](https://github.com/MoonshotAI/kimi-code/commit/490303db16ed374eae20572e4c6f9880db911547) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Refine goal mode controls with animated strip interactions, budget-aware progress, and design-system cancellation confirmation. + +- [#1597](https://github.com/MoonshotAI/kimi-code/pull/1597) [`d601847`](https://github.com/MoonshotAI/kimi-code/commit/d601847f22366b041d949d7c9f7857471be8970c) Thanks [@7Sageer](https://github.com/7Sageer)! - Send the kimi-code-cli User-Agent on provider registry (api.json) and model catalog fetches, so registries can identify the client version. + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix approval and question prompts not appearing in real time for web clients connected to the v2 server; they previously only showed up after a page refresh. + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Log a warning when a skill fails to parse instead of silently dropping it, and fix the skill catalog so scanned skill roots and policy-skipped skills are actually reported. + +- [#1589](https://github.com/MoonshotAI/kimi-code/pull/1589) [`f338fcd`](https://github.com/MoonshotAI/kimi-code/commit/f338fcdac4fa8d4235c44310953e5d512f6549fb) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the AgentSwarm member list disappearing after a page refresh while subagents are still running. + +- [#1591](https://github.com/MoonshotAI/kimi-code/pull/1591) [`83e1753`](https://github.com/MoonshotAI/kimi-code/commit/83e175399f4dc3dfc3bb478543ff5897a24dfa3d) Thanks [@liruifengv](https://github.com/liruifengv)! - Optimize the TaskOutput tool prompts to discourage blocking waits on background tasks. + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Enforce a typed registry for v2 engine telemetry events and redact URLs, tokens, and file paths from outgoing telemetry properties. + +- [#1624](https://github.com/MoonshotAI/kimi-code/pull/1624) [`3215129`](https://github.com/MoonshotAI/kimi-code/commit/321512986099037acb4b2677d4455db316d27b50) Thanks [@kermanx](https://github.com/kermanx)! - Fix the experimental v2 engine crashing when the first prompt is sent right after a new conversation is created (for example sending /goal on the web's new-conversation page): agent creation now joins the in-flight bootstrap instead of failing, and the v2 agent lifecycle is split into focused existence, sub-agent, and session MCP domains. + +- [#1637](https://github.com/MoonshotAI/kimi-code/pull/1637) [`0e0a6e9`](https://github.com/MoonshotAI/kimi-code/commit/0e0a6e9a5170c28c5e6809c1b2cf6d6f8904de73) Thanks [@sailist](https://github.com/sailist)! - Support caller-supplied MCP server configs on session create in the v2 engine (experimental), merged over the file config and under plugin servers. + +- [#1626](https://github.com/MoonshotAI/kimi-code/pull/1626) [`1c85f94`](https://github.com/MoonshotAI/kimi-code/commit/1c85f94472ead2746ad6860ec0e09f4384dd95ec) Thanks [@sailist](https://github.com/sailist)! - v2 engine: expose the prompt scheduler over /api/v2 for native clients, and add an experimental fault-injection service (KIMI_CODE_EXPERIMENTAL_FAULT_INJECTION) that arms a one-shot provider failure so the media-degraded / media-stripped recovery resends can be exercised end-to-end. + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Introduce a graded error taxonomy for the v2 engine's filesystem, storage, and wire layers, translating raw OS and parse failures into specific error codes instead of generic internal errors. + +- [#1626](https://github.com/MoonshotAI/kimi-code/pull/1626) [`1c85f94`](https://github.com/MoonshotAI/kimi-code/commit/1c85f94472ead2746ad6860ec0e09f4384dd95ec) Thanks [@sailist](https://github.com/sailist)! - v2 engine: block unsupported image formats (AVIF, HEIC, BMP, TIFF, ICO) at every ingestion point so they can no longer poison session history, and auto-recover provider image-format rejections with a media-stripped resend. + +- [#1626](https://github.com/MoonshotAI/kimi-code/pull/1626) [`1c85f94`](https://github.com/MoonshotAI/kimi-code/commit/1c85f94472ead2746ad6860ec0e09f4384dd95ec) Thanks [@sailist](https://github.com/sailist)! - v2 engine: recover image-heavy sessions from provider request-size rejections (HTTP 413) by resending with older media degraded to text markers, re-encode oversized WebP images instead of passing them through, and keep downscaled PNGs readable by switching to JPEG below 1000px. + +- [#1613](https://github.com/MoonshotAI/kimi-code/pull/1613) [`b2daa40`](https://github.com/MoonshotAI/kimi-code/commit/b2daa405f075cb6847c0a313809b1bcac750b611) Thanks [@7Sageer](https://github.com/7Sageer)! - Support the `services.moonshot_search` api-key config for WebSearch in the v2 engine, matching v1: the tool is now available without an OAuth login, and explicit config takes precedence over the OAuth-derived provider. + +- [#1590](https://github.com/MoonshotAI/kimi-code/pull/1590) [`8a4ee05`](https://github.com/MoonshotAI/kimi-code/commit/8a4ee05951ebe4f804fd1fb0989aaf44b3b7a3ed) Thanks [@sailist](https://github.com/sailist)! - Fix bash auto-detection on Windows in the experimental v2 engine when git comes from a native MSYS2 toolchain (ucrt64/clang64/clangarm64). + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Send the CLI identity headers (User-Agent and device identity) with outbound requests from the experimental v2 server, matching direct CLI runs. + +- [#1593](https://github.com/MoonshotAI/kimi-code/pull/1593) [`2185237`](https://github.com/MoonshotAI/kimi-code/commit/2185237c2f5c5fb3cc6b44c01ac158c6e2b81fe6) Thanks [@kermanx](https://github.com/kermanx)! - Declare v2 engine wire op payloads with required zod schemas and derive their types from the schemas, with ops declared on their models and every op type registered for replay classification. + +- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Keep sessions from the new agent engine compatible with existing transcript replay. + +- [#1592](https://github.com/MoonshotAI/kimi-code/pull/1592) [`924d5c9`](https://github.com/MoonshotAI/kimi-code/commit/924d5c914143d178020c2dddc56906ce15088680) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show the connected backend engine (v1 / v2) in Settings, and add a dev-mode backend pill next to the sidebar brand that can switch the dev proxy between the two engines at runtime. + +- [#1606](https://github.com/MoonshotAI/kimi-code/pull/1606) [`2da45fc`](https://github.com/MoonshotAI/kimi-code/commit/2da45fc419cf5285a9353df8690bba444037ffe4) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix the goal card disappearing after a page refresh while a session goal is active. + +- [#1587](https://github.com/MoonshotAI/kimi-code/pull/1587) [`49a8c84`](https://github.com/MoonshotAI/kimi-code/commit/49a8c84a493610c2b2cc2c7da0a8ec0261d876db) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Let wide Markdown tables in the desktop chat grow up to 1040px with each column capped at 700px so long cell content wraps, temporarily hiding the conversation outline while a table passes under it; anything wider still scrolls inside the table. + +## 0.23.6 + +### Patch Changes + +- [#1550](https://github.com/MoonshotAI/kimi-code/pull/1550) [`f17a6ec`](https://github.com/MoonshotAI/kimi-code/commit/f17a6ecb52907ffabf67a26de65df89572ac515a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Treat a dismissed question prompt as the user choosing not to answer, instead of implicitly selecting the recommended option. + +- [#1488](https://github.com/MoonshotAI/kimi-code/pull/1488) [`7bd29ab`](https://github.com/MoonshotAI/kimi-code/commit/7bd29ab0117a1c15691404f411fd67f511bbb897) Thanks [@starquakee](https://github.com/starquakee)! - Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`. + +- [#1497](https://github.com/MoonshotAI/kimi-code/pull/1497) [`c6e02da`](https://github.com/MoonshotAI/kimi-code/commit/c6e02daf421b47e8451e60ed4d7b3847a895d00b) Thanks [@sailist](https://github.com/sailist)! - Add a print-mode background policy that lets `kimi -p` stay alive across background-task completions so the main agent can be steered into follow-up turns. Set `[background].print_background_mode = "steer"` to enable it. + +- [#1555](https://github.com/MoonshotAI/kimi-code/pull/1555) [`2f97917`](https://github.com/MoonshotAI/kimi-code/commit/2f97917bb5edc8bdb9837724e57a88f5c0e1f2bd) Thanks [@sailist](https://github.com/sailist)! - Keep `kimi -p` runs alive after a turn ends while a goal is still active or a cron task is pending, so goal continuations and cron fires run their turns instead of being cut off when the main turn finishes. + +- [#1564](https://github.com/MoonshotAI/kimi-code/pull/1564) [`cc03816`](https://github.com/MoonshotAI/kimi-code/commit/cc03816ee0a89b272c1ab87ca43ed246833f0453) Thanks [@sailist](https://github.com/sailist)! - Recognize the support_efforts and default_effort fields when importing a custom registry, so thinking effort levels are available for those models. + +- [#1562](https://github.com/MoonshotAI/kimi-code/pull/1562) [`faefad0`](https://github.com/MoonshotAI/kimi-code/commit/faefad0e290ceacb89851baa42043c8685b08dc9) Thanks [@sailist](https://github.com/sailist)! - Add a `subagent.timeout_ms` config option to control how long a single subagent may run before timing out, and raise the default from 30 minutes to 2 hours. Set `[subagent] timeout_ms` in config.toml (or the `KIMI_SUBAGENT_TIMEOUT_MS` env var) to adjust it. + +- [#1572](https://github.com/MoonshotAI/kimi-code/pull/1572) [`3a7aad6`](https://github.com/MoonshotAI/kimi-code/commit/3a7aad653f1226ce4e2c7103318471f65154c406) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix sessions getting stuck in a sending state after a reconnect, so the working spinner stops and the next message sends normally once a turn finishes while the connection is down. + +- [#1574](https://github.com/MoonshotAI/kimi-code/pull/1574) [`b1942bd`](https://github.com/MoonshotAI/kimi-code/commit/b1942bd5718c46991ba5021b4ae96dbf2458617c) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the first visit after starting or updating the web UI bouncing to the login page when the initial auth check fails; the connecting screen now stays up, shows the connection error, and retries until the server answers. + +- [#1553](https://github.com/MoonshotAI/kimi-code/pull/1553) [`264525e`](https://github.com/MoonshotAI/kimi-code/commit/264525eb51f87409a8961bcf3f5f0271ab767c49) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the chat view jumping downward while scrolling through conversation history. + +- [#1565](https://github.com/MoonshotAI/kimi-code/pull/1565) [`1d3dba5`](https://github.com/MoonshotAI/kimi-code/commit/1d3dba56832b69628f3bb22ce240f38a08f0af3a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the model dropdown showing checkmarks on same-named models from other providers; the current model is now matched by its unique model id. + +- [#1567](https://github.com/MoonshotAI/kimi-code/pull/1567) [`f901b9e`](https://github.com/MoonshotAI/kimi-code/commit/f901b9e1da9a9575b5d47dba40babe2ccd035180) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Keep the server access token for up to 7 days across tab close and browser restarts, instead of asking for it again with every new tab. + +- [#1552](https://github.com/MoonshotAI/kimi-code/pull/1552) [`37bb4b8`](https://github.com/MoonshotAI/kimi-code/commit/37bb4b870edf6a5458dda755a5b4a432c32df2a7) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix ReadMediaFile results rendering as plain tool cards instead of images after resuming or reloading a session. + +- [#1563](https://github.com/MoonshotAI/kimi-code/pull/1563) [`c982386`](https://github.com/MoonshotAI/kimi-code/commit/c98238699c1ae51a2237969b43282373fc0c0e89) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix sidebar lag with many sessions by removing repeated session list scans during rendering. + +- [#1475](https://github.com/MoonshotAI/kimi-code/pull/1475) [`5a208cb`](https://github.com/MoonshotAI/kimi-code/commit/5a208cb041530e320f343a46e231bf3c109e30c9) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Auto-enable the default thinking effort when switching to a model that supports effort levels in the web UI. + +- [#1577](https://github.com/MoonshotAI/kimi-code/pull/1577) [`6fc1deb`](https://github.com/MoonshotAI/kimi-code/commit/6fc1deb45312574a9e97ffaf6d7ced530d38910d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Let wide Markdown tables in the desktop chat grow beyond the reading column up to 1040px, temporarily hiding the conversation outline while a table passes under it; anything wider still scrolls inside the table. + +- [#1575](https://github.com/MoonshotAI/kimi-code/pull/1575) [`9d96b53`](https://github.com/MoonshotAI/kimi-code/commit/9d96b538bf4311fdf07aa262a1b4141f7bdd83ed) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Let wide Markdown tables scroll horizontally inside the table instead of being squeezed into the reading column. + +- [#1556](https://github.com/MoonshotAI/kimi-code/pull/1556) [`d2c2c33`](https://github.com/MoonshotAI/kimi-code/commit/d2c2c33f3e89c7c9ed06aa7c2376b88b6107e41d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add workspaces by typing an absolute path directly in the workspace picker's search box, with live validation and completion suggestions. + +- [#1547](https://github.com/MoonshotAI/kimi-code/pull/1547) [`19c5aa6`](https://github.com/MoonshotAI/kimi-code/commit/19c5aa64ebef86925ad58074ebcac6a5a7a8ff8d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Update the WebBridge install page link opened from the /plugins panel. + +## 0.23.5 + +### Patch Changes + +- [#1542](https://github.com/MoonshotAI/kimi-code/pull/1542) [`f80b2ea`](https://github.com/MoonshotAI/kimi-code/commit/f80b2eaf04925ce920f693fc8d4d81cb00e825d7) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the "Turn finished" desktop notification and completion sound firing twice per turn. + +- [#1535](https://github.com/MoonshotAI/kimi-code/pull/1535) [`04041eb`](https://github.com/MoonshotAI/kimi-code/commit/04041eb998b6798898fa5df97f7587b3aa119b27) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Hide the internal image-compression note so it no longer renders as user message text. + +- [#1536](https://github.com/MoonshotAI/kimi-code/pull/1536) [`db61c9e`](https://github.com/MoonshotAI/kimi-code/commit/db61c9e2dddcb6c35b019ef7f374385248ece881) Thanks [@RealKai42](https://github.com/RealKai42)! - Stop unsupported image formats (AVIF, BMP, TIFF, ICO, …) from breaking sessions at every entry point — including remote image URLs and images mislabeled by a tool — and recover an already-stuck session by dropping the offending image and retrying, so one such image can no longer make every later request fail. + +- [#1530](https://github.com/MoonshotAI/kimi-code/pull/1530) [`9f66ec4`](https://github.com/MoonshotAI/kimi-code/commit/9f66ec416cc658842dc1414b79b6447d1b4cc7f9) Thanks [@sailist](https://github.com/sailist)! - Retry provider 429, overload, and other transient errors more reliably, honoring the server Retry-After delay, and surface retries in `-p --output-format stream-json`. + +## 0.23.4 + +### Patch Changes + +- [#1501](https://github.com/MoonshotAI/kimi-code/pull/1501) [`b91099e`](https://github.com/MoonshotAI/kimi-code/commit/b91099ed7a2590d1afa4d6e3675671da52b7661c) Thanks [@liruifengv](https://github.com/liruifengv)! - Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands. + +- [#1517](https://github.com/MoonshotAI/kimi-code/pull/1517) [`173bdfd`](https://github.com/MoonshotAI/kimi-code/commit/173bdfdab1f484ed79927aeaac7dc8116d3fd346) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix resuming sessions whose original working directory no longer exists. + +- [#1516](https://github.com/MoonshotAI/kimi-code/pull/1516) [`9fb1915`](https://github.com/MoonshotAI/kimi-code/commit/9fb19154accf6b6f7abfbf7a9820ccda517bc87e) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts. + +- [#1508](https://github.com/MoonshotAI/kimi-code/pull/1508) [`1bf2c9a`](https://github.com/MoonshotAI/kimi-code/commit/1bf2c9afee4643fbf6755f0b92fd60aa14240501) Thanks [@RealKai42](https://github.com/RealKai42)! - Keep image-heavy sessions within provider request-size limits: model-read images now honor a 256 KB per-image budget and a 2000px downscale cap (configurable via `[image]` in config.toml or `KIMI_IMAGE_*` env vars), oversized WebP is compressed as well, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and a request-too-large rejection (HTTP 413) now recovers automatically — the request and /compact both retry with older media replaced by text markers instead of failing the session. + +- [#1519](https://github.com/MoonshotAI/kimi-code/pull/1519) [`170ae44`](https://github.com/MoonshotAI/kimi-code/commit/170ae4420526b6592d696cd597d1693dbd1a660b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Polish the session sidebar layout, colors, icons, and typography. + +- [#1521](https://github.com/MoonshotAI/kimi-code/pull/1521) [`046b6c4`](https://github.com/MoonshotAI/kimi-code/commit/046b6c417581792933732c7ffe154e120c96171d) Thanks [@RealKai42](https://github.com/RealKai42)! - The `[image]` limits in config.toml now also apply to pasted images (CLI paste and ACP prompts), and each core now uses its own settings, so reloading one client's config no longer changes another client's image compression. + +- [#1494](https://github.com/MoonshotAI/kimi-code/pull/1494) [`a354803`](https://github.com/MoonshotAI/kimi-code/commit/a3548035a8b6d25df9a11daab37a21daee1ef73f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add a Kimi WebBridge entry to the Official tab of the /plugins panel that opens the WebBridge install page in your browser. + +- [#1479](https://github.com/MoonshotAI/kimi-code/pull/1479) [`735922c`](https://github.com/MoonshotAI/kimi-code/commit/735922c291ec3d32d60da6af053f75e1c6179f92) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Add notifications when a tool needs approval, and improve notification reliability. + +- [#1522](https://github.com/MoonshotAI/kimi-code/pull/1522) [`ec8dc34`](https://github.com/MoonshotAI/kimi-code/commit/ec8dc3456c1696a5eba6c37b6e26ef99837c35e2) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent. + +- [#1502](https://github.com/MoonshotAI/kimi-code/pull/1502) [`ad30a1c`](https://github.com/MoonshotAI/kimi-code/commit/ad30a1c6328327729221f9f5fc700b621dfef779) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Polish the chat UI with Inter typography, localized labels, and tighter sidebar, composer, and menu styling. + +## 0.23.3 + +### Patch Changes + +- [#1506](https://github.com/MoonshotAI/kimi-code/pull/1506) [`e83511a`](https://github.com/MoonshotAI/kimi-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account. + +## 0.23.2 + +### Patch Changes + +- [#1489](https://github.com/MoonshotAI/kimi-code/pull/1489) [`2206d21`](https://github.com/MoonshotAI/kimi-code/commit/2206d21327129aa2331b6b159cfce61110b7f94f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add the Vercel plugin to the bundled plugin marketplace. Run /plugins and select Vercel Plugin to install it. + +- [#1477](https://github.com/MoonshotAI/kimi-code/pull/1477) [`150206a`](https://github.com/MoonshotAI/kimi-code/commit/150206a6f7027879df954e26736b4baa5d336235) Thanks [@chengluyu](https://github.com/chengluyu)! - Count the turn that starts an autonomous goal toward its goal turn usage. + +- [#1483](https://github.com/MoonshotAI/kimi-code/pull/1483) [`f30781b`](https://github.com/MoonshotAI/kimi-code/commit/f30781bb273321f3e3bbb548a9d0724ab6299fc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix `kimi -p` runs exiting with code 0 when a turn fails. + +- [#1466](https://github.com/MoonshotAI/kimi-code/pull/1466) [`063bce2`](https://github.com/MoonshotAI/kimi-code/commit/063bce2a2f52601abaa0d13173ab88371cbbe9ae) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix console windows flashing on Windows each time a hook runs. + +- [#1474](https://github.com/MoonshotAI/kimi-code/pull/1474) [`11c6a37`](https://github.com/MoonshotAI/kimi-code/commit/11c6a37ce030f8e64de5c810da07ec7fab3b0615) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background. + +- [#1476](https://github.com/MoonshotAI/kimi-code/pull/1476) [`d1a964f`](https://github.com/MoonshotAI/kimi-code/commit/d1a964fba9b3dca902ea6f81bacaccc839955c03) Thanks [@chengluyu](https://github.com/chengluyu)! - Prevent autonomous goals from being paused by model-reported status updates. + +- [#1481](https://github.com/MoonshotAI/kimi-code/pull/1481) [`1317000`](https://github.com/MoonshotAI/kimi-code/commit/131700097a732b97b3d17c5e2efa1c5a44b013ef) Thanks [@chengluyu](https://github.com/chengluyu)! - Tighten goal-mode guidance for blocked and complete status updates. + +- [#1460](https://github.com/MoonshotAI/kimi-code/pull/1460) [`474ce28`](https://github.com/MoonshotAI/kimi-code/commit/474ce289dd39aa42d1a77a9a2e15531aee49aa15) Thanks [@RealKai42](https://github.com/RealKai42)! - Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly. + +- [#1467](https://github.com/MoonshotAI/kimi-code/pull/1467) [`ee38545`](https://github.com/MoonshotAI/kimi-code/commit/ee385456d0eda380fec067db92c025462db13f5a) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Compile icons at build time so the bundled web UI only carries the icons it renders. + +- [#1471](https://github.com/MoonshotAI/kimi-code/pull/1471) [`9b76e5b`](https://github.com/MoonshotAI/kimi-code/commit/9b76e5bff631cceaeecb2b0cbc096533c5fdc8cc) Thanks [@starquakee](https://github.com/starquakee)! - Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them. After a compaction the boundary announcement re-lists every loadable tool name and the model re-selects what it still needs; a from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. This keeps the post-compaction context at its minimal users+summary floor and removes the schema-rebuild budget heuristics. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active. + +- [#1491](https://github.com/MoonshotAI/kimi-code/pull/1491) [`0cc9831`](https://github.com/MoonshotAI/kimi-code/commit/0cc9831a2f79d93903259bd3353e746abac01b67) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: The composer model switcher switches the active session's model as before and additionally bumps the global default model, so new sessions inherit the choice. + +- [#1490](https://github.com/MoonshotAI/kimi-code/pull/1490) [`b30a45e`](https://github.com/MoonshotAI/kimi-code/commit/b30a45efecfa5ece4f4f10f2c5403ba097e7690b) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Press Enter to confirm in archive and other confirmation dialogs. + +- [#1480](https://github.com/MoonshotAI/kimi-code/pull/1480) [`2ad0120`](https://github.com/MoonshotAI/kimi-code/commit/2ad0120c2a5c8383892e4da1ee7c6853926ed365) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Redesign the scheduled reminder UI. + +- [#1492](https://github.com/MoonshotAI/kimi-code/pull/1492) [`b0809dd`](https://github.com/MoonshotAI/kimi-code/commit/b0809ddac833d8d920d95187f7ef64f97bafdbc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show session skills in the slash menu as `/skill:` so they are distinguishable from built-in commands; typing the bare skill name still works. + +## 0.23.1 + +### Patch Changes + +- [#1432](https://github.com/MoonshotAI/kimi-code/pull/1432) [`25a655c`](https://github.com/MoonshotAI/kimi-code/commit/25a655cf88b2f5861f9c0b7ea95ba9308f48d23a) Thanks [@RealKai42](https://github.com/RealKai42)! - Preserve prior turns' thinking by default on the Anthropic provider (Claude and Kimi's Anthropic-compatible mode), matching the Kimi default. Disable with `[thinking] keep = "off"` or `KIMI_MODEL_THINKING_KEEP=off`. + +- [#1451](https://github.com/MoonshotAI/kimi-code/pull/1451) [`16dc940`](https://github.com/MoonshotAI/kimi-code/commit/16dc940834d9cc693b1f0022c4c70ef0004a6102) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Recover chat streaming after a stale background-tab WebSocket instead of requiring a page refresh. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal completion and blocked updates to produce one final user-facing outcome summary from the tool result. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal startup and queue handling so failed starts restore permission mode and queued goals wait behind new user messages. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal token budgets to count model completion tokens and stop without extra continuation steps when the budget is exhausted. + +- [#1452](https://github.com/MoonshotAI/kimi-code/pull/1452) [`244ec07`](https://github.com/MoonshotAI/kimi-code/commit/244ec077f98c2b498cee1d0002978b6963ccfd4d) Thanks [@sailist](https://github.com/sailist)! - Fix kimi -p abandoning background subagents that start late or run long, so their results reach the main agent. + +- [#1457](https://github.com/MoonshotAI/kimi-code/pull/1457) [`260a807`](https://github.com/MoonshotAI/kimi-code/commit/260a80793a95d7796950a00bdc89cf99f8b196ad) Thanks [@liruifengv](https://github.com/liruifengv)! - Respect the --skills-dir flag in interactive mode. + +- [#1445](https://github.com/MoonshotAI/kimi-code/pull/1445) [`809a88c`](https://github.com/MoonshotAI/kimi-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix `/btw []` opening an empty side chat on the new-session screen. + +- [#1445](https://github.com/MoonshotAI/kimi-code/pull/1445) [`809a88c`](https://github.com/MoonshotAI/kimi-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix `/goal ` silently doing nothing on the new-session screen. + +- [#1445](https://github.com/MoonshotAI/kimi-code/pull/1445) [`809a88c`](https://github.com/MoonshotAI/kimi-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix slash skill activations (for example `/pre-changelog`) silently doing nothing on the new-session screen. + +- [#1437](https://github.com/MoonshotAI/kimi-code/pull/1437) [`743f66e`](https://github.com/MoonshotAI/kimi-code/commit/743f66e547279916d5e37454e78b11eb4b54dca3) Thanks [@RealKai42](https://github.com/RealKai42)! - Stop showing tool-produced `` metadata in tool outputs; failed tools now show their own error text. + +- [#1465](https://github.com/MoonshotAI/kimi-code/pull/1465) [`bfdbce5`](https://github.com/MoonshotAI/kimi-code/commit/bfdbce593f1dd667530cbfb5b10b5659b1968e48) Thanks [@liruifengv](https://github.com/liruifengv)! - Honor explicit Anthropic `max_output_size` settings instead of clamping them to built-in ceilings. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Keep goal tools available to the main agent and return clear messages for invalid goal-control calls. + +- [#1463](https://github.com/MoonshotAI/kimi-code/pull/1463) [`03e78ae`](https://github.com/MoonshotAI/kimi-code/commit/03e78ae19063b38119f27b1bc89097a09614c0ce) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix newer Claude minor versions (e.g. Opus 4.8) defaulting to the family-baseline max output tokens; an uncatalogued minor now reuses the nearest earlier known version's limit. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Show long-running goal wall-clock budget reminders in hours. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Tighten goal-mode guidance so agents continue reasonable work across turns instead of ending goals prematurely. + +- [#1450](https://github.com/MoonshotAI/kimi-code/pull/1450) [`7a65e0d`](https://github.com/MoonshotAI/kimi-code/commit/7a65e0d1c0da515dbd69f1266ba7e75713e0108e) Thanks [@liruifengv](https://github.com/liruifengv)! - Clarify the permission mode descriptions shown by `/permission`, `/auto`, and `/yolo`, and reorder `/auto` and `/yolo` in the command list. + +- [#1448](https://github.com/MoonshotAI/kimi-code/pull/1448) [`65d3017`](https://github.com/MoonshotAI/kimi-code/commit/65d30177adc11a56bdbbe9fbc3c4b92f96efd6bb) Thanks [@RealKai42](https://github.com/RealKai42)! - Record a per-request trace in the session wire log, so model requests can be reconstructed for debugging. Not a user-facing feature. + +## 0.23.0 + +### Minor Changes + +- [#1417](https://github.com/MoonshotAI/kimi-code/pull/1417) [`79b360c`](https://github.com/MoonshotAI/kimi-code/commit/79b360c96ad5d0af6a8c1d3a8df73adc65254d7c) Thanks [@RealKai42](https://github.com/RealKai42)! - Enable Preserved Thinking by default for Kimi models when Thinking is on, keeping prior reasoning across turns. Set `[thinking] keep = "off"` in config.toml (or `KIMI_MODEL_THINKING_KEEP=off`) to disable it. + +- [#1073](https://github.com/MoonshotAI/kimi-code/pull/1073) [`6c0ce09`](https://github.com/MoonshotAI/kimi-code/commit/6c0ce09414bbfd42d8991c88c89b82c088ff2099) Thanks [@sailist](https://github.com/sailist)! - Add server APIs to restore archived sessions and list only archived sessions. + +- [#1369](https://github.com/MoonshotAI/kimi-code/pull/1369) [`f0896a5`](https://github.com/MoonshotAI/kimi-code/commit/f0896a53b01f7e5b9bf5b8f93d2cd7387d765f07) Thanks [@starquakee](https://github.com/starquakee)! - Add experimental progressive tool disclosure (`select_tools`). When the `tool-select` experimental flag is on and the active model declares the `select_tools` capability, MCP tool schemas stay out of the request's top-level `tools[]` (preserving the provider prompt cache); the model loads tools on demand by exact name via the new built-in `select_tools` tool, guided by `/` announcements. Off by default and inert on models without the capability — behavior is unchanged until a supporting model is catalogued. The SDK additionally maps the `select_tools` capability when building model aliases from a catalog and reports the new flag through `getExperimentalFeatures()`. + +- [#1073](https://github.com/MoonshotAI/kimi-code/pull/1073) [`6c0ce09`](https://github.com/MoonshotAI/kimi-code/commit/6c0ce09414bbfd42d8991c88c89b82c088ff2099) Thanks [@sailist](https://github.com/sailist)! - web: Add an Archived sessions page in Settings to browse and restore archived sessions. Open Settings → Archived to find it. + +- [#1425](https://github.com/MoonshotAI/kimi-code/pull/1425) [`c5e3e80`](https://github.com/MoonshotAI/kimi-code/commit/c5e3e80041a763143934c271d2524ac555d48d2a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Replace the swarm footer with a single inline tool card that shows live subagent progress and the aggregated result, and keep swarm badges stable after refresh. + +### Patch Changes + +- [#1414](https://github.com/MoonshotAI/kimi-code/pull/1414) [`c12f309`](https://github.com/MoonshotAI/kimi-code/commit/c12f30951f2c278bebb42d0f61d67946c42b9577) Thanks [@RealKai42](https://github.com/RealKai42)! - AskUserQuestion now feeds answers back to the model as question text and option labels (e.g. `{"answers":{"Which database?":"Postgres"}}`) instead of synthesized ids like `q_0`/`opt_0_1`, so the model no longer has to map positional ids back to the original options — the wire protocol is unchanged, clients still answer with option ids. Question texts must now be unique per call and option labels unique per question; the web transcript card resolves both the new label form and legacy id transcripts. + +- [#1408](https://github.com/MoonshotAI/kimi-code/pull/1408) [`fc259ab`](https://github.com/MoonshotAI/kimi-code/commit/fc259abdb415fe9ac10132a142bdb5ce507ccda2) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix `@` file completion missing deeply nested files in large projects after adding extra workspace directories. + +- [#1346](https://github.com/MoonshotAI/kimi-code/pull/1346) [`b9258ee`](https://github.com/MoonshotAI/kimi-code/commit/b9258ee07d32ff63afe9a2eb40fce6d136548fb2) Thanks [@liruifengv](https://github.com/liruifengv)! - Show compaction summaries in the TUI after compaction. Press Ctrl-O to show or hide the summary. + +- [#1419](https://github.com/MoonshotAI/kimi-code/pull/1419) [`5ea3ec4`](https://github.com/MoonshotAI/kimi-code/commit/5ea3ec489e0a7d66b844c39ee65162fd6a8ed8b1) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the Bash tool card collapsing in height when a multi-line command finishes with short output, and visually separate the command from its output. + +- [#1410](https://github.com/MoonshotAI/kimi-code/pull/1410) [`1c817df`](https://github.com/MoonshotAI/kimi-code/commit/1c817df1e522f438d4392568b64fc039dc867031) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the edit approval preview shown by ctrl+e to include surrounding context lines, matching the summary panel. + +- [#1421](https://github.com/MoonshotAI/kimi-code/pull/1421) [`1de0286`](https://github.com/MoonshotAI/kimi-code/commit/1de028612c80c38cd6fbe4483c123ded57a0a678) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the Edit tool card jumping in height and flickering while its result streams in. + +- [#1389](https://github.com/MoonshotAI/kimi-code/pull/1389) [`ebdffc7`](https://github.com/MoonshotAI/kimi-code/commit/ebdffc7df7b89dcafcf62f5705eafb50bfbaf5ab) Thanks [@sailist](https://github.com/sailist)! - Fix tool calling with Google Gemini models, including Gemini 3 thinking-signature round-trips across turns. + +- [#1393](https://github.com/MoonshotAI/kimi-code/pull/1393) [`4c43935`](https://github.com/MoonshotAI/kimi-code/commit/4c43935e31170140699a54cba631792872628655) Thanks [@justjavac](https://github.com/justjavac)! - web: Show the correct session search shortcut on Windows. + +- [#1406](https://github.com/MoonshotAI/kimi-code/pull/1406) [`ce41f4b`](https://github.com/MoonshotAI/kimi-code/commit/ce41f4b58d128ae47b0312eab24a845bbc0d08a3) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the collapsed sidebar not hiding and squeezing the conversation layout. + +- [#1413](https://github.com/MoonshotAI/kimi-code/pull/1413) [`913d042`](https://github.com/MoonshotAI/kimi-code/commit/913d042208b4bfe45dc13144c4797dba1cac5d05) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the input box shifting upward after the slash command menu closes. + +- [#1433](https://github.com/MoonshotAI/kimi-code/pull/1433) [`ac5b5e4`](https://github.com/MoonshotAI/kimi-code/commit/ac5b5e4cbfdb050817c9fce7e08dd3bdd8ea354e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix tool components jumping the conversation when expanded or collapsed. + +- [#1394](https://github.com/MoonshotAI/kimi-code/pull/1394) [`e95fc83`](https://github.com/MoonshotAI/kimi-code/commit/e95fc83cc295dccf3e4c748fba087530dba614b6) Thanks [@justjavac](https://github.com/justjavac)! - web: Fix the font size setting so chat text, composer text, and sidebar text follow the selected size. + +- [#1411](https://github.com/MoonshotAI/kimi-code/pull/1411) [`e6e6dd5`](https://github.com/MoonshotAI/kimi-code/commit/e6e6dd53ce9106f47684534a91acb1a803d1ab07) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Stop the chat history from replaying its entrance animation every time a session is opened. + +- [#1409](https://github.com/MoonshotAI/kimi-code/pull/1409) [`578f7d3`](https://github.com/MoonshotAI/kimi-code/commit/578f7d334c7919e5987229c157060b1daae30139) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the end of a reply staying missing after reopening a session. + +- [#1390](https://github.com/MoonshotAI/kimi-code/pull/1390) [`083d0ca`](https://github.com/MoonshotAI/kimi-code/commit/083d0caf0524ed9cc7978007cd0f342f6bd2917e) Thanks [@sailist](https://github.com/sailist)! - Fix sessions that exist on disk but were missing from the session list or returned 404 on direct access, by rebuilding the session index at server startup and keeping it consistent. + +- [#1357](https://github.com/MoonshotAI/kimi-code/pull/1357) [`be7c991`](https://github.com/MoonshotAI/kimi-code/commit/be7c9916b019b19e057301c39bc7944fcac09414) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix queued media messages not loading back into the composer and keep attachments when undoing a message. + +- [#1428](https://github.com/MoonshotAI/kimi-code/pull/1428) [`903e8ed`](https://github.com/MoonshotAI/kimi-code/commit/903e8ed93afc5b35d0fa1d33c86da2b3fae9ba9f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add an Archived sessions entry to the mobile settings sheet and clarify the archive confirmation to mention restoring from Settings. + +- [#1391](https://github.com/MoonshotAI/kimi-code/pull/1391) [`c5c6282`](https://github.com/MoonshotAI/kimi-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Render AskUserQuestion answers as a readable option list with the chosen option(s) highlighted, instead of raw JSON. + +- [#1423](https://github.com/MoonshotAI/kimi-code/pull/1423) [`fa6d198`](https://github.com/MoonshotAI/kimi-code/commit/fa6d198b0174ad76aa4ca3c0ea2ed45e099e521b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix an almost-invisible composer input caret and a washed-out strikethrough on completed todos. + +- [#1436](https://github.com/MoonshotAI/kimi-code/pull/1436) [`a5fbcb7`](https://github.com/MoonshotAI/kimi-code/commit/a5fbcb75b4b3ab937536a7a2f621c0374812c753) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Keep the composer toolbar from clipping its controls on narrow windows and phones, with the context ring staying visible at every width. + +- [#1426](https://github.com/MoonshotAI/kimi-code/pull/1426) [`2374bc4`](https://github.com/MoonshotAI/kimi-code/commit/2374bc41c35adc1d2e2b5116559946c8de1b98a8) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Show scheduled-reminder (cron) fires as notice cards in the chat instead of hiding them. + +- [#1391](https://github.com/MoonshotAI/kimi-code/pull/1391) [`c5c6282`](https://github.com/MoonshotAI/kimi-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Align the markdown diff code block with the design system: code text keeps the normal ink colour while the sign and a soft row background carry the change, matching the ~/diff panel. + +- [#1434](https://github.com/MoonshotAI/kimi-code/pull/1434) [`4aacddc`](https://github.com/MoonshotAI/kimi-code/commit/4aacddc43222d0a44f202360462617788ca75660) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Show the Kimi icon and clearer titles in web desktop notifications. + +- [#1392](https://github.com/MoonshotAI/kimi-code/pull/1392) [`4963c90`](https://github.com/MoonshotAI/kimi-code/commit/4963c9016fa19d1e01f8dc938c8d250afec87965) Thanks [@sailist](https://github.com/sailist)! - web: Show available skills in the composer before a session is created. + +- [#1438](https://github.com/MoonshotAI/kimi-code/pull/1438) [`d86fa38`](https://github.com/MoonshotAI/kimi-code/commit/d86fa38e119c5834fff13a67194efe8d62c117e1) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Prevent chat text from hyphenating at line breaks and render code without font ligatures. + +- [#1391](https://github.com/MoonshotAI/kimi-code/pull/1391) [`c5c6282`](https://github.com/MoonshotAI/kimi-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Drop the stray left indent in the tool-call card body so expanded content aligns with the header. + +## 0.22.3 + +### Patch Changes + +- [#1367](https://github.com/MoonshotAI/kimi-code/pull/1367) [`23daf0f`](https://github.com/MoonshotAI/kimi-code/commit/23daf0f3c199b4aaa9bd9388a2903d7827f98d32) - Revert the recent TUI transcript rendering changes to the original upstream behavior and fix related rendering issues. + +- [#1343](https://github.com/MoonshotAI/kimi-code/pull/1343) [`ec758c7`](https://github.com/MoonshotAI/kimi-code/commit/ec758c747a95555847b8a0275ed0809010c7d5e7) - Add click-to-enlarge for images uploaded in the web chat. Click an image in a message to open it. + +- [#1343](https://github.com/MoonshotAI/kimi-code/pull/1343) [`ec758c7`](https://github.com/MoonshotAI/kimi-code/commit/ec758c747a95555847b8a0275ed0809010c7d5e7) - Fix uploaded videos failing to play in the web chat. + +- [#1371](https://github.com/MoonshotAI/kimi-code/pull/1371) [`5394fea`](https://github.com/MoonshotAI/kimi-code/commit/5394feaabb5d373fab046b3986b10a1180b4991d) - Wait for background subagents to finish and respond to their results before exiting in `kimi -p`, instead of ending the turn early. + +- [#1373](https://github.com/MoonshotAI/kimi-code/pull/1373) [`e715b16`](https://github.com/MoonshotAI/kimi-code/commit/e715b1648c57bd0863edf859cb67db0327b7bb94) - Add `--dangerous-bypass-auth` and `--keep-alive` flags to `kimi server run`, so the server can run without a token on trusted networks and stay alive past the idle timeout. + +- [#1344](https://github.com/MoonshotAI/kimi-code/pull/1344) [`26b9022`](https://github.com/MoonshotAI/kimi-code/commit/26b90225d21bd18f4f7e3b775f3f7f49034afad9) - Add a segmented thinking-level control in the web model picker for models that support multiple reasoning efforts. Open the composer model menu to choose a level. + +## 0.22.2 + +### Patch Changes + +- [#1353](https://github.com/MoonshotAI/kimi-code/pull/1353) [`68ad686`](https://github.com/MoonshotAI/kimi-code/commit/68ad686211760eb1c3e6b5c23eb28ace9009c17f) - Fix duplicated transcript content appearing in scrollback during streaming. + +- [#1340](https://github.com/MoonshotAI/kimi-code/pull/1340) [`e2fe62a`](https://github.com/MoonshotAI/kimi-code/commit/e2fe62a5eff124b816a0f656355fc5bea85e3893) - Fix sessions silently dropping later user messages after a turn was interrupted between a tool call and its result. + +- [#1342](https://github.com/MoonshotAI/kimi-code/pull/1342) [`84d8d5b`](https://github.com/MoonshotAI/kimi-code/commit/84d8d5b06399d29a9d8caba701835061c30a4817) - Have context-compaction notes capture a forward plan for the remaining work — upcoming steps, settled decisions, and foreseeable obstacles — instead of only the immediate next step, so the agent continues more coherently after auto-compaction. + +- [#1340](https://github.com/MoonshotAI/kimi-code/pull/1340) [`e2fe62a`](https://github.com/MoonshotAI/kimi-code/commit/e2fe62a5eff124b816a0f656355fc5bea85e3893) - Fix requests being rejected by strict providers when the model emits duplicate tool call ids. + +- [#1339](https://github.com/MoonshotAI/kimi-code/pull/1339) [`021786f`](https://github.com/MoonshotAI/kimi-code/commit/021786f5a201df5466a963d4d1ac915b3977582b) - Enrich PATH from the user's login shell at startup, so shell commands find user-installed tools (e.g. Homebrew's `gh`) even when kimi-code was launched without the full profile PATH. + +- [#1336](https://github.com/MoonshotAI/kimi-code/pull/1336) [`4c1d0a1`](https://github.com/MoonshotAI/kimi-code/commit/4c1d0a1633c98ae5703addbf86ffe50b81545c08) - Keep automatic background updates from flashing a console window on Windows. + +- [#1332](https://github.com/MoonshotAI/kimi-code/pull/1332) [`93f16c3`](https://github.com/MoonshotAI/kimi-code/commit/93f16c32d71d974f30c3ea3b1134691936ac5f53) - Fix `kimi upgrade` failing on Windows with a spawn error when installing the new version. + +- [#1348](https://github.com/MoonshotAI/kimi-code/pull/1348) [`175b95f`](https://github.com/MoonshotAI/kimi-code/commit/175b95f3af684f7c5447967b9fe7c8a58b6ffe1b) - Fix compressed-image prompts leaking an internal `` compression note into the visible message and the session title. + +- [#1338](https://github.com/MoonshotAI/kimi-code/pull/1338) [`276407d`](https://github.com/MoonshotAI/kimi-code/commit/276407d2a46b03ce32cce02b73c5d485b1b02b17) - Promote the language-matching rule to a dedicated section in the system prompt, so replies and reasoning consistently follow the user's language through long English tool output, while repository artifacts keep project conventions. + +- [#1347](https://github.com/MoonshotAI/kimi-code/pull/1347) [`02da587`](https://github.com/MoonshotAI/kimi-code/commit/02da5877953ce082826ba5ab1a1abd914d82b24a) - In `kimi -p` runs, wait for background subagents to finish before exiting when `background.keep_alive_on_exit` is enabled. Set `keep_alive_on_exit = true` to let concurrent background subagents complete. + +- [#1349](https://github.com/MoonshotAI/kimi-code/pull/1349) [`e9db9ca`](https://github.com/MoonshotAI/kimi-code/commit/e9db9cafcf7a0d26122b2cac247d866d7724fd7a) - Record model response ids in session wire logs to make individual model requests easier to trace. + +- [#1345](https://github.com/MoonshotAI/kimi-code/pull/1345) [`3ed22e3`](https://github.com/MoonshotAI/kimi-code/commit/3ed22e35a4ee09ce353e699406c6c994423ff39f) - Keep subagent cards at a stable height and show a live status spinner with a compact two-row activity window. + +- [#1305](https://github.com/MoonshotAI/kimi-code/pull/1305) [`9091627`](https://github.com/MoonshotAI/kimi-code/commit/909162725770700efd3051f4cfa68156d9b84fa8) - Add a TUI preference to keep rapid multi-line pastes from submitting line by line when bracketed paste is unavailable. Set `disable_paste_burst = true` in `tui.toml` to turn it off. + +- [#1328](https://github.com/MoonshotAI/kimi-code/pull/1328) [`01b65bd`](https://github.com/MoonshotAI/kimi-code/commit/01b65bdddc28c7c492096000103687f6a507e353) - Rebuild the web design-system easter egg as an in-app overlay that uses the app's real design tokens, so it stays in sync instead of drifting as a separate copy. + +## 0.22.1 + +### Patch Changes + +- [#1304](https://github.com/MoonshotAI/kimi-code/pull/1304) [`0fc0ae3`](https://github.com/MoonshotAI/kimi-code/commit/0fc0ae380b09aa96aad0eff1ae66f239e061d01a) - When large images are compressed, tell the model the original and delivered image details. Keep the original image available, and support cropped or full-resolution reads for fine details. + +- [#1315](https://github.com/MoonshotAI/kimi-code/pull/1315) [`b40bb71`](https://github.com/MoonshotAI/kimi-code/commit/b40bb7139939eb2ba734ce5dd4871b894d7033e8) - Fix TUI rendering bugs that caused the screen to go blank and the input box to disappear. + +- [#1303](https://github.com/MoonshotAI/kimi-code/pull/1303) [`2639786`](https://github.com/MoonshotAI/kimi-code/commit/2639786ce578f15c020a2c11c344797dae18de61) - Fix the TUI crashing when the terminal is resized to a very narrow width while the input contains CJK or emoji text. + +- [#1315](https://github.com/MoonshotAI/kimi-code/pull/1315) [`b40bb71`](https://github.com/MoonshotAI/kimi-code/commit/b40bb7139939eb2ba734ce5dd4871b894d7033e8) - Clear the screen fully when starting a new session via /new, /clear, or a session switch. + +- [#1301](https://github.com/MoonshotAI/kimi-code/pull/1301) [`c3653a1`](https://github.com/MoonshotAI/kimi-code/commit/c3653a1c50ffa3856484599e132980628eb9fca4) - Show an up arrow on the web composer send button. + +- [#1290](https://github.com/MoonshotAI/kimi-code/pull/1290) [`3ea84a5`](https://github.com/MoonshotAI/kimi-code/commit/3ea84a56e4dfdeaddd58add5b269be0342f3f986) - Fix the session search dialog showing a horizontal scrollbar for long session titles or snippets. + +- [#1316](https://github.com/MoonshotAI/kimi-code/pull/1316) [`5322c63`](https://github.com/MoonshotAI/kimi-code/commit/5322c638895a934c1ce220fefed54f5077d2a49e) - Fix web tooltips that could get stuck on screen when their trigger element is removed while open. + +- [#1319](https://github.com/MoonshotAI/kimi-code/pull/1319) [`e8ab7ca`](https://github.com/MoonshotAI/kimi-code/commit/e8ab7ca78661de7f00a8196444be1db93e7c14b4) - Fix the sidebar session row shifting its title and status badges when hovered. + +- [#1293](https://github.com/MoonshotAI/kimi-code/pull/1293) [`6a469b3`](https://github.com/MoonshotAI/kimi-code/commit/6a469b3e07022e56b29b1fd8a7c58df36b2111fe) - Refresh the web UI icon set and unify the message copy and undo button hover states and tooltips. + +- [#1311](https://github.com/MoonshotAI/kimi-code/pull/1311) [`b40649b`](https://github.com/MoonshotAI/kimi-code/commit/b40649b2ae7a4b6a0aea04e32eba200555393064) - Remove duplicate newline-shortcut handling from the prompt editor. + +- [#1317](https://github.com/MoonshotAI/kimi-code/pull/1317) [`78a058a`](https://github.com/MoonshotAI/kimi-code/commit/78a058acd2fc91de5cca0c1d66d415ee35884889) - Remove the experimental micro compaction feature and its toggle from the experiments panel. + +- [#1283](https://github.com/MoonshotAI/kimi-code/pull/1283) [`ea55911`](https://github.com/MoonshotAI/kimi-code/commit/ea55911062eefcb0414cfddb84c8a4494c45f363) - Improve compaction handoff summaries for more reliable resumed sessions. They now keep the latest intent, key tool results, decisions, open questions, and context to re-check. + +- [#1295](https://github.com/MoonshotAI/kimi-code/pull/1295) [`77eb3a9`](https://github.com/MoonshotAI/kimi-code/commit/77eb3a9fe40c93fa32e335f07160b8128355bab6) - Save shell commands to input history and recall them in bash mode. Press Up on an empty `!` prompt to browse previous shell commands. + +- [#1316](https://github.com/MoonshotAI/kimi-code/pull/1316) [`5322c63`](https://github.com/MoonshotAI/kimi-code/commit/5322c638895a934c1ce220fefed54f5077d2a49e) - Trim redundant and incorrect tooltips in the web UI. + +- [#1320](https://github.com/MoonshotAI/kimi-code/pull/1320) [`444e6b1`](https://github.com/MoonshotAI/kimi-code/commit/444e6b15f0e53b6c4d75d1bfdc0b35639dce6f4c) - Fix the web UI becoming sluggish after opening many sessions. + +- [#1322](https://github.com/MoonshotAI/kimi-code/pull/1322) [`5441ad1`](https://github.com/MoonshotAI/kimi-code/commit/5441ad1838a5cfa1f3df0ca2ee1524e1433fb513) - Let the web sidebar collapse an expanded workspace session list back to its first page. + +## 0.22.0 + +### Minor Changes + +- [#1243](https://github.com/MoonshotAI/kimi-code/pull/1243) [`ace7901`](https://github.com/MoonshotAI/kimi-code/commit/ace79010669d19ad175bc25443b6efb41ca2e2ac) - Automatically compress oversized images before they reach the model. Whatever the source — pasted into the CLI, uploaded from the web/desktop client, sent over ACP, read via `ReadMediaFile`, or returned by an MCP tool — images are downsampled (longest edge ≤ 2000px) and re-encoded to fit a per-image byte budget, cutting vision-token cost and avoiding provider image-size errors. Screenshots stay lossless PNG and only degrade to JPEG when the byte budget cannot otherwise be met. Compression runs as an input-stage step at each ingestion point (while the content part is built), and guards against decompression bombs by skipping absurdly large pixel/byte payloads before decoding. Best-effort: if it fails for any reason the original image is sent unchanged. + +- [#1262](https://github.com/MoonshotAI/kimi-code/pull/1262) [`c070fbe`](https://github.com/MoonshotAI/kimi-code/commit/c070fbeddeb1c147d8859a76046f9465f696c9cb) - Add model alias overrides so manual thinking effort levels and model metadata survive provider catalog refreshes. Set them under `[models."".overrides]`. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Refresh the web UI with a new design system, including updated colors, typography, spacing, light and dark palettes, restyled tooltips, and subtle enter/exit and expand/collapse animations. + +### Patch Changes + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show draft pull requests with a distinct draft status instead of displaying them as open. + +- [#1254](https://github.com/MoonshotAI/kimi-code/pull/1254) [`7859b0a`](https://github.com/MoonshotAI/kimi-code/commit/7859b0afe8898852806e5a0c21b9dd52cb82f834) - Fix the transcript jumping to the top when scrolling up through history during streaming output. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix plan, swarm, and goal modes being shared across sessions in the web UI; each session now keeps its own toggles. + +- [#1264](https://github.com/MoonshotAI/kimi-code/pull/1264) [`003733c`](https://github.com/MoonshotAI/kimi-code/commit/003733c751584ce30d8ebae4f5e608f0df049d32) - Hide the unsupported Off option in the /model thinking switcher for always-on models that already expose multiple effort levels. + +- [#1272](https://github.com/MoonshotAI/kimi-code/pull/1272) [`54703d9`](https://github.com/MoonshotAI/kimi-code/commit/54703d9457dcda7bc782301fc2dbb41a2c8d7293) - Release pasted images and streaming timers once they are no longer shown, so memory stops growing in long sessions. + +- [#1272](https://github.com/MoonshotAI/kimi-code/pull/1272) [`54703d9`](https://github.com/MoonshotAI/kimi-code/commit/54703d9457dcda7bc782301fc2dbb41a2c8d7293) - Fix the terminal being left in raw mode with a hidden cursor and disabled flow control after a crash or abrupt exit. + +- [#1265](https://github.com/MoonshotAI/kimi-code/pull/1265) [`8cfb165`](https://github.com/MoonshotAI/kimi-code/commit/8cfb1657ad7bf525269df4ab6cf5c12aa1d406a9) - Reduce the default TUI transcript window to keep long sessions responsive. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Replace the Explore and Native theme options with a single chat layout and a Blue or Black accent-color setting. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show time, duration, connection, and stack details in web error and warning toasts. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix an active workspace showing only its five most recent sessions on load, so it now keeps loading older sessions from the last 12 hours. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Reduce the web composer's default height for a more compact empty state, and fix ArrowUp recalling the previous message while editing a multi-line draft; ArrowUp now recalls only from the very start of the text and is disabled in the expanded editor. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix the Thinking-by-default setting not taking effect, so new sessions correctly start with thinking enabled. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix spurious errors from the web question, approval, and task actions when the action was already complete, and add loading feedback so each click is acknowledged immediately. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show queued prompts inline below the running turn in the web chat, and split Stop into its own button so Send no longer interrupts. + +- [#1278](https://github.com/MoonshotAI/kimi-code/pull/1278) [`bbda90a`](https://github.com/MoonshotAI/kimi-code/commit/bbda90af846ca66232158d2e9605d3d59a7e3a49) - Hide the conversation outline when there is not enough room to expand its labels, so it no longer clips against the window edge. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show the conversation outline as one entry per user query that expands into a labeled list on hover. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Remove the fade-out animation when undoing a message in the web chat. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Improve session search with a Cmd/Ctrl+K palette that filters by title, workspace, and last prompt with highlighted matches. Press Cmd+K or Ctrl+K to open it. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Group consecutive tool calls into a collapsible stack with per-tool renderers, including diff line-count chips for edits and inline previews for image, video, and audio results. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Use one consistent modal dialog for confirmations in the web UI (archive session, delete workspace, delete provider, undo message, and mode toggles). + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Add workspace sorting by manual order or last-edited time, plus collapse-all and expand-all controls, to the sidebar. + +## 0.21.1 + +### Patch Changes + +- [#1256](https://github.com/MoonshotAI/kimi-code/pull/1256) [`0cc02ac`](https://github.com/MoonshotAI/kimi-code/commit/0cc02ac67d465d1d4d7fe070422bab17053cdaa3) - Keep the waiting spinner visible while encrypted reasoning streams, fixing a blank spinner-less gap before the first response text appears. + +## 0.21.0 + +### Minor Changes + +- [#1204](https://github.com/MoonshotAI/kimi-code/pull/1204) [`5cb80ce`](https://github.com/MoonshotAI/kimi-code/commit/5cb80ce879406d239048c32d61202778cb860e58) - Plugins can now provide slash commands via a `commands` field in their manifest, registered as `:` and invoked with `$ARGUMENTS` expansion. + +- [#1214](https://github.com/MoonshotAI/kimi-code/pull/1214) [`86e0c92`](https://github.com/MoonshotAI/kimi-code/commit/86e0c9201ed58c7c1ce5543b1dfb47a4cf5117f6) - Rework conversation compaction: + + - Keep only recent user prompts plus a single user-role summary; drop assistant and tool messages. + - Repair tool_use/tool_result adjacency before sending, fixing a strict-provider HTTP 400 when a tool call and its result became non-adjacent. + - Merge consecutive user turns for strict providers (Gemini/Vertex), fixing an HTTP 400 ("roles must alternate") after compaction or when a turn is steered in right after a tool result. + - Micro-compaction now defaults off. + +- [#1132](https://github.com/MoonshotAI/kimi-code/pull/1132) [`108299b`](https://github.com/MoonshotAI/kimi-code/commit/108299be3cdffc31a23f64efd3ff5ba50976b412) - Refactor the thinking effort system + +### Patch Changes + +- [#1231](https://github.com/MoonshotAI/kimi-code/pull/1231) [`ceb27f5`](https://github.com/MoonshotAI/kimi-code/commit/ceb27f5e449e177493f320d90e292487a8fc3410) - Add a server-side key-value store API for persisting web UI preferences to the user's data directory. + +- [#1220](https://github.com/MoonshotAI/kimi-code/pull/1220) [`ec51324`](https://github.com/MoonshotAI/kimi-code/commit/ec51324230484f2ebaad1ab0aebf2e38f531d914) - Add a double-Esc shortcut to open the undo selector. Press Esc twice while idle to undo. + +- [#1223](https://github.com/MoonshotAI/kimi-code/pull/1223) [`80e6888`](https://github.com/MoonshotAI/kimi-code/commit/80e6888e34e4362247c0eac5b77340df014ba286) - Fix @ file mentions not opening when typed inside a slash command argument. + +- [#1233](https://github.com/MoonshotAI/kimi-code/pull/1233) [`020992c`](https://github.com/MoonshotAI/kimi-code/commit/020992c286f0f6bff6a038a7c7bd7e9db639e3c9) - Force-exit headless runs (`kimi -p`) so a stray ref'd handle left over from the run can't keep a completed run alive until an external timeout, and bound prompt cleanup so a wedged shutdown step can't hang shutdown. + +- [#1225](https://github.com/MoonshotAI/kimi-code/pull/1225) [`659062d`](https://github.com/MoonshotAI/kimi-code/commit/659062d11cc272fe631fc6d4faf64d0e0b1a0142) - Show file path completions when typing `/` in shell mode (`!`). + +- [#1236](https://github.com/MoonshotAI/kimi-code/pull/1236) [`bfe8e6a`](https://github.com/MoonshotAI/kimi-code/commit/bfe8e6ace3cda76b1991bf29c25b9444611d5512) - Fix adding a workspace by path in the web UI failing silently when the daemon rejects the path; it now shows an error instead of a broken workspace. + +- [#1221](https://github.com/MoonshotAI/kimi-code/pull/1221) [`a3f9cec`](https://github.com/MoonshotAI/kimi-code/commit/a3f9cec8a975f11e37e992e42f954789ed394207) - Fix duplicate workspaces showing in the web sidebar when the same folder is registered more than once. + +- [#1241](https://github.com/MoonshotAI/kimi-code/pull/1241) [`8ac337a`](https://github.com/MoonshotAI/kimi-code/commit/8ac337a2b2ac800aa79a373459308abb6c9e63bb) - Stop a malformed message history from permanently bricking a session on strict providers (Anthropic). The request is repaired before sending — orphaned tool calls are closed and empty/whitespace-only text blocks dropped — and if the provider still rejects its structure, it is resent once with a wire-compliant rebuild. + +- [#1228](https://github.com/MoonshotAI/kimi-code/pull/1228) [`42e37eb`](https://github.com/MoonshotAI/kimi-code/commit/42e37eb898b722829d2ec83e909525ff18e336a5) - Split LLM streaming timing in the session log and `KIMI_CODE_DEBUG=1` output into client vs. API-server portions, so slow turns can be attributed without parsing the wire log. Time-to-first-token splits into the API-server portion (network + server) and the client portion (in-process request building); the decode window splits into time awaiting tokens from the server and time the client spends processing each streamed chunk. + +- [#1234](https://github.com/MoonshotAI/kimi-code/pull/1234) [`882cf35`](https://github.com/MoonshotAI/kimi-code/commit/882cf355a9cb45bb5b3424a27b953bde8e106bb0) - Hide the provider management dialog in the web UI until the server supports it. + +- [#1226](https://github.com/MoonshotAI/kimi-code/pull/1226) [`7f05f58`](https://github.com/MoonshotAI/kimi-code/commit/7f05f589e7bc77a2f26463a41317ff7087e3c3a0) - Add Mermaid diagram rendering to the web chat. Fenced `mermaid` blocks in assistant responses now render as diagrams. KaTeX math and Mermaid diagram parsing also run in Web Workers to keep the UI responsive during live streaming. + +- [#1232](https://github.com/MoonshotAI/kimi-code/pull/1232) [`aa6b0d0`](https://github.com/MoonshotAI/kimi-code/commit/aa6b0d065ee888056c3812781483ddb74739897f) - Always show the usage-data opt-out toggle in the web settings with a clearer label and description. + +- [#1234](https://github.com/MoonshotAI/kimi-code/pull/1234) [`882cf35`](https://github.com/MoonshotAI/kimi-code/commit/882cf355a9cb45bb5b3424a27b953bde8e106bb0) - Fix the web workspace rename not persisting after a page refresh. + +## 0.20.3 + +### Patch Changes + +- [#1207](https://github.com/MoonshotAI/kimi-code/pull/1207) [`14d9e98`](https://github.com/MoonshotAI/kimi-code/commit/14d9e98903f30f83199e30b5fa20b3c61ab28781) - Refresh provider model lists automatically in the background instead of only at startup, so newly available models appear without restarting. + +- [#1191](https://github.com/MoonshotAI/kimi-code/pull/1191) [`0df1812`](https://github.com/MoonshotAI/kimi-code/commit/0df18125022103dabb149b4f26f90959b669187b) - Fix provider error messages rendering as blank lines in the TUI when the server returns an HTML error page. + +- [#1212](https://github.com/MoonshotAI/kimi-code/pull/1212) [`636ccc4`](https://github.com/MoonshotAI/kimi-code/commit/636ccc40f19f259bdd6653b2ca563a75b3548e23) - Fix the web composer being hidden behind the mobile Safari toolbar and the page auto-zooming when the composer is focused. + +- [#1068](https://github.com/MoonshotAI/kimi-code/pull/1068) [`c82dcf9`](https://github.com/MoonshotAI/kimi-code/commit/c82dcf9cd8276eddf6acbf1030d1712b83a38083) - Glob now uses ripgrep, so it respects .gitignore by default, supports brace patterns, returns only files, and keeps partial results with a warning when some directories are unreadable. + +- [#1209](https://github.com/MoonshotAI/kimi-code/pull/1209) [`0635387`](https://github.com/MoonshotAI/kimi-code/commit/063538744f64a1bd3da6f37ebd0643d10bfc068f) - Align malformed tool call argument handling with schema validation fallback. + +## 0.20.2 + +### Patch Changes + +- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Add an optional exclude_empty parameter to the session list API to omit sessions that have no messages. + +- [#1156](https://github.com/MoonshotAI/kimi-code/pull/1156) [`794db55`](https://github.com/MoonshotAI/kimi-code/commit/794db55538e01b4bf0c008c493de5d8b8bf67c5d) - Cap compaction output at 128k tokens by default to avoid provider max_tokens errors. + +- [#1129](https://github.com/MoonshotAI/kimi-code/pull/1129) [`d02b5c4`](https://github.com/MoonshotAI/kimi-code/commit/d02b5c49844d65e005632fafcb1c172a7d32bfbe) - Fix compaction ignoring the configured max output size. + +- [#1188](https://github.com/MoonshotAI/kimi-code/pull/1188) [`db5fbc5`](https://github.com/MoonshotAI/kimi-code/commit/db5fbc53c00c9945fc1fa98c69c4e5c7efb8077e) - Fix unnecessary full-screen redraws when typing in the input box or toggling the slash panel. + +- [#1187](https://github.com/MoonshotAI/kimi-code/pull/1187) [`97f9263`](https://github.com/MoonshotAI/kimi-code/commit/97f9263c6f13ead5edc051f96993f8d1d7d5ec6f) - Fix debug timing output lingering after undoing a turn. + +- [#1163](https://github.com/MoonshotAI/kimi-code/pull/1163) [`ff6e8bb`](https://github.com/MoonshotAI/kimi-code/commit/ff6e8bbd7c328dcc6575902cfd0cb3e522f20948) - Fix the web composer occasionally keeping typed text after sending the first message of a new session. + +- [#1189](https://github.com/MoonshotAI/kimi-code/pull/1189) [`04b3492`](https://github.com/MoonshotAI/kimi-code/commit/04b3492e740dad5fca2af9f66eca98da3e14058a) - Fix working tips getting squeezed against the agent swarm progress bar. + +- [#1159](https://github.com/MoonshotAI/kimi-code/pull/1159) [`23a553b`](https://github.com/MoonshotAI/kimi-code/commit/23a553bb91e9ee794aaf769f78f5acec739aec85) - In the bundled web UI, `/new` and `/clear` are now aliases that open the session onboarding composer and focus the input. iOS auto-zoom is prevented by keeping text inputs at 16px instead of disabling viewport scaling. + +- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add `KIMI_CODE_CUSTOM_HEADERS` for custom outbound LLM request headers and send the `User-Agent` header to non-Kimi providers. Set `KIMI_CODE_CUSTOM_HEADERS` to newline-separated `Name: Value` lines. + +- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Route managed Kimi Code models on the Anthropic-compatible protocol through the beta Messages API. + +- [#1170](https://github.com/MoonshotAI/kimi-code/pull/1170) [`cf558cd`](https://github.com/MoonshotAI/kimi-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Recover from provider 413 context overflows by compacting before retrying. + +- [#1170](https://github.com/MoonshotAI/kimi-code/pull/1170) [`cf558cd`](https://github.com/MoonshotAI/kimi-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Support the Anthropic-compatible protocol for managed Kimi Code, including video input. + +- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add provider type and protocol attributes to turn and API error telemetry. + +- [#1155](https://github.com/MoonshotAI/kimi-code/pull/1155) [`54baf5d`](https://github.com/MoonshotAI/kimi-code/commit/54baf5d07fe718b70b8840e509a905ac48b1ccac) - Upgrade web markdown renderer dependencies (katex, markstream-vue, shiki) for bug fixes and performance improvements. + +- [#1162](https://github.com/MoonshotAI/kimi-code/pull/1162) [`b070846`](https://github.com/MoonshotAI/kimi-code/commit/b0708464f4160f7b73f25a520e493bf87e92149f) - Rework the web ask-user-question card into a step-by-step wizard so multi-question navigation and the final Submit action are easier to see. + +- [#1179](https://github.com/MoonshotAI/kimi-code/pull/1179) [`fc3d69d`](https://github.com/MoonshotAI/kimi-code/commit/fc3d69dbdc965e525b5486a6b91e4ec44194ca97) - Add a completion sound and question notifications to the web UI, with separate Settings toggles for completion notifications, question notifications, and sound. Question notifications default off so question text only reaches your desktop after you opt in. + +- [#1165](https://github.com/MoonshotAI/kimi-code/pull/1165) [`f3b1532`](https://github.com/MoonshotAI/kimi-code/commit/f3b15322da518b0e3d0560d19651435793c790d9) - Replace the web composer attach button's plus icon with an image icon. + +- [#1167](https://github.com/MoonshotAI/kimi-code/pull/1167) [`c63edd5`](https://github.com/MoonshotAI/kimi-code/commit/c63edd5bf6d764c3ab771cb697a334ac100a0944) - In the bundled web UI, a new session is now created only when the first message is sent, so + New without a workspace opens the composer instead of making an empty session. + +- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Hide unused "New Session" entries from the web session list by default. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Restore each session's scroll position when switching back to it in the web UI. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep the open side panel when switching between sessions in the web UI. + +- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Remove the /sessions slash command from the web UI; the sidebar already covers session browsing. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep unsent composer attachments scoped to their session in the web UI, so switching sessions no longer leaks them into another session's next message. + +- [#1161](https://github.com/MoonshotAI/kimi-code/pull/1161) [`d968642`](https://github.com/MoonshotAI/kimi-code/commit/d968642384f672295756394ee07a536dbfdb4dfd) - Show the first five sessions per workspace in the web sidebar instead of ten. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Scope the web composer's up/down input history to the current session instead of sharing it across all sessions. + +## 0.20.1 + +### Patch Changes + +- [#1125](https://github.com/MoonshotAI/kimi-code/pull/1125) [`e9a3b7c`](https://github.com/MoonshotAI/kimi-code/commit/e9a3b7c83a623c7323da509ba885567c465093fc) - Add an `update` alias for the `kimi upgrade` command. Run `kimi update` to upgrade to the latest version. + +- [#1122](https://github.com/MoonshotAI/kimi-code/pull/1122) [`820d77a`](https://github.com/MoonshotAI/kimi-code/commit/820d77ab4cfad7752358a4692fd3d7def49f005d) - Show the done / in progress / pending breakdown of hidden todos in the collapsed todo panel. + +- [#1131](https://github.com/MoonshotAI/kimi-code/pull/1131) [`76c643b`](https://github.com/MoonshotAI/kimi-code/commit/76c643bcb6da447c8c47728b4f58512a7a11cfa6) - Cap completion tokens to the remaining context window for chat-completions providers, avoiding context-overflow and invalid max_tokens errors. + +- [#1120](https://github.com/MoonshotAI/kimi-code/pull/1120) [`e736349`](https://github.com/MoonshotAI/kimi-code/commit/e736349a7c8ff55b73e05cc0192dfaf0114745fa) - Add optional feedback attachments for diagnostic logs and codebase context. + +- [#1135](https://github.com/MoonshotAI/kimi-code/pull/1135) [`bf51fb7`](https://github.com/MoonshotAI/kimi-code/commit/bf51fb7a105b2f34a59ed4e83d2588e790cfb086) - Fix the local server failing to start on Windows after the first run because the persistent token file's synthesized mode was rejected as too permissive. + +- [#1102](https://github.com/MoonshotAI/kimi-code/pull/1102) [`9c97161`](https://github.com/MoonshotAI/kimi-code/commit/9c9716125e104b217540d0591229d03c6d676ead) - Harden the default system prompt and built-in tool descriptions: stop the agent from blocking on background tasks it should let run, keep its guidance matched to the tools each profile actually provides, and surface tool-result details (fetched-page mode, Grep match totals) it previously missed. + +- [#1127](https://github.com/MoonshotAI/kimi-code/pull/1127) [`184acf5`](https://github.com/MoonshotAI/kimi-code/commit/184acf5db521a964a8af9dfdb1502121a9be76dc) - Plugins can now declare hooks in their manifest to run scripts on lifecycle events. + +- [#1128](https://github.com/MoonshotAI/kimi-code/pull/1128) [`0886bff`](https://github.com/MoonshotAI/kimi-code/commit/0886bff2bcd3aed954990c948201d84787c0f3f3) - Add a --allowed-host flag to kimi server run that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host to allow an extra host. + +- [#1119](https://github.com/MoonshotAI/kimi-code/pull/1119) [`b0b2aee`](https://github.com/MoonshotAI/kimi-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep the terminal responsive in long conversations by caching rendered message lines. + +- [#1119](https://github.com/MoonshotAI/kimi-code/pull/1119) [`b0b2aee`](https://github.com/MoonshotAI/kimi-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep long sessions responsive by retaining only recent turns in the transcript and collapsing older steps within each turn. + +- [#1121](https://github.com/MoonshotAI/kimi-code/pull/1121) [`81ba48f`](https://github.com/MoonshotAI/kimi-code/commit/81ba48f45534e133947c4e5e78907c2ad0db0b90) - Make the web chat input grow with its content and add an expandable editor for longer messages. + +- [#1133](https://github.com/MoonshotAI/kimi-code/pull/1133) [`f1c8175`](https://github.com/MoonshotAI/kimi-code/commit/f1c8175f9c5766f6a928fd07fb680e3159c564b0) - Fix the /web slash command not carrying the server token, so the opened web UI signs in automatically and the token is shown before the terminal exits. + +## 0.20.0 + +### Minor Changes + +- [#1079](https://github.com/MoonshotAI/kimi-code/pull/1079) [`2db5fc2`](https://github.com/MoonshotAI/kimi-code/commit/2db5fc20ecdf3212afd47e7c26195e428f8eddd5) - Add shell mode for running shell commands. + Type `!` in the input box to enable it. + The command output is visible to the AI. + For long-running commands, press Ctrl+B to move them to the background. + For example, you can run `!gh auth login` to sign in to the GitHub CLI without opening a new terminal, so Kimi can use `gh`. + +- [#1088](https://github.com/MoonshotAI/kimi-code/pull/1088) [`0030f76`](https://github.com/MoonshotAI/kimi-code/commit/0030f76c5cc6465c5a6646c166375127d83696d3) - Add a confirmation prompt before installing third-party plugins. + +- [#1066](https://github.com/MoonshotAI/kimi-code/pull/1066) [`3554f7e`](https://github.com/MoonshotAI/kimi-code/commit/3554f7e7d6e472413aa7a9873d7a2eef5f2b819c) - Show update badges on the /plugins Installed tab, where Enter now installs the available update and I opens plugin details. + +- [#1025](https://github.com/MoonshotAI/kimi-code/pull/1025) [`5ef66dd`](https://github.com/MoonshotAI/kimi-code/commit/5ef66ddfeda2f23c40fc0cf53225cdaf3cc1147d) - Redesign `/plugins` as a single tabbed panel: **Installed** (manage installed + plugins — toggle, remove, MCP, details, reload), **Official** (Kimi-maintained + marketplace plugins), **Third-party** (marketplace plugins from other + publishers), and **Custom** (install straight from a GitHub URL, zip URL, or + local path). `Tab` / `Shift-Tab` switch tabs. The Official and Third-party + catalogs load lazily, so `/plugins` opens instantly and keeps working offline — + a marketplace fetch failure is shown inline instead of closing the panel. The + tab strip is shared with the `/model` provider tabs via the new `renderTabStrip` + helper. + +- [#1006](https://github.com/MoonshotAI/kimi-code/pull/1006) [`60dfb68`](https://github.com/MoonshotAI/kimi-code/commit/60dfb68a2d4c342cfbad5f48d4d269fb6cdd43c0) - Add server authentication and safe `--host` exposure. The local server now + requires a per-start bearer token on all API and WebSocket calls (the CLI reads + it automatically), enforces Host/Origin checks, and gains `--host` with a + public-binding hardening tier: mandatory `KIMI_CODE_PASSWORD`, TLS (or + `--insecure-no-tls`), auth-failure rate limiting, disabled remote + shutdown/terminals, and security response headers. See `packages/server/SECURITY.md`. + +- [#1040](https://github.com/MoonshotAI/kimi-code/pull/1040) [`6664038`](https://github.com/MoonshotAI/kimi-code/commit/66640380ebf60141994986beadf5347617f82814) - Replace silent AGENTS.md truncation with a visible warning in the TUI status bar and web UI. + +- [#1101](https://github.com/MoonshotAI/kimi-code/pull/1101) [`3ea6ac2`](https://github.com/MoonshotAI/kimi-code/commit/3ea6ac278d2e57bb859ab423704bbd0fb2033c72) - Show the plan body and approach choices in the plan review card when exiting plan mode in the web UI. + +- [#1103](https://github.com/MoonshotAI/kimi-code/pull/1103) [`18f7c34`](https://github.com/MoonshotAI/kimi-code/commit/18f7c34a0739dab454af1f09d951a1bbf278cccb) - Show a line-by-line diff when the agent edits or writes a file in the web chat. + +### Patch Changes + +- [#1072](https://github.com/MoonshotAI/kimi-code/pull/1072) [`a86bb97`](https://github.com/MoonshotAI/kimi-code/commit/a86bb9757d99f32983e82a6a82fd3ccaab691b1a) - Improve the image paste hint. + +- [#1076](https://github.com/MoonshotAI/kimi-code/pull/1076) [`500677a`](https://github.com/MoonshotAI/kimi-code/commit/500677ab8baf9081b73a35df5fbbcfc49cb2f9b7) - Fix Ctrl-C during compaction so it clears a pending editor draft first instead of cancelling immediately. + +- [#1067](https://github.com/MoonshotAI/kimi-code/pull/1067) [`0e227ba`](https://github.com/MoonshotAI/kimi-code/commit/0e227ba18aec793aa4c233be7c578068ae91e604) - Fix explore subagents silently losing git context when git commands time out or the directory is not a repository. + +- [#1075](https://github.com/MoonshotAI/kimi-code/pull/1075) [`3aaf1e5`](https://github.com/MoonshotAI/kimi-code/commit/3aaf1e58037c4045aaa3b9fbabaffa158c60d2ca) - Fix a startup crash on Linux caused by an unhandled native clipboard error. + +- [#1094](https://github.com/MoonshotAI/kimi-code/pull/1094) [`8ee5c0f`](https://github.com/MoonshotAI/kimi-code/commit/8ee5c0ff813d361733226a1606e7c724e5e38f2e) - Fix the terminal window repeatedly losing focus on Linux Wayland, which broke IME input. + +- [#1057](https://github.com/MoonshotAI/kimi-code/pull/1057) [`ee69e16`](https://github.com/MoonshotAI/kimi-code/commit/ee69e16dc8fb18153d7ddff04bef1f4fc593688a) - Fix MCP server working directories when sessions are hosted by the web server. + +- [#1064](https://github.com/MoonshotAI/kimi-code/pull/1064) [`a752a53`](https://github.com/MoonshotAI/kimi-code/commit/a752a5309b3c456f7da0e6141bcd435b497d127a) - Fix truncated skill descriptions missing an ellipsis in the model's skill listing. + +- [#903](https://github.com/MoonshotAI/kimi-code/pull/903) [`bbd8a1a`](https://github.com/MoonshotAI/kimi-code/commit/bbd8a1a947ba26c0e59f98819cab9e20898ff0b7) - Fix `kimi web` and `/web` failing to start the background server daemon on Windows with `spawn EFTYPE` when the CLI is installed via npm/pnpm or run from source. The official single-binary install script was not affected. + +- [#1070](https://github.com/MoonshotAI/kimi-code/pull/1070) [`ff17715`](https://github.com/MoonshotAI/kimi-code/commit/ff177155ca630248bcd692421faab21e7b5be069) - Stop auto-dismissing questions in the web UI after 60 seconds so they wait for the user's answer. + +- [#1097](https://github.com/MoonshotAI/kimi-code/pull/1097) [`27ef516`](https://github.com/MoonshotAI/kimi-code/commit/27ef5166955b5deaecc367a4b3393909b0ccc9f9) - Add a hint to the per-turn step limit error pointing users to the loop_control.max_steps_per_turn config option. + +- [#1062](https://github.com/MoonshotAI/kimi-code/pull/1062) [`ea6a4bf`](https://github.com/MoonshotAI/kimi-code/commit/ea6a4bfe6ef8914f67f254f24b0c5c543c48a341) - Preserve full tool output logs when previews are truncated and link background task completion notifications to saved output. + +- [#1086](https://github.com/MoonshotAI/kimi-code/pull/1086) [`fe667d7`](https://github.com/MoonshotAI/kimi-code/commit/fe667d7c2ef113aef8a9546148f980d9adf560a3) - `/reload` now refreshes the assistant's view of plugin skills, so plugin changes take effect in the current session instead of requiring a new one. + +- [#1081](https://github.com/MoonshotAI/kimi-code/pull/1081) [`8fc6aa5`](https://github.com/MoonshotAI/kimi-code/commit/8fc6aa5f6842aa78acf8f23912342b721efcf7a9) - Sync session title changes across all connected clients in server mode. + +- [#1078](https://github.com/MoonshotAI/kimi-code/pull/1078) [`75ca3b2`](https://github.com/MoonshotAI/kimi-code/commit/75ca3b21609d7197bb2c9b4389901595840ac7e3) - Add Ctrl+U and Ctrl+D as page up and page down shortcuts in the task output viewer. + +- [#1069](https://github.com/MoonshotAI/kimi-code/pull/1069) [`d18aa16`](https://github.com/MoonshotAI/kimi-code/commit/d18aa1666a09b038d5a107e9a37fff1031b2e847) - Reduce streaming redraw cost for long assistant messages with code blocks. + +- [#1112](https://github.com/MoonshotAI/kimi-code/pull/1112) [`6a97d0b`](https://github.com/MoonshotAI/kimi-code/commit/6a97d0bf431bc7038ce801da21164a67e07422d8) - Add a copy button to user messages in the web chat. + +- [#1035](https://github.com/MoonshotAI/kimi-code/pull/1035) [`ea03f30`](https://github.com/MoonshotAI/kimi-code/commit/ea03f30e5174825049ed4dfedebf8e43fbe751a4) - Render LaTeX display math (`$$…$$`) in the web chat via KaTeX. Single `$` is intentionally left as literal text, so prices, env vars, and shell paths (e.g. `$PATH`, `$5/$10`, `$HOME/bin`) are never swallowed as a formula. + +- [#1084](https://github.com/MoonshotAI/kimi-code/pull/1084) [`d6e5246`](https://github.com/MoonshotAI/kimi-code/commit/d6e524682d9fb95460fceb86e17632ed858f7fcb) - Page the web session list per workspace so the first screen no longer fetches every session up front. + +- [#1113](https://github.com/MoonshotAI/kimi-code/pull/1113) [`6194d3f`](https://github.com/MoonshotAI/kimi-code/commit/6194d3fad3b53e6c2b80c422fe98043145494655) - Keep the web session sidebar from re-rendering on every streaming token. The + event reducer now reuses the `sessions` array reference for events that do not + change sessions, so the sidebar computeds (`sessionsForView` / `workspaceGroups` + / `mergedWorkspaces`) are no longer dirtied by unrelated high-frequency events. + +- [#1087](https://github.com/MoonshotAI/kimi-code/pull/1087) [`884b65a`](https://github.com/MoonshotAI/kimi-code/commit/884b65a04014be8d68ffd406f89fc2d26af6e62c) - Fix duplicate session snapshot reloads in the bundled web UI during resync. + +- [#1109](https://github.com/MoonshotAI/kimi-code/pull/1109) [`d554f9a`](https://github.com/MoonshotAI/kimi-code/commit/d554f9ac8771be09b5c9a56943167dd45108dc4f) - Show the full accumulated progress of a subagent in its detail panel, with concise tool-call summaries instead of raw JSON. + +- [#1065](https://github.com/MoonshotAI/kimi-code/pull/1065) [`4b837d6`](https://github.com/MoonshotAI/kimi-code/commit/4b837d6bfbf3850807b5f88ccdd10f31e69b019c) - Create missing parent directories automatically when writing a file. + +## 0.19.2 + +### Patch Changes + +- [#999](https://github.com/MoonshotAI/kimi-code/pull/999) [`6b68aa8`](https://github.com/MoonshotAI/kimi-code/commit/6b68aa85e2a58cfdaacba5580f66a6a74550ccf6) - Add `-c` as a shorthand for `--continue`. + +- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Show a transient footer hint when an image is detected in the clipboard, displaying the platform-appropriate paste shortcut. + +- [#1004](https://github.com/MoonshotAI/kimi-code/pull/1004) [`d70c3a8`](https://github.com/MoonshotAI/kimi-code/commit/d70c3a8c0121f55e5f29f9a2ad01b17df449467a) - Show the command in running Bash tool cards and allow expanding it with Ctrl+O before the result arrives. + +- [#1009](https://github.com/MoonshotAI/kimi-code/pull/1009) [`e47de61`](https://github.com/MoonshotAI/kimi-code/commit/e47de610e4de9b11ccd182c0c16387f9d3fb0de4) - Add a Ctrl+T shortcut to expand and collapse a truncated todo list. + +- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Fix stale rows occasionally leaving duplicate input boxes after tall content shrinks. + +- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Fix inline images being rendered as broken escape sequences in the transcript. + +- [#1027](https://github.com/MoonshotAI/kimi-code/pull/1027) [`c240bfa`](https://github.com/MoonshotAI/kimi-code/commit/c240bfab7d2b00d41b993f681be612f2db45baa7) - Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. The `/messages` wire transcript reducer mirrors the same closure so its folded length stays aligned with live history, preventing the later turn from being duplicated/reordered. Replay also drops a tool result whose call is no longer awaiting one, so a stale interrupted result left at the log tail by an older resume of a damaged session is not re-applied as a duplicate. + +- [#1012](https://github.com/MoonshotAI/kimi-code/pull/1012) [`fd16ffb`](https://github.com/MoonshotAI/kimi-code/commit/fd16ffb80a90fda8a611a27a158b9b7a33e13303) - Show subcommand suggestions after Tab-completing a slash command name. + +- [#1012](https://github.com/MoonshotAI/kimi-code/pull/1012) [`fd16ffb`](https://github.com/MoonshotAI/kimi-code/commit/fd16ffb80a90fda8a611a27a158b9b7a33e13303) - Fix the Tab key unexpectedly opening the file completion list. + +- [#1044](https://github.com/MoonshotAI/kimi-code/pull/1044) [`9d197e0`](https://github.com/MoonshotAI/kimi-code/commit/9d197e0f67c879306b9d7659d66e9295e63faa5a) - Fix clipboard copy actions in the web UI when served over plain HTTP. + +- [#1032](https://github.com/MoonshotAI/kimi-code/pull/1032) [`a753b05`](https://github.com/MoonshotAI/kimi-code/commit/a753b0535e44f624289715bd560cf1346149e786) - Fix code blocks nested inside list items rendering blank in the web chat after a turn finishes generating. + +- [#1015](https://github.com/MoonshotAI/kimi-code/pull/1015) [`83384ee`](https://github.com/MoonshotAI/kimi-code/commit/83384ee6d46b37c00b7b8f160a7c48aebbd6921e) - Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. Slash commands are now recorded too — both typed-and-submitted and ones picked from the slash menu — so they can be recalled like plain messages. + +- [#1003](https://github.com/MoonshotAI/kimi-code/pull/1003) [`e15edfd`](https://github.com/MoonshotAI/kimi-code/commit/e15edfd017506fde396b8b0dcf68008b61b39752) - Fix the web question prompt missing the free-text Other option. + +- [#1056](https://github.com/MoonshotAI/kimi-code/pull/1056) [`b93e936`](https://github.com/MoonshotAI/kimi-code/commit/b93e9365b68d53f8f1a148e7349a5865b1b669a8) - Fix yolo mode in the web app auto-approving plan reviews and sensitive file access. + +- [#971](https://github.com/MoonshotAI/kimi-code/pull/971) [`b84704b`](https://github.com/MoonshotAI/kimi-code/commit/b84704bff39ae5cb382d2a8dc0911db286e84ead) - Read large text files in bounded memory and read tail lines without scanning whole files. + +- [#1020](https://github.com/MoonshotAI/kimi-code/pull/1020) [`9c553e4`](https://github.com/MoonshotAI/kimi-code/commit/9c553e4bf7d0a2c09030212fe06577343ea76a60) - Add an Alt+S shortcut in the model picker to switch the model for the current session only, without saving it as the default. + +- [#1043](https://github.com/MoonshotAI/kimi-code/pull/1043) [`27df39c`](https://github.com/MoonshotAI/kimi-code/commit/27df39c7ed2b012815c380a33fe56bd37c7fc7c1) - Fix web chat stop actions so stale prompt ids fall back to cancelling the active session. + +- [#1036](https://github.com/MoonshotAI/kimi-code/pull/1036) [`866b91c`](https://github.com/MoonshotAI/kimi-code/commit/866b91c8f5dc98dfc18e5c658beaa11afea5032e) - Reorganize the web app's components into area subdirectories (chat/settings/dialogs/mobile) and refresh the component path comments. + +- [#1042](https://github.com/MoonshotAI/kimi-code/pull/1042) [`dc6b9ef`](https://github.com/MoonshotAI/kimi-code/commit/dc6b9ef02bf7583d166c8c5b001a960329c225f8) - Add a development-mode indicator to the web sidebar for local development. + +- [#1047](https://github.com/MoonshotAI/kimi-code/pull/1047) [`98d3e5b`](https://github.com/MoonshotAI/kimi-code/commit/98d3e5b71d5760475f7a5a23b2b794584d12b89b) - Keep the web sidebar's workspace order stable and let workspaces be reordered by drag-and-drop, persisted locally instead of following recent activity; sessions now also float to the top of their group as soon as a new message arrives. + +- [#1034](https://github.com/MoonshotAI/kimi-code/pull/1034) [`603a767`](https://github.com/MoonshotAI/kimi-code/commit/603a7679de91e221802a7f7b0ab7df23c7e5526c) - Extract the composer's image/video attachment handling into a reusable composable. + +- [#1031](https://github.com/MoonshotAI/kimi-code/pull/1031) [`2bfd686`](https://github.com/MoonshotAI/kimi-code/commit/2bfd6860e487f902be53fd5f52f03e66d1839ae2) - Extract the composer's text state and per-session draft persistence into a reusable composable. + +- [#1011](https://github.com/MoonshotAI/kimi-code/pull/1011) [`fb780fc`](https://github.com/MoonshotAI/kimi-code/commit/fb780fce9665e2119cee6d0bc7f85895c6970865) - Extract the composer's shell-style input-history recall into a reusable composable. + +- [#1030](https://github.com/MoonshotAI/kimi-code/pull/1030) [`661c1fb`](https://github.com/MoonshotAI/kimi-code/commit/661c1fbe5b026ec32d80696290a18313b24eafef) - Extract the composer's @-mention menu logic into a reusable composable. + +- [#1026](https://github.com/MoonshotAI/kimi-code/pull/1026) [`318c964`](https://github.com/MoonshotAI/kimi-code/commit/318c964f074123ad228cbddcf7809fa4baaa7fb2) - Extract the composer's slash-command menu logic into a reusable composable. + +- [#1045](https://github.com/MoonshotAI/kimi-code/pull/1045) [`ac1882f`](https://github.com/MoonshotAI/kimi-code/commit/ac1882fe28c906904ffaacd8434bb20e689d6677) - Persist the collapsed state of workspace groups in the web sidebar across page reloads. + +- [#1001](https://github.com/MoonshotAI/kimi-code/pull/1001) [`ea1b33b`](https://github.com/MoonshotAI/kimi-code/commit/ea1b33b6743b822aa5083dbeb2d5e84a78b0ab3d) - Extract pure turn-rendering helpers out of the chat pane into their own module. + +- [#1010](https://github.com/MoonshotAI/kimi-code/pull/1010) [`a2650f8`](https://github.com/MoonshotAI/kimi-code/commit/a2650f85d467707e7c85d22cff590f68852d33f3) - Extract the beta conversation outline (table of contents) into its own component. + +- [#998](https://github.com/MoonshotAI/kimi-code/pull/998) [`3e4793d`](https://github.com/MoonshotAI/kimi-code/commit/3e4793d6111059cbfb97159f682ed4bd7a33441d) - Extract the workspace group rendering out of the sidebar into its own component. + +- [#985](https://github.com/MoonshotAI/kimi-code/pull/985) [`92c2cf0`](https://github.com/MoonshotAI/kimi-code/commit/92c2cf0ef57f00928d337bcfeb1d7eff9b0d0f7f) - Allow the web sidebar and detail panel to be resized up to the available viewport width, keeping their resize handles reachable on narrow windows. + +- [#1033](https://github.com/MoonshotAI/kimi-code/pull/1033) [`b1e6b64`](https://github.com/MoonshotAI/kimi-code/commit/b1e6b6431903fde002fdddbdfcabfab39f3ef5c5) - Optimize the loading tips display. + +## 0.19.1 + +### Patch Changes + +- [#992](https://github.com/MoonshotAI/kimi-code/pull/992) [`7341fb4`](https://github.com/MoonshotAI/kimi-code/commit/7341fb4979523d4429ccf9177b5e3907f544d8c0) - Fix ACP editors such as Zed failing to start a new thread. + +- [#984](https://github.com/MoonshotAI/kimi-code/pull/984) [`da81858`](https://github.com/MoonshotAI/kimi-code/commit/da81858802127cb8bb8ed2deaa1989793b356adf) - Clear all per-session state when a session is archived or removed, so archived sessions no longer leave orphaned data behind. + +- [#978](https://github.com/MoonshotAI/kimi-code/pull/978) [`d4ae02d`](https://github.com/MoonshotAI/kimi-code/commit/d4ae02d82e9da0d163ea4235a54d6535c591172e) - Fix the web sidebar's unread dots getting out of sync across browser tabs. + +- [#979](https://github.com/MoonshotAI/kimi-code/pull/979) [`8c6cade`](https://github.com/MoonshotAI/kimi-code/commit/8c6cade69efa42fdcc280f51a283ea6f717d62fc) - Consolidate web client localStorage access and split the root state store and app shell into focused composables. + +## 0.19.0 + +### Minor Changes + +- [#812](https://github.com/MoonshotAI/kimi-code/pull/812) [`c0eeca2`](https://github.com/MoonshotAI/kimi-code/commit/c0eeca24692edd736eecd3c2541d7566bac9f80f) - Added the ability to add extra workspace directories: + + - Use the `/add-dir ` command to add extra working directories to the current session, or remember them for the project. + - Use `kimi --add-dir ` to add them on startup. + - Project-level local config is now managed in `.kimi-code/local.toml`; we recommend adding it to your `.gitignore`. + +- [#975](https://github.com/MoonshotAI/kimi-code/pull/975) [`c5c1834`](https://github.com/MoonshotAI/kimi-code/commit/c5c18347251221fab74e4f452ac4910116c4224d) - Speed up session snapshot loading with a direct disk reader and a request timeout safeguard, keeping the previous path as a legacy fallback. + +### Patch Changes + +- [#910](https://github.com/MoonshotAI/kimi-code/pull/910) [`7644f10`](https://github.com/MoonshotAI/kimi-code/commit/7644f1036ca1079e4527c0b1c825ec5384d6d8da) - Fix provider requests failing when restored conversation history contains empty text content blocks. + +- [#963](https://github.com/MoonshotAI/kimi-code/pull/963) [`4292ae9`](https://github.com/MoonshotAI/kimi-code/commit/4292ae9f9bc49e9edaaaeae50dbddabbd4b9bb25) - Surface provider safety-policy blocks instead of silently treating them as completed turns, and prevent the context token count from dropping to zero after a filtered response. + +- [#970](https://github.com/MoonshotAI/kimi-code/pull/970) [`2730079`](https://github.com/MoonshotAI/kimi-code/commit/27300797f2149900219b05dda49dce65e71fa85a) - Detect the real image format from file contents when reading media, so a mismatched filename extension no longer produces a data URL the model API rejects. + +- [#977](https://github.com/MoonshotAI/kimi-code/pull/977) [`d521932`](https://github.com/MoonshotAI/kimi-code/commit/d521932c3e99a0c5fa1d5d658cf1cd64f0306a75) - Stop showing unread dots on cancelled or failed sessions in the web sidebar. + +- [#957](https://github.com/MoonshotAI/kimi-code/pull/957) [`b57fc90`](https://github.com/MoonshotAI/kimi-code/commit/b57fc905fe480aac07839dd0213768dbeb2a8002) - Fix commands flashing an empty console window on Windows. + +- [#821](https://github.com/MoonshotAI/kimi-code/pull/821) [`ba64072`](https://github.com/MoonshotAI/kimi-code/commit/ba64072559c1e9bb3447ede39991ac2e8bdb7645) - Allow long-running foreground commands and subagents to be moved into background tasks with Ctrl+B, and inspect them via the `/tasks` panel. + +- [#812](https://github.com/MoonshotAI/kimi-code/pull/812) [`c0eeca2`](https://github.com/MoonshotAI/kimi-code/commit/c0eeca24692edd736eecd3c2541d7566bac9f80f) - Polish file mention UX. + +- [#974](https://github.com/MoonshotAI/kimi-code/pull/974) [`d434d8f`](https://github.com/MoonshotAI/kimi-code/commit/d434d8f0d809599f4ae7de77b58e337bfd4ebcc9) - Unify image format detection when sniffing fails. + +- [#958](https://github.com/MoonshotAI/kimi-code/pull/958) [`98905eb`](https://github.com/MoonshotAI/kimi-code/commit/98905eb409ec643fd916a13beecec85212f834bd) - Show longer branch names in the web chat header and expose the full name on hover. + +- [#964](https://github.com/MoonshotAI/kimi-code/pull/964) [`4223739`](https://github.com/MoonshotAI/kimi-code/commit/42237392ddc3a0816c045da23e77c4875cc692e5) - Keep the web page title fixed instead of changing with the session or workspace name. + +- [#973](https://github.com/MoonshotAI/kimi-code/pull/973) [`3b9938b`](https://github.com/MoonshotAI/kimi-code/commit/3b9938b4c3a386394ed4d35c7b89b48878476977) - Consolidate web client localStorage access and decouple appearance/notification state into dedicated modules. + +## 0.18.0 + +### Minor Changes + +- [#888](https://github.com/MoonshotAI/kimi-code/pull/888) [`58898de`](https://github.com/MoonshotAI/kimi-code/commit/58898de0200d6626ca634e344fe85b860abcfd1b) - Add an environment variable to cap AgentSwarm concurrency during the initial ramp, so large swarms do not trip provider rate limits as easily. + +- [#895](https://github.com/MoonshotAI/kimi-code/pull/895) [`495fe8c`](https://github.com/MoonshotAI/kimi-code/commit/495fe8c674d654cdf87217ca4ada775507f861f6) - Add instant session search to the web sidebar, filtering by title and the last user prompt. + +### Patch Changes + +- [#896](https://github.com/MoonshotAI/kimi-code/pull/896) [`de610de`](https://github.com/MoonshotAI/kimi-code/commit/de610deb5f760606b82cc595e59c5176cc66ce82) - Fix the web workspace session count so it drops to 0 after archiving the last session instead of staying at 1. + +- [#876](https://github.com/MoonshotAI/kimi-code/pull/876) [`49183d8`](https://github.com/MoonshotAI/kimi-code/commit/49183d8729e3e7d361a253dc5c68f409e6382ba9) - Suggest `/reload` alongside `/new` in plugin-change hints. + +- [#867](https://github.com/MoonshotAI/kimi-code/pull/867) [`d1dc2a3`](https://github.com/MoonshotAI/kimi-code/commit/d1dc2a3e77ec1422d60cb008c5520a44a2ed7c00) - Redesign the web OAuth login dialog: lead with a single "Authorize in browser" button that opens the verification link with the device code already embedded, demote manual code entry to a clearly secondary fallback, and drop the duplicate open-browser and cancel controls so the order of steps is unambiguous. + +- [#867](https://github.com/MoonshotAI/kimi-code/pull/867) [`d1dc2a3`](https://github.com/MoonshotAI/kimi-code/commit/d1dc2a3e77ec1422d60cb008c5520a44a2ed7c00) - Fix the web login slash command description to match the browser authorization flow. + +- [#893](https://github.com/MoonshotAI/kimi-code/pull/893) [`d7ec056`](https://github.com/MoonshotAI/kimi-code/commit/d7ec05686a09580f9ffd99f6ef26385aed8eb02c) - Add scroll-up lazy loading for older messages in the web chat session view, and fix the "new messages" pill overlapping the composer dock. + +- [#882](https://github.com/MoonshotAI/kimi-code/pull/882) [`8ab9e96`](https://github.com/MoonshotAI/kimi-code/commit/8ab9e969637ffee18b09a0b265ffa860c5a2e11c) - Fix the web app only loading the 20 most recent sessions; it now follows pagination so older sessions are reachable. + +- [#889](https://github.com/MoonshotAI/kimi-code/pull/889) [`23277a5`](https://github.com/MoonshotAI/kimi-code/commit/23277a574c7e0782c04f62e10370494247be3a66) - Show the connected server version in the web settings General tab. + +- [#881](https://github.com/MoonshotAI/kimi-code/pull/881) [`7bc3d99`](https://github.com/MoonshotAI/kimi-code/commit/7bc3d99933b0bbc3f9188a2b02bcc90e81623f72) - Keep the highlighted web slash command visible while navigating a long slash menu. + +- [#878](https://github.com/MoonshotAI/kimi-code/pull/878) [`a74a6b7`](https://github.com/MoonshotAI/kimi-code/commit/a74a6b7f6b1d13d24eae356a2208c012128b180d) - Allow long web slash command names and descriptions to wrap without overflowing the slash menu. + +- [#878](https://github.com/MoonshotAI/kimi-code/pull/878) [`a74a6b7`](https://github.com/MoonshotAI/kimi-code/commit/a74a6b7f6b1d13d24eae356a2208c012128b180d) - Fix web slash skill selection sending immediately and allow slash search to match skill names by substring. + +## 0.17.1 + +### Patch Changes + +- [#861](https://github.com/MoonshotAI/kimi-code/pull/861) [`bd09795`](https://github.com/MoonshotAI/kimi-code/commit/bd0979578bcad5fe3bf989e022b7823824f3f25c) - Prevent the web login dialog from closing when clicking the backdrop. + +- [#860](https://github.com/MoonshotAI/kimi-code/pull/860) [`0e2877b`](https://github.com/MoonshotAI/kimi-code/commit/0e2877bee347466ed6cc8afda9f9faf338069012) - Stop the background local server from locking the directory it was started in. + +- [#860](https://github.com/MoonshotAI/kimi-code/pull/860) [`0e2877b`](https://github.com/MoonshotAI/kimi-code/commit/0e2877bee347466ed6cc8afda9f9faf338069012) - Fix the local server failing to start in the background on the native binary. + +- [#861](https://github.com/MoonshotAI/kimi-code/pull/861) [`bd09795`](https://github.com/MoonshotAI/kimi-code/commit/bd0979578bcad5fe3bf989e022b7823824f3f25c) - Group the default model dropdown in web settings by provider. + +## 0.17.0 + +### Minor Changes + +- [#625](https://github.com/MoonshotAI/kimi-code/pull/625) [`9a8fea5`](https://github.com/MoonshotAI/kimi-code/commit/9a8fea5c85177cd887896108c05ba9e174f28250) - Add the server-hosted web UI and the CLI commands that power it: + + - `kimi server` to start, stop, and manage the local server. + - `kimi web` to open the server-hosted web UI in a browser. + - Server REST and WebSocket APIs for the web client. + - Web chat layout, session list, auto-scroll, and related behaviors. + +### Patch Changes + +- [#838](https://github.com/MoonshotAI/kimi-code/pull/838) [`843a731`](https://github.com/MoonshotAI/kimi-code/commit/843a731097fc18b2e41ab0405b5fbcb6149ba55c) - Show the underlying connection error when OAuth token refresh fails after internal retries, instead of prompting for login. Token refresh failures are no longer re-retried at the agent loop level. + +- [#849](https://github.com/MoonshotAI/kimi-code/pull/849) [`254f946`](https://github.com/MoonshotAI/kimi-code/commit/254f946a506b01df7a559ed63bd8d705e9fa7496) - Skip debug TPS when the output stream is too short to measure reliably. + +- [#833](https://github.com/MoonshotAI/kimi-code/pull/833) [`a71b2e3`](https://github.com/MoonshotAI/kimi-code/commit/a71b2e3123ff8454f725b3d24e8c985608c5c4f9) - Restore the turn counter from persisted loop events on resume so post-resume turns no longer reuse turn ids that already appear in history. + +- [#853](https://github.com/MoonshotAI/kimi-code/pull/853) [`05fe759`](https://github.com/MoonshotAI/kimi-code/commit/05fe7595ab9bac8230fd9f2fe7bdbaaa157ddc9b) - Fix the web login page and no-workspace conversation startup flow. + +## 0.16.0 + +### Minor Changes + +- [#788](https://github.com/MoonshotAI/kimi-code/pull/788) [`efdf8a1`](https://github.com/MoonshotAI/kimi-code/commit/efdf8a1b2d4e906fbb35620083c3e7b490e0e88a) - Add a built-in `kimi vis` command that launches the session visualizer in your browser, pointed at your local sessions. Supports `--port`/`--host`, `--no-open`, and `kimi vis ` deep-links. + +### Patch Changes + +- [#790](https://github.com/MoonshotAI/kimi-code/pull/790) [`d0d5821`](https://github.com/MoonshotAI/kimi-code/commit/d0d58219007cd9d7355f1ea8900e9777b66abda2) - Stop Anthropic-compatible providers from reading ambient Anthropic shell credentials and custom headers. + +- [#809](https://github.com/MoonshotAI/kimi-code/pull/809) [`6f442bd`](https://github.com/MoonshotAI/kimi-code/commit/6f442bd8cde29e21526fa36c9836e2d4c282b4bf) - Add configurable banner display frequencies with local display state. + +- [#807](https://github.com/MoonshotAI/kimi-code/pull/807) [`b45672c`](https://github.com/MoonshotAI/kimi-code/commit/b45672cdaac9959024c3ae36bf35b16a423aa1dc) - Close wrapped output streams when buffered readers are destroyed. + +- [#813](https://github.com/MoonshotAI/kimi-code/pull/813) [`7b5b818`](https://github.com/MoonshotAI/kimi-code/commit/7b5b8188157ec902e5cd4e73545bc5ca6c52bb76) - Fix repeated compaction handling when context remains over the blocking threshold. + +- [#801](https://github.com/MoonshotAI/kimi-code/pull/801) [`ff332be`](https://github.com/MoonshotAI/kimi-code/commit/ff332be6d364ce3d5974133deb7c76220684181a) - Polish queue pane styling + +- [#802](https://github.com/MoonshotAI/kimi-code/pull/802) [`aa1896c`](https://github.com/MoonshotAI/kimi-code/commit/aa1896ca749e41a67d7c4b655dcc8be830cbec82) - Reduce the maximum height of the /btw side panel from half to one-third of the terminal. + +- [#805](https://github.com/MoonshotAI/kimi-code/pull/805) [`3e6196e`](https://github.com/MoonshotAI/kimi-code/commit/3e6196e6b227c66860651f4335e06973865b2714) - Project session replay ranges over rendered replay records instead of raw persisted records. + +- [#804](https://github.com/MoonshotAI/kimi-code/pull/804) [`299b9fc`](https://github.com/MoonshotAI/kimi-code/commit/299b9fcad4c9c4b755fae4dfae01a1dbf60aec3c) - Prevent session shutdown from resuming the agent when stopping background tasks. + +- [#823](https://github.com/MoonshotAI/kimi-code/pull/823) [`90fc04b`](https://github.com/MoonshotAI/kimi-code/commit/90fc04b7072ec20055022c50583d35286ca715a6) - Remove redundant LLM request logging context plumbing. + +## 0.15.0 + +### Minor Changes + +- [#779](https://github.com/MoonshotAI/kimi-code/pull/779) [`2746c71`](https://github.com/MoonshotAI/kimi-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Add an all-sessions picker view with name search, paginated browsing, and clipboard-ready resume commands for sessions in other working directories. + +- [#744](https://github.com/MoonshotAI/kimi-code/pull/744) [`18f299f`](https://github.com/MoonshotAI/kimi-code/commit/18f299fd0b266545a1f7cebae9f58b83b9d9776e) - Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports. + +### Patch Changes + +- [#777](https://github.com/MoonshotAI/kimi-code/pull/777) [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Clarify AGENTS.md prompt guidance and mark truncated instruction files. + +- [#780](https://github.com/MoonshotAI/kimi-code/pull/780) [`8a92db6`](https://github.com/MoonshotAI/kimi-code/commit/8a92db6a0c110a21c6e6e86622f498e836178e5f) - Prompt the CLI to show one brief same-language status sentence before non-trivial tool calls. + +- [#786](https://github.com/MoonshotAI/kimi-code/pull/786) [`e10b25f`](https://github.com/MoonshotAI/kimi-code/commit/e10b25f9be18ca64aada0d0a3cab0e02fdbd46df) - Stop writing resume version markers into persisted agent metadata. + +- [#768](https://github.com/MoonshotAI/kimi-code/pull/768) [`c6a9967`](https://github.com/MoonshotAI/kimi-code/commit/c6a996756cd8f1fb317b6eee6f4e668eebc7dc14) - Recover resumed sessions when an interrupted tool call result was not recorded. + +- [#775](https://github.com/MoonshotAI/kimi-code/pull/775) [`3fa1b8e`](https://github.com/MoonshotAI/kimi-code/commit/3fa1b8ea7deb558b88073b5f7b02857e52c3f60c) - Optimize the npm packaging system. + +- [#343](https://github.com/MoonshotAI/kimi-code/pull/343) [`73be7ba`](https://github.com/MoonshotAI/kimi-code/commit/73be7ba17d41df7999d4c1fba410994e7024eb7b) - Repair mismatched JSON Schema types emitted by Xcode 26.5 MCP server for Moonshot compatibility. + +- [#777](https://github.com/MoonshotAI/kimi-code/pull/777) [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Collapse hidden directories in the workspace prompt and explain how to inspect them. + +- [#766](https://github.com/MoonshotAI/kimi-code/pull/766) [`9cef896`](https://github.com/MoonshotAI/kimi-code/commit/9cef89656311974a57e6675f474ea6c2adb1d8e9) - Clarify that compaction summaries must be emitted in the final answer. + +- [#765](https://github.com/MoonshotAI/kimi-code/pull/765) [`046856b`](https://github.com/MoonshotAI/kimi-code/commit/046856b740afb604132e914f1fc489de72394036) - Read media files using header-detected types before falling back to media extensions. + +- [#779](https://github.com/MoonshotAI/kimi-code/pull/779) [`2746c71`](https://github.com/MoonshotAI/kimi-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Show the all-sessions toggle hint when the current working directory has no sessions. + +- [#785](https://github.com/MoonshotAI/kimi-code/pull/785) [`4578f05`](https://github.com/MoonshotAI/kimi-code/commit/4578f05f44101f24d45c6452e2a6993cbb52e331) - Include the skill's directory on the loaded-skill context block so the agent can locate a skill's bundled resources (scripts, templates) after it is invoked. + +- [#784](https://github.com/MoonshotAI/kimi-code/pull/784) [`a562ef5`](https://github.com/MoonshotAI/kimi-code/commit/a562ef54e537a36211c48f0fe19e9252e83397a0) - Decouple agent skill access from session-specific registry implementations. + +- [#772](https://github.com/MoonshotAI/kimi-code/pull/772) [`d47e699`](https://github.com/MoonshotAI/kimi-code/commit/d47e699015f02f4f76723aa8fb17d51a74aa74ff) - Do not carry obsolete legacy loop, background, plan, yolo, or unknown experimental flags into migrated config files. + +- [#783](https://github.com/MoonshotAI/kimi-code/pull/783) [`e2a407c`](https://github.com/MoonshotAI/kimi-code/commit/e2a407ce31685220b2f891a7f6d8b89c62418c98) - Keep TUI components within narrow terminal widths by wrapping, compacting, or truncating lines that could exceed the render width. + +- [#776](https://github.com/MoonshotAI/kimi-code/pull/776) [`ecd7a0a`](https://github.com/MoonshotAI/kimi-code/commit/ecd7a0afb646d14a14c780a4088fd8a59da134ad) - Resolve model capabilities through a static lookup instead of instantiating a temporary provider. + +- [#767](https://github.com/MoonshotAI/kimi-code/pull/767) [`a355f2a`](https://github.com/MoonshotAI/kimi-code/commit/a355f2af2fd68ad9e2bdc72ce854cd18c8242ce8) - Prioritize clearing draft editor text before Ctrl-C cancels an active stream. + +- [#787](https://github.com/MoonshotAI/kimi-code/pull/787) [`1eb363f`](https://github.com/MoonshotAI/kimi-code/commit/1eb363f655aa44abc1e5c3af89016f00764ecc95) - Extend the same-language rule to the model's reasoning, so thinking follows the user's language while keeping code and technical terms in their original form. + +## 0.14.3 + +### Patch Changes + +- [#713](https://github.com/MoonshotAI/kimi-code/pull/713) [`f874251`](https://github.com/MoonshotAI/kimi-code/commit/f874251288927243a9b9d4bfd546e8c17754d566) - Refresh provider model metadata before opening the model picker. + +## 0.14.2 + +### Patch Changes + +- [#683](https://github.com/MoonshotAI/kimi-code/pull/683) [`ad239cb`](https://github.com/MoonshotAI/kimi-code/commit/ad239cb1c08266a442c9ca0382fefed87bcb1fd4) - Allow `--auto`, `--yolo`, and `--plan` to be combined with `--session` or `--continue` by applying the requested mode to the resumed session. + +- [#690](https://github.com/MoonshotAI/kimi-code/pull/690) [`7f0dde2`](https://github.com/MoonshotAI/kimi-code/commit/7f0dde2ece3f9a004e934d69258dfd47c954043c) - Fix endless desktop notifications in iTerm2 by only sending terminal progress sequences to terminals that support them. + +- [#651](https://github.com/MoonshotAI/kimi-code/pull/651) [`c39c625`](https://github.com/MoonshotAI/kimi-code/commit/c39c62590db708fc81bd8627ea661c38f3fff9af) - Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI. + +- [#617](https://github.com/MoonshotAI/kimi-code/pull/617) [`911e7c3`](https://github.com/MoonshotAI/kimi-code/commit/911e7c3fcfc8a005b1b8d90388260d1a4032f76f) - Show completed and cancelled compaction records correctly when resuming a session. + +- [#676](https://github.com/MoonshotAI/kimi-code/pull/676) [`dcf3075`](https://github.com/MoonshotAI/kimi-code/commit/dcf30754d09c7560101bc410387792194c3fe2b4) - Stream foreground Bash stdout and stderr while commands are still running. + +- [#692](https://github.com/MoonshotAI/kimi-code/pull/692) [`7ca9bdf`](https://github.com/MoonshotAI/kimi-code/commit/7ca9bdfed516d148b063229a9686a28f9e29aaef) - Skip re-entering plan mode when resuming a session that is already in plan mode (previously failed with "Already in plan mode"), and stop re-applying `--auto`/`--yolo`/`--plan` startup flags when switching sessions through the `/sessions` picker. + +- [#675](https://github.com/MoonshotAI/kimi-code/pull/675) [`d1ba145`](https://github.com/MoonshotAI/kimi-code/commit/d1ba14562bafdb6b93c3eec1b5c453186507ed56) - Sync custom registry provider additions, removals, and rotated registry keys during startup refresh. + +- [#689](https://github.com/MoonshotAI/kimi-code/pull/689) [`8d251f8`](https://github.com/MoonshotAI/kimi-code/commit/8d251f8ab44ead65f6c1bb264980ee7d075142ad) - Drop invalid config.toml sections with a warning instead of failing to start. + +## 0.14.1 + +### Patch Changes + +- [#643](https://github.com/MoonshotAI/kimi-code/pull/643) [`4e5043b`](https://github.com/MoonshotAI/kimi-code/commit/4e5043b03b2fb03374550dc65d04871bc83e932a) - Require AgentSwarm tool calls to run alone in a model response. + +- [#631](https://github.com/MoonshotAI/kimi-code/pull/631) [`2961425`](https://github.com/MoonshotAI/kimi-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Wrap long command and skill descriptions in the autocomplete menu onto a second line instead of cutting them off. + +- [#661](https://github.com/MoonshotAI/kimi-code/pull/661) [`0927f79`](https://github.com/MoonshotAI/kimi-code/commit/0927f79883e036d0127d4384f60f8e486afb3b8c) - Cancel active turns during session shutdown so foreground shell commands do not outlive prompt-mode exits. + +- [#604](https://github.com/MoonshotAI/kimi-code/pull/604) [`7ec738c`](https://github.com/MoonshotAI/kimi-code/commit/7ec738c4a1de41b3a042cfb48700dfaf51e9de94) - Fix premature stream close errors when shell processes time out or are killed. + +- [#632](https://github.com/MoonshotAI/kimi-code/pull/632) [`d8cdebf`](https://github.com/MoonshotAI/kimi-code/commit/d8cdebf3c03efa3a3dfa4f1deb3186a8f8f7f5ef) - Degrade unsupported audio/video to placeholder text and reattach tool result media instead of silently dropping them. + +- [#628](https://github.com/MoonshotAI/kimi-code/pull/628) [`0ee9106`](https://github.com/MoonshotAI/kimi-code/commit/0ee91066eaa8ec794c8337faefc14d1b1200ce82) - Fix ACP file reads and edits for Windows workspaces opened through IDE clients. + +- [#658](https://github.com/MoonshotAI/kimi-code/pull/658) [`0381329`](https://github.com/MoonshotAI/kimi-code/commit/0381329570d3dca9fd861761c843968cc1c5e927) - Send OpenAI Responses system prompts as request instructions. + +- [#654](https://github.com/MoonshotAI/kimi-code/pull/654) [`ff80327`](https://github.com/MoonshotAI/kimi-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f) - Propagate configured execution environment overrides across spawned processes. + +- [#644](https://github.com/MoonshotAI/kimi-code/pull/644) [`a58b5b2`](https://github.com/MoonshotAI/kimi-code/commit/a58b5b20bb42228c72277daba9fa07bb1cd539a6) - Polish builtin skills. + +- [#649](https://github.com/MoonshotAI/kimi-code/pull/649) [`a2c5e1b`](https://github.com/MoonshotAI/kimi-code/commit/a2c5e1be25484f7c52f729e333196c485f83b84c) - Add runtime support for dynamic MCP server updates, reference skills, replay timestamps, and Node file uploads. + +- [#631](https://github.com/MoonshotAI/kimi-code/pull/631) [`2961425`](https://github.com/MoonshotAI/kimi-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Find slash commands by their aliases in autocomplete — typing `/clear` now suggests `new (clear)`. + +- [#648](https://github.com/MoonshotAI/kimi-code/pull/648) [`54302ad`](https://github.com/MoonshotAI/kimi-code/commit/54302ad612294056a47ada74b76737f2284861b5) - Prevent overlapping interactive agent requests from using the wrong active agent. + +- [#641](https://github.com/MoonshotAI/kimi-code/pull/641) [`30459af`](https://github.com/MoonshotAI/kimi-code/commit/30459af6abc8308e7f13822d9dbef3a5be80dd4a) - Stop background tasks by default when sessions close. + +- [#645](https://github.com/MoonshotAI/kimi-code/pull/645) [`1b58aa8`](https://github.com/MoonshotAI/kimi-code/commit/1b58aa8cdf675e6f4c02cd083feb55debbe9b3f1) - Add a YOLO choice when starting swarm tasks from Manual mode. + +- [#655](https://github.com/MoonshotAI/kimi-code/pull/655) [`1e2e679`](https://github.com/MoonshotAI/kimi-code/commit/1e2e679693af2fc97826078aa671555a3a900349) - Display a tips banner below the welcome panel on startup. + +## 0.14.0 + +### Minor Changes + +- [#607](https://github.com/MoonshotAI/kimi-code/pull/607) [`b253a82`](https://github.com/MoonshotAI/kimi-code/commit/b253a82a7a5f7d91883dc77a30b8b38f8b6e1470) - Add an `Interrupt` hook event that fires when the user interrupts a turn (e.g. pressing Esc), letting hooks observe the turn stopping instead of getting stuck on a working state. + +### Patch Changes + +- [#626](https://github.com/MoonshotAI/kimi-code/pull/626) [`856ec00`](https://github.com/MoonshotAI/kimi-code/commit/856ec002906f4964086915ceb9aa616b89ab6594) - Preserve image outputs from tools when using OpenAI-compatible chat completions. + +## 0.13.1 + +### Patch Changes + +- [#610](https://github.com/MoonshotAI/kimi-code/pull/610) [`b747c6a`](https://github.com/MoonshotAI/kimi-code/commit/b747c6a9501e208250d09cf9a2810c885c6ce91b) - Add Claude Fable 5 support to the Anthropic provider. + +- [#615](https://github.com/MoonshotAI/kimi-code/pull/615) [`494554e`](https://github.com/MoonshotAI/kimi-code/commit/494554eac5d34d6a3c5c36b6fb2b2e5397b07f0c) - Add an interactive undo selector and clearer undo-limit messages. + +- [#598](https://github.com/MoonshotAI/kimi-code/pull/598) [`32d7080`](https://github.com/MoonshotAI/kimi-code/commit/32d708083730c14090f855b1fcb650e2bc713797) - Clarify active skill prompts so loaded skills are no longer represented as system reminders. + +- [#595](https://github.com/MoonshotAI/kimi-code/pull/595) [`1580f35`](https://github.com/MoonshotAI/kimi-code/commit/1580f35136eed02331dcff6c8482247d5cf35458) - Fix Kimi Datasource to use the matching OAuth credentials and service endpoint for the active Kimi Code environment. + +- [#619](https://github.com/MoonshotAI/kimi-code/pull/619) [`1fbe0e4`](https://github.com/MoonshotAI/kimi-code/commit/1fbe0e4ee89241bee6b5b1d5a4a38b6c6de3c5bf) - Fix goal marker text overflowing terminal width. + +- [#612](https://github.com/MoonshotAI/kimi-code/pull/612) [`4603d8a`](https://github.com/MoonshotAI/kimi-code/commit/4603d8ad6e92a303f396f3d79d4e4d212d1c4b14) - Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package. + +- [#540](https://github.com/MoonshotAI/kimi-code/pull/540) [`2ebe387`](https://github.com/MoonshotAI/kimi-code/commit/2ebe38769fc50215a7c94a362cd4e943130e1143) - Tighten file tool guidance to route incremental edits through Edit. + +- [#606](https://github.com/MoonshotAI/kimi-code/pull/606) [`a1b419a`](https://github.com/MoonshotAI/kimi-code/commit/a1b419ab5901d16ab9527eef62bcd468e76b27a3) - YOLO mode no longer asks before writing or editing files outside the working directory. + +## 0.13.0 + +### Minor Changes + +- [#484](https://github.com/MoonshotAI/kimi-code/pull/484) [`f863127`](https://github.com/MoonshotAI/kimi-code/commit/f863127ab7e8b8e2e9af11c54694c08900e3103a) - Add custom color themes. Define your own palette as a JSON file in `~/.kimi-code/themes/`, or generate one with the built-in `/custom-theme` skill command. + +- [#582](https://github.com/MoonshotAI/kimi-code/pull/582) [`d85dc0b`](https://github.com/MoonshotAI/kimi-code/commit/d85dc0b96a3c98c6951b8f6e6fa8b663d4c95360) - Add `/import-from-cc-codex` to import selected Claude Code and Codex instructions, Skills, and MCP settings. + +- [#593](https://github.com/MoonshotAI/kimi-code/pull/593) [`40506f4`](https://github.com/MoonshotAI/kimi-code/commit/40506f49d689aaf3e920c6bc9ae2b91219ee3f7f) - Show available plugin updates in the marketplace. An installed plugin whose marketplace version is newer than the local version now renders an `update ` badge (and updates in place on Enter); up-to-date plugins show `installed · v`. The marketplace `version` served in dev and written by the CDN build is now stamped from each plugin's manifest so "latest" stays accurate. + +### Patch Changes + +- [#587](https://github.com/MoonshotAI/kimi-code/pull/587) [`0abde86`](https://github.com/MoonshotAI/kimi-code/commit/0abde8662a531293fc8faa7cf9089c43ad8d6d76) - Clarify grouped subagent progress with active status breakdowns and elapsed time. + +- [#594](https://github.com/MoonshotAI/kimi-code/pull/594) [`f2863af`](https://github.com/MoonshotAI/kimi-code/commit/f2863af267b2e7d5ff5b99ff80c95c379a5b0272) - Fix device login to keep the URL and code visible when the browser cannot be opened. + +- [#591](https://github.com/MoonshotAI/kimi-code/pull/591) [`e48234a`](https://github.com/MoonshotAI/kimi-code/commit/e48234af576e41e630736450c66b690226707bc3) - Fix Windows builds and development launches that could fail when package binaries resolve to command shims. + +- [#586](https://github.com/MoonshotAI/kimi-code/pull/586) [`7cb4a23`](https://github.com/MoonshotAI/kimi-code/commit/7cb4a23e01dfaf0e049891b90a27b36000714151) - Truncate queued message display to a single line with ellipsis when it exceeds terminal width. + +## 0.12.1 + +### Patch Changes + +- [#584](https://github.com/MoonshotAI/kimi-code/pull/584) [`11bb62c`](https://github.com/MoonshotAI/kimi-code/commit/11bb62c12f38d380a0ca1bb89ee2df67f93300e1) - Allow obsolete experimental config entries to remain without blocking startup. + +- [#581](https://github.com/MoonshotAI/kimi-code/pull/581) [`aa3471f`](https://github.com/MoonshotAI/kimi-code/commit/aa3471f5d3d2960834ba3239c0b8459144bc79fa) - Pass through xhigh reasoning effort for OpenAI-compatible chat completions requests. + +## 0.12.0 + +### Minor Changes + +- [#569](https://github.com/MoonshotAI/kimi-code/pull/569) [`d7407b0`](https://github.com/MoonshotAI/kimi-code/commit/d7407b0ecfc87a3840e26ddaddb69e7f52383699) - Enable micro compaction by default while keeping its opt-out flag. + +- [#531](https://github.com/MoonshotAI/kimi-code/pull/531) [`b47734c`](https://github.com/MoonshotAI/kimi-code/commit/b47734ca0bac84e0b2c4ff50cd3d5eedb9e0c7c1) - Detect Homebrew installations and use `brew upgrade kimi-code` for updates instead of falling back to npm. + +- [#487](https://github.com/MoonshotAI/kimi-code/pull/487) [`4d11394`](https://github.com/MoonshotAI/kimi-code/commit/4d113949c8e906c20c7188817926f44786653923) - Honor the standard `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables, including SOCKS proxies, for all outbound traffic. + +- [#569](https://github.com/MoonshotAI/kimi-code/pull/569) [`d7407b0`](https://github.com/MoonshotAI/kimi-code/commit/d7407b0ecfc87a3840e26ddaddb69e7f52383699) - Make goals, background questions, and sub-skill discovery available without experimental opt-ins. + +- [#424](https://github.com/MoonshotAI/kimi-code/pull/424) [`72c4b0a`](https://github.com/MoonshotAI/kimi-code/commit/72c4b0adaa6ae0466875cd8e4066c42456195f21) - Add the `/swarm` command for running agent swarms with live progress and rate-limit-aware retries. + +### Patch Changes + +- [#395](https://github.com/MoonshotAI/kimi-code/pull/395) [`879a7ee`](https://github.com/MoonshotAI/kimi-code/commit/879a7eeb33a8bedf18779d74a00d78369dae3db5) - Fix ACP slash skill routing, bootstrap context reads, file and permission edge cases, subagent event handling, and stale-file edit messaging. + +- [#529](https://github.com/MoonshotAI/kimi-code/pull/529) [`3b62b12`](https://github.com/MoonshotAI/kimi-code/commit/3b62b123e68cc4543bfa8fa376c7e8a24fee0afb) - Detect Git Bash installed through Scoop and other Git shims on Windows. + +- [#547](https://github.com/MoonshotAI/kimi-code/pull/547) [`3765a49`](https://github.com/MoonshotAI/kimi-code/commit/3765a491636a57c0f84ba409c325df10f7613a49) - Rework file reference completion in the TUI. + +- [#537](https://github.com/MoonshotAI/kimi-code/pull/537) [`8d0c91f`](https://github.com/MoonshotAI/kimi-code/commit/8d0c91faa1c878e395bffe9bafa89e10736c2384) - Wrap long single-line shell commands in approval prompts so the full command remains visible. + +- [#552](https://github.com/MoonshotAI/kimi-code/pull/552) [`db82e33`](https://github.com/MoonshotAI/kimi-code/commit/db82e33a20fd1ec204672df4ba5bc38800ce8dea) - Fix goal resume behavior by restoring goal state from agent records. + +- [#521](https://github.com/MoonshotAI/kimi-code/pull/521) [`9aba465`](https://github.com/MoonshotAI/kimi-code/commit/9aba465fd8689be998fa8581d04792b3c7c54359) - Fix the `/mcp` status panel border being broken by multi-line MCP server errors, which are now folded onto a single row. + +- [#543](https://github.com/MoonshotAI/kimi-code/pull/543) [`0c3d556`](https://github.com/MoonshotAI/kimi-code/commit/0c3d556778f969b3c99e69e07ecba27af8bd6c29) - Fix session workdir mismatch on Windows caused by inconsistent path separators. + +- [#544](https://github.com/MoonshotAI/kimi-code/pull/544) [`5cff6d6`](https://github.com/MoonshotAI/kimi-code/commit/5cff6d60273a6145ee38539b9c1306adddc66510) - Load Kimi-specific user Skills and global agent instructions from `KIMI_CODE_HOME` when it is set. + +- [#536](https://github.com/MoonshotAI/kimi-code/pull/536) [`b785e26`](https://github.com/MoonshotAI/kimi-code/commit/b785e2698a2da7adc9ef10251a2aed9b243e3b5f) - Show full plan cards directly and remove the Plan card keyboard shortcut. + +- [#555](https://github.com/MoonshotAI/kimi-code/pull/555) [`41ebe9f`](https://github.com/MoonshotAI/kimi-code/commit/41ebe9fb9f403e2ee6a8721640a79faa64e9210a) - Improve goal mode outcome handling with follow-up messages, safer error pauses, and clearer TUI transcript display. + +- [#506](https://github.com/MoonshotAI/kimi-code/pull/506) [`f09ec7b`](https://github.com/MoonshotAI/kimi-code/commit/f09ec7bbb59af42805a93df2993301dbd317ff2d) - Remove the per-turn auto-compaction limit so long conversations can keep compacting instead of failing early. + +- [#473](https://github.com/MoonshotAI/kimi-code/pull/473) [`3787c30`](https://github.com/MoonshotAI/kimi-code/commit/3787c3016a12af3434072da1cb6fd0c95821ea45) - Allow the startup session picker to exit with repeated Ctrl-C or Ctrl-D. + +- [#210](https://github.com/MoonshotAI/kimi-code/pull/210) [`d995928`](https://github.com/MoonshotAI/kimi-code/commit/d995928681fa2446902a0164919cf893b81efd75) - Show the underlying error when migration fails. + +- [#541](https://github.com/MoonshotAI/kimi-code/pull/541) [`2db1bd9`](https://github.com/MoonshotAI/kimi-code/commit/2db1bd9675ef3b6adf3833f05b7b6d87a137c6eb) - Fix thinking text and tool output display for subagents. + +## 0.11.0 + +### Minor Changes + +- [#468](https://github.com/MoonshotAI/kimi-code/pull/468) [`df4f2d6`](https://github.com/MoonshotAI/kimi-code/commit/df4f2d6e8611074cc0b439928f27decba53d2e9a) - Add experimental sub-skill discovery gated by the `KIMI_CODE_EXPERIMENTAL_SUB_SKILL` environment variable. Ships the `sub-skill` builtin bundle (`sub-skill.review`, `sub-skill.consolidate`) for inventorying and consolidating skills into hierarchical groups. + +- [#480](https://github.com/MoonshotAI/kimi-code/pull/480) [`f555c89`](https://github.com/MoonshotAI/kimi-code/commit/f555c89de79c5d7ae59521a9ed360ad1cf045fcd) - Show built-in skills as direct slash commands and group them ahead of external skill commands. + +- [#458](https://github.com/MoonshotAI/kimi-code/pull/458) [`93eb70a`](https://github.com/MoonshotAI/kimi-code/commit/93eb70a727c9724e19a31b0d2fbebb78b7390c78) - Migrate still-relevant environment variables from kimi-cli: + + - `KIMI_MODEL_TEMPERATURE`, `KIMI_MODEL_TOP_P` — sampling parameters applied globally to any `kimi` provider (not tied to `KIMI_MODEL_NAME`). + - `KIMI_MODEL_THINKING_KEEP` — Moonshot preserved-thinking passthrough (`thinking.keep`), injected only while Thinking is on. + - `KIMI_CODE_NO_AUTO_UPDATE` (legacy alias `KIMI_CLI_NO_AUTO_UPDATE`) — fully disables the update preflight (no check, background install, or prompt). + +- [#470](https://github.com/MoonshotAI/kimi-code/pull/470) [`aa610e2`](https://github.com/MoonshotAI/kimi-code/commit/aa610e247deca737101e4de848122db1c8ee9fb3) - Use a fixed 30-minute timeout for subagents and show concise resume instructions when they time out. + +### Patch Changes + +- [#474](https://github.com/MoonshotAI/kimi-code/pull/474) [`658e465`](https://github.com/MoonshotAI/kimi-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Show the upcoming-goal confirmation with the same accent treatment as goal lifecycle messages. + +- [#474](https://github.com/MoonshotAI/kimi-code/pull/474) [`658e465`](https://github.com/MoonshotAI/kimi-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Fix slash command autocomplete so goal text can be submitted when the cursor is before existing text. + +- [#474](https://github.com/MoonshotAI/kimi-code/pull/474) [`658e465`](https://github.com/MoonshotAI/kimi-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Fix queued goals so failed promotion attempts do not lose or duplicate queued work. + +- [#456](https://github.com/MoonshotAI/kimi-code/pull/456) [`3a98713`](https://github.com/MoonshotAI/kimi-code/commit/3a987130500fe5b403b696850165735c7d0ee076) - Show concise provider filtering errors when responses are blocked before visible output. + +- [#442](https://github.com/MoonshotAI/kimi-code/pull/442) [`960a0e2`](https://github.com/MoonshotAI/kimi-code/commit/960a0e2885b5a6a32ccd62506e9dcf4e35206b6f) - Show "unknown command" instead of "too many arguments" when an invalid subcommand is entered. + +- [#474](https://github.com/MoonshotAI/kimi-code/pull/474) [`658e465`](https://github.com/MoonshotAI/kimi-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Fix upcoming-goal queue handling while editing or pasting queued goals. + +- [#457](https://github.com/MoonshotAI/kimi-code/pull/457) [`1fe5d55`](https://github.com/MoonshotAI/kimi-code/commit/1fe5d5549c84de17183c4c76a9713cd8538ca755) - Clamp OpenAI Chat Completions `xhigh` and `max` thinking effort to `high` unless the model supports `xhigh` on `v1/chat/completions`. + +- [#464](https://github.com/MoonshotAI/kimi-code/pull/464) [`4f9977d`](https://github.com/MoonshotAI/kimi-code/commit/4f9977d4dcd2df14e6a310396c37af170b2eac50) - Preserve thinking effort when compacting long conversations. + +- [#474](https://github.com/MoonshotAI/kimi-code/pull/474) [`658e465`](https://github.com/MoonshotAI/kimi-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Ask before starting goals in YOLO mode so users can switch to Auto for unattended work. + +- [#461](https://github.com/MoonshotAI/kimi-code/pull/461) [`2af19e2`](https://github.com/MoonshotAI/kimi-code/commit/2af19e29b9f49163b23cade71d3bcaa6d0b11773) - Refresh provider model metadata when capabilities change without model ID changes. + +- [#474](https://github.com/MoonshotAI/kimi-code/pull/474) [`658e465`](https://github.com/MoonshotAI/kimi-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Start upcoming goals immediately when there is no active goal to wait for. + Support multiline edits when managing upcoming goals. + +- [#474](https://github.com/MoonshotAI/kimi-code/pull/474) [`658e465`](https://github.com/MoonshotAI/kimi-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Highlight goal queue subcommands while typing slash commands. + +## 0.10.1 + +### Patch Changes + +- [#443](https://github.com/MoonshotAI/kimi-code/pull/443) [`15a4c64`](https://github.com/MoonshotAI/kimi-code/commit/15a4c64e5cea45c9f72d8c889f306f1f964a8ac6) - Fix a crash when starting a goal in the TUI. + +## 0.10.0 + +### Minor Changes + +- [#433](https://github.com/MoonshotAI/kimi-code/pull/433) [`85338e9`](https://github.com/MoonshotAI/kimi-code/commit/85338e9f7df5d98234fd42891e9bf2a2e6ad767b) - Add the built-in `update-config` skill — you can now have Kimi edit its own config files. + +- [#420](https://github.com/MoonshotAI/kimi-code/pull/420) [`86a42a2`](https://github.com/MoonshotAI/kimi-code/commit/86a42a26a1e01f1748a937031fa76ebeaa1e28a8) - Add persistent experimental feature toggles and a TUI panel that applies confirmed changes by reloading the current session. + +- [#383](https://github.com/MoonshotAI/kimi-code/pull/383) [`15d71b5`](https://github.com/MoonshotAI/kimi-code/commit/15d71b5130d949c35d9dc2641e807e08d72dce48) - Add /reload to reload the current session and apply updated config files, plus /reload-tui to reload only TUI preferences. + +- [#393](https://github.com/MoonshotAI/kimi-code/pull/393) [`beb12ac`](https://github.com/MoonshotAI/kimi-code/commit/beb12ac0216818a5c5eda24fb304e4ab01792784) - Users now can prepare several goals for the agent to work on sequentially. The agent will pick up the next goal from the queue once the current goal is completed. Use `/goal next ` to queue a goal and `/goal next manage` to review and change the queue interactively. + +- [#431](https://github.com/MoonshotAI/kimi-code/pull/431) [`6a4e4c7`](https://github.com/MoonshotAI/kimi-code/commit/6a4e4c75d4bf6db3fefbb5c115d7a7c324bcae16) - Add a doctor command for validating Kimi Code configuration files. + +### Patch Changes + +- [#393](https://github.com/MoonshotAI/kimi-code/pull/393) [`beb12ac`](https://github.com/MoonshotAI/kimi-code/commit/beb12ac0216818a5c5eda24fb304e4ab01792784) - Stop carrying active and queued goals into forked sessions. + +- [#408](https://github.com/MoonshotAI/kimi-code/pull/408) [`6303bd2`](https://github.com/MoonshotAI/kimi-code/commit/6303bd2936ae168c674af6e685b0eed5a890c42f) - Point session error diagnostics to the `/export-debug-zip` command. + +- [#398](https://github.com/MoonshotAI/kimi-code/pull/398) [`b2801c4`](https://github.com/MoonshotAI/kimi-code/commit/b2801c4dbfe3f7e13f5468bfba1555fa12d1707c) - Set terminal tab titles without renaming the running process. + +- [#403](https://github.com/MoonshotAI/kimi-code/pull/403) [`d645d7e`](https://github.com/MoonshotAI/kimi-code/commit/d645d7e443857b3c974b9fd6065027c0f0cd6953) - Start automatic background updates as soon as startup's fresh update check finds a newer version. + +- [#387](https://github.com/MoonshotAI/kimi-code/pull/387) [`6e74027`](https://github.com/MoonshotAI/kimi-code/commit/6e74027fdc48ad124b2a62465bb5fd07e84d4712) - Lowercase the stale file content message in edit tool errors. + +- [#428](https://github.com/MoonshotAI/kimi-code/pull/428) [`853c5fc`](https://github.com/MoonshotAI/kimi-code/commit/853c5fc43741582ecbde3b4fccf82cddffe3626e) - Ensure Nix-packaged CLI builds can find ripgrep and fd. + +- [#411](https://github.com/MoonshotAI/kimi-code/pull/411) [`4598262`](https://github.com/MoonshotAI/kimi-code/commit/459826292f855592288bcfddaa1c72529a6d8c64) - Normalize malformed Responses stream rate limit errors as provider rate limit failures. + +- [#405](https://github.com/MoonshotAI/kimi-code/pull/405) [`07e2e0f`](https://github.com/MoonshotAI/kimi-code/commit/07e2e0f094fcbc8a6026eb53f5a70cc437bf7c52) - Refresh the update target before showing foreground update prompts so the displayed version matches the install. + +- [#399](https://github.com/MoonshotAI/kimi-code/pull/399) [`232ed87`](https://github.com/MoonshotAI/kimi-code/commit/232ed874d41de777e6ff9c539ac22d830d0b5c3a) - Keep managed OAuth credentials scoped to their configured authentication and API endpoints. + +- [#407](https://github.com/MoonshotAI/kimi-code/pull/407) [`07609b4`](https://github.com/MoonshotAI/kimi-code/commit/07609b41a31499bb5c7811dbab71fa427e621efc) - Set the CLI process title to kimi-code during startup. + +- [#419](https://github.com/MoonshotAI/kimi-code/pull/419) [`d0f8e24`](https://github.com/MoonshotAI/kimi-code/commit/d0f8e24e9b4d2c6dd68d93bc804a4390bf661c10) - Document the Git Bash prerequisite for Windows installs. + +- [#430](https://github.com/MoonshotAI/kimi-code/pull/430) [`be0da5f`](https://github.com/MoonshotAI/kimi-code/commit/be0da5ff39641e117d60045a43a7d5d2e0b85b75) - Fail early when Git Bash is missing on Windows before starting CLI sessions. + +## 0.9.0 + +### Minor Changes + +- [#368](https://github.com/MoonshotAI/kimi-code/pull/368) [`3eafa79`](https://github.com/MoonshotAI/kimi-code/commit/3eafa79f39c06b67d18bd2c1fd5321d2d889ed90) - Add `@moonshot-ai/acp-adapter` and the `kimi acp` subcommand: kimi-code now speaks [Agent Client Protocol 0.23](https://agentclientprotocol.com/) over stdio so IDEs (Zed, JetBrains AI Chat, custom clients) can drive sessions directly — coverage matrix, Zed configuration and breaking pre-release notes are in [kimi acp Subcommand Page](https://moonshotai.github.io/kimi-code/en/reference/kimi-acp.html). + +- [#338](https://github.com/MoonshotAI/kimi-code/pull/338) [`ba7dd73`](https://github.com/MoonshotAI/kimi-code/commit/ba7dd736a3b295b2a29c229a944208c232d51458) - Add `/btw` for side-channel conversations without steering the active main turn. + +- [#357](https://github.com/MoonshotAI/kimi-code/pull/357) [`179aecf`](https://github.com/MoonshotAI/kimi-code/commit/179aecf42379e8ef4091f5351c91cd460ba11bdd) - Log enabled experimental flags at startup. + +- [#378](https://github.com/MoonshotAI/kimi-code/pull/378) [`e0d28b4`](https://github.com/MoonshotAI/kimi-code/commit/e0d28b4941ad6f16e69bdf56a4185655feec5320) - Allow `/btw` to open the side-channel panel before entering a question. + +### Patch Changes + +- [#246](https://github.com/MoonshotAI/kimi-code/pull/246) [`7d1f889`](https://github.com/MoonshotAI/kimi-code/commit/7d1f889d3dc123f44a8d14543e5aaf8aeef2c752) - Fix external editor (Ctrl+G) on Windows by removing `/bin/sh` dependency and using platform-aware shell quoting for temp file paths. + +- [#365](https://github.com/MoonshotAI/kimi-code/pull/365) [`6a22523`](https://github.com/MoonshotAI/kimi-code/commit/6a2252343a0d624b326b2d369ec908bc8d60092d) - Fix goal budget tool schemas for OpenAI-compatible providers. + +- [#365](https://github.com/MoonshotAI/kimi-code/pull/365) [`6a22523`](https://github.com/MoonshotAI/kimi-code/commit/6a2252343a0d624b326b2d369ec908bc8d60092d) - Use the OpenAI completion token field required by newer Chat Completions models. + +- [#380](https://github.com/MoonshotAI/kimi-code/pull/380) [`8639105`](https://github.com/MoonshotAI/kimi-code/commit/86391053139ad4ea437afe79f472412fb1b106a1) - Resume saved subagents lazily when they are accessed. + +- [#339](https://github.com/MoonshotAI/kimi-code/pull/339) [`a6b16ce`](https://github.com/MoonshotAI/kimi-code/commit/a6b16ce6b4bdc20ed33888975c7da7ff1919e22f) - Allow SDK runtime creation to use a separate RPC client while preserving local CLI startup. + +- [#363](https://github.com/MoonshotAI/kimi-code/pull/363) [`90879f3`](https://github.com/MoonshotAI/kimi-code/commit/90879f37af2ddb941223d293a67615f8f557e3af) - Unify the interaction and visuals across TUI dialogs and selectors. + +- [#365](https://github.com/MoonshotAI/kimi-code/pull/365) [`6a22523`](https://github.com/MoonshotAI/kimi-code/commit/6a2252343a0d624b326b2d369ec908bc8d60092d) - Use configured model output limits for completion token caps. + +## 0.8.0 + +### Minor Changes + +- [#319](https://github.com/MoonshotAI/kimi-code/pull/319) [`fe7db4a`](https://github.com/MoonshotAI/kimi-code/commit/fe7db4a7e361b83194eb1ebb52d27daed53be532) - Append the current todo list as markdown to compaction summaries before writing them to history. + +- [#334](https://github.com/MoonshotAI/kimi-code/pull/334) [`eeefa98`](https://github.com/MoonshotAI/kimi-code/commit/eeefa98083e9d037d2ba7c59de9e5eb51b19fdd7) - Add background automatic upgrades, which can be disabled in tui.toml. + +- [#270](https://github.com/MoonshotAI/kimi-code/pull/270) [`ac37d74`](https://github.com/MoonshotAI/kimi-code/commit/ac37d7448458fdb73fbe00e35856dcf44a13f734) - Add experimental goal mode for longer tasks that need more than one turn. Turn it on with `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1` before you start Kimi. + + Use `/goal ` in the TUI when you want Kimi to keep working on one task across turns. For example: + + ```text + /goal Fix the failing checkout test + ``` + + Kimi shows the goal in the TUI and keeps progress visible while it works. Use `/goal status`, `/goal pause`, `/goal resume`, `/goal cancel`, and `/goal replace ` to manage the goal. This feature is still experimental. Try it and tell us what would make it more useful. + +- [#315](https://github.com/MoonshotAI/kimi-code/pull/315) [`191059d`](https://github.com/MoonshotAI/kimi-code/commit/191059d40049d3bfd07661ac03bb961eac1407f7) - Add background structured questions so agents can continue while waiting for user answers. + +- [#313](https://github.com/MoonshotAI/kimi-code/pull/313) [`3c5dee8`](https://github.com/MoonshotAI/kimi-code/commit/3c5dee8836ac823fce01707f60b9c095a963060e) - Add `kimi provider` CLI subcommand with `add`, `remove`, `list`, and `catalog list` / `catalog add` actions, so providers from a custom registry (api.json) or the public models.dev catalog can be imported and managed without launching the TUI. + +- [#277](https://github.com/MoonshotAI/kimi-code/pull/277) [`a217ff0`](https://github.com/MoonshotAI/kimi-code/commit/a217ff09aad0665b1501b156c2cc1f186b876087) - Add `/undo` slash command to withdraw the last prompt from conversation history, and keep replay records in sync when a prompt is undone. + +- [#334](https://github.com/MoonshotAI/kimi-code/pull/334) [`eeefa98`](https://github.com/MoonshotAI/kimi-code/commit/eeefa98083e9d037d2ba7c59de9e5eb51b19fdd7) - Add a `kimi upgrade` command for manually checking and upgrade Kimi Code CLI. + +- [#336](https://github.com/MoonshotAI/kimi-code/pull/336) [`7cda9c3`](https://github.com/MoonshotAI/kimi-code/commit/7cda9c3866bad6b3ce8f95c383a111e1ee5e9325) - Add approval lifecycle hook events for observing pending and completed permission prompts. + +### Patch Changes + +- [#285](https://github.com/MoonshotAI/kimi-code/pull/285) [`573c56e`](https://github.com/MoonshotAI/kimi-code/commit/573c56e829a10e8a45738a37250d8c15f4ab8d8d) - Consolidate background task management under the agent background runtime. + +- [#314](https://github.com/MoonshotAI/kimi-code/pull/314) [`6de3d97`](https://github.com/MoonshotAI/kimi-code/commit/6de3d97d82e2c585035d1d7f969a3504f712df21) - Prevent modified keyboard release sequences from appearing after exiting the CLI. + +- [#335](https://github.com/MoonshotAI/kimi-code/pull/335) [`7284f30`](https://github.com/MoonshotAI/kimi-code/commit/7284f30479142fd66b1e8a731fd00198b1e8684f) - Fix custom registry provider handling during re-import. Prevent loss of multi-provider entries and remove stale providers along with their model aliases and default model references. + +- [#311](https://github.com/MoonshotAI/kimi-code/pull/311) [`80164c2`](https://github.com/MoonshotAI/kimi-code/commit/80164c2e975ba82f7c915dc3fce6cb00b9d29f6e) - Normalize glob patterns before brace expansion to prevent incorrect path matching. + +- [#247](https://github.com/MoonshotAI/kimi-code/pull/247) [`58e2915`](https://github.com/MoonshotAI/kimi-code/commit/58e2915c0f726747a94a8dc5a9eda001ef0d4009) - Fix a crash in the `/sessions` picker on very narrow terminals by clamping every rendered line to the terminal width. + +- [#317](https://github.com/MoonshotAI/kimi-code/pull/317) [`1f8c36a`](https://github.com/MoonshotAI/kimi-code/commit/1f8c36af288ca6120d620f3944c921bc4f0f77ce) - Fix tool output preview rendering: trim trailing empty lines, append ellipsis to multi-line Bash command headers, and truncate long single-line output by visual wrapped lines instead of raw newline count. + +- [#145](https://github.com/MoonshotAI/kimi-code/pull/145) [`d912053`](https://github.com/MoonshotAI/kimi-code/commit/d912053b0d3983f4e67450c347616086cfbd1fe7) - Fix Git Bash path detection on Windows by also searching `usr\bin\bash.exe` locations, which is where bash lives in many Git for Windows installations where `bin\bash.exe` does not exist. + +- [#310](https://github.com/MoonshotAI/kimi-code/pull/310) [`a4511ff`](https://github.com/MoonshotAI/kimi-code/commit/a4511ffc87a1414cb8a5295eeef1103b9ed59645) - Show the full model name in the footer status bar instead of truncating the provider prefix. + +- [#283](https://github.com/MoonshotAI/kimi-code/pull/283) [`91b292e`](https://github.com/MoonshotAI/kimi-code/commit/91b292e898e9d97b0501cf787919d7f1a90c89d8) - Allow glob searches to target explicit absolute paths outside the workspace. + +- [#223](https://github.com/MoonshotAI/kimi-code/pull/223) [`811f252`](https://github.com/MoonshotAI/kimi-code/commit/811f252625bc20a27687b11754b18cc68c7d50dc) - Show MCP server summary in the welcome panel and add configuration hints in the /mcp command output. + +- [#229](https://github.com/MoonshotAI/kimi-code/pull/229) [`fb35bca`](https://github.com/MoonshotAI/kimi-code/commit/fb35bca032486eaefb7b9d7b612d353033e0922c) - Replace chalk named color with theme-aware hex in session-directory warning. + +- [#303](https://github.com/MoonshotAI/kimi-code/pull/303) [`3d7e20e`](https://github.com/MoonshotAI/kimi-code/commit/3d7e20e6978cb35787738e12f6f352fbc2733582) - Point users to `/provider` instead of the removed `/connect` command in the welcome screen and the no-models-configured hint. + +- [#135](https://github.com/MoonshotAI/kimi-code/pull/135) [`0071b63`](https://github.com/MoonshotAI/kimi-code/commit/0071b63fc83821430472e11db3c6aa613c0bdf7e) - Fix slash-activated skills not being recognized by the model due to missing system reminder wrapper. + +- [#330](https://github.com/MoonshotAI/kimi-code/pull/330) [`7a47045`](https://github.com/MoonshotAI/kimi-code/commit/7a47045af2790eba0e68d5406c670ac759b21755) - Allow subagents to use custom tools registered on their parent agent. + +- [#333](https://github.com/MoonshotAI/kimi-code/pull/333) [`1178c5c`](https://github.com/MoonshotAI/kimi-code/commit/1178c5cd148d9d5851574afaafb986be1dfe9b63) - Remind the model to refresh TodoList during long-running tasks and strengthen TodoList progress-tracking guidance. + +- [#327](https://github.com/MoonshotAI/kimi-code/pull/327) [`8809f3e`](https://github.com/MoonshotAI/kimi-code/commit/8809f3eb114172ac64cefe43bbf9b9257c5245c0) - Fix cross-provider replay failures from incompatible tool call IDs and unsigned Claude thinking history. + ## 0.7.0 ### Minor Changes -- [#232](https://github.com/MoonshotAI/kimi-code/pull/232) [`a24bfb1`](https://github.com/MoonshotAI/kimi-code/commit/a24bfb1df38e58120827a1d8ed881724af2e7b23) - Add `KIMI_MODEL_ADAPTIVE_THINKING` (and a matching `adaptive_thinking` model-alias field) to force adaptive thinking (`thinking: { type: 'adaptive' }`) on or off, overriding the Anthropic model-name version inference. This lets custom-named staff endpoints that back an adaptive-capable model opt in even when the model name does not encode a parseable Claude version. +- [#232](https://github.com/MoonshotAI/kimi-code/pull/232) [`a24bfb1`](https://github.com/MoonshotAI/kimi-code/commit/a24bfb1df38e58120827a1d8ed881724af2e7b23) - Add `KIMI_MODEL_ADAPTIVE_THINKING` (and a matching `adaptive_thinking` model-alias field) to force adaptive thinking (`thinking: { type: 'adaptive' }`) on or off, overriding the Anthropic model-name version inference. This lets custom-named compatible endpoints that back an adaptive-capable model opt in even when the model name does not encode a parseable Claude version. - [#264](https://github.com/MoonshotAI/kimi-code/pull/264) [`42bb914`](https://github.com/MoonshotAI/kimi-code/commit/42bb9141d8ee7023639f943dd4c6a0f6c8fa8945) - Add `/provider` command for managing AI providers, support custom registry imports, and introduce a tabbed model selector. diff --git a/apps/kimi-code/README.md b/apps/kimi-code/README.md index 79cd3ad91..7c92bc3a5 100644 --- a/apps/kimi-code/README.md +++ b/apps/kimi-code/README.md @@ -24,6 +24,8 @@ curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash irm https://code.kimi.com/kimi-code/install.ps1 | iex ``` +> On Windows, install [Git for Windows](https://gitforwindows.org/) before first launch because Kimi Code CLI uses the bundled Git Bash as its shell environment. If Git Bash is installed in a custom location, set `KIMI_SHELL_PATH` to the absolute path of `bash.exe`. + Then run it with a new Terminal session: ```sh diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 2380efd3b..d67164fec 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.7.0", + "version": "0.24.1", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", @@ -27,23 +27,31 @@ }, "files": [ "dist", + "dist-web", + "native", "scripts/postinstall.mjs", "scripts/postinstall", "README.md" ], "type": "module", "imports": { - "#/*": [ - "./src/*.ts", - "./src/*/index.ts" - ] + "#/tui/theme": "./src/tui/theme/index.ts", + "#/tui/commands": "./src/tui/commands/index.ts", + "#/cli/sub/server": "./src/cli/sub/server/index.ts", + "#/cli/sub/server/*": "./src/cli/sub/server/*.ts", + "#/generated/vis-web-asset": [ + "./src/generated/vis-web-asset.ts", + "./src/generated/vis-web-asset.d.ts" + ], + "#/*": "./src/*.ts" }, "publishConfig": { "access": "public", "provenance": true }, "scripts": { - "build": "tsdown", + "build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs", + "prebuild": "node scripts/build-vis-asset.mjs", "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", "smoke": "node scripts/smoke.mjs", "build:native:js": "node scripts/native/01-bundle.mjs", @@ -55,6 +63,10 @@ "test:native:smoke": "node scripts/native/smoke.mjs", "dev": "node scripts/dev.mjs", "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", + "dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground", + "dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground", + "dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground", + "dev:server:restart": "node scripts/dev-server-restart.mjs", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", "dev:prod": "node dist/main.mjs", @@ -65,26 +77,35 @@ "e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts", "postinstall": "node scripts/postinstall.mjs" }, - "dependencies": { - "@earendil-works/pi-tui": "^0.74.0", - "@mariozechner/clipboard": "^0.3.2", - "chalk": "^5.4.1", - "cli-highlight": "^2.1.11", - "commander": "^13.1.0", - "semver": "^7.7.4", - "smol-toml": "^1.6.1", - "zod": "^4.3.6" + "optionalDependencies": { + "@mariozechner/clipboard": "^0.3.9", + "node-pty": "^1.1.0" }, "devDependencies": { + "@moonshot-ai/acp-adapter": "workspace:^", + "@moonshot-ai/agent-core-v2": "workspace:^", + "@moonshot-ai/kap-server": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^", + "@moonshot-ai/kimi-web": "workspace:^", "@moonshot-ai/migration-legacy": "workspace:^", + "@moonshot-ai/pi-tui": "workspace:^", + "@moonshot-ai/vis-server": "workspace:^", + "@moonshot-ai/vis-web": "workspace:*", "@types/semver": "^7.7.0", "@types/yazl": "^2.4.6", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "commander": "^13.1.0", + "jimp": "^1.6.1", + "pathe": "^2.0.3", "postject": "1.0.0-alpha.6", + "semver": "^7.7.4", + "smol-toml": "^1.6.1", "tsx": "^4.21.0", - "yazl": "^3.3.1" + "yazl": "^3.3.1", + "zod": "^4.3.6" }, "engines": { "node": ">=22.19.0" diff --git a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs index 59d05fa86..eaa8d331f 100644 --- a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs +++ b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs @@ -6,6 +6,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import yazl from 'yazl'; +import { readPluginManifestVersion } from './plugin-manifest-version.mjs'; + const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, '../../..'); const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins'); @@ -46,7 +48,13 @@ export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) { continue; } const result = await materializeEntrySource(entry.source, pluginsRoot, outDir); - plugins.push({ ...entry, source: result.source }); + let stamped = { ...entry, source: result.source }; + if (isLocalRelativeSource(entry.source)) { + // Stamp the version from the plugin's real manifest so "latest" stays truthful. + const version = await readPluginManifestVersion(resolveInsideRoot(pluginsRoot, entry.source)); + if (version !== undefined) stamped = { ...stamped, version }; + } + plugins.push(stamped); if (result.archive !== undefined) archives.push(result.archive); } diff --git a/apps/kimi-code/scripts/build-vis-asset.mjs b/apps/kimi-code/scripts/build-vis-asset.mjs new file mode 100644 index 000000000..962e37392 --- /dev/null +++ b/apps/kimi-code/scripts/build-vis-asset.mjs @@ -0,0 +1,54 @@ +// Builds the vis web single-file bundle, gzips it, and writes a generated +// TS module that embeds it as base64 so tsdown can later bundle it into +// dist/main.mjs (works identically for the npm package and the native SEA +// binary). +import { execSync } from 'node:child_process'; +import { gzipSync } from 'node:zlib'; +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..'); +const visWeb = join(repoRoot, 'apps', 'vis', 'web'); +const out = join(here, '..', 'src', 'generated', 'vis-web-asset.ts'); + +console.log('[build-vis-asset] building vis web single-file bundle…'); +try { + // Run vite with VIS_SINGLEFILE set on the spawn so the build is + // cross-platform (Node sets the env, not a POSIX-only inline-env shell + // prefix). `pnpm --filter X exec` runs in X's package dir, so vite picks up + // vis-web's vite.config.ts, which gates the single-file output on + // `process.env.VIS_SINGLEFILE === '1'`. + // execSync runs through the platform shell, which is required on Windows: + // pnpm's launcher is `pnpm.cmd`, which a bare argv exec cannot resolve (no + // PATHEXT without a shell). The win32 native binary IS built on Windows + // runners (.github/workflows/_native-build.yml), which run this generator. + // A single command string (not an args array) avoids the args+shell + // deprecation; the command is static (no injection surface). + execSync('pnpm --filter @moonshot-ai/vis-web exec vite build', { + stdio: 'inherit', + cwd: repoRoot, + env: { ...process.env, VIS_SINGLEFILE: '1' }, + }); +} catch (err) { + throw new Error( + `[build-vis-asset] failed to run the vis-web single-file build via pnpm (is pnpm on PATH?): ${err instanceof Error ? err.message : String(err)}`, + ); +} + +const html = readFileSync(join(visWeb, 'dist-single', 'index.html')); +if (html.length < 1024 || !html.toString('utf8', 0, 256).toLowerCase().includes(' { + console.error(`[dev:server:restart] spawn error: ${err.message}`); + }); + + child.on('exit', (code, signal) => { + if (killTimer !== null) { + clearTimeout(killTimer); + killTimer = null; + } + const prev = child; + child = null; + if (shuttingDown) { + process.exit(code ?? 0); + return; + } + if (restarting) { + restarting = false; + start(); + return; + } + // Server died on its own (port conflict, runtime error, etc.). Stay alive + // so the user can fix the issue and press Enter to retry. + const tag = signal !== null ? `signal=${signal}` : `code=${code}`; + console.error( + `[dev:server:restart] server exited (${tag}). Press Enter to restart, Ctrl+C to quit.`, + ); + void prev; // silence unused warning + }); +} + +function restart() { + if (shuttingDown) return; + if (child === null) { + // Previous run already exited; just spin up a new one. + start(); + return; + } + if (restarting) return; // debounce — multiple Enters during shutdown collapse + restarting = true; + console.error('[dev:server:restart] restarting…'); + child.kill('SIGTERM'); + // Safety net: if the child ignores SIGTERM, force-kill after 5s so the + // restart loop doesn't wedge. + killTimer = setTimeout(() => { + if (child !== null && child.exitCode === null && child.signalCode === null) { + console.error('[dev:server:restart] SIGTERM timed out, sending SIGKILL'); + child.kill('SIGKILL'); + } + }, 5000); +} + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + // Any newline (Enter on most terminals) triggers a restart. Empty Enter is + // the canonical signal; typing `r` works too. + if (chunk.includes('\n') || chunk.includes('\r')) { + restart(); + } +}); + +const onShutdownSignal = (signal) => { + if (shuttingDown) return; + shuttingDown = true; + if (child !== null) { + child.kill(signal); + // Give the server a moment to flush logs / release the lock. + setTimeout(() => process.exit(0), 1000).unref(); + } else { + process.exit(0); + } +}; +process.on('SIGINT', () => onShutdownSignal('SIGINT')); +process.on('SIGTERM', () => onShutdownSignal('SIGTERM')); + +start(); diff --git a/apps/kimi-code/scripts/dev.mjs b/apps/kimi-code/scripts/dev.mjs index 5bf5d460a..3f50b969c 100644 --- a/apps/kimi-code/scripts/dev.mjs +++ b/apps/kimi-code/scripts/dev.mjs @@ -1,31 +1,64 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { startPluginMarketplaceServer } from './dev-plugin-marketplace-server.mjs'; +const require = createRequire(import.meta.url); const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const APP_ROOT = resolve(SCRIPT_DIR, '..'); +// Monorepo root. Used as the dev CLI's working directory so `make dev` opens +// the whole repo instead of just apps/kimi-code. +const REPO_ROOT = resolve(APP_ROOT, '../..'); +// Runtime variable the CLI reads to locate the marketplace JSON. const MARKETPLACE_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; +// Opt-in for dev: point this run at an external marketplace instead of a local one. +const EXTERNAL_MARKETPLACE_ENV = 'KIMI_CODE_DEV_MARKETPLACE_URL'; let marketplaceServer; const env = { ...process.env }; -if (env[MARKETPLACE_ENV] === undefined || env[MARKETPLACE_ENV]?.trim().length === 0) { +const externalUrl = process.env[EXTERNAL_MARKETPLACE_ENV]?.trim(); +if (externalUrl !== undefined && externalUrl.length > 0) { + // Explicitly asked to use an external marketplace; don't start a local server. + env[MARKETPLACE_ENV] = externalUrl; + console.error(`Using external plugin marketplace: ${externalUrl}`); +} else { + // Default: every `pnpm run dev:cli` runs its own isolated marketplace server on a + // random port, so multiple concurrent dev instances never collide. Overwrite any + // inherited MARKETPLACE_ENV so a stale URL from a dead instance can't break this run. + const inherited = process.env[MARKETPLACE_ENV]?.trim(); marketplaceServer = await startPluginMarketplaceServer(); env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl; console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`); + if (inherited !== undefined && inherited.length > 0 && inherited !== marketplaceServer.marketplaceUrl) { + console.error( + `(ignored inherited ${MARKETPLACE_ENV}=${inherited}; set ${EXTERNAL_MARKETPLACE_ENV} to use an external marketplace)`, + ); + } } -const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx'; +const tsxCli = require.resolve('tsx/cli'); const cliArgs = process.argv.slice(2); if (cliArgs[0] === '--') cliArgs.shift(); const child = spawn( - tsxBin, - ['--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', ...cliArgs], + process.execPath, + [ + tsxCli, + // Use the dev tsconfig whose `include` covers packages/*/src, so tsx's + // esbuild transform sees `experimentalDecorators: true` for DI parameter + // decorators in agent-core. Mirrors `dev:server` in package.json. + '--tsconfig', + resolve(APP_ROOT, 'tsconfig.dev.json'), + '--import', + pathToFileURL(resolve(REPO_ROOT, 'build/register-raw-text-loader.mjs')).href, + resolve(APP_ROOT, 'src/main.ts'), + ...cliArgs, + ], { - cwd: APP_ROOT, + cwd: REPO_ROOT, env, stdio: 'inherit', }, diff --git a/apps/kimi-code/scripts/native/01-bundle.mjs b/apps/kimi-code/scripts/native/01-bundle.mjs index 46193d3e7..df46acc11 100644 --- a/apps/kimi-code/scripts/native/01-bundle.mjs +++ b/apps/kimi-code/scripts/native/01-bundle.mjs @@ -6,8 +6,14 @@ import { run } from './exec.mjs'; const requireFromScript = createRequire(import.meta.url); const tsdownCliPath = requireFromScript.resolve('tsdown/run'); const checkBundlePath = resolve(import.meta.dirname, 'check-bundle.mjs'); +const buildVisAssetPath = resolve(import.meta.dirname, '..', 'build-vis-asset.mjs'); export async function runBundleStep() { + // Generate the embedded `kimi vis` web asset before bundling. The native + // tsdown run here never goes through the npm `prebuild` lifecycle, so the + // generated module must be produced explicitly first or the bundle would + // miss it (npm builds get it via the `prebuild` script). + await run(process.execPath, [buildVisAssetPath]); await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.native.config.ts']); await run(process.execPath, [checkBundlePath]); } diff --git a/apps/kimi-code/scripts/native/02-sea-blob.mjs b/apps/kimi-code/scripts/native/02-sea-blob.mjs index 8bac05a84..434a7861c 100644 --- a/apps/kimi-code/scripts/native/02-sea-blob.mjs +++ b/apps/kimi-code/scripts/native/02-sea-blob.mjs @@ -16,6 +16,7 @@ import { nativeSeaConfigPath, targetTriple, } from './paths.mjs'; +import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs'; async function ensureBundleExists() { try { @@ -31,13 +32,19 @@ async function writeSeaConfig(target) { appRoot, target, }); + const web = await collectWebAssets({ appRoot, target }); const manifestPath = resolve(nativeManifestDir(target), 'manifest.json'); + const webManifestPath = resolve(nativeIntermediatesDir(), 'web-assets', target, 'manifest.json'); await mkdir(dirname(manifestPath), { recursive: true }); + await mkdir(dirname(webManifestPath), { recursive: true }); await writeFile(manifestPath, manifestJson); + await writeFile(webManifestPath, web.manifestJson); const seaAssets = { [nativeAssetManifestKey(target)]: manifestPath, + [webAssetManifestKey(target)]: webManifestPath, ...assets, + ...web.assets, }; const config = { main: nativeJsBundlePath(), @@ -55,6 +62,9 @@ async function writeSeaConfig(target) { for (const line of nativeAssetSummary(manifest)) { console.log(`- ${line}`); } + console.log( + `Collected web assets for ${web.manifest.target}: ${web.manifest.files.length} files`, + ); } export async function runSeaBlobStep() { diff --git a/apps/kimi-code/scripts/native/assets.mjs b/apps/kimi-code/scripts/native/assets.mjs index 7b0560b69..859262449 100644 --- a/apps/kimi-code/scripts/native/assets.mjs +++ b/apps/kimi-code/scripts/native/assets.mjs @@ -17,9 +17,7 @@ export const NATIVE_TARGETS = Object.freeze( SUPPORTED_TARGETS.map((t) => { const deps = resolveTargetDeps(t); const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName; - const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0]; - const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null; - return [t, { clipboardPackage: clipboardTarget, koffiTriplet }]; + return [t, { clipboardPackage: clipboardTarget }]; }), ), ); @@ -161,16 +159,19 @@ async function collectPackageFiles({ packageName, packageRoot, includeNativeFiles, + includeEntryJs = true, nativeFileRelatives = [], }) { const packageJsonPath = join(packageRoot, 'package.json'); const packageJson = await readJson(packageJsonPath); const selected = new Set([packageJsonPath]); - const entry = resolvePackageEntry(packageRoot, packageJson); - if (entry !== null) { - selected.add(entry); - await addRuntimeDependencyFiles(packageRoot, entry, selected); + if (includeEntryJs) { + const entry = resolvePackageEntry(packageRoot, packageJson); + if (entry !== null) { + selected.add(entry); + await addRuntimeDependencyFiles(packageRoot, entry, selected); + } } for (const nativeFileRelative of nativeFileRelatives) { @@ -250,6 +251,7 @@ export async function collectNativeAssets({ appRoot, target }) { packageName: dep.resolvedName, packageRoot, includeNativeFiles: dep.collect === 'native-files', + includeEntryJs: dep.collect !== 'native-file-only', nativeFileRelatives: dep.nativeFileRelatives, }); const result = await packageManifestEntries({ diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index a0479f209..3cd10c278 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -18,10 +18,12 @@ const optionalRuntimeRequires = new Set([ 'canvas', 'chokidar', 'cpu-features', + 'fast-json-stringify/lib/serializer', + 'fast-json-stringify/lib/validator', 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); -const handledNativeRuntimeRequires = new Set(['koffi']); +const handledNativeRuntimeRequires = new Set(); function isAllowedSpecifier(specifier) { if (builtins.has(specifier) || specifier.startsWith('node:')) return true; @@ -44,7 +46,7 @@ function executableLines() { } for (const line of executableLines()) { - for (const match of line.matchAll(/\brequire\(\s*["']([^"']+)["']\s*\)/g)) { + for (const match of line.matchAll(/(? string} name * — npm package name (may depend on target) - * @property {'js-only'|'native-files'|'js-and-native-file'|'virtual'} collect + * @property {'js-only'|'native-files'|'js-and-native-file'|'native-file-only'|'virtual'} collect * @property {string|null} parent * — id of another registered dep this nests under (for pnpm), * or null for top-level (resolvable from app root) * @property {(target: string) => string[]} [nativeFileRelatives] * — explicit list of .node files relative to package root - * (used by 'js-and-native-file'; native-files mode auto-scans *.node) + * (used by 'js-and-native-file' and 'native-file-only'; + * native-files mode auto-scans *.node). 'native-file-only' collects + * package.json + these .node files but skips the package entry JS. */ /** @type {readonly NativeDepDescriptor[]} */ @@ -70,18 +75,14 @@ export const nativeDeps = Object.freeze([ }, { id: 'pi-tui', - name: () => '@earendil-works/pi-tui', - // pi-tui is bundled into main.cjs at build time — we don't collect it as - // a native dep, only register it so koffi can declare it as parent. - collect: 'virtual', + name: () => '@moonshot-ai/pi-tui', + // pi-tui's JS is bundled into main.cjs, so only the platform-specific + // native helper (.node under native/) ships alongside the binary — its + // dist/ JS is intentionally NOT collected (it stays in the bundle). This + // keeps the SEA native-asset payload small. Linux has no native helper. + collect: 'native-file-only', parent: null, - }, - { - id: 'koffi', - name: () => 'koffi', - collect: 'js-and-native-file', - parent: 'pi-tui', - nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`], + nativeFileRelatives: (target) => piTuiNativeFileByTarget[target] ?? [], }, ]); diff --git a/apps/kimi-code/scripts/native/web-assets.mjs b/apps/kimi-code/scripts/native/web-assets.mjs new file mode 100644 index 000000000..8f8a893c5 --- /dev/null +++ b/apps/kimi-code/scripts/native/web-assets.mjs @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; + +import { + WEB_ASSET_MANIFEST_VERSION, + buildWebAssetKey, + buildWebManifestKey, +} from './manifest.mjs'; + +export { WEB_ASSET_MANIFEST_VERSION }; + +const WEB_ASSETS_DIR = 'dist-web'; + +function toPosixPath(path) { + return path.split('\\').join('/'); +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function listFiles(root) { + const files = []; + + async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + continue; + } + if (entry.isFile()) { + files.push(path); + } + } + } + + await walk(root); + return files; +} + +async function assertBuiltAssetRoot({ assetRoot, requiredFile, message }) { + const requiredPath = join(assetRoot, requiredFile); + try { + const info = await stat(requiredPath); + if (!info.isFile()) { + throw new Error(`${requiredFile} is not a file`); + } + } catch { + throw new Error(message); + } +} + +export function webAssetManifestKey(target) { + return buildWebManifestKey(target); +} + +export function webAssetKey(target, relativePath) { + return buildWebAssetKey(target, relativePath); +} + +async function collectAssetRoot({ + appRoot, + target, + root, + requiredFile, + missingMessage, + assetKey, +}) { + const assetRoot = resolve(appRoot, ...root.split('/')); + await assertBuiltAssetRoot({ assetRoot, requiredFile, message: missingMessage }); + + const files = (await listFiles(assetRoot)).sort((a, b) => a.localeCompare(b)); + const manifestFiles = []; + const assets = {}; + + for (const file of files) { + if (!existsSync(file)) continue; + const bytes = await readFile(file); + const relativePath = toPosixPath(relative(assetRoot, file)); + const key = assetKey(target, relativePath); + manifestFiles.push({ + assetKey: key, + relativePath, + sha256: sha256(bytes), + }); + assets[key] = file; + } + + const manifest = { + version: WEB_ASSET_MANIFEST_VERSION, + target, + root, + files: manifestFiles, + }; + + return { + manifest, + manifestJson: `${JSON.stringify(manifest, null, 2)}\n`, + assets, + }; +} + +export async function collectWebAssets({ appRoot, target }) { + const buildCommand = + 'pnpm --filter @moonshot-ai/kimi-web run build && pnpm --filter @moonshot-ai/kimi-code run build'; + return collectAssetRoot({ + appRoot, + target, + root: WEB_ASSETS_DIR, + requiredFile: 'index.html', + missingMessage: `Kimi web build output was not found at ${resolve(appRoot, WEB_ASSETS_DIR)}. Run \`${buildCommand}\` before building native SEA assets. App root: ${appRoot}`, + assetKey: webAssetKey, + }); +} diff --git a/apps/kimi-code/scripts/plugin-manifest-version.mjs b/apps/kimi-code/scripts/plugin-manifest-version.mjs new file mode 100644 index 000000000..1fd768af5 --- /dev/null +++ b/apps/kimi-code/scripts/plugin-manifest-version.mjs @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +// Read a local plugin directory's declared version from its manifest, mirroring +// the plugin loader's precedence (packages/agent-core/src/plugin/manifest.ts): +// `kimi.plugin.json` is authoritative once it exists, and `.kimi-plugin/plugin.json` +// is only consulted when the root manifest is absent. Returns undefined when no +// manifest is present or the chosen manifest has no version — callers then leave +// the marketplace entry's existing version untouched. +export async function readPluginManifestVersion(pluginDir) { + for (const rel of ['kimi.plugin.json', '.kimi-plugin/plugin.json']) { + const raw = await readFileOrUndefined(resolve(pluginDir, rel)); + if (raw === undefined) continue; // manifest absent — fall back to the next candidate + return versionFromManifest(raw); // the chosen manifest wins, even if it has no version + } + return undefined; +} + +async function readFileOrUndefined(file) { + try { + return await readFile(file, 'utf8'); + } catch { + return undefined; + } +} + +function versionFromManifest(raw) { + try { + const parsed = JSON.parse(raw); + if (parsed !== null && typeof parsed === 'object' && typeof parsed.version === 'string') { + const trimmed = parsed.version.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + } catch { + return undefined; + } + return undefined; +} diff --git a/apps/kimi-code/scripts/smoke.mjs b/apps/kimi-code/scripts/smoke.mjs index 9c64dd80d..b4a5afa09 100644 --- a/apps/kimi-code/scripts/smoke.mjs +++ b/apps/kimi-code/scripts/smoke.mjs @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const bundlePath = resolve(appRoot, 'dist', 'main.mjs'); +const webIndexPath = resolve(appRoot, 'dist-web', 'index.html'); const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8')); const expectedVersion = packageJson.version; @@ -23,6 +24,14 @@ async function ensureBundleExists() { } } +async function ensureRuntimeAssetsExist() { + try { + await stat(webIndexPath); + } catch { + fail(`Runtime asset not found at ${webIndexPath}. Run \`pnpm build\` first.`); + } +} + async function runBundle(args) { try { const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], { @@ -45,6 +54,7 @@ function assertIncludes(output, expected, command) { } await ensureBundleExists(); +await ensureRuntimeAssetsExist(); const versionOutput = await runBundle(['--version']); assertIncludes(versionOutput, expectedVersion, '--version'); @@ -55,4 +65,7 @@ assertIncludes(helpOutput, 'Usage: kimi', '--help'); const exportHelpOutput = await runBundle(['export', '--help']); assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help'); +const webHelpOutput = await runBundle(['web', '--help']); +assertIncludes(webHelpOutput, 'Usage: kimi web', 'web --help'); + console.log(`Bundle smoke passed: ${bundlePath}`); diff --git a/apps/kimi-code/scripts/update-catalog.mjs b/apps/kimi-code/scripts/update-catalog.mjs index 41cfca6fa..ee43eeafc 100644 --- a/apps/kimi-code/scripts/update-catalog.mjs +++ b/apps/kimi-code/scripts/update-catalog.mjs @@ -24,6 +24,10 @@ const KEEP_MODEL = new Set([ "reasoning", "interleaved", "modalities", + // Message-level tool declarations capability — kosong's + // catalogModelToCapability reads it; stripping it here would silently + // disable tool-select for catalog-imported aliases. + "dynamically_loaded_tools", ]); function resolveOutputFile(args) { diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 2cdd4bd08..32a65eb0e 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -1,21 +1,27 @@ +import { CLI_COMMAND_NAME } from '#/constant/app'; +import { registerMigrateCommand } from '#/migration/index'; import { Command, Option } from 'commander'; -import { CLI_COMMAND_NAME } from '#/constant/app'; - -import { registerMigrateCommand } from '#/migration/index'; - import type { CLIOptions } from './options'; +import { registerAcpCommand } from './sub/acp'; +import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; +import { registerLoginCommand } from './sub/login'; +import { registerProviderCommand } from './sub/provider'; +import { registerServerCommand } from './sub/server'; +import { registerVisCommand } from './sub/vis'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; +export type UpgradeCommandHandler = () => void | Promise; export function createProgram( version: string, onMain: MainCommandHandler, onMigrate: MigrateCommandHandler, onPluginNodeRunner: PluginNodeRunnerHandler = () => {}, + onUpgrade: UpgradeCommandHandler = () => {}, ): Command { const program = new Command(CLI_COMMAND_NAME) .description('The Starting Point for Next-Gen Agents') @@ -23,10 +29,8 @@ export function createProgram( .allowUnknownOption(false) .configureHelp({ helpWidth: 100 }) .helpOption('-h, --help', 'Show help.') - .addHelpText( - 'after', - '\nDocumentation: https://moonshotai.github.io/kimi-code/\n' - ); + .usage('[options] [command]') + .addHelpText('after', '\nDocumentation: https://moonshotai.github.io/kimi-code/\n'); program .addOption( @@ -40,7 +44,8 @@ export function createProgram( .hideHelp() .argParser((val: string | boolean) => (val === true ? '' : (val as string))), ) - .option('-C, --continue', 'Continue the previous session for the working directory.', false) + .option('-c, --continue', 'Continue the previous session for the working directory.', false) + .addOption(new Option('-C').hideHelp().default(false)) .option('-y, --yolo', 'Automatically approve all actions.', false) .option('--auto', 'Start in auto permission mode.', false) .addOption( @@ -69,12 +74,33 @@ export function createProgram( .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) .default([]), ) + .addOption( + new Option( + '--add-dir

', + 'Add an additional workspace directory for this session. Can be repeated.', + ) + .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) + .default([]), + ) .addOption(new Option('--yes').hideHelp().default(false)) .addOption(new Option('--auto-approve').hideHelp().default(false)) .option('--plan', 'Start in plan mode.', false); registerExportCommand(program); + registerProviderCommand(program); + registerAcpCommand(program); + registerServerCommand(program); + registerLoginCommand(program); + registerDoctorCommand(program); + registerVisCommand(program); registerMigrateCommand(program, onMigrate); + program + .command('upgrade') + .alias('update') + .description('Upgrade Kimi Code to the latest version.') + .action(async () => { + await onUpgrade(); + }); program .command('__plugin_run_node', { hidden: true }) @@ -85,7 +111,11 @@ export function createProgram( onPluginNodeRunner(entry, args); }); - program.action(() => { + program.argument('[args...]').action((args: string[]) => { + if (args.length > 0) { + program.error(`unknown command '${args[0]}'. See '${CLI_COMMAND_NAME} --help'.`); + } + const raw = program.opts>(); const rawSession = raw['session'] ?? raw['resume']; @@ -95,7 +125,7 @@ export function createProgram( const opts: CLIOptions = { session: sessionValue, - continue: raw['continue'] as boolean, + continue: raw['continue'] === true || raw['C'] === true, yolo: yoloValue, auto: autoValue, plan: raw['plan'] as boolean, @@ -103,6 +133,7 @@ export function createProgram( outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'], prompt: raw['prompt'] as string | undefined, skillsDirs: raw['skillsDir'] as string[], + addDirs: raw['addDir'] as string[], }; onMain(opts); diff --git a/apps/kimi-code/src/cli/experimental-v2.ts b/apps/kimi-code/src/cli/experimental-v2.ts new file mode 100644 index 000000000..155325241 --- /dev/null +++ b/apps/kimi-code/src/cli/experimental-v2.ts @@ -0,0 +1,29 @@ +/** + * Experimental agent-core-v2 engine gate for `kimi -p` (print mode). + * + * 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 + * `cli/update/rollout.ts`) because the CLI must not depend on the core flag + * registry. Unset / any non-truthy value keeps the v1 harness. + * + * Note: `kimi server run` always boots kap-server (the agent-core-v2 engine + * server) — it no longer consults this switch. + */ + +export const KIMI_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; + +const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']); + +function isTruthyEnv( + key: string, + env: Readonly>, +): boolean { + return TRUTHY_VALUES.has((env[key] ?? '').trim().toLowerCase()); +} + +export function isKimiV2Enabled( + env: Readonly> = process.env, +): boolean { + return isTruthyEnv(KIMI_V2_ENV, env); +} diff --git a/apps/kimi-code/src/cli/goal-prompt.ts b/apps/kimi-code/src/cli/goal-prompt.ts new file mode 100644 index 000000000..57f223ad4 --- /dev/null +++ b/apps/kimi-code/src/cli/goal-prompt.ts @@ -0,0 +1,103 @@ +import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; + +import { parseGoalCommand } from '#/tui/commands/index'; + +/** + * Headless goal-mode support for the `kimi -p "/goal "` prompt path. + * + * The goal driver keeps the prompt's turn-run alive across continuation turns + * until the goal reaches a terminal state, so the existing prompt-turn waiter + * already blocks until then. This module adds the create-on-entry parsing, a + * machine-readable summary, and the terminal-status → exit-code mapping. + */ + +export interface HeadlessGoalCreate { + readonly objective: string; + readonly replace: boolean; +} + +/** + * Exit codes by final goal status. The lifecycle has only one success outcome + * (`complete` → 0) and two resumable stopped states: `blocked` (the system + * stopped pursuing — the model's UpdateGoal, a budget, or an error) and `paused` + * (a turn abort / SIGINT). Both are non-zero — the goal did not complete. An absent goal + * (should not happen on the create path) maps to success. + */ +export const GOAL_EXIT_CODES = { + complete: 0, + blocked: 3, + paused: 6, +} as const; + +export function goalExitCode(status: string | undefined): number { + switch (status) { + case 'blocked': + return GOAL_EXIT_CODES.blocked; + case 'paused': + return GOAL_EXIT_CODES.paused; + default: + return GOAL_EXIT_CODES.complete; + } +} + +const GOAL_PREFIX = /^\/goal(\s|$)/; + +/** + * Parses a headless prompt into a goal-create request, or `undefined` when the + * prompt is not a `/goal` create command (so the caller runs it as a normal + * prompt). Non-create goal subcommands are not supported headless and fall + * through to normal prompt handling. Malformed create commands throw instead of + * falling through, so validation errors are reported before anything is sent to + * the model. + */ +export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined { + const trimmed = prompt.trim(); + if (!GOAL_PREFIX.test(trimmed)) return undefined; + const args = trimmed.replace(/^\/goal/, '').trim(); + const parsed = parseGoalCommand(args); + if (parsed.kind === 'error') { + throw new Error(parsed.message); + } + if (parsed.kind !== 'create') return undefined; + return { objective: parsed.objective, replace: parsed.replace }; +} + +export interface GoalSummary { + readonly type: 'goal.summary'; + readonly goalId: string | null; + readonly status: string | null; + readonly reason: string | null; + readonly turnsUsed: number | null; + readonly tokensUsed: number | null; + readonly wallClockMs: number | null; +} + +export function goalSummaryJson(goal: GoalSnapshot | null): GoalSummary { + if (goal === null) { + return { + type: 'goal.summary', + goalId: null, + status: null, + reason: null, + turnsUsed: null, + tokensUsed: null, + wallClockMs: null, + }; + } + return { + type: 'goal.summary', + goalId: goal.goalId, + status: goal.status, + reason: goal.terminalReason ?? null, + turnsUsed: goal.turnsUsed, + tokensUsed: goal.tokensUsed, + wallClockMs: goal.wallClockMs, + }; +} + +export function formatGoalSummaryText(goal: GoalSnapshot | null): string { + if (goal === null) return 'Goal: no goal found.'; + const parts = [`Goal [${goal.status}]`]; + if (goal.terminalReason !== undefined) parts.push(goal.terminalReason); + return `${parts.join(': ')} (turns: ${goal.turnsUsed}, tokens: ${goal.tokensUsed})`; +} diff --git a/apps/kimi-code/src/cli/headless-exit.ts b/apps/kimi-code/src/cli/headless-exit.ts new file mode 100644 index 000000000..180572844 --- /dev/null +++ b/apps/kimi-code/src/cli/headless-exit.ts @@ -0,0 +1,96 @@ +import type { Writable } from 'node:stream'; + +import { HEADLESS_FORCE_EXIT_GRACE_MS, HEADLESS_STDIO_DRAIN_TIMEOUT_MS } from '#/constant/app'; + +/** Minimal process surface needed to force a headless run to terminate. */ +export interface ExitableProcess { + exit(code?: number): void; +} + +/** + * Schedule a best-effort force-exit for a completed headless (`kimi -p`) run. + * + * Print mode does not call `process.exit()`; it relies on the Node event loop + * draining once the run is done. If a stray ref'd handle survives shutdown — a + * lingering socket (e.g. a connection blackholed by a restrictive firewall, or + * an HTTP/2 session kept alive by PING), an un-cleared timer, or a child whose + * pipes stay open — the loop never empties and the process hangs until an + * external timeout kills it. + * + * This arms an **unref'd** fallback timer: a healthy run drains and exits + * naturally before it fires (so behaviour is unchanged), and the timer itself + * never keeps the loop alive. It only force-exits a run whose loop is already + * wedged. The exit code is read lazily at fire time so callers may set + * `process.exitCode` after scheduling (e.g. a goal turn mapping its terminal + * status to a non-zero code). + * + * Returns the timer handle so callers/tests can `clearTimeout` it. + */ +export function scheduleHeadlessForceExit( + proc: ExitableProcess, + getExitCode: () => number, + graceMs: number = HEADLESS_FORCE_EXIT_GRACE_MS, +): NodeJS.Timeout { + const timer = setTimeout(() => { + proc.exit(getExitCode()); + }, graceMs); + timer.unref?.(); + return timer; +} + +/** Resolve once a stream's currently-buffered writes have flushed to its sink. */ +function flushStream(stream: Writable): Promise { + return new Promise((resolve) => { + try { + // An empty write's callback fires after all previously-queued writes have + // been flushed (writes are ordered), which is the documented way to know a + // stream's buffer has drained. + stream.write('', () => resolve()); + } catch { + resolve(); + } + }); +} + +/** + * Wait for buffered output on the given streams to flush, bounded by `timeoutMs`. + * + * A slow or piped consumer that hasn't read all of stdout/stderr yet leaves the + * pipe as a legitimate ref'd handle keeping the loop alive. Flushing before any + * force-exit prevents truncating output from an otherwise-successful run. The + * wait is bounded so a permanently-stuck consumer can't re-introduce the hang. + */ +export async function drainStdio( + streams: readonly Writable[], + timeoutMs: number = HEADLESS_STDIO_DRAIN_TIMEOUT_MS, +): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + timer.unref?.(); + }); + try { + await Promise.race([Promise.all(streams.map(flushStream)).then(() => undefined), timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +/** + * Finalize a completed headless run: flush stdio, then arm the force-exit + * backstop. + * + * Draining first means in-flight legitimate output is fully written before the + * backstop can fire, and — since drained stdio no longer holds the loop — only a + * genuinely leaked handle can keep it alive afterwards, which is exactly what + * the backstop is for. + */ +export async function finalizeHeadlessRun( + proc: ExitableProcess, + streams: readonly Writable[], + getExitCode: () => number, + options: { drainTimeoutMs?: number; graceMs?: number } = {}, +): Promise { + await drainStdio(streams, options.drainTimeoutMs ?? HEADLESS_STDIO_DRAIN_TIMEOUT_MS); + scheduleHeadlessForceExit(proc, getExitCode, options.graceMs); +} diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 5b1c50699..ae524abf7 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -1,6 +1,39 @@ export type UIMode = 'shell' | 'print'; export type PromptOutputFormat = 'text' | 'stream-json'; +/** Environment variable that sets the default `-p` output format (flag wins). */ +export const OUTPUT_FORMAT_ENV = 'KIMI_MODEL_OUTPUT_FORMAT'; + +const OUTPUT_FORMATS = ['text', 'stream-json'] as const; + +function isOutputFormat(value: string): value is PromptOutputFormat { + return (OUTPUT_FORMATS as readonly string[]).includes(value); +} + +/** + * Resolve the effective `-p` output format. + * + * Precedence: explicit `--output-format` flag → `KIMI_MODEL_OUTPUT_FORMAT` env + * (prompt mode only) → `text`. The env var is ignored outside prompt mode so an + * ambient value never affects interactive `kimi`. An invalid env value fails + * fast via `OptionConflictError`. + */ +export function resolveOutputFormat( + opts: Pick, + env: Readonly> = process.env, +): PromptOutputFormat { + if (opts.outputFormat !== undefined) return opts.outputFormat; + if (opts.prompt === undefined) return 'text'; + const raw = (env[OUTPUT_FORMAT_ENV] ?? '').trim(); + if (raw.length === 0) return 'text'; + if (!isOutputFormat(raw)) { + throw new OptionConflictError( + `Invalid ${OUTPUT_FORMAT_ENV} value "${raw}". Expected one of: text, stream-json.`, + ); + } + return raw; +} + export interface CLIOptions { session: string | undefined; continue: boolean; @@ -11,6 +44,7 @@ export interface CLIOptions { outputFormat: PromptOutputFormat | undefined; prompt: string | undefined; skillsDirs: string[]; + addDirs?: string[]; } export interface ValidatedOptions { @@ -25,7 +59,10 @@ export class OptionConflictError extends Error { } } -export function validateOptions(opts: CLIOptions): ValidatedOptions { +export function validateOptions( + opts: CLIOptions, + env: Readonly> = process.env, +): ValidatedOptions { const prompt = opts.prompt; const promptMode = prompt !== undefined; if (promptMode && prompt.trim().length === 0) { @@ -55,14 +92,8 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions { if (opts.yolo && opts.auto) { throw new OptionConflictError('Cannot combine --yolo with --auto.'); } - if (!promptMode && (opts.continue || opts.session !== undefined) && opts.yolo) { - throw new OptionConflictError('Cannot combine --yolo with --continue or --session.'); - } - if (!promptMode && (opts.continue || opts.session !== undefined) && opts.auto) { - throw new OptionConflictError('Cannot combine --auto with --continue or --session.'); - } - if (!promptMode && (opts.continue || opts.session !== undefined) && opts.plan) { - throw new OptionConflictError('Cannot combine --plan with --continue or --session.'); - } + // Validate `KIMI_MODEL_OUTPUT_FORMAT` eagerly in prompt mode so a typo fails + // fast through the friendly `error:` path instead of mid-run. + if (promptMode) resolveOutputFormat(opts, env); return { options: opts, uiMode: promptMode ? 'print' : 'shell' }; } diff --git a/apps/kimi-code/src/cli/prompt-render.ts b/apps/kimi-code/src/cli/prompt-render.ts new file mode 100644 index 000000000..0ef505810 --- /dev/null +++ b/apps/kimi-code/src/cli/prompt-render.ts @@ -0,0 +1,409 @@ +/** + * Output rendering for `kimi -p` (print mode) — shared by the v1 driver + * (`run-prompt.ts`) and the native v2 runner (`v2/run-v2-print.ts`). + * + * Both engines feed the same writer classes: v1 via the SDK `Event` stream, v2 + * via the main agent's native `IEventBus` (whose `DomainEvent` payloads are + * already v1-protocol-shaped). Keeping the writers here lets v2 reuse them + * without re-implementing rendering, while v1's `runPromptTurn` keeps its own + * event-filtering / completion flow intact. + */ + +import type { PromptOutputFormat } from './options'; + +/** + * Structural hook-result shape the renderer reads. Both the v1 SDK + * `HookResultEvent` and the v2 native `hook.result` `DomainEvent` satisfy it, + * so the renderer stays engine-agnostic without depending on either event + * definition. + */ +interface HookResultEventLike { + readonly hookEvent: string; + readonly content: string; + readonly blocked?: boolean; +} + +/** + * Structural retry shape the renderer reads. Mirrors the v1 SDK + * `turn.step.retrying` event fields the stream-json meta line surfaces. Only + * the v1 driver forwards retries to `writeRetrying`; the v2 runner currently + * just discards the failed attempt's partial output and stays silent. + */ +interface RetryingEventLike { + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + +export interface PromptOutput { + readonly columns?: number | undefined; + write(chunk: string): boolean; +} + +const PROMPT_BLOCK_BULLET = '• '; +const PROMPT_BLOCK_INDENT = ' '; + +export interface PromptTurnWriter { + writeAssistantDelta(delta: string): void; + writeHookResult(event: HookResultEventLike): void; + writeThinkingDelta(delta: string): void; + writeToolCall(toolCallId: string, name: string, args: unknown): void; + writeToolCallDelta( + toolCallId: string, + name: string | undefined, + argumentsPart: string | undefined, + ): void; + writeToolResult(toolCallId: string, output: unknown): void; + writeRetrying(event: RetryingEventLike): void; + flushAssistant(): void; + discardAssistant(): void; + finish(): void; +} + +interface PromptJsonToolCall { + type: 'function'; + id: string; + function: { + name: string; + arguments: string; + }; +} + +interface PromptJsonAssistantMessage { + role: 'assistant'; + content?: string; + tool_calls?: PromptJsonToolCall[]; +} + +interface PromptJsonToolMessage { + role: 'tool'; + tool_call_id: string; + content: string; +} + +interface PromptJsonRetryMetaMessage { + role: 'meta'; + type: 'turn.step.retrying'; + failed_attempt: number; + next_attempt: number; + max_attempts: number; + delay_ms: number; + error_name: string; + error_message: string; + status_code?: number; +} + +export class PromptTranscriptWriter implements PromptTurnWriter { + private readonly assistantWriter: PromptBlockWriter; + private readonly thinkingWriter: PromptBlockWriter; + + constructor(stdout: PromptOutput, stderr: PromptOutput) { + this.assistantWriter = new PromptBlockWriter(stdout); + this.thinkingWriter = new PromptBlockWriter(stderr); + } + + writeAssistantDelta(delta: string): void { + this.thinkingWriter.finish(); + this.assistantWriter.write(delta); + } + + writeHookResult(event: HookResultEventLike): void { + this.thinkingWriter.finish(); + this.assistantWriter.finish(); + this.assistantWriter.write(formatHookResultPlain(event)); + this.assistantWriter.finish(); + } + + writeThinkingDelta(delta: string): void { + this.thinkingWriter.write(delta); + } + + writeToolCall(): void {} + + writeToolCallDelta(): void {} + + writeToolResult(): void {} + + // Text `-p` keeps retries silent: only the failed attempt's partial assistant + // text is discarded (handled by the caller). No human-readable retry line is + // emitted, matching the prior behavior. + writeRetrying(): void {} + + flushAssistant(): void { + this.assistantWriter.finish(); + } + + discardAssistant(): void {} + + finish(): void { + this.thinkingWriter.finish(); + this.assistantWriter.finish(); + } +} + +export class PromptJsonWriter implements PromptTurnWriter { + private assistantText = ''; + private readonly toolCalls: PromptJsonToolCall[] = []; + + constructor(private readonly stdout: PromptOutput) {} + + writeAssistantDelta(delta: string): void { + this.assistantText += delta; + } + + writeHookResult(event: HookResultEventLike): void { + this.flushAssistant(); + this.writeJsonLine({ + role: 'assistant', + content: formatHookResultPlain(event), + }); + } + + writeThinkingDelta(): void {} + + writeToolCall(toolCallId: string, name: string, args: unknown): void { + const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); + if (existing !== undefined) { + existing.function.name = name; + existing.function.arguments = stringifyJsonValue(args); + return; + } + this.toolCalls.push({ + type: 'function', + id: toolCallId, + function: { + name, + arguments: stringifyJsonValue(args), + }, + }); + } + + writeToolCallDelta( + toolCallId: string, + name: string | undefined, + argumentsPart: string | undefined, + ): void { + const toolCall = this.findOrCreateToolCall(toolCallId, name ?? ''); + if (name !== undefined) { + toolCall.function.name = name; + } + if (argumentsPart !== undefined) { + toolCall.function.arguments += argumentsPart; + } + } + + writeToolResult(toolCallId: string, output: unknown): void { + this.flushAssistant(); + this.writeJsonLine({ + role: 'tool', + tool_call_id: toolCallId, + content: stringifyToolOutput(output), + }); + } + + writeRetrying(event: RetryingEventLike): void { + // Emit a machine-readable meta line so stream-json consumers can observe + // provider retries. The failed attempt's partial assistant text was already + // discarded by the caller, so no half-formed assistant message leaks. + const message: PromptJsonRetryMetaMessage = { + role: 'meta', + type: 'turn.step.retrying', + failed_attempt: event.failedAttempt, + next_attempt: event.nextAttempt, + max_attempts: event.maxAttempts, + delay_ms: event.delayMs, + error_name: event.errorName, + error_message: event.errorMessage, + status_code: event.statusCode, + }; + this.writeJsonLine(message); + } + + flushAssistant(): void { + if (this.assistantText.length === 0 && this.toolCalls.length === 0) return; + const message: PromptJsonAssistantMessage = { + role: 'assistant', + content: this.assistantText.length > 0 ? this.assistantText : undefined, + tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined, + }; + this.writeJsonLine(message); + this.discardAssistant(); + } + + discardAssistant(): void { + this.assistantText = ''; + this.toolCalls.length = 0; + } + + finish(): void { + this.flushAssistant(); + } + + private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall { + const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); + if (existing !== undefined) return existing; + const toolCall: PromptJsonToolCall = { + type: 'function', + id: toolCallId, + function: { + name, + arguments: '', + }, + }; + this.toolCalls.push(toolCall); + return toolCall; + } + + private writeJsonLine( + message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage, + ): void { + this.stdout.write(`${JSON.stringify(message)}\n`); + } +} + +class PromptBlockWriter { + private started = false; + private atLineStart = false; + private lineWidth = 0; + private readonly wrapWidth: number | undefined; + + constructor(private readonly output: PromptOutput) { + this.wrapWidth = + typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1 + ? output.columns + : undefined; + } + + write(chunk: string): void { + if (chunk.length === 0) return; + let rendered = this.start(); + for (const char of chunk) { + if (this.atLineStart && char !== '\n') { + rendered += PROMPT_BLOCK_INDENT; + this.atLineStart = false; + this.lineWidth = PROMPT_BLOCK_INDENT.length; + } + const charWidth = visibleCharWidth(char); + if ( + this.wrapWidth !== undefined && + !this.atLineStart && + char !== '\n' && + this.lineWidth + charWidth > this.wrapWidth + ) { + rendered += `\n${PROMPT_BLOCK_INDENT}`; + this.lineWidth = PROMPT_BLOCK_INDENT.length; + } + rendered += char; + if (char === '\n') { + this.atLineStart = true; + this.lineWidth = 0; + } else { + this.lineWidth += charWidth; + } + } + this.output.write(rendered); + } + + finish(): void { + if (!this.started) return; + this.output.write(this.atLineStart ? '\n' : '\n\n'); + this.started = false; + this.atLineStart = false; + this.lineWidth = 0; + } + + private start(): string { + if (this.started) return ''; + this.started = true; + this.atLineStart = false; + this.lineWidth = PROMPT_BLOCK_BULLET.length; + return PROMPT_BLOCK_BULLET; + } +} + +function visibleCharWidth(char: string): number { + return char === '\t' ? 4 : 1; +} + +function formatHookResultPlain(event: HookResultEventLike): string { + return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`; +} + +function formatHookResultTitle(event: HookResultEventLike): string { + return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`; +} + +function formatHookResultBody(event: HookResultEventLike): string { + const content = event.content.trim(); + return content.length === 0 ? '(empty)' : content; +} + +function stringifyJsonValue(value: unknown): string { + if (typeof value === 'string') return value; + const json = JSON.stringify(value); + return json ?? ''; +} + +function stringifyToolOutput(output: unknown): string { + if (typeof output === 'string') return output; + const json = JSON.stringify(output); + return json ?? String(output); +} + +interface PromptJsonResumeMetaMessage { + role: 'meta'; + type: 'session.resume_hint'; + session_id: string; + command: string; + content: string; +} + +interface PromptJsonVersionMetaMessage { + role: 'meta'; + type: 'system.version'; + version: string; +} + +export function writeExperimentalVersion( + version: string, + outputFormat: PromptOutputFormat, + stdout: PromptOutput, + stderr: PromptOutput, +): void { + if (outputFormat === 'stream-json') { + const message: PromptJsonVersionMetaMessage = { + role: 'meta', + type: 'system.version', + version, + }; + stdout.write(`${JSON.stringify(message)}\n`); + return; + } + stderr.write(`kimi version ${version}\n`); +} + +export function writeResumeHint( + sessionId: string, + outputFormat: PromptOutputFormat, + stdout: PromptOutput, + stderr: PromptOutput, +): void { + const command = `kimi -r ${sessionId}`; + const content = `To resume this session: ${command}`; + if (outputFormat === 'stream-json') { + const message: PromptJsonResumeMetaMessage = { + role: 'meta', + type: 'session.resume_hint', + session_id: sessionId, + command, + content, + }; + stdout.write(`${JSON.stringify(message)}\n`); + return; + } + stderr.write(`${content}\n`); +} diff --git a/apps/kimi-code/src/cli/prompt-session.ts b/apps/kimi-code/src/cli/prompt-session.ts new file mode 100644 index 000000000..f6e3a240a --- /dev/null +++ b/apps/kimi-code/src/cli/prompt-session.ts @@ -0,0 +1,66 @@ +/** + * Minimal harness/session surface consumed by `kimi -p` (print mode). + * + * `run-prompt.ts` only needs a small subset of the SDK `KimiHarness` / `Session` + * API. Coding the print-mode driver against these narrow interfaces — instead of + * the concrete SDK classes — lets the same driver run on either the v1 engine + * (`createKimiHarness`, the default) or the experimental agent-core-v2 engine + * (`createPromptHarnessV2`, gated by `KIMI_CODE_EXPERIMENTAL_FLAG`). Both the + * v1 `KimiHarness` / `Session` and the v2 harness structurally satisfy these + * interfaces, so no adapter wrappers are needed on the v1 path. + */ + +import type { + ApprovalHandler, + ConfigDiagnostics, + CreateGoalInput, + CreateSessionOptions, + Event, + GetCronTasksResult, + GoalSnapshot, + GoalToolResult, + KimiAuthFacade, + KimiConfig, + ListSessionsOptions, + PermissionMode, + PromptInput, + QuestionHandler, + ResumeSessionInput, + SessionStatus, + SessionSummary, + TelemetryProperties, + Unsubscribe, +} from '@moonshot-ai/kimi-code-sdk'; + +export interface PromptHarness { + readonly homeDir: string; + readonly auth: KimiAuthFacade; + + track(event: string, properties?: TelemetryProperties): void; + + ensureConfigFile(): Promise; + getConfig(): Promise>; + getConfigDiagnostics(): Promise; + listSessions(options: ListSessionsOptions): Promise; + createSession(options: CreateSessionOptions): Promise; + resumeSession(input: ResumeSessionInput): Promise; + close(): Promise; +} + +export interface PromptSession { + readonly id: string; + readonly workDir: string; + + getStatus(): Promise; + setModel(model: string): Promise; + setPermission(mode: PermissionMode): Promise; + setApprovalHandler(handler: ApprovalHandler | undefined): void; + setQuestionHandler(handler: QuestionHandler | undefined): void; + onEvent(listener: (event: Event) => void): Unsubscribe; + prompt(input: string | PromptInput): Promise; + waitForBackgroundTasksOnPrint(): Promise; + handlePrintMainTurnCompleted?(): Promise<'finish' | 'continue'>; + createGoal(input: CreateGoalInput): Promise; + getGoal(): Promise; + getCronTasks(): Promise; +} diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index a542fb270..abee29962 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -7,33 +7,85 @@ import { } from '@moonshot-ai/kimi-telemetry'; import chalk from 'chalk'; import { - KimiHarness, + createKimiHarness, log, type Event, - type HookResultEvent, - type Session, + type GoalSnapshot, type SessionStatus, type TelemetryClient, } from '@moonshot-ai/kimi-code-sdk'; +import { resolve } from 'pathe'; -import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app'; +import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; +import { isKimiV2Enabled } from './experimental-v2'; +import { resolveOutputFormat } from './options'; import type { CLIOptions, PromptOutputFormat } from './options'; +import { + formatGoalSummaryText, + goalExitCode, + goalSummaryJson, + parseHeadlessGoalCreate, + type HeadlessGoalCreate, +} from './goal-prompt'; +import type { PromptHarness, PromptSession } from './prompt-session'; +import { PromptJsonWriter, PromptTranscriptWriter, writeResumeHint } from './prompt-render'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createKimiCodeHostIdentity } from './version'; +/** + * Await `promise`, but stop waiting after `timeoutMs`. + * + * The timeout only bounds how long we WAIT — it does not change the outcome: + * - if `promise` settles first, its result is propagated (a rejection throws), + * so a cleanup step that actually fails in time still surfaces; + * - if the timeout wins, we resolve (give up waiting) and swallow the abandoned + * promise's eventual late rejection so it can't surface as an unhandled + * rejection. + * + * Used to bound shutdown so a wedged cleanup step can't keep a completed + * headless run alive, without silently swallowing a cleanup that fails fast. The + * timer stays ref'd so a cleanup step that suspends on an unref'd handle (e.g. + * telemetry's retry backoff when the network is blocked) can't drain the event + * loop and exit 0 before the rejection propagates — the timer keeps the loop + * alive until it fires, then gives the rejection a chance to surface. A wedged + * cleanup is still bounded by `timeoutMs`, so this can't hang the run forever. + */ +export async function raceWithTimeout(promise: Promise, timeoutMs: number): Promise { + let timedOut = false; + let timer: ReturnType | undefined; + // Attach the catch eagerly (synchronously) so `promise` is always consumed and + // a late rejection can never become an unhandled rejection. Before the timeout + // wins, the handler rethrows so a real cleanup failure still propagates. + const guarded = promise.catch((error: unknown) => { + if (timedOut) return; + throw error; + }); + const timedOutSignal = new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true; + resolve(); + }, timeoutMs); + }); + try { + await Promise.race([guarded, timedOutSignal]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + interface PromptOutput { readonly columns?: number | undefined; write(chunk: string): boolean; } -interface PromptRunIO { +export interface PromptRunIO { readonly stdout?: PromptOutput; readonly stderr?: PromptOutput; readonly process?: PromptProcess; } -interface PromptProcess { +export interface PromptProcess { once(signal: NodeJS.Signals, listener: () => Promise): unknown; off(signal: NodeJS.Signals, listener: () => Promise): unknown; exit(code?: number): never | void; @@ -41,18 +93,27 @@ interface PromptProcess { const PROMPT_UI_MODE = 'print'; const PROMPT_MAIN_AGENT_ID = 'main'; -const PROMPT_BLOCK_BULLET = '• '; -const PROMPT_BLOCK_INDENT = ' '; export async function runPrompt( opts: CLIOptions, version: string, io: PromptRunIO = {}, ): Promise { + if (isKimiV2Enabled()) { + // The experimental agent-core-v2 engine runs on its own native DI service + // runtime (see v2/run-v2-print.ts); it does not share the v1 PromptHarness + // path below. Loaded lazily so the v2 module graph stays off the default + // (v1) path. + const { runV2Print } = await import('./v2/run-v2-print'); + await runV2Print(opts, version, io); + return; + } + const startedAt = Date.now(); const stdout = io.stdout ?? process.stdout; const stderr = io.stderr ?? process.stderr; const promptProcess = io.process ?? process; + const outputFormat = resolveOutputFormat(opts); const workDir = process.cwd(); const telemetryBootstrap = createCliTelemetryBootstrap(); const telemetryClient: TelemetryClient = { @@ -60,7 +121,7 @@ export async function runPrompt( withContext: withTelemetryContext, setContext: setTelemetryContext, }; - const harness = new KimiHarness({ + const harness = await createPromptHarness({ homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), uiMode: PROMPT_UI_MODE, @@ -68,11 +129,12 @@ export async function runPrompt( telemetry: telemetryClient, onOAuthRefresh: (outcome) => { if (outcome.success) { - track('oauth_refresh', { success: true }); + track('oauth_refresh', { outcome: 'success' }); return; } - track('oauth_refresh', { success: false, reason: outcome.reason }); + track('oauth_refresh', { outcome: 'error', reason: outcome.reason }); }, + sessionStartedProperties: { yolo: false, plan: false, afk: true }, }); log.info('kimi-code starting', { version, @@ -85,7 +147,7 @@ export async function runPrompt( let removeTerminationCleanup: (() => void) | undefined; let cleanupPromise: Promise | undefined; const cleanupPromptRun = async (): Promise => { - cleanupPromise ??= (async () => { + const pending = (cleanupPromise ??= (async () => { removeTerminationCleanup?.(); setCrashPhase('shutdown'); try { @@ -94,24 +156,33 @@ export async function runPrompt( await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); await harness.close(); } - })(); - await cleanupPromise; + })()); + // Bound cleanup so a wedged shutdown step (e.g. a SessionEnd hook, MCP + // shutdown, or a connection blackholed by a restrictive firewall) cannot + // keep a completed headless run alive forever. The cleanup keeps running in + // the background if it overruns; the caller (`kimi -p`) force-exits shortly + // after, so any straggling work is torn down with the process. + await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS); }; removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun); try { await harness.ensureConfigFile(); const config = await harness.getConfig(); - const { session, resumed, restorePermission, telemetryModel } = await resolvePromptSession( - harness, - opts, - workDir, - config.defaultModel, - stderr, - (restorePermission) => { - restorePromptSessionPermission = restorePermission; - }, - ); + for (const warning of (await harness.getConfigDiagnostics()).warnings) { + stderr.write(`Warning: ${warning}\n`); + } + const { session, restorePermission, telemetryModel, goalModel } = + await resolvePromptSession( + harness, + opts, + workDir, + config.defaultModel, + stderr, + (restorePermission) => { + restorePromptSessionPermission = restorePermission; + }, + ); restorePromptSessionPermission = restorePermission; initializeCliTelemetry({ @@ -121,37 +192,104 @@ export async function runPrompt( version, uiMode: PROMPT_UI_MODE, model: telemetryModel, + sessionId: session.id, }); setCrashPhase('runtime'); - withTelemetryContext({ sessionId: session.id }).track('started', { - resumed, - yolo: false, - plan: false, - afk: true, - }); - - const outputFormat = opts.outputFormat ?? 'text'; - await runPromptTurn(session, opts.prompt!, outputFormat, stdout, stderr); + // Headless goal mode: `kimi -p "/goal "`. The goal driver keeps + // the turn-run alive across continuation turns, so the normal prompt-turn + // waiter blocks until the goal is terminal; we then emit a summary and set a + // distinct exit code. + const goalCreate = parseHeadlessGoalCreate(opts.prompt!); + if (goalCreate !== undefined) { + await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr); + } else { + await runPromptTurn( + session as PrintTurnSession, + opts.prompt!, + outputFormat, + stdout, + stderr, + ); + } writeResumeHint(session.id, outputFormat, stdout, stderr); withTelemetryContext({ sessionId: session.id }).track('exit', { - duration_s: (Date.now() - startedAt) / 1000, + duration_ms: Date.now() - startedAt, }); } finally { await cleanupPromptRun(); } } +async function createPromptHarness( + options: Parameters[0], +): Promise { + // The v2 engine is dispatched earlier in `runPrompt` (see the + // `isKimiV2Enabled()` branch) and never reaches here; this is the v1 path. + return createKimiHarness(options); +} + +async function runHeadlessGoal( + session: PromptSession, + goal: HeadlessGoalCreate, + model: string | undefined, + outputFormat: PromptOutputFormat, + stdout: PromptOutput, + stderr: PromptOutput, +): Promise { + requireConfiguredModel(model); + await session.createGoal({ + objective: goal.objective, + replace: goal.replace, + }); + let completedSnapshot: GoalSnapshot | null = null; + const unsubscribeGoalEvents = session.onEvent((event) => { + if ( + event.type === 'goal.updated' && + event.agentId === 'main' && + event.change?.kind === 'completion' && + event.snapshot !== null + ) { + completedSnapshot = event.snapshot; + } + }); + try { + // The objective is sent as the normal prompt; goal continuation keeps the + // turn alive until a terminal state is reached. + await runPromptTurn( + session as PrintTurnSession, + goal.objective, + outputFormat, + stdout, + stderr, + ); + } finally { + unsubscribeGoalEvents(); + const snapshot = completedSnapshot ?? (await session.getGoal()).goal; + if (outputFormat === 'stream-json') { + stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`); + } else { + stderr.write(`${formatGoalSummaryText(snapshot)}\n`); + } + // Map the terminal goal status to a distinct, non-fatal exit code. A turn + // that threw (error / cancellation) already propagates its own exit path. + if (snapshot !== null && snapshot.status !== 'complete') { + process.exitCode = goalExitCode(snapshot.status); + } + } +} + interface ResolvedPromptSession { - readonly session: Session; + readonly session: PromptSession; readonly resumed: boolean; readonly restorePermission: () => Promise; readonly telemetryModel?: string; + readonly goalModel?: string; } async function resolvePromptSession( - harness: KimiHarness, + harness: PromptHarness, opts: CLIOptions, workDir: string, defaultModel: string | undefined, @@ -164,9 +302,9 @@ async function resolvePromptSession( if (target === undefined) { throw new Error(`Session "${opts.session}" not found.`); } - if (target.workDir !== workDir) { + if (resolve(target.workDir) !== resolve(workDir)) { stderr.write( - `${chalk.yellow( + `${chalk.hex('#E8A838')( `Session "${opts.session}" was created under a different directory.\n` + ` cd "${target.workDir}" && kimi -r ${opts.session}`, )}\n\n`, @@ -175,7 +313,10 @@ async function resolvePromptSession( `Session "${opts.session}" was created under a different directory.`, ); } - const session = await harness.resumeSession({ id: opts.session }); + const session = await harness.resumeSession({ + id: opts.session, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( session, @@ -191,6 +332,7 @@ async function resolvePromptSession( resumed: true, restorePermission, telemetryModel: configuredModel(opts.model, status.model, defaultModel), + goalModel: configuredModel(opts.model, status.model), }; } @@ -198,7 +340,10 @@ async function resolvePromptSession( const sessions = await harness.listSessions({ workDir }); const previous = sessions[0]; if (previous !== undefined) { - const session = await harness.resumeSession({ id: previous.id }); + const session = await harness.resumeSession({ + id: previous.id, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( session, @@ -214,19 +359,32 @@ async function resolvePromptSession( resumed: true, restorePermission, telemetryModel: configuredModel(opts.model, status.model, defaultModel), + goalModel: configuredModel(opts.model, status.model), }; } stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`); } const model = requireConfiguredModel(opts.model, defaultModel); - const session = await harness.createSession({ workDir, model, permission: 'auto' }); + const session = await harness.createSession({ + workDir, + model, + permission: 'auto', + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + drainAgentTasksOnStop: true, + }); installHeadlessHandlers(session); - return { session, resumed: false, restorePermission: async () => {}, telemetryModel: model }; + return { + session, + resumed: false, + restorePermission: async () => {}, + telemetryModel: model, + goalModel: model, + }; } async function forcePromptPermission( - session: Session, + session: PromptSession, previousPermission: SessionStatus['permission'], setRestorePermission: (restorePermission: () => Promise) => void, ): Promise<() => Promise> { @@ -245,7 +403,7 @@ async function forcePromptPermission( return restorePermission; } -function requireConfiguredModel(...models: readonly (string | undefined)[]): string { +export function requireConfiguredModel(...models: readonly (string | undefined)[]): string { const model = configuredModel(...models); if (model === undefined) { throw new Error( @@ -255,16 +413,16 @@ function requireConfiguredModel(...models: readonly (string | undefined)[]): str return model; } -function configuredModel(...models: readonly (string | undefined)[]): string | undefined { +export function configuredModel(...models: readonly (string | undefined)[]): string | undefined { return models.find((model) => model !== undefined && model.trim().length > 0); } -function installHeadlessHandlers(session: Session): void { +function installHeadlessHandlers(session: PromptSession): void { session.setApprovalHandler(() => ({ decision: 'approved' })); session.setQuestionHandler(() => null); } -function installPromptTerminationCleanup( +export function installPromptTerminationCleanup( promptProcess: PromptProcess, cleanup: () => Promise, ): () => void { @@ -280,20 +438,28 @@ function installPromptTerminationCleanup( }; const onSigint = () => exitAfterCleanup('SIGINT'); const onSigterm = () => exitAfterCleanup('SIGTERM'); + const onSighup = () => exitAfterCleanup('SIGHUP'); promptProcess.once('SIGINT', onSigint); promptProcess.once('SIGTERM', onSigterm); + promptProcess.once('SIGHUP', onSighup); return () => { promptProcess.off('SIGINT', onSigint); promptProcess.off('SIGTERM', onSigterm); + promptProcess.off('SIGHUP', onSighup); }; } -function signalExitCode(signal: NodeJS.Signals): number { - return signal === 'SIGINT' ? 130 : 143; +export function signalExitCode(signal: NodeJS.Signals): number { + if (signal === 'SIGINT') return 130; + if (signal === 'SIGHUP') return 129; + return 143; } +type PrintTurnSession = PromptSession & + Required>; + function runPromptTurn( - session: Session, + session: PrintTurnSession, prompt: string, outputFormat: PromptOutputFormat, stdout: PromptOutput, @@ -307,11 +473,28 @@ function runPromptTurn( : new PromptTranscriptWriter(stdout, stderr); let settled = false; let unsubscribe: (() => void) | undefined; + // A `kimi -p` run is not done just because the model ended a turn: an active + // goal drives continuation turns on its own, and a scheduled cron task fires + // later from an idle session — both trigger new turns after `end_turn`. While + // either is pending, something must keep the event loop alive: the cron + // scheduler's tick is deliberately unref'd, so without a ref'd handle the + // process would drain and exit before the next turn is ever triggered. This + // no-op interval is that handle; finish() always clears it. + let keepAliveTimer: NodeJS.Timeout | undefined; + const holdEventLoop = (): void => { + keepAliveTimer ??= setInterval(() => {}, 60_000); + }; + const releaseEventLoop = (): void => { + if (keepAliveTimer === undefined) return; + clearInterval(keepAliveTimer); + keepAliveTimer = undefined; + }; return new Promise((resolve, reject) => { const finish = (error?: Error): void => { if (settled) return; settled = true; + releaseEventLoop(); unsubscribe?.(); outputWriter.finish(); if (error !== undefined) { @@ -321,6 +504,36 @@ function runPromptTurn( resolve(); }; + // Re-evaluates whether the run can settle now that the main agent is idle. + // The run outlives a completed turn while a goal is still active (the goal + // driver launches the next continuation turn itself) or while cron tasks + // with a future fire remain (their fire steers a fresh turn when idle). + // Called on turn.ended and on a terminal goal.updated — the latter covers + // the driver blocking a goal on a hard budget, which emits no further + // turn.ended. Only when neither is pending do we drain background tasks + // and settle. + const evaluateRunCompletion = async (): Promise => { + try { + const { goal } = await session.getGoal(); + if (settled || activeTurnId !== undefined) return; + if (goal?.status === 'active') { + holdEventLoop(); + return; + } + const { tasks } = await session.getCronTasks(); + if (settled || activeTurnId !== undefined) return; + // A task whose expression has no future fire can never trigger a + // turn; don't hold the run open for it. + if (tasks.some((task) => task.nextFireAt !== null)) { + holdEventLoop(); + return; + } + await finishCompletedTurn(); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }; + unsubscribe = session.onEvent((event) => { if (event.type === 'error') { if (event.agentId !== PROMPT_MAIN_AGENT_ID) { @@ -329,7 +542,7 @@ function runPromptTurn( finish(new Error(`${event.code}: ${event.message}`)); return; } - if (event.type === 'turn.started' && activeTurnId === undefined) { + if (event.type === 'turn.started') { if (event.agentId !== PROMPT_MAIN_AGENT_ID) { return; } @@ -337,6 +550,16 @@ function runPromptTurn( activeAgentId = event.agentId; return; } + if ( + event.type === 'goal.updated' && + event.agentId === PROMPT_MAIN_AGENT_ID && + activeTurnId === undefined && + event.snapshot !== null && + event.snapshot.status !== 'active' + ) { + void evaluateRunCompletion(); + return; + } if ( activeTurnId === undefined || activeAgentId === undefined || @@ -353,6 +576,7 @@ function runPromptTurn( return; case 'turn.step.retrying': outputWriter.discardAssistant(); + outputWriter.writeRetrying(event); return; case 'assistant.delta': outputWriter.writeAssistantDelta(event.delta); @@ -381,7 +605,10 @@ function runPromptTurn( return; case 'turn.ended': if (event.reason === 'completed') { - finish(); + outputWriter.flushAssistant(); + activeTurnId = undefined; + activeAgentId = undefined; + void evaluateRunCompletion(); return; } finish(new Error(formatTurnEndedFailure(event))); @@ -389,20 +616,21 @@ function runPromptTurn( case 'agent.status.updated': case 'background.task.started': case 'background.task.terminated': - case 'background.task.updated': case 'compaction.blocked': case 'compaction.cancelled': case 'compaction.completed': case 'compaction.started': case 'cron.fired': + case 'goal.updated': case 'mcp.server.status': case 'session.meta.updated': case 'skill.activated': case 'subagent.completed': case 'subagent.failed': case 'subagent.spawned': + case 'subagent.started': + case 'subagent.suspended': case 'tool.list.updated': - case 'turn.started': case 'turn.step.completed': case 'warning': return; @@ -412,311 +640,41 @@ function runPromptTurn( session.prompt(prompt).catch((error: unknown) => { finish(error instanceof Error ? error : new Error(String(error))); }); + + async function finishCompletedTurn(): Promise { + // Flush the buffered assistant message before the end-of-turn policy + // runs: in stream-json mode the final message is only emitted by + // finish(), so a long drain/steer wait would otherwise withhold the main + // turn's result until the run exits. + outputWriter.flushAssistant(); + try { + const action = await session.handlePrintMainTurnCompleted(); + if (action === 'continue') { + // Stay alive: a still-pending background task will, on completion, + // steer the main agent into a new turn whose events we keep mapping. + // Do not finish yet. + holdEventLoop(); + return; + } + } catch (error) { + log.warn('handlePrintMainTurnCompleted failed', { error }); + } + finish(); + } }); } -interface PromptTurnWriter { - writeAssistantDelta(delta: string): void; - writeHookResult(event: HookResultEvent): void; - writeThinkingDelta(delta: string): void; - writeToolCall(toolCallId: string, name: string, args: unknown): void; - writeToolCallDelta( - toolCallId: string, - name: string | undefined, - argumentsPart: string | undefined, - ): void; - writeToolResult(toolCallId: string, output: unknown): void; - flushAssistant(): void; - discardAssistant(): void; - finish(): void; -} - -class PromptTranscriptWriter implements PromptTurnWriter { - private readonly assistantWriter: PromptBlockWriter; - private readonly thinkingWriter: PromptBlockWriter; - - constructor(stdout: PromptOutput, stderr: PromptOutput) { - this.assistantWriter = new PromptBlockWriter(stdout); - this.thinkingWriter = new PromptBlockWriter(stderr); - } - - writeAssistantDelta(delta: string): void { - this.thinkingWriter.finish(); - this.assistantWriter.write(delta); - } - - writeHookResult(event: HookResultEvent): void { - this.thinkingWriter.finish(); - this.assistantWriter.finish(); - this.assistantWriter.write(formatHookResultPlain(event)); - this.assistantWriter.finish(); - } - - writeThinkingDelta(delta: string): void { - this.thinkingWriter.write(delta); - } - - writeToolCall(): void {} - - writeToolCallDelta(): void {} - - writeToolResult(): void {} - - flushAssistant(): void {} - - discardAssistant(): void {} - - finish(): void { - this.thinkingWriter.finish(); - this.assistantWriter.finish(); - } -} - -interface PromptJsonToolCall { - type: 'function'; - id: string; - function: { - name: string; - arguments: string; - }; -} - -interface PromptJsonAssistantMessage { - role: 'assistant'; - content?: string; - tool_calls?: PromptJsonToolCall[]; -} - -interface PromptJsonToolMessage { - role: 'tool'; - tool_call_id: string; - content: string; -} - -interface PromptJsonResumeMetaMessage { - role: 'meta'; - type: 'session.resume_hint'; - session_id: string; - command: string; - content: string; -} - -function writeResumeHint( - sessionId: string, - outputFormat: PromptOutputFormat, - stdout: PromptOutput, - stderr: PromptOutput, -): void { - const command = `kimi -r ${sessionId}`; - const content = `To resume this session: ${command}`; - if (outputFormat === 'stream-json') { - const message: PromptJsonResumeMetaMessage = { - role: 'meta', - type: 'session.resume_hint', - session_id: sessionId, - command, - content, - }; - stdout.write(`${JSON.stringify(message)}\n`); - return; - } - stderr.write(`${content}\n`); -} - -class PromptJsonWriter implements PromptTurnWriter { - private assistantText = ''; - private readonly toolCalls: PromptJsonToolCall[] = []; - - constructor(private readonly stdout: PromptOutput) {} - - writeAssistantDelta(delta: string): void { - this.assistantText += delta; - } - - writeHookResult(event: HookResultEvent): void { - this.flushAssistant(); - this.writeJsonLine({ - role: 'assistant', - content: formatHookResultPlain(event), - }); - } - - writeThinkingDelta(): void {} - - writeToolCall(toolCallId: string, name: string, args: unknown): void { - const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); - if (existing !== undefined) { - existing.function.name = name; - existing.function.arguments = stringifyJsonValue(args); - return; - } - this.toolCalls.push({ - type: 'function', - id: toolCallId, - function: { - name, - arguments: stringifyJsonValue(args), - }, - }); - } - - writeToolCallDelta( - toolCallId: string, - name: string | undefined, - argumentsPart: string | undefined, - ): void { - const toolCall = this.findOrCreateToolCall(toolCallId, name ?? ''); - if (name !== undefined) { - toolCall.function.name = name; - } - if (argumentsPart !== undefined) { - toolCall.function.arguments += argumentsPart; - } - } - - writeToolResult(toolCallId: string, output: unknown): void { - this.flushAssistant(); - this.writeJsonLine({ - role: 'tool', - tool_call_id: toolCallId, - content: stringifyToolOutput(output), - }); - } - - flushAssistant(): void { - if (this.assistantText.length === 0 && this.toolCalls.length === 0) return; - const message: PromptJsonAssistantMessage = { - role: 'assistant', - content: this.assistantText.length > 0 ? this.assistantText : undefined, - tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined, - }; - this.writeJsonLine(message); - this.discardAssistant(); - } - - discardAssistant(): void { - this.assistantText = ''; - this.toolCalls.length = 0; - } - - finish(): void { - this.flushAssistant(); - } - - private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall { - const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); - if (existing !== undefined) return existing; - const toolCall: PromptJsonToolCall = { - type: 'function', - id: toolCallId, - function: { - name, - arguments: '', - }, - }; - this.toolCalls.push(toolCall); - return toolCall; - } - - private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void { - this.stdout.write(`${JSON.stringify(message)}\n`); - } -} - -class PromptBlockWriter { - private started = false; - private atLineStart = false; - private lineWidth = 0; - private readonly wrapWidth: number | undefined; - - constructor(private readonly output: PromptOutput) { - this.wrapWidth = - typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1 - ? output.columns - : undefined; - } - - write(chunk: string): void { - if (chunk.length === 0) return; - let rendered = this.start(); - for (const char of chunk) { - if (this.atLineStart && char !== '\n') { - rendered += PROMPT_BLOCK_INDENT; - this.atLineStart = false; - this.lineWidth = PROMPT_BLOCK_INDENT.length; - } - const charWidth = visibleCharWidth(char); - if ( - this.wrapWidth !== undefined && - !this.atLineStart && - char !== '\n' && - this.lineWidth + charWidth > this.wrapWidth - ) { - rendered += `\n${PROMPT_BLOCK_INDENT}`; - this.lineWidth = PROMPT_BLOCK_INDENT.length; - } - rendered += char; - if (char === '\n') { - this.atLineStart = true; - this.lineWidth = 0; - } else { - this.lineWidth += charWidth; - } - } - this.output.write(rendered); - } - - finish(): void { - if (!this.started) return; - this.output.write(this.atLineStart ? '\n' : '\n\n'); - this.started = false; - this.atLineStart = false; - this.lineWidth = 0; - } - - private start(): string { - if (this.started) return ''; - this.started = true; - this.atLineStart = false; - this.lineWidth = PROMPT_BLOCK_BULLET.length; - return PROMPT_BLOCK_BULLET; - } -} - -function visibleCharWidth(char: string): number { - return char === '\t' ? 4 : 1; -} - -function formatHookResultPlain(event: HookResultEvent): string { - return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`; -} - -function formatHookResultTitle(event: HookResultEvent): string { - return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`; -} - -function formatHookResultBody(event: HookResultEvent): string { - const content = event.content.trim(); - return content.length === 0 ? '(empty)' : content; -} - -function stringifyJsonValue(value: unknown): string { - if (typeof value === 'string') return value; - const json = JSON.stringify(value); - return json ?? ''; -} - -function stringifyToolOutput(output: unknown): string { - if (typeof output === 'string') return output; - const json = JSON.stringify(output); - return json ?? String(output); -} - function hasTurnId(event: Event): event is Event & { readonly turnId: number } { return 'turnId' in event; } function formatTurnEndedFailure(event: Extract): string { + if (event.error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`; + if (event.reason === 'blocked') { + return 'Prompt hook blocked the request.'; + } return `Prompt turn ended with reason: ${event.reason}`; } diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index a68d2f896..caeae907c 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -1,7 +1,13 @@ -import { execSync } from 'node:child_process'; +import { execSync, spawnSync } from 'node:child_process'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { + createKimiHarness, + log, + type KimiHarness, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; import { setCrashPhase, setTelemetryContext, @@ -9,7 +15,6 @@ import { track, withTelemetryContext, } from '@moonshot-ai/kimi-telemetry'; -import { KimiHarness, log, type TelemetryClient } from '@moonshot-ai/kimi-code-sdk'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; import { detectPendingMigration } from '#/migration/index'; @@ -17,7 +22,10 @@ import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; import { KimiTUI } from '#/tui/index'; -import { detectTerminalTheme } from '#/tui/theme/detect'; +import { currentTheme, getColorPalette } from '#/tui/theme'; +import { combineStartupNotice } from '#/tui/utils/startup'; +import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; +import { restoreTerminalModes } from '#/utils/terminal-restore'; import type { CLIOptions } from './options'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; @@ -40,9 +48,9 @@ export async function runShell( configWarning = error.message; } - // Resolve `theme = "auto"` against the live terminal once, before pi-tui - // grabs stdin. Explicit `dark` / `light` skip detection. - const resolvedTheme = tuiConfig.theme === 'auto' ? await detectTerminalTheme() : tuiConfig.theme; + // Initialise the global Theme singleton before pi-tui grabs stdin. + const palette = await getColorPalette(tuiConfig.theme); + currentTheme.setPalette(palette); const workDir = process.cwd(); const telemetryBootstrap = createCliTelemetryBootstrap(); @@ -51,20 +59,22 @@ export async function runShell( withContext: withTelemetryContext, setContext: setTelemetryContext, }; - const harness = new KimiHarness({ + const harness = createKimiHarness({ homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), + skillDirs: opts.skillsDirs, telemetry: telemetryClient, onOAuthRefresh: (outcome) => { if (outcome.success) { - track('oauth_refresh', { success: true }); + track('oauth_refresh', { outcome: 'success' }); return; } track('oauth_refresh', { - success: false, + outcome: 'error', reason: outcome.reason, }); }, + sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false }, }); log.info('kimi-code starting', { version, @@ -73,6 +83,7 @@ export async function runShell( platform: `${process.platform}/${process.arch}`, workDir, }); + await harness.ensureConfigFile(); const migrationPlan = await detectPendingMigration({ sourceHome: join(homedir(), '.kimi'), @@ -85,14 +96,17 @@ export async function runShell( return; } const config = await harness.getConfig(); + for (const warning of (await harness.getConfigDiagnostics()).warnings) { + configWarning = combineStartupNotice(configWarning, warning); + } const configMs = Date.now() - configStartedAt; const tui = new KimiTUI(harness, { cliOptions: opts, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, tuiConfig, version, workDir, startupNotice: configWarning, - resolvedTheme, migrationPlan, migrateOnly: runOptions.migrateOnly, }); @@ -106,7 +120,6 @@ export async function runShell( }); setCrashPhase('runtime'); - const resumed = opts.continue || opts.session !== undefined; const trackLifecycleForSession = ( sessionId: string, event: string, @@ -122,35 +135,85 @@ export async function runShell( trackLifecycleForSession(tui.getCurrentSessionId(), event, properties); }; + let savedStty: string | undefined; + try { + // stty operates on the terminal behind stdin, so stdin must be the TTY — + // piping /dev/null (ignore) makes stty fail with "not a tty". + const saved = execSync('stty -g', { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'ignore'], + }); + savedStty = typeof saved === 'string' ? saved.trim() : undefined; + execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] }); + } catch { + /* ignore */ + } + const restoreStty = (): void => { + if (savedStty === undefined) return; + const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0); + if (args.length === 0) return; + spawnSync('stty', args, { stdio: ['inherit', 'ignore', 'ignore'] }); + }; + + // If we crash without going through KimiTUI.stop(), the terminal is left in + // raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore + // both before exiting so the user's shell is usable afterwards. + const emergencyExit = (exitCode: number): void => { + restoreTerminalModes(); + restoreStty(); + process.exit(exitCode); + }; + const onUncaughtException = (error: unknown): void => { + try { + log.error('uncaughtException, restoring terminal and exiting', { error: String(error) }); + } catch { + /* ignore */ + } + emergencyExit(1); + }; + const onUnhandledRejection = (reason: unknown): void => { + try { + log.error('unhandledRejection, restoring terminal and exiting', { reason: String(reason) }); + } catch { + /* ignore */ + } + emergencyExit(1); + }; + process.on('uncaughtException', onUncaughtException); + process.on('unhandledRejection', onUnhandledRejection); + // Remove the crash handlers once the TUI exits cleanly so repeated runShell() + // calls in the same process (e.g. tests) don't accumulate process listeners. + const removeCrashHandlers = (): void => { + process.off('uncaughtException', onUncaughtException); + process.off('unhandledRejection', onUnhandledRejection); + }; + tui.onExit = async (exitCode = 0) => { const sessionId = tui.getCurrentSessionId(); const hasContent = tui.hasSessionContent(); setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); const gutter = ' '.repeat(CHROME_GUTTER); process.stdout.write(`${gutter}Bye!\n`); + const hints: string[] = []; if (sessionId !== '' && hasContent) { - process.stderr.write(`\n${gutter}To resume this session: kimi -r ${sessionId}\n`); + hints.push(`${gutter}To resume this session: kimi -r ${sessionId}`); } + if (tui.exitOpenUrl !== undefined) { + hints.push(`${gutter}open ${toTerminalHyperlink(tui.exitOpenUrl, tui.exitOpenUrl)}`); + } + if (hints.length > 0) { + process.stderr.write(`\n${hints.join('\n')}\n`); + } + removeCrashHandlers(); + restoreStty(); process.exit(exitCode); }; - try { - execSync('stty -ixon', { stdio: 'ignore' }); - } catch { - /* ignore */ - } try { const initStartedAt = Date.now(); await tui.start(); const initMs = Date.now() - initStartedAt; - trackLifecycle('started', { - resumed, - yolo: opts.yolo, - auto: opts.auto, - plan: opts.plan, - afk: false, - }); const startupSessionId = tui.getCurrentSessionId(); const mcpMs = await tui.getStartupMcpMs(); trackLifecycleForSession(startupSessionId, 'startup_perf', { @@ -160,8 +223,9 @@ export async function runShell( mcp_ms: mcpMs, }); } catch (error) { + removeCrashHandlers(); setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); await harness.close(); throw error; diff --git a/apps/kimi-code/src/cli/sub/acp.ts b/apps/kimi-code/src/cli/sub/acp.ts new file mode 100644 index 000000000..a98991464 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/acp.ts @@ -0,0 +1,126 @@ +/** + * `kimi acp` sub-command. + * + * Starts the Agent Client Protocol (ACP) server over stdio so that + * ACP-compatible clients (editors, IDEs, custom front-ends) can drive + * a kimi-code session. + * + * Wire-up: + * - A {@link KimiHarness} is constructed with the kimi-code host identity + * and a dedicated `uiMode: 'acp'` so downstream telemetry can + * distinguish ACP sessions from the TUI. + * - {@link runAcpServer} owns the JSON-RPC stdio bridge and redirects + * rogue `console.*` traffic to stderr. + * - `--login` pivots into the device-code login flow instead of + * starting the server. This is the entry point ACP clients hit + * via the first-class `AuthMethodTerminal` path when they re-invoke + * the agent binary with the advertised `args:['--login']` appended. + * - On stream close or unhandled error the process exits with the + * appropriate code. + */ + +import type { Command } from 'commander'; + +import { + ACP_BUILTIN_SLASH_COMMANDS, + runAcpServer, + type AvailableCommand, + type SlashCommandsSnapshot, +} from '@moonshot-ai/acp-adapter'; +import { createKimiHarness, type Session, type SkillSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { KIMI_CODE_HOME_ENV } from '#/constant/app'; +import { createKimiCodeHostIdentity, getVersion } from '#/cli/version'; +import { buildSkillSlashCommands } from '#/tui/commands/skills'; + +import { runLoginFlow } from './login-flow'; + +export function registerAcpCommand(parent: Command): void { + parent + .command('acp') + .description('Run kimi-code as an Agent Client Protocol (ACP) server over stdio.') + .option( + '--login', + 'Run the device-code login flow then exit (entry point for ACP terminal-auth).', + false, + ) + .action(async (opts: { login?: boolean }) => { + if (opts.login === true) { + await runLoginFlow(); + return; + } + const identity = createKimiCodeHostIdentity(); + const harness = createKimiHarness({ + identity, + uiMode: 'acp', + }); + // Forward `KIMI_CODE_HOME` (if set) into `authMethods[0].env` so the + // `kimi login` subprocess clients spawn for terminal-auth writes its + // token under the same data root the ACP server reads from. Used for + // sandboxed test setups (Zed's `agent_servers.*.env.KIMI_CODE_HOME = + // /tmp/...`). Production runs leave the env unset and the field stays + // empty. + const sandboxHome = process.env[KIMI_CODE_HOME_ENV]; + const terminalAuthEnv = + sandboxHome !== undefined && sandboxHome.length > 0 + ? { [KIMI_CODE_HOME_ENV]: sandboxHome } + : undefined; + // Legacy `_meta.terminal-auth` fallback for clients that don't yet + // honor the first-class `type:'terminal'` (Zed without the + // AcpBetaFeatureFlag, current JetBrains plugin, etc.). `command` is + // the absolute path to this very binary (`process.argv[1]`) so the + // client can spawn it with `args:['login']` for the top-level + // `kimi login` subcommand — matches kimi-cli `acp/server.py:77-96`. + const legacyCommand = process.argv[1]; + const builtinCommands: AvailableCommand[] = (ACP_BUILTIN_SLASH_COMMANDS as readonly AvailableCommand[]).map((cmd) => ({ + name: cmd.name, + description: cmd.description, + input: cmd.input, + })); + // Skills are session-scoped (per-cwd config), so we defer the + // listSkills() call until the adapter hands us the just-created + // Session — mirrors opencode's per-directory snapshot. A + // listSkills() failure degrades to builtins-only so a broken + // skill source never blanks the palette. + const resolveSlashCommands = async ( + session: Session, + ): Promise => { + let skills: readonly SkillSummary[] = []; + try { + skills = await session.listSkills(); + } catch { + skills = []; + } + // `buildSkillSlashCommands` already returns both views — the + // palette entries (advertised via `available_commands_update`) + // and the `commandName → skillName` map the adapter uses to + // intercept `/skill:` inputs and route them to + // `Session.activateSkill`. Passing both through keeps the two + // surfaces in lockstep (palette ↔ interceptable set) without + // a second `listSkills()` round trip. + const built = buildSkillSlashCommands(skills); + const skillCommands = built.commands.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })); + return { + commands: [...builtinCommands, ...skillCommands], + skillCommandMap: built.commandMap, + }; + }; + try { + await runAcpServer(harness, { + agentInfo: { name: 'Kimi Code CLI', version: getVersion() }, + slashCommands: resolveSlashCommands, + ...(terminalAuthEnv ? { terminalAuthEnv } : {}), + ...(legacyCommand !== undefined && legacyCommand.length > 0 + ? { terminalAuthLegacyCommand: legacyCommand } + : {}), + }); + process.exit(0); + } catch (err) { + process.stderr.write(`acp server: fatal error: ${String(err)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/kimi-code/src/cli/sub/doctor.ts b/apps/kimi-code/src/cli/sub/doctor.ts new file mode 100644 index 000000000..0ccc38d28 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/doctor.ts @@ -0,0 +1,342 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute, resolve } from 'node:path'; + +import { + createKimiConfigRpc, + type KimiConfigRpc, + type KimiConfigValidationIssue, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; +import { z } from 'zod'; + +import { getTuiConfigPath, parseTuiConfig } from '#/tui/config'; + +interface WritableLike { + write(chunk: string): boolean; +} + +type MaybePromise = T | Promise; + +export interface DoctorDeps { + readonly cwd: () => string; + readonly defaultConfigPath: () => MaybePromise; + readonly defaultTuiConfigPath: () => string; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; + readonly configRpc?: KimiConfigRpc; + readonly fileExists?: (path: string) => boolean; + readonly readTextFile?: (path: string) => Promise; + readonly validateConfigToml?: (text: string, path: string) => MaybePromise; +} + +export interface DoctorOptions { + readonly target?: 'config' | 'tui'; + readonly path?: string; +} + +interface CheckSpec { + readonly label: 'config.toml' | 'tui.toml'; + readonly path: string; + readonly explicit: boolean; + readonly parse: (text: string, path: string) => MaybePromise; +} + +interface CheckResult { + readonly label: CheckSpec['label']; + readonly path: string; + readonly status: 'OK' | 'SKIP' | 'ERROR'; + readonly message?: string; +} + +interface ResolvedDoctorDeps { + readonly cwd: () => string; + readonly defaultConfigPath: () => MaybePromise; + readonly defaultTuiConfigPath: () => string; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; + readonly fileExists: (path: string) => boolean; + readonly readTextFile: (path: string) => Promise; + readonly validateConfigToml: (text: string, path: string) => MaybePromise; +} + +export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise { + const resolved = resolveDeps(deps); + const cwd = resolved.cwd(); + const specs = await buildCheckSpecs(resolved, options, cwd); + const results = await Promise.all(specs.map((spec) => checkTomlFile(resolved, spec))); + + const issueCount = results.filter((result) => result.status === 'ERROR').length; + const text = issueCount === 0 ? formatSuccess(results) : formatFailure(results, issueCount); + if (issueCount === 0) { + resolved.stdout.write(text); + } else { + resolved.stderr.write(text); + } + return issueCount === 0 ? 0 : 1; +} + +export function registerDoctorCommand(parent: Command, deps?: Partial): void { + const doctor = parent + .command('doctor') + .description('Validate Kimi Code configuration files.') + .action(async () => { + await runDoctorCommand(deps, {}); + }); + + doctor + .command('config') + .description('Validate config.toml.') + .argument('[path]', 'Validate this file as config.toml instead of the default path.') + .action(async (path: string | undefined) => { + await runDoctorCommand(deps, { target: 'config', path }); + }); + + doctor + .command('tui') + .description('Validate tui.toml.') + .argument('[path]', 'Validate this file as tui.toml instead of the default path.') + .action(async (path: string | undefined) => { + await runDoctorCommand(deps, { target: 'tui', path }); + }); +} + +async function runDoctorCommand( + deps: Partial | undefined, + options: DoctorOptions, +): Promise { + const resolved = resolveDeps(deps); + const code = await handleDoctor(resolved, options); + if (code !== 0) resolved.exit(code); +} + +function resolveDeps(deps: Partial | DoctorDeps | undefined): ResolvedDoctorDeps { + let configRpc = deps?.configRpc; + const getConfigRpc = (): KimiConfigRpc => { + configRpc ??= createKimiConfigRpc(); + return configRpc; + }; + + return { + cwd: deps?.cwd ?? (() => process.cwd()), + defaultConfigPath: deps?.defaultConfigPath ?? (() => getConfigRpc().resolveConfigPath()), + defaultTuiConfigPath: deps?.defaultTuiConfigPath ?? getTuiConfigPath, + stdout: deps?.stdout ?? process.stdout, + stderr: deps?.stderr ?? process.stderr, + exit: deps?.exit ?? ((code) => process.exit(code)), + fileExists: deps?.fileExists ?? existsSync, + readTextFile: deps?.readTextFile ?? ((path) => readFile(path, 'utf-8')), + validateConfigToml: + deps?.validateConfigToml ?? + ((text, filePath) => getConfigRpc().validateConfigToml({ text, filePath })), + }; +} + +async function buildCheckSpecs( + deps: ResolvedDoctorDeps, + options: DoctorOptions, + cwd: string, +): Promise { + if (options.target === 'config') { + return [ + makeConfigSpec( + await resolveConfigTargetPath(deps, options.path, cwd), + options.path !== undefined, + deps, + ), + ]; + } + + if (options.target === 'tui') { + return [ + makeTuiSpec( + resolveTuiTargetPath(deps, options.path, cwd), + options.path !== undefined, + ), + ]; + } + + return [ + makeConfigSpec(await deps.defaultConfigPath(), false, deps), + makeTuiSpec(deps.defaultTuiConfigPath(), false), + ]; +} + +function makeConfigSpec( + path: string, + explicit: boolean, + deps: ResolvedDoctorDeps, +): CheckSpec { + return { + label: 'config.toml', + path, + explicit, + parse: (text, filePath) => { + return deps.validateConfigToml(text, filePath); + }, + }; +} + +function makeTuiSpec(path: string, explicit: boolean): CheckSpec { + return { + label: 'tui.toml', + path, + explicit, + parse: (text) => { + parseTuiConfig(text); + }, + }; +} + +async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise { + if (!deps.fileExists(spec.path)) { + return { + label: spec.label, + path: spec.path, + status: spec.explicit ? 'ERROR' : 'SKIP', + message: spec.explicit + ? 'File does not exist.' + : 'File does not exist; built-in defaults will apply.', + }; + } + + try { + const text = await deps.readTextFile(spec.path); + await spec.parse(text, spec.path); + return { label: spec.label, path: spec.path, status: 'OK' }; + } catch (error) { + return { + label: spec.label, + path: spec.path, + status: 'ERROR', + message: formatErrorMessage(error, spec.path), + }; + } +} + +async function resolveConfigTargetPath( + deps: ResolvedDoctorDeps, + input: string | undefined, + cwd: string, +): Promise { + return input === undefined ? deps.defaultConfigPath() : resolveInputPath(input, cwd); +} + +function resolveTuiTargetPath( + deps: ResolvedDoctorDeps, + input: string | undefined, + cwd: string, +): string { + return input === undefined ? deps.defaultTuiConfigPath() : resolveInputPath(input, cwd); +} + +function resolveInputPath(input: string, cwd: string): string { + return isAbsolute(input) ? input : resolve(cwd, input); +} + +function formatSuccess(results: readonly CheckResult[]): string { + return [ + 'Kimi doctor', + '', + ...formatResults(results), + '', + 'All checked config files are valid.', + '', + ].join('\n'); +} + +function formatFailure(results: readonly CheckResult[], issueCount: number): string { + return [ + `Kimi doctor found ${String(issueCount)} ${issueCount === 1 ? 'issue' : 'issues'}.`, + '', + ...formatResults(results), + '', + ].join('\n'); +} + +function formatResults(results: readonly CheckResult[]): string[] { + const lines: string[] = []; + for (const result of results) { + lines.push(`${result.status} ${result.label.padEnd(12)} ${result.path}`); + if (result.message !== undefined) { + for (const line of result.message.split('\n')) { + lines.push(` ${line}`); + } + } + } + return lines; +} + +function formatErrorMessage(error: unknown, filePath: string): string { + const validationIssues = findValidationIssues(error); + if (validationIssues !== undefined) { + return [ + `Invalid configuration in ${filePath}.`, + 'Validation issues:', + ...validationIssues.map((issue) => ` ${formatIssuePath(issue.path)}: ${issue.message}`), + ].join('\n'); + } + + const zodError = findZodError(error); + if (zodError !== undefined) { + return [ + `Invalid configuration in ${filePath}.`, + 'Validation issues:', + ...zodError.issues.map((issue) => ` ${formatIssuePath(issue.path)}: ${issue.message}`), + ].join('\n'); + } + return error instanceof Error ? error.message : String(error); +} + +function findValidationIssues(error: unknown): readonly KimiConfigValidationIssue[] | undefined { + if (!(error instanceof Error)) return undefined; + const details = 'details' in error ? error.details : undefined; + if (!isRecord(details)) return undefined; + const validationIssues = details['validationIssues']; + return isValidationIssueArray(validationIssues) ? validationIssues : undefined; +} + +function isValidationIssueArray(value: unknown): value is readonly KimiConfigValidationIssue[] { + return Array.isArray(value) && value.every(isValidationIssue); +} + +function isValidationIssue(value: unknown): value is KimiConfigValidationIssue { + if (!isRecord(value) || typeof value['message'] !== 'string') return false; + const path = value['path']; + return ( + Array.isArray(path) && + path.every((segment) => typeof segment === 'string' || typeof segment === 'number') + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function findZodError(error: unknown): z.ZodError | undefined { + if (error instanceof z.ZodError) return error; + if (error instanceof Error && error.cause instanceof z.ZodError) return error.cause; + return undefined; +} + +function formatIssuePath(path: readonly PropertyKey[]): string { + if (path.length === 0) return ''; + + let out = ''; + for (const segment of path) { + if (typeof segment === 'number') { + out += `[${String(segment)}]`; + } else if (out.length === 0) { + out = camelToSnake(String(segment)); + } else { + out += `.${camelToSnake(String(segment))}`; + } + } + return out; +} + +function camelToSnake(value: string): string { + return value.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); +} diff --git a/apps/kimi-code/src/cli/sub/export.ts b/apps/kimi-code/src/cli/sub/export.ts index f779cef58..a93c3e271 100644 --- a/apps/kimi-code/src/cli/sub/export.ts +++ b/apps/kimi-code/src/cli/sub/export.ts @@ -14,9 +14,10 @@ import { withTelemetryContext, } from '@moonshot-ai/kimi-telemetry'; import { - KimiHarness, + createKimiHarness, type ExportSessionInput, type ExportSessionResult, + type KimiHarness, type SessionSummary, type ShellEnvironment, type TelemetryClient, @@ -144,7 +145,7 @@ function createDefaultExportDeps(overrides: Partial = {}): ExportDep }; const getHarness = (): KimiHarness => { const currentTelemetryBootstrap = getTelemetryBootstrap(); - harness ??= new KimiHarness({ + harness ??= createKimiHarness({ homeDir: currentTelemetryBootstrap.homeDir, identity, telemetry: telemetryClient, diff --git a/apps/kimi-code/src/cli/sub/login-flow.ts b/apps/kimi-code/src/cli/sub/login-flow.ts new file mode 100644 index 000000000..005dc1729 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/login-flow.ts @@ -0,0 +1,63 @@ +/** + * Shared device-code login flow used by both `kimi login` (top-level + * subcommand) and `kimi acp --login` (the first-class ACP terminal-auth + * entry point). Exiting the process is part of the contract — callers + * MUST treat the returned promise as `Promise`. + */ + +import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { createKimiCodeHostIdentity } from '#/cli/version'; +import { openUrl } from '#/utils/open-url'; + +export async function runLoginFlow(): Promise { + const identity = createKimiCodeHostIdentity(); + const harness = createKimiHarness({ + identity, + uiMode: 'cli', + }); + const controller = new AbortController(); + process.once('SIGINT', () => { + controller.abort(); + }); + try { + const result = await harness.auth.login(undefined, { + signal: controller.signal, + onDeviceCode: (data) => { + const url = data.verificationUriComplete || data.verificationUri; + // Print the manual fallback before attempting to open the user's + // browser so headless/browser-opener failures never hide the URL + // and code needed to complete login. + process.stderr.write( + [ + '', + `Opening browser for Kimi device login: ${url}`, + `If the browser did not open, paste the URL above and enter code: ${data.userCode}`, + data.expiresIn !== null && data.expiresIn !== undefined + ? `Code expires in ${data.expiresIn}s.` + : undefined, + 'Waiting for authorization to complete...', + '', + ] + .filter((line): line is string => line !== undefined) + .join('\n'), + ); + try { + openUrl(url); + } catch { + // Best effort only: the manual fallback has already been printed. + } + }, + }); + process.stderr.write(`Logged in to ${result.providerName}.\n`); + process.exit(0); + } catch (error) { + if (controller.signal.aborted) { + process.stderr.write('Login cancelled.\n'); + } else { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Login failed: ${message}\n`); + } + process.exit(1); + } +} diff --git a/apps/kimi-code/src/cli/sub/login.ts b/apps/kimi-code/src/cli/sub/login.ts new file mode 100644 index 000000000..2c17b4c3a --- /dev/null +++ b/apps/kimi-code/src/cli/sub/login.ts @@ -0,0 +1,20 @@ +/** + * `kimi login` — drive the OAuth device-code flow non-interactively. + * The `authMethods.terminal-auth.args=['login']` (legacy `_meta` path) + * advertised by the ACP server points clients at this entry point. The + * first-class ACP `args=['--login']` path enters the same flow via + * `kimi acp --login`. + */ + +import type { Command } from 'commander'; + +import { runLoginFlow } from './login-flow'; + +export function registerLoginCommand(parent: Command): void { + parent + .command('login') + .description('Authenticate with Kimi Code CLI via the device-code flow.') + .action(async () => { + await runLoginFlow(); + }); +} diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts new file mode 100644 index 000000000..509ec2d22 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -0,0 +1,543 @@ +/** + * `kimi provider` sub-command — non-interactive provider management. + * + * Mirrors the TUI `/provider` flow (apps/kimi-code/src/tui/commands/provider.ts) + * for the custom-registry path so users can import an api.json document, drop + * a provider, or inspect what is configured without launching the TUI. + * + * `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the + * TUI does; the next launch's `refreshAllProviderModels` + * (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by URL, retries + * available API-key candidates, and re-fetches the model list, so periodic + * refresh is automatic. + */ + +import { + applyCustomRegistryProvider, + CustomRegistryApiError, + fetchCustomRegistry, + type CustomRegistrySource, + type ManagedKimiConfigShape, +} from '@moonshot-ai/kimi-code-oauth'; +import { + applyCatalogProvider, + catalogBaseUrl, + catalogProviderModels, + CatalogFetchError, + createKimiHarness, + DEFAULT_CATALOG_URL, + fetchCatalog, + inferWireType, + type Catalog, + type CatalogProviderEntry, + type KimiConfig, + type KimiHarness, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; + +import { createKimiCodeHostIdentity, createKimiCodeUserAgent } from '#/cli/version'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface ProviderDeps { + readonly getHarness: () => KimiHarness; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly env: NodeJS.ProcessEnv; + readonly exit: (code: number) => never; +} + +interface AddOptions { + readonly apiKey?: string; +} + +interface ListOptions { + readonly json: boolean; +} + +interface CatalogListOptions { + readonly json: boolean; + readonly filter?: string; + readonly url?: string; +} + +interface CatalogAddOptions { + readonly apiKey?: string; + readonly defaultModel?: string; + readonly url?: string; +} + +export async function handleProviderAdd( + deps: ProviderDeps, + url: string, + opts: AddOptions, +): Promise { + const apiKey = resolveApiKey(opts.apiKey, deps.env); + if (apiKey === undefined) { + deps.stderr.write( + 'Missing API key. Pass --api-key or set KIMI_REGISTRY_API_KEY.\n', + ); + deps.exit(1); + } + + const trimmedUrl = url.trim(); + if (trimmedUrl.length === 0) { + deps.stderr.write('Registry URL is required.\n'); + deps.exit(1); + } + + const source: CustomRegistrySource = { + kind: 'apiJson', + url: trimmedUrl, + apiKey, + }; + + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + + let entries: Awaited>; + try { + entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() }); + } catch (error) { + const suffix = error instanceof CustomRegistryApiError ? ` (HTTP ${String(error.status)})` : ''; + deps.stderr.write(`Failed to fetch registry${suffix}: ${errorMessage(error)}\n`); + deps.exit(1); + } + + const entryList = Object.values(entries); + if (entryList.length === 0) { + deps.stderr.write(`Registry at ${trimmedUrl} contained no usable providers.\n`); + deps.exit(1); + } + + // `harness.removeProvider` reloads the config from disk on each call (see + // `core-impl.ts removeKimiProvider`), so calling it inside the apply loop + // would discard providers we already applied in memory but have not yet + // persisted. Drop every stale id up front in a single batch instead, then + // apply against the resulting fresh config. + let config = await harness.getConfig(); + const staleIds = entryList + .filter((entry) => config.providers[entry.id] !== undefined) + .map((entry) => entry.id); + for (const id of staleIds) { + config = await harness.removeProvider(id); + } + + const addedProviderIds: string[] = []; + let modelCount = 0; + for (const entry of entryList) { + applyCustomRegistryProvider(asManaged(config), entry, source); + addedProviderIds.push(entry.id); + modelCount += Object.keys(entry.models).length; + } + + await harness.setConfig({ + providers: config.providers, + models: config.models, + }); + + deps.stdout.write( + `Imported ${String(addedProviderIds.length)} provider${addedProviderIds.length === 1 ? '' : 's'} ` + + `(${String(modelCount)} model${modelCount === 1 ? '' : 's'}) from ${trimmedUrl}:\n`, + ); + for (const id of addedProviderIds) { + deps.stdout.write(` - ${id}\n`); + } +} + +export async function handleProviderRemove( + deps: ProviderDeps, + providerId: string, +): Promise { + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + if (config.providers[providerId] === undefined) { + deps.stderr.write(`Provider "${providerId}" not found.\n`); + deps.exit(1); + } + await harness.removeProvider(providerId); + deps.stdout.write(`Removed provider "${providerId}".\n`); +} + +export async function handleProviderList( + deps: ProviderDeps, + opts: ListOptions, +): Promise { + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + + if (opts.json) { + deps.stdout.write( + `${JSON.stringify({ providers: config.providers, models: config.models ?? {} }, null, 2)}\n`, + ); + return; + } + + const modelsByProvider = new Map(); + for (const [alias, model] of Object.entries(config.models ?? {})) { + const list = modelsByProvider.get(model.provider) ?? []; + list.push(alias); + modelsByProvider.set(model.provider, list); + } + + const providerIds = Object.keys(config.providers).toSorted(); + if (providerIds.length === 0) { + deps.stdout.write('No providers configured.\n'); + return; + } + + for (const id of providerIds) { + const provider = config.providers[id]!; + const aliases = modelsByProvider.get(id) ?? []; + const sourceLabel = providerSourceLabel(provider); + deps.stdout.write( + `${id} type=${provider.type} models=${String(aliases.length)} source=${sourceLabel}\n`, + ); + } + if (config.defaultModel !== undefined) { + deps.stdout.write(`\nDefault model: ${config.defaultModel}\n`); + } +} + +/** + * Fetches the models.dev-style public catalog and lists providers, or — when + * `providerId` is given — drills into one provider and lists its models. This + * mirrors the discovery half of the TUI "Known third-party provider" flow. + */ +export async function handleCatalogList( + deps: ProviderDeps, + providerId: string | undefined, + opts: CatalogListOptions, +): Promise { + const url = opts.url ?? DEFAULT_CATALOG_URL; + const catalog = await loadCatalogOrExit(deps, url); + + if (providerId !== undefined) { + const entry = catalog[providerId]; + if (entry === undefined) { + deps.stderr.write(`Provider "${providerId}" not found in catalog at ${url}.\n`); + deps.exit(1); + } + const models = catalogProviderModels(entry); + if (opts.json) { + deps.stdout.write( + `${JSON.stringify({ providerId, name: entry.name ?? providerId, models }, null, 2)}\n`, + ); + return; + } + if (models.length === 0) { + deps.stdout.write(`Provider "${providerId}" lists no usable models in this catalog.\n`); + return; + } + deps.stdout.write(`${entry.name ?? providerId} (${providerId})\n`); + for (const model of models) { + const cap: string[] = []; + if (model.capability.tool_use) cap.push('tool_use'); + if (model.capability.thinking) cap.push('thinking'); + if (model.capability.image_in) cap.push('image_in'); + const ctx = + typeof model.capability.max_context_tokens === 'number' + ? String(model.capability.max_context_tokens) + : '?'; + const capLabel = cap.length > 0 ? ` [${cap.join(',')}]` : ''; + deps.stdout.write(` ${model.id} ctx=${ctx}${capLabel}\n`); + } + return; + } + + const filter = opts.filter?.toLowerCase(); + const entries = Object.entries(catalog) + .filter(([id, entry]) => { + if (filter === undefined) return true; + const haystack = `${id} ${entry.name ?? ''}`.toLowerCase(); + return haystack.includes(filter); + }) + .toSorted(([a], [b]) => a.localeCompare(b)); + + if (opts.json) { + const out: Record = {}; + for (const [id, entry] of entries) out[id] = entry; + deps.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + return; + } + + if (entries.length === 0) { + if (filter !== undefined) { + deps.stdout.write(`No providers in catalog match "${filter}".\n`); + } else { + deps.stdout.write('Catalog is empty.\n'); + } + return; + } + + for (const [id, entry] of entries) { + const modelCount = entry.models === undefined ? 0 : Object.keys(entry.models).length; + const wire = inferWireType(entry) ?? '?'; + deps.stdout.write( + `${id} wire=${wire} models=${String(modelCount)} ${entry.name ?? ''}\n`, + ); + } +} + +/** + * Imports a known provider from the models.dev catalog by id. Unlike + * `provider add` (which expects a custom api.json), this command relies on + * the catalog's normalized metadata to fill in context limits and capabilities. + */ +export async function handleCatalogAdd( + deps: ProviderDeps, + providerId: string, + opts: CatalogAddOptions, +): Promise { + const apiKey = resolveApiKey(opts.apiKey, deps.env); + if (apiKey === undefined) { + deps.stderr.write( + 'Missing API key. Pass --api-key or set KIMI_REGISTRY_API_KEY.\n', + ); + deps.exit(1); + } + + const url = opts.url ?? DEFAULT_CATALOG_URL; + const catalog = await loadCatalogOrExit(deps, url); + + const entry = catalog[providerId]; + if (entry === undefined) { + deps.stderr.write(`Provider "${providerId}" not found in catalog at ${url}.\n`); + deps.exit(1); + } + + const wire = inferWireType(entry); + if (wire === undefined) { + deps.stderr.write(`Provider "${providerId}" has an unsupported wire type in the catalog.\n`); + deps.exit(1); + } + + const models = catalogProviderModels(entry); + if (models.length === 0) { + deps.stderr.write(`Provider "${providerId}" lists no usable models in this catalog.\n`); + deps.exit(1); + } + + if (opts.defaultModel !== undefined && !models.some((m) => m.id === opts.defaultModel)) { + deps.stderr.write( + `Model "${opts.defaultModel}" is not in provider "${providerId}". Run "kimi provider catalog list ${providerId}" to see available ids.\n`, + ); + deps.exit(1); + } + + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + + let config = await harness.getConfig(); + + // Capture defaults BEFORE `removeProvider`, because that call clears + // `defaultModel` when it points at one of this provider's aliases (see + // `core-impl.ts removeKimiProvider`). Without this, re-importing an + // already-configured provider would lose the user's previously-set default + // even when `--default-model` is not supplied. + const previousDefaultModel = config.defaultModel; + const previousThinking = config.thinking; + + if (config.providers[providerId] !== undefined) { + config = await harness.removeProvider(providerId); + } + + const baseUrl = catalogBaseUrl(entry, wire); + // `applyCatalogProvider` always overwrites both `defaultModel` and + // `[thinking]`. The values we pass here are temporary; we restore + // a consistent state in the post-apply block below. + applyCatalogProvider(config, { + providerId, + wire, + ...(baseUrl === undefined ? {} : { baseUrl }), + apiKey, + models, + selectedModelId: opts.defaultModel ?? '', + thinking: false, + }); + + // Resolve the final `defaultModel`: + // - If the caller asked for one, `applyCatalogProvider` already set it. + // - Else, restore the previous default ONLY when its alias still resolves + // after the catalog refresh; the catalog may have dropped the old + // model, in which case restoring would point default_model at a + // non-existent alias and break the next session. + if (opts.defaultModel === undefined) { + const stillResolves = + previousDefaultModel !== undefined && + config.models?.[previousDefaultModel] !== undefined; + config.defaultModel = stillResolves ? previousDefaultModel : undefined; + } + + // Always restore `[thinking]` from what was there before — including + // `undefined`. Persisting `enabled: false` when the user never set it would + // make `resolveThinkingEffort` (agent-core/src/agent/config/thinking.ts) treat + // it as an explicit "off" request and silently disable thinking, even for + // thinking-capable models. + config.thinking = previousThinking; + + await harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + thinking: config.thinking, + }); + + const displayName = entry.name ?? providerId; + deps.stdout.write( + `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`, + ); + if (opts.defaultModel !== undefined) { + deps.stdout.write(`Default model set to ${providerId}/${opts.defaultModel}.\n`); + } +} + +async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise { + try { + return await fetchCatalog(url, { userAgent: createKimiCodeUserAgent() }); + } catch (error) { + const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : ''; + deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export function registerProviderCommand(parent: Command, deps?: Partial): void { + const provider = parent + .command('provider') + .description('Manage LLM providers non-interactively.'); + + // Last-resort boundary: handlers report expected failures themselves, but + // anything that escapes (e.g. a config write rejected because config.toml + // is invalid) must end as a one-line error + exit 1, not an unhandled + // rejection dumping a stack trace. + const runAction = async (resolved: ProviderDeps, run: () => Promise): Promise => { + try { + await run(); + } catch (error) { + resolved.stderr.write(`${errorMessage(error)}\n`); + resolved.exit(1); + } + }; + + provider + .command('add ') + .description('Import every provider listed in a custom registry (api.json).') + .option('--api-key ', 'Registry API key. Falls back to KIMI_REGISTRY_API_KEY.') + .action(async (url: string, options: { apiKey?: string }) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleProviderAdd(resolved, url, { apiKey: options.apiKey })); + }); + + provider + .command('remove ') + .description('Remove a provider and every model alias that referenced it.') + .action(async (providerId: string) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleProviderRemove(resolved, providerId)); + }); + + provider + .command('list') + .description('Show configured providers and their model counts.') + .option('--json', 'Emit the raw providers/models config as JSON.', false) + .action(async (options: { json?: boolean }) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleProviderList(resolved, { json: options.json === true })); + }); + + const catalog = provider + .command('catalog') + .description('Discover and import providers from the public models.dev catalog.'); + + catalog + .command('list [providerId]') + .description('List providers in the catalog, or models when a providerId is given.') + .option('--filter ', 'Case-insensitive id/name substring filter.') + .option('--url ', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`) + .option('--json', 'Emit the matching catalog slice as JSON.', false) + .action( + async ( + providerId: string | undefined, + options: { filter?: string; url?: string; json?: boolean }, + ) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => + handleCatalogList(resolved, providerId, { + json: options.json === true, + ...(options.filter === undefined ? {} : { filter: options.filter }), + ...(options.url === undefined ? {} : { url: options.url }), + }), + ); + }, + ); + + catalog + .command('add ') + .description('Import a known provider from the catalog by id.') + .option('--api-key ', 'API key for the provider. Falls back to KIMI_REGISTRY_API_KEY.') + .option('--default-model ', 'Mark the imported model as default_model after import.') + .option('--url ', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`) + .action( + async ( + providerId: string, + options: { apiKey?: string; defaultModel?: string; url?: string }, + ) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => + handleCatalogAdd(resolved, providerId, { + ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), + ...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }), + ...(options.url === undefined ? {} : { url: options.url }), + }), + ); + }, + ); +} + +function resolveDeps(overrides: Partial = {}): ProviderDeps { + let harness: KimiHarness | undefined; + const identity = createKimiCodeHostIdentity(); + return { + getHarness: + overrides.getHarness ?? + (() => { + harness ??= createKimiHarness({ identity }); + return harness; + }), + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + env: overrides.env ?? process.env, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + }; +} + +function resolveApiKey(flag: string | undefined, env: NodeJS.ProcessEnv): string | undefined { + if (typeof flag === 'string' && flag.length > 0) return flag; + const fromEnv = env['KIMI_REGISTRY_API_KEY']; + if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv; + return undefined; +} + +function asManaged(config: KimiConfig): ManagedKimiConfigShape { + return config as unknown as ManagedKimiConfigShape; +} + +function providerSourceLabel(provider: KimiConfig['providers'][string]): string { + const source = provider.source; + if (source !== undefined) { + if (source['kind'] === 'apiJson' && typeof source['url'] === 'string') { + return `apiJson(${source['url']})`; + } + } + if (provider.oauth !== undefined) return 'oauth'; + return 'inline'; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/src/cli/sub/server/access-urls.ts b/apps/kimi-code/src/cli/sub/server/access-urls.ts new file mode 100644 index 000000000..0c6233fe8 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/access-urls.ts @@ -0,0 +1,85 @@ +/** + * Build the clickable/copyable access URLs for the running server. + * + * Shared by the `server run` ready banner and `server rotate-token` so both + * show the same Local/Network links. When a token is known it rides in the + * `#token=` fragment (never sent to the server, so never logged), letting a + * user open the link on another device and be authenticated automatically. + */ + +import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks'; + +/** + * Build a directly-openable server URL. When the token is known it is appended + * as `#token=`; otherwise the bare origin (with a trailing slash) is + * returned. + */ +export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string { + const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin; + return token === undefined ? `${base}/` : `${base}/#token=${token}`; +} + +/** + * Split a full URL into the part before `#token=` and the `#token=…` fragment + * itself, so callers can render the fragment in a de-emphasized color. Returns + * `[fullUrl, '']` when there is no token fragment. + */ +export function splitTokenFragment(fullUrl: string): [string, string] { + const marker = '#token='; + const idx = fullUrl.indexOf(marker); + return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)]; +} + +export interface AccessUrlLine { + /** Fixed-width label including trailing padding, e.g. `"Local: "`. */ + label: string; + /** Full URL, carrying `#token=` when a token is known. */ + url: string; +} + +function isWildcard(host: string): boolean { + return host === '' || host === '0.0.0.0' || host === '::'; +} + +/** True when `host` is a loopback address (this host only). */ +export function isLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +function hostOrigin(host: string, port: number): string { + const family = host.includes(':') ? 'IPv6' : 'IPv4'; + return `http://${formatHostForUrl(host, family)}:${port}`; +} + +/** + * Compute the access-URL lines for a bind host/port. + * + * - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one + * `Network:` line per non-loopback interface. + * - loopback: a single `Local:` line. + * - specific host: a single `URL:` line. + */ +export function accessUrlLines( + host: string, + port: number, + token: string | undefined, + networkAddresses?: NetworkAddress[], +): AccessUrlLine[] { + if (isWildcard(host)) { + const lines: AccessUrlLine[] = [ + { label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) }, + ]; + const addrs = networkAddresses ?? listNetworkAddresses(); + for (const addr of addrs) { + lines.push({ + label: 'Network: ', + url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token), + }); + } + return lines; + } + if (isLoopbackHost(host)) { + return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; + } + return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; +} diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts new file mode 100644 index 000000000..e7aa3f14a --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -0,0 +1,412 @@ +/** + * `kimi web` daemon orchestration — parent (spawner) side. + * + * Ensures a single background server daemon exists for this device, then + * returns its origin so the caller can open the web UI. The flow: + * + * 1. Read `~/.kimi-code/server/lock`. If it names a *live* daemon, reuse it + * (wait for it to be healthy) — never spawn a second one. + * 2. Otherwise pick a free port (preferred port when available, else an + * OS-assigned one) and spawn `kimi server run --daemon` as a detached + * child whose stdio is redirected to the server log. + * 3. Poll the lock until *some* live daemon (ours, or a concurrent racer's + * that won the lock) is healthy, then return its origin. + * + * The child side (`startServerDaemon`) lives in `./run.ts` next to the + * foreground runner so it can share the same bootstrap helpers. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; + +import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/kap-server'; + +import { + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + LOCAL_SERVER_HOST, + isServerHealthy, + serverOrigin, + waitForServerHealthy, +} from './shared'; + +const SERVER_LOG_FILENAME = 'server.log'; + +/** How long to wait for an already-running daemon to answer `/healthz`. */ +const REUSE_HEALTH_TIMEOUT_MS = 15_000; +/** How long to wait for a freshly-spawned daemon to come up. */ +const SPAWN_TIMEOUT_MS = 20_000; +/** Poll cadence while waiting for the daemon to appear in the lock + healthz. */ +const POLL_INTERVAL_MS = 200; +/** Default log level for a daemon spawned without an explicit `--log-level`. */ +const DEFAULT_DAEMON_LOG_LEVEL = 'info'; + +export interface EnsureDaemonOptions { + /** Bind host for the spawned daemon (default `127.0.0.1`). */ + host?: string; + /** Preferred port; on conflict a free port is chosen automatically. */ + port?: number; + /** Pino log level for the spawned daemon (defaults to `info`). */ + logLevel?: string; + /** Mount `/api/v1/debug/*` routes on the spawned daemon. */ + debugEndpoints?: boolean; + /** Allow a non-loopback bind without a TLS-terminating reverse proxy. */ + insecureNoTls?: boolean; + /** Keep `POST /api/v1/shutdown` enabled on a non-loopback bind. */ + allowRemoteShutdown?: boolean; + /** Keep the PTY `/api/v1/terminals/*` routes enabled on a non-loopback bind. */ + allowRemoteTerminals?: boolean; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + dangerousBypassAuth?: boolean; + /** Keep the daemon alive instead of idle-killing it (`--keep-alive`). */ + keepAlive?: boolean; + /** Extra `Host` header values to allow through the DNS-rebinding check. */ + allowedHosts?: readonly string[]; + /** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */ + idleGraceMs?: number; +} + +export interface EnsureDaemonResult { + readonly origin: string; + /** True when an already-running daemon was reused (no new server started). */ + readonly reused: boolean; + /** Bind host the running daemon is actually listening on (from the lock). */ + readonly host: string; + /** Port the running daemon is actually listening on (from the lock). */ + readonly port: number; +} + +/** Path of the daemon log file (shared with the OS-service log location). */ +export function daemonLogPath(): string { + return join(DEFAULT_LOCK_DIR, SERVER_LOG_FILENAME); +} + +export function lockConnectHost(lock: LockContents): string { + const host = lock.host ?? LOCAL_SERVER_HOST; + return host === '0.0.0.0' ? LOCAL_SERVER_HOST : host; +} + +/** True when `host:port` is currently free to bind (nothing listening). */ +function canBind(host: string, port: number): Promise { + return new Promise((resolvePromise) => { + const probe = createServer(); + probe.once('error', () => resolvePromise(false)); + probe.listen({ host, port }, () => { + probe.close(() => resolvePromise(true)); + }); + }); +} + +/** Ask the OS for an ephemeral free port on `host`. */ +function getFreePort(host: string): Promise { + return new Promise((resolvePromise, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen({ host, port: 0 }, () => { + const address = probe.address(); + if (address === null || typeof address === 'string') { + probe.close(() => reject(new Error('failed to allocate a free port'))); + return; + } + const { port } = address; + probe.close(() => resolvePromise(port)); + }); + }); +} + +/** + * How many consecutive `preferred + n` ports to probe before giving up and + * asking the OS for any free port. Mirrors `PORT_RETRY_LIMIT` in the server's + * own bind retry so the spawner and the daemon agree on the policy. + */ +export const DAEMON_PORT_SCAN_LIMIT = 100; + +/** + * Pick a port for a new daemon: prefer `preferred` when it is free, otherwise + * walk `preferred + 1`, `+ 2`, … upward and take the first free one. Only when + * the whole scan window is saturated do we fall back to an OS-assigned free + * port. + * + * Reusing an already-live daemon is handled by `ensureDaemon` before this runs, + * so a busy port here is held by a third-party process — bumping by one (rather + * than jumping to a random ephemeral port) keeps the URL predictable, matching + * the server's own "port busy ⇒ +1" bind retry. + */ +export async function resolveDaemonPort( + host: string = DEFAULT_SERVER_HOST, + preferred: number = DEFAULT_SERVER_PORT, +): Promise { + for ( + let candidate = preferred; + candidate < preferred + DAEMON_PORT_SCAN_LIMIT && candidate <= 65535; + candidate++ + ) { + if (await canBind(host, candidate)) return candidate; + } + return getFreePort(host); +} + +interface NodeSeaModule { + isSea(): boolean; +} + +const nodeRequire = createRequire(import.meta.url); +let cachedSea: NodeSeaModule | null | undefined; + +function loadSeaModule(): NodeSeaModule | null { + if (cachedSea !== undefined) return cachedSea; + try { + cachedSea = nodeRequire('node:sea') as NodeSeaModule; + } catch { + cachedSea = null; + } + return cachedSea; +} + +/** True when running as a compiled single-executable (SEA / native) binary. */ +function detectSea(): boolean { + const sea = loadSeaModule(); + if (sea === null) return false; + try { + return sea.isSea(); + } catch { + return false; + } +} + +/** + * Absolute path to the CLI entry that should be re-execed to run the daemon. + * Mirrors `resolveSupervisorProgram` in `packages/kap-server/src/svc/program.ts`: + * when the CLI is a compiled single binary, `argv[1]` is the invoked command + * name (e.g. `kimi`) or the first user argument — never a script path — so we + * must re-exec `process.execPath` itself. + */ +export function resolveDaemonProgram( + argv: readonly string[] = process.argv, + cwd: string = process.cwd(), + execPath: string = process.execPath, + isSea: boolean = detectSea(), +): string { + // In a SEA binary `argv[1]` is not a script path, so resolving it against + // `cwd` would produce a bogus path (e.g. `/kimi`) and crash the spawn + // with ENOENT. Always re-exec the binary itself. + if (isSea) return execPath; + const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath); + return isAbsolute(candidate) ? candidate : resolve(cwd, candidate); +} + +interface SpawnDaemonChildOptions { + host?: string; + port: number; + logLevel: string; + debugEndpoints?: boolean; + insecureNoTls?: boolean; + allowRemoteShutdown?: boolean; + allowRemoteTerminals?: boolean; + dangerousBypassAuth?: boolean; + keepAlive?: boolean; + allowedHosts?: readonly string[]; + idleGraceMs?: number; +} + +export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess { + const program = resolveDaemonProgram(); + const logPath = daemonLogPath(); + const logDir = dirname(logPath); + mkdirSync(logDir, { recursive: true }); + const args = [ + 'server', + 'run', + '--daemon', + '--port', + String(options.port), + '--log-level', + options.logLevel, + ]; + if (options.host !== undefined) { + args.push('--host', options.host); + } + if (options.debugEndpoints === true) { + args.push('--debug-endpoints'); + } + if (options.insecureNoTls === true) { + args.push('--insecure-no-tls'); + } + if (options.allowRemoteShutdown === true) { + args.push('--allow-remote-shutdown'); + } + if (options.allowRemoteTerminals === true) { + args.push('--allow-remote-terminals'); + } + if (options.dangerousBypassAuth === true) { + args.push('--dangerous-bypass-auth'); + } + if (options.keepAlive === true) { + args.push('--keep-alive'); + } + if (options.idleGraceMs !== undefined) { + args.push('--idle-grace-ms', String(options.idleGraceMs)); + } + if (options.allowedHosts !== undefined && options.allowedHosts.length > 0) { + args.push('--allowed-host', ...options.allowedHosts); + } + // On Windows `.mjs` files are not executable PE binaries, so we must run + // the script through the Node binary rather than spawning it directly. In + // SEA mode or when re-spawning from an already-running daemon, `program` is + // `process.execPath` itself, so no script argument is needed. + const execPath = process.execPath; + const spawnArgs = program === execPath ? args : [program, ...args]; + + const logFd = openSync(logPath, 'a'); + try { + const child = spawn(execPath, spawnArgs, { + detached: true, + // Run from the server log directory instead of inheriting the caller's + // cwd, so the long-lived daemon does not pin the directory it was + // launched from (notably blocking its deletion on Windows). + cwd: logDir, + stdio: ['ignore', logFd, logFd], + }); + child.once('error', (error) => { + // A spawn failure (e.g. ENOENT) surfaces asynchronously on the child, + // not as a thrown error. Without a listener Node would crash the parent + // with an unhandled 'error' event; record it instead and let the polling + // loop in `ensureDaemon` report the timeout. + try { + appendFileSync(logPath, `[spawner] failed to launch daemon: ${error.message}\n`); + } catch { + // Best-effort; the log directory may already be gone. + } + }); + child.unref(); + return child; + } finally { + // `spawn` dups the fd into the child; the parent must not keep it open. + closeSync(logFd); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => { + setTimeout(resolvePromise, ms); + }); +} + +/** + * Ensure a daemon is running and return its origin. Non-blocking for the + * caller beyond the short health wait — the server itself keeps running in a + * detached process after this returns. + */ +export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { + const host = options.host ?? DEFAULT_SERVER_HOST; + const preferred = options.port ?? DEFAULT_SERVER_PORT; + const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL; + + // 1. Reuse an already-live daemon if one holds the lock. + const existing = getLiveLock(); + if (existing) { + const origin = serverOrigin(lockConnectHost(existing), existing.port); + if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) { + return { + origin, + reused: true, + host: existing.host ?? DEFAULT_SERVER_HOST, + port: existing.port, + }; + } + // Live pid but not responding (wedged or mid-boot failure). Fall through + // and spawn: if it is truly wedged our child loses the lock race and we + // reconnect below; if it died, stale takeover lets our child claim it. + } + + // 2. No reusable daemon — pick a free port and spawn one detached. + const port = await resolveDaemonPort(host, preferred); + const child = spawnDaemonChild({ + host, + port, + logLevel, + debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, + dangerousBypassAuth: options.dangerousBypassAuth, + keepAlive: options.keepAlive, + allowedHosts: options.allowedHosts, + idleGraceMs: options.idleGraceMs, + }); + + // Watch for an early exit so a boot failure (e.g. the non-loopback TLS gate, + // a config error, or a lost lock race with no other daemon to fall back to) + // surfaces the real error immediately instead of waiting out the full spawn + // timeout. The exit code/signal plus a tail of the daemon log is what tells + // the operator *why* it failed. + let childExit: { code: number | null; signal: NodeJS.Signals | null } | undefined; + child.once('exit', (code, signal) => { + childExit = { code, signal }; + }); + child.once('error', () => { + // Spawn failure (ENOENT etc.) is already recorded in the log by + // spawnDaemonChild; treat it as an early exit here. + childExit = { code: -1, signal: null }; + }); + + // 3. Wait until some live daemon (ours, or a racer that won the lock) is up. + const deadline = Date.now() + SPAWN_TIMEOUT_MS; + while (Date.now() < deadline) { + const live = getLiveLock(); + if (live) { + const origin = serverOrigin(lockConnectHost(live), live.port); + if (await isServerHealthy(origin, 500)) { + return { + origin, + reused: false, + host: live.host ?? DEFAULT_SERVER_HOST, + port: live.port, + }; + } + } + if (childExit !== undefined && !live) { + // Our child exited and no other live daemon holds the lock to fall back + // to — this is a real boot failure, not a lost race. + throw new Error(formatDaemonBootFailure(childExit, daemonLogPath())); + } + await sleep(POLL_INTERVAL_MS); + } + + throw new Error( + `Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms.\n\n` + + formatLogTail(daemonLogPath()), + ); +} + +function formatDaemonBootFailure( + exit: { code: number | null; signal: NodeJS.Signals | null }, + logPath: string, +): string { + const reason = + exit.signal === null + ? `exited with code ${String(exit.code)}` + : `was terminated by signal ${exit.signal}`; + return `Kimi server daemon ${reason} during startup.\n\n${formatLogTail(logPath)}`; +} + +function formatLogTail(logPath: string): string { + const tail = tailFile(logPath, 30); + if (tail.length === 0) { + return `Check the log for details: ${logPath}`; + } + return `Last log lines (${logPath}):\n${tail}`; +} + +function tailFile(filePath: string, maxLines: number): string { + try { + const content = readFileSync(filePath, 'utf8'); + const lines = content.split('\n').filter((line) => line.length > 0); + return lines.slice(-maxLines).join('\n'); + } catch { + return ''; + } +} diff --git a/apps/kimi-code/src/cli/sub/server/index.ts b/apps/kimi-code/src/cli/sub/server/index.ts new file mode 100644 index 000000000..0e162fd59 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/index.ts @@ -0,0 +1,49 @@ +/** + * `kimi server` parent command. Mounts: + * - `server run` (background daemon by default; `--foreground` to attach; the + * detached daemon child runs the same command with `--daemon`) + * + * The OS service-manager subcommands (`install/uninstall/start/stop/restart/ + * status`) are temporarily NOT registered — see the commented + * `addLifecycleCommands(server)` below. Their implementation is preserved in + * `./lifecycle.ts` + `packages/kap-server/src/svc/*` for later re-exposure. + * + * The top-level `kimi web` alias is registered separately via + * `registerWebAliasCommand` so it stays at the program root. + */ + +import type { Command } from 'commander'; + +import { registerPsCommand } from './ps'; +import { registerKillCommand } from './kill'; +import { buildRunCommand } from './run'; +import { registerRotateTokenCommand } from './rotate-token'; +import { registerWebAliasCommand } from './web-alias'; + +export function registerServerCommand(program: Command): void { + const server = program + .command('server') + .description('Run the local Kimi server (REST + WebSocket + web UI).'); + + buildRunCommand( + server.command('run').description('Start the Kimi server (background daemon; use --foreground to attach).'), + { defaultOpen: false }, + ); + + registerPsCommand(server); + + registerKillCommand(server); + + registerRotateTokenCommand(server); + + // OS service-manager commands (`install/uninstall/start/stop/restart/status`) + // are temporarily hidden — the product now favors the on-demand background + // daemon (`kimi web`) over service-ization. The implementation still lives in + // `./lifecycle.ts` + `packages/kap-server/src/svc/*`; re-import + // `addLifecycleCommands` and call it here to re-expose. + // addLifecycleCommands(server); + + registerWebAliasCommand(program); +} + +export { registerWebAliasCommand }; diff --git a/apps/kimi-code/src/cli/sub/server/kill.ts b/apps/kimi-code/src/cli/sub/server/kill.ts new file mode 100644 index 000000000..92e5f00b0 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/kill.ts @@ -0,0 +1,167 @@ +/** + * `kimi server kill` — terminate the running server. + * + * Combines two independent mechanisms so the server dies even if one path + * fails: + * + * 1. API path — `POST /api/v1/shutdown` for a graceful, in-process shutdown + * (best-effort; older builds or a wedged server may not answer). + * 2. PID path — signal the pid recorded in the lock (SIGTERM → wait → + * SIGKILL). SIGKILL / TerminateProcess is the hard guarantee: + * it cannot be caught or ignored. + * + * The only honest failure mode is insufficient permissions (a process owned by + * another user), which surfaces as an error rather than a silent miss. + */ + +import type { Command } from 'commander'; + +import { getLiveLock, type LockContents } from '@moonshot-ai/kap-server'; + +import { getDataDir } from '#/utils/paths'; + +import { lockConnectHost } from './daemon'; +import { authHeaders, serverOrigin, tryResolveServerToken } from './shared'; + +/** How long to wait for the graceful API shutdown request. */ +const API_TIMEOUT_MS = 2000; +/** Grace period after SIGTERM before escalating to SIGKILL. */ +const TERM_GRACE_MS = 3000; +/** Grace period after SIGKILL before giving up. */ +const KILL_GRACE_MS = 2000; +/** Poll cadence while waiting for the pid to exit. */ +const POLL_INTERVAL_MS = 100; + +export interface KillCommandDeps { + getLiveLock(): LockContents | undefined; + requestShutdown(origin: string, token: string | undefined): Promise; + /** Best-effort read of the persistent bearer token; undefined on miss. */ + resolveToken(): string | undefined; + signalPid(pid: number, signal: NodeJS.Signals): boolean; + pidAlive(pid: number): boolean; + sleep(ms: number): Promise; + stdout: Pick; + now(): number; +} + +export function registerKillCommand(server: Command): void { + server + .command('kill') + .description('Stop the running Kimi server (graceful API + forced PID kill).') + .action(async () => { + try { + await handleKillCommand(DEFAULT_KILL_DEPS); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleKillCommand(deps: KillCommandDeps): Promise { + const lock = deps.getLiveLock(); + if (!lock) { + deps.stdout.write('No running Kimi server.\n'); + return; + } + + const { pid } = lock; + const origin = serverOrigin(lockConnectHost(lock), lock.port); + + // 1. API path — best-effort graceful shutdown. Ignore every outcome: the + // server may be an older build without the route, already wedged, or may + // drop the connection as it exits. The bearer token (M5.1) is best-effort + // too: if it can't be read the API call 401s and the PID path below still + // guarantees the kill. + const token = deps.resolveToken(); + await deps.requestShutdown(origin, token).catch(() => {}); + + // 2. PID path — SIGTERM, wait, then SIGKILL. + deps.signalPid(pid, 'SIGTERM'); + + if (await waitForExit(pid, TERM_GRACE_MS, deps)) { + deps.stdout.write(`Kimi server (pid ${String(pid)}) stopped.\n`); + return; + } + + deps.signalPid(pid, 'SIGKILL'); + + if (await waitForExit(pid, KILL_GRACE_MS, deps)) { + deps.stdout.write(`Kimi server (pid ${String(pid)}) killed.\n`); + return; + } + + throw new Error( + `Failed to stop Kimi server (pid ${String(pid)}); insufficient permissions?`, + ); +} + +async function waitForExit( + pid: number, + timeoutMs: number, + deps: Pick, +): Promise { + const deadline = deps.now() + timeoutMs; + do { + if (!deps.pidAlive(pid)) return true; + await deps.sleep(POLL_INTERVAL_MS); + } while (deps.now() < deadline); + return !deps.pidAlive(pid); +} + +/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return false; + // EPERM = process exists but we can't signal it. Treat as alive. + return true; + } +} + +/** Send `signal` to `pid`. Returns false if the signal could not be sent. */ +export function signalPid(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} + +/** POST the shutdown endpoint; resolves once the request completes or times out. */ +export async function requestShutdownViaApi( + origin: string, + token: string | undefined, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, API_TIMEOUT_MS); + try { + await fetch(`${origin}/api/v1/shutdown`, { + method: 'POST', + headers: token !== undefined ? authHeaders(token) : undefined, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +const DEFAULT_KILL_DEPS: KillCommandDeps = { + getLiveLock, + requestShutdown: requestShutdownViaApi, + resolveToken: () => tryResolveServerToken(getDataDir()), + signalPid, + pidAlive, + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + stdout: process.stdout, + now: () => Date.now(), +}; diff --git a/apps/kimi-code/src/cli/sub/server/lifecycle.ts b/apps/kimi-code/src/cli/sub/server/lifecycle.ts new file mode 100644 index 000000000..84352cebf --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/lifecycle.ts @@ -0,0 +1,254 @@ +/** + * `kimi server install/uninstall/start/stop/restart/status`. + * + * The lifecycle calls into the platform service manager from + * `@moonshot-ai/kap-server` (`src/svc/*`). + * + * The Commander wiring here mirrors `addGatewayServiceCommands` from + * `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`. + */ + +import type { Command } from 'commander'; + +import { + ServiceUnavailableError, + ServiceUnsupportedError, + resolveServiceManager, + type InstallArgs, + type ServiceManager, + type ServiceStatus, +} from '@moonshot-ai/kap-server'; + +import { openUrl as defaultOpenUrl } from '#/utils/open-url'; + +import { + DEFAULT_LOG_LEVEL, + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + LOCAL_SERVER_HOST, + parseLogLevel, + parsePort, + serverOrigin, + VALID_LOG_LEVELS, +} from './shared'; + +export interface InstallCliOptions { + port?: string; + logLevel?: string; + force?: boolean; + open?: boolean; + json?: boolean; +} + +export interface JsonCliOptions { + json?: boolean; +} + +export interface LifecycleCommandDeps { + resolveManager(): ServiceManager; + openUrl(url: string): void; + stdout: Pick; + stderr: Pick; +} + +const DEFAULT_DEPS: LifecycleCommandDeps = { + resolveManager: resolveServiceManager, + openUrl: defaultOpenUrl, + stdout: process.stdout, + stderr: process.stderr, +}; + +/** Mount install/uninstall/start/stop/restart/status under a parent command. */ +export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps = DEFAULT_DEPS): void { + parent + .command('install') + .description('Install the Kimi server as an OS-managed service (launchd/systemd/schtasks).') + .option('--port ', `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT)) + .option( + '--log-level ', + `Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`, + DEFAULT_LOG_LEVEL, + ) + .option('--force', 'Reinstall and overwrite if already installed', false) + .option('--no-open', 'Do not open the web UI after install.', true) + .option('--json', 'Output JSON', false) + .action(async (opts: InstallCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const args: InstallArgs = { + host: DEFAULT_SERVER_HOST, + port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), + logLevel: parseLogLevel(opts.logLevel), + force: opts.force === true, + }; + const result = await mgr.install(args); + const status = await readStatus(mgr); + const enriched = withStatusDetails({ + ok: true, + action: 'install', + status: result.status, + plistPath: result.plistPath, + unitPath: result.unitPath, + taskName: result.taskName, + message: result.message, + }, status, args); + if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') { + deps.openUrl(enriched.url); + } + return enriched; + }); + }); + + parent + .command('uninstall') + .description('Uninstall the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.uninstall(); + return { ok: result.ok, action: 'uninstall', message: result.message }; + }); + }); + + parent + .command('start') + .description('Start the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.start(); + const status = await readStatus(mgr); + return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status); + }); + }); + + parent + .command('stop') + .description('Stop the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.stop(); + return { ok: result.ok, action: 'stop', message: result.message }; + }); + }); + + parent + .command('restart') + .description('Restart the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.restart(); + const status = await readStatus(mgr); + return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status); + }); + }); + + parent + .command('status') + .description('Show Kimi server service status and connectivity.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const status: ServiceStatus = await mgr.status(); + return withStatusDetails({ ok: true, action: 'status', ...status }, status); + }); + }); +} + +async function runLifecycle( + deps: LifecycleCommandDeps, + json: boolean, + body: (mgr: ServiceManager) => Promise>, +): Promise { + try { + const mgr = deps.resolveManager(); + const result = await body(mgr); + if (json) { + deps.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + deps.stdout.write(formatHuman(result)); + } catch (error) { + if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) { + const payload = { + ok: false, + action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported', + platform: error.platform, + message: error.message, + }; + if (json) { + deps.stdout.write(`${JSON.stringify(payload)}\n`); + } else { + deps.stderr.write(`${error.message}\n`); + } + process.exit(2); + return; + } + if (json) { + deps.stdout.write( + `${JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })}\n`, + ); + } else { + deps.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + } + process.exit(1); + } +} + +function formatHuman(result: Record): string { + const rawAction = result['action']; + const action = typeof rawAction === 'string' ? rawAction : 'action'; + const rawMessage = result['message']; + const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : ''; + const lines = [`${action}${message}`]; + + const url = result['url']; + if (typeof url === 'string') lines.push(`URL: ${url}`); + + const running = result['running']; + if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`); + + const logPath = result['logPath']; + if (typeof logPath === 'string') lines.push(`Log: ${logPath}`); + + const notes = result['notes']; + if (Array.isArray(notes)) { + for (const note of notes) { + if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`); + } + } + + return `${lines.join('\n')}\n`; +} + +async function readStatus(mgr: ServiceManager): Promise { + try { + return await mgr.status(); + } catch { + return undefined; + } +} + +function withStatusDetails( + result: Record, + status: ServiceStatus | undefined, + fallback?: { host: string; port: number }, +): Record & { url?: string; running?: boolean } { + const host = status?.host ?? fallback?.host; + const port = status?.port ?? fallback?.port; + const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined; + return { + ...result, + url, + running: status?.running, + host, + port, + logPath: status?.logPath, + notes: status?.notes, + }; +} + +function formatServiceUrl(host: string, port: number): string { + return serverOrigin(host === '0.0.0.0' ? LOCAL_SERVER_HOST : host, port); +} diff --git a/apps/kimi-code/src/cli/sub/server/networks.ts b/apps/kimi-code/src/cli/sub/server/networks.ts new file mode 100644 index 000000000..39ca7d084 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/networks.ts @@ -0,0 +1,84 @@ +/** + * Enumerate this machine's non-loopback network interface addresses, used to + * print `Network: http://:/` hints (à la Vite) when the server + * binds a wildcard host (`0.0.0.0` / `::`). + */ + +import { networkInterfaces } from 'node:os'; + +export interface NetworkAddress { + /** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */ + address: string; + family: 'IPv4' | 'IPv6'; +} + +/** + * List non-internal interface addresses, IPv4 first then IPv6, preserving + * interface order within each family. + * + * Like Vite, this lists the machine's own interface addresses — LAN + * (192.168/10/172.16) plus any directly-assigned public address. It does not + * (and cannot, without an external service) discover a NAT-translated WAN IP, + * and we deliberately avoid any network call for a startup hint. + */ +export function listNetworkAddresses(): NetworkAddress[] { + const raw: NetworkAddress[] = []; + for (const entries of Object.values(networkInterfaces())) { + for (const info of entries ?? []) { + if (info.internal) { + continue; + } + if (info.family === 'IPv4') { + raw.push({ address: info.address, family: 'IPv4' }); + } else if (info.family === 'IPv6') { + raw.push({ address: info.address, family: 'IPv6' }); + } + } + } + return filterDisplayAddresses(raw); +} + +/** + * Drop addresses that are not useful as a connect target and de-duplicate. + * + * IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a + * zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it + * is pure noise — and it is the bulk of what `os.networkInterfaces()` reports + * on a typical machine. Duplicates (the same address reported on more than one + * interface) are collapsed. The result is IPv4 first, then IPv6, preserving + * order within each family. + */ +export function filterDisplayAddresses( + addrs: readonly NetworkAddress[], +): NetworkAddress[] { + const seen = new Set(); + const kept: NetworkAddress[] = []; + for (const addr of addrs) { + if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) { + continue; + } + if (seen.has(addr.address)) { + continue; + } + seen.add(addr.address); + kept.push(addr); + } + return [ + ...kept.filter((a) => a.family === 'IPv4'), + ...kept.filter((a) => a.family === 'IPv6'), + ]; +} + +/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::`–`febf::`). */ +function isLinkLocalV6(address: string): boolean { + const first = Number.parseInt(address.split(':')[0] ?? '', 16); + return first >= 0xfe80 && first <= 0xfebf; +} + +/** + * Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986, + * return IPv4 as-is. + */ +export function formatHostForUrl(address: string, family: NetworkAddress['family']): string { + return family === 'IPv6' ? `[${address}]` : address; +} diff --git a/apps/kimi-code/src/cli/sub/server/ps.ts b/apps/kimi-code/src/cli/sub/server/ps.ts new file mode 100644 index 000000000..33933e7cd --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/ps.ts @@ -0,0 +1,148 @@ +/** + * `kimi server ps` — list clients currently connected to the running server. + * + * Talks to the running server over HTTP (`GET /api/v1/connections`) using the + * single-instance lock (`~/.kimi-code/server/lock`) to discover its origin — + * the same way `kimi web` locates the daemon. + */ + +import chalk from 'chalk'; +import type { Command } from 'commander'; + +import { getLiveLock } from '@moonshot-ai/kap-server'; + +import { getDataDir } from '#/utils/paths'; + +import { lockConnectHost } from './daemon'; +import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared'; + +/** Wire shape of a single connection returned by `GET /api/v1/connections`. */ +interface ConnectionInfo { + id: string; + connected_at: string; + remote_address: string | null; + user_agent: string | null; + has_client_hello: boolean; + subscriptions: string[]; +} + +interface ConnectionsEnvelope { + code: number; + msg: string; + data?: { connections?: ConnectionInfo[] }; +} + +const HEALTH_TIMEOUT_MS = 1500; +const FETCH_TIMEOUT_MS = 5000; +const USER_AGENT_MAX_WIDTH = 40; + +export function registerPsCommand(server: Command): void { + server + .command('ps') + .description('List clients currently connected to the running Kimi server.') + .option('--json', 'Print the raw connection list as JSON.') + .action(async (opts: { json?: boolean }) => { + try { + await handlePsCommand(opts); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +async function handlePsCommand(opts: { json?: boolean }): Promise { + const lock = getLiveLock(); + if (!lock) { + throw new Error( + 'No running Kimi server. Start one with `kimi server run` or `kimi web`.', + ); + } + + const origin = serverOrigin(lockConnectHost(lock), lock.port); + if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) { + throw new Error(`Kimi server at ${origin} is not responding.`); + } + + // The `/api/v1/connections` route is gated by bearer auth (M5.1). Read the + // persistent token; a clear error here means the server has never been + // started (no token file yet) or the token file was removed. + const token = resolveServerToken(getDataDir()); + const connections = await fetchConnections(origin, token); + + if (opts.json) { + process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`); + return; + } + process.stdout.write(formatTable(connections)); +} + +async function fetchConnections(origin: string, token: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, FETCH_TIMEOUT_MS); + try { + const res = await fetch(`${origin}/api/v1/connections`, { + headers: authHeaders(token), + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`Failed to list clients: HTTP ${String(res.status)} from ${origin}.`); + } + const body = (await res.json()) as ConnectionsEnvelope; + if (body.code !== 0) { + throw new Error(`Failed to list clients: ${body.msg}`); + } + return body.data?.connections ?? []; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Timed out listing clients from ${origin}.`); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +function formatTable(connections: ConnectionInfo[]): string { + if (connections.length === 0) { + return 'No active clients.\n'; + } + + const header = ['ID', 'CONNECTED', 'REMOTE', 'USER_AGENT', 'SESSIONS', 'HELLO']; + const rows = connections.map((c) => [ + c.id, + formatAge(c.connected_at), + c.remote_address ?? '-', + truncate(c.user_agent ?? '-', USER_AGENT_MAX_WIDTH), + String(c.subscriptions.length), + c.has_client_hello ? 'yes' : 'no', + ]); + + const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length))); + const formatRow = (cells: string[]): string => + cells.map((cell, i) => cell + ' '.repeat(Math.max(0, widths[i]! - cell.length))).join(' '); + + const lines = [chalk.bold(formatRow(header)), ...rows.map(formatRow)]; + return `${lines.join('\n')}\n`; +} + +function formatAge(iso: string): string { + const ms = Date.now() - Date.parse(iso); + if (!Number.isFinite(ms) || ms < 0) return '-'; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${String(minutes)}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${String(hours)}h`; + const days = Math.floor(hours / 24); + return `${String(days)}d`; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + if (max <= 1) return value.slice(0, max); + return `${value.slice(0, max - 1)}…`; +} diff --git a/apps/kimi-code/src/cli/sub/server/rotate-token.ts b/apps/kimi-code/src/cli/sub/server/rotate-token.ts new file mode 100644 index 000000000..0acbdcc7c --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/rotate-token.ts @@ -0,0 +1,57 @@ +/** + * `kimi server rotate-token` — generate a new persistent server token. + * + * Rewrites `/server.token` (0600, atomic). The previous token + * stops working immediately: a running server re-reads the file on its next + * auth check, so rotation takes effect without a restart. + */ + +import { getLiveLock, rotateServerToken } from '@moonshot-ai/kap-server'; +import chalk from 'chalk'; +import type { Command } from 'commander'; + +import { darkColors } from '#/tui/theme/colors'; +import { getDataDir } from '#/utils/paths'; + +import { accessUrlLines, splitTokenFragment } from './access-urls'; +import { DEFAULT_SERVER_HOST } from './shared'; + +export function registerRotateTokenCommand(server: Command): void { + server + .command('rotate-token') + .description( + 'Generate a new persistent server token; the previous token stops working immediately.', + ) + .action(async () => { + try { + const token = await rotateServerToken(getDataDir()); + process.stdout.write( + 'The previous token is now invalid. A running server picks up the new token automatically.\n', + ); + + // Token in the middle: indented and set off by blank lines (no color + // highlight), so it is easy to spot without dominating the output. + process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`); + + // Re-print the access links with the new token so the user can + // reconnect immediately. When a server is running its bind host/port + // come from the lock; otherwise there is nothing to connect to yet. + const lock = getLiveLock(); + if (lock !== undefined) { + const host = lock.host ?? DEFAULT_SERVER_HOST; + for (const { label, url: href } of accessUrlLines(host, lock.port, token)) { + // De-emphasize the `#token=…` fragment so the host/port stands out. + const [base, frag] = splitTokenFragment(href); + const rendered = + frag === '' + ? chalk.hex(darkColors.accent)(base) + : chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag); + process.stdout.write(` ${chalk.dim(label)}${rendered}\n`); + } + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts new file mode 100644 index 000000000..b7a9ffb03 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -0,0 +1,600 @@ +/** + * `kimi server run` — starts the local server. + * + * By default this ensures a single background daemon is running (spawning a + * detached `kimi server run --daemon` child when needed) and returns once it is + * healthy. Pass `--foreground` to run the server in-process and keep this + * terminal attached until SIGINT/SIGTERM. OS-managed background operation + * (launchd / systemd / schtasks) lives in `kimi server install` + `kimi server start`. + * + * `kimi web` is an alias of this command with `--open` defaulted to `true`, + * registered in `./web-alias.ts`. + */ + +import { join } from 'node:path'; + +import { hostRequestHeadersSeed } from '@moonshot-ai/agent-core-v2'; +import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server'; +import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; +import chalk from 'chalk'; +import { Option, type Command } from 'commander'; + +import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app'; +import { getNativeWebAssetsDir } from '#/native/web-assets'; +import { darkColors } from '#/tui/theme/colors'; +import { openUrl as defaultOpenUrl } from '#/utils/open-url'; +import { getDataDir } from '#/utils/paths'; + +import { initializeServerTelemetry } from '../../telemetry'; +import { + buildKimiDefaultHeaders, + getHostPackageRoot, + getVersion, +} from '../../version'; +import { + accessUrlLines, + buildOpenableUrl, + isLoopbackHost, + splitTokenFragment, +} from './access-urls'; +import { ensureDaemon, type EnsureDaemonResult } from './daemon'; +import { type NetworkAddress } from './networks'; +import { + DEFAULT_FOREGROUND_LOG_LEVEL, + DEFAULT_LAN_HOST, + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + parseServerOptions, + tryResolveServerToken, + VALID_LOG_LEVELS, + type ParsedServerOptions, + type ServerCliOptions, +} from './shared'; + +const WEB_ASSETS_DIR = 'dist-web'; + +/** + * Minimal surface `runServerInProcess` needs from the server. kap-server's + * `RunningServer` is adapted to it (it returns `{ host, port, close }` + * instead of `{ address, logger, close }`). + */ +interface RoutedServer { + readonly address: string; + readonly logger: ServerLogger; + close(): Promise; +} + +export interface RunCliOptions extends ServerCliOptions { + open?: boolean; + /** Run the server in-process instead of spawning a background daemon. */ + foreground?: boolean; +} + +export interface StartForegroundHooks { + /** Fires once the server is listening, before the foreground runner blocks. */ + onReady?: (origin: string) => void; +} + +export interface RunCommandDeps { + startServerBackground(options: ParsedServerOptions): Promise<{ + origin: string; + /** True when an already-running daemon was reused (no new server started). */ + reused?: boolean; + /** Bind host the running daemon is actually listening on (from the lock). */ + host?: string; + /** Port the running daemon is actually listening on (from the lock). */ + port?: number; + }>; + /** Foreground runner; defaults to the real in-process runner when omitted. */ + startServerForeground?: ( + options: ParsedServerOptions, + hooks?: StartForegroundHooks, + ) => Promise; + openUrl(url: string): void; + /** + * Best-effort read of the server's persistent bearer token. When it returns + * a token, the ready banner prints it and the opened Web UI URL carries it in + * the `#token=` fragment (M5.5). Optional so callers/tests that don't supply + * it simply print/open the plain origin. + */ + resolveToken?: () => string | undefined; + /** + * Non-loopback interface addresses to display for a wildcard bind. Defaults + * to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed + * list in tests for deterministic output. + */ + networkAddresses?: NetworkAddress[]; + stdout: Pick; + stderr: Pick; +} + +/** + * Build the Web UI URL, carrying the bearer token in the URL fragment. + * + * The token rides in `#token=` — a client-side fragment that is never + * sent to the server (so it never appears in server access logs) and is not + * logged by proxies. The Web UI reads it from `location.hash` after load. + */ +export function buildWebUrl(origin: string, token: string): string { + return buildOpenableUrl(origin, token); +} + +/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ +export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command { + return cmd + .option( + '--port ', + `Bind port (default ${DEFAULT_SERVER_PORT})`, + String(DEFAULT_SERVER_PORT), + ) + .option( + '--host [host]', + `Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host for a specific host. The bearer token is printed at startup.`, + ) + .option( + '--allowed-host ', + 'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).', + ) + .option( + '--keep-alive', + 'Keep the server running instead of exiting after 60s with no connected clients. Implied automatically by --host / --allowed-host, and always on in --foreground mode.', + false, + ) + .option( + '--insecure-no-tls', + 'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.', + true, + ) + .option( + '--allow-remote-shutdown', + 'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).', + false, + ) + .option( + '--allow-remote-terminals', + 'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.', + false, + ) + .option( + '--dangerous-bypass-auth', + 'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.', + false, + ) + .option( + '--log-level ', + `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, + ) + .option( + '--debug-endpoints', + 'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.', + false, + ) + .option( + '--foreground', + 'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (do not daemonize).', + false, + ) + .option( + options.defaultOpen ? '--no-open' : '--open', + options.defaultOpen + ? 'Do not open the web UI in the default browser.' + : 'Open the web UI in the default browser once the server is healthy.', + options.defaultOpen, + ) + .addOption( + new Option('--daemon', 'Run as an idle-exiting background daemon (internal).').hideHelp(), + ) + .addOption( + new Option( + '--idle-grace-ms ', + 'Idle-shutdown grace in ms (daemon mode, internal).', + ).hideHelp(), + ) + .action(async (opts: RunCliOptions) => { + try { + await handleRunCommand(opts); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleRunCommand( + opts: RunCliOptions, + deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS, +): Promise { + const parsed = parseServerOptions(opts); + if (parsed.daemon) { + await startServerDaemon(parsed); + return; + } + // Foreground is always keep-alive: a server attached to the operator's + // terminal must never idle-kill itself. Background daemons respect the + // derived `--keep-alive` flag. + const runOptions: ParsedServerOptions = + opts.foreground === true ? { ...parsed, keepAlive: true } : parsed; + // Resolve the persistent token once: it is printed in the ready banner and + // rides in the opened Web UI URL's `#token=` fragment (M5.5). Falls back to + // the plain origin / no token line when unavailable. When auth is bypassed, + // the token is meaningless and is intentionally NOT shown or carried in the + // opened URL. + const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => { + const { origin } = result; + const host = result.host ?? parsed.host; + // When a daemon is reused, this command's flags were NOT applied to the + // already-running server. Don't trust the requested `--dangerous-bypass-auth` + // for display/open: treat the server as token-protected so we never hide a + // token the user actually needs, nor claim bypass for a server that is + // authenticating. (Probing the running server's `/meta` would give its real + // mode; we conservatively assume non-bypass on reuse.) + const effectiveBypass = result.reused === true ? false : parsed.dangerousBypassAuth; + const token = effectiveBypass ? undefined : deps.resolveToken?.(); + let output = ''; + if (result.reused === true) { + // A daemon was already running, so this command's --host/--port/etc. did + // not start a new one. Say so loudly, then print the actual running + // server's URLs (using its real bind host, not the requested one). + output += formatReuseNotice(origin); + } + output += + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatReadyBanner(origin, host, { + token, + networkAddresses: deps.networkAddresses, + dangerousBypassAuth: effectiveBypass, + }) + : formatReadyLine(origin, token, effectiveBypass); + deps.stdout.write(output); + if (opts.open === true) { + deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); + } + }; + if (opts.foreground === true) { + const run = deps.startServerForeground ?? startServerForeground; + await run(runOptions, { + onReady: (origin) => { + writeReady({ origin, reused: false, host: parsed.host }); + }, + }); + return; + } + const result = await deps.startServerBackground(runOptions); + writeReady(result); +} + +function formatReuseNotice(origin: string): string { + return ( + `${chalk.hex(darkColors.warning)('A server is already running')} at ${origin} — ` + + `the options from this command were not applied. ` + + `Run ${chalk.bold('kimi server kill')} first to bind a new host/port.\n` + ); +} + +function formatReadyLine( + origin: string, + token: string | undefined, + dangerousBypassAuth = false, +): string { + const notice = dangerousBypassAuth + ? `${formatDangerNoticeLines().join('\n')}\n` + : ''; + return `${notice}Kimi server: ${buildOpenableUrl(origin, token)}\n`; +} + +/** + * Red, impossible-to-miss notice emitted when `--dangerous-bypass-auth` + * disables the bearer-token gate. Shared by the full ready banner and the + * compact one-line output so the warning always shows regardless of log level. + */ +function formatDangerNoticeLines(): string[] { + const danger = (text: string): string => chalk.hex(darkColors.error)(text); + const dangerBold = (text: string): string => chalk.bold.hex(darkColors.error)(text); + return [ + ` ${dangerBold('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).')}`, + ` ${danger('Anyone who can reach this port gets full access. Only continue if you understand the risk.')}`, + ` ${danger(`If you are unsure, run `)}${dangerBold('kimi server kill')}${danger(' now to stop this process.')}`, + ]; +} + +/** + * `kimi server run` (non-daemon) — ensures a background daemon is running + * (spawning a detached `kimi server run --daemon` child if needed), then + * returns its origin so the caller can print the ready banner and exit. The + * server keeps running in the background after this returns. + */ +export async function startServerBackground( + options: ParsedServerOptions, +): Promise { + return ensureDaemon({ + host: options.host, + port: options.port, + logLevel: options.logLevel, + debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, + dangerousBypassAuth: options.dangerousBypassAuth, + keepAlive: options.keepAlive, + allowedHosts: options.allowedHosts, + idleGraceMs: options.idleGraceMs, + }); +} + +/** + * `kimi server run --daemon` — runs the local server as a background daemon. + * + * Spawned as a detached child by {@link startServerBackground}. The process is + * expected to be detached (no controlling terminal) and self-terminates after + * the last web client disconnects and a grace period elapses. The grace timer + * is driven by the WS connection count reported through `wsGatewayOptions`. + * Resolves only via `process.exit`. + */ +export async function startServerDaemon(options: ParsedServerOptions): Promise { + return runServerInProcess(options, { daemon: true }); +} + +/** + * `kimi server run --foreground` — runs the local server in-process, attached + * to the current terminal. Resolves only via `process.exit` (SIGINT/SIGTERM). + */ +export async function startServerForeground( + options: ParsedServerOptions, + hooks: StartForegroundHooks = {}, +): Promise { + return runServerInProcess(options, { daemon: false }, hooks.onReady); +} + +/** + * Start the server in the current process and block until shutdown. Shared by + * the detached daemon (`daemon: true`, with idle-exit) and the foreground + * runner (`daemon: false`). `onReady` fires once the server is listening. + */ +async function runServerInProcess( + options: ParsedServerOptions, + mode: { daemon: boolean }, + onReady?: (origin: string) => void, +): Promise { + const version = getVersion(); + // Registers the telemetry provider for `track` / `shutdownTelemetry`; the + // client itself is not passed into kap-server. + initializeServerTelemetry({ version }); + + let running: RoutedServer | undefined; + let stopping = false; + + // Idle auto-shutdown is only for the on-demand personal daemon. It is skipped + // in foreground mode (`mode.daemon` is false) and whenever `--keep-alive` is + // set — explicitly, or implied by `--host` / `--allowed-host`. + const idle = + mode.daemon && !options.keepAlive + ? createIdleShutdownHandler({ + graceMs: options.idleGraceMs, + onIdle: () => { + void shutdown('idle'); + }, + }) + : undefined; + + async function shutdown(reason: string): Promise { + if (stopping) return; + stopping = true; + idle?.cancel(); + running?.logger.info({ reason }, 'server shutting down'); + try { + await running?.close(); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } catch (error) { + running?.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'server shutdown error', + ); + } + process.exit(0); + } + + // kap-server (the DI × Scope engine server) is the only server flavor. Its + // `startServer` returns `{ host, port, close }` rather than `{ address, + // logger, close }`, so adapt it to the `RoutedServer` surface the rest of + // this runner consumes. + const logger = createServerLogger({ level: options.logLevel }); + const v2 = await startServer({ + host: options.host, + port: options.port, + // Report the CLI's product version as `server_version` (/meta, web UI) + // rather than kap-server's private package version. + version, + logLevel: options.logLevel, + logger, + debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, + allowedHosts: options.allowedHosts, + disableAuth: options.dangerousBypassAuth, + // Seed the CLI's Kimi identity headers so the engine's outbound + // requests (model, WebSearch, FetchURL) carry the same User-Agent + + // X-Msh-* identity as direct CLI runs. + seeds: hostRequestHeadersSeed(buildKimiDefaultHeaders(version)), + webAssetsDir: serverWebAssetsDir(), + }); + // The connection registry exposes no count-change hook, so forward + // add/remove to the daemon's idle-shutdown handler (a no-op when `idle` + // is undefined, e.g. foreground or --keep-alive). + if (idle !== undefined) { + const registry = v2.connectionRegistry; + const add = registry.add.bind(registry); + const remove = registry.remove.bind(registry); + registry.add = (conn) => { + add(conn); + idle.onConnectionCountChange(registry.size()); + }; + registry.remove = (connId) => { + remove(connId); + idle.onConnectionCountChange(registry.size()); + }; + } + logger.info('serving the REST/WS API and the bundled web UI'); + running = { + address: `http://${v2.host}:${v2.port}`, + logger, + close: () => v2.close(), + }; + + track('server_started', { daemon: mode.daemon }); + + process.once('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.once('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + const readyFields = mode.daemon + ? options.keepAlive + ? { address: running.address, idleShutdown: 'disabled' as const } + : { address: running.address, idleGraceMs: options.idleGraceMs } + : { address: running.address }; + running.logger.info(readyFields, mode.daemon ? 'daemon ready' : 'server ready'); + + onReady?.(running.address); + + return new Promise(() => { + // Keeps the event loop alive; the process ends via shutdown()/process.exit. + }); +} + +/** + * Pure idle-shutdown state machine, exported for tests. + * + * Watches the live WS connection count and fires `onIdle` exactly once, after + * the count has dropped back to zero for `graceMs` ms *and* at least one + * client had connected since startup. A reconnect before the grace elapses + * cancels the pending exit. The initial "no clients yet" state never arms the + * timer (so a freshly-spawned daemon is not killed before anyone connects). + */ +export function createIdleShutdownHandler(opts: { graceMs: number; onIdle: () => void }): { + onConnectionCountChange(size: number): void; + cancel(): void; +} { + let timer: NodeJS.Timeout | undefined; + let seenClient = false; + + const cancel = (): void => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + }; + + return { + onConnectionCountChange(size: number): void { + if (size > 0) { + seenClient = true; + cancel(); + return; + } + if (seenClient) { + cancel(); + timer = setTimeout(opts.onIdle, opts.graceMs); + } + }, + cancel, + }; +} + +function serverWebAssetsDir(): string { + return resolveServerWebAssetsDir(); +} + +export function resolveServerWebAssetsDir( + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string { + return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); +} + +interface FormatReadyBannerOptions { + /** Persistent bearer token to print; omitted when unresolvable. */ + token?: string; + /** Non-loopback interface addresses to list for a wildcard bind. */ + networkAddresses?: NetworkAddress[]; + /** When true, render a red danger notice (auth is disabled). */ + dangerousBypassAuth?: boolean; +} + +function formatReadyBanner( + origin: string, + host: string, + opts: FormatReadyBannerOptions = {}, +): string { + const primary = (text: string): string => chalk.hex(darkColors.primary)(text); + const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); + const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const url = (text: string): string => chalk.hex(darkColors.accent)(text); + // Render the `#token=…` fragment in a de-emphasized gray so the host/port + // stands out while the full URL stays selectable for copying. + const urlWithDimToken = (href: string): string => { + const [base, frag] = splitTokenFragment(href); + return frag === '' ? url(base) : url(base) + dim(frag); + }; + + const port = Number(new URL(origin).port); + // Borderless header: the Kimi sprite (the little mascot with eyes) sits next + // to the title, keeping the brand without the enclosing box. + const logo = ['▐█▛█▛█▌', '▐█████▌'] as const; + const lines: string[] = [ + '', + ` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`, + ` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`, + '', + ]; + + if (opts.dangerousBypassAuth === true) { + // Red, impossible-to-miss notice: the bearer-token gate is off, so anyone + // who can reach this port gets full session / filesystem / shell access. + lines.push(...formatDangerNoticeLines(), ''); + } + + // Access links. + for (const { label: text, url: href } of accessUrlLines( + host, + port, + opts.token, + opts.networkAddresses, + )) { + lines.push(` ${label(text)}${urlWithDimToken(href)}`); + } + // On a loopback bind there is no network URL; show how to enable one. + if (isLoopbackHost(host)) { + lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host to enable')}`); + } + if (opts.token !== undefined) { + // Set the token off with surrounding whitespace rather than color, so it is + // easy to spot without being highlighted. + lines.push(''); + lines.push(` ${label('Token: ')}${opts.token}`); + lines.push(''); + } + + // Auxiliary controls last. + lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); + lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`); + lines.push(''); + return lines.join('\n'); +} + +const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { + startServerBackground, + startServerForeground, + openUrl: defaultOpenUrl, + resolveToken: () => { + // Read the persistent `/server.token` written on first boot + // (M5.1). Best-effort: a missing/older server yields undefined and the + // caller opens the plain origin. + return tryResolveServerToken(getDataDir()); + }, + stdout: process.stdout, + stderr: process.stderr, +}; diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts new file mode 100644 index 000000000..bcf96d22c --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -0,0 +1,279 @@ +/** + * Shared helpers for `kimi server …` subcommands. + * + * Owns the default host/port, option parsers, and health/readiness probes that + * `run`, `web`, and `status` all use. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { ServerLogLevel } from '@moonshot-ai/kap-server'; + +export const LOCAL_SERVER_HOST = '127.0.0.1'; +export const DEFAULT_LAN_HOST = '0.0.0.0'; +export const DEFAULT_SERVER_HOST = LOCAL_SERVER_HOST; +export const DEFAULT_SERVER_PORT = 58627; +export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT); + +/** Filename (under KIMI_CODE_HOME) of the persistent server bearer token. */ +export const SERVER_TOKEN_FILE = 'server.token'; + +export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info'; +export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent'; + +/** + * Default idle-shutdown grace for the background daemon: once the last web + * client disconnects, the daemon waits this long before exiting. Overridable + * via the internal `--idle-grace-ms` flag (used by tests). + */ +export const DEFAULT_IDLE_GRACE_MS = 60_000; + +export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [ + 'fatal', + 'error', + 'warn', + 'info', + 'debug', + 'trace', + 'silent', +]; + +export interface ParsedServerOptions { + host: string; + port: number; + logLevel: ServerLogLevel; + debugEndpoints: boolean; + /** Allow a non-loopback bind without a TLS-terminating reverse proxy. */ + insecureNoTls: boolean; + /** Allow `POST /api/v1/shutdown` on a non-loopback bind. */ + allowRemoteShutdown: boolean; + /** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */ + allowRemoteTerminals: boolean; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + dangerousBypassAuth: boolean; + /** Extra `Host` header values to allow through the DNS-rebinding check. */ + allowedHosts: readonly string[]; + /** + * Keep the server running instead of idle-killing it after 60s with no + * connected clients (`--keep-alive`). Also implied automatically by a + * non-default bind (`--host`) or a proxy/tunnel setup (`--allowed-host`), + * and always on in `--foreground` mode. Only the daemon mode consults this — + * foreground never idle-kills regardless. + */ + keepAlive: boolean; + /** Internal: run as an idle-exiting background daemon instead of foreground. */ + daemon: boolean; + /** Internal: idle-shutdown grace in ms (daemon mode only). */ + idleGraceMs: number; +} + +export interface ServerCliOptions { + host?: string | boolean; + port?: string; + logLevel?: string; + debugEndpoints?: boolean; + /** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */ + insecureNoTls?: boolean; + /** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */ + allowRemoteShutdown?: boolean; + /** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */ + allowRemoteTerminals?: boolean; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + dangerousBypassAuth?: boolean; + /** Extra `Host` header values to allow (`--allowed-host`). */ + allowedHost?: string[]; + /** Keep the server running instead of idle-killing it (`--keep-alive`). */ + keepAlive?: boolean; + /** Internal flag set by the daemon spawner (`kimi web`). */ + daemon?: boolean; + /** Internal flag set by the daemon spawner / tests. */ + idleGraceMs?: string; +} + +export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions { + const host = parseHost(opts.host); + const allowedHosts = parseAllowedHostArgs(opts.allowedHost); + // `--keep-alive` is explicit, but also implied by a non-default bind + // (`--host`) or a proxy/tunnel setup (`--allowed-host`). Foreground mode is + // forced keep-alive later in `handleRunCommand`. + const keepAlive = + opts.keepAlive === true || host !== DEFAULT_SERVER_HOST || allowedHosts.length > 0; + return { + host, + port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), + logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), + debugEndpoints: opts.debugEndpoints === true, + insecureNoTls: opts.insecureNoTls !== false, + allowRemoteShutdown: opts.allowRemoteShutdown === true, + allowRemoteTerminals: opts.allowRemoteTerminals === true, + dangerousBypassAuth: opts.dangerousBypassAuth === true, + allowedHosts, + keepAlive, + daemon: opts.daemon === true, + idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), + }; +} + +export function parseAllowedHostArgs(raw: readonly string[] | undefined): string[] { + if (raw === undefined) return []; + return raw + .flatMap((entry) => entry.split(',')) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function parseHost(raw: string | boolean | undefined): string { + if (raw === undefined || raw === false) return DEFAULT_SERVER_HOST; + if (raw === true || raw === '') return DEFAULT_LAN_HOST; + return raw; +} + +function parseIdleGraceMs(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_IDLE_GRACE_MS; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`error: invalid --idle-grace-ms value: ${raw}`); + } + return n; +} + +export function parsePort(raw: string | undefined, label: string, fallback: number): number { + if (raw === undefined) return fallback; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0 || n > 65535) { + throw new Error(`error: invalid ${label} value: ${raw}`); + } + return n; +} + +export function parseLogLevel(raw: string | undefined): ServerLogLevel { + if (raw === undefined) return DEFAULT_LOG_LEVEL; + if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) { + return raw as ServerLogLevel; + } + throw new Error( + `error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`, + ); +} + +export function serverOrigin(host: string, port: number): string { + return `http://${host}:${port}`; +} + +/** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */ +export function normalizeServerOrigin(value: string): string { + const url = new URL(value); + url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +/** Single probe of `/api/v1/healthz`. Returns true if the response envelope reports `code: 0`. */ +export async function isServerHealthy(origin: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); + try { + const response = await fetch(`${origin}/api/v1/healthz`, { + signal: controller.signal, + }); + if (!response.ok) return false; + const body = (await response.json()) as { code?: unknown }; + return body.code === 0; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +/** Poll `/api/v1/healthz` until it reports healthy or `timeoutMs` elapses. */ +export async function waitForServerHealthy(origin: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + do { + if (await isServerHealthy(origin, 500)) { + return true; + } + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + } while (Date.now() < deadline); + return false; +} + +/** + * Probe `/` and confirm the bundled web UI is being served. + * + * A different build that runs on the same port serves its own bundle — opening + * a browser at that origin lands on stale code. Catching that here lets the + * caller surface a clear "stop the running server" message instead of silently + * handing the user the wrong UI. + */ +export async function ensureServerWebReady(origin: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 3000); + try { + const response = await fetch(`${origin}/`, { + headers: { accept: 'text/html' }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const body = await response.text(); + if (!body.includes('
/server.token` (0600) on first boot and reuses + * it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route + * read it back here and send it as `Authorization: Bearer `. `homeDir` + * is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`). + * + * Throws a clear error when the file is missing/unreadable — the usual cause + * is a server that has never been started (no token file yet), or an older + * build that predates token auth. + */ +export function resolveServerToken(homeDir: string): string { + const tokenPath = join(homeDir, SERVER_TOKEN_FILE); + try { + return readFileSync(tokenPath, 'utf8').trim(); + } catch (error) { + throw new Error( + `unable to read server token at ${tokenPath}; has the server been started at least once?`, + { cause: error }, + ); + } +} + +/** Best-effort token read: returns `undefined` instead of throwing. */ +export function tryResolveServerToken(homeDir: string): string | undefined { + try { + return resolveServerToken(homeDir); + } catch { + return undefined; + } +} + +/** An `Authorization: Bearer ` header bag for `fetch`. */ +export function authHeaders(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} diff --git a/apps/kimi-code/src/cli/sub/server/web-alias.ts b/apps/kimi-code/src/cli/sub/server/web-alias.ts new file mode 100644 index 000000000..eb4831890 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/web-alias.ts @@ -0,0 +1,22 @@ +/** + * `kimi web` — open the Kimi web UI. + * + * Shares the exact same code path as `kimi server run`: it is registered via + * the same `buildRunCommand` builder (and therefore the same `handleRunCommand` + * handler, the same background-daemon flow, and the same ready banner) with + * `defaultOpen` flipped to `true`. The only difference from `server run` is + * that `web` opens the browser by default. + */ + +import type { Command } from 'commander'; + +import { buildRunCommand } from './run'; + +export function registerWebAliasCommand(program: Command): void { + buildRunCommand( + program + .command('web') + .description('Open the Kimi web UI (starts a background daemon if needed).'), + { defaultOpen: true }, + ); +} diff --git a/apps/kimi-code/src/cli/sub/upgrade.ts b/apps/kimi-code/src/cli/sub/upgrade.ts new file mode 100644 index 000000000..c54710645 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/upgrade.ts @@ -0,0 +1,224 @@ +import { log, type Logger } from '@moonshot-ai/kimi-code-sdk'; +import { track as trackTelemetry, type TelemetryProperties } from '@moonshot-ai/kimi-telemetry'; + +import { refreshUpdateCache } from '#/cli/update/refresh'; +import { selectUpdateTarget } from '#/cli/update/select'; +import { detectInstallSource } from '#/cli/update/source'; +import { + canAutoInstall, + installCommandFor, + installUpdate as installUpdateForeground, + renderInstallSuccessMessage, + renderManualUpdateMessage, +} from '#/cli/update/preflight'; +import { + promptForInstallChoice, + type InstallPromptChoiceValue, + type InstallPromptOptions, +} from '#/cli/update/prompt'; +import { + NPM_PACKAGE_NAME, + type InstallSource, + type UpdateCache, +} from '#/cli/update/types'; + +interface WritableLike { + write(chunk: string): boolean; +} + +type UpgradeTrack = (event: string, properties?: TelemetryProperties) => void; +type UpgradeLogger = Pick; + +export interface UpgradeDeps { + readonly refreshUpdateCache: () => Promise; + readonly detectInstallSource: () => Promise; + readonly installUpdate: ( + source: InstallSource, + version: string, + platform: NodeJS.Platform, + ) => Promise; + readonly promptForInstallChoice: ( + options: InstallPromptOptions, + ) => Promise; + readonly platform: NodeJS.Platform; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly isInteractive: boolean; + readonly track: UpgradeTrack; + readonly logger: UpgradeLogger; +} + +export async function handleUpgrade( + currentVersion: string, + overrides: Partial = {}, +): Promise { + const deps = createDefaultUpgradeDeps(overrides); + + let cache: UpdateCache; + try { + cache = await deps.refreshUpdateCache(); + } catch (error) { + const reason = formatErrorMessage(error); + trackUpgradeEvent(deps.track, 'upgrade_command_failed', { + current_version: currentVersion, + stage: 'refresh', + reason, + }); + logUpgradeWarn(deps.logger, 'manual upgrade check failed', { + currentVersion, + error, + }); + deps.stderr.write(`error: failed to check for updates: ${reason}\n`); + return 1; + } + + const target = selectUpdateTarget(currentVersion, cache.latest); + if (target === null) { + trackUpgradeEvent(deps.track, 'upgrade_command_no_update', { + current_version: currentVersion, + }); + logUpgradeInfo(deps.logger, 'manual upgrade no update', { + currentVersion, + }); + deps.stdout.write(`Kimi Code is already up to date (${formatDisplayVersion(currentVersion)}).\n`); + return 0; + } + + const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); + const installCommand = installCommandFor(source, target.version, deps.platform); + if (!canAutoInstall(source, deps.platform) || !deps.isInteractive) { + trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade command shown', { + currentVersion, + targetVersion: target.version, + source, + }); + deps.stdout.write(renderManualUpdateMessage(currentVersion, target, source, installCommand)); + return 0; + } + + trackUpgradeEvent(deps.track, 'upgrade_command_prompted', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade prompted', { + currentVersion, + targetVersion: target.version, + source, + }); + const choice = await deps.promptForInstallChoice({ + currentVersion, + target, + installCommand, + installSource: source, + }); + if (choice === 'skip') { + trackUpgradeEvent(deps.track, 'upgrade_command_skipped', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade skipped', { + currentVersion, + targetVersion: target.version, + source, + }); + return 0; + } + + try { + trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', { + current_version: currentVersion, + target_version: target.version, + source, + }); + await deps.installUpdate(source, target.version, deps.platform); + trackUpgradeEvent(deps.track, 'upgrade_command_succeeded', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade install succeeded', { + currentVersion, + targetVersion: target.version, + source, + }); + deps.stdout.write(renderInstallSuccessMessage(target)); + return 0; + } catch (error) { + trackUpgradeEvent(deps.track, 'upgrade_command_failed', { + current_version: currentVersion, + target_version: target.version, + source, + stage: 'install', + reason: formatErrorMessage(error), + }); + logUpgradeWarn(deps.logger, 'manual upgrade install failed', { + currentVersion, + targetVersion: target.version, + source, + error, + }); + deps.stderr.write( + `warning: failed to install ${NPM_PACKAGE_NAME}@${target.version}: ` + + `${formatErrorMessage(error)}\n`, + ); + return 1; + } +} + +function createDefaultUpgradeDeps(overrides: Partial): UpgradeDeps { + return { + refreshUpdateCache: overrides.refreshUpdateCache ?? (() => refreshUpdateCache()), + detectInstallSource: overrides.detectInstallSource ?? (() => detectInstallSource()), + installUpdate: overrides.installUpdate ?? installUpdateForeground, + promptForInstallChoice: overrides.promptForInstallChoice ?? promptForInstallChoice, + platform: overrides.platform ?? process.platform, + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + isInteractive: overrides.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY), + track: overrides.track ?? trackTelemetry, + logger: overrides.logger ?? log, + }; +} + +function formatDisplayVersion(version: string): string { + return version.startsWith('v') ? version : `v${version}`; +} + +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function trackUpgradeEvent( + track: UpgradeTrack, + event: string, + properties: TelemetryProperties, +): void { + try { + track(event, properties); + } catch { + // Telemetry must never affect upgrade flow. + } +} + +function logUpgradeInfo(logger: UpgradeLogger, message: string, payload: Record): void { + try { + logger.info(message, payload); + } catch { + // Diagnostic logging must never affect upgrade flow. + } +} + +function logUpgradeWarn(logger: UpgradeLogger, message: string, payload: Record): void { + try { + logger.warn(message, payload); + } catch { + // Diagnostic logging must never affect upgrade flow. + } +} diff --git a/apps/kimi-code/src/cli/sub/vis.ts b/apps/kimi-code/src/cli/sub/vis.ts new file mode 100644 index 000000000..7e6f2e1f2 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/vis.ts @@ -0,0 +1,158 @@ +/** + * `kimi vis` sub-command. + * + * CLI glue only: resolves the kimi home, starts the in-process session + * visualizer server (auto-picking a free port by default), prints the URL, + * optionally opens the browser (with an optional session deep-link), then + * waits for Ctrl-C and shuts the server down. The visualizer server itself + * lives in `@moonshot-ai/vis-server`. + */ + +import type { Command } from 'commander'; + +import { createCliTelemetryBootstrap } from '#/cli/telemetry'; +import { openUrl } from '#/utils/open-url'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface StartedVisServer { + readonly port: number; + readonly host: string; + readonly url: string; + readonly close: () => Promise; +} + +export interface StartVisServerArgs { + readonly homeDir: string; + readonly port: number; + readonly host?: string; + readonly webAsset?: { gzipped: Uint8Array }; +} + +export interface VisDeps { + readonly getHomeDir: () => string; + readonly startVisServer: (opts: StartVisServerArgs) => Promise; + readonly openUrl: (url: string) => Promise; + readonly waitForShutdown: () => Promise; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; +} + +export interface VisOptions { + readonly open: boolean; + readonly port?: number; + readonly host?: string; + readonly sessionId?: string; +} + +export async function handleVis(deps: VisDeps, opts: VisOptions): Promise { + const homeDir = deps.getHomeDir(); + + // Lazily load the embedded single-file SPA so normal `kimi` startup never + // pays for it. The module is generated at build time (prebuild). When running + // from source without a build — e.g. tests — the generated value module is + // absent and the dynamic import throws; in that case the server falls back to + // its own static `public/` directory. + let webAsset: { gzipped: Uint8Array } | undefined; + try { + const { VIS_WEB_GZIP_B64 } = await import('#/generated/vis-web-asset'); + if (VIS_WEB_GZIP_B64.length > 0) { + webAsset = { gzipped: new Uint8Array(Buffer.from(VIS_WEB_GZIP_B64, 'base64')) }; + } + } catch { + // Embedded asset not generated in this context — fall back to filesystem. + } + + let server: StartedVisServer; + try { + server = await deps.startVisServer({ + homeDir, + port: opts.port ?? 0, + ...(opts.host === undefined ? {} : { host: opts.host }), + ...(webAsset === undefined ? {} : { webAsset }), + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + deps.stderr.write(`Failed to start kimi vis: ${msg}\n`); + return deps.exit(1); + } + + const target = + opts.sessionId === undefined + ? server.url + : `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`; + + deps.stdout.write(`kimi vis is running at ${server.url}\n`); + deps.stdout.write('Press Ctrl-C to stop.\n'); + + if (opts.open) { + try { + await deps.openUrl(target); + } catch { + deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`); + } + } + + await deps.waitForShutdown(); + await server.close(); +} + +export function registerVisCommand(parent: Command, overrides?: Partial): void { + parent + .command('vis') + .description('Launch the session visualizer in your browser.') + .option('--port ', 'Port to bind. Default: auto-pick a free port.') + .option('--host ', 'Host to bind. Default: 127.0.0.1.') + .option('--no-open', 'Do not open the browser automatically.') + .argument('[sessionId]', 'Open directly to this session.') + .action( + async ( + sessionId: string | undefined, + options: { port?: string; host?: string; open?: boolean }, + ) => { + const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10); + await handleVis(createDefaultVisDeps(overrides), { + open: options.open !== false, + ...(port === undefined || Number.isNaN(port) ? {} : { port }), + ...(options.host === undefined ? {} : { host: options.host }), + ...(sessionId === undefined ? {} : { sessionId }), + }); + }, + ); +} + +function createDefaultVisDeps(overrides: Partial = {}): VisDeps { + return { + getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir), + startVisServer: + overrides.startVisServer ?? + (async (opts) => { + // Dynamic import keeps the vis server (and Hono) out of the hot path. + const { startVisServer } = await import('@moonshot-ai/vis-server/start'); + return startVisServer(opts); + }), + // `openUrl` is a synchronous fire-and-forget; adapt it to the async dep. + openUrl: + overrides.openUrl ?? + (async (url: string) => { + openUrl(url); + }), + waitForShutdown: overrides.waitForShutdown ?? waitForSigint, + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + }; +} + +function waitForSigint(): Promise { + return new Promise((resolve) => { + const onSig = (): void => { + process.off('SIGINT', onSig); + resolve(); + }; + process.on('SIGINT', onSig); + }); +} diff --git a/apps/kimi-code/src/cli/telemetry.ts b/apps/kimi-code/src/cli/telemetry.ts index b0f85fd01..7a52cc23d 100644 --- a/apps/kimi-code/src/cli/telemetry.ts +++ b/apps/kimi-code/src/cli/telemetry.ts @@ -1,8 +1,24 @@ import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; -import { initializeTelemetry } from '@moonshot-ai/kimi-telemetry'; -import { resolveKimiHome, type KimiConfig, type KimiHarness } from '@moonshot-ai/kimi-code-sdk'; +import { + KimiAuthFacade, + loadRuntimeConfigSafe, + resolveConfigPath, + resolveKimiHome, + type KimiConfig, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; -import { CLI_USER_AGENT_PRODUCT } from '#/constant/app'; +import type { PromptHarness } from './prompt-session'; +import { + initializeTelemetry, + setTelemetryContext, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-telemetry'; + +import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app'; + +import { createKimiCodeHostIdentity } from './version'; export interface CliTelemetryBootstrap { readonly homeDir: string; @@ -11,12 +27,13 @@ export interface CliTelemetryBootstrap { } export interface InitializeCliTelemetryOptions { - readonly harness: KimiHarness; + readonly harness: PromptHarness; readonly bootstrap: CliTelemetryBootstrap; readonly config: Pick; readonly version: string; readonly uiMode: string; readonly model?: string; + readonly sessionId?: string; } export function createCliTelemetryBootstrap(): CliTelemetryBootstrap { @@ -39,6 +56,7 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): version: options.version, uiMode: options.uiMode, model: options.model ?? options.config.defaultModel, + sessionId: options.sessionId, getAccessToken: async () => (await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); @@ -46,3 +64,67 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): options.harness.track('first_launch'); } } + +export interface InitializeServerTelemetryOptions { + readonly version: string; +} + +/** + * Bootstrap telemetry for the `kimi web` / `kimi server run` host. + * + * Mirrors {@link initializeCliTelemetry}: mints the device id, reads config to + * honor the `telemetry` toggle and pick up the default model, attaches the + * sink with `ui_mode = "web"`, and returns a {@link TelemetryClient} the + * caller hands to `startServer` via `coreProcessOptions.telemetry`. That wires + * the same real client into `KimiCore`, so agent-core events emitted inside the + * server process (`mcp_connected`, `session_load_failed`, plan-mode / cron + * events, …) actually leave the process carrying the enriched context + * (`app_name` / `version` / `ui_mode` / `model` / platform fields). + * + * The returned client wraps the `@moonshot-ai/kimi-telemetry` module + * functions, so the module-level `track` / `withTelemetryContext` (used to + * fire the startup event) share the same underlying client + sink. + */ +export function initializeServerTelemetry( + options: InitializeServerTelemetryOptions, +): TelemetryClient { + const bootstrap = createCliTelemetryBootstrap(); + const configPath = resolveConfigPath({ homeDir: bootstrap.homeDir }); + const config = readServerTelemetryConfig(configPath); + const auth = new KimiAuthFacade({ + homeDir: bootstrap.homeDir, + configPath, + identity: createKimiCodeHostIdentity(options.version), + }); + + initializeTelemetry({ + homeDir: bootstrap.homeDir, + deviceId: bootstrap.deviceId, + enabled: config.telemetry !== false, + appName: CLI_USER_AGENT_PRODUCT, + version: options.version, + uiMode: WEB_UI_MODE, + model: config.defaultModel, + getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, + }); + + return { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; +} + +function readServerTelemetryConfig( + configPath: string, +): Pick { + try { + const { config, fileError } = loadRuntimeConfigSafe(configPath); + // A broken config fails the server on its own inside KimiCore; for + // telemetry just degrade to "enabled, no model" so we never block startup. + if (fileError !== undefined) return {}; + return config; + } catch { + return {}; + } +} diff --git a/apps/kimi-code/src/cli/update/cache.ts b/apps/kimi-code/src/cli/update/cache.ts index 83132bf36..63885e2bb 100644 --- a/apps/kimi-code/src/cli/update/cache.ts +++ b/apps/kimi-code/src/cli/update/cache.ts @@ -3,13 +3,20 @@ import { z } from 'zod'; import { getUpdateStateFile } from '#/utils/paths'; import { readJsonFile, writeJsonFile } from '#/utils/persistence'; +import { UpdateManifestSchema } from './cdn'; import { emptyUpdateCache, type UpdateCache } from './types'; -const UpdateCacheSchema: z.ZodType = z +// Stays `.strict()` (we own this file), but a malformed manifest is treated +// as no manifest so one bad optional field does not discard the whole cache. +const UpdateCacheSchema = z .object({ source: z.literal('cdn'), checkedAt: z.string().min(1).nullable(), latest: z.string().min(1).nullable(), + manifest: z.preprocess((value) => { + const parsed = UpdateManifestSchema.nullable().safeParse(value === undefined ? null : value); + return parsed.success ? parsed.data : null; + }, z.union([UpdateManifestSchema, z.null()])), }) .strict(); diff --git a/apps/kimi-code/src/cli/update/cdn.ts b/apps/kimi-code/src/cli/update/cdn.ts index 5664f021f..4990568c9 100644 --- a/apps/kimi-code/src/cli/update/cdn.ts +++ b/apps/kimi-code/src/cli/update/cdn.ts @@ -1,6 +1,49 @@ import { valid } from 'semver'; +import { z } from 'zod'; -import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; +import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; + +import type { UpdateManifest } from './types'; + +const CDN_FETCH_TIMEOUT_MS = 3_000; + +const RolloutBatchSchema = z.object({ + percent: z.number().int().min(0).max(100), + delaySeconds: z.number().int().min(0), +}); + +/** + * CDN `latest.json` wire format. Deliberately NOT `.strict()` — unknown + * fields are ignored so future manifest additions never break shipped + * clients (the plain-text `/latest` taught us that hard-failing on + * unexpected content bricks the update path forever). + */ +export const UpdateManifestSchema = z.object({ + version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }), + publishedAt: z + .string() + .refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }), + rollout: z.array(RolloutBatchSchema).readonly().default([]), +}); + +export interface FetchLatestResult { + /** Raw newest version — what `kimi upgrade` installs, never rollout-gated. */ + readonly latest: string; + /** Null when the JSON manifest was unavailable and we fell back to plain text. */ + readonly manifest: UpdateManifest | null; +} + +async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, CDN_FETCH_TIMEOUT_MS); + try { + return await fetchImpl(input, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} /** * Fetch the latest published Kimi Code version from the CDN. @@ -15,7 +58,7 @@ import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; export async function fetchLatestVersionFromCdn( fetchImpl: typeof fetch = fetch, ): Promise { - const response = await fetchImpl(KIMI_CODE_CDN_LATEST_URL); + const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_URL); if (!response.ok) { throw new Error(`CDN /latest returned HTTP ${response.status}`); } @@ -25,3 +68,30 @@ export async function fetchLatestVersionFromCdn( } return raw; } + +async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise { + const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_JSON_URL); + if (!response.ok) { + throw new Error(`CDN /latest.json returned HTTP ${response.status}`); + } + return UpdateManifestSchema.parse(JSON.parse(await response.text())); +} + +/** + * Fetch the rollout manifest, falling back to the plain-text `/latest` when + * `latest.json` is unavailable or malformed. The fallback removes any + * deployment-order coupling between client releases and the CDN file, and a + * null manifest means "fully rolled out" — exactly the pre-rollout behavior. + * + * **Throws** only when both sources fail; callers must catch (see above). + */ +export async function fetchLatestFromCdn( + fetchImpl: typeof fetch = fetch, +): Promise { + const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null); + if (manifest !== null) { + return { latest: manifest.version, manifest }; + } + const latest = await fetchLatestVersionFromCdn(fetchImpl); + return { latest, manifest: null }; +} diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts new file mode 100644 index 000000000..0b6f3834c --- /dev/null +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -0,0 +1,95 @@ +import { mkdir, open, readFile, unlink } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { getUpdateInstallLockFile } from '#/utils/paths'; + +const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; + +export interface UpdateInstallLockRequest { + readonly version: string; + readonly now?: Date; +} + +export interface UpdateInstallLockHandle { + readonly filePath: string; + release(): Promise; +} + +function isNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT' + ); +} + +function isAlreadyExists(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: string }).code === 'EEXIST' + ); +} + +async function isStaleLock(filePath: string, now: Date): Promise { + try { + const raw = await readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null) return true; + const lock = parsed as { readonly startedAt?: unknown }; + if (typeof lock.startedAt !== 'string') return true; + const startedAt = Date.parse(lock.startedAt); + if (!Number.isFinite(startedAt)) return true; + return now.getTime() - startedAt > UPDATE_INSTALL_LOCK_STALE_MS; + } catch (error) { + if (isNotFound(error)) return true; + if (error instanceof SyntaxError) return true; + return false; + } +} + +async function createLockFile( + filePath: string, + request: UpdateInstallLockRequest, +): Promise { + const now = request.now ?? new Date(); + const file = await open(filePath, 'wx', 0o600); + try { + await file.writeFile(`${JSON.stringify({ + version: request.version, + pid: process.pid, + startedAt: now.toISOString(), + }, null, 2)}\n`, 'utf-8'); + } finally { + await file.close(); + } + + return { + filePath, + release: async (): Promise => { + await unlink(filePath).catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }); + }, + }; +} + +export async function tryAcquireUpdateInstallLock( + request: UpdateInstallLockRequest, + filePath: string = getUpdateInstallLockFile(), +): Promise { + await mkdir(dirname(filePath), { recursive: true }); + try { + return await createLockFile(filePath, request); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + + if (!(await isStaleLock(filePath, request.now ?? new Date()))) return null; + await unlink(filePath).catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }); + + try { + return await createLockFile(filePath, request); + } catch (error) { + if (isAlreadyExists(error)) return null; + throw error; + } +} diff --git a/apps/kimi-code/src/cli/update/install-state.ts b/apps/kimi-code/src/cli/update/install-state.ts new file mode 100644 index 000000000..7edc1b675 --- /dev/null +++ b/apps/kimi-code/src/cli/update/install-state.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import { getUpdateInstallStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +import { emptyUpdateInstallState, type InstallSource, type UpdateInstallState } from './types'; + +const InstallSourceSchema: z.ZodType = z.enum([ + 'npm-global', + 'pnpm-global', + 'yarn-global', + 'bun-global', + 'homebrew', + 'native', + 'unsupported', +]); + +const UpdateInstallStateSchema: z.ZodType = z + .object({ + active: z + .object({ + version: z.string().min(1), + source: InstallSourceSchema, + startedAt: z.string().min(1), + }) + .strict() + .nullable(), + lastFailure: z + .object({ + version: z.string().min(1), + failedAt: z.string().min(1), + attempts: z.number().int().min(1), + }) + .strict() + .nullable(), + lastSuccess: z + .object({ + version: z.string().min(1), + installedAt: z.string().min(1), + notifiedAt: z.string().min(1).nullable(), + }) + .strict() + .nullable(), + }) + .strict(); + +export { emptyUpdateInstallState }; + +export async function readUpdateInstallState( + filePath: string = getUpdateInstallStateFile(), +): Promise { + try { + return await readJsonFile(filePath, UpdateInstallStateSchema, emptyUpdateInstallState()); + } catch { + return emptyUpdateInstallState(); + } +} + +export async function writeUpdateInstallState( + value: UpdateInstallState, + filePath: string = getUpdateInstallStateFile(), +): Promise { + await writeJsonFile(filePath, UpdateInstallStateSchema, value); +} diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 692784d4e..5fda96d6a 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -1,21 +1,40 @@ import { spawn } from 'node:child_process'; +import { log, type Logger } from '@moonshot-ai/kimi-code-sdk'; import type { TelemetryProperties } from '@moonshot-ai/kimi-telemetry'; import { NATIVE_INSTALL_COMMAND_UNIX, NATIVE_INSTALL_COMMAND_WIN, } from '#/constant/app'; +import { loadTuiConfig } from '#/tui/config'; import { readUpdateCache } from './cache'; -import { promptForInstallConfirmation, type InstallPromptOptions } from './prompt'; +import { tryAcquireUpdateInstallLock } from './install-lock'; +import { emptyUpdateInstallState, readUpdateInstallState, writeUpdateInstallState } from './install-state'; +import { + CHANGELOG_URL, + promptForInstallChoice, + type InstallPromptChoiceValue, + type InstallPromptOptions, +} from './prompt'; import { refreshUpdateCache } from './refresh'; -import { selectUpdateTarget } from './select'; +import { + appendRolloutDecisionLog, + decidePassiveUpdateTarget, + isRolloutBypassedByExperimentalEnv, + resolveUpdateDeviceId, + rolloutBucket, + rolloutDelayForBucket, + type PassiveUpdateDecision, +} from './rollout'; import { detectInstallSource } from './source'; import { NPM_PACKAGE_NAME, type InstallSource, type UpdateDecision, + type UpdateInstallState, + type UpdateManifest, type UpdatePreflightResult, type UpdateTarget, } from './types'; @@ -27,8 +46,15 @@ export interface RunUpdatePreflightOptions { readonly stderr?: { write(chunk: string): boolean }; readonly isTTY?: boolean; readonly track?: (event: string, properties?: TelemetryProperties) => void; + readonly logger?: UpdateLogger; } +const AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD = 2; +const AUTO_INSTALL_ACTIVE_TTL_MS = 6 * 60 * 60 * 1000; +const USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS = 1_000; + +type UpdateLogger = Pick; + function withCmdSuffix(base: string, platform: NodeJS.Platform): string { return platform === 'win32' ? `${base}.cmd` : base; } @@ -37,7 +63,7 @@ function bunCommand(platform: NodeJS.Platform): string { return platform === 'win32' ? 'bun.exe' : 'bun'; } -function installCommandFor( +export function installCommandFor( source: InstallSource, version: string, platform: NodeJS.Platform, @@ -51,6 +77,8 @@ function installCommandFor( return `yarn global add ${NPM_PACKAGE_NAME}@${version}`; case 'bun-global': return `bun add -g ${NPM_PACKAGE_NAME}@${version}`; + case 'homebrew': + return 'brew upgrade kimi-code'; case 'native': return platform === 'win32' ? NATIVE_INSTALL_COMMAND_WIN : NATIVE_INSTALL_COMMAND_UNIX; case 'unsupported': @@ -58,13 +86,17 @@ function installCommandFor( } } -function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean { +export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean { switch (source) { case 'npm-global': case 'pnpm-global': case 'yarn-global': case 'bun-global': return true; + case 'homebrew': + // Homebrew upgrade may mutate other dependents and the formula can lag + // behind the CDN release — prompt the user to run `brew upgrade` manually. + return false; case 'native': return platform !== 'win32'; case 'unsupported': @@ -91,6 +123,8 @@ export function spawnForSource( return { cmd: withCmdSuffix('yarn', platform), args: ['global', 'add', `${NPM_PACKAGE_NAME}@${version}`] }; case 'bun-global': return { cmd: bunCommand(platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] }; + case 'homebrew': + return { cmd: 'brew', args: ['upgrade', 'kimi-code'] }; case 'native': // `curl … | bash` reports only the trailing bash's exit status, so a // failed download (curl can't connect → empty stdin → bash exits 0) @@ -107,16 +141,30 @@ function formatErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function renderManualUpdateMessage( +export function renderManualUpdateMessage( currentVersion: string, target: UpdateTarget, source: InstallSource, installCommand: string, ): string { - const sourceDesc = - source === 'native' - ? 'native (windows). Auto-update is not supported on this platform.' - : 'unsupported package manager or layout.'; + let sourceDesc: string; + switch (source) { + case 'npm-global': + case 'pnpm-global': + case 'yarn-global': + case 'bun-global': + sourceDesc = source; + break; + case 'homebrew': + sourceDesc = 'homebrew'; + break; + case 'native': + sourceDesc = 'native (windows). Auto-update is not supported on this platform.'; + break; + case 'unsupported': + sourceDesc = 'unsupported package manager or layout.'; + break; + } return ( `A newer version of ${NPM_PACKAGE_NAME} is available ` + `(${currentVersion} -> ${target.version}).\n` + @@ -125,58 +173,329 @@ function renderManualUpdateMessage( ); } -function renderInstallSuccessMessage(target: UpdateTarget): string { +export function renderInstallSuccessMessage(target: UpdateTarget): string { return `Updated ${NPM_PACKAGE_NAME} to ${target.version}. Restart the CLI to use the new version.\n`; } +function renderBackgroundInstallSuccessNotice(version: string): string { + const displayVersion = version.startsWith('v') ? version : `v${version}`; + return `Kimi Code updated to ${displayVersion}\nChangelog: ${CHANGELOG_URL}\n`; +} + function refreshInBackground(): void { void refreshUpdateCache().catch(() => {}); } +/** Telemetry properties describing where this device sits in the rollout. */ +interface RolloutTelemetry { + readonly rollout_bucket: number; + readonly rollout_delay_seconds: number; + readonly rollout_from_manifest: boolean; + readonly rollout_bypassed: boolean; +} + +function rolloutTelemetryFor( + deviceId: string, + targetVersion: string, + manifest: UpdateManifest | null, + bypassRollout: boolean, +): RolloutTelemetry { + const bucket = rolloutBucket(deviceId, targetVersion); + return { + rollout_bucket: bucket, + rollout_delay_seconds: + manifest === null || bypassRollout ? 0 : rolloutDelayForBucket(manifest.rollout, bucket), + rollout_from_manifest: manifest !== null, + rollout_bypassed: bypassRollout, + }; +} + +type RolloutCheckPhase = 'startup-cache' | 'background-refresh' | 'prompt-refresh'; + +/** Record which case a passive version check hit in `updates/rollout.log`. */ +function logRolloutDecision( + phase: RolloutCheckPhase, + currentVersion: string, + latest: string | null, + manifest: UpdateManifest | null, + decision: PassiveUpdateDecision, +): void { + void appendRolloutDecisionLog({ + ts: nowIso(), + phase, + reason: decision.reason, + current: currentVersion, + latest, + target: decision.target?.version ?? null, + manifestPresent: manifest !== null, + publishedAt: manifest?.publishedAt ?? null, + bucket: decision.bucket, + delaySeconds: decision.delaySeconds, + eligibleAt: decision.eligibleAt, + }); +} + +function refreshAndMaybeInstallInBackground( + currentVersion: string, + deviceId: string, + bypassRollout: boolean, + isInteractive: boolean, + installState: UpdateInstallState, + platform: NodeJS.Platform, + track: RunUpdatePreflightOptions['track'], + logger: UpdateLogger, +): void { + void (async () => { + const refreshed = await refreshUpdateCache(); + if (!isInteractive) return; + const decision = decidePassiveUpdateTarget( + currentVersion, + refreshed.latest, + refreshed.manifest, + deviceId, + new Date(), + bypassRollout, + ); + logRolloutDecision('background-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision); + const target = decision.target; + if (target === null) return; + const source = await detectInstallSource().catch(() => 'unsupported' as const); + await tryStartAutomaticBackgroundInstall( + installState, + currentVersion, + target, + source, + platform, + track, + logger, + rolloutTelemetryFor(deviceId, target.version, refreshed.manifest, bypassRollout), + ); + })().catch(() => {}); +} + +interface UserVisibleUpdateTarget { + readonly target: UpdateTarget | null; + readonly manifest: UpdateManifest | null; +} + +async function refreshUserVisibleUpdateTarget( + currentVersion: string, + deviceId: string, + bypassRollout: boolean, + fallbackTarget: UpdateTarget, + fallbackManifest: UpdateManifest | null, +): Promise { + let timeout: ReturnType | undefined; + const fallback: UserVisibleUpdateTarget = { + target: fallbackTarget, + manifest: fallbackManifest, + }; + try { + const refresh = refreshUpdateCache() + .then((refreshed) => { + const decision = decidePassiveUpdateTarget( + currentVersion, + refreshed.latest, + refreshed.manifest, + deviceId, + new Date(), + bypassRollout, + ); + logRolloutDecision('prompt-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision); + return { + target: decision.target, + manifest: refreshed.manifest, + }; + }) + .catch(() => fallback); + const timeoutFallback = new Promise((resolve) => { + timeout = setTimeout(() => { + resolve(fallback); + }, USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS); + }); + return await Promise.race([refresh, timeoutFallback]); + } catch { + return fallback; + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function failureAttemptsFor(state: UpdateInstallState, target: UpdateTarget): number { + return state.lastFailure?.version === target.version ? state.lastFailure.attempts : 0; +} + +function hasFreshActiveInstall(state: UpdateInstallState, target: UpdateTarget): boolean { + const active = state.active; + if (active === null || active.version !== target.version) return false; + const startedAt = Date.parse(active.startedAt); + if (!Number.isFinite(startedAt)) return false; + return Date.now() - startedAt < AUTO_INSTALL_ACTIVE_TTL_MS; +} + +async function showPendingBackgroundInstallNotice( + state: UpdateInstallState, + currentVersion: string, + stdout: { write(chunk: string): boolean }, + track: RunUpdatePreflightOptions['track'], + logger: UpdateLogger, +): Promise { + const success = state.lastSuccess; + if (success !== null && success.notifiedAt === null && success.version === currentVersion) { + stdout.write(renderBackgroundInstallSuccessNotice(success.version)); + trackUpdateEvent(track, 'update_success_notice_shown', { + version: success.version, + inferred_from_active: false, + }); + logUpdateInfo(logger, 'background update success notice shown', { + version: success.version, + inferredFromActive: false, + }); + const nextState: UpdateInstallState = { + ...state, + active: null, + lastFailure: null, + lastSuccess: { + ...success, + notifiedAt: nowIso(), + }, + }; + await writeUpdateInstallState(nextState).catch(() => {}); + return nextState; + } + + const active = state.active; + if (active === null || active.version !== currentVersion) return state; + if (success !== null && success.version === currentVersion && success.notifiedAt !== null) { + return state; + } + + const notifiedAt = nowIso(); + stdout.write(renderBackgroundInstallSuccessNotice(active.version)); + trackUpdateEvent(track, 'update_success_notice_shown', { + version: active.version, + inferred_from_active: true, + }); + logUpdateInfo(logger, 'background update success notice shown', { + version: active.version, + inferredFromActive: true, + }); + const nextState: UpdateInstallState = { + ...state, + active: null, + lastFailure: null, + lastSuccess: { + version: active.version, + installedAt: notifiedAt, + notifiedAt, + }, + }; + await writeUpdateInstallState(nextState).catch(() => {}); + return nextState; +} + +/** + * `KIMI_CODE_NO_AUTO_UPDATE` (or the legacy `KIMI_CLI_NO_AUTO_UPDATE` alias) + * fully disables the update preflight — no check, no background install, no + * prompt. Migrated from kimi-cli, where the variable gated all auto-update + * behavior. Accepts the usual truthy values (`1`/`true`/`yes`/`on`). + */ +function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { + const truthy = (value?: string): boolean => + ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase()); + return truthy(env['KIMI_CODE_NO_AUTO_UPDATE']) || truthy(env['KIMI_CLI_NO_AUTO_UPDATE']); +} + +async function shouldAutoInstallUpdates(): Promise { + try { + const config = await loadTuiConfig(); + return config.upgrade.autoInstall; + } catch { + return true; + } +} + function trackUpdatePrompted( track: RunUpdatePreflightOptions['track'], currentVersion: string, target: UpdateTarget, source: InstallSource, decision: UpdateDecision, + rolloutTelemetry: RolloutTelemetry, +): void { + trackUpdateEvent(track, 'update_prompted', { + current_version: currentVersion, + target_version: target.version, + source, + decision, + ...rolloutTelemetry, + }); +} + +function trackUpdateEvent( + track: RunUpdatePreflightOptions['track'], + event: string, + properties: TelemetryProperties, ): void { try { - track?.('update_prompted', { - current: currentVersion, - latest: target.version, - current_version: currentVersion, - target_version: target.version, - source, - decision, - }); + track?.(event, properties); } catch { // Telemetry must never affect update prompting. } } +function logUpdateInfo(logger: UpdateLogger, message: string, payload: Record): void { + try { + logger.info(message, payload); + } catch { + // Diagnostic logging must never affect update prompting. + } +} + +function logUpdateWarn(logger: UpdateLogger, message: string, payload: Record): void { + try { + logger.warn(message, payload); + } catch { + // Diagnostic logging must never affect update prompting. + } +} + async function promptInstall( currentVersion: string, target: UpdateTarget, source: InstallSource, installCommand: string, -): Promise { +): Promise { const options: InstallPromptOptions = { currentVersion, target, installSource: source, installCommand, }; - return promptForInstallConfirmation(options); + return promptForInstallChoice(options); } -async function installUpdate( +export async function installUpdate( source: InstallSource, version: string, platform: NodeJS.Platform, ): Promise { const { cmd, args } = spawnForSource(source, version, platform); await new Promise((resolve, reject) => { - const child = spawn(cmd, [...args], { stdio: 'inherit' }); + // Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the + // CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without + // a shell, so run through the shell on win32. The version is a validated + // semver and the package name is a constant, so args are shell-safe. + const child = spawn(cmd, [...args], { + stdio: 'inherit', + shell: platform === 'win32' ? true : undefined, + }); child.once('error', reject); child.once('exit', (code, signal) => { if (code === 0) { @@ -189,6 +508,149 @@ async function installUpdate( }); } +async function startBackgroundInstall( + state: UpdateInstallState, + currentVersion: string, + target: UpdateTarget, + source: InstallSource, + platform: NodeJS.Platform, + track: RunUpdatePreflightOptions['track'], + logger: UpdateLogger, + rolloutTelemetry: RolloutTelemetry, +): Promise { + const lock = await tryAcquireUpdateInstallLock({ version: target.version }); + if (lock === null) return; + + try { + const freshState = await readUpdateInstallState().catch(() => state); + if ( + hasFreshActiveInstall(freshState, target) || + failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD + ) { + return; + } + + const startedState: UpdateInstallState = { + ...freshState, + active: { + version: target.version, + source, + startedAt: nowIso(), + }, + }; + await writeUpdateInstallState(startedState); + trackUpdateEvent(track, 'update_background_install_started', { + current_version: currentVersion, + target_version: target.version, + source, + ...rolloutTelemetry, + }); + logUpdateInfo(logger, 'background update install started', { + currentVersion, + targetVersion: target.version, + source, + }); + + const { cmd, args } = spawnForSource(source, target.version, platform); + let settled = false; + + const finish = (succeeded: boolean): void => { + if (settled) return; + settled = true; + const attempts = failureAttemptsFor(startedState, target) + 1; + + const nextState: UpdateInstallState = succeeded + ? { + ...startedState, + active: null, + lastFailure: null, + lastSuccess: { + version: target.version, + installedAt: nowIso(), + notifiedAt: null, + }, + } + : { + ...startedState, + active: null, + lastFailure: { + version: target.version, + failedAt: nowIso(), + attempts, + }, + }; + void writeUpdateInstallState(nextState).catch(() => {}); + if (succeeded) { + trackUpdateEvent(track, 'update_background_install_succeeded', { + target_version: target.version, + source, + }); + logUpdateInfo(logger, 'background update install succeeded', { + targetVersion: target.version, + source, + }); + return; + } + trackUpdateEvent(track, 'update_background_install_failed', { + target_version: target.version, + source, + attempts, + }); + logUpdateWarn(logger, 'background update install failed', { + targetVersion: target.version, + source, + attempts, + }); + }; + + const child = spawn(cmd, [...args], { + detached: true, + stdio: 'ignore', + shell: platform === 'win32' ? true : undefined, + // On Windows a detached child gets its own console window; with shell:true + // that window would flash during a passive background update. Hide it so + // the silent updater stays silent. + windowsHide: platform === 'win32' ? true : undefined, + }); + child.once('error', () => { finish(false); }); + child.once('exit', (code) => { finish(code === 0); }); + child.unref(); + } finally { + await lock.release().catch(() => {}); + } +} + +async function tryStartAutomaticBackgroundInstall( + installState: UpdateInstallState, + currentVersion: string, + target: UpdateTarget, + source: InstallSource, + platform: NodeJS.Platform, + track: RunUpdatePreflightOptions['track'], + logger: UpdateLogger, + rolloutTelemetry: RolloutTelemetry, +): Promise { + const sourceCanAutoInstall = canAutoInstall(source, platform); + const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false; + if (!autoInstallUpdates || !sourceCanAutoInstall) return false; + if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) { + return false; + } + if (!hasFreshActiveInstall(installState, target)) { + await startBackgroundInstall( + installState, + currentVersion, + target, + source, + platform, + track, + logger, + rolloutTelemetry, + ).catch(() => {}); + } + return true; +} + export function decideUpdateAction( target: UpdateTarget | null, isInteractive: boolean, @@ -205,42 +667,135 @@ export async function runUpdatePreflight( ): Promise { const stdout = options.stdout ?? process.stdout; const stderr = options.stderr ?? process.stderr; + const logger = options.logger ?? log; const platform = process.platform; - try { - const cache = await readUpdateCache().catch(() => null); - const latest = cache?.latest ?? null; - const target = selectUpdateTarget(currentVersion, latest); - refreshInBackground(); + if (isAutoUpdateDisabledByEnv()) { + return 'continue'; + } + try { const isInteractive = options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY); + const deviceId = resolveUpdateDeviceId(); + const bypassRollout = isRolloutBypassedByExperimentalEnv(); + let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); + if (isInteractive) { + installState = await showPendingBackgroundInstallNotice( + installState, + currentVersion, + stdout, + options.track, + logger, + ); + } + + const cache = await readUpdateCache().catch(() => null); + const cachedManifest = cache?.manifest ?? null; + const cachedDecision = decidePassiveUpdateTarget( + currentVersion, + cache?.latest ?? null, + cachedManifest, + deviceId, + new Date(), + bypassRollout, + ); + logRolloutDecision('startup-cache', currentVersion, cache?.latest ?? null, cachedManifest, cachedDecision); + const target = cachedDecision.target; + if (target === null) { + refreshAndMaybeInstallInBackground( + currentVersion, + deviceId, + bypassRollout, + isInteractive, + installState, + platform, + options.track, + logger, + ); + return 'continue'; + } + const source: InstallSource = - target === null || !isInteractive + !isInteractive ? 'unsupported' : await detectInstallSource().catch(() => 'unsupported' as const); const decision = decideUpdateAction(target, isInteractive, source, platform); - if (decision === 'none' || target === null) return 'continue'; - - const installCommand = installCommandFor(source, target.version, platform); - trackUpdatePrompted(options.track, currentVersion, target, source, decision); - - if (decision === 'manual-command') { - stdout.write(renderManualUpdateMessage(currentVersion, target, source, installCommand)); + if (decision === 'none') { + refreshInBackground(); return 'continue'; } - const confirmed = await promptInstall(currentVersion, target, source, installCommand); - if (!confirmed) return 'continue'; + if ( + await tryStartAutomaticBackgroundInstall( + installState, + currentVersion, + target, + source, + platform, + options.track, + logger, + rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout), + ) + ) { + refreshInBackground(); + return 'continue'; + } + + const userVisibleUpdate = await refreshUserVisibleUpdateTarget( + currentVersion, + deviceId, + bypassRollout, + target, + cachedManifest, + ); + const userVisibleTarget = userVisibleUpdate.target; + if (userVisibleTarget === null) return 'continue'; + const userVisibleRollout = rolloutTelemetryFor( + deviceId, + userVisibleTarget.version, + userVisibleUpdate.manifest, + bypassRollout, + ); + if ( + await tryStartAutomaticBackgroundInstall( + installState, + currentVersion, + userVisibleTarget, + source, + platform, + options.track, + logger, + userVisibleRollout, + ) + ) { + return 'continue'; + } + + const installCommand = installCommandFor(source, userVisibleTarget.version, platform); + trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision, userVisibleRollout); + + if (decision === 'manual-command') { + stdout.write(renderManualUpdateMessage( + currentVersion, + userVisibleTarget, + source, + installCommand, + )); + return 'continue'; + } + + const choice = await promptInstall(currentVersion, userVisibleTarget, source, installCommand); + if (choice === 'skip') return 'continue'; try { - await installUpdate(source, target.version, platform); - stdout.write(renderInstallSuccessMessage(target)); + await installUpdate(source, userVisibleTarget.version, platform); + stdout.write(renderInstallSuccessMessage(userVisibleTarget)); return 'exit'; } catch (error) { stderr.write( - `warning: failed to install ${NPM_PACKAGE_NAME}@${target.version}: ` + + `warning: failed to install ${NPM_PACKAGE_NAME}@${userVisibleTarget.version}: ` + `${formatErrorMessage(error)}\n`, ); return 'continue'; diff --git a/apps/kimi-code/src/cli/update/prompt.ts b/apps/kimi-code/src/cli/update/prompt.ts index bd3fd9338..3b48ad072 100644 --- a/apps/kimi-code/src/cli/update/prompt.ts +++ b/apps/kimi-code/src/cli/update/prompt.ts @@ -14,7 +14,7 @@ import { import { type InstallSource, type UpdateTarget } from './types'; -const CHANGELOG_URL = 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html'; +export const CHANGELOG_URL = 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html'; export type InstallPromptChoiceValue = 'install' | 'skip'; @@ -120,15 +120,15 @@ function writePromptFrame( return lines.length; } -export async function promptForInstallConfirmation( +export async function promptForInstallChoice( options: InstallPromptOptions, -): Promise { +): Promise { const input = options.input ?? process.stdin; const output = options.output ?? process.stdout; const choices = createInstallPromptChoices(options.target); let selectedIndex = getDefaultInstallPromptSelection(choices); - return new Promise((resolve) => { + return new Promise((resolve) => { let lineCount = 0; const hadRawMode = 'isRaw' in input ? input.isRaw : false; const canSetRawMode = typeof input.setRawMode === 'function'; @@ -144,7 +144,7 @@ export async function promptForInstallConfirmation( const finish = (choice: InstallPromptChoiceValue): void => { cleanup(); - resolve(choice === 'install'); + resolve(choice); }; const render = (): void => { diff --git a/apps/kimi-code/src/cli/update/refresh.ts b/apps/kimi-code/src/cli/update/refresh.ts index 20bcdf0de..938a4a0fa 100644 --- a/apps/kimi-code/src/cli/update/refresh.ts +++ b/apps/kimi-code/src/cli/update/refresh.ts @@ -1,13 +1,14 @@ import { writeUpdateCache } from './cache'; -import { fetchLatestVersionFromCdn } from './cdn'; +import { fetchLatestFromCdn, type FetchLatestResult } from './cdn'; import { type UpdateCache } from './types'; export interface RefreshUpdateCacheDeps { - /** Resolves with the latest semver. **Throws** on any failure — callers - * (including the default background invocation in preflight) must catch. - * Errors intentionally skip `writeCache` so a transient CDN blip does not - * overwrite a previously known `latest` with `null`. */ - readonly fetchLatest: () => Promise; + /** Resolves with the latest version + rollout manifest. **Throws** on any + * failure — callers (including the default background invocation in + * preflight) must catch. Errors intentionally skip `writeCache` so a + * transient CDN blip does not overwrite a previously known `latest` with + * `null`. */ + readonly fetchLatest: () => Promise; readonly writeCache: (cache: UpdateCache) => Promise; readonly now: () => Date; } @@ -16,16 +17,17 @@ export async function refreshUpdateCache( overrides: Partial = {}, ): Promise { const resolved: RefreshUpdateCacheDeps = { - fetchLatest: overrides.fetchLatest ?? (() => fetchLatestVersionFromCdn()), + fetchLatest: overrides.fetchLatest ?? (() => fetchLatestFromCdn()), writeCache: overrides.writeCache ?? writeUpdateCache, now: overrides.now ?? (() => new Date()), }; - const latest = await resolved.fetchLatest(); + const { latest, manifest } = await resolved.fetchLatest(); const cache: UpdateCache = { source: 'cdn', checkedAt: resolved.now().toISOString(), latest, + manifest, }; await resolved.writeCache(cache); return cache; diff --git a/apps/kimi-code/src/cli/update/rollout.ts b/apps/kimi-code/src/cli/update/rollout.ts new file mode 100644 index 000000000..a4ca50256 --- /dev/null +++ b/apps/kimi-code/src/cli/update/rollout.ts @@ -0,0 +1,210 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { readKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; +import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk'; + +import { getUpdateRolloutLogFile } from '#/utils/paths'; + +import { selectUpdateTarget } from './select'; +import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types'; + +/** + * Hard ceiling for any rollout delay. Combined with the uncovered-bucket + * fallback below, it guarantees every device sees a release no later than + * `publishedAt + 24h`, no matter what the published plan says. + */ +export const MAX_ROLLOUT_DELAY_SECONDS = 86_400; + +/** + * Deterministic 0-99 bucket for a device. The version is mixed into the hash + * so each release reshuffles which devices land in the early batches. + */ +export function rolloutBucket(deviceId: string, version: string): number { + const digest = createHash('sha256').update(`${deviceId}:${version}`, 'utf-8').digest(); + return digest.readUInt32BE(0) % 100; +} + +/** + * Delay assigned to a bucket by the published plan. Batches claim bucket + * ranges in array order; buckets left uncovered (percents summing under 100) + * fall into the slowest cohort, and oversized delays are clamped to 24h. + */ +export function rolloutDelayForBucket(rollout: readonly RolloutBatch[], bucket: number): number { + let cumulative = 0; + for (const batch of rollout) { + cumulative += batch.percent; + if (bucket < cumulative) { + return Math.min(Math.max(batch.delaySeconds, 0), MAX_ROLLOUT_DELAY_SECONDS); + } + } + if (rollout.length === 0) return 0; + return MAX_ROLLOUT_DELAY_SECONDS; +} + +export function rolloutDelaySeconds(manifest: UpdateManifest, deviceId: string): number { + return rolloutDelayForBucket(manifest.rollout, rolloutBucket(deviceId, manifest.version)); +} + +export function isRolloutEligible( + manifest: UpdateManifest, + deviceId: string, + now: Date, +): boolean { + const publishedAt = Date.parse(manifest.publishedAt); + // Schema validation rejects unparseable timestamps before they get here; + // fail open defensively so a defect can never block updates indefinitely. + if (!Number.isFinite(publishedAt)) return true; + const delayMs = rolloutDelaySeconds(manifest, deviceId) * 1000; + return now.getTime() >= publishedAt + delayMs; +} + +/** Which case a passive update check hit; written to the rollout log. */ +export type PassiveUpdateReason = + /** Nothing known yet (no cache / CDN never reached). */ + | 'no-latest' + /** Known version is not an upgrade over the running one. */ + | 'not-newer' + /** Plain-text fallback or legacy cache: visible immediately, no gating. */ + | 'no-manifest' + /** Gated: this device's batch delay has not elapsed yet. */ + | 'held' + /** Gated and the batch delay has elapsed: update is visible. */ + | 'eligible' + /** KIMI_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */ + | 'experimental'; + +export interface PassiveUpdateDecision { + readonly target: UpdateTarget | null; + readonly reason: PassiveUpdateReason; + readonly bucket: number | null; + readonly delaySeconds: number | null; + readonly eligibleAt: string | null; +} + +/** + * Update decision for the passive surfaces (background install, startup + * prompt, manual-command notice). Devices whose batch is not yet eligible see + * no update at all. A null manifest (plain-text fallback or legacy cache) + * keeps the pre-rollout behavior: the latest version is visible immediately. + * + * `kimi upgrade` must NOT go through this gate — it selects directly from the + * raw latest version. + */ +export function decidePassiveUpdateTarget( + currentVersion: string, + latest: string | null, + manifest: UpdateManifest | null, + deviceId: string, + now: Date, + bypassRollout = false, +): PassiveUpdateDecision { + if (bypassRollout) { + if (latest === null) { + return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null }; + } + const target = selectUpdateTarget(currentVersion, latest); + return { + target, + reason: target === null ? 'not-newer' : 'experimental', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }; + } + + if (manifest === null) { + if (latest === null) { + return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null }; + } + const target = selectUpdateTarget(currentVersion, latest); + return { + target, + reason: target === null ? 'not-newer' : 'no-manifest', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }; + } + + const target = selectUpdateTarget(currentVersion, manifest.version); + if (target === null) { + return { target: null, reason: 'not-newer', bucket: null, delaySeconds: null, eligibleAt: null }; + } + + const bucket = rolloutBucket(deviceId, manifest.version); + const delaySeconds = rolloutDelayForBucket(manifest.rollout, bucket); + const publishedAt = Date.parse(manifest.publishedAt); + const eligibleAt = Number.isFinite(publishedAt) + ? new Date(publishedAt + delaySeconds * 1000).toISOString() + : null; + const eligible = isRolloutEligible(manifest, deviceId, now); + return { + target: eligible ? target : null, + reason: eligible ? 'eligible' : 'held', + bucket, + delaySeconds, + eligibleAt, + }; +} + +export function selectPassiveUpdateTarget( + currentVersion: string, + latest: string | null, + manifest: UpdateManifest | null, + deviceId: string, + now: Date, +): UpdateTarget | null { + return decidePassiveUpdateTarget(currentVersion, latest, manifest, deviceId, now).target; +} + +const ROLLOUT_LOG_MAX_BYTES = 256 * 1024; + +/** + * Append one JSON line describing a passive update decision to + * `/updates/rollout.log`. Best-effort diagnostics: any I/O failure + * is swallowed — logging must never affect update prompting. The file is + * reset once it grows past a small cap so it cannot grow unbounded. + */ +export async function appendRolloutDecisionLog( + entry: Record, + filePath: string = getUpdateRolloutLogFile(), +): Promise { + try { + await mkdir(dirname(filePath), { recursive: true }); + const line = `${JSON.stringify(entry)}\n`; + const size = await stat(filePath).then((s) => s.size, () => 0); + if (size > ROLLOUT_LOG_MAX_BYTES) { + await writeFile(filePath, line, 'utf-8'); + return; + } + await appendFile(filePath, line, 'utf-8'); + } catch { + // Diagnostic logging must never affect the update flow. + } +} + +/** + * Stable per-installation id used for bucketing when telemetry has already + * minted one. Missing ids stay ephemeral here so update preflight never + * creates the telemetry device_id before telemetry can emit first_launch. + */ +export function resolveUpdateDeviceId(): string { + return readKimiDeviceId(resolveKimiHome()) ?? randomUUID(); +} + +/** + * The experimental master switch opts a device out of staged rollouts: the + * newest version is always visible to the passive update surfaces, exactly as + * if every release were fully rolled out. Read directly from the env (same + * truthy values as `KIMI_CODE_NO_AUTO_UPDATE`) — the update preflight runs + * before the harness exists, so the core flag registry is not consulted. + * `KIMI_CODE_NO_AUTO_UPDATE` still wins: disabling updates beats opting in. + */ +export function isRolloutBypassedByExperimentalEnv( + env: Readonly> = process.env, +): boolean { + const value = (env['KIMI_CODE_EXPERIMENTAL_FLAG'] ?? '').trim().toLowerCase(); + return ['1', 'true', 'yes', 'on'].includes(value); +} diff --git a/apps/kimi-code/src/cli/update/source.ts b/apps/kimi-code/src/cli/update/source.ts index 4b544d140..7d6904b67 100644 --- a/apps/kimi-code/src/cli/update/source.ts +++ b/apps/kimi-code/src/cli/update/source.ts @@ -40,6 +40,10 @@ export function detectNativeInstall(): boolean { const PNPM_PATH_SEGMENT = 'pnpm/global/'; const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/']; const BUN_PATH_SEGMENT = '.bun/install/global/'; +// Homebrew installs formulae under its Cellar directory. Avoid matching the +// broader /homebrew/ prefix — on Apple Silicon, npm itself lives under +// /opt/homebrew/, so `npm install -g` paths also contain /homebrew/. +const HOMEBREW_PATH_SEGMENT = '/cellar/'; function normalizeForHeuristic(filePath: string): string { return filePath.replaceAll('\\', '/').toLowerCase(); @@ -57,6 +61,7 @@ export function classifyByPathHeuristic(packageRoot: string): InstallSource | nu if (normalized.includes(seg)) return 'yarn-global'; } if (normalized.includes(BUN_PATH_SEGMENT)) return 'bun-global'; + if (normalized.includes(HOMEBREW_PATH_SEGMENT)) return 'homebrew'; return null; } diff --git a/apps/kimi-code/src/cli/update/types.ts b/apps/kimi-code/src/cli/update/types.ts index 652fc97d5..b03af767d 100644 --- a/apps/kimi-code/src/cli/update/types.ts +++ b/apps/kimi-code/src/cli/update/types.ts @@ -8,6 +8,7 @@ export type InstallSource = | 'pnpm-global' | 'yarn-global' | 'bun-global' + | 'homebrew' | 'native' | 'unsupported'; @@ -15,10 +16,52 @@ export interface UpdateTarget { readonly version: string; } +/** One gradual-rollout cohort: `percent` of devices delayed by `delaySeconds`. */ +export interface RolloutBatch { + readonly percent: number; + readonly delaySeconds: number; +} + +/** + * Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array + * order; an empty array means the release is fully rolled out immediately. + */ +export interface UpdateManifest { + readonly version: string; + readonly publishedAt: string; + readonly rollout: readonly RolloutBatch[]; +} + export interface UpdateCache { readonly source: 'cdn'; readonly checkedAt: string | null; readonly latest: string | null; + /** Null when the manifest came from the plain-text fallback or a legacy cache file. */ + readonly manifest: UpdateManifest | null; +} + +export interface UpdateInstallActive { + readonly version: string; + readonly source: InstallSource; + readonly startedAt: string; +} + +export interface UpdateInstallFailure { + readonly version: string; + readonly failedAt: string; + readonly attempts: number; +} + +export interface UpdateInstallSuccess { + readonly version: string; + readonly installedAt: string; + readonly notifiedAt: string | null; +} + +export interface UpdateInstallState { + readonly active: UpdateInstallActive | null; + readonly lastFailure: UpdateInstallFailure | null; + readonly lastSuccess: UpdateInstallSuccess | null; } export type UpdateDecision = 'none' | 'prompt-install' | 'manual-command'; @@ -29,5 +72,14 @@ export function emptyUpdateCache(): UpdateCache { source: 'cdn', checkedAt: null, latest: null, + manifest: null, + }; +} + +export function emptyUpdateInstallState(): UpdateInstallState { + return { + active: null, + lastFailure: null, + lastSuccess: null, }; } diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts new file mode 100644 index 000000000..5cfbeb148 --- /dev/null +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -0,0 +1,687 @@ +/** + * Native v2 `kimi -p` (print mode) runner. + * + * Unlike the v1 path (and the former `V2PromptHarness` / `V2Session` shim), this + * runner talks to agent-core-v2's native DI services directly — no + * `PromptHarness`, no SDK-shaped session, no v2→v1 event translation. It: + * - `bootstrap()`s the app scope, + * - creates / resumes a session and its main agent via native services, + * - subscribes to the main agent's per-agent `IEventBus` and renders the + * native `DomainEvent` stream (payloads are already v1-protocol-shaped), + * - drives a turn through `IAgentPromptService.enqueue()` and awaits + * `Turn.result` for authoritative completion, + * - applies the print-mode background policy (config-driven, v1-aligned: + * `exit` / `drain` / `steer`) before exiting. + * + * Selected by `runPrompt` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set. + */ + +import { + IAgentGoalService, + IAgentLifecycleService, + IAgentPermissionModeService, + IAgentProfileService, + IAgentPromptService, + IAgentTaskService, + IAuthSummaryService, + IConfigService, + IEventBus, + IOAuthToolkit, + ISessionIndex, + ISessionLifecycleService, + ITelemetryService, + bootstrap, + createCloudAppender, + ensureMainAgent, + hostRequestHeadersSeed, + logSeed, + resolveAgentTaskConfig, + resolveKimiHome, + resolveLoggingConfig, + resolvePrintBackgroundMode, + skillCatalogRuntimeOptionsSeed, + type DomainEvent, + type IAgentScopeHandle, + type ISessionScopeHandle, + type LoopRunResult, + type PrintBackgroundMode, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; +import { resolve } from 'pathe'; + +import { + CLI_SHUTDOWN_TIMEOUT_MS, + CLI_USER_AGENT_PRODUCT, + PROMPT_CLEANUP_TIMEOUT_MS, +} from '#/constant/app'; + +import { + formatGoalSummaryText, + goalExitCode, + goalSummaryJson, + parseHeadlessGoalCreate, + type HeadlessGoalCreate, +} from '../goal-prompt'; +import { + type PromptRunIO, + configuredModel, + installPromptTerminationCleanup, + raceWithTimeout, + requireConfiguredModel, +} from '../run-prompt'; +import { createKimiCodeHostIdentity } from '../version'; + +import { resolveOutputFormat } from '../options'; +import type { CLIOptions, PromptOutputFormat } from '../options'; +import { + type PromptOutput, + PromptJsonWriter, + type PromptTurnWriter, + PromptTranscriptWriter, + writeExperimentalVersion, + writeResumeHint, +} from '../prompt-render'; + +const PROMPT_UI_MODE = 'print'; +const DEFAULT_PRINT_WAIT_CEILING_S = 3600; +const DEFAULT_PRINT_MAX_TURNS = 50; + +export async function runV2Print( + opts: CLIOptions, + version: string, + io: PromptRunIO = {}, +): Promise { + const startedAt = Date.now(); + const stdout = io.stdout ?? process.stdout; + const stderr = io.stderr ?? process.stderr; + const promptProcess = io.process ?? process; + const outputFormat = resolveOutputFormat(opts); + const workDir = process.cwd(); + + writeExperimentalVersion(version, outputFormat, stdout, stderr); + + const homeDir = resolveKimiHome(); + let firstLaunch = false; + const deviceId = createKimiDeviceId(homeDir, { + onFirstLaunch: () => { + firstLaunch = true; + }, + }); + const logging = resolveLoggingConfig({ homeDir, env: process.env }); + const identity = createKimiCodeHostIdentity(version); + const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity }); + + const { app } = bootstrap({ homeDir, clientVersion: version }, [ + ...logSeed(logging), + ...hostRequestHeadersSeed(hostHeaders), + // `--skillsDir` (v1 print parity): explicit skill dirs replace default + // user / project discovery for this process. + ...skillCatalogRuntimeOptionsSeed(opts.skillsDirs), + ]); + const auth = app.accessor.get(IOAuthToolkit); + + const configService = app.accessor.get(IConfigService); + await configService.ready; + const defaultModel = configService.get('defaultModel') ?? undefined; + let telemetryEnabled = true; + try { + telemetryEnabled = configService.get('telemetry') !== false; + } catch { + telemetryEnabled = true; + } + for (const diagnostic of configService.diagnostics()) { + if (diagnostic.severity === 'warning') { + stderr.write(`Warning: ${diagnostic.message}\n`); + } + } + + let restorePermission = async (): Promise => {}; + let removeTerminationCleanup: (() => void) | undefined; + let cleanupPromise: Promise | undefined; + let telemetryService: ITelemetryService | undefined; + const cleanup = async (): Promise => { + const pending = (cleanupPromise ??= (async () => { + removeTerminationCleanup?.(); + try { + await restorePermission(); + } finally { + if (telemetryService !== undefined) { + await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS); + } + app.dispose(); + } + })()); + await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS); + }; + removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup); + + try { + const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); + restorePermission = resolved.restorePermission; + + telemetryService = app.accessor.get(ITelemetryService); + if (telemetryEnabled) { + telemetryService.setAppender( + createCloudAppender(app.accessor, { + deviceId, + appName: CLI_USER_AGENT_PRODUCT, + uiMode: PROMPT_UI_MODE, + model: resolved.telemetryModel, + getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null, + }), + ); + } + telemetryService.setContext({ sessionId: resolved.session.id }); + if (firstLaunch) { + telemetryService.track2('first_launch'); + } + + const goalCreate = parseHeadlessGoalCreate(opts.prompt!); + if (goalCreate !== undefined) { + await runNativeGoal( + app, + resolved.session, + resolved.agent, + goalCreate, + resolved.goalModel, + outputFormat, + stdout, + stderr, + ); + } else { + await runNativeTurn( + app, + resolved.session, + resolved.agent, + opts.prompt!, + outputFormat, + stdout, + stderr, + ); + } + writeResumeHint(resolved.session.id, outputFormat, stdout, stderr); + + telemetryService.withContext({ sessionId: resolved.session.id }).track2('exit', { + duration_ms: Date.now() - startedAt, + }); + } finally { + await cleanup(); + } +} + +interface ResolvedNativeSession { + readonly session: ISessionScopeHandle; + readonly agent: IAgentScopeHandle; + readonly restorePermission: () => Promise; + readonly telemetryModel: string | undefined; + readonly goalModel: string | undefined; +} + +async function resolveNativeSession( + app: Scope, + opts: CLIOptions, + workDir: string, + defaultModel: string | undefined, + stderr: PromptOutput, +): Promise { + const lifecycle = app.accessor.get(ISessionLifecycleService); + const index = app.accessor.get(ISessionIndex); + + const resumeById = async (id: string): Promise => { + const session = await lifecycle.resume(id); + if (session === undefined) { + throw new Error(`Session "${id}" not found.`); + } + return session; + }; + + const forceAuto = ( + agent: IAgentScopeHandle, + ): { readonly restorePermission: () => Promise } => { + const permissionMode = agent.accessor.get(IAgentPermissionModeService); + const previous = permissionMode.mode; + permissionMode.setMode('auto'); + return { + restorePermission: async () => { + permissionMode.setMode(previous); + }, + }; + }; + + if (opts.session !== undefined) { + const page = await index.list({}); + const target = page.items.find((summary) => summary.id === opts.session); + if (target === undefined) { + throw new Error(`Session "${opts.session}" not found.`); + } + if (target.cwd !== undefined && resolve(target.cwd) !== resolve(workDir)) { + stderr.write( + `Session "${opts.session}" was created under a different directory.\n` + + ` cd "${target.cwd}" && kimi -r ${opts.session}\n\n`, + ); + throw new Error(`Session "${opts.session}" was created under a different directory.`); + } + const session = await resumeById(opts.session); + const agent = await ensureMainAgent(session); + const profile = agent.accessor.get(IAgentProfileService); + if (opts.model !== undefined) { + await profile.setModel(opts.model); + } + const currentModel = profile.getModel(); + const { restorePermission } = forceAuto(agent); + return { + session, + agent, + restorePermission, + telemetryModel: configuredModel(opts.model, currentModel, defaultModel), + goalModel: configuredModel(opts.model, currentModel), + }; + } + + if (opts.continue) { + const page = await index.list({}); + const previous = page.items.find((summary) => summary.cwd === workDir); + if (previous !== undefined) { + const session = await resumeById(previous.id); + const agent = await ensureMainAgent(session); + const profile = agent.accessor.get(IAgentProfileService); + if (opts.model !== undefined) { + await profile.setModel(opts.model); + } + const currentModel = profile.getModel(); + const { restorePermission } = forceAuto(agent); + return { + session, + agent, + restorePermission, + telemetryModel: configuredModel(opts.model, currentModel, defaultModel), + goalModel: configuredModel(opts.model, currentModel), + }; + } + stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`); + } + + const model = requireConfiguredModel(opts.model, defaultModel); + const session = await lifecycle.create({ + workDir, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + }); + const agent = await ensureMainAgent(session); + await agent.accessor.get(IAgentProfileService).setModel(model); + agent.accessor.get(IAgentPermissionModeService).setMode('auto'); + return { + session, + agent, + restorePermission: async () => {}, + telemetryModel: model, + goalModel: model, + }; +} + +async function runNativeTurn( + app: Scope, + session: ISessionScopeHandle, + agent: IAgentScopeHandle, + prompt: string, + outputFormat: PromptOutputFormat, + stdout: PromptOutput, + stderr: PromptOutput, +): Promise { + const writer: PromptTurnWriter = + outputFormat === 'stream-json' + ? new PromptJsonWriter(stdout) + : new PromptTranscriptWriter(stdout, stderr); + + await agent.accessor.get(IAuthSummaryService).ensureReady(); + + const turnEndings = createPrintTurnEndings(); + const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => { + dispatchNativeEvent(writer, event, stderr); + // Arm the turn-endings collector before `turn.result` settles so a + // background-task completion that steers a new turn right after the main + // turn ends cannot have its `turn.ended` slip past the policy loop. + if (event.type === 'turn.ended') turnEndings.push(event); + }); + try { + const handle = await agent.accessor.get(IAgentPromptService).enqueue({ + message: { + role: 'user', + content: [{ type: 'text', text: prompt }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }); + const turn = await handle.launched; + if (turn === undefined) { + // A prompt blocked by an onBeforeSubmitPrompt hook never launches a turn. + writer.finish(); + const completion = await handle.completion; + throw new Error( + completion.state === 'blocked' + ? 'Prompt hook blocked the request.' + : 'Prompt turn could not be started', + ); + } + const result = await turn.result; + + // Turn settled, but `-p` is not done until the print-mode background + // policy says so (config-driven: exit / drain / steer). Flush the buffered + // assistant message first so a long drain/steer wait does not withhold the + // final message. + writer.flushAssistant(); + if (result.type === 'completed') { + const configService = app.accessor.get(IConfigService); + const taskConfig = resolveAgentTaskConfig(configService); + try { + await applyPrintBackgroundPolicy({ + mode: resolvePrintBackgroundMode(configService), + ceilingS: taskConfig?.printWaitCeilingS ?? DEFAULT_PRINT_WAIT_CEILING_S, + maxTurns: taskConfig?.printMaxTurns ?? DEFAULT_PRINT_MAX_TURNS, + countPending: () => countPendingBackgroundTasks(session), + drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS), + turnEndings, + skipTurnId: turn.id, + warn: (message) => stderr.write(`Warning: ${message}\n`), + now: () => Date.now(), + }); + } catch (error) { + // A steered turn that fails fails the run (v1 parity). Anything else + // is best-effort: a wedged background task must not fail the (already + // completed) main turn. + if (error instanceof PrintSteeredTurnFailedError) { + writer.finish(); + throw error; + } + stderr.write( + `Warning: print background policy failed: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + } + writer.finish(); + return; + } + writer.finish(); + throw new Error(formatNativeTurnFailure(result)); + } catch (error) { + writer.finish(); + throw error instanceof Error ? error : new Error(String(error)); + } finally { + subscription.dispose(); + } +} + +async function runNativeGoal( + app: Scope, + session: ISessionScopeHandle, + agent: IAgentScopeHandle, + goal: HeadlessGoalCreate, + model: string | undefined, + outputFormat: PromptOutputFormat, + stdout: PromptOutput, + stderr: PromptOutput, +): Promise { + requireConfiguredModel(model); + const goalService = agent.accessor.get(IAgentGoalService); + await goalService.createGoal({ + objective: goal.objective, + replace: goal.replace, + }); + let completedSnapshot: { readonly status: string } | null = null; + const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => { + if ( + event.type === 'goal.updated' && + event.change?.kind === 'completion' && + event.snapshot !== null + ) { + completedSnapshot = event.snapshot; + } + }); + try { + await runNativeTurn(app, session, agent, goal.objective, outputFormat, stdout, stderr); + } finally { + subscription.dispose(); + const snapshot = completedSnapshot ?? goalService.getGoal().goal; + if (outputFormat === 'stream-json') { + stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`); + } else { + stderr.write(`${formatGoalSummaryText(snapshot)}\n`); + } + if (snapshot !== null && snapshot.status !== 'complete') { + process.exitCode = goalExitCode(snapshot.status); + } + } +} + +function dispatchNativeEvent( + writer: PromptTurnWriter, + event: DomainEvent, + stderr: PromptOutput, +): void { + switch (event.type) { + case 'turn.step.started': + case 'turn.step.interrupted': + writer.flushAssistant(); + return; + case 'turn.step.retrying': + writer.discardAssistant(); + return; + case 'assistant.delta': + writer.writeAssistantDelta(event.delta); + return; + case 'hook.result': + writer.writeHookResult(event); + return; + case 'thinking.delta': + writer.writeThinkingDelta(event.delta); + return; + case 'tool.call.started': + writer.writeToolCall(event.toolCallId, event.name, event.args); + return; + case 'tool.call.delta': + writer.writeToolCallDelta(event.toolCallId, event.name, event.argumentsPart); + return; + case 'tool.result': + writer.writeToolResult(event.toolCallId, event.output); + return; + case 'tool.progress': + if (event.update.text !== undefined && event.update.text.length > 0) { + stderr.write(event.update.text.endsWith('\n') ? event.update.text : `${event.update.text}\n`); + } + return; + } +} + +export type PrintTurnEnding = Extract; + +/** + * Source of `turn.ended` events for the print steer loop. `next` resolves with + * the next ending (skipping `skipTurnId`, the main turn's own buffered + * ending), or `null` when `remainingMs` elapses first. + */ +export interface PrintTurnEndings { + next(remainingMs: number, skipTurnId: number): Promise; +} + +/** + * Buffered `turn.ended` collector fed from the agent event bus. Events that + * arrive while no one is waiting are queued, so endings that fire between the + * main turn settling and the policy loop starting are not missed. + */ +export function createPrintTurnEndings(): PrintTurnEndings & { + push: (event: PrintTurnEnding) => void; +} { + const buffer: PrintTurnEnding[] = []; + let waiter: ((ending: PrintTurnEnding | null) => void) | undefined; + return { + push: (event) => { + const resolve = waiter; + if (resolve !== undefined) { + waiter = undefined; + resolve(event); + return; + } + buffer.push(event); + }, + next: async (remainingMs, skipTurnId) => { + const deadlineAt = Date.now() + remainingMs; + const waitOnce = (ms: number): Promise => + new Promise((resolve) => { + let settled = false; + const settle = (value: PrintTurnEnding | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + waiter = undefined; + // oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it + resolve(value); + }; + const timer = setTimeout(() => { + settle(null); + }, ms); + waiter = settle; + }); + for (;;) { + while (buffer.length > 0) { + const ending = buffer.shift()!; + if (ending.turnId !== skipTurnId) return ending; + } + const ms = deadlineAt - Date.now(); + if (ms <= 0) return null; + const ending = await waitOnce(ms); + if (ending === null) return null; + if (ending.turnId !== skipTurnId) return ending; + // The skipped turn's own ending: keep waiting within the same budget. + } + }, + }; +} + +/** A background-task completion steered a new main turn that did not complete. */ +export class PrintSteeredTurnFailedError extends Error {} + +export interface PrintBackgroundPolicyInput { + readonly mode: PrintBackgroundMode; + readonly ceilingS: number; + readonly maxTurns: number; + readonly countPending: () => number; + readonly drain: () => Promise; + readonly turnEndings: PrintTurnEndings; + readonly skipTurnId: number; + readonly warn: (message: string) => void; + readonly now: () => number; +} + +/** + * Apply the print-mode (`kimi -p`) background-task policy after the main turn + * completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`: + * - 'exit' : return immediately (default). + * - 'drain' : suppress + drain background tasks, then return. + * - 'steer' : while background tasks are still pending, stay alive so task + * completions steer new main turns; return once quiescent, or + * when the wall-clock ceiling (`ceilingS`) or the turn cap + * (`maxTurns`) is reached. A steered turn that does not complete + * fails the run. + */ +export async function applyPrintBackgroundPolicy( + input: PrintBackgroundPolicyInput, +): Promise { + if (input.mode === 'exit') return; + if (input.mode === 'drain') { + await input.drain(); + return; + } + + // 'steer' + const deadline = input.now() + input.ceilingS * 1000; + let turns = 0; + for (;;) { + turns += 1; + if (input.now() >= deadline) { + input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`); + return; + } + if (turns > input.maxTurns) { + input.warn(`print steer max turns reached (${input.maxTurns}), finishing`); + return; + } + if (input.countPending() === 0) return; + const ended = await input.turnEndings.next(deadline - input.now(), input.skipTurnId); + if (ended === null) return; + if (ended.reason !== 'completed') { + throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended)); + } + } +} + +function formatTurnEndingFailure(ending: PrintTurnEnding): string { + if (ending.error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } + if (ending.error !== undefined) return `${ending.error.code}: ${ending.error.message}`; + if (ending.reason === 'blocked') { + return 'Prompt hook blocked the request.'; + } + return `Prompt turn ended with reason: ${ending.reason}`; +} + +function countPendingBackgroundTasks(session: ISessionScopeHandle): number { + let count = 0; + for (const handle of session.accessor.get(IAgentLifecycleService).list()) { + count += handle.accessor.get(IAgentTaskService).list(true).length; + } + return count; +} + +async function drainBackgroundTasks( + session: ISessionScopeHandle, + ceilingS: number | undefined, +): Promise { + const ceilingMs = + typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0 + ? ceilingS * 1000 + : DEFAULT_PRINT_WAIT_CEILING_S * 1000; + + const deadline = Date.now() + ceilingMs; + const seen = new Set(); + const allWaiters: Promise[] = []; + while (Date.now() < deadline) { + const batch: Promise[] = []; + const suppressions: Promise[] = []; + let activeCount = 0; + for (const handle of session.accessor.get(IAgentLifecycleService).list()) { + const taskService = handle.accessor.get(IAgentTaskService); + for (const task of taskService.list(true)) { + activeCount++; + if (seen.has(task.taskId)) continue; + seen.add(task.taskId); + suppressions.push(taskService.suppressTerminalNotification(task.taskId)); + const remaining = Math.max(1, deadline - Date.now()); + const waiter = taskService.wait(task.taskId, remaining); + batch.push(waiter); + allWaiters.push(waiter); + } + } + if (suppressions.length > 0) await Promise.all(suppressions); + if (activeCount === 0 || batch.length === 0) break; + await Promise.all(batch); + } + if (allWaiters.length > 0) await Promise.all(allWaiters); +} + +function formatNativeTurnFailure(result: LoopRunResult): string { + if (result.type === 'failed') { + const error = result.error as { readonly code?: string; readonly message?: string } | undefined; + if (error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } + if (error?.code !== undefined) { + return `${error.code}: ${error.message ?? ''}`.trimEnd(); + } + if (result.error instanceof Error) { + return result.error.message; + } + } + return `Prompt turn ended with reason: ${result.type}`; +} diff --git a/apps/kimi-code/src/cli/version.ts b/apps/kimi-code/src/cli/version.ts index f0a9f695d..d5a4f36cd 100644 --- a/apps/kimi-code/src/cli/version.ts +++ b/apps/kimi-code/src/cli/version.ts @@ -7,7 +7,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import { createKimiDefaultHeaders, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; +import { createKimiDefaultHeaders, createKimiUserAgent, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; import { CLI_USER_AGENT_PRODUCT } from '#/constant/app'; @@ -55,6 +55,14 @@ export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIden }; } +/** + * Product User-Agent (`kimi-code-cli/`) for ad-hoc outbound fetches + * that don't go through the provider pipeline (registry / catalog imports). + */ +export function createKimiCodeUserAgent(version = getVersion()): string { + return createKimiUserAgent(createKimiCodeHostIdentity(version)); +} + export function buildKimiDefaultHeaders(version: string): Record { return createKimiDefaultHeaders({ homeDir: getDataDir(), diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 43bb7d70e..e38ea6a68 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -2,14 +2,38 @@ import { ErrorCodes } from '@moonshot-ai/kimi-code-sdk'; export const PRODUCT_NAME = 'Kimi Code'; export const CLI_COMMAND_NAME = 'kimi'; +export const PROCESS_NAME = 'kimi-code'; // Used in telemetry app names and HTTP User-Agent headers. export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli'; export const CLI_UI_MODE = 'shell'; +// Telemetry ui_mode for the `kimi web` / `kimi server run` host. Same product +// as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode. +export const WEB_UI_MODE = 'web'; // Give telemetry a short flush window without making CLI exit feel stuck. export const CLI_SHUTDOWN_TIMEOUT_MS = 3000; +// Upper bound on headless (`kimi -p`) shutdown. A wedged cleanup step (e.g. a +// SessionEnd hook, an MCP shutdown, or a connection blackholed by a restrictive +// firewall) must not keep a completed run alive indefinitely — once this elapses +// we stop waiting on cleanup and let the run return. +export const PROMPT_CLEANUP_TIMEOUT_MS = 8000; + +// Grace after a headless run has fully completed (turn done, cleanup attempted) +// before force-exiting. `kimi -p` otherwise relies on the event loop draining to +// exit; a stray ref'd handle (socket/timer/child) left over from the run would +// wedge it. The guard timer is unref'd, so a healthy run still exits naturally +// well before this fires. +export const HEADLESS_FORCE_EXIT_GRACE_MS = 2000; + +// Max time to wait for buffered stdout/stderr to flush before arming the +// force-exit fallback. A slow/piped consumer's still-draining stdio is a +// legitimate ref'd handle — flushing first prevents the fallback from +// truncating completed output. Bounded so a permanently-stuck consumer can't +// re-introduce the hang. +export const HEADLESS_STDIO_DRAIN_TIMEOUT_MS = 10000; + // Published npm package name; this can differ from the executable command. export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code'; @@ -17,9 +41,16 @@ export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code'; export const KIMI_CODE_HOME_ENV = 'KIMI_CODE_HOME'; export const KIMI_CODE_DATA_DIR_NAME = '.kimi-code'; export const KIMI_CODE_LOG_DIR_NAME = 'logs'; +export const KIMI_CODE_CACHE_DIR_NAME = 'cache'; export const KIMI_CODE_UPDATE_DIR_NAME = 'updates'; +export const KIMI_CODE_BIN_DIR_NAME = 'bin'; export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; +export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; +export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; +export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log'; export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; +export const KIMI_CODE_BANNER_DIR_NAME = 'banner'; +export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json'; // Managed Kimi auth provider key shared with OAuth/SDK config. export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code'; @@ -41,6 +72,11 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code'; export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; +// Rollout manifest consumed by update checks; the plain-text `/latest` above +// stays unchanged forever — already-shipped clients hard-fail on non-semver +// bodies, and the CDN install scripts read it for fresh installs. +export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`; +export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`; diff --git a/apps/kimi-code/src/feedback/archive.ts b/apps/kimi-code/src/feedback/archive.ts new file mode 100644 index 000000000..439f68d1b --- /dev/null +++ b/apps/kimi-code/src/feedback/archive.ts @@ -0,0 +1,72 @@ +import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { getCacheDir } from '../utils/paths'; + +const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours. + +/** + * A file produced for a feedback attachment upload. Both the session log + * archive and the codebase archive share this shape; the generic uploader + * consumes it without caring how the file was produced. + */ +export interface FeedbackArchive { + readonly path: string; + readonly size: number; + readonly sha256: string; + readonly fingerprint: string; + readonly fileCount: number; + /** Directory created exclusively for this archive and safe to remove after upload. */ + readonly cleanupDir?: string; +} + +export async function createFeedbackArchivePath(filename: string): Promise<{ + readonly archivePath: string; + readonly cleanupDir: string; +}> { + const archivePath = await createArchivePath(filename); + return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) }; +} + +/** + * Remove feedback-upload archive directories older than 24 hours. Packaging + * cleans up its own archive on success and on failure, but a killed process + * or an empty parent dir can still leave leftovers behind; this is a + * best-effort backstop so the cache dir does not grow without bound. + * + * `dir` is injectable for tests; production callers leave it as the default. + */ +export async function removeStaleFeedbackUploads( + options: { readonly now?: number; readonly dir?: string } = {}, +): Promise { + const now = options.now ?? Date.now(); + const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads'); + const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (entries === null) return; + + const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS; + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory() && !entry.isSymbolicLink()) return; + const target = join(dir, entry.name); + const targetStat = await stat(target).catch(() => null); + if (targetStat === null || targetStat.mtimeMs >= cutoff) return; + await rm(target, { recursive: true, force: true }).catch(() => {}); + }), + ); +} + +async function createArchivePath(filename: string): Promise { + await removeStaleFeedbackUploads(); + const root = join(getCacheDir(), 'feedback-uploads'); + await mkdir(root, { recursive: true }); + const dir = await mkdtemp(join(root, 'upload-')); + return join(dir, filename); +} + +function archivePathCleanupDir(archivePath: string): string { + return dirname(archivePath); +} diff --git a/apps/kimi-code/src/feedback/codebase/filter.ts b/apps/kimi-code/src/feedback/codebase/filter.ts new file mode 100644 index 000000000..1df5976e9 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/filter.ts @@ -0,0 +1,92 @@ +export const DEFAULT_MAX_FILES = 50000; +export const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; +// Upper bound for the compressed codebase archive, aligned with the backend's +// per-upload limit. The scanner uses cumulative raw file size as a conservative +// estimate so the resulting zip stays within this bound. +export const DEFAULT_MAX_ARCHIVE_SIZE = 500 * 1024 * 1024; + +const IGNORED_DIR_NAMES: ReadonlySet = new Set([ + '.git', + '.hg', + '.svn', + 'node_modules', + 'dist', + 'build', + 'out', + '.next', + '.nuxt', + '.turbo', + '.cache', + '.parcel-cache', + 'coverage', + '.nyc_output', + 'target', + '__pycache__', + '.pytest_cache', + '.mypy_cache', + '.venv', + 'venv', + 'env', + '.idea', +]); + +const SENSITIVE_DIR_NAMES: ReadonlySet = new Set([ + '.ssh', + '.gnupg', + '.aws', + '.kube', + '.docker', +]); + +const SENSITIVE_FILE_NAMES: ReadonlySet = new Set([ + '.env', + 'id_rsa', + 'id_dsa', + 'id_ecdsa', + 'id_ed25519', + 'credentials.json', + 'service-account.json', + 'serviceAccount.json', + '.netrc', + '.htpasswd', + '.pypirc', + '.npmrc', + '.envrc', + '.yarnrc', + '.yarnrc.yml', +]); + +const SENSITIVE_FILE_SUFFIXES: readonly string[] = [ + '.pem', + '.key', + '.p12', + '.pfx', + '.jks', + '.keystore', +]; + +const ENV_FILE_ALLOWED_SUFFIXES: ReadonlySet = new Set(['.example', '.sample', '.template']); + +export function isIgnoredDirName(name: string): boolean { + return IGNORED_DIR_NAMES.has(name); +} + +export function isSensitivePath(relativePath: string): boolean { + const segments = relativePath.split('/'); + for (let i = 0; i < segments.length - 1; i += 1) { + const segment = segments[i]; + if (segment !== undefined && SENSITIVE_DIR_NAMES.has(segment)) return true; + } + + const base = segments.at(-1); + if (base === undefined || base.length === 0) return false; + if (SENSITIVE_FILE_NAMES.has(base)) return true; + if (SENSITIVE_FILE_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true; + + if (base.startsWith('.env.')) { + const suffix = base.slice('.env'.length); + return !ENV_FILE_ALLOWED_SUFFIXES.has(suffix); + } + + return false; +} diff --git a/apps/kimi-code/src/feedback/codebase/index.ts b/apps/kimi-code/src/feedback/codebase/index.ts new file mode 100644 index 000000000..7ef51870d --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/index.ts @@ -0,0 +1,3 @@ +export * from './packager'; +export * from './scanner'; +export * from './types'; diff --git a/apps/kimi-code/src/feedback/codebase/packager.ts b/apps/kimi-code/src/feedback/codebase/packager.ts new file mode 100644 index 000000000..cd4bf2c66 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/packager.ts @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { mkdir, rm, stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { ZipFile } from 'yazl'; + +import type { FeedbackArchive } from '../archive'; +import type { FeedbackCodebaseScanResult } from './types'; + +interface PackageEntry { + readonly absolutePath: string; + readonly archivePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +/** + * Pack the scanned codebase into a zip, with files placed at the zip root. + */ +export async function packageCodebase( + scan: FeedbackCodebaseScanResult, + archivePath: string, +): Promise { + const entries: PackageEntry[] = scan.files.map((file) => ({ + absolutePath: file.absolutePath, + archivePath: file.path, + size: file.size, + mtimeMs: file.mtimeMs, + })); + return packageEntries(entries, archivePath); +} + +async function packageEntries( + entries: readonly PackageEntry[], + archivePath: string, +): Promise { + if (entries.length === 0) { + throw new Error('Cannot package an empty feedback archive.'); + } + await mkdir(dirname(archivePath), { recursive: true }); + + const zip = new ZipFile(); + const hash = createHash('sha256'); + const output = createWriteStream(archivePath); + + try { + const done = new Promise((resolvePromise, rejectPromise) => { + output.on('finish', resolvePromise); + output.on('error', rejectPromise); + zip.outputStream.on('error', rejectPromise); + }); + + zip.outputStream.on('data', (chunk: Buffer) => { + hash.update(chunk); + }); + zip.outputStream.pipe(output); + + for (const entry of entries) { + zip.addFile(entry.absolutePath, entry.archivePath, { + mtime: new Date(entry.mtimeMs), + mode: 0o100644, + }); + } + zip.end(); + await done; + + const archiveStat = await stat(archivePath); + return { + path: archivePath, + size: archiveStat.size, + sha256: hash.digest('hex'), + fingerprint: fingerprintEntries(entries), + fileCount: entries.length, + }; + } catch (error) { + // A failed zip (e.g. a source file vanished or became unreadable between + // scan and packaging) would otherwise leave a partial archive behind in + // the cache dir. Destroy the stream so the handle is released before we + // remove the file, then best-effort delete it. + output.destroy(); + await rm(archivePath, { force: true }).catch(() => {}); + throw error; + } +} + +function fingerprintEntries(entries: readonly PackageEntry[]): string { + const hash = createHash('sha256'); + for (const entry of entries) { + hash.update(entry.archivePath); + hash.update('\0'); + hash.update(String(entry.size)); + hash.update('\0'); + hash.update(String(Math.trunc(entry.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} diff --git a/apps/kimi-code/src/feedback/codebase/scanner.ts b/apps/kimi-code/src/feedback/codebase/scanner.ts new file mode 100644 index 000000000..6df420217 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/scanner.ts @@ -0,0 +1,217 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstat, readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { + DEFAULT_MAX_ARCHIVE_SIZE, + DEFAULT_MAX_FILES, + DEFAULT_MAX_FILE_SIZE, + isIgnoredDirName, + isSensitivePath, +} from './filter'; +import type { + FeedbackCodebaseFile, + FeedbackCodebaseLimitExceeded, + FeedbackCodebaseScanResult, +} from './types'; + +const execFileAsync = promisify(execFile); + +export interface ScanCodebaseLimits { + readonly maxFiles: number; + readonly maxFileSize: number; + readonly maxArchiveSize: number; +} + +export interface ScanCodebaseOptions { + readonly limits?: { + readonly maxFiles?: number; + readonly maxFileSize?: number; + readonly maxArchiveSize?: number; + }; + readonly signal?: AbortSignal; +} + +interface CollectedFiles { + readonly files: FeedbackCodebaseFile[]; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} + +export async function scanCodebase( + rootInput: string, + options: ScanCodebaseOptions = {}, +): Promise { + const root = resolve(rootInput); + const limits = resolveLimits(options.limits); + throwIfAborted(options.signal); + const usedGitIgnore = await isInsideGitWorkTree(root); + const collected = usedGitIgnore + ? await scanWithGit(root, limits, options.signal) + : await scanWithoutFilter(root, limits, options.signal); + const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path)); + + return { + root, + files: sortedFiles, + fingerprint: fingerprintFiles(sortedFiles), + usedGitIgnore, + exceedsLimit: collected.exceedsLimit, + }; +} + +function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimits { + return { + maxFiles: limits?.maxFiles ?? DEFAULT_MAX_FILES, + maxFileSize: limits?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE, + maxArchiveSize: limits?.maxArchiveSize ?? DEFAULT_MAX_ARCHIVE_SIZE, + }; +} + +async function isInsideGitWorkTree(root: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']); + return stdout.trim() === 'true'; + } catch { + return false; + } +} + +async function scanWithGit( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise { + const { stdout } = await execFileAsync( + 'git', + ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], + { encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal }, + ); + + throwIfAborted(signal); + const relativePaths = splitNull(stdout); + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let totalSize = 0; + + for (const relativePath of relativePaths) { + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + break; + } + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + break; + } + files.push(file); + totalSize += file.size; + } + } + + return { files, exceedsLimit }; +} + +async function scanWithoutFilter( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise { + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let stopped = false; + let totalSize = 0; + + async function walk(dir: string): Promise { + if (stopped) return; + throwIfAborted(signal); + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (stopped) return; + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + stopped = true; + return; + } + if (entry.isSymbolicLink()) continue; + const absolutePath = join(dir, entry.name); + if (entry.isDirectory()) { + if (isIgnoredDirName(entry.name)) continue; + await walk(absolutePath); + if (stopped) return; + continue; + } + if (!entry.isFile()) continue; + const relativePath = toPosixPath(relative(root, absolutePath)); + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + stopped = true; + return; + } + files.push(file); + totalSize += file.size; + } + } + } + + await walk(root); + return { files, exceedsLimit }; +} + +async function statFile(root: string, relativePath: string): Promise { + const absolutePath = resolve(root, relativePath); + // A tracked file can be deleted from the working tree but still listed by + // `git ls-files`; lstat then throws ENOENT. Treat unreadable/vanished paths + // like any other non-regular entry so one bad path does not abort the scan. + const stat = await lstat(absolutePath).catch(() => null); + if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return null; + + return { + path: toPosixPath(relativePath), + absolutePath, + size: stat.size, + mtimeMs: stat.mtimeMs, + }; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + const error = new Error('Codebase scan aborted.'); + error.name = 'AbortError'; + throw error; + } +} + +function fingerprintFiles(files: readonly FeedbackCodebaseFile[]): string { + const hash = createHash('sha256'); + for (const file of files) { + hash.update(file.path); + hash.update('\0'); + hash.update(String(file.size)); + hash.update('\0'); + hash.update(String(Math.trunc(file.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} + +function splitNull(buffer: Buffer): string[] { + return buffer + .toString('utf8') + .split('\0') + .filter((item) => item.length > 0); +} + +function toPosixPath(value: string): string { + return value.split('\\').join('/'); +} diff --git a/apps/kimi-code/src/feedback/codebase/types.ts b/apps/kimi-code/src/feedback/codebase/types.ts new file mode 100644 index 000000000..a611d93a3 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/types.ts @@ -0,0 +1,19 @@ +export interface FeedbackCodebaseFile { + readonly path: string; + readonly absolutePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +export interface FeedbackCodebaseLimitExceeded { + readonly reason: 'file-count' | 'total-size'; + readonly limit: number; +} + +export interface FeedbackCodebaseScanResult { + readonly root: string; + readonly files: readonly FeedbackCodebaseFile[]; + readonly fingerprint: string; + readonly usedGitIgnore: boolean; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} diff --git a/apps/kimi-code/src/feedback/feedback-attachments.ts b/apps/kimi-code/src/feedback/feedback-attachments.ts new file mode 100644 index 000000000..c372e4d11 --- /dev/null +++ b/apps/kimi-code/src/feedback/feedback-attachments.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { detectInstallSource } from '#/cli/update/source'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import type { FeedbackAttachmentLevel } from '#/tui/commands/prompts'; +import { getLogDir } from '#/utils/paths'; +import { detectShellEnvironment } from '#/utils/process/shell-env'; + +import { createFeedbackArchivePath, type FeedbackArchive } from './archive'; +import { packageCodebase, scanCodebase, type FeedbackCodebaseScanResult } from './codebase'; +import { uploadArchive, type FeedbackUploadUrlApi } from './upload'; + +export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip'; +export const SESSION_ARCHIVE_FILENAME = 'session.zip'; + +const CODEBASE_SCAN_TIMEOUT_MS = 3000; + +/** + * Stage 3 of the `/feedback` flow: prepare and upload each requested attachment + * independently. Attachment failures are non-fatal because the text feedback + * already exists, but any requested artifact that cannot be prepared/uploaded + * is reported as a partial attachment failure instead of silently downgrading + * the request. + * + * Returns `true` when at least one requested attachment failed so the caller + * can surface a partial-failure status. + */ +export async function submitFeedbackWithAttachments( + host: SlashCommandHost, + feedbackId: number, + level: FeedbackAttachmentLevel, +): Promise { + const api = createFeedbackUploadApi(host); + + if (level === 'logs') { + const uploaded = await prepareAndUploadSessionArchive(host, api, feedbackId); + return !uploaded; + } + if (level === 'logs+codebase') { + const [sessionDir, scan] = await Promise.all([ + resolveCurrentSessionDir(host), + scanCodebaseForFeedback(host.state.appState.workDir), + ]); + const [uploadedSession, uploadedCodebase] = await Promise.all([ + prepareAndUploadSessionArchive(host, api, feedbackId, sessionDir), + prepareAndUploadCodebaseArchive(api, feedbackId, scan), + ]); + return !uploadedSession || !uploadedCodebase; + } + return false; +} + +async function prepareAndUploadSessionArchive( + host: SlashCommandHost, + api: FeedbackUploadUrlApi, + feedbackId: number, + knownSessionDir?: string, +): Promise { + const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host)); + if (sessionDir === undefined) { + await logFeedbackUploadError(new Error('cannot locate the current session directory')); + return false; + } + return uploadProducedArchive(api, feedbackId, SESSION_ARCHIVE_FILENAME, async (archivePath) => { + const exported = await host.harness.exportSession({ + id: host.state.appState.sessionId, + outputPath: archivePath, + includeGlobalLog: true, + version: host.state.appState.version, + installSource: await detectInstallSource(), + shellEnv: detectShellEnvironment(), + }); + return archiveFromExportedSession(exported.zipPath); + }); +} + +async function prepareAndUploadCodebaseArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + scan: FeedbackCodebaseScanResult | undefined, +): Promise { + if (scan === undefined) return false; + return uploadProducedArchive(api, feedbackId, CODEBASE_ARCHIVE_FILENAME, (archivePath) => + packageCodebase(scan, archivePath), + ); +} + +/** + * Shared lifecycle for a single attachment: create a temp archive path, let + * `produce` write the archive to it, upload it, then always remove the temp + * directory — even when `produce` or the upload throws. Both the session log + * archive and the codebase archive flow through here so their cleanup and + * error handling cannot drift apart. + */ +async function uploadProducedArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + filename: string, + produce: (archivePath: string) => Promise, +): Promise { + const { archivePath, cleanupDir } = await createFeedbackArchivePath(filename); + try { + const archive = await produce(archivePath); + await uploadArchive(api, { ...archive, cleanupDir }, feedbackId, { filename }); + return true; + } catch (error) { + await logFeedbackUploadError(error); + return false; + } finally { + await rm(cleanupDir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function archiveFromExportedSession(zipPath: string): Promise { + const data = await readFile(zipPath); + const archiveStat = await stat(zipPath); + return { + path: zipPath, + size: archiveStat.size, + sha256: createHash('sha256').update(data).digest('hex'), + fingerprint: createHash('sha256').update(data).digest('hex'), + fileCount: 1, + }; +} + +async function resolveCurrentSessionDir(host: SlashCommandHost): Promise { + try { + const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir }); + return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir; + } catch { + return undefined; + } +} + +async function scanCodebaseForFeedback( + workDir: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, CODEBASE_SCAN_TIMEOUT_MS); + try { + return await scanCodebase(workDir, { signal: controller.signal }); + } catch (error) { + await logFeedbackUploadError(error); + return undefined; + } finally { + clearTimeout(timer); + } +} + +async function logFeedbackUploadError(error: unknown): Promise { + try { + const logDir = getLogDir(); + await mkdir(logDir, { recursive: true }); + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`); + } catch { + // best-effort logging only + } +} + +function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi { + return { + async createUploadUrl(input) { + const res = await host.harness.auth.createFeedbackUploadUrl(input); + if (res.kind !== 'ok') throw new Error(res.message); + return { + uploadId: res.uploadId, + parts: res.parts, + }; + }, + async completeUpload(input) { + const res = await host.harness.auth.completeFeedbackUpload({ + uploadId: input.uploadId, + parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })), + }); + if (res.kind !== 'ok') throw new Error(res.message); + }, + }; +} diff --git a/apps/kimi-code/src/feedback/upload.ts b/apps/kimi-code/src/feedback/upload.ts new file mode 100644 index 000000000..629fc6a6f --- /dev/null +++ b/apps/kimi-code/src/feedback/upload.ts @@ -0,0 +1,208 @@ +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; + +import type { FeedbackArchive } from './archive'; + +const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit. +const DEFAULT_CONCURRENCY = 3; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_PART_TIMEOUT_MS = 60_000; +const RETRY_BASE_DELAY_MS = 1_000; + +export interface FeedbackUploadPart { + readonly partNumber: number; + readonly url: string; + readonly method: string; + readonly size: number; +} + +export interface CreateFeedbackUploadUrlInput { + readonly feedbackId: number; + readonly filename: string; + readonly size: number; + readonly sha256: string; +} + +export interface CreateFeedbackUploadUrlResult { + readonly uploadId: number; + readonly parts: readonly FeedbackUploadPart[]; +} + +export interface CompletedUploadPart { + readonly partNumber: number; + readonly etag: string; +} + +export interface CompleteFeedbackUploadUrlInput { + readonly uploadId: number; + readonly parts: readonly CompletedUploadPart[]; +} + +export interface FeedbackUploadUrlApi { + createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise; + completeUpload(input: CompleteFeedbackUploadUrlInput): Promise; +} + +export interface UploadArchiveOptions { + /** Zip entry name sent to the backend. */ + readonly filename: string; + /** Abort a single part PUT if it does not complete within this many milliseconds. */ + readonly timeoutMs?: number; + /** Number of parts to upload concurrently (defaults to 3). */ + readonly concurrency?: number; + /** Per-part retry attempts after the first failure (defaults to 3). */ + readonly maxRetries?: number; + /** Called after each part finishes with the cumulative uploaded bytes. */ + readonly onProgress?: (uploadedBytes: number, totalBytes: number) => void; +} + +export async function uploadArchive( + api: FeedbackUploadUrlApi, + archive: FeedbackArchive, + feedbackId: number, + options: UploadArchiveOptions, +): Promise { + if (archive.size > MAX_ARCHIVE_SIZE) { + throw new Error( + `Failed to upload archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`, + ); + } + const created = await api.createUploadUrl({ + feedbackId, + filename: options.filename, + size: archive.size, + sha256: archive.sha256, + }); + const completed = await uploadParts(archive.path, created.parts, archive.size, options); + await api.completeUpload({ uploadId: created.uploadId, parts: completed }); +} + +interface PartLayout { + readonly part: FeedbackUploadPart; + readonly start: number; +} + +function layoutParts(parts: readonly FeedbackUploadPart[]): PartLayout[] { + const sorted = parts.toSorted((a, b) => a.partNumber - b.partNumber); + let offset = 0; + return sorted.map((part) => { + const start = offset; + offset += part.size; + return { part, start }; + }); +} + +async function uploadParts( + filePath: string, + parts: readonly FeedbackUploadPart[], + totalBytes: number, + options: UploadArchiveOptions, +): Promise { + const layout = layoutParts(parts); + const results: CompletedUploadPart[] = Array.from({ length: layout.length }); + const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, layout.length)); + let nextIndex = 0; + let uploadedBytes = 0; + + async function worker(): Promise { + while (true) { + const index = nextIndex; + nextIndex += 1; + if (index >= layout.length) return; + const entry = layout[index]; + if (entry === undefined) return; + const completed = await uploadOnePartWithRetry(filePath, entry, options); + results[index] = completed; + uploadedBytes += entry.part.size; + options.onProgress?.(uploadedBytes, totalBytes); + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + return results; +} + +async function uploadOnePartWithRetry( + filePath: string, + layout: PartLayout, + options: UploadArchiveOptions, +): Promise { + const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES); + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await uploadOnePart(filePath, layout, options); + } catch (error) { + lastError = error; + if (attempt === maxRetries || !isRetryable(error)) break; + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + } + } + throw lastError; +} + +async function uploadOnePart( + filePath: string, + layout: PartLayout, + options: UploadArchiveOptions, +): Promise { + const { part, start } = layout; + const timeoutMs = options.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const stream = createReadStream(filePath, { start, end: start + part.size - 1 }); + try { + const res = await fetch(part.url, { + method: part.method, + body: Readable.toWeb(stream), + headers: { 'Content-Length': String(part.size) }, + duplex: 'half', + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new UploadPartHttpError(part.partNumber, res.status, text); + } + const etag = res.headers.get('etag'); + if (etag === null || etag.length === 0) { + throw new Error(`Failed to upload part ${part.partNumber}: missing ETag in response.`); + } + return { partNumber: part.partNumber, etag }; + } catch (error) { + stream.destroy(); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Failed to upload part ${part.partNumber}: upload timed out.`, { cause: error }); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +class UploadPartHttpError extends Error { + constructor( + readonly partNumber: number, + readonly status: number, + readonly responseBody: string, + ) { + super( + `Failed to upload part ${partNumber}: HTTP ${String(status)}${responseBody.length > 0 ? ` ${responseBody}` : ''}`, + ); + } +} + +function isRetryable(error: unknown): boolean { + if (error instanceof UploadPartHttpError) { + return error.status >= 500 || error.status === 408 || error.status === 429; + } + // Network errors and timeouts are retryable. + return true; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/apps/kimi-code/src/generated/vis-web-asset.d.ts b/apps/kimi-code/src/generated/vis-web-asset.d.ts new file mode 100644 index 000000000..9b87ddcf5 --- /dev/null +++ b/apps/kimi-code/src/generated/vis-web-asset.d.ts @@ -0,0 +1 @@ +export declare const VIS_WEB_GZIP_B64: string; diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index ffa13412f..860c4423d 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -6,28 +6,55 @@ */ import { + createKimiHarness, flushDiagnosticLogs, + installGlobalProxyDispatcher, log, resolveGlobalLogPath, resolveKimiHome, + type TelemetryClient, } from '@moonshot-ai/kimi-code-sdk'; -import { installCrashHandlers, track } from '@moonshot-ai/kimi-telemetry'; +import { + installCrashHandlers, + setTelemetryContext, + shutdownTelemetry, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-telemetry'; import { createProgram } from './cli/commands'; +import { finalizeHeadlessRun } from './cli/headless-exit'; import type { CLIOptions } from './cli/options'; import { OptionConflictError, validateOptions } from './cli/options'; import { runPrompt } from './cli/run-prompt'; import { runShell } from './cli/run-shell'; import { formatStartupError } from './cli/startup-error'; import { runPluginNodeEntry } from './cli/sub/plugin-run-node'; +import { handleUpgrade } from './cli/sub/upgrade'; +import { createCliTelemetryBootstrap, initializeCliTelemetry } from './cli/telemetry'; import { runUpdatePreflight } from './cli/update/preflight'; -import { getVersion } from './cli/version'; +import { createKimiCodeHostIdentity, getVersion } from './cli/version'; +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; -import { initProcessName } from './utils/process/proctitle'; -export async function handleMainCommand(opts: CLIOptions, version: string): Promise { +/** + * Outcome of a CLI command run, reported back to the process entrypoint. + * + * `handleMainCommand` is a reusable, unit-tested handler — it must not terminate + * the process itself. It reports here whether a headless (`kimi -p`) run + * completed so the entrypoint (the only place that owns the process) can arm the + * force-exit fallback. + */ +export interface MainCommandOutcome { + readonly headlessCompleted: boolean; +} + +export async function handleMainCommand( + opts: CLIOptions, + version: string, +): Promise { let validated: ReturnType; try { validated = validateOptions(opts); @@ -49,10 +76,11 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom if (validated.uiMode === 'print') { await runPrompt(validated.options, version); - return; + return { headlessCompleted: true }; } await runShell(validated.options, version); + return { headlessCompleted: false }; } /** `kimi migrate`: launch the migration screen only, then exit. */ @@ -60,6 +88,37 @@ async function handleMigrateCommand(version: string): Promise { await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true }); } +export async function handleUpgradeCommand(version: string): Promise { + const telemetryBootstrap = createCliTelemetryBootstrap(); + const telemetryClient: TelemetryClient = { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; + const harness = createKimiHarness({ + homeDir: telemetryBootstrap.homeDir, + identity: createKimiCodeHostIdentity(version), + telemetry: telemetryClient, + }); + let exitCode = 1; + try { + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + initializeCliTelemetry({ + harness, + bootstrap: telemetryBootstrap, + config, + version, + uiMode: CLI_UI_MODE, + }); + exitCode = await handleUpgrade(version, { track, logger: log }); + } finally { + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}); + await harness.close().catch(() => {}); + } + process.exit(exitCode); +} + /** A neutral CLIOptions value — `kimi migrate` never opens a chat session. */ const MIGRATE_CLI_OPTIONS: CLIOptions = { session: undefined, @@ -74,8 +133,12 @@ const MIGRATE_CLI_OPTIONS: CLIOptions = { }; export function main(): void { - initProcessName(); + process.title = PROCESS_NAME; installCrashHandlers(); + // Route all outbound fetch through HTTP_PROXY/HTTPS_PROXY (honoring NO_PROXY) + // before any client is constructed. No-op when no proxy variable is set; an + // invalid proxy URL is reported and ignored rather than aborting startup. + installGlobalProxyDispatcher(); installNativeModuleHook(); if (runNativeAssetSmokeIfRequested()) return; @@ -93,17 +156,42 @@ export function main(): void { const program = createProgram( version, (opts) => { - void handleMainCommand(opts, version).catch(async (error: unknown) => { - const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell'; - await logStartupFailure(operation, error); - process.stderr.write( - formatStartupError(error, { - operation, - }), - ); - process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); - process.exit(1); - }); + void handleMainCommand(opts, version) + .then(async (outcome) => { + // Only the process entrypoint disposes of the process. Print mode + // relies on the event loop draining to exit; flush any buffered output + // and then arm an unref'd fallback so a stray ref'd handle left over + // from the run can't wedge a completed `kimi -p` until an external + // timeout. A healthy run drains and exits before the fallback fires. + if (outcome.headlessCompleted) { + await finalizeHeadlessRun( + process, + [process.stdout, process.stderr], + () => Number(process.exitCode) || 0, + ); + } + }) + .catch(async (error: unknown) => { + // Set the failure exit code synchronously, before any `await`. The + // terminal `process.exit(1)` below is our intended exit, but it sits + // behind `await logStartupFailure(...)`; by the time we reach that + // await, the failed run's `finally` cleanup has already torn down its + // ref'd handles (sockets, timers, background tasks). If the event loop + // drains during the await, Node exits on its own with the DEFAULT code + // 0 and `process.exit(1)` never runs — headless (`kimi -p`) failures + // would then exit 0 nondeterministically. Setting `process.exitCode` + // up front makes that drain-exit report failure too. + process.exitCode = 1; + const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell'; + await logStartupFailure(operation, error); + process.stderr.write( + formatStartupError(error, { + operation, + }), + ); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); }, () => { void handleMigrateCommand(version).catch(async (error: unknown) => { @@ -120,6 +208,14 @@ export function main(): void { process.exit(1); }); }, + () => { + void handleUpgradeCommand(version).catch(async (error: unknown) => { + await logStartupFailure('upgrade', error); + process.stderr.write(formatStartupError(error, { operation: 'upgrade' })); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); + }, ); program.parse(process.argv); diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index a0800d84c..d4c4fee7e 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -11,10 +11,11 @@ * This file implements the ask, progress, and result phases. `beginMigration` * drives the real runMigration flow (injectable for tests). */ -import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui'; +import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { resolveMigrationScope, runMigration as realRunMigration, @@ -46,7 +47,7 @@ export interface MigrationScreenOptions { readonly plan: MigrationPlan; readonly sourceHome: string; readonly targetHome: string; - readonly colors: ColorPalette; + readonly colors?: ColorPalette; /** Called once the screen is finished; the host then restores the editor. */ readonly onComplete: (result: MigrationScreenResult) => void; /** Triggers a re-render; the host wires this to `ui.requestRender()`. */ @@ -93,6 +94,7 @@ export class MigrationScreenComponent extends Container implements Focusable { private spinnerTimer: ReturnType | undefined; private report: MigrationReport | undefined; private migrationFailed = false; + private migrationFailureReason: string | undefined; constructor(opts: MigrationScreenOptions) { super(); @@ -113,8 +115,9 @@ export class MigrationScreenComponent extends Container implements Focusable { } /** Host calls this if runMigration threw. */ - showFailure(): void { + showFailure(error?: unknown): void { this.migrationFailed = true; + this.migrationFailureReason = formatMigrationFailureReason(error); this.phase = 'result'; this.stopSpinner(); } @@ -260,8 +263,8 @@ export class MigrationScreenComponent extends Container implements Focusable { this.showResult(report); this.opts.requestRender?.(); }, - () => { - this.showFailure(); + (error) => { + this.showFailure(error); this.opts.requestRender?.(); }, ); @@ -276,10 +279,14 @@ export class MigrationScreenComponent extends Container implements Focusable { } private renderResult(width: number): string[] { - const { colors } = this.opts; + const colors = this.opts.colors ?? currentTheme.palette; const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; if (this.migrationFailed) { lines.push(chalk.hex(colors.error).bold(' Migration failed')); + if (this.migrationFailureReason !== undefined) { + lines.push(''); + lines.push(chalk.hex(colors.text)(` Reason: ${this.migrationFailureReason}`)); + } lines.push(''); lines.push(chalk.hex(colors.text)(' You can retry later by running "kimi migrate".')); lines.push(''); @@ -422,7 +429,7 @@ export class MigrationScreenComponent extends Container implements Focusable { } private renderProgress(width: number): string[] { - const { colors } = this.opts; + const colors = this.opts.colors ?? currentTheme.palette; const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0]; const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), @@ -452,7 +459,7 @@ export class MigrationScreenComponent extends Container implements Focusable { } private renderAsk(width: number): string[] { - const { colors } = this.opts; + const colors = this.opts.colors ?? currentTheme.palette; const step = this.currentStep(); const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), @@ -487,6 +494,45 @@ export class MigrationScreenComponent extends Container implements Focusable { } } +function formatMigrationFailureReason(error: unknown): string | undefined { + let reason: string | undefined; + if (error instanceof Error) { + reason = error.message !== '' ? error.message : error.name; + } else if (typeof error === 'string') { + reason = error; + } else if (typeof error === 'object' && error !== null) { + const maybeMessage = (error as { readonly message?: unknown }).message; + if (typeof maybeMessage === 'string' && maybeMessage !== '') { + reason = maybeMessage; + } + } + if (reason === undefined) { + switch (typeof error) { + case 'number': + case 'boolean': + case 'bigint': + reason = `${error}`; + break; + case 'symbol': + reason = + error.description !== undefined ? `Symbol(${error.description})` : 'Symbol rejection'; + break; + case 'function': + reason = error.name !== '' ? `Function ${error.name}` : 'Function rejection'; + break; + case 'object': + if (error !== null) reason = 'Object rejection'; + break; + case 'undefined': + break; + case 'string': + break; + } + } + const trimmed = reason?.trim(); + return trimmed === undefined || trimmed === '' ? undefined : trimmed; +} + function summarizePlan(plan: MigrationPlan): string { const parts: string[] = []; if (plan.totalSessions > 0) parts.push(`${plan.totalSessions} sessions`); diff --git a/apps/kimi-code/src/native/module-hook.ts b/apps/kimi-code/src/native/module-hook.ts index bbef5d4cb..bc8a1a67b 100644 --- a/apps/kimi-code/src/native/module-hook.ts +++ b/apps/kimi-code/src/native/module-hook.ts @@ -1,6 +1,8 @@ +import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; +import { join } from 'node:path'; -import { loadNativePackage } from './native-require'; +import { getNativePackageRoot } from './native-assets'; type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown; @@ -10,7 +12,16 @@ interface ModuleWithLoad { const nodeRequire = createRequire(import.meta.url); let installed = false; -let loadingNativePackage = false; + +// pi-tui loads its platform-specific native helpers via an absolute-path +// require() computed from import.meta.url / process.execPath +// (see pi-tui dist/terminal.js and dist/native-modifiers.js). In a SEA binary +// those .node files live in the native-asset cache, so redirect any absolute +// require of a pi-tui native helper to the cached copy. +// +// Path shape: native//prebuilds//.node — note the +// two path segments after "prebuilds", so ".+" (not "[^/]+") is required. +const PI_TUI_NATIVE_PATTERN = /native[\\/](?:win32|darwin)[\\/]prebuilds[\\/].+\.node$/; export function installNativeModuleHook(): void { if (installed) return; @@ -26,13 +37,18 @@ export function installNativeModuleHook(): void { parent: unknown, isMain: boolean, ): unknown { - if (request === 'koffi' && !loadingNativePackage) { - loadingNativePackage = true; - try { - const pkg = loadNativePackage('koffi'); - if (pkg !== null) return pkg; - } finally { - loadingNativePackage = false; + if ( + typeof request === 'string' && + PI_TUI_NATIVE_PATTERN.test(request) && + !existsSync(request) + ) { + const pkgRoot = getNativePackageRoot('@moonshot-ai/pi-tui'); + if (pkgRoot !== null) { + const match = request.match(PI_TUI_NATIVE_PATTERN); + if (match !== null) { + const redirected = join(pkgRoot, match[0]); + return originalLoad.call(this, redirected, parent, isMain); + } } } return originalLoad.call(this, request, parent, isMain); diff --git a/apps/kimi-code/src/native/native-assets.ts b/apps/kimi-code/src/native/native-assets.ts index 576c8079e..d66547695 100644 --- a/apps/kimi-code/src/native/native-assets.ts +++ b/apps/kimi-code/src/native/native-assets.ts @@ -12,6 +12,7 @@ import { import { createRequire } from 'node:module'; import { homedir } from 'node:os'; import { dirname, join, win32 as pathWin32 } from 'node:path'; +import { join as joinPosix } from 'pathe'; import { KIMI_BUILD_INFO } from '#/cli/build-info'; import { NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, buildManifestKey } from '../../scripts/native/manifest.mjs'; @@ -143,7 +144,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string { const cacheDirEnv = optionalEnvValue(env, 'KIMI_CODE_CACHE_DIR'); if (cacheDirEnv !== null) return cacheDirEnv; - if (platform === 'darwin') return join(home, 'Library', 'Caches', 'kimi-code'); + if (platform === 'darwin') return joinPosix(home, 'Library', 'Caches', 'kimi-code'); if (platform === 'win32') { const localAppData = optionalEnvValue(env, 'LOCALAPPDATA'); return localAppData !== null @@ -151,7 +152,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string { : pathWin32.join(home, 'AppData', 'Local', 'kimi-code', 'Cache'); } - return join(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? join(home, '.cache'), 'kimi-code'); + return joinPosix(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? joinPosix(home, '.cache'), 'kimi-code'); } export function getNativeAssetCacheRoot( diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts index 56d39253f..c77f1419d 100644 --- a/apps/kimi-code/src/native/smoke.ts +++ b/apps/kimi-code/src/native/smoke.ts @@ -1,6 +1,38 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; -const smokePackages = ['@mariozechner/clipboard', 'koffi']; +const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui']; + +// Verify pi-tui's native helper can actually be loaded through the module hook. +// pi-tui computes native helper paths from process.execPath and require()s them; +// those paths do not exist next to the SEA binary, so this only succeeds when +// installNativeModuleHook() redirects the require into the native-asset cache. +function smokePiTuiNativeLoad(): void { + const platform = process.platform; + const arch = process.arch; + let rel: string | undefined; + if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'darwin', 'prebuilds', `darwin-${arch}`, 'darwin-modifiers.node'); + } else if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'win32', 'prebuilds', `win32-${arch}`, 'win32-console-mode.node'); + } + if (rel === undefined) return; // Linux: no native helper, nothing to load. + + const req = createRequire(import.meta.url); + const bogusPath = join(dirname(process.execPath), rel); + const helper = req(bogusPath) as { + isModifierPressed?: unknown; + enableVirtualTerminalInput?: unknown; + }; + const ok = + typeof helper.isModifierPressed === 'function' || + typeof helper.enableVirtualTerminalInput === 'function'; + if (!ok) { + throw new Error(`pi-tui native helper loaded but exports are unexpected: ${rel}`); + } +} export function runNativeAssetSmokeIfRequested(): boolean { if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; @@ -16,6 +48,7 @@ export function runNativeAssetSmokeIfRequested(): boolean { throw new Error(`Native package is not available: ${packageName}`); } } + smokePiTuiNativeLoad(); process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`); process.exit(0); } catch (error) { diff --git a/apps/kimi-code/src/native/web-assets.ts b/apps/kimi-code/src/native/web-assets.ts new file mode 100644 index 000000000..6fb0845e5 --- /dev/null +++ b/apps/kimi-code/src/native/web-assets.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { KIMI_BUILD_INFO } from '#/cli/build-info'; +import { + getNativeCacheBase, + getSeaAssetSource, + type NativeAssetSource, +} from './native-assets'; +import { + WEB_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, + buildWebManifestKey, +} from '../../scripts/native/manifest.mjs'; + +export const WEB_ASSET_MANIFEST_VERSION = MANIFEST_VERSION; + +export interface WebAssetFile { + readonly assetKey: string; + readonly relativePath: string; + readonly sha256: string; +} + +export interface WebAssetManifest { + readonly version: typeof WEB_ASSET_MANIFEST_VERSION; + readonly target: string; + readonly root: 'dist-web'; + readonly files: readonly WebAssetFile[]; +} + +export type WebAssetSource = NativeAssetSource; + +export interface WebAssetOptions { + readonly source?: WebAssetSource | null; + readonly manifest?: WebAssetManifest | null; + readonly cacheBase?: string; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly homeDir?: string; + readonly version?: string; +} + +type RawWebAssetManifest = Omit & { + readonly version: number; + readonly root: string; +}; + +function currentTarget(): string { + return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`; +} + +function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer { + if (Buffer.isBuffer(value)) return value; + if (typeof value === 'string') return Buffer.from(value); + if (ArrayBuffer.isView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + return Buffer.from(value); +} + +function sha256(bytes: Buffer | Uint8Array | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function sanitizeSegment(value: string): string { + const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +function readFileSha256(path: string): string | null { + try { + return sha256(readFileSync(path)); + } catch { + return null; + } +} + +function ensureFile(path: string, bytes: Buffer, expectedSha256: string): void { + if (readFileSha256(path) === expectedSha256) return; + + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, bytes, { mode: 0o644 }); + + try { + renameSync(tempPath, path); + return; + } catch { + if (readFileSha256(path) === expectedSha256) { + rmSync(tempPath, { force: true }); + return; + } + } + + try { + rmSync(path, { force: true }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + if (readFileSha256(path) === expectedSha256) return; + throw error; + } +} + +function assertSafeRelativePath(relativePath: string): void { + if ( + relativePath.length === 0 || + relativePath.startsWith('/') || + relativePath.includes('\\') || + relativePath.split('/').includes('..') || + /^[A-Za-z]:/.test(relativePath) + ) { + throw new Error(`Invalid web asset relative path: ${relativePath}`); + } +} + +export function webAssetManifestKey(target: string = currentTarget()): string { + return buildWebManifestKey(target); +} + +export function getEmbeddedWebAssetManifest( + source: WebAssetSource | null = getSeaAssetSource(), + target = currentTarget(), +): WebAssetManifest | null { + if (source === null) return null; + const key = webAssetManifestKey(target); + if (!source.getAssetKeys().includes(key)) return null; + const raw = source.getRawAsset(key); + const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawWebAssetManifest; + if (manifest.version !== WEB_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported web asset manifest version: ${manifest.version}`); + } + if (manifest.target !== target) { + throw new Error(`Web asset manifest target mismatch: ${manifest.target} !== ${target}`); + } + if (manifest.root !== 'dist-web') { + throw new Error(`Unsupported web asset root: ${manifest.root}`); + } + return manifest as WebAssetManifest; +} + +export function getWebAssetCacheRoot( + manifest: WebAssetManifest, + options: WebAssetOptions = {}, +): string { + const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); + const manifestHash = sha256(JSON.stringify(manifest)); + return join( + getNativeCacheBase({ + cacheBase: options.cacheBase, + env: options.env, + platform: options.platform, + homeDir: options.homeDir, + }), + 'web', + version, + sanitizeSegment(manifest.target), + manifestHash, + manifest.root, + ); +} + +export function getNativeWebAssetsDir(options: WebAssetOptions = {}): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = options.manifest ?? getEmbeddedWebAssetManifest(source, currentTarget()); + if (manifest === null) return null; + + const cacheRoot = getWebAssetCacheRoot(manifest, options); + for (const file of manifest.files) { + assertSafeRelativePath(file.relativePath); + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Web asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256); + } + return cacheRoot; +} diff --git a/apps/kimi-code/src/tui/banner/banner-provider.ts b/apps/kimi-code/src/tui/banner/banner-provider.ts new file mode 100644 index 000000000..087b02934 --- /dev/null +++ b/apps/kimi-code/src/tui/banner/banner-provider.ts @@ -0,0 +1,331 @@ +import { createHash } from 'node:crypto'; + +import { gte, valid } from 'semver'; + +import { KIMI_CODE_TIPS_BANNER_URL } from '#/constant/app'; +import type { BannerDisplay, BannerState } from '#/tui/types'; + +import type { BannerDisplayState } from './state'; + +interface TipsBannerFallbackItem { + banner_id?: string | null; + enabled?: boolean; + banner_title?: string | null; + banner_maintext?: string; + banner_subtext?: string | null; + banner_min_version?: string | null; + banner_display?: unknown; + banner_display_ttl_hours?: unknown; +} + +interface TipsBannerJson { + banner_id?: string | null; + banner_enabled?: boolean; + banner_title?: string | null; + banner_maintext?: string; + banner_subtext?: string | null; + banner_start_time?: string | null; + banner_end_time?: string | null; + banner_min_version?: string | null; + banner_display?: unknown; + banner_display_ttl_hours?: unknown; + banner_fallback_enabled?: boolean; + banner_fallback_list?: unknown[]; +} + +interface BannerHashInput { + tag: string | null; + mainText: string; + subText: string | null; + startTime: string | null; + endTime: string | null; + display: BannerDisplay; + ttlHours?: number; +} + +interface BannerCandidateInput { + id: unknown; + tag: unknown; + mainText: string; + subText: unknown; + display: BannerDisplay; + ttlHours?: number; + startTime?: unknown; + endTime?: unknown; +} + +export interface SelectDisplayableBannerArgs { + json: unknown; + clientVersion: string; + now: Date; + random: () => number; + state: BannerDisplayState; +} + +interface BannerProviderLoadOptions { + state?: BannerDisplayState; + now?: Date; + random?: () => number; +} + +const HOUR_MS = 60 * 60 * 1000; +export const DEFAULT_COOLDOWN_TTL_HOURS = 24; + +function normalizeTag(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeUtcDate(value: string): string { + if (value.endsWith('Z')) return value; + if (/[+-]\d{2}:\d{2}$/.test(value)) return value; + return `${value}Z`; +} + +function parseDate(value: unknown): Date | null { + if (typeof value !== 'string' || value.length === 0) return null; + const normalized = normalizeUtcDate(value); + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +} + +function isWithinWindow(start: Date | null, end: Date | null, now: Date): boolean { + if (start !== null && now < start) return false; + if (end !== null && now > end) return false; + return true; +} + +function meetsMinVersion(minVersion: unknown, clientVersion: string): boolean { + if (minVersion === undefined || minVersion === null) return true; + if (typeof minVersion !== 'string' || minVersion.length === 0) return true; + const min = valid(minVersion); + const current = valid(clientVersion); + if (min === null || current === null) return false; + return gte(current, min); +} + +function parseBannerDisplay(value: unknown): BannerDisplay { + if (value === 'once') return 'once'; + if (value === 'cooldown') return 'cooldown'; + return 'always'; +} + +function parseBannerDisplayTtlHours(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : DEFAULT_COOLDOWN_TTL_HOURS; +} + +function normalizeBannerId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function hashBannerIdentity(input: BannerHashInput): string { + const raw = JSON.stringify([ + input.tag ?? '', + input.mainText, + input.subText ?? '', + input.startTime ?? '', + input.endTime ?? '', + input.display, + input.ttlHours ?? '', + ]); + return createHash('sha256').update(raw).digest('hex').slice(0, 32); +} + +function getBannerKey(rawBannerId: unknown, input: BannerHashInput): string { + return normalizeBannerId(rawBannerId) ?? hashBannerIdentity(input); +} + +function toBannerState(input: BannerCandidateInput): BannerState { + const tag = normalizeTag(input.tag); + const subText = normalizeText(input.subText); + const display = input.display; + const ttlHours = display === 'cooldown' ? parseBannerDisplayTtlHours(input.ttlHours) : undefined; + const startTime = normalizeText(input.startTime); + const endTime = normalizeText(input.endTime); + const key = getBannerKey(input.id, { + tag, + mainText: input.mainText, + subText, + startTime, + endTime, + display, + ttlHours, + }); + + return { + key, + tag, + mainText: input.mainText, + subText, + display, + ttlHours, + }; +} + +function pickActiveBanner( + json: TipsBannerJson, + clientVersion: string, + now: Date, +): BannerState | null { + if (json.banner_enabled !== true) return null; + if (!meetsMinVersion(json.banner_min_version, clientVersion)) return null; + const start = parseDate(json.banner_start_time); + const end = parseDate(json.banner_end_time); + if (!isWithinWindow(start, end, now)) return null; + const mainText = normalizeText(json.banner_maintext); + if (mainText === null) return null; + const display = parseBannerDisplay(json.banner_display); + return toBannerState({ + id: json.banner_id, + tag: json.banner_title, + mainText, + subText: json.banner_subtext, + display, + ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(json.banner_display_ttl_hours) : undefined, + startTime: json.banner_start_time, + endTime: json.banner_end_time, + }); +} + +function pickFallbackCandidates( + json: TipsBannerJson, + clientVersion: string, +): BannerState[] { + if (json.banner_fallback_enabled !== true) return []; + const list = Array.isArray(json.banner_fallback_list) ? json.banner_fallback_list : []; + const candidates: BannerState[] = []; + for (const raw of list) { + if (typeof raw !== 'object' || raw === null) continue; + const item = raw as TipsBannerFallbackItem; + if (item.enabled !== true) continue; + if (!meetsMinVersion(item.banner_min_version, clientVersion)) continue; + const mainText = normalizeText(item.banner_maintext); + if (mainText === null) continue; + const display = parseBannerDisplay(item.banner_display); + candidates.push( + toBannerState({ + id: item.banner_id, + tag: item.banner_title, + mainText, + subText: item.banner_subtext, + display, + ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(item.banner_display_ttl_hours) : undefined, + }), + ); + } + return candidates; +} + +function pickRandomCandidate(candidates: BannerState[], random: () => number): BannerState | null { + if (candidates.length === 0) return null; + const index = Math.floor(random() * candidates.length); + return candidates[index]!; +} + +function pickFallbackBanner( + json: TipsBannerJson, + clientVersion: string, + random: () => number, +): BannerState | null { + return pickRandomCandidate(pickFallbackCandidates(json, clientVersion), random); +} + +function parseShownAt(value: string | undefined): Date | null { + if (value === undefined) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function getCooldownTtlHours(banner: BannerState): number { + return typeof banner.ttlHours === 'number' && Number.isFinite(banner.ttlHours) && banner.ttlHours > 0 + ? banner.ttlHours + : DEFAULT_COOLDOWN_TTL_HOURS; +} + +export function shouldDisplayBanner( + banner: BannerState, + state: BannerDisplayState, + now: Date, +): boolean { + if (banner.display === 'always') return true; + const lastShownAt = parseShownAt(state.shown[banner.key]?.lastShownAt); + if (lastShownAt === null) return true; + if (banner.display === 'once') return false; + return now.getTime() - lastShownAt.getTime() >= getCooldownTtlHours(banner) * HOUR_MS; +} + +export function selectBannerState( + json: unknown, + clientVersion: string, + now: Date, + random: () => number, +): BannerState | null { + const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {}; + return ( + pickActiveBanner(typed, clientVersion, now) ?? + pickFallbackBanner(typed, clientVersion, random) + ); +} + +export function selectDisplayableBanner({ + json, + clientVersion, + now, + random, + state, +}: SelectDisplayableBannerArgs): BannerState | null { + const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {}; + const active = pickActiveBanner(typed, clientVersion, now); + if (active !== null && shouldDisplayBanner(active, state, now)) return active; + const candidates = pickFallbackCandidates(typed, clientVersion).filter((candidate) => + shouldDisplayBanner(candidate, state, now), + ); + return pickRandomCandidate(candidates, random); +} + +export class BannerProvider { + constructor( + private readonly clientVersion: string, + private readonly url: string = KIMI_CODE_TIPS_BANNER_URL, + ) {} + + async load( + fetchImpl: typeof fetch = fetch, + options: BannerProviderLoadOptions = {}, + ): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 3000); + const response = await fetchImpl(this.url, { signal: controller.signal }); + clearTimeout(timeout); + if (!response.ok) return null; + const json = await response.json(); + const now = options.now ?? new Date(); + const random = options.random ?? Math.random; + return options.state === undefined + ? selectBannerState(json, this.clientVersion, now, random) + : selectDisplayableBanner({ + json, + clientVersion: this.clientVersion, + now, + random, + state: options.state, + }); + } catch { + return null; + } + } +} diff --git a/apps/kimi-code/src/tui/banner/state.ts b/apps/kimi-code/src/tui/banner/state.ts new file mode 100644 index 000000000..77877d82a --- /dev/null +++ b/apps/kimi-code/src/tui/banner/state.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +import { getBannerStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +export type BannerDisplayRecord = { + lastShownAt: string; +}; + +export type BannerDisplayState = { + version: 1; + shown: Record; +}; + +const BannerDisplayRecordSchema = z + .object({ + lastShownAt: z.string().min(1), + }) + .strict(); + +const BannerDisplayStateSchema = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const shown = (value as { shown?: unknown }).shown; + if (typeof shown !== 'object' || shown === null) { + return { ...(value as Record), shown: {} }; + } + + const normalizedShown: Record = {}; + for (const [key, record] of Object.entries(shown)) { + if (key.length === 0 || typeof record !== 'object' || record === null) continue; + const lastShownAt = (record as { lastShownAt?: unknown }).lastShownAt; + if (typeof lastShownAt !== 'string' || Number.isNaN(Date.parse(lastShownAt))) continue; + normalizedShown[key] = { lastShownAt }; + } + + return { ...(value as Record), shown: normalizedShown }; + }, + z + .object({ + version: z.literal(1), + shown: z.record(z.string().min(1), BannerDisplayRecordSchema), + }) + .strict(), +); + +export function emptyBannerDisplayState(): BannerDisplayState { + return { + version: 1, + shown: {}, + }; +} + +export async function readBannerDisplayState( + filePath: string = getBannerStateFile(), +): Promise { + try { + return await readJsonFile(filePath, BannerDisplayStateSchema, emptyBannerDisplayState()); + } catch { + return emptyBannerDisplayState(); + } +} + +export async function writeBannerDisplayState( + value: BannerDisplayState, + filePath: string = getBannerStateFile(), +): Promise { + await writeJsonFile(filePath, BannerDisplayStateSchema, value); +} diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts new file mode 100644 index 000000000..90636cea5 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -0,0 +1,91 @@ +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; +import type { SlashCommandHost } from './dispatch'; + +type AddDirChoice = 'session' | 'remember' | 'cancel'; + +export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise { + const input = args.trim(); + const session = host.session; + + if (input.length === 0 || input.toLowerCase() === 'list') { + const additionalDirs = session?.summary?.additionalDirs ?? []; + if (additionalDirs.length === 0) { + host.showStatus('No additional directories configured.'); + return; + } + host.showStatus(formatAdditionalDirsStatus(additionalDirs)); + return; + } + + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + host.mountEditorReplacement( + new ChoicePickerComponent({ + title: `Add directory to workspace: ${input}`, + hint: '↑↓ navigate · Enter confirm · Esc cancel', + options: [ + { + value: 'session', + label: 'Yes, for this session', + }, + { + value: 'remember', + label: 'Yes, and remember this directory', + }, + { + value: 'cancel', + label: 'No', + }, + ], + onSelect: (value) => { + void handleAddDirChoice(host, session.id, input, value as AddDirChoice); + }, + onCancel: () => { + host.restoreEditor(); + host.showStatus(`Did not add ${input} as a working directory.`); + }, + }), + ); +} + +function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string { + return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n'); +} + +async function handleAddDirChoice( + host: SlashCommandHost, + sessionId: string, + path: string, + choice: AddDirChoice, +): Promise { + host.restoreEditor(); + + if (choice === 'cancel') { + host.showStatus(`Did not add ${path} as a working directory.`); + return; + } + + const session = host.session; + if (session === undefined || session.id !== sessionId) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + try { + const result = await session.addAdditionalDir(path, { persist: choice === 'remember' }); + host.setAppState({ additionalDirs: result.additionalDirs }); + host.refreshSlashCommandAutocomplete(); + host.showStatus( + choice === 'remember' + ? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}` + : `Added workspace directory:\n ${path}\n For this session only`, + 'success', + ); + } catch (error) { + host.showError(error instanceof Error ? error.message : String(error)); + } +} diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index bb2622f07..773638485 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -8,36 +8,22 @@ import { type ManagedKimiConfigShape, type OpenPlatformDefinition, } from '@moonshot-ai/kimi-code-oauth'; -import { - applyCatalogProvider, - catalogBaseUrl, - catalogProviderModels, - CatalogFetchError, - fetchCatalog, - inferWireType, - loadBuiltInCatalog, - log, - type Catalog, -} from '@moonshot-ai/kimi-code-sdk'; +import { log } from '@moonshot-ai/kimi-code-sdk'; -import { BUILT_IN_CATALOG_JSON } from '../../built-in-catalog'; import type { ChoiceOption } from '../components/dialogs/choice-picker'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/kimi-tui'; -import { resolveConnectCatalogRequest } from '../utils/connect-catalog'; import { formatErrorMessage } from '../utils/event-payload'; import type { LoginProgressSpinnerHandle } from '../types'; import { promptApiKey, - promptCatalogProviderSelection, promptLogoutProviderSelection, - promptModelSelectionForCatalog, promptModelSelectionForOpenPlatform, promptPlatformSelection, } from './prompts'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- -// Auth: login / logout / connect +// Auth: login / logout // --------------------------------------------------------------------------- export async function handleLoginCommand(host: SlashCommandHost): Promise { @@ -84,6 +70,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { } host.track('login', { provider: DEFAULT_OAUTH_PROVIDER_NAME, + method: 'oauth', already_logged_in: alreadyLoggedIn, }); if (alreadyLoggedIn) { @@ -172,7 +159,11 @@ async function handleOpenPlatformLogin( platform, models, selectedModel: selection.model, - thinking: selection.thinking, + thinking: selection.thinking !== 'off', + effort: + selection.thinking !== 'off' && selection.thinking !== 'on' + ? selection.thinking + : undefined, apiKey, }); @@ -180,7 +171,7 @@ async function handleOpenPlatformLogin( providers: config.providers, models: config.models, defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, + thinking: config.thinking, }); await host.authFlow.refreshConfigAfterLogin(); @@ -188,110 +179,6 @@ async function handleOpenPlatformLogin( host.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`); } -export async function handleConnectCommand(host: SlashCommandHost, args: string): Promise { - const resolution = resolveConnectCatalogRequest(args); - if (resolution.kind === 'error') { - host.showError(resolution.message); - return; - } - const { url, preferBuiltIn, allowBuiltInFallback } = resolution.request; - - let catalog: Catalog | undefined; - - if (preferBuiltIn) { - const builtIn = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON); - if (builtIn !== undefined) { - host.showStatus('Loaded built-in catalog. Run /connect refresh for the latest.'); - catalog = builtIn; - } - } - - if (catalog === undefined) { - const controller = new AbortController(); - const cancel = (): void => { - controller.abort(); - }; - host.cancelInFlight = cancel; - - const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${url}`); - try { - catalog = await fetchCatalog(url, controller.signal); - spinner.stop({ ok: true, label: 'Catalog loaded.' }); - } catch (error) { - if (controller.signal.aborted) { - spinner.stop({ ok: false, label: 'Aborted.' }); - } else { - const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : ''; - if (!allowBuiltInFallback) { - spinner.stop({ ok: false, label: 'Failed to load catalog.' }); - host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); - } else { - const fallback = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON); - if (fallback !== undefined) { - spinner.stop({ ok: true, label: 'Using built-in catalog (offline mode).' }); - catalog = fallback; - } else { - spinner.stop({ ok: false, label: 'Failed to load catalog.' }); - host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); - } - } - } - } finally { - if (host.cancelInFlight === cancel) host.cancelInFlight = undefined; - } - } - - if (catalog === undefined) return; - - const providerId = await promptCatalogProviderSelection(host, catalog); - if (providerId === undefined) return; - const entry = catalog[providerId]; - if (entry === undefined) return; - - const models = catalogProviderModels(entry); - if (models.length === 0) { - host.showError(`Provider "${providerId}" has no usable models in this catalog.`); - return; - } - - const selection = await promptModelSelectionForCatalog(host, providerId, models); - if (selection === undefined) return; - - const apiKey = await promptApiKey(host, entry.name ?? providerId); - if (apiKey === undefined) return; - - const wire = inferWireType(entry); - if (wire === undefined) return; - const baseUrl = catalogBaseUrl(entry, wire); - - const existingConfig = await host.harness.getConfig(); - if (existingConfig.providers[providerId] !== undefined) { - await host.harness.removeProvider(providerId); - } - - const config = await host.harness.getConfig(); - applyCatalogProvider(config, { - providerId, - wire, - baseUrl, - apiKey, - models, - selectedModelId: selection.model.id, - thinking: selection.thinking, - }); - - await host.harness.setConfig({ - providers: config.providers, - models: config.models, - defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, - }); - - await host.authFlow.refreshConfigAfterLogin(); - host.track('connect', { provider: providerId, model: selection.model.id }); - host.showStatus(`Connected: ${entry.name ?? providerId} · ${selection.model.id}`); -} - export async function handleLogoutCommand(host: SlashCommandHost): Promise { const oauthStatus = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); const hasOAuthToken = oauthStatus.providers.some( diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts new file mode 100644 index 000000000..a55ceca0a --- /dev/null +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -0,0 +1,20 @@ +import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleBtwCommand(host: SlashCommandHost, args: string): Promise { + const prompt = args.trim(); + const session = host.session; + if (host.state.appState.model.trim().length === 0 || session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + host.btwPanelController.closeOrCancel(); + + try { + const agentId = await session.startBtw(); + host.btwPanelController.open(agentId, prompt); + } catch (error) { + host.showError(`Failed to start /btw: ${formatErrorMessage(error)}`); + } +} diff --git a/apps/kimi-code/src/tui/commands/complete-args.ts b/apps/kimi-code/src/tui/commands/complete-args.ts new file mode 100644 index 000000000..e377c9bb0 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/complete-args.ts @@ -0,0 +1,41 @@ +import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; + +/** + * A completable token (subcommand or flag) for a slash command's argument + * position. Generic across commands — any `KimiSlashCommand` can build a + * `getArgumentCompletions` from a list of these via {@link completeLeadingArg}. + */ +export interface ArgCompletionSpec { + /** The token inserted on completion, e.g. `pause` or `resume`. */ + readonly value: string; + /** Short description shown in the autocomplete menu. */ + readonly description: string; +} + +/** + * Generic leading-token completer for slash-command arguments. + * + * pi-tui passes `argumentPrefix` = everything typed after `/ `. We only + * complete the *first* token: once the user has typed a space after it (moved on + * to an objective, a flag value, etc.) we return `null` so completion never + * clobbers free text. Matching is case-insensitive prefix match on `value`. + */ +export function completeLeadingArg( + specs: readonly ArgCompletionSpec[], + argumentPrefix: string, +): AutocompleteItem[] | null { + if (argumentPrefix.includes(' ')) return null; + const lower = argumentPrefix.toLowerCase(); + const items = specs + .filter((spec) => spec.value.toLowerCase().startsWith(lower)) + .map((spec) => ({ value: spec.value, label: spec.value, description: spec.description })); + // Nothing left to complete: the user has finished typing a token that is the + // sole remaining match (e.g. `status`). Keeping the menu open here would make + // Enter confirm the no-op completion instead of submitting the command, so we + // suppress it. (A space after the token already returns null above.) + const [only] = items; + if (items.length === 1 && only !== undefined && only.value.toLowerCase() === lower) { + return null; + } + return items.length > 0 ? items : null; +} diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index c70bc8a2c..2d86fa180 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,22 +1,50 @@ -import type { PermissionMode, Session } from '@moonshot-ai/kimi-code-sdk'; +import { + effectiveModelAlias, + type ExperimentalFeatureState, + type ModelAlias, + type PermissionMode, + type Session, + type ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; +import { EffortSelectorComponent } from '../components/dialogs/effort-selector'; +import { + ExperimentsSelectorComponent, + type ExperimentalFeatureDraftChange, +} from '../components/dialogs/experiments-selector'; +import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; -import { saveTuiConfig } from '../config'; -import type { Theme } from '../theme'; +import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; +import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config'; +import type { ThemeName } from '#/tui/theme'; +import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; -import { isTheme } from '../theme/index'; import { formatErrorMessage } from '../utils/event-payload'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; import { showUsage } from './info'; +import { setExperimentalFeatures } from './experimental-flags'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- // Plan / Config commands // --------------------------------------------------------------------------- +const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000; + +function currentTuiConfig(host: SlashCommandHost): TuiConfig { + return { + theme: host.state.appState.theme, + editorCommand: host.state.appState.editorCommand, + disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + notifications: host.state.appState.notifications, + upgrade: host.state.appState.upgrade, + }; +} + export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; if (session === undefined) { @@ -79,7 +107,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P } await session.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); - host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.'); + host.showNotice('YOLO mode: ON', 'AI auto-approves safe actions, asks for approval on risky ones.'); return; } @@ -102,7 +130,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P } else { await session.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); - host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.'); + host.showNotice('YOLO mode: ON', 'AI auto-approves safe actions, asks for approval on risky ones.'); } } @@ -123,7 +151,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P } await session.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); - host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.'); + host.showNotice('Auto mode: ON', 'Run all actions automatically, including risky ones.'); return; } @@ -146,7 +174,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P } else { await session.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); - host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.'); + host.showNotice('Auto mode: ON', 'Run all actions automatically, including risky ones.'); } } @@ -175,15 +203,19 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string): showThemePicker(host); return; } - if (!isTheme(theme)) { - host.showError(`Unknown theme: ${theme}`); - return; + if (!isBuiltInTheme(theme)) { + const custom = await loadCustomThemeMerged(theme); + if (custom === null) { + host.showError(`Unknown theme: ${theme}`); + return; + } } await applyThemeChoice(host, theme); } -export function handleModelCommand(host: SlashCommandHost, args: string): void { +export async function handleModelCommand(host: SlashCommandHost, args: string): Promise { const alias = args.trim(); + await refreshModelsForPicker(host); if (alias.length === 0) { showModelPicker(host); return; @@ -195,6 +227,56 @@ export function handleModelCommand(host: SlashCommandHost, args: string): void { showModelPicker(host, alias); } +export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise { + const alias = host.state.appState.model; + const model = host.state.appState.availableModels[alias]; + if (model === undefined) { + host.showError('No model selected. Run /model to select one first.'); + return; + } + const effective = effectiveModelAlias(model); + const segments = segmentsFor(effective); + const arg = args.trim().toLowerCase(); + if (arg.length === 0) { + showEffortPicker(host, effective, segments); + return; + } + if (!segments.includes(arg)) { + host.showError( + `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, + ); + return; + } + await performModelSwitch(host, alias, arg, true); +} + +function showEffortPicker( + host: SlashCommandHost, + model: ModelAlias, + segments: readonly string[], +): void { + const liveEffort = host.state.appState.thinkingEffort; + const currentValue = segments.includes(liveEffort) ? liveEffort : (segments[0] ?? 'off'); + const alias = host.state.appState.model; + host.mountEditorReplacement( + new EffortSelectorComponent({ + efforts: segments, + currentValue, + onSelect: (effort) => { + host.restoreEditor(); + void performModelSwitch(host, alias, effort, true); + }, + onSessionOnlySelect: (effort) => { + host.restoreEditor(); + void performModelSwitch(host, alias, effort, false); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + // --------------------------------------------------------------------------- // Pickers & config apply // --------------------------------------------------------------------------- @@ -204,7 +286,6 @@ function showEditorPicker(host: SlashCommandHost): void { host.mountEditorReplacement( new EditorSelectorComponent({ currentValue, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); void applyEditorChoice(host, value); @@ -216,6 +297,37 @@ function showEditorPicker(host: SlashCommandHost): void { ); } +async function refreshModelsForPicker(host: SlashCommandHost): Promise { + try { + const result = await withTimeout( + host.authFlow.refreshOAuthProviderModels(), + MODEL_PICKER_REFRESH_TIMEOUT_MS, + ); + if (result === undefined) return; + for (const f of result.failed) { + host.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning'); + } + } catch (error) { + host.showStatus(`Skipped refreshing models: ${formatErrorMessage(error)}`, 'warning'); + } +} + +async function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timeout = setTimeout(() => { + resolve(undefined); + }, timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + async function applyEditorChoice(host: SlashCommandHost, value: string): Promise { const previous = host.state.appState.editorCommand ?? ''; if (value === previous && value.length > 0) { @@ -226,14 +338,13 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise const editorCommand = value.length > 0 ? value : null; try { await saveTuiConfig({ - theme: host.state.appState.theme, + ...currentTuiConfig(host), editorCommand, - notifications: host.state.appState.notifications, }); } catch (error) { host.showStatus( `Failed to save editor: ${formatErrorMessage(error)}`, - host.state.theme.colors.error, + 'error', ); return; } @@ -251,7 +362,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = if (entries.length === 0) { host.showNotice( 'No models configured', - 'Run /login to sign in to Kimi, or /connect to add another provider from a model catalog.', + 'Run /login to sign in to Kimi, or /provider to add another provider from a model catalog.', ); return; } @@ -260,11 +371,14 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = models: host.state.appState.availableModels, currentValue: host.state.appState.model, selectedValue, - currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, + currentThinkingEffort: host.state.appState.thinkingEffort, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void performModelSwitch(host, alias, thinking); + void performModelSwitch(host, alias, thinking, true); + }, + onSessionOnlySelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performModelSwitch(host, alias, thinking, false); }, onCancel: () => { host.restoreEditor(); @@ -273,28 +387,39 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = ); } -async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise { +async function performModelSwitch( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, + persist: boolean, +): Promise { if (host.state.appState.streamingPhase !== 'idle') { host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); return; } - const level = thinking ? 'on' : 'off'; const prevModel = host.state.appState.model; - const prevThinking = host.state.appState.thinking; - const runtimeChanged = alias !== prevModel || thinking !== prevThinking; + const prevEffort = host.state.appState.thinkingEffort; + const modelChanged = alias !== prevModel; + const effortChanged = effort !== prevEffort; + const runtimeChanged = modelChanged || effortChanged; + let effectiveAlias = alias; + let effectiveEffort = effort; const session = host.session; try { if (session === undefined && runtimeChanged) { - await host.authFlow.activateModelAfterLogin(alias, thinking); + await host.authFlow.activateModelAfterLogin(alias, effort); } else if (session !== undefined) { if (alias !== prevModel) { await session.setModel(alias); } - if (thinking !== prevThinking) { - await session.setThinking(level); + if (effort !== prevEffort) { + await session.setThinking(effort); } + const status = await session.getStatus(); + effectiveAlias = status.model ?? alias; + effectiveEffort = status.thinkingEffort; } } catch (error) { const msg = formatErrorMessage(error); @@ -302,41 +427,75 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, thinkin return; } - host.setAppState({ model: alias, thinking }); + if (session === undefined) { + effectiveAlias = host.state.appState.model; + effectiveEffort = host.state.appState.thinkingEffort; + } + const effectiveModelChanged = effectiveAlias !== prevModel; + const effectiveEffortChanged = effectiveEffort !== prevEffort; + const displayName = modelDisplayName( + effectiveAlias, + host.state.appState.availableModels[effectiveAlias], + ); + host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort }); if (session === undefined && runtimeChanged) { - if (alias !== prevModel) { - host.track('model_switch', { model: alias }); + if (effectiveModelChanged) { + host.track('model_switch', { model: effectiveAlias }); } - if (thinking !== prevThinking) { - host.track('thinking_toggle', { enabled: thinking }); + if (effectiveEffortChanged) { + host.track('thinking_toggle', { + enabled: effectiveEffort !== 'off', + effort: effectiveEffort, + from: prevEffort, + }); } } let persisted = false; - try { - persisted = await persistModelSelection(host, alias, thinking); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); - return; + if (persist) { + try { + persisted = await persistModelSelection(host, effectiveAlias, effectiveEffort); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); + return; + } } - const status = runtimeChanged - ? `Switched to ${alias} with thinking ${level}.` - : persisted - ? `Saved ${alias} with thinking ${level} as default.` - : `Already using ${alias} with thinking ${level}.`; - host.showStatus(status, host.state.theme.colors.success); + let status: string; + if (effectiveModelChanged) { + status = persist + ? `Switched to ${displayName} with thinking ${effectiveEffort}.` + : `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`; + } else if (effectiveEffortChanged) { + status = persist + ? `Thinking set to ${effectiveEffort}.` + : `Thinking set to ${effectiveEffort} for this session only.`; + } else if (persist && persisted) { + status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`; + } else { + status = `Already using ${displayName} with thinking ${effectiveEffort}.`; + } + host.showStatus(status, 'success'); } -async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise { +async function persistModelSelection( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, +): Promise { const config = await host.harness.getConfig({ reload: true }); - if (config.defaultModel === alias && config.defaultThinking === thinking) { + const patch = thinkingEffortToConfig(effort); + if ( + config.defaultModel === alias && + config.thinking?.enabled === patch.enabled && + config.thinking?.effort === patch.effort + ) { return false; } await host.harness.setConfig({ defaultModel: alias, - defaultThinking: thinking, + thinking: patch, }); return true; } @@ -345,7 +504,6 @@ function showThemePicker(host: SlashCommandHost): void { host.mountEditorReplacement( new ThemeSelectorComponent({ currentValue: host.state.appState.theme, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); void applyThemeChoice(host, value); @@ -357,29 +515,41 @@ function showThemePicker(host: SlashCommandHost): void { ); } -async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { +async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promise { if (theme === host.state.appState.theme) { if (theme === 'auto') host.refreshTerminalThemeTracking(); host.showStatus(`Theme unchanged: "${theme}".`); return; } + // Validate custom themes up front so a missing / malformed file reports an + // error instead of silently persisting a name that resolves to the dark + // fallback. + if (!isBuiltInTheme(theme)) { + const palette = await loadCustomThemeMerged(theme); + if (palette === null) { + host.showStatus(`Theme "${theme}" could not be loaded.`, 'error'); + return; + } + } + try { await saveTuiConfig({ + ...currentTuiConfig(host), theme, - editorCommand: host.state.appState.editorCommand, - notifications: host.state.appState.notifications, }); } catch (error) { host.showStatus( `Failed to save theme: ${formatErrorMessage(error)}`, - host.state.theme.colors.error, + 'error', ); return; } - const resolved = theme === 'auto' ? host.state.theme.resolvedTheme : theme; - host.applyTheme(theme, resolved); + const resolved = theme === 'auto' + ? (currentTheme.palette === lightColors ? 'light' : 'dark') + : undefined; + await host.applyTheme(theme, resolved); host.refreshTerminalThemeTracking(); host.track('theme_switch', { theme }); const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : ''; @@ -390,7 +560,6 @@ export function showPermissionPicker(host: SlashCommandHost): void { host.mountEditorReplacement( new PermissionSelectorComponent({ currentValue: host.state.appState.permissionMode, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); void applyPermissionChoice(host, value); @@ -402,6 +571,127 @@ export function showPermissionPicker(host: SlashCommandHost): void { ); } +export function showUpdatePreferencePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new UpdatePreferenceSelectorComponent({ + currentValue: host.state.appState.upgrade.autoInstall, + onSelect: (value) => { + host.restoreEditor(); + void applyUpdatePreferenceChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +export async function showExperimentsPanel(host: SlashCommandHost): Promise { + let features: readonly ExperimentalFeatureState[]; + try { + features = await host.harness.getExperimentalFeatures(); + } catch (error) { + host.showError(`Failed to load experimental features: ${formatErrorMessage(error)}`); + return; + } + mountExperimentsPanel(host, features); +} + +export async function applyExperimentalFeatureChanges( + host: SlashCommandHost, + changes: readonly ExperimentalFeatureDraftChange[], +): Promise { + if (changes.length === 0) { + host.showStatus( + 'No experimental feature changes to apply.', + 'textMuted', + ); + return; + } + + const experimental: Record = {}; + for (const change of changes) { + experimental[change.id] = change.enabled; + } + + try { + await host.harness.setConfig({ experimental }); + const features = await host.harness.getExperimentalFeatures(); + setExperimentalFeatures(features); + host.refreshSlashCommandAutocomplete(); + host.restoreEditor(); + if (host.session !== undefined) { + await host.session.reloadSession(); + await host.reloadCurrentSessionView( + host.session, + 'Experimental features updated. Session reloaded.', + ); + } else { + host.showStatus('Experimental features updated.', 'success'); + } + host.track('experimental_features_apply', { changed: changes.length }); + } catch (error) { + host.showError(`Failed to update experimental features: ${formatErrorMessage(error)}`); + } +} + +function mountExperimentsPanel( + host: SlashCommandHost, + features: readonly ExperimentalFeatureState[], +): void { + host.mountEditorReplacement( + new ExperimentsSelectorComponent({ + features, + onApply: (changes) => { + void applyExperimentalFeatureChanges(host, changes); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +type UpdatePreferenceHost = { + readonly state: { + readonly appState: Pick< + SlashCommandHost['state']['appState'], + 'theme' | 'editorCommand' | 'notifications' | 'upgrade' + >; + }; + setAppState(patch: Pick): void; + showStatus(msg: string, color?: string): void; + track: SlashCommandHost['track']; +}; + +export async function applyUpdatePreferenceChoice( + host: UpdatePreferenceHost, + autoInstall: boolean, +): Promise { + if (autoInstall === host.state.appState.upgrade.autoInstall) { + host.showStatus(`Automatic updates already ${autoInstall ? 'enabled' : 'disabled'}.`); + return; + } + + const upgrade = { autoInstall }; + try { + await saveTuiConfig({ + ...currentTuiConfig(host as unknown as SlashCommandHost), + upgrade, + }); + } catch (error) { + host.showStatus( + `Failed to save automatic update setting: ${formatErrorMessage(error)}`, + 'error', + ); + return; + } + + host.setAppState({ upgrade }); + host.track('upgrade_preference_changed', { auto_install: autoInstall }); + host.showStatus(`Automatic updates ${autoInstall ? 'enabled' : 'disabled'}.`); +} + async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMode): Promise { if (mode === host.state.appState.permissionMode) { host.showStatus(`Permission mode unchanged: ${mode}.`); @@ -423,7 +713,6 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod export function showSettingsSelector(host: SlashCommandHost): void { host.mountEditorReplacement( new SettingsSelectorComponent({ - colors: host.state.theme.colors, onSelect: (value) => { handleSettingsSelection(host, value); }, @@ -441,6 +730,8 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio case 'permission': showPermissionPicker(host); return; case 'theme': showThemePicker(host); return; case 'editor': showEditorPicker(host); return; + case 'experiments': void showExperimentsPanel(host); return; + case 'upgrade': showUpdatePreferencePicker(host); return; case 'usage': void showUsage(host); return; } } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 3737178d7..7508bc06a 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -1,42 +1,49 @@ -import type { Component, Focusable } from '@earendil-works/pi-tui'; +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; -import type { Theme } from '../theme'; -import type { ResolvedTheme } from '../theme/colors'; -import { - LLM_NOT_SET_MESSAGE, -} from '../constant/kimi-tui'; -import { formatErrorMessage } from '../utils/event-payload'; -import { parseSlashInput } from './parse'; -import { - resolveSlashCommandInput, - slashBusyMessage, -} from './resolve'; -import type { BuiltinSlashCommandName } from './registry'; +import type { ColorToken, ThemeName } from '#/tui/theme'; + +import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; import type { AuthFlowController } from '../controllers/auth-flow'; +import type { BtwPanelController } from '../controllers/btw-panel'; import type { StreamingUIController } from '../controllers/streaming-ui'; import type { TasksBrowserController } from '../controllers/tasks-browser'; -import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types'; -import type { TUIState } from '../tui-state'; - -import { handleLoginCommand, handleLogoutCommand } from './auth'; import { tryHandleDanceCommand } from '../easter-eggs/dance'; +import type { ResolvedTheme } from '../theme/colors'; +import type { TUIState } from '../tui-state'; +import type { + AppState, + LoginProgressSpinnerHandle, + QueuedMessage, + TranscriptEntry, +} from '../types'; +import { formatErrorMessage } from '../utils/event-payload'; +import { handleLoginCommand, handleLogoutCommand } from './auth'; +import { handleBtwCommand } from './btw'; import { handleAutoCommand, handleCompactCommand, handleEditorCommand, + handleEffortCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, handleYoloCommand, + showExperimentsPanel, showModelPicker, showPermissionPicker, showSettingsSelector, } from './config'; -import { handleProviderCommand } from './provider'; +import { handleGoalCommand } from './goal'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handleAddDirCommand } from './add-dir'; +import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; +import { handleProviderCommand } from './provider'; +import type { BuiltinSlashCommandName } from './registry'; +import { handleReloadCommand, handleReloadTuiCommand } from './reload'; +import { resolveSlashCommandInput, slashBusyMessage } from './resolve'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -44,35 +51,36 @@ import { handleInitCommand, handleTitleCommand, } from './session'; +import { handleSwarmCommand } from './swarm'; +import { handleUndoCommand } from './undo'; +import { handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Re-exports — keep existing consumers working // --------------------------------------------------------------------------- -export { - handleConnectCommand, - handleLoginCommand, - handleLogoutCommand, -} from './auth'; +export { handleLoginCommand, handleLogoutCommand } from './auth'; +export { handleBtwCommand } from './btw'; +export { handleAddDirCommand } from './add-dir'; export { handleAutoCommand, handleCompactCommand, handleEditorCommand, + handleEffortCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, handleYoloCommand, showModelPicker, + showExperimentsPanel, showPermissionPicker, showSettingsSelector, } from './config'; -export { - handleFeedbackCommand, - showMcpServers, - showStatusReport, - showUsage, -} from './info'; +export { handleSwarmCommand } from './swarm'; +export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; +export { handleReloadCommand, handleReloadTuiCommand } from './reload'; +export { handleGoalCommand } from './goal'; export { handleExportDebugZipCommand, handleExportMdCommand, @@ -80,6 +88,8 @@ export { handleInitCommand, handleTitleCommand, } from './session'; +export { handleUndoCommand } from './undo'; +export { handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Host interface @@ -95,18 +105,23 @@ export interface SlashCommandHost { setAppState(patch: Partial): void; resetLivePane(): void; showError(msg: string): void; - showStatus(msg: string, color?: string): void; + showStatus(msg: string, color?: ColorToken): void; showNotice(title: string, detail?: string): void; + appendTranscriptEntry(entry: TranscriptEntry): void; track(event: string, props?: Record): void; mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; + restoreInputText(text: string): void; + refreshSlashCommandAutocomplete(): void; // Session requireSession(): Session; switchToSession(session: Session, message: string): Promise; + reloadCurrentSessionView(session: Session, message: string): Promise; beginSessionRequest(): void; failSessionRequest(message: string): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; + requestQueuedGoalPromotion?(): void; // UI showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; @@ -114,20 +129,29 @@ export interface SlashCommandHost { showProgressSpinner(label: string): LoginProgressSpinnerHandle; // Theme - applyTheme(theme: Theme, resolved?: ResolvedTheme): void; + applyTheme(theme: ThemeName, resolved?: ResolvedTheme): Promise; refreshTerminalThemeTracking(): void; // Dispatch stop(exitCode?: number): Promise; + setExitOpenUrl(url: string): void; showHelpPanel(): void; createNewSession(): Promise; showSessionPicker(): Promise; sendNormalUserInput(text: string): void; sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; + activatePluginCommand( + session: Session, + pluginId: string, + commandName: string, + args: string, + ): void; readonly skillCommandMap: Map; + readonly pluginCommandMap: Map; // Controller refs readonly streamingUI: StreamingUIController; + readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; readonly authFlow: AuthFlowController; } @@ -149,6 +173,7 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi const intent = resolveSlashCommandInput({ input, skillCommandMap: host.skillCommandMap, + pluginCommandMap: host.pluginCommandMap, isStreaming: host.state.appState.streamingPhase !== 'idle', isCompacting: host.state.appState.isCompacting, }); @@ -160,6 +185,13 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); host.showError(slashBusyMessage(intent.commandName, intent.reason)); return; + case 'invalid': + host.track('input_command_invalid', { + reason: intent.reason, + command: intent.commandName, + }); + host.showError(`Invalid slash command: /${intent.commandName}`); + return; case 'skill': { const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { @@ -173,6 +205,20 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.sendSkillActivation(session, intent.skillName, intent.args); return; } + case 'plugin-command': { + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + const session = host.session; + if (session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` }); + host.activatePluginCommand(session, intent.pluginId, intent.commandName, intent.args); + return; + } case 'message': // Unknown slash command: let /dance claim it before it falls through to // the model as a normal message. This runs *after* builtin and skill @@ -227,6 +273,18 @@ async function handleBuiltInSlashCommand( case 'plugins': void handlePluginsCommand(host, args); return; + case 'add-dir': + await handleAddDirCommand(host, args); + return; + case 'experiments': + await showExperimentsPanel(host); + return; + case 'reload': + await handleReloadCommand(host); + return; + case 'reload-tui': + await handleReloadTuiCommand(host); + return; case 'editor': await handleEditorCommand(host, args); return; @@ -234,7 +292,10 @@ async function handleBuiltInSlashCommand( await handleThemeCommand(host, args); return; case 'model': - handleModelCommand(host, args); + await handleModelCommand(host, args); + return; + case 'effort': + await handleEffortCommand(host, args); return; case 'provider': await handleProviderCommand(host); @@ -254,6 +315,9 @@ async function handleBuiltInSlashCommand( case 'feedback': await handleFeedbackCommand(host); return; + case 'btw': + await handleBtwCommand(host, args); + return; case 'title': await handleTitleCommand(host, args); return; @@ -266,9 +330,15 @@ async function handleBuiltInSlashCommand( case 'plan': await handlePlanCommand(host, args); return; + case 'swarm': + await handleSwarmCommand(host, args); + return; case 'compact': await handleCompactCommand(host, args); return; + case 'goal': + await handleGoalCommand(host, args); + return; case 'init': await handleInitCommand(host); return; @@ -287,6 +357,12 @@ async function handleBuiltInSlashCommand( case 'logout': await handleLogoutCommand(host); return; + case 'undo': + await handleUndoCommand(host, args); + return; + case 'web': + await handleWebCommand(host); + return; default: host.showError(`Unknown slash command: /${String(name)}`); return; diff --git a/apps/kimi-code/src/tui/commands/experimental-flags.ts b/apps/kimi-code/src/tui/commands/experimental-flags.ts index e1231742c..0800e7b36 100644 --- a/apps/kimi-code/src/tui/commands/experimental-flags.ts +++ b/apps/kimi-code/src/tui/commands/experimental-flags.ts @@ -1,12 +1,16 @@ -import type { ExperimentalFlagMap } from '@moonshot-ai/kimi-code-sdk'; +import type { ExperimentalFeatureState, ExperimentalFlagMap } from '@moonshot-ai/kimi-code-sdk'; -// Resolved experimental flags, fetched once from the core over RPC at startup and then read +import { experimentalFeatureMap } from '#/utils/experimental-features'; + +// Resolved experimental features, fetched once from the core over RPC at startup and then read // synchronously by the command palette and dispatch. App-local cache, not a source of truth. let snapshot: ExperimentalFlagMap = {}; -/** Replace the cached flag snapshot. Call once after fetching via `harness.getExperimentalFlags()`. */ -export function setExperimentalFlags(flags: ExperimentalFlagMap): void { - snapshot = flags; +/** Replace the cached flag snapshot. Call after fetching via `harness.getExperimentalFeatures()`. */ +export function setExperimentalFeatures( + features: readonly Pick[], +): void { + snapshot = experimentalFeatureMap(features); } /** An `undefined` flag means "not gated" → always enabled, so callers can pass an optional flag id. */ diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts new file mode 100644 index 000000000..de790b906 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -0,0 +1,507 @@ +import { ErrorCodes, isKimiError, type PermissionMode } from '@moonshot-ai/kimi-code-sdk'; + +import { + GoalStartPermissionPromptComponent, + type GoalStartPermissionChoice, +} from '../components/dialogs/goal-start-permission-prompt'; +import { + GoalQueueEditDialogComponent, + GoalQueueManagerComponent, + type GoalQueueEditResult, + type GoalQueueManagerAction, +} from '../components/dialogs/goal-queue-manager'; +import { + GoalSetMessageComponent, + GoalStatusMessageComponent, + UpcomingGoalAddedMessageComponent, +} from '../components/messages/goal-panel'; +import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; +import { + appendGoalQueueItem, + moveGoalQueueItem, + readGoalQueue, + removeGoalQueueItem, + updateGoalQueueItem, + type GoalQueueSnapshot, +} from '../goal-queue-store'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +const MAX_GOAL_OBJECTIVE_LENGTH = 4000; +const RESUME_GOAL_INPUT = 'Resume the active goal.'; +const START_NEXT_GOAL_NOW_MESSAGE = 'No active goal. Starting this goal now.'; + +type GoalCommandHost = Pick< + SlashCommandHost, + | 'state' + | 'session' + | 'requireSession' + | 'setAppState' + | 'showError' + | 'showStatus' + | 'track' + | 'mountEditorReplacement' + | 'restoreEditor' + | 'restoreInputText' + | 'sendNormalUserInput' +>; + +export interface GoalStartOptions { + readonly beforeSend?: () => boolean | Promise; + readonly sendInput?: (objective: string) => void; +} + +export type ParsedGoalCommand = + | { readonly kind: 'status' } + | { readonly kind: 'pause' } + | { readonly kind: 'resume' } + | { readonly kind: 'cancel' } + | { + readonly kind: 'create'; + readonly objective: string; + readonly replace: boolean; + } + | { readonly kind: 'next-add'; readonly objective: string } + | { readonly kind: 'next-manage' } + | { readonly kind: 'error'; readonly message: string; readonly severity?: 'error' | 'hint' }; + +const CONTROL_SUBCOMMANDS = new Set(['pause', 'resume', 'cancel']); + +/** + * Parses the deterministic `/goal` command grammar. Reserved subcommands + * (`pause`/`resume`/`cancel`/`status`/`replace`) are only honored as the first + * token; use `/goal -- ` to start a goal whose text begins with one + * of those words. (`cancel` is the single discard action — it removes the + * current goal.) Stop conditions are expressed in the objective in natural + * language (e.g. "…or stop after 20 turns"); the model honors them when it + * self-audits each turn and reports `complete`/`blocked` via UpdateGoal. + */ +export function parseGoalCommand(rawArgs: string): ParsedGoalCommand { + const args = rawArgs.trim(); + if (args.length === 0 || args === 'status') return { kind: 'status' }; + + const tokens = args.split(/\s+/); + const first = tokens[0]; + if (first === 'next') { + return parseNextGoalCommand(tokens); + } + if (first !== undefined && CONTROL_SUBCOMMANDS.has(first) && tokens.length === 1) { + return { kind: first as 'pause' | 'resume' | 'cancel' }; + } + + let index = 0; + let replace = false; + if (tokens[index] === 'replace') { + replace = true; + index += 1; + } + // `--` ends subcommand parsing so an objective can begin with a reserved word + // (e.g. `/goal -- pause the rollout`). + if (tokens[index] === '--') { + index += 1; + } + + const objective = tokens.slice(index).join(' ').trim(); + if (objective.length === 0) { + // A usage hint, not a failure — shown in the same calm style as the other + // "nothing to act on" messages (no goal to pause/resume/cancel). + return { + kind: 'error', + severity: 'hint', + message: 'Provide a goal objective, e.g. `/goal Ship feature X`.', + }; + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + return { + kind: 'error', + message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Reference long details by file path.`, + }; + } + return { kind: 'create', objective, replace }; +} + +export async function handleGoalCommand(host: SlashCommandHost, args: string): Promise { + const parsed = parseGoalCommand(args); + switch (parsed.kind) { + case 'error': + if (parsed.severity === 'hint') host.showStatus(parsed.message); + else host.showError(parsed.message); + return; + case 'status': + await showGoalStatus(host); + return; + case 'pause': + await pauseGoal(host); + return; + case 'resume': + await resumeGoal(host); + return; + case 'cancel': + await cancelGoal(host); + return; + case 'next-add': + await queueNextGoal(host, parsed); + return; + case 'next-manage': + await showGoalQueueManager(host); + return; + case 'create': + await createGoal(host, parsed, args); + return; + } +} + +function parseNextGoalCommand(tokens: readonly string[]): ParsedGoalCommand { + if (tokens.length === 2 && tokens[1] === 'manage') return { kind: 'next-manage' }; + let index = 1; + if (tokens[index] === '--') index += 1; + const objective = tokens.slice(index).join(' ').trim(); + if (objective.length === 0) { + return { + kind: 'error', + severity: 'hint', + message: + 'Provide an upcoming goal objective, e.g. `/goal next Ship feature X`, or use `/goal next manage`.', + }; + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + return { + kind: 'error', + message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Reference long details by file path.`, + }; + } + return { kind: 'next-add', objective }; +} + +async function queueNextGoal( + host: SlashCommandHost, + parsed: Extract, +): Promise { + const session = host.requireSession(); + let hasCurrentGoal: boolean; + try { + const { goal } = await session.getGoal(); + hasCurrentGoal = goal !== null; + } catch (error) { + host.showError(`Failed to inspect current goal: ${formatErrorMessage(error)}`); + return; + } + + if (!hasCurrentGoal && !isBusy(host)) { + host.showStatus(START_NEXT_GOAL_NOW_MESSAGE); + await createGoal( + host, + { kind: 'create', objective: parsed.objective, replace: false }, + `next ${parsed.objective}`, + ); + return; + } + + try { + await appendGoalQueueItem(session, { objective: parsed.objective }); + } catch (error) { + host.showError(formatErrorMessage(error)); + return; + } + host.track('goal_queue_append'); + if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.(); + host.state.transcriptContainer.addChild( + new UpcomingGoalAddedMessageComponent(), + ); + host.state.ui.requestRender(); +} + +async function showGoalQueueManager( + host: SlashCommandHost, + selectedGoalId?: string, +): Promise { + let snapshot: GoalQueueSnapshot; + try { + snapshot = await readGoalQueue(host.requireSession()); + } catch (error) { + host.showError(`Failed to load upcoming goals: ${formatErrorMessage(error)}`); + return; + } + + host.track('goal_queue_manage'); + host.mountEditorReplacement( + new GoalQueueManagerComponent({ + goals: snapshot.goals, + selectedGoalId, + onAction: async (action) => { + try { + return await handleGoalQueueManagerAction(host, action); + } catch (error) { + host.showError(`Failed to update upcoming goals: ${formatErrorMessage(error)}`); + return undefined; + } + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function handleGoalQueueManagerAction( + host: SlashCommandHost, + action: GoalQueueManagerAction, +): Promise { + const session = host.requireSession(); + switch (action.kind) { + case 'move': { + const snapshot = await moveGoalQueueItem(session, { + goalId: action.goalId, + direction: action.direction, + }); + host.track('goal_queue_move', { direction: action.direction }); + return snapshot; + } + case 'delete': { + const snapshot = await removeGoalQueueItem(session, { goalId: action.goalId }); + host.track('goal_queue_remove'); + return snapshot; + } + case 'edit': + await showGoalQueueEditDialog(host, action.goalId); + return; + } +} + +async function showGoalQueueEditDialog( + host: SlashCommandHost, + goalId: string, +): Promise { + let snapshot: GoalQueueSnapshot; + try { + snapshot = await readGoalQueue(host.requireSession()); + } catch (error) { + host.showError(`Failed to load upcoming goals: ${formatErrorMessage(error)}`); + return; + } + + const goal = snapshot.goals.find((item) => item.id === goalId); + if (goal === undefined) { + host.showStatus('Queued goal no longer exists.'); + await showGoalQueueManager(host); + return; + } + + host.mountEditorReplacement( + new GoalQueueEditDialogComponent({ + goal, + onDone: (result) => { + void handleGoalQueueEditResult(host, result).catch((error: unknown) => { + host.showError(`Failed to update upcoming goal: ${formatErrorMessage(error)}`); + }); + }, + }), + ); +} + +async function handleGoalQueueEditResult( + host: SlashCommandHost, + result: GoalQueueEditResult, +): Promise { + if (result.kind === 'cancel') { + await showGoalQueueManager(host, result.goalId); + return; + } + + await updateGoalQueueItem(host.requireSession(), { + goalId: result.goalId, + objective: result.objective, + }); + host.track('goal_queue_update'); + await showGoalQueueManager(host, result.goalId); +} + +export async function createGoal( + host: GoalCommandHost, + parsed: Extract, + rawArgs?: string, + options: GoalStartOptions = {}, +): Promise { + // A goal must be able to start a model turn; refuse to create one otherwise. + if (host.state.appState.model.trim().length === 0 || host.session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return false; + } + + if ( + host.state.appState.permissionMode === 'manual' || + host.state.appState.permissionMode === 'yolo' + ) { + showGoalStartPermissionPrompt(host, parsed, rawArgs ?? parsed.objective, options); + return false; + } + + return startGoal(host, parsed, options); +} + +function showGoalStartPermissionPrompt( + host: GoalCommandHost, + parsed: Extract, + rawArgs: string, + options: GoalStartOptions, +): void { + const commandText = `/goal ${rawArgs.trim()}`; + const cancelStart = (): void => { + host.restoreInputText(commandText); + host.showStatus('Goal not started.'); + }; + host.mountEditorReplacement( + new GoalStartPermissionPromptComponent({ + mode: host.state.appState.permissionMode === 'yolo' ? 'yolo' : 'manual', + onSelect: (choice) => { + if (choice === 'cancel') { + cancelStart(); + return; + } + host.restoreEditor(); + void startGoalWithPermission(host, parsed, choice, options); + }, + onCancel: cancelStart, + }), + ); +} + +async function startGoalWithPermission( + host: GoalCommandHost, + parsed: Extract, + choice: GoalStartPermissionChoice, + options: GoalStartOptions, +): Promise { + const previousMode = host.state.appState.permissionMode; + const switched = + choice !== previousMode && (choice === 'auto' || choice === 'yolo'); + if (switched) { + if (!(await setPermissionForGoal(host, choice))) return; + } + const started = await startGoal(host, parsed, options); + // The permission switch only exists to run this goal. If creation fails + // (e.g. a goal already exists and `replace` was not given), restore the + // previous mode so the session is not left more permissive than before. + if (!started && switched) { + await setPermissionForGoal(host, previousMode); + } +} + +async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise { + try { + await host.requireSession().setPermission(mode); + } catch (error) { + host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`); + return false; + } + host.setAppState({ permissionMode: mode }); + return true; +} + +async function startGoal( + host: GoalCommandHost, + parsed: Extract, + options: GoalStartOptions, +): Promise { + try { + await host.requireSession().createGoal({ + objective: parsed.objective, + replace: parsed.replace, + }); + } catch (error) { + if (isKimiError(error) && error.code === ErrorCodes.GOAL_ALREADY_EXISTS) { + host.showError( + 'A goal is already active. Use `/goal replace ` to replace it, or `/goal status` to inspect it.', + ); + return false; + } + host.showError(formatErrorMessage(error)); + return false; + } + if (options.beforeSend !== undefined && !(await options.beforeSend())) { + return false; + } + host.state.transcriptContainer.addChild(new GoalSetMessageComponent()); + host.state.ui.requestRender(); + if (options.sendInput !== undefined) { + options.sendInput(parsed.objective); + } else { + host.sendNormalUserInput(parsed.objective); + } + return true; +} + +async function pauseGoal(host: SlashCommandHost): Promise { + const session = host.requireSession(); + try { + await session.pauseGoal(); + if (isStreaming(host)) await session.cancel(); + } catch (error) { + if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) { + host.showStatus('No goal to pause.'); + return; + } + host.showError(formatErrorMessage(error)); + return; + } + host.track('goal_pause'); + host.showStatus('Goal paused. Use `/goal resume` to continue.'); +} + +async function resumeGoal(host: SlashCommandHost): Promise { + if (host.state.appState.model.trim().length === 0 || host.session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + + try { + await host.requireSession().resumeGoal(); + } catch (error) { + if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) { + host.showStatus('No goal to resume.'); + return; + } + host.showError(formatErrorMessage(error)); + return; + } + host.track('goal_resume'); + host.sendNormalUserInput(RESUME_GOAL_INPUT); +} + +async function cancelGoal(host: SlashCommandHost): Promise { + const session = host.requireSession(); + try { + await session.cancelGoal(); + if (isStreaming(host)) await session.cancel(); + } catch (error) { + if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) { + host.showStatus('No goal to cancel.'); + return; + } + host.showError(formatErrorMessage(error)); + return; + } + host.track('goal_cancel'); + host.showNotice('Goal cancelled.'); +} + +async function showGoalStatus(host: SlashCommandHost): Promise { + const { goal } = await host.requireSession().getGoal(); + host.track('goal_status', { status: goal?.status ?? 'none' }); + if (goal === null) { + host.showStatus('No goal set. Start one with `/goal `.'); + return; + } + host.state.transcriptContainer.addChild( + new GoalStatusMessageComponent(goal), + ); + host.state.ui.requestRender(); +} + +function isStreaming(host: SlashCommandHost): boolean { + return host.state.appState.streamingPhase !== 'idle'; +} + +function isBusy(host: SlashCommandHost): boolean { + return isStreaming(host) || host.state.appState.isCompacting; +} diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 60178b265..784ef7e61 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -3,14 +3,12 @@ export * from './parse'; export * from './registry'; export * from './resolve'; export * from './skills'; +export * from './plugin-commands'; export * from './types'; export { dispatchInput, type SlashCommandHost } from './dispatch'; -export { - handleConnectCommand, - handleLoginCommand, - handleLogoutCommand, -} from './auth'; +export { handleLoginCommand, handleLogoutCommand } from './auth'; +export { handleBtwCommand } from './btw'; export { handleCompactCommand, handleEditorCommand, @@ -18,22 +16,20 @@ export { handlePlanCommand, handleThemeCommand, handleYoloCommand, + showExperimentsPanel, showModelPicker, showPermissionPicker, showSettingsSelector, } from './config'; -export { - handleFeedbackCommand, - showMcpServers, - showStatusReport, - showUsage, -} from './info'; +export { handleSwarmCommand } from './swarm'; +export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; -export { - handleForkCommand, - handleInitCommand, - handleTitleCommand, -} from './session'; +export { handleReloadCommand, handleReloadTuiCommand } from './reload'; +export { handleGoalCommand, parseGoalCommand } from './goal'; +export { goalArgumentCompletions } from './registry'; +export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; +export { handleUndoCommand } from './undo'; +export { handleWebCommand } from './web'; export { promptApiKey, promptCatalogProviderSelection, diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index a5dd47959..fd5d397f4 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -9,17 +9,21 @@ import { FEEDBACK_ISSUE_URL, FEEDBACK_STATUS_CANCELLED, FEEDBACK_STATUS_FALLBACK, + FEEDBACK_STATUS_NETWORK_ERROR, FEEDBACK_STATUS_NOT_SIGNED_IN, FEEDBACK_STATUS_SUBMITTING, FEEDBACK_STATUS_SUCCESS, + FEEDBACK_STATUS_UPLOAD_FAILED, FEEDBACK_TELEMETRY_EVENT, + feedbackIdLine, feedbackSessionLine, withFeedbackVersionPrefix, } from '../constant/feedback'; import { isManagedUsageProvider } from '../constant/kimi-tui'; +import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments'; import { formatErrorMessage } from '../utils/event-payload'; -import { openUrl } from '../utils/open-url'; -import { promptFeedbackInput } from './prompts'; +import { openUrl } from '#/utils/open-url'; +import { promptFeedbackAttachment, promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -39,30 +43,60 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise 0 ? host.state.appState.model : null, - }); - - if (res.kind === 'ok') { - spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); - host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); - host.track(FEEDBACK_TELEMETRY_EVENT); + // Stage 2: ask whether to attach diagnostics (logs / codebase). + const level = await promptFeedbackAttachment(host); + if (level === undefined) { + host.showStatus(FEEDBACK_STATUS_CANCELLED); return; } - spinner.stop({ ok: false, label: res.message }); - fallback(FEEDBACK_STATUS_FALLBACK); + const version = withFeedbackVersionPrefix(host.state.appState.version); + const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); + // Guarantee the spinner's underlying setInterval is always cleared, even when + // submitFeedback or submitFeedbackWithAttachments throws — otherwise the + // interval (and its per-frame requestRender) leaks for the rest of the session. + let stopped = false; + const stopSpinner = (opts: { ok: boolean; label: string }): void => { + if (stopped) return; + stopped = true; + spinner.stop(opts); + }; + try { + const res = await host.harness.auth.submitFeedback({ + content: input.value, + sessionId: host.state.appState.sessionId, + version, + os: `${osType()} ${osRelease()}`, + model: host.state.appState.model.length > 0 ? host.state.appState.model : null, + }); + + if (res.kind !== 'ok') { + stopSpinner({ ok: false, label: res.message }); + fallback(FEEDBACK_STATUS_FALLBACK); + return; + } + + // Stage 3: prepare and upload each requested attachment independently. + const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level); + + stopSpinner({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); + host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); + host.showStatus(feedbackIdLine(res.feedbackId)); + host.track(FEEDBACK_TELEMETRY_EVENT); + if (attachmentFailed) { + host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED); + } + } catch (error) { + stopSpinner({ ok: false, label: FEEDBACK_STATUS_NETWORK_ERROR }); + throw error; + } } // --------------------------------------------------------------------------- @@ -87,8 +121,7 @@ interface ManagedUsageResult { export async function showUsage(host: SlashCommandHost): Promise { const sessionUsage = await loadSessionUsageReport(host); const managedUsage = await loadManagedUsageReport(host); - const lines = buildUsageReportLines({ - colors: host.state.theme.colors, + const reportArgs = { sessionUsage: sessionUsage.usage, sessionUsageError: sessionUsage.error, contextUsage: host.state.appState.contextUsage, @@ -96,8 +129,8 @@ export async function showUsage(host: SlashCommandHost): Promise { maxContextTokens: host.state.appState.maxContextTokens, managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary); + }; + const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary'); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -108,14 +141,13 @@ export async function showStatusReport(host: SlashCommandHost): Promise { loadManagedUsageReport(host), ]); const appState = host.state.appState; - const lines = buildStatusReportLines({ - colors: host.state.theme.colors, + const reportArgs = { version: appState.version, model: appState.model, workDir: appState.workDir, sessionId: appState.sessionId, sessionTitle: appState.sessionTitle, - thinking: appState.thinking, + thinkingEffort: appState.thinkingEffort, permissionMode: appState.permissionMode, planMode: appState.planMode, contextUsage: appState.contextUsage, @@ -126,8 +158,8 @@ export async function showStatusReport(host: SlashCommandHost): Promise { statusError: runtimeStatus.error, managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status '); + }; + const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status '); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -141,12 +173,12 @@ export async function showMcpServers(host: SlashCommandHost): Promise { return; } - const lines = buildMcpStatusReportLines({ - colors: host.state.theme.colors, - servers, - }); const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP '; - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + const panel = new UsagePanelComponent( + () => buildMcpStatusReportLines({ servers }), + 'primary', + title, + ); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -181,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise; +} + +export function pluginCommandName(pluginId: string, name: string): string { + return `${pluginId}:${name}`; +} + +export function buildPluginSlashCommands(defs: readonly PluginCommandDef[]): PluginSlashCommands { + const commandMap = new Map(); + const commands = defs.map((def) => { + const commandName = pluginCommandName(def.pluginId, def.name); + commandMap.set(commandName, def.body); + return { + name: commandName, + aliases: [], + description: def.description, + } satisfies KimiSlashCommand; + }); + return { commands, commandMap }; +} diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 4a9423508..b69f0724a 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -4,14 +4,15 @@ import { isAbsolute, join, resolve } from 'node:path'; import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, - type PluginMarketplaceSelection, type PluginRemoveConfirmResult, - type PluginsOverviewSelection, + type PluginsPanelSelection, + type PluginsPanelTabId, } from '../components/dialogs/plugins-selector'; import { buildPluginsInfoLines, @@ -19,8 +20,9 @@ import { } from '../components/messages/plugins-status-panel'; import { UsagePanelComponent } from '../components/messages/usage-panel'; import { formatErrorMessage } from '../utils/event-payload'; -import { formatPluginSourceLabel } from '../utils/plugin-source-label'; +import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label'; import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { openUrl } from '#/utils/open-url'; import type { SlashCommandHost } from './dispatch'; interface ShowPluginsPickerOptions { @@ -29,6 +31,8 @@ interface ShowPluginsPickerOptions { readonly id: string; readonly text: string; }; + readonly initialTab?: PluginsPanelTabId; + readonly marketplaceSource?: string; } interface PluginMcpServerHint { @@ -62,6 +66,10 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showError('Usage: /plugins install '); return; } + if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) { + host.showStatus('Install cancelled.'); + return; + } const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`); try { await installPluginFromSource(host, source); @@ -73,7 +81,15 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri return; } if (sub === 'marketplace') { - await showPluginMarketplacePicker(host, rest.join(' ').trim() || undefined); + const marketplaceSource = rest.join(' ').trim() || undefined; + await showPluginsPicker(host, { + // Custom marketplaces often omit `tier`, so their entries land on the + // Third-party tab (entry.tier !== 'official'). Open there when a custom + // source is supplied; otherwise the default catalog's official entries + // make Official the right landing tab. + initialTab: marketplaceSource === undefined ? 'official' : 'third-party', + marketplaceSource, + }); return; } if (sub === 'info') { @@ -95,7 +111,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri } await session.setPluginMcpServerEnabled(id, server, action === 'enable'); host.showStatus( - `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`, + `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /reload or /new to apply.`, ); return; } @@ -118,8 +134,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showStatus(`Remove cancelled: ${id}.`); return; } - await session.removePlugin(id); - host.showStatus(`Removed ${id} (plugin files left in place).`); + await removePlugin(host, id); return; } if (sub === 'reload') { @@ -149,52 +164,58 @@ async function showPluginsPicker( return; } - host.mountEditorReplacement( - new PluginsOverviewSelectorComponent({ - plugins, - selectedId: options?.selectedId, - pluginHint: options?.pluginHint, - colors: host.state.theme.colors, - onSelect: (selection) => { - host.restoreEditor(); - void handlePluginsOverviewSelection(host, selection).catch((error: unknown) => { - host.showError(`/plugins failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); + const panel = new PluginsPanelComponent({ + installed: plugins, + installedIds: new Set(plugins.map((plugin) => plugin.id)), + initialTab: options?.initialTab, + selectedId: options?.selectedId, + pluginHint: options?.pluginHint, + onSelect: (selection) => { + // Each branch of the handler either mounts the next view or restores the + // editor itself, so do not pre-restore here — that would flash the editor + // for in-place actions like toggling a plugin. + void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => { + host.showError(`/plugins failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + // Every tab except Custom needs the catalog: Official/Third-party list it, + // and Installed uses it to show update badges. The Installed/Custom tabs + // keep working even when the marketplace is unreachable (badges simply stay + // hidden until data arrives). + onRequestMarketplace: () => { + void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); + }, + }); + host.mountEditorReplacement(panel); + // Kick off the catalog fetch for any tab that needs it: Installed uses it for + // update badges, Official/Third-party list it. Custom never reads the catalog, + // so skip the fetch there. Done here (after `panel` is initialized) rather + // than inside the component constructor, because the callback above closes + // over `panel`. + if (options?.initialTab !== 'custom') { + panel.setMarketplaceLoading(); + void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); + } } -async function showPluginMarketplacePicker(host: SlashCommandHost, source?: string): Promise { +async function loadMarketplaceCatalog( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source?: string, +): Promise { try { - const [marketplace, installed] = await Promise.all([ - loadPluginMarketplace({ workDir: host.state.appState.workDir, source }), - host.requireSession().listPlugins(), - ]); - host.mountEditorReplacement( - new PluginMarketplaceSelectorComponent({ - entries: marketplace.plugins, - installedIds: new Set(installed.map((plugin) => plugin.id)), - source: marketplace.source, - colors: host.state.theme.colors, - onSelect: (selection) => { - host.restoreEditor(); - void handlePluginMarketplaceSelection(host, selection).catch((error: unknown) => { - host.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - host.restoreEditor(); - void showPluginsPicker(host); - }, - }), - ); + const marketplace = await loadPluginMarketplace({ + workDir: host.state.appState.workDir, + source, + }); + panel.setMarketplace(marketplace.plugins, marketplace.source); } catch (error) { - host.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`); + panel.setMarketplaceError(formatErrorMessage(error)); } + host.state.ui.requestRender(); } async function showPluginMcpPicker( @@ -215,9 +236,9 @@ async function showPluginMcpPicker( info, selectedServer: options?.selectedServer, serverHint: options?.serverHint, - colors: host.state.theme.colors, onSelect: (selection) => { - host.restoreEditor(); + // Every MCP action re-mounts a picker, so let the handler do the + // mounting — pre-restoring the editor here would flash on toggle. void handlePluginMcpSelection(host, selection).catch((error: unknown) => { host.showError(`/plugins mcp failed: ${formatErrorMessage(error)}`); }); @@ -243,7 +264,6 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise< new PluginRemoveConfirmComponent({ id, displayName, - colors: host.state.theme.colors, onDone: (result: PluginRemoveConfirmResult) => { host.restoreEditor(); resolveConfirmed(result.kind === 'confirm'); @@ -253,6 +273,67 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise< }); } +async function confirmInstallTrust( + host: SlashCommandHost, + label: string, + official: boolean, +): Promise { + // Kimi-built official plugins are trusted implicitly; anything else requires + // the user to explicitly opt in via the trust prompt. + if (official) return true; + return new Promise((resolveConfirmed) => { + host.mountEditorReplacement( + new PluginInstallTrustConfirmComponent({ + label, + onDone: (result: PluginInstallTrustConfirmResult) => { + host.restoreEditor(); + resolveConfirmed(result.kind === 'confirm'); + }, + }), + ); + }); +} + +async function installFromPanel( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source: string, + label: string, + official: boolean, +): Promise { + if (!(await confirmInstallTrust(host, label, official))) { + host.showStatus(`Install cancelled: ${label}.`); + host.restoreEditor(); + return; + } + // Official installs keep the panel mounted and show the inline installing + // state; third-party installs pass through a trust prompt that replaces the + // panel, so fall back to a transcript status for those. + if (official) { + panel.setInstalling(truncateForStatus(label)); + } else { + host.showStatus(`Installing or updating ${label} from marketplace...`); + } + host.state.ui.requestRender(); + try { + await installPluginFromSource(host, source); + } catch (error) { + if (official) { + panel.clearInstalling(); + host.state.ui.requestRender(); + } else { + // The trust prompt replaced the panel; re-mount it so the user can retry + // instead of being dropped back at the editor. + host.mountEditorReplacement(panel); + } + host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); + return; + } + // Close the panel after installing so the result status and the + // "/reload or /new" tip are visible in the transcript. + host.restoreEditor(); +} + async function applyPluginEnabled( host: SlashCommandHost, id: string, @@ -272,52 +353,71 @@ async function applyPluginEnabled( ? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} .` : ''; if (showStatus) { - host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`); + host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /reload or /new to apply.${mcpHint}`); } const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : ''; return `${pluginInlineChangeHint()}${inlineMcpHint}`; } -async function handlePluginsOverviewSelection( +async function handlePluginsPanelSelection( host: SlashCommandHost, - selection: PluginsOverviewSelection, + panel: PluginsPanelComponent, + selection: PluginsPanelSelection, ): Promise { - const session = host.requireSession(); switch (selection.kind) { - case 'marketplace': - await showPluginMarketplacePicker(host); - return; - case 'reload': - await reloadPlugins(host); - await showPluginsPicker(host); - return; - case 'show-list': - await renderPluginsList(host); - return; case 'toggle': { const hint = await applyPluginEnabled(host, selection.id, selection.enabled, false); await showPluginsPicker(host, { + initialTab: 'installed', selectedId: selection.id, pluginHint: { id: selection.id, text: hint }, }); return; } - case 'mcp': - await showPluginMcpPicker(host, selection.id); - return; case 'remove': if (!(await confirmRemovePlugin(host, selection.id))) { host.showStatus(`Remove cancelled: ${selection.id}.`); - await showPluginsPicker(host, { selectedId: selection.id }); + await showPluginsPicker(host, { initialTab: 'installed', selectedId: selection.id }); return; } - await session.removePlugin(selection.id); - host.showStatus(`Removed ${selection.id} (plugin files left in place).`); - await showPluginsPicker(host); + await removePlugin(host, selection.id); + await showPluginsPicker(host, { initialTab: 'installed' }); return; - case 'info': + case 'mcp': + await showPluginMcpPicker(host, selection.id); + return; + case 'details': + host.restoreEditor(); await renderPluginInfo(host, selection.id); return; + case 'reload': + await reloadPlugins(host); + await showPluginsPicker(host, { initialTab: 'installed' }); + return; + case 'install': + await installFromPanel( + host, + panel, + selection.entry.source, + selection.entry.displayName, + isOfficialPluginSource(selection.entry.source), + ); + return; + case 'install-source': + await installFromPanel( + host, + panel, + selection.source, + selection.source, + isOfficialPluginSource(selection.source), + ); + return; + case 'open-url': + host.restoreEditor(); + openUrl(selection.url); + host.showStatus(`Opening the ${selection.label} page in your browser…`, 'success'); + host.showStatus(`If it did not open, visit ${selection.url}`); + return; } } @@ -346,22 +446,10 @@ async function handlePluginMcpSelection( } } -async function handlePluginMarketplaceSelection( - host: SlashCommandHost, - selection: PluginMarketplaceSelection, -): Promise { - switch (selection.kind) { - case 'install': - host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`); - await installPluginFromSource(host, selection.entry.source, { - successNotice: 'marketplace', - }); - await showPluginsPicker(host, { selectedId: selection.entry.id }); - return; - case 'back': - await showPluginsPicker(host); - return; - } +async function removePlugin(host: SlashCommandHost, id: string): Promise { + await host.requireSession().removePlugin(id); + host.showStatus(`Removed ${id}.`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } async function renderPluginsList( @@ -369,20 +457,23 @@ async function renderPluginsList( plugins?: readonly PluginSummary[], ): Promise { const currentPlugins = plugins ?? (await host.requireSession().listPlugins()); - const lines = buildPluginsListLines({ - colors: host.state.theme.colors, - plugins: currentPlugins, - }); const title = ` Plugins (${currentPlugins.length}) `; - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + const panel = new UsagePanelComponent( + () => buildPluginsListLines({ plugins: currentPlugins }), + 'primary', + title, + ); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { const info = await host.requireSession().getPluginInfo(id); - const lines = buildPluginsInfoLines({ colors: host.state.theme.colors, info }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ` ${info.id} `); + const panel = new UsagePanelComponent( + () => buildPluginsInfoLines({ info }), + 'primary', + ` ${info.id} `, + ); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -390,25 +481,21 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { const session = host.requireSession(); const beforeList = await session.listPlugins(); const summary = await session.installPlugin( resolvePluginInstallSource(source, host.state.appState.workDir), ); - showPluginInstallResult(host, beforeList, summary, options); + showPluginInstallResult(host, beforeList, summary); } +const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; + function showPluginInstallResult( host: SlashCommandHost, beforeList: readonly PluginSummary[], summary: PluginSummary, - options?: { - readonly successNotice?: 'marketplace'; - }, ): void { const previous = beforeList.find((entry) => entry.id === summary.id); const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers'; @@ -417,15 +504,8 @@ function showPluginInstallResult( ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.` : ''; const action = describeInstallAction(previous, summary); - host.showStatus( - `${action} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`, - ); - if (options?.successNotice === 'marketplace') { - host.showNotice( - `Installed or updated ${summary.displayName}`, - `Marketplace install or update succeeded for ${summary.id}. Run /new to apply plugin changes.`, - ); - } + host.showStatus(`${action} (${summary.id}).${mcpHint}`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } function describeInstallAction( @@ -438,13 +518,19 @@ function describeInstallAction( return ` ${prev} → ${cur ?? '-'}`; }; if (previous === undefined) { - return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} from ${sourceLabel}`; + return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} ${sourcePhrase(sourceLabel)}`; } if (sourceIdentity(previous) !== sourceIdentity(next)) { const prevSourceLabel = formatPluginSourceLabel(previous); return `Migrated ${next.displayName}: ${prevSourceLabel} → ${sourceLabel}${versionFromTo(previous.version, next.version)}`; } - return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} from ${sourceLabel}`; + return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} ${sourcePhrase(sourceLabel)}`; +} + +// formatPluginSourceLabel already prefixes zip-url hosts with "via", so adding +// "from" would read as "from via ". Only prepend "from" otherwise. +function sourcePhrase(sourceLabel: string): string { + return sourceLabel.startsWith('via ') ? sourceLabel : `from ${sourceLabel}`; } function sourceIdentity(plugin: PluginSummary): string { @@ -475,5 +561,5 @@ function resolvePluginInstallSource(source: string, workDir: string): string { } function pluginInlineChangeHint(): string { - return 'pending /new'; + return 'run /reload or /new to apply'; } diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index a6b7a8c80..5b0fd5533 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -4,6 +4,7 @@ import { type Catalog, type CatalogModel, type ModelAlias, + type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { capabilitiesForModel } from '@moonshot-ai/kimi-code-oauth'; import type { @@ -21,7 +22,6 @@ import type { SlashCommandHost } from './dispatch'; export function promptPlatformSelection(host: SlashCommandHost): Promise { return new Promise((resolve) => { const selector = new PlatformSelectorComponent({ - colors: host.state.theme.colors, onSelect: (platformId) => { host.restoreEditor(); resolve(platformId); @@ -45,7 +45,6 @@ export function promptLogoutProviderSelection( title: 'Select a provider to log out', options, currentValue, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); resolve(value); @@ -59,16 +58,58 @@ export function promptLogoutProviderSelection( }); } -export function promptFeedbackInput(host: SlashCommandHost): Promise { +export interface FeedbackPromptResult { + readonly value: string; +} + +export function promptFeedbackInput(host: SlashCommandHost): Promise { return new Promise((resolve) => { const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => { host.restoreEditor(); - resolve(result.kind === 'ok' ? result.value : undefined); - }, host.state.theme.colors); + resolve(result.kind === 'ok' ? { value: result.value } : undefined); + }); host.mountEditorReplacement(dialog); }); } +export type FeedbackAttachmentLevel = 'none' | 'logs' | 'logs+codebase'; + +const FEEDBACK_ATTACHMENT_OPTIONS: readonly ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs', + label: 'Logs only', + description: 'Upload wire events and diagnostic logs from this session', + }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: + 'Include your codebase for deeper diagnosis. Sensitive files are automatically excluded — e.g. .env, config files, secret keys. We use attachments only for diagnosis and never share them.', + descriptionTone: 'warning', + }, +]; + +export function promptFeedbackAttachment( + host: SlashCommandHost, +): Promise { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info to help us investigate?', + options: FEEDBACK_ATTACHMENT_OPTIONS, + onSelect: (value) => { + host.restoreEditor(); + resolve(value as FeedbackAttachmentLevel); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + export function promptApiKey( host: SlashCommandHost, platformName: string, @@ -82,7 +123,6 @@ export function promptApiKey( host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); }, - host.state.theme.colors, ); host.mountEditorReplacement(dialog); }); @@ -109,7 +149,6 @@ export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: const picker = new ChoicePickerComponent({ title: 'Select a provider', options, - colors: host.state.theme.colors, searchable: true, onSelect: (value) => { host.restoreEditor(); @@ -128,7 +167,7 @@ export async function promptModelSelectionForOpenPlatform( host: SlashCommandHost, models: ManagedKimiCodeModelInfo[], platform: OpenPlatformDefinition, -): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> { +): Promise<{ model: ManagedKimiCodeModelInfo; thinking: ThinkingEffort } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${platform.id}/${m.id}`] = { @@ -149,7 +188,7 @@ export async function promptModelSelectionForCatalog( host: SlashCommandHost, providerId: string, models: CatalogModel[], -): Promise<{ model: CatalogModel; thinking: boolean } | undefined> { +): Promise<{ model: CatalogModel; thinking: ThinkingEffort } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m); @@ -163,7 +202,7 @@ export async function promptModelSelectionForCatalog( export function runModelSelector( host: SlashCommandHost, modelDict: Record, -): Promise<{ alias: string; thinking: boolean } | undefined> { +): Promise<{ alias: string; thinking: ThinkingEffort } | undefined> { return new Promise((resolve) => { const firstAlias = Object.keys(modelDict)[0] ?? ''; const caps = modelDict[firstAlias]?.capabilities ?? []; @@ -171,8 +210,7 @@ export function runModelSelector( const selector = new ModelSelectorComponent({ models: modelDict, currentValue: firstAlias, - currentThinking: initialThinking, - colors: host.state.theme.colors, + currentThinkingEffort: initialThinking ? 'on' : 'off', searchable: true, onSelect: ({ alias, thinking }) => { host.restoreEditor(); diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 7690a805a..994cae8b3 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -1,7 +1,8 @@ import { - applyCustomRegistryProvider, + applyCustomRegistryEntries, fetchCustomRegistry, type CustomRegistrySource, + type ManagedKimiConfigShape, } from '@moonshot-ai/kimi-code-oauth'; import { applyCatalogProvider, @@ -12,8 +13,10 @@ import { fetchCatalog, inferWireType, type Catalog, + type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; +import { createKimiCodeUserAgent } from '#/cli/version'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { CustomRegistryImportDialogComponent, @@ -26,6 +29,7 @@ import { import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; import { promptApiKey, promptCatalogProviderSelection, @@ -48,12 +52,15 @@ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOpt return { providers: host.state.appState.availableProviders, activeProviderId, - colors: host.state.theme.colors, onAdd: () => { - void handleProviderAdd(host); + void handleProviderAdd(host).catch((error: unknown) => { + host.showError(`Add provider failed: ${formatErrorMessage(error)}`); + }); }, onDeleteSource: (providerIds) => { - void handleProviderManagerDeleteSource(host, providerIds); + void handleProviderManagerDeleteSource(host, providerIds).catch((error: unknown) => { + host.showError(`Remove provider failed: ${formatErrorMessage(error)}`); + }); }, onClose: () => { host.restoreEditor(); @@ -131,7 +138,6 @@ function promptProviderAddSource( { value: 'known', label: 'Known third-party provider' }, { value: 'custom', label: 'Custom registry (api.json)' }, ], - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); resolve(value === 'known' || value === 'custom' ? value : undefined); @@ -155,7 +161,10 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`); let catalog: Catalog | undefined; try { - catalog = await fetchCatalog(DEFAULT_CATALOG_URL, controller.signal); + catalog = await fetchCatalog(DEFAULT_CATALOG_URL, { + signal: controller.signal, + userAgent: createKimiCodeUserAgent(), + }); spinner.stop({ ok: true, label: 'Catalog loaded.' }); } catch (error) { if (controller.signal.aborted) { @@ -221,8 +230,8 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { host.showStatus(`Provider added: ${entry.name ?? providerId}`); // Build a merged model dictionary that includes existing models plus the - // newly-persisted provider's models, so the TabbedModelSelectorComponent - // shows all tabs. + // newly-persisted provider's models, so the tabbed selector shows every + // provider's tab (the new provider's tab starts active via initialTabId). const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); const mergedModels = { ...stateModels }; @@ -230,12 +239,13 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { models: mergedModels, currentValue: host.state.appState.model, selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)), - currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, + currentThinkingEffort: host.state.appState.thinkingEffort, initialTabId: providerId, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void setDefaultModel(host, alias, thinking); + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { + host.showError(`Set default model failed: ${formatErrorMessage(error)}`); + }); }, onCancel: () => { host.restoreEditor(); @@ -247,15 +257,15 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { async function setDefaultModel( host: SlashCommandHost, alias: string, - thinking: boolean, + effort: ThinkingEffort, ): Promise { await host.harness.setConfig({ defaultModel: alias, - defaultThinking: thinking, + thinking: thinkingEffortToConfig(effort), }); await host.authFlow.refreshConfigAfterLogin(); host.track('model_switch', { model: alias }); - host.showStatus(`Default model set to ${alias} with thinking ${thinking ? 'on' : 'off'}.`); + host.showStatus(`Default model set to ${alias} with thinking ${effort}.`); } async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise { @@ -268,35 +278,29 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise apiKey: value.apiKey, }; - let entries: Record }>; + let entries: Awaited>; try { - entries = await fetchCustomRegistry(source); - } catch (err) { - host.showError(`Failed to import registry: ${formatErrorMessage(err)}`); + entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() }); + } catch (error) { + host.showError(`Failed to import registry: ${formatErrorMessage(error)}`); return false; } - const addedProviderIds: string[] = []; + const addedProviderIds = Object.values(entries).map((entry) => entry.id); try { - let config = await host.harness.getConfig(); - for (const entry of Object.values(entries)) { - if (config.providers[entry.id] !== undefined) { - config = await host.harness.removeProvider(entry.id); - } - applyCustomRegistryProvider( - config as unknown as Parameters[0], - entry as Parameters[1], - source, - ); - addedProviderIds.push(entry.id); - } + const config = await host.harness.getConfig(); + applyCustomRegistryEntries( + config as unknown as ManagedKimiConfigShape, + entries, + source, + ); await host.harness.setConfig({ providers: config.providers, models: config.models, }); await host.authFlow.refreshConfigAfterLogin(); - } catch (err) { - host.showError(`Failed to apply registry: ${formatErrorMessage(err)}`); + } catch (error) { + host.showError(`Failed to apply registry: ${formatErrorMessage(error)}`); return false; } @@ -309,7 +313,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise count === 1 ? 'Imported 1 provider from registry.' : `Imported ${String(count)} providers from registry.`, - host.state.theme.colors.success, + 'success', ); // Offer the model selector so the user can pick a default, just like the @@ -321,17 +325,17 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise const firstNewProvider = firstNewAlias ? stateModels[firstNewAlias]?.provider : addedProviderIds[0]; - const selector = new TabbedModelSelectorComponent({ models: stateModels, currentValue: host.state.appState.model, selectedValue: firstNewAlias, - currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, + currentThinkingEffort: host.state.appState.thinkingEffort, initialTabId: firstNewProvider, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void setDefaultModel(host, alias, thinking); + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { + host.showError(`Set default model failed: ${formatErrorMessage(error)}`); + }); }, onCancel: () => { host.restoreEditor(); @@ -350,7 +354,6 @@ function promptCustomRegistryImport( host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); }, - host.state.theme.colors, ); host.mountEditorReplacement(dialog); }); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 4f4b75847..8e5a16386 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -1,18 +1,150 @@ +import { readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'pathe'; + +import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; + +import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; import type { KimiSlashCommand, SlashCommandAvailability } from './types'; +/** Subcommands offered when autocompleting `/goal <…>`. */ +const GOAL_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'status', description: 'Show the current goal' }, + { value: 'pause', description: 'Pause the active goal' }, + { value: 'resume', description: 'Resume a paused goal' }, + { value: 'cancel', description: 'Cancel and remove the current goal' }, + { value: 'replace', description: 'Replace the current goal with a new objective' }, + { value: 'next', description: 'Queue an upcoming goal' }, +]; + +const GOAL_NEXT_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'manage', description: 'Manage upcoming goals' }, +]; + +const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'on', description: 'Turn swarm mode on' }, + { value: 'off', description: 'Turn swarm mode off' }, +]; + +const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'list', description: 'Show configured additional workspace directories' }, +]; + +/** Argument autocompletion for the `/goal` command (subcommands). */ +export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i); + if (nextMatch !== null) { + return ( + completeLeadingArg(GOAL_NEXT_ARG_COMPLETIONS, nextMatch[1] ?? '')?.map((item) => ({ + ...item, + value: `next ${item.value}`, + })) ?? null + ); + } + return completeLeadingArg(GOAL_ARG_COMPLETIONS, argumentPrefix); +} + +/** Argument autocompletion for the `/swarm` command (subcommands). */ +export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); +} + +/** Argument autocompletion for the `/add-dir` command. */ +export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + if (isPathLikeAddDirArgument(argumentPrefix)) { + return completeAddDirPath(argumentPrefix); + } + return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix); +} + +function isPathLikeAddDirArgument(argumentPrefix: string): boolean { + return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~'); +} + +function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null { + const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix; + const expandedPrefix = expandHomePrefix(normalizedPrefix); + const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix); + const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix); + const parentDir = resolveDirectoryCompletionParent(parentInput); + let entries; + try { + entries = readdirSync(parentDir, { withFileTypes: true }); + } catch { + return null; + } + + const items: AutocompleteItem[] = []; + for (const entry of entries) { + if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue; + if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue; + const absolutePath = join(parentDir, entry.name); + if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue; + const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name); + items.push({ + value, + label: `${entry.name}/`, + description: absolutePath, + }); + } + + return items.length > 0 ? items : null; +} + +function expandHomePrefix(argumentPrefix: string): string { + if (argumentPrefix === '~') return homedir(); + if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2)); + return argumentPrefix; +} + +function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string { + if (argumentPrefix === '/') return '/'; + if (argumentPrefix === '~/') return homedir(); + if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1); + return dirname(expandedPrefix); +} + +function resolveDirectoryCompletionParent(parentInput: string): string { + if (parentInput === '~') return homedir(); + if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2)); + return resolve(parentInput); +} + +function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean { + if (isDirectory) return true; + if (!isSymlink) return false; + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string { + if (argumentPrefix.startsWith('~/')) { + const home = homedir(); + const homeRelative = relative(home, parentInput); + return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`; + } + if (argumentPrefix.startsWith('/')) { + return `${join(parentInput, entryName)}/`; + } + return `${join(parentInput, entryName)}/`; +} + export const BUILTIN_SLASH_COMMANDS = [ { name: 'yolo', aliases: ['yes'], - description: 'Toggle auto-approve mode', - priority: 100, + description: 'Toggle YOLO mode: AI auto-approves safe actions, asks for approval on risky ones.', + priority: 101, availability: 'always', }, { name: 'auto', aliases: [], - description: 'Toggle auto permission mode', - priority: 100, + description: 'Toggle Auto mode: run all actions automatically, including risky ones.', + priority: 99, availability: 'always', }, { @@ -36,6 +168,15 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'), }, + { + name: 'swarm', + aliases: [], + description: 'Toggle swarm mode or run one task in swarm mode', + priority: 100, + argumentHint: '[on|off] | ', + completeArgs: swarmArgumentCompletions, + availability: 'idle-only', + }, { name: 'model', aliases: [], @@ -43,6 +184,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: 'always', }, + { + name: 'effort', + aliases: ['thinking'], + description: 'Switch thinking effort', + priority: 95, + availability: 'always', + }, { name: 'provider', aliases: ['providers'], @@ -50,6 +198,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 95, availability: 'always', }, + { + name: 'btw', + aliases: [], + description: 'Ask a forked side agent a question', + priority: 90, + availability: 'always', + }, { name: 'help', aliases: ['h', '?'], @@ -90,11 +245,59 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'add-dir', + aliases: [], + description: 'Add or list an additional workspace directory', + priority: 60, + availability: 'idle-only', + argumentHint: '[list] | ', + completeArgs: addDirArgumentCompletions, + }, + { + name: 'experiments', + aliases: ['experimental'], + description: 'Manage experimental features', + priority: 60, + availability: 'idle-only', + }, + { + name: 'reload', + aliases: [], + description: 'Reload session and apply config.toml settings plus tui.toml UI preferences', + priority: 60, + availability: 'idle-only', + }, + { + name: 'reload-tui', + aliases: [], + description: 'Reload only tui.toml UI preferences', + priority: 60, + availability: 'always', + }, { name: 'compact', aliases: [], description: 'Compact the conversation context', priority: 80, + argumentHint: '', + }, + { + name: 'goal', + aliases: [], + description: 'Start or manage an autonomous goal', + priority: 80, + argumentHint: '[status|pause|resume|cancel|replace|next] | ', + completeArgs: goalArgumentCompletions, + // status / pause / cancel are always available; creation, replacement, and + // resume start (or restart) a turn and so are idle-only. + availability: (args) => { + const trimmed = args.trim(); + if (trimmed === 'next' || trimmed.startsWith('next ')) return 'always'; + return trimmed === '' || trimmed === 'status' || trimmed === 'pause' || trimmed === 'cancel' + ? 'always' + : 'idle-only'; + }, }, { name: 'init', @@ -112,6 +315,7 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: ['rename'], description: 'Set or show session title', priority: 60, + argumentHint: '', availability: 'always', }, { @@ -135,6 +339,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'undo', + aliases: [], + description: 'Withdraw the last prompt from the transcript', + priority: 80, + availability: 'idle-only', + }, { name: 'editor', aliases: [], @@ -173,6 +384,13 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Export current session as a debug ZIP archive', priority: 40, }, + { + name: 'web', + aliases: [], + description: 'Open the current session in the Web UI and exit the terminal', + priority: 40, + availability: 'always', + }, { name: 'exit', aliases: ['quit', 'q'], diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts new file mode 100644 index 000000000..6f28a16da --- /dev/null +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -0,0 +1,60 @@ +import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; + +import { currentTheme, lightColors } from '#/tui/theme'; +import { loadTuiConfig, type TuiConfig } from '../config'; +import type { SlashCommandHost } from './dispatch'; +import { setExperimentalFeatures } from './experimental-flags'; + +export async function handleReloadTuiCommand(host: SlashCommandHost): Promise<void> { + const tuiConfig = await loadTuiConfig(); + await applyReloadedTuiConfig(host, tuiConfig); + host.showStatus('TUI config reloaded.', 'success'); +} + +export async function handleReloadCommand(host: SlashCommandHost): Promise<void> { + const tuiConfig = await loadTuiConfig(); + const session = host.session; + + if (session !== undefined) { + await session.reloadSession({ forcePluginSessionStartReminder: true }); + await host.reloadCurrentSessionView(session, 'Session reloaded.'); + } + + const config = await host.harness.getConfig({ reload: true }); + setExperimentalFeatures(await host.harness.getExperimentalFeatures()); + host.refreshSlashCommandAutocomplete(); + applyRuntimeConfig(host, config); + await applyReloadedTuiConfig(host, tuiConfig); + + if (session === undefined) { + host.showStatus( + 'Runtime and TUI config reloaded; no active session.', + 'success', + ); + } +} + +export async function applyReloadedTuiConfig( + host: SlashCommandHost, + config: TuiConfig, +): Promise<void> { + const resolved = config.theme === 'auto' + ? (currentTheme.palette === lightColors ? 'light' : 'dark') + : undefined; + await host.applyTheme(config.theme, resolved); + host.refreshTerminalThemeTracking(); + host.setAppState({ + editorCommand: config.editorCommand, + disablePasteBurst: config.disablePasteBurst, + notifications: config.notifications, + upgrade: config.upgrade, + }); + host.state.editor.setDisablePasteBurst(config.disablePasteBurst); +} + +function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void { + host.setAppState({ + availableModels: config.models ?? {}, + availableProviders: config.providers ?? {}, + }); +} diff --git a/apps/kimi-code/src/tui/commands/resolve.ts b/apps/kimi-code/src/tui/commands/resolve.ts index a47f11409..e67457a94 100644 --- a/apps/kimi-code/src/tui/commands/resolve.ts +++ b/apps/kimi-code/src/tui/commands/resolve.ts @@ -26,6 +26,12 @@ export type SlashCommandIntent = readonly skillName: string; readonly args: string; } + | { + readonly kind: 'plugin-command'; + readonly commandName: string; + readonly pluginId: string; + readonly args: string; + } | { readonly kind: 'message'; readonly input: string } | { readonly kind: 'blocked'; @@ -41,6 +47,7 @@ export type SlashCommandIntent = export interface ResolveSlashCommandInput { readonly input: string; readonly skillCommandMap: ReadonlyMap<string, string>; + readonly pluginCommandMap: ReadonlyMap<string, string>; readonly isStreaming: boolean; readonly isCompacting: boolean; } @@ -92,6 +99,26 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla }; } + if (options.pluginCommandMap.has(parsed.name)) { + const busyReason = slashCommandBusyReason(options); + if (busyReason !== undefined) { + return { + kind: 'blocked', + commandName: parsed.name, + reason: busyReason, + }; + } + const separator = parsed.name.indexOf(':'); + const pluginId = separator === -1 ? parsed.name : parsed.name.slice(0, separator); + const commandName = separator === -1 ? '' : parsed.name.slice(separator + 1); + return { + kind: 'plugin-command', + commandName, + pluginId, + args: parsed.args.trim(), + }; + } + return { kind: 'message', input: options.input, diff --git a/apps/kimi-code/src/tui/commands/skills.ts b/apps/kimi-code/src/tui/commands/skills.ts index 08dfafc7e..2997a8b15 100644 --- a/apps/kimi-code/src/tui/commands/skills.ts +++ b/apps/kimi-code/src/tui/commands/skills.ts @@ -18,10 +18,25 @@ export function isUserActivatableSkill(skill: SkillSummary): boolean { ); } +function compareSkillSlashCommands(a: SkillSummary, b: SkillSummary): number { + return ( + getSkillSlashCommandGroup(a.source) - getSkillSlashCommandGroup(b.source) || + a.name.localeCompare(b.name) + ); +} + +function getSkillSlashCommandGroup(source: SkillSummary['source']): number { + return source === 'builtin' ? 0 : 1; +} + export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands { const commandMap = new Map<string, string>(); - const commands = skills.filter(isUserActivatableSkill).map((skill) => { - const commandName = `skill:${skill.name}`; + const sortedSkills = [...skills].toSorted(compareSkillSlashCommands); + const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => { + const commandName = + skill.source === 'builtin' || skill.isSubSkill === true + ? skill.name + : `skill:${skill.name}`; commandMap.set(commandName, skill.name); return { name: commandName, diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts new file mode 100644 index 000000000..540aa5860 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -0,0 +1,156 @@ +import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; + +import { + SwarmStartPermissionPromptComponent, + type SwarmStartPermissionChoice, +} from '../components/dialogs/swarm-start-permission-prompt'; +import { + SwarmModeMarkerComponent, + type SwarmModeMarkerState, +} from '../components/messages/swarm-markers'; +import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleSwarmCommand(host: SlashCommandHost, args: string): Promise<void> { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const prompt = args.trim(); + const mode = swarmModeSubcommand(prompt); + if (mode !== undefined) { + await applySwarmMode(host, mode, `/swarm ${prompt}`); + return; + } + + if (prompt.length === 0) { + await applySwarmMode(host, !host.state.appState.swarmMode, '/swarm'); + return; + } + + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + + if (host.state.appState.permissionMode === 'manual') { + showSwarmStartPermissionPrompt(host, `/swarm ${prompt}`, 'Swarm task not started.', (choice) => + startSwarmWithPermission(host, prompt, choice), + ); + return; + } + + await startSwarmTask(host, prompt); +} + +function showSwarmStartPermissionPrompt( + host: SlashCommandHost, + commandText: string, + cancelStatus: string, + onSelect: (choice: SwarmStartPermissionChoice) => Promise<void>, +): void { + const cancelStart = (): void => { + host.restoreInputText(commandText); + host.showStatus(cancelStatus); + }; + host.mountEditorReplacement( + new SwarmStartPermissionPromptComponent({ + onSelect: (choice) => { + host.restoreEditor(); + void onSelect(choice); + }, + onCancel: cancelStart, + }), + ); +} + +async function startSwarmWithPermission( + host: SlashCommandHost, + prompt: string, + choice: SwarmStartPermissionChoice, +): Promise<void> { + if (choice === 'auto' || choice === 'yolo') { + if (!(await setPermissionForSwarm(host, choice))) return; + } + await startSwarmTask(host, prompt); +} + +async function setPermissionForSwarm(host: SlashCommandHost, mode: PermissionMode): Promise<boolean> { + try { + await host.requireSession().setPermission(mode); + } catch (error) { + host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`); + return false; + } + host.setAppState({ permissionMode: mode }); + return true; +} + +async function startSwarmTask(host: SlashCommandHost, prompt: string): Promise<void> { + if (!host.state.appState.swarmMode && !(await setSwarmMode(host, true, 'task'))) { + return; + } + renderSwarmModeMarker(host, 'active'); + host.sendNormalUserInput(prompt); +} + +async function applySwarmMode( + host: SlashCommandHost, + enabled: boolean, + commandText: string, +): Promise<void> { + if (enabled && host.state.appState.swarmMode) { + host.showStatus('Swarm mode is already on.'); + return; + } + if (!enabled && !host.state.appState.swarmMode) { + host.showStatus('Swarm mode is already off.'); + return; + } + if (enabled && host.state.appState.permissionMode === 'manual') { + showSwarmStartPermissionPrompt(host, commandText, 'Swarm mode not enabled.', async (choice) => { + if ((choice === 'auto' || choice === 'yolo') && !(await setPermissionForSwarm(host, choice))) { + return; + } + if (!(await setSwarmMode(host, true, 'manual'))) return; + renderSwarmModeMarker(host, 'active'); + }); + return; + } + if (!(await setSwarmMode(host, enabled, 'manual'))) return; + renderSwarmModeMarker(host, enabled ? 'active' : 'inactive'); +} + +async function setSwarmMode( + host: SlashCommandHost, + enabled: boolean, + trigger: 'manual' | 'task', +): Promise<boolean> { + try { + await host.requireSession().setSwarmMode(enabled, trigger); + } catch (error) { + host.showError( + `Failed to ${enabled ? 'enable' : 'disable'} swarm mode: ${formatErrorMessage(error)}`, + ); + return false; + } + host.setAppState({ swarmMode: enabled }); + host.state.swarmModeEntry = enabled ? trigger : undefined; + return true; +} + +function swarmModeSubcommand(input: string): boolean | undefined { + const command = input.toLowerCase(); + if (command === 'on') return true; + if (command === 'off') return false; + return undefined; +} + +function renderSwarmModeMarker(host: SlashCommandHost, state: SwarmModeMarkerState): void { + host.state.transcriptContainer.addChild( + new SwarmModeMarkerComponent(state), + ); + host.state.ui.requestRender(); +} diff --git a/apps/kimi-code/src/tui/commands/types.ts b/apps/kimi-code/src/tui/commands/types.ts index 532a301ea..1ec3c6835 100644 --- a/apps/kimi-code/src/tui/commands/types.ts +++ b/apps/kimi-code/src/tui/commands/types.ts @@ -1,4 +1,4 @@ -import type { SlashCommand } from '@earendil-works/pi-tui'; +import type { AutocompleteItem, SlashCommand } from '@moonshot-ai/pi-tui'; import type { FlagId } from '@moonshot-ai/kimi-code-sdk'; export type SlashCommandAvailability = 'always' | 'idle-only'; @@ -11,6 +11,13 @@ export interface KimiSlashCommand<Name extends string = string> extends SlashCom readonly availability?: SlashCommandAvailability | ((args: string) => SlashCommandAvailability); /** When set, the command is hidden from the palette and blocked unless this flag is enabled. */ readonly experimentalFlag?: FlagId; + /** + * Generic argument autocompletion. `argumentPrefix` is the text typed after + * `/<command> `; return suggestions or `null`. Declared as a plain function + * property (not a method) so passing it around is `this`-free. Adapted to + * pi-tui's `getArgumentCompletions` in the autocomplete setup. + */ + readonly completeArgs?: (argumentPrefix: string) => AutocompleteItem[] | null; } export interface ParsedSlashInput { diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts new file mode 100644 index 000000000..bd427277d --- /dev/null +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -0,0 +1,499 @@ +import type { Component } from '@moonshot-ai/pi-tui'; +import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; +import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; + +import { WelcomeComponent } from '../components/chrome/welcome'; +import { CompactionComponent } from '../components/dialogs/compaction'; +import { + UndoSelectorComponent, + type UndoChoice, +} from '../components/dialogs/undo-selector'; +import { AgentGroupComponent } from '../components/messages/agent-group'; +import { AgentSwarmProgressComponent } from '../components/messages/agent-swarm-progress'; +import { AssistantMessageComponent } from '../components/messages/assistant-message'; +import { BackgroundAgentStatusComponent } from '../components/messages/background-agent-status'; +import { CronMessageComponent } from '../components/messages/cron-message'; +import { ReadGroupComponent } from '../components/messages/read-group'; +import { SkillActivationComponent } from '../components/messages/skill-activation'; +import { PluginCommandComponent } from '../components/messages/plugin-command'; +import { ThinkingComponent } from '../components/messages/thinking'; +import { ToolCallComponent } from '../components/messages/tool-call'; +import { UserMessageComponent } from '../components/messages/user-message'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import type { TranscriptEntry } from '../types'; +import { formatErrorMessage } from '../utils/event-payload'; +import { getTranscriptComponentEntry } from '../utils/transcript-component-metadata'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// Undo command +// --------------------------------------------------------------------------- + +interface UndoAvailability { + readonly maxCount: number; + readonly stoppedAtCompaction: boolean; +} + +type UndoSessionContext = Awaited< + ReturnType<NonNullable<SlashCommandHost['session']>['getContext']> +>; + +const UNDO_LIMIT_STATUS_TURN_ID = 'undo-limit-status'; + +export async function handleUndoCommand( + host: SlashCommandHost, + args: string = '', +): Promise<void> { + if (host.state.appState.streamingPhase !== 'idle') { + host.showError('Cannot undo while streaming — press Esc or Ctrl-C first.'); + return; + } + + const trimmed = args.trim(); + if (trimmed.length === 0) { + await showUndoSelector(host); + return; + } + + const count = parseUndoCount(trimmed); + if (count === undefined) { + host.showError('Usage: /undo [count], where count is a positive integer.'); + return; + } + + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const availability = await resolveUndoAvailability(host); + if (count > availability.maxCount) { + showUndoLimitStatus(host, formatUndoLimitMessage(count, availability)); + return; + } + + await undoByCount(host, count); +} + +async function undoByCount(host: SlashCommandHost, count: number): Promise<boolean> { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return false; + } + + const entries = host.state.transcriptEntries; + const lastUserIndex = findUndoAnchorEntryIndex(entries, count); + if (lastUserIndex === undefined) { + showUndoLimitStatus(host, 'Nothing to undo.'); + return false; + } + + try { + await session.undoHistory(count); + } catch (error) { + const limit = undoLimitFromError(error); + if (limit !== undefined) { + showUndoLimitStatus(host, formatUndoLimitMessage(limit.requestedCount, limit)); + return false; + } + const message = formatErrorMessage(error); + host.showError(`Failed to undo: ${message}`); + return false; + } + + const children = host.state.transcriptContainer.children; + const lastUserComponentIndex = findUndoAnchorComponentIndex(children, count); + if (lastUserComponentIndex !== undefined) { + removeUndoContextComponents(children, lastUserComponentIndex); + host.state.transcriptContainer.invalidate(); + } + + const preservedEntries = entries.slice(lastUserIndex).filter( + (entry) => !isUndoContextEntry(entry), + ); + entries.splice(lastUserIndex, entries.length - lastUserIndex, ...preservedEntries); + + if (entries.length === 0) { + renderWelcome(host); + } + + host.state.ui.requestRender(); + return true; +} + +async function showUndoSelector(host: SlashCommandHost): Promise<void> { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const availability = await resolveUndoAvailability(host); + const choices = createUndoChoices( + host.state.transcriptEntries, + host.state.transcriptContainer.children, + availability.maxCount, + ); + if (choices.length === 0) { + showUndoLimitStatus(host, formatNothingToUndoMessage(availability)); + return; + } + + host.mountEditorReplacement( + new UndoSelectorComponent({ + choices, + onSelect: (choice) => { + void undoByCount(host, choice.count).then((undone) => { + if (undone) { + host.restoreInputText(choice.input); + return; + } + host.restoreEditor(); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +function parseUndoCount(args: string): number | undefined { + const value = args.trim(); + if (value.length === 0) return 1; + if (!/^[1-9]\d*$/.test(value)) return undefined; + const count = Number(value); + return Number.isSafeInteger(count) ? count : undefined; +} + +async function resolveUndoAvailability( + host: SlashCommandHost, +): Promise<UndoAvailability> { + const local = undoAvailabilityFromTranscript( + host.state.transcriptEntries, + host.state.transcriptContainer.children, + ); + const context = await getSessionContext(host.session); + if (context === undefined) return local; + + const activeContext = undoAvailabilityFromContext(context.history); + return { + maxCount: Math.min(local.maxCount, activeContext.maxCount), + stoppedAtCompaction: + local.stoppedAtCompaction || activeContext.stoppedAtCompaction, + }; +} + +async function getSessionContext( + session: SlashCommandHost['session'], +): Promise<UndoSessionContext | undefined> { + const getContext = ( + session as { getContext?: () => Promise<UndoSessionContext> } | undefined + )?.getContext; + if (session === undefined || getContext === undefined) return undefined; + try { + return await getContext.call(session); + } catch { + return undefined; + } +} + +function undoAvailabilityFromTranscript( + entries: readonly TranscriptEntry[], + children: readonly Component[], +): UndoAvailability { + const { anchors, stoppedAtCompaction } = activeUndoAnchorEntries(entries, children); + return { + maxCount: anchors.length, + stoppedAtCompaction, + }; +} + +function undoAvailabilityFromContext( + history: readonly ContextMessage[], +): UndoAvailability { + let maxCount = 0; + let stoppedAtCompaction = false; + + for (let i = history.length - 1; i >= 0; i--) { + const message = history[i]; + if (message === undefined) continue; + if (message.origin?.kind === 'injection') continue; + if (message.origin?.kind === 'compaction_summary') { + stoppedAtCompaction = true; + break; + } + if (isContextUndoAnchor(message)) maxCount++; + } + + return { maxCount, stoppedAtCompaction }; +} + +function isContextUndoAnchor(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + if (origin === undefined || origin.kind === 'user') return true; + if (origin.kind === 'skill_activation') { + return origin.trigger === 'user-slash'; + } + if (origin.kind === 'plugin_command') { + return origin.trigger === 'user-slash'; + } + return false; +} + +function createUndoChoices( + entries: readonly TranscriptEntry[], + children: readonly Component[], + maxCount: number, +): readonly UndoChoice[] { + if (maxCount <= 0) return []; + const anchors = activeUndoAnchorEntries(entries, children).anchors.slice(-maxCount); + return anchors.map((entry, index) => ({ + id: entry.id, + count: anchors.length - index, + input: formatUndoChoiceInput(entry), + label: formatUndoChoiceLabel(entry), + })); +} + +function activeUndoAnchorEntries( + entries: readonly TranscriptEntry[], + children: readonly Component[], +): { readonly anchors: readonly TranscriptEntry[]; readonly stoppedAtCompaction: boolean } { + const lastCompactionChildIndex = children.findLastIndex( + (child) => child instanceof CompactionComponent, + ); + if (lastCompactionChildIndex >= 0) { + return { + anchors: children + .slice(lastCompactionChildIndex + 1) + .map((child) => getTranscriptComponentEntry(child)) + .filter((entry): entry is TranscriptEntry => entry !== undefined) + .filter(isUndoAnchorEntry), + stoppedAtCompaction: true, + }; + } + + const lastCompactionEntryIndex = entries.findLastIndex( + (entry) => entry.compactionData !== undefined, + ); + const activeEntries = + lastCompactionEntryIndex >= 0 ? entries.slice(lastCompactionEntryIndex + 1) : entries; + return { + anchors: activeEntries.filter(isUndoAnchorEntry), + stoppedAtCompaction: lastCompactionEntryIndex >= 0, + }; +} + +function formatUndoChoiceLabel( + entry: TranscriptEntry, +): string { + if (entry.kind === 'skill_activation') { + const name = singleLine( + entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''), + ); + const args = singleLine(entry.skillArgs ?? ''); + if (name.length === 0) return 'Skill: unknown'; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; + } + if (entry.kind === 'plugin_command' && entry.pluginCommandData !== undefined) { + return formatPluginCommandSlash(entry.pluginCommandData) ?? 'User message'; + } + + const content = singleLine(entry.content); + const imageCount = entry.imageAttachmentIds?.length ?? 0; + if (content.length > 0) return content; + if (imageCount > 0) { + return `User message (${String(imageCount)} ${imageCount === 1 ? 'image' : 'images'})`; + } + return 'User message'; +} + +function formatUndoChoiceInput(entry: TranscriptEntry): string { + if (entry.kind === 'skill_activation') { + const name = singleLine( + entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''), + ); + const args = singleLine(entry.skillArgs ?? ''); + if (name.length === 0) return ''; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; + } + if (entry.kind === 'plugin_command' && entry.pluginCommandData !== undefined) { + return formatPluginCommandSlash(entry.pluginCommandData) ?? entry.content; + } + return entry.content; +} + +function formatPluginCommandSlash(data: NonNullable<TranscriptEntry['pluginCommandData']>): string | undefined { + const name = `${data.pluginId}:${data.commandName}`; + const args = singleLine(data.args ?? ''); + if (name.length === 0) return undefined; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; +} + +function singleLine(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +function formatUndoLimitMessage( + requestedCount: number, + availability: UndoAvailability, +): string { + const reason = availability.stoppedAtCompaction ? ' after the last compaction' : ''; + const requested = formatPromptCount(requestedCount); + const max = formatPromptCount(availability.maxCount); + return `Cannot undo ${requested}; only ${max} can be undone in the active context${reason}.`; +} + +function formatNothingToUndoMessage(availability: UndoAvailability): string { + if (availability.stoppedAtCompaction) { + return 'Nothing to undo after the last compaction.'; + } + return 'Nothing to undo.'; +} + +function formatPromptCount(count: number): string { + return `${String(count)} ${count === 1 ? 'prompt' : 'prompts'}`; +} + +function showUndoLimitStatus(host: SlashCommandHost, message: string): void { + host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + turnId: UNDO_LIMIT_STATUS_TURN_ID, + renderMode: 'plain', + content: message, + }); +} + +function undoLimitFromError( + error: unknown, +): (UndoAvailability & { readonly requestedCount: number }) | undefined { + if (!isKimiError(error)) return undefined; + const details = error.details; + if (details?.['reason'] !== 'undo_limit') return undefined; + const requestedCount = details['requestedCount']; + const maxCount = details['undoableCount']; + const stoppedAtCompaction = details['stoppedAtCompaction']; + if ( + typeof requestedCount !== 'number' || + typeof maxCount !== 'number' || + typeof stoppedAtCompaction !== 'boolean' + ) { + return undefined; + } + return { requestedCount, maxCount, stoppedAtCompaction }; +} + +function isUndoAnchorEntry(entry: TranscriptEntry): boolean { + return ( + entry.kind === 'user' || + (entry.kind === 'skill_activation' && entry.skillTrigger === 'user-slash') || + entry.kind === 'plugin_command' + ); +} + +function findUndoAnchorEntryIndex( + entries: readonly TranscriptEntry[], + count: number, +): number | undefined { + let found = 0; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry !== undefined && isUndoAnchorEntry(entry)) { + found++; + if (found === count) return i; + } + } + return undefined; +} + +function isUndoContextEntry(entry: TranscriptEntry): boolean { + switch (entry.kind) { + case 'user': + case 'assistant': + case 'tool_call': + case 'thinking': + case 'skill_activation': + case 'plugin_command': + case 'cron': + return true; + case 'status': + case 'goal': + return entry.turnId !== undefined; + case 'welcome': + return false; + } +} + +function findUndoAnchorComponentIndex( + children: readonly Component[], + count: number, +): number | undefined { + let found = 0; + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if (child !== undefined && isUndoAnchorComponent(child)) { + found++; + if (found === count) return i; + } + } + return undefined; +} + +function removeUndoContextComponents( + children: Component[], + startIndex: number, +): void { + for (let i = children.length - 1; i >= startIndex; i--) { + const child = children[i]; + if (child !== undefined && isUndoContextComponent(child)) { + children.splice(i, 1); + } + } +} + +function isUndoAnchorComponent(child: Component): boolean { + return ( + child instanceof UserMessageComponent || + (child instanceof SkillActivationComponent && child.trigger === 'user-slash') || + child instanceof PluginCommandComponent + ); +} + +function isUndoContextComponent(child: Component): boolean { + const entry = getTranscriptComponentEntry(child); + if (entry !== undefined) { + return isUndoContextEntry(entry); + } + + return ( + child instanceof UserMessageComponent || + child instanceof AssistantMessageComponent || + child instanceof ThinkingComponent || + child instanceof ToolCallComponent || + child instanceof AgentGroupComponent || + child instanceof AgentSwarmProgressComponent || + child instanceof ReadGroupComponent || + child instanceof SkillActivationComponent || + child instanceof PluginCommandComponent || + child instanceof BackgroundAgentStatusComponent || + child instanceof CronMessageComponent + ); +} + +function renderWelcome(host: SlashCommandHost): void { + if ( + host.state.transcriptContainer.children.some( + (child) => child instanceof WelcomeComponent, + ) + ) { + return; + } + host.state.transcriptContainer.addChild( + new WelcomeComponent(host.state.appState), + ); +} diff --git a/apps/kimi-code/src/tui/commands/web.ts b/apps/kimi-code/src/tui/commands/web.ts new file mode 100644 index 000000000..366860016 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -0,0 +1,92 @@ +import { ensureDaemon } from '#/cli/sub/server/daemon'; +import { tryResolveServerToken } from '#/cli/sub/server/shared'; +import { openUrl } from '#/utils/open-url'; +import { getDataDir } from '#/utils/paths'; + +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +const WEB_CONFIRM = 'confirm'; +const WEB_CANCEL = 'cancel'; + +/** + * `/web` — hand the current session off to the browser. + * + * Equivalent to `kimi server run` (ensures the background daemon is up) plus + * `kimi web` (opens the browser), but deep-linked to the active session and + * followed by shutting down this terminal UI. A confirmation step spells out + * the consequences and only proceeds when the user presses Enter on Continue. + */ +export async function handleWebCommand(host: SlashCommandHost): Promise<void> { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + const sessionId = session.id; + + const confirmed = await new Promise<boolean>((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Open current session in the Web UI?', + hint: '↑↓ navigate · Enter select · Esc cancel', + options: [ + { + value: WEB_CONFIRM, + label: 'Continue', + description: + 'Start the Kimi server (background daemon if needed), open this session in your default browser, and exit the terminal UI.', + }, + { + value: WEB_CANCEL, + label: 'Cancel', + description: 'Stay in the terminal UI.', + }, + ], + onSelect: (value) => { + resolve(value === WEB_CONFIRM); + }, + onCancel: () => { + resolve(false); + }, + }); + host.mountEditorReplacement(picker); + }); + host.restoreEditor(); + if (!confirmed) return; + + host.showStatus('Starting Kimi server and opening web UI…'); + let origin: string; + try { + ({ origin } = await ensureDaemon({})); + } catch (error) { + host.showError(`Failed to start server: ${formatErrorMessage(error)}`); + return; + } + + // Resolve the persistent token so the opened browser auto-authenticates via + // the `#token=` fragment — matching the `kimi web` subcommand. Show the URL + // and token in green under the status line so they can be copied before the + // terminal exits. Best-effort: an older/never-started server has no token + // file, so we fall back to the plain URL and skip the token line. + const token = tryResolveServerToken(getDataDir()); + const url = webSessionUrl(origin, sessionId, token); + host.showStatus(`open ${url}`, 'success'); + if (token !== undefined) { + host.showStatus(`Token: ${token}`, 'success'); + } + openUrl(url); + host.setExitOpenUrl(url); + await host.stop(); +} + +/** + * Build the deep-link URL the web UI recognises for a session. When a token is + * known it rides in the `#token=` fragment (never sent to the server, so never + * logged), so the browser authenticates on load just like `kimi web`. + */ +export function webSessionUrl(origin: string, sessionId: string, token?: string): string { + const base = `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`; + return token === undefined ? base : `${base}#token=${token}`; +} diff --git a/apps/kimi-code/src/tui/components/chrome/banner.ts b/apps/kimi-code/src/tui/components/chrome/banner.ts new file mode 100644 index 000000000..58b6faa58 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/banner.ts @@ -0,0 +1,76 @@ +import type { Component } from '@moonshot-ai/pi-tui'; +import { visibleWidth, wrapTextWithAnsi } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { BannerState } from '#/tui/types'; + +const PREFIX_STAR = '✦'; +const PADDING = ' '; + +export class BannerComponent implements Component { + constructor(private readonly state: BannerState) {} + + invalidate(): void {} + + render(width: number): string[] { + const main = (s: string): string => currentTheme.boldFg('textStrong', s); + const dim = (s: string): string => currentTheme.fg('textDim', s); + + // Render nothing but the trailing blank if the terminal cannot hold a + // single visible column. + if (width < 1) { + return ['']; + } + + const tagText = this.state.tag ?? ''; + // Do not add a colon/tag suffix here; the caller-provided tag includes its + // own punctuation/separator. + const tagLabel = tagText.length > 0 ? `${PREFIX_STAR} ${tagText}` : ''; + const tagStyled = tagLabel.length > 0 ? currentTheme.boldFg('primary', tagLabel) : ''; + const tagDisplay = tagStyled.length > 0 ? tagStyled + PADDING : ''; + const tagWidth = visibleWidth(tagDisplay); + const showTag = tagWidth > 0 && tagWidth < width; + // Body lines (continuations of the main text) indent to match the first + // line's main-text column, which starts right after the tag display. + const bodyIndent = showTag ? ' '.repeat(tagWidth) : ''; + // Descriptive subtext lines (the second line in the design) start at the + // column after the leading star + space, aligning with the tag text itself. + const descIndent = showTag ? ' '.repeat(visibleWidth(PREFIX_STAR + PADDING)) : ''; + const bodyContentWidth = width - (showTag ? tagWidth : 0); + const descContentWidth = width - (showTag ? visibleWidth(PREFIX_STAR + PADDING) : 0); + + if (bodyContentWidth <= 0) { + return ['']; + } + + const mainSegments = this.state.mainText.split('\n'); + const subSegments = this.state.subText ? this.state.subText.split('\n') : []; + + const result: string[] = []; + for (let i = 0; i < mainSegments.length; i++) { + const wrapped = wrapTextWithAnsi(mainSegments[i]!, bodyContentWidth); + for (let j = 0; j < wrapped.length; j++) { + const boldLine = main(wrapped[j]!); + if (i === 0 && j === 0 && showTag) { + result.push(tagDisplay + boldLine); + } else { + result.push(bodyIndent + boldLine); + } + } + } + + for (const sub of subSegments) { + const available = descContentWidth <= 0 ? bodyContentWidth : descContentWidth; + const wrapped = wrapTextWithAnsi(sub, available); + for (const line of wrapped) { + result.push(descIndent + dim(line)); + } + } + + // Add a blank line below the banner so the following transcript content + // (e.g. the input prompt / status messages) is visually separated. + result.push(''); + + return result; + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts index de2704228..26ec211a0 100644 --- a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts +++ b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts @@ -6,18 +6,16 @@ * active palette so theme switches take effect on the next render. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface DeviceCodeBoxParams { readonly title: string; readonly url: string; readonly code: string; readonly hint?: string; - readonly colors: ColorPalette; } export class DeviceCodeBoxComponent implements Component { @@ -30,28 +28,33 @@ export class DeviceCodeBoxComponent implements Component { invalidate(): void {} render(width: number): string[] { - const { title, url, code, hint, colors } = this.params; - const border = (s: string): string => chalk.hex(colors.primary)(s); - const safeWidth = Math.max(28, width); - const innerWidth = Math.max(10, safeWidth - 4); + const { title, url, code, hint } = this.params; + const border = (s: string): string => currentTheme.fg('primary', s); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; - const titleLine = truncateToWidth(chalk.bold.hex(colors.textStrong)(title), innerWidth, '…'); + const titleLine = truncateToWidth(currentTheme.boldFg('textStrong', title), innerWidth, '…'); const promptLine = truncateToWidth( - chalk.hex(colors.textDim)('Visit the URL below in your browser to authorize:'), + currentTheme.fg('textDim', 'Visit the URL below in your browser to authorize:'), innerWidth, '…', ); - const urlLine = truncateToWidth(chalk.hex(colors.primary)(url), innerWidth, '…'); + const urlLine = truncateToWidth(currentTheme.fg('primary', url), innerWidth, '…'); - const codeLabel = chalk.bold.hex(colors.textDim)('Verification code: '); - const codeValue = chalk.bold.hex(colors.accent)(code); + const codeLabel = currentTheme.boldFg('textDim', 'Verification code: '); + const codeValue = currentTheme.boldFg('accent', code); const codeLine = truncateToWidth(`${codeLabel}${codeValue}`, innerWidth, '…'); const contentLines: string[] = [titleLine, '', promptLine, urlLine, '', codeLine]; if (hint !== undefined && hint.length > 0) { contentLines.push(''); - contentLines.push(truncateToWidth(chalk.hex(colors.textDim)(hint), innerWidth, '…')); + contentLines.push(truncateToWidth(currentTheme.fg('textDim', hint), innerWidth, '…')); + } + + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; } const lines: string[] = [ @@ -71,6 +74,6 @@ export class DeviceCodeBoxComponent implements Component { lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index b0807d69c..b91193b53 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -6,11 +6,14 @@ * Line 2: context: XX.X% (tokens/max) */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; import { @@ -23,51 +26,16 @@ import { import { safeUsageRatio } from '#/utils/usage/usage-format'; const MAX_CWD_SEGMENTS = 3; +const GOAL_TIMER_INTERVAL_MS = 1_000; // Toolbar tips — rotates every 10s. Most tips are short and pair up (two // joined by " | ") when space allows; tips flagged `solo` are long or // important enough to take the whole slot on their own. A `priority` weight // makes a tip recur more often in the rotation (default 1). Width is always // the final arbiter (a pair that doesn't fit falls back to its first tip). -// -// This is deliberately code-level configuration: edit the interval and the -// TOOLBAR_TIPS array below to change what the footer advertises. const TIP_ROTATE_INTERVAL_MS = 10_000; const TIP_SEPARATOR = ' | '; -export interface ToolbarTip { - readonly text: string; - /** - * Long/important tips render on their own. They never pair with a - * neighbour and never appear as the second half of someone else's pair. - */ - readonly solo?: boolean; - /** - * Rotation weight: a higher value makes the tip recur more often. Defaults - * to 1. Used to give newer/important features more airtime. - */ - readonly priority?: number; -} - -const TOOLBAR_TIPS: readonly ToolbarTip[] = [ - { text: 'shift+tab: plan mode' }, - { text: '/model: switch model' }, - { text: 'ctrl+s: steer mid-turn', priority: 2 }, - { text: '/compact: compact context', priority: 2 }, - { text: 'ctrl+o: expand tool output' }, - { text: '/tasks: background tasks' }, - { text: 'shift+enter: newline' }, - { text: '/init: generate AGENTS.md', priority: 2 }, - { text: '@: mention files' }, - { text: 'ctrl+c: cancel' }, - { text: '/theme: switch theme' }, - { text: '/auto: auto permission mode' }, - { text: '/yolo: toggle yolo' }, - { text: '/help: show commands' }, - { text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 }, - { text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, -]; - /** * Expand tips into a rotation sequence using smooth weighted round-robin * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while @@ -95,7 +63,7 @@ export function buildWeightedTips(tips: readonly ToolbarTip[]): readonly Toolbar return seq; } -const ROTATION: readonly ToolbarTip[] = buildWeightedTips(TOOLBAR_TIPS); +const ROTATION: readonly ToolbarTip[] = buildWeightedTips(ALL_TIPS); function currentTipIndex(): number { return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS); @@ -120,15 +88,53 @@ function tipsForIndex(index: number): { primary: string; pair: string | null } { return { primary: current.text, pair: current.text + TIP_SEPARATOR + next.text }; } -function shortenModel(model: string): string { - if (!model) return model; - const slash = model.lastIndexOf('/'); - return slash >= 0 ? model.slice(slash + 1) : model; +/** + * Footer goal badge, e.g. `[goal ● active · 4m · 7 turns]`. Only shown for a + * live (active/paused) goal; terminal/no goal -> no badge. Turn count is a raw + * count unless an explicit turn budget is set, in which case it shows used/limit. + */ +function formatGoalBadge( + goal: AppState['goal'], + colors: ColorPalette, + wallClockMs?: number, +): string | null { + if (goal === null || goal === undefined) return null; + // Show the badge for every persisted, resumable status. `complete` clears the + // goal, so it never reaches here; only the unset case returns null. + if (goal.status !== 'active' && goal.status !== 'paused' && goal.status !== 'blocked') { + return null; + } + const dotColor = + goal.status === 'active' + ? colors.primary + : goal.status === 'blocked' + ? colors.warning + : colors.textMuted; + const turns = + goal.budget.turnBudget !== null + ? `${goal.turnsUsed}/${goal.budget.turnBudget} turns` + : `${goal.turnsUsed} ${goal.turnsUsed === 1 ? 'turn' : 'turns'}`; + const label = `${goal.status} · ${formatBadgeElapsed(wallClockMs ?? goal.wallClockMs)} · ${turns}`; + return ( + chalk.hex(colors.textMuted)('[goal ') + + chalk.hex(dotColor)('●') + + chalk.hex(colors.textMuted)(` ${label}]`) + ); +} + +function formatBadgeElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h${minutes % 60}m`; } function modelDisplayName(state: AppState): string { const model = state.availableModels[state.model]; - return model?.displayName ?? model?.model ?? state.model; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? state.model; } function shortenCwd(path: string): string { @@ -167,7 +173,7 @@ function formatContextStatus(usage: number, tokens?: number, maxTokens?: number) } export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { - const base = chalk.hex(colors.status)(formatGitBadgeBase(status)); + const base = chalk.hex(colors.textDim)(formatGitBadgeBase(status)); if (status.pullRequest === null) return base; const pullRequest = chalk.hex(colors.primary)( @@ -178,11 +184,13 @@ export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): s export class FooterComponent implements Component { private state: AppState; - private colors: ColorPalette; - private readonly onGitStatusChange: () => void; + private readonly onRefresh: () => void; private gitCache: GitStatusCache; private gitCacheWorkDir: string; private transientHint: string | null = null; + private goalSnapshotKey: string | null = null; + private goalObservedAtMs = Date.now(); + private goalTimer: ReturnType<typeof setInterval> | null = null; /** * Non-terminal background-task counts split by kind so the footer can * render two distinct badges. `bashTasks` covers `bash-*` BPM tasks @@ -193,26 +201,25 @@ export class FooterComponent implements Component { private backgroundBashTaskCount = 0; private backgroundAgentCount = 0; - constructor(state: AppState, colors: ColorPalette, onGitStatusChange: () => void = () => {}) { + constructor(state: AppState, onRefresh: () => void = () => {}) { this.state = state; - this.colors = colors; - this.onGitStatusChange = onGitStatusChange; + this.onRefresh = onRefresh; this.gitCacheWorkDir = state.workDir; - this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange }); + this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); + this.syncGoalClock(state.goal); + this.syncGoalTimer(state.goal); } setState(state: AppState): void { if (state.workDir !== this.gitCacheWorkDir) { this.gitCacheWorkDir = state.workDir; - this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange }); + this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); } + this.syncGoalClock(state.goal); + this.syncGoalTimer(state.goal); this.state = state; } - setColors(colors: ColorPalette): void { - this.colors = colors; - } - /** * Short-lived hint that replaces the rotating toolbar tips on line 1. * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C @@ -223,6 +230,10 @@ export class FooterComponent implements Component { this.transientHint = hint; } + getTransientHint(): string | null { + return this.transientHint; + } + /** * Sync both background-task badges with live counts. Each non-zero * count produces its own bracketed badge on line 1; zeros hide them @@ -236,22 +247,39 @@ export class FooterComponent implements Component { invalidate(): void {} render(width: number): string[] { - const colors = this.colors; + const colors = currentTheme.palette; const state = this.state; // ── Line 1: mode badges + model + [N task(s) running] + [N agent(s) running] + cwd + git + hints ── const left: string[] = []; - if (state.permissionMode === 'auto') left.push(chalk.hex(colors.warning).bold('auto')); - if (state.permissionMode === 'yolo') left.push(chalk.hex(colors.warning).bold('yolo')); - if (state.planMode) left.push(chalk.hex(colors.primary).bold('plan')); + 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(' ')); - const model = shortenModel(modelDisplayName(state)); + const goalBadge = formatGoalBadge(state.goal, colors, this.goalWallClockMs(state.goal)); + if (goalBadge !== null) left.push(goalBadge); + + const model = modelDisplayName(state); if (model) { - const thinkingLabel = state.thinking ? ' thinking' : ''; + const effort = state.thinkingEffort; + const rawCurrentModel = state.availableModels[state.model]; + const currentModel = rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel); + // Only effort-capable models (those declaring support_efforts) show the + // concrete effort; legacy boolean models keep the plain "thinking" suffix. + const hasEfforts = (currentModel?.supportEfforts?.length ?? 0) > 0; + const thinkingLabel = + effort !== 'off' + ? hasEfforts && effort !== 'on' + ? ` thinking: ${effort}` + : ' thinking' + : ''; const modelLabel = `${model}${thinkingLabel}`; let renderedModelLabel = chalk.hex(colors.text)(modelLabel); if (isRainbowDancing()) { - renderedModelLabel = renderDanceFooterModel(modelLabel, colors); + renderedModelLabel = renderDanceFooterModel(modelLabel); } left.push(renderedModelLabel); } @@ -273,7 +301,7 @@ export class FooterComponent implements Component { } const cwd = shortenCwd(state.workDir); - if (cwd) left.push(chalk.hex(colors.status)(cwd)); + if (cwd) left.push(chalk.hex(colors.textDim)(cwd)); const git = this.gitCache.getStatus(); if (git !== null) { @@ -331,4 +359,55 @@ export class FooterComponent implements Component { return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; } + + private syncGoalClock(goal: AppState['goal']): void { + const key = goalSnapshotKey(goal); + if (key === this.goalSnapshotKey) return; + this.goalSnapshotKey = key; + this.goalObservedAtMs = Date.now(); + } + + private syncGoalTimer(goal: AppState['goal']): void { + if (goal?.status === 'active') { + if (this.goalTimer !== null) return; + this.goalTimer = setInterval(() => { + this.onRefresh(); + }, GOAL_TIMER_INTERVAL_MS); + this.goalTimer.unref?.(); + return; + } + + if (this.goalTimer !== null) { + clearInterval(this.goalTimer); + this.goalTimer = null; + } + } + + dispose(): void { + if (this.goalTimer !== null) { + clearInterval(this.goalTimer); + this.goalTimer = null; + } + } + + private goalWallClockMs(goal: AppState['goal']): number | undefined { + if (goal === null || goal === undefined) return undefined; + if (goal.status !== 'active') return goal.wallClockMs; + return goal.wallClockMs + Math.max(0, Date.now() - this.goalObservedAtMs); + } +} + +function goalSnapshotKey(goal: AppState['goal']): string | null { + if (goal === null || goal === undefined) return null; + return [ + goal.goalId, + goal.status, + goal.terminalReason ?? '', + String(goal.turnsUsed), + String(goal.tokensUsed), + String(goal.wallClockMs), + String(goal.budget.tokenBudget), + String(goal.budget.turnBudget), + String(goal.budget.wallClockBudgetMs), + ].join('\u0000'); } diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts index 39ed97c49..72b1e94b1 100644 --- a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -9,9 +9,21 @@ * the edge and adding them would just churn the diff renderer. */ -import { Container } from '@earendil-works/pi-tui'; +import { Container } from '@moonshot-ai/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; + +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +interface TranscriptRenderCache { + width: number; + childRefs: Component[]; + childRenderRefs: string[][]; + prefixed: string[][]; + out: string[]; +} export class GutterContainer extends Container { + private renderCache: TranscriptRenderCache | undefined; constructor( private readonly leftPad: number, private readonly rightPad: number, @@ -19,15 +31,56 @@ export class GutterContainer extends Container { super(); } + override invalidate(): void { + this.renderCache = undefined; + super.invalidate(); + } + override render(width: number): string[] { const inner = Math.max(1, width - this.leftPad - this.rightPad); const lead = ' '.repeat(this.leftPad); - const out: string[] = []; + + const cache = this.renderCache; + const cacheValid = + isRenderCacheEnabled() && + cache !== undefined && + cache.width === width && + cache.childRefs.length === this.children.length; + + const childRefs: Component[] = []; + const childRenderRefs: string[][] = []; + const prefixed: string[][] = []; + let allReused = cacheValid; + + let i = 0; for (const child of this.children) { - for (const line of child.render(inner)) { - out.push(lead + line); + const lines = child.render(inner); + childRefs.push(child); + childRenderRefs.push(lines); + const reused = cacheValid && cache.childRefs[i] === child && cache.childRenderRefs[i] === lines; + if (reused) { + prefixed.push(cache.prefixed[i]!); + } else { + allReused = false; + prefixed.push(lines.map((line) => lead + line)); + } + i++; + } + + let out: string[]; + if (allReused) { + out = cache!.out; + } else { + out = []; + for (const lines of prefixed) { + for (const line of lines) out.push(line); } } + + if (isRenderCacheEnabled()) { + this.renderCache = { width, childRefs, childRenderRefs, prefixed, out }; + } + return out; } } diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts index 0e88e6af4..a9d21452e 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -1,5 +1,5 @@ -import { Text } from '@earendil-works/pi-tui'; -import type { TUI } from '@earendil-works/pi-tui'; +import { Text, visibleWidth } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { BRAILLE_SPINNER_FRAMES, @@ -7,6 +7,7 @@ import { MOON_SPINNER_FRAMES, MOON_SPINNER_INTERVAL_MS, } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; export type SpinnerStyle = 'moon' | 'braille'; @@ -18,6 +19,15 @@ export class MoonLoader extends Text { private interval: number; private colorFn?: (s: string) => string; private label: string; + private displayText = ''; + // Inline text used when the spinner is embedded into another line (e.g. the + // agent-swarm progress status line). It intentionally excludes the tip: the + // tip is only rendered when the loader sits on its own row in the activity + // pane, otherwise it would get squeezed against whatever follows the inline + // spinner (like the swarm progress bar). + private inlineText = ''; + private tip: string = ''; + private availableWidth = 0; constructor( ui: TUI, @@ -49,6 +59,10 @@ export class MoonLoader extends Text { } } + dispose(): void { + this.stop(); + } + setLabel(label: string): void { this.label = label; this.updateDisplay(); @@ -59,10 +73,35 @@ export class MoonLoader extends Text { this.updateDisplay(); } + setTip(tip: string): void { + this.tip = tip; + this.updateDisplay(); + } + + setAvailableWidth(width: number): void { + if (this.availableWidth === width) return; + this.availableWidth = width; + this.updateDisplay(); + } + + renderInline(): string { + return this.inlineText; + } + private updateDisplay(): void { const frame = this.frames[this.currentFrame]!; const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; - this.setText(this.label ? `${coloredFrame} ${this.label}` : coloredFrame); + const baseText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + this.inlineText = baseText; + let text = baseText; + if (this.tip) { + const withTip = baseText + currentTheme.fg('textDim', this.tip); + if (this.availableWidth === 0 || visibleWidth(withTip) <= this.availableWidth) { + text = withTip; + } + } + this.displayText = text; + this.setText(this.displayText); this.ui.requestRender(); } } diff --git a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts index 45ed12c83..b101b6d6c 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -9,10 +9,11 @@ * is issued. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; export type TodoStatus = 'pending' | 'in_progress' | 'done'; @@ -27,6 +28,7 @@ const MAX_VISIBLE = 5; export interface VisibleTodos { readonly rows: readonly TodoItem[]; readonly hidden: number; + readonly hiddenCounts: Record<TodoStatus, number>; } /** @@ -48,7 +50,11 @@ export interface VisibleTodos { */ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { if (todos.length <= MAX_VISIBLE) { - return { rows: [...todos], hidden: 0 }; + return { + rows: [...todos], + hidden: 0, + hiddenCounts: { done: 0, in_progress: 0, pending: 0 }, + }; } const inProgress: number[] = []; @@ -90,19 +96,24 @@ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { } const sortedIdx = [...picked].toSorted((a, b) => a - b); + + const hiddenCounts: Record<TodoStatus, number> = { done: 0, in_progress: 0, pending: 0 }; + for (const [i, todo] of todos.entries()) { + if (!picked.has(i)) { + hiddenCounts[todo.status] += 1; + } + } + return { rows: sortedIdx.map((i) => todos[i] as TodoItem), hidden: todos.length - sortedIdx.length, + hiddenCounts, }; } export class TodoPanelComponent implements Component { private todos: readonly TodoItem[] = []; - private colors: ColorPalette; - - constructor(colors: ColorPalette) { - this.colors = colors; - } + private expanded = false; setTodos(todos: readonly TodoItem[]): void { this.todos = todos.map((t) => ({ title: t.title, status: t.status })); @@ -114,31 +125,57 @@ export class TodoPanelComponent implements Component { clear(): void { this.todos = []; + this.expanded = false; } isEmpty(): boolean { return this.todos.length === 0; } - setColors(colors: ColorPalette): void { - this.colors = colors; + /** True when the list exceeds the collapsed cap, i.e. there is something to expand. */ + hasOverflow(): boolean { + return this.todos.length > MAX_VISIBLE; + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + } + + toggleExpanded(): void { + this.expanded = !this.expanded; } invalidate(): void {} render(width: number): string[] { if (this.todos.length === 0) return []; - const c = this.colors; - const { rows, hidden } = selectVisibleTodos(this.todos); + const c = currentTheme.palette; const lines: string[] = [ chalk.hex(c.border)('─'.repeat(width)), - chalk.hex(c.primary).bold(' Todo'), + chalk.hex(c.primary).bold(' Todo'), ]; - for (const todo of rows) { - lines.push(renderRow(todo, c)); - } - if (hidden > 0) { - lines.push(chalk.hex(c.textDim)(` … +${hidden} more`)); + + if (this.expanded) { + for (const todo of this.todos) { + lines.push(renderRow(todo, c)); + } + if (this.todos.length > MAX_VISIBLE) { + lines.push( + chalk.hex(c.textDim)(` all ${String(this.todos.length)} items · ctrl+t to collapse`), + ); + } + } else { + const { rows, hidden, hiddenCounts } = selectVisibleTodos(this.todos); + for (const todo of rows) { + lines.push(renderRow(todo, c)); + } + if (hidden > 0) { + const distribution = formatHiddenCounts(hiddenCounts); + const suffix = distribution.length > 0 ? ` (${distribution})` : ''; + lines.push( + chalk.hex(c.textDim)(` … +${hidden} more${suffix} · ctrl+t to expand`), + ); + } } return lines.map((line) => truncateToWidth(line, width)); @@ -172,3 +209,15 @@ function styleTitle(title: string, status: TodoStatus, colors: ColorPalette): st return chalk.hex(colors.text)(title); } } + +const STATUS_LABELS: readonly { status: TodoStatus; label: string }[] = [ + { status: 'done', label: 'done' }, + { status: 'in_progress', label: 'in progress' }, + { status: 'pending', label: 'pending' }, +]; + +export function formatHiddenCounts(counts: Record<TodoStatus, number>): string { + return STATUS_LABELS.filter(({ status }) => counts[status] > 0) + .map(({ status, label }) => `${counts[status]} ${label}`) + .join(' · '); +} diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 899139e25..10cdecbdb 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -3,28 +3,46 @@ * Renders a round-bordered box with the logo, session, model, and version. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; + import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance'; -import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; +import { currentTheme } from '#/tui/theme'; export class WelcomeComponent implements Component { private state: AppState; - private colors: ColorPalette; - constructor(state: AppState, colors: ColorPalette) { + constructor(state: AppState) { this.state = state; - this.colors = colors; } invalidate(): void {} render(width: number): string[] { - const primary = (s: string): string => chalk.hex(this.colors.primary)(s); - const innerWidth = Math.max(10, width - 4); + const safeWidth = Math.max(0, width); + const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s); + const isLoggedOut = !this.state.model; + const activeModel = this.state.availableModels[this.state.model]; + const effectiveActiveModel = activeModel === undefined ? undefined : effectiveModelAlias(activeModel); + + if (safeWidth < 24) { + const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'); + const prompt = isLoggedOut + ? chalk.hex(currentTheme.palette.warning)('Run /login or /provider to get started.') + : chalk.hex(currentTheme.palette.textDim)('Send /help for help information.'); + const model = isLoggedOut + ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); + return ['', title, prompt, `Model: ${model}`].map((line) => + truncateToWidth(line, safeWidth, '…'), + ); + } + + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; // Logo + side-by-side text. @@ -34,15 +52,14 @@ export class WelcomeComponent implements Component { const textWidth = Math.max(4, innerWidth - logoWidth - gap.length); const rightRow0 = truncateToWidth( - chalk.bold.hex(this.colors.primary)('Welcome to Kimi Code!'), + chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'), textWidth, '…', ); - const isLoggedOut = !this.state.model; - const dim = chalk.hex(this.colors.textDim); - const labelStyle = chalk.bold.hex(this.colors.textDim); + const dim = chalk.hex(currentTheme.palette.textDim); + const labelStyle = chalk.bold.hex(currentTheme.palette.textDim); const rightRow1 = truncateToWidth( - dim(isLoggedOut ? 'Run /login or /connect to get started.' : 'Send /help for help information.'), + dim(isLoggedOut ? 'Run /login or /provider to get started.' : 'Send /help for help information.'), textWidth, '…', ); @@ -52,13 +69,12 @@ export class WelcomeComponent implements Component { primary(logo[1].padEnd(logoWidth)) + gap + rightRow1, ]; if (isRainbowDancing()) { - renderedHeaderLines = renderDanceWelcomeHeader(this.colors, logo, textWidth, rightRow1); + renderedHeaderLines = renderDanceWelcomeHeader(logo, textWidth, rightRow1); } - const activeModel = this.state.availableModels[this.state.model]; const modelValue = isLoggedOut - ? chalk.hex(this.colors.warning)('not set, run /login or /connect') - : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); + ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); const infoLines = [ labelStyle('Directory: ') + this.state.workDir, @@ -67,12 +83,16 @@ export class WelcomeComponent implements Component { labelStyle('Version: ') + this.state.version, ]; + if (this.state.mcpServersSummary) { + infoLines.push(labelStyle('MCP: ') + this.state.mcpServersSummary); + } + const contentLines: string[] = [...renderedHeaderLines, '', ...infoLines]; const lines: string[] = [ '', - primary('╭' + '─'.repeat(width - 2) + '╮'), - primary('│') + ' '.repeat(width - 2) + primary('│'), + primary('╭' + '─'.repeat(safeWidth - 2) + '╮'), + primary('│') + ' '.repeat(safeWidth - 2) + primary('│'), ]; for (const content of contentLines) { @@ -82,10 +102,10 @@ export class WelcomeComponent implements Component { lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│')); } - lines.push(primary('│') + ' '.repeat(width - 2) + primary('│')); - lines.push(primary('╰' + '─'.repeat(width - 2) + '╯')); + lines.push(primary('│') + ' '.repeat(safeWidth - 2) + primary('│')); + lines.push(primary('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/kimi-code/src/tui/components/chrome/working-tips.ts b/apps/kimi-code/src/tui/components/chrome/working-tips.ts new file mode 100644 index 000000000..23d002738 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/working-tips.ts @@ -0,0 +1,31 @@ +import { WORKING_TIPS, type ToolbarTip } from '#/tui/constant/tips'; + +import { buildWeightedTips } from './footer'; + +export { WORKING_TIPS }; + +const TIP_ROTATE_INTERVAL_MS = 10_000; + +const WORKING_TIP_ROTATION = buildWeightedTips(WORKING_TIPS); + +export function currentWorkingTip(now = Date.now()): ToolbarTip | undefined { + if (WORKING_TIP_ROTATION.length === 0) return undefined; + const index = Math.floor(now / TIP_ROTATE_INTERVAL_MS) % WORKING_TIP_ROTATION.length; + return WORKING_TIP_ROTATION[index]; +} + +/** + * Pick a random tip from the weighted working-tip rotation. + * If `excludeText` is provided and there are other tips available, avoid + * returning the same text twice in a row. + */ +export function pickRandomWorkingTip(excludeText?: string): ToolbarTip | undefined { + if (WORKING_TIP_ROTATION.length === 0) return undefined; + const candidates = + excludeText === undefined || WORKING_TIP_ROTATION.length === 1 + ? WORKING_TIP_ROTATION + : WORKING_TIP_ROTATION.filter((t) => t.text !== excludeText); + const pool = candidates.length > 0 ? candidates : WORKING_TIP_ROTATION; + const index = Math.floor(Math.random() * pool.length); + return pool[index]; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index 2a06a4278..d95d9ff93 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -6,10 +6,9 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type ApiKeyInputResult = | { readonly kind: 'ok'; readonly value: string } @@ -47,7 +46,6 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private readonly input = new Input(); private readonly onDone: (result: ApiKeyInputResult) => void; - private readonly colors: ColorPalette; private readonly title: string; private readonly subtitleLines: readonly string[]; private done = false; @@ -57,11 +55,9 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { platformName: string, subtitleLines: readonly string[], onDone: (result: ApiKeyInputResult) => void, - colors: ColorPalette, ) { super(); this.onDone = onDone; - this.colors = colors; this.title = `Enter API key for ${platformName}`; this.subtitleLines = subtitleLines; this.input.onSubmit = (value) => { @@ -93,17 +89,18 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { override render(width: number): string[] { this.input.focused = this.focused && !this.done; - const safeWidth = Math.max(28, width); - const innerWidth = Math.max(10, safeWidth - 4); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(this.title); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', this.title); const subtitleSource = this.emptyHinted ? ['API key cannot be empty.'] : this.subtitleLines; const subtitleLines = subtitleSource.map((line) => - truncateToWidth(chalk.hex(this.colors.textDim)(line), innerWidth, '…'), + truncateToWidth(currentTheme.fg('textDim', line), innerWidth, '…'), ); - const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + const footerStyled = currentTheme.fg('textDim', FOOTER); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); @@ -120,6 +117,10 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { footerLine, ]; + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; + } + const lines: string[] = [ '', border('╭' + '─'.repeat(safeWidth - 2) + '╮'), @@ -136,7 +137,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } private submit(value: string): void { diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts index 51aea329a..e18f5709b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -13,9 +13,9 @@ import { type Focusable, truncateToWidth, visibleWidth, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - + wrapTextWithAnsi, +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import type { @@ -25,7 +25,6 @@ import type { FileContentDisplayBlock, PendingApproval, } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; export interface ApprovalPanelResponse { readonly response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; @@ -49,24 +48,66 @@ interface BlockStyles { errorBold: (s: string) => string; } -function makeBlockStyles(colors: ColorPalette): BlockStyles { +function makeBlockStyles(): BlockStyles { return { - strong: (s) => chalk.hex(colors.textStrong)(s), - dim: (s) => chalk.hex(colors.textDim)(s), - accent: (s) => chalk.hex(colors.accent)(s), - gutter: (s) => chalk.hex(colors.diffGutter)(s), - errorBold: (s) => chalk.bold.hex(colors.error)(s), + strong: (s) => currentTheme.fg('textStrong', s), + dim: (s) => currentTheme.fg('textDim', s), + accent: (s) => currentTheme.fg('accent', s), + gutter: (s) => currentTheme.fg('diffGutter', s), + errorBold: (s) => currentTheme.boldFg('error', s), }; } +function appendWrappedLine( + lines: string[], + firstPrefix: string, + continuationPrefix: string, + content: string, + width: number, +): void { + const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix)); + const wrapped = wrapTextWithAnsi(content, Math.max(1, width - prefixWidth)); + if (wrapped.length === 0) { + lines.push(firstPrefix); + return; + } + lines.push(`${firstPrefix}${wrapped[0] ?? ''}`); + for (let i = 1; i < wrapped.length; i++) { + lines.push(`${continuationPrefix}${wrapped[i] ?? ''}`); + } +} + +function renderShellDisplayBlock( + block: Extract<DisplayBlock, { type: 'shell' }>, + s: BlockStyles, + width: number, +): string[] { + const lines: string[] = []; + if (block.cwd !== undefined && block.cwd.length > 0) { + lines.push(s.dim(`cwd: ${block.cwd}`)); + } + if (block.danger !== undefined) { + lines.push(s.errorBold(`Dangerous: ${block.danger}`)); + } + const cmdLines = block.command.length > 0 ? block.command.split('\n') : ['']; + cmdLines.forEach((cmdLine, idx) => { + const prefix = idx === 0 ? `${s.accent('$')} ` : `${s.dim('·')} `; + appendWrappedLine(lines, prefix, ' ', s.strong(cmdLine), width); + }); + if (block.description !== undefined && block.description.length > 0) { + lines.push(` ${s.dim(block.description)}`); + } + return lines; +} + function renderDisplayBlock( block: DisplayBlock, s: BlockStyles, - colors: ColorPalette, + contentWidth: number, ): string[] { switch (block.type) { case 'diff': - return renderDiffLinesClustered(block.old_text, block.new_text, block.path, colors, { + return renderDiffLinesClustered(block.old_text, block.new_text, block.path, { contextLines: 3, expandKeyHint: 'ctrl+e to preview', maxLines: DIFF_SUMMARY_MAX_LINES, @@ -89,24 +130,8 @@ function renderDisplayBlock( } return lines; } - case 'shell': { - const lines: string[] = []; - if (block.cwd !== undefined && block.cwd.length > 0) { - lines.push(s.dim(`cwd: ${block.cwd}`)); - } - if (block.danger !== undefined) { - lines.push(s.errorBold(`Dangerous: ${block.danger}`)); - } - const cmdLines = block.command.length > 0 ? block.command.split('\n') : ['']; - cmdLines.forEach((cmdLine, idx) => { - const prefix = idx === 0 ? s.accent('$') : s.dim('·'); - lines.push(`${prefix} ${s.strong(cmdLine)}`); - }); - if (block.description !== undefined && block.description.length > 0) { - lines.push(` ${s.dim(block.description)}`); - } - return lines; - } + case 'shell': + return renderShellDisplayBlock(block, s, contentWidth); case 'file_op': { const op = s.accent(block.operation.padEnd(5)); const lines = [`${op} ${s.strong(block.path)}`]; @@ -187,9 +212,7 @@ export class ApprovalPanelComponent extends Container implements Focusable { private readonly feedbackInput = new Input(); private onResponse: (response: ApprovalPanelResponse) => void; private request: PendingApproval; - private readonly colors: ColorPalette; private readonly onToggleToolOutput: (() => void) | undefined; - private readonly onTogglePlanExpand: (() => void) | undefined; private readonly onOpenPreview: | ((block: DiffDisplayBlock | FileContentDisplayBlock) => void) | undefined; @@ -197,17 +220,13 @@ export class ApprovalPanelComponent extends Container implements Focusable { constructor( request: PendingApproval, onResponse: (response: ApprovalPanelResponse) => void, - colors: ColorPalette, onToggleToolOutput?: () => void, - onTogglePlanExpand?: () => void, onOpenPreview?: (block: DiffDisplayBlock | FileContentDisplayBlock) => void, ) { super(); this.request = request; this.onResponse = onResponse; - this.colors = colors; this.onToggleToolOutput = onToggleToolOutput; - this.onTogglePlanExpand = onTogglePlanExpand; this.onOpenPreview = onOpenPreview; this.feedbackInput.onSubmit = (value) => { this.submit(this.selectedIndex, value); @@ -253,8 +272,6 @@ export class ApprovalPanelComponent extends Container implements Focusable { const previewable = this.findPreviewableBlock(); if (previewable !== undefined && this.onOpenPreview !== undefined) { this.onOpenPreview(previewable); - } else { - this.onTogglePlanExpand?.(); } return; } @@ -305,12 +322,12 @@ export class ApprovalPanelComponent extends Container implements Focusable { this.ensureValidSelection(); this.feedbackInput.focused = this.focused && this.feedbackMode; const { data } = this.request; - const blockStyles = makeBlockStyles(this.colors); - const borderColor = chalk.hex(this.colors.borderFocus); - const borderColorBold = chalk.bold.hex(this.colors.borderFocus); - const selectColorBold = chalk.bold.hex(this.colors.accent); - const dim = chalk.hex(this.colors.textDim); - const strong = chalk.hex(this.colors.textStrong); + const blockStyles = makeBlockStyles(); + const borderColor = (text: string) => currentTheme.fg('borderFocus', text); + const borderColorBold = (text: string) => currentTheme.boldFg('borderFocus', text); + const selectColorBold = (text: string) => currentTheme.boldFg('accent', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const strong = (text: string) => currentTheme.fg('textStrong', text); const horizontalBar = borderColor('─'.repeat(width)); const indent = (s: string): string => ` ${s}`; @@ -331,7 +348,11 @@ export class ApprovalPanelComponent extends Container implements Focusable { if (visibleBlocks.length > 0) { lines.push(''); for (const block of visibleBlocks) { - const blockLines = renderDisplayBlock(block, blockStyles, this.colors); + const blockLines = renderDisplayBlock( + block, + blockStyles, + Math.max(1, width - 2), + ); for (const line of blockLines) { lines.push(indent(line)); } @@ -358,6 +379,18 @@ export class ApprovalPanelComponent extends Container implements Focusable { } else { lines.push(indent(strong(` ${labelWithNum}`))); } + + // Optional helper text under the label, aligned past the pointer/number. + // Choices without a description render exactly as before. + if ( + option.description !== undefined && + option.description.length > 0 && + !(this.feedbackMode && option.requires_feedback === true && isSelected) + ) { + for (const descLine of wrapTextWithAnsi(option.description, Math.max(20, width - 7))) { + lines.push(indent(` ${dim(descLine)}`)); + } + } } lines.push(''); @@ -405,8 +438,7 @@ export class ApprovalPanelComponent extends Container implements Focusable { } private renderInlineFeedbackLine(width: number, labelWithNum: string): string { - const selectColorBold = chalk.bold.hex(this.colors.accent); - const prefix = `${selectColorBold('▶')} ${selectColorBold(labelWithNum)} `; + const prefix = `${currentTheme.boldFg('accent', '▶')} ${currentTheme.boldFg('accent', labelWithNum)} `; const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); const inputLine = this.feedbackInput.render(inputWidth)[0] ?? '> '; const inlineInput = inputLine.startsWith('> ') ? inputLine.slice(2) : inputLine; diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts index 8133a9f36..044884792 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -24,13 +24,12 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; -import { renderDiffLines } from '#/tui/components/media/diff-preview'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -39,7 +38,6 @@ export type ApprovalPreviewBlock = DiffDisplayBlock | FileContentDisplayBlock; export interface ApprovalPreviewViewerProps { readonly block: ApprovalPreviewBlock; - readonly colors: ColorPalette; readonly onClose: () => void; } @@ -62,9 +60,9 @@ export class ApprovalPreviewViewer extends Container implements Focusable { private readonly props: ApprovalPreviewViewerProps; private readonly terminal: Terminal; /** Pre-rendered body lines (ANSI-styled, no border / no gutter). */ - private readonly bodyLines: string[]; + private bodyLines: string[]; /** Title shown in the header (path + diff stats / "Write" label). */ - private readonly headerTitle: string; + private headerTitle: string; /** Index of the topmost visible line. */ private scrollTop = 0; @@ -72,7 +70,7 @@ export class ApprovalPreviewViewer extends Container implements Focusable { super(); this.props = props; this.terminal = terminal; - const built = buildBody(props.block, props.colors); + const built = buildBody(props.block); this.bodyLines = built.lines; this.headerTitle = built.title; } @@ -98,11 +96,11 @@ export class ApprovalPreviewViewer extends Container implements Focusable { this.scrollBy(1); return; } - if (matchesKey(data, Key.pageUp) || k === ' ' || data === '') { + if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\x02') { this.scrollBy(-Math.max(1, visible - 1)); return; } - if (matchesKey(data, Key.pageDown) || data === '') { + if (matchesKey(data, Key.pageDown) || data === '\x06') { this.scrollBy(Math.max(1, visible - 1)); return; } @@ -120,9 +118,15 @@ export class ApprovalPreviewViewer extends Container implements Focusable { this.scrollTo(this.scrollTop + delta); } + override invalidate(): void { + const built = buildBody(this.props.block); + this.bodyLines = built.lines; + this.headerTitle = built.title; + } + private scrollTo(target: number): void { this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); - this.invalidate(); + super.invalidate(); } private maxScroll(): number { @@ -146,14 +150,11 @@ export class ApprovalPreviewViewer extends Container implements Focusable { } private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' Preview '); + const title = currentTheme.boldFg('primary', ' Preview '); return fitExactly(title + this.headerTitle, width); } private renderBody(width: number, bodyHeight: number): string[] { - const colors = this.props.colors; - const stroke = colors.primary; const innerWidth = Math.max(1, width - 4); const max = this.maxScroll(); @@ -161,23 +162,22 @@ export class ApprovalPreviewViewer extends Container implements Focusable { if (this.scrollTop < 0) this.scrollTop = 0; const viewRows = bodyHeight - 2; - const top = chalk.hex(stroke)('┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); const out: string[] = [top]; for (let i = 0; i < viewRows; i++) { const lineIndex = this.scrollTop + i; const raw = this.bodyLines[lineIndex] ?? ''; - out.push(chalk.hex(stroke)('│ ') + fitExactly(raw, innerWidth) + chalk.hex(stroke)(' │')); + out.push(currentTheme.fg('primary', '│ ') + fitExactly(raw, innerWidth) + currentTheme.fg('primary', ' │')); } out.push(bottom); return out; } private renderFooter(width: number, bodyHeight: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); const total = this.bodyLines.length; const viewRows = Math.max(1, bodyHeight - 2); @@ -186,14 +186,15 @@ export class ApprovalPreviewViewer extends Container implements Focusable { const lineFrom = total === 0 ? 0 : this.scrollTop + 1; const lineTo = Math.min(total, this.scrollTop + viewRows); - const position = chalk.hex(colors.textMuted)( + const position = currentTheme.fg( + 'textMuted', ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, ); const keys = `${key('↑↓')} ${dim('line')} ` + `${key('PgUp/PgDn')} ${dim('page')} ` + `${key('g/G')} ${dim('top/bot')} ` + - `${key('Q/Esc/Ctrl+E')} ${dim('back')}`; + `${key('Q/Esc/Ctrl+E')} ${dim('cancel')}`; const left = ` ${keys}`; const leftW = visibleWidth(left); const rightW = visibleWidth(position); @@ -209,39 +210,39 @@ interface BuiltBody { title: string; } -function buildBody(block: ApprovalPreviewBlock, colors: ColorPalette): BuiltBody { +function buildBody(block: ApprovalPreviewBlock): BuiltBody { if (block.type === 'diff') { - return buildDiffBody(block, colors); + return buildDiffBody(block); } - return buildFileContentBody(block, colors); + return buildFileContentBody(block); } -function buildDiffBody(block: DiffDisplayBlock, colors: ColorPalette): BuiltBody { - // renderDiffLines emits a `+N -M path` header on its first line followed - // by every changed line. We pull the header out into the viewer chrome so - // the body is purely scrollable diff content; this also means we don't - // double-render the path. - const rendered = renderDiffLines( +function buildDiffBody(block: DiffDisplayBlock): BuiltBody { + // renderDiffLinesClustered emits a `+N -M path` header on its first line + // followed by every changed line plus surrounding context. We pull the + // header out into the viewer chrome so the body is purely scrollable diff + // content; this also means we don't double-render the path. + const rendered = renderDiffLinesClustered( block.old_text, block.new_text, block.path, - colors, - false, - block.old_start ?? 1, - block.new_start ?? 1, + { + contextLines: 3, + oldStart: block.old_start ?? 1, + newStart: block.new_start ?? 1, + }, ); const [header = '', ...rest] = rendered; return { lines: rest, title: stripLeadingSpace(header) }; } -function buildFileContentBody(block: FileContentDisplayBlock, colors: ColorPalette): BuiltBody { +function buildFileContentBody(block: FileContentDisplayBlock): BuiltBody { const lang = block.language ?? langFromPath(block.path); const highlighted = highlightLines(block.content, lang); - const gutter = chalk.hex(colors.diffGutter); const lines = highlighted.map( - (line, i) => gutter(String(i + 1).padStart(4) + ' ') + line, + (line, i) => currentTheme.fg('diffGutter', String(i + 1).padStart(4) + ' ') + line, ); - const title = chalk.hex(colors.textStrong)(block.path); + const title = currentTheme.fg('textStrong', block.path); return { lines, title }; } diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index a07b4a3b5..b6b74fe5b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -15,10 +15,9 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { ColorPalette } from '#/tui/theme/colors'; +} from '@moonshot-ai/pi-tui'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme, type ColorToken } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -31,26 +30,31 @@ export interface ChoiceOption { readonly tone?: 'danger'; /** Optional explanatory text shown below the label. */ readonly description?: string | undefined; + /** Color token applied to the description while this option is selected, drawing + * attention to important details. Falls back to `textMuted` when unset or not selected. */ + readonly descriptionTone?: ColorToken; } export interface ChoicePickerOptions { readonly title: string; readonly hint?: string; - readonly formatHint?: (text: string, colors: ColorPalette) => string; + readonly formatHint?: (text: string) => string; readonly notice?: string; + /** Color tone for the notice line. Defaults to 'success'. */ + readonly noticeTone?: 'success' | 'warning'; readonly options: readonly ChoiceOption[]; readonly currentValue?: string; - readonly colors: ColorPalette; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate. */ readonly pageSize?: number; readonly onSelect: (value: string) => void; + /** When provided, Alt+S invokes this with the selected value instead of + * onSelect — used to apply the choice to the current session only. */ + readonly onSessionOnlySelect?: (value: string) => void; readonly onCancel: () => void; } -const CURRENT_MARK = '← current'; - function wrapDescription(text: string, width: number): string[] { const maxWidth = Math.max(1, width); const words = text @@ -98,6 +102,11 @@ export class ChoicePickerComponent extends Container implements Focusable { this.opts.onCancel(); return; } + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const chosen = this.list.selected(); + if (chosen !== undefined) this.opts.onSessionOnlySelect(chosen.value); + return; + } // Left/Right page through the list (this picker has no horizontal control). if (matchesKey(data, Key.left)) { this.list.pageUp(); @@ -107,11 +116,10 @@ export class ChoicePickerComponent extends Container implements Focusable { this.list.pageDown(); return; } - if ( - matchesKey(data, Key.enter) || - matchesKey(data, Key.space) || - printableChar(data) === ' ' - ) { + // Enter always selects. Space selects too — but only when the list is not + // searchable; in a searchable list a space must reach the query instead. + const isSpace = matchesKey(data, Key.space) || printableChar(data) === ' '; + if (matchesKey(data, Key.enter) || (isSpace && this.opts.searchable !== true)) { const chosen = this.list.selected(); if (chosen !== undefined) this.opts.onSelect(chosen.value); return; @@ -120,54 +128,67 @@ export class ChoicePickerComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const searchable = this.opts.searchable === true; const view = this.list.view(); const options = view.items; + // Header mirrors the model dialog (see model-selector.ts): border, title + // with a "(type to search)" suffix until you type, the hint, a blank, then + // the search line. Key vocabulary is lowercase to match every list dialog. const navParts = ['↑↓ navigate']; if (view.page.pageCount > 1) navParts.push('←→ page'); navParts.push('Enter select', 'Esc cancel'); const hint = this.opts.hint ?? navParts.join(' · '); const titleSuffix = - searchable && view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintLines = hint.split(/\r?\n/); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(` ${this.opts.title}`) + titleSuffix, + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix, ]; - if (searchable && view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query)); + for (const hintLine of hintLines) { + lines.push( + this.opts.formatHint === undefined + ? currentTheme.fg('textMuted', ` ${hintLine}`) + : this.opts.formatHint(` ${hintLine}`), + ); } - lines.push( - this.opts.formatHint === undefined - ? chalk.hex(colors.textMuted)(` ${hint}`) - : this.opts.formatHint(` ${hint}`, colors), - ); if (this.opts.notice !== undefined) { - lines.push(chalk.hex(colors.success)(` ${this.opts.notice}`)); + const tone = this.opts.noticeTone ?? 'success'; + const noticeWidth = Math.max(1, width - 1); + for (const noticeLine of this.opts.notice.split(/\r?\n/)) { + for (const wrapped of wrapDescription(noticeLine, noticeWidth)) { + lines.push(currentTheme.fg(tone, ` ${wrapped}`)); + } + } } lines.push(''); + if (searchable && view.query.length > 0) { + lines.push(currentTheme.fg('primary', ` Search: `) + currentTheme.fg('text', view.query)); + } if (options.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('textMuted', ' No matches')); } for (let i = view.page.start; i < view.page.end; i++) { const opt = options[i]!; const isSelected = i === view.selectedIndex; const isCurrent = opt.value === this.opts.currentValue; - const pointer = isSelected ? '❯' : ' '; - const labelStyle = optionLabelStyle(opt, isSelected, colors); - let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); + const pointer = isSelected ? SELECT_POINTER : ' '; + const labelStyle = optionLabelStyle(opt, isSelected); + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); line += labelStyle(opt.label); if (isCurrent) { - line += ' ' + chalk.hex(colors.success)(CURRENT_MARK); + line += ' ' + currentTheme.fg('success', CURRENT_MARK); } lines.push(line); if (opt.description !== undefined && opt.description.length > 0) { const descriptionWidth = Math.max(1, width - 4); + const descriptionColor = + isSelected && opt.descriptionTone !== undefined ? opt.descriptionTone : 'textMuted'; for (const descLine of wrapDescription(opt.description, descriptionWidth)) { - lines.push(chalk.hex(colors.textMuted)(` ${descLine}`)); + lines.push(currentTheme.fg(descriptionColor, ` ${descLine}`)); } } } @@ -175,12 +196,12 @@ export class ChoicePickerComponent extends Container implements Focusable { lines.push(''); if (view.page.pageCount > 1) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg('textMuted', ` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`, ), ); } - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } } @@ -188,10 +209,13 @@ export class ChoicePickerComponent extends Container implements Focusable { function optionLabelStyle( option: ChoiceOption, selected: boolean, - colors: ColorPalette, ): (text: string) => string { if (option.tone === 'danger') { - return selected ? chalk.hex(colors.error).bold : chalk.hex(colors.error); + return selected + ? (text) => currentTheme.boldFg('error', text) + : (text) => currentTheme.fg('error', text); } - return selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + return selected + ? (text) => currentTheme.boldFg('primary', text) + : (text) => currentTheme.fg('text', text); } diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts index 6a55ede98..9ade9350c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/compaction.ts +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -13,50 +13,90 @@ * reads the same "work in progress" signal across the UI. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; -import type { TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; const BLINK_INTERVAL = 500; export class CompactionComponent extends Container { - private readonly colors: ColorPalette; private readonly ui: TUI | undefined; private readonly headerText: Text; + private instructionText: Text | undefined; + private readonly instruction: string | undefined; + private readonly tip: string | undefined; private blinkOn = true; private blinkTimer: ReturnType<typeof setInterval> | null = null; private done = false; private canceled = false; private tokensBefore: number | undefined; private tokensAfter: number | undefined; + private summary: string | undefined; + private summaryText: Text | undefined; + private expanded = false; - constructor(colors: ColorPalette, ui?: TUI, instruction?: string | undefined) { + constructor(ui?: TUI, instruction?: string | undefined, tip?: string) { super(); - this.colors = colors; this.ui = ui; + this.instruction = instruction; + this.tip = tip; // Top margin so the block isn't glued to the previous transcript // entry (status line, tool result, etc.). this.addChild(new Spacer(1)); this.headerText = new Text(this.buildHeader(), 0, 0); this.addChild(this.headerText); - if (instruction !== undefined) { - this.addChild(new Text(chalk.dim(` ${instruction}`), 0, 0)); - } + this.addInstructionChild(); this.startBlink(); } - markDone(tokensBefore?: number, tokensAfter?: number): void { + private addInstructionChild(): void { + if (this.instruction !== undefined) { + this.instructionText = new Text(currentTheme.dim(` ${this.instruction}`), 0, 0); + this.addChild(this.instructionText); + } + } + + private removeInstructionChild(): void { + if (this.instructionText === undefined) return; + const index = this.children.indexOf(this.instructionText); + if (index !== -1) { + this.children.splice(index, 1); + } + this.instructionText = undefined; + } + + override invalidate(): void { + // Repaint the header with the active palette (it caches ANSI codes). + this.headerText.setText(this.buildHeader()); + // Rebuild instruction and summary text with fresh theme colours, preserving + // header → instruction → summary child order. + const expanded = this.expanded; + this.removeInstructionChild(); + if (expanded) { + this.removeSummaryChild(); + } + this.addInstructionChild(); + if (expanded) { + this.addSummaryChild(); + } + super.invalidate(); + } + + markDone(tokensBefore?: number, tokensAfter?: number, summary?: string): void { if (this.done || this.canceled) return; this.done = true; this.tokensBefore = tokensBefore; this.tokensAfter = tokensAfter; + this.summary = summary; this.stopBlink(); this.headerText.setText(this.buildHeader()); + if (this.expanded) { + this.addSummaryChild(); + } this.ui?.requestRender(); } @@ -68,28 +108,66 @@ export class CompactionComponent extends Container { this.ui?.requestRender(); } + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + if (expanded) { + this.addSummaryChild(); + } else { + this.removeSummaryChild(); + } + this.headerText.setText(this.buildHeader()); + this.ui?.requestRender(); + } + + private addSummaryChild(): void { + if (this.summaryText !== undefined || this.summary === undefined || this.summary.length === 0) { + return; + } + const indentedSummary = this.summary + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + this.summaryText = new Text(currentTheme.dim(indentedSummary), 0, 0); + this.addChild(this.summaryText); + } + + private removeSummaryChild(): void { + if (this.summaryText === undefined) return; + const index = this.children.indexOf(this.summaryText); + if (index !== -1) { + this.children.splice(index, 1); + } + this.summaryText = undefined; + } + dispose(): void { this.stopBlink(); } private buildHeader(): string { if (this.done) { - const bullet = chalk.hex(this.colors.success)(STATUS_BULLET); - const label = chalk.hex(this.colors.success).bold('Compaction complete'); + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('success', 'Compaction complete'); const detail = this.tokensBefore !== undefined && this.tokensAfter !== undefined - ? chalk.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) + ? currentTheme.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) : ''; - return `${bullet}${label}${detail}`; + const shortcutHint = + this.summary !== undefined && this.summary.length > 0 + ? currentTheme.dim(` (Ctrl-O to ${this.expanded ? 'hide' : 'show'} compaction summary)`) + : ''; + return `${bullet}${label}${detail}${shortcutHint}`; } if (this.canceled) { - const bullet = chalk.hex(this.colors.warning)(STATUS_BULLET); - const label = chalk.hex(this.colors.warning).bold('Compaction cancelled'); + const bullet = currentTheme.fg('warning', STATUS_BULLET); + const label = currentTheme.boldFg('warning', 'Compaction cancelled'); return `${bullet}${label}`; } - const bullet = this.blinkOn ? chalk.hex(this.colors.roleAssistant)(STATUS_BULLET) : ' '; - const label = chalk.hex(this.colors.primary).bold('Compacting context...'); - return `${bullet}${label}`; + const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; + const label = currentTheme.boldFg('primary', 'Compacting context...'); + const tip = this.tip ? currentTheme.fg('textDim', ` · Tip: ${this.tip}`) : ''; + return `${bullet}${label}${tip}`; } private startBlink(): void { diff --git a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts index 9f10471ac..aa4aa910b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts +++ b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts @@ -5,7 +5,8 @@ * * Geometry mirrors `ApiKeyInputDialogComponent` so the chrome stays * consistent with the API-key login flow. Two fields, switched with - * Tab / Shift-Tab; Enter on the last field submits, Esc cancels. + * Tab / Shift-Tab / Up / Down; Enter advances to the next field (and submits + * on the last field), Esc cancels. Both fields are required. */ import { @@ -16,10 +17,9 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface CustomRegistryImportValue { readonly url: string; @@ -34,7 +34,8 @@ const TITLE = 'Import custom provider registry'; const SUBTITLE_DEFAULT = 'Paste an api.json URL and its Bearer token.'; const SUBTITLE_URL_EMPTY = 'Registry URL cannot be empty.'; const SUBTITLE_TOKEN_EMPTY = 'Bearer token cannot be empty.'; -const FOOTER = 'Tab to switch · Enter to submit · Esc to cancel'; +const FOOTER_NOT_LAST = 'Tab / ↑↓ to switch · Enter for next field · Esc to cancel'; +const FOOTER_LAST = 'Tab / ↑↓ to switch · Enter to submit · Esc to cancel'; type FieldId = 'url' | 'token'; @@ -52,7 +53,7 @@ function maskInputLine(raw: string): string { // Protect ANSI escape sequences (reverse-video cursor, IME marker, etc.) // while masking every other visible character. - const parts = content.split(/((?:\[[0-9;]*m|_pi:c))/); + const parts = content.split(/(\u001B(?:\[[0-9;]*m|_pi:c\u0007))/); const maskedContent = parts .map((part, index) => { if (index % 2 === 1) return part; // ANSI sequence @@ -69,25 +70,24 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo private readonly urlInput = new Input(); private readonly tokenInput = new Input(); private readonly onDone: (result: CustomRegistryImportResult) => void; - private readonly colors: ColorPalette; private activeField: FieldId = 'url'; private done = false; private hint: 'none' | 'url-empty' | 'token-empty' = 'none'; constructor( onDone: (result: CustomRegistryImportResult) => void, - colors: ColorPalette, defaultUrl: string = '', ) { super(); this.onDone = onDone; - this.colors = colors; if (defaultUrl.length > 0) this.urlInput.setValue(defaultUrl); + // Enter on the URL field advances to the token field; Enter on the token + // (last) field submits. this.urlInput.onSubmit = () => { - this.handleEnter(); + this.focusField('token'); }; this.tokenInput.onSubmit = () => { - this.handleEnter(); + this.handleSubmit(); }; } @@ -106,6 +106,14 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo this.toggleField(); return; } + if (matchesKey(data, Key.down)) { + this.focusField('token'); + return; + } + if (matchesKey(data, Key.up)) { + this.focusField('url'); + return; + } if (this.hint !== 'none') { this.hint = 'none'; @@ -129,31 +137,35 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo this.urlInput.focused = dialogActive && this.activeField === 'url'; this.tokenInput.focused = dialogActive && this.activeField === 'token'; - const safeWidth = Math.max(36, width); - const innerWidth = Math.max(10, safeWidth - 4); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(TITLE); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', TITLE); const subtitleText = this.hint === 'url-empty' ? SUBTITLE_URL_EMPTY : this.hint === 'token-empty' ? SUBTITLE_TOKEN_EMPTY : SUBTITLE_DEFAULT; - const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); - const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + const subtitleStyled = currentTheme.fg('textDim', subtitleText); + const footerStyled = currentTheme.fg( + 'textDim', + this.activeField === 'url' ? FOOTER_NOT_LAST : FOOTER_LAST, + ); const urlLabelText = 'Registry URL'; const tokenLabelText = 'Bearer token'; const urlLabelStyled = this.activeField === 'url' - ? chalk.bold.hex(this.colors.accent)(urlLabelText) - : chalk.hex(this.colors.textDim)(urlLabelText); + ? currentTheme.boldFg('accent', urlLabelText) + : currentTheme.fg('textDim', urlLabelText); const tokenLabelStyled = this.activeField === 'token' - ? chalk.bold.hex(this.colors.accent)(tokenLabelText) - : chalk.hex(this.colors.textDim)(tokenLabelText); + ? currentTheme.boldFg('accent', tokenLabelText) + : currentTheme.fg('textDim', tokenLabelText); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); @@ -178,6 +190,10 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo footerLine, ]; + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; + } + const lines: string[] = [ '', border('╭' + '─'.repeat(safeWidth - 2) + '╮'), @@ -194,15 +210,19 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } private toggleField(): void { - this.hint = 'none'; - this.activeField = this.activeField === 'url' ? 'token' : 'url'; + this.focusField(this.activeField === 'url' ? 'token' : 'url'); } - private handleEnter(): void { + private focusField(field: FieldId): void { + this.hint = 'none'; + this.activeField = field; + } + + private handleSubmit(): void { if (this.done) return; const urlValue = this.urlInput.getValue().trim(); @@ -213,6 +233,11 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo this.activeField = 'url'; return; } + if (tokenValue.length === 0) { + this.hint = 'token-empty'; + this.activeField = 'token'; + return; + } this.done = true; this.onDone({ kind: 'ok', value: { url: urlValue, apiKey: tokenValue } }); diff --git a/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts index 24467dd05..9e98a457b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts @@ -1,7 +1,5 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const EDITOR_OPTIONS: readonly ChoiceOption[] = [ { value: 'code --wait', label: 'VS Code (code --wait)' }, { value: 'vim', label: 'Vim' }, @@ -12,7 +10,6 @@ const EDITOR_OPTIONS: readonly ChoiceOption[] = [ export interface EditorSelectorOptions { readonly currentValue: string; - readonly colors: ColorPalette; readonly onSelect: (value: string) => void; readonly onCancel: () => void; } @@ -23,7 +20,6 @@ export class EditorSelectorComponent extends ChoicePickerComponent { title: 'Select external editor', options: [...EDITOR_OPTIONS], currentValue: opts.currentValue, - colors: opts.colors, onSelect: opts.onSelect, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts new file mode 100644 index 000000000..2678899ad --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts @@ -0,0 +1,94 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; + +import { currentTheme } from '#/tui/theme'; + +import { effortLabel } from './model-selector'; + +export interface EffortSelectorOptions { + readonly title?: string; + /** Selectable thinking efforts for the current model (e.g. ["off","low","high","max"]). */ + readonly efforts: readonly ThinkingEffort[]; + /** Currently active effort (highlighted). */ + readonly currentValue: ThinkingEffort; + readonly onSelect: (effort: ThinkingEffort) => void; + /** When provided, Alt+S applies the choice to the current session only. */ + readonly onSessionOnlySelect?: (effort: ThinkingEffort) => void; + readonly onCancel: () => void; +} + +/** + * Horizontal segmented picker for the `/effort` command. + * + * Mirrors the thinking control rendered under `/model` (see + * `renderThinkingControl` in model-selector.ts): a single row of segments, + * the active one wrapped in `[ ]`. ←/→ step the active segment, Enter + * commits, and Alt+S (when provided) applies session-only. + */ +export class EffortSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: EffortSelectorOptions; + private activeIndex: number; + + constructor(opts: EffortSelectorOptions) { + super(); + this.opts = opts; + const idx = opts.efforts.indexOf(opts.currentValue); + this.activeIndex = Math.max(idx, 0); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.left)) { + this.activeIndex = Math.max(0, this.activeIndex - 1); + return; + } + if (matchesKey(data, Key.right)) { + this.activeIndex = Math.min(this.opts.efforts.length - 1, this.activeIndex + 1); + return; + } + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + this.opts.onSessionOnlySelect(this.opts.efforts[this.activeIndex]!); + return; + } + if (matchesKey(data, Key.enter)) { + this.opts.onSelect(this.opts.efforts[this.activeIndex]!); + return; + } + } + + override render(width: number): string[] { + const hintParts = ['←→ switch', 'Enter select']; + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${this.opts.title ?? 'Select thinking effort'}`), + currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), + '', + ]; + + const segments = this.opts.efforts.map((effort, index) => { + const label = effortLabel(effort); + return index === this.activeIndex + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); + }); + lines.push(` ${segments.join(' ')}`); + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts new file mode 100644 index 000000000..1e466f873 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts @@ -0,0 +1,233 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +const ELLIPSIS = '…'; + +export interface ExperimentalFeatureDraftChange { + readonly id: ExperimentalFeatureState['id']; + readonly enabled: boolean; +} + +export interface ExperimentsSelectorOptions { + readonly features: readonly ExperimentalFeatureState[]; + readonly onApply: (changes: readonly ExperimentalFeatureDraftChange[]) => void; + readonly onCancel: () => void; +} + +export class ExperimentsSelectorComponent extends Container implements Focusable { + focused = false; + + private readonly opts: ExperimentsSelectorOptions; + private readonly list: SearchableList<ExperimentalFeatureState>; + private readonly draft = new Map<ExperimentalFeatureState['id'], boolean>(); + + constructor(opts: ExperimentsSelectorOptions) { + super(); + this.opts = opts; + this.list = new SearchableList({ + items: opts.features, + toSearchText: (feature) => `${feature.title} ${feature.id} ${feature.description}`, + searchable: true, + }); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) return; + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.enter)) { + const changes = this.draftChanges(); + if (changes.length > 0) this.opts.onApply(changes); + return; + } + const decoded = printableChar(data); + if (matchesKey(data, Key.space) || decoded === ' ') { + const selected = this.list.selected(); + if (selected !== undefined) this.toggleDraft(selected); + return; + } + this.list.handleKey(data); + } + + override render(width: number): string[] { + const view = this.list.view(); + const titleSuffix = + view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintParts = ['↑↓ navigate']; + if (view.page.pageCount > 1) hintParts.push('PgUp/PgDn page'); + hintParts.push('Space toggle', 'Enter apply', 'Esc cancel'); + if (view.query.length > 0) hintParts.push('Backspace clear'); + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Experimental features') + titleSuffix, + currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), + '', + ]; + + if (view.query.length > 0) { + lines.push(currentTheme.fg('primary', ` Search: `) + currentTheme.fg('text', view.query)); + } + + if (view.items.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No matches')); + } + + for (let i = view.page.start; i < view.page.end; i++) { + const feature = view.items[i]!; + const selected = i === view.selectedIndex; + lines.push(...this.renderFeature(feature, selected, width)); + } + + lines.push(''); + if (view.query.length > 0) { + lines.push( + currentTheme.fg( + 'textMuted', + ` ${String(view.items.length)} / ${String(this.opts.features.length)}`, + ), + ); + } else if (view.page.end < view.items.length) { + lines.push( + currentTheme.fg( + 'textMuted', + ` ▼ ${String(view.items.length - view.page.end)} more`, + ), + ); + } + lines.push(this.renderApplyButton()); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private toggleDraft(feature: ExperimentalFeatureState): void { + if (isLocked(feature)) return; + + const enabled = !this.effectiveEnabled(feature); + if (enabled === feature.enabled) { + this.draft.delete(feature.id); + return; + } + this.draft.set(feature.id, enabled); + } + + private effectiveEnabled(feature: ExperimentalFeatureState): boolean { + return this.draft.get(feature.id) ?? feature.enabled; + } + + private isDraftChanged(feature: ExperimentalFeatureState): boolean { + return this.effectiveEnabled(feature) !== feature.enabled; + } + + private draftChanges(): ExperimentalFeatureDraftChange[] { + const changes: ExperimentalFeatureDraftChange[] = []; + for (const feature of this.opts.features) { + if (this.isDraftChanged(feature)) { + changes.push({ id: feature.id, enabled: this.effectiveEnabled(feature) }); + } + } + return changes; + } + + private renderApplyButton(): string { + const changes = this.draftChanges(); + const count = changes.length; + const label = '[ Apply changes and reload ]'; + const summary = + count === 0 ? 'no changes' : `${String(count)} ${count === 1 ? 'change' : 'changes'}`; + const button = count === 0 + ? currentTheme.fg('textDim', label) + : currentTheme.boldFg('primary', label); + const summaryText = count === 0 + ? currentTheme.fg('textMuted', summary) + : currentTheme.fg('success', summary); + return ` ${button} ${summaryText}`; + } + + private renderFeature( + feature: ExperimentalFeatureState, + selected: boolean, + width: number, + ): string[] { + const pointer = selected ? SELECT_POINTER : ' '; + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); + const label = selected ? currentTheme.boldFg('primary', feature.title) : currentTheme.fg('text', feature.title); + const enabled = this.effectiveEnabled(feature); + const status = enabled ? 'enabled' : 'disabled'; + const statusText = enabled ? currentTheme.fg('success', status) : currentTheme.fg('textDim', status); + const detail = this.isDraftChanged(feature) + ? `${featureDetail(feature)} · modified` + : featureDetail(feature); + const lines = [ + `${prefix}${label} ${statusText}`, + currentTheme.fg('textMuted', ` ${detail}`), + ]; + const descriptionWidth = Math.max(1, width - 4); + for (const line of wrapText(feature.description, descriptionWidth)) { + lines.push(currentTheme.fg('textMuted', ` ${line}`)); + } + return lines; + } +} + +function isLocked(feature: ExperimentalFeatureState): boolean { + return feature.source === 'env' || feature.source === 'master-env'; +} + +function featureDetail(feature: ExperimentalFeatureState): string { + const source = sourceLabel(feature); + if (feature.source === 'env' || feature.source === 'master-env') { + return `id ${feature.id} · ${source}`; + } + return `id ${feature.id} · ${source} · ${feature.env}`; +} + +function sourceLabel(feature: ExperimentalFeatureState): string { + switch (feature.source) { + case 'master-env': + return 'locked by KIMI_CODE_EXPERIMENTAL_FLAG'; + case 'env': + return `locked by ${feature.env}`; + case 'config': + return 'config'; + case 'default': + return 'default'; + } +} + +function wrapText(text: string, width: number): string[] { + const maxWidth = Math.max(1, width); + const words = text + .trim() + .split(/\s+/) + .filter((word) => word.length > 0); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (visibleWidth(candidate) <= maxWidth) { + current = candidate; + continue; + } + if (current.length > 0) lines.push(current); + current = visibleWidth(word) <= maxWidth ? word : truncateToWidth(word, maxWidth, ELLIPSIS); + } + + if (current.length > 0) lines.push(current); + return lines; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts index 1fb963625..c1f108dd7 100644 --- a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -5,6 +5,10 @@ * Geometry mirrors `DeviceCodeBox` so the chrome stays consistent with * the OAuth login flow. The box embeds a `pi-tui` Input for the actual * text entry; cursor visibility tracks the dialog's `focused` flag. + * + * This is stage 1 of the feedback flow: it collects the free-form text + * only. Whether to attach diagnostic logs / codebase is decided in a + * follow-up stage (see `promptFeedbackAttachment`). */ import { @@ -15,10 +19,8 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { ColorPalette } from '#/tui/theme/colors'; +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; export type FeedbackInputDialogResult = | { readonly kind: 'ok'; readonly value: string } @@ -34,14 +36,12 @@ export class FeedbackInputDialogComponent extends Container implements Focusable private readonly input = new Input(); private readonly onDone: (result: FeedbackInputDialogResult) => void; - private readonly colors: ColorPalette; private done = false; private emptyHinted = false; - constructor(onDone: (result: FeedbackInputDialogResult) => void, colors: ColorPalette) { + constructor(onDone: (result: FeedbackInputDialogResult) => void) { super(); this.onDone = onDone; - this.colors = colors; this.input.onSubmit = (value) => { this.submit(value); }; @@ -71,22 +71,35 @@ export class FeedbackInputDialogComponent extends Container implements Focusable override render(width: number): string[] { this.input.focused = this.focused && !this.done; - const safeWidth = Math.max(28, width); - const innerWidth = Math.max(10, safeWidth - 4); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(TITLE); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', TITLE); const subtitleText = this.emptyHinted ? SUBTITLE_EMPTY : SUBTITLE_DEFAULT; - const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); - const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + const subtitleStyled = currentTheme.fg('textDim', subtitleText); + const footerStyled = currentTheme.fg('textDim', FOOTER); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); const inputLine = this.input.render(innerWidth)[0] ?? '> '; - const contentLines: string[] = [titleLine, '', subtitleLine, '', inputLine, '', footerLine]; + const contentLines: string[] = [ + titleLine, + '', + subtitleLine, + '', + inputLine, + '', + footerLine, + ]; + + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; + } const lines: string[] = [ '', @@ -104,7 +117,7 @@ export class FeedbackInputDialogComponent extends Container implements Focusable lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } private submit(value: string): void { diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts new file mode 100644 index 000000000..b5c2e7ac1 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -0,0 +1,645 @@ +import { + Container, + Key, + matchesKey, + CURSOR_MARKER, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import type { + GoalQueueMoveDirection, + GoalQueueSnapshot, + UpcomingGoal, +} from '#/tui/goal-queue-store'; +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +const MAX_GOAL_OBJECTIVE_LENGTH = 4000; +const MAX_EDIT_INPUT_LINES = 8; +const ELLIPSIS = '…'; +const BRACKET_PASTE_START = '\u001B[200~'; +const BRACKET_PASTE_END = '\u001B[201~'; +const SHIFT_ENTER_LEGACY = '\u001B\r'; +const SHIFT_ENTER_CSI = '\u001B[13;2~'; +const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to strip pasted terminal control sequences +const ANSI_CSI = /\u001B\[[0-?]*[ -/]*[@-~]/g; + +export type GoalQueueManagerAction = + | { + readonly kind: 'move'; + readonly goalId: string; + readonly direction: GoalQueueMoveDirection; + } + | { readonly kind: 'edit'; readonly goalId: string } + | { readonly kind: 'delete'; readonly goalId: string }; + +export interface GoalQueueManagerOptions { + readonly goals: readonly UpcomingGoal[]; + readonly selectedGoalId?: string; + readonly pageSize?: number; + readonly onAction: ( + action: GoalQueueManagerAction, + ) => GoalQueueSnapshot | void | Promise<GoalQueueSnapshot | void>; + readonly onCancel: () => void; +} + +export type GoalQueueEditResult = + | { readonly kind: 'save'; readonly goalId: string; readonly objective: string } + | { readonly kind: 'cancel'; readonly goalId: string }; + +export interface GoalQueueEditDialogOptions { + readonly goal: UpcomingGoal; + readonly onDone: (result: GoalQueueEditResult) => void; +} + +export class GoalQueueManagerComponent extends Container implements Focusable { + focused = false; + + private readonly opts: GoalQueueManagerOptions; + private goals: readonly UpcomingGoal[]; + private list: SearchableList<UpcomingGoal>; + private movingGoalId: string | undefined; + private busy = false; + + constructor(opts: GoalQueueManagerOptions) { + super(); + this.opts = opts; + this.goals = opts.goals; + this.list = this.createList(opts.selectedGoalId); + } + + handleInput(data: string): void { + if (this.busy) return; + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + + const selected = this.selectedGoal(); + const decoded = printableChar(data); + if (matchesKey(data, Key.space) || decoded === ' ') { + this.movingGoalId = this.movingGoalId === selected?.id ? undefined : selected?.id; + return; + } + + if ((decoded === 'e' || decoded === 'E') && selected !== undefined) { + void this.opts.onAction({ kind: 'edit', goalId: selected.id }); + return; + } + + if ((decoded === 'd' || decoded === 'D') && selected !== undefined) { + void this.applyQueueAction({ kind: 'delete', goalId: selected.id }); + return; + } + + if (this.movingGoalId !== undefined) { + if (matchesKey(data, Key.up)) { + void this.applyQueueAction({ kind: 'move', goalId: this.movingGoalId, direction: 'up' }); + return; + } + if (matchesKey(data, Key.down)) { + void this.applyQueueAction({ kind: 'move', goalId: this.movingGoalId, direction: 'down' }); + return; + } + } + + if (this.list.handleKey(data)) return; + } + + override render(width: number): string[] { + const view = this.list.view(); + const hint = this.movingGoalId === undefined + ? '↑↓ navigate · Space select · E edit · D delete · Esc cancel' + : '↑↓ reorder · Space done · E edit · D delete · Esc cancel'; + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Upcoming goals'), + currentTheme.fg('textMuted', ` ${hint}`), + '', + ]; + + if (this.goals.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No upcoming goals.')); + } else { + for (let i = view.page.start; i < view.page.end; i++) { + const goal = view.items[i]; + if (goal === undefined) continue; + lines.push(this.renderGoal(goal, i, i === view.selectedIndex, width)); + } + + const below = view.items.length - view.page.end; + if (below > 0) { + lines.push(''); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); + } + } + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private renderGoal(goal: UpcomingGoal, index: number, selected: boolean, width: number): string { + const moving = goal.id === this.movingGoalId; + const pointer = selected ? SELECT_POINTER : ' '; + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); + const labelPrefix = `${String(index + 1)}. `; + const stateLabel = moving ? ' selected' : ''; + const labelWidth = visibleWidth(labelPrefix); + const stateWidth = visibleWidth(stateLabel); + const objectiveWidth = Math.max(1, width - 5 - labelWidth - stateWidth); + const objective = truncateToWidth( + formatListObjective(goal.objective), + objectiveWidth, + ELLIPSIS, + ); + const textStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); + let line = prefix + textStyle(labelPrefix + objective); + if (moving) line += currentTheme.fg('success', stateLabel); + return line; + } + + private selectedGoal(): UpcomingGoal | undefined { + return this.list.selected(); + } + + private async applyQueueAction(action: Exclude<GoalQueueManagerAction, { kind: 'edit' }>) { + this.busy = true; + try { + const result = await this.opts.onAction(action); + if (result !== undefined) { + const selectedGoalId = action.kind === 'delete' ? undefined : action.goalId; + this.goals = result.goals; + if (!this.goals.some((goal) => goal.id === this.movingGoalId)) { + this.movingGoalId = undefined; + } + this.list = this.createList(selectedGoalId ?? this.movingGoalId); + } + } finally { + this.busy = false; + this.invalidate(); + } + } + + private createList(selectedGoalId?: string): SearchableList<UpcomingGoal> { + const initialIndex = this.goals.findIndex((goal) => goal.id === selectedGoalId); + return new SearchableList({ + items: this.goals, + toSearchText: (goal) => goal.objective, + pageSize: this.opts.pageSize, + initialIndex: initialIndex === -1 ? 0 : initialIndex, + searchable: false, + }); + } +} + +export class GoalQueueEditDialogComponent extends Container implements Focusable { + focused = false; + + private readonly input = new MultilineGoalInput(); + private readonly opts: GoalQueueEditDialogOptions; + private done = false; + private error: string | undefined; + + constructor(opts: GoalQueueEditDialogOptions) { + super(); + this.opts = opts; + this.input.setValue(opts.goal.objective); + this.input.onSubmit = (value) => { + this.submit(value); + }; + } + + handleInput(data: string): void { + if (this.done) return; + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) + ) { + this.done = true; + this.opts.onDone({ kind: 'cancel', goalId: this.opts.goal.id }); + return; + } + this.error = undefined; + this.input.handleInput(data); + } + + override invalidate(): void { + super.invalidate(); + this.input.invalidate(); + } + + override render(width: number): string[] { + this.input.focused = this.focused && !this.done; + + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); + const pad = ' '; + const border = (s: string): string => currentTheme.fg('primary', s); + const title = truncateToWidth( + currentTheme.boldFg('textStrong', 'Edit upcoming goal'), + innerWidth, + ELLIPSIS, + ); + const subtitle = truncateToWidth( + currentTheme.fg( + this.error === undefined ? 'textDim' : 'warning', + this.error ?? 'Update the queued objective.', + ), + innerWidth, + ELLIPSIS, + ); + const inputLines = this.input.render(innerWidth); + const footer = truncateToWidth( + currentTheme.fg('textDim', 'Enter submit · Shift-Enter/Ctrl-J newline · Esc cancel'), + innerWidth, + ELLIPSIS, + ); + const contentLines = [title, '', subtitle, '', ...inputLines, '', footer]; + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS))]; + } + + const lines = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + for (const content of contentLines) { + const rightPad = Math.max(0, innerWidth - visibleWidth(content)); + lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS)); + } + + private submit(value: string): void { + const objective = value.trim(); + if (objective.length === 0) { + this.error = 'Goal objective cannot be empty.'; + return; + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + this.error = `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters.`; + return; + } + this.opts.onDone({ kind: 'save', goalId: this.opts.goal.id, objective }); + } +} + +class MultilineGoalInput { + focused = false; + onSubmit?: (value: string) => void; + + private value = ''; + private cursor = 0; + private pasteBuffer: string | undefined; + + getValue(): string { + return this.value; + } + + setValue(value: string): void { + this.value = normalizeNewlines(value); + this.cursor = this.value.length; + } + + handleInput(data: string): void { + if (this.handleBracketedPaste(data)) return; + + if (isNewlineInput(data)) { + this.insert('\n'); + return; + } + + if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { + this.onSubmit?.(this.value); + return; + } + + if (matchesKey(data, Key.backspace)) { + this.deleteBeforeCursor(); + return; + } + + if (matchesKey(data, Key.delete)) { + this.deleteAfterCursor(); + return; + } + + if (matchesKey(data, Key.left)) { + this.cursor = previousGraphemeStart(this.value, this.cursor); + return; + } + + if (matchesKey(data, Key.right)) { + this.cursor = nextGraphemeEnd(this.value, this.cursor); + return; + } + + if (matchesKey(data, Key.up)) { + this.moveVertical(-1); + return; + } + + if (matchesKey(data, Key.down)) { + this.moveVertical(1); + return; + } + + if (matchesKey(data, Key.home) || matchesKey(data, Key.ctrl('a'))) { + this.cursor = this.currentLineStart(); + return; + } + + if (matchesKey(data, Key.end) || matchesKey(data, Key.ctrl('e'))) { + this.cursor = this.currentLineEnd(); + return; + } + + const decoded = printableChar(data); + if (isPrintableText(decoded)) { + this.insert(decoded); + } + } + + invalidate(): void { + // No cached layout. + } + + render(width: number): string[] { + const safeWidth = Math.max(4, width); + const logicalLines = this.value.split('\n'); + const cursor = this.cursorLocation(); + const range = visibleLineRange(logicalLines.length, cursor.line); + const rendered: string[] = []; + + if (range.start > 0) { + rendered.push(padInputLine(` ${ELLIPSIS} ${String(range.start)} previous`, safeWidth)); + } + + for (let lineIndex = range.start; lineIndex < range.end; lineIndex++) { + const line = logicalLines[lineIndex] ?? ''; + const prefix = lineIndex === 0 ? '> ' : ' '; + rendered.push( + lineIndex === cursor.line + ? renderCursorLine(line, cursor.column, prefix, safeWidth, this.focused) + : renderTextLine(line, prefix, safeWidth), + ); + } + + const remaining = logicalLines.length - range.end; + if (remaining > 0) { + rendered.push(padInputLine(` ${ELLIPSIS} ${String(remaining)} more`, safeWidth)); + } + + return rendered; + } + + private insert(text: string): void { + const normalized = normalizeNewlines(text); + this.value = + this.value.slice(0, this.cursor) + normalized + this.value.slice(this.cursor); + this.cursor += normalized.length; + } + + private deleteBeforeCursor(): void { + if (this.cursor === 0) return; + const start = previousGraphemeStart(this.value, this.cursor); + this.value = this.value.slice(0, start) + this.value.slice(this.cursor); + this.cursor = start; + } + + private deleteAfterCursor(): void { + if (this.cursor >= this.value.length) return; + const end = nextGraphemeEnd(this.value, this.cursor); + this.value = this.value.slice(0, this.cursor) + this.value.slice(end); + } + + private moveVertical(delta: -1 | 1): void { + const starts = lineStarts(this.value); + const location = this.cursorLocation(starts); + const targetLine = location.line + delta; + if (targetLine < 0 || targetLine >= starts.length) return; + + const targetStart = starts[targetLine] ?? 0; + const targetEnd = lineEndForStart(this.value, starts, targetLine); + this.cursor = Math.min(targetStart + location.column, targetEnd); + } + + private currentLineStart(): number { + return this.value.lastIndexOf('\n', Math.max(0, this.cursor - 1)) + 1; + } + + private currentLineEnd(): number { + return lineEndAt(this.value, this.cursor); + } + + private cursorLocation(starts = lineStarts(this.value)): { line: number; column: number } { + let line = 0; + for (let i = 0; i < starts.length; i++) { + const start = starts[i] ?? 0; + if (start > this.cursor) break; + line = i; + } + const lineStart = starts[line] ?? 0; + return { line, column: this.cursor - lineStart }; + } + + private handleBracketedPaste(data: string): boolean { + if (this.pasteBuffer !== undefined) { + this.appendPasteChunk(data); + return true; + } + + const start = data.indexOf(BRACKET_PASTE_START); + if (start === -1) return false; + + this.pasteBuffer = ''; + const before = data.slice(0, start); + if (isPrintableText(before)) this.insert(before); + this.appendPasteChunk(data.slice(start + BRACKET_PASTE_START.length)); + return true; + } + + private appendPasteChunk(data: string): void { + if (this.pasteBuffer === undefined) return; + + this.pasteBuffer += data; + const end = this.pasteBuffer.indexOf(BRACKET_PASTE_END); + if (end === -1) return; + + const pasted = this.pasteBuffer.slice(0, end); + const remaining = this.pasteBuffer.slice(end + BRACKET_PASTE_END.length); + this.pasteBuffer = undefined; + this.insert(sanitizePastedText(pasted)); + if (remaining.length > 0) this.handleInput(remaining); + } +} + +function isNewlineInput(data: string): boolean { + return ( + data === '\n' || + data === SHIFT_ENTER_LEGACY || + data === SHIFT_ENTER_CSI || + matchesKey(data, Key.ctrl('j')) + ); +} + +function normalizeNewlines(text: string): string { + return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); +} + +function formatListObjective(objective: string): string { + return objective.replaceAll(/\s+/g, ' ').trim(); +} + +function sanitizePastedText(text: string): string { + const normalized = normalizeNewlines(text).replaceAll(ANSI_CSI, ''); + let out = ''; + for (let i = 0; i < normalized.length;) { + const code = normalized.codePointAt(i); + if (code === undefined) break; + const char = String.fromCodePoint(code); + if (char === '\n' || isPrintableText(char)) { + out += char; + } + i += code > 0xffff ? 2 : 1; + } + return out; +} + +function isPrintableText(text: string): boolean { + if (text.length === 0) return false; + for (let i = 0; i < text.length;) { + const code = text.codePointAt(i); + if (code === undefined) return false; + if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) return false; + i += code > 0xffff ? 2 : 1; + } + return true; +} + +function lineStarts(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + if (text[i] === '\n') starts.push(i + 1); + } + return starts; +} + +function lineEndAt(text: string, offset: number): number { + const end = text.indexOf('\n', offset); + return end === -1 ? text.length : end; +} + +function lineEndForStart(text: string, starts: readonly number[], line: number): number { + const nextStart = starts[line + 1]; + return nextStart === undefined ? text.length : nextStart - 1; +} + +function previousGraphemeStart(text: string, offset: number): number { + if (offset <= 0) return 0; + let previous = 0; + for (const segment of SEGMENTER.segment(text.slice(0, offset))) { + previous = segment.index; + } + return previous; +} + +function nextGraphemeEnd(text: string, offset: number): number { + if (offset >= text.length) return text.length; + const segment = SEGMENTER.segment(text.slice(offset))[Symbol.iterator]().next().value; + return segment === undefined ? text.length : offset + segment.segment.length; +} + +function visibleLineRange(totalLines: number, cursorLine: number): { start: number; end: number } { + if (totalLines <= MAX_EDIT_INPUT_LINES) return { start: 0, end: totalLines }; + + const half = Math.floor(MAX_EDIT_INPUT_LINES / 2); + const start = Math.min( + Math.max(0, cursorLine - half), + Math.max(0, totalLines - MAX_EDIT_INPUT_LINES), + ); + return { start, end: start + MAX_EDIT_INPUT_LINES }; +} + +function renderTextLine(line: string, prefix: string, width: number): string { + const prefixWidth = visibleWidth(prefix); + const textWidth = Math.max(1, width - prefixWidth); + const text = truncateToWidth(line, textWidth, ELLIPSIS); + return padInputLine(prefix + text, width); +} + +function renderCursorLine( + line: string, + column: number, + prefix: string, + width: number, + focused: boolean, +): string { + const prefixWidth = visibleWidth(prefix); + const textWidth = Math.max(1, width - prefixWidth); + const cursorEnd = nextGraphemeEnd(line, column); + const before = line.slice(0, column); + const cursorText = line.slice(column, cursorEnd) || ' '; + const after = line.slice(cursorEnd); + const cursorWidth = Math.max(1, visibleWidth(cursorText)); + const beforeWidth = Math.max(0, textWidth - cursorWidth); + const beforeView = takeEndByWidth(before, beforeWidth); + const afterView = takeStartByWidth( + after, + Math.max(0, textWidth - visibleWidth(beforeView) - cursorWidth), + ); + const marker = focused ? CURSOR_MARKER : ''; + return padInputLine( + prefix + beforeView + marker + chalk.inverse(cursorText) + afterView, + width, + ); +} + +function takeStartByWidth(text: string, width: number): string { + let out = ''; + let used = 0; + for (const segment of SEGMENTER.segment(text)) { + const segmentWidth = visibleWidth(segment.segment); + if (used + segmentWidth > width) break; + out += segment.segment; + used += segmentWidth; + } + return out; +} + +function takeEndByWidth(text: string, width: number): string { + let out = ''; + let used = 0; + const segments = [...SEGMENTER.segment(text)]; + for (let i = segments.length - 1; i >= 0; i--) { + const segment = segments[i]; + if (segment === undefined) continue; + const segmentWidth = visibleWidth(segment.segment); + if (used + segmentWidth > width) break; + out = segment.segment + out; + used += segmentWidth; + } + return out; +} + +function padInputLine(line: string, width: number): string { + return line + ' '.repeat(Math.max(0, width - visibleWidth(line))); +} diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts new file mode 100644 index 000000000..e60d85ce0 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -0,0 +1,93 @@ +import { + StartPermissionPromptComponent, + type StartPermissionOption, +} from './start-permission-prompt'; + +export type GoalStartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; + +export interface GoalStartPermissionPromptOptions { + readonly mode: 'manual' | 'yolo'; + readonly onSelect: (choice: GoalStartPermissionChoice) => void; + readonly onCancel: () => void; +} + +export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ + { + value: 'auto', + label: 'Switch to Auto and start', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + value: 'yolo', + label: 'Switch to YOLO and start', + description: + 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + }, + { + value: 'manual', + label: 'Start in Manual', + description: + 'Keep approvals on. Kimi Code will ask before risky actions, so the goal may stop and wait for you.', + }, + { + value: 'cancel', + label: 'Do not start', + description: 'Return to the input box with your goal command.', + }, +]; + +export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [ + { + value: 'auto', + label: 'Switch to Auto and start', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + value: 'yolo', + label: 'Keep YOLO and start', + description: + 'Tools and plan changes stay approved automatically. Kimi Code may still ask you questions.', + }, + { + value: 'cancel', + label: 'Do not start', + description: 'Return to the input box with your goal command.', + }, +]; + +export function goalStartOptions(mode: 'manual' | 'yolo'): readonly StartPermissionOption[] { + return mode === 'yolo' ? GOAL_START_YOLO_OPTIONS : GOAL_START_MANUAL_OPTIONS; +} + +const MANUAL_OPTIONS = GOAL_START_MANUAL_OPTIONS; + +const YOLO_OPTIONS = GOAL_START_YOLO_OPTIONS; + +const MANUAL_NOTICE_LINES = [ + 'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', + 'Manual mode is not suitable for unattended goal work.', + 'You can go back without losing your command.', +] as const; + +const YOLO_NOTICE_LINES = [ + 'YOLO mode approves tools and plan changes automatically.', + 'YOLO mode can still stop for questions.', + 'Switch to Auto if you want questions skipped during goal work.', +] as const; + +export class GoalStartPermissionPromptComponent extends StartPermissionPromptComponent { + constructor(opts: GoalStartPermissionPromptOptions) { + super({ + title: + opts.mode === 'yolo' + ? 'Start a goal in YOLO mode?' + : 'Start a goal with approvals on?', + noticeLines: opts.mode === 'yolo' ? YOLO_NOTICE_LINES : MANUAL_NOTICE_LINES, + options: opts.mode === 'yolo' ? YOLO_OPTIONS : MANUAL_OPTIONS, + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts index 1c26dec34..10fd5d5fe 100644 --- a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts @@ -15,10 +15,8 @@ import { decodeKittyPrintable, type Focusable, truncateToWidth, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { ColorPalette } from '#/tui/theme/colors'; +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; export interface KeyboardShortcut { readonly keys: string; @@ -34,8 +32,9 @@ export interface HelpPanelCommand { /** Static list — keep in sync with the global editor bindings. */ export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ { keys: 'Shift-Tab', description: 'Toggle plan mode' }, - // { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, - { keys: 'Ctrl-O', description: 'Toggle tool output expansion' }, + { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, + { keys: 'Ctrl-O', description: 'Toggle tool output / compaction summary expansion' }, + { keys: 'Ctrl-T', description: 'Expand / collapse the todo list (when truncated)' }, { keys: 'Ctrl-S', description: 'Steer — inject a follow-up during streaming' }, { keys: 'Shift-Enter / Ctrl-J', description: 'Insert newline' }, { keys: 'Ctrl-C', description: 'Interrupt stream / clear input' }, @@ -48,7 +47,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ export interface HelpPanelOptions { readonly commands: readonly HelpPanelCommand[]; readonly shortcuts?: readonly KeyboardShortcut[]; - readonly colors: ColorPalette; readonly onClose: () => void; /** Terminal height — used to decide whether to show the hint tail. */ readonly maxVisible?: number; @@ -93,16 +91,15 @@ export class HelpPanelComponent extends Container implements Focusable { } override render(width: number): string[] { - const colors = this.opts.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const muted = chalk.hex(colors.textMuted); - const kbdColor = chalk.hex(colors.warning); - const slashColor = chalk.hex(colors.primary); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const muted = (text: string) => currentTheme.fg('textMuted', text); + const kbdColor = (text: string) => currentTheme.fg('warning', text); + const slashColor = (text: string) => currentTheme.fg('primary', text); const shortcuts = this.opts.shortcuts ?? DEFAULT_KEYBOARD_SHORTCUTS; const kbdWidth = Math.max(8, ...shortcuts.map((s) => s.keys.length)); - const sortedCmds = [...this.opts.commands].toSorted((a, b) => a.name.localeCompare(b.name)); + const sortedCmds = [...this.opts.commands].toSorted(compareSlashCommandsForDisplay); const cmdLabels = sortedCmds.map((c) => { const aliases = c.aliases.length > 0 ? ` (${c.aliases.map((a) => '/' + a).join(', ')})` : ''; return `/${c.name}${aliases}`; @@ -110,17 +107,17 @@ export class HelpPanelComponent extends Container implements Focusable { const cmdWidth = Math.max(12, ...cmdLabels.map((l) => l.length)); const lines: string[] = [ accent('─'.repeat(width)), - accent.bold(' help ') + muted('· Esc / Enter / q to close · ↑↓ scroll'), + currentTheme.boldFg('primary', ' help ') + muted('· Esc / Enter / q to cancel · ↑↓ scroll'), '', // Greeting ` ${dim('Sure, Kimi is ready to help! Just send a message to get started.')}`, '', // Section: keyboard shortcuts - ` ${chalk.bold('Keyboard shortcuts')}`, + ` ${currentTheme.bold('Keyboard shortcuts')}`, ...shortcuts.map((s) => ` ${kbdColor(s.keys.padEnd(kbdWidth))} ${dim(s.description)}`), '', // Section: slash commands - ` ${chalk.bold('Slash commands')}`, + ` ${currentTheme.bold('Slash commands')}`, ...sortedCmds.map((cmd, i) => { const label = cmdLabels[i] ?? `/${cmd.name}`; return ` ${slashColor(label.padEnd(cmdWidth))} ${dim(cmd.description)}`; @@ -146,3 +143,14 @@ export class HelpPanelComponent extends Container implements Focusable { return lines.map((line) => truncateToWidth(line, width)); } } + +function compareSlashCommandsForDisplay(a: HelpPanelCommand, b: HelpPanelCommand): number { + return ( + getSlashCommandDisplayGroup(a.name) - getSlashCommandDisplayGroup(b.name) || + a.name.localeCompare(b.name) + ); +} + +function getSlashCommandDisplayGroup(name: string): number { + return name.startsWith('skill:') ? 1 : 0; +} 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 38ce8d876..82906d1d5 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -1,16 +1,16 @@ -import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { effectiveModelAlias, type ModelAlias, type ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; import { Container, Key, matchesKey, truncateToWidth, + visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; -import type { ColorPalette } from '#/tui/theme/colors'; -import { printableChar } from '#/tui/utils/printable-key'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import { SearchableList } from '#/tui/utils/searchable-list'; import type { ChoiceOption } from './choice-picker'; @@ -20,16 +20,25 @@ type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported'; interface ModelChoice { readonly alias: string; readonly model: ModelAlias; + /** Model display name (left column). */ + readonly name: string; + /** Provider display name (right column). */ + readonly provider: string; + /** Combined text the fuzzy filter matches against (name + provider). */ readonly label: string; } export interface ModelSelection { readonly alias: string; - readonly thinking: boolean; + /** Chosen thinking effort: 'off', or a concrete effort such as 'low' / + * 'high' / 'max'. Boolean 'on' is normalized to the model's default effort + * before the selection is committed (see commitEffort). */ + readonly thinking: ThinkingEffort; } export function modelDisplayName(alias: string, model: ModelAlias | undefined): string { - return model?.displayName ?? model?.model ?? alias; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? alias; } export function providerDisplayName(provider: string): string { @@ -41,55 +50,117 @@ export function providerDisplayName(provider: string): string { export function createModelChoiceOptions( models: Record<string, ModelAlias>, ): readonly ChoiceOption[] { - return Object.entries(models).map(([alias, cfg]) => ({ - value: alias, - label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, - })); + return Object.entries(models).map(([alias, cfg]) => { + const effective = effectiveModelAlias(cfg); + return { + value: alias, + label: `${modelDisplayName(alias, effective)} (${providerDisplayName(effective.provider)})`, + }; + }); } export interface ModelSelectorOptions { readonly models: Record<string, ModelAlias>; readonly currentValue: string; readonly selectedValue?: string; - readonly currentThinking: boolean; - readonly colors: ColorPalette; + /** Live thinking effort of the currently active model (e.g. 'off', 'on', + * 'high'). Used to highlight the active segment for the current model. */ + readonly currentThinkingEffort: ThinkingEffort; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ readonly pageSize?: number; - /** When true, the hint line includes a Tab/Shift+Tab provider switch tip. */ + /** When true, the hint line mentions the Tab provider switch — set by + * TabbedModelSelectorComponent so the inner list advertises the tab keys. */ readonly providerSwitchHint?: boolean; readonly onSelect: (selection: ModelSelection) => void; + /** When provided, Alt+S invokes this instead of onSelect — used to apply the + * choice to the current session only, without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } function createModelChoices(models: Record<string, ModelAlias>): readonly ModelChoice[] { - return Object.entries(models).map(([alias, cfg]) => ({ - alias, - model: cfg, - label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, - })); + return Object.entries(models).map(([alias, cfg]) => { + const effective = effectiveModelAlias(cfg); + const name = modelDisplayName(alias, effective); + const provider = providerDisplayName(effective.provider); + return { alias, model: effective, name, provider, label: `${name} (${provider})` }; + }); } -function thinkingAvailability(model: ModelAlias): ThinkingAvailability { +export function thinkingAvailability(model: ModelAlias): ThinkingAvailability { const caps = model.capabilities ?? []; if (caps.includes('always_thinking')) return 'always-on'; if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle'; return 'unsupported'; } -function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean { - const availability = thinkingAvailability(model); - if (availability === 'always-on') return true; - if (availability === 'unsupported') return false; - return thinkingDraft; +export function effortsOf(model: ModelAlias): readonly string[] { + return model.supportEfforts ?? []; } +/** + * Ordered list of selectable thinking efforts for a model. Effort-capable models + * expose their declared efforts (with an 'off' entry when the model is not + * always-on); legacy boolean models expose 'on'/'off'; single-segment lists + * mean the control is effectively locked. + */ +export function segmentsFor(model: ModelAlias): readonly string[] { + const efforts = effortsOf(model); + const availability = thinkingAvailability(model); + if (efforts.length > 0) { + return availability === 'always-on' ? efforts : ['off', ...efforts]; + } + if (availability === 'always-on') return ['on']; + if (availability === 'unsupported') return ['off']; + return ['on', 'off']; +} + +export function effortLabel(effort: string): string { + if (effort.length === 0) return effort; + return effort.charAt(0).toUpperCase() + effort.slice(1); +} + +/** + * Default thinking effort for a model: declared `default_effort`, else the + * middle `support_efforts` entry, else `'on'` for boolean models, `'off'` when + * thinking is unsupported. + */ +function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { + if (thinkingAvailability(model) === 'unsupported') return 'off'; + const efforts = effortsOf(model); + if (efforts.length > 0) { + return model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; + } + return 'on'; +} + +/** + * Normalize a draft effort before committing a selection. A boolean `'on'` + * never leaks past the UI boundary — it becomes the model's default effort + * (a concrete effort for effort-capable models, `'on'` only for genuine + * boolean models). + */ +function commitEffort(choice: ModelChoice, draft: ThinkingEffort): ThinkingEffort { + if (draft === 'on') return defaultThinkingEffortFor(choice.model); + return draft; +} + +/** + * Flat, searchable single-list model picker. + * + * One navigation axis: ↑/↓ move the cursor (PgUp/PgDn page), typing fuzzy-filters + * across every provider (provider name included), and ←/→ toggle the thinking + * draft for models that support it. There are no provider tabs — filtering by + * typing a provider name replaces them. See .agents/skills/write-tui/DESIGN.md. + */ export class ModelSelectorComponent extends Container implements Focusable { focused = false; private readonly opts: ModelSelectorOptions; private readonly list: SearchableList<ModelChoice>; - private thinkingDraft: boolean; + /** Per-model thinking-effort override set by ←/→; absent → the default. */ + private readonly thinkingOverrides = new Map<string, string>(); constructor(opts: ModelSelectorOptions) { super(); @@ -104,7 +175,34 @@ export class ModelSelectorComponent extends Container implements Focusable { initialIndex: Math.max(selectedIdx, 0), searchable: opts.searchable === true, }); - this.thinkingDraft = opts.currentThinking; + } + + /** + * Thinking effort for a model: an explicit ←/→ override when set, otherwise + * the live effort for the active model, otherwise the model's default effort + * (effort-capable) or 'on' (other thinking-capable models). + */ + private draftFor(choice: ModelChoice): string { + const override = this.thinkingOverrides.get(choice.alias); + if (override !== undefined) return override; + if (choice.alias === this.opts.currentValue) return this.opts.currentThinkingEffort; + const efforts = effortsOf(choice.model); + if (efforts.length > 0) { + // A model with support_efforts but no default_effort defaults to the + // middle entry of its supported efforts. + const def = choice.model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]; + if (def !== undefined && efforts.includes(def)) return def; + return efforts[0]!; + } + return thinkingAvailability(choice.model) !== 'unsupported' ? 'on' : 'off'; + } + + /** Draft coerced onto the model's segment list so rendering/selection never + * reference a effort the model cannot actually select. */ + private effectiveEffort(choice: ModelChoice): string { + const draft = this.draftFor(choice); + const segments = segmentsFor(choice.model); + return segments.includes(draft) ? draft : segments[0]!; } handleInput(data: string): void { @@ -114,93 +212,142 @@ export class ModelSelectorComponent extends Container implements Focusable { return; } - const selected = this.selectedChoice(); - if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') { - const ch = printableChar(data); - if (ch === '/') { - this.thinkingDraft = !this.thinkingDraft; - return; - } + // ↑/↓, PgUp/PgDn, and — when searchable — typing + Backspace. + if (this.list.handleKey(data)) { + return; } - if (this.list.handleKey(data)) { - // Consumed by SearchableList (↑/↓/PgUp/PgDn/typing/Backspace). - return; - } - if (matchesKey(data, Key.left)) { - this.list.pageUp(); - return; - } - if (matchesKey(data, Key.right)) { - this.list.pageDown(); + // Left/Right move the active thinking effort within the model's segments. + if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) { + const selected = this.selectedChoice(); + if (selected !== undefined) { + const segments = segmentsFor(selected.model); + if (segments.length > 1) { + const current = this.effectiveEffort(selected); + const idx = segments.indexOf(current); + // The two-segment case is the legacy boolean On/Off control: both + // arrows flip it. With more segments (efforts), ←/→ step. + let next: number; + if (segments.length === 2) { + next = idx === 0 ? 1 : 0; + } else { + const delta = matchesKey(data, Key.left) ? -1 : 1; + next = Math.max(0, Math.min(segments.length - 1, idx + delta)); + } + if (next !== idx) { + this.thinkingOverrides.set(selected.alias, segments[next]!); + } + } + } return; } + if (matchesKey(data, Key.enter)) { + const selected = this.selectedChoice(); if (selected === undefined) return; this.opts.onSelect({ alias: selected.alias, - thinking: effectiveThinking(selected.model, this.thinkingDraft), + thinking: commitEffort(selected, this.effectiveEffort(selected)), + }); + return; + } + + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const selected = this.selectedChoice(); + if (selected === undefined) return; + this.opts.onSessionOnlySelect({ + alias: selected.alias, + thinking: commitEffort(selected, this.effectiveEffort(selected)), }); } } override render(width: number): string[] { - const { colors } = this.opts; + const searchable = this.opts.searchable === true; const view = this.list.view(); + const totalCount = Object.keys(this.opts.models).length; + const titleSuffix = - view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + searchable && view.query.length === 0 + ? currentTheme.fg('textMuted', ' (type to search)') + : ''; + + // "type to search" already lives in the title suffix, so the hint only + // surfaces the backspace shortcut once a query is active. const hintParts: string[] = []; - if (this.opts.providerSwitchHint) { - hintParts.push('Tab/Shift+Tab provider'); - } - hintParts.push('↑↓ model', '←→ page', '/ thinking', 'Enter apply', 'Esc cancel'); + if (this.opts.providerSwitchHint) hintParts.push('Tab toggle provider'); + hintParts.push('↑↓ navigate'); + if (searchable && view.query.length > 0) hintParts.push('Backspace clear'); + hintParts.push('Enter select'); + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); + const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Select a model') + titleSuffix, - chalk.hex(colors.textMuted)(' ' + hintParts.join(' · ')), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Select a model') + titleSuffix, + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), '', ]; - if (view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(' Search: ') + chalk.hex(colors.text)(view.query)); + if (searchable && view.query.length > 0) { + lines.push(currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query)); } if (view.items.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('textMuted', ' No matches')); } else { + // Column width for model names so the provider column lines up. Capped so + // the provider + "← current" marker still fit on normal terminal widths. + const nameCap = Math.max(8, Math.floor(width * 0.5)); + let nameWidth = 0; + for (let i = view.page.start; i < view.page.end; i++) { + const choice = view.items[i]; + if (choice !== undefined) nameWidth = Math.max(nameWidth, visibleWidth(choice.name)); + } + nameWidth = Math.min(nameWidth, nameCap); + for (let i = view.page.start; i < view.page.end; i++) { const choice = view.items[i]; if (choice === undefined) continue; const isSelected = i === view.selectedIndex; const isCurrent = choice.alias === this.opts.currentValue; - const pointer = isSelected ? '❯' : ' '; - const labelStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); - let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); - line += labelStyle(choice.label); + const pointer = isSelected ? SELECT_POINTER : ' '; + const truncatedName = truncateToWidth(choice.name, nameWidth, '…'); + const namePad = ' '.repeat(Math.max(0, nameWidth - visibleWidth(truncatedName))); + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); + line += (isSelected ? currentTheme.boldFg('primary', truncatedName) : currentTheme.fg('text', truncatedName)) + namePad; + line += ' ' + currentTheme.fg('textMuted', choice.provider); if (isCurrent) { - line += ' ' + chalk.hex(colors.success)('← current'); + line += ' ' + currentTheme.fg('success', CURRENT_MARK); } lines.push(line); } } - if (view.page.pageCount > 1) { + // Scroll / match indicator. + if (view.query.length > 0) { lines.push(''); lines.push( - chalk.hex(colors.textMuted)( - ` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`, - ), + currentTheme.fg('textMuted', ` ${String(view.items.length)} / ${String(totalCount)}`), ); + } else { + const below = view.items.length - view.page.end; + if (below > 0) { + lines.push(''); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); + } } lines.push(''); - lines.push(chalk.hex(colors.textMuted)(' Thinking (/ to toggle)')); const selected = this.selectedChoice(); if (selected !== undefined) { - lines.push(this.renderThinkingControl(selected.model)); + const canSwitch = segmentsFor(selected.model).length > 1; + const thinkingHeader = canSwitch ? ' Thinking (←→ to switch)' : ' Thinking'; + lines.push(currentTheme.fg('textMuted', thinkingHeader)); + lines.push(this.renderThinkingControl(selected)); } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } @@ -208,20 +355,30 @@ export class ModelSelectorComponent extends Container implements Focusable { return this.list.selected(); } - private renderThinkingControl(model: ModelAlias): string { - const { colors } = this.opts; + private renderThinkingControl(choice: ModelChoice): string { const segment = (label: string, active: boolean): string => active - ? chalk.hex(colors.primary).bold(`[ ${label} ]`) - : chalk.hex(colors.text)(` ${label} `); + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); + // The whole segment is muted, suffix included, so the disabled side reads + // as a single greyed-out control rather than a selectable option. + const unavailable = (label: string): string => + currentTheme.fg('textMuted', ` ${label} (Unsupported) `); - const availability = thinkingAvailability(model); - if (availability === 'always-on') { - return ` ${segment('Always on', true)}`; + // Non-effort always-on / unsupported models keep the original On/Off layout + // so the control never shifts while moving across legacy models. + const efforts = effortsOf(choice.model); + const availability = thinkingAvailability(choice.model); + if (efforts.length === 0 && availability === 'always-on') { + return ` ${segment('On', true)} ${unavailable('Off')}`; } - if (availability === 'unsupported') { - return ` ${segment('Off', true)} ${chalk.hex(colors.textMuted)('unsupported')}`; + if (efforts.length === 0 && availability === 'unsupported') { + return ` ${unavailable('On')} ${segment('Off', true)}`; } - return ` ${segment('On', this.thinkingDraft)} ${segment('Off', !this.thinkingDraft)}`; + + const segments = segmentsFor(choice.model); + const active = this.effectiveEffort(choice); + const rendered = segments.map((effort) => segment(effortLabel(effort), effort === active)); + return ` ${rendered.join(' ')}`; } } diff --git a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts index 6445206d3..4b2db673d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -2,26 +2,21 @@ import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const PERMISSION_OPTIONS: readonly ChoiceOption[] = [ { value: 'manual', label: 'Manual', - description: - 'Ask before commands, edits, and other risky actions. Read/search tools run directly; session approval rules are respected.', + description: 'Approve every action yourself.', }, { value: 'auto', label: 'Auto', - description: - 'Run fully non-interactively. Tool actions are approved automatically, and agent questions are skipped so it can decide on its own.', + description: 'Run all actions automatically, including risky ones.', }, { value: 'yolo', label: 'YOLO', - description: - 'Automatically approve tool actions and plan transitions. The agent can still ask you explicit questions when your input is needed.', + description: 'AI decides which actions need your approval.', }, ]; @@ -31,7 +26,6 @@ function isPermissionModeChoice(value: string): value is PermissionMode { export interface PermissionSelectorOptions { readonly currentValue: PermissionMode; - readonly colors: ColorPalette; readonly onSelect: (mode: PermissionMode) => void; readonly onCancel: () => void; } @@ -42,7 +36,6 @@ export class PermissionSelectorComponent extends ChoicePickerComponent { title: 'Select permission mode', options: [...PERMISSION_OPTIONS], currentValue: opts.currentValue, - colors: opts.colors, onSelect: (value) => { if (isPermissionModeChoice(value)) opts.onSelect(value); }, diff --git a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts index c1d8a1467..a332f70af 100644 --- a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts @@ -2,15 +2,12 @@ import { OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const PLATFORM_OPTIONS: readonly ChoiceOption[] = [ { value: 'kimi-code', label: 'Kimi Code (OAuth)' }, ...OPEN_PLATFORMS.map((platform) => ({ value: platform.id, label: platform.name })), ]; export interface PlatformSelectorOptions { - readonly colors: ColorPalette; readonly onSelect: (platformId: string) => void; readonly onCancel: () => void; } @@ -20,7 +17,6 @@ export class PlatformSelectorComponent extends ChoicePickerComponent { super({ title: 'Select a platform', options: [...PLATFORM_OPTIONS], - colors: opts.colors, onSelect: opts.onSelect, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index f3600b5c2..64ec28614 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -1,31 +1,54 @@ import { Container, + Input, Key, matchesKey, truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import chalk from 'chalk'; +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; import { printableChar } from '#/tui/utils/printable-key'; -import type { PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; +import { computeUpdateStatus, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; import { ChoicePickerComponent } from './choice-picker'; -const OVERVIEW_MARKETPLACE = 'marketplace'; -const OVERVIEW_RELOAD = 'reload'; -const OVERVIEW_SHOW_LIST = 'show-list'; -const OVERVIEW_PLUGIN_PREFIX = 'plugin:'; const MCP_SERVER_PREFIX = 'mcp:'; const REMOVE_CONFIRM_CANCEL = 'cancel'; const REMOVE_CONFIRM_REMOVE = 'remove'; +const INSTALL_TRUST_EXIT = 'exit'; +const INSTALL_TRUST_TRUST = 'trust'; const ELLIPSIS = '…'; +// Hardcoded Web Bridge promotion: a built-in entry that always leads the +// Official tab, even when the marketplace catalog is unavailable. Selecting it +// opens the install page in the browser rather than installing from a source, +// because Web Bridge is a browser extension + daemon, not a plugin package. +const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge#local-agent'; +const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = { + id: 'kimi-webbridge', + displayName: 'Kimi WebBridge', + source: WEB_BRIDGE_URL, + tier: 'official', + homepage: WEB_BRIDGE_URL, + description: 'Control your real browser from Kimi Code — navigate, click, type, and screenshot', +}; + +// Only the hardcoded pinned row should open the WebBridge install page. Match +// by reference (not id) so a catalog entry on another tab that happens to +// reuse the same id still installs normally instead of being hijacked. +function isPinnedWebBridgeEntry(entry: PluginMarketplaceEntry): boolean { + return entry === WEB_BRIDGE_ENTRY; +} + interface PluginsOverviewItem { readonly value: string; readonly kind: 'plugin' | 'action'; @@ -34,253 +57,6 @@ interface PluginsOverviewItem { readonly description: string; } -export type PluginsOverviewSelection = - | { readonly kind: 'marketplace' } - | { readonly kind: 'reload' } - | { readonly kind: 'show-list' } - | { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean } - | { readonly kind: 'mcp'; readonly id: string } - | { readonly kind: 'remove'; readonly id: string } - | { readonly kind: 'info'; readonly id: string }; - -export interface PluginsOverviewSelectorOptions { - readonly plugins: readonly PluginSummary[]; - readonly selectedId?: string; - readonly pluginHint?: { - readonly id: string; - readonly text: string; - }; - readonly colors: ColorPalette; - readonly onSelect: (selection: PluginsOverviewSelection) => void; - readonly onCancel: () => void; -} - -export class PluginsOverviewSelectorComponent extends Container implements Focusable { - focused = false; - - private readonly opts: PluginsOverviewSelectorOptions; - private readonly items: readonly PluginsOverviewItem[]; - private selectedIndex = 0; - - constructor(opts: PluginsOverviewSelectorOptions) { - super(); - this.opts = opts; - this.items = buildOverviewItems(opts.plugins); - const selectedIndex = this.items.findIndex( - (item) => item.value === `${OVERVIEW_PLUGIN_PREFIX}${opts.selectedId}`, - ); - this.selectedIndex = Math.max(0, selectedIndex); - } - - handleInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) { - this.opts.onCancel(); - return; - } - if (matchesKey(data, Key.up)) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return; - } - if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); - return; - } - const chosen = this.items[this.selectedIndex]; - if (chosen === undefined) return; - const pluginId = overviewItemPluginId(chosen); - const decoded = printableChar(data); - if (matchesKey(data, Key.space) || decoded === ' ') { - if (pluginId === undefined) return; - const plugin = this.opts.plugins.find((item) => item.id === pluginId); - if (plugin !== undefined) { - this.opts.onSelect({ kind: 'toggle', id: pluginId, enabled: !plugin.enabled }); - } - return; - } - if (decoded === 'd' || decoded === 'D') { - if (pluginId !== undefined) this.opts.onSelect({ kind: 'remove', id: pluginId }); - return; - } - if (decoded === 'm' || decoded === 'M') { - if (pluginId === undefined) return; - const plugin = this.opts.plugins.find((item) => item.id === pluginId); - if (plugin !== undefined && plugin.mcpServerCount > 0) { - this.opts.onSelect({ kind: 'mcp', id: pluginId }); - } - return; - } - if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) { - if (pluginId !== undefined) { - this.opts.onSelect({ kind: 'info', id: pluginId }); - return; - } - const selection = parseOverviewSelection(chosen.value); - if (selection !== undefined) this.opts.onSelect(selection); - } - } - - override render(width: number): string[] { - const { colors, plugins } = this.opts; - const hint = - '↑↓ navigate · Space toggle · M MCP servers · D remove · Enter details · Esc close'; - const pluginItems = this.items.filter((item) => item.kind === 'plugin'); - const actionItems = this.items.filter((item) => item.kind === 'action'); - const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Plugins'), - pluginShortcutHint(` ${hint}`, colors), - '', - sectionLabel(`Installed plugins (${plugins.length})`, colors), - ]; - - if (pluginItems.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No plugins installed.')); - } else { - let absoluteIndex = 0; - for (const item of pluginItems) { - lines.push(...this.renderItem(item, absoluteIndex, width)); - absoluteIndex++; - } - } - - lines.push(''); - lines.push(sectionLabel('Actions', colors)); - for (let i = 0; i < actionItems.length; i++) { - lines.push(...this.renderItem(actionItems[i]!, pluginItems.length + i, width)); - } - - lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); - return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); - } - - private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; - const selected = index === this.selectedIndex; - const pointer = selected ? '❯' : ' '; - const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); - let line = prefix + labelStyle(item.label); - if (item.status !== undefined) { - line += ' ' + statusStyle(item, colors)(item.status); - } - const pluginId = overviewItemPluginId(item); - if (pluginId !== undefined && this.opts.pluginHint?.id === pluginId) { - line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); - } - - const descriptionWidth = Math.max(1, width - 4); - const lines = [line]; - for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(pluginShortcutHint(` ${descLine}`, colors)); - } - return lines; - } -} - -export type PluginMarketplaceSelection = - | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } - | { readonly kind: 'back' }; - -export interface PluginMarketplaceSelectorOptions { - readonly entries: readonly PluginMarketplaceEntry[]; - readonly installedIds: ReadonlySet<string>; - readonly source: string; - readonly colors: ColorPalette; - readonly onSelect: (selection: PluginMarketplaceSelection) => void; - readonly onCancel: () => void; -} - -export class PluginMarketplaceSelectorComponent extends Container implements Focusable { - focused = false; - - private readonly opts: PluginMarketplaceSelectorOptions; - private readonly items: readonly PluginsOverviewItem[]; - private selectedIndex = 0; - - constructor(opts: PluginMarketplaceSelectorOptions) { - super(); - this.opts = opts; - this.items = buildMarketplaceItems(opts.entries, opts.installedIds); - } - - handleInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) { - this.opts.onCancel(); - return; - } - if (matchesKey(data, Key.up)) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return; - } - if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); - return; - } - if (matchesKey(data, Key.enter) || matchesKey(data, Key.space) || printableChar(data) === ' ') { - const chosen = this.items[this.selectedIndex]; - if (chosen === undefined) return; - if (chosen.value === 'back') { - this.opts.onSelect({ kind: 'back' }); - return; - } - const entry = this.opts.entries.find((item) => item.id === chosen.value); - if (entry === undefined) return; - this.opts.onSelect({ kind: 'install', entry }); - } - } - - override render(width: number): string[] { - const { colors } = this.opts; - const entries = this.items.filter((item) => item.kind === 'plugin'); - const actions = this.items.filter((item) => item.kind === 'action'); - const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Official plugins'), - pluginShortcutHint(' ↑↓ navigate · Enter/Space install/update · ←/Esc back', colors), - chalk.hex(colors.textMuted)(` Source: ${this.opts.source}`), - '', - sectionLabel(`Marketplace (${entries.length})`, colors), - ]; - - if (entries.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No marketplace plugins found.')); - } else { - for (let i = 0; i < entries.length; i++) { - lines.push(...this.renderItem(entries[i]!, i, width)); - } - } - - lines.push(''); - lines.push(sectionLabel('Actions', colors)); - for (let i = 0; i < actions.length; i++) { - lines.push(...this.renderItem(actions[i]!, entries.length + i, width)); - } - - lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); - return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); - } - - private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; - const selected = index === this.selectedIndex; - const pointer = selected ? '❯' : ' '; - const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); - let line = prefix + labelStyle(item.label); - if (item.status !== undefined) { - line += ' ' + statusStyle(item, colors)(item.status); - } - const descriptionWidth = Math.max(1, width - 4); - const lines = [line]; - for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(pluginShortcutHint(` ${descLine}`, colors)); - } - return lines; - } -} - export type PluginMcpSelection = | { readonly kind: 'toggle'; readonly pluginId: string; readonly server: string; readonly enabled: boolean } | { readonly kind: 'back'; readonly pluginId: string }; @@ -292,7 +68,6 @@ export interface PluginMcpSelectorOptions { readonly server: string; readonly text: string; }; - readonly colors: ColorPalette; readonly onSelect: (selection: PluginMcpSelection) => void; readonly onCancel: () => void; } @@ -315,7 +90,7 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } handleInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) { + if (matchesKey(data, Key.escape)) { this.opts.onCancel(); return; } @@ -348,13 +123,14 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors, info } = this.opts; + const { info } = this.opts; + const colors = currentTheme.palette; const serverItems = this.items.filter((item) => item.kind === 'plugin'); const actionItems = this.items.filter((item) => item.kind === 'action'); const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), chalk.hex(colors.primary).bold(` MCP servers · ${info.displayName}`), - pluginShortcutHint(' ↑↓ navigate · Enter/Space enable/disable · ←/Esc back', colors), + mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel', colors), '', sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors), ]; @@ -379,9 +155,9 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; + const colors = currentTheme.palette; const selected = index === this.selectedIndex; - const pointer = selected ? '❯' : ' '; + const pointer = selected ? SELECT_POINTER : ' '; const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); let line = prefix + labelStyle(item.label); @@ -395,7 +171,7 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(pluginShortcutHint(` ${descLine}`, colors)); + lines.push(mutedHintLine(` ${descLine}`, colors)); } return lines; } @@ -408,7 +184,6 @@ export type PluginRemoveConfirmResult = export interface PluginRemoveConfirmOptions { readonly id: string; readonly displayName: string; - readonly colors: ColorPalette; readonly onDone: (result: PluginRemoveConfirmResult) => void; } @@ -417,7 +192,7 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { super({ title: `Remove ${opts.displayName} (${opts.id})?`, hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel', - formatHint: pluginShortcutHint, + formatHint: mutedHintLine, options: [ { value: REMOVE_CONFIRM_CANCEL, @@ -431,7 +206,6 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { description: 'Remove only the install record; plugin files are left in place.', }, ], - colors: opts.colors, onSelect: (value) => { opts.onDone(value === REMOVE_CONFIRM_REMOVE ? { kind: 'confirm' } : { kind: 'cancel' }); }, @@ -442,35 +216,53 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { } } -function buildOverviewItems(plugins: readonly PluginSummary[]): PluginsOverviewItem[] { - const options: PluginsOverviewItem[] = plugins.map((plugin) => ({ - value: `${OVERVIEW_PLUGIN_PREFIX}${plugin.id}`, - kind: 'plugin', - label: plugin.displayName, - status: pluginStatus(plugin), - description: overviewPluginDescription(plugin), - })); - options.push( - { - value: OVERVIEW_MARKETPLACE, - kind: 'action', - label: 'Marketplace', - description: 'Browse official plugins.', - }, - { - value: OVERVIEW_RELOAD, - kind: 'action', - label: 'Reload', - description: 'Re-read installed plugins and manifests.', - }, - { - value: OVERVIEW_SHOW_LIST, - kind: 'action', - label: 'Summary', - description: 'Append the current plugin summary to the transcript.', - }, - ); - return options; +export type PluginInstallTrustConfirmResult = + | { readonly kind: 'confirm' } + | { readonly kind: 'cancel' }; + +export interface PluginInstallTrustConfirmOptions { + /** Plugin display name or source, shown in the title for identification. */ + readonly label: string; + readonly onDone: (result: PluginInstallTrustConfirmResult) => void; +} + +/** + * Confirmation shown before installing a third-party (unofficial) plugin. + * Defaults to "Exit" so the user must explicitly switch to "Trust and install" + * to proceed with a plugin that Kimi has not reviewed. + */ +export class PluginInstallTrustConfirmComponent extends ChoicePickerComponent { + constructor(opts: PluginInstallTrustConfirmOptions) { + super({ + title: `Install third-party plugin ${opts.label}?`, + hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel', + formatHint: mutedHintLine, + notice: + '⚠️ This is a third-party plugin that Kimi has not reviewed. It can bundle MCP servers, ' + + 'skills, or files that run code and access your workspace. Install it only if you ' + + 'trust the source.', + noticeTone: 'warning', + options: [ + { + value: INSTALL_TRUST_EXIT, + label: 'Exit', + description: 'Cancel the installation.', + }, + { + value: INSTALL_TRUST_TRUST, + label: 'Trust and install', + tone: 'danger', + description: 'Install this third-party plugin anyway.', + }, + ], + onSelect: (value) => { + opts.onDone(value === INSTALL_TRUST_TRUST ? { kind: 'confirm' } : { kind: 'cancel' }); + }, + onCancel: () => { + opts.onDone({ kind: 'cancel' }); + }, + }); + } } function overviewPluginDescription(plugin: PluginSummary): string { @@ -486,41 +278,465 @@ function overviewPluginDescription(plugin: PluginSummary): string { return `id ${plugin.id} · ${skills}${mcp}${source}${trust}${state}${diagnostics}`; } -function pluginStatus(plugin: PluginSummary): string { +function pluginStatus(plugin: PluginSummary): string | undefined { if (plugin.state !== 'ok') return plugin.state; return plugin.enabled ? 'enabled' : 'disabled'; } -function parseOverviewSelection(value: string): PluginsOverviewSelection | undefined { - if (value === OVERVIEW_MARKETPLACE) return { kind: 'marketplace' }; - if (value === OVERVIEW_RELOAD) return { kind: 'reload' }; - if (value === OVERVIEW_SHOW_LIST) return { kind: 'show-list' }; - return undefined; +function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: string) => string { + // "update …" is a warning (actionable); "installed …" is success; + // "install …" is the available action. + if (status.startsWith('update')) return chalk.hex(colors.warning); + if (status.startsWith('installed')) return chalk.hex(colors.success); + return chalk.hex(colors.primary); } -function overviewItemPluginId(item: PluginsOverviewItem): string | undefined { - if (!item.value.startsWith(OVERVIEW_PLUGIN_PREFIX)) return undefined; - return item.value.slice(OVERVIEW_PLUGIN_PREFIX.length); +/** Rounded single-line URL input box (DESIGN §9), shared by the marketplace + * Custom tab and the unified plugins panel. */ +function renderUrlInputBox( + input: Input, + focused: boolean, + width: number, + colors: ColorPalette, +): string[] { + input.focused = focused; + const border = (s: string): string => chalk.hex(colors.primary)(s); + const boxWidth = Math.max(24, width - 2); + const innerWidth = Math.max(10, boxWidth - 4); + const inputLine = input.render(innerWidth)[0] ?? ''; + const rightPad = Math.max(0, innerWidth - visibleWidth(inputLine)); + return [ + ' ' + border('╭' + '─'.repeat(boxWidth - 2) + '╮'), + ' ' + border('│') + ' ' + inputLine + ' '.repeat(rightPad) + border('│'), + ' ' + border('╰' + '─'.repeat(boxWidth - 2) + '╯'), + ]; } -function buildMarketplaceItems( - entries: readonly PluginMarketplaceEntry[], - installedIds: ReadonlySet<string>, -): PluginsOverviewItem[] { - const items: PluginsOverviewItem[] = entries.map((entry) => ({ - value: entry.id, - kind: 'plugin', - label: entry.displayName, - status: installedIds.has(entry.id) ? 'installed' : installStatus(entry), - description: marketplaceEntryDescription(entry), - })); - items.push({ - value: 'back', - kind: 'action', - label: 'Back to installed plugins', - description: 'Return to the local plugin manager.', - }); - return items; +// =========================================================================== +// Unified /plugins panel: Installed / Official / Third-party / Custom tabs. +// =========================================================================== + +export type PluginsPanelTabId = 'installed' | 'official' | 'third-party' | 'custom'; + +export type PluginsPanelSelection = + | { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean } + | { readonly kind: 'remove'; readonly id: string } + | { readonly kind: 'mcp'; readonly id: string } + | { readonly kind: 'details'; readonly id: string } + | { readonly kind: 'reload' } + | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } + | { readonly kind: 'install-source'; readonly source: string } + | { readonly kind: 'open-url'; readonly url: string; readonly label: string }; + +export interface PluginsPanelOptions { + readonly installed: readonly PluginSummary[]; + readonly installedIds: ReadonlySet<string>; + readonly initialTab?: PluginsPanelTabId; + readonly selectedId?: string; + readonly pluginHint?: { readonly id: string; readonly text: string }; + readonly onSelect: (selection: PluginsPanelSelection) => void; + readonly onCancel: () => void; + /** Called the first time the Official or Third-party tab needs its catalog. + * The host fetches the marketplace and calls setMarketplace / setMarketplaceError. */ + readonly onRequestMarketplace?: () => void; +} + +type MarketState = + | { readonly status: 'idle' } + | { readonly status: 'loading' } + | { readonly status: 'error'; readonly message: string } + | { readonly status: 'loaded'; readonly entries: readonly PluginMarketplaceEntry[]; readonly source: string }; + +const PLUGINS_PANEL_TABS: readonly { id: PluginsPanelTabId; label: string }[] = [ + { id: 'installed', label: 'Installed' }, + { id: 'official', label: 'Official' }, + { id: 'third-party', label: 'Third-party' }, + { id: 'custom', label: 'Custom' }, +]; + +export class PluginsPanelComponent extends Container implements Focusable { + focused = false; + + private readonly opts: PluginsPanelOptions; + private readonly customInput = new Input(); + private activeTabIndex: number; + private selectedIndex = 0; + private market: MarketState = { status: 'idle' }; + private installing: string | undefined; + + constructor(opts: PluginsPanelOptions) { + super(); + this.opts = opts; + this.activeTabIndex = Math.max( + 0, + PLUGINS_PANEL_TABS.findIndex((tab) => tab.id === (opts.initialTab ?? 'installed')), + ); + if (opts.selectedId !== undefined && this.activeTab.id === 'installed') { + const idx = opts.installed.findIndex((p) => p.id === opts.selectedId); + if (idx >= 0) this.selectedIndex = idx; + } + this.customInput.onSubmit = (value) => { + const source = value.trim(); + if (source.length > 0) this.opts.onSelect({ kind: 'install-source', source }); + }; + } + + marketplaceStatus(): MarketState['status'] { + return this.market.status; + } + + setMarketplaceLoading(): void { + this.market = { status: 'loading' }; + } + + setMarketplace(entries: readonly PluginMarketplaceEntry[], source: string): void { + this.market = { status: 'loaded', entries, source }; + } + + setMarketplaceError(message: string): void { + this.market = { status: 'error', message }; + } + + setInstalling(label: string): void { + this.installing = label; + this.invalidate(); + } + + clearInstalling(): void { + this.installing = undefined; + this.invalidate(); + } + + private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] { + return PLUGINS_PANEL_TABS[this.activeTabIndex]!; + } + + private get marketplaceEntries(): readonly PluginMarketplaceEntry[] { + if (this.market.status !== 'loaded') return []; + const { installedIds } = this.opts; + return this.market.entries.toSorted( + (a, b) => Number(installedIds.has(b.id)) - Number(installedIds.has(a.id)), + ); + } + + private get installedVersions(): ReadonlyMap<string, string | undefined> { + return new Map(this.opts.installed.map((plugin) => [plugin.id, plugin.version])); + } + + private get officialEntries(): readonly PluginMarketplaceEntry[] { + // The hardcoded Web Bridge entry always leads the Official tab, even when + // the catalog is loading or unreachable. Dedupe by id so a catalog that + // also lists it does not render a second row. + return [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries]; + } + + private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] { + // Dedupe by id (not reference): if the official catalog also lists + // kimi-webbridge, the pinned row already represents it, so suppress the + // catalog copy to avoid a duplicate row on the Official tab. + return this.marketplaceEntries.filter( + (entry) => entry.tier === 'official' && entry.id !== WEB_BRIDGE_ENTRY.id, + ); + } + + private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] { + // Anything not explicitly marked official lands here: `curated` entries plus + // entries that omit `tier` (custom marketplaces often do). Without this, + // untiered entries would be invisible in both marketplace tabs. + return this.marketplaceEntries.filter((entry) => entry.tier !== 'official'); + } + + private requestMarketplaceIfNeeded(): void { + // The Installed tab also needs the catalog to render update badges; only the + // Custom tab (manual URL entry) can skip the fetch entirely. + if (this.market.status === 'idle' && this.activeTab.id !== 'custom') { + this.market = { status: 'loading' }; + this.opts.onRequestMarketplace?.(); + } + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.tab)) { + this.activeTabIndex = (this.activeTabIndex + 1) % PLUGINS_PANEL_TABS.length; + this.selectedIndex = 0; + this.requestMarketplaceIfNeeded(); + return; + } + if (matchesKey(data, Key.shift('tab'))) { + this.activeTabIndex = + (this.activeTabIndex - 1 + PLUGINS_PANEL_TABS.length) % PLUGINS_PANEL_TABS.length; + this.selectedIndex = 0; + this.requestMarketplaceIfNeeded(); + return; + } + switch (this.activeTab.id) { + case 'installed': + this.handleInstalledInput(data); + return; + case 'official': + case 'third-party': + this.handleMarketplaceInput(data); + return; + case 'custom': + this.customInput.handleInput(data); + return; + } + } + + private handleInstalledInput(data: string): void { + const plugins = this.opts.installed; + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(plugins.length - 1, this.selectedIndex + 1); + return; + } + const plugin = plugins[this.selectedIndex]; + const ch = printableChar(data); + // Decode Space for terminals that send printable keys via Kitty/CSI-u + // sequences (e.g. VS Code's integrated terminal); `matchesKey(Key.space)` + // alone misses those and the toggle silently stops working. + if (matchesKey(data, Key.space) || ch === ' ') { + if (plugin !== undefined) { + this.opts.onSelect({ kind: 'toggle', id: plugin.id, enabled: !plugin.enabled }); + } + return; + } + if (ch === 'd' || ch === 'D') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'remove', id: plugin.id }); + return; + } + if (ch === 'm' || ch === 'M') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'mcp', id: plugin.id }); + return; + } + if (ch === 'r' || ch === 'R') { + this.opts.onSelect({ kind: 'reload' }); + return; + } + if (matchesKey(data, Key.enter)) { + if (plugin === undefined) return; + const update = this.installedUpdateStatus(plugin); + if (update !== undefined) { + this.opts.onSelect({ kind: 'install', entry: update.entry }); + } else { + this.opts.onSelect({ kind: 'details', id: plugin.id }); + } + return; + } + if (ch === 'i' || ch === 'I') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'details', id: plugin.id }); + } + } + + private handleMarketplaceInput(data: string): void { + const entries = this.activeTab.id === 'official' ? this.officialEntries : this.thirdPartyEntries; + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + // Clamp to 0 while the catalog is still loading (entries empty); otherwise + // `entries.length - 1` is -1 and a later Enter reads `entries[-1]`. + this.selectedIndex = entries.length === 0 ? 0 : Math.min(entries.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter)) { + const entry = entries[this.selectedIndex]; + if (entry === undefined) return; + if (isPinnedWebBridgeEntry(entry)) { + this.opts.onSelect({ kind: 'open-url', url: WEB_BRIDGE_URL, label: entry.displayName }); + return; + } + this.opts.onSelect({ kind: 'install', entry }); + } + } + + override invalidate(): void { + super.invalidate(); + this.customInput.invalidate(); + } + + override render(width: number): string[] { + if (this.installing !== undefined) { + return this.renderInstalling(width); + } + const colors = currentTheme.palette; + const tab = this.activeTab.id; + const hint = + tab === 'installed' + ? this.installedHint() + : tab === 'custom' + ? ' Tab switch · Enter install · Esc cancel' + : ' Tab switch · ↑↓ navigate · Enter open/install · Esc cancel'; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Plugins'), + mutedHintLine(hint, colors), + '', + renderTabStrip({ + labels: PLUGINS_PANEL_TABS.map((t) => t.label), + activeIndex: this.activeTabIndex, + width, + colors, + }), + '', + ]; + + if (tab === 'installed') this.renderInstalled(lines, width); + else if (tab === 'official') this.renderOfficial(lines, width); + else if (tab === 'third-party') this.renderThirdParty(lines, width); + else this.renderCustom(lines, width); + + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private renderInstalled(lines: string[], width: number): void { + const { installed } = this.opts; + const colors = currentTheme.palette; + if (installed.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No plugins installed.')); + } else { + for (let i = 0; i < installed.length; i++) { + lines.push(...this.renderInstalledRow(installed[i]!, i, width)); + } + } + lines.push(''); + lines.push(mutedHintLine(` ${installed.length} installed`, colors)); + } + + private installedHint(): string { + const plugin = this.opts.installed[this.selectedIndex]; + const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined; + const enter = hasUpdate ? 'Enter update' : 'Enter details'; + return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`; + } + + private installedUpdateStatus( + plugin: PluginSummary, + ): { entry: PluginMarketplaceEntry; local: string; latest: string } | undefined { + if (this.market.status !== 'loaded') return undefined; + const entry = this.market.entries.find((e) => e.id === plugin.id); + if (entry === undefined) return undefined; + const status = computeUpdateStatus(entry.version, plugin.version, true); + return status.kind === 'update' ? { entry, local: status.local, latest: status.latest } : undefined; + } + + private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] { + const colors = currentTheme.palette; + const selected = index === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const status = pluginStatus(plugin); + const update = this.installedUpdateStatus(plugin); + let line = prefix + labelStyle(plugin.displayName); + if (status !== undefined) { + line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status); + } + if (update !== undefined) { + const badge = `update ${update.local} → ${update.latest}`; + line += ' ' + marketplaceStatusStyle(badge, colors)(badge); + } + if (this.opts.pluginHint?.id === plugin.id) { + line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); + } + const descWidth = Math.max(1, width - 4); + const out = [line]; + for (const descLine of wrapOverviewDescription(overviewPluginDescription(plugin), descWidth)) { + out.push(mutedHintLine(` ${descLine}`, colors)); + } + return out; + } + + private renderMarketplaceTab( + lines: string[], + width: number, + entries: readonly PluginMarketplaceEntry[], + indexOffset = 0, + ): void { + const colors = currentTheme.palette; + if (this.market.status === 'loading' || this.market.status === 'idle') { + lines.push(chalk.hex(colors.textMuted)(' Loading marketplace…')); + return; + } + if (this.market.status === 'error') { + lines.push(chalk.hex(colors.warning)(` Marketplace unavailable: ${this.market.message}`)); + lines.push(mutedHintLine(' Use the Custom tab to install from a URL.', colors)); + return; + } + if (entries.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No plugins found.')); + } else { + for (let i = 0; i < entries.length; i++) { + lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width)); + } + } + const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length; + lines.push(''); + lines.push( + mutedHintLine(` ${installedCount} installed · ${entries.length - installedCount} available`, colors), + ); + lines.push(mutedHintLine(` Source: ${this.market.source}`, colors)); + } + + private renderOfficial(lines: string[], width: number): void { + // Web Bridge is pinned above the catalog and stays visible while the + // catalog loads or errors, since it's built into the TUI rather than + // fetched. Catalog rows shift down by one index to match. + lines.push(...this.renderMarketplaceRow(WEB_BRIDGE_ENTRY, 0, width)); + this.renderMarketplaceTab(lines, width, this.officialCatalogEntries, 1); + } + + private renderThirdParty(lines: string[], width: number): void { + this.renderMarketplaceTab(lines, width, this.thirdPartyEntries); + } + + private renderMarketplaceRow(entry: PluginMarketplaceEntry, index: number, width: number): string[] { + const colors = currentTheme.palette; + const selected = index === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const status = isPinnedWebBridgeEntry(entry) + ? 'open in browser' + : marketplaceEntryStatus(entry, this.installedVersions); + const line = + prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status); + const descWidth = Math.max(1, width - 4); + const out = [line]; + for (const descLine of wrapOverviewDescription(marketplaceEntryDescription(entry), descWidth)) { + out.push(mutedHintLine(` ${descLine}`, colors)); + } + return out; + } + + private renderCustom(lines: string[], width: number): void { + const colors = currentTheme.palette; + lines.push(mutedHintLine(' Install from a GitHub URL (or zip URL / local path):', colors)); + lines.push(''); + lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors)); + } + + private renderInstalling(width: number): string[] { + const colors = currentTheme.palette; + const lines = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Plugins'), + '', + chalk.hex(colors.textMuted)(` Installing ${this.installing} from marketplace…`), + '', + chalk.hex(colors.primary)('─'.repeat(width)), + ]; + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } } function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] { @@ -542,8 +758,8 @@ function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] { function mcpServerDescription(server: PluginMcpServerInfo): string { const action = server.enabled ? 'Enter/Space disable' : 'Enter/Space enable'; - if (server.transport === 'http') { - return `${action} · HTTP · ${server.url ?? server.runtimeName}`; + if (server.transport === 'http' || server.transport === 'sse') { + return `${action} · ${server.transport.toUpperCase()} · ${server.url ?? server.runtimeName}`; } const args = server.args !== undefined && server.args.length > 0 ? ` ${server.args.join(' ')}` : ''; const command = `${server.command ?? ''}${args}`.trim(); @@ -578,6 +794,21 @@ function installStatus(entry: PluginMarketplaceEntry): string { return entry.version === undefined ? 'install' : `install v${entry.version}`; } +function marketplaceEntryStatus( + entry: PluginMarketplaceEntry, + installed: ReadonlyMap<string, string | undefined>, +): string { + const status = computeUpdateStatus(entry.version, installed.get(entry.id), installed.has(entry.id)); + switch (status.kind) { + case 'update': + return `update ${status.local} → ${status.latest}`; + case 'up-to-date': + return status.version === undefined ? 'installed' : `installed · v${status.version}`; + case 'not-installed': + return installStatus(entry); + } +} + function sectionLabel(label: string, colors: ColorPalette): string { return chalk.hex(colors.textDim).bold(` ${label}`); } @@ -594,27 +825,11 @@ function statusStyle( return chalk.hex(colors.warning); } -function pluginShortcutHint(text: string, colors: ColorPalette): string { - const shortcutPattern = /D(?= remove)|M(?= MCP)|Space|Enter|Esc|[←→↑↓]/gu; - let output = ''; - let offset = 0; - - for (const match of text.matchAll(shortcutPattern)) { - const index = match.index; - if (index === undefined) continue; - const token = match[0]; - output += chalk.hex(colors.textMuted)(text.slice(offset, index)); - output += shortcutTokenStyle(token, colors)(token); - offset = index + token.length; +function mutedHintLine(text: string, colors?: ColorPalette): string { + if (colors !== undefined) { + return chalk.hex(colors.textMuted)(text); } - - output += chalk.hex(colors.textMuted)(text.slice(offset)); - return output; -} - -function shortcutTokenStyle(token: string, colors: ColorPalette): (text: string) => string { - if (token === 'D') return chalk.hex(colors.error).bold; - return chalk.hex(colors.primary).bold; + return currentTheme.fg('textMuted', text); } function wrapOverviewDescription(text: string, width: number): string[] { diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts index 8ff2a21a2..7ae0da03d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -12,12 +12,12 @@ * * Keyboard: * - ↑ / ↓ move highlight - * - ← / → page up / down + * - ← / → · PgUp/PgDn page * - Enter on `[ Add New Platform ]` → `onAdd()` - * - d (lowercase) delete with inline `[y/N]` confirmation + * - D delete with inline `[y/N]` confirmation * on a source row → `onDeleteSource(providerIds)` * on `[ Add New Platform ]` → ignored - * - Esc `onClose()` (outside the confirm substate) + * - Esc `onClose()` (outside confirm) * * The `[y/N]` confirmation is a transient substate handled in-component: * while armed, only `y` / `Y` / `n` / `N` / `Esc` are honored and the @@ -42,11 +42,11 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { pageView, type PageView } from '#/tui/utils/paging'; @@ -60,7 +60,6 @@ export interface ProviderManagerOptions { readonly providers: Record<string, ProviderConfig>; /** Provider id of the currently active model. */ readonly activeProviderId?: string; - readonly colors: ColorPalette; readonly onAdd: () => void; /** Delete all providers under a source (Open Platform / custom-registry * fetch / standalone). Passed the full provider-id list so the host @@ -92,7 +91,7 @@ type Row = SourceRow | AddRow; const ADD_ROW_LABEL = '[ Add New Platform ]'; const PAGE_SIZE = 8; -const HEADER_HINT = '↑↓ navigate · ←→ page · d delete · Esc close'; +const HEADER_HINT = '↑↓ navigate · D delete · Esc cancel'; // Narrows a `ProviderConfig` blob to a `CustomRegistrySource` payload. // Mirrors `readCustomRegistrySource` in `kimi-tui.ts`. We can't import @@ -225,7 +224,7 @@ export class ProviderManagerComponent extends Container implements Focusable { (row) => row.kind === 'source' && row.providerIds.includes(opts.activeProviderId ?? ''), ) : -1; - this.selectedIndex = activeIdx >= 0 ? activeIdx : 0; + this.selectedIndex = Math.max(activeIdx, 0); this.confirm = undefined; } @@ -261,6 +260,7 @@ export class ProviderManagerComponent extends Container implements Focusable { this.invalidate(); } + /** Rows after applying the active fuzzy filter; the add-row is always kept. */ private page(): PageView { return pageView(this.rows.length, this.selectedIndex, PAGE_SIZE); } @@ -276,42 +276,45 @@ export class ProviderManagerComponent extends Container implements Focusable { return; } + const rows = this.rows; + if (matchesKey(data, Key.up)) { - if (this.rows.length === 0) return; + if (rows.length === 0) return; this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.invalidate(); return; } if (matchesKey(data, Key.down)) { - if (this.rows.length === 0) return; - this.selectedIndex = Math.min(this.rows.length - 1, this.selectedIndex + 1); + if (rows.length === 0) return; + this.selectedIndex = Math.min(rows.length - 1, this.selectedIndex + 1); this.invalidate(); return; } if (matchesKey(data, Key.left) || matchesKey(data, Key.pageUp)) { - if (this.rows.length === 0) return; + if (rows.length === 0) return; this.selectedIndex = Math.max(0, this.selectedIndex - PAGE_SIZE); this.invalidate(); return; } if (matchesKey(data, Key.right) || matchesKey(data, Key.pageDown)) { - if (this.rows.length === 0) return; - this.selectedIndex = Math.min(this.rows.length - 1, this.selectedIndex + PAGE_SIZE); + if (rows.length === 0) return; + this.selectedIndex = Math.min(rows.length - 1, this.selectedIndex + PAGE_SIZE); this.invalidate(); return; } if (matchesKey(data, Key.enter)) { - const selected = this.rows[this.selectedIndex]; + const selected = rows[this.selectedIndex]; if (selected?.kind === 'add') { this.opts.onAdd(); } return; } - const k = printableChar(data); - if (k === 'd') { + // Delete the highlighted provider with the D key. + const ch = printableChar(data); + if (ch === 'd' || ch === 'D') { this.armDeleteConfirm(); } } @@ -350,25 +353,26 @@ export class ProviderManagerComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const lines: string[] = []; - const border = chalk.hex(colors.primary)('─'.repeat(width)); - lines.push(border); - lines.push(renderHeader(width, colors)); + // Header shape mirrors the model dialog (see model-selector.ts): a single + // top border, the title, the keymap hint, then a blank line. No inner + // border under the title. + const border = currentTheme.fg('primary', '─'.repeat(width)); lines.push(border); + lines.push(currentTheme.boldFg('primary', ' Providers')); + lines.push(currentTheme.fg('textMuted', ' ' + HEADER_HINT)); lines.push(''); - lines.push(renderColumnHeader(width, colors)); - - if (this.rows.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No providers configured.')); + const rows = this.rows; + if (rows.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No providers configured.')); } else { const view = this.page(); for (let i = view.start; i < view.end; i++) { - const row = this.rows[i]; + const row = rows[i]; if (row === undefined) continue; - for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width, colors })) { + for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width })) { lines.push(line); } } @@ -382,12 +386,12 @@ export class ProviderManagerComponent extends Container implements Focusable { const view = this.page(); if (view.pageCount > 1) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', ` Page ${String(view.page + 1)}/${String(view.pageCount)}`, ), ); } - lines.push(renderFooterHint(width, colors)); } lines.push(border); @@ -395,66 +399,49 @@ export class ProviderManagerComponent extends Container implements Focusable { } private renderConfirmLine(width: number): string { - const { colors } = this.opts; const confirm = this.confirm; const prompt = confirm?.label ?? ''; - const styled = chalk.hex(colors.warning).bold(` ${prompt} [y/N]`); + const styled = currentTheme.boldFg('warning', ` ${prompt} [y/N]`); return truncateToWidth(styled, width, '…'); } } -function renderHeader(width: number, colors: ColorPalette): string { - const title = chalk.hex(colors.primary).bold(' Providers'); - return truncateToWidth(title, width, '…'); -} - -function renderColumnHeader(width: number, colors: ColorPalette): string { - const muted = chalk.hex(colors.textMuted); - const padded = padCell('', Math.max(0, width - 2)); - return ` ${muted(padded)}`; -} - -function renderFooterHint(width: number, colors: ColorPalette): string { - const hint = chalk.hex(colors.textMuted)(' ' + HEADER_HINT); - return truncateToWidth(hint, width, '…'); -} function renderRow( row: Row, - ctx: { isSelected: boolean; width: number; colors: ColorPalette }, + ctx: { isSelected: boolean; width: number }, ): string[] { - const { isSelected, width, colors } = ctx; - const pointer = isSelected ? '❯' : ' '; - const pointerStyle = isSelected ? chalk.hex(colors.primary) : chalk.hex(colors.textDim); - const indicatorStyle = chalk.hex(colors.success); - const labelStyle = - row.kind === 'add' - ? chalk.hex(colors.textMuted) - : isSelected - ? chalk.hex(colors.primary).bold - : chalk.hex(colors.text); + const { isSelected, width } = ctx; + const pointer = isSelected ? SELECT_POINTER : ' '; + const pointerStyle = (text: string) => + isSelected ? currentTheme.fg('primary', text) : currentTheme.fg('textDim', text); + // The synthetic "Add New Platform" row is an action/CTA: keep it in the brand + // color so it never reads as disabled, and bold it when selected (matching + // the other rows' selected treatment). + const labelStyle = (text: string) => + isSelected + ? currentTheme.boldFg('primary', text) + : row.kind === 'add' + ? currentTheme.fg('primary', text) + : currentTheme.fg('text', text); - const indicatorRendered = - row.kind === 'source' && row.hasActive ? indicatorStyle('● ') : ' '; + // The active provider is flagged with a trailing "← current" (success), + // matching the model selector's current-item marker — see .agents/skills/write-tui/DESIGN.md. + const isActive = row.kind === 'source' && row.hasActive; + const marker = isActive ? ` ${CURRENT_MARK}` : ''; - // Reserve 2 leading spaces + 2 for pointer + 2 for indicator. - const labelWidth = Math.max(0, width - 6); + // Reserve 2 leading spaces + 2 for the pointer + room for the marker. + const labelWidth = Math.max(0, width - 4 - visibleWidth(marker)); const labelText = truncateToWidth(row.label, labelWidth, '…'); - const styledLabel = labelStyle(labelText); - const labelPadded = styledLabel + ' '.repeat(Math.max(0, labelWidth - visibleWidth(labelText))); + let line = ` ${pointerStyle(`${pointer} `)}${labelStyle(labelText)}`; + if (isActive) line += currentTheme.fg('success', marker); - const lines: string[] = [` ${pointerStyle(`${pointer} `)}${indicatorRendered}${labelPadded}`]; + const lines: string[] = [line]; if (row.kind === 'source' && row.baseUrl !== undefined && row.baseUrl.length > 0) { const urlText = truncateToWidth(row.baseUrl, Math.max(0, width - 6), '…'); - lines.push(chalk.hex(colors.textMuted)(` ${urlText}`)); + lines.push(currentTheme.fg('textMuted', ` ${urlText}`)); } return lines; } - -function padCell(text: string, width: number): string { - const w = visibleWidth(text); - if (w >= width) return truncateToWidth(text, width, '…'); - return text + ' '.repeat(width - w); -} diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index 6c31e342c..6764b88d8 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -15,15 +15,14 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; import type { PendingQuestion, QuestionPanelResponse, QuestionSubmissionMethod, } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; const NUMBER_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; const MAX_BODY_LINES = 12; @@ -74,7 +73,6 @@ export class QuestionDialogComponent extends Container implements Focusable { focused = false; private readonly request: PendingQuestion; - private readonly colors: ColorPalette; private readonly onAnswer: (response: QuestionPanelResponse) => void; private readonly maxVisibleOptions: number; private readonly otherInput = new Input(); @@ -99,23 +97,18 @@ export class QuestionDialogComponent extends Container implements Focusable { private readonly answers: (string | undefined)[]; private readonly onToggleToolOutput: (() => void) | undefined; - private readonly onTogglePlanExpand: (() => void) | undefined; constructor( request: PendingQuestion, onAnswer: (response: QuestionPanelResponse) => void, - colors: ColorPalette, maxVisibleOptions = 6, onToggleToolOutput?: () => void, - onTogglePlanExpand?: () => void, ) { super(); this.request = request; this.onAnswer = onAnswer; - this.colors = colors; this.maxVisibleOptions = maxVisibleOptions; this.onToggleToolOutput = onToggleToolOutput; - this.onTogglePlanExpand = onTogglePlanExpand; this.otherInput.onSubmit = (value) => { this.commitOtherInput(value, 'enter'); }; @@ -147,11 +140,6 @@ export class QuestionDialogComponent extends Container implements Focusable { return; } - if (matchesKey(data, Key.ctrl('e'))) { - this.onTogglePlanExpand?.(); - return; - } - if (this.isEditingOther()) { this.handleOtherInput(data); return; @@ -453,13 +441,12 @@ export class QuestionDialogComponent extends Container implements Focusable { const question = this.request.data.questions[questionIdx]; if (question === undefined) return []; - const colors = this.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const success = chalk.hex(colors.success); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const success = (text: string) => currentTheme.fg('success', text); const renderWidth = Math.max(1, width); - const lines: string[] = [accent('─'.repeat(renderWidth)), accent.bold(' question'), '']; + const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; this.pushTabs(lines); lines.push(''); @@ -509,13 +496,13 @@ export class QuestionDialogComponent extends Container implements Focusable { if (question.multi_select) { const checked = isSelected ? '✓' : ' '; prefix = ` [${checked}] `; - if (isSelected && isCursor) tone = (s) => success.bold(s); + if (isSelected && isCursor) tone = (s) => currentTheme.boldFg('success', s); else if (isSelected) tone = success; else if (isCursor) tone = accent; else tone = dim; } else if (isSelected && this.isAnswered(questionIdx)) { prefix = isCursor ? ` → [${String(num)}] ` : ` [${String(num)}] `; - tone = isCursor ? (s) => success.bold(s) : success; + tone = isCursor ? (s) => currentTheme.boldFg('success', s) : success; } else if (isCursor) { prefix = ` → [${String(num)}] `; tone = accent; @@ -551,17 +538,16 @@ export class QuestionDialogComponent extends Container implements Focusable { } private renderSubmitTab(width: number): string[] { - const colors = this.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const text = chalk.hex(colors.text); - const warning = chalk.hex(colors.warning); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const text = (t: string) => currentTheme.fg('text', t); + const warning = (text: string) => currentTheme.fg('warning', text); const renderWidth = Math.max(1, width); - const lines: string[] = [accent('─'.repeat(renderWidth)), accent.bold(' question'), '']; + const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; this.pushTabs(lines); lines.push(''); - lines.push(text.bold(` ${REVIEW_TITLE}`)); + lines.push(currentTheme.boldFg('text', ` ${REVIEW_TITLE}`)); const reviewWarning = this.reviewMessage ?? (this.hasUnansweredQuestions() ? UNANSWERED_WARNING : undefined); if (reviewWarning !== undefined) { @@ -616,8 +602,9 @@ export class QuestionDialogComponent extends Container implements Focusable { } private pushTabs(lines: string[]): void { - const dim = chalk.hex(this.colors.textDim); - const active = chalk.bgHex(this.colors.primary).hex(this.colors.text).bold; + const dim = (text: string) => currentTheme.fg('textDim', text); + const active = (text: string) => + currentTheme.bg('primary', currentTheme.boldFg('text', text)); const tabs: string[] = []; for (let i = 0; i < this.request.data.questions.length; i++) { @@ -628,7 +615,7 @@ export class QuestionDialogComponent extends Container implements Focusable { ? question.header : `Q${String(i + 1)}`; if (i === this.currentTab) tabs.push(active(` ${label} `)); - else if (this.isAnswered(i)) tabs.push(chalk.hex(this.colors.success)(`(✓) ${label}`)); + else if (this.isAnswered(i)) tabs.push(currentTheme.fg('success', `(✓) ${label}`)); else tabs.push(dim(`(○) ${label}`)); } @@ -645,7 +632,7 @@ export class QuestionDialogComponent extends Container implements Focusable { 'type answer', '↵ save', ...(this.totalTabs() > 1 ? ['tab switch'] : []), - 'esc dismiss', + 'esc cancel', ]; return dim(` ${parts.join(' ')}`); } @@ -653,21 +640,21 @@ export class QuestionDialogComponent extends Container implements Focusable { const optionCount = Math.min(this.displayOptions(questionIdx).length, NUMBER_KEYS.length); const numberHint = optionCount <= 1 ? '1' : `1-${String(optionCount)}`; const question = this.request.data.questions[questionIdx]; - if (question === undefined) return dim(' esc dismiss'); + if (question === undefined) return dim(' esc cancel'); const parts: string[] = [ - '▲/▼ select', + '↑↓ select', `${numberHint} / ↵ ${question.multi_select ? 'toggle' : 'choose'}`, ]; if (this.totalTabs() > 1) parts.push('←/→/tab switch'); - parts.push('esc dismiss'); + parts.push('esc cancel'); return dim(` ${parts.join(' ')}`); } private buildSubmitHint(dim: (s: string) => string): string { - const parts: string[] = ['▲/▼ select', '1/2 choose', '↵ confirm']; + const parts: string[] = ['↑↓ select', '1/2 choose', '↵ confirm']; if (this.totalTabs() > 1) parts.push('←/→/tab switch'); - parts.push('esc dismiss'); + parts.push('esc cancel'); return dim(` ${parts.join(' ')}`); } @@ -758,14 +745,14 @@ export class QuestionDialogComponent extends Container implements Focusable { const checked = isSelected ? '✓' : ' '; const body = ` [${checked}] ${option.label}: `; prefix = isSelected - ? chalk.hex(this.colors.success).bold(body) - : chalk.hex(this.colors.primary)(body); + ? currentTheme.boldFg('success', body) + : currentTheme.fg('primary', body); } else { const body = ` → [${String(num)}] ${option.label}: `; prefix = isSelected && this.isAnswered(questionIdx) - ? chalk.hex(this.colors.success).bold(body) - : chalk.hex(this.colors.primary)(body); + ? currentTheme.boldFg('success', body) + : currentTheme.fg('primary', body); } const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index da1a139e6..c8bd9017b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -9,11 +9,11 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - +} from '@moonshot-ai/pi-tui'; import { formatSessionLabel } from '#/migration/index'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { SearchableList } from '#/tui/utils/searchable-list'; export interface SessionRow { readonly id: string; @@ -25,7 +25,6 @@ export interface SessionRow { } const ELLIPSIS = '…'; -const CURRENT_BADGE = '(current)'; function formatRelativeTime(ts: number): string { // SessionSummary timestamps come from filesystem stat `*timeMs`, @@ -75,118 +74,232 @@ function singleLine(text: string): string { return text.replaceAll(/\s+/g, ' ').trim(); } +function sessionSearchText(session: SessionRow): string { + return singleLine((session.title ?? session.id).trim() || session.id); +} + export class SessionPickerComponent extends Container implements Focusable { private sessions: SessionRow[]; private currentSessionId: string; - private colors: ColorPalette; - private onSelect: (sessionId: string) => void; + private onSelect: (session: SessionRow) => void; private onCancel: () => void; + private onToggleScope?: (selectedSessionId: string) => void; private maxVisibleSessions: number; + private pageSize: number; + private visibleCount: number; + private scope: 'cwd' | 'all'; private loading: boolean; + private list: SearchableList<SessionRow>; focused = false; - private selectedIndex = 0; constructor(opts: { sessions: SessionRow[]; loading: boolean; currentSessionId: string; - colors: ColorPalette; - onSelect: (sessionId: string) => void; + scope?: 'cwd' | 'all'; + initialSelectedSessionId?: string; + pageSize?: number; + onSelect: (session: SessionRow) => void; onCancel: () => void; + onCtrlC?: () => void; + onCtrlD?: () => void; + onToggleScope?: (selectedSessionId: string) => void; maxVisibleSessions?: number; }) { super(); this.sessions = opts.sessions; this.loading = opts.loading; this.currentSessionId = opts.currentSessionId; - this.colors = opts.colors; + this.scope = opts.scope ?? 'cwd'; this.onSelect = opts.onSelect; this.onCancel = opts.onCancel; + this.onToggleScope = opts.onToggleScope; this.maxVisibleSessions = opts.maxVisibleSessions ?? 4; + this.pageSize = Math.max(1, opts.pageSize ?? 50); + const initialIndex = this.resolveInitialSelectedIndex(opts.initialSelectedSessionId); + this.list = new SearchableList({ + items: this.sessions, + toSearchText: sessionSearchText, + pageSize: this.pageSize, + initialIndex, + searchable: true, + }); + const initialLoadedPages = Math.ceil((initialIndex + 1) / this.pageSize); + this.visibleCount = Math.min(this.sessions.length, initialLoadedPages * this.pageSize); + this.onCtrlC = opts.onCtrlC; + this.onCtrlD = opts.onCtrlD; + } + + private readonly onCtrlC?: () => void; + private readonly onCtrlD?: () => void; + + private resolveInitialSelectedIndex(initialSelectedSessionId: string | undefined): number { + if (initialSelectedSessionId === undefined) return 0; + const index = this.sessions.findIndex((session) => session.id === initialSelectedSessionId); + return Math.max(index, 0); + } + + private filteredSessions(): readonly SessionRow[] { + return this.list.view().items; + } + + private loadedSessions(sessions: readonly SessionRow[] = this.filteredSessions()): SessionRow[] { + return sessions.slice(0, Math.min(sessions.length, this.visibleCount)); + } + + private syncVisibleCount(previousQuery: string): void { + const view = this.list.view(); + if (view.query !== previousQuery) { + this.visibleCount = Math.min(view.items.length, this.pageSize); + return; + } + + const loadedCount = Math.min(view.items.length, this.visibleCount); + if (view.selectedIndex >= loadedCount - 1 && loadedCount < view.items.length) { + this.visibleCount = Math.min(view.items.length, this.visibleCount + this.pageSize); + } } handleInput(data: string): void { + if (matchesKey(data, Key.ctrl('c'))) { + this.onCtrlC?.(); + return; + } + if (matchesKey(data, Key.ctrl('d'))) { + this.onCtrlD?.(); + return; + } + if (matchesKey(data, Key.ctrl('a'))) { + this.onToggleScope?.(this.list.selected()?.id ?? this.currentSessionId); + return; + } if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) { + this.visibleCount = Math.min(this.filteredSessions().length, this.pageSize); + return; + } this.onCancel(); return; } - if (matchesKey(data, Key.enter) && this.sessions.length > 0) { - const session = this.sessions[this.selectedIndex]; - if (session) this.onSelect(session.id); + if (matchesKey(data, Key.enter)) { + const session = this.list.selected(); + if (session) this.onSelect(session); return; } - if (matchesKey(data, Key.up)) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return; - } - if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(this.sessions.length - 1, this.selectedIndex + 1); + + const previousQuery = this.list.view().query; + if (this.list.handleKey(data)) { + this.syncVisibleCount(previousQuery); } } override render(width: number): string[] { - const colors = this.colors; - const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; + return this.renderLines(width).map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + // Builds the raw lines; `render()` applies a final width clamp so no line + // can ever exceed the terminal width. The per-line budgets below keep the + // layout tidy at normal widths, but on a very narrow terminal those budgets + // floor at a minimum and the trailing time/badge are appended in full, so + // the clamp in `render()` is what guarantees the renderer's invariant and + // prevents the "Rendered line exceeds terminal width" crash (issue #240). + private renderLines(width: number): string[] { + const lines: string[] = [currentTheme.fg('primary', '─'.repeat(width))]; + const title = this.scope === 'all' ? 'All sessions' : 'Sessions'; + const scopeHint = + this.onToggleScope === undefined + ? undefined + : this.scope === 'all' + ? 'Ctrl+A current cwd' + : 'Ctrl+A all'; if (this.loading) { - lines.push(chalk.hex(colors.primary).bold(truncateToWidth('Sessions', width, ELLIPSIS))); + lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS))); lines.push( - chalk.hex(colors.textMuted)(truncateToWidth('Loading sessions...', width, ELLIPSIS)), + currentTheme.fg('textMuted', truncateToWidth('Loading sessions...', width, ELLIPSIS)), ); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } if (this.sessions.length === 0) { - lines.push(chalk.hex(colors.primary).bold(truncateToWidth('Sessions', width, ELLIPSIS))); - lines.push( - chalk.hex(colors.textMuted)( - truncateToWidth('No sessions found. Press Escape to close.', width, ELLIPSIS), - ), + const hintParts = [scopeHint, 'Esc cancel'].filter( + (item): item is string => item !== undefined, ); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS))); + lines.push( + currentTheme.fg('textMuted', truncateToWidth(hintParts.join(' · '), width, ELLIPSIS)), + ); + lines.push(''); + lines.push( + currentTheme.fg('textMuted', truncateToWidth('No sessions found.', width, ELLIPSIS)), + ); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } - const headerLabel = 'Sessions '; - const headerHint = '(↑↓ navigate, Enter select, Esc cancel)'; - const labelWidth = visibleWidth(headerLabel); - const hintBudget = Math.max(0, width - labelWidth); - const shownHint = truncateToWidth(headerHint, hintBudget, ELLIPSIS); - lines.push( - chalk.hex(colors.primary).bold(headerLabel) + chalk.hex(colors.textMuted)(shownHint), - ); + const view = this.list.view(); + const titleSuffix = + view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintParts = [ + ...(view.query.length > 0 ? ['Backspace clear'] : []), + '↑↓ navigate', + scopeHint, + 'Enter select', + 'Esc cancel', + ].filter((item): item is string => item !== undefined); + + lines.push(currentTheme.boldFg('primary', title) + titleSuffix); + lines.push(currentTheme.fg('textMuted', hintParts.join(' · '))); lines.push(''); + if (view.query.length > 0) { + lines.push(currentTheme.fg('primary', 'Search: ') + currentTheme.fg('text', view.query)); + } + + const loadedSessions = this.loadedSessions(view.items); + if (loadedSessions.length === 0) { + lines.push(currentTheme.fg('textMuted', truncateToWidth('No matches', width, ELLIPSIS))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines; + } + const selectedIndex = view.selectedIndex; const visibleStart = Math.max( 0, Math.min( - this.selectedIndex - Math.floor(this.maxVisibleSessions / 2), - Math.max(0, this.sessions.length - this.maxVisibleSessions), + selectedIndex - Math.floor(this.maxVisibleSessions / 2), + Math.max(0, loadedSessions.length - this.maxVisibleSessions), ), ); - const visibleSessions = this.sessions.slice( + const visibleSessions = loadedSessions.slice( visibleStart, visibleStart + this.maxVisibleSessions, ); for (const [vi, session] of visibleSessions.entries()) { const index = visibleStart + vi; - const isSelected = index === this.selectedIndex; + const isSelected = index === selectedIndex; const isCurrent = session.id === this.currentSessionId; const card = this.renderSessionCard(width, session, isSelected, isCurrent); lines.push(...card); if (vi < visibleSessions.length - 1) lines.push(''); } - if (this.sessions.length > visibleSessions.length) { + const filteredCount = view.items.length; + if (loadedSessions.length > visibleSessions.length || view.query.length > 0) { lines.push(''); - const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${String(this.sessions.length)} sessions`; - lines.push(chalk.hex(colors.textMuted)(truncateToWidth(footer, width, ELLIPSIS))); + const totalSuffix = + view.query.length > 0 + ? `${String(loadedSessions.length)} loaded / ${String(filteredCount)} matches` + : loadedSessions.length === this.sessions.length + ? `${String(loadedSessions.length)} sessions` + : `${String(loadedSessions.length)} loaded / ${String(this.sessions.length)} sessions`; + const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${totalSuffix}`; + lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS))); } - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } @@ -196,19 +309,19 @@ export class SessionPickerComponent extends Container implements Focusable { isSelected: boolean, isCurrent: boolean, ): string[] { - const colors = this.colors; - const pointer = isSelected ? '❯' : ' '; + const pointer = isSelected ? SELECT_POINTER : ' '; const indent = ' '; const indentWidth = visibleWidth(indent); - const titleColor = isSelected ? colors.primary : colors.text; - const titleStyle = isSelected ? chalk.hex(titleColor).bold : chalk.hex(titleColor); + const titleColor: 'primary' | 'text' = isSelected ? 'primary' : 'text'; + const titleStyle = (text: string) => + isSelected ? currentTheme.boldFg(titleColor, text) : currentTheme.fg(titleColor, text); const time = formatRelativeTime(session.updated_at); - const badge = isCurrent ? CURRENT_BADGE : ''; + const badge = isCurrent ? CURRENT_MARK : ''; const rawTitle = (session.title ?? session.id).trim() || session.id; const titleSource = formatSessionLabel({ title: rawTitle, metadata: session.metadata }); - // Inline trailing parts after the title: "<title> <time> (current)". + // Inline trailing parts after the title: "<title> <time> ← current". const trailingParts = [time, badge].filter((p) => p.length > 0); const trailingText = trailingParts.length > 0 ? ' ' + trailingParts.join(' ') : ''; const trailingWidth = visibleWidth(trailingText); @@ -216,14 +329,15 @@ export class SessionPickerComponent extends Container implements Focusable { const titleBudget = Math.max(8, width - headerPrefixWidth - trailingWidth); const shownTitle = truncateToWidth(singleLine(titleSource), titleBudget, ELLIPSIS); - let header = chalk.hex(isSelected ? colors.primary : colors.textDim)(pointer + ' '); + let header = currentTheme.fg(isSelected ? 'primary' : 'textDim', pointer + ' '); header += titleStyle(shownTitle); - if (time.length > 0) header += ' ' + chalk.hex(colors.textDim)(time); - if (badge.length > 0) header += ' ' + chalk.hex(colors.success)(badge); + if (time.length > 0) header += ' ' + currentTheme.fg('textDim', time); + if (badge.length > 0) header += ' ' + currentTheme.fg('success', badge); const card: string[] = [header]; - // Session id is rendered in full (no truncation). The directory wraps to - // its own line if it would push past the terminal edge. + // Session id is rendered in full at normal widths (the final clamp in + // `render()` truncates it only when the terminal is narrower than the id). + // The directory wraps to its own line if it would push past the edge. const fullId = session.id; const idWidth = visibleWidth(fullId); const metaGap = ' '; @@ -235,22 +349,23 @@ export class SessionPickerComponent extends Container implements Focusable { if (idLineWidth + metaGapWidth + dirWidth <= width) { card.push( indent + - chalk.hex(colors.textMuted)(fullId) + - chalk.hex(colors.textDim)(metaGap) + - chalk.hex(colors.textMuted)(aliasedDir), + currentTheme.fg('textMuted', fullId) + + currentTheme.fg('textDim', metaGap) + + currentTheme.fg('textMuted', aliasedDir), ); } else { // Not enough room for both on one line — keep the id intact and put the // directory on the next line (left-truncated only if it still doesn't fit). card.push( indent + - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', truncateToWidth(fullId, Math.max(idWidth, width - indentWidth), ELLIPSIS), ), ); const dirBudget = Math.max(8, width - indentWidth); const dir = truncatePathLeft(aliasedDir, dirBudget); - card.push(indent + chalk.hex(colors.textMuted)(dir)); + card.push(indent + currentTheme.fg('textMuted', dir)); } const rawPrompt = session.last_prompt?.trim(); @@ -259,7 +374,7 @@ export class SessionPickerComponent extends Container implements Focusable { const promptMarkerWidth = visibleWidth(promptMarker); const promptBudget = Math.max(8, width - indentWidth - promptMarkerWidth); const promptText = truncateToWidth(singleLine(rawPrompt), promptBudget, ELLIPSIS); - const promptLine = indent + chalk.hex(colors.textDim)(promptMarker + promptText); + const promptLine = indent + currentTheme.fg('textDim', promptMarker + promptText); card.push(promptLine); } diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts index fb224345b..81e4b8d12 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -1,8 +1,13 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - -export type SettingsSelection = 'model' | 'theme' | 'editor' | 'permission' | 'usage'; +export type SettingsSelection = + | 'model' + | 'theme' + | 'editor' + | 'permission' + | 'experiments' + | 'upgrade' + | 'usage'; const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ { @@ -25,6 +30,16 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Editor', description: 'Set the external editor command.', }, + { + value: 'experiments', + label: 'Experiments', + description: 'Turn experimental features on or off.', + }, + { + value: 'upgrade', + label: 'Automatic updates', + description: 'Turn automatic CLI updates on or off.', + }, { value: 'usage', label: 'Usage', @@ -38,12 +53,13 @@ function isSettingsSelection(value: string): value is SettingsSelection { value === 'theme' || value === 'editor' || value === 'permission' || + value === 'experiments' || + value === 'upgrade' || value === 'usage' ); } export interface SettingsSelectorOptions { - readonly colors: ColorPalette; readonly onSelect: (value: SettingsSelection) => void; readonly onCancel: () => void; } @@ -53,7 +69,6 @@ export class SettingsSelectorComponent extends ChoicePickerComponent { super({ title: 'Settings', options: [...SETTINGS_OPTIONS], - colors: opts.colors, onSelect: (value) => { if (isSettingsSelection(value)) opts.onSelect(value); }, diff --git a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts new file mode 100644 index 000000000..341ced723 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts @@ -0,0 +1,125 @@ +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Component, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +export type StartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; + +export interface StartPermissionOption<TChoice extends StartPermissionChoice = StartPermissionChoice> { + readonly value: TChoice; + readonly label: string; + readonly description: string; +} + +export interface StartPermissionPromptOptions< + TChoice extends StartPermissionChoice = StartPermissionChoice, +> { + readonly title: string; + readonly noticeLines: readonly string[]; + readonly options: readonly StartPermissionOption<TChoice>[]; + readonly onSelect: (choice: TChoice) => void; + readonly onCancel: () => void; +} + +export class StartPermissionPromptComponent<TChoice extends StartPermissionChoice = StartPermissionChoice> + implements Component, Focusable +{ + focused = false; + private selectedIndex = 0; + + constructor(private readonly opts: StartPermissionPromptOptions<TChoice>) {} + + invalidate(): void {} + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(this.opts.options.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + this.opts.onSelect(this.opts.options[this.selectedIndex]!.value); + } + } + + render(width: number): string[] { + const rule = currentTheme.fg('primary', '─'.repeat(width)); + const lines = [ + rule, + currentTheme.boldFg('primary', ` ${this.opts.title}`), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'), + '', + ]; + + const textWidth = Math.max(20, width - 2); + for (const paragraph of this.opts.noticeLines) { + for (const line of wrapPlain(paragraph, textWidth)) { + lines.push(` ${styleModeNames(line, 'textMuted')}`); + } + lines.push(''); + } + + for (let i = 0; i < this.opts.options.length; i += 1) { + const option = this.opts.options[i]!; + const selected = i === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + lines.push( + currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + + styleLabel(option.label, selected), + ); + for (const line of wrapPlain(option.description, Math.max(20, width - 4))) { + lines.push(` ${styleModeNames(line, 'textMuted')}`); + } + lines.push(''); + } + + lines.push(rule); + return lines.map((line) => truncateToWidth(line, width)); + } +} + +function styleLabel(label: string, selected: boolean): string { + if (selected) return currentTheme.boldFg('primary', label); + return styleModeNames(label, 'text'); +} + +function styleModeNames(text: string, baseToken: 'text' | 'textMuted'): string { + return text + .split(/(\b(?:Manual|Auto|YOLO)\b)/g) + .map((part) => { + if (part === 'Manual' || part === 'Auto' || part === 'YOLO') return currentTheme.boldFg('textStrong', part); + return currentTheme.fg(baseToken, part); + }) + .join(''); +} + +function wrapPlain(text: string, width: number): string[] { + const words = text.split(/\s+/).filter((word) => word.length > 0); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (visibleWidth(candidate) <= width) { + current = candidate; + continue; + } + if (current.length > 0) lines.push(current); + current = visibleWidth(word) <= width ? word : truncateToWidth(word, width, '…'); + } + if (current.length > 0) lines.push(current); + return lines.length > 0 ? lines : ['']; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts new file mode 100644 index 000000000..694c0c0e6 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts @@ -0,0 +1,50 @@ +import { + StartPermissionPromptComponent, + type StartPermissionOption, +} from './start-permission-prompt'; + +export type SwarmStartPermissionChoice = 'auto' | 'yolo' | 'manual'; + +export interface SwarmStartPermissionPromptOptions { + readonly onSelect: (choice: SwarmStartPermissionChoice) => void; + readonly onCancel: () => void; +} + +const OPTIONS: readonly StartPermissionOption<SwarmStartPermissionChoice>[] = [ + { + value: 'auto', + label: 'Switch to Auto and start', + description: + 'Best for swarm tasks. Tools are approved automatically, and questions are skipped.', + }, + { + value: 'yolo', + label: 'Switch to YOLO and start', + description: + 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + }, + { + value: 'manual', + label: 'Start in Manual', + description: + 'Keep approvals on. Kimi Code may stop and wait for you during the swarm task.', + }, +]; + +const NOTICE_LINES = [ + 'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', + 'Manual mode can block swarm work while agents are running.', + 'You can go back without losing your command.', +] as const; + +export class SwarmStartPermissionPromptComponent extends StartPermissionPromptComponent<SwarmStartPermissionChoice> { + constructor(opts: SwarmStartPermissionPromptOptions) { + super({ + title: 'Start a swarm task with approvals on?', + noticeLines: NOTICE_LINES, + options: OPTIONS, + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); + } +} 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 5d04b5900..9a986b096 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -6,12 +6,11 @@ * ['all', ...uniqueProviderIds] (insertion order, deduplicated) * * Each tab owns its own inner ModelSelectorComponent built from the filtered - * subset of models. Up/Down/Enter/Esc/Left/Right are forwarded to the active - * inner selector; Tab / Shift-Tab cycle between tabs. + * subset of models. ↑/↓/Enter/Esc/←/→ (thinking) and typing (filter) are + * forwarded to the active inner selector; Tab / Shift-Tab cycle between tabs. * - * Note: the flat ModelSelectorComponent is intentionally left untouched — the - * OpenPlatform login flow (see promptModelSelectionForOpenPlatform) keeps - * using it directly. + * The active tab is highlighted with a filled background (matching the + * AskUserQuestion dialog's tab strip) — see .agents/skills/write-tui/DESIGN.md. */ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; @@ -20,12 +19,11 @@ import { Key, matchesKey, truncateToWidth, - visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; import { ModelSelectorComponent, @@ -41,12 +39,14 @@ export interface TabbedModelSelectorOptions { readonly models: Record<string, ModelAlias>; readonly currentValue: string; readonly selectedValue?: string; - readonly currentThinking: boolean; - readonly colors: ColorPalette; + readonly currentThinkingEffort: string; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; readonly onSelect: (selection: ModelSelection) => void; + /** Forwarded to each inner selector; when set, Alt+S applies the choice to + * the current session only without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } @@ -67,14 +67,13 @@ export class TabbedModelSelectorComponent extends Container implements Focusable this.opts = opts; this.tabs = buildTabs(opts); - const preferredProvider = - opts.initialTabId ?? - opts.models[opts.selectedValue ?? '']?.provider ?? - opts.models[opts.currentValue]?.provider; - const initialTabIdx = preferredProvider - ? this.tabs.findIndex((tab) => tab.id === preferredProvider) + // Default to the "All" tab. Only an explicit initialTabId (e.g. the + // provider just added via /provider) opens on a specific provider tab — + // the current model is still highlighted inside whichever tab is active. + const initialTabIdx = opts.initialTabId + ? this.tabs.findIndex((tab) => tab.id === opts.initialTabId) : -1; - this.activeIndex = initialTabIdx >= 0 ? initialTabIdx : 0; + this.activeIndex = Math.max(initialTabIdx, 0); this.syncFocusToActive(); } @@ -101,14 +100,24 @@ export class TabbedModelSelectorComponent extends Container implements Focusable if (this.tabs.length <= 1) { return inner.map((line) => truncateToWidth(line, width)); } - // Inject the tab strip just after the inner selector's top divider so - // it sits inside the frame rather than above it. - const stripLine = this.renderTabStrip(width); - const out: string[] = []; - out.push(inner[0] ?? ''); - out.push(stripLine); - out.push(chalk.hex(this.opts.colors.primary)('─'.repeat(width))); - for (let i = 1; i < inner.length; i++) out.push(inner[i]!); + // Layout: divider, title, hint, blank, tab strip, blank, then the model + // list. The inner selector's blank line (inner[3]) separates the hint from + // the tab strip; an extra blank separates the tabs from their list. + const stripLine = renderTabStrip({ + labels: this.tabs.map((tab) => tab.label), + activeIndex: this.activeIndex, + width, + colors: currentTheme.palette, + }); + const out: string[] = [ + inner[0] ?? '', + inner[1] ?? '', + inner[2] ?? '', + inner[3] ?? '', + stripLine, + '', + ]; + for (let i = 4; i < inner.length; i++) out.push(inner[i]!); return out.map((line) => truncateToWidth(line, width)); } @@ -125,91 +134,6 @@ export class TabbedModelSelectorComponent extends Container implements Focusable tab.selector.focused = this.focused && i === this.activeIndex; } } - - private renderTabStrip(width: number): string { - const { colors } = this.opts; - const segments: string[] = []; - for (let i = 0; i < this.tabs.length; i++) { - const tab = this.tabs[i]!; - const isActive = i === this.activeIndex; - const label = ` ${tab.label} `; - const styled = isActive - ? chalk.hex(colors.primary).bold(`[${label}]`) - : chalk.hex(colors.textMuted)(` ${label} `); - segments.push(styled); - } - - // If everything fits with a leading space, show all. - const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0); - if (1 + totalSegmentWidth <= width) { - const hint = chalk.hex(colors.textMuted)('Tab / Shift+Tab provider'); - let strip = ' ' + segments.join(''); - const available = width - visibleWidth(strip) - 1; - if (available >= visibleWidth(hint) + 1) { - const pad = ' '.repeat(available - visibleWidth(hint)); - strip += pad + hint; - } - return strip; - } - - // Scrolling needed. Find the widest window that contains activeIndex. - const segmentWidths = segments.map((s) => visibleWidth(s)); - let start = this.activeIndex; - let end = this.activeIndex + 1; - let contentWidth = segmentWidths[this.activeIndex]!; - - const fits = (s: number, e: number, cw: number): boolean => { - const needLeft = s > 0; - const needRight = e < segments.length; - const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0); - return cw + frameWidth <= width; - }; - - while (true) { - const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity; - const rightW = end < segments.length ? segmentWidths[end]! : Infinity; - if (leftW === Infinity && rightW === Infinity) break; - - if (leftW <= rightW) { - if (fits(start - 1, end, contentWidth + leftW)) { - contentWidth += leftW; - start--; - } else if (fits(start, end + 1, contentWidth + rightW)) { - contentWidth += rightW; - end++; - } else { - break; - } - } else { - if (fits(start, end + 1, contentWidth + rightW)) { - contentWidth += rightW; - end++; - } else if (fits(start - 1, end, contentWidth + leftW)) { - contentWidth += leftW; - start--; - } else { - break; - } - } - } - - const hasLeft = start > 0; - const hasRight = end < segments.length; - let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' '; - strip += segments.slice(start, end).join(''); - if (hasRight) { - strip += chalk.hex(colors.textMuted)(' >'); - } - - const hint = chalk.hex(colors.textMuted)('Tab / Shift+Tab provider'); - const available = width - visibleWidth(strip) - 1; - if (available >= visibleWidth(hint) + 1) { - const pad = ' '.repeat(available - visibleWidth(hint)); - strip += pad + hint; - } - - return strip; - } } function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] { @@ -224,12 +148,13 @@ function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] { } } - const tabs: ModelTab[] = []; - tabs.push({ - id: ALL_TAB_ID, - label: ALL_TAB_LABEL, - selector: makeSelector(opts, opts.models), - }); + const tabs: ModelTab[] = [ + { + id: ALL_TAB_ID, + label: ALL_TAB_LABEL, + selector: makeSelector(opts, opts.models), + }, + ]; for (const providerId of providerIds) { const subset: Record<string, ModelAlias> = {}; for (const [alias, model] of entries) { @@ -254,11 +179,11 @@ function makeSelector( models: subset, currentValue: opts.currentValue, ...(selectedValue !== undefined ? { selectedValue } : {}), - currentThinking: opts.currentThinking, - colors: opts.colors, + currentThinkingEffort: opts.currentThinkingEffort, searchable: true, providerSwitchHint: true, onSelect: opts.onSelect, + onSessionOnlySelect: opts.onSessionOnlySelect, onCancel: opts.onCancel, }; return new ModelSelectorComponent(inner); diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index 0f1fa04b7..c0f647f67 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -17,11 +17,10 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '@/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -30,31 +29,29 @@ export interface TaskOutputViewerProps { readonly taskId: string; readonly info: BackgroundTaskInfo | undefined; readonly output: string; - readonly colors: ColorPalette; readonly onClose: () => void; } const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { running: 'running', - awaiting_approval: 'awaiting', completed: 'completed', failed: 'failed', + timed_out: 'timed out', killed: 'killed', lost: 'lost', }; -function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string { +function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { switch (status) { case 'running': - return colors.success; - case 'awaiting_approval': - return colors.warning; + return 'success'; case 'completed': - return colors.textMuted; + return 'textMuted'; case 'failed': + case 'timed_out': case 'killed': case 'lost': - return colors.error; + return 'error'; } } @@ -128,11 +125,20 @@ export class TaskOutputViewer extends Container implements Focusable { this.scrollBy(1); return; } - if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\u0002' /* C-b */) { + if ( + matchesKey(data, Key.pageUp) || + matchesKey(data, Key.ctrl('u')) || + k === ' ' || + data === '\u0002' /* C-b */ + ) { this.scrollBy(-Math.max(1, visible - 1)); return; } - if (matchesKey(data, Key.pageDown) || data === '\u0006' /* C-f */) { + if ( + matchesKey(data, Key.pageDown) || + matchesKey(data, Key.ctrl('d')) || + data === '\u0006' /* C-f */ + ) { this.scrollBy(Math.max(1, visible - 1)); return; } @@ -184,18 +190,17 @@ export class TaskOutputViewer extends Container implements Focusable { } private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' Task output '); - const id = chalk.hex(colors.text).bold(this.props.taskId); + const title = currentTheme.boldFg('primary', ' Task output '); + const id = currentTheme.boldFg('text', this.props.taskId); const info = this.props.info; const segments: string[] = []; if (info !== undefined) { - segments.push(chalk.hex(statusColor(colors, info.status))(STATUS_LABEL[info.status])); - if (info.exitCode !== null && info.exitCode !== undefined) { - segments.push(chalk.hex(colors.textMuted)(`exit ${String(info.exitCode)}`)); + segments.push(currentTheme.fg(statusColor(info.status), STATUS_LABEL[info.status])); + if (info.kind === 'process' && info.exitCode !== null) { + segments.push(currentTheme.fg('textMuted', `exit ${String(info.exitCode)}`)); } if (info.description && info.description.length > 0) { - segments.push(chalk.hex(colors.textMuted)(info.description)); + segments.push(currentTheme.fg('textMuted', info.description)); } } const composed = title + id + (segments.length > 0 ? ' ' + segments.join(' ') : ''); @@ -203,9 +208,6 @@ export class TaskOutputViewer extends Container implements Focusable { } private renderBody(width: number, bodyHeight: number): string[] { - const colors = this.props.colors; - const stroke = colors.primary; - // Reserve 1 col for left/right border each, 1 col for left padding. const innerWidth = Math.max(1, width - 4); @@ -215,24 +217,23 @@ export class TaskOutputViewer extends Container implements Focusable { if (this.scrollTop < 0) this.scrollTop = 0; const viewRows = bodyHeight - 2; // inside top + bottom border - const top = chalk.hex(stroke)('┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); const out: string[] = [top]; for (let i = 0; i < viewRows; i++) { const lineIndex = this.scrollTop + i; const raw = this.lines[lineIndex] ?? ''; - const inner = fitExactly(chalk.hex(colors.text)(raw), innerWidth); - out.push(chalk.hex(stroke)('│ ') + inner + chalk.hex(stroke)(' │')); + const inner = fitExactly(currentTheme.fg('text', raw), innerWidth); + out.push(currentTheme.fg('primary', '│ ') + inner + currentTheme.fg('primary', ' │')); } out.push(bottom); return out; } private renderFooter(width: number, bodyHeight: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); const total = this.lines.length; const viewRows = Math.max(1, bodyHeight - 2); @@ -242,14 +243,15 @@ export class TaskOutputViewer extends Container implements Focusable { const lineFrom = this.scrollTop + 1; const lineTo = Math.min(total, this.scrollTop + viewRows); - const position = chalk.hex(colors.textMuted)( + const position = currentTheme.fg( + 'textMuted', ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, ); const keys = `${key('↑↓')} ${dim('line')} ` + - `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('PgUp/PgDn/Ctrl+U/D')} ${dim('page')} ` + `${key('g/G')} ${dim('top/bot')} ` + - `${key('Q/Esc')} ${dim('back')}`; + `${key('Q/Esc')} ${dim('cancel')}`; const left = ` ${keys}`; const leftW = visibleWidth(left); const rightW = visibleWidth(position); diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 8634e045d..7902e81e1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -21,11 +21,11 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '@/tui/theme/colors'; +import { SELECT_POINTER } from '@/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -39,7 +39,6 @@ export interface TasksBrowserProps { readonly tailOutput: string | undefined; readonly tailLoading: boolean; readonly flashMessage: string | undefined; - readonly colors: ColorPalette; readonly onSelect: (taskId: string) => void; readonly onToggleFilter: () => void; readonly onRefresh: () => void; @@ -54,9 +53,9 @@ export interface TasksBrowserProps { const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { running: 'running', - awaiting_approval: 'awaiting', completed: 'completed', failed: 'failed', + timed_out: 'timed out', killed: 'killed', lost: 'lost', }; @@ -73,24 +72,27 @@ const LIST_COL_MIN = 28; const LIST_COL_MAX = 44; const LIST_COL_RATIO = 0.32; -function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string { +function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { switch (status) { case 'running': - return colors.success; - case 'awaiting_approval': - return colors.warning; + return 'success'; case 'completed': - return colors.textMuted; + return 'textMuted'; case 'failed': + case 'timed_out': case 'killed': case 'lost': - return colors.error; + return 'error'; } } function isTerminal(status: BackgroundTaskStatus): boolean { return ( - status === 'completed' || status === 'failed' || status === 'killed' || status === 'lost' + status === 'completed' || + status === 'failed' || + status === 'timed_out' || + status === 'killed' || + status === 'lost' ); } @@ -128,8 +130,13 @@ function visibleTasks( tasks: readonly BackgroundTaskInfo[], filter: TasksFilter, ): BackgroundTaskInfo[] { - if (filter === 'all') return [...tasks]; - return tasks.filter((t) => !isTerminal(t.status)); + // The /tasks panel is for background task management. Foreground tasks + // (detached === false) are shown in the main transcript instead, and only + // appear here after being detached via Ctrl+B. `detached !== false` keeps + // reconcile ghosts whose `detached` field may be undefined. + const backgroundOnly = tasks.filter((t) => t.detached !== false); + if (filter === 'all') return [...backgroundOnly]; + return backgroundOnly.filter((t) => !isTerminal(t.status)); } function compareTasks(a: BackgroundTaskInfo, b: BackgroundTaskInfo): number { @@ -142,25 +149,22 @@ function compareTasks(a: BackgroundTaskInfo, b: BackgroundTaskInfo): number { interface StatusCounts { running: number; - awaiting: number; completed: number; terminalFailed: number; } function countByStatus(tasks: readonly BackgroundTaskInfo[]): StatusCounts { - const counts: StatusCounts = { running: 0, awaiting: 0, completed: 0, terminalFailed: 0 }; + const counts: StatusCounts = { running: 0, completed: 0, terminalFailed: 0 }; for (const t of tasks) { switch (t.status) { case 'running': counts.running += 1; break; - case 'awaiting_approval': - counts.awaiting += 1; - break; case 'completed': counts.completed += 1; break; case 'failed': + case 'timed_out': case 'killed': case 'lost': counts.terminalFailed += 1; @@ -329,39 +333,40 @@ export class TasksBrowserApp extends Container implements Focusable { // ── header / footer ────────────────────────────────────────────────── private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' TASK BROWSER '); - const filterText = chalk.hex(colors.textMuted)( + const title = currentTheme.boldFg('primary', ' TASK BROWSER '); + const filterText = currentTheme.fg( + 'textMuted', ` filter=${this.props.filter === 'all' ? 'ALL' : 'ACTIVE'} `, ); - const counts = countByStatus(this.props.tasks); + // Count only the tasks actually listed (background tasks after the + // foreground-task filter), so a foreground-only session doesn't read + // "1 running / 1 total" above an empty list. + const visible = visibleTasks(this.props.tasks, this.props.filter); + const counts = countByStatus(visible); const countSegments: string[] = []; if (counts.running > 0) - countSegments.push(chalk.hex(colors.success)(` ${String(counts.running)} running `)); - if (counts.awaiting > 0) - countSegments.push(chalk.hex(colors.warning)(` ${String(counts.awaiting)} awaiting `)); + countSegments.push(currentTheme.fg('success', ` ${String(counts.running)} running `)); if (counts.completed > 0) - countSegments.push(chalk.hex(colors.textDim)(` ${String(counts.completed)} completed `)); + countSegments.push(currentTheme.fg('textDim', ` ${String(counts.completed)} completed `)); if (counts.terminalFailed > 0) countSegments.push( - chalk.hex(colors.error)(` ${String(counts.terminalFailed)} interrupted `), + currentTheme.fg('error', ` ${String(counts.terminalFailed)} interrupted `), ); - const totals = chalk.hex(colors.textMuted)(` ${String(this.props.tasks.length)} total `); + const totals = currentTheme.fg('textMuted', ` ${String(visible.length)} total `); const composed = title + filterText + countSegments.join('') + totals; return fitExactly(composed, width); } private renderFooter(width: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); if (this.pendingStopTaskId !== undefined) { - const warn = (text: string): string => chalk.hex(colors.warning).bold(text); + const warn = (text: string): string => currentTheme.boldFg('warning', text); const line = - ` ${warn('Stop')} ${chalk.hex(colors.text)(this.pendingStopTaskId)}? ` + - `${key('Y')} ${dim('confirm')} ${key('N')} ${dim('cancel')} `; + ` ${warn('Stop')} ${currentTheme.fg('text', this.pendingStopTaskId)}? ` + + `${key('Y')} ${dim('confirm')} ${key('N')}${dim('/')}${key('esc')} ${dim('cancel')} `; return fitExactly(line, width); } @@ -371,12 +376,12 @@ export class TasksBrowserApp extends Container implements Focusable { `${key('S')} ${dim('stop')}`, `${key('R')} ${dim('refresh')}`, `${key('Tab')} ${dim('filter')}`, - `${key('Q/Esc')} ${dim('exit')} `, + `${key('Q/Esc')} ${dim('cancel')} `, ]; const left = parts.join(' '); const flash = this.props.flashMessage; if (flash !== undefined && flash.length > 0) { - const flashStyled = chalk.hex(colors.warning)(` ${flash} `); + const flashStyled = currentTheme.fg('warning', ` ${flash} `); const total = visibleWidth(left) + visibleWidth(flashStyled); if (total <= width) { return left + ' '.repeat(width - total) + flashStyled; @@ -403,29 +408,28 @@ export class TasksBrowserApp extends Container implements Focusable { for (let i = 0; i < height; i++) out.push(' '.repeat(width)); return out; } - const stroke = this.props.colors.primary; const innerWidth = width - 2; const innerHeight = height - 2; - const titleStyled = chalk.hex(this.props.colors.textStrong).bold(title); + const titleStyled = currentTheme.boldFg('textStrong', title); const titleWidth = visibleWidth(titleStyled); const titleSegment = `─ ${titleStyled} `; const titleSegmentWidth = visibleWidth(titleSegment); const remainingDashes = Math.max(0, innerWidth - titleSegmentWidth); const topMid = titleWidth > 0 && titleSegmentWidth <= innerWidth - ? chalk.hex(stroke)('─ ') + + ? currentTheme.fg('primary', '─ ') + titleStyled + ' ' + - chalk.hex(stroke)('─'.repeat(remainingDashes)) - : chalk.hex(stroke)('─'.repeat(innerWidth)); - const top = chalk.hex(stroke)('┌') + topMid + chalk.hex(stroke)('┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(innerWidth) + '┘'); + currentTheme.fg('primary', '─'.repeat(remainingDashes)) + : currentTheme.fg('primary', '─'.repeat(innerWidth)); + const top = currentTheme.fg('primary', '┌') + topMid + currentTheme.fg('primary', '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(innerWidth) + '┘'); const lines: string[] = [top]; for (let i = 0; i < innerHeight; i++) { const inner = content[i] ?? ''; - lines.push(chalk.hex(stroke)('│') + fitExactly(inner, innerWidth) + chalk.hex(stroke)('│')); + lines.push(currentTheme.fg('primary', '│') + fitExactly(inner, innerWidth) + currentTheme.fg('primary', '│')); } lines.push(bottom); return lines; @@ -442,7 +446,7 @@ export class TasksBrowserApp extends Container implements Focusable { this.props.filter === 'active' ? 'No active tasks. Tab = show all.' : 'No background tasks in this session.'; - const lines: string[] = [chalk.hex(this.props.colors.textMuted)(empty)]; + const lines: string[] = [currentTheme.fg('textMuted', empty)]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame(title, lines, width, height); } @@ -463,20 +467,23 @@ export class TasksBrowserApp extends Container implements Focusable { } private renderListRow(task: BackgroundTaskInfo, selected: boolean, innerWidth: number): string { - const colors = this.props.colors; - const pointer = selected ? '> ' : ' '; - const pointerStyled = chalk.hex(selected ? colors.primary : colors.textDim)(pointer); + const pointer = selected ? `${SELECT_POINTER} ` : ' '; + const pointerStyled = currentTheme.fg(selected ? 'primary' : 'textDim', pointer); - const idColor = selected ? colors.primary : task.taskId.startsWith('agent-') - ? colors.success - : colors.accent; + const idColor = selected + ? 'primary' + : task.kind === 'agent' + ? 'success' + : task.kind === 'question' + ? 'warning' + : 'accent'; const idText = selected - ? chalk.hex(idColor).bold(task.taskId) - : chalk.hex(idColor)(task.taskId); + ? currentTheme.boldFg(idColor, task.taskId) + : currentTheme.fg(idColor, task.taskId); const idPad = ' '.repeat(Math.max(0, 17 - task.taskId.length)); const status = STATUS_LABEL[task.status]; - const statusBadge = chalk.hex(statusColor(colors, task.status))(status); + const statusBadge = currentTheme.fg(statusColor(task.status), status); const prefix = `${pointerStyled}${idText}${idPad} ${statusBadge}`; const prefixWidth = visibleWidth(prefix); @@ -484,9 +491,11 @@ export class TasksBrowserApp extends Container implements Focusable { if (descBudget < 4) return fitExactly(prefix, innerWidth); const description = - singleLine(task.description) || singleLine(task.command) || '(no description)'; + singleLine(task.description) || + (task.kind === 'process' ? singleLine(task.command) : '') || + '(no description)'; const desc = truncateToWidth(description, descBudget, ELLIPSIS); - return fitExactly(`${prefix} ${chalk.hex(colors.text)(desc)}`, innerWidth); + return fitExactly(`${prefix} ${currentTheme.fg('text', desc)}`, innerWidth); } private adjustScroll(visibleRows: number): void { @@ -518,60 +527,63 @@ export class TasksBrowserApp extends Container implements Focusable { } private renderDetailFrame(width: number, height: number): string[] { - const colors = this.props.colors; const innerHeight = Math.max(0, height - 2); const task = this.sortedVisible[this.selectedIndex]; if (task === undefined) { - const empty = chalk.hex(colors.textMuted)('Select a task from the list.'); + const empty = currentTheme.fg('textMuted', 'Select a task from the list.'); const lines: string[] = [empty]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Detail', lines, width, height); } - const label = (text: string): string => chalk.hex(colors.textMuted)(text.padEnd(14)); - const value = (text: string): string => chalk.hex(colors.text)(text); + const label = (text: string): string => currentTheme.fg('textMuted', text.padEnd(14)); + const value = (text: string): string => currentTheme.fg('text', text); const lines: string[] = [ `${label('Task ID:')}${value(task.taskId)}`, - `${label('Status:')}${chalk.hex(statusColor(colors, task.status))(STATUS_LABEL[task.status])}`, + `${label('Status:')}${currentTheme.fg(statusColor(task.status), STATUS_LABEL[task.status])}`, `${label('Description:')}${value(singleLine(task.description) || '—')}`, ]; - if (task.command && task.command !== task.description) { + if (task.kind === 'process' && task.command && task.command !== task.description) { lines.push(`${label('Command:')}${value(singleLine(task.command))}`); } + if (task.kind === 'agent' && task.agentId !== undefined) { + lines.push(`${label('Agent ID:')}${value(task.agentId)}`); + } + if (task.kind === 'agent' && task.subagentType !== undefined) { + lines.push(`${label('Agent type:')}${value(task.subagentType)}`); + } + if (task.kind === 'question') { + lines.push(`${label('Questions:')}${currentTheme.fg('textMuted', String(task.questionCount))}`); + if (task.toolCallId !== undefined) { + lines.push(`${label('Tool call:')}${currentTheme.fg('textMuted', task.toolCallId)}`); + } + } const timing = - task.status === 'running' || task.status === 'awaiting_approval' + task.status === 'running' ? `running ${formatRelativeTime(task.startedAt)}` : task.endedAt !== null && task.endedAt !== undefined ? `finished ${formatRelativeTime(task.endedAt)}` : ''; - if (timing.length > 0) lines.push(`${label('Time:')}${chalk.hex(colors.textMuted)(timing)}`); - if (task.pid > 0) lines.push(`${label('Pid:')}${chalk.hex(colors.textMuted)(String(task.pid))}`); - if (task.exitCode !== null && task.exitCode !== undefined) { - lines.push(`${label('Exit code:')}${chalk.hex(colors.textMuted)(String(task.exitCode))}`); + if (timing.length > 0) lines.push(`${label('Time:')}${currentTheme.fg('textMuted', timing)}`); + if (task.kind === 'process' && task.pid > 0) { + lines.push(`${label('Pid:')}${currentTheme.fg('textMuted', String(task.pid))}`); + } + if (task.kind === 'process' && task.exitCode !== null) { + lines.push(`${label('Exit code:')}${currentTheme.fg('textMuted', String(task.exitCode))}`); } if (task.stopReason !== undefined && task.stopReason.length > 0) { - lines.push(`${label('Stop reason:')}${chalk.hex(colors.textMuted)(task.stopReason)}`); + lines.push(`${label('Reason:')}${currentTheme.fg('textMuted', task.stopReason)}`); } - if (task.timedOut === true) { - lines.push(`${label('Timed out:')}${chalk.hex(colors.warning)('yes')}`); - } - if (task.approvalReason !== undefined && task.approvalReason.length > 0) { - lines.push( - `${label('Awaiting:')}${chalk.hex(colors.warning)(singleLine(task.approvalReason))}`, - ); - } - while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Detail', lines, width, height); } private renderPreviewFrame(width: number, height: number): string[] { - const colors = this.props.colors; const innerHeight = Math.max(0, height - 2); const task = this.sortedVisible[this.selectedIndex]; if (task === undefined) { - const lines: string[] = [chalk.hex(colors.textMuted)('No task selected.')]; + const lines: string[] = [currentTheme.fg('textMuted', 'No task selected.')]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Preview Output', lines, width, height); } @@ -584,7 +596,7 @@ export class TasksBrowserApp extends Container implements Focusable { const rawLines = body.split('\n'); const tailLines = rawLines.slice(-innerHeight); - const styled = tailLines.map((line) => chalk.hex(colors.textDim)(line)); + const styled = tailLines.map((line) => currentTheme.fg('textDim', line)); while (styled.length < innerHeight) styled.push(''); return this.renderFrame('Preview Output', styled, width, height); } @@ -593,7 +605,8 @@ export class TasksBrowserApp extends Container implements Focusable { private renderTooSmall(width: number, rows: number): string[] { const lines: string[] = []; - const msg = chalk.hex(this.props.colors.error)( + const msg = currentTheme.fg( + 'error', `Terminal too small (need ≥ ${String(MIN_WIDTH)} × ${String(MIN_HEIGHT)})`, ); lines.push(fitExactly(msg, width)); diff --git a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts index 8d6381c61..ecd3953fd 100644 --- a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts @@ -1,7 +1,7 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; -import type { Theme } from '#/tui/theme/index'; +import { listCustomThemesSync } from '#/tui/theme/custom-theme-loader'; +import type { ThemeName } from '#/tui/theme/index'; const THEME_OPTIONS: readonly ChoiceOption[] = [ { value: 'auto', label: 'Auto (match terminal)' }, @@ -9,26 +9,25 @@ const THEME_OPTIONS: readonly ChoiceOption[] = [ { value: 'light', label: 'Light' }, ]; -function isThemeChoice(value: string): value is Theme { - return value === 'auto' || value === 'dark' || value === 'light'; -} - export interface ThemeSelectorOptions { - readonly currentValue: Theme; - readonly colors: ColorPalette; - readonly onSelect: (theme: Theme) => void; + readonly currentValue: ThemeName; + readonly onSelect: (theme: ThemeName) => void; readonly onCancel: () => void; } export class ThemeSelectorComponent extends ChoicePickerComponent { constructor(opts: ThemeSelectorOptions) { + const customThemes = listCustomThemesSync(); + const options: ChoiceOption[] = [ + ...THEME_OPTIONS, + ...customThemes.map((name) => ({ value: name, label: `Custom: ${name}` })), + ]; super({ title: 'Select theme', - options: [...THEME_OPTIONS], + options, currentValue: opts.currentValue, - colors: opts.colors, onSelect: (value) => { - if (isThemeChoice(value)) opts.onSelect(value); + opts.onSelect(value); }, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts new file mode 100644 index 000000000..37320f92b --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts @@ -0,0 +1,120 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +const MAX_VISIBLE_CHOICES = 5; +const PREFERRED_SELECTED_OFFSET = 2; + +export interface UndoChoice { + readonly id: string; + readonly count: number; + readonly input: string; + readonly label: string; +} + +export interface UndoSelectorOptions { + readonly choices: readonly UndoChoice[]; + readonly onSelect: (choice: UndoChoice) => void; + readonly onCancel: () => void; +} + +export class UndoSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: UndoSelectorOptions; + private readonly list: SearchableList<UndoChoice>; + private submitted = false; + + constructor(opts: UndoSelectorOptions) { + super(); + this.opts = opts; + this.list = new SearchableList({ + items: opts.choices, + toSearchText: (choice) => choice.label, + initialIndex: Math.max(0, opts.choices.length - 1), + }); + } + + handleInput(data: string): void { + if (this.submitted) return; + + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + + if (this.list.handleKey(data)) { + return; + } + + if (matchesKey(data, Key.enter)) { + const selected = this.list.selected(); + if (selected !== undefined) { + this.submitted = true; + this.opts.onSelect(selected); + } + } + } + + override render(width: number): string[] { + const view = this.list.view(); + const hintParts = ['↑↓ navigate', 'Enter select', 'Esc cancel']; + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Select messages to undo'), + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), + '', + ]; + + if (view.items.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No messages')); + } else { + const visibleCount = Math.min(MAX_VISIBLE_CHOICES, view.items.length); + const maxStart = view.items.length - visibleCount; + const start = Math.min( + Math.max(0, view.selectedIndex - PREFERRED_SELECTED_OFFSET), + maxStart, + ); + const end = start + visibleCount; + + for (let i = start; i < end; i++) { + const choice = view.items[i]; + if (choice === undefined) continue; + lines.push( + this.renderChoiceLine(choice, i === view.selectedIndex, i > view.selectedIndex, width), + ); + } + } + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } + + private renderChoiceLine( + choice: UndoChoice, + isSelected: boolean, + inUndoRange: boolean, + width: number, + ): string { + const pointer = isSelected ? SELECT_POINTER : ' '; + const prefix = ` ${pointer} `; + const labelBudget = Math.max(8, width - visibleWidth(prefix)); + const label = truncateToWidth(choice.label, labelBudget, '…'); + const token = isSelected ? 'primary' : inUndoRange ? 'textDim' : 'text'; + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', prefix); + line += isSelected + ? currentTheme.boldFg(token, label) + : currentTheme.fg(token, label); + return line; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts new file mode 100644 index 000000000..35055e084 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts @@ -0,0 +1,34 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'on', + label: 'On', + description: 'Install new versions in the background.', + }, + { + value: 'off', + label: 'Off', + description: 'Show the install prompt instead.', + }, +]; + +export interface UpdatePreferenceSelectorOptions { + readonly currentValue: boolean; + readonly onSelect: (value: boolean) => void; + readonly onCancel: () => void; +} + +export class UpdatePreferenceSelectorComponent extends ChoicePickerComponent { + constructor(opts: UpdatePreferenceSelectorOptions) { + super({ + title: 'Automatic updates', + options: [...UPDATE_PREFERENCE_OPTIONS], + currentValue: opts.currentValue ? 'on' : 'off', + onSelect: (value) => { + opts.onSelect(value === 'on'); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 3ceff7a9d..e3532b157 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -2,11 +2,24 @@ * Custom editor extending pi-tui Editor with app-level keybindings. */ -import { Editor, isKeyRelease, matchesKey, Key, type TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { + Editor, + isKeyRelease, + matchesKey, + Key, + SelectList, + visibleWidth, + type SelectItem, + type TUI, +} from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { isInsideTmux } from '#/tui/utils/terminal-notification'; + +import { extractAtPrefix } from './file-mention-provider'; +import { WrappingSelectList } from './wrapping-select-list'; // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences const ANSI_SGR = /\u001B\[[0-9;]*m/g; @@ -32,6 +45,22 @@ interface AutocompleteInternals { readonly autocompleteDebounceTimer?: ReturnType<typeof setTimeout>; } +interface AutocompleteListFactoryInternals { + createAutocompleteList?: (prefix: string, items: SelectItem[]) => SelectList; +} + +interface AutocompleteTriggerInternals { + tryTriggerAutocomplete: (explicitTab?: boolean) => void; + requestAutocomplete: (options: { force: boolean; explicitTab: boolean }) => void; +} + +// Mirror pi-tui's private SLASH_COMMAND_SELECT_LIST_LAYOUT +// (dist/components/editor.js); keep in sync when bumping pi-tui. +const SLASH_COMMAND_SELECT_LIST_LAYOUT = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +} as const; + /** * Workaround for a pi-tui bug that surfaces when Kitty keyboard protocol * is active AND caps_lock is on. In that state terminals emit, e.g., @@ -85,25 +114,27 @@ function stripSgr(s: string): string { return s.replace(ANSI_SGR, ''); } -function getNewlineInput(data: string): string | undefined { - if (data === '\n' || data === '\u001B\r' || data === '\u001B[13;2~') return data; - if (matchesKey(data, Key.ctrl('j'))) return '\n'; - return undefined; +interface CustomEditorOptions { + disablePasteBurst?: boolean; } export class CustomEditor extends Editor { public onEscape?: () => void; + /** + * Fired for every input that is not a lone Escape. Used to disarm a pending + * double-Esc so only two consecutive Escape presses trigger the shortcut. + */ + public onNonEscapeInput?: () => void; public onCtrlD?: () => void; public onCtrlC?: () => void; public onToggleToolExpand?: () => void; - // Returns true when a plan card actually handled the toggle. When it - // returns false (no plan in the transcript) the keystroke falls through - // to pi-tui's default ctrl+e binding (move cursor to end of line). - public onTogglePlanExpand?: () => boolean; public onOpenExternalEditor?: () => void; public onCtrlS?: () => void; + /** Return `true` to consume Ctrl+B; return `false`/`undefined` to fall through to the editor default (cursor-left). */ + public onCtrlB?: () => boolean; + /** Return `true` to consume Ctrl+T (the todo list had overflow to toggle); return `false`/`undefined` to fall through to the editor default. */ + public onToggleTodoExpand?: () => boolean; public onUndo?: () => void; - public onInsertNewline?: () => void; public onTextPaste?: () => void; /** * Called when ↑ is pressed in an empty editor. Return `true` to consume @@ -111,7 +142,14 @@ export class CustomEditor extends Editor { * through so pi-tui's built-in history navigation runs. */ public onUpArrowEmpty?: () => boolean; + public onDownArrowEmpty?: () => boolean; public onShiftTab?: () => void; + /** 'bash' when entering a `!` shell command. The `!` is never part of the + * text buffer — it is a separate mode + prompt symbol (see handleInput). */ + public inputMode: 'prompt' | 'bash' = 'prompt'; + public onInputModeChange?: (mode: 'prompt' | 'bash') => void; + public connectedAbove = false; + public borderHighlighted = false; /** * Called when the user triggers "paste image" (Ctrl-V on Unix, * Alt-V on Windows — Ctrl-V is terminal-reserved there). Return @@ -124,27 +162,61 @@ export class CustomEditor extends Editor { private consumingPaste = false; private consumeBuffer = ''; + private argumentHints: ReadonlyMap<string, string> = new Map(); + private autocompleteWasShowing = false; - /** - * `colors` is the live `ColorPalette` reference — the host mutates it - * in place on theme switch (`Object.assign(state.theme.colors, ...)`), so - * reading `this.colors.<token>` at render time always sees the - * current theme without any setter plumbing. The `EditorTheme` that - * pi-tui's `Editor` requires is derived from the same palette, and - * `paddingX: 2` reserves the two leading columns where `render()` - * paints the terminal-style `> ` prompt — both are implementation - * details, not caller knobs. - */ - constructor( - tui: TUI, - private readonly colors: ColorPalette, - ) { + setArgumentHints(hints: ReadonlyMap<string, string>): void { + this.argumentHints = hints; + } + + constructor(tui: TUI, options: CustomEditorOptions = {}) { // paddingX: 4 reserves column 0 for the left vertical border (│), // column 1 as a single space between border and prompt, column 2 for // the `>` prompt token, and column 3 as the space between prompt and // content. The right side mirrors with 3 padding columns and the right // border at the last column. - super(tui, createEditorTheme(colors), { paddingX: 4 }); + const theme = createEditorTheme(); + super(tui, theme, { paddingX: 4, disablePasteBurst: options.disablePasteBurst }); + + // pi-tui keeps `createAutocompleteList` private; shadow it with an + // instance property so slash command menus render descriptions wrapped + // to at most two lines. Non-slash completion (paths, @ mentions) keeps + // pi-tui's single-line list. + (this as unknown as AutocompleteListFactoryInternals).createAutocompleteList = ( + prefix, + items, + ) => { + if (prefix.startsWith('/')) { + return new WrappingSelectList( + items, + this.getAutocompleteMaxVisible(), + theme.selectList, + SLASH_COMMAND_SELECT_LIST_LAYOUT, + ); + } + return new SelectList(items, this.getAutocompleteMaxVisible(), theme.selectList); + }; + + // pi-tui auto-triggers autocomplete for `/` (and letters in a slash + // context) with force:false, which routes through the slash-command + // branch. In bash mode `/` is a path separator, not a command prefix, so + // shadow the trigger to request file path completion (force:true) instead. + // Prompt mode keeps the original force:false behaviour. `tryTriggerAutocomplete` + // is private in pi-tui's typings but a plain prototype method at runtime. + const triggerInternals = this as unknown as AutocompleteTriggerInternals; + triggerInternals.tryTriggerAutocomplete = (explicitTab = false) => { + triggerInternals.requestAutocomplete({ force: this.inputMode === 'bash', explicitTab }); + }; + } + + override setDisablePasteBurst(disabled: boolean): void { + super.setDisablePasteBurst(disabled); + } + + public setInputMode(mode: 'prompt' | 'bash'): void { + if (this.inputMode === mode) return; + this.inputMode = mode; + this.onInputModeChange?.(mode); } private expandPasteMarkerAtCursor(): boolean { @@ -186,25 +258,68 @@ export class CustomEditor extends Editor { (this as unknown as AutocompleteInternals).cancelAutocomplete(); } + // Force a full re-render when the autocomplete dropdown closes, so the editor + // snaps back to the bottom instead of sitting where the taller dropdown left it. + // Only worthwhile when the session content already overflows one screen; below + // that a full clear + home would pull the editor to the top and leave a blank + // tail. Always skipped inside tmux, whose own reflow handles the shrink. + private requestFullRenderOnAutocompleteClose(): void { + if (isInsideTmux()) return; + const { columns, rows } = this.tui.terminal; + // Redraw when content fills or overflows the viewport. An exact fill (== + // rows) is safe to clear (no blank tail) and still needs the redraw: the + // differential renderer keeps the old viewport offset after a shrink. + if (this.tui.render(columns).length < rows) return; + this.tui.requestRender(true); + } + + // Detect an autocomplete open→close edge from a render frame and force a full + // re-render. Running from render() (not handleInput) also catches asynchronous + // closes — e.g. Backspace deleting the leading `/`, where pi-tui only cancels + // the menu once the provider re-query resolves. The render request is deferred + // to a microtask so the overflow probe inside the helper does not re-enter + // render() synchronously. + private trackAutocompleteCloseForFullRender(): void { + const showing = this.isShowingAutocomplete(); + const closed = this.autocompleteWasShowing && !showing; + this.autocompleteWasShowing = showing; + if (closed) { + queueMicrotask(() => this.requestFullRenderOnAutocompleteClose()); + } + } + override render(width: number): string[] { + this.trackAutocompleteCloseForFullRender(); const lines = super.render(width); if (lines.length < 3) return lines; const firstContentIdx = 1; + const isBash = this.inputMode === 'bash'; const text = this.getText().trimStart(); - if (text.startsWith('/')) { + if (text.startsWith('/') && !isBash) { // Paint only the FIRST editor content line; multi-line slash commands // are not a thing in practice. const original = lines[firstContentIdx]; if (original !== undefined) { - const highlighted = highlightFirstSlashToken(original, this.colors.primary); + const highlighted = highlightFirstSlashToken(original, 'primary'); if (highlighted !== undefined) { lines[firstContentIdx] = highlighted; } } } + const hint = this.computeArgumentHint(); + if (hint !== undefined) { + const line = lines[firstContentIdx]; + if (line !== undefined) { + lines[firstContentIdx] = injectArgumentHint(line, hint, this.getText().length, width); + } + } const firstContent = lines[firstContentIdx]; if (firstContent !== undefined) { - const withPrompt = injectPromptSymbol(firstContent); + const withPrompt = injectPromptSymbol( + firstContent, + isBash ? '!' : '>', + isBash ? (s) => this.borderColor(s) : undefined, + ); if (withPrompt !== undefined) { lines[firstContentIdx] = withPrompt; } @@ -213,7 +328,28 @@ export class CustomEditor extends Editor { // overwrite it (e.g. plan-mode / slash-context highlight via // `editor.borderColor = chalk.hex(primary)`), so we route corners and // side bars through the same hook to stay in sync. - return wrapWithSideBorders(lines, (s) => this.borderColor(s)); + return wrapWithSideBorders(lines, (s) => this.borderColor(s), { + connectedAbove: this.connectedAbove && !this.borderHighlighted, + label: isBash ? ` ${currentTheme.boldFg('shellMode', '! shell mode')} ` : undefined, + }); + } + + private computeArgumentHint(): string | undefined { + // Argument hints describe slash commands, which do not exist in bash mode. + if (this.inputMode === 'bash') return undefined; + const text = this.getText(); + const match = /^\/(\S+)( ?)$/.exec(text); + if (match === null) return undefined; + const cmd = match[1]; + const trailingSpace = match[2] ?? ''; + if (cmd === undefined) return undefined; + const hint = this.argumentHints.get(cmd); + if (hint === undefined) return undefined; + const { line, col } = this.getCursor(); + if (line !== 0) return undefined; + const currentLine = this.getLines()[0] ?? ''; + if (col !== currentLine.length) return undefined; + return trailingSpace.length > 0 ? hint : ` ${hint}`; } override handleInput(data: string): void { @@ -222,6 +358,12 @@ export class CustomEditor extends Editor { return; } + // Any input other than a lone Escape breaks a pending double-Esc sequence, + // so the shortcut only fires for two consecutive Escape presses. + if (!matchesKey(normalized, Key.escape)) { + this.onNonEscapeInput?.(); + } + // When a paste marker was just expanded, discard the trailing bracketed // paste data that the terminal sends alongside the Ctrl-V keystroke. if (this.consumingPaste) { @@ -287,16 +429,24 @@ export class CustomEditor extends Editor { return; } - if (matchesKey(normalized, Key.ctrl('e'))) { - if (this.onTogglePlanExpand?.() === true) return; - // No plan to toggle — fall through to pi-tui's end-of-line. - } - if (matchesKey(normalized, Key.ctrl('s'))) { this.onCtrlS?.(); return; } + if (matchesKey(normalized, Key.ctrl('b'))) { + // Only consume the key when the handler actually detached something; + // otherwise fall through so readline's backward-char still works at the + // idle prompt. + if (this.onCtrlB?.() === true) return; + } + + if (matchesKey(normalized, Key.ctrl('t'))) { + // Only consume the key when the todo list actually has overflow to + // expand/collapse; otherwise fall through to the editor default. + if (this.onToggleTodoExpand?.() === true) return; + } + if (matchesKey(normalized, 'shift+tab')) { this.onShiftTab?.(); return; @@ -306,10 +456,16 @@ export class CustomEditor extends Editor { this.onUndo?.(); } - const newlineInput = getNewlineInput(normalized); - if (newlineInput !== undefined) { - this.onInsertNewline?.(); - super.handleInput(newlineInput); + // Exit bash mode: Backspace/Escape on an empty `!` prompt returns to prompt + // mode. Because the `!` is not in the buffer, "deleting" it is really + // "delete on empty bash input". + if ( + this.inputMode === 'bash' && + this.getText().length === 0 && + (matchesKey(normalized, Key.escape) || matchesKey(normalized, Key.backspace)) + ) { + this.inputMode = 'prompt'; + this.onInputModeChange?.('prompt'); return; } @@ -320,6 +476,12 @@ export class CustomEditor extends Editor { } } + if (matchesKey(normalized, Key.down)) { + if (this.getText().length === 0 && this.onDownArrowEmpty) { + if (this.onDownArrowEmpty()) return; + } + } + if (matchesKey(normalized, Key.escape)) { if (this.hasAutocompleteActivity()) { this.cancelAutocompleteActivity(); @@ -329,17 +491,107 @@ export class CustomEditor extends Editor { return; } + // Swallow Tab while the autocomplete dropdown is closed so it does not + // trigger pi-tui's built-in file completion. When the dropdown is open, + // fall through so pi-tui can still accept the selected item with Tab. + if (matchesKey(normalized, Key.tab) && !this.isShowingAutocomplete()) { + return; + } + + // Enter bash mode: typing `!` at the start of an empty prompt. The `!` is + // not inserted into the buffer — it becomes the mode + prompt symbol, so the + // cursor never has to skip over it and submit never has to strip it. + if ( + this.inputMode === 'prompt' && + printableChar(normalized) === '!' && + this.getText().length === 0 + ) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + return; + } + + const emptyPromptBeforeInput = this.inputMode === 'prompt' && this.getText().length === 0; super.handleInput(normalized); + + // Enter bash mode when `!...` is pasted into an empty prompt. The typed path + // above handles the single `!` keystroke; this catches bracketed / Ctrl-V + // pastes whose content starts with `!`. Strip the leading `!` so the buffer + // holds only the command, exactly like the typed path. + if (emptyPromptBeforeInput && this.inputMode === 'prompt' && this.getText().startsWith('!')) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + this.setText(this.getText().slice(1)); + } + + this.reopenAutocompleteAfterInput(); + } + + private reopenAutocompleteAfterInput(): void { + if (this.isShowingAutocomplete()) return; + const { line, col } = this.getCursor(); + const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? ''; + const editor = this as unknown as { + requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void; + }; + if (editor.requestAutocomplete === undefined) return; + const trigger = (): void => { + // Use force:false so slash-aware logic runs: commands with argument + // completions return their subcommands, commands without them return + // null. force:true would bypass the slash branch and fall through to + // path completion, wrongly popping up the file list. + editor.requestAutocomplete?.({ force: false, explicitTab: false }); + }; + + // Reopen path / argument completion right after a `/` is typed + // (e.g. `/add-dir /` or an `@dir/` mention). + if (textBeforeCursor.endsWith('/')) { + const isAtMention = extractAtPrefix(textBeforeCursor) !== null; + if (isAtMention) { + trigger(); + } else if (this.inputMode === 'bash') { + // In bash mode `/` is a path separator, not a slash command. A bare + // leading `/` is already handled by the tryTriggerAutocomplete shadow + // in the constructor; this branch covers the inline case (e.g. `ls /`, + // `cat /etc/`, `/add-dir/`) that pi-tui never auto-triggers. force:true + // is required so pi-tui's own slash-command handling is bypassed — + // force:false would let it pop up subcommand completions. + if (textBeforeCursor.trimStart() !== '/') { + editor.requestAutocomplete?.({ force: true, explicitTab: false }); + } + } else { + const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); + if (isSlashArgument) { + trigger(); + } + } + return; + } + + // After accepting a slash command name via Tab, pi-tui inserts a trailing + // space and closes the menu without triggering argument completion. Reopen + // it so subcommands (e.g. `/goal ` → status/pause/…) show immediately. + // Skipped in bash mode: `/` is a path there, and force:false would let + // pi-tui's own slash-command handling pop up subcommand completions. + if ( + this.inputMode !== 'bash' && + textBeforeCursor.endsWith(' ') && + textBeforeCursor.startsWith('/') && + textBeforeCursor.includes(' ') + ) { + trigger(); + } } } /** * Return a copy of `line` with the first `/token` coloured using `hex`. + * For `/goal next manage`, also colour the command-path tokens. * `line` may already contain SGR escapes (cursor inverse, etc.); we * locate `/` via visible-index math so ANSI pass-through survives. * Returns `undefined` if no token is found. */ -export function highlightFirstSlashToken(line: string, hex: string): string | undefined { +export function highlightFirstSlashToken(line: string, token: 'primary'): string | undefined { const visible = stripSgr(line); const slashIdx = visible.indexOf('/'); if (slashIdx < 0) return undefined; @@ -357,12 +609,104 @@ export function highlightFirstSlashToken(line: string, hex: string): string | un } const visibleToken = visible.slice(slashIdx, endVisible); if (visibleToken.slice(1).includes('/')) return undefined; - const rawStart = mapVisibleIdxToRaw(line, slashIdx); - const rawEnd = mapVisibleIdxToRaw(line, endVisible); - const before = line.slice(0, rawStart); - const token = line.slice(rawStart, rawEnd); - const after = line.slice(rawEnd); - return before + chalk.hex(hex).bold(token) + after; + const ranges = [{ start: slashIdx, end: endVisible }]; + if (visibleToken === '/goal') { + ranges.push(...goalCommandPathRanges(visible, endVisible)); + } + return highlightVisibleRanges(line, ranges, token); +} + +function goalCommandPathRanges( + visible: string, + commandEnd: number, +): Array<{ start: number; end: number }> { + const nextRange = readTokenRange(visible, commandEnd); + if (nextRange === null || visible.slice(nextRange.start, nextRange.end) !== 'next') { + return []; + } + const ranges = [nextRange]; + const manageRange = readTokenRange(visible, nextRange.end); + if (manageRange !== null && visible.slice(manageRange.start, manageRange.end) === 'manage') { + ranges.push(manageRange); + } + return ranges; +} + +function readTokenRange(visible: string, start: number): { start: number; end: number } | null { + let tokenStart = start; + while (tokenStart < visible.length && isTokenSpace(visible[tokenStart])) tokenStart++; + if (tokenStart >= visible.length) return null; + let tokenEnd = tokenStart; + while (tokenEnd < visible.length && !isTokenSpace(visible[tokenEnd])) tokenEnd++; + return { start: tokenStart, end: tokenEnd }; +} + +function isTokenSpace(ch: string | undefined): boolean { + return ch === ' ' || ch === '\t'; +} + +function highlightVisibleRanges( + line: string, + ranges: Array<{ start: number; end: number }>, + token: 'primary', +): string { + let out = ''; + let rawCursor = 0; + for (const range of ranges) { + const rawStart = mapVisibleIdxToRaw(line, range.start); + const rawEnd = mapVisibleIdxToRaw(line, range.end); + out += line.slice(rawCursor, rawStart); + out += currentTheme.boldFg(token, line.slice(rawStart, rawEnd)); + rawCursor = rawEnd; + } + return out + line.slice(rawCursor); +} + +// Mirrors the editor's paddingX (see constructor). The hint is spliced into +// the first content line, which starts with this many spaces of left padding. +const EDITOR_LEFT_PADDING = 4; +// pi-tui renders the end-of-input cursor as an inverse-video space. +const CURSOR_BLOCK = '\u001B[7m \u001B[0m'; + +/** + * Splice a dimmed argument-hint ghost string into the first content line. + * + * The hint is purely visual: it is appended after the typed command (and + * after the cursor block when one is rendered) so the cursor stays at the + * end of the real input. It consumes trailing padding space, so the line + * width is preserved; if it would overflow the box it is truncated with an + * ellipsis. Returns the line unchanged when there is no room for a hint. + */ +function injectArgumentHint( + line: string, + hint: string, + realTextLength: number, + width: number, +): string { + const cursorIdx = line.indexOf(CURSOR_BLOCK); + const cursorPresent = cursorIdx !== -1; + const contentWidth = Math.max(1, width - EDITOR_LEFT_PADDING * 2); + // Room left in the content area after the typed text (and cursor). The hint + // must fit within this so the rendered line keeps its width. + const available = contentWidth - realTextLength - (cursorPresent ? 1 : 0); + const trimmed = truncateHint(hint, available); + if (trimmed.length === 0) return line; + const colored = currentTheme.fg('textDim', trimmed); + const insertAt = cursorPresent + ? cursorIdx + CURSOR_BLOCK.length + : mapVisibleIdxToRaw(line, EDITOR_LEFT_PADDING + realTextLength); + // Everything after the insertion point is trailing padding + right padding + // (plain spaces). Replace it with the hint followed by the remaining spaces + // so the visible line width is preserved. + const trailing = line.length - insertAt; + return line.slice(0, insertAt) + colored + ' '.repeat(Math.max(0, trailing - trimmed.length)); +} + +function truncateHint(hint: string, maxLen: number): string { + if (maxLen <= 0) return ''; + if (hint.length <= maxLen) return hint; + if (maxLen === 1) return '…'; + return `${hint.slice(0, maxLen - 1)}…`; } /** @@ -375,12 +719,17 @@ export function highlightFirstSlashToken(line: string, hex: string): string | un * default foreground colour renders the symbol. Returns `undefined` if the * line is too short or doesn't begin with the expected padding. */ -export function injectPromptSymbol(line: string): string | undefined { +export function injectPromptSymbol( + line: string, + symbol = '>', + paint?: (s: string) => string, +): string | undefined { if (line.length < 4) return undefined; for (let i = 0; i < 4; i++) { if (line[i] !== ' ') return undefined; } - return ' > ' + line.slice(4); + const rendered = paint ? paint(symbol) : symbol; + return ' ' + rendered + ' ' + line.slice(4); } /** @@ -394,28 +743,44 @@ export function injectPromptSymbol(line: string): string | undefined { * inner SGR intact; only column 0 and the last column are overlaid, and * only if they're literal spaces — that protects the cursor-overflow * case where the rightmost column is an SGR-tagged inverse cursor. + * + * When `options.label` is set, it is overlaid on the left of the top border + * (e.g. the `! shell mode` badge), replacing the leading dashes. It is only + * applied to a plain dash run, never to a `↑/↓ N more` scroll indicator. */ export function wrapWithSideBorders( lines: string[], paint: (s: string) => string, + options: { readonly connectedAbove?: boolean; readonly label?: string } = {}, ): string[] { let seenTop = false; return lines.map((line) => { const plain = stripSgr(line); if (plain.length > 0 && plain[0] === '─') { - const leftCorner = seenTop ? '╰' : '╭'; - const rightCorner = seenTop ? '╯' : '╮'; + const isTop = !seenTop; + const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭'; + const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮'; seenTop = true; if (plain.length === 1) return paint(leftCorner); const middle = plain.slice(1, -1); + if (isTop && options.label !== undefined && /^─+$/.test(middle)) { + const labelWidth = visibleWidth(options.label); + if (labelWidth <= middle.length) { + return ( + paint(leftCorner) + + options.label + + paint('─'.repeat(middle.length - labelWidth)) + + paint(rightCorner) + ); + } + } return paint(leftCorner + middle + rightCorner); } if (line.length === 0) return line; const firstCh = line[0]; const lastCh = line.at(-1); const head = firstCh === ' ' ? paint('│') : (firstCh ?? ''); - const tail = - line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? ''); + const tail = line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? ''); if (line.length === 1) return head; return head + line.slice(1, -1) + tail; }); diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index d2a7d39c1..722682db6 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -1,66 +1,62 @@ -/** - * `@file` autocomplete provider for the input box. - * - * pi-tui's `CombinedAutocompleteProvider` handles the mechanical parts - * (extract `@…` prefix, insert completion with the right quoting). This - * wrapper adds kimi-specific ranking + filtering so the default "empty - * `@`" list surfaces files the user actually wants, not alphabetical - * noise from `.agents/skills/*` et al. - * - * Sort order — empty query: - * 1. recently edited (from `git log --name-only`) - * 2. recent fs mtime - * 3. basename alphabetical - * (first 15, not 50 — pi-tui's menu height is ~6-10 lines anyway) - * - * Sort order — non-empty query (strict to fuzzy): - * cat 0: basename starts-with query - * cat 1: basename contains query - * cat 2: fuzzyMatch succeeds on full path - * tie-break within each cat: recency rank → mtime → basename length - * (first 50) - * - * Filter — dot directories are hidden by default. User can opt in by starting the query - * with `.` (e.g. `@.github/`), since those paths rarely need - * completion. - * - * When `fd` is available the inner pi-tui provider owns the `@` branch - * verbatim — its fd invocation respects `.gitignore` and is strictly - * better than anything we can cheaply reproduce in TS. We only kick in - * when `fd` is missing AND we're in a git repo. - */ - -import { basename } from 'node:path'; +import { accessSync, constants as fsConstants, readdirSync, statSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; import { CombinedAutocompleteProvider, - fuzzyFilter, fuzzyMatch, type AutocompleteItem, type AutocompleteProvider, type AutocompleteSuggestions, type SlashCommand, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; -import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; - -const MAX_SUGGESTIONS_WHEN_QUERY = 50; -const MAX_SUGGESTIONS_WHEN_EMPTY = 15; - -// Mirrors pi-tui's PATH_DELIMITERS. Keeping a local copy so @-detection -// stays aligned even if pi-tui extends its set. const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); +const MAX_FALLBACK_SCAN = 2000; +const MAX_FALLBACK_SUGGESTIONS = 50; +export interface SlashAutocompleteCommand extends SlashCommand { + readonly aliases?: readonly string[]; +} + +interface FsMentionCandidate { + readonly path: string; + readonly absolutePath: string; + readonly isDirectory: boolean; +} + +/** + * Kimi wrapper around pi-tui's combined autocomplete provider. + * + * File / folder mention behavior uses pi-tui's fd-backed provider whenever fd + * is available, fanning out across the working directory and any additional + * roots so `@` completion pushes the query down to fd instead of enumerating + * every file. A small filesystem fallback is used only while managed fd is + * downloading, when it is unavailable, or if fd fails to spawn. Ordinary path + * completion is still handled by pi-tui's readdir-backed path completer. This + * wrapper also keeps Kimi-specific slash-command guards. + */ export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; + private readonly additionalDirs: readonly string[]; constructor( - slashCommands: SlashCommand[], - workDir: string, + private readonly slashCommands: SlashAutocompleteCommand[], + private readonly workDir: string, private readonly fdPath: string | null, - private readonly gitCache: GitLsFilesCache, + additionalDirs: readonly string[] = [], + private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt', ) { - this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath); + this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir))); + // Build an expanded list that includes alias entries so that + // inner's argument completion can find commands by alias too. + const expanded: SlashAutocompleteCommand[] = []; + for (const cmd of slashCommands) { + expanded.push(cmd); + for (const alias of cmd.aliases ?? []) { + expanded.push({ ...cmd, name: alias }); + } + } + this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath, this.additionalDirs); } async getSuggestions( @@ -69,45 +65,138 @@ export class FileMentionProvider implements AutocompleteProvider { cursorCol: number, options: { signal: AbortSignal; force?: boolean }, ): Promise<AutocompleteSuggestions | null> { - const textBeforeCursor = (lines[cursorLine] ?? '').slice(0, cursorCol); + const currentLine = lines[cursorLine] ?? ''; + const textBeforeCursor = currentLine.slice(0, cursorCol); + + // `@` file / folder mentions take priority over the slash-command guards + // below. Without this, typing `@` inside a slash command's argument text + // (e.g. `/goal Fix the @|checkout docs`) would be swallowed by + // `shouldSuppressSlashArgumentCompletion` before the mention branch ever + // runs, so the file list never opens. const atPrefix = extractAtPrefix(textBeforeCursor); - - // Non-`@` branch (slash commands, `/path`, quoted paths) — pi-tui - // already owns the edge cases. No intercept. - if (atPrefix === null) { - return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if (atPrefix !== null) { + // fd backs `@` completion across every root (cwd + additional dirs). Fall + // back to the filesystem scanner when fd is unavailable, not executable + // (e.g. the managed binary was removed or lost execute permission), or if + // spawning it fails below. A genuine fd no-match still returns null. + if (this.fdPath === null || !isExecutableFd(this.fdPath)) { + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); + } + try { + return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } catch { + // If fd fails to spawn unexpectedly, keep @ completion usable. + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); + } } - // `fd` available → inner's fuzzy search is strictly better than our - // git fallback (fd respects .gitignore AND covers unstaged paths - // without a second spawn). Accept its output as-is. - if (this.fdPath !== null) { - return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) { + return null; } - const snapshot = this.gitCache.getSnapshot(); - if (snapshot === null || snapshot.files.length === 0) { - return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if ( + shouldSuppressSlashArgumentCompletion( + textBeforeCursor, + currentLine.slice(cursorCol), + options.force, + ) + ) { + return null; } - const query = atPrefix.slice(1); // strip leading '@' - const includeDotDirs = query.startsWith('.'); - const candidates = includeDotDirs - ? snapshot.files - : snapshot.files.filter((p) => !containsDotSegment(p)); + // Handle slash-command name completion ourselves so that aliases are + // searchable and visible in the label. + if (!options.force && textBeforeCursor.startsWith('/')) { + const spaceIndex = textBeforeCursor.indexOf(' '); + if (spaceIndex === -1) { + const tokens = textBeforeCursor + .slice(1) + .trim() + .split(/\s+/) + .filter((t) => t.length > 0); - const items = - query.length === 0 - ? rankForEmptyQuery(candidates, snapshot) - : rankForQuery(candidates, query, snapshot); + type SlashMatch = { + cmd: SlashAutocompleteCommand; + score: number; + viaAlias: boolean; + label: string; + }; + const matches: SlashMatch[] = []; - if (items.length === 0) { - // Git cache had nothing useful — fall through to readdir (user - // may be typing a path that exists but isn't tracked, e.g. a - // freshly created file not yet in the 2s cache). - return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + for (const cmd of this.slashCommands) { + const nameScore = scoreTokens(tokens, cmd.name); + if (nameScore !== null) { + matches.push({ cmd, score: nameScore, viaAlias: false, label: cmd.name }); + continue; + } + // Aliases only count when the primary name missed; the label then + // lists them so the user can see why the command matched. + const aliases = cmd.aliases ?? []; + let bestAliasScore: number | null = null; + for (const alias of aliases) { + const aliasScore = scoreTokens(tokens, alias); + if (aliasScore !== null && (bestAliasScore === null || aliasScore < bestAliasScore)) { + bestAliasScore = aliasScore; + } + } + if (bestAliasScore !== null) { + matches.push({ + cmd, + score: bestAliasScore, + viaAlias: true, + label: `${cmd.name} (${aliases.join(', ')})`, + }); + } + } + + // Primary-name matches outrank alias matches on score ties. + matches.sort((a, b) => a.score - b.score || Number(a.viaAlias) - Number(b.viaAlias)); + + if (matches.length === 0) return null; + return { + items: matches.map((m) => ({ + value: m.cmd.name, + label: m.label, + description: formatSlashCommandDescription(m.cmd), + })), + prefix: textBeforeCursor, + }; + } + } + + // In bash mode `/` is a path separator, not a slash command. Skip slash + // command argument handling so an absolute path that happens to start with + // a command name (e.g. `/add-dir/...`) completes inside the path instead of + // returning the command's argument completions. + if (this.getInputMode() !== 'bash') { + const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor); + if (slashArgumentSuggestions !== null) { + return slashArgumentSuggestions; + } + } + + try { + const inner = await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if (inner === null || this.getInputMode() !== 'bash') { + return inner; + } + // In bash mode `/` is a path separator; hide dot-prefixed entries to + // match the `/add-dir` directory completer (registry.ts skips any name + // starting with `.`). Ordinary prompt-mode path completion is left as-is. + return { ...inner, items: inner.items.filter((item) => !isDotPrefixedEntry(item)) }; + } catch { + return null; } - return { items, prefix: atPrefix }; } applyCompletion( @@ -117,20 +206,20 @@ export class FileMentionProvider implements AutocompleteProvider { item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number } { - // Reuse pi-tui's insertion logic — it handles `@` prefix, quoted - // paths, directory trailing slash. Our item shape matches what - // pi-tui produces. + // In bash mode a leading `/` is a path, but pi-tui's applyCompletion + // mistakes it for a slash command (prefix starts with `/`, nothing before + // it, no second `/`) and prepends another `/`, producing e.g. + // `//Applications/ ` with a trailing space that also blocks further + // completion. Handle path completion ourselves so the value replaces the + // prefix verbatim. `@` mentions keep pi-tui's behaviour. + if (this.getInputMode() === 'bash' && prefix.startsWith('/')) { + return applyPathCompletion(lines, cursorLine, cursorCol, item, prefix); + } return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix); } } -/** - * Return the `@…` token ending at the cursor, or `null` if we're not in - * an `@` mention. Mirrors pi-tui's `extractAtPrefix` — the token - * boundary is the last PATH_DELIMITER before the cursor, and the token - * must start with `@`. - */ -function extractAtPrefix(text: string): string | null { +export function extractAtPrefix(text: string): string | null { let tokenStart = 0; for (let i = text.length - 1; i >= 0; i -= 1) { if (PATH_DELIMITERS.has(text[i] ?? '')) { @@ -142,131 +231,295 @@ function extractAtPrefix(text: string): string | null { return text.slice(tokenStart); } -/** True when any path segment starts with a dot (e.g. `.github/x.yml`). */ -function containsDotSegment(path: string): boolean { - for (const segment of path.split('/')) { - if (segment.startsWith('.')) return true; +function isExecutableFd(fdPath: string): boolean { + // Bare command names (for example "fd" discovered on the system PATH) are + // trusted: spawn resolves them through PATH. Only absolute/relative paths are + // probed, which is how the managed fd is referenced and which can go stale. + if (!fdPath.includes('/') && !fdPath.includes('\\')) { + return true; + } + try { + accessSync(fdPath, fsConstants.X_OK); + return true; + } catch { + return false; } - return false; } /** - * Empty-query ranking: stratified by signal strength. - * - * Layer 1: files touched in the last RECENT_COMMIT_DEPTH commits, - * ordered by how recently. Strongest signal — if the user - * just worked on it, they probably want to mention it. - * Layer 2: files with the newest fs mtime (covers uncommitted edits - * and files edited but not yet added to git). - * Layer 3: everything else, alphabetical by basename so - * README/package.json-style top-level files bubble up - * relative to deeply-nested alphabetical paths. - * - * Cap at MAX_SUGGESTIONS_WHEN_EMPTY. Layers fill in order; dedup by - * path so a recently-edited file isn't also listed in layer 2. + * Match the `/add-dir` directory completer, which skips every entry whose name + * starts with `.` (see registry.ts). pi-tui's path completer sets `label` to + * the entry basename, with a trailing `/` for directories. */ -function rankForEmptyQuery(files: readonly string[], snapshot: GitSnapshot): AutocompleteItem[] { - const picked = new Set<string>(); - const result: string[] = []; - const cap = MAX_SUGGESTIONS_WHEN_EMPTY; - const inFiles = new Set(files); - - // Layer 1 — git log recency. - const byRecency = [...snapshot.recencyOrder.entries()] - .filter(([path]) => inFiles.has(path)) - .toSorted((a, b) => a[1] - b[1]); - for (const [path] of byRecency) { - if (result.length >= cap) break; - if (picked.has(path)) continue; - picked.add(path); - result.push(path); - } - - // Layer 2 — fs mtime. - if (result.length < cap) { - const byMtime = files - .filter((p) => !picked.has(p) && snapshot.mtimeByPath.has(p)) - .toSorted((a, b) => (snapshot.mtimeByPath.get(b) ?? 0) - (snapshot.mtimeByPath.get(a) ?? 0)); - for (const path of byMtime) { - if (result.length >= cap) break; - picked.add(path); - result.push(path); - } - } - - // Layer 3 — alphabetical by basename. - if (result.length < cap) { - const rest = files - .filter((p) => !picked.has(p)) - .toSorted((a, b) => basename(a).localeCompare(basename(b)) || a.localeCompare(b)); - for (const path of rest) { - if (result.length >= cap) break; - result.push(path); - } - } - - return result.map(toItem); +function isDotPrefixedEntry(item: AutocompleteItem): boolean { + const name = item.label.endsWith('/') ? item.label.slice(0, -1) : item.label; + return name.startsWith('.'); } /** - * Non-empty-query ranking: three strictness tiers, with recency / - * mtime as tie-breakers inside each tier so "the readme you just - * edited" beats "a readme deep in a vendor dir". + * Replace `prefix` with `item.value` verbatim, mirroring pi-tui's file-path + * branch (no trailing space, so a completed directory can be extended with the + * next `/`). Used in bash mode to avoid pi-tui's slash-command branch, which + * would prepend an extra `/` to a bare leading `/` path. For a quoted + * directory value (path contains spaces), the cursor stays inside the closing + * quote so follow-up `/` completion keeps working. */ -function rankForQuery( - files: readonly string[], +function applyPathCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, +): { lines: string[]; cursorLine: number; cursorCol: number } { + const currentLine = lines[cursorLine] ?? ''; + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const afterCursor = currentLine.slice(cursorCol); + const newLine = beforePrefix + item.value + afterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + const isDirectory = item.label.endsWith('/'); + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = + isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset, + }; +} + +function getFsMentionSuggestions( + workDir: string, + additionalDirs: readonly string[], + atPrefix: string, + signal: AbortSignal, +): AutocompleteSuggestions | null { + if (signal.aborted) return null; + + const query = atPrefix.slice(1); + const candidates = collectFsMentionCandidates(workDir, additionalDirs, signal); + if (candidates.length === 0 || signal.aborted) return null; + + const ranked = rankFsMentionCandidates(candidates, query).slice(0, MAX_FALLBACK_SUGGESTIONS); + if (ranked.length === 0) return null; + + return { + prefix: atPrefix, + items: ranked.map(toMentionItem), + }; +} + +function collectFsMentionCandidates( + workDir: string, + additionalDirs: readonly string[], + signal: AbortSignal, +): FsMentionCandidate[] { + const candidatesByAbsolutePath = new Map<string, FsMentionCandidate>(); + const roots = [ + { root: normalizePath(resolve(workDir)), isAdditionalDir: false }, + ...additionalDirs.map((dir) => ({ + root: normalizePath(resolve(workDir, dir)), + isAdditionalDir: true, + })), + ]; + let scanned = 0; + + for (const { root, isAdditionalDir } of roots) { + const stack = ['']; + + while (stack.length > 0 && scanned < MAX_FALLBACK_SCAN) { + if (signal.aborted) break; + const relativeDir = stack.pop() ?? ''; + const absoluteDir = relativeDir.length === 0 ? root : join(root, relativeDir); + let entries; + try { + entries = readdirSync(absoluteDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (signal.aborted || scanned >= MAX_FALLBACK_SCAN) break; + if (entry.name === '.git') continue; + + const relativePath = normalizePath( + relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name), + ); + const absolutePath = normalizePath(join(absoluteDir, entry.name)); + const isSymlink = entry.isSymbolicLink(); + let isDirectory = entry.isDirectory(); + if (!isDirectory && isSymlink) { + try { + isDirectory = statSync(absolutePath).isDirectory(); + } catch { + // Broken symlink or permission error — keep it as a file candidate. + } + } + + scanned += 1; + if (!candidatesByAbsolutePath.has(absolutePath)) { + candidatesByAbsolutePath.set(absolutePath, { + path: isAdditionalDir ? absolutePath : relativePath, + absolutePath, + isDirectory, + }); + } + if (isDirectory && !isSymlink) { + stack.push(relativePath); + } + } + } + } + + return [...candidatesByAbsolutePath.values()]; +} + +function rankFsMentionCandidates( + candidates: readonly FsMentionCandidate[], query: string, - snapshot: GitSnapshot, -): AutocompleteItem[] { +): FsMentionCandidate[] { const lowerQuery = query.toLowerCase(); - const scored: Array<{ path: string; cat: number; fuzzyScore: number }> = []; - for (const path of files) { - const base = basename(path).toLowerCase(); - if (base.startsWith(lowerQuery)) { - scored.push({ path, cat: 0, fuzzyScore: 0 }); - continue; - } - if (base.includes(lowerQuery)) { - scored.push({ path, cat: 1, fuzzyScore: 0 }); - continue; - } - const fuzzy = fuzzyMatch(query, path); - if (fuzzy.matches) { - scored.push({ path, cat: 2, fuzzyScore: fuzzy.score }); - } - } + const scored: Array<{ candidate: FsMentionCandidate; score: number }> = []; - if (scored.length === 0) { - // pi-tui's fuzzyFilter is slightly different (token-splitting); - // try it as a last-resort safety net. - return fuzzyFilter([...files], query, (p) => p) - .slice(0, MAX_SUGGESTIONS_WHEN_QUERY) - .map(toItem); + for (const candidate of candidates) { + const score = scoreCandidate(candidate, lowerQuery); + if (score > 0) scored.push({ candidate, score }); } scored.sort((a, b) => { - if (a.cat !== b.cat) return a.cat - b.cat; - if (a.cat === 2 && a.fuzzyScore !== b.fuzzyScore) return a.fuzzyScore - b.fuzzyScore; - const ra = snapshot.recencyOrder.get(a.path); - const rb = snapshot.recencyOrder.get(b.path); - if (ra !== undefined && rb !== undefined && ra !== rb) return ra - rb; - if (ra !== undefined && rb === undefined) return -1; - if (ra === undefined && rb !== undefined) return 1; - const ma = snapshot.mtimeByPath.get(a.path) ?? 0; - const mb = snapshot.mtimeByPath.get(b.path) ?? 0; - if (ma !== mb) return mb - ma; - const baseLenDiff = basename(a.path).length - basename(b.path).length; - if (baseLenDiff !== 0) return baseLenDiff; - return a.path.localeCompare(b.path); + if (a.score !== b.score) return b.score - a.score; + if (a.candidate.isDirectory !== b.candidate.isDirectory) { + return a.candidate.isDirectory ? -1 : 1; + } + return a.candidate.path.localeCompare(b.candidate.path); }); - return scored.slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map((entry) => toItem(entry.path)); + return scored.map((entry) => entry.candidate); } -function toItem(path: string): AutocompleteItem { +function scoreCandidate(candidate: FsMentionCandidate, lowerQuery: string): number { + if (lowerQuery.length === 0) { + const depthPenalty = candidate.path.split('/').length - 1; + return (candidate.isDirectory ? 120 : 100) - depthPenalty; + } + + const lowerPath = candidate.path.toLowerCase(); + const lowerBase = basename(candidate.path).toLowerCase(); + let score = 0; + if (lowerBase === lowerQuery) score = 100; + else if (lowerBase.startsWith(lowerQuery)) score = 80; + else if (lowerBase.includes(lowerQuery)) score = 50; + else if (lowerPath.includes(lowerQuery)) score = 30; + if (candidate.isDirectory && score > 0) score += 10; + return score; +} + +function toMentionItem(candidate: FsMentionCandidate): AutocompleteItem { + const valuePath = candidate.isDirectory ? `${candidate.path}/` : candidate.path; + const value = valuePath.includes(' ') ? `@"${valuePath}"` : `@${valuePath}`; + const label = `${basename(candidate.path)}${candidate.isDirectory ? '/' : ''}`; return { - value: `@${path}`, - label: basename(path), - description: path, + value, + label, + description: candidate.absolutePath, }; } + +function normalizePath(path: string): string { + return path.replaceAll('\\', '/'); +} + +async function getSlashArgumentSuggestions( + slashCommands: readonly SlashAutocompleteCommand[], + textBeforeCursor: string, +): Promise<AutocompleteSuggestions | null> { + const parsed = parseSlashArgumentContext(textBeforeCursor, slashCommands); + if (parsed === null) return null; + + const items = await parsed.command.getArgumentCompletions?.(parsed.argumentPrefix); + if (items === undefined || items === null || items.length === 0) return null; + + return { + prefix: parsed.argumentPrefix, + items, + }; +} + +function parseSlashArgumentContext( + textBeforeCursor: string, + slashCommands: readonly SlashAutocompleteCommand[], +): { command: SlashAutocompleteCommand; argumentPrefix: string } | null { + const whitespaceMatch = textBeforeCursor.match(/^\/(\S+)\s+(\S*)$/); + if (whitespaceMatch !== null) { + const [, commandName = '', argumentPrefix = ''] = whitespaceMatch; + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + if (!textBeforeCursor.endsWith(' ') && argumentPrefix.length === 0) return null; + return { command, argumentPrefix }; + } + + const pathLikeMatch = textBeforeCursor.match(/^\/([^/\s]+)(\/.*)$/); + const commandName = pathLikeMatch?.[1]; + const argumentPrefix = pathLikeMatch?.[2]; + if (commandName === undefined || argumentPrefix === undefined) return null; + + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + return { command, argumentPrefix }; +} + +function findSlashCommand( + slashCommands: readonly SlashAutocompleteCommand[], + commandName: string, +): SlashAutocompleteCommand | undefined { + return slashCommands.find((cmd) => cmd.name === commandName || (cmd.aliases ?? []).includes(commandName)); +} + +function shouldSuppressLeadingWhitespaceSlashPath( + textBeforeCursor: string, + force: boolean | undefined, +): boolean { + if (force === true) return false; + if (textBeforeCursor.startsWith('/')) return false; + return textBeforeCursor.trimStart().startsWith('/'); +} + +function shouldSuppressSlashArgumentCompletion( + textBeforeCursor: string, + textAfterCursor: string, + force: boolean | undefined, +): boolean { + if (force === true) return false; + if (!textBeforeCursor.startsWith('/')) return false; + if (!textBeforeCursor.includes(' ')) return false; + return textAfterCursor.trimStart().length > 0; +} + +/** + * All tokens must fuzzy-match `text`; returns the summed score, or null when + * any token misses. An empty token list matches everything with score 0. + * Mirrors pi-tui fuzzyFilter's token semantics — keep in sync if it changes. + */ +function scoreTokens(tokens: readonly string[], text: string): number | null { + let score = 0; + for (const token of tokens) { + const m = fuzzyMatch(token, text); + if (!m.matches) return null; + score += m.score; + } + return score; +} + +/** + * Mirrors CombinedAutocompleteProvider's description rendering so the + * intercepted name completion keeps showing the argument hint. + */ +function formatSlashCommandDescription(cmd: SlashAutocompleteCommand): string | undefined { + const desc = cmd.description ?? ''; + const full = cmd.argumentHint + ? desc + ? `${cmd.argumentHint} — ${desc}` + : cmd.argumentHint + : desc; + return full || undefined; +} diff --git a/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts new file mode 100644 index 000000000..b6969c630 --- /dev/null +++ b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts @@ -0,0 +1,177 @@ +import { + SelectList, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type SelectItem, + type SelectListLayoutOptions, + type SelectListTheme, +} from '@moonshot-ai/pi-tui'; + +// Mirror pi-tui's private select-list layout constants +// (dist/components/select-list.js); keep in sync when bumping pi-tui. +const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; +const PRIMARY_COLUMN_GAP = 2; +const MIN_DESCRIPTION_WIDTH = 10; + +const DESCRIPTION_MAX_LINES = 2; +const ELLIPSIS = '…'; +const ELLIPSIS_WIDTH = visibleWidth(ELLIPSIS); + +// truncateToWidth appends an ANSI reset whenever it actually truncates. +// Labels and descriptions here are plain text, and the reset would sit +// inside the theme's colour wrapping and reset the rest of the line (e.g. +// a selected row with a truncated name loses its colour after the name), +// so strip it. +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences +const TRAILING_ANSI_RESET = /(?:\u001B\[0m)+$/; + +function truncatePlainToWidth(text: string, maxWidth: number): string { + return truncateToWidth(text, maxWidth, '').replace(TRAILING_ANSI_RESET, ''); +} + +interface SelectListInternals { + readonly filteredItems: SelectItem[]; + readonly selectedIndex: number; + readonly maxVisible: number; + readonly theme: SelectListTheme; + readonly layout: SelectListLayoutOptions; +} + +/** + * SelectList that wraps item descriptions onto up to two lines instead of + * truncating them to one. Long command / skill descriptions stay readable; + * anything past the second line is ellipsized. + * + * Only `render` is replaced — selection, filtering, and key handling stay in + * pi-tui. pi-tui keeps the row state private, so the renderer reads it + * through a cast, the same idiom CustomEditor uses for autocomplete + * internals. + */ +export class WrappingSelectList extends SelectList { + override render(width: number): string[] { + const { filteredItems, selectedIndex, maxVisible, theme } = this.internals(); + if (filteredItems.length === 0) { + return [theme.noMatch(' No matching commands')]; + } + + const primaryColumnWidth = this.primaryColumnWidth(); + const startIndex = Math.max( + 0, + Math.min(selectedIndex - Math.floor(maxVisible / 2), filteredItems.length - maxVisible), + ); + const endIndex = Math.min(startIndex + maxVisible, filteredItems.length); + + const lines: string[] = []; + for (let i = startIndex; i < endIndex; i++) { + const item = filteredItems[i]; + if (!item) continue; + lines.push(...this.renderItemLines(item, i === selectedIndex, width, primaryColumnWidth)); + } + + if (startIndex > 0 || endIndex < filteredItems.length) { + const scrollText = ` (${selectedIndex + 1}/${filteredItems.length})`; + lines.push(theme.scrollInfo(truncatePlainToWidth(scrollText, width - 2))); + } + return lines; + } + + private renderItemLines( + item: SelectItem, + isSelected: boolean, + width: number, + primaryColumnWidth: number, + ): string[] { + const { theme } = this.internals(); + const prefix = isSelected ? '→ ' : ' '; + const prefixWidth = visibleWidth(prefix); + const description = item.description + ? item.description.replaceAll(/[\r\n]+/g, ' ').trim() + : undefined; + + if (description && width > 40) { + const effectivePrimaryColumnWidth = Math.max( + 1, + Math.min(primaryColumnWidth, width - prefixWidth - 4), + ); + const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP); + const truncatedValue = this.truncatePrimaryValue( + item, + isSelected, + maxPrimaryWidth, + effectivePrimaryColumnWidth, + ); + const truncatedValueWidth = visibleWidth(truncatedValue); + const spacing = ' '.repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth)); + const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length; + const remainingWidth = width - descriptionStart - 2; // -2 for safety, as upstream + if (remainingWidth > MIN_DESCRIPTION_WIDTH) { + const descriptionLines = wrapDescription(description, remainingWidth); + const indent = ' '.repeat(descriptionStart); + if (isSelected) { + return descriptionLines.map((line, index) => + theme.selectedText(index === 0 ? `${prefix}${truncatedValue}${spacing}${line}` : indent + line), + ); + } + return descriptionLines.map((line, index) => + index === 0 + ? prefix + truncatedValue + theme.description(spacing + line) + : theme.description(indent + line), + ); + } + } + + const maxWidth = width - prefixWidth - 2; + const truncatedValue = this.truncatePrimaryValue(item, isSelected, maxWidth, maxWidth); + return [isSelected ? theme.selectedText(`${prefix}${truncatedValue}`) : prefix + truncatedValue]; + } + + private truncatePrimaryValue( + item: SelectItem, + isSelected: boolean, + maxWidth: number, + columnWidth: number, + ): string { + const { layout } = this.internals(); + const displayValue = item.label || item.value; + const truncated = layout.truncatePrimary + ? layout.truncatePrimary({ text: displayValue, maxWidth, columnWidth, item, isSelected }) + : displayValue; + return truncatePlainToWidth(truncated, maxWidth); + } + + private primaryColumnWidth(): number { + const { filteredItems, layout } = this.internals(); + const rawMin = + layout.minPrimaryColumnWidth ?? layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const rawMax = + layout.maxPrimaryColumnWidth ?? layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const min = Math.max(1, Math.min(rawMin, rawMax)); + const max = Math.max(1, Math.max(rawMin, rawMax)); + const widest = filteredItems.reduce( + (acc, item) => Math.max(acc, visibleWidth(item.label || item.value) + PRIMARY_COLUMN_GAP), + 0, + ); + return Math.max(min, Math.min(widest, max)); + } + + private internals(): SelectListInternals { + return this as unknown as SelectListInternals; + } +} + +/** + * Wrap `text` to at most DESCRIPTION_MAX_LINES lines of `width` columns. + * When the text needs more lines, the last visible line is rebuilt from the + * remaining text and ellipsized. + */ +function wrapDescription(text: string, width: number): string[] { + const wrapped = wrapTextWithAnsi(text, width); + if (wrapped.length <= DESCRIPTION_MAX_LINES) { + return wrapped; + } + const kept = wrapped.slice(0, DESCRIPTION_MAX_LINES - 1); + const rest = wrapped.slice(DESCRIPTION_MAX_LINES - 1).join(' '); + const clipped = truncatePlainToWidth(rest, width - ELLIPSIS_WIDTH).trimEnd(); + return [...kept, `${clipped}${ELLIPSIS}`]; +} diff --git a/apps/kimi-code/src/tui/components/index.ts b/apps/kimi-code/src/tui/components/index.ts index bcff5b2ac..52f6c052e 100644 --- a/apps/kimi-code/src/tui/components/index.ts +++ b/apps/kimi-code/src/tui/components/index.ts @@ -7,6 +7,7 @@ export * from './dialogs/approval-panel'; export * from './dialogs/choice-picker'; export * from './dialogs/compaction'; export * from './dialogs/editor-selector'; +export * from './dialogs/experiments-selector'; export * from './dialogs/help-panel'; export * from './dialogs/model-selector'; export * from './dialogs/permission-selector'; @@ -27,6 +28,7 @@ export * from './messages/read-group'; export * from './messages/shell-execution'; export * from './messages/skill-activation'; export * from './messages/status-message'; +export * from './messages/swarm-markers'; export * from './messages/thinking'; export * from './messages/tool-call'; export * from './messages/usage-panel'; diff --git a/apps/kimi-code/src/tui/components/media/diff-preview.ts b/apps/kimi-code/src/tui/components/media/diff-preview.ts index 5a978723d..1fec48b26 100644 --- a/apps/kimi-code/src/tui/components/media/diff-preview.ts +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -7,7 +7,7 @@ import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type DiffLineKind = 'context' | 'add' | 'delete'; @@ -20,14 +20,15 @@ interface DiffStyles { meta: (s: string) => string; } -function makeDiffStyles(colors: ColorPalette): DiffStyles { +function makeDiffStyles(): DiffStyles { + const palette = currentTheme.palette; return { - add: (s) => chalk.hex(colors.diffAdded)(s), - del: (s) => chalk.hex(colors.diffRemoved)(s), - addBold: (s) => chalk.bold.hex(colors.diffAddedStrong)(s), - delBold: (s) => chalk.bold.hex(colors.diffRemovedStrong)(s), - gutter: (s) => chalk.hex(colors.diffGutter)(s), - meta: (s) => chalk.hex(colors.diffMeta)(s), + add: (s) => chalk.hex(palette.diffAdded)(s), + del: (s) => chalk.hex(palette.diffRemoved)(s), + addBold: (s) => chalk.bold.hex(palette.diffAddedStrong)(s), + delBold: (s) => chalk.bold.hex(palette.diffRemovedStrong)(s), + gutter: (s) => chalk.hex(palette.diffGutter)(s), + meta: (s) => chalk.hex(palette.diffMeta)(s), }; } @@ -108,13 +109,12 @@ export function renderDiffLines( oldText: string, newText: string, path: string, - colors: ColorPalette, isIncomplete: boolean = false, oldStart?: number, newStart?: number, maxLines?: number, ): string[] { - const s = makeDiffStyles(colors); + const s = makeDiffStyles(); const diffLines = computeDiffLines(oldText, newText, oldStart ?? 1, newStart ?? 1, isIncomplete); const changedLines = diffLines.filter((l) => l.kind !== 'context'); const added = changedLines.filter((l) => l.kind === 'add').length; @@ -156,6 +156,8 @@ export interface ClusteredDiffOptions { readonly maxLines?: number; readonly isIncomplete?: boolean; readonly expandKeyHint?: string; + readonly oldStart?: number; + readonly newStart?: number; } interface Cluster { @@ -234,13 +236,18 @@ export function renderDiffLinesClustered( oldText: string, newText: string, path: string, - colors: ColorPalette, opts: ClusteredDiffOptions = {}, ): string[] { - const s = makeDiffStyles(colors); + const s = makeDiffStyles(); const contextLines = opts.contextLines ?? 3; const maxLines = opts.maxLines; - const diffLines = computeDiffLines(oldText, newText, 1, 1, opts.isIncomplete ?? false); + const diffLines = computeDiffLines( + oldText, + newText, + opts.oldStart ?? 1, + opts.newStart ?? 1, + opts.isIncomplete ?? false, + ); const { clusters, changedCount, addedCount, removedCount } = buildClusters( diffLines, contextLines, diff --git a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts index 86253582f..04e80f534 100644 --- a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts +++ b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts @@ -12,44 +12,78 @@ * the viewport; pi-tui handles proportional scaling internally. */ -import { Container, Image, Text, type ImageTheme, getCapabilities } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, Image, Text, type ImageTheme, getCapabilities } from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; const MAX_IMAGE_ROWS = 12; const MAX_IMAGE_WIDTH = 40; export class ImageThumbnail extends Container { - constructor(attachment: ImageAttachment, colors: ColorPalette) { - super(); + private readonly attachment: ImageAttachment; + private lastRenderWidth = 80; + private lastBuiltWidth: number | undefined; + private lastBuiltInline: boolean | undefined; + constructor(attachment: ImageAttachment) { + super(); + this.attachment = attachment; + this.buildChildren(this.lastRenderWidth); + } + + private buildChildren(width: number): void { + this.clear(); const caps = getCapabilities(); const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2'; if (!supportsInline) { - // Non-graphic terminal — show the placeholder text in dim cyan so - // it's clearly an attachment reference but doesn't shout. - this.addChild(new Text(chalk.hex(colors.accent)(attachment.placeholder), 0, 0)); + this.addChild(new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0)); + this.lastBuiltWidth = width; + this.lastBuiltInline = false; return; } const theme: ImageTheme = { - fallbackColor: (s: string) => chalk.hex(colors.textDim)(s), + fallbackColor: (s: string) => currentTheme.fg('textDim', s), }; - const base64 = Buffer.from(attachment.bytes).toString('base64'); + const base64 = Buffer.from(this.attachment.bytes).toString('base64'); const image = new Image( base64, - attachment.mime, + this.attachment.mime, theme, { maxHeightCells: MAX_IMAGE_ROWS, - maxWidthCells: MAX_IMAGE_WIDTH, - filename: attachment.placeholder, + maxWidthCells: Math.max(1, Math.min(MAX_IMAGE_WIDTH, width - 2)), + filename: this.attachment.placeholder, }, - { widthPx: attachment.width, heightPx: attachment.height }, + { widthPx: this.attachment.width, heightPx: this.attachment.height }, ); this.addChild(image); + this.lastBuiltWidth = width; + this.lastBuiltInline = true; + } + + override render(width: number): string[] { + const safeWidth = Math.max(0, width); + this.lastRenderWidth = safeWidth; + + if (safeWidth < MAX_IMAGE_WIDTH + 2) { + return new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0).render( + safeWidth, + ); + } + + const caps = getCapabilities(); + const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2'; + if (this.lastBuiltWidth !== safeWidth || this.lastBuiltInline !== supportsInline) { + this.buildChildren(safeWidth); + } + return super.render(safeWidth); + } + + override invalidate(): void { + this.buildChildren(this.lastRenderWidth); + super.invalidate(); } } diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index d936d01b8..6fd1624d5 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -15,33 +15,42 @@ * - Ungrouping is not implemented. Once formed, a group stays grouped. */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call'; const THROTTLE_MS = 200; +const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background'; + interface AgentEntry { readonly toolCallId: string; readonly tc: ToolCallComponent; } +interface PhaseCounts { + readonly done: number; + readonly failed: number; + readonly backgrounded: number; + readonly running: number; + readonly waiting: number; + readonly starting: number; + readonly terminal: number; +} + export class AgentGroupComponent extends Container { private readonly entries: AgentEntry[] = []; private readonly headerText: Text; private readonly bodyContainer: Container; private throttleTimer: ReturnType<typeof setTimeout> | null = null; private lastFlushPhases = new Map<string, ToolCallSubagentSnapshot['phase']>(); + private _invalidating = false; - constructor( - private readonly colors: ColorPalette, - private readonly ui: TUI | undefined, - ) { + constructor(private readonly ui: TUI | undefined) { super(); this.addChild(new Spacer(1)); this.headerText = new Text('', 0, 0); @@ -124,6 +133,9 @@ export class AgentGroupComponent extends Container { const isLast = idx === snapshots.length - 1; this.appendLines(snap, isLast); }); + if (this.shouldShowDetachHint(snapshots)) { + this.bodyContainer.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } this.lastFlushPhases.clear(); this.entries.forEach((entry, i) => { @@ -136,15 +148,13 @@ export class AgentGroupComponent extends Container { } private buildHeader(snapshots: readonly ToolCallSubagentSnapshot[]): string { - const colors = this.colors; const total = snapshots.length; - const done = snapshots.filter((s) => s.phase === 'done').length; - const failed = snapshots.filter((s) => s.phase === 'failed').length; - const finished = done + failed; - const allDone = finished === total; + const counts = countPhases(snapshots); + const allDone = counts.terminal === total; const bullet = allDone - ? chalk.hex(colors.success)(STATUS_BULLET) - : chalk.hex(colors.roleAssistant)(STATUS_BULLET); + ? currentTheme.fg('success', STATUS_BULLET) + : currentTheme.fg('text', STATUS_BULLET); + const elapsedSeconds = maxElapsedSeconds(snapshots); if (allDone) { const types = new Set(snapshots.map((s) => s.agentName).filter((n) => n !== undefined)); @@ -154,33 +164,27 @@ export class AgentGroupComponent extends Container { : `${String(total)} agents finished`; const totalTools = snapshots.reduce((acc, s) => acc + s.toolCount, 0); const totalTokens = snapshots.reduce((acc, s) => acc + s.tokens, 0); - const tail = formatHeaderTail(totalTools, totalTokens); - return `${bullet}${chalk.hex(colors.primary).bold(headerLabel)}${tail}`; + const tail = formatHeaderTail({ toolCount: totalTools, tokens: totalTokens, elapsedSeconds }); + return `${bullet}${currentTheme.boldFg('primary', headerLabel)}${tail}`; } - let headerText = `Running ${String(total)} agents`; - // Mixed status gets a breakdown so the current state is clear. - if (finished > 0) { - const running = total - finished; - const parts: string[] = []; - if (done > 0) parts.push(`${String(done)} done`); - if (failed > 0) parts.push(`${String(failed)} failed`); - if (running > 0) parts.push(`${String(running)} running`); - headerText = `Running ${String(total)} agents (${parts.join(', ')})`; - } - return `${bullet}${chalk.hex(colors.primary).bold(headerText)}`; + const parts = formatBreakdownParts(counts); + const headerText = parts.length > 0 + ? `Running ${String(total)} agents (${parts.join(', ')})` + : `Running ${String(total)} agents`; + const tail = formatHeaderTail({ toolCount: 0, tokens: 0, elapsedSeconds }); + return `${bullet}${currentTheme.boldFg('primary', headerText)}${tail}`; } private appendLines(snap: ToolCallSubagentSnapshot, isLast: boolean): void { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string) => currentTheme.dim(text); // First-level branch line. const branch1 = isLast ? '└─' : '├─'; const agentType = snap.agentName ?? 'agent'; const desc = snap.toolCallDescription || '(no description)'; - const tail = formatLineTail(snap, colors); - const namePart = chalk.hex(colors.primary)(agentType); + const tail = formatLineTail(snap); + const namePart = currentTheme.fg('primary', agentType); const descPart = dim(`· ${desc}`); const stats = formatStats(snap); const line1 = ` ${branch1} ${namePart} ${descPart}${stats}${tail}`; @@ -191,7 +195,7 @@ export class AgentGroupComponent extends Container { if (snap.phase === 'failed') { // Show one error line; error messages can be long. const errLine = (snap.errorText ?? 'Failed').split('\n').at(0) ?? 'Failed'; - const errStr = chalk.hex(colors.error)(`Error: ${errLine}`); + const errStr = currentTheme.fg('error', `Error: ${errLine}`); this.bodyContainer.addChild(new Text(` ${branch2} ${errStr}`, 0, 0)); return; } @@ -200,11 +204,36 @@ export class AgentGroupComponent extends Container { return; } // Running or not-yet-started agents show latest activity, with a fallback. - const activity = snap.latestActivity ?? 'Initializing…'; + const activity = snap.latestActivity ?? fallbackActivityForPhase(snap.phase); this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); } + /** + * Show the Ctrl+B hint while at least one agent in the group is still + * running in the foreground (i.e. can be detached). Hide it once every + * agent is done, failed, or already backgrounded. + */ + private shouldShowDetachHint(snapshots: readonly ToolCallSubagentSnapshot[]): boolean { + return snapshots.some( + (s) => + s.phase === 'running' || + s.phase === 'queued' || + s.phase === 'spawning' || + s.phase === undefined, + ); + } + /** Releases throttle timers so destroyed components cannot refresh later. */ + override invalidate(): void { + if (this._invalidating) { + super.invalidate(); + return; + } + this._invalidating = true; + this.flushRender(); + this._invalidating = false; + } + dispose(): void { if (this.throttleTimer !== null) { clearTimeout(this.throttleTimer); @@ -216,32 +245,129 @@ export class AgentGroupComponent extends Container { } } -function formatStats(snap: ToolCallSubagentSnapshot): string { - const dim = chalk.dim; - const tools = ` · ${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`; - const tokens = snap.tokens > 0 ? ` · ${formatTokens(snap.tokens)}` : ''; - return dim(`${tools}${tokens}`); +function countPhases(snapshots: readonly ToolCallSubagentSnapshot[]): PhaseCounts { + let done = 0; + let failed = 0; + let backgrounded = 0; + let running = 0; + let waiting = 0; + let starting = 0; + + for (const snap of snapshots) { + switch (snap.phase) { + case 'done': + done += 1; + break; + case 'failed': + failed += 1; + break; + case 'backgrounded': + backgrounded += 1; + break; + case 'queued': + waiting += 1; + break; + case 'running': + running += 1; + break; + case 'spawning': + case undefined: + starting += 1; + break; + } + } + + return { + done, + failed, + backgrounded, + running, + waiting, + starting, + terminal: done + failed + backgrounded, + }; } -function formatLineTail(snap: ToolCallSubagentSnapshot, colors: ColorPalette): string { - if (snap.phase === 'done') { - return chalk.dim(' · ') + chalk.hex(colors.success)('✓ Completed'); - } - if (snap.phase === 'failed') { - return chalk.dim(' · ') + chalk.hex(colors.error)('✗ Failed'); - } - if (snap.phase === 'backgrounded') { - return chalk.dim(' · ◐ backgrounded'); - } - return ''; -} - -function formatHeaderTail(toolCount: number, tokens: number): string { - const dim = chalk.dim; +function formatBreakdownParts(counts: PhaseCounts): string[] { const parts: string[] = []; - if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`); - if (tokens > 0) parts.push(formatTokens(tokens)); - return parts.length > 0 ? dim(` · ${parts.join(' · ')}`) : ''; + if (counts.done > 0) parts.push(`${String(counts.done)} done`); + if (counts.failed > 0) parts.push(`${String(counts.failed)} failed`); + if (counts.backgrounded > 0) parts.push(`${String(counts.backgrounded)} backgrounded`); + if (counts.running > 0) parts.push(`${String(counts.running)} running`); + if (counts.waiting > 0) parts.push(`${String(counts.waiting)} waiting`); + if (counts.starting > 0) parts.push(`${String(counts.starting)} starting`); + return parts; +} + +function formatStats(snap: ToolCallSubagentSnapshot): string { + const parts = [`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`]; + if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds)); + if (snap.tokens > 0) parts.push(formatTokens(snap.tokens)); + return currentTheme.dim(` · ${parts.join(' · ')}`); +} + +function formatLineTail(snap: ToolCallSubagentSnapshot): string { + const separator = currentTheme.dim(' · '); + switch (snap.phase) { + case 'done': + return separator + currentTheme.fg('success', '✓ Completed'); + case 'failed': + return separator + currentTheme.fg('error', '✗ Failed'); + case 'backgrounded': + return separator + currentTheme.dim('◐ backgrounded'); + case 'queued': + return separator + currentTheme.fg('primary', 'Waiting'); + case 'running': + return separator + currentTheme.fg('primary', 'Running'); + case 'spawning': + case undefined: + return separator + currentTheme.fg('primary', 'Starting'); + } +} + +function fallbackActivityForPhase(phase: ToolCallSubagentSnapshot['phase']): string { + switch (phase) { + case 'queued': + return 'Waiting to start…'; + case 'running': + return 'Still working…'; + case 'spawning': + case undefined: + return 'Starting…'; + case 'done': + case 'failed': + case 'backgrounded': + return ''; + } +} + +function formatHeaderTail(args: { + readonly toolCount: number; + readonly tokens: number; + readonly elapsedSeconds: number | undefined; +}): string { + const parts: string[] = []; + if (args.toolCount > 0) parts.push(`${String(args.toolCount)} tool${args.toolCount === 1 ? '' : 's'}`); + if (args.tokens > 0) parts.push(formatTokens(args.tokens)); + if (args.elapsedSeconds !== undefined) parts.push(formatElapsed(args.elapsedSeconds)); + return parts.length > 0 ? currentTheme.dim(` · ${parts.join(' · ')}`) : ''; +} + +function maxElapsedSeconds(snapshots: readonly ToolCallSubagentSnapshot[]): number | undefined { + let max: number | undefined; + for (const snap of snapshots) { + const elapsed = snap.elapsedSeconds; + if (elapsed === undefined) continue; + max = max === undefined ? elapsed : Math.max(max, elapsed); + } + return max; +} + +function formatElapsed(seconds: number): string { + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${String(minutes)}m ${String(remainder)}s`; } function formatTokens(n: number): string { diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts new file mode 100644 index 000000000..64343f9d0 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts @@ -0,0 +1,436 @@ +const DEFAULT_RATE_WINDOW_MS = 45_000; +const DEFAULT_CATCHUP_TIME_MS = 1_500; +const DEFAULT_WORKLOAD_SPREAD_FACTOR = 1.5; +const DEFAULT_UNFINISHED_PROGRESS_CAP = 0.85; +const DEFAULT_MAX_BOOST_GAIN = 0.75; +const RATE_TOOL_CONFIDENCE_SCALE = 4; +const BOOST_TOOL_CONFIDENCE_SCALE = 3; +const MIN_RATE_FACTOR = 0.25; +const HALF_TICK = 0.5; + +export type AgentSwarmProgressEstimatorPhase = + | 'pending' + | 'queued' + | 'suspended' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface AgentSwarmProgressEstimatorOptions { + readonly rateWindowMs?: number; + readonly catchupTimeMs?: number; + readonly maxCatchupTicksPerSecond?: number; + readonly workloadSpreadFactor?: number; + readonly unfinishedProgressCap?: number; + readonly maxBoostGain?: number; +} + +export interface AgentSwarmProgressEstimateInput { + readonly memberKey: string; + readonly phase: AgentSwarmProgressEstimatorPhase; + readonly capacityTicks: number; + readonly nowMs: number; +} + +export interface AgentSwarmProgressEstimate { + readonly rawTicks: number; + readonly displayTicks: number; + readonly estimatedTotalToolCalls?: number; + readonly estimatedProgress?: number; + readonly targetProgress?: number; + readonly targetTicks?: number; + readonly boosted: boolean; + readonly confidence?: number; +} + +interface MemberProgressState { + startedAtMs?: number; + pausedAtMs?: number; + pausedDurationMs: number; + terminalAtMs?: number; + terminalKind?: 'completed' | 'failed' | 'cancelled'; + rawTicks: number; + readonly seenToolCallIds: Set<string>; + toolCallActiveTimesMs: number[]; + displayTicks: number; + lastEstimateAtMs?: number; + lastTargetTicks?: number; +} + +interface CompletedSample { + readonly totalMs: number; + readonly rawTicks: number; +} + +interface EstimatePrior { + readonly completedCount: number; + readonly typicalTotalMs: number; + readonly typicalToolCalls: number; + readonly typicalRatePerMs: number; +} + +export class AgentSwarmProgressEstimator { + private readonly members = new Map<string, MemberProgressState>(); + private readonly rateWindowMs: number; + private readonly catchupTimeMs: number; + private readonly maxCatchupTicksPerSecond: number | undefined; + private readonly workloadSpreadFactor: number; + private readonly unfinishedProgressCap: number; + private readonly maxBoostGain: number; + + constructor(options: AgentSwarmProgressEstimatorOptions = {}) { + this.rateWindowMs = positiveOrDefault(options.rateWindowMs, DEFAULT_RATE_WINDOW_MS); + this.catchupTimeMs = positiveOrDefault(options.catchupTimeMs, DEFAULT_CATCHUP_TIME_MS); + this.maxCatchupTicksPerSecond = positiveOrUndefined(options.maxCatchupTicksPerSecond); + this.workloadSpreadFactor = spreadFactorOrDefault( + options.workloadSpreadFactor, + DEFAULT_WORKLOAD_SPREAD_FACTOR, + ); + this.unfinishedProgressCap = clampPositiveRatio( + options.unfinishedProgressCap, + DEFAULT_UNFINISHED_PROGRESS_CAP, + ); + this.maxBoostGain = clampPositiveRatio(options.maxBoostGain, DEFAULT_MAX_BOOST_GAIN); + } + + ensureMember(memberKey: string, nowMs: number): void { + void nowMs; + this.getOrCreateMember(memberKey); + } + + removeMissingMembers(memberKeys: readonly string[]): void { + const live = new Set(memberKeys); + for (const memberKey of this.members.keys()) { + if (!live.has(memberKey)) this.members.delete(memberKey); + } + } + + markStarted(memberKey: string, nowMs: number): void { + const state = this.getOrCreateMember(memberKey); + this.startWork(state, nowMs); + if (state.rawTicks === 0) { + state.rawTicks = 1; + state.displayTicks = Math.max(state.displayTicks, 1); + } + delete state.terminalAtMs; + delete state.terminalKind; + } + + markQueued(memberKey: string, nowMs: number): void { + const state = this.getOrCreateMember(memberKey); + if (state.startedAtMs === undefined || state.terminalKind !== undefined) return; + state.pausedAtMs ??= nowMs; + state.lastEstimateAtMs = nowMs; + delete state.lastTargetTicks; + } + + recordToolCall(input: { + readonly memberKey: string; + readonly toolCallId: string; + readonly nowMs: number; + }): { readonly accepted: boolean; readonly rawTicks: number } { + const state = this.getOrCreateMember(input.memberKey); + this.startWork(state, input.nowMs); + if (state.seenToolCallIds.has(input.toolCallId)) { + return { accepted: false, rawTicks: state.rawTicks }; + } + state.seenToolCallIds.add(input.toolCallId); + state.toolCallActiveTimesMs.push(this.activeElapsedMs(state, input.nowMs)); + state.rawTicks += 1; + state.displayTicks = Math.max(state.displayTicks + 1, state.rawTicks); + delete state.terminalAtMs; + delete state.terminalKind; + return { accepted: true, rawTicks: state.rawTicks }; + } + + markCompleted(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'completed'); + } + + markFailed(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'failed'); + } + + markCancelled(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'cancelled'); + } + + estimate(input: AgentSwarmProgressEstimateInput): AgentSwarmProgressEstimate { + const state = this.getOrCreateMember(input.memberKey); + const capacityTicks = Math.max(1, input.capacityTicks); + const rawTicks = state.rawTicks; + const previousDisplayTicks = Math.max(state.displayTicks, rawTicks); + const prior = this.buildPrior(); + const baseEstimate = { + rawTicks, + displayTicks: previousDisplayTicks, + boosted: false, + }; + + if (input.phase !== 'running' || rawTicks <= 0 || prior === undefined) { + state.displayTicks = previousDisplayTicks; + state.lastEstimateAtMs = input.nowMs; + delete state.lastTargetTicks; + return baseEstimate; + } + + const completedConfidence = this.completedSampleConfidence(prior.completedCount); + const estimatedTotalToolCalls = this.estimateTotalToolCalls( + state, + prior, + input.nowMs, + completedConfidence, + ); + const estimatedProgress = Math.min( + this.unfinishedProgressCap, + rawTicks / estimatedTotalToolCalls, + ); + const rawProgress = Math.min(1, rawTicks / capacityTicks); + if (estimatedProgress <= rawProgress) { + state.displayTicks = previousDisplayTicks; + state.lastEstimateAtMs = input.nowMs; + delete state.lastTargetTicks; + return { + ...baseEstimate, + estimatedTotalToolCalls, + estimatedProgress, + boosted: false, + }; + } + + const toolConfidence = confidence(rawTicks, BOOST_TOOL_CONFIDENCE_SCALE); + const boostConfidence = completedConfidence * toolConfidence; + const boostGain = this.maxBoostGain * boostConfidence; + const targetProgress = rawProgress + boostGain * (estimatedProgress - rawProgress); + const targetTicks = Math.max(rawTicks, targetProgress * capacityTicks); + const displayTicks = this.catchUpDisplayTicks( + state, + previousDisplayTicks, + targetTicks, + capacityTicks, + input.nowMs, + ); + + state.displayTicks = displayTicks; + state.lastEstimateAtMs = input.nowMs; + state.lastTargetTicks = targetTicks; + return { + rawTicks, + displayTicks, + estimatedTotalToolCalls, + estimatedProgress, + targetProgress, + targetTicks, + boosted: displayTicks > rawTicks, + confidence: boostConfidence, + }; + } + + estimateAll( + inputs: readonly AgentSwarmProgressEstimateInput[], + ): Map<string, AgentSwarmProgressEstimate> { + const estimates = new Map<string, AgentSwarmProgressEstimate>(); + for (const input of inputs) { + estimates.set(input.memberKey, this.estimate(input)); + } + return estimates; + } + + hasPendingCatchup(): boolean { + return Array.from(this.members.values()).some( + (state) => state.lastTargetTicks !== undefined && state.lastTargetTicks > state.displayTicks + 0.1, + ); + } + + private markTerminal( + memberKey: string, + nowMs: number, + terminalKind: 'completed' | 'failed' | 'cancelled', + ): void { + const state = this.getOrCreateMember(memberKey); + this.finishPausedInterval(state, nowMs); + state.terminalAtMs = nowMs; + state.terminalKind = terminalKind; + state.displayTicks = Math.max(state.displayTicks, state.rawTicks); + delete state.lastTargetTicks; + } + + private startWork(state: MemberProgressState, nowMs: number): void { + const wasQueued = state.startedAtMs === undefined || state.pausedAtMs !== undefined; + state.startedAtMs ??= nowMs; + this.finishPausedInterval(state, nowMs); + if (!wasQueued) return; + delete state.lastEstimateAtMs; + delete state.lastTargetTicks; + } + + private finishPausedInterval(state: MemberProgressState, nowMs: number): void { + if (state.pausedAtMs === undefined) return; + state.pausedDurationMs += Math.max(0, nowMs - state.pausedAtMs); + delete state.pausedAtMs; + } + + private activeElapsedMs(state: MemberProgressState, nowMs: number): number { + if (state.startedAtMs === undefined) return 0; + const currentPausedMs = + state.pausedAtMs === undefined ? 0 : Math.max(0, nowMs - state.pausedAtMs); + return Math.max(0, nowMs - state.startedAtMs - state.pausedDurationMs - currentPausedMs); + } + + private getOrCreateMember(memberKey: string): MemberProgressState { + const state = this.members.get(memberKey) ?? { + pausedDurationMs: 0, + rawTicks: 0, + seenToolCallIds: new Set(), + toolCallActiveTimesMs: [], + displayTicks: 0, + }; + this.members.set(memberKey, state); + return state; + } + + private buildPrior(): EstimatePrior | undefined { + const samples = this.completedSamples(); + if (samples.length === 0) return undefined; + return { + completedCount: samples.length, + typicalTotalMs: logMedian(samples.map((sample) => sample.totalMs)), + typicalToolCalls: logMedian(samples.map((sample) => sample.rawTicks)), + typicalRatePerMs: logMedian( + samples.map((sample) => (sample.rawTicks + HALF_TICK) / sample.totalMs), + ), + }; + } + + private completedSamples(): CompletedSample[] { + const samples: CompletedSample[] = []; + for (const state of this.members.values()) { + if (state.terminalKind !== 'completed') continue; + if (state.startedAtMs === undefined || state.terminalAtMs === undefined) continue; + if (state.rawTicks <= 0) continue; + const totalMs = this.activeElapsedMs(state, state.terminalAtMs); + if (totalMs <= 0) continue; + samples.push({ totalMs, rawTicks: state.rawTicks }); + } + return samples; + } + + private estimateTotalToolCalls( + state: MemberProgressState, + prior: EstimatePrior, + nowMs: number, + completedConfidence: number, + ): number { + const elapsedMs = this.activeElapsedMs(state, nowMs); + const localRatePerMs = this.estimateLocalRatePerMs(state, elapsedMs); + const rateWeight = confidence(state.rawTicks, RATE_TOOL_CONFIDENCE_SCALE); + const clampedLocalRatePerMs = Math.max( + localRatePerMs, + prior.typicalRatePerMs * MIN_RATE_FACTOR, + ); + const ratePerMs = geometricInterpolate( + prior.typicalRatePerMs, + clampedLocalRatePerMs, + rateWeight, + ); + const totalMs = Math.max(prior.typicalTotalMs, elapsedMs / this.unfinishedProgressCap); + const estimatedTotalToolCalls = ratePerMs * totalMs; + const boundedTotalToolCalls = this.softBoundTotalToolCalls( + estimatedTotalToolCalls, + prior, + completedConfidence, + ); + return Math.max( + boundedTotalToolCalls, + state.rawTicks / this.unfinishedProgressCap, + 1, + ); + } + + private softBoundTotalToolCalls( + totalToolCalls: number, + prior: EstimatePrior, + completedConfidence: number, + ): number { + const lowerBound = prior.typicalToolCalls / this.workloadSpreadFactor; + const upperBound = prior.typicalToolCalls * this.workloadSpreadFactor; + const bounded = Math.max(lowerBound, Math.min(upperBound, totalToolCalls)); + if (bounded === totalToolCalls) return totalToolCalls; + return geometricInterpolate(totalToolCalls, bounded, completedConfidence); + } + + private estimateLocalRatePerMs( + state: MemberProgressState, + elapsedMs: number, + ): number { + if (elapsedMs <= 0 || state.toolCallActiveTimesMs.length === 0) return 0; + let decayedToolCalls = 0; + for (const timeMs of state.toolCallActiveTimesMs) { + decayedToolCalls += Math.exp(-Math.max(0, elapsedMs - timeMs) / this.rateWindowMs); + } + const decayedElapsedMs = this.rateWindowMs * (1 - Math.exp(-elapsedMs / this.rateWindowMs)); + if (decayedElapsedMs <= 0) return 0; + return decayedToolCalls / decayedElapsedMs; + } + + private catchUpDisplayTicks( + state: MemberProgressState, + previousDisplayTicks: number, + targetTicks: number, + capacityTicks: number, + nowMs: number, + ): number { + if (targetTicks <= previousDisplayTicks) return previousDisplayTicks; + const lastEstimateAtMs = state.lastEstimateAtMs ?? nowMs; + const elapsedMs = Math.max(0, nowMs - lastEstimateAtMs); + if (elapsedMs <= 0) return previousDisplayTicks; + const alpha = 1 - Math.exp(-elapsedMs / this.catchupTimeMs); + const desiredDelta = (targetTicks - previousDisplayTicks) * alpha; + const maxCatchupTicksPerSecond = this.maxCatchupTicksPerSecond ?? capacityTicks / 2; + const maxDelta = Math.max(0, maxCatchupTicksPerSecond * (elapsedMs / 1_000)); + return previousDisplayTicks + Math.min(desiredDelta, maxDelta); + } + + private completedSampleConfidence(completedCount: number): number { + return confidence(completedCount, 1 + this.workloadSpreadFactor); + } +} + +function positiveOrDefault(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback; +} + +function positiveOrUndefined(value: number | undefined): number | undefined { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function spreadFactorOrDefault(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 1 ? value : fallback; +} + +function clampPositiveRatio(value: number | undefined, fallback: number): number { + const ratio = positiveOrDefault(value, fallback); + return Math.max(0.01, Math.min(0.99, ratio)); +} + +function confidence(count: number, scale: number): number { + return 1 - Math.exp(-Math.max(0, count) / scale); +} + +function geometricInterpolate(low: number, high: number, weight: number): number { + const safeLow = Math.max(Number.EPSILON, low); + const safeHigh = Math.max(Number.EPSILON, high); + return Math.exp((1 - weight) * Math.log(safeLow) + weight * Math.log(safeHigh)); +} + +function logMedian(values: readonly number[]): number { + const logs = values + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.log(value)) + .toSorted((left, right) => left - right); + if (logs.length === 0) return 1; + const middle = Math.floor(logs.length / 2); + if (logs.length % 2 === 1) return Math.exp(logs[middle]!); + return Math.exp((logs[middle - 1]! + logs[middle]!) / 2); +} 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 new file mode 100644 index 000000000..75c7266a2 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -0,0 +1,1727 @@ +import { truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { + AgentSwarmProgressEstimator, + type AgentSwarmProgressEstimatorPhase, +} from '#/tui/components/messages/agent-swarm-progress-estimator'; +import { FAILURE_MARK, SUCCESS_MARK } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; +import { gradientText } from '#/tui/theme/gradient-text'; + +const TEXT_CELL_PREFERRED_WIDTH = 30; +const CELL_GAP = ' '; +const FRAME_INTERVAL_MS = 80; +const TEXT_BRAILLE_BAR_MIN_WIDTH = 6; +const BRAILLE_BAR_MAX_WIDTH = 8; +const BRAILLE_EMPTY = '⣀'; +const BRAILLE_RIGHT_COLUMN_FULL = '⢸'; +const BRAILLE_LEVELS = ['⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'] as const; +const PHASE_LABEL_WIDTH = 'Completed'.length; +const MIN_LABEL_WIDTH = PHASE_LABEL_WIDTH; +const MAX_LATEST_MODEL_CHARS = 2_000; +const COMPLETE_FILL_MS = 360; +const FAILED_PLACEHOLDER_RED_FACTOR = 0.75; +const FAILED_PLACEHOLDER_NON_RED_FACTOR = 0.25; +const STATUS_BAR_CHAR = '━'; +const CANCELLED_MARK = '⊘ '; +const TOTAL_STATUS_BAR_GAP = 2; +const PROMPTING_TEXT_TRAILING_GAP = 1; +const ACTIVITY_SPINNER_PLACEHOLDER = ' '; +const AGENT_SWARM_LEFT_INDENT = ' '; +const AGENT_SWARM_RIGHT_GAP = 1; +const AGENT_SWARM_NON_GRID_LINES = 6; +const COMPACT_TERMINAL_MARK_WIDTH = 1; +const ORCHESTRATING_LABEL = 'Orchestrating...'; +const PROMPTING_LABEL = 'Prompting...'; +const WORKING_LABEL = 'Working...'; +const COMPLETED_LABEL = 'Completed.'; +const FAILED_LABEL = 'Failed.'; +const ABORTED_LABEL = 'Aborted.'; +const CANCELLED_LABEL = 'Cancelled.'; +const QUEUED_LABEL = 'Queued...'; +const SUSPENDED_LABEL = 'Rate limited...'; +const RESUMED_ITEM_LABEL = '(resumed)'; +const CANCELLED_LABEL_DARKEN_FACTOR = 0.72; +const AGENT_SWARM_TITLE_ACCENT_BIAS = 1.3; + +const STATUS_BAR_ORDER = [ + 'completed', + 'working', + 'suspended', + 'queued', + 'cancelled', + 'failed', +] as const; + +type AgentSwarmPhase = AgentSwarmProgressEstimatorPhase; +type StatusBarPhase = typeof STATUS_BAR_ORDER[number]; +type TotalStatus = 'working' | 'completed' | 'suspended' | 'failed' | 'aborted'; +type ClearableMemberKey = + | 'completedAtMs' + | 'completedText' + | 'failedAtMs' + | 'failureText' + | 'cancelledLabelText' + | 'cancelledLabelColor' + | 'cancelledMarkColor' + | 'cancelledBarColor' + | 'suspendedReason'; + +const COMPLETED_CLEAR_KEYS = [ + 'failedAtMs', + 'failureText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const FAILED_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const TERMINAL_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'failedAtMs', + 'failureText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const CANCELLED_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'failedAtMs', + 'failureText', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; + +interface AgentSwarmMember { + readonly id: string; + agentId?: string; + phase: AgentSwarmPhase; + ticks: number; + itemText: string; + latestModelText: string; + completedText?: string; + failureText?: string; + cancelledLabelText?: string; + cancelledLabelColor?: string; + cancelledMarkColor?: string; + cancelledBarColor?: string; + suspendedReason?: string; + completedAtMs?: number; + failedAtMs?: number; +} + +interface AgentSwarmSnapshot { + readonly phase: AgentSwarmPhase; + readonly ticks: number; + readonly latestModelText: string; + readonly phaseElapsedMs: number; +} + +interface AgentSwarmResultStatus { + readonly index: number; + readonly status: 'completed' | 'failed' | 'cancelled'; + readonly completedText?: string; + readonly failureText?: string; +} + +export interface AgentSwarmResultSummary { + readonly completed: number; + readonly failed: number; + readonly aborted: number; + readonly parsed: boolean; +} + +interface AgentSwarmSummary { + readonly active: number; + readonly completed: number; + readonly failed: number; + readonly cancelled: number; +} + +export interface AgentSwarmGridLayoutInput { + readonly width: number; + readonly height: number; + readonly count: number; +} + +export interface AgentSwarmGridLayout { + readonly renderText: boolean; + readonly barCells: number; + readonly columns: number; + readonly rows: number; + readonly cellWidth: number; + readonly columnGap: number; + readonly leftPadding: number; +} + +export interface AgentSwarmProgressOptions { + readonly description: string; + readonly requestRender?: () => void; + readonly availableGridHeight?: () => number | undefined; +} + +const PHASE_LABELS: Record<AgentSwarmPhase, string> = { + pending: QUEUED_LABEL, + queued: QUEUED_LABEL, + suspended: SUSPENDED_LABEL, + running: 'Running', + completed: 'Completed', + failed: 'Failed', + cancelled: ABORTED_LABEL, +}; + +export class AgentSwarmProgressComponent implements Component { + private members: AgentSwarmMember[]; + private readonly progressEstimator = new AgentSwarmProgressEstimator(); + private description: string; + private readonly requestRender: (() => void) | undefined; + private readonly availableGridHeight: (() => number | undefined) | undefined; + private inputComplete = false; + private failed = false; + private aborted = false; + private itemsStarted = false; + private toolCallActive = true; + private promptTemplateText = ''; + private activitySpinnerText: (() => string) | undefined; + private timer: ReturnType<typeof setInterval> | undefined; + + constructor(options: AgentSwarmProgressOptions) { + this.description = options.description; + this.requestRender = options.requestRender; + this.availableGridHeight = options.availableGridHeight; + this.members = []; + } + + /** Live palette, read on each render so a theme switch recolors the panel. */ + private get colors(): ColorPalette { + return currentTheme.palette; + } + + dispose(): void { + if (this.timer === undefined) return; + clearInterval(this.timer); + this.timer = undefined; + } + + invalidate(): void {} + + setActivitySpinnerText(provider: (() => string) | undefined): void { + if (!this.toolCallActive) return; + this.activitySpinnerText = provider; + } + + markToolCallEnded(): void { + this.toolCallActive = false; + this.activitySpinnerText = undefined; + } + + isToolCallActive(): boolean { + return this.toolCallActive; + } + + isRequestStreaming(): boolean { + return !this.inputComplete; + } + + updateArgs( + args: Record<string, unknown>, + options: { readonly streamingArguments?: string | undefined } = {}, + ): void { + const streamingArguments = options.streamingArguments; + const description = agentSwarmDescriptionFromArgs(args); + if (description.length > 0 || this.description.length === 0) { + this.description = description; + } + const fullRows = [...agentSwarmResumeItemsFromArgs(args), ...agentSwarmItemsFromArgs(args)]; + const partialRows = streamingArguments === undefined + ? [] + : [ + ...agentSwarmPartialResumeItemsFromArguments(streamingArguments), + ...agentSwarmPartialItemsFromArguments(streamingArguments), + ]; + if ( + fullRows.length > 0 || + partialRows.length > 0 || + (streamingArguments !== undefined && agentSwarmWorkItemsStartedFromArguments(streamingArguments)) + ) { + this.itemsStarted = true; + } + const fullPromptTemplate = agentSwarmPromptTemplateFromArgs(args); + const partialPromptTemplate = + streamingArguments === undefined + ? '' + : agentSwarmPartialPromptTemplateFromArguments(streamingArguments); + const promptTemplate = + fullPromptTemplate.length > 0 ? fullPromptTemplate : partialPromptTemplate; + if (promptTemplate.length > 0 || this.promptTemplateText.length === 0) { + this.promptTemplateText = promptTemplate; + } + + const itemCount = Math.max(fullRows.length, partialRows.length); + if (itemCount > 0) this.ensureMemberCount(itemCount); + this.updateItemTexts(fullRows, partialRows); + } + + markInputComplete(): void { + if (!this.inputComplete) { + this.inputComplete = true; + for (const member of this.members) { + if (member.phase === 'pending') member.phase = 'queued'; + } + } + this.startAnimationIfNeeded(); + } + + registerSubagent(input: { + readonly agentId: string; + readonly swarmIndex?: number; + readonly description?: string | undefined; + }): void { + const member = this.findMemberForSubagent(input.agentId, input.swarmIndex); + if (member === undefined) return; + member.agentId = input.agentId; + if (member.phase === 'pending') member.phase = 'queued'; + this.startAnimationIfNeeded(); + } + + markStarted(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + const nowMs = Date.now(); + this.progressEstimator.markStarted(member.id, nowMs); + member.ticks = Math.max(member.ticks, 1); + this.promoteToRunning(member, nowMs); + this.startAnimationIfNeeded(); + } + + recordToolCall(input: { + readonly agentId: string; + readonly toolCallId: string; + }): void { + const member = this.findMemberByAgentId(input.agentId); + if (member === undefined) return; + const result = this.progressEstimator.recordToolCall({ + memberKey: member.id, + toolCallId: input.toolCallId, + nowMs: Date.now(), + }); + if (!result.accepted) return; + member.ticks = result.rawTicks; + this.promoteToRunning(member); + this.startAnimationIfNeeded(); + } + + appendModelDelta(input: { + readonly agentId: string; + readonly delta: string; + }): void { + const member = this.findMemberByAgentId(input.agentId); + if (member === undefined || input.delta.length === 0) return; + member.latestModelText = `${member.latestModelText}${input.delta}`.slice( + -MAX_LATEST_MODEL_CHARS, + ); + this.promoteToRunning(member, Date.now(), true); + } + + markCompleted(agentId: string, completedText?: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined || member.phase === 'failed' || member.phase === 'cancelled') return; + const nowMs = Date.now(); + this.completeMember(member, nowMs, completedText); + this.startAnimationIfNeeded(); + } + + markSuspended(input: { + readonly agentId: string; + readonly reason: string; + readonly swarmIndex?: number; + readonly description?: string | undefined; + }): void { + const member = this.findMemberByAgentId(input.agentId) ?? + this.findMemberForSubagent(input.agentId, input.swarmIndex); + if (member === undefined || member.phase === 'completed' || member.phase === 'cancelled') return; + member.agentId = input.agentId; + this.progressEstimator.markQueued(member.id, Date.now()); + member.phase = 'suspended'; + clearMemberState(member, ...TERMINAL_CLEAR_KEYS); + this.startAnimationIfNeeded(); + } + + markFailed(agentId: string, failureText?: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + const nowMs = Date.now(); + this.failMember(member, nowMs, failureText); + this.startAnimationIfNeeded(); + } + + markSwarmFailed(failureText?: string): void { + this.failed = true; + this.aborted = false; + const nowMs = Date.now(); + for (const member of this.members) { + if (isTerminalPhase(member.phase)) continue; + this.failMember(member, nowMs, failureText); + } + this.startAnimationIfNeeded(); + } + + markCancelled(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + this.cancelMember(member, Date.now()); + } + + markActiveCancelled(): void { + this.aborted = true; + const nowMs = Date.now(); + for (const member of this.members) { + if (isTerminalPhase(member.phase)) continue; + this.cancelMember(member, nowMs); + } + this.startAnimationIfNeeded(); + } + + applyResult(output: string): boolean { + const statuses = parseAgentSwarmResultStatuses(output); + if (statuses.length === 0) return false; + this.aborted = false; + const nowMs = Date.now(); + for (const entry of statuses) { + this.ensureMemberCount(entry.index); + const member = this.members[entry.index - 1]; + if (member === undefined) continue; + if (entry.status === 'completed') { + this.completeMember(member, nowMs, entry.completedText); + } else if (entry.status === 'failed') { + this.failMember(member, nowMs, entry.failureText); + } else { + this.cancelMember(member, nowMs); + } + } + this.startAnimationIfNeeded(); + return true; + } + + render(width: number): string[] { + const outerWidth = Math.max(1, width); + const innerWidth = Math.max( + 1, + outerWidth - visibleWidth(AGENT_SWARM_LEFT_INDENT) - AGENT_SWARM_RIGHT_GAP, + ); + if (this.members.length === 0) { + const lines = [ + '', + this.renderHeader(innerWidth, undefined), + '', + this.renderStatusLine(innerWidth), + '', + ]; + return this.indentLines(lines, outerWidth); + } + + const nowMs = Date.now(); + const snapshots = this.members.map((member): AgentSwarmSnapshot => ({ + phase: member.phase, + ticks: member.ticks, + latestModelText: member.latestModelText, + phaseElapsedMs: terminalPhaseElapsedMs(member, nowMs), + })); + const summary = summarizeSnapshots(snapshots); + const lines = [ + '', + this.renderHeader(innerWidth, summary), + '', + ...this.renderGrid( + innerWidth, + this.availableGridHeight?.(), + snapshots, + nowMs, + ), + '', + this.renderStatusLine(innerWidth), + '', + ]; + this.startAnimationIfNeeded(); + return this.indentLines(lines, outerWidth); + } + + private indentLines(lines: readonly string[], width: number): string[] { + const contentWidth = Math.max( + 0, + width - visibleWidth(AGENT_SWARM_LEFT_INDENT) - AGENT_SWARM_RIGHT_GAP, + ); + return lines.map((line) => + truncateToWidth( + AGENT_SWARM_LEFT_INDENT + truncateToWidth(line, contentWidth), + width, + ) + ); + } + + private renderHeader(width: number, _summary: AgentSwarmSummary | undefined): string { + if (width <= 3) return chalk.hex(this.colors.primary)('─'.repeat(width)); + + const title = gradientText('Agent Swarm', this.colors.primary, this.colors.accent, AGENT_SWARM_TITLE_ACCENT_BIAS); + const description = + this.description.length > 0 + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.text)(this.description) + : ''; + const prefixText = '─ '; + const labelWidth = Math.max(1, width - visibleWidth(prefixText) - 1); + const label = truncateToWidth(title + description, 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); + } + + private renderStatusLine(width: number): string { + const status = totalStatus(this.members, { + failed: this.failed, + aborted: this.aborted, + }); + const prefix = this.renderActivityPrefix(status); + if (prefix.length > 0) { + const contentWidth = Math.max(0, width - visibleWidth(prefix)); + if (contentWidth <= 0) return truncateToWidth(prefix, width); + return truncateToWidth(`${prefix}${this.renderStatusLineContent(contentWidth, status)}`, width); + } + return this.renderStatusLineContent(width, status); + } + + private renderActivityPrefix(status: TotalStatus): string { + if (this.toolCallActive) return this.activitySpinnerText?.() ?? ''; + return activityPrefixForTotalStatus(status, this.colors); + } + + private renderStatusLineContent(width: number, status: TotalStatus): string { + if (status !== 'working') return this.renderProgressStatusLine(width, status); + + if (!this.inputComplete) { + return this.renderOrchestratingStatusLine(width); + } + + return this.renderProgressStatusLine(width, status); + } + + private renderProgressStatusLine(width: number, status: TotalStatus): string { + const label = renderStatusLabel( + totalStatusLabel(status), + totalStatusLabelColor(status, this.members, this.colors), + ); + if (this.members.length === 0) return truncateToWidth(label, width); + const barWidth = Math.max(0, width - visibleWidth(label) - TOTAL_STATUS_BAR_GAP); + if (barWidth <= 0) return truncateToWidth(label, width); + return truncateToWidth( + `${label}${' '.repeat(TOTAL_STATUS_BAR_GAP)}${renderStatusPipBar(this.members, barWidth, this.colors)}`, + width, + ); + } + + private renderOrchestratingStatusLine(width: number): string { + if (this.itemsStarted) { + return truncateToWidth( + renderStatusLabel(ORCHESTRATING_LABEL, this.colors.primary), + width, + ); + } + + const promptTemplate = collapseWhitespace(this.promptTemplateText); + const label = renderStatusLabel( + promptTemplate.length > 0 ? PROMPTING_LABEL : ORCHESTRATING_LABEL, + this.colors.primary, + ); + if (promptTemplate.length === 0) return truncateToWidth(label, width); + + const availablePromptWidth = Math.max( + 0, + width - visibleWidth(label) - PROMPTING_TEXT_TRAILING_GAP, + ); + const separator = visibleWidth(promptTemplate) <= availablePromptWidth - 1 ? ' ' : ' '; + const promptWidth = Math.max(0, availablePromptWidth - visibleWidth(separator)); + if (promptWidth <= 0) return truncateToWidth(label, width); + const prompt = chalk.hex(this.colors.textDim)(truncateStartToWidth(promptTemplate, promptWidth)); + return truncateToWidth(`${label}${separator}${prompt}`, width); + } + + private renderGrid( + width: number, + height: number | undefined, + snapshots: readonly AgentSwarmSnapshot[], + nowMs: number, + ): string[] { + const layout = calculateAgentSwarmGridLayout({ + width, + height: height ?? Number.POSITIVE_INFINITY, + count: this.members.length, + }); + const columns = Math.max(1, layout.columns); + const rows = layout.rows; + const cellGap = ' '.repeat(layout.columnGap); + const leftPadding = ' '.repeat(layout.leftPadding); + const lines: string[] = []; + + for (let row = 0; row < rows; row += 1) { + const cells: string[] = []; + for (let col = 0; col < columns; col += 1) { + const index = row * columns + col; + const member = this.members[index]; + const snapshot = snapshots[index]; + if (member === undefined || snapshot === undefined) continue; + cells.push(padAnsi(this.renderCell(member, snapshot, layout, nowMs), layout.cellWidth)); + } + lines.push(leftPadding + cells.join(cellGap)); + } + return lines; + } + + private renderCell( + member: AgentSwarmMember, + snapshot: AgentSwarmSnapshot, + layout: AgentSwarmGridLayout, + nowMs: number, + ): string { + const width = layout.cellWidth; + if (snapshot.phase === 'pending') { + return renderPendingCell(member, width, this.colors); + } + if (snapshot.phase === 'cancelled' && snapshot.ticks <= 0) { + return renderCancelledUnstartedCell(member, width, this.colors); + } + if (!layout.renderText) { + return this.renderCompactCell(member, snapshot, layout.barCells, nowMs); + } + if (snapshot.phase === 'queued' && snapshot.ticks <= 0) { + return renderQueuedCell(member, width, this.colors); + } + + const estimate = this.progressEstimator.estimate({ + memberKey: member.id, + phase: snapshot.phase, + capacityTicks: layout.barCells * BRAILLE_LEVELS.length, + nowMs, + }); + const id = chalk.hex(this.colors.primary)(member.id); + const bar = brailleBar( + estimate.displayTicks, + snapshot.phase, + layout.barCells, + this.colors, + snapshot.phaseElapsedMs, + cancelledProgressColor(member, snapshot.phase, this.colors), + ); + const prefix = `${id} ${bar} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + const label = renderCellLabel(member, snapshot, labelWidth, this.colors); + return prefix + label; + } + + private renderCompactCell( + member: AgentSwarmMember, + snapshot: AgentSwarmSnapshot, + barCells: number, + nowMs: number, + ): string { + const estimatePhase = snapshot.phase === 'pending' ? 'queued' : snapshot.phase; + const estimate = this.progressEstimator.estimate({ + memberKey: member.id, + phase: estimatePhase, + capacityTicks: barCells * BRAILLE_LEVELS.length, + nowMs, + }); + const id = chalk.hex(this.colors.primary)(member.id); + const bar = brailleBar( + estimate.displayTicks, + estimatePhase, + barCells, + this.colors, + snapshot.phaseElapsedMs, + cancelledProgressColor(member, snapshot.phase, this.colors), + ); + return `${id} ${bar}${compactTerminalMark(member, snapshot.phase, this.colors)}`; + } + + private findMemberForSubagent( + agentId: string, + swarmIndex: number | undefined, + ): AgentSwarmMember | undefined { + const existing = this.findMemberByAgentId(agentId); + if (existing !== undefined) return existing; + + if (swarmIndex !== undefined && Number.isInteger(swarmIndex) && swarmIndex > 0) { + this.ensureMemberCount(swarmIndex); + const byIndex = this.members[swarmIndex - 1]; + if (byIndex !== undefined) return byIndex; + } + + const unassigned = this.members.find((member) => member.agentId === undefined); + if (unassigned !== undefined) return unassigned; + + this.ensureMemberCount(this.members.length + 1); + return this.members.at(-1); + } + + private findMemberByAgentId(agentId: string): AgentSwarmMember | undefined { + return this.members.find((member) => member.agentId === agentId); + } + + private ensureMemberCount(count: number): void { + if (count <= this.members.length) return; + const previousLength = this.members.length; + this.members = [ + ...this.members, + ...createMembers(count, this.inputComplete ? 'queued' : 'pending').slice(this.members.length), + ]; + const nowMs = Date.now(); + for (let index = previousLength; index < this.members.length; index += 1) { + const member = this.members[index]; + if (member !== undefined) this.progressEstimator.ensureMember(member.id, nowMs); + } + } + + private updateItemTexts(fullItems: readonly string[], partialItems: readonly string[]): void { + const count = Math.max(fullItems.length, partialItems.length, this.members.length); + for (let index = 0; index < count; index += 1) { + const member = this.members[index]; + if (member === undefined) continue; + const itemText = fullItems[index] ?? partialItems[index]; + if (itemText !== undefined) member.itemText = itemText; + } + } + + private startAnimationIfNeeded(): void { + if (this.requestRender === undefined || this.timer !== undefined) return; + if (!this.hasAnimatedMembers()) return; + const requestRender = this.requestRender; + this.timer = setInterval(() => { + requestRender(); + if (!this.hasAnimatedMembers()) this.dispose(); + }, FRAME_INTERVAL_MS); + if (typeof this.timer === 'object' && 'unref' in this.timer) { + this.timer.unref(); + } + } + + private hasAnimatedMembers(): boolean { + const now = Date.now(); + return ( + this.progressEstimator.hasPendingCatchup() || + this.members.some((member) => + ( + member.phase === 'completed' && + member.completedAtMs !== undefined && + now - member.completedAtMs < COMPLETE_FILL_MS + ) || + ( + member.phase === 'failed' && + member.failedAtMs !== undefined && + now - member.failedAtMs < COMPLETE_FILL_MS + ), + ) + ); + } + + private promoteToRunning(member: AgentSwarmMember, nowMs?: number, setTicks = false): void { + if (member.phase === 'pending' || member.phase === 'queued' || member.phase === 'suspended') { + member.phase = 'running'; + if (nowMs !== undefined) this.progressEstimator.markStarted(member.id, nowMs); + if (setTicks) member.ticks = Math.max(member.ticks, 1); + } + delete member.suspendedReason; + } + + private completeMember(member: AgentSwarmMember, nowMs: number, completedText?: string): void { + if (member.phase !== 'completed') { + this.progressEstimator.markCompleted(member.id, nowMs); + member.completedAtMs = nowMs; + } + const normalizedCompletedText = normalizeFinalOutputText(completedText); + if (normalizedCompletedText !== undefined) member.completedText = normalizedCompletedText; + member.phase = 'completed'; + clearMemberState(member, ...COMPLETED_CLEAR_KEYS); + } + + private failMember(member: AgentSwarmMember, nowMs: number, failureText?: string): void { + if (member.phase !== 'failed') { + this.progressEstimator.markFailed(member.id, nowMs); + member.failedAtMs = nowMs; + } + const normalizedFailureText = normalizeFailureText(failureText); + if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; + member.phase = 'failed'; + clearMemberState(member, ...FAILED_CLEAR_KEYS); + } + + private cancelMember(member: AgentSwarmMember, nowMs: number): void { + const previousPhase = member.phase; + this.progressEstimator.markCancelled(member.id, nowMs); + member.phase = 'cancelled'; + clearMemberState(member, ...CANCELLED_CLEAR_KEYS); + if (previousPhase === 'pending' || previousPhase === 'queued' || previousPhase === 'suspended') { + member.cancelledLabelText = CANCELLED_LABEL; + member.cancelledLabelColor = cancelledLabelColor(this.colors); + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } else if (previousPhase === 'running') { + member.cancelledLabelText = runningCellLabelText(member); + member.cancelledLabelColor = cancelledLabelColor(this.colors); + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } else { + member.cancelledLabelText = ABORTED_LABEL; + member.cancelledLabelColor = this.colors.warning; + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } + } +} + +function createMembers(count: number, phase: AgentSwarmPhase): AgentSwarmMember[] { + return Array.from({ length: count }, (_item, index) => ({ + id: String(index + 1).padStart(3, '0'), + phase, + ticks: 0, + itemText: '', + latestModelText: '', + })); +} + +function clearMemberState(member: AgentSwarmMember, ...keys: ClearableMemberKey[]): void { + for (const key of keys) delete member[key]; +} + +function isTerminalPhase(phase: AgentSwarmPhase): boolean { + return phase === 'completed' || phase === 'failed' || phase === 'cancelled'; +} + +function terminalPhaseElapsedMs(member: AgentSwarmMember, nowMs: number): number { + const startedAtMs = member.phase === 'completed' + ? member.completedAtMs + : member.phase === 'failed' + ? member.failedAtMs + : undefined; + return startedAtMs === undefined ? 0 : Math.max(0, nowMs - startedAtMs); +} + +export function agentSwarmItemsFromArgs(args: Record<string, unknown>): string[] { + const items = args['items']; + if (!Array.isArray(items)) return []; + return items.map(String); +} + +function agentSwarmResumeItemsFromArgs(args: Record<string, unknown>): string[] { + const resumeAgentIds = args['resume_agent_ids']; + if ( + typeof resumeAgentIds !== 'object' || + resumeAgentIds === null || + Array.isArray(resumeAgentIds) + ) { + return []; + } + return Object.keys(resumeAgentIds).map(() => RESUMED_ITEM_LABEL); +} + +export function agentSwarmPartialItemsCountFromArguments(argumentsText: string): number { + return agentSwarmPartialItemsFromArguments(argumentsText).length; +} + +function agentSwarmWorkItemsStartedFromArguments(argumentsText: string): boolean { + return /"items"\s*:/.test(argumentsText) || /"resume_agent_ids"\s*:/.test(argumentsText); +} + +export function agentSwarmPartialItemsFromArguments(argumentsText: string): string[] { + const match = /"items"\s*:\s*\[/.exec(argumentsText); + if (match === null) return []; + const items: string[] = []; + for (let i = match.index + match[0].length; i < argumentsText.length; i += 1) { + const ch = argumentsText[i]; + if (ch === ']') return items; + if (ch !== '"') continue; + + const parsed = parsePartialJsonString(argumentsText, i + 1); + items.push(parsed.value); + if (parsed.closed) { + i = parsed.nextIndex; + continue; + } + return items; + } + return items; +} + +function agentSwarmPartialResumeItemsFromArguments(argumentsText: string): string[] { + const match = /"resume_agent_ids"\s*:\s*\{/.exec(argumentsText); + if (match === null) return []; + return Array.from( + { length: countPartialJsonObjectEntries(argumentsText, match.index + match[0].length) }, + () => RESUMED_ITEM_LABEL, + ); +} + +export function agentSwarmDescriptionFromArgs(args: Record<string, unknown>): string { + const description = args['description']; + return typeof description === 'string' ? description : ''; +} + +function agentSwarmPromptTemplateFromArgs(args: Record<string, unknown>): string { + const promptTemplate = args['prompt_template']; + return typeof promptTemplate === 'string' ? promptTemplate : ''; +} + +function agentSwarmPartialPromptTemplateFromArguments(argumentsText: string): string { + const match = /"prompt_template"\s*:\s*"/.exec(argumentsText); + if (match === null) return ''; + return parsePartialJsonString(argumentsText, match.index + match[0].length).value; +} + +export function agentSwarmResultSummaryFromOutput(output: string): AgentSwarmResultSummary { + const statuses = parseAgentSwarmResultStatuses(output); + let completed = 0; + let failed = 0; + let aborted = 0; + for (const status of statuses) { + if (status.status === 'completed') completed += 1; + if (status.status === 'failed') failed += 1; + if (status.status === 'cancelled') aborted += 1; + } + return { + completed, + failed, + aborted, + parsed: statuses.length > 0, + }; +} + +function parseAgentSwarmResultStatuses(output: string): AgentSwarmResultStatus[] { + const xmlStatuses = parseAgentSwarmXmlResultStatuses(output); + if (xmlStatuses.length > 0) return xmlStatuses; + return parseAgentSwarmLegacyResultStatuses(output); +} + +function forEachSubagentTag<T>( + output: string, + callback: (attrs: string, body: string, index: number) => T | undefined, +): T[] { + const result: T[] = []; + const tagPattern = /<subagent\b([^>]*)>/g; + let match: RegExpExecArray | null; + let index = 0; + while ((match = tagPattern.exec(output)) !== null) { + const attrs = match[1] ?? ''; + const closeIndex = output.indexOf('</subagent>', tagPattern.lastIndex); + if (closeIndex < 0) break; + const body = output.slice(tagPattern.lastIndex, closeIndex); + index += 1; + const value = callback(attrs, body, index); + if (value !== undefined) result.push(value); + tagPattern.lastIndex = closeIndex + '</subagent>'.length; + } + return result; +} + +function parseAgentSwarmXmlResultStatuses(output: string): AgentSwarmResultStatus[] { + return forEachSubagentTag(output, (attrs, body, tagIndex) => { + const explicitIndex = Number(xmlAttribute(attrs, 'index')); + const index = + Number.isInteger(explicitIndex) && explicitIndex > 0 ? explicitIndex : tagIndex; + const outcome = xmlAttribute(attrs, 'outcome'); + if ( + outcome !== 'completed' && + outcome !== 'failed' && + outcome !== 'aborted' && + outcome !== 'cancelled' + ) { + return undefined; + } + return { + index, + status: outcome === 'aborted' || outcome === 'cancelled' ? 'cancelled' : outcome, + completedText: outcome === 'completed' ? body : undefined, + failureText: outcome === 'failed' ? body : undefined, + }; + }); +} + +function xmlAttribute(attrs: string, name: string): string | undefined { + const match = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs); + return match?.[1]; +} + +function forEachAgentBlock<T>( + output: string, + callback: (block: string, index: number) => T | undefined, +): T[] { + const result: T[] = []; + for (const block of output.split(/\n(?=\[agent \d+\]\n)/)) { + const indexMatch = /^\[agent (\d+)\]$/m.exec(block); + if (indexMatch === null) continue; + const value = callback(block, Number(indexMatch[1])); + if (value !== undefined) result.push(value); + } + return result; +} + +function parseAgentSwarmLegacyResultStatuses(output: string): AgentSwarmResultStatus[] { + return forEachAgentBlock(output, (block, index) => { + const statusMatch = /^status: (completed|failed|aborted|cancelled)$/m.exec(block); + if (statusMatch === null) return undefined; + const status = statusMatch[1] as 'completed' | 'failed' | 'aborted' | 'cancelled'; + return { + index, + status: status === 'aborted' || status === 'cancelled' ? 'cancelled' : status, + completedText: status === 'completed' ? parseAgentSwarmCompletedText(block) : undefined, + failureText: status === 'failed' ? parseAgentSwarmFailureText(block) : undefined, + }; + }); +} + +function parseAgentSwarmCompletedText(block: string): string | undefined { + const marker = '\n[summary]\n'; + const markerIndex = block.indexOf(marker); + if (markerIndex < 0) return undefined; + return normalizeFinalOutputText(block.slice(markerIndex + marker.length)); +} + +function parseAgentSwarmFailureText(block: string): string | undefined { + const match = /^subagent error:\s*([\s\S]*)$/m.exec(block); + if (match === null) return undefined; + return normalizeFailureText(match[1]); +} + +function textGridLayout( + columns: number, + rows: number, + cellWidth: number, + gapWidth: number, + idWidth: number, +): AgentSwarmGridLayout { + return { + renderText: true, + barCells: barCellsForTextCellWidth(cellWidth, idWidth), + columns, + rows, + cellWidth, + columnGap: gapWidth, + leftPadding: 0, + }; +} + +export function calculateAgentSwarmGridLayout( + input: AgentSwarmGridLayoutInput, +): AgentSwarmGridLayout { + const count = Math.max(0, Math.floor(input.count)); + const width = Math.max(0, Math.floor(input.width)); + const height = Math.max(0, Math.floor(input.height)); + const idWidth = agentSwarmGridIdWidth(count); + + if (count === 0) { + return { + renderText: true, + barCells: 1, + columns: 0, + rows: 0, + cellWidth: 0, + columnGap: 0, + leftPadding: 0, + }; + } + + const textGapWidth = visibleWidth(CELL_GAP); + const compactGapWidth = textGapWidth; + const textColumns = columnsForCellWidth(width, count, TEXT_CELL_PREFERRED_WIDTH, textGapWidth); + const textRows = rowsForColumns(count, textColumns); + const textCellWidth = gridCellWidth(width, textColumns, textGapWidth); + if (textRows <= height && textCellWidth >= minTextCellWidth(idWidth)) { + return textGridLayout(textColumns, textRows, textCellWidth, textGapWidth, idWidth); + } + const targetTextColumns = height <= 0 ? count : Math.min(count, Math.ceil(count / height)); + const targetTextCellWidth = gridCellWidth(width, targetTextColumns, textGapWidth); + const targetTextRows = rowsForColumns(count, targetTextColumns); + if (height > 0 && targetTextRows <= height && targetTextCellWidth >= minTextCellWidth(idWidth)) { + return textGridLayout(targetTextColumns, targetTextRows, targetTextCellWidth, textGapWidth, idWidth); + } + + const compactColumns = compactColumnsForLayout(width, count, height, idWidth, compactGapWidth); + const compactCellWidthBudget = gridCellWidth(width, compactColumns, compactGapWidth); + const compactBarCells = compactBarCellsForCellWidth(compactCellWidthBudget, idWidth); + const compactActualCellWidth = compactCellWidth(idWidth, compactBarCells); + return { + renderText: false, + barCells: compactBarCells, + columns: compactColumns, + rows: rowsForColumns(count, compactColumns), + cellWidth: compactActualCellWidth, + columnGap: compactGapWidth, + leftPadding: 0, + }; +} + +export function agentSwarmGridHeightForTerminalRows( + rows: number | undefined, + followingRows = 0, +): number | undefined { + if (rows === undefined || !Number.isFinite(rows)) return undefined; + const rowsAfterSwarm = Number.isFinite(followingRows) + ? Math.max(0, Math.floor(followingRows)) + : 0; + return Math.max(0, Math.floor(rows) - rowsAfterSwarm - AGENT_SWARM_NON_GRID_LINES); +} + +function agentSwarmGridIdWidth(count: number): number { + return Math.max(3, String(Math.max(1, count)).length); +} + +function columnsForCellWidth( + width: number, + count: number, + cellWidth: number, + gapWidth: number, +): number { + if (count <= 1) return count <= 0 ? 0 : 1; + const columns = Math.floor((width + gapWidth) / (Math.max(1, cellWidth) + gapWidth)); + return Math.max(1, Math.min(count, columns)); +} + +function rowsForColumns(count: number, columns: number): number { + if (count <= 0) return 0; + return Math.ceil(count / Math.max(1, columns)); +} + +function gridCellWidth(width: number, columns: number, gapWidth: number): number { + if (columns <= 0) return 0; + return Math.max( + 1, + Math.floor((width - gapWidth * Math.max(0, columns - 1)) / columns), + ); +} + +function minTextCellWidth(idWidth: number): number { + return idWidth + TEXT_BRAILLE_BAR_MIN_WIDTH + 4 + MIN_LABEL_WIDTH; +} + +function barCellsForTextCellWidth(cellWidth: number, idWidth: number): number { + const fixedWidth = idWidth + 1 + 2 + 1 + MIN_LABEL_WIDTH; + const availableForBar = cellWidth - fixedWidth; + return availableForBar >= TEXT_BRAILLE_BAR_MIN_WIDTH + ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) + : TEXT_BRAILLE_BAR_MIN_WIDTH; +} + +function compactColumnsForLayout( + width: number, + count: number, + height: number, + idWidth: number, + gapWidth: number, +): number { + const maxColumns = columnsForCellWidth(width, count, compactCellWidth(idWidth, 1), gapWidth); + if (height <= 0) return maxColumns; + const targetColumns = Math.min(count, Math.ceil(count / height)); + return Math.max(1, Math.min(targetColumns, maxColumns)); +} + +function compactBarCellsForCellWidth(cellWidth: number, idWidth: number): number { + return Math.max( + 1, + cellWidth - compactFixedWidth(idWidth) - COMPACT_TERMINAL_MARK_WIDTH, + ); +} + +function compactCellWidth(idWidth: number, barCells: number): number { + return compactFixedWidth(idWidth) + Math.max(1, barCells) + COMPACT_TERMINAL_MARK_WIDTH; +} + +function compactFixedWidth(idWidth: number): number { + return idWidth + 1 + 2; +} + +function summarizeSnapshots(snapshots: readonly AgentSwarmSnapshot[]): AgentSwarmSummary { + let completed = 0; + let failed = 0; + let cancelled = 0; + for (const snapshot of snapshots) { + if (snapshot.phase === 'completed') completed += 1; + if (snapshot.phase === 'failed') failed += 1; + if (snapshot.phase === 'cancelled') cancelled += 1; + } + return { + active: snapshots.length - completed - failed - cancelled, + completed, + failed, + cancelled, + }; +} + +function brailleBar( + ticks: number, + phase: AgentSwarmPhase, + width: number, + colors: ColorPalette, + phaseElapsedMs: number, + phaseColorOverride?: string, +): string { + const innerWidth = Math.max(1, width); + if (phase === 'pending') return ''; + if (phase === 'failed') return bracketBar(failedBrailleBar(ticks, innerWidth, phaseElapsedMs, colors), colors); + const displayTicks = phase === 'completed' ? completedDisplayTicks(ticks, innerWidth, phaseElapsedMs) : ticks; + if (phase === 'cancelled') { + const cancelledColor = phaseColorOverride ?? colors.warning; + return bracketBar( + accumulatedBrailleBar(displayTicks, innerWidth, cancelledColor, colors, () => cancelledColor), + colors, + ); + } + const colorMap: Record<Exclude<AgentSwarmPhase, 'pending' | 'failed' | 'cancelled'>, string> = { + queued: colors.textDim, + suspended: colors.textDim, + running: colors.success, + completed: colors.success, + }; + return bracketBar(accumulatedBrailleBar(displayTicks, innerWidth, colorMap[phase], colors), colors); +} + +function cancelledProgressColor( + member: AgentSwarmMember, + phase: AgentSwarmPhase, + colors: ColorPalette, +): string | undefined { + if (phase !== 'cancelled') return undefined; + return member.cancelledBarColor ?? colors.warning; +} + +function bracketBar(content: string, colors: ColorPalette): string { + const bracket = chalk.hex(colors.textMuted); + return bracket('[') + content + bracket(']'); +} + +function phaseColor(phase: AgentSwarmPhase, colors: ColorPalette): string { + const map: Record<AgentSwarmPhase, string> = { + pending: colors.textDim, + queued: colors.textDim, + suspended: colors.textDim, + running: colors.textDim, + completed: colors.success, + failed: colors.error, + cancelled: colors.warning, + }; + return map[phase]; +} + +interface StatusBarCount { + readonly phase: StatusBarPhase; + readonly count: number; +} + +function renderStatusPipBar( + members: readonly AgentSwarmMember[], + width: number, + colors: ColorPalette, +): string { + const safeWidth = Math.max(1, width); + const counts = statusBarCounts(members); + if (counts.length === 0) { + return chalk.hex(colors.textMuted)(STATUS_BAR_CHAR.repeat(safeWidth)); + } + + const segmentWidths = allocateSegmentWidths(counts.map((entry) => entry.count), safeWidth); + return counts.map((entry, index) => { + const segmentWidth = segmentWidths[index] ?? 0; + if (segmentWidth <= 0) return ''; + return chalk.hex(statusBarColor(entry.phase, colors))(STATUS_BAR_CHAR.repeat(segmentWidth)); + }).join(''); +} + +function renderStatusLabel(label: string, color: string): string { + return ` ${chalk.hex(color)(label)}`; +} + +function activityPrefixForTotalStatus(status: TotalStatus, colors: ColorPalette): string { + const marks: Record<TotalStatus, string> = { + completed: SUCCESS_MARK.trimEnd(), + failed: FAILURE_MARK.trimEnd(), + aborted: CANCELLED_MARK.trimEnd(), + working: '', + suspended: '', + }; + const mark = marks[status]; + return mark.length > 0 + ? ` ${chalk.hex(totalStatusColor(status, colors))(mark)}` + : ACTIVITY_SPINNER_PLACEHOLDER; +} + +function statusBarCounts(members: readonly AgentSwarmMember[]): StatusBarCount[] { + const counts = new Map<StatusBarPhase, number>(); + for (const member of members) { + const phase = statusBarPhase(member.phase); + counts.set(phase, (counts.get(phase) ?? 0) + 1); + } + return STATUS_BAR_ORDER.flatMap((phase) => { + const count = counts.get(phase) ?? 0; + return count > 0 ? [{ phase, count }] : []; + }); +} + +function statusBarPhase(phase: AgentSwarmPhase): StatusBarPhase { + const map: Record<AgentSwarmPhase, StatusBarPhase> = { + pending: 'queued', + queued: 'queued', + suspended: 'suspended', + running: 'working', + completed: 'completed', + failed: 'failed', + cancelled: 'cancelled', + }; + return map[phase]; +} + +function statusBarColor(phase: StatusBarPhase, colors: ColorPalette): string { + const map: Record<StatusBarPhase, string> = { + queued: colors.textMuted, + working: colors.primary, + suspended: colors.textMuted, + completed: colors.success, + failed: colors.error, + cancelled: colors.warning, + }; + return map[phase]; +} + +function totalStatus( + members: readonly AgentSwarmMember[], + force: { readonly failed: boolean; readonly aborted: boolean }, +): TotalStatus { + if (force.aborted) return 'aborted'; + const phases = new Set(members.map((m) => m.phase)); + const hasActive = phases.has('pending') || phases.has('queued') || phases.has('suspended') || phases.has('running'); + if (!hasActive && members.length > 0) { + if (phases.has('cancelled')) return 'aborted'; + if (phases.has('completed')) return 'completed'; + return 'failed'; + } + if (force.failed) return 'failed'; + if (phases.has('suspended') && !phases.has('running')) return 'suspended'; + return 'working'; +} + +function totalStatusLabel(status: TotalStatus): string { + const map: Record<TotalStatus, string> = { + working: WORKING_LABEL, + completed: COMPLETED_LABEL, + suspended: SUSPENDED_LABEL, + failed: FAILED_LABEL, + aborted: ABORTED_LABEL, + }; + return map[status]; +} + +function totalStatusColor(status: TotalStatus, colors: ColorPalette): string { + const map: Record<TotalStatus, string> = { + working: colors.success, + completed: colors.success, + suspended: colors.textDim, + failed: colors.error, + aborted: colors.warning, + }; + return map[status]; +} + +function totalStatusLabelColor( + status: TotalStatus, + members: readonly AgentSwarmMember[], + colors: ColorPalette, +): string { + if (status === 'working' && !members.some((member) => member.phase === 'completed')) { + return colors.primary; + } + return totalStatusColor(status, colors); +} + +function allocateSegmentWidths(counts: readonly number[], width: number): number[] { + const total = counts.reduce((sum, count) => sum + count, 0); + if (total <= 0 || width <= 0) return counts.map(() => 0); + + const exact = counts.map((count) => count * width / total); + const widths = exact.map(Math.floor); + let remaining = width - widths.reduce((sum, value) => sum + value, 0); + const order = exact + .map((value, index) => ({ index, fraction: value - Math.floor(value) })) + .toSorted((a, b) => b.fraction - a.fraction || a.index - b.index); + + for (const entry of order) { + if (remaining <= 0) break; + widths[entry.index] = (widths[entry.index] ?? 0) + 1; + remaining -= 1; + } + return widths; +} + +function renderCellLabel( + member: AgentSwarmMember, + snapshot: AgentSwarmSnapshot, + width: number, + colors: ColorPalette, +): string { + const latestLine = latestNonEmptyLine(snapshot.latestModelText); + if (snapshot.phase === 'running') { + return truncateWithColor(runningCellLabelText(member), width, colors.textDim); + } + if (snapshot.phase === 'failed' && member.failureText !== undefined) { + return truncateWithColor(`${FAILURE_MARK}${member.failureText}`, width, colors.error); + } + if (snapshot.phase === 'completed') { + return renderCompletedCellLabel(member.completedText ?? latestLine, width, colors); + } + if (snapshot.phase === 'cancelled') { + return renderCancelledCellLabel(member, width, colors); + } + return truncateWithColor(PHASE_LABELS[snapshot.phase], width, phaseColor(snapshot.phase, colors)); +} + +function runningCellLabelText(member: AgentSwarmMember): string { + const latestLine = latestNonEmptyLine(member.latestModelText); + const itemText = collapseWhitespace(member.itemText); + const text = latestLine.length > 0 ? latestLine : itemText; + return text.length > 0 ? text : PHASE_LABELS.running; +} + +function renderCancelledCellLabel( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const labelText = member.cancelledLabelText ?? ABORTED_LABEL; + const labelColor = member.cancelledLabelColor ?? colors.warning; + const markColor = member.cancelledMarkColor ?? colors.warning; + const labelStyle = chalk.hex(labelColor); + return truncateToWidth( + chalk.hex(markColor)(CANCELLED_MARK) + labelStyle(labelText), + width, + labelStyle('…'), + ); +} + +function renderCompletedCellLabel( + text: string, + width: number, + colors: ColorPalette, +): string { + const finalText = normalizeFinalOutputText(text); + const label = finalText === undefined ? SUCCESS_MARK.trimEnd() : `${SUCCESS_MARK}${finalText}`; + return truncateWithColor(label, width, colors.success); +} + +function compactTerminalMark( + member: AgentSwarmMember, + phase: AgentSwarmPhase, + colors: ColorPalette, +): string { + if (phase === 'completed') return chalk.hex(colors.success)(SUCCESS_MARK.trimEnd()); + if (phase === 'failed') return chalk.hex(colors.error)(FAILURE_MARK.trimEnd()); + if (phase === 'cancelled') { + return chalk.hex(member.cancelledMarkColor ?? colors.warning)(CANCELLED_MARK.trimEnd()); + } + return ''; +} + +function renderPendingCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const itemText = collapseWhitespace(member.itemText); + const label = itemText.length > 0 ? itemText : QUEUED_LABEL; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + truncateWithColor(label, labelWidth, colors.textDim); +} + +function renderQueuedCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + truncateWithColor(QUEUED_LABEL, labelWidth, colors.textDim); +} + +function renderCancelledUnstartedCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + renderCancelledCellLabel(member, labelWidth, colors); +} + +function truncateWithColor(text: string, width: number, color: string): string { + const colorize = chalk.hex(color); + return truncateToWidth(colorize(text), width, colorize('…')); +} + +function truncateStartToWidth(text: string, width: number): string { + if (visibleWidth(text) <= width) return text; + const ellipsis = '…'; + const ellipsisWidth = visibleWidth(ellipsis); + if (width <= ellipsisWidth) return truncateToWidth(ellipsis, width); + + const targetWidth = width - ellipsisWidth; + const segments = Array.from(text); + let tail = ''; + let tailWidth = 0; + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index] ?? ''; + const segmentWidth = visibleWidth(segment); + if (tailWidth + segmentWidth > targetWidth) break; + tail = segment + tail; + tailWidth += segmentWidth; + } + return ellipsis + tail; +} + +function collapseWhitespace(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +function normalizeFailureText(text: string | undefined): string | undefined { + if (text === undefined) return undefined; + const nestedFailureText = nestedAgentSwarmFailureText(text); + const normalized = stripAgentSwarmPrefix(collapseWhitespace(nestedFailureText ?? text)); + return normalized.length > 0 ? normalized : undefined; +} + +function nestedAgentSwarmFailureText(text: string): string | undefined { + const xmlFailureText = nestedAgentSwarmXmlFailureText(text); + if (xmlFailureText !== undefined) return nestedAgentSwarmFailureText(xmlFailureText) ?? xmlFailureText; + + if (!/^\s*agent_swarm:\s*failed\b/m.test(text)) return undefined; + const match = /^\s*subagent error:\s*([\s\S]*?)(?=\n\[agent \d+\]\n|$)/m.exec(text); + if (match === null) return undefined; + const failureText = match[1]; + if (failureText === undefined) return undefined; + return nestedAgentSwarmFailureText(failureText) ?? failureText; +} + +function nestedAgentSwarmXmlFailureText(text: string): string | undefined { + if (!/<agent_swarm_result\b/.test(text)) return undefined; + const failed = parseAgentSwarmXmlResultStatuses(text).find((entry) => { + return entry.status === 'failed' && entry.failureText !== undefined; + }); + return failed?.failureText; +} + +function stripAgentSwarmPrefix(text: string): string { + return text.replace(/^agent_swarm:\s*(?:failed|completed)?\s*/i, '').trim(); +} + +function normalizeFinalOutputText(text: string | undefined): string | undefined { + if (text === undefined) return undefined; + const normalized = collapseWhitespace(text); + return normalized.length > 0 ? normalized : undefined; +} + +function latestNonEmptyLine(text: string): string { + const lines = text.split(/\r?\n/); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = collapseWhitespace(lines[index] ?? ''); + if (line.length > 0) return line; + } + return ''; +} + +function countPartialJsonObjectEntries(text: string, startIndex: number): number { + let count = 0; + let expectKey = true; + for (let i = startIndex; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '}') return count; + if (ch === ',') { + expectKey = true; + continue; + } + if (ch !== '"') continue; + + const parsed = parsePartialJsonString(text, i + 1); + if (expectKey) { + if (parsed.closed || parsed.value.length > 0) count += 1; + expectKey = false; + } + if (!parsed.closed) return count; + i = parsed.nextIndex; + } + return count; +} + +function parsePartialJsonString( + text: string, + startIndex: number, +): { value: string; closed: boolean; nextIndex: number } { + let value = ''; + for (let i = startIndex; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '"') return { value, closed: true, nextIndex: i }; + if (ch !== '\\') { + value += ch; + continue; + } + + const escaped = text[i + 1]; + if (escaped === undefined) return { value, closed: false, nextIndex: i }; + switch (escaped) { + case 'n': value += '\n'; break; + case 't': value += '\t'; break; + case 'r': value += '\r'; break; + case 'b': value += '\b'; break; + case 'f': value += '\f'; break; + case '"': + case '\\': + case '/': + value += escaped; + break; + case 'u': { + const hex = text.slice(i + 2, i + 6); + if (hex.length < 4) return { value, closed: false, nextIndex: i }; + const code = Number.parseInt(hex, 16); + if (Number.isNaN(code)) return { value, closed: false, nextIndex: i }; + value += String.fromCodePoint(code); + i += 4; + break; + } + default: + value += escaped; + } + i += 1; + } + return { value, closed: false, nextIndex: text.length }; +} + +function padAnsi(text: string, width: number): string { + const truncated = truncateToWidth(text, width); + return truncated + ' '.repeat(Math.max(0, width - visibleWidth(truncated))); +} + +function completedDisplayTicks(ticks: number, width: number, phaseElapsedMs: number): number { + const fullBarTicks = width * BRAILLE_LEVELS.length; + if (ticks >= fullBarTicks) return fullBarTicks; + const fillProgress = Math.max(0, Math.min(1, phaseElapsedMs / COMPLETE_FILL_MS)); + return Math.min(fullBarTicks, Math.ceil(ticks + (fullBarTicks - ticks) * fillProgress)); +} + +function failedBrailleBar( + ticks: number, + width: number, + phaseElapsedMs: number, + colors: ColorPalette, +): string { + const redCellCount = Math.ceil( + completedDisplayTicks(ticks, width, phaseElapsedMs) / BRAILLE_LEVELS.length, + ); + const placeholderColor = darkenRedHexColor(colors.error); + return accumulatedBrailleBar( + ticks, + width, + colors.error, + colors, + (cellIndex) => cellIndex < redCellCount ? placeholderColor : colors.textDim, + ); +} + +function darkenRedHexColor(hex: string): string { + return darkenHexColor( + hex, + FAILED_PLACEHOLDER_RED_FACTOR, + FAILED_PLACEHOLDER_NON_RED_FACTOR, + FAILED_PLACEHOLDER_NON_RED_FACTOR, + ); +} + +function cancelledLabelColor(colors: ColorPalette): string { + return darkenHexColor(colors.warning, CANCELLED_LABEL_DARKEN_FACTOR); +} + +function darkenHexColor( + hex: string, + redFactor: number, + greenFactor = redFactor, + blueFactor = redFactor, +): string { + const match = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex); + if (match === null) return hex; + const darken = (channel: string, factor: number): string => + Math.max(0, Math.min(255, Math.round(Number.parseInt(channel, 16) * factor))) + .toString(16) + .padStart(2, '0'); + return `#${darken(match[1]!, redFactor)}${darken(match[2]!, greenFactor)}${darken( + match[3]!, + blueFactor, + )}`; +} + +function accumulatedBrailleBar( + ticks: number, + width: number, + filledColor: string, + colors: ColorPalette, + emptyColorForCell?: (cellIndex: number) => string, +): string { + const dotsPerCell = BRAILLE_LEVELS.length; + const cycleSize = width * dotsPerCell; + const safeTicks = Math.max(0, Math.ceil(ticks)); + const completedCycles = Math.floor(safeTicks / cycleSize); + const cycleTicks = safeTicks % cycleSize; + const activeCells = cycleTicks === 0 ? 0 : Math.ceil(cycleTicks / dotsPerCell); + const separatorIndex = completedCycles > 0 && activeCells > 0 && activeCells < width + ? activeCells + : -1; + + let out = ''; + let pending = ''; + let pendingColor: string | undefined; + const flush = (): void => { + if (pending.length === 0 || pendingColor === undefined) return; + out += chalk.hex(pendingColor)(pending); + pending = ''; + }; + const append = (char: string, color: string): void => { + if (pendingColor !== color) { + flush(); + pendingColor = color; + } + pending += char; + }; + + for (let i = 0; i < width; i += 1) { + if (i === separatorIndex) { + append(BRAILLE_RIGHT_COLUMN_FULL, filledColor); + continue; + } + + const cellStart = i * dotsPerCell; + const countThisCycle = Math.max(0, Math.min(dotsPerCell, cycleTicks - cellStart)); + const count = countThisCycle > 0 ? countThisCycle : completedCycles > 0 ? dotsPerCell : 0; + append( + count === 0 ? BRAILLE_EMPTY : BRAILLE_LEVELS[count - 1]!, + count === 0 ? emptyColorForCell?.(i) ?? colors.textDim : filledColor, + ); + } + flush(); + return out; +} diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 1be89b2ca..c1b39537d 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -5,59 +5,119 @@ * to align after the bullet. */ -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; -import { Container, Markdown, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +type AssistantMarkdownOptions = { + transient?: boolean; +}; export class AssistantMessageComponent implements Component { private contentContainer: Container; - private markdownTheme: MarkdownTheme; - private bulletColor: string; + private markdown: Markdown | undefined; + private markdownTransient = false; private lastText = ''; + private lastTransient = false; private showBullet: boolean; - constructor(markdownTheme: MarkdownTheme, colors: ColorPalette, showBullet: boolean = true) { - this.markdownTheme = markdownTheme; - this.bulletColor = colors.roleAssistant; + private renderCache: { width: number; lines: string[] } | undefined; + + constructor(showBullet: boolean = true) { this.showBullet = showBullet; this.contentContainer = new Container(); } - setShowBullet(show: boolean): void { - this.showBullet = show; + private markRenderDirty(): void { + this.renderCache = undefined; } - updateContent(text: string): void { - const displayText = text; - if (displayText === this.lastText) return; + setShowBullet(show: boolean): void { + if (this.showBullet === show) return; + this.showBullet = show; + this.markRenderDirty(); + } + + updateContent(text: string, opts?: AssistantMarkdownOptions): void { + const displayText = text.trim(); + const transient = opts?.transient === true; + + if (displayText === this.lastText && transient === this.lastTransient) return; + this.lastText = displayText; - this.contentContainer.clear(); - if (displayText.trim().length > 0) { - this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, this.markdownTheme)); + this.lastTransient = transient; + this.markRenderDirty(); + + if (displayText.length === 0) { + this.contentContainer.clear(); + this.markdown = undefined; + this.markdownTransient = false; + return; } + + if (this.markdown === undefined || this.markdownTransient !== transient) { + this.contentContainer.clear(); + this.markdown = new Markdown(displayText, 0, 0, createMarkdownTheme({ transient })); + this.markdownTransient = transient; + this.contentContainer.addChild(this.markdown); + return; + } + + this.markdown.setText(displayText); } invalidate(): void { - this.contentContainer.invalidate?.(); + // Markdown caches ANSI colour codes keyed on (text, width). When the + // theme changes the cached strings contain stale colours, so we rebuild + // the Markdown child with the new theme while preserving transient mode. + this.markRenderDirty(); + this.contentContainer.clear(); + this.markdown = undefined; + + if (this.lastText.trim().length > 0) { + this.markdown = new Markdown( + this.lastText.trim(), + 0, + 0, + createMarkdownTheme({ transient: this.lastTransient }), + ); + this.markdownTransient = this.lastTransient; + this.contentContainer.addChild(this.markdown); + } } render(width: number): string[] { if (this.lastText.trim().length === 0) return []; + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === safeWidth + ) { + return this.renderCache.lines; + } + const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT; - const contentWidth = Math.max(1, width - visibleWidth(prefix)); + const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix)); const contentLines = this.contentContainer.render(contentWidth); const lines: string[] = ['']; for (let i = 0; i < contentLines.length; i++) { const p = - i === 0 && this.showBullet ? chalk.hex(this.bulletColor)(STATUS_BULLET) : MESSAGE_INDENT; + i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; lines.push(p + contentLines[i]); } - return lines; + const rendered = lines.map((line) => truncateToWidth(line, safeWidth, '…')); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } diff --git a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts index c1086f805..9c1a3d815 100644 --- a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -1,42 +1,41 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { BackgroundAgentStatusData } from '#/tui/types'; export class BackgroundAgentStatusComponent implements Component { - constructor( - private readonly data: BackgroundAgentStatusData, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly data: BackgroundAgentStatusData) {} invalidate(): void {} render(width: number): string[] { - const tone = + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const tone: keyof ColorPalette = this.data.phase === 'started' - ? this.colors.primary + ? 'primary' : this.data.phase === 'completed' - ? this.colors.success - : this.colors.error; + ? 'success' + : 'error'; const bullet = - this.data.phase === 'failed' ? chalk.hex(tone)(FAILURE_MARK) : chalk.hex(tone)(STATUS_BULLET); + this.data.phase === 'failed' ? currentTheme.fg(tone, FAILURE_MARK) : currentTheme.fg(tone, STATUS_BULLET); const text = - chalk.hex(tone)(this.data.headline) + + currentTheme.fg(tone, this.data.headline) + (this.data.detail !== undefined && this.data.detail.length > 0 - ? chalk.hex(this.colors.textDim)(` (${this.data.detail})`) + ? currentTheme.fg('textDim', ` (${this.data.detail})`) : ''); const textComponent = new Text(text, 0, 0); - const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); + const contentWidth = Math.max(1, safeWidth - MESSAGE_INDENT.length); const contentLines = textComponent.render(contentWidth); return [ '', ...contentLines.map((line, index) => (index === 0 ? bullet : MESSAGE_INDENT) + line), - ]; + ].map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/kimi-code/src/tui/components/messages/cron-message.ts b/apps/kimi-code/src/tui/components/messages/cron-message.ts index 075ce3378..8cb81f5d3 100644 --- a/apps/kimi-code/src/tui/components/messages/cron-message.ts +++ b/apps/kimi-code/src/tui/components/messages/cron-message.ts @@ -1,54 +1,67 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Spacer, Text, visibleWidth } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { CronTranscriptData } from '#/tui/types'; export class CronMessageComponent implements Component { private readonly spacer = new Spacer(1); + private readonly data: CronTranscriptData; private readonly title: string; private readonly detail: string | undefined; - private readonly titleColor: string; private readonly promptText: Text; + private readonly prompt: string; constructor( prompt: string, data: CronTranscriptData, - private readonly colors: ColorPalette, ) { const missed = data.missedCount !== undefined; + this.data = data; this.title = missed ? 'Missed scheduled reminders' : 'Scheduled reminder fired'; this.detail = cronDetail(data); - this.titleColor = data.stale === true || missed ? colors.warning : colors.accent; - this.promptText = new Text(chalk.hex(colors.text)(prompt), 0, 0); + this.prompt = prompt; + this.promptText = new Text(currentTheme.fg('text', prompt), 0, 0); } invalidate(): void { + this.promptText.setText(currentTheme.fg('text', this.prompt)); this.promptText.invalidate(); } render(width: number): string[] { - const bullet = chalk.hex(this.titleColor).bold(STATUS_BULLET); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const missed = this.data.missedCount !== undefined; + const titleToken: keyof ColorPalette = this.data.stale === true || missed ? 'warning' : 'accent'; + const bullet = currentTheme.boldFg(titleToken, STATUS_BULLET); const bulletWidth = visibleWidth(bullet); - const contentWidth = Math.max(1, width - bulletWidth); + const contentWidth = Math.max(1, safeWidth - bulletWidth); + const continuationIndent = ' '.repeat(bulletWidth); const lines: string[] = []; - for (const line of this.spacer.render(width)) { + for (const line of this.spacer.render(safeWidth)) { lines.push(line); } - const title = chalk.hex(this.titleColor).bold(this.title); - lines.push(`${bullet}${title}`); + const titleLines = new Text(currentTheme.boldFg(titleToken, this.title), 0, 0).render(contentWidth); + for (let i = 0; i < titleLines.length; i += 1) { + lines.push(`${i === 0 ? bullet : continuationIndent}${titleLines[i]}`); + } if (this.detail !== undefined) { - lines.push(`${' '.repeat(bulletWidth)}${chalk.hex(this.colors.textDim)(this.detail)}`); + const detailLines = new Text(currentTheme.fg('textDim', this.detail), 0, 0).render(contentWidth); + for (const line of detailLines) { + lines.push(`${continuationIndent}${line}`); + } } const promptLines = this.promptText.render(contentWidth); for (const line of promptLines) { - lines.push(`${' '.repeat(bulletWidth)}${line}`); + lines.push(`${continuationIndent}${line}`); } return lines; diff --git a/apps/kimi-code/src/tui/components/messages/goal-format.ts b/apps/kimi-code/src/tui/components/messages/goal-format.ts new file mode 100644 index 000000000..2a5933425 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/goal-format.ts @@ -0,0 +1,13 @@ +export function formatGoalElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${String(totalSeconds)}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return `${String(minutes)}m ${seconds.toString().padStart(2, '0')}s`; + const hours = Math.floor(minutes / 60); + return `${String(hours)}h ${(minutes % 60).toString().padStart(2, '0')}m`; +} + +export function pluralizeGoalCount(n: number, singular: string, plural?: string): string { + return `${String(n)} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; +} diff --git a/apps/kimi-code/src/tui/components/messages/goal-markers.ts b/apps/kimi-code/src/tui/components/messages/goal-markers.ts new file mode 100644 index 000000000..f4482941f --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -0,0 +1,187 @@ +/** + * Low-profile transcript markers for the autonomous goal loop. + * + * Lifecycle changes (paused / resumed / cancelled) and `no_progress` verdicts + * render as a single dim line — `◦ Goal paused` — that expands (ctrl+o, shared + * with tool output) to show the reason when there is one. Terminal outcomes use + * the richer completion card (the `/goal` box), not this marker. + */ + +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; +import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; + +const HEAD_INDENT = ' '; +const DETAIL_INDENT = ' '; + +type GoalMarkerActor = 'user' | 'model' | 'runtime' | 'system'; + +interface GoalMarkerOptions { + readonly marker?: string; + readonly textToken?: ColorToken; + readonly expandable?: boolean; + readonly indent?: string; + readonly leadingBlank?: boolean; +} + +export class GoalMarkerComponent implements Component { + private expanded = false; + private readonly marker: string; + private readonly textToken: ColorToken; + private readonly expandable: boolean; + private readonly indent: string; + private readonly leadingBlank: boolean; + + constructor( + private readonly headline: string, + private readonly detail: string | undefined, + private readonly accentToken: ColorToken, + options: GoalMarkerOptions = {}, + ) { + this.marker = options.marker ?? '◦'; + this.textToken = options.textToken ?? 'textDim'; + this.expandable = options.expandable ?? true; + this.indent = options.indent ?? HEAD_INDENT; + this.leadingBlank = options.leadingBlank ?? false; + } + + invalidate(): void {} + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + } + + render(width: number): string[] { + const dot = currentTheme.fg(this.accentToken, this.marker); + const head = currentTheme.fg(this.textToken, this.headline); + const hasDetail = this.detail !== undefined && this.detail.length > 0; + if (!hasDetail) return this.clampToWidth([`${this.indent}${dot} ${head}`], width); + + if (!this.expandable) { + return this.clampToWidth([`${this.indent}${dot} ${head}`], width); + } + if (!this.expanded) { + return this.clampToWidth( + [`${this.indent}${dot} ${head} ${currentTheme.fg('textMuted', '(ctrl+o)')}`], + width, + ); + } + const out = [`${this.indent}${dot} ${head}`]; + const wrapWidth = Math.max(20, width - DETAIL_INDENT.length); + for (const line of wrap(this.detail!, wrapWidth)) { + out.push(DETAIL_INDENT + currentTheme.fg('textDim', line)); + } + return this.clampToWidth(out, width); + } + + private clampToWidth(lines: string[], width: number): string[] { + const withBlank = this.withLeadingBlank(lines); + if (width <= 0) return withBlank.map(() => ''); + return withBlank.map((line) => truncateToWidth(line, width)); + } + + private withLeadingBlank(lines: string[]): string[] { + return this.leadingBlank ? ['', ...lines] : lines; + } +} + +/** + * Builds a marker for a lifecycle change (paused / resumed / blocked), or `null` + * when the change should be silent (a `completion` change posts its own message, + * not a marker). `expanded` seeds the initial ctrl+o state. + */ +export function buildGoalMarker( + change: GoalChange, + expanded: boolean, + actor?: GoalMarkerActor, +): GoalMarkerComponent | null { + const spec = markerSpec(change, actor); + if (spec === null) return null; + const marker = new GoalMarkerComponent( + spec.headline, + spec.detail ?? change.reason, + spec.accentToken, + spec.options, + ); + marker.setExpanded(expanded); + return marker; +} + +function markerSpec( + change: GoalChange, + actor?: GoalMarkerActor, +): { + headline: string; + accentToken: ColorToken; + detail?: string | undefined; + options?: GoalMarkerOptions | undefined; +} | null { + if (change.kind === 'lifecycle') { + switch (change.status) { + case 'paused': + return prominentMarker(pausedHeadline(change.reason, actor), 'warning'); + case 'active': + return prominentMarker(resumedHeadline(actor), 'primary'); + case 'blocked': + // The system stopped pursuing the goal; resumable via `/goal resume`. + return { headline: 'Goal blocked', accentToken: 'warning' }; + default: + return null; + } + } + return null; // completion -> posts its own message, not a marker +} + +function prominentMarker(headline: string, accentToken: ColorToken) { + return { + headline, + accentToken, + detail: undefined, + options: { + marker: STATUS_BULLET.trimEnd(), + textToken: accentToken, + expandable: false, + indent: '', + leadingBlank: true, + }, + }; +} + +function pausedHeadline(reason: string | undefined, actor: GoalMarkerActor | undefined): string { + if (reason === 'Paused after interruption') return "Goal paused due to user's interruption"; + if (actor === 'user') return 'Goal paused by the user.'; + if (reason?.startsWith('Paused ') === true) return `Goal ${lowercaseFirst(reason)}`; + if (reason !== undefined && reason.length > 0) return `Goal paused: ${reason}`; + if (actor === 'model') return 'Goal paused by the agent.'; + return 'Goal paused'; +} + +function resumedHeadline(actor: GoalMarkerActor | undefined): string { + if (actor === 'user') return 'Goal resumed by the user.'; + if (actor === 'model') return 'Goal resumed by the agent.'; + return 'Goal resumed'; +} + +function lowercaseFirst(text: string): string { + return text.length === 0 ? text : `${text[0]!.toLowerCase()}${text.slice(1)}`; +} + +function wrap(text: string, width: number): string[] { + const words = text.replaceAll(/\s+/g, ' ').trim().split(' '); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (candidate.length > width && current.length > 0) { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current.length > 0) lines.push(current); + return lines.length > 0 ? lines : ['']; +} diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts new file mode 100644 index 000000000..d01f580fa --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -0,0 +1,222 @@ +/** + * Builds the line content for the `/goal` status box. The lines are rendered + * inside a {@link UsagePanelComponent} (the same bordered box as `/usage`), so + * this module only owns the goal-specific layout: + * + * ▌ <objective> (blockquote left-trail, wrapped) + * ▌ ✓ <completion criterion> + * + * Status complete — <reason> (terminal goals only) + * Running 4m 12s + * Turns 7 + * Tokens 128.4k + * Stop after 20 turns (7/20) (or a dim "no stop condition" note) + */ + +import { + Text, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type Component, +} from '@moonshot-ai/pi-tui'; +import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk'; + +import { MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; +import { formatTokenCount } from '#/utils/usage/usage-format'; +import { formatGoalElapsed } from './goal-format'; +import { UsagePanelComponent } from './usage-panel'; + +const WRAP_WIDTH = 72; +const MAX_OBJECTIVE_LINES = 6; +const MAX_CRITERION_LINES = 3; +const LABEL_WIDTH = 11; + +function renderLifecycleLine(label: string, width: number): string[] { + if (width <= 0) return ['']; + + const marker = currentTheme.boldFg('primary', STATUS_BULLET); + const text = new Text(currentTheme.boldFg('primary', label), 0, 0); + const contentWidth = Math.max(1, width - visibleWidth(STATUS_BULLET)); + return [ + '', + ...text + .render(contentWidth) + .map((line, index) => (index === 0 ? marker : MESSAGE_INDENT) + line.trimEnd()), + ]; +} + +/** + * The "Goal set" confirmation shown after `/goal <objective>`. The objective is + * rendered as the following user prompt, so this message only marks the state + * change in the transcript. + */ +export class GoalSetMessageComponent implements Component { + invalidate(): void {} + + render(width: number): string[] { + return renderLifecycleLine('Goal set', width); + } +} + +export class UpcomingGoalAddedMessageComponent implements Component { + invalidate(): void {} + + render(width: number): string[] { + return renderLifecycleLine( + 'Upcoming goal added. It will start after the current goal is complete.', + width, + ); + } +} + +export class GoalCompletionMessageComponent implements Component { + constructor(private readonly message: string) {} + + invalidate(): void {} + + render(width: number): string[] { + const [headline = '', ...details] = this.message.trim().split(/\r?\n/); + if (headline.length === 0) return []; + + const bullet = currentTheme.boldFg('success', STATUS_BULLET); + const bulletWidth = visibleWidth(STATUS_BULLET); + const contentWidth = Math.max(1, width - bulletWidth); + const lines: string[] = ['']; + + const headlineText = new Text(currentTheme.boldFg('success', headline), 0, 0); + const headlineLines = headlineText.render(contentWidth); + for (let i = 0; i < headlineLines.length; i += 1) { + lines.push((i === 0 ? bullet : MESSAGE_INDENT) + headlineLines[i]); + } + + const detailText = details.join('\n').trim(); + if (detailText.length > 0) { + const detailLines = new Text(currentTheme.fg('textDim', detailText), 0, 0).render( + contentWidth, + ); + for (const line of detailLines) { + lines.push(MESSAGE_INDENT + line); + } + } + + return lines; + } +} + +export class GoalStatusMessageComponent implements Component { + constructor(private readonly goal: GoalSnapshot) {} + + invalidate(): void {} + + render(width: number): string[] { + const panelContentWidth = Math.max(1, width - 6); + const panel = new UsagePanelComponent( + () => buildGoalReportLines(this.goal, panelContentWidth), + 'primary', + goalPanelTitle(this.goal), + ); + return ['', ...panel.render(width)]; + } +} + +/** Box title, e.g. ` Goal · active `. */ +export function goalPanelTitle(goal: GoalSnapshot): string { + return ` Goal · ${goal.status} `; +} + +export function buildGoalReportLines(goal: GoalSnapshot, wrapWidth: number = WRAP_WIDTH): string[] { + const statusColor = statusToken(goal.status); + const bar = (s: string) => currentTheme.fg(statusColor, s); + const value = (s: string) => currentTheme.fg('text', s); + const muted = (s: string) => currentTheme.fg('textDim', s); + // `complete` is the terminal outcome (the completion card); everything else + // (active / paused / blocked) is a persisted, resumable goal that still shows + // its stop condition. A reason is worth surfacing for stopped / complete states. + const isComplete = goal.status === 'complete'; + const reason = goal.terminalReason; + const showReason = + (goal.status === 'paused' && reason !== undefined) || goal.status === 'blocked' || isComplete; + const lines: string[] = []; + + // Condition as a blockquote left-trail. Reserve the visible "▌ " prefix before + // wrapping so the panel doesn't clip rows that exactly fit the panel interior. + const blockquoteWrapWidth = Math.max(1, wrapWidth - visibleWidth('▌ ')); + for (const line of wrap(goal.objective, blockquoteWrapWidth, MAX_OBJECTIVE_LINES)) { + lines.push(`${bar('▌')} ${value(line)}`); + } + if (goal.completionCriterion !== undefined) { + for (const line of wrap(`✓ ${goal.completionCriterion}`, blockquoteWrapWidth, MAX_CRITERION_LINES)) { + lines.push(`${bar('▌')} ${muted(line)}`); + } + } + lines.push(''); + + const row = (label: string, val: string): string => `${muted(label.padEnd(LABEL_WIDTH))}${val}`; + + if (showReason) { + lines.push( + row( + 'Status', + currentTheme.fg(statusColor, goal.status) + + (reason !== undefined ? muted(` — ${reason}`) : ''), + ), + ); + } + lines.push(row('Running', value(formatGoalElapsed(goal.wallClockMs)))); + lines.push(row('Turns', value(`${goal.turnsUsed}`))); + lines.push(row('Tokens', value(formatTokenCount(goal.tokensUsed)))); + if (!isComplete) { + const stop = formatStopRow(goal); + lines.push( + stop !== null + ? row('Stop', value(stop)) + : muted('No stop condition — runs until evaluated complete.'), + ); + } + return lines; +} + +/** The configured hard stop(s), or null when the goal is unbounded. */ +function formatStopRow(goal: GoalSnapshot): string | null { + const { budget } = goal; + const parts: string[] = []; + if (budget.turnBudget !== null) { + parts.push(`after ${budget.turnBudget} turns (${goal.turnsUsed}/${budget.turnBudget})`); + } + if (budget.tokenBudget !== null) { + parts.push(`at ${formatTokenCount(budget.tokenBudget)} tokens`); + } + if (budget.wallClockBudgetMs !== null) { + parts.push(`after ${formatGoalElapsed(budget.wallClockBudgetMs)}`); + } + return parts.length > 0 ? parts.join(', ') : null; +} + +function statusToken(status: GoalStatus): ColorToken { + switch (status) { + case 'active': + return 'primary'; + case 'complete': + return 'success'; + case 'blocked': + return 'warning'; + case 'paused': + return 'textDim'; + } +} + +/** Word-wrap to `width`, capped at `maxLines` (last line gets an ellipsis when clipped). */ +function wrap(text: string, width: number, maxLines: number): string[] { + const safeWidth = Math.max(1, width); + const lines = wrapTextWithAnsi(text.replaceAll(/\s+/g, ' ').trim(), safeWidth); + if (lines.length === 0) return ['']; + if (lines.length <= maxLines) return lines; + const clipped = lines.slice(0, maxLines); + const lastLine = clipped[maxLines - 1] ?? ''; + clipped[maxLines - 1] = truncateToWidth(`${lastLine}…`, safeWidth, '…'); + return clipped; +} diff --git a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts index 9b7905eb0..416cf6a33 100644 --- a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts @@ -1,10 +1,8 @@ import type { McpServerInfo } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface McpStatusReportOptions { - readonly colors: ColorPalette; readonly servers: readonly McpServerInfo[]; } @@ -34,18 +32,17 @@ const SUMMARY_ORDER: readonly McpServerInfo['status'][] = [ function statusPainter( status: McpServerInfo['status'], - colors: ColorPalette, ): (text: string) => string { switch (status) { case 'connected': - return chalk.hex(colors.success); + return (text) => currentTheme.fg('success', text); case 'failed': - return chalk.hex(colors.error); + return (text) => currentTheme.fg('error', text); case 'needs-auth': case 'pending': - return chalk.hex(colors.warning); + return (text) => currentTheme.fg('warning', text); case 'disabled': - return chalk.hex(colors.textDim); + return (text) => currentTheme.fg('textDim', text); } } @@ -58,6 +55,19 @@ function formatToolsAvailable(count: number): string { return `${count} tool${count === 1 ? '' : 's'} available`; } +/** + * Collapse a (possibly multi-line) MCP error into a single line. The status + * panel renders each returned string as exactly one boxed row (see + * `UsagePanelComponent.render`), so an embedded newline — e.g. the + * `\nstderr: ...` a failed stdio server appends — would drop the trailing + * text to column 0 and punch through the rounded border. Folding every run + * of whitespace to a single space keeps the error on one row, which the + * panel then truncates to the available width. + */ +function formatErrorLine(error: string): string { + return error.trim().replaceAll(/\s+/g, ' '); +} + function sortedServers(servers: readonly McpServerInfo[]): McpServerInfo[] { return servers.toSorted( (a, b) => @@ -84,11 +94,10 @@ function buildSummary(servers: readonly McpServerInfo[]): string { export function buildMcpStatusReportLines(options: McpStatusReportOptions): string[] { const servers = sortedServers(options.servers); - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const muted = chalk.hex(colors.textDim); - const value = chalk.hex(colors.text); - const error = chalk.hex(colors.error); + const accent = (text: string) => currentTheme.boldFg('primary', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const error = (text: string) => currentTheme.fg('error', text); const lines: string[] = [accent('Servers')]; @@ -116,7 +125,6 @@ export function buildMcpStatusReportLines(options: McpStatusReportOptions): stri for (const server of servers) { const status = statusPainter( server.status, - colors, )(STATUS_LABEL[server.status].padEnd(statusWidth)); lines.push( ` ${value(server.name.padEnd(nameWidth))} ${status} ${muted( @@ -129,7 +137,7 @@ export function buildMcpStatusReportLines(options: McpStatusReportOptions): stri server.error !== undefined && server.error.trim().length > 0 ) { - lines.push(` ${muted('error:')} ${error(server.error.trim())}`); + lines.push(` ${muted('error:')} ${error(formatErrorLine(server.error))}`); } if (server.status === 'needs-auth') { lines.push(` ${muted('action:')} ${value(`run /mcp-config login ${server.name}`)}`); @@ -138,6 +146,7 @@ export function buildMcpStatusReportLines(options: McpStatusReportOptions): stri lines.push(''); lines.push(` ${value(buildSummary(servers))}`); + lines.push(` ${muted('Configure with')} ${value('/mcp-config')}`); return lines; } diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index c1cafd48d..d1eeec03c 100644 --- a/apps/kimi-code/src/tui/components/messages/plan-box.ts +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -7,8 +7,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; -import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; +import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; @@ -19,8 +18,6 @@ const TITLE_PREFIX = ' plan: '; const TITLE_SUFFIX = ' '; export interface PlanBoxOptions { - maxContentLines?: number; - expanded?: boolean; status?: { readonly label: string; readonly colorHex: string; @@ -29,8 +26,6 @@ export interface PlanBoxOptions { export class PlanBoxComponent implements Component { private readonly markdown: Markdown; - private readonly maxContentLines: number | undefined; - private readonly expanded: boolean; private readonly status: PlanBoxOptions['status']; private cachedWidth: number | undefined; private cachedLines: string[] | undefined; @@ -47,8 +42,6 @@ export class PlanBoxComponent implements Component { // instance means repeated render() calls from the parent Container // hit the cache instead of re-parsing on every frame. this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme); - this.maxContentLines = opts?.maxContentLines; - this.expanded = opts?.expanded ?? false; this.status = opts?.status; } @@ -59,6 +52,12 @@ export class PlanBoxComponent implements Component { } render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + if (safeWidth < LEFT_MARGIN + 4) { + return this.markdown.render(Math.max(1, safeWidth)).map((line) => truncateToWidth(line, safeWidth, '…')); + } + if (this.cachedLines !== undefined && this.cachedWidth === width) { return this.cachedLines; } @@ -68,7 +67,7 @@ export class PlanBoxComponent implements Component { // " └──...──┘" // width = LEFT_MARGIN + 1 + horzLen + 1 ⇒ horzLen = width - 4 // content width = horzLen - 2 * SIDE_PADDING = width - 6 - const horzLen = Math.max(2, width - LEFT_MARGIN - 2); + const horzLen = Math.max(2, safeWidth - LEFT_MARGIN - 2); const contentWidth = Math.max(1, horzLen - 2 * SIDE_PADDING); const paint = (s: string): string => chalk.hex(this.borderHex)(s); @@ -81,42 +80,30 @@ export class PlanBoxComponent implements Component { const bottom = indent + paint('└' + '─'.repeat(horzLen) + '┘'); const rawLines = this.markdown.render(contentWidth); - const { shown, hiddenCount } = this.capContentLines(rawLines); const lines: string[] = [top]; - for (const raw of shown) { + for (const raw of rawLines) { const pad = Math.max(0, contentWidth - visibleWidth(raw)); lines.push(indent + paint('│') + ' ' + raw + ' '.repeat(pad) + ' ' + paint('│')); } - if (hiddenCount > 0) { - const footer = chalk.dim( - `... (${String(hiddenCount)} more line${hiddenCount === 1 ? '' : 's'}, ctrl+e to expand)`, - ); - const pad = Math.max(0, contentWidth - visibleWidth(footer)); - lines.push(indent + paint('│') + ' ' + footer + ' '.repeat(pad) + ' ' + paint('│')); - } lines.push(bottom); + const fitted = lines.map((line) => truncateToWidth(line, safeWidth, '…')); this.cachedWidth = width; - this.cachedLines = lines; - return lines; - } - - private capContentLines(rawLines: string[]): { shown: string[]; hiddenCount: number } { - const cap = this.maxContentLines; - if (this.expanded || cap === undefined || rawLines.length <= cap) { - return { shown: rawLines, hiddenCount: 0 }; - } - const shownCount = Math.max(0, cap - 1); - return { shown: rawLines.slice(0, shownCount), hiddenCount: rawLines.length - shownCount }; + this.cachedLines = fitted; + return fitted; } private buildTitle(horzLen: number): string { const fallback = ' plan '; const statusSuffix = this.buildStatusSuffix(); const fallbackWithStatus = ` plan${statusSuffix} `; - const budget = horzLen - 1; - const fallbackTitle = visibleWidth(fallbackWithStatus) <= budget ? fallbackWithStatus : fallback; + const budget = Math.max(0, horzLen - 1); + const fallbackTitle = truncateToWidth( + visibleWidth(fallbackWithStatus) <= budget ? fallbackWithStatus : fallback, + budget, + '…', + ); const planPath = this.planPath; if (planPath === undefined || planPath.length === 0) return fallbackTitle; const basename = path.basename(planPath); diff --git a/apps/kimi-code/src/tui/components/messages/plugin-command.ts b/apps/kimi-code/src/tui/components/messages/plugin-command.ts new file mode 100644 index 000000000..afbc2b444 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/plugin-command.ts @@ -0,0 +1,58 @@ +/** + * Plugin command invocation card. + * + * When the user runs `/plugin:command args`, the TUI renders a compact card + * instead of expanding the command body into the user bubble: + * + * ▶ /plugin:command + * args + * + * The args line is optional. Core expands the command body into the LLM + * context; the TUI only consumes the `plugin_command.activated` event. + */ + +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +const ARGS_PREVIEW_MAX = 200; + +export class PluginCommandComponent extends Container { + private headText: Text; + private previewText?: Text; + private readonly label: string; + private readonly args?: string; + + constructor(pluginId: string, commandName: string, args?: string) { + super(); + this.label = `/${pluginId}:${commandName}`; + this.args = args; + this.addChild(new Spacer(1)); + const head = + currentTheme.boldFg('primary', '▶ Invoked command: ') + + currentTheme.boldFg('roleUser', this.label); + this.headText = new Text(head, 0, 0); + this.addChild(this.headText); + const trimmed = args?.trim() ?? ''; + if (trimmed.length > 0) { + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText = new Text(' ' + currentTheme.fg('textDim', preview), 0, 0); + this.addChild(this.previewText); + } + } + + override invalidate(): void { + const head = + currentTheme.boldFg('primary', '▶ Invoked command: ') + + currentTheme.boldFg('roleUser', this.label); + this.headText.setText(head); + if (this.previewText !== undefined && this.args !== undefined) { + const trimmed = this.args.trim(); + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText.setText(' ' + currentTheme.fg('textDim', preview)); + } + super.invalidate(); + } +} diff --git a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts index 2158aecd0..4654ee82d 100644 --- a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts @@ -1,7 +1,6 @@ import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; import { CURATED_BADGE, OFFICIAL_BADGE, @@ -12,16 +11,15 @@ import { } from '../../utils/plugin-source-label'; export interface PluginsListPanelInput { - readonly colors: ColorPalette; readonly plugins: readonly PluginSummary[]; } export function buildPluginsListLines(input: PluginsListPanelInput): readonly string[] { - const muted = chalk.hex(input.colors.textDim); - const value = chalk.hex(input.colors.text); - const success = chalk.hex(input.colors.success); - const primary = chalk.hex(input.colors.primary); - const warning = chalk.hex(input.colors.warning); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const success = (text: string) => currentTheme.fg('success', text); + const primary = (text: string) => currentTheme.fg('primary', text); + const warning = (text: string) => currentTheme.fg('warning', text); if (input.plugins.length === 0) { return [ muted('No plugins installed.'), @@ -56,18 +54,17 @@ export function buildPluginsListLines(input: PluginsListPanelInput): readonly st export interface PluginsInfoPanelInput { - readonly colors: ColorPalette; readonly info: PluginInfo; } export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly string[] { const { info } = input; - const muted = chalk.hex(input.colors.textDim); - const value = chalk.hex(input.colors.text); - const success = chalk.hex(input.colors.success); - const warning = chalk.hex(input.colors.warning); - const error = chalk.hex(input.colors.error); - const primary = chalk.hex(input.colors.primary); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const success = (text: string) => currentTheme.fg('success', text); + const warning = (text: string) => currentTheme.fg('warning', text); + const error = (text: string) => currentTheme.fg('error', text); + const primary = (text: string) => currentTheme.fg('primary', text); const status = info.enabled ? success('enabled') : muted('disabled'); const trustLine = (() => { const label = pluginTrustLabel(info); @@ -81,7 +78,7 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st })(); const lines: string[] = [ `${value(info.displayName)} (${muted(info.id)}) ${muted(info.version ?? '')}`.trim(), - `${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state, input.colors)}`, + `${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state)}`, trustLine, `${muted('Source:')} ${value(info.source)}`, `${muted('Root:')} ${value(info.root)}`, @@ -164,7 +161,7 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st return lines; } -function stateText(state: PluginInfo['state'], colors: ColorPalette): string { - if (state === 'ok') return chalk.hex(colors.success)(state); - return chalk.hex(colors.error)(state); +function stateText(state: PluginInfo['state']): string { + if (state === 'ok') return currentTheme.fg('success', state); + return currentTheme.fg('error', state); } diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts index 1d48f3c37..141562e4c 100644 --- a/apps/kimi-code/src/tui/components/messages/read-group.ts +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -20,12 +20,11 @@ * src/missing.ts · failed */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call'; @@ -42,11 +41,9 @@ export class ReadGroupComponent extends Container { private readonly bodyContainer: Container; private throttleTimer: ReturnType<typeof setTimeout> | null = null; private lastFlushPhases = new Map<string, ToolCallReadSnapshot['phase']>(); + private _invalidating = false; - constructor( - private readonly colors: ColorPalette, - private readonly ui: TUI | undefined, - ) { + constructor(private readonly ui: TUI | undefined) { super(); this.addChild(new Spacer(1)); this.headerText = new Text('', 0, 0); @@ -134,47 +131,55 @@ export class ReadGroupComponent extends Container { } private buildHeader(total: number, pending: number, failed: number, totalLines: number): string { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string): string => currentTheme.dim(text); if (pending > 0) { - const bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); - const label = chalk.hex(colors.primary).bold(`Reading ${String(total)} files…`); + const bullet = currentTheme.fg('text', STATUS_BULLET); + const label = currentTheme.boldFg('primary', `Reading ${String(total)} files…`); return `${bullet}${label}`; } // All reads have finished, either successfully or with failures. if (failed === total) { - const bullet = chalk.hex(colors.error)('✗ '); - const label = chalk.hex(colors.error).bold(`Read ${String(total)} files`); - return `${bullet}${label}${chalk.hex(colors.error)(' · failed')}`; + const bullet = currentTheme.fg('error', '✗ '); + const label = currentTheme.boldFg('error', `Read ${String(total)} files`); + return `${bullet}${label}${currentTheme.fg('error', ' · failed')}`; } - const bullet = chalk.hex(colors.success)(STATUS_BULLET); - const label = chalk.hex(colors.primary).bold(`Read ${String(total)} files`); + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('primary', `Read ${String(total)} files`); const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`); - const failPart = failed > 0 ? chalk.hex(colors.error)(` · ${String(failed)} failed`) : ''; + const failPart = failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : ''; return `${bullet}${label}${linesPart}${failPart}`; } private buildBodyLine(snap: ToolCallReadSnapshot, isLast: boolean): string { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string): string => currentTheme.dim(text); const branch = isLast ? '└─' : '├─'; const path = snap.filePath ?? ''; - const pathPart = chalk.hex(colors.text)(path); + const pathPart = currentTheme.fg('text', path); let tail: string; if (snap.phase === 'pending') { tail = dim(' · reading…'); } else if (snap.phase === 'failed') { - tail = chalk.hex(colors.error)(' · failed'); + tail = currentTheme.fg('error', ' · failed'); } else { tail = dim(` · ${String(snap.lines)} ${snap.lines === 1 ? 'line' : 'lines'}`); } return ` ${branch} ${pathPart}${tail}`; } + override invalidate(): void { + if (this._invalidating) { + super.invalidate(); + return; + } + this._invalidating = true; + this.flushRender(); + this._invalidating = false; + } + /** Releases throttle timers so destroyed components cannot refresh later. */ dispose(): void { if (this.throttleTimer !== null) { diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index 3271353e2..cb6f95dcd 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -1,17 +1,16 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Container, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Container, Text } from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { ResultRenderer } from './tool-renderers/types'; import { PREVIEW_LINES } from './tool-renderers/types'; +import { TruncatedOutputComponent } from './tool-renderers/truncated'; export interface ShellExecutionOptions { readonly command?: string; readonly result?: ToolResultBlockData; - readonly colors: ColorPalette; readonly expanded?: boolean; readonly showCommand?: boolean; /** @@ -21,6 +20,8 @@ export interface ShellExecutionOptions { */ readonly commandPreviewLines?: number; readonly resultPreviewLines?: number; + readonly tailOutput?: boolean; + readonly expandHint?: boolean; } export class ShellExecutionComponent extends Container { @@ -34,9 +35,10 @@ export class ShellExecutionComponent extends Container { if (options.result !== undefined) { this.addResultPreview( options.result, - options.colors, options.expanded ?? false, options.resultPreviewLines ?? PREVIEW_LINES, + options.tailOutput ?? false, + options.expandHint ?? true, ); } } @@ -46,50 +48,50 @@ export class ShellExecutionComponent extends Container { const allLines = command.split('\n'); const lines = previewLines === undefined ? allLines : allLines.slice(0, previewLines); for (const [i, line] of lines.entries()) { - const prefix = i === 0 ? '$ ' : ' '; - this.addChild(new Text(chalk.dim(prefix + line), 2, 0)); + // Distinguish the command (input) from the result (output): the `$` + // prompt uses the dedicated shell-mode hue, the command body uses + // `textDim`, and the result below is rendered one step dimmer in + // `textMuted` so the two stay separable without a connecting glyph. + const text = + i === 0 + ? currentTheme.fg('shellMode', '$ ') + currentTheme.dim(line) + : ` ${currentTheme.dim(line)}`; + this.addChild(new Text(text, 2, 0)); } } private addResultPreview( result: ToolResultBlockData, - colors: ColorPalette, expanded: boolean, previewLines: number, + tailOutput: boolean, + expandHint: boolean, ): void { if (!result.output) return; - const tint = result.is_error ? chalk.hex(colors.error) : chalk.dim; - if (expanded) { - this.addChild(new Text(tint(result.output), 2, 0)); - return; - } - - const lines = result.output.split('\n'); - const shown = lines.slice(0, previewLines); - const remaining = lines.length - shown.length; - this.addChild(new Text(tint(shown.join('\n')), 2, 0)); - if (remaining > 0) { - this.addChild( - new Text(chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), 2, 0), - ); - } + this.addChild( + new TruncatedOutputComponent(result.output, { + expanded, + isError: result.is_error ?? false, + maxLines: previewLines, + tail: tailOutput, + expandHint, + color: 'textMuted', + }), + ); } } export const shellExecutionResultRenderer: ResultRenderer = ( - toolCall: ToolCallBlockData, + _toolCall: ToolCallBlockData, result: ToolResultBlockData, ctx, ): Component[] => [ + // Result only. The command preview is owned by ToolCallComponent's + // buildCallPreview across the whole lifecycle (streaming, running, and + // done); rendering it here too would duplicate the command once the result + // lands. new ShellExecutionComponent({ - command: typeof toolCall.args['command'] === 'string' ? toolCall.args['command'] : '', result, - colors: ctx.colors, expanded: ctx.expanded, - // Header truncates long bash commands to 60 chars. When the user expands - // the card with ctrl+o, reveal the full command (no line cap) so they - // can read what actually ran. - showCommand: ctx.expanded, - commandPreviewLines: undefined, }), ]; diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts new file mode 100644 index 000000000..ca99f2e76 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -0,0 +1,134 @@ +import { Container, Text } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; + +const RUNNING_TAIL_LINES = 5; +const TIMER_INTERVAL_MS = 1000; +// Cap the live running buffer so a command that spews output for minutes can't +// grow memory without bound or make every render re-strip a multi-MB string. +// Only affects the transient running tail; the final view uses the full +// captured stdout/stderr passed to finish(). +const MAX_COMBINED_CHARS = 256 * 1024; +const KEEP_COMBINED_CHARS = 64 * 1024; + +/** + * Live view for a user-initiated `!` shell command. Two phases: + * + * - running: dim, ANSI-stripped tail of the combined output, a `+N lines` + * overflow marker, an elapsed `(Xs)` timer that ticks every second, and a + * `(ctrl+b to run in background)` hint — matching claude-code's running card + * so warnings are grey rather than red while the command works. + * - finished: the standard `formatBashOutputForDisplay` view (stderr red only + * on failure), the timer stopped and the running chrome removed. + * + * Hardened so a misbehaving command can never crash the TUI: the running + * buffer is capped, and every render/render-request path swallows errors. + */ +export class ShellRunComponent extends Container { + private readonly textComponent: Text; + private combined = ''; + private running = true; + private backgrounded = false; + private disposed = false; + private finalStdout = ''; + private finalStderr = ''; + private finalIsError?: boolean; + private readonly startedAt = Date.now(); + private timer: ReturnType<typeof setInterval> | undefined; + + constructor(private readonly requestRender: () => void) { + super(); + this.textComponent = new Text(this.renderText(), 0, 0); + this.addChild(this.textComponent); + this.timer = setInterval(() => this.tick(), TIMER_INTERVAL_MS); + } + + append(text: string): void { + if (this.disposed || !this.running || text.length === 0) return; + this.combined += text; + if (this.combined.length > MAX_COMBINED_CHARS) { + this.combined = this.combined.slice(-KEEP_COMBINED_CHARS); + } + this.flush(); + } + + finish(stdout: string, stderr: string, isError?: boolean): void { + if (this.disposed || !this.running) return; + this.running = false; + this.finalStdout = stdout; + this.finalStderr = stderr; + this.finalIsError = isError; + this.clearTimer(); + this.flush(); + } + + finishBackgrounded(): void { + if (this.disposed || !this.running) return; + this.running = false; + this.backgrounded = true; + this.clearTimer(); + this.flush(); + } + + dispose(): void { + this.disposed = true; + this.clearTimer(); + } + + private tick(): void { + if (!this.running) return; + this.flush(); + } + + private flush(): void { + if (this.disposed) return; + try { + this.textComponent.setText(this.renderText()); + this.requestRender(); + } catch { + // Never let a render/render-request error escape into a timer or event + // handler — an uncaught exception there can take down the whole TUI. + } + } + + private clearTimer(): void { + if (this.timer !== undefined) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private renderText(): string { + try { + if (this.backgrounded) { + return ` ${currentTheme.fg('textDim', 'Moved to background.')}`; + } + if (!this.running) { + return formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError) + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + } + const elapsed = Math.floor((Date.now() - this.startedAt) / 1000); + const dim = (s: string): string => currentTheme.fg('textDim', s); + const trimmed = sanitizeShellOutput(this.combined).trimEnd(); + let body: string; + let extra = 0; + if (trimmed.length === 0) { + body = ` ${dim('Running…')}`; + } else { + const lines = trimmed.split('\n'); + const tail = lines.slice(-RUNNING_TAIL_LINES); + extra = Math.max(0, lines.length - RUNNING_TAIL_LINES); + body = tail.map((line) => ` ${dim(line)}`).join('\n'); + } + const timing = ` ${dim(`${extra > 0 ? `+${extra} lines ` : ''}(${elapsed}s)`)}`; + const hint = ` ${dim('(ctrl+b to run in background)')}`; + return `${body}\n${timing}\n${hint}`; + } catch { + return ' (output unavailable)'; + } + } +} diff --git a/apps/kimi-code/src/tui/components/messages/skill-activation.ts b/apps/kimi-code/src/tui/components/messages/skill-activation.ts index 7f9e05c9f..f977d21d9 100644 --- a/apps/kimi-code/src/tui/components/messages/skill-activation.ts +++ b/apps/kimi-code/src/tui/components/messages/skill-activation.ts @@ -12,25 +12,53 @@ * metadata. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { SkillActivationTrigger } from '#/tui/types'; const ARGS_PREVIEW_MAX = 200; export class SkillActivationComponent extends Container { - constructor(name: string, args: string | undefined, colors: ColorPalette) { + private headText: Text; + private previewText?: Text; + private name: string; + private args?: string; + + constructor( + name: string, + args: string | undefined, + readonly trigger?: SkillActivationTrigger, + ) { super(); + this.name = name; + this.args = args; this.addChild(new Spacer(1)); const head = - chalk.hex(colors.primary).bold('▶ Activated skill: ') + chalk.hex(colors.roleUser).bold(name); - this.addChild(new Text(head, 0, 0)); + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', name); + this.headText = new Text(head, 0, 0); + this.addChild(this.headText); const trimmed = args?.trim() ?? ''; if (trimmed.length > 0) { const preview = trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; - this.addChild(new Text(' ' + chalk.hex(colors.textDim)(preview), 0, 0)); + this.previewText = new Text(' ' + currentTheme.fg('textDim', preview), 0, 0); + this.addChild(this.previewText); } } + + override invalidate(): void { + const head = + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', this.name); + this.headText.setText(head); + if (this.previewText !== undefined && this.args !== undefined) { + const trimmed = this.args.trim(); + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText.setText(' ' + currentTheme.fg('textDim', preview)); + } + super.invalidate(); + } } diff --git a/apps/kimi-code/src/tui/components/messages/status-message.ts b/apps/kimi-code/src/tui/components/messages/status-message.ts index be2292358..f88c1861b 100644 --- a/apps/kimi-code/src/tui/components/messages/status-message.ts +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -1,23 +1,72 @@ -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; export class StatusMessageComponent extends Container { - constructor(content: string, colors: ColorPalette, color?: string) { + private textComponent: Text; + private content: string; + private color?: ColorToken; + + constructor(content: string, color?: ColorToken) { super(); - const text = color === undefined ? chalk.hex(colors.textDim)(content) : chalk.hex(color)(content); - this.addChild(new Text(` ${text}`, 0, 0)); + this.content = content; + this.color = color; + this.textComponent = new Text(this.renderText(), 0, 0); + this.addChild(this.textComponent); + } + + // Update the body in place (used for live-streamed `!` shell output) without + // remounting the component. + updateContent(content: string): void { + this.content = content; + this.textComponent.setText(this.renderText()); + } + + override invalidate(): void { + this.textComponent.setText(this.renderText()); + super.invalidate(); + } + + // Indent every line, not just the first. The `content` may be multi-line + // (e.g. `!` shell output); prefixing the whole string once would only indent + // the first line and leave the rest at column 0. Strip carriage returns + // first: a trailing `\r` (e.g. from CRLF server error pages) is zero-width + // for the line wrapper, so the padding spaces appended after it overwrite + // the visible content and the line renders blank. + private renderText(): string { + const colored = + this.color === undefined + ? currentTheme.fg('textDim', this.content) + : currentTheme.fg(this.color, this.content); + return colored.replaceAll('\r', '').split('\n').map((line) => ` ${line}`).join('\n'); } } export class NoticeMessageComponent extends Container { - constructor(title: string, detail: string | undefined, colors: ColorPalette) { + private titleText: Text; + private detailText?: Text; + private title: string; + private detail?: string; + + constructor(title: string, detail: string | undefined) { super(); + this.title = title; + this.detail = detail; this.addChild(new Spacer(1)); - this.addChild(new Text(` ${chalk.hex(colors.textStrong)(title)}`, 0, 0)); + this.titleText = new Text(` ${currentTheme.fg('textStrong', title)}`, 0, 0); + this.addChild(this.titleText); if (detail !== undefined && detail.length > 0) { - this.addChild(new Text(` ${chalk.hex(colors.textDim)(detail)}`, 0, 0)); + this.detailText = new Text(` ${currentTheme.fg('textDim', detail)}`, 0, 0); + this.addChild(this.detailText); } } + + override invalidate(): void { + this.titleText.setText(` ${currentTheme.fg('textStrong', this.title)}`); + if (this.detailText !== undefined && this.detail !== undefined) { + this.detailText.setText(` ${currentTheme.fg('textDim', this.detail)}`); + } + super.invalidate(); + } } diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts index c60e2f546..1d788d53b 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -5,11 +5,16 @@ * separate from the TUI orchestration layer. */ -import type { ModelAlias, PermissionMode, SessionStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; +import { + effectiveModelAlias, + type ModelAlias, + type PermissionMode, + type SessionStatus, + type ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; import { PRODUCT_NAME } from '#/constant/app'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { formatTokenCount, ratioSeverity, @@ -17,7 +22,11 @@ import { safeUsageRatio, } from '#/utils/usage/usage-format'; -import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel'; +import { + buildExtraUsageSection, + buildManagedUsageReportLines, + type ManagedUsageReport, +} from './usage-panel'; interface FieldRow { readonly label: string; @@ -26,13 +35,12 @@ interface FieldRow { } export interface StatusReportOptions { - readonly colors: ColorPalette; readonly version: string; readonly model: string; readonly workDir: string; readonly sessionId: string; readonly sessionTitle: string | null; - readonly thinking: boolean; + readonly thinkingEffort: ThinkingEffort; readonly permissionMode: PermissionMode; readonly planMode: boolean; readonly contextUsage: number; @@ -49,17 +57,16 @@ type Colorize = (text: string) => string; function displayModelName(alias: string, models: Record<string, ModelAlias>): string { const model = models[alias]; - return model?.displayName ?? model?.model ?? alias; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? alias; } function formatModelStatus(options: StatusReportOptions): string { const model = options.status?.model ?? options.model; if (model.trim().length === 0) return 'not set'; - const thinking = (options.status?.thinkingLevel ?? (options.thinking ? 'on' : 'off')) === 'off' - ? 'off' - : 'on'; - return `${displayModelName(model, options.availableModels)} (thinking ${thinking})`; + const effort = options.status?.thinkingEffort ?? options.thinkingEffort; + return `${displayModelName(model, options.availableModels)} (thinking ${effort})`; } function addFieldRows( @@ -89,13 +96,12 @@ function contextValues(options: StatusReportOptions): { } export function buildStatusReportLines(options: StatusReportOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); + const severityToken = (sev: 'ok' | 'warn' | 'danger'): 'error' | 'warning' | 'success' => + sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const permission = options.status?.permission ?? options.permissionMode; const planMode = options.status?.planMode ?? options.planMode; @@ -125,7 +131,7 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { if (maxTokens > 0) { const safeRatio = safeUsageRatio(ratio); const bar = renderProgressBar(safeRatio, 20); - const barColoured = chalk.hex(severityHex(ratioSeverity(safeRatio)))(bar); + const barColoured = currentTheme.fg(severityToken(ratioSeverity(safeRatio)), bar); lines.push( ` ${barColoured} ${value(`${(safeRatio * 100).toFixed(1)}%`.padStart(6, ' '))} ` + muted(`(${formatTokenCount(tokens)} / ${formatTokenCount(maxTokens)})`), @@ -135,7 +141,6 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { } const managedSection = buildManagedUsageReportLines({ - colors, managedUsage: options.managedUsage, managedUsageError: options.managedUsageError, }); @@ -144,5 +149,16 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { lines.push(...managedSection); } + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } diff --git a/apps/kimi-code/src/tui/components/messages/step-summary.ts b/apps/kimi-code/src/tui/components/messages/step-summary.ts new file mode 100644 index 000000000..325ae6eb2 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/step-summary.ts @@ -0,0 +1,32 @@ +import type { Component } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +/** + * A collapsed summary of older steps within a turn. Accumulates counts of + * merged steps (thinking blocks and tool calls) and renders them as a single + * muted line, e.g. `… thinking 5 times, call 50 tools`. + */ +export class StepSummaryComponent implements Component { + private thinking = 0; + private tool = 0; + + get isEmpty(): boolean { + return this.thinking === 0 && this.tool === 0; + } + + addCounts(thinking: number, tool: number): void { + this.thinking += thinking; + this.tool += tool; + } + + invalidate(): void {} + + render(_width: number): string[] { + const parts: string[] = []; + if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`); + if (this.tool > 0) parts.push(`call ${this.tool} tools`); + if (parts.length === 0) return []; + return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)]; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts new file mode 100644 index 000000000..4fee9629f --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -0,0 +1,33 @@ +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +export type SwarmModeMarkerState = 'active' | 'inactive' | 'ended'; + +export class SwarmModeMarkerComponent implements Component { + constructor(private readonly state: SwarmModeMarkerState) {} + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const token = this.state === 'inactive' ? 'textDim' : 'success'; + const marker = currentTheme.boldFg(token, STATUS_BULLET); + const label = currentTheme.boldFg(token, swarmMarkerLabel(this.state)); + return ['', truncateToWidth(marker + label, safeWidth, '…')]; + } +} + +function swarmMarkerLabel(state: SwarmModeMarkerState): string { + switch (state) { + case 'active': + return 'Swarm activated'; + case 'inactive': + return 'Swarm deactivated'; + case 'ended': + return 'Swarm ended'; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index 0fee3669a..23a038c70 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -5,9 +5,7 @@ * Supports expand/collapse via Ctrl+O (shared with tool output). */ -import type { Component, TUI } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Text, truncateToWidth, type Component, type TUI } from '@moonshot-ai/pi-tui'; import { BRAILLE_SPINNER_FRAMES, @@ -16,13 +14,13 @@ import { THINKING_PREVIEW_LINES, } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export type ThinkingRenderMode = 'live' | 'finalized'; export class ThinkingComponent implements Component { private text: string; - private color: string; private showMarker: boolean; private mode: ThinkingRenderMode; private expanded = false; @@ -35,15 +33,15 @@ export class ThinkingComponent implements Component { // once the transcript accumulates many finalized thinking blocks. private readonly textComponent: Text; + private renderCache: { width: number; lines: string[] } | undefined; + constructor( text: string, - colors: ColorPalette, showMarker: boolean = true, mode: ThinkingRenderMode = 'finalized', ui?: TUI, ) { this.text = text; - this.color = colors.roleThinking; this.showMarker = showMarker; this.mode = mode; this.ui = ui; @@ -53,20 +51,29 @@ export class ThinkingComponent implements Component { } } - invalidate(): void {} + private markRenderDirty(): void { + this.renderCache = undefined; + } + + invalidate(): void { + this.markRenderDirty(); + this.textComponent.setText(this.styled(this.text)); + } setText(text: string): void { if (this.text === text) return; this.text = text; + this.markRenderDirty(); this.textComponent.setText(this.styled(text)); } private styled(text: string): string { - return chalk.hex(this.color).italic(text); + return currentTheme.italicFg('textDim', text); } finalize(): void { this.mode = 'finalized'; + this.markRenderDirty(); this.stopSpinner(); } @@ -77,50 +84,70 @@ export class ThinkingComponent implements Component { setExpanded(expanded: boolean): void { if (this.expanded === expanded) return; this.expanded = expanded; + this.markRenderDirty(); } render(width: number): string[] { + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === width + ) { + return this.renderCache.lines; + } + const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : ['']; + let rendered: string[]; if (this.mode === 'live') { const visibleLines = contentLines.length > THINKING_PREVIEW_LINES ? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES) : contentLines; - const spinner = chalk.hex(this.color)( + const spinner = currentTheme.fg( + 'textDim', `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, ); - return [ + rendered = [ '', - spinner + chalk.hex(this.color)('thinking...'), + spinner + currentTheme.fg('textDim', 'thinking...'), ...visibleLines.map((line) => MESSAGE_INDENT + line), ]; + } else { + const lines: string[] = ['']; + for (let i = 0; i < contentLines.length; i++) { + const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; + lines.push(p + contentLines[i]); + } + + if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) { + rendered = lines; + } else { + // Leading blank + first PREVIEW_LINES content lines + hint line. + const truncated = lines.slice(0, 1 + THINKING_PREVIEW_LINES); + const remaining = contentLines.length - THINKING_PREVIEW_LINES; + const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`; + const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width)); + const hintWidth = Math.max(0, width - indentWidth); + truncated.push( + ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')), + ); + rendered = truncated; + } } - const rendered: string[] = ['']; - for (let i = 0; i < contentLines.length; i++) { - const p = i === 0 && this.showMarker ? chalk.hex(this.color)(STATUS_BULLET) : MESSAGE_INDENT; - rendered.push(p + contentLines[i]); + if (isRenderCacheEnabled()) { + this.renderCache = { width, lines: rendered }; } - - if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) { - return rendered; - } - - // Leading blank + first PREVIEW_LINES content lines + hint line. - const truncated = rendered.slice(0, 1 + THINKING_PREVIEW_LINES); - const remaining = contentLines.length - THINKING_PREVIEW_LINES; - truncated.push( - MESSAGE_INDENT + chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), - ); - return truncated; + return rendered; } private startSpinner(): void { if (this.ui === undefined || this.spinnerInterval !== undefined) return; this.spinnerInterval = setInterval(() => { this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; + this.markRenderDirty(); this.ui?.requestRender(); }, BRAILLE_SPINNER_INTERVAL_MS); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 049922235..e5303111a 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -5,39 +5,55 @@ import { isAbsolute, relative, sep } from 'node:path'; -import { Container, Text, Spacer, visibleWidth } from '@earendil-works/pi-tui'; -import type { Component, MarkdownTheme, TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - +import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import type { Component, TUI } from '@moonshot-ai/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; -import { COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering'; +import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, + COMMAND_PREVIEW_LINES, + RESULT_PREVIEW_LINES, + THINKING_PREVIEW_LINES, +} from '#/tui/constant/rendering'; import { STREAMING_ARGS_FIELD_RE, STREAMING_ARGS_PREVIEW_MAX_CHARS, } from '#/tui/constant/streaming'; -import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { FAILURE_MARK, STATUS_BULLET, SUCCESS_MARK } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; +import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress'; import { PlanBoxComponent } from './plan-box'; import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; -import { pickResultRenderer } from './tool-renderers/registry'; +import { buildGoalToolHeader } from './tool-renderers/goal'; +import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; const MAX_ARG_LENGTH = 60; const MAX_SUB_TOOL_CALLS_SHOWN = 4; -const MAX_SINGLE_SUBAGENT_TOOL_ROWS = 4; +// Cap the Agent `description` in the single-subagent header so a long prompt +// cannot wrap the header onto a second row and break the card's stable height. +const MAX_SUBAGENT_DESCRIPTION_LENGTH = 60; const APPROVED_PLAN_MARKER = '## Approved Plan:'; +const AUTO_APPROVED_PLAN_MARKER = '## Plan (auto-approved, not user-reviewed):'; const STREAMING_PROGRESS_INTERVAL_MS = 1000; -const SUBAGENT_ELAPSED_INTERVAL_MS = 1000; const PROGRESS_URL_RE = /https?:\/\/\S+/g; +const ABORTED_MARK = '⊘'; +const MAX_LIVE_OUTPUT_CHARS = 50_000; + +/** Delay before a long-running foreground Bash/Agent card advertises Ctrl+B. */ +const DETACH_HINT_DELAY_MS = 10_000; +const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background'; type SubagentTextKind = 'thinking' | 'text'; - +type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded'; interface FinishedSubCall { readonly name: string; @@ -57,6 +73,7 @@ interface SubToolActivity { name: string; args: Record<string, unknown>; phase: 'ongoing' | 'done' | 'failed'; + output?: string; readonly orderSeq: number; } @@ -75,8 +92,9 @@ export interface ToolCallSubagentSnapshot { readonly toolName: string; readonly toolCallDescription: string; readonly agentName: string | undefined; - readonly phase: 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded' | undefined; + readonly phase: SubagentPhase | undefined; readonly toolCount: number; + readonly elapsedSeconds: number | undefined; readonly tokens: number; readonly isError: boolean; readonly errorText: string | undefined; @@ -97,13 +115,15 @@ export interface ToolCallReadSnapshot { } function backgroundFailureMessage( - status: 'completed' | 'failed' | 'killed' | 'lost' | undefined, + status: 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost' | undefined, ): string | undefined { switch (status) { case 'lost': return 'Background agent lost (session restarted before completion)'; case 'killed': return 'Background agent killed'; + case 'timed_out': + return 'Background agent timed out'; case 'failed': return 'Background agent failed'; case 'completed': @@ -152,13 +172,16 @@ function formatElapsed(seconds: number): string { } function extractApprovedPlan(output: string): string { - const markerIndex = output.indexOf(APPROVED_PLAN_MARKER); + const marker = output.includes(AUTO_APPROVED_PLAN_MARKER) + ? AUTO_APPROVED_PLAN_MARKER + : APPROVED_PLAN_MARKER; + const markerIndex = output.indexOf(marker); if (markerIndex < 0) return ''; - return output.slice(markerIndex + APPROVED_PLAN_MARKER.length).trim(); + return output.slice(markerIndex + marker.length).trim(); } interface ExitPlanModeOutcome { - readonly kind: 'approved' | 'rejected'; + readonly kind: 'approved' | 'auto_approved' | 'rejected'; readonly chosen?: string; readonly feedback?: string; readonly path?: string; @@ -174,11 +197,17 @@ const PLAN_SAVED_TO_RE = /\nPlan saved to: ([^\n]+)\n/; /** * Parses the ExitPlanMode result content string to recover the approval outcome * and optional plan path. Core-side templates live in - * `packages/agent-core/src/tools/builtin/planning/exit-plan-mode.ts`: + * `packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts` (auto-approved + * path) and `.../permissionPolicy/policies/exit-plan-mode-review-ask.ts` + * (user-reviewed path): * - Approved output starts with 'Exited plan mode.' and selected options * are reported as 'Selected approach: <label>'. Older outputs may start * with 'User approved option "<label>".' Plan-file mode may include * 'Plan saved to: <path>'. + * - Auto-approved output (auto permission mode skips the review ask) also + * starts with 'Exited plan mode.' but marks the plan body with + * '## Plan (auto-approved, not user-reviewed):' instead of + * '## Approved Plan:' — the user never saw or approved the plan. * - Rejected output starts with 'Plan rejected by user.' or older * 'User rejected the plan.'; feedback uses 'User rejected the plan. * Feedback:\n\n<text>'. @@ -198,6 +227,11 @@ function interpretExitPlanModeOutcome(output: string): ExitPlanModeOutcome { } const pathMatch = PLAN_SAVED_TO_RE.exec(output); const path = pathMatch?.[1]?.trim(); + if (output.includes(AUTO_APPROVED_PLAN_MARKER)) { + return path !== undefined && path.length > 0 + ? { kind: 'auto_approved', path } + : { kind: 'auto_approved' }; + } const optionMatch = SELECTED_APPROACH_RE.exec(output) ?? APPROVED_OPTION_RE.exec(output); if (optionMatch !== null) { return path !== undefined && path.length > 0 @@ -213,7 +247,8 @@ function isExitPlanModeOutcomeOutput(output: string): boolean { output.startsWith(PLAN_REJECT_PREFIX) || output.startsWith('Exited plan mode.') || APPROVED_OPTION_RE.test(output) || - output.includes(APPROVED_PLAN_MARKER) + output.includes(APPROVED_PLAN_MARKER) || + output.includes(AUTO_APPROVED_PLAN_MARKER) ); } @@ -395,7 +430,7 @@ function extractKeyArgument( }; // Glob: concatenate multiple args into a single summary so the header - // shows pattern, optional explicit path, and include_dirs override. + // shows pattern, optional explicit path, and ignored-file inclusion. if (toolName === 'Glob') { const pattern = args['pattern']; if (typeof pattern !== 'string' || pattern.length === 0) return null; @@ -404,8 +439,8 @@ function extractKeyArgument( if (typeof path === 'string' && path.length > 0) { summary += ` · ${makeWorkspaceRelativePath(path, workspaceDir)}`; } - if (args['include_dirs'] === false) { - summary += ' · no dirs'; + if (args['include_ignored'] === true) { + summary += ' · include ignored'; } return truncateArgValue('pattern', summary); } @@ -415,7 +450,9 @@ function extractKeyArgument( const val = args[key]; if (typeof val === 'string' && val.length > 0) { const firstLine = val.split('\n')[0] ?? val; - return formatKeyArgument(toolName, key, firstLine, workspaceDir); + const displayValue = + toolName === 'Bash' && val.includes('\n') ? `${firstLine}…` : firstLine; + return formatKeyArgument(toolName, key, displayValue, workspaceDir); } } return null; @@ -443,35 +480,65 @@ function tailNonEmptyLines(text: string, maxLines: number): string[] { } class PrefixedWrappedLine implements Component { + private renderCache: { width: number; lines: string[] } | undefined; + constructor( private readonly firstPrefix: string, private readonly continuationPrefix: string, private readonly text: string, + // When set, only the last N wrapped display rows are kept, so a long + // unwrapped paragraph scrolls within a fixed window instead of growing + // unbounded. The first kept row still gets `firstPrefix`. + private readonly tailLines?: number, + // When set, the output is padded with empty continuation rows until it + // reaches this many display rows, so a short paragraph still fills a + // fixed-height window. Applied after `tailLines`. + private readonly minLines?: number, ) { } - invalidate(): void { } + invalidate(): void { + this.renderCache = undefined; + } render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + if (isRenderCacheEnabled() && this.renderCache?.width === safeWidth) { + return this.renderCache.lines; + } + const prefixWidth = Math.max( visibleWidth(this.firstPrefix), visibleWidth(this.continuationPrefix), ); - const contentWidth = Math.max(1, width - prefixWidth); - const lines = new Text(this.text, 0, 0).render(contentWidth); - return lines.map((line, index) => - index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`, - ); + const contentWidth = Math.max(1, safeWidth - prefixWidth); + const wrapped = new Text(this.text, 0, 0).render(contentWidth); + const lines = + this.tailLines !== undefined && wrapped.length > this.tailLines + ? wrapped.slice(wrapped.length - this.tailLines) + : wrapped; + if (this.minLines !== undefined) { + while (lines.length < this.minLines) lines.push(''); + } + const rendered = lines + .map((line, index) => + index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`, + ) + .map((line) => truncateToWidth(line, safeWidth, '…')); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } export class ToolCallComponent extends Container { private expanded = false; - private planExpanded = false; private toolCall: ToolCallBlockData; + private readonly markdownTheme = createMarkdownTheme(); private result: ToolResultBlockData | undefined; - private colors: ColorPalette; private ui: TUI | undefined; - private markdownTheme: MarkdownTheme | undefined; private planPath: string | undefined; /** * Fallback plan body used when the LLM uses plan-file mode and @@ -504,8 +571,19 @@ export class ToolCallComponent extends Container { */ private subagentText = ''; private subagentThinkingText = ''; - // ── Subagent lifecycle state from subagent.spawned/completed/failed ── - private subagentPhase: 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded' | undefined; + /** Tracks whether the child agent's latest streamed delta was text or thinking, + * so the active window can follow whichever is currently live. */ + private lastSubagentStreamKind: SubagentTextKind = 'text'; + // ── Subagent lifecycle state from subagent.spawned/started/completed/failed ── + private subagentPhase: SubagentPhase | undefined; + /** + * Distinguishes a foreground subagent that the user detached via Ctrl+B from + * one that started in the background. Both set `subagentPhase = 'backgrounded'`, + * but only the detached one should keep showing `◐ backgrounded` after its + * spawn-success ToolResult lands — a started-in-background agent reads as + * `done` once its result arrives. + */ + private detachedFromForeground = false; /** * Authoritative terminal phase for a backgrounded subagent. Set from * `BackgroundTaskInfo.status` via `setBackgroundTaskTerminalStatus` once @@ -525,6 +603,7 @@ export class ToolCallComponent extends Container { private subagentElapsedTimer: ReturnType<typeof setInterval> | undefined; private subagentStartedAtMs: number | undefined; private subagentEndedAtMs: number | undefined; + private subagentSpinnerFrame = 0; // ── Live progress lines ────────────────────────────────────────── // @@ -536,6 +615,14 @@ export class ToolCallComponent extends Container { // authoritative final state. private progressLines: string[] = []; private static readonly MAX_PROGRESS_LINES = 24; + private liveOutput = ''; + + /** + * Advertises `Ctrl+B` on a foreground Bash/Agent card that has been running + * for {@link DETACH_HINT_DELAY_MS}. Cleared when the result lands. + */ + private detachHintTimer: ReturnType<typeof setTimeout> | undefined; + private detachHintVisible = false; /** * Registered by a group container (`AgentGroupComponent` or @@ -550,17 +637,13 @@ export class ToolCallComponent extends Container { constructor( toolCall: ToolCallBlockData, result: ToolResultBlockData | undefined, - colors: ColorPalette, ui?: TUI, - markdownTheme?: MarkdownTheme, private readonly workspaceDir?: string, ) { super(); this.toolCall = toolCall; this.result = result; - this.colors = colors; this.ui = ui; - this.markdownTheme = markdownTheme; this.applySubagentReplay(toolCall.subagent); this.addChild(new Spacer(1)); @@ -569,10 +652,60 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); + this.startDetachHintTimer(); + } + + private renderCache: + | { width: number; lines: string[]; childRefs: Component[]; childLines: string[][] } + | undefined; + + override render(width: number): string[] { + const cache = this.renderCache; + const cacheValid = + isRenderCacheEnabled() && + cache !== undefined && + cache.width === width && + cache.childRefs.length === this.children.length; + + const childRefs: Component[] = []; + const childLines: string[][] = []; + let allReused = cacheValid; + + let i = 0; + for (const child of this.children) { + const lines = child.render(width); + childRefs.push(child); + childLines.push(lines); + if (cacheValid && (cache.childRefs[i] !== child || cache.childLines[i] !== lines)) { + allReused = false; + } + i++; + } + + if (allReused) { + return cache!.lines; + } + + const out: string[] = []; + for (const lines of childLines) { + for (const line of lines) out.push(line); + } + if (isRenderCacheEnabled()) { + this.renderCache = { width, lines: out, childRefs, childLines }; + } + return out; + } + + override invalidate(): void { + this.renderCache = undefined; + this.headerText.setText(this.buildHeader()); + this.rebuildBody(); + super.invalidate(); } setExpanded(expanded: boolean): void { @@ -586,23 +719,15 @@ export class ToolCallComponent extends Container { this.rebuildBody(); } - // Toggle the plan box's expanded state independently from tool-output - // expansion. Returns true iff this card actually owns a plan preview - // (ExitPlanMode), so the caller can decide whether to consume the keystroke. - setPlanExpanded(expanded: boolean): boolean { - if (this.toolCall.name !== 'ExitPlanMode') return false; - if (this.planExpanded === expanded) return true; - this.planExpanded = expanded; - this.rebuildBody(); - return true; - } - setResult(result: ToolResultBlockData): void { this.result = result; // Result supersedes any live progress chatter; the result body is the // authoritative final state. Without this clear, a finished tool would // show both the streamed status lines and the final output stacked. this.progressLines = []; + this.liveOutput = ''; + this.detachHintVisible = false; + this.stopDetachHintTimer(); this.finalizeSubagentElapsedIfNeeded(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); @@ -645,9 +770,23 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } + appendLiveOutput(text: string): void { + if (this.result !== undefined || text.length === 0) return; + this.liveOutput += text; + if (this.liveOutput.length > MAX_LIVE_OUTPUT_CHARS) { + this.liveOutput = `[...truncated]\n${this.liveOutput.slice( + this.liveOutput.length - MAX_LIVE_OUTPUT_CHARS, + )}`; + } + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + dispose(): void { this.stopStreamingProgressTimer(); this.stopSubagentElapsedTimer(); + this.stopDetachHintTimer(); } /** @@ -694,6 +833,7 @@ export class ToolCallComponent extends Container { call.name, call.args, call.result.is_error === true ? 'failed' : 'done', + call.result.output, ); } while (this.finishedSubCalls.length > MAX_SUB_TOOL_CALLS_SHOWN) { @@ -748,14 +888,11 @@ export class ToolCallComponent extends Container { // 'spawning' and keep showing `Initializing...`. // Intermediate states without a result still use `subagentPhase`. // `backgrounded` has no result because background agents do not enter the - // transcript. - const derivedPhase: ToolCallSubagentSnapshot['phase'] = - this.backgroundTaskTerminalPhase ?? - (this.result !== undefined - ? this.result.is_error - ? 'failed' - : 'done' - : this.subagentPhase); + // transcript — but a foreground subagent detached via Ctrl+B keeps + // `subagentPhase === 'backgrounded'` even after its ToolResult lands, so + // the group card shows `◐ backgrounded` rather than `✓ Completed`. Reuse + // the standalone derivation so both paths agree. + const derivedPhase = this.getDerivedSubagentPhase(); const errorText = this.subagentError ?? (derivedPhase === 'failed' ? this.result?.output : undefined); return { @@ -765,6 +902,7 @@ export class ToolCallComponent extends Container { agentName: this.subagentAgentName, phase: derivedPhase, toolCount: finished, + elapsedSeconds: this.getSubagentElapsedSeconds(), tokens, isError: derivedPhase === 'failed', errorText, @@ -814,12 +952,14 @@ export class ToolCallComponent extends Container { name: string, args: Record<string, unknown>, phase: SubToolActivity['phase'], + output?: string, ): void { const existing = this.subToolActivities.get(id); if (existing !== undefined) { existing.name = name; existing.args = args; existing.phase = phase; + if (output !== undefined) existing.output = output; return; } this.subToolActivities.set(id, { @@ -827,6 +967,7 @@ export class ToolCallComponent extends Container { name, args, phase, + ...(output !== undefined ? { output } : {}), orderSeq: ++this.subToolOrderSeq, }); } @@ -865,12 +1006,52 @@ export class ToolCallComponent extends Container { this.streamingProgressTimer = undefined; } + /** Only foreground Bash/Agent calls can be detached via Ctrl+B. */ + private isDetachHintEligible(): boolean { + return this.toolCall.name === 'Bash' || this.toolCall.name === 'Agent'; + } + + private startDetachHintTimer(): void { + if (!this.isDetachHintEligible()) return; + if (this.result !== undefined) return; + if (this.ui === undefined) return; + if (this.toolCall.name === 'Agent') { + // Subagents are long-running by nature; advertise Ctrl+B immediately + // instead of waiting out the delay used for short Bash commands. + if (this.detachHintVisible) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); + return; + } + if (this.detachHintTimer !== undefined) return; + this.detachHintTimer = setTimeout(() => { + this.detachHintTimer = undefined; + if (this.result !== undefined) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); + }, DETACH_HINT_DELAY_MS); + } + + private stopDetachHintTimer(): void { + if (this.detachHintTimer === undefined) return; + clearTimeout(this.detachHintTimer); + this.detachHintTimer = undefined; + } + + private buildDetachHintBlock(): void { + if (!this.detachHintVisible) return; + if (this.result !== undefined) return; + this.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } + private syncSubagentElapsedTimer(): void { const phase = this.getDerivedSubagentPhase(); const shouldTick = this.isSingleSubagentView() && this.subagentStartedAtMs !== undefined && - (phase === 'spawning' || phase === 'running'); + (phase === 'queued' || phase === 'spawning' || phase === 'running'); if (!shouldTick) { this.stopSubagentElapsedTimer(); return; @@ -878,14 +1059,18 @@ export class ToolCallComponent extends Container { if (this.ui === undefined || this.subagentElapsedTimer !== undefined) return; this.subagentElapsedTimer = setInterval(() => { const latestPhase = this.getDerivedSubagentPhase(); - if (latestPhase !== 'spawning' && latestPhase !== 'running') { + if (latestPhase !== 'queued' && latestPhase !== 'spawning' && latestPhase !== 'running') { this.stopSubagentElapsedTimer(); return; } + // Drives both the braille spinner in the header and the elapsed-seconds + // refresh. Only the header text changes on a tick, so we avoid rebuilding + // the body (which would defeat the per-component render caches). + this.subagentSpinnerFrame = (this.subagentSpinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; this.headerText.setText(this.buildHeader()); - this.invalidate(); + this.notifySnapshotChange(); this.ui?.requestRender(); - }, SUBAGENT_ELAPSED_INTERVAL_MS); + }, BRAILLE_SPINNER_INTERVAL_MS); } private stopSubagentElapsedTimer(): void { @@ -905,10 +1090,10 @@ export class ToolCallComponent extends Container { } /** - * Handles SDK `subagent.spawned`. The child agent is registered, but internal - * activity events (`assistant.delta` or `tool.call.started`) may not have - * arrived yet, so the UI moves to the 'spawning' placeholder state unless the - * agent is running in the background. + * Handles SDK `subagent.spawned`. The child agent is registered with the + * parent call, but its prompt may still be queued behind other subagents. + * `subagent.started` moves it to 'running' when the child turn actually + * begins. */ onSubagentSpawned(meta: { agentId: string; @@ -917,7 +1102,7 @@ export class ToolCallComponent extends Container { }): void { this.subagentAgentId = meta.agentId; this.subagentAgentName = meta.agentName; - this.subagentPhase = meta.runInBackground ? 'backgrounded' : 'spawning'; + this.subagentPhase = meta.runInBackground ? 'backgrounded' : 'queued'; this.subagentStartedAtMs = Date.now(); this.subagentEndedAtMs = undefined; this.syncSubagentElapsedTimer(); @@ -927,6 +1112,27 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } + /** Handles SDK `subagent.started` once a queued child turn begins. */ + onSubagentStarted(meta: { + agentId: string; + agentName?: string | undefined; + runInBackground: boolean; + }): void { + this.subagentAgentId = meta.agentId; + this.subagentAgentName = meta.agentName; + if ( + !meta.runInBackground && + (this.subagentPhase === undefined || this.subagentPhase === 'queued') + ) { + this.subagentPhase = 'running'; + } + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + /** * Handles SDK `subagent.completed`. Moves the phase to 'done' and records * token usage plus the result summary for the header chip and tail summary. @@ -991,7 +1197,7 @@ export class ToolCallComponent extends Container { * reclassifies a previously-running task as `lost`). */ setBackgroundTaskTerminalStatus( - status: 'completed' | 'failed' | 'killed' | 'lost', + status: 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost', options: { errorText?: string | undefined } = {}, ): void { const phase: 'done' | 'failed' = status === 'completed' ? 'done' : 'failed'; @@ -1030,6 +1236,22 @@ export class ToolCallComponent extends Container { this.notifySnapshotChange(); } + /** + * Mark a foreground subagent as detached-to-background. Called when a + * `background.task.started` event arrives for this agent (i.e. the user + * pressed Ctrl+B). Keeps the card showing `◐ backgrounded` instead of + * flipping to `✓ Completed` when the spawn-success ToolResult lands. + */ + markBackgrounded(): void { + if (this.detachedFromForeground) return; + this.detachedFromForeground = true; + this.subagentPhase = 'backgrounded'; + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + /** * Subagent id for the backing AgentTool call, used by routing to find a * tool call's backing subagent when reconciling background task lifecycle @@ -1068,13 +1290,18 @@ export class ToolCallComponent extends Container { } appendSubagentText(text: string, kind: SubagentTextKind = 'text'): void { + this.lastSubagentStreamKind = kind; if (kind === 'thinking') { this.subagentThinkingText += text; } else { this.subagentText += text; } // Child-agent activity means it is running unless already terminal/backgrounded. - if (this.subagentPhase === undefined || this.subagentPhase === 'spawning') { + if ( + this.subagentPhase === undefined || + this.subagentPhase === 'queued' || + this.subagentPhase === 'spawning' + ) { this.subagentPhase = 'running'; } this.headerText.setText(this.buildHeader()); @@ -1093,7 +1320,11 @@ export class ToolCallComponent extends Container { : {}), }); this.upsertSubToolActivity(call.id, call.name, call.args, 'ongoing'); - if (this.subagentPhase === undefined || this.subagentPhase === 'spawning') { + if ( + this.subagentPhase === undefined || + this.subagentPhase === 'queued' || + this.subagentPhase === 'spawning' + ) { this.subagentPhase = 'running'; } this.headerText.setText(this.buildHeader()); @@ -1119,12 +1350,37 @@ export class ToolCallComponent extends Container { streamingArguments: nextArgsText, }); this.upsertSubToolActivity(delta.id, delta.name ?? existing?.name ?? 'Tool', parsed, 'ongoing'); + if ( + this.subagentPhase === undefined || + this.subagentPhase === 'queued' || + this.subagentPhase === 'spawning' + ) { + this.subagentPhase = 'running'; + } this.headerText.setText(this.buildHeader()); this.rebuildContent(); this.notifySnapshotChange(); this.ui?.requestRender(); } + appendSubToolLiveOutput(id: string, text: string): void { + if (text.length === 0) return; + const activity = this.subToolActivities.get(id); + const ongoing = this.ongoingSubCalls.get(id); + if (activity === undefined && ongoing === undefined) return; + const name = activity?.name ?? ongoing?.name ?? 'Tool'; + const args = activity?.args ?? ongoing?.args ?? {}; + const existingOutput = activity?.output ?? ''; + let output = existingOutput + text; + if (output.length > MAX_LIVE_OUTPUT_CHARS) { + output = `[...truncated]\n${output.slice(output.length - MAX_LIVE_OUTPUT_CHARS)}`; + } + this.upsertSubToolActivity(id, name, args, activity?.phase ?? 'ongoing', output); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + finishSubToolCall(result: { tool_call_id: string; output: string; @@ -1144,6 +1400,7 @@ export class ToolCallComponent extends Container { ongoing.name, ongoing.args, result.is_error === true ? 'failed' : 'done', + result.output, ); while (this.finishedSubCalls.length > MAX_SUB_TOOL_CALLS_SHOWN) { this.finishedSubCalls.shift(); @@ -1156,24 +1413,24 @@ export class ToolCallComponent extends Container { } private buildHeader(): string { - const { toolCall, result, colors } = this; + const { toolCall, result } = this; const isFinished = result !== undefined; const isError = result?.is_error ?? false; const isTruncated = toolCall.truncated === true && !isFinished; let bullet: string; if (isFinished) { - bullet = isError ? chalk.hex(colors.error)('✗ ') : chalk.hex(colors.success)(STATUS_BULLET); + bullet = isError ? currentTheme.fg('error', '✗ ') : currentTheme.fg('success', STATUS_BULLET); } else if (isTruncated) { - bullet = chalk.hex(colors.error)('✗ '); + bullet = currentTheme.fg('error', '✗ '); } else { // Solid bullet for in-flight tools — the previous marker ↔ blank // toggle caused visible flicker on every re-render. - bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); + bullet = currentTheme.fg('text', STATUS_BULLET); } if (toolCall.name === 'ExitPlanMode') { - const label = chalk.hex(colors.primary).bold('Current plan'); + const label = currentTheme.boldFg('primary', 'Current plan'); if (!isFinished || result === undefined || result.is_error === true) { return label; } @@ -1183,21 +1440,53 @@ export class ToolCallComponent extends Container { outcome.chosen !== undefined && outcome.chosen.length > 0 ? `Approved: ${outcome.chosen}` : 'Approved'; - return `${label}${chalk.hex(colors.success)(` · ${chipText}`)}`; + return `${label}${currentTheme.fg('success', ` · ${chipText}`)}`; + } + if (outcome.kind === 'auto_approved') { + // Auto permission mode let the plan through without user review — + // a warning-toned chip keeps "the user approved this" out of the UI. + return `${label}${currentTheme.fg('warning', ' · Auto-approved')}`; } return label; } if (toolCall.name === 'AskUserQuestion') { + const isBackgroundAsk = toolCall.args['background'] === true; const label = isFinished ? isError ? 'Could not collect your input' + : isBackgroundAsk + ? 'Started background question' : 'Collected your answers' - : 'Waiting for your input'; - const tone = isError ? chalk.hex(colors.error) : chalk.hex(colors.primary); - return `${bullet}${tone.bold(label)}`; + : isBackgroundAsk + ? 'Starting background question' + : 'Waiting for your input'; + const tone = isError ? 'error' : 'primary'; + return `${bullet}${currentTheme.boldFg(tone, label)}`; } + if (toolCall.name === 'Bash') { + // The command itself is rendered in the body (with a `$` prompt), so the + // header only names the action — repeating the command in parentheses + // would duplicate the body. Wording mirrors the other label-only headers + // (e.g. AskUserQuestion): the whole label takes the tone colour. + if (isTruncated) { + return `${bullet}${currentTheme.fg('error', 'Truncated')} ${currentTheme.boldFg('primary', 'Bash')}`; + } + const label = isFinished ? 'Ran a command' : 'Running a command'; + const tone = isError ? 'error' : 'primary'; + const chipStr = isFinished && result !== undefined ? this.buildHeaderChip(result) : ''; + return `${bullet}${currentTheme.boldFg(tone, label)}${chipStr}`; + } + + const goalHeader = buildGoalToolHeader({ + toolCall, + result, + bullet, + chip: isFinished && result !== undefined ? this.buildHeaderChip(result) : '', + }); + if (goalHeader !== undefined) return goalHeader; + if (this.isSingleSubagentView()) { return this.buildSingleSubagentHeader(); } @@ -1206,13 +1495,13 @@ export class ToolCallComponent extends Container { const keyArg = extractKeyArgument(toolCall.name, toolCall.args, this.workspaceDir); const decoded = decodeMcpToolName(toolCall.name); const verbStyled = isTruncated - ? chalk.hex(colors.error)(verb) + ? currentTheme.fg('error', verb) : verb; const toolLabel = decoded !== null - ? `${chalk.hex(colors.primary).bold(decoded.toolName)}${chalk.dim(` · MCP/${decoded.serverName}`)}` - : chalk.hex(colors.primary).bold(toolCall.name); - const argStr = keyArg ? chalk.dim(` (${keyArg})`) : ''; + ? `${currentTheme.boldFg('primary', decoded.toolName)}${currentTheme.dim(` · MCP/${decoded.serverName}`)}` + : currentTheme.boldFg('primary', toolCall.name); + const argStr = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; let chipStr = ''; if (isFinished && result) chipStr = this.buildHeaderChip(result); return `${bullet}${verbStyled} ${toolLabel}${argStr}${chipStr}`; @@ -1223,8 +1512,8 @@ export class ToolCallComponent extends Container { if (provider === undefined) return ''; const text = provider(this.toolCall, result); if (text.length === 0) return ''; - const tone = result.is_error ? chalk.hex(this.colors.error) : chalk.dim; - return tone(` · ${text}`); + if (result.is_error) return currentTheme.fg('error', ` · ${text}`); + return currentTheme.dim(` · ${text}`); } private rebuildContent(): void { @@ -1232,6 +1521,8 @@ export class ToolCallComponent extends Container { this.children.pop(); } this.buildProgressBlock(); + this.buildDetachHintBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); } @@ -1243,6 +1534,8 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildDetachHintBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); } @@ -1268,15 +1561,33 @@ export class ToolCallComponent extends Container { PROGRESS_URL_RE.lastIndex = 0; const styled = PROGRESS_URL_RE.test(raw) ? raw.replace(PROGRESS_URL_RE, (url) => { - const visible = chalk.hex(this.colors.warning).underline(url); + const visible = currentTheme.underlineFg('warning', url); return `\u001B]8;;${url}\u001B\\${visible}\u001B]8;;\u001B\\`; }) - : chalk.dim(raw); + : currentTheme.dim(raw); PROGRESS_URL_RE.lastIndex = 0; this.addChild(new Text(styled, 2, 0)); } } + private buildLiveOutputBlock(): void { + if (this.result !== undefined) return; + if (this.liveOutput.length === 0) return; + this.addChild( + new ShellExecutionComponent({ + result: { + tool_call_id: this.toolCall.id, + output: this.liveOutput, + is_error: false, + }, + expanded: this.expanded, + resultPreviewLines: RESULT_PREVIEW_LINES, + tailOutput: true, + expandHint: false, + }), + ); + } + private buildSubagentBlock(): void { if ( this.subagentAgentId === undefined && @@ -1294,19 +1605,18 @@ export class ToolCallComponent extends Container { return; } - const dim = chalk.dim; const phaseChip = this.formatPhaseChip(); const headerLabel = this.subagentAgentName !== undefined ? `subagent ${this.subagentAgentName} (${this.formatAgentId()})` : `subagent (${this.formatAgentId()})`; - this.addChild(new Text(` ${dim(`↳ ${headerLabel}`)}${phaseChip}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim(`↳ ${headerLabel}`)}${phaseChip}`, 0, 0)); if (this.hiddenSubCallCount > 0) { const suffix = this.hiddenSubCallCount > 1 ? 's' : ''; this.addChild( new Text( - dim.italic(` ${String(this.hiddenSubCallCount)} more tool call${suffix} ...`), + currentTheme.italic(currentTheme.dim(` ${String(this.hiddenSubCallCount)} more tool call${suffix} ...`)), 0, 0, ), @@ -1315,26 +1625,26 @@ export class ToolCallComponent extends Container { for (const sub of this.finishedSubCalls) { const mark = sub.isError - ? chalk.hex(this.colors.error)('✗') - : chalk.hex(this.colors.success)('•'); + ? currentTheme.fg('error', '✗') + : currentTheme.fg('success', '•'); const keyArg = extractKeyArgument(sub.name, sub.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(sub.name); - const argCol = keyArg ? dim(` (${keyArg})`) : ''; + const nameCol = currentTheme.fg('primary', sub.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; this.addChild(new Text(` ${mark} Used ${nameCol}${argCol}`, 0, 0)); } for (const [id, call] of this.ongoingSubCalls) { const keyArg = extractKeyArgument(call.name, call.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(call.name); - const argCol = keyArg ? dim(` (${keyArg})`) : ''; + const nameCol = currentTheme.fg('primary', call.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; void id; - this.addChild(new Text(` ${dim('…')} Using ${nameCol}${argCol}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('…')} Using ${nameCol}${argCol}`, 0, 0)); } if (this.subagentText.length > 0) { const tailLines = this.subagentText.split('\n').slice(-3); for (const line of tailLines) { - this.addChild(new Text(` ${dim(line)}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim(line)}`, 0, 0)); } } @@ -1342,7 +1652,7 @@ export class ToolCallComponent extends Container { if (this.subagentPhase === 'done' && this.subagentResultSummary !== undefined) { const summaryLines = this.subagentResultSummary.split('\n').slice(0, 2); for (const line of summaryLines) { - this.addChild(new Text(` ${dim('└')} ${line}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('└')} ${line}`, 0, 0)); } } @@ -1350,13 +1660,14 @@ export class ToolCallComponent extends Container { if (this.subagentPhase === 'failed' && this.subagentError !== undefined) { const errLines = this.subagentError.split('\n'); for (const line of errLines) { - this.addChild(new Text(` ${chalk.hex(this.colors.error)('└')} ${line}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.fg('error', '└')} ${line}`, 0, 0)); } } } /** * Header phase/token chip. No chip is shown when phase is undefined. + * queued -> queued * spawning -> starting * running -> running * done -> N tools, 8.4k tok @@ -1365,9 +1676,11 @@ export class ToolCallComponent extends Container { */ private formatPhaseChip(): string { if (this.subagentPhase === undefined) return ''; - const dim = chalk.dim; const parts: string[] = []; switch (this.subagentPhase) { + case 'queued': + parts.push('○ queued'); + break; case 'spawning': parts.push('↻ starting…'); break; @@ -1375,7 +1688,7 @@ export class ToolCallComponent extends Container { parts.push('↻ running'); break; case 'done': { - parts.push(chalk.hex(this.colors.success)('✓ done')); + parts.push(currentTheme.fg('success', '✓ done')); const toolCount = this.finishedSubCalls.length + this.hiddenSubCallCount; if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount > 1 ? 's' : ''}`); const tokens = @@ -1385,13 +1698,13 @@ export class ToolCallComponent extends Container { break; } case 'failed': - parts.push(chalk.hex(this.colors.error)('✗ failed')); + parts.push(currentTheme.fg('error', '✗ failed')); break; case 'backgrounded': parts.push('◐ backgrounded'); break; } - return parts.length > 0 ? dim(` · ${parts.join(' · ')}`) : ''; + return parts.length > 0 ? currentTheme.dim(` · ${parts.join(' · ')}`) : ''; } private formatAgentId(): string { @@ -1416,59 +1729,59 @@ export class ToolCallComponent extends Container { return this.toolCall.name === 'Agent' && this.hasSubagentState(); } - private getDerivedSubagentPhase(): - | 'spawning' - | 'running' - | 'done' - | 'failed' - | 'backgrounded' - | undefined { + private getDerivedSubagentPhase(): SubagentPhase | undefined { if (this.backgroundTaskTerminalPhase !== undefined) { return this.backgroundTaskTerminalPhase; } + // A foreground subagent detached via Ctrl+B keeps showing `backgrounded` + // even after its spawn-success ToolResult lands, so the card doesn't flip + // to `✓ Completed` and look like the work actually finished. Agents that + // started in the background (`detachedFromForeground === false`) read as + // `done` once their result lands. + if (this.detachedFromForeground && this.subagentPhase === 'backgrounded') { + return 'backgrounded'; + } if (this.result !== undefined) return this.result.is_error ? 'failed' : 'done'; return this.subagentPhase; } private buildSingleSubagentHeader(): string { const phase = this.getDerivedSubagentPhase(); - const isFailed = phase === 'failed'; const isDone = phase === 'done'; - const bullet = isFailed - ? chalk.hex(this.colors.error)('✗ ') - : isDone - ? chalk.hex(this.colors.success)(STATUS_BULLET) - : chalk.hex(this.colors.roleAssistant)(STATUS_BULLET); + const marker = this.buildSingleSubagentMarker(phase); const labelText = formatSubagentLabel(this.subagentAgentName); - const label = chalk.hex(this.colors.primary).bold(labelText); + const label = currentTheme.boldFg('primary', labelText); const status = this.formatSingleSubagentStatus(phase); - const description = str(this.toolCall.args['description']); + const rawDescription = str(this.toolCall.args['description']); + const description = + rawDescription.length > MAX_SUBAGENT_DESCRIPTION_LENGTH + ? `${rawDescription.slice(0, MAX_SUBAGENT_DESCRIPTION_LENGTH - 1)}…` + : rawDescription; const descriptionPlain = description.length > 0 ? ` (${description})` : ''; - const descriptionText = descriptionPlain.length > 0 ? chalk.dim(descriptionPlain) : ''; + const descriptionText = descriptionPlain.length > 0 ? currentTheme.dim(descriptionPlain) : ''; const statsText = this.formatSingleSubagentStatsText(); if (isDone) { - const success = chalk.hex(this.colors.success); - return `${bullet}${success.bold(labelText)} ${success(`Completed${descriptionPlain}${statsText}`)}`; + return `${marker}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; } - const stats = chalk.dim(statsText); - return `${bullet}${label} ${status}${descriptionText}${stats}`; + const stats = currentTheme.dim(statsText); + return `${marker}${label} ${status}${descriptionText}${stats}`; } - private formatSingleSubagentStatus( - phase: 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded' | undefined, - ): string { + private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string { switch (phase) { case 'done': - return chalk.hex(this.colors.success)('Completed'); + return currentTheme.fg('success', 'Completed'); case 'failed': - return chalk.hex(this.colors.error)('Failed'); + return currentTheme.fg('error', 'Failed'); case 'running': - return chalk.hex(this.colors.primary)('Running'); + return currentTheme.fg('primary', 'Running'); case 'backgrounded': return 'Backgrounded'; + case 'queued': + return currentTheme.fg('primary', 'Queued'); case 'spawning': case undefined: - return chalk.hex(this.colors.primary)('Starting'); + return currentTheme.fg('primary', 'Starting'); } } @@ -1494,61 +1807,133 @@ export class ToolCallComponent extends Container { return Math.max(0, Math.floor((end - this.subagentStartedAtMs) / 1000)); } - private buildSingleSubagentBlock(): void { - for (const activity of this.getRecentSubToolActivities()) { - const mark = - activity.phase === 'failed' - ? chalk.hex(this.colors.error)('✗') - : activity.phase === 'done' - ? chalk.hex(this.colors.success)('•') - : chalk.hex(this.colors.text)('•'); - const verb = activity.phase === 'ongoing' ? 'Using' : 'Used'; - this.addChild(new Text(` ${mark} ${this.formatSubToolActivity(verb, activity)}`, 0, 0)); - } + private buildSingleSubagentMarker(phase: SubagentPhase | undefined): string { + if (phase === 'failed') return currentTheme.fg('error', '✗ '); + if (phase === 'done') return currentTheme.fg('success', STATUS_BULLET); + if (phase === 'backgrounded') return currentTheme.dim('◐ '); + // Active (queued / spawning / running): a braille spinner reads as alive + // where a static bullet looked frozen. + const frame = BRAILLE_SPINNER_FRAMES[this.subagentSpinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]; + return currentTheme.fg('primary', `${frame} `); + } - if (this.getDerivedSubagentPhase() === 'failed' && this.subagentError !== undefined) { - const errorLine = tailNonEmptyLines(this.subagentError, 1).at(-1); - if (errorLine !== undefined) { - this.addChild( - new PrefixedWrappedLine( - ` ${chalk.hex(this.colors.error)('└')} `, - ' ', - chalk.hex(this.colors.error)(errorLine), - ), - ); - } + private buildSingleSubagentBlock(): void { + const phase = this.getDerivedSubagentPhase(); + + // Every state shares the same skeleton — header, a one-line tool summary, + // and a fixed two-row content window — so the card height is identical + // while running and after it finishes (no end-of-run shrink). + this.addChild(new Text(this.buildSingleSubagentSummaryLine(), 0, 0)); + + if (phase === 'failed') { + this.addChild(this.buildSingleSubagentResultWindow('error')); return; } - - const outputLine = tailNonEmptyLines(this.subagentText, 1).at(-1); - const thinkingLine = tailNonEmptyLines(this.subagentThinkingText, 1).at(-1); - if (this.getDerivedSubagentPhase() !== 'done' && thinkingLine !== undefined) { - this.addChild( - new PrefixedWrappedLine(` ${chalk.dim('◌')} `, ' ', chalk.dim(thinkingLine)), - ); - } - if (outputLine !== undefined) { - this.addChild( - new PrefixedWrappedLine( - ` ${chalk.hex(this.colors.text)('└')} `, - ' ', - chalk.hex(this.colors.text)(outputLine), - ), - ); + if (phase === 'done' || phase === 'backgrounded') { + this.addChild(this.buildSingleSubagentResultWindow('output')); + return; } + this.addChild(this.buildSingleSubagentActiveWindow()); } - private getRecentSubToolActivities(): SubToolActivity[] { - return [...this.subToolActivities.values()] - .toSorted((a, b) => a.orderSeq - b.orderSeq) - .slice(-MAX_SINGLE_SUBAGENT_TOOL_ROWS); + /** Most-recently-started sub-tool, preferring one that is still running. */ + private getCurrentSubToolActivity(): SubToolActivity | undefined { + let latestOngoing: SubToolActivity | undefined; + let latest: SubToolActivity | undefined; + for (const activity of this.subToolActivities.values()) { + if (latest === undefined || activity.orderSeq > latest.orderSeq) latest = activity; + if ( + activity.phase === 'ongoing' && + (latestOngoing === undefined || activity.orderSeq > latestOngoing.orderSeq) + ) { + latestOngoing = activity; + } + } + return latestOngoing ?? latest; } - private formatSubToolActivity(verb: string, activity: SubToolActivity): string { - const keyArg = extractKeyArgument(activity.name, activity.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(activity.name); - const argCol = keyArg ? chalk.dim(` (${keyArg})`) : ''; - return `${verb} ${nameCol}${argCol}`; + /** + * The single live stream shown in the active window. A running sub-tool with + * previewable output (Bash or any tool without a dedicated renderer) wins; + * otherwise the most-recently-updated of the child agent's text / thinking. + */ + private getActiveSubagentContent(): { text: string; tone: 'text' | 'thinking' } | undefined { + const current = this.getCurrentSubToolActivity(); + if ( + current?.phase === 'ongoing' && + current.output !== undefined && + current.output.trim().length > 0 && + (current.name === 'Bash' || isGenericToolResult(current.name)) + ) { + return { text: current.output, tone: 'text' }; + } + if (this.lastSubagentStreamKind === 'thinking' && this.subagentThinkingText.trim().length > 0) { + return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' }; + } + if (this.subagentText.trim().length > 0) { + return { text: this.subagentText, tone: 'text' }; + } + if (this.subagentThinkingText.trim().length > 0) { + return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' }; + } + return undefined; + } + + private buildSingleSubagentSummaryLine(): string { + const toolCount = this.subToolActivities.size; + const countLabel = `${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`; + const current = this.getCurrentSubToolActivity(); + if (current === undefined) { + return currentTheme.dim(` · ${countLabel}`); + } + const verb = current.phase === 'ongoing' ? 'Using' : 'Used'; + const keyArg = extractKeyArgument(current.name, current.args, this.workspaceDir); + const nameCol = currentTheme.fg('primary', current.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; + const mark = + current.phase === 'failed' + ? currentTheme.fg('error', ' ✗') + : current.phase === 'done' + ? currentTheme.fg('success', ' ✓') + : ''; + return `${currentTheme.dim(` · ${countLabel} · `)}${verb} ${nameCol}${argCol}${mark}`; + } + + private buildSingleSubagentActiveWindow(): Component { + const gutter = currentTheme.dim('│'); + const content = this.getActiveSubagentContent(); + // Keep both tones muted: a bright `fg('text')` here flashed white whenever + // the window flipped between thinking and a brief text/tool-output segment. + const styled = + content === undefined + ? currentTheme.dim('…') + : content.tone === 'thinking' + ? currentTheme.dim(content.text) + : currentTheme.fg('textDim', content.text); + // Always exactly two rows (padded when short) so the live window matches + // the finished card's height. + return new PrefixedWrappedLine( + ` ${gutter} `, + ` ${gutter} `, + styled, + THINKING_PREVIEW_LINES, + THINKING_PREVIEW_LINES, + ); + } + + private buildSingleSubagentResultWindow(kind: 'output' | 'error'): Component { + const gutter = currentTheme.dim('│'); + const source = kind === 'error' ? this.subagentError : this.subagentText; + const text = source === undefined ? '' : tailNonEmptyLines(source, 2).join('\n'); + const styled = + kind === 'error' ? currentTheme.fg('error', text) : currentTheme.fg('text', text); + return new PrefixedWrappedLine( + ` ${gutter} `, + ` ${gutter} `, + styled, + THINKING_PREVIEW_LINES, + THINKING_PREVIEW_LINES, + ); } private buildCallPreview(): void { @@ -1560,7 +1945,7 @@ export class ToolCallComponent extends Container { if (this.result === undefined && this.toolCall.truncated === true) { this.addChild( new Text( - chalk.dim('Tool call arguments truncated by max_tokens — call never executed.'), + currentTheme.dim('Tool call arguments truncated by max_tokens — call never executed.'), 2, 0, ), @@ -1571,7 +1956,14 @@ export class ToolCallComponent extends Container { this.buildStreamingPreview(this.toolCall.streamingArguments); return; } - const shouldCap = this.result !== undefined && !this.expanded; + // Cap Edit's diff as soon as args finalize, not only when the result + // lands — mirroring Write's writeShouldCap below. Otherwise the render + // tick between finalized args (streamingArguments cleared by the + // `tool.call.started` payload) and the result draws the full diff, then + // snaps back to the cap: a height collapse that triggers pi-tui's full + // redraw and wipes scrollback. Streaming frames (streamingArguments set) + // still take buildStreamingPreview above and never reach here. + const shouldCap = !this.expanded; if (name === 'Write') { const content = str(this.toolCall.args['content']); if (content.length === 0) return; @@ -1586,13 +1978,13 @@ export class ToolCallComponent extends Container { const shown = writeShouldCap ? allLines.slice(0, COMMAND_PREVIEW_LINES) : allLines; const remaining = allLines.length - shown.length; for (const [i, line] of shown.entries()) { - const lineNum = chalk.dim(String(i + 1).padStart(4) + ' '); + const lineNum = currentTheme.dim(String(i + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } if (writeShouldCap && remaining > 0) { this.addChild( new Text( - chalk.dim( + currentTheme.dim( `... (${String(remaining)} more lines, ${String(allLines.length)} total, ctrl+o to expand)`, ), 2, @@ -1605,13 +1997,31 @@ export class ToolCallComponent extends Container { const newStr = str(this.toolCall.args['new_string']); if (oldStr.length === 0 && newStr.length === 0) return; const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); - const lines = renderDiffLinesClustered(oldStr, newStr, filePath, this.colors, { + const lines = renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3, ...(shouldCap ? { maxLines: COMMAND_PREVIEW_LINES } : {}), }); for (const line of lines) { this.addChild(new Text(line, 2, 0)); } + } else if (name === 'Bash') { + // Surface the command in the body across the whole lifecycle — while + // streaming, running, and after the result lands. Keeping the collapsed + // command preview here (instead of yielding to the result renderer once + // the result lands) avoids a height collapse when a multi-line command + // finishes with short output: the command block stays put and only the + // live-output tail swaps for the result. Owned solely by buildCallPreview + // so the command never renders twice; shellExecutionResultRenderer + // renders the result only. + const command = str(this.toolCall.args['command']); + if (command.length === 0) return; + this.addChild( + new ShellExecutionComponent({ + command, + showCommand: true, + commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, + }), + ); } } @@ -1647,7 +2057,7 @@ export class ToolCallComponent extends Container { allLines.length > maxLines ? allLines.length - maxLines + i : i; - const lineNum = chalk.dim(String(originalLineNumber + 1).padStart(4) + ' '); + const lineNum = currentTheme.dim(String(originalLineNumber + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } return; @@ -1665,7 +2075,7 @@ export class ToolCallComponent extends Container { const progress = `Preparing changes${target}... ${formatByteSize(bytes)} · ${formatElapsed( elapsedSeconds, )} elapsed`; - this.addChild(new Text(chalk.dim(progress), 2, 0)); + this.addChild(new Text(currentTheme.dim(progress), 2, 0)); return; } if (name === 'Bash') { @@ -1674,9 +2084,8 @@ export class ToolCallComponent extends Container { this.addChild( new ShellExecutionComponent({ command: cmd, - colors: this.colors, showCommand: true, - commandPreviewLines: COMMAND_PREVIEW_LINES, + commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, }), ); } @@ -1687,28 +2096,15 @@ export class ToolCallComponent extends Container { private buildPlanPreview(): void { // Priority: inline `args.plan`, approved plan parsed from result, then // asynchronously injected currentPlan used while approval is in flight. - // Once a plan is found, PlanBoxComponent renders it. Without markdownTheme - // (unit tests), fall back to indented dim text so it remains visible. + // Once a plan is found, PlanBoxComponent renders it. const plan = this.resolvePlanForPreview(); if (plan.length === 0) return; const path = this.resolvePlanPath(); - if (this.markdownTheme !== undefined) { - this.addChild( - new PlanBoxComponent(plan, this.markdownTheme, this.colors.success, path, { - maxContentLines: this.computePlanBoxMaxContentLines(), - expanded: this.planExpanded, - status: this.resolvePlanBoxStatus(), - }), - ); - } else { - this.addChild(new Text(chalk.dim(plan), 2, 0)); - } - } - - private computePlanBoxMaxContentLines(): number | undefined { - const rows = this.ui?.terminal.rows; - if (rows === undefined || !Number.isFinite(rows) || rows <= 0) return undefined; - return Math.max(8, Math.floor(rows * 0.6) - 4); + this.addChild( + new PlanBoxComponent(plan, this.markdownTheme, currentTheme.color('success'), path, { + status: this.resolvePlanBoxStatus(), + }), + ); } private resolvePlanForPreview(): string { @@ -1737,21 +2133,32 @@ export class ToolCallComponent extends Container { if (!isExitPlanModeOutcomeOutput(result.output)) return undefined; const outcome = interpretExitPlanModeOutcome(result.output); if (outcome.kind !== 'rejected') return undefined; - return { label: 'Rejected', colorHex: this.colors.error }; + return { label: 'Rejected', colorHex: currentTheme.color('error') }; } private buildContent(): void { const { result } = this; - if (result === undefined || !result.output) return; + if (result === undefined) return; + + if (this.toolCall.name === 'AgentSwarm') { + this.buildAgentSwarmResultSummary(result); + return; + } + + if (!result.output) return; if (this.isSingleSubagentView()) { return; } - // Outputs that start with a `<system…>` tag are harness-injected - // reminders piggy-backing on a tool result. They are noise for the - // user, so suppress the body while keeping the header chip intact. - if (result.output.trimStart().startsWith('<system')) { + // Outputs that start with a `<system-reminder>` tag are harness-injected + // reminders piggy-backing on a tool result (e.g. a finalize hook rewrote + // the output). They are noise for the user, so suppress the body while + // keeping the header chip intact. Match the full reminder tag only: tool + // metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so real output starting with a literal `<system>` + // is user data and must stay visible. + if (result.output.trimStart().startsWith('<system-reminder>')) { return; } @@ -1763,7 +2170,7 @@ export class ToolCallComponent extends Container { if (outcome.kind === 'rejected' && outcome.feedback !== undefined) { const trimmed = outcome.feedback.trim(); if (trimmed.length > 0) { - const labelTone = chalk.hex(this.colors.warning).bold; + const labelTone = (text: string) => currentTheme.boldFg('warning', text); this.addChild(new Text(labelTone('↪ Suggestion'), 2, 0)); for (const line of trimmed.split('\n')) { this.addChild(new Text(line, 4, 0)); @@ -1786,6 +2193,7 @@ export class ToolCallComponent extends Container { if ( this.toolCall.name === 'AskUserQuestion' && + this.toolCall.args['background'] !== true && !result.is_error && this.renderAskUserQuestionResult(result.output) ) { @@ -1795,13 +2203,48 @@ export class ToolCallComponent extends Container { const renderer = pickResultRenderer(this.toolCall.name); const components = renderer(this.toolCall, result, { expanded: this.expanded, - colors: this.colors, }); for (const component of components) { this.addChild(component); } } + private buildAgentSwarmResultSummary(result: ToolResultBlockData): void { + const summary = agentSwarmResultSummaryFromOutput(result.output); + const dim = (s: string): string => currentTheme.fg('textDim', s); + const segments: string[] = []; + + if (summary.completed > 0) { + segments.push( + currentTheme.fg('success', `${SUCCESS_MARK.trimEnd()} ${String(summary.completed)} completed`), + ); + } + if (summary.failed > 0) { + segments.push( + currentTheme.fg('error', `${FAILURE_MARK.trimEnd()} ${String(summary.failed)} failed`), + ); + } + if (summary.aborted > 0) { + segments.push( + currentTheme.fg('warning', `${ABORTED_MARK} ${String(summary.aborted)} aborted`), + ); + } + + if (segments.length > 0) { + this.addChild(new Text(`${dim('Agent swarm: ')}${segments.join(dim(' · '))}`, 2, 0)); + return; + } + + const isAborted = result.is_error === true && /\b(?:aborted|cancelled)\b/i.test(result.output); + const colorToken = isAborted ? 'warning' : result.is_error === true ? 'error' : 'success'; + const label = isAborted + ? `${ABORTED_MARK} Aborted.` + : result.is_error === true + ? `${FAILURE_MARK.trimEnd()} Failed.` + : `${SUCCESS_MARK.trimEnd()} Completed.`; + this.addChild(new Text(`${dim('Agent swarm: ')}${currentTheme.fg(colorToken, label)}`, 2, 0)); + } + /** * Render AskUserQuestion's JSON payload as a friendly Q/A list. * Returns true on success (caller skips the default JSON dump); @@ -1816,9 +2259,7 @@ export class ToolCallComponent extends Container { } if (typeof parsed !== 'object' || parsed === null) return false; - const colors = this.colors; - const dim = chalk.dim; - const accent = chalk.hex(colors.primary); + const accent = (text: string) => currentTheme.fg('primary', text); const answers = (parsed as { answers?: unknown }).answers; const note = (parsed as { note?: unknown }).note; @@ -1829,13 +2270,13 @@ export class ToolCallComponent extends Container { if (!hasAnswers) { const noteText = typeof note === 'string' && note.length > 0 ? note : 'User dismissed the question.'; - this.addChild(new Text(dim(` ${noteText}`), 0, 0)); + this.addChild(new Text(currentTheme.dim(` ${noteText}`), 0, 0)); return true; } for (const [question, answer] of Object.entries(answers as Record<string, unknown>)) { const answerText = typeof answer === 'string' ? answer : JSON.stringify(answer); - this.addChild(new Text(` ${dim('Q')} ${question}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('Q')} ${question}`, 0, 0)); this.addChild(new Text(` ${accent('→')} ${answerText}`, 0, 0)); } return true; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index a0200a761..c7c8120f2 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -11,6 +11,7 @@ import { computeDiffLines } from '#/tui/components/media/diff-preview'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; +import { goalStatusChip } from './goal'; import { readMediaChip } from './media'; import { strArg } from './types'; @@ -110,6 +111,9 @@ const webSearchChip: ChipProvider = (_toolCall, result) => { return pluralize(count, 'result'); }; +const goalStatusOutputChip: ChipProvider = (_toolCall, result) => + result.is_error ? '' : goalStatusChip(result.output); + const REGISTRY: Record<string, ChipProvider> = { Edit: editChip, Write: writeChip, @@ -119,6 +123,8 @@ const REGISTRY: Record<string, ChipProvider> = { Glob: globChip, FetchURL: fetchChip, WebSearch: webSearchChip, + CreateGoal: goalStatusOutputChip, + GetGoal: goalStatusOutputChip, }; export function pickChip(toolName: string): ChipProvider | undefined { diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts new file mode 100644 index 000000000..1b38fd278 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -0,0 +1,224 @@ +import { Text } from '@moonshot-ai/pi-tui'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; +import { formatTokenCount } from '#/utils/usage/usage-format'; + +import { formatGoalElapsed, pluralizeGoalCount } from '../goal-format'; +import { renderTruncated } from './truncated'; +import type { ResultRenderer } from './types'; + +type GoalToolName = 'CreateGoal' | 'GetGoal' | 'SetGoalBudget' | 'UpdateGoal'; + +interface GoalSnapshotView { + readonly objective: string; + readonly status: string; + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; + readonly terminalReason?: string | undefined; +} + +const GOAL_TOOLS = new Set<string>([ + 'CreateGoal', + 'GetGoal', + 'SetGoalBudget', + 'UpdateGoal', +]); + +export function isGoalToolName(toolName: string): toolName is GoalToolName { + return GOAL_TOOLS.has(toolName); +} + +export const goalSummary: ResultRenderer = (toolCall, result, ctx) => { + if (result.is_error) return renderTruncated(toolCall, result, ctx); + + switch (toolCall.name) { + case 'CreateGoal': + case 'GetGoal': + return renderGoalSnapshot(toolCall, result, ctx); + case 'SetGoalBudget': + case 'UpdateGoal': + return []; + default: + return renderTruncated(toolCall, result, ctx); + } +}; + +export function buildGoalToolHeader(options: { + readonly toolCall: ToolCallBlockData; + readonly result: ToolResultBlockData | undefined; + readonly bullet: string; + readonly chip: string; +}): string | undefined { + const { toolCall, result, bullet, chip } = options; + if (!isGoalToolName(toolCall.name)) return undefined; + + const tone = result?.is_error === true ? 'error' : 'primary'; + const label = currentTheme.boldFg(tone, goalToolLabel(toolCall.name, result, toolCall.args)); + const marker = + result !== undefined && result.is_error !== true + ? currentTheme.fg('primary', STATUS_BULLET) + : bullet; + const arg = + toolCall.name === 'UpdateGoal' + ? undefined + : formatGoalToolArgument(toolCall.name, toolCall.args); + const argText = arg === undefined ? '' : currentTheme.dimFg('textDim', ` (${arg})`); + return `${marker}${label}${argText}${chip}`; +} + +function formatGoalBudgetArg(args: Record<string, unknown>): string | undefined { + const value = args['value']; + const unit = args['unit']; + if (typeof value !== 'number' || !Number.isFinite(value) || typeof unit !== 'string') { + return undefined; + } + if (unit.length === 0) return undefined; + const normalized = unit === 'turns' || unit === 'tokens' + ? Math.max(1, Math.round(value)) + : value; + const singular = unit.endsWith('s') ? unit.slice(0, -1) : unit; + return `${String(normalized)} ${normalized === 1 ? singular : unit}`; +} + +export function goalStatusChip(output: string): string { + const goal = parseGoalValue(output); + if (goal === undefined) return ''; + if (goal === null) return 'no goal'; + return stringField(goal, 'status') ?? ''; +} + +function renderGoalSnapshot( + toolCall: ToolCallBlockData, + result: ToolResultBlockData, + _ctx: Parameters<ResultRenderer>[2], +) { + const goal = parseGoalToolOutput(result.output); + if (goal === undefined) return renderTruncated(toolCall, result, _ctx); + + const muted = (s: string) => currentTheme.dimFg('textDim', s); + const value = (s: string) => currentTheme.fg('text', s); + if (goal === null) return [new Text(muted(' No current goal.'), 0, 0)]; + + const lines = [ + ` ${value(`Goal ${goal.status}: ${truncateOneLine(goal.objective, 96)}`)}`, + ` ${muted(formatGoalStats(goal))}`, + ]; + if (goal.terminalReason !== undefined && goal.terminalReason.length > 0) { + lines.push(` ${muted(goal.terminalReason)}`); + } + return lines.map((line) => new Text(line, 0, 0)); +} + +function goalToolLabel( + toolName: GoalToolName, + result: ToolResultBlockData | undefined, + args: Record<string, unknown>, +): string { + const failed = result?.is_error === true; + const finished = result !== undefined; + switch (toolName) { + case 'CreateGoal': + return failed ? 'Could not start goal' : finished ? 'Started goal' : 'Starting goal'; + case 'GetGoal': + return failed ? 'Could not check goal' : finished ? 'Checked goal' : 'Checking goal'; + case 'SetGoalBudget': + return failed + ? 'Could not set goal budget' + : finished + ? 'Set goal budget' + : 'Setting goal budget'; + case 'UpdateGoal': { + const status = stringArg(args, 'status'); + const suffix = status ?? 'status'; + return failed + ? `Could not report goal ${suffix}` + : finished + ? `Reported goal ${suffix}` + : `Reporting goal ${suffix}`; + } + } +} + +function formatGoalToolArgument( + toolName: GoalToolName, + args: Record<string, unknown>, +): string | undefined { + switch (toolName) { + case 'CreateGoal': { + const objective = stringArg(args, 'objective'); + return objective === undefined ? undefined : truncateOneLine(objective, 60); + } + case 'SetGoalBudget': + return formatGoalBudgetArg(args); + case 'UpdateGoal': + return stringArg(args, 'status'); + case 'GetGoal': + return undefined; + } +} + +function parseGoalToolOutput(output: string): GoalSnapshotView | null | undefined { + const goal = parseGoalValue(output); + if (goal === undefined || goal === null) return goal; + const objective = stringField(goal, 'objective'); + const status = stringField(goal, 'status'); + if (objective === undefined || status === undefined) return undefined; + return { + objective, + status, + turnsUsed: numberField(goal, 'turnsUsed'), + tokensUsed: numberField(goal, 'tokensUsed'), + wallClockMs: numberField(goal, 'wallClockMs'), + terminalReason: stringField(goal, 'terminalReason'), + }; +} + +function parseGoalValue(output: string): Record<string, unknown> | null | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return undefined; + } + if (!isRecord(parsed) || !('goal' in parsed)) return undefined; + const goal = parsed['goal']; + if (goal === null) return null; + if (!isRecord(goal)) return undefined; + return goal; +} + +function formatGoalStats(goal: GoalSnapshotView): string { + return [ + pluralizeGoalCount(goal.turnsUsed, 'turn'), + `${formatTokenCount(goal.tokensUsed)} tokens`, + formatGoalElapsed(goal.wallClockMs), + ].join(' · '); +} + +function truncateOneLine(text: string, max: number): string { + const firstLine = text.replaceAll(/\s+/g, ' ').trim(); + if (firstLine.length <= max) return firstLine; + return `${firstLine.slice(0, Math.max(0, max - 1))}…`; +} + +function stringArg(args: Record<string, unknown>, key: string): string | undefined { + const value = args[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringField(record: Record<string, unknown>, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' ? value : undefined; +} + +function numberField(record: Record<string, unknown>, key: string): number { + const value = record[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts index fd753cd27..b798cc8e5 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts @@ -13,8 +13,8 @@ * message. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import type { ChipProvider } from './chip'; @@ -27,11 +27,9 @@ export interface ReadMediaSummary { mimeType?: string; bytes?: number; url?: string; - originalSize?: string; } const PATH_TAG_RE = /^<(image|video)\s+path="([^"]+)">$/; -const ORIGINAL_SIZE_RE = /original size\s+(\d+x\d+px)/; const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s; function bytesFromBase64(b64: string): number { @@ -55,7 +53,6 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { let mimeType: string | undefined; let bytes: number | undefined; let url: string | undefined; - let originalSize: string | undefined; let foundMedia = false; for (const raw of parsed) { @@ -64,15 +61,11 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { const type = part['type']; if (type === 'text' && typeof part['text'] === 'string') { - const text = part['text']; - const tag = PATH_TAG_RE.exec(text); + const tag = PATH_TAG_RE.exec(part['text']); if (tag) { kind = tag[1] as 'image' | 'video'; path = tag[2]; - continue; } - const size = ORIGINAL_SIZE_RE.exec(text); - if (size) originalSize = size[1]; continue; } @@ -103,7 +96,6 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { if (mimeType !== undefined) summary.mimeType = mimeType; if (bytes !== undefined) summary.bytes = bytes; if (url !== undefined) summary.url = url; - if (originalSize !== undefined) summary.originalSize = originalSize; return summary; } @@ -117,7 +109,6 @@ function metaSegments(summary: ReadMediaSummary): string[] { const segs: string[] = []; if (summary.mimeType !== undefined) segs.push(summary.mimeType); if (summary.bytes !== undefined) segs.push(formatBytes(summary.bytes)); - if (summary.originalSize !== undefined) segs.push(summary.originalSize); return segs; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts index 2d59b805b..2a7b39539 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts @@ -12,6 +12,7 @@ import { readMediaSummary } from './media'; import { shellExecutionResultRenderer } from '../shell-execution'; +import { goalSummary } from './goal'; import { editSummary, fetchSummary, @@ -25,6 +26,16 @@ import { import { renderTruncated } from './truncated'; import type { ResultRenderer } from './types'; +/** + * True when a tool has no dedicated renderer and falls back to the generic + * truncated output (every MCP tool and any tool not listed below). Used to + * decide whether subagent sub-tool output should be previewed the same way + * the main agent previews it. + */ +export function isGenericToolResult(toolName: string): boolean { + return pickResultRenderer(toolName) === renderTruncated; +} + export function pickResultRenderer(toolName: string): ResultRenderer { switch (toolName) { case 'Read': @@ -47,6 +58,11 @@ export function pickResultRenderer(toolName: string): ResultRenderer { return editSummary; case 'Write': return writeSummary; + case 'CreateGoal': + case 'GetGoal': + case 'SetGoalBudget': + case 'UpdateGoal': + return goalSummary; default: return renderTruncated; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index a3f929dcc..ac31cec8e 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -10,8 +10,8 @@ * sees the actual error message, not a synthetic summary. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { renderTruncated } from './truncated'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index 2bd9cf01b..dc1066bec 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -1,22 +1,111 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; +const DEFAULT_INDENT = 2; + +export function trimTrailingEmptyLines(lines: string[]): string[] { + let end = lines.length; + while (end > 0) { + const line = lines[end - 1]; + if (line === undefined || line.length > 0) break; + end--; + } + return lines.slice(0, end); +} + +/** + * Component that renders tool output with wrap-aware line truncation. + * Uses pi-tui's Text component to compute actual visual wrapped lines, + * then caps at PREVIEW_LINES. This handles long single-line output (e.g. + * JSON blobs) that would otherwise wrap to dozens of visual rows. + */ +export class TruncatedOutputComponent implements Component { + private textComponent: Text; + private readonly expanded: boolean; + private readonly maxLines: number; + private readonly indent: number; + private readonly expandHint: boolean; + private readonly tail: boolean; + + constructor( + output: string, + options: { + expanded: boolean; + isError: boolean | undefined; + maxLines?: number; + indent?: number; + // When false, the truncation footer omits the "ctrl+o to expand" promise + // (for contexts whose output is fixed-truncated and never expands). + expandHint?: boolean; + // When true, collapsed rendering keeps the latest visual rows instead of + // the first rows. This is useful for live output from a running command. + tail?: boolean; + // Foreground colour for successful (non-error) output. Defaults to + // `textDim`; Bash passes `textMuted` so its result sits one shade below + // the `textDim` command. Error output always uses `error`. + color?: keyof ColorPalette; + }, + ) { + this.expanded = options.expanded; + this.maxLines = options.maxLines ?? PREVIEW_LINES; + this.indent = options.indent ?? DEFAULT_INDENT; + this.expandHint = options.expandHint ?? true; + this.tail = options.tail ?? false; + const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); + const successColor = options.color ?? 'textDim'; + this.textComponent = new Text( + options.isError ? currentTheme.fg('error', cleaned) : currentTheme.fg(successColor, cleaned), + this.indent, + 0, + ); + } + + invalidate(): void { + // Text component caches wrapped lines; invalidate on terminal resize. + this.textComponent.invalidate(); + } + + private renderHint(width: number, hint: string): string { + const indentWidth = Math.min(this.indent, Math.max(0, width)); + const hintWidth = Math.max(0, width - indentWidth); + return ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')); + } + + render(width: number): string[] { + const contentLines = this.textComponent.render(width); + + if (this.expanded || contentLines.length <= this.maxLines) { + return contentLines; + } + + const remaining = contentLines.length - this.maxLines; + if (this.tail) { + const shown = contentLines.slice(contentLines.length - this.maxLines); + return [ + this.renderHint(width, `... (${String(remaining)} earlier lines)`), + ...shown, + ]; + } + + const shown = contentLines.slice(0, this.maxLines); + const hint = this.expandHint + ? `... (${String(remaining)} more lines, ctrl+o to expand)` + : `... (${String(remaining)} more lines)`; + return [...shown, this.renderHint(width, hint)]; + } +} + export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => { if (!result.output) return []; - const tint = result.is_error ? chalk.hex(ctx.colors.error) : chalk.dim; - const lines = result.output.split('\n'); - if (ctx.expanded) { - return [new Text(tint(result.output), 2, 0)]; - } - const shown = lines.slice(0, PREVIEW_LINES); - const remaining = lines.length - shown.length; - const out: Component[] = [new Text(tint(shown.join('\n')), 2, 0)]; - if (remaining > 0) { - out.push(new Text(chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), 2, 0)); - } - return out; + return [ + new TruncatedOutputComponent(result.output, { + expanded: ctx.expanded, + isError: result.is_error ?? false, + }), + ]; }; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts index 462b53a44..da3dc3a5a 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts @@ -1,12 +1,10 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { RESULT_PREVIEW_LINES } from '#/tui/constant/rendering'; -import type { ColorPalette } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; export interface RendererContext { readonly expanded: boolean; - readonly colors: ColorPalette; } export type ResultRenderer = ( diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 0e4401a11..195860bc3 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -4,10 +4,9 @@ * the pattern stays consistent across command-triggered panels. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { formatTokenCount, @@ -15,11 +14,12 @@ import { renderProgressBar, safeUsageRatio, } from '#/utils/usage/usage-format'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; const LEFT_MARGIN = 2; const SIDE_PADDING = 1; -const MIN_INTERIOR_WIDTH = 20; +const BOX_OVERHEAD = LEFT_MARGIN + 2 + 2 * SIDE_PADDING; type Colorize = (text: string) => string; @@ -30,13 +30,22 @@ export interface ManagedUsageRow { readonly resetHint?: string; } +export interface BoosterWalletInfo { + readonly balanceCents: number; + readonly totalCents: number; + readonly monthlyChargeLimitEnabled: boolean; + readonly monthlyChargeLimitCents: number; + readonly monthlyUsedCents: number; + readonly currency: string; +} + export interface ManagedUsageReport { readonly summary: ManagedUsageRow | null; readonly limits: readonly ManagedUsageRow[]; + readonly extraUsage?: BoosterWalletInfo | null; } export interface UsageReportOptions { - readonly colors: ColorPalette; readonly sessionUsage?: SessionUsage; readonly sessionUsageError?: string; readonly contextUsage: number; @@ -47,7 +56,6 @@ export interface UsageReportOptions { } export interface ManagedUsageReportLineOptions { - readonly colors: ColorPalette; readonly managedUsage?: ManagedUsageReport; readonly managedUsageError?: string; } @@ -108,7 +116,6 @@ function buildManagedUsageSection( value: Colorize, muted: Colorize, errorStyle: Colorize, - severityHex: (sev: 'ok' | 'warn' | 'danger') => string, ): string[] { if (error !== undefined) return [accent('Plan usage'), errorStyle(` ${error}`)]; if (usage === undefined) return []; @@ -124,12 +131,13 @@ function buildManagedUsageSection( r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); + const out: string[] = [accent('Plan usage')]; for (const row of rows) { const ratioUsed = usedRatio(row); const bar = renderProgressBar(ratioUsed, 20); const pct = `${Math.round(ratioUsed * 100)}% used`; - const barColoured = chalk.hex(severityHex(ratioSeverity(ratioUsed)))(bar); + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratioUsed)), bar); const label = row.label.padEnd(labelWidth, ' '); const resetStr = row.resetHint ? ` ${muted(row.resetHint)}` : ''; out.push(` ${muted(label)} ${barColoured} ${value(pct.padEnd(pctWidth, ' '))}${resetStr}`); @@ -137,14 +145,96 @@ function buildManagedUsageSection( return out; } +function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' { + return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; +} + +function currencySymbol(currency: string): string { + switch (currency.toUpperCase()) { + case 'CNY': + return '¥'; + case 'USD': + return '$'; + default: + return ''; + } +} + +interface CurrencyParts { + readonly symbol: string; + readonly number: string; +} + +function formatCurrencyParts(cents: number, currency: string): CurrencyParts { + const symbol = currencySymbol(currency); + const main = cents / 100; + const formatted = main.toFixed(2); + return symbol.length > 0 + ? { symbol, number: formatted } + : { symbol: '', number: `${formatted} ${currency}` }; +} + +export function buildExtraUsageSection( + extraUsage: BoosterWalletInfo | undefined | null, + accent: Colorize, + value: Colorize, + muted: Colorize, +): string[] { + if (extraUsage === undefined || extraUsage === null) return []; + + const hasMonthlyLimit = + extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0; + + const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency); + const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency); + const rows: Array<{ label: string; symbol: string; number: string }> = []; + let barLine: string | null = null; + + if (hasMonthlyLimit) { + const ratio = Math.max( + 0, + Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1), + ); + const bar = renderProgressBar(ratio, 20); + barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`; + const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency); + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', ...limit }); + rows.push({ label: 'Balance', ...balance }); + } else { + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' }); + rows.push({ label: 'Balance', ...balance }); + } + + // `Used this month` is the longest label; size the column to the widest label + // so the currency symbol starts in the same column on every row. + const labelWidth = Math.max(...rows.map((r) => r.label.length)); + // Right-align the numeric part of currency rows against each other so the + // decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as + // `Unlimited` carry no currency symbol, so they must not widen the numeric + // column — otherwise money values get padded with stray spaces. + const numberWidth = Math.max( + 0, + ...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)), + ); + const row = (label: string, symbol: string, number: string): string => { + const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number; + return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`; + }; + + const lines: string[] = [accent('Extra Usage')]; + if (barLine !== null) lines.push(barLine); + for (const r of rows) lines.push(row(r.label, r.symbol, r.number)); + + return lines; +} + export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); return buildManagedUsageSection( options.managedUsage, @@ -153,18 +243,14 @@ export function buildManagedUsageReportLines(options: ManagedUsageReportLineOpti value, muted, errorStyle, - severityHex, ); } export function buildUsageReportLines(options: UsageReportOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); const lines: string[] = [ accent('Session usage'), @@ -181,7 +267,7 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { const ratio = safeUsageRatio(options.contextUsage); const bar = renderProgressBar(ratio, 20); const pct = `${(ratio * 100).toFixed(1)}%`; - const barColoured = chalk.hex(severityHex(ratioSeverity(ratio)))(bar); + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); lines.push(''); lines.push(accent('Context window')); lines.push( @@ -195,7 +281,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { } const managedSection = buildManagedUsageReportLines({ - colors, managedUsage: options.managedUsage, managedUsageError: options.managedUsageError, }); @@ -204,36 +289,63 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { lines.push(...managedSection); } + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } export class UsagePanelComponent implements Component { - constructor( - private readonly lines: readonly string[], - private readonly borderHex: string, - private readonly title: string = ' Usage ', - ) {} + /** Cached coloured lines; rebuilt from `buildLines` on every invalidate. */ + private lines: readonly string[]; - invalidate(): void {} + constructor( + private readonly buildLines: () => readonly string[], + private readonly borderToken: ColorToken, + private readonly title: string = ' Usage ', + ) { + this.lines = buildLines(); + } + + invalidate(): void { + // Report bodies embed palette colours, so a theme switch must re-run the + // builder to repaint the cached lines (the data itself is captured). + this.lines = this.buildLines(); + } render(width: number): string[] { - const paint = (s: string): string => chalk.hex(this.borderHex)(s); - const indent = ' '.repeat(LEFT_MARGIN); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; - const availableInterior = Math.max( - MIN_INTERIOR_WIDTH, - width - LEFT_MARGIN - 2 - 2 * SIDE_PADDING, - ); + const paint = (s: string): string => currentTheme.fg(this.borderToken, s); + const availableInterior = safeWidth - BOX_OVERHEAD; + if (availableInterior < 1) { + return [ + truncateToWidth(this.title.trim(), safeWidth, '…'), + ...this.lines.map((line) => truncateToWidth(line, safeWidth, '…')), + ]; + } + + const indent = ' '.repeat(LEFT_MARGIN); const longestLine = this.lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); const contentWidth = Math.max( - MIN_INTERIOR_WIDTH, - Math.min(availableInterior, longestLine, Math.max(longestLine, this.title.length)), + 1, + Math.min(availableInterior, Math.max(longestLine, visibleWidth(this.title))), ); const horzLen = contentWidth + 2 * SIDE_PADDING; + const title = truncateToWidth(this.title, horzLen, '…'); - const trailingDashLen = Math.max(0, horzLen - this.title.length); + const trailingDashLen = Math.max(0, horzLen - visibleWidth(title)); const top = - indent + paint('╭') + paint(this.title) + paint('─'.repeat(trailingDashLen)) + paint('╮'); + indent + paint('╭') + paint(title) + paint('─'.repeat(trailingDashLen)) + paint('╮'); const bottom = indent + paint('╰' + '─'.repeat(horzLen) + '╯'); const out: string[] = [top]; @@ -243,6 +355,6 @@ export class UsagePanelComponent implements Component { out.push(indent + paint('│') + ' ' + clipped + ' '.repeat(pad) + ' ' + paint('│')); } out.push(bottom); - return out; + return out.map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index 8617598a0..cec7fbd13 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -2,49 +2,68 @@ * Renders a user message in the transcript. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export class UserMessageComponent implements Component { - private color: string; - private textComponent: Text; + private text: string; + private readonly bullet?: string; private spacerComponent: Spacer; private imageThumbnails: ImageThumbnail[]; - constructor(text: string, colors: ColorPalette, images?: ImageAttachment[]) { - this.color = colors.roleUser; - this.textComponent = new Text(chalk.hex(colors.roleUser).bold(text), 0, 0); + private renderCache: { width: number; lines: string[] } | undefined; + + constructor(text: string, images?: ImageAttachment[], bullet?: string) { + this.text = text; + this.bullet = bullet; this.spacerComponent = new Spacer(1); - this.imageThumbnails = images?.map((img) => new ImageThumbnail(img, colors)) ?? []; + this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; + } + + private markRenderDirty(): void { + this.renderCache = undefined; } invalidate(): void { - this.textComponent.invalidate(); + this.markRenderDirty(); for (const img of this.imageThumbnails) { img.invalidate?.(); } } render(width: number): string[] { - const bullet = chalk.hex(this.color).bold(USER_MESSAGE_BULLET); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === safeWidth + ) { + return this.renderCache.lines; + } + + const marker = this.bullet ?? USER_MESSAGE_BULLET; + const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; const bulletWidth = visibleWidth(bullet); - const contentWidth = Math.max(1, width - bulletWidth); + const contentWidth = Math.max(1, safeWidth - bulletWidth); const lines: string[] = []; // Spacer - for (const line of this.spacerComponent.render(width)) { + for (const line of this.spacerComponent.render(safeWidth)) { lines.push(line); } - // Text - const textLines = this.textComponent.render(contentWidth); + // Text is re-dyed from the current theme; invalidate() (theme change) clears + // the render cache so the new colours are picked up on the next render. + const coloredText = currentTheme.boldFg('roleUser', this.text); + const textLines = new Text(coloredText, 0, 0).render(contentWidth); for (let i = 0; i < textLines.length; i++) { const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth); lines.push(prefix + textLines[i]); @@ -58,6 +77,22 @@ export class UserMessageComponent implements Component { } } - return lines; + const rendered = lines.map((line) => { + // Inline image sequences (Kitty / iTerm2) carry their own placement + // information and have zero visible width, but pi-tui's truncateToWidth + // treats the embedded base64 payload as visible text and would chop the + // escape sequence in half, leaving garbage like "0m...". Skip truncation + // for those lines; the image itself already respects maxWidthCells. + if (isImageLine(line)) return line; + return truncateToWidth(line, safeWidth, '…'); + }); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } + +function isImageLine(line: string): boolean { + return line.includes('\u001B_G') || line.includes('\u001B]1337;File='); +} diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts index 2a1d8ea1d..22e6f3bc5 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -1,29 +1,38 @@ -import { Container, Spacer } from '@earendil-works/pi-tui'; +import { Container, Spacer } from '@moonshot-ai/pi-tui'; -import type { MoonLoader } from '../chrome/moon-loader'; +import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool'; export interface ActivityPaneOptions { readonly mode: ActivityPaneMode; readonly spinner?: MoonLoader; + readonly tip?: string; } export class ActivityPaneComponent extends Container { + private spinnerRef?: MoonLoader; + constructor(options: ActivityPaneOptions) { super(); + this.spinnerRef = options.spinner; - if (options.mode === 'waiting' || options.mode === 'tool') { - if (options.spinner !== undefined) { - this.addChild(new Spacer(1)); - this.addChild(options.spinner); - } - return; - } - - if (options.mode === 'composing' && options.spinner !== undefined) { + if ( + (options.mode === 'waiting' || options.mode === 'tool' || options.mode === 'composing') && + options.spinner !== undefined + ) { this.addChild(new Spacer(1)); + if (options.tip) { + options.spinner.setTip(` · Tip: ${options.tip}`); + } this.addChild(options.spinner); } } + + override render(width: number): string[] { + if (this.spinnerRef && 'setAvailableWidth' in this.spinnerRef) { + this.spinnerRef.setAvailableWidth(width); + } + return super.render(width); + } } diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts new file mode 100644 index 000000000..f32aa9321 --- /dev/null +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -0,0 +1,250 @@ +import type { Component, MarkdownTheme } from '@moonshot-ai/pi-tui'; +import { + Markdown, + Text, + truncateToWidth, + visibleWidth, +} from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; +import { currentTheme } from '../../theme'; + +type BtwPanelPhase = 'running' | 'done' | 'failed'; + +const MIN_COLLAPSED_PANEL_LINES = 3; + +interface BtwTurn { + readonly prompt: string; + answer: string; + thinking: string; + error?: string | undefined; + phase: BtwPanelPhase; +} + +interface BtwBodyRender { + readonly lines: string[]; + readonly truncated: boolean; +} + +export interface BtwPanelOptions { + readonly markdownTheme: MarkdownTheme; + readonly canUseScrollKeys: () => boolean; + readonly onPrompt: (prompt: string) => void; + readonly terminalRows: () => number; +} + +export class BtwPanelComponent implements Component { + private readonly turns: BtwTurn[] = []; + private readonly transientNotices: string[] = []; + private minBodyLines = 0; + private followTail = true; + private scrollTop = 0; + private maxScrollTop = 0; + + constructor(private readonly options: BtwPanelOptions) {} + + submit(prompt: string): void { + const normalized = prompt.trim(); + if (normalized.length === 0 || this.isRunning()) return; + this.followTail = true; + this.scrollTop = 0; + this.transientNotices.length = 0; + this.turns.push({ + prompt: normalized, + answer: '', + thinking: '', + phase: 'running', + }); + this.options.onPrompt(normalized); + } + + addTransientNotice(message: string): void { + this.transientNotices.push(message); + this.followTail = true; + } + + appendAnswer(delta: string): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + turn.answer += delta; + } + + appendThinking(delta: string): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + turn.thinking += delta; + } + + markDone(resultSummary?: string | undefined): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + if (turn.answer.trim().length === 0 && resultSummary !== undefined) { + turn.answer = resultSummary; + } + this.transientNotices.length = 0; + turn.phase = 'done'; + } + + markFailed(error: string): void { + const turn = this.currentTurn(); + if (turn === undefined || turn.phase !== 'running') { + this.turns.push({ + prompt: '', + answer: '', + thinking: '', + error, + phase: 'failed', + }); + this.transientNotices.length = 0; + return; + } + turn.error = error; + this.transientNotices.length = 0; + turn.phase = 'failed'; + } + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(4, width); + const contentWidth = Math.max(1, safeWidth - 4); + const body = this.renderBody(contentWidth); + const lines = [this.renderTopBorder(safeWidth, body.truncated)]; + for (const line of body.lines) { + lines.push(this.renderBodyLine(line, safeWidth)); + } + return lines; + } + + private renderTopBorder(width: number, truncated: boolean): string { + const paint = (s: string): string => chalk.hex(currentTheme.palette.border)(s); + const hint = truncated && this.options.canUseScrollKeys() + ? 'Esc close · ↑↓ scroll ' + : 'Esc close '; + const title = + chalk.hex(currentTheme.palette.accent).bold(' BTW ') + + paint('─ ') + + chalk.hex(currentTheme.palette.textMuted)(hint); + const innerWidth = Math.max(1, width - 2); + const clippedTitle = + visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; + const dashCount = Math.max(0, innerWidth - visibleWidth(clippedTitle)); + return paint('╭') + clippedTitle + paint('─'.repeat(dashCount)) + paint('╮'); + } + + private renderBody(width: number): BtwBodyRender { + const lines: string[] = []; + for (const [index, turn] of this.turns.entries()) { + if (index > 0) lines.push(''); + lines.push(...this.renderTurn(turn, width)); + } + if (this.turns.length === 0) { + lines.push(chalk.hex(currentTheme.palette.textDim)('Ready for a side question...')); + } + lines.push(...this.renderTransientNotices(width)); + return this.fitBodyLines(lines); + } + + private renderTransientNotices(width: number): string[] { + const lines: string[] = []; + for (const notice of this.transientNotices) { + lines.push(...new Text(chalk.hex(currentTheme.palette.textDim)(notice), 0, 0).render(width)); + } + return lines; + } + + private fitBodyLines(lines: string[]): BtwBodyRender { + const bodyLimit = this.collapsedBodyLimit(); + const targetUncapped = Math.max(this.minBodyLines, lines.length); + const target = + bodyLimit === undefined ? targetUncapped : Math.min(bodyLimit, targetUncapped); + this.minBodyLines = Math.max(this.minBodyLines, target); + + if (lines.length > target) { + this.maxScrollTop = lines.length - target; + if (this.followTail) { + this.scrollTop = this.maxScrollTop; + } else { + this.scrollTop = Math.min(this.scrollTop, this.maxScrollTop); + } + const start = this.scrollTop; + return { lines: lines.slice(start, start + target), truncated: true }; + } + + this.followTail = true; + this.scrollTop = 0; + this.maxScrollTop = 0; + const padded = [...lines]; + while (padded.length < target) { + padded.push(''); + } + return { lines: padded, truncated: false }; + } + + private collapsedBodyLimit(): number | undefined { + const terminalRows = this.options.terminalRows(); + if (!Number.isFinite(terminalRows) || terminalRows <= 0) return undefined; + const maxPanelLines = Math.max(MIN_COLLAPSED_PANEL_LINES, Math.floor(terminalRows / 3)); + return Math.max(1, maxPanelLines - 1); + } + + private renderTurn(turn: BtwTurn, width: number): string[] { + const prompt = chalk.hex(currentTheme.palette.accent)(`Q: ${turn.prompt}`); + const lines = [...new Text(prompt, 0, 0).render(width)]; + const answer = turn.answer.trim(); + const thinking = turn.thinking.trim(); + if (answer.length > 0) { + lines.push(...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width)); + } else if (thinking.length > 0) { + const thinkingLines = new Text(chalk.hex(currentTheme.palette.textDim)(thinking), 0, 0).render( + width, + ); + const visibleThinking = + thinkingLines.length > THINKING_PREVIEW_LINES + ? thinkingLines.slice(thinkingLines.length - THINKING_PREVIEW_LINES) + : thinkingLines; + lines.push(...visibleThinking); + } else if (turn.error === undefined) { + lines.push(chalk.hex(currentTheme.palette.textDim)('Waiting for answer...')); + } + if (turn.error !== undefined) { + const error = chalk.hex(currentTheme.palette.error)(turn.error); + lines.push(...new Text(error, 0, 0).render(width)); + } + return lines; + } + + private renderBodyLine(line: string, width: number): string { + const paint = (s: string): string => chalk.hex(currentTheme.palette.border)(s); + const contentWidth = Math.max(1, width - 4); + const clipped = + visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth, '…') : line; + const padding = Math.max(0, contentWidth - visibleWidth(clipped)); + return paint('│') + ' ' + clipped + ' '.repeat(padding) + ' ' + paint('│'); + } + + private currentTurn(): BtwTurn | undefined { + return this.turns.at(-1); + } + + isRunning(): boolean { + return this.currentTurn()?.phase === 'running'; + } + + isEmpty(): boolean { + return this.turns.length === 0; + } + + scroll(direction: 'up' | 'down'): boolean { + if (this.maxScrollTop <= 0) return false; + const current = this.followTail ? this.maxScrollTop : this.scrollTop; + const next = + direction === 'up' + ? Math.max(0, current - 1) + : Math.min(this.maxScrollTop, current + 1); + this.scrollTop = next; + this.followTail = next === this.maxScrollTop; + return true; + } +} diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 87b0d924f..1a2b26d07 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -1,36 +1,67 @@ -import { Container, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import { SELECT_POINTER } from '../../constant/symbols'; import type { QueuedMessage } from '../../types'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface QueuePaneOptions { readonly messages: readonly QueuedMessage[]; - readonly colors: ColorPalette; readonly isCompacting: boolean; readonly isStreaming: boolean; readonly canSteerImmediately: boolean; } +const ELLIPSIS = '…'; + export class QueuePaneComponent extends Container { + private readonly messages: readonly QueuedMessage[]; + private readonly hint: string | undefined; + constructor(options: QueuePaneOptions) { super(); - - const accent = chalk.hex(options.colors.accent); - const dim = chalk.hex(options.colors.textDim); - - for (const item of options.messages) { - this.addChild(new Text(accent(` ❯ ${item.text}`), 0, 0)); - } + this.messages = options.messages; if (options.messages.length > 0) { - const hint = + // Bash commands (`! …`) are not steerable, so only advertise Ctrl-S when + // there is at least one plain-text item that steering would actually send. + const hasSteerable = options.messages.some((m) => m.mode !== 'bash'); + const canSteer = options.canSteerImmediately && hasSteerable; + this.hint = options.isCompacting && !options.isStreaming ? ' ↑ to edit · will send after compaction' - : !options.canSteerImmediately - ? ' ↑ to edit · will send after current task' - : ' ↑ to edit · ctrl-s to steer immediately'; - this.addChild(new Text(dim(hint), 0, 0)); + : canSteer + ? ' ↑ to edit · ctrl-s to steer immediately' + : ' ↑ to edit · will send after current task'; } } + + override render(width: number): string[] { + const accent = (text: string) => currentTheme.fg('accent', text); + const shell = (text: string) => currentTheme.fg('shellMode', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const lines: string[] = [currentTheme.fg('border', '─'.repeat(width))]; + + for (const item of this.messages) { + const singleLine = item.text.replaceAll(/\s+/g, ' ').trim(); + const prefix = ` ${SELECT_POINTER} `; + if (item.mode === 'bash') { + // Shell commands get a `$ ` prompt and the shell-mode hue so they read + // as commands, not as plain text that would be sent to the model. + const prompt = '$ '; + const availableWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(prompt)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix) + shell(prompt + truncated)); + } else { + const availableWidth = Math.max(1, width - visibleWidth(prefix)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix + truncated)); + } + } + + if (this.hint !== undefined) { + lines.push(dim(truncateToWidth(this.hint, width, ELLIPSIS))); + } + + return lines; + } } diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index e09774a40..cd3329b55 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -1,8 +1,8 @@ /** - * TUI-owned configuration. + * Client-owned preferences. * - * Agent/runtime settings live in core's `config.toml`; this file owns only - * terminal UI preferences for the kimi-code client. + * Agent/runtime settings live in core's `config.toml`; this file owns + * kimi-code client preferences such as terminal UI and update behavior. */ import { existsSync } from 'node:fs'; @@ -17,7 +17,7 @@ import { getDataDir } from '#/utils/paths'; export const INVALID_TUI_CONFIG_MESSAGE = 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.'; -export const TuiThemeSchema = z.enum(['dark', 'light', 'auto']); +export const TuiThemeSchema = z.string(); export const NotificationConditionSchema = z.enum(['unfocused', 'always']); @@ -26,8 +26,13 @@ export const NotificationsConfigSchema = z.object({ condition: NotificationConditionSchema, }); +export const UpgradePreferencesSchema = z.object({ + autoInstall: z.boolean(), +}); + export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), + disable_paste_burst: z.boolean().optional(), editor: z .object({ command: z.string().optional(), @@ -39,27 +44,41 @@ export const TuiConfigFileSchema = z.object({ notification_condition: NotificationConditionSchema.optional(), }) .optional(), + upgrade: z + .object({ + auto_install: z.boolean().optional(), + }) + .optional(), }); export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, + disablePasteBurst: z.boolean(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, + upgrade: UpgradePreferencesSchema, }); export type TuiConfigFileShape = z.infer<typeof TuiConfigFileSchema>; export type TuiConfig = z.infer<typeof TuiConfigSchema>; export type NotificationsConfig = z.infer<typeof NotificationsConfigSchema>; +export type UpgradePreferences = z.infer<typeof UpgradePreferencesSchema>; export const DEFAULT_NOTIFICATIONS_CONFIG: NotificationsConfig = { enabled: true, condition: 'unfocused', }; +export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { + autoInstall: true, +}; + export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', + disablePasteBurst: false, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, + upgrade: DEFAULT_UPGRADE_PREFERENCES, }); /** @@ -116,21 +135,26 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { const command = config.editor?.command?.trim(); return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, + disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, condition: config.notifications?.notification_condition ?? DEFAULT_NOTIFICATIONS_CONFIG.condition, }, + upgrade: { + autoInstall: config.upgrade?.auto_install ?? DEFAULT_UPGRADE_PREFERENCES.autoInstall, + }, }); } export function renderTuiConfig(config: TuiConfig): string { return `# ~/.kimi-code/tui.toml -# Terminal UI preferences for kimi-code. +# Client preferences for kimi-code. # Agent/runtime settings stay in ~/.kimi-code/config.toml. -theme = "${config.theme}" # "auto" | "dark" | "light" +theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name +disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR @@ -138,6 +162,9 @@ command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $V [notifications] enabled = ${String(config.notifications.enabled)} # true | false notification_condition = "${config.notifications.condition}" # "unfocused" | "always" + +[upgrade] +auto_install = ${String(config.upgrade.autoInstall)} # true | false `; } diff --git a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts new file mode 100644 index 000000000..eccb3f3fb --- /dev/null +++ b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts @@ -0,0 +1,3 @@ +// Timing constants for the clipboard-image hint controller. +export const FOCUS_DEBOUNCE_MS = 1_000; +export const HINT_DISPLAY_MS = 4_000; diff --git a/apps/kimi-code/src/tui/constant/feedback.ts b/apps/kimi-code/src/tui/constant/feedback.ts index 37a50a43a..d72a62def 100644 --- a/apps/kimi-code/src/tui/constant/feedback.ts +++ b/apps/kimi-code/src/tui/constant/feedback.ts @@ -16,12 +16,15 @@ export { } from '#/constant/app'; export const FEEDBACK_STATUS_SUBMITTING = 'Submitting feedback…'; +export const FEEDBACK_STATUS_UPLOADING = 'Uploading attachments, this could take a few minutes…'; export const FEEDBACK_STATUS_SUCCESS = 'Feedback submitted, thank you!'; export const FEEDBACK_STATUS_CANCELLED = 'Feedback cancelled.'; export const FEEDBACK_STATUS_NETWORK_ERROR = 'Network error, failed to submit feedback.'; export const FEEDBACK_STATUS_FALLBACK = 'Opening GitHub Issues as fallback…'; export const FEEDBACK_STATUS_NOT_SIGNED_IN = "You're not signed in. Opening GitHub Issues for feedback…"; +export const FEEDBACK_STATUS_UPLOAD_FAILED = + 'Feedback sent; attachment upload failed — see feedback-upload.log.'; export function feedbackHttpErrorMessage(status: number): string { return `Failed to submit feedback (HTTP ${String(status)}).`; @@ -31,10 +34,14 @@ export function feedbackSessionLine(sessionId: string): string { return `Session: ${sessionId}`; } +export function feedbackIdLine(feedbackId: number): string { + return `Feedback ID: ${String(feedbackId)}`; +} + // Hint shown beneath session-level error messages in the TUI to point users -// at the `kimi export` workflow so they can share diagnostics with us. -export function errorReportHintLine(sessionId: string): string { - return `If this persists, run \`kimi export ${sessionId}\` and share the file with us for diagnosis. Please don't share it publicly.`; +// at the `/export-debug-zip` workflow so they can share diagnostics with us. +export function errorReportHintLine(): string { + return "If this persists, run `/export-debug-zip` and share the file with us for diagnosis. Please don't share it publicly."; } export function withFeedbackVersionPrefix(version: string): string { diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index ed05d93e1..8c8f9807b 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -9,6 +9,10 @@ export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; +// Time window for treating two consecutive Esc presses as a double-Esc, which +// opens the undo selector. Kept short (double-click feel) so two deliberate +// presses far apart don't accidentally trigger undo. +export const DOUBLE_ESC_WINDOW_MS = 600; export function isManagedUsageProvider( providerKey: string | undefined, diff --git a/apps/kimi-code/src/tui/constant/symbols.ts b/apps/kimi-code/src/tui/constant/symbols.ts index 670b94eb0..bba41d60a 100644 --- a/apps/kimi-code/src/tui/constant/symbols.ts +++ b/apps/kimi-code/src/tui/constant/symbols.ts @@ -4,4 +4,11 @@ export const STATUS_BULLET = '● '; // Shared transcript markers. Keep widths stable because message wrapping // assumes the marker occupies the leading cells. export const USER_MESSAGE_BULLET = '✨ '; +export const SUCCESS_MARK = '✓ '; export const FAILURE_MARK = '✗ '; + +// Shared selector markers — keep every list picker visually consistent. +// SELECT_POINTER marks the highlighted row; CURRENT_MARK is appended to the +// row that is the currently-active value. See .agents/skills/write-tui/DESIGN.md. +export const SELECT_POINTER = '❯'; +export const CURRENT_MARK = '← current'; diff --git a/apps/kimi-code/src/tui/constant/terminal.ts b/apps/kimi-code/src/tui/constant/terminal.ts index 3530d19b3..ec87a0780 100644 --- a/apps/kimi-code/src/tui/constant/terminal.ts +++ b/apps/kimi-code/src/tui/constant/terminal.ts @@ -33,7 +33,7 @@ export const OSC11_RESPONSE_PREFIX_NO_ESC = "]11;rgb:"; // Keep notification/title payloads bounded so terminal tabs and desktop // notifications stay readable. export const MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH = 240; -export const MAX_PROCESS_TITLE_LENGTH = 32; +export const MAX_TERMINAL_TITLE_LENGTH = 32; // OSC 11 probing must be short because unsupported terminals do not reply. export const TERMINAL_THEME_DETECT_TIMEOUT_MS = 250; diff --git a/apps/kimi-code/src/tui/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts new file mode 100644 index 000000000..f9d0a7f27 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -0,0 +1,50 @@ +export interface ToolbarTip { + readonly text: string; + /** + * Long/important tips render on their own. They never pair with a + * neighbour and never appear as the second half of someone else's pair. + */ + readonly solo?: boolean; + /** + * Rotation weight: a higher value makes the tip recur more often. Defaults + * to 1. Used to give newer/important features more airtime. + */ + readonly priority?: number; +} + +/** + * Subset of toolbar tips shown behind the composing spinner. + */ +export const WORKING_TIPS: readonly ToolbarTip[] = [ + { text: 'ctrl-s to add guidance without waiting for the turn to finish', priority: 2, solo: true }, + { text: '/tasks to check progress and status for background tasks', priority: 2 }, + { text: '/init: generate AGENTS.md', priority: 2 }, + { text: 'Try /dance for a hidden Easter egg' }, + { text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 }, + { + text: '/plugins: manage plugins — try the "Kimi Datasource" for reliable financial, economic, and academic data', + solo: true, + priority: 3, + }, + { text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, + { text: '/sessions to browse and resume earlier sessions', solo: true }, + { text: '/goal for multi-step work with a clear finish line', priority: 2, solo: true }, + { text: '/goal next to queue follow-up work while the current goal keeps running', solo: true }, + { text: '/web: use the Web UI for a better experience', solo: true }, + { text: '@: mention files', priority: 2 }, + { text: '! to run a shell command', priority: 2 }, +]; + +export const ALL_TIPS: readonly ToolbarTip[] = [ + ...WORKING_TIPS, + { text: 'shift+enter: newline' }, + { text: 'ctrl+c: cancel' }, + { text: '/theme to switch the terminal UI theme' }, + { text: '/auto when you want Kimi to handle approvals and keep going unattended' }, + { text: '/yolo to skip most approvals for trusted batch work, only use it in repos you trust' }, + { text: '/help: show commands' }, + { text: '/compact compresses context when it gets long', priority: 2 }, + { text: 'ctrl-o to hide or reveal tool output switching between a clean chat view and full execution details', priority: 2 }, + { text: 'shift-tab to Plan mode to review the approach before Kimi edits files.', priority: 2 }, + { text: '/model: switch model', priority: 2 }, +]; diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 4f1fdc104..a7acc77ce 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,12 +1,24 @@ -import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; + +import { createKimiCodeUserAgent } from '#/cli/version'; + import type { SkillListSession } from '../commands'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; -import { refreshAllProviderModels } from '../utils/refresh-providers'; +import { + refreshAllProviderModels, + type RefreshProviderScope, + type RefreshResult, +} from '../utils/refresh-providers'; +import { thinkingEffortFromConfig } from '../utils/thinking-config'; import type { SessionEventHandler } from './session-event-handler'; import type { AppState, KimiTUIOptions } from '../types'; import type { TUIState } from '../tui-state'; +type MutableCreateSessionOptions = { + -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; +}; + export interface AuthFlowHost { state: TUIState; session: Session | undefined; @@ -22,8 +34,9 @@ export interface AuthFlowHost { appendStartupNotice(extra: string): void; readonly sessionEventHandler: SessionEventHandler; fetchSessions(): Promise<void>; - refreshSessionTitle(): void; + updateTerminalTitle(): void; refreshSkillCommands(session?: SkillListSession): Promise<void>; + refreshPluginCommands(session?: Session): Promise<void>; } export class AuthFlowController { @@ -42,7 +55,7 @@ export class AuthFlowController { this.host.setAppState({ sessionId: '', model: '', - thinking: false, + thinkingEffort: 'off', contextTokens: 0, maxContextTokens: 0, contextUsage: 0, @@ -52,28 +65,31 @@ export class AuthFlowController { this.host.setStartupReady(); } - async activateModelAfterLogin(model: string, thinking?: boolean): Promise<void> { + async activateModelAfterLogin(model: string, effort?: string): Promise<void> { const { host } = this; - const level = thinking === undefined ? undefined : thinking ? 'on' : 'off'; if (host.session !== undefined) { await host.session.setModel(model); - if (level !== undefined) { - await host.session.setThinking(level); + if (effort !== undefined) { + await host.session.setThinking(effort); } return; } - const session = await host.harness.createSession({ + const options: MutableCreateSessionOptions = { workDir: host.state.appState.workDir, model, - thinking: level, + thinking: effort, permission: host.options.startup.auto ? 'auto' : host.options.startup.yolo ? 'yolo' : undefined, planMode: host.state.appState.planMode ? true : undefined, - }); + }; + if (host.state.appState.additionalDirs.length > 0) { + options.additionalDirs = [...host.state.appState.additionalDirs]; + } + const session = await host.harness.createSession(options); await host.setSession(session); host.setAppState({ sessionId: session.id, @@ -82,8 +98,9 @@ export class AuthFlowController { await host.syncRuntimeState(session); host.sessionEventHandler.startSubscription(); void host.fetchSessions(); - host.refreshSessionTitle(); + host.updateTerminalTitle(); void host.refreshSkillCommands(host.session); + void host.refreshPluginCommands(host.session); } async clearActiveSessionAfterLogout(): Promise<void> { @@ -95,6 +112,7 @@ export class AuthFlowController { sessionTitle: null, }); await this.host.refreshSkillCommands(); + await this.host.refreshPluginCommands(); } async refreshConfigAfterLogin(): Promise<void> { @@ -110,16 +128,13 @@ export class AuthFlowController { return; } - await this.activateModelAfterLogin(defaultModel, config.defaultThinking); + await this.activateModelAfterLogin(defaultModel, thinkingEffortFromConfig(config.thinking)); const appStatePatch: Partial<AppState> = { availableModels, availableProviders, model: defaultModel, maxContextTokens: selected.maxContextSize, }; - if (config.defaultThinking !== undefined) { - appStatePatch.thinking = config.defaultThinking; - } host.setAppState(appStatePatch); } @@ -129,7 +144,7 @@ export class AuthFlowController { availableModels: config.models ?? {}, availableProviders: config.providers ?? {}, model: '', - thinking: false, + thinkingEffort: 'off', maxContextTokens: 0, contextUsage: 0, contextTokens: 0, @@ -142,26 +157,29 @@ export class AuthFlowController { * config. Runs best-effort: individual provider failures are collected * and returned instead of thrown. */ - async refreshProviderModels(): Promise<{ - readonly changed: ReadonlyArray<{ - readonly providerId: string; - readonly providerName: string; - readonly added: number; - readonly removed: number; - }>; - readonly unchanged: readonly string[]; - readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>; - }> { + async refreshProviderModels(): Promise<RefreshResult> { + return this.refreshProviderModelsWithScope('all'); + } + + async refreshOAuthProviderModels(): Promise<RefreshResult> { + return this.refreshProviderModelsWithScope('oauth'); + } + + private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise<RefreshResult> { const { host } = this; - const result = await refreshAllProviderModels({ - getConfig: () => host.harness.getConfig({ reload: true }), - removeProvider: (id) => host.harness.removeProvider(id), - setConfig: (patch) => host.harness.setConfig(patch), - resolveOAuthToken: async (providerName, oauthRef) => { - const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); - return tokenProvider.getAccessToken(); + const result = await refreshAllProviderModels( + { + getConfig: () => host.harness.getConfig({ reload: true }), + removeProvider: (id) => host.harness.removeProvider(id), + setConfig: (patch) => host.harness.setConfig(patch), + resolveOAuthToken: async (providerName, oauthRef) => { + const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); + return tokenProvider.getAccessToken(); + }, + userAgent: createKimiCodeUserAgent(), }, - }); + { scope }, + ); if (result.changed.length > 0) { await this.refreshAvailableModels(); } diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts new file mode 100644 index 000000000..a8ee45e61 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -0,0 +1,212 @@ +import { Spacer } from '@moonshot-ai/pi-tui'; +import type { + Event, + KimiHarness, + Session, + TurnEndedEvent, +} from '@moonshot-ai/kimi-code-sdk'; + +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { BtwPanelComponent } from '../components/panes/btw-panel'; +import { formatErrorMessage } from '../utils/event-payload'; +import { formatHookResultPlain } from '../utils/hook-result-format'; +import { createMarkdownTheme } from '../theme/pi-tui-theme'; +import type { TUIState } from '../tui-state'; + +const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question.'; + +export interface BtwPanelHost { + state: TUIState; + session: Session | undefined; + readonly harness: KimiHarness; + + showError(msg: string): void; +} + +export class BtwPanelController { + private active: + | { + readonly agentId: string; + readonly panel: BtwPanelComponent; + } + | undefined; + private readonly panelsByAgentId = new Map<string, BtwPanelComponent>(); + + constructor(private readonly host: BtwPanelHost) {} + + open(agentId: string, initialPrompt: string): void { + let panel: BtwPanelComponent; + panel = new BtwPanelComponent({ + markdownTheme: createMarkdownTheme(), + canUseScrollKeys: () => this.host.state.editor.getText().length === 0, + terminalRows: () => this.host.state.terminal.rows, + onPrompt: (prompt) => { + this.promptAgent(agentId, prompt, panel); + }, + }); + this.active = { agentId, panel }; + this.panelsByAgentId.set(agentId, panel); + this.mount(panel); + panel.submit(initialPrompt); + } + + clear(): void { + const active = this.active; + if (active !== undefined && this.shouldCancelOnUnmount(active.panel)) { + void this.cancelAgent(active.agentId); + } + this.active = undefined; + this.panelsByAgentId.clear(); + this.host.state.btwPanelContainer.clear(); + this.host.state.editor.connectedAbove = false; + } + + closeOrCancel(): boolean { + const active = this.active; + if (active === undefined) return false; + const shouldCancel = this.shouldCancelOnUnmount(active.panel); + this.close(active.panel); + if (shouldCancel) { + void this.cancelAgent(active.agentId); + } + return true; + } + + cancelRunning(): boolean { + const active = this.active; + if (active === undefined || !active.panel.isRunning()) return false; + void this.cancelAgent(active.agentId); + return true; + } + + sendUserInput(text: string): boolean { + const active = this.active; + if (active === undefined) return false; + if (active.panel.isRunning()) { + this.showBusyNotice(active, text); + return true; + } + active.panel.submit(text); + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(); + return true; + } + + scroll(direction: 'up' | 'down'): boolean { + const panel = this.active?.panel; + if (panel === undefined || !panel.scroll(direction)) return false; + this.host.state.ui.requestRender(); + return true; + } + + routeEvent(event: Event): boolean { + const panel = this.panelsByAgentId.get(event.agentId); + if (panel === undefined) return false; + + switch (event.type) { + case 'assistant.delta': + panel.appendAnswer(event.delta); + this.host.state.ui.requestRender(); + return true; + case 'thinking.delta': + panel.appendThinking(event.delta); + this.host.state.ui.requestRender(); + return true; + case 'hook.result': + panel.appendAnswer(formatHookResultPlain(event)); + this.host.state.ui.requestRender(); + return true; + case 'turn.ended': + if (event.reason === 'completed') { + panel.markDone(); + } else { + panel.markFailed(formatBtwTurnEnd(event)); + } + this.host.state.ui.requestRender(); + return true; + default: + return true; + } + } + + private mount(panel: BtwPanelComponent): void { + this.host.state.btwPanelContainer.clear(); + this.host.state.btwPanelContainer.addChild(new Spacer(1)); + this.host.state.btwPanelContainer.addChild(panel); + this.host.state.editor.connectedAbove = true; + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(); + } + + private close(panel: BtwPanelComponent): void { + if (!this.host.state.btwPanelContainer.children.includes(panel)) return; + this.unregister(panel); + this.host.state.btwPanelContainer.clear(); + this.host.state.editor.connectedAbove = false; + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(true); + } + + private unregister(panel: BtwPanelComponent): void { + for (const [agentId, candidate] of this.panelsByAgentId) { + if (candidate === panel) { + this.panelsByAgentId.delete(agentId); + } + } + if (this.active?.panel === panel) this.active = undefined; + } + + private showBusyNotice( + active: { readonly panel: BtwPanelComponent }, + input: string, + ): void { + this.host.state.editor.setText(input); + active.panel.addTransientNotice(BTW_BUSY_NOTICE); + this.host.state.ui.requestRender(); + } + + private promptAgent(agentId: string, prompt: string, panel: BtwPanelComponent): void { + const session = this.host.session; + if (session === undefined) { + panel.markFailed(NO_ACTIVE_SESSION_MESSAGE); + this.host.state.ui.requestRender(); + return; + } + void this.withInteractiveAgent(agentId, () => session.prompt(prompt)).catch((error: unknown) => { + panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`); + this.host.state.ui.requestRender(); + }); + } + + private async cancelAgent(agentId: string): Promise<void> { + const session = this.host.session; + if (session === undefined) return; + await this.withInteractiveAgent(agentId, () => session.cancel()).catch((error: unknown) => { + this.host.showError(`Failed to cancel /btw: ${formatErrorMessage(error)}`); + }); + } + + private shouldCancelOnUnmount(panel: BtwPanelComponent): boolean { + return panel.isRunning() || panel.isEmpty(); + } + + private withInteractiveAgent<T>(agentId: string, fn: () => Promise<T>): Promise<T> { + return this.host.harness.withInteractiveAgent(agentId, fn); + } +} + +function formatBtwTurnEnd(event: TurnEndedEvent): string { + if (event.reason === 'cancelled') { + return 'Interrupted by user'; + } + if (event.error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } + if (event.error !== undefined) { + return `[${event.error.code}] ${event.error.message}`; + } + if (event.reason === 'blocked') { + return 'Prompt hook blocked the request.'; + } + return `BTW turn ended with reason: ${event.reason}`; +} diff --git a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts new file mode 100644 index 000000000..48dd1f8b8 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts @@ -0,0 +1,167 @@ +import type { TUI } from '@moonshot-ai/pi-tui'; + +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; + +import { FOCUS_DEBOUNCE_MS, HINT_DISPLAY_MS } from '../constant/clipboard-image-hint'; +import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '../utils/terminal-focus'; +import type { FooterComponent } from '../components/chrome/footer'; + +export interface ClipboardImageHintHost { + readonly ui: TUI; + readonly footer: FooterComponent; + getModelSupportsImage(): boolean; + requestRender(): void; +} + +function getPasteImageShortcut(): string { + return process.platform === 'win32' ? 'Alt+V' : 'Ctrl+V'; +} + +export class ClipboardImageHintController { + private readonly host: ClipboardImageHintHost; + private disposeInputListener: (() => void) | undefined; + private debounceTimer: ReturnType<typeof setTimeout> | undefined; + private clearHintTimer: ReturnType<typeof setTimeout> | undefined; + private lastHintText: string | undefined; + private checkGeneration = 0; + private focused = true; + // Whether the controller has completed its first clipboard observation since + // start. The first observation only establishes a baseline: an image already + // in the clipboard when the session starts is not "new", so it must not + // trigger a hint during initialization. + private initialized = false; + // Whether a detected clipboard image is allowed to trigger a hint. After + // showing a hint for an image it disarms so the same lingering image does + // not nag on every focus. A focus check that finds the clipboard empty + // re-arms it, so the next genuinely new image notifies again. + private armed = true; + + constructor(host: ClipboardImageHintHost) { + this.host = host; + } + + start(): void { + this.disposeInputListener = this.host.ui.addInputListener((data) => { + this.handleInput(data); + }); + void this.establishInitialBaseline(); + } + + stop(): void { + this.clearDebounceTimer(); + this.clearClearHintTimer(); + this.disposeInputListener?.(); + this.disposeInputListener = undefined; + + this.checkGeneration += 1; + this.clearOwnedHint(); + this.initialized = false; + this.armed = true; + } + + private handleInput(data: string): void { + if (data === TERMINAL_FOCUS_IN) { + this.focused = true; + this.scheduleCheck(); + return; + } + if (data === TERMINAL_FOCUS_OUT) { + this.focused = false; + this.clearDebounceTimer(); + return; + } + } + + private scheduleCheck(): void { + this.clearDebounceTimer(); + this.checkGeneration += 1; + const generation = this.checkGeneration; + this.debounceTimer = setTimeout(() => void this.runCheck(generation), FOCUS_DEBOUNCE_MS); + } + + private clearDebounceTimer(): void { + if (this.debounceTimer !== undefined) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + } + + private clearClearHintTimer(): void { + if (this.clearHintTimer !== undefined) { + clearTimeout(this.clearHintTimer); + this.clearHintTimer = undefined; + } + } + + private clearOwnedHint(): void { + if (this.host.footer.getTransientHint() === this.lastHintText) { + this.host.footer.setTransientHint(null); + this.host.requestRender(); + } + this.lastHintText = undefined; + } + + private async establishInitialBaseline(): Promise<void> { + if (!this.host.getModelSupportsImage()) return; + + this.checkGeneration += 1; + const generation = this.checkGeneration; + + let hasImage = false; + try { + hasImage = await clipboardHasImage(); + } catch { + return; + } + + if (generation !== this.checkGeneration) return; + + this.initialized = true; + this.armed = !hasImage; + } + + private async runCheck(generation: number): Promise<void> { + if (!this.focused) return; + if (!this.host.getModelSupportsImage()) return; + + let hasImage = false; + try { + hasImage = await clipboardHasImage(); + } catch { + return; + } + + if (generation !== this.checkGeneration) return; + if (!this.focused) return; + + // First observation after start only establishes the baseline. An image + // already in the clipboard when the session began is not "new", so we + // record the state and stay quiet instead of nagging during initialization. + if (!this.initialized) { + this.initialized = true; + this.armed = !hasImage; + return; + } + + if (!hasImage) { + // Clipboard holds no image, so the next image that appears is a new one + // worth notifying about. Re-arm and bail out. + this.armed = true; + return; + } + + // Same image we already notified about — stay quiet until it changes. + if (!this.armed) return; + + const hintText = `Image in clipboard · ${getPasteImageShortcut()} to paste`; + this.clearClearHintTimer(); + this.lastHintText = hintText; + this.armed = false; + this.host.footer.setTransientHint(hintText); + this.host.requestRender(); + + this.clearHintTimer = setTimeout(() => { + this.clearOwnedHint(); + }, HINT_DISPLAY_MS); + } +} diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 75473d7ae..5b6d95e19 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -1,4 +1,5 @@ -import type { Session } from '@moonshot-ai/kimi-code-sdk'; +import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; import { parseImageMeta } from '#/utils/image/image-mime'; @@ -7,38 +8,58 @@ import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/exte import { CTRL_C_HINT, CTRL_D_HINT, + DOUBLE_ESC_WINDOW_MS, EXIT_CONFIRM_WINDOW_MS, LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE, } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; import type { ImageAttachmentStore } from '../utils/image-attachment-store'; -import type { PendingExit } from '../types'; +import { extractMediaAttachments } from '../utils/image-placeholder'; +import type { PendingExit, QueuedMessage, SteerInputItem } from '../types'; import type { TUIState } from '../tui-state'; +import type { BtwPanelController } from './btw-panel'; export interface EditorKeyboardHost { state: TUIState; session: Session | undefined; cancelInFlight: (() => void) | undefined; + /** + * The host's harness (KimiTUI always has one). Its `imageLimits` drives + * paste-time image compression; hosts without one fall back to the + * env/built-in default. + */ + harness?: KimiHarness | undefined; handleUserInput(text: string): void; - steerMessage(session: Session, input: string[]): void; - recallLastQueued(): string | undefined; + readonly btwPanelController: BtwPanelController; + steerMessage(session: Session, input: readonly SteerInputItem[]): void; + validateMediaCapabilities(extraction: { + hasMedia: boolean; + imageAttachmentIds: readonly number[]; + videoAttachmentIds: readonly number[]; + }): boolean; + recallLastQueued(): QueuedMessage | undefined; showError(msg: string): void; track(event: string, props?: Record<string, unknown>): void; updateEditorBorderHighlight(text?: string): void; updateQueueDisplay(): void; toggleToolOutputExpansion(): void; - togglePlanExpansion(): boolean; + toggleTodoPanelExpansion(): void; + detachCurrentForegroundTask(): void; + cancelRunningShellCommand(): void; hideSessionPicker(): void; + openUndoSelector(): void; stop(exitCode?: number): Promise<void>; handlePlanToggle(next: boolean): void; + handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; } export class EditorKeyboardController { private pendingExit: PendingExit | null = null; + private pendingUndoEsc: { readonly timer: ReturnType<typeof setTimeout> } | null = null; constructor( private readonly host: EditorKeyboardHost, @@ -58,6 +79,47 @@ export class EditorKeyboardController { host.updateEditorBorderHighlight(text); }; + // bash mode recalls only shell (`!`-prefixed) history entries; prompt mode + // recalls everything. The filter is locked to the mode captured when the + // user first enters history browsing (see onHistoryDraftSave), so landing on + // a shell entry mid-browse doesn't switch the filter to shell-only. + let browseMode: 'prompt' | 'bash' | null = null; + editor.setHistoryFilter((entry: string) => { + const mode = browseMode ?? editor.inputMode; + return mode === 'bash' ? entry.startsWith('!') : true; + }); + + // Recalling a `!`-prefixed entry strips the marker and returns to bash + // mode; recalling a plain entry returns to prompt mode. The filter above + // guarantees bash mode only ever lands on `!` entries, so this never + // misfires on commands typed in bash mode. + editor.onRecall = (entry: string) => { + if (entry.startsWith('!')) { + editor.setInputMode('bash'); + return entry.slice(1); + } + editor.setInputMode('prompt'); + return undefined; + }; + + // Save/restore the input mode alongside pi-tui's history draft. Without + // this, recalling a shell entry and then pressing Down back to an empty + // draft would leave the editor stuck in bash mode, so the next typed + // message would be submitted as a shell command. Also locks the history + // filter (browseMode) for the duration of the browse session. + editor.onHistoryDraftSave = () => { + browseMode = editor.inputMode; + return editor.inputMode; + }; + editor.onHistoryDraftRestore = (state: unknown) => { + editor.setInputMode(state as 'prompt' | 'bash'); + browseMode = null; + }; + + editor.onNonEscapeInput = () => { + this.clearPendingUndoEsc(); + }; + editor.onCtrlC = () => { if (host.cancelInFlight !== undefined) { const cancel = host.cancelInFlight; @@ -69,12 +131,27 @@ export class EditorKeyboardController { if (host.state.appState.isCompacting) { this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + this.cancelCurrentCompaction(); return; } + if (host.btwPanelController.cancelRunning()) { + this.clearPendingExit(); + return; + } + if (host.btwPanelController.closeOrCancel()) { + this.clearPendingExit(); + return; + } + if (host.state.appState.streamingPhase !== 'idle') { this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + this.cancelCurrentStream(); return; } @@ -104,15 +181,30 @@ export class EditorKeyboardController { if (this.pendingExit) this.clearPendingExit(); if (host.state.activeDialog === 'session-picker') { host.hideSessionPicker(); + this.clearPendingUndoEsc(); return; } if (host.state.appState.isCompacting) { this.cancelCurrentCompaction(); + this.clearPendingUndoEsc(); + return; + } + if (host.btwPanelController.closeOrCancel()) { + this.clearPendingUndoEsc(); return; } if (host.state.appState.streamingPhase !== 'idle') { this.cancelCurrentStream(); + this.clearPendingUndoEsc(); + return; } + // Idle: a second Esc within the double-tap window opens the undo selector. + if (this.pendingUndoEsc !== null) { + this.clearPendingUndoEsc(); + host.openUndoSelector(); + return; + } + this.armPendingUndoEsc(); }; editor.onShiftTab = () => { @@ -126,6 +218,10 @@ export class EditorKeyboardController { host.handlePlanToggle(next); }; + editor.onInputModeChange = (mode) => { + host.handleInputModeChange(mode); + }; + editor.onOpenExternalEditor = () => { host.track('shortcut_editor'); void this.openExternalEditor(); @@ -136,40 +232,96 @@ export class EditorKeyboardController { host.toggleToolOutputExpansion(); }; - editor.onTogglePlanExpand = () => host.togglePlanExpansion(); + editor.onToggleTodoExpand = (): boolean => { + if (!host.state.todoPanel.hasOverflow()) return false; + // Disarm a pending double-press exit confirmation so expanding the + // todo list in between two Ctrl-C presses does not accidentally exit. + this.clearPendingExit(); + host.track('shortcut_todo_expand'); + host.toggleTodoPanelExpansion(); + return true; + }; editor.onCtrlS = () => { - if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) return; + if ( + host.state.appState.streamingPhase === 'idle' || + host.state.appState.streamingPhase === 'shell' || + host.state.appState.isCompacting + ) + return; const text = editor.getText().trim(); - const queuedTexts = host.state.queuedMessages.map((m) => m.text); - host.clearQueuedMessages(); + const editorIsBash = editor.inputMode === 'bash'; - const parts: string[] = []; - for (const q of queuedTexts) { - const trimmed = q.trim(); - if (trimmed.length > 0) parts.push(trimmed); + // Bash commands (`! …`) are not steerable: keep them queued so they run + // after the current task instead of being injected into the turn as text. + const queued = host.state.queuedMessages; + const steerable = queued.filter((m) => m.mode !== 'bash'); + + const items: SteerInputItem[] = []; + for (const m of steerable) { + const trimmed = m.text.trim(); + if (trimmed.length > 0) { + // Queued items carry the parts extracted when they were submitted + // (and were already capability-validated then). + items.push({ text: trimmed, parts: m.parts, imageAttachmentIds: m.imageAttachmentIds }); + } + } + let editorExtraction: ReturnType<typeof extractMediaAttachments> | undefined; + if (!editorIsBash && text.length > 0) { + try { + editorExtraction = extractMediaAttachments(text, this.imageStore); + } catch (error) { + // Cache copy failed (e.g. the pasted video's source vanished) — + // leave the queue and the editor draft untouched. + host.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + items.push({ + text, + parts: editorExtraction.hasMedia ? editorExtraction.parts : undefined, + imageAttachmentIds: + editorExtraction.imageAttachmentIds.length > 0 + ? editorExtraction.imageAttachmentIds + : undefined, + }); } - if (text.length > 0) parts.push(text); - if (parts.length > 0) { - editor.setText(''); + if (items.length > 0) { + // The editor draft is fresh input: gate it on the model's media + // capabilities before splicing the queue, so a rejection leaves the + // queue and the draft untouched. + if ( + editorExtraction !== undefined && + !host.validateMediaCapabilities(editorExtraction) + ) { + return; + } + host.state.queuedMessages = queued.filter((m) => m.mode === 'bash'); + if (!editorIsBash) editor.setText(''); const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { host.showError(LLM_NOT_SET_MESSAGE); } else { - host.steerMessage(session, parts); + host.steerMessage(session, items); } } host.updateQueueDisplay(); host.state.ui.requestRender(); }; - editor.onUndo = () => { - host.track('undo'); + editor.onCtrlB = (): boolean => { + // Shell command execution is treated as a streaming phase ('shell'), so + // this gate already covers it; only idle + not-compacting falls through. + if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) { + return false; + } + host.track('shortcut_background_task'); + host.detachCurrentForegroundTask(); + return true; }; - editor.onInsertNewline = () => { - host.track('shortcut_newline'); + editor.onUndo = () => { + host.track('undo'); }; editor.onTextPaste = () => { @@ -177,10 +329,18 @@ export class EditorKeyboardController { }; editor.onUpArrowEmpty = () => { + if (host.btwPanelController.scroll('up')) return true; if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false; const recalled = host.recallLastQueued(); if (recalled !== undefined) { - editor.setText(recalled); + editor.setText(recalled.text); + // Restore the queued item's mode so a recalled `!` command runs as a + // shell command again instead of being submitted as a normal prompt. + const mode = recalled.mode ?? 'prompt'; + if (editor.inputMode !== mode) { + editor.inputMode = mode; + editor.onInputModeChange?.(mode); + } host.updateQueueDisplay(); host.state.ui.requestRender(); return true; @@ -188,6 +348,8 @@ export class EditorKeyboardController { return false; }; + editor.onDownArrowEmpty = () => host.btwPanelController.scroll('down'); + editor.onPasteImage = async () => this.handleClipboardImagePaste(); } @@ -198,6 +360,27 @@ export class EditorKeyboardController { this.pendingExit = null; } + dispose(): void { + this.clearPendingExit(); + this.clearPendingUndoEsc(); + } + + private armPendingUndoEsc(): void { + this.clearPendingUndoEsc(); + const timer = setTimeout(() => { + if (this.pendingUndoEsc?.timer === timer) { + this.pendingUndoEsc = null; + } + }, DOUBLE_ESC_WINDOW_MS); + this.pendingUndoEsc = { timer }; + } + + private clearPendingUndoEsc(): void { + if (!this.pendingUndoEsc) return; + clearTimeout(this.pendingUndoEsc.timer); + this.pendingUndoEsc = null; + } + private armPendingExit(kind: 'ctrl-c' | 'ctrl-d', hint: string): void { this.clearPendingExit(); this.host.state.footer.setTransientHint(hint); @@ -213,7 +396,17 @@ export class EditorKeyboardController { this.host.state.ui.requestRender(); } + private clearEditorTextIfPresent(): boolean { + const editor = this.host.state.editor; + if (editor.getText().length === 0) return false; + editor.setText(''); + return true; + } + private cancelCurrentStream(): void { + // Cancel any running `!` shell command (treated as a streaming phase) in + // addition to the agent turn, so Esc / Ctrl+C interrupts it too. + this.host.cancelRunningShellCommand(); void this.host.session?.cancel(); } @@ -249,7 +442,56 @@ export class EditorKeyboardController { const meta = parseImageMeta(media.bytes); if (meta === null) return false; - const attachment = this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height); + // Compress at ingestion — a pure data step while building the attachment, so + // the stored bytes, the inline thumbnail, the `[image #N (W×H)]` placeholder, + // and the submitted image all agree, and the agent core only ever sees an + // already-compressed image. Best effort: originals pass through on failure. + // When compression changed the bytes, the original is persisted (into the + // session's media-originals dir when known, else the temp-dir fallback) + // and recorded on the attachment, so submit-time expansion can announce + // the compression and point the model at the full-fidelity copy. + // The edge cap comes from the host harness's [image] config (resolved per + // paste so a config reload applies immediately); hosts without a harness + // use the env/built-in default. + const compressed = await compressImageForModel(media.bytes, meta.mime, { + maxEdge: this.host.harness?.imageLimits?.maxEdgePx(), + telemetry: { + client: { + track: (event, properties) => + this.host.track(event, properties === undefined ? undefined : { ...properties }), + }, + source: 'tui_paste', + }, + }); + const sessionDir = this.host.session?.summary?.sessionDir; + // Dimensions come from the compression result, not parseImageMeta: the + // compressor reports display space (EXIF orientation applied) — the space + // the sent image, the caption, and ReadMediaFile region readback share — + // while parseImageMeta reads the raw pre-rotation header. + const attachment = compressed.changed + ? this.imageStore.addImage( + compressed.data, + compressed.mimeType, + compressed.width, + compressed.height, + { + path: await persistOriginalImage( + media.bytes, + meta.mime, + sessionDir === undefined ? {} : { dir: sessionMediaOriginalsDir(sessionDir) }, + ), + width: compressed.originalWidth, + height: compressed.originalHeight, + byteLength: media.bytes.length, + mime: meta.mime, + }, + ) + : this.imageStore.addImage( + media.bytes, + meta.mime, + compressed.width || meta.width, + compressed.height || meta.height, + ); this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); this.host.state.ui.requestRender(); this.host.track('shortcut_paste', { kind: 'image' }); diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 27b13a131..82257c86c 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -1,24 +1,23 @@ -import chalk from 'chalk'; +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; import type { AgentStatusUpdatedEvent, AssistantDeltaEvent, BackgroundTaskInfo, BackgroundTaskStartedEvent, BackgroundTaskTerminatedEvent, - BackgroundTaskUpdatedEvent, CompactionCancelledEvent, CompactionCompletedEvent, CompactionStartedEvent, CronFiredEvent, ErrorEvent, Event, + GoalChange, + GoalUpdatedEvent, HookResultEvent, Session, SessionMetaUpdatedEvent, SkillActivatedEvent, - SubagentCompletedEvent, - SubagentFailedEvent, - SubagentSpawnedEvent, + PluginCommandActivatedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -33,21 +32,33 @@ import type { } from '@moonshot-ai/kimi-code-sdk'; import { MoonLoader } from '../components/chrome/moon-loader'; +import { buildGoalMarker } from '../components/messages/goal-markers'; import { StatusMessageComponent } from '../components/messages/status-message'; import { - MAIN_AGENT_ID, + SwarmModeMarkerComponent, + type SwarmModeMarkerState, +} from '../components/messages/swarm-markers'; +import { OAUTH_LOGIN_REQUIRED_CODE, OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, } from '../constant/kimi-tui'; +import { buildGoalCompletionMessage } from '../utils/goal-completion'; import { argsRecord, + formatErrorPayload, + formatErrorMessage, isTodoItemShape, serializeToolResultOutput, stringValue, } from '../utils/event-payload'; -import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; +import { + readGoalQueue, + removeGoalQueueItem, + restoreGoalQueueItem, + type UpcomingGoal, +} from '../goal-queue-store'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; -import { formatHookResultMarkdown, formatHookResultPlain } from '../utils/hook-result-format'; +import { formatHookResultMarkdown } from '../utils/hook-result-format'; import { McpOAuthAuthorizationUrlOpener } from '../utils/mcp-oauth'; import { formatMcpStartupStatusSummary, @@ -55,16 +66,18 @@ import { type McpServerStatusSnapshot, selectMcpStartupStatusRows, } from '../utils/mcp-server-status'; -import { openUrl } from '../utils/open-url'; -import { setProcessTitle } from '../utils/proctitle'; +import { openUrl } from '#/utils/open-url'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; +import type { BtwPanelController } from './btw-panel'; import type { StreamingUIController } from './streaming-ui'; import type { TasksBrowserController } from './tasks-browser'; +import { SubAgentEventHandler } from './subagent-event-handler'; import type { AppState, - BackgroundAgentMetadata, LivePaneState, QueuedMessage, ToolCallBlockData, @@ -72,6 +85,7 @@ import type { TranscriptEntry, } from '../types'; import type { TUIState } from '../tui-state'; +import { createGoal as startGoalCommand } from '../commands/goal'; export interface SessionEventHost { state: TUIState; @@ -85,36 +99,84 @@ export interface SessionEventHost { patchLivePane(patch: Partial<LivePaneState>): void; resetLivePane(): void; showError(msg: string): void; - showStatus(msg: string, color?: string): void; + showStatus(msg: string, color?: ColorToken): void; showNotice(title: string, detail?: string): void; + updateActivityPane(): void; + track(event: string, props?: Record<string, unknown>): void; + mountEditorReplacement(panel: Component & Focusable): void; + restoreEditor(): void; + restoreInputText(text: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void; + handleShellStarted(event: { commandId: string; taskId: string }): void; + sendNormalUserInput(text: string): void; + updateTerminalTitle(): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; shiftQueuedMessage(): QueuedMessage | undefined; + readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; } export class SessionEventHandler { - constructor(private readonly host: SessionEventHost) {} + readonly subAgentEventHandler: SubAgentEventHandler; + + constructor(private readonly host: SessionEventHost) { + this.subAgentEventHandler = new SubAgentEventHandler(host, { + backgroundTasks: this.backgroundTasks, + backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal, + syncBackgroundAgentBadge: () => { + this.syncBackgroundTaskBadge(); + }, + }); + } // Runtime state – owned by this handler, reset between sessions. - backgroundAgentMetadata: Map<string, BackgroundAgentMetadata> = new Map(); backgroundTasks: Map<string, BackgroundTaskInfo> = new Map(); backgroundTaskTranscriptedTerminal: Set<string> = new Set(); - subagentInfo: Map<string, { parentToolCallId: string; name: string }> = new Map(); + renderedSkillActivationIds: Set<string> = new Set(); + renderedPluginCommandActivationIds: Set<string> = new Set(); renderedMcpServerStatusKeys: Map<string, string> = new Map(); mcpServerStatusSpinners: Map<string, MoonLoader> = new Map(); + mcpServers: Map<string, McpServerStatusSnapshot> = new Map(); + private goalCompletionAwaitingClear = false; + private goalCompletionTurnEnded = false; + private currentTurnHasAssistantText = false; + private pendingModelBlockedFallback: GoalChange | undefined; + private queuedGoalPromotionPending = false; + private queuedGoalPromotionInFlight = false; + private queuedGoalPromotionTimer: ReturnType<typeof setTimeout> | undefined; resetRuntimeState(): void { - this.backgroundAgentMetadata.clear(); this.backgroundTasks.clear(); this.backgroundTaskTranscriptedTerminal.clear(); - this.subagentInfo.clear(); + this.subAgentEventHandler.resetRuntimeState(); this.renderedSkillActivationIds.clear(); + this.renderedPluginCommandActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); + this.mcpServers.clear(); + this.goalCompletionAwaitingClear = false; + this.goalCompletionTurnEnded = false; + this.currentTurnHasAssistantText = false; + this.pendingModelBlockedFallback = undefined; + this.queuedGoalPromotionPending = false; + this.queuedGoalPromotionInFlight = false; + this.clearQueuedGoalPromotionTimer(); this.stopAllMcpServerStatusSpinners(); } + clearAgentSwarmProgress(): void { + this.subAgentEventHandler.clearAgentSwarmProgress(); + } + + hasActiveAgentSwarmToolCall(): boolean { + return this.subAgentEventHandler.hasActiveAgentSwarmToolCall(); + } + + syncAgentSwarmActivitySpinner(spinner: MoonLoader | undefined): void { + this.subAgentEventHandler.syncAgentSwarmActivitySpinner(spinner); + } + startSubscription(): void { const { host } = this; const session = host.requireSession(); @@ -155,6 +217,10 @@ export class SessionEventHandler { this.renderMcpServerStatus(server); } + this.mcpServers.clear(); + for (const server of servers) { + this.mcpServers.set(server.name, server); + } const hidden: McpServerStatusSnapshot[] = []; for (const server of servers) { if (visibleNames.has(server.name)) continue; @@ -162,16 +228,12 @@ export class SessionEventHandler { this.renderedMcpServerStatusKeys.set(server.name, mcpServerStatusKey(server)); hidden.push(server); } - if (hidden.length > 0) { - host.showStatus( - formatMcpStartupStatusSummary(hidden, visible.length), - host.state.theme.colors.textMuted, - ); - } + const summary = formatMcpStartupStatusSummary(servers); + host.setAppState({ mcpServersSummary: summary || null }); } handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { - if (this.routeSubagentEvent(event)) return; + if (this.subAgentEventHandler.routeChildAgentEvent(event)) return; if ('turnId' in event && event.turnId !== undefined) { this.host.streamingUI.setTurnId(String(event.turnId)); @@ -185,6 +247,8 @@ export class SessionEventHandler { case 'turn.step.completed': this.handleStepCompleted(event); break; case 'turn.step.retrying': break; case 'tool.progress': this.handleToolProgress(event); break; + case 'shell.output': this.host.handleShellOutput(event); break; + case 'shell.started': this.host.handleShellStarted(event); break; case 'assistant.delta': this.handleAssistantDelta(event); break; case 'hook.result': this.handleHookResult(event); break; case 'thinking.delta': this.handleThinkingDelta(event); break; @@ -193,18 +257,22 @@ export class SessionEventHandler { case 'tool.result': this.handleToolResult(event); break; case 'agent.status.updated': this.handleStatusUpdate(event); break; case 'session.meta.updated': this.handleSessionMetaChanged(event); break; + case 'goal.updated': this.handleGoalUpdated(event); break; case 'skill.activated': this.handleSkillActivated(event); break; + case 'plugin_command.activated': this.handlePluginCommandActivated(event); break; case 'error': this.handleSessionError(event); break; case 'warning': this.handleSessionWarning(event); break; case 'compaction.started': this.handleCompactionBegin(event); break; case 'compaction.completed': this.handleCompactionEnd(event, sendQueued); break; case 'compaction.blocked': break; case 'compaction.cancelled': this.handleCompactionCancel(event, sendQueued); break; - case 'subagent.spawned': this.handleSubagentSpawned(event); break; - case 'subagent.completed': this.handleSubagentCompleted(event); break; - case 'subagent.failed': this.handleSubagentFailed(event); break; + case 'subagent.spawned': + case 'subagent.started': + case 'subagent.suspended': + case 'subagent.completed': + case 'subagent.failed': + this.subAgentEventHandler.handleLifecycleEvent(event); break; case 'background.task.started': - case 'background.task.updated': case 'background.task.terminated': this.handleBackgroundTaskEvent(event); break; case 'cron.fired': this.handleCronFired(event); break; @@ -225,91 +293,10 @@ export class SessionEventHandler { // Private handlers // --------------------------------------------------------------------------- - private routeSubagentEvent(event: Event): boolean { - const subagentId = event.agentId; - if (subagentId === MAIN_AGENT_ID) return false; - - const { streamingUI } = this.host; - const info = this.subagentInfo.get(subagentId); - if (info === undefined || info.parentToolCallId.length === 0) return true; - const { parentToolCallId } = info; - const sourceName = info.name; - const toolCall = streamingUI.getToolComponent(parentToolCallId); - if (toolCall === undefined) return true; - toolCall.setSubagentMeta(subagentId, sourceName); - - switch (event.type) { - case 'hook.result': - toolCall.appendSubagentText(formatHookResultPlain(event), 'text'); - return true; - case 'assistant.delta': - toolCall.appendSubagentText(event.delta, 'text'); - return true; - case 'thinking.delta': - toolCall.appendSubagentText(event.delta, 'thinking'); - return true; - case 'tool.call.started': - toolCall.appendSubToolCall({ - id: `${subagentId}:${event.toolCallId}`, - name: event.name, - args: argsRecord(event.args), - }); - return true; - case 'tool.call.delta': - toolCall.appendSubToolCallDelta({ - id: `${subagentId}:${event.toolCallId}`, - name: event.name, - argumentsPart: event.argumentsPart ?? null, - }); - return true; - case 'tool.result': - toolCall.finishSubToolCall({ - tool_call_id: `${subagentId}:${event.toolCallId}`, - output: serializeToolResultOutput(event.output), - is_error: event.isError, - }); - return true; - case 'agent.status.updated': { - const usageObj = event.usage; - const totalUsage = usageObj?.total ?? usageObj?.currentTurn; - toolCall.updateSubagentMetrics({ - contextTokens: event.contextTokens, - usage: totalUsage, - }); - return true; - } - case 'background.task.started': - case 'background.task.updated': - case 'background.task.terminated': - case 'compaction.blocked': - case 'compaction.cancelled': - case 'compaction.completed': - case 'compaction.started': - case 'cron.fired': - case 'error': - case 'warning': - case 'session.meta.updated': - case 'skill.activated': - case 'subagent.completed': - case 'subagent.failed': - case 'subagent.spawned': - case 'tool.progress': - case 'tool.list.updated': - case 'mcp.server.status': - case 'turn.ended': - case 'turn.started': - case 'turn.step.completed': - case 'turn.step.interrupted': - case 'turn.step.retrying': - case 'turn.step.started': - return true; - default: - return true; - } - } - private handleTurnBegin(_event: TurnStartedEvent): void { void _event; + this.currentTurnHasAssistantText = false; + this.clearAgentSwarmProgress(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.setStep(0); this.host.patchLivePane({ @@ -341,15 +328,27 @@ export class SessionEventHandler { }); } - private handleTurnEnd(_event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { - void _event; + private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { this.host.streamingUI.flushNow(); + if (event.reason === 'cancelled') { + this.markActiveAgentSwarmsCancelled(); + } + if (event.reason === 'failed' && event.error?.code === 'provider.filtered') { + this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error'); + } + if (event.reason === 'blocked') { + this.host.showStatus('Turn stopped: prompt hook blocked the request.', 'error'); + } const todos = this.host.state.todoPanel.getTodos(); if (todos.length > 0 && todos.every((t) => t.status === 'done')) { this.host.streamingUI.setTodoList([]); } this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeTurn(sendQueued); + this.renderPendingModelBlockedFallback(); + this.currentTurnHasAssistantText = false; + this.goalCompletionTurnEnded = true; + this.scheduleQueuedGoalPromotion(); } private handleStepBegin(event: TurnStepStartedEvent): void { @@ -371,6 +370,15 @@ export class SessionEventHandler { private handleStepCompleted(event: TurnStepCompletedEvent): void { this.host.streamingUI.flushNow(); this.maybeShowDebugTiming(event); + + if (event.providerFinishReason === 'filtered') { + this.host.showNotice( + 'Provider safety policy blocked the response.', + `The model output was filtered (${event.rawFinishReason ?? 'content_filter'}).`, + ); + return; + } + if (event.finishReason !== 'max_tokens') return; const truncatedCount = this.host.streamingUI.markStepTruncated( @@ -391,14 +399,26 @@ export class SessionEventHandler { private maybeShowDebugTiming(event: TurnStepCompletedEvent): void { if (process.env['KIMI_CODE_DEBUG'] !== '1') return; const text = formatStepDebugTiming(event); - if (text !== undefined) this.host.showStatus(text); + if (text === undefined) return; + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + turnId: String(event.turnId), + renderMode: 'plain', + content: text, + }); + } + + private markActiveAgentSwarmsCancelled(): void { + this.subAgentEventHandler.markActiveAgentSwarmsCancelled(); } private isAnthropicSessionActive(): boolean { const { state } = this.host; - const providerKey = state.appState.availableModels[state.appState.model]?.provider; - if (providerKey === undefined) return false; - return state.appState.availableProviders[providerKey]?.type === 'anthropic'; + const model = state.appState.availableModels[state.appState.model]; + if (model === undefined) return false; + if (model.protocol === 'anthropic') return true; + return state.appState.availableProviders[model.provider]?.type === 'anthropic'; } private handleStepInterrupted(event: TurnStepInterruptedEvent): void { @@ -408,7 +428,12 @@ export class SessionEventHandler { const reason = event.reason; if (reason === 'error') return; if (reason === 'aborted' || reason === undefined || reason === '') { - this.host.showStatus('Interrupted by user', this.host.state.theme.colors.error); + this.markActiveAgentSwarmsCancelled(); + if (event.message === undefined || event.message === '') { + this.host.showStatus('Interrupted by user', 'error'); + } else { + this.host.showError(event.message); + } return; } this.host.showError( @@ -420,6 +445,14 @@ export class SessionEventHandler { private handleThinkingDelta(event: ThinkingDeltaEvent): void { const { state, streamingUI } = this.host; + // Encrypted / redacted reasoning (e.g. Kimi over the Anthropic-compatible + // protocol) streams thinking deltas whose visible text is empty — only an + // opaque signature rides along. Such deltas carry nothing to render, so + // switching into the `thinking` pane mode here would stop the "waiting" + // moon spinner while no ThinkingComponent is ever created (it needs visible + // text), leaving a blank, spinner-less gap until the first real text/tool + // token arrives. Keep the moon up until actual thinking text shows up. + if (event.delta.length === 0 && !streamingUI.hasThinkingDraft()) return; streamingUI.appendThinkingDelta(event.delta); this.host.patchLivePane({ mode: 'idle' }); if (state.appState.streamingPhase !== 'thinking') { @@ -434,6 +467,10 @@ export class SessionEventHandler { streamingUI.flushThinkingToTranscript('idle'); } + if (event.delta.trim().length > 0) { + this.currentTurnHasAssistantText = true; + this.pendingModelBlockedFallback = undefined; + } streamingUI.appendAssistantDelta(event.delta); this.host.patchLivePane({ @@ -453,6 +490,10 @@ export class SessionEventHandler { this.host.streamingUI.flushThinkingToTranscript('idle'); } this.host.streamingUI.finalizeAssistantStream(); + if (event.content.trim().length > 0) { + this.currentTurnHasAssistantText = true; + this.pendingModelBlockedFallback = undefined; + } this.host.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'assistant', @@ -481,6 +522,9 @@ export class SessionEventHandler { turnId, }; streamingUI.registerToolCall(toolCall); + if (event.name === 'AgentSwarm') { + this.subAgentEventHandler.handleAgentSwarmToolCallStarted(event.toolCallId, toolCall.args); + } this.host.patchLivePane({ mode: 'tool', pendingApproval: null, @@ -492,6 +536,15 @@ export class SessionEventHandler { if (event.toolCallId.length === 0) return; const { state, streamingUI } = this.host; streamingUI.accumulateToolCallDelta(event.toolCallId, event.name, event.argumentsPart); + const preview = streamingUI.getStreamingToolCallPreview(event.toolCallId); + if ( + preview !== undefined && + (preview.name === 'AgentSwarm' || this.subAgentEventHandler.hasAgentSwarmProgress(event.toolCallId)) + ) { + this.subAgentEventHandler.handleAgentSwarmToolCallDelta(event.toolCallId, preview.args, { + streamingArguments: preview.argumentsText, + }); + } this.host.patchLivePane({ mode: 'tool', @@ -505,12 +558,17 @@ export class SessionEventHandler { } private handleToolProgress(event: ToolProgressEvent): void { - if (event.update.kind !== 'status') return; const text = event.update.text; if (text === undefined || text.length === 0) return; const tc = this.host.streamingUI.getToolComponent(event.toolCallId); if (tc === undefined) return; - tc.appendProgress(text); + if (event.update.kind === 'status') { + tc.appendProgress(text); + return; + } + if (event.update.kind === 'stdout' || event.update.kind === 'stderr') { + tc.appendLiveOutput(text); + } } private handleToolResult(event: ToolResultEvent): void { @@ -523,6 +581,11 @@ export class SessionEventHandler { synthetic: event.synthetic, }; const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData); + this.subAgentEventHandler.handleAgentSwarmToolResult( + event.toolCallId, + resultData, + event.isError === true, + ); if (matchedCall !== undefined && matchedCall.name === 'TodoList' && !event.isError) { const rawTodos = (matchedCall.args as { todos?: unknown }).todos; if (Array.isArray(rawTodos)) { @@ -538,23 +601,253 @@ export class SessionEventHandler { } private handleStatusUpdate(event: AgentStatusUpdatedEvent): void { + const shouldRenderSwarmEnded = + event.swarmMode === false && + this.host.state.appState.swarmMode && + this.host.state.swarmModeEntry === 'task'; const patch: Partial<AppState> = {}; if (event.contextUsage !== undefined) patch.contextUsage = event.contextUsage; if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens; if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; if (event.planMode !== undefined) patch.planMode = event.planMode; + if (event.swarmMode !== undefined) patch.swarmMode = event.swarmMode; if (event.permission !== undefined) { patch.permissionMode = event.permission; } if (event.model !== undefined) patch.model = event.model; + if (event.thinkingEffort !== undefined) patch.thinkingEffort = event.thinkingEffort; if (Object.keys(patch).length > 0) this.host.setAppState(patch); + if (event.swarmMode === false) { + this.host.state.swarmModeEntry = undefined; + if (shouldRenderSwarmEnded) { + this.renderSwarmModeMarker('ended'); + } + } + } + + private renderSwarmModeMarker(state: SwarmModeMarkerState): void { + this.host.state.transcriptContainer.addChild( + new SwarmModeMarkerComponent(state), + ); + this.host.state.ui.requestRender(); + } + + private handleGoalUpdated(event: GoalUpdatedEvent): void { + this.host.setAppState({ goal: event.snapshot }); + if (event.snapshot === null && this.goalCompletionAwaitingClear) { + this.goalCompletionAwaitingClear = false; + this.queuedGoalPromotionPending = true; + this.scheduleQueuedGoalPromotion(); + } + if (event.snapshot === null) { + this.pendingModelBlockedFallback = undefined; + } + const change = event.change; + if (change === undefined) return; + const { state } = this.host; + + // Completion -> the box disappears (snapshot cleared on the follow-up null + // update) and a deterministic completion message lands in the transcript. + // Resume renders the same text from the durable goal completion replay + // record, so live and replayed completion cards stay identical. + if (change.kind === 'completion' && event.snapshot !== null) { + this.pendingModelBlockedFallback = undefined; + this.goalCompletionAwaitingClear = true; + this.goalCompletionTurnEnded = false; + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'assistant', + renderMode: 'markdown', + content: buildGoalCompletionMessage(event.snapshot), + }); + state.ui.requestRender(); + return; + } + + // Lifecycle change (pause / resume / blocked) -> a low-profile, + // ctrl+o-expandable marker. + if (change.kind === 'lifecycle' && change.status === 'blocked') { + void this.notifyQueuedGoalWaitingOnBlocked(); + if (change.actor === 'model' || change.reason === undefined) { + this.pendingModelBlockedFallback = this.currentTurnHasAssistantText + ? undefined + : change; + return; + } + this.pendingModelBlockedFallback = undefined; + } else if (change.kind === 'lifecycle') { + this.pendingModelBlockedFallback = undefined; + } + const marker = buildGoalMarker(change, state.toolOutputExpanded, change.actor); + if (marker !== null) { + state.transcriptContainer.addChild(marker); + state.ui.requestRender(); + } + } + + private renderPendingModelBlockedFallback(): void { + const change = this.pendingModelBlockedFallback; + if (change === undefined) return; + this.pendingModelBlockedFallback = undefined; + const { state } = this.host; + const marker = buildGoalMarker(change, state.toolOutputExpanded, 'model'); + if (marker !== null) { + state.transcriptContainer.addChild(marker); + state.ui.requestRender(); + } + } + + private scheduleQueuedGoalPromotion(): void { + if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; + if (this.queuedGoalPromotionInFlight) return; + if (this.queuedGoalPromotionTimer !== undefined) return; + this.queuedGoalPromotionTimer = setTimeout(() => { + this.queuedGoalPromotionTimer = undefined; + if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; + if (this.queuedGoalPromotionInFlight) return; + if (!this.isReadyForQueuedGoalPromotion()) { + return; + } + this.queuedGoalPromotionInFlight = true; + void this.promoteNextQueuedGoal() + .then((complete) => { + if (complete) { + this.queuedGoalPromotionPending = false; + this.goalCompletionTurnEnded = false; + return; + } + this.goalCompletionTurnEnded = false; + }) + .finally(() => { + this.queuedGoalPromotionInFlight = false; + this.scheduleQueuedGoalPromotion(); + }); + }, 0); + } + + private clearQueuedGoalPromotionTimer(): void { + if (this.queuedGoalPromotionTimer === undefined) return; + clearTimeout(this.queuedGoalPromotionTimer); + this.queuedGoalPromotionTimer = undefined; + } + + requestQueuedGoalPromotion(): void { + this.queuedGoalPromotionPending = true; + this.goalCompletionTurnEnded = true; + this.scheduleQueuedGoalPromotion(); + } + + retryQueuedGoalPromotion(): void { + this.scheduleQueuedGoalPromotion(); + } + + private isReadyForQueuedGoalPromotion(session?: Session): boolean { + return ( + (session === undefined || this.host.session === session) && + !this.host.aborted && + this.host.state.appState.streamingPhase === 'idle' && + this.host.state.queuedMessages.length === 0 && + !this.host.state.queuedMessageDispatchPending + ); + } + + private async promoteNextQueuedGoal(): Promise<boolean> { + const { host } = this; + const session = host.session; + if (session === undefined || host.aborted) return true; + + let queue; + try { + queue = await readGoalQueue(session); + } catch (error) { + host.showError(`Failed to read upcoming goals: ${formatErrorMessage(error)}`); + return false; + } + if (host.session !== session || host.aborted) return true; + + const next = queue.goals[0]; + if (next === undefined) return true; + + if (!this.isReadyForQueuedGoalPromotion(session)) return false; + + const started = await startGoalCommand( + host, + { kind: 'create', objective: next.objective, replace: false }, + next.objective, + { + beforeSend: async () => { + if (!this.isReadyForQueuedGoalPromotion(session)) { + await this.cancelStartedQueuedGoal(session); + return false; + } + try { + await removeGoalQueueItem(session, { goalId: next.id }); + } catch (error) { + host.showError( + `Queued goal started, but could not be removed from the queue: ${formatErrorMessage(error)}`, + ); + await this.cancelStartedQueuedGoal(session); + return false; + } + if (this.isReadyForQueuedGoalPromotion(session)) { + return true; + } + await this.restoreAndCancelStartedQueuedGoal(session, next); + return false; + }, + sendInput: (objective) => { + host.sendQueuedMessage(session, { text: objective }); + }, + }, + ); + return started || host.session !== session || host.aborted; + } + + private async restoreAndCancelStartedQueuedGoal( + session: Session, + goal: UpcomingGoal, + ): Promise<void> { + try { + await restoreGoalQueueItem(session, goal); + } catch (error) { + this.host.showError(`Queued goal could not be restored: ${formatErrorMessage(error)}`); + } + await this.cancelStartedQueuedGoal(session); + } + + private async cancelStartedQueuedGoal(session: Session): Promise<void> { + try { + await session.cancelGoal(); + } catch (error) { + this.host.showError(`Queued goal could not be cancelled: ${formatErrorMessage(error)}`); + } + } + + private async notifyQueuedGoalWaitingOnBlocked(): Promise<void> { + const { host } = this; + const session = host.session; + if (session === undefined || host.aborted) return; + + let hasQueuedGoal = false; + try { + const queue = await readGoalQueue(session); + hasQueuedGoal = queue.goals.length > 0; + } catch { + return; + } + if (!hasQueuedGoal || host.session !== session || host.aborted) return; + + host.showNotice( + 'Goal blocked.', + 'The next queued goal will start only after this goal is complete.', + ); } private handleSessionMetaChanged(event: SessionMetaUpdatedEvent): void { const title = event.title ?? stringValue(event.patch?.['title']); if (title !== undefined) { this.host.setAppState({ sessionTitle: title }); - setProcessTitle(title, this.host.state.appState.sessionId); + this.host.updateTerminalTitle(); } } @@ -566,46 +859,47 @@ export class SessionEventHandler { this.host.showError(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); return; } - this.host.showError(`[${event.code}] ${event.message}`); + this.host.showError(formatErrorPayload(event)); const sessionId = this.host.state.appState.sessionId; if (sessionId.length > 0) { - this.host.showStatus(errorReportHintLine(sessionId)); + this.host.showStatus(errorReportHintLine()); } } private handleSessionWarning(event: WarningEvent): void { - this.host.showStatus(`Warning: ${event.message}`, this.host.state.theme.colors.warning); + this.host.showStatus(`Warning: ${event.message}`, 'warning'); } private renderMcpServerStatus(server: McpServerStatusSnapshot): void { - const { state } = this.host; const key = mcpServerStatusKey(server); if (this.renderedMcpServerStatusKeys.get(server.name) === key) return; this.renderedMcpServerStatusKeys.set(server.name, key); + this.mcpServers.set(server.name, server); + const summary = formatMcpStartupStatusSummary([...this.mcpServers.values()]); + this.host.setAppState({ mcpServersSummary: summary || null }); - const colors = state.theme.colors; switch (server.status) { case 'connected': { const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`; - this.finalizeMcpServerStatusRow(server.name, message, colors.success); + this.finalizeMcpServerStatusRow(server.name, message, 'success'); return; } case 'failed': { const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`; - this.finalizeMcpServerStatusRow(server.name, message, colors.error); + this.finalizeMcpServerStatusRow(server.name, message, 'error'); return; } case 'needs-auth': { const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`; - this.finalizeMcpServerStatusRow(server.name, message, colors.warning); + this.finalizeMcpServerStatusRow(server.name, message, 'warning'); return; } case 'disabled': this.finalizeMcpServerStatusRow( server.name, `MCP server "${server.name}" disabled`, - colors.textMuted, + 'textMuted', ); return; case 'pending': @@ -622,14 +916,14 @@ export class SessionEventHandler { existing.setLabel(label); return; } - const tint = (s: string): string => chalk.hex(state.theme.colors.textMuted)(s); + const tint = (s: string): string => currentTheme.fg('textMuted', s); const spinner = new MoonLoader(state.ui, 'braille', tint, label); state.transcriptContainer.addChild(spinner); this.mcpServerStatusSpinners.set(name, spinner); state.ui.requestRender(); } - private finalizeMcpServerStatusRow(name: string, message: string, color: string): void { + private finalizeMcpServerStatusRow(name: string, message: string, color: ColorToken): void { const { state } = this.host; const spinner = this.mcpServerStatusSpinners.get(name); if (spinner === undefined) { @@ -637,7 +931,7 @@ export class SessionEventHandler { return; } spinner.stop(); - const status = new StatusMessageComponent(message, state.theme.colors, color); + const status = new StatusMessageComponent(message, color); const children = state.transcriptContainer.children; const idx = children.indexOf(spinner); if (idx >= 0) { @@ -662,6 +956,26 @@ export class SessionEventHandler { skillActivationId: event.activationId, skillName: event.skillName, skillArgs: event.skillArgs, + skillTrigger: event.trigger, + }); + } + + private handlePluginCommandActivated(event: PluginCommandActivatedEvent): void { + if (this.renderedPluginCommandActivationIds.has(event.activationId)) return; + this.renderedPluginCommandActivationIds.add(event.activationId); + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'plugin_command', + turnId: undefined, + renderMode: 'plain', + content: `/${event.pluginId}:${event.commandName}`, + pluginCommandData: { + activationId: event.activationId, + pluginId: event.pluginId, + commandName: event.commandName, + args: event.commandArgs, + trigger: event.trigger, + }, }); } @@ -679,7 +993,11 @@ export class SessionEventHandler { event: CompactionCompletedEvent, sendQueued: (item: QueuedMessage) => void, ): void { - this.host.streamingUI.endCompaction(event.result.tokensBefore, event.result.tokensAfter); + this.host.streamingUI.endCompaction( + event.result.tokensBefore, + event.result.tokensAfter, + event.result.summary, + ); this.finishCompaction(sendQueued); } @@ -694,14 +1012,18 @@ export class SessionEventHandler { private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { const hasActiveTurn = this.host.streamingUI.hasActiveTurn(); if (!hasActiveTurn) { + const next = this.host.shiftQueuedMessage(); + if (next !== undefined) { + this.host.state.queuedMessageDispatchPending = true; + } this.host.setAppState({ isCompacting: false, streamingPhase: 'idle', }); this.host.resetLivePane(); - const next = this.host.shiftQueuedMessage(); if (next !== undefined) { setTimeout(() => { + this.host.state.queuedMessageDispatchPending = false; sendQueued(next); }, 0); } @@ -710,171 +1032,12 @@ export class SessionEventHandler { } } - private handleSubagentSpawned(event: SubagentSpawnedEvent): void { - const { streamingUI } = this.host; - this.subagentInfo.set(event.subagentId, { - parentToolCallId: event.parentToolCallId, - name: event.subagentName, - }); - - if (event.runInBackground) { - const meta = this.buildBackgroundAgentMetadata(event); - this.backgroundAgentMetadata.set(event.subagentId, meta); - this.appendBackgroundAgentEntry('started', meta); - this.syncBackgroundAgentBadge(); - return; - } - - let tc = streamingUI.getToolComponent(event.parentToolCallId); - if (tc === undefined) { - const toolCall = streamingUI.getActiveToolCall(event.parentToolCallId); - if (toolCall !== undefined) { - streamingUI.onToolCallStart(toolCall); - tc = streamingUI.getToolComponent(event.parentToolCallId); - } - } - tc ??= this.createStandaloneSubagentToolCall(event); - if (tc === undefined) return; - tc.onSubagentSpawned({ - agentId: event.subagentId, - agentName: event.subagentName, - runInBackground: event.runInBackground, - }); - } - - private handleSubagentCompleted(event: SubagentCompletedEvent): void { - const { streamingUI } = this.host; - const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); - if (backgroundMeta !== undefined) { - this.backgroundAgentMetadata.delete(event.subagentId); - this.syncBackgroundAgentBadge(); - const taskId = this.findAgentTaskId(event.subagentId); - if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) { - return; - } - if (taskId !== undefined) { - this.backgroundTaskTranscriptedTerminal.add(taskId); - } - const extras = - event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; - this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); - return; - } - const tc = streamingUI.getToolComponent(event.parentToolCallId); - if (tc === undefined) return; - tc.onSubagentCompleted({ - contextTokens: event.contextTokens, - usage: event.usage, - resultSummary: event.resultSummary, - }); - streamingUI.removeToolComponentIfInactive(event.parentToolCallId); - } - - private handleSubagentFailed(event: SubagentFailedEvent): void { - const { streamingUI } = this.host; - const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); - if (backgroundMeta !== undefined) { - this.backgroundAgentMetadata.delete(event.subagentId); - this.syncBackgroundAgentBadge(); - // Push the real subagent error onto the parent Agent card too — - // `background.task.terminated` arrives separately (possibly later) - // with no error string and would only stamp the generic - // `Background agent failed`. The card and the separate transcript - // entry now share the same actual reason. - streamingUI.applyBackgroundTaskTerminalStatus({ - agentId: event.subagentId, - description: backgroundMeta.description ?? '', - status: 'failed', - errorText: event.error, - }); - const taskId = this.findAgentTaskId(event.subagentId); - if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) { - return; - } - if (taskId !== undefined) { - this.backgroundTaskTranscriptedTerminal.add(taskId); - } - this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); - return; - } - const tc = streamingUI.getToolComponent(event.parentToolCallId); - if (tc === undefined) return; - tc.onSubagentFailed({ error: event.error }); - streamingUI.removeToolComponentIfInactive(event.parentToolCallId); - } - - private createStandaloneSubagentToolCall(event: SubagentSpawnedEvent) { - const { streamingUI } = this.host; - const description = event.description ?? `Run ${event.subagentName} agent`; - const { turnId, step } = streamingUI.getTurnContext(); - const toolCall: ToolCallBlockData = { - id: event.parentToolCallId, - name: 'Agent', - args: { - description, - subagent_type: event.subagentName, - }, - description, - step, - turnId, - }; - streamingUI.onToolCallStart(toolCall); - return streamingUI.getToolComponent(event.parentToolCallId); - } - - private findAgentTaskId(subagentId: string): string | undefined { - const meta = this.backgroundAgentMetadata.get(subagentId); - const description = meta?.description ?? meta?.agentName; - if (description === undefined) return undefined; - let match: string | undefined; - for (const info of this.backgroundTasks.values()) { - if (!info.taskId.startsWith('agent-')) continue; - if (info.description !== description) continue; - if (match !== undefined) return undefined; - match = info.taskId; - } - return match; - } - - private buildBackgroundAgentMetadata(event: SubagentSpawnedEvent): BackgroundAgentMetadata { - const parent = this.host.streamingUI.getActiveToolCall(event.parentToolCallId); - const description = parent?.args['description'] ?? event.description; - return { - agentId: event.subagentId, - parentToolCallId: event.parentToolCallId, - agentName: event.subagentName, - description: typeof description === 'string' ? description : undefined, - }; - } - - private appendBackgroundAgentEntry( - phase: 'started' | 'completed' | 'failed', - meta: BackgroundAgentMetadata, - extras: { resultSummary?: string; error?: string } | undefined = undefined, - ): void { - const status = formatBackgroundAgentTranscript(phase, meta, extras); - const entry: TranscriptEntry = { - id: nextTranscriptId(), - kind: 'status', - turnId: this.host.streamingUI.getTurnContext().turnId, - renderMode: 'plain', - content: status.headline, - detail: status.detail, - backgroundAgentStatus: status, - }; - this.host.appendTranscriptEntry(entry); - } - - private syncBackgroundAgentBadge(): void { - this.syncBackgroundTaskBadge(); - } - // --------------------------------------------------------------------------- // Background task lifecycle // --------------------------------------------------------------------------- private handleBackgroundTaskEvent( - event: BackgroundTaskStartedEvent | BackgroundTaskUpdatedEvent | BackgroundTaskTerminatedEvent, + event: BackgroundTaskStartedEvent | BackgroundTaskTerminatedEvent, ): void { const { state } = this.host; const { info } = event; @@ -889,11 +1052,15 @@ export class SessionEventHandler { const isTerminal = info.status === 'completed' || info.status === 'failed' || + info.status === 'timed_out' || info.status === 'killed' || info.status === 'lost'; if (event.type === 'background.task.started') { - if (info.taskId.startsWith('agent-')) { + if (info.kind === 'agent') { + // A foreground subagent detached via Ctrl+B: flip its card to + // `◐ backgrounded` so it doesn't look like it completed. + this.host.streamingUI.markSubagentBackgrounded(info.agentId); this.syncBackgroundTaskBadge(); this.host.tasksBrowserController.repaint(); return; @@ -905,7 +1072,7 @@ export class SessionEventHandler { } if (event.type === 'background.task.terminated' && isTerminal) { - if (info.taskId.startsWith('agent-')) { + if (info.kind === 'agent') { // The Agent tool's spawn-success ToolResult is not an error, so the // parent toolCall card would otherwise render `✓ Completed` for any // terminated bg agent — including `lost` / `failed` / `killed`. @@ -917,7 +1084,7 @@ export class SessionEventHandler { }); } if (!this.backgroundTaskTranscriptedTerminal.has(info.taskId)) { - if (info.taskId.startsWith('bash-')) { + if (info.kind === 'process' || info.kind === 'question') { this.appendBackgroundTaskEntry(info); } this.backgroundTaskTranscriptedTerminal.add(info.taskId); @@ -955,12 +1122,13 @@ export class SessionEventHandler { if ( info.status === 'completed' || info.status === 'failed' || + info.status === 'timed_out' || info.status === 'killed' || info.status === 'lost' ) { continue; } - if (info.taskId.startsWith('agent-')) { + if (info.kind === 'agent') { agentTasks += 1; } else { bashTasks += 1; diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 27b76cebe..00ee5a64b 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -1,7 +1,7 @@ import type { AgentReplayRecord, - BackgroundTaskInfo, ContextMessage, + GoalChange, PermissionMode, PromptOrigin, ResumedAgentState, @@ -10,6 +10,7 @@ import type { } from '@moonshot-ai/kimi-code-sdk'; import { ToolCallComponent } from '../components/messages/tool-call'; +import { currentTheme } from '../theme'; import type { TodoItem } from '../components/chrome/todo-panel'; import type { AppState, @@ -20,6 +21,8 @@ import type { import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload'; import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; +import { buildGoalCompletionMessage } from '../utils/goal-completion'; +import { formatBashOutputForDisplay } from '../utils/shell-output'; import { appStateFromResumeAgent, backgroundOrigin, @@ -34,15 +37,21 @@ import { replayBackgroundProjection, replayEntry, skillActivationFromOrigin, + pluginCommandFromOrigin, toolCallFromReplayMessage, toolResultOutput, type ReplayRenderContext, type SkillActivationProjection, + type PluginCommandProjection, } from '../utils/message-replay'; import type { StreamingUIController } from './streaming-ui'; import type { SessionEventHandler } from './session-event-handler'; import type { TUIState } from '../tui-state'; +type GoalReplayRecord = Extract<AgentReplayRecord, { type: 'goal_updated' }>; +type CompactionReplayRecord = Extract<AgentReplayRecord, { type: 'compaction' }>; +type GoalReplayLifecycleChange = GoalChange & { readonly kind: 'lifecycle' }; + export interface SessionReplayHost { state: TUIState; readonly streamingUI: StreamingUIController; @@ -50,6 +59,23 @@ export interface SessionReplayHost { setAppState(patch: Partial<AppState>): void; showError(msg: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + mergeAllTurnSteps(): void; +} + +function extractBashTag( + text: string, + tag: 'bash-input' | 'bash-stdout' | 'bash-stderr', +): string | undefined { + const match = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text); + return match?.[1] === undefined ? undefined : unescapeBashXml(match[1]); +} + +function unescapeBashXml(text: string): string { + return text + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('&', '&'); } export class SessionReplayRenderer { @@ -67,6 +93,7 @@ export class SessionReplayRenderer { this.hydrateSnapshot(main); this.renderRecords(main); this.applyTerminalBackgroundAgentStatuses(main); + this.host.mergeAllTurnSteps(); return true; } catch (error) { const message = formatErrorMessage(error); @@ -116,12 +143,13 @@ export class SessionReplayRenderer { */ private applyTerminalBackgroundAgentStatuses(agent: ResumedAgentState): void { for (const info of agent.background) { - if (!info.taskId.startsWith('agent-')) continue; + if (info.kind !== 'agent') continue; if (!isTerminalBackgroundTask(info)) continue; const status = info.status; if ( status !== 'completed' && status !== 'failed' && + status !== 'timed_out' && status !== 'killed' && status !== 'lost' ) { @@ -138,10 +166,13 @@ export class SessionReplayRenderer { private hydrateBackgroundState(agent: ResumedAgentState): void { const { state, sessionEventHandler } = this.host; const projection = replayBackgroundProjection(agent.background); - sessionEventHandler.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); - sessionEventHandler.backgroundTasks = new Map<string, BackgroundTaskInfo>( - agent.background.map((info) => [info.taskId, info]), + sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata = new Map( + projection.backgroundAgentMetadata, ); + sessionEventHandler.backgroundTasks.clear(); + for (const info of agent.background) { + sessionEventHandler.backgroundTasks.set(info.taskId, info); + } sessionEventHandler.backgroundTaskTranscriptedTerminal.clear(); for (const info of agent.background) { if (isTerminalBackgroundTask(info)) { @@ -170,6 +201,12 @@ export class SessionReplayRenderer { case 'message': this.renderMessage(context, record.message); return; + case 'compaction': + this.renderCompaction(context, record); + return; + case 'goal_updated': + this.renderGoalReplayRecord(context, record); + return; case 'plan_updated': this.flushAssistant(context); if (!record.enabled && context.suppressNextPlanModeOffNotice) { @@ -234,6 +271,28 @@ export class SessionReplayRenderer { if (message.origin?.kind === 'injection') { return; } + if (message.origin?.kind === 'shell_command') { + // A `!` command, replayed from records. Unwrap the XML tags back into the + // same `$ cmd` + output view the live editor produced. (Must NOT fall into + // the `injection` branch above — that returns without rendering.) + this.flushAssistant(context); + const text = contentPartsToText(message.content); + if (message.origin.phase === 'input') { + const cmd = (extractBashTag(text, 'bash-input') ?? text).trim(); + this.advanceTurn(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'user', currentTheme.fg('shellMode', `$ ${cmd}`), 'plain', { + bullet: '', + }), + ); + } else { + const stdout = (extractBashTag(text, 'bash-stdout') ?? '').trim(); + const stderr = (extractBashTag(text, 'bash-stderr') ?? '').trim(); + const out = formatBashOutputForDisplay(stdout, stderr, message.origin.isError); + this.host.appendTranscriptEntry(replayEntry(context, 'status', out, 'plain')); + } + return; + } if (message.origin?.kind === 'cron_job') { this.renderCronJob(context, message); return; @@ -242,6 +301,19 @@ export class SessionReplayRenderer { this.renderCronMissed(context, message); return; } + if (isGoalForkClearedSystemReminder(message)) { + return; + } + const goalReminder = goalOutcomeReminderFromSystemMessage(message); + if (goalReminder !== null) { + if (goalReminder !== undefined) { + this.flushAssistant(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'assistant', goalReminder, 'markdown'), + ); + } + return; + } this.flushAssistant(context); const skill = skillActivationFromOrigin(message.origin); @@ -252,6 +324,14 @@ export class SessionReplayRenderer { } return; } + const pluginCommand = pluginCommandFromOrigin(message.origin); + if (pluginCommand !== undefined) { + this.renderPluginCommand(context, pluginCommand); + if (message.origin?.kind === 'plugin_command' && message.origin.trigger === 'user-slash') { + this.advanceTurn(context); + } + return; + } this.advanceTurn(context); this.host.appendTranscriptEntry( @@ -345,6 +425,95 @@ export class SessionReplayRenderer { skillActivationId: skill.activationId, skillName: skill.skillName, skillArgs: skill.skillArgs, + skillTrigger: skill.trigger, + }); + } + + private renderPluginCommand( + context: ReplayRenderContext, + command: PluginCommandProjection, + ): void { + const { sessionEventHandler } = this.host; + if (context.pluginCommandActivationIds.has(command.activationId)) return; + if (sessionEventHandler.renderedPluginCommandActivationIds.has(command.activationId)) return; + context.pluginCommandActivationIds.add(command.activationId); + sessionEventHandler.renderedPluginCommandActivationIds.add(command.activationId); + this.host.appendTranscriptEntry({ + ...replayEntry( + context, + 'plugin_command', + `/${command.pluginId}:${command.commandName}`, + 'plain', + ), + pluginCommandData: { + activationId: command.activationId, + pluginId: command.pluginId, + commandName: command.commandName, + args: command.commandArgs, + trigger: command.trigger, + }, + }); + } + + private renderCompaction(context: ReplayRenderContext, record: CompactionReplayRecord): void { + this.flushAssistant(context); + if (record.result === undefined) return; + if (record.result === 'cancelled') { + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', 'Compaction cancelled', 'plain'), + compactionData: { + result: 'cancelled', + instruction: record.instruction, + }, + }); + return; + } + + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', 'Compaction complete', 'plain'), + compactionData: { + summary: record.result.summary, + tokensBefore: record.result.tokensBefore, + tokensAfter: record.result.tokensAfter, + instruction: record.instruction, + }, + }); + } + + private renderGoalReplayRecord(context: ReplayRenderContext, record: GoalReplayRecord): void { + this.flushAssistant(context); + const { change } = record; + switch (change.kind) { + case 'created': + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'goal', 'Goal set', 'plain'), + goalData: { kind: 'created' }, + }); + return; + case 'completion': + this.host.appendTranscriptEntry( + replayEntry(context, 'assistant', buildGoalCompletionMessage(record.snapshot), 'markdown'), + ); + return; + case 'lifecycle': { + const lifecycleChange: GoalReplayLifecycleChange = { ...change, kind: 'lifecycle' }; + if (isResumeNormalizationGoalPause(lifecycleChange)) return; + if (isModelBlockedGoalLifecycle(lifecycleChange)) { + return; + } + this.appendGoalLifecycleReplayEntry(context, lifecycleChange); + return; + } + } + } + + private appendGoalLifecycleReplayEntry( + context: ReplayRenderContext, + change: GoalReplayLifecycleChange, + ): void { + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'goal', goalLifecycleReplayContent(change), 'plain'), + goalData: { kind: 'lifecycle', change }, }); } @@ -496,7 +665,7 @@ export class SessionReplayRenderer { ): void { const { sessionEventHandler } = this.host; const task = sessionEventHandler.backgroundTasks.get(origin.taskId); - if (task !== undefined && task.taskId.startsWith('bash-')) { + if (task !== undefined && task.kind !== 'agent') { const status = formatBackgroundTaskTranscript({ ...task, status: origin.status }); this.host.appendTranscriptEntry({ ...replayEntry(context, 'status', status.headline, 'plain'), @@ -526,16 +695,64 @@ export class SessionReplayRenderer { ...status, headline: status.headline.replace(' failed in background', ' stopped'), }; + } else if (origin.status === 'timed_out') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' timed out'), + }; } this.host.appendTranscriptEntry({ ...replayEntry(context, 'status', status.headline, 'plain'), detail: status.detail, backgroundAgentStatus: status, }); - sessionEventHandler.backgroundAgentMetadata.delete(meta.agentId); + sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.delete(meta.agentId); } } +const RESUME_NORMALIZATION_GOAL_PAUSE_REASONS = new Set([ + 'Paused after agent resume', + 'Paused after session resume', +]); + +function isResumeNormalizationGoalPause(change: GoalReplayLifecycleChange): boolean { + return ( + change.status === 'paused' && + change.reason !== undefined && + RESUME_NORMALIZATION_GOAL_PAUSE_REASONS.has(change.reason) + ); +} + +function goalLifecycleReplayContent(change: GoalReplayLifecycleChange): string { + switch (change.status) { + case 'paused': + return 'Goal paused'; + case 'active': + return 'Goal resumed'; + case 'blocked': + return 'Goal blocked'; + case 'complete': + case undefined: + return 'Goal updated'; + } +} + +function isModelBlockedGoalLifecycle(change: GoalReplayLifecycleChange): boolean { + return change.status === 'blocked' && change.actor === 'model'; +} + +function goalOutcomeReminderFromSystemMessage(message: ContextMessage): string | undefined | null { + if (message.origin?.kind !== 'system_trigger') return null; + if (message.origin.name !== 'goal_completion' && message.origin.name !== 'goal_blocked') { + return null; + } + return undefined; +} + +function isGoalForkClearedSystemReminder(message: ContextMessage): boolean { + return message.origin?.kind === 'system_trigger' && message.origin.name === 'goal_fork_cleared'; +} + function extractCronPrompt(text: string): string { const open = '<prompt>\n'; const close = '\n</prompt>'; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index cb0a33217..ae5eac768 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -2,6 +2,7 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; import { AgentGroupComponent } from '../components/messages/agent-group'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; +import { currentWorkingTip } from '../components/chrome/working-tips'; import { CompactionComponent } from '../components/dialogs/compaction'; import { ReadGroupComponent } from '../components/messages/read-group'; import { ThinkingComponent } from '../components/messages/thinking'; @@ -34,6 +35,7 @@ export interface StreamingUIHost { deferUserMessages: boolean; shiftQueuedMessage(): QueuedMessage | undefined; pushTranscriptEntry(entry: TranscriptEntry): void; + mergeCurrentTurnSteps(): void; } export class StreamingUIController { @@ -211,7 +213,7 @@ export class StreamingUIController { applyBackgroundTaskTerminalStatus(args: { agentId?: string | undefined; description: string; - status: 'completed' | 'failed' | 'killed' | 'lost'; + status: 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost'; /** * Real failure message to surface on the card. Pass the `subagent.failed` * event's `error` for live crashes — it is far more useful than the @@ -265,6 +267,41 @@ export class StreamingUIController { return true; } + /** + * Mark a foreground subagent card as detached-to-background (`◐ backgrounded`). + * Routed from a `background.task.started` event whose `info.kind === 'agent'`, + * keyed by `agentId`. Returns true iff a matching component was found. + * + * Gated to cards that are currently foreground-running: `background.task.started` + * also fires for `Agent(run_in_background=true)` launches and for background + * resumes, and those must not mutate older completed rows that happen to share + * the same `agentId` (a resume's new card has no parsed `agent_id` yet, so the + * search can otherwise hit the previous completed card). + */ + markSubagentBackgrounded(agentId: string | undefined): boolean { + if (agentId === undefined) return false; + const visit = (tc: ToolCallComponent): boolean => { + if (tc.getSubagentAgentId() !== agentId) return false; + const phase = tc.getSubagentSnapshot().phase; + if (phase !== 'running' && phase !== 'queued' && phase !== 'spawning') return false; + tc.markBackgrounded(); + return true; + }; + for (const tc of this._pendingToolComponents.values()) { + if (visit(tc)) return true; + } + for (const child of this.host.state.transcriptContainer.children) { + if (child instanceof ToolCallComponent) { + if (visit(child)) return true; + } else if (child instanceof AgentGroupComponent) { + for (const tc of child.getToolComponents()) { + if (visit(tc)) return true; + } + } + } + return false; + } + /** Registers a tool call that arrived via tool.call.started. * Clears any pending streaming state for this id, updates or creates the * component, and returns whether the call was new (no previous entry). */ @@ -278,7 +315,7 @@ export class StreamingUIController { existingComponent.updateToolCall(toolCall); } else if (existing === undefined) { this.finalizeLiveTextBuffers('tool'); - if (toolCall.name !== 'Agent') { + if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentSwarm') { this.onToolCallStart(toolCall); } } @@ -299,6 +336,19 @@ export class StreamingUIController { this.pendingToolCallFlushIds.add(id); } + getStreamingToolCallPreview( + id: string, + ): { name: string; args: Record<string, unknown>; argumentsText: string; startedAtMs: number } | undefined { + const streaming = this._streamingToolCallArguments.get(id); + if (streaming === undefined) return undefined; + return { + name: streaming.name ?? this._activeToolCalls.get(id)?.name ?? 'Tool', + args: parseStreamingArgs(streaming.argumentsText), + argumentsText: streaming.argumentsText, + startedAtMs: streaming.startedAtMs, + }; + } + /** Completes a tool call: delivers the result and removes tracking state. * Returns the matched ToolCallBlockData, or undefined if no call was tracked. */ completeToolResult(toolCallId: string, result: ToolResultBlockData): ToolCallBlockData | undefined { @@ -486,6 +536,7 @@ export class StreamingUIController { this.disposeAndClearPendingToolComponents(); this._pendingAgentGroup = null; this._pendingReadGroup = null; + this.resetToolCallState(); } resetToolCallState(): void { @@ -509,9 +560,15 @@ export class StreamingUIController { const next = this.host.shiftQueuedMessage(); if (next !== undefined) { + // The message is out of the queue but not yet sent. Mark the dispatch + // pending *before* setAppState — that call synchronously retries + // queued-goal promotion, which would otherwise see an empty queue and an + // idle phase and start a goal ahead of this message. + state.queuedMessageDispatchPending = true; this.host.setAppState({ streamingPhase: 'idle' }); this.host.resetLivePane(); setTimeout(() => { + state.queuedMessageDispatchPending = false; sendQueued(next); }, 0); return; @@ -540,10 +597,7 @@ export class StreamingUIController { renderMode: 'markdown' as const, content: '', }; - const component = new AssistantMessageComponent( - state.theme.markdownTheme, - state.theme.colors, - ); + const component = new AssistantMessageComponent(); this._streamingBlock = { component, entry }; this.host.pushTranscriptEntry(entry); state.transcriptContainer.addChild(component); @@ -554,12 +608,16 @@ export class StreamingUIController { const block = this._streamingBlock; if (block !== null) { block.entry.content = fullText; - block.component.updateContent(fullText); + block.component.updateContent(fullText, { transient: true }); this.host.state.ui.requestRender(); } } onStreamingTextEnd(): void { + const block = this._streamingBlock; + if (block !== null) { + block.component.updateContent(block.entry.content, { transient: false }); + } this._streamingBlock = null; } @@ -571,7 +629,6 @@ export class StreamingUIController { this._pendingReadGroup = null; this._activeThinkingComponent = new ThinkingComponent( fullText, - state.theme.colors, true, 'live', state.ui, @@ -589,6 +646,7 @@ export class StreamingUIController { this._activeThinkingComponent.finalize(); this._activeThinkingComponent = undefined; this.host.state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); } onToolCallStart(toolCall: ToolCallBlockData): void { @@ -598,13 +656,10 @@ export class StreamingUIController { const tc = new ToolCallComponent( toolCall, undefined, - state.theme.colors, state.ui, - state.theme.markdownTheme, state.appState.workDir, ); if (state.toolOutputExpanded) tc.setExpanded(true); - if (state.planExpanded) tc.setPlanExpanded(true); this._pendingToolComponents.set(toolCall.id, tc); if (toolCall.name !== 'Agent') this._pendingAgentGroup = null; @@ -638,6 +693,7 @@ export class StreamingUIController { tc.setResult(result); this._pendingToolComponents.delete(toolCallId); state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); return; } @@ -645,16 +701,14 @@ export class StreamingUIController { const completed = new ToolCallComponent( matchedCall, result, - state.theme.colors, state.ui, - state.theme.markdownTheme, state.appState.workDir, ); if (state.toolOutputExpanded) completed.setExpanded(true); - if (state.planExpanded) completed.setPlanExpanded(true); state.transcriptContainer.addChild(completed); state.ui.requestRender(); } + this.host.mergeCurrentTurnSteps(); } setTodoList(todos: readonly TodoItem[]): void { @@ -673,16 +727,19 @@ export class StreamingUIController { this._activeCompactionBlock.markDone(); this._activeCompactionBlock = undefined; } - const block = new CompactionComponent(state.theme.colors, state.ui, instruction); + const block = new CompactionComponent(state.ui, instruction, currentWorkingTip()?.text); this._activeCompactionBlock = block; state.transcriptContainer.addChild(block); + if (state.toolOutputExpanded) { + block.setExpanded(true); + } state.ui.requestRender(); } - endCompaction(tokensBefore?: number, tokensAfter?: number): void { + endCompaction(tokensBefore?: number, tokensAfter?: number, summary?: string): void { const block = this._activeCompactionBlock; if (block === undefined) return; - block.markDone(tokensBefore, tokensAfter); + block.markDone(tokensBefore, tokensAfter, summary); this._activeCompactionBlock = undefined; this.host.state.ui.requestRender(); } @@ -720,7 +777,7 @@ export class StreamingUIController { const existingComponent = this._pendingToolComponents.get(id); if (existingComponent !== undefined) { existingComponent.updateToolCall(toolCall); - } else if (toolCall.name !== 'Agent') { + } else if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentSwarm') { this.onToolCallStart(toolCall); } } @@ -769,7 +826,7 @@ export class StreamingUIController { private upgradeSoloAgentToGroup(solo: ToolCallComponent): AgentGroupComponent { const { state } = this.host; - const group = new AgentGroupComponent(state.theme.colors, state.ui); + const group = new AgentGroupComponent(state.ui); const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { @@ -826,7 +883,7 @@ export class StreamingUIController { private upgradeSoloReadToGroup(solo: ToolCallComponent): ReadGroupComponent { const { state } = this.host; - const group = new ReadGroupComponent(state.theme.colors, state.ui); + const group = new ReadGroupComponent(state.ui); const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts new file mode 100644 index 000000000..deb407bfc --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -0,0 +1,643 @@ +import type { + BackgroundTaskInfo, + Event, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Component } from '@moonshot-ai/pi-tui'; + +import { + AgentSwarmProgressComponent, + agentSwarmDescriptionFromArgs, + agentSwarmGridHeightForTerminalRows, +} from '../components/messages/agent-swarm-progress'; +import { MAIN_AGENT_ID } from '../constant/kimi-tui'; +import type { + BackgroundAgentMetadata, + ToolCallBlockData, + ToolResultBlockData, + TranscriptEntry, +} from '../types'; +import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; +import { argsRecord, serializeToolResultOutput } from '../utils/event-payload'; +import { formatHookResultPlain } from '../utils/hook-result-format'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { SessionEventHost } from './session-event-handler'; + +export interface SubagentInfo { + readonly parentToolCallId: string; + readonly name: string; + readonly runInBackground: boolean; + readonly swarmIndex?: number; +} + +export type SubagentLifecycleEvent = Event & { type: `subagent.${string}` }; +type SubagentLifecycleEventOf<Type extends SubagentLifecycleEvent['type']> = + SubagentLifecycleEvent & { type: Type }; + +export interface SubAgentEventHandlerDependencies { + readonly backgroundTasks: ReadonlyMap<string, BackgroundTaskInfo>; + readonly backgroundTaskTranscriptedTerminal: Set<string>; + readonly syncBackgroundAgentBadge: () => void; +} + +function renderedRowsAfterChild( + children: readonly Component[], + child: Component, + width: number, +): number { + const childIndex = children.indexOf(child); + if (childIndex < 0) return 0; + return children + .slice(childIndex + 1) + .reduce((sum, component) => sum + component.render(width).length, 0); +} + +export class SubAgentEventHandler { + readonly subagentInfo: Map<string, SubagentInfo> = new Map(); + private readonly agentSwarmProgress: Map<string, AgentSwarmProgressComponent> = new Map(); + backgroundAgentMetadata: Map<string, BackgroundAgentMetadata> = new Map(); + + constructor( + private readonly host: SessionEventHost, + private readonly deps: SubAgentEventHandlerDependencies, + ) {} + + resetRuntimeState(): void { + this.subagentInfo.clear(); + this.backgroundAgentMetadata.clear(); + this.clearAgentSwarmProgress(); + } + + routeChildAgentEvent(event: Event): boolean { + if (isSubagentLifecycleEvent(event)) return false; + + const childAgentId = event.agentId; + if (childAgentId === MAIN_AGENT_ID) return false; + if (this.host.btwPanelController.routeEvent(event)) return true; + + const info = this.subagentInfo.get(childAgentId); + if (info === undefined || info.parentToolCallId.length === 0) return true; + + const { parentToolCallId } = info; + const swarmProgress = this.agentSwarmProgress.get(parentToolCallId); + if (swarmProgress !== undefined) { + this.applySubagentEventToSwarmProgress(swarmProgress, event, childAgentId); + this.requestRender(); + return true; + } + + const toolCall = this.host.streamingUI.getToolComponent(parentToolCallId); + if (toolCall === undefined) return true; + toolCall.setSubagentMeta(childAgentId, info.name); + + if (event.type === 'hook.result') { + toolCall.appendSubagentText(formatHookResultPlain(event), 'text'); + } else if (event.type === 'assistant.delta') { + toolCall.appendSubagentText(event.delta, 'text'); + } else if (event.type === 'thinking.delta') { + toolCall.appendSubagentText(event.delta, 'thinking'); + } else if (event.type === 'tool.call.started') { + toolCall.appendSubToolCall({ + id: `${childAgentId}:${event.toolCallId}`, + name: event.name, + args: argsRecord(event.args), + }); + } else if (event.type === 'tool.call.delta') { + toolCall.appendSubToolCallDelta({ + id: `${childAgentId}:${event.toolCallId}`, + name: event.name, + argumentsPart: event.argumentsPart ?? null, + }); + } else if ( + event.type === 'tool.progress' && + (event.update.kind === 'stdout' || event.update.kind === 'stderr') && + event.update.text !== undefined + ) { + toolCall.appendSubToolLiveOutput(`${childAgentId}:${event.toolCallId}`, event.update.text); + } else if (event.type === 'tool.result') { + toolCall.finishSubToolCall({ + tool_call_id: `${childAgentId}:${event.toolCallId}`, + output: serializeToolResultOutput(event.output), + is_error: event.isError, + }); + } else if (event.type === 'agent.status.updated') { + const usageObj = event.usage; + const totalUsage = usageObj?.total ?? usageObj?.currentTurn; + toolCall.updateSubagentMetrics({ + contextTokens: event.contextTokens, + usage: totalUsage, + }); + } + return true; + } + + handleLifecycleEvent(event: SubagentLifecycleEvent): void { + switch (event.type) { + case 'subagent.spawned': + this.handleSubagentSpawned(event); + return; + case 'subagent.started': + this.handleSubagentStarted(event); + return; + case 'subagent.suspended': + this.handleSubagentSuspended(event); + return; + case 'subagent.completed': + this.handleSubagentCompleted(event); + return; + case 'subagent.failed': + this.handleSubagentFailed(event); + return; + } + } + + clearAgentSwarmProgress(): void { + for (const progress of this.agentSwarmProgress.values()) { + progress.dispose(); + } + this.agentSwarmProgress.clear(); + this.host.updateActivityPane(); + } + + hasAgentSwarmProgress(toolCallId: string): boolean { + return this.agentSwarmProgress.has(toolCallId); + } + + hasActiveAgentSwarmToolCall(): boolean { + return Array.from(this.agentSwarmProgress.values()).some((progress) => + progress.isToolCallActive() + ); + } + + syncAgentSwarmActivitySpinner( + spinner: { renderInline(): string } | undefined, + ): void { + for (const progress of this.agentSwarmProgress.values()) { + progress.setActivitySpinnerText( + spinner === undefined ? undefined : () => spinner.renderInline(), + ); + } + } + + handleAgentSwarmToolCallStarted( + toolCallId: string, + args: Record<string, unknown>, + ): void { + const progress = this.ensureAgentSwarmProgress(toolCallId, args); + progress.markInputComplete(); + this.requestRender(); + } + + handleAgentSwarmToolCallDelta( + toolCallId: string, + args: Record<string, unknown>, + options: { readonly streamingArguments?: string | undefined }, + ): void { + this.ensureAgentSwarmProgress(toolCallId, args, options); + this.requestRender(); + } + + handleAgentSwarmToolResult( + toolCallId: string, + resultData: ToolResultBlockData, + isError: boolean, + ): void { + const progress = this.agentSwarmProgress.get(toolCallId); + if (progress === undefined) return; + + if (isError && isUserCancelledSubagentError(resultData.output)) { + if (progress.isRequestStreaming()) { + this.removeAgentSwarmProgress(toolCallId, progress); + } else { + progress.markToolCallEnded(); + progress.markActiveCancelled(); + } + } else if (isError) { + progress.markToolCallEnded(); + if (!progress.applyResult(resultData.output)) { + progress.markSwarmFailed(resultData.output); + } + } else { + progress.markToolCallEnded(); + progress.applyResult(resultData.output); + } + this.host.updateActivityPane(); + this.requestRender(); + } + + markActiveAgentSwarmsCancelled(): void { + let updated = false; + for (const [toolCallId, progress] of this.agentSwarmProgress) { + if (progress.isRequestStreaming()) { + this.removeAgentSwarmProgress(toolCallId, progress); + updated = true; + continue; + } + progress.markActiveCancelled(); + updated = true; + } + if (updated) this.requestRender(); + } + + private handleSubagentSpawned( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): void { + this.rememberSubagent(event); + + if (event.runInBackground) { + const meta = this.buildBackgroundAgentMetadata(event); + this.backgroundAgentMetadata.set(event.subagentId, meta); + this.appendBackgroundAgentEntry('started', meta); + this.deps.syncBackgroundAgentBadge(); + return; + } + + this.handleForegroundSubagentSpawned(event); + } + + private handleSubagentStarted( + event: SubagentLifecycleEventOf<'subagent.started'>, + ): void { + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined) return; + if (!info.runInBackground) this.handleForegroundSubagentStarted(event, info); + } + + private handleSubagentSuspended( + event: SubagentLifecycleEventOf<'subagent.suspended'>, + ): void { + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined) return; + if (!info.runInBackground) this.handleForegroundSubagentSuspended(event, info); + } + + private handleSubagentCompleted( + event: SubagentLifecycleEventOf<'subagent.completed'>, + ): void { + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); + if (backgroundMeta !== undefined) { + const taskId = this.findAgentTaskId( + event.subagentId, + backgroundMeta, + this.deps.backgroundTasks, + ); + this.backgroundAgentMetadata.delete(event.subagentId); + this.deps.syncBackgroundAgentBadge(); + if (taskId !== undefined && this.deps.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.deps.backgroundTaskTranscriptedTerminal.add(taskId); + } + const extras = + event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; + this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); + return; + } + + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined || info.runInBackground) return; + this.handleForegroundSubagentCompleted(event, info); + } + + private handleSubagentFailed( + event: SubagentLifecycleEventOf<'subagent.failed'>, + ): void { + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); + if (backgroundMeta !== undefined) { + const taskId = this.findAgentTaskId( + event.subagentId, + backgroundMeta, + this.deps.backgroundTasks, + ); + const task = taskId === undefined ? undefined : this.deps.backgroundTasks.get(taskId); + this.backgroundAgentMetadata.delete(event.subagentId); + this.deps.syncBackgroundAgentBadge(); + if (task?.kind === 'agent' && task.status === 'timed_out') { + return; + } + this.host.streamingUI.applyBackgroundTaskTerminalStatus({ + agentId: event.subagentId, + description: backgroundMeta.description ?? '', + status: 'failed', + errorText: event.error, + }); + if (taskId !== undefined && this.deps.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.deps.backgroundTaskTranscriptedTerminal.add(taskId); + } + this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); + return; + } + + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined || info.runInBackground) return; + this.handleForegroundSubagentFailed(event, info); + } + + private findAgentTaskId( + subagentId: string, + meta: BackgroundAgentMetadata, + backgroundTasks: ReadonlyMap<string, BackgroundTaskInfo>, + ): string | undefined { + for (const info of backgroundTasks.values()) { + if (info.kind !== 'agent') continue; + if (info.agentId === subagentId) return info.taskId; + } + const description = meta.description ?? meta.agentName; + if (description === undefined) return undefined; + let match: string | undefined; + for (const info of backgroundTasks.values()) { + if (info.kind !== 'agent') continue; + if (info.description !== description) continue; + if (match !== undefined) return undefined; + match = info.taskId; + } + return match; + } + + private buildBackgroundAgentMetadata( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): BackgroundAgentMetadata { + const parent = this.host.streamingUI.getActiveToolCall(event.parentToolCallId); + const description = parent?.args['description'] ?? event.description; + return { + agentId: event.subagentId, + parentToolCallId: event.parentToolCallId, + agentName: event.subagentName, + description: typeof description === 'string' ? description : undefined, + }; + } + + private appendBackgroundAgentEntry( + phase: 'started' | 'completed' | 'failed', + meta: BackgroundAgentMetadata, + extras: { resultSummary?: string; error?: string } | undefined = undefined, + ): void { + const status = formatBackgroundAgentTranscript(phase, meta, extras); + const entry: TranscriptEntry = { + id: nextTranscriptId(), + kind: 'status', + turnId: this.host.streamingUI.getTurnContext().turnId, + renderMode: 'plain', + content: status.headline, + detail: status.detail, + backgroundAgentStatus: status, + }; + this.host.appendTranscriptEntry(entry); + } + + private rememberSubagent( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): void { + this.subagentInfo.set(event.subagentId, { + parentToolCallId: event.parentToolCallId, + name: event.subagentName, + runInBackground: event.runInBackground, + swarmIndex: event.swarmIndex, + }); + } + + private handleForegroundSubagentSpawned( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): void { + if (this.updateAgentSwarmProgress(event.parentToolCallId, (progress) => { + progress.registerSubagent({ + agentId: event.subagentId, + swarmIndex: event.swarmIndex, + }); + })) { + return; + } + + let tc = this.getOrActivateToolComponent(event.parentToolCallId); + tc ??= this.createStandaloneSubagentToolCall(event); + if (tc === undefined) return; + tc.onSubagentSpawned({ + agentId: event.subagentId, + agentName: event.subagentName, + runInBackground: event.runInBackground, + }); + } + + private handleForegroundSubagentStarted( + event: SubagentLifecycleEventOf<'subagent.started'>, + info: SubagentInfo, + ): void { + if (this.updateAgentSwarmProgress(info.parentToolCallId, (progress) => { + progress.markStarted(event.subagentId); + })) { + return; + } + + const tc = this.getOrActivateToolComponent(info.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentStarted({ + agentId: event.subagentId, + agentName: info.name, + runInBackground: info.runInBackground, + }); + } + + private handleForegroundSubagentSuspended( + event: SubagentLifecycleEventOf<'subagent.suspended'>, + info: SubagentInfo, + ): void { + this.updateAgentSwarmProgress(info.parentToolCallId, (progress) => { + progress.markSuspended({ + agentId: event.subagentId, + reason: event.reason, + swarmIndex: info.swarmIndex, + }); + }); + } + + private handleForegroundSubagentCompleted( + event: SubagentLifecycleEventOf<'subagent.completed'>, + info: SubagentInfo, + ): void { + const { parentToolCallId } = info; + if (this.updateAgentSwarmProgress(parentToolCallId, (progress) => { + progress.markCompleted(event.subagentId, event.resultSummary); + })) { + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + return; + } + + const tc = this.host.streamingUI.getToolComponent(parentToolCallId); + if (tc === undefined) return; + tc.onSubagentCompleted({ + contextTokens: event.contextTokens, + usage: event.usage, + resultSummary: event.resultSummary, + }); + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + } + + private handleForegroundSubagentFailed( + event: SubagentLifecycleEventOf<'subagent.failed'>, + info: SubagentInfo, + ): void { + const { parentToolCallId } = info; + if (this.updateAgentSwarmProgress(parentToolCallId, (progress) => { + this.markAgentSwarmFailedOrCancelled(progress, event.subagentId, event.error); + })) { + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + return; + } + + const tc = this.host.streamingUI.getToolComponent(parentToolCallId); + if (tc === undefined) return; + tc.onSubagentFailed({ error: event.error }); + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + } + + private applySubagentEventToSwarmProgress( + progress: AgentSwarmProgressComponent, + event: Event, + subagentId: string, + ): void { + if (event.type === 'assistant.delta' || event.type === 'thinking.delta') { + progress.appendModelDelta({ agentId: subagentId, delta: event.delta }); + } else if (event.type === 'tool.call.started') { + progress.recordToolCall({ agentId: subagentId, toolCallId: event.toolCallId }); + } + } + + private updateAgentSwarmProgress( + parentToolCallId: string, + update: (progress: AgentSwarmProgressComponent) => void, + ): boolean { + const progress = this.agentSwarmProgress.get(parentToolCallId); + if (progress === undefined) return false; + update(progress); + this.requestRender(); + return true; + } + + private ensureAgentSwarmProgress( + toolCallId: string, + args: Record<string, unknown>, + options: { readonly streamingArguments?: string | undefined } = {}, + ): AgentSwarmProgressComponent { + const existing = this.agentSwarmProgress.get(toolCallId); + if (existing !== undefined) { + existing.updateArgs(args, options); + return existing; + } + + const progress = new AgentSwarmProgressComponent({ + description: agentSwarmDescriptionFromArgs(args), + availableGridHeight: () => this.agentSwarmGridHeight(), + requestRender: () => { + this.requestRender(); + }, + }); + progress.updateArgs(args, options); + this.agentSwarmProgress.set(toolCallId, progress); + this.host.streamingUI.finalizeLiveTextBuffers('tool'); + this.host.state.transcriptContainer.addChild(progress); + this.host.updateActivityPane(); + this.requestRender(); + return progress; + } + + private removeAgentSwarmProgress( + toolCallId: string, + progress: AgentSwarmProgressComponent, + ): void { + this.agentSwarmProgress.delete(toolCallId); + progress.dispose(); + const children = this.host.state.transcriptContainer.children; + const index = children.indexOf(progress); + if (index >= 0) { + children.splice(index, 1); + this.host.state.transcriptContainer.invalidate(); + } + this.host.updateActivityPane(); + } + + private agentSwarmGridHeight(): number | undefined { + const { state } = this.host; + const terminalRows = state.ui.terminal.rows; + const terminalColumns = state.ui.terminal.columns; + if (!Number.isFinite(terminalColumns) || terminalColumns <= 0) { + return agentSwarmGridHeightForTerminalRows(terminalRows); + } + + const width = Math.floor(terminalColumns); + const rowsAfterSwarm = renderedRowsAfterChild( + state.ui.children, + state.transcriptContainer, + width, + ); + return agentSwarmGridHeightForTerminalRows(terminalRows, rowsAfterSwarm); + } + + private markAgentSwarmFailedOrCancelled( + progress: AgentSwarmProgressComponent, + subagentId: string, + error: string, + ): void { + if (isUserCancelledSubagentError(error)) { + progress.markCancelled(subagentId); + } else { + progress.markFailed(subagentId, error); + } + } + + private getOrActivateToolComponent(parentToolCallId: string) { + let component = this.host.streamingUI.getToolComponent(parentToolCallId); + if (component !== undefined) return component; + const toolCall = this.host.streamingUI.getActiveToolCall(parentToolCallId); + if (toolCall === undefined) return undefined; + this.host.streamingUI.onToolCallStart(toolCall); + return this.host.streamingUI.getToolComponent(parentToolCallId); + } + + private createStandaloneSubagentToolCall( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ) { + const description = event.description ?? `Run ${event.subagentName} agent`; + const { turnId, step } = this.host.streamingUI.getTurnContext(); + const toolCall: ToolCallBlockData = { + id: event.parentToolCallId, + name: 'Agent', + args: { + description, + subagent_type: event.subagentName, + }, + description, + step, + turnId, + }; + this.host.streamingUI.onToolCallStart(toolCall); + return this.host.streamingUI.getToolComponent(event.parentToolCallId); + } + + private requestRender(): void { + this.host.state.ui.requestRender(); + } +} + +function isSubagentLifecycleEvent(event: Event): event is SubagentLifecycleEvent { + return ( + event.type === 'subagent.spawned' || + event.type === 'subagent.started' || + event.type === 'subagent.suspended' || + event.type === 'subagent.completed' || + event.type === 'subagent.failed' + ); +} + +function isUserCancelledSubagentError(error: string): boolean { + // Structured AgentSwarm results use outcome="aborted" and are parsed separately. + switch (error.trim()) { + case 'Aborted by the user': + case 'The user manually interrupted this subagent batch.': + return true; + default: + return false; + } +} diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 56f1cecc6..6994b13b8 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -1,15 +1,15 @@ import type { BackgroundTaskInfo, Session } from '@moonshot-ai/kimi-code-sdk'; -import type { Component, ProcessTerminal, TUI } from '@earendil-works/pi-tui'; +import type { Component, ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; -import type { ColorPalette } from '../theme'; +import type { Theme } from '#/tui/theme'; import type { CustomEditor } from '../components/editor/custom-editor'; export interface TasksBrowserHost { readonly state: { readonly tasksBrowser: TasksBrowserState | undefined; - readonly theme: { readonly colors: ColorPalette }; + readonly theme: Theme; readonly terminal: ProcessTerminal; readonly ui: TUI; readonly editor: CustomEditor; @@ -77,7 +77,6 @@ export class TasksBrowserController { tailOutput: undefined, tailLoading: false, flashMessage: undefined, - colors: state.theme.colors, ...this.buildCallbacks(), }, state.terminal, @@ -167,7 +166,6 @@ export class TasksBrowserController { taskId: viewer.taskId, info, output, - colors: state.theme.colors, onClose: () => { this.closeOutputViewer(); }, @@ -188,15 +186,12 @@ export class TasksBrowserController { (t) => t.status !== 'completed' && t.status !== 'failed' && + t.status !== 'timed_out' && t.status !== 'killed' && t.status !== 'lost', ); if (candidates.length === 0) return undefined; - return ( - candidates.find( - (t) => t.status === 'running' || t.status === 'awaiting_approval', - )?.taskId ?? candidates[0]!.taskId - ); + return candidates.find((t) => t.status === 'running')?.taskId ?? candidates[0]!.taskId; } private async refresh(opts: { silent?: boolean } = {}): Promise<void> { @@ -232,7 +227,6 @@ export class TasksBrowserController { tailOutput: browser.tailOutput, tailLoading: browser.tailLoading, flashMessage: browser.flashMessage, - colors: this.host.state.theme.colors, ...this.buildCallbacks(), }); this.host.state.ui.requestRender(); @@ -346,7 +340,6 @@ export class TasksBrowserController { taskId, info, output, - colors: state.theme.colors, onClose: () => { this.closeOutputViewer(); }, diff --git a/apps/kimi-code/src/tui/easter-eggs/dance.ts b/apps/kimi-code/src/tui/easter-eggs/dance.ts index a8ab72c7a..15e3608f8 100644 --- a/apps/kimi-code/src/tui/easter-eggs/dance.ts +++ b/apps/kimi-code/src/tui/easter-eggs/dance.ts @@ -10,11 +10,11 @@ */ import chalk from 'chalk'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import type { SlashCommandHost } from '../commands/dispatch'; import type { ParsedSlashInput } from '../commands/types'; -import type { ColorPalette } from '../theme/colors'; +import { currentTheme } from '../theme'; /** Frame interval for the rainbow flow animation. */ export const DANCE_FRAME_MS = 110; @@ -44,8 +44,8 @@ const LIGHT_RAINBOW = [ '#354CB5', ] as const; -function getDanceRainbowPalette(colors: ColorPalette): readonly [string, ...string[]] { - return colors.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; +function getDanceRainbowPalette(): readonly [string, ...string[]] { + return currentTheme.palette.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; } /** Paint a string character-by-character through a palette, skipping spaces. */ @@ -110,13 +110,12 @@ export function isRainbowDancing(): boolean { } export function renderDanceWelcomeHeader( - colors: ColorPalette, logo: readonly [string, string], textWidth: number, rightRow1: string, ): string[] { const phase = currentDanceView?.phase ?? 0; - const palette = getDanceRainbowPalette(colors); + const palette = getDanceRainbowPalette(); const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); const gap = ' '; const rightRow0 = truncateToWidth( @@ -131,8 +130,8 @@ export function renderDanceWelcomeHeader( ]; } -export function renderDanceFooterModel(modelLabel: string, colors: ColorPalette): string { - return rainbowText(modelLabel, getDanceRainbowPalette(colors), currentDanceView?.phase ?? 0); +export function renderDanceFooterModel(modelLabel: string): string { + return rainbowText(modelLabel, getDanceRainbowPalette(), currentDanceView?.phase ?? 0); } /** @@ -230,15 +229,20 @@ export function tryHandleDanceCommand(host: SlashCommandHost, parsed: ParsedSlas if (parsed.name !== 'dance') return false; if (currentDanceController === undefined) return false; + // The status line dims the whole message, which buried the command in the + // hint. Paint just the command in the brand color (bold) so it reads as a + // command; chalk nesting resumes the dim run right after it. + const cmd = (text: string): string => currentTheme.boldFg('primary', text); + const sub = parsed.args.trim().toLowerCase(); if (sub === 'off') { currentDanceController.stop(); } else if (sub === 'on') { currentDanceController.start({ hold: true }); - host.showStatus('Dancing — use /dance off to turn it off.'); + host.showStatus(`Dancing — use ${cmd('/dance off')} to turn it off.`); } else { currentDanceController.start({ hold: false }); - host.showStatus('Use /dance on to keep the rainbow on.'); + host.showStatus(`Use ${cmd('/dance on')} to keep the rainbow on.`); } return true; } diff --git a/apps/kimi-code/src/tui/goal-queue-store.ts b/apps/kimi-code/src/tui/goal-queue-store.ts new file mode 100644 index 000000000..0b98eda67 --- /dev/null +++ b/apps/kimi-code/src/tui/goal-queue-store.ts @@ -0,0 +1,269 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + ErrorCodes, + KimiError, +} from '@moonshot-ai/kimi-code-sdk'; + +const GOAL_QUEUE_FILE = 'upcoming-goals.json'; +const GOAL_QUEUE_VERSION = 1; +const MAX_GOAL_OBJECTIVE_LENGTH = 4000; + +export interface UpcomingGoal { + readonly id: string; + readonly objective: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface GoalQueueSnapshot { + readonly goals: readonly UpcomingGoal[]; +} + +export type GoalQueueMoveDirection = 'up' | 'down'; + +interface GoalQueueFile { + readonly version: typeof GOAL_QUEUE_VERSION; + readonly goals: readonly UpcomingGoal[]; +} + +interface GoalQueueSession { + readonly id: string; + readonly summary?: { + readonly sessionDir?: string; + }; +} + +const queueMutationLocks = new Map<string, Promise<void>>(); + +export async function readGoalQueue(session: GoalQueueSession): Promise<GoalQueueSnapshot> { + const state = await readQueueFile(session); + return toSnapshot(state); +} + +export async function appendGoalQueueItem( + session: GoalQueueSession, + input: { readonly objective: string }, +): Promise<GoalQueueSnapshot> { + const objective = normalizeObjective(input.objective); + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const now = new Date().toISOString(); + const goal: UpcomingGoal = { + id: randomUUID(), + objective, + createdAt: now, + updatedAt: now, + }; + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals: [...state.goals, goal] }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function updateGoalQueueItem( + session: GoalQueueSession, + input: { readonly goalId: string; readonly objective: string }, +): Promise<GoalQueueSnapshot> { + const objective = normalizeObjective(input.objective); + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const index = findGoalIndex(state, input.goalId); + const current = state.goals[index]!; + const updatedAt = timestampAfter(current.updatedAt); + const goals = state.goals.map((goal, goalIndex) => + goalIndex === index ? { ...goal, objective, updatedAt } : goal, + ); + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function removeGoalQueueItem( + session: GoalQueueSession, + input: { readonly goalId: string }, +): Promise<GoalQueueSnapshot> { + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const index = findGoalIndex(state, input.goalId); + const goals = state.goals.filter((_, goalIndex) => goalIndex !== index); + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function restoreGoalQueueItem( + session: GoalQueueSession, + goal: UpcomingGoal, +): Promise<GoalQueueSnapshot> { + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + if (state.goals.some((item) => item.id === goal.id)) { + return toSnapshot(state); + } + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals: [goal, ...state.goals] }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function moveGoalQueueItem( + session: GoalQueueSession, + input: { readonly goalId: string; readonly direction: GoalQueueMoveDirection }, +): Promise<GoalQueueSnapshot> { + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const index = findGoalIndex(state, input.goalId); + const targetIndex = input.direction === 'up' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= state.goals.length) { + return toSnapshot(state); + } + const goals = [...state.goals]; + const [goal] = goals.splice(index, 1); + goals.splice(targetIndex, 0, goal!); + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +function goalQueuePath(session: GoalQueueSession): string { + const sessionDir = session.summary?.sessionDir; + if (sessionDir === undefined || sessionDir.trim().length === 0) { + throw new Error(`Session ${session.id} does not expose a session directory`); + } + return join(sessionDir, GOAL_QUEUE_FILE); +} + +async function readQueueFile(session: GoalQueueSession): Promise<GoalQueueFile> { + const filePath = goalQueuePath(session); + let raw: string; + try { + raw = await readFile(filePath, 'utf-8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) return emptyQueueFile(); + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Invalid JSON in goal queue: ${describeError(error)}`, + ); + } + + if (!isGoalQueueFile(parsed)) { + const empty = emptyQueueFile(); + await writeQueueFile(session, empty); + return empty; + } + + return parsed; +} + +async function writeQueueFile(session: GoalQueueSession, file: GoalQueueFile): Promise<void> { + const filePath = goalQueuePath(session); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(file, null, 2)}\n`, 'utf-8'); +} + +async function withQueueMutationLock<T>( + session: GoalQueueSession, + work: () => Promise<T>, +): Promise<T> { + const filePath = goalQueuePath(session); + const previous = queueMutationLocks.get(filePath) ?? Promise.resolve(); + const run = previous.catch(() => undefined).then(work); + const lock = run.then( + () => undefined, + () => undefined, + ); + queueMutationLocks.set(filePath, lock); + try { + return await run; + } finally { + if (queueMutationLocks.get(filePath) === lock) { + queueMutationLocks.delete(filePath); + } + } +} + +function emptyQueueFile(): GoalQueueFile { + return { version: GOAL_QUEUE_VERSION, goals: [] }; +} + +function toSnapshot(file: GoalQueueFile): GoalQueueSnapshot { + return { goals: file.goals }; +} + +function normalizeObjective(value: string): string { + const objective = value.trim(); + if (objective.length === 0) { + throw new KimiError(ErrorCodes.GOAL_OBJECTIVE_EMPTY, 'Goal objective cannot be empty'); + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + throw new KimiError( + ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, + `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`, + ); + } + return objective; +} + +function findGoalIndex(file: GoalQueueFile, goalId: string): number { + const index = file.goals.findIndex((goal) => goal.id === goalId); + if (index === -1) { + throw new KimiError(ErrorCodes.GOAL_NOT_FOUND, 'No queued goal found'); + } + return index; +} + +function isGoalQueueFile(value: unknown): value is GoalQueueFile { + if (!isRecord(value)) return false; + return ( + value['version'] === GOAL_QUEUE_VERSION && + Array.isArray(value['goals']) && + value['goals'].every(isUpcomingGoal) + ); +} + +function isUpcomingGoal(value: unknown): value is UpcomingGoal { + if (!isRecord(value)) return false; + return ( + isNonEmptyString(value['id']) && + isNonEmptyString(value['objective']) && + isNonEmptyString(value['createdAt']) && + isNonEmptyString(value['updatedAt']) + ); +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function timestampAfter(previous: string): string { + const now = new Date(); + const previousMs = Date.parse(previous); + if (Number.isFinite(previousMs) && now.getTime() <= previousMs) { + return new Date(previousMs + 1).toISOString(); + } + return now.toISOString(); +} + +function isErrno(error: unknown, code: string): boolean { + return isRecord(error) && error['code'] === code; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index c3b3ae415..a05165ca2 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1,15 +1,6 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { - deleteAllKittyImages, - type Component, - type Focusable, - getCapabilities, - type SlashCommand, - Spacer, -} from '@earendil-works/pi-tui'; -import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { ApprovalRequest, @@ -21,30 +12,45 @@ import type { PromptPart, Session, } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; +import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { + deleteAllKittyImages, + type Component, + type Focusable, + getCapabilities, + Spacer, +} from '@moonshot-ai/pi-tui'; +import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; -import type { GitLsFilesCache } from '#/utils/git/git-ls-files'; -import { createGitLsFilesCache } from '#/utils/git/git-ls-files'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; +import { openUrl } from '#/utils/open-url'; import { getInputHistoryFile } from '#/utils/paths'; -import { detectFdPath } from '#/utils/process/fd-detect'; +import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect'; +import { quoteShellArg } from '#/utils/shell-quote'; +import { restoreTerminalModes } from '#/utils/terminal-restore'; +import { BannerProvider } from './banner/banner-provider'; +import { readBannerDisplayState, writeBannerDisplayState } from './banner/state'; import { BUILTIN_SLASH_COMMANDS, + buildPluginSlashCommands, buildSkillSlashCommands, isExperimentalFlagEnabled, - setExperimentalFlags, + setExperimentalFeatures, sortSlashCommands, type KimiSlashCommand, type SkillListSession, } from './commands'; +import * as slashCommands from './commands/dispatch'; +import { BannerComponent } from './components/chrome/banner'; import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; import { GutterContainer } from './components/chrome/gutter-container'; -import { CHROME_GUTTER } from './constant/rendering'; import { MoonLoader, type SpinnerStyle } from './components/chrome/moon-loader'; import { WelcomeComponent } from './components/chrome/welcome'; +import { pickRandomWorkingTip } from './components/chrome/working-tips'; import { ApprovalPanelComponent, type ApprovalPanelResponse, @@ -56,24 +62,27 @@ import { import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; -import { SessionPickerComponent } from './components/dialogs/session-picker'; -import { AuthFlowController } from './controllers/auth-flow'; -import { EditorKeyboardController } from './controllers/editor-keyboard'; -import { SessionEventHandler } from './controllers/session-event-handler'; -import * as slashCommands from './commands/dispatch'; -import { SessionReplayRenderer } from './controllers/session-replay'; -import { StreamingUIController } from './controllers/streaming-ui'; -import { TasksBrowserController } from './controllers/tasks-browser'; -import { installRainbowDance } from './easter-eggs/dance'; -import { FileMentionProvider } from './components/editor/file-mention-provider'; +import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { + FileMentionProvider, + type SlashAutocompleteCommand, +} from './components/editor/file-mention-provider'; import { AssistantMessageComponent } from './components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; import { CronMessageComponent } from './components/messages/cron-message'; +import { buildGoalMarker } from './components/messages/goal-markers'; +import { + GoalCompletionMessageComponent, + GoalSetMessageComponent, +} from './components/messages/goal-panel'; +import { PluginCommandComponent } from './components/messages/plugin-command'; +import { ShellRunComponent } from './components/messages/shell-run'; import { SkillActivationComponent } from './components/messages/skill-activation'; import { NoticeMessageComponent, StatusMessageComponent, } from './components/messages/status-message'; +import { StepSummaryComponent } from './components/messages/step-summary'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; import { UserMessageComponent } from './components/messages/user-message'; @@ -84,8 +93,19 @@ import { LLM_NOT_SET_MESSAGE, MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, + PRODUCT_NAME, } from './constant/kimi-tui'; -import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; +import { CHROME_GUTTER } from './constant/rendering'; +import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal'; +import { AuthFlowController } from './controllers/auth-flow'; +import { BtwPanelController } from './controllers/btw-panel'; +import { ClipboardImageHintController } from './controllers/clipboard-image-hint'; +import { EditorKeyboardController } from './controllers/editor-keyboard'; +import { SessionEventHandler } from './controllers/session-event-handler'; +import { SessionReplayRenderer } from './controllers/session-replay'; +import { StreamingUIController } from './controllers/streaming-ui'; +import { TasksBrowserController } from './controllers/tasks-browser'; +import { installRainbowDance } from './easter-eggs/dance'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; @@ -93,9 +113,9 @@ import { registerReverseRPCHandlers } from './reverse-rpc/index'; import { QuestionController } from './reverse-rpc/question/controller'; import { createQuestionAskHandler } from './reverse-rpc/question/handler'; import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; -import { createKimiTUIThemeBundle } from './theme/bundle'; -import type { ResolvedTheme } from './theme/colors'; -import type { Theme } from './theme/index'; +import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; +import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; +import { createTUIState, type TUIState } from './tui-state'; import { INITIAL_LIVE_PANE, type AppState, @@ -103,25 +123,39 @@ import { type LivePaneState, type LoginProgressSpinnerHandle, type QueuedMessage, + type SteerInputItem, type TranscriptEntry, type TUIStartupOptions, type TUIStartupState, } from './types'; -import { createTUIState, type TUIState } from './tui-state'; -import { isExpandable, isPlanExpandable } from './utils/component-capabilities'; +import { hasDispose, isExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; +import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; -import { extractMediaAttachments } from './utils/image-placeholder'; +import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; -import { openUrl } from './utils/open-url'; -import { setProcessTitle } from './utils/proctitle'; import { sessionRowsForPicker } from './utils/session-picker-rows'; +import { formatBashOutputForDisplay } from './utils/shell-output'; +import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; import { installTerminalThemeTracking } from './utils/terminal-theme'; import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard'; +import { + getTranscriptComponentEntry, + markTranscriptComponent, +} from './utils/transcript-component-metadata'; import { nextTranscriptId } from './utils/transcript-id'; +import { + TRANSCRIPT_EXPAND_TURNS, + TRANSCRIPT_HYSTERESIS, + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_MAX_TURNS, + TRANSCRIPT_WINDOW_ENABLED, + groupTurns, + turnsToTrim, +} from './utils/transcript-window'; export type { TUIState } from './tui-state'; export { createTUIState } from './tui-state'; @@ -134,17 +168,32 @@ export type { export interface KimiTUIStartupInput { readonly cliOptions: CLIOptions; + readonly additionalDirs?: readonly string[]; readonly tuiConfig: TuiConfig; readonly version: string; readonly workDir: string; readonly startupNotice?: string; - readonly resolvedTheme?: ResolvedTheme; readonly migrationPlan?: MigrationPlan | null; /** When true, run only the migration screen, then exit (the `kimi migrate` command). */ readonly migrateOnly?: boolean; } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; +type LoadingTipKind = 'moon' | 'composing'; + +function loadingTipKind(mode: EffectiveActivityPaneMode): LoadingTipKind | undefined { + if (mode === 'waiting' || mode === 'tool') return 'moon'; + if (mode === 'composing') return 'composing'; + return undefined; +} + +function sameStringArrays(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +type MutableCreateSessionOptions = { + -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; +}; function createInitialAppState(input: KimiTUIStartupInput): AppState { const startupPermission: PermissionMode = input.cliOptions.auto @@ -155,10 +204,13 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { return { model: '', workDir: input.workDir, + additionalDirs: [...(input.additionalDirs ?? [])], sessionId: '', permissionMode: startupPermission, planMode: input.cliOptions.plan, - thinking: false, + inputMode: 'prompt', + swarmMode: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -169,10 +221,15 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { theme: input.tuiConfig.theme, version: input.version, editorCommand: input.tuiConfig.editorCommand, + disablePasteBurst: input.tuiConfig.disablePasteBurst, notifications: input.tuiConfig.notifications, + upgrade: input.tuiConfig.upgrade, availableModels: {}, availableProviders: {}, sessionTitle: null, + goal: null, + mcpServersSummary: null, + banner: undefined, }; } @@ -182,6 +239,53 @@ interface SendMessageOptions { readonly hasMedia?: boolean; } +/** + * Flatten steer items into the payload `session.steer` expects: the + * historical `'\n\n'`-joined string when nothing carries media, or a + * merged part list when any item has extracted media parts (queued image + * messages, or the editor draft after placeholder extraction). + * + * Items are separated by the historical `'\n\n'`, which merges into the + * adjacent text part. The one exception is two touching media parts: a + * standalone `{type:'text',text:'\n\n'}` between them would be rejected + * by `normalizePromptInput` as an empty text part, so the separator is + * dropped there (media parts are self-delimiting anyway). + */ +function combineSteerInput(items: readonly SteerInputItem[]): string | PromptPart[] { + const hasMedia = items.some((item) => item.parts !== undefined && item.parts.length > 0); + if (!hasMedia) return items.map((item) => item.text).join('\n\n'); + const parts: PromptPart[] = []; + for (const item of items) { + const startsWithMedia = + item.parts !== undefined && item.parts.length > 0 && item.parts[0]?.type !== 'text'; + const lastIsMedia = parts.length > 0 && parts.at(-1)?.type !== 'text'; + if (parts.length > 0 && !(lastIsMedia && startsWithMedia)) { + appendSteerText(parts, '\n\n'); + } + if (item.parts !== undefined && item.parts.length > 0) { + for (const part of item.parts) { + if (part.type === 'text') appendSteerText(parts, part.text); + else parts.push(part); + } + } else { + appendSteerText(parts, item.text); + } + } + return parts; +} + +function appendSteerText(parts: PromptPart[], text: string): void { + const last = parts.at(-1); + if (last?.type === 'text') { + parts[parts.length - 1] = { type: 'text', text: last.text + text }; + return; + } + parts.push({ type: 'text', text }); +} + +/** How long the one-shot "moved to background" footer hint stays visible. */ +const DETACH_HINT_DISPLAY_MS = 4_000; + export class KimiTUI { readonly harness: KimiHarness; readonly options: KimiTUIOptions; @@ -192,15 +296,18 @@ export class KimiTUI { private readonly reverseRpcDisposers: Array<() => void> = []; private skillCommands: readonly KimiSlashCommand[] = []; readonly skillCommandMap = new Map<string, string>(); + private pluginCommands: readonly KimiSlashCommand[] = []; + readonly pluginCommandMap = new Map<string, string>(); private readonly imageStore = new ImageAttachmentStore(); - private readonly fdPath: string | null = detectFdPath(); - private readonly gitLsFilesCache: GitLsFilesCache; + private fdPath: string | null = detectFdPath(); + private fdDownloadStarted = false; sessionEventUnsubscribe: (() => void) | undefined; cancelInFlight: (() => void) | undefined; deferUserMessages = false; aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; + private clipboardImageHintController: ClipboardImageHintController | undefined; private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; @@ -208,14 +315,28 @@ export class KimiTUI { private readonly migrateOnly: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; + private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = + undefined; private lastHistoryContent: string | undefined; + // Live `!` shell output entries, keyed by commandId so concurrent commands + // each update their own card and stale events are dropped. Mutated in place + // as `shell.output` events arrive; removed when the command completes. + // `taskId` (from `shell.started`) lets ctrl+b detach the exact task. + private readonly shellOutputStreams = new Map< + string, + { entry: TranscriptEntry; component: ShellRunComponent; taskId?: string } + >(); readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; + readonly btwPanelController: BtwPanelController; readonly sessionEventHandler: SessionEventHandler; readonly sessionReplay: SessionReplayRenderer; readonly tasksBrowserController: TasksBrowserController; readonly editorKeyboard: EditorKeyboardController; + /** Timer that auto-clears the one-shot "moved to background" footer hint. */ + private detachHintClearTimer: ReturnType<typeof setTimeout> | undefined; + // The currently-mounted approval panel, if any. Kept so the full-screen // preview viewer can restore focus to the exact same instance (and its // selection / feedback state) when it closes. @@ -232,10 +353,10 @@ export class KimiTUI { public onExit?: (exitCode?: number) => Promise<void>; - track( - event: string, - properties?: Parameters<KimiHarness['track']>[1], - ): void { + /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ + public exitOpenUrl: string | undefined; + + track(event: string, properties?: Parameters<KimiHarness['track']>[1]): void { this.harness.track(event, properties); } @@ -252,7 +373,6 @@ export class KimiTUI { model: startupInput.cliOptions.model, startupNotice: startupInput.startupNotice, }, - resolvedTheme: startupInput.resolvedTheme, }; this.options = tuiOptions; this.migrationPlan = startupInput.migrationPlan ?? null; @@ -262,7 +382,6 @@ export class KimiTUI { this.uninstallRainbowDance = installRainbowDance(() => { this.state.ui.requestRender(); }); - this.gitLsFilesCache = createGitLsFilesCache(tuiOptions.initialAppState.workDir); this.reverseRpcDisposers.push( ...registerReverseRPCHandlers(this.approvalController, this.questionController, { @@ -282,6 +401,7 @@ export class KimiTUI { ); this.streamingUI = new StreamingUIController(this); this.authFlow = new AuthFlowController(this); + this.btwPanelController = new BtwPanelController(this); this.sessionEventHandler = new SessionEventHandler(this); this.sessionReplay = new SessionReplayRenderer(this); this.tasksBrowserController = new TasksBrowserController(this); @@ -298,21 +418,44 @@ export class KimiTUI { const builtins = sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter((command) => isExperimentalFlagEnabled(command.experimentalFlag), ); - return [...builtins, ...this.skillCommands]; + return [...builtins, ...this.skillCommands, ...this.pluginCommands]; } private setupAutocomplete(): void { - const slashCommands: SlashCommand[] = this.getSlashCommands().map((cmd) => ({ - name: cmd.name, - description: cmd.description, - })); + const slashCommands: SlashAutocompleteCommand[] = this.getSlashCommands().map((cmd) => { + const completer = cmd.completeArgs; + return { + name: cmd.name, + aliases: cmd.aliases, + description: cmd.description, + ...(cmd.argumentHint !== undefined ? { argumentHint: cmd.argumentHint } : {}), + ...(completer !== undefined + ? { getArgumentCompletions: (prefix: string) => completer(prefix) } + : {}), + }; + }); const provider = new FileMentionProvider( slashCommands, this.state.appState.workDir, this.fdPath, - this.gitLsFilesCache, + this.state.appState.additionalDirs, + () => this.state.appState.inputMode, ); this.state.editor.setAutocompleteProvider(provider); + + const argumentHints = new Map<string, string>(); + for (const cmd of slashCommands) { + if (cmd.argumentHint === undefined) continue; + argumentHints.set(cmd.name, cmd.argumentHint); + for (const alias of cmd.aliases ?? []) { + argumentHints.set(alias, cmd.argumentHint); + } + } + this.state.editor.setArgumentHints(argumentHints); + } + + refreshSlashCommandAutocomplete(): void { + this.setupAutocomplete(); } async refreshSkillCommands(session?: SkillListSession): Promise<void> { @@ -338,6 +481,29 @@ export class KimiTUI { this.setupAutocomplete(); } + async refreshPluginCommands(session?: Session): Promise<void> { + if (session === undefined) { + this.pluginCommands = []; + this.pluginCommandMap.clear(); + this.setupAutocomplete(); + return; + } + + let defs; + try { + defs = await session.listPluginCommands(); + } catch { + return; + } + const pluginSlashCommands = buildPluginSlashCommands(defs); + this.pluginCommands = pluginSlashCommands.commands; + this.pluginCommandMap.clear(); + for (const [commandName, body] of pluginSlashCommands.commandMap) { + this.pluginCommandMap.set(commandName, body); + } + this.setupAutocomplete(); + } + // ========================================================================= // Lifecycle // ========================================================================= @@ -353,14 +519,14 @@ export class KimiTUI { try { const migrationResult = await this.runMigrationScreen(this.migrationPlan); if (this.migrateOnly) { - const failed = - migrationResult.decision === 'now' && migrationResult.migrated === false; + const failed = migrationResult.decision === 'now' && migrationResult.migrated === false; this.disposeTerminalTracking(); this.state.ui.stop(); await this.onExit?.(failed ? 1 : 0); return; } const shouldReplayHistory = await this.initMainTui(); + this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); } catch (error) { this.disposeTerminalTracking(); @@ -373,6 +539,7 @@ export class KimiTUI { const shouldReplayHistory = await this.initMainTui(); this.startEventLoop(); try { + this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); } catch (error) { this.disposeTerminalTracking(); @@ -385,13 +552,60 @@ export class KimiTUI { } } + private async loadBanner(): Promise<void> { + const provider = new BannerProvider(this.state.appState.version); + const displayState = await readBannerDisplayState(); + const now = new Date(); + const banner = await provider.load(fetch, { + state: displayState, + now, + }); + this.state.appState.banner = banner; + if (banner === null) return; + + this.renderBanner(); + this.state.ui.requestRender(); + + if (banner.display === 'always') return; + try { + await writeBannerDisplayState({ + version: 1, + shown: { + ...displayState.shown, + [banner.key]: { lastShownAt: now.toISOString() }, + }, + }); + } catch { + // Best-effort: banner display state should never block startup. + } + } + + private renderBanner(): void { + if (this.state.appState.banner === null || this.state.appState.banner === undefined) { + return; + } + if (this.state.transcriptContainer.children.some((child) => child instanceof BannerComponent)) { + return; + } + const welcomeIndex = this.state.transcriptContainer.children.findIndex( + (child) => child instanceof WelcomeComponent, + ); + const banner = new BannerComponent(this.state.appState.banner); + if (welcomeIndex >= 0) { + this.state.transcriptContainer.children.splice(welcomeIndex + 1, 0, banner); + } else { + this.state.transcriptContainer.children.unshift(banner); + } + this.state.transcriptContainer.invalidate(); + } + private async initMainTui(): Promise<boolean> { const shouldReplayHistory = await this.init(); // Mount only after init() succeeds; see mountFooter(). this.mountFooter(); this.renderWelcome(); - setExperimentalFlags(await this.harness.getExperimentalFlags()); + void this.loadBanner(); this.setupAutocomplete(); void this.loadPersistedInputHistory(); this.state.editorContainer.clear(); @@ -401,25 +615,51 @@ export class KimiTUI { } private startEventLoop(): void { + // Dispose any previous focus/clipboard/theme tracking so re-entering the + // event loop (e.g. a future TUI reconnect) can't stack duplicate listeners. + this.disposeTerminalTracking(); this.state.ui.start(); + this.startClipboardImageHintController(); this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); this.refreshTerminalThemeTracking(); } + private startClipboardImageHintController(): void { + this.clipboardImageHintController = new ClipboardImageHintController({ + ui: this.state.ui, + footer: this.state.footer, + getModelSupportsImage: () => this.supportsCurrentModelCapability('image_in'), + requestRender: () => { + this.state.ui.requestRender(); + }, + }); + this.clipboardImageHintController.start(); + } + + private startBackgroundFdAutocomplete(): void { + if (this.fdPath !== null || this.fdDownloadStarted) return; + this.fdDownloadStarted = true; + + void ensureFdPath() + .then((fdPath) => { + if (fdPath === null) return; + this.fdPath = fdPath; + this.setupAutocomplete(); + }) + .catch(() => { + // Best-effort background bootstrap: autocomplete keeps using the filesystem fallback. + }); + } + private async refreshProviderModelsInBackground(): Promise<void> { try { const result = await this.authFlow.refreshProviderModels(); for (const c of result.changed) { - const parts: string[] = [c.providerName]; - if (c.added > 0) parts.push(`+${String(c.added)} model${c.added > 1 ? 's' : ''}`); - if (c.removed > 0) parts.push(`-${String(c.removed)} model${c.removed > 1 ? 's' : ''}`); - this.showStatus(parts.join(' · ') + '.'); + if (c.added <= 0) continue; + this.showStatus(`${c.providerName} · +${String(c.added)} model${c.added > 1 ? 's' : ''}.`); } for (const f of result.failed) { - this.showStatus( - `Skipped refreshing ${f.provider}: ${f.reason}`, - this.state.theme.colors.warning, - ); + this.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning'); } } catch { // Best-effort: startup must not crash on background refresh failures. @@ -438,28 +678,45 @@ export class KimiTUI { } if (shouldReplayHistory) { await this.sessionReplay.hydrateFromReplay(this.requireSession()); + this.applyStartupPermissionAndPlanToAppState(); } const resumeState = this.session?.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } if (this.session !== undefined) { this.sessionEventHandler.startSubscription(); + void this.showSessionWarnings(this.session); } void this.fetchSessions(); if (this.session !== undefined) { - this.refreshSessionTitle(); + this.updateTerminalTitle(); } void this.refreshSkillCommands(this.session); + void this.refreshPluginCommands(this.session); + } + + private async showSessionWarnings(session: Session): Promise<void> { + try { + const warnings = await session.getSessionWarnings(); + if (this.session !== session) return; + for (const warning of warnings) { + const severity = warning.severity === 'error' ? 'error' : 'warning'; + this.showStatus(`Warning: ${warning.message}`, severity); + } + } catch { + // Best-effort: startup must not block on warning retrieval. + } } private async showTmuxKeyboardWarningIfNeeded(): Promise<void> { const warning = await detectTmuxKeyboardWarning(); if (warning === undefined || this.aborted) return; - this.showStatus(warning, this.state.theme.colors.warning); + this.showStatus(warning, 'warning'); } private async init(): Promise<boolean> { + setExperimentalFeatures(await this.harness.getExperimentalFeatures()); await this.authFlow.refreshAvailableModels(); void this.refreshProviderModelsInBackground(); @@ -468,12 +725,15 @@ export class KimiTUI { let session: Session | undefined; let shouldReplayHistory = false; const isResumeStartup = startup.sessionFlag !== undefined || startup.continueLast; - const createSessionOptions: CreateSessionOptions = { + const createSessionOptions: MutableCreateSessionOptions = { workDir, model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, planMode: startup.plan ? true : undefined, }; + if (this.state.appState.additionalDirs.length > 0) { + createSessionOptions.additionalDirs = [...this.state.appState.additionalDirs]; + } try { if (isResumeStartup) { @@ -491,10 +751,11 @@ export class KimiTUI { if (target === undefined) { throw new Error(`Session "${startup.sessionFlag}" not found.`); } - if (target.workDir !== workDir) { + if (resolve(target.workDir) !== resolve(workDir)) { this.state.ui.stop(); process.stderr.write( - `${chalk.yellow( + `${currentTheme.fg( + 'warning', `Session "${startup.sessionFlag}" was created under a different directory.\n` + ` cd "${target.workDir}" && kimi -r ${startup.sessionFlag}`, )}\n\n`, @@ -503,13 +764,19 @@ export class KimiTUI { `Session "${startup.sessionFlag}" was created under a different directory.`, ); } - session = await this.harness.resumeSession({ id: startup.sessionFlag }); + session = await this.harness.resumeSession({ + id: startup.sessionFlag, + additionalDirs: createSessionOptions.additionalDirs, + }); shouldReplayHistory = true; } else { const sessions = await this.harness.listSessions({ workDir }); const target = sessions[0]; if (target !== undefined) { - session = await this.harness.resumeSession({ id: target.id }); + session = await this.harness.resumeSession({ + id: target.id, + additionalDirs: createSessionOptions.additionalDirs, + }); shouldReplayHistory = true; } else { session = await this.harness.createSession(createSessionOptions); @@ -522,8 +789,11 @@ export class KimiTUI { } else { session = await this.harness.createSession(createSessionOptions); } - if (session !== undefined && startup.model !== undefined && isResumeStartup) { - await session.setModel(startup.model); + if (session !== undefined && shouldReplayHistory) { + await this.applyStartupModesToResumedSession(session); + if (startup.model !== undefined) { + await session.setModel(startup.model); + } } } catch (error) { if (!isOAuthLoginRequiredError(error)) throw error; @@ -536,6 +806,7 @@ export class KimiTUI { } await this.setSession(session); await this.syncRuntimeState(session); + this.applyStartupPermissionAndPlanToAppState(); this.state.startupState = 'ready'; return shouldReplayHistory; } @@ -546,17 +817,42 @@ export class KimiTUI { this.unregisterSignalHandlers(); this.aborted = true; this.streamingUI.discardPending(); - this.editorKeyboard.clearPendingExit(); + // Stop background polling, streaming intervals, and per-component timers + // before tearing the UI down, so they can't keep firing requestRender after + // stop() returns (or leak when stop() runs without process.exit). + this.tasksBrowserController.close(); + this.btwPanelController.clear(); + this.stopActivitySpinner(); + this.streamingUI.disposeActiveCompactionBlock(); + this.streamingUI.resetToolUi(); + this.disposeTranscriptChildren(); + this.editorKeyboard.dispose(); + this.state.footer.dispose(); for (const dispose of this.reverseRpcDisposers) { dispose(); } this.reverseRpcDisposers.length = 0; this.disposeTerminalTracking(); - await this.closeSession('shutting down'); - await this.harness.close(); - this.sessionEventHandler.stopAllMcpServerStatusSpinners(); - this.uninstallRainbowDance(); - this.state.ui.stop(); + // Restore the terminal even if closing the session / harness throws — a + // SIGTERM during a network or MCP shutdown must not leave the user stuck in + // raw mode with a hidden cursor. + try { + await this.closeSession('shutting down'); + await this.harness.close(); + } finally { + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.uninstallRainbowDance(); + try { + await this.state.terminal.drainInput(); + } catch { + // best effort — the terminal may already be dead (SIGHUP / EIO). + } + try { + this.state.ui.stop(); + } catch { + // best effort terminal restore. + } + } if (this.onExit) { await this.onExit(exitCode); } @@ -620,11 +916,17 @@ export class KimiTUI { private emergencyTerminalExit(exitCode = 129): never { this.isShuttingDown = true; this.unregisterSignalHandlers(); + // Best-effort terminal restore: stop() may not have run (SIGHUP) or may + // have thrown (SIGTERM cleanup failure), so recover raw mode / cursor / + // bracketed paste before exiting instead of leaving the user's shell broken. + restoreTerminalModes(); process.exit(exitCode); } private disposeTerminalTracking(): void { this.stopTerminalThemeTracking(); + this.clipboardImageHintController?.stop(); + this.clipboardImageHintController = undefined; this.terminalFocusTrackingDispose?.(); this.terminalFocusTrackingDispose = undefined; } @@ -636,6 +938,7 @@ export class KimiTUI { ui.addChild(this.state.activityContainer); ui.addChild(this.state.todoPanelContainer); ui.addChild(this.state.queueContainer); + ui.addChild(this.state.btwPanelContainer); ui.addChild(this.state.editorContainer); // Footer is mounted later (mountFooter), not here. } @@ -659,17 +962,160 @@ export class KimiTUI { void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); } + handleInputModeChange(mode: 'prompt' | 'bash'): void { + this.setAppState({ inputMode: mode }); + this.updateEditorBorderHighlight(); + } + handleUserInput(text: string): void { + const wasBashMode = this.state.appState.inputMode === 'bash'; + if (wasBashMode) { + // A submit always exits bash mode (the `!` is consumed by this command). + this.state.editor.inputMode = 'prompt'; + this.handleInputModeChange('prompt'); + } if (text.trim().length === 0) return; if (this.state.appState.isReplaying) { this.showError('Cannot send input while session history is replaying.'); return; } - void this.persistInputHistory(text); + // Shell commands are stored with a leading `!` so ↑ recall can tell them + // apart from prompts and restore bash mode (see CustomEditor's mode-aware + // history navigation). The `!` is stripped again when the entry is recalled. + const historyText = wasBashMode ? `!${text}` : text; + void this.persistInputHistory(historyText); + if (wasBashMode) { + // Only one foreground action at a time: queue the shell command while + // another shell command is running or an agent turn is in progress. + if (this.state.appState.streamingPhase !== 'idle') { + this.enqueueMessage(text, undefined, 'bash'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + this.runShellCommandFromInput(text); + return; + } slashCommands.dispatchInput(this, text); } + private runShellCommandFromInput(command: string): void { + const session = this.session; + if (session === undefined) { + this.showError('No active session for shell command.'); + return; + } + // Echo the command locally (bash-input) with a `$` prompt. The agent also + // records it for resume; this is the live view. + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: currentTheme.fg('shellMode', `$ ${command}`), + bullet: '', + }); + // Create the live output entry up front. ShellRunComponent owns its own + // rendering (running card → final view) and is mutated in place as output + // streams in and on completion. + const commandId = nextTranscriptId(); + const outputEntry: TranscriptEntry = { + id: commandId, + kind: 'status', + turnId: undefined, + renderMode: 'plain', + content: '', + }; + const outputComponent = new ShellRunComponent(() => this.state.ui.requestRender()); + this.shellOutputStreams.set(commandId, { entry: outputEntry, component: outputComponent }); + this.state.transcriptEntries.push(outputEntry); + markTranscriptComponent(outputComponent, outputEntry); + this.state.transcriptContainer.addChild(outputComponent); + // Treat command execution as a streaming phase so input queues, the activity + // pane shows the moon spinner, and ctrl+b is enabled while it runs. + this.setAppState({ streamingPhase: 'shell' }); + this.state.ui.requestRender(); + + this.track('shell_command'); + + void session.runShellCommand(command, { commandId }).then( + ({ stdout, stderr, isError, backgrounded }) => { + this.finishShellOutput(commandId, stdout, stderr, isError, backgrounded); + }, + (error: unknown) => { + const message = formatErrorMessage(error); + this.finishShellOutput(commandId, '', message, true); + this.showError(`Shell command failed: ${message}`); + }, + ); + } + + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + const text = event.update.text ?? ''; + if (text.length === 0) return; + stream.component.append(text); + } + + handleShellStarted(event: { commandId: string; taskId: string }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + stream.taskId = event.taskId; + } + + cancelRunningShellCommand(): void { + const session = this.session; + if (session === undefined) return; + for (const commandId of this.shellOutputStreams.keys()) { + void session.cancelShellCommand(commandId).catch((error: unknown) => { + this.showError(`Failed to cancel shell command: ${formatErrorMessage(error)}`); + }); + } + } + + private finishShellOutput( + commandId: string, + stdout: string, + stderr: string, + isError?: boolean, + backgrounded?: boolean, + ): void { + const stream = this.shellOutputStreams.get(commandId); + if (stream === undefined) return; + if (backgrounded === true) { + // The command was moved to the background; detachRunningShellCommand owns + // the UI and the model notification, so there is nothing to render here. + return; + } + stream.component.finish(stdout, stderr, isError); + // Keep the transcript entry's metadata in sync for anything that reads it + // (export / copy). The component renders itself. + stream.entry.content = formatBashOutputForDisplay(stdout, stderr, isError); + this.shellOutputStreams.delete(commandId); + // When the last shell command finishes, leave the shell streaming phase, + // release one queued message (if any), and refresh the activity pane. + if (this.shellOutputStreams.size === 0) { + this.setAppState({ streamingPhase: 'idle' }); + this.drainOneQueuedMessage(); + } + } + + private drainOneQueuedMessage(): void { + const item = this.shiftQueuedMessage(); + if (item === undefined) return; + const session = this.session; + if (session === undefined) return; + if (item.mode === 'bash') { + this.runShellCommandFromInput(item.text); + } else { + this.sendQueuedMessage(session, item); + } + this.updateQueueDisplay(); + } + sendNormalUserInput(text: string): void { + if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { this.showError(LLM_NOT_SET_MESSAGE); return; @@ -694,9 +1140,11 @@ export class KimiTUI { this.state.ui.requestRender(); } - private validateMediaCapabilities( - extraction: ReturnType<typeof extractMediaAttachments>, - ): boolean { + validateMediaCapabilities(extraction: { + hasMedia: boolean; + imageAttachmentIds: readonly number[]; + videoAttachmentIds: readonly number[]; + }): boolean { if (!extraction.hasMedia) return true; if ( extraction.imageAttachmentIds.length > 0 && @@ -749,18 +1197,22 @@ export class KimiTUI { } } - recallLastQueued(): string | undefined { + recallLastQueued(): QueuedMessage | undefined { if (this.state.queuedMessages.length === 0) return undefined; const last = this.state.queuedMessages.at(-1)!; this.state.queuedMessages = this.state.queuedMessages.slice(0, -1); - return last.text; + return last; } // ========================================================================= // Session Requests / Queues // ========================================================================= - private enqueueMessage(text: string, options?: SendMessageOptions): void { + private enqueueMessage( + text: string, + options?: SendMessageOptions, + mode?: 'prompt' | 'bash', + ): void { this.state.queuedMessages.push({ text, agentId: this.harness.interactiveAgentId, @@ -769,6 +1221,7 @@ export class KimiTUI { options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 ? options.imageAttachmentIds : undefined, + mode, }); this.track('input_queue'); } @@ -797,13 +1250,22 @@ export class KimiTUI { } sendQueuedMessage(session: Session, item: QueuedMessage): void { - this.harness.interactiveAgentId = item.agentId ?? MAIN_AGENT_ID; - this.sendMessageInternal(session, item.text, { - parts: item.parts, - imageAttachmentIds: item.imageAttachmentIds, + if (item.mode === 'bash') { + this.runShellCommandFromInput(item.text); + return; + } + this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => { + this.sendMessageInternal(session, item.text, { + parts: item.parts, + imageAttachmentIds: item.imageAttachmentIds, + }); }); } + requestQueuedGoalPromotion(): void { + this.sessionEventHandler.requestQueuedGoalPromotion(); + } + private sendMessageInternal(session: Session, input: string, options?: SendMessageOptions): void { const imageAttachmentIds = options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 @@ -828,13 +1290,53 @@ export class KimiTUI { } sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { + // Args are a plain-text channel, so pasted media can't ride along as + // inline parts. Skill args are XML-escaped on render (renderSkillAttributes + // + expandSkillParameters), so rewrite placeholders into escape-proof + // plain-text file references the model can open with ReadMediaFile. + let rewrite: ReturnType<typeof rewriteMediaPlaceholders>; + try { + rewrite = rewriteMediaPlaceholders(skillArgs, this.imageStore, 'plain'); + } catch (error) { + // Cache copy failed (unwritable cache dir, vanished video source…); + // nothing has been dispatched yet, so just report and keep the input. + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + if (!this.validateMediaCapabilities(rewrite)) return; this.beginSessionRequest(); - void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { + void session.activateSkill(skillName, rewrite.text).catch((error: unknown) => { const message = formatErrorMessage(error); this.failSessionRequest(`Skill "${skillName}" failed: ${message}`); }); } + activatePluginCommand( + session: Session, + pluginId: string, + commandName: string, + args: string, + ): void { + // Plugin command args are expanded verbatim (no XML escaping), so the + // standard <image|video path> tag convention works — see + // sendSkillActivation for the escaped-channel variant. + let rewrite: ReturnType<typeof rewriteMediaPlaceholders>; + try { + rewrite = rewriteMediaPlaceholders(args, this.imageStore, 'tag'); + } catch (error) { + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + if (!this.validateMediaCapabilities(rewrite)) return; + this.beginSessionRequest(); + void session + .activatePluginCommand(pluginId, commandName, rewrite.text) + .catch((error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Command "${pluginId}:${commandName}" failed: ${message}`); + }); + } + private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { if ( this.deferUserMessages || @@ -847,31 +1349,35 @@ export class KimiTUI { this.sendMessageInternal(session, input, options); } - steerMessage(session: Session, input: string[]): void { + steerMessage(session: Session, input: readonly SteerInputItem[]): void { if (this.deferUserMessages || this.state.appState.isCompacting) { - for (const part of input) { - this.enqueueMessage(part); + for (const item of input) { + this.enqueueMessage(item.text, item); } return; } if (this.state.appState.streamingPhase === 'idle') { - for (const part of input) { - this.sendMessageInternal(session, part); + for (const item of input) { + this.sendMessageInternal(session, item.text, item); } return; } - for (const part of input) { + for (const item of input) { this.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'user', turnId: this.streamingUI.getTurnContext().turnId, renderMode: 'plain', - content: part, + content: item.text, + imageAttachmentIds: + item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0 + ? item.imageAttachmentIds + : undefined, }); } - void session.steer(input.join('\n\n')).catch((error: unknown) => { + void session.steer(combineSteerInput(input)).catch((error: unknown) => { const message = formatErrorMessage(error); this.showError(`Failed to steer: ${message}`); }); @@ -924,6 +1430,10 @@ export class KimiTUI { return this.state.transcriptEntries.length > 0; } + setExitOpenUrl(url: string): void { + this.exitOpenUrl = url; + } + async getStartupMcpMs(): Promise<number> { const session = this.session; if (session === undefined) return 0; @@ -937,12 +1447,19 @@ export class KimiTUI { setAppState(patch: Partial<AppState>): void { if (!hasPatchChanges(this.state.appState, patch)) return; + const additionalDirsChanged = + 'additionalDirs' in patch && + !sameStringArrays(this.state.appState.additionalDirs, patch.additionalDirs ?? []); const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; Object.assign(this.state.appState, patch); if ('planMode' in patch) this.updateEditorBorderHighlight(); this.state.footer.setState(this.state.appState); this.updateActivityPane(); - if (busyChanged) this.updateQueueDisplay(); + if (busyChanged) { + this.updateQueueDisplay(); + this.sessionEventHandler.retryQueuedGoalPromotion(); + } + if (additionalDirsChanged) this.setupAutocomplete(); this.state.ui.requestRender(); } @@ -959,6 +1476,12 @@ export class KimiTUI { this.state.ui.requestRender(); } + private syncAdditionalDirs(session: Session): void { + const additionalDirs = session.summary?.additionalDirs ?? []; + if (sameStringArrays(this.state.appState.additionalDirs, additionalDirs)) return; + this.setAppState({ additionalDirs: [...additionalDirs] }); + } + // ========================================================================= // Session Runtime // ========================================================================= @@ -975,14 +1498,17 @@ export class KimiTUI { if (model.length === 0) { throw new Error(LLM_NOT_SET_MESSAGE); } - return this.harness.createSession({ + const options: MutableCreateSessionOptions = { workDir: this.state.appState.workDir, model, - thinking: - this.session === undefined ? undefined : this.state.appState.thinking ? 'on' : 'off', + thinking: this.session === undefined ? undefined : this.state.appState.thinkingEffort, permission: this.state.appState.permissionMode, planMode: this.state.appState.planMode ? true : undefined, - }); + }; + if (this.state.appState.additionalDirs.length > 0) { + options.additionalDirs = [...this.state.appState.additionalDirs]; + } + return this.harness.createSession(options); } async setSession(session: Session): Promise<void> { @@ -991,21 +1517,59 @@ export class KimiTUI { this.session = session; this.harness.setTelemetryContext({ sessionId: session.id }); this.registerSessionHandlers(session); + this.syncAdditionalDirs(session); } async syncRuntimeState(session: Session = this.requireSession()): Promise<void> { - const status = await session.getStatus(); + const [status, goalResult] = await Promise.all([session.getStatus(), session.getGoal()]); this.setAppState({ sessionId: session.id, model: status.model ?? '', - thinking: status.thinkingLevel !== 'off', + thinkingEffort: status.thinkingEffort, permissionMode: status.permission, planMode: status.planMode, + swarmMode: status.swarmMode ?? false, contextTokens: status.contextTokens, maxContextTokens: status.maxContextTokens, contextUsage: status.contextUsage, sessionTitle: session.summary?.title ?? null, + goal: goalResult.goal, }); + this.syncAdditionalDirs(session); + } + + // Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed + // session may already be in plan mode from its persisted records, and + // re-entering plan mode throws, so only enable it when it is not active yet. + // setPermission is idempotent and needs no such guard. + private async applyStartupModesToResumedSession(session: Session): Promise<void> { + const { startup } = this.options; + if (startup.auto) { + await session.setPermission('auto'); + } else if (startup.yolo) { + await session.setPermission('yolo'); + } + if (startup.plan) { + const status = await session.getStatus(); + if (!status.planMode) { + await session.setPlanMode(true); + } + } + } + + // Re-apply startup flags that the user explicitly passed on the command line. + // syncRuntimeState and session-replay hydration can both read stale persisted + // values, so this guarantees the footer reflects the CLI intent. + private applyStartupPermissionAndPlanToAppState(): void { + const { startup } = this.options; + if (startup.auto) { + this.setAppState({ permissionMode: 'auto' }); + } else if (startup.yolo) { + this.setAppState({ permissionMode: 'yolo' }); + } + if (startup.plan) { + this.setAppState({ planMode: true }); + } } // Plan mode is set by createSession — do not re-enter it here. @@ -1030,7 +1594,9 @@ export class KimiTUI { this.approvalController.cancelAll(reason); this.questionController.cancelAll(reason); this.session = undefined; + this.state.swarmModeEntry = undefined; this.harness.setTelemetryContext({ sessionId: null }); + this.setAppState({ goal: null }); return previous; } @@ -1038,6 +1604,7 @@ export class KimiTUI { for (const dispose of this.reverseRpcDisposers) { dispose(); } + this.reverseRpcDisposers.length = 0; } private registerSessionHandlers(session: Session): void { @@ -1049,10 +1616,14 @@ export class KimiTUI { session.setQuestionHandler(createQuestionAskHandler(this.questionController)); } - async fetchSessions(): Promise<void> { + async fetchSessions(scope: 'cwd' | 'all' = this.state.sessionsScope): Promise<void> { this.state.loadingSessions = true; + this.state.sessionsScope = scope; try { - const sessions = await this.harness.listSessions({ workDir: this.state.appState.workDir }); + const sessions = + scope === 'all' + ? await this.harness.listSessions({}) + : await this.harness.listSessions({ workDir: this.state.appState.workDir }); this.state.sessions = sessionRowsForPicker( sessions, this.state.appState.sessionId, @@ -1065,27 +1636,43 @@ export class KimiTUI { } } - refreshSessionTitle(): void { - setProcessTitle(this.state.appState.sessionTitle, this.state.appState.sessionId); + updateTerminalTitle(): void { + const trimmed = this.state.appState.sessionTitle?.trim() ?? ''; + const label = trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME; + this.state.terminal.setTitle(label); } resetSessionRuntime(): void { this.aborted = false; this.streamingUI.discardPending(); this.state.queuedMessages = []; - this.harness.interactiveAgentId = MAIN_AGENT_ID; + this.state.swarmModeEntry = undefined; this.streamingUI.resetToolCallState(); this.streamingUI.resetToolUi(); this.sessionEventHandler.resetRuntimeState(); this.tasksBrowserController.close(); + this.btwPanelController.clear(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); this.streamingUI.setTodoList([]); this.streamingUI.setTurnId(undefined); + this.setAppState({ mcpServersSummary: null }); this.streamingUI.setStep(0); this.streamingUI.resetLiveText(); this.updateQueueDisplay(); } + private async showResumeOtherWorkDirHint(session: SessionRow): Promise<void> { + this.hideSessionPicker(); + const command = `cd ${quoteShellArg(session.work_dir)} && kimi --resume ${quoteShellArg(session.id)}`; + const message = `Current session is in a different working directory.\n To resume, run: ${command}`; + try { + await copyTextToClipboard(command); + this.showStatus(`${message}\n Command copied to clipboard`, 'warning'); + } catch { + this.showStatus(`${message}\n Failed to copy command to clipboard`, 'warning'); + } + } + private async resumeSession(targetSessionId: string): Promise<boolean> { if (targetSessionId === this.state.appState.sessionId) { this.showStatus('Already on this session.'); @@ -1117,9 +1704,10 @@ export class KimiTUI { this.resetSessionRuntime(); await this.setSession(session); await this.syncRuntimeState(session); - this.refreshSessionTitle(); + this.updateTerminalTitle(); try { await this.refreshSkillCommands(this.session); + await this.refreshPluginCommands(this.session); } catch { /* keep the switched session usable even if dynamic skills fail */ } @@ -1134,9 +1722,40 @@ export class KimiTUI { } const resumeState = session.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } this.showStatus(statusMessage); + void this.showSessionWarnings(session); + } + + async reloadCurrentSessionView(session: Session, statusMessage: string): Promise<void> { + this.sessionEventUnsubscribe?.(); + this.sessionEventUnsubscribe = undefined; + this.clearReverseRpcPanels(); + session.setApprovalHandler(undefined); + session.setQuestionHandler(undefined); + this.approvalController.cancelAll('reloading session'); + this.questionController.cancelAll('reloading session'); + + this.resetSessionRuntime(); + this.session = session; + this.harness.setTelemetryContext({ sessionId: session.id }); + this.registerSessionHandlers(session); + await this.syncRuntimeState(session); + this.updateTerminalTitle(); + try { + await this.refreshSkillCommands(session); + await this.refreshPluginCommands(session); + } catch { + /* keep the reloaded session usable even if dynamic skills fail */ + } + this.sessionEventHandler.startSubscription(); + const resumeState = session.getResumeState(); + if (resumeState?.warning !== undefined) { + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); + } + this.showStatus(statusMessage); + void this.showSessionWarnings(session); } async createNewSession(): Promise<void> { @@ -1168,12 +1787,27 @@ export class KimiTUI { } try { await this.refreshSkillCommands(this.session); + await this.refreshPluginCommands(this.session); } catch { /* keep the new session usable even if dynamic skills fail */ } this.sessionEventHandler.startSubscription(); this.clearTranscriptAndRedraw(); this.showStatus(`Started a new session (${session.id}).`); + void this.showSessionWarnings(session); + void this.showConfigWarningsIfAny(); + } + + /** Surface config.toml load warnings (degraded or kept-previous config) in the status bar. */ + private async showConfigWarningsIfAny(): Promise<void> { + try { + const { warnings } = await this.harness.getConfigDiagnostics(); + for (const warning of warnings) { + this.showStatus(warning, 'warning'); + } + } catch { + /* diagnostics are best-effort */ + } } // ========================================================================= @@ -1183,12 +1817,15 @@ export class KimiTUI { private createTranscriptComponent(entry: TranscriptEntry): Component | null { if (entry.compactionData !== undefined) { const data = entry.compactionData; - const block = new CompactionComponent( - this.state.theme.colors, - this.state.ui, - data.instruction, - ); - block.markDone(data.tokensBefore, data.tokensAfter); + const block = new CompactionComponent(this.state.ui, data.instruction); + if (data.result === 'cancelled') { + block.markCanceled(); + } else { + block.markDone(data.tokensBefore, data.tokensAfter, data.summary); + if (this.state.toolOutputExpanded) { + block.setExpanded(true); + } + } return block; } @@ -1197,30 +1834,39 @@ export class KimiTUI { const images = entry.imageAttachmentIds ?.map((id) => this.imageStore.get(id)) .filter((a): a is ImageAttachment => a?.kind === 'image'); - return new UserMessageComponent(entry.content, this.state.theme.colors, images); + return new UserMessageComponent(entry.content, images, entry.bullet); } case 'skill_activation': return new SkillActivationComponent( entry.skillName ?? entry.content, entry.skillArgs, - this.state.theme.colors, + entry.skillTrigger, ); + case 'plugin_command': { + const data = entry.pluginCommandData; + if (data === undefined) return null; + return new PluginCommandComponent(data.pluginId, data.commandName, data.args); + } case 'cron': - return new CronMessageComponent( - entry.content, - entry.cronData ?? {}, - this.state.theme.colors, - ); + return new CronMessageComponent(entry.content, entry.cronData ?? {}); + case 'goal': + if (entry.goalData?.kind === 'created') { + return new GoalSetMessageComponent(); + } + if (entry.goalData?.kind === 'lifecycle') { + return buildGoalMarker(entry.goalData.change, this.state.toolOutputExpanded); + } + return null; case 'assistant': { - const component = new AssistantMessageComponent( - this.state.theme.markdownTheme, - this.state.theme.colors, - ); + if (entry.content.trimStart().startsWith('✓ Goal complete')) { + return new GoalCompletionMessageComponent(entry.content); + } + const component = new AssistantMessageComponent(); component.updateContent(entry.content); return component; } case 'thinking': { - const thinking = new ThinkingComponent(entry.content, this.state.theme.colors, true); + const thinking = new ThinkingComponent(entry.content, true); if (this.state.toolOutputExpanded) thinking.setExpanded(true); return thinking; } @@ -1229,34 +1875,25 @@ export class KimiTUI { const tc = new ToolCallComponent( entry.toolCallData, entry.toolCallData.result, - this.state.theme.colors, this.state.ui, - this.state.theme.markdownTheme, this.state.appState.workDir, ); if (this.state.toolOutputExpanded) tc.setExpanded(true); - if (this.state.planExpanded) tc.setPlanExpanded(true); return tc; } if (entry.backgroundAgentStatus !== undefined) { - return new BackgroundAgentStatusComponent( - entry.backgroundAgentStatus, - this.state.theme.colors, - ); + return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); } return entry.renderMode === 'notice' - ? new NoticeMessageComponent(entry.content, entry.detail, this.state.theme.colors) - : new StatusMessageComponent(entry.content, this.state.theme.colors, entry.color); + ? new NoticeMessageComponent(entry.content, entry.detail) + : new StatusMessageComponent(entry.content, entry.color); case 'status': if (entry.backgroundAgentStatus !== undefined) { - return new BackgroundAgentStatusComponent( - entry.backgroundAgentStatus, - this.state.theme.colors, - ); + return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); } return entry.renderMode === 'notice' - ? new NoticeMessageComponent(entry.content, entry.detail, this.state.theme.colors) - : new StatusMessageComponent(entry.content, this.state.theme.colors, entry.color); + ? new NoticeMessageComponent(entry.content, entry.detail) + : new StatusMessageComponent(entry.content, entry.color); case 'welcome': return null; default: @@ -1268,13 +1905,26 @@ export class KimiTUI { this.state.transcriptEntries.push(entry); const component = this.createTranscriptComponent(entry); if (component) { + markTranscriptComponent(component, entry); this.state.transcriptContainer.addChild(component); + } + const trimmed = this.trimTranscriptWindow(); + const merged = this.mergeCurrentTurnSteps(); + if (component || trimmed || merged) { this.state.ui.requestRender(); } } - private appendApprovalTranscriptEntry(request: ApprovalRequest, response: ApprovalResponse): void { - if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return; + private appendApprovalTranscriptEntry( + request: ApprovalRequest, + response: ApprovalResponse, + ): void { + if ( + request.toolName === 'ExitPlanMode' || + request.display.kind === 'plan_review' || + request.display.kind === 'goal_start' + ) + return; const parts: string[] = []; switch (response.decision) { case 'approved': @@ -1294,13 +1944,19 @@ export class KimiTUI { this.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'status', + turnId: request.turnId === undefined ? undefined : String(request.turnId), renderMode: 'notice', content: parts.join(''), }); } private renderWelcome(): void { - const welcome = new WelcomeComponent(this.state.appState, this.state.theme.colors); + if ( + this.state.transcriptContainer.children.some((child) => child instanceof WelcomeComponent) + ) { + return; + } + const welcome = new WelcomeComponent(this.state.appState); this.state.transcriptContainer.addChild(welcome); } @@ -1309,6 +1965,16 @@ export class KimiTUI { this.state.terminal.write(deleteAllKittyImages()); } + private disposeTranscriptChildren(): void { + // Dispose disposable children (e.g. ShellRunComponent's 1s timer, + // ThinkingComponent's spinner) before dropping them, so a /clear, session + // switch, or shutdown can't leak intervals that keep firing requestRender + // on a removed component. + for (const child of this.state.transcriptContainer.children) { + if (hasDispose(child)) child.dispose(); + } + } + private clearTranscriptAndRedraw(): void { this.streamingUI.discardPending(); this.state.transcriptEntries = []; @@ -1316,30 +1982,259 @@ export class KimiTUI { this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.disposeTranscriptChildren(); this.state.transcriptContainer.clear(); + this.btwPanelController.clear(); this.clearTerminalInlineImages(); this.state.todoPanel.clear(); this.state.todoPanelContainer.clear(); this.imageStore.clear(); this.renderWelcome(); + // Session resets (/new, /clear, session switch) want a pristine screen. + // Force a destructive full render: the renderer's collapse repaint + // intentionally preserves scrollback, which would leave the previous + // session's text above the welcome banner. + this.state.ui.requestRender(true); } - showStatus(message: string, color?: string): void { - this.state.transcriptContainer.addChild( - new StatusMessageComponent(message, this.state.theme.colors, color), - ); + private isTurnBoundaryComponent(child: Component): boolean { + if ( + !(child instanceof UserMessageComponent) && + !(child instanceof SkillActivationComponent) && + !(child instanceof PluginCommandComponent) + ) { + return false; + } + const entry = getTranscriptComponentEntry(child); + if (entry === undefined) return false; + // Live user messages / slash activations have an undefined turnId; replayed + // ones get a `replay:N` turnId. Both start a new turn. Steer messages carry + // a defined non-replay turnId and are not boundaries. + return entry.turnId === undefined || entry.turnId.startsWith('replay:'); + } + + private trimTranscriptWindow(): boolean { + if (!TRANSCRIPT_WINDOW_ENABLED || TRANSCRIPT_MAX_TURNS <= 0) return false; + // Session replay already caps history to its own turn limit; trimming during + // replay would shrink it further and fight that limit. + if (this.state.appState.isReplaying) return false; + + const children = this.state.transcriptContainer.children; + + // Trim whole turns by *position* in the child list rather than by entry + // lookup — otherwise only the (registered) user message would be removed and + // the rest of the turn would be left behind. + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + + const turns = groupTurns(this.state.transcriptEntries); + + const toRemove = turnsToTrim(turns, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_HYSTERESIS); + if (toRemove.size === 0) return false; + + // Reclaim image bytes referenced by trimmed user messages. The transcript + // renders historical thumbnails via imageStore.get(id), so an attachment can + // only be dropped once its owning user message leaves the transcript. + for (const entry of toRemove) { + if (entry.kind === 'user' && entry.imageAttachmentIds !== undefined) { + this.imageStore.removeMany(entry.imageAttachmentIds); + } + } + + let boundariesToRemove = 0; + for (const entry of toRemove) { + if ( + (entry.kind === 'user' || + entry.kind === 'skill_activation' || + entry.kind === 'plugin_command') && + entry.turnId === undefined + ) { + boundariesToRemove++; + } + } + if (boundariesToRemove === 0) { + this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e)); + return true; + } + + let boundariesSeen = 0; + let cutoff = 0; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) { + if (boundariesSeen === boundariesToRemove) { + cutoff = i; + break; + } + boundariesSeen++; + } + } + + const componentsToRemove: Component[] = []; + for (let i = 0; i < cutoff; i++) { + const child = children[i]!; + if (child instanceof WelcomeComponent) continue; + componentsToRemove.push(child); + } + for (const child of componentsToRemove) { + // pi-tui Container.removeChild (not a DOM node); `child.remove()` does not exist. + // oxlint-disable-next-line unicorn/prefer-dom-node-remove + this.state.transcriptContainer.removeChild(child); + if (hasDispose(child)) child.dispose(); + } + + this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e)); + return true; + } + + mergeCurrentTurnSteps(): boolean { + if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return false; + const children = this.state.transcriptContainer.children; + + // Find the start of the current turn (last turn-starting user message). + let turnStart = -1; + for (let i = children.length - 1; i >= 0; i--) { + if (this.isTurnBoundaryComponent(children[i]!)) { + turnStart = i; + break; + } + } + if (turnStart < 0) return false; + + // Locate an existing summary, the assistant message, and the mergeable steps. + let summaryIndex = -1; + const stepIndices: number[] = []; + for (let i = turnStart + 1; i < children.length; i++) { + const child = children[i]!; + if (child instanceof StepSummaryComponent) { + summaryIndex = i; + continue; + } + if (child instanceof AssistantMessageComponent) continue; + stepIndices.push(i); + } + + if (stepIndices.length <= TRANSCRIPT_KEEP_RECENT_STEPS) return false; + const mergeCount = stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS; + const toMergeIndices = stepIndices.slice(0, mergeCount); + + let thinkingCount = 0; + let toolCount = 0; + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (child instanceof ThinkingComponent) thinkingCount++; + else if (child instanceof ToolCallComponent) toolCount++; + } + if (thinkingCount === 0 && toolCount === 0) return false; + + let summary: StepSummaryComponent; + if (summaryIndex >= 0) { + summary = children[summaryIndex] as StepSummaryComponent; + summary.addCounts(thinkingCount, toolCount); + } else { + summary = new StepSummaryComponent(); + summary.addCounts(thinkingCount, toolCount); + } + + // Rebuild children: keep everything except the merged steps, with the summary + // sitting right after the user message. + const toMergeSet = new Set(toMergeIndices); + const newChildren: Component[] = []; + for (let i = 0; i <= turnStart; i++) newChildren.push(children[i]!); + newChildren.push(summary); + for (let i = turnStart + 1; i < children.length; i++) { + if (i === summaryIndex) continue; + if (toMergeSet.has(i)) continue; + newChildren.push(children[i]!); + } + + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (hasDispose(child)) child.dispose(); + } + + children.splice(0, children.length, ...newChildren); + return true; + } + + mergeAllTurnSteps(): void { + if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return; + const children = this.state.transcriptContainer.children; + + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + if (boundaries.length === 0) return; + + const newChildren: Component[] = []; + const toDispose: Component[] = []; + for (let i = 0; i < boundaries[0]!; i++) newChildren.push(children[i]!); + + for (let t = 0; t < boundaries.length; t++) { + const turnStart = boundaries[t]!; + const turnEnd = t + 1 < boundaries.length ? boundaries[t + 1]! : children.length; + newChildren.push(children[turnStart]!); + + let summaryIndex = -1; + const stepIndices: number[] = []; + for (let i = turnStart + 1; i < turnEnd; i++) { + const child = children[i]!; + if (child instanceof StepSummaryComponent) summaryIndex = i; + else if (child instanceof AssistantMessageComponent) continue; + else stepIndices.push(i); + } + + if (stepIndices.length > TRANSCRIPT_KEEP_RECENT_STEPS) { + const mergeCount = stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS; + const toMergeIndices = stepIndices.slice(0, mergeCount); + let thinkingCount = 0; + let toolCount = 0; + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (child instanceof ThinkingComponent) thinkingCount++; + else if (child instanceof ToolCallComponent) toolCount++; + } + let summary: StepSummaryComponent; + if (summaryIndex >= 0) { + summary = children[summaryIndex] as StepSummaryComponent; + summary.addCounts(thinkingCount, toolCount); + } else { + summary = new StepSummaryComponent(); + summary.addCounts(thinkingCount, toolCount); + } + newChildren.push(summary); + for (const idx of toMergeIndices) toDispose.push(children[idx]!); + const toMergeSet = new Set(toMergeIndices); + for (let i = turnStart + 1; i < turnEnd; i++) { + if (i === summaryIndex) continue; + if (toMergeSet.has(i)) continue; + newChildren.push(children[i]!); + } + } else { + for (let i = turnStart + 1; i < turnEnd; i++) newChildren.push(children[i]!); + } + } + + for (const child of toDispose) { + if (hasDispose(child)) child.dispose(); + } + children.splice(0, children.length, ...newChildren); + } + + showStatus(message: string, color?: ColorToken): void { + this.state.transcriptContainer.addChild(new StatusMessageComponent(message, color)); this.state.ui.requestRender(); } showNotice(title: string, detail?: string): void { - this.state.transcriptContainer.addChild( - new NoticeMessageComponent(title, detail, this.state.theme.colors), - ); + this.state.transcriptContainer.addChild(new NoticeMessageComponent(title, detail)); this.state.ui.requestRender(); } showError(message: string): void { - this.showStatus(`Error: ${message}`, this.state.theme.colors.error); + this.showStatus(`Error: ${message}`, 'error'); } showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { @@ -1347,7 +2242,7 @@ export class KimiTUI { } showProgressSpinner(label: string): LoginProgressSpinnerHandle { - const tint = (s: string): string => chalk.hex(this.state.theme.colors.primary)(s); + const tint = (s: string): string => currentTheme.fg('primary', s); const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); this.state.transcriptContainer.addChild(new Spacer(1)); this.state.transcriptContainer.addChild(spinner); @@ -1355,11 +2250,14 @@ export class KimiTUI { return { stop: ({ ok, label: finalLabel }) => { spinner.stop(); - const tone = ok ? this.state.theme.colors.success : this.state.theme.colors.error; + const tone = ok ? 'success' : 'error'; const symbol = ok ? '✓' : '✗'; - spinner.setText(chalk.hex(tone)(`${symbol} ${finalLabel}`)); + spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`)); this.state.ui.requestRender(); }, + setLabel: (nextLabel) => { + spinner.setLabel(nextLabel); + }, }; } @@ -1371,7 +2269,6 @@ export class KimiTUI { url: auth.verificationUriComplete, code: auth.userCode, hint: 'Press Ctrl-C to cancel', - colors: this.state.theme.colors, }), ); this.state.ui.requestRender(); @@ -1384,55 +2281,87 @@ export class KimiTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); + const tipKind = loadingTipKind(effectiveMode); + // Pick a fresh loading tip when the loading kind changes. The same kind + // covers waiting/tool (both moon spinners) and any intermediate thinking + // phase, so a continuous burst of tool calls does not flip tips. Clear the + // cache only when there is no loading UI at all. + if (effectiveMode === 'idle' || effectiveMode === 'session' || effectiveMode === 'hidden') { + this.currentLoadingTip = undefined; + } else if ( + tipKind !== undefined && + (this.currentLoadingTip === undefined || this.currentLoadingTip.kind !== tipKind) + ) { + const previousTip = this.currentLoadingTip?.tip; + this.currentLoadingTip = { + kind: tipKind, + tip: pickRandomWorkingTip(previousTip)?.text, + }; + } this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); + const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode); + const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}`; if ( - effectiveMode === this.lastActivityMode && + activityModeKey === this.lastActivityMode && (effectiveMode === 'waiting' || effectiveMode === 'thinking' || effectiveMode === 'tool') ) { + if (placeSpinnerInAgentSwarm) { + this.syncAgentSwarmActivitySpinner(this.state.activitySpinner?.instance); + } return; } - this.lastActivityMode = effectiveMode; + this.lastActivityMode = activityModeKey; this.state.activityContainer.clear(); switch (effectiveMode) { case 'hidden': this.stopActivitySpinner(); + this.syncAgentSwarmActivitySpinner(undefined); this.state.ui.requestRender(); return; case 'waiting': { const spinner = this.ensureActivitySpinner('moon'); + this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); + if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'waiting', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; } case 'thinking': { this.stopActivitySpinner(); + this.syncAgentSwarmActivitySpinner(undefined); break; } case 'composing': { const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => - chalk.hex(this.state.theme.colors.primary)(s), + currentTheme.fg('primary', s), ); + this.syncAgentSwarmActivitySpinner(undefined); this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'composing', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; } case 'tool': { const spinner = this.ensureActivitySpinner('moon'); + this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); + if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'tool', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -1440,6 +2369,11 @@ export class KimiTUI { case 'idle': case 'session': { this.stopActivitySpinner(); + this.syncAgentSwarmActivitySpinner(undefined); + // Keep a placeholder row so the activity area does not fully shrink + // when the spinner is removed at the end of streaming; combined with + // pi-tui's clamp, this avoids a destructive full redraw (viewport jump). + this.state.activityContainer.addChild(new Spacer(1)); break; } } @@ -1453,6 +2387,11 @@ export class KimiTUI { if (this.state.livePane.pendingQuestion !== null) return 'hidden'; const streamingPhase = this.state.appState.streamingPhase; + + // A running `!` shell command shows the moon spinner (same as `waiting`) + // until it finishes, signalling that input is busy / queued. + if (streamingPhase === 'shell') return 'waiting'; + if (this.state.livePane.mode === 'idle') { if (streamingPhase === 'thinking' || streamingPhase === 'composing') { return streamingPhase; @@ -1470,7 +2409,6 @@ export class KimiTUI { this.state.queueContainer.addChild( new QueuePaneComponent({ messages: queued, - colors: this.state.theme.colors, isCompacting: this.state.appState.isCompacting, isStreaming: this.state.appState.streamingPhase !== 'idle', canSteerImmediately: !this.deferUserMessages, @@ -1480,56 +2418,177 @@ export class KimiTUI { toggleToolOutputExpansion(): void { this.state.toolOutputExpanded = !this.state.toolOutputExpanded; - for (const child of this.state.transcriptContainer.children) { - if (isExpandable(child)) { - child.setExpanded(this.state.toolOutputExpanded); - } + const children = this.state.transcriptContainer.children; + + // A component is expandable only if it sits at or after the start of the + // (totalTurns - expandTurns)-th turn — i.e. it belongs to one of the most + // recent `expandTurns` turns. Position-based so it also covers streaming + // components that have no entry in the metadata map. + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); } + const expandCutoff = + TRANSCRIPT_EXPAND_TURNS <= 0 + ? children.length + : boundaries.length > TRANSCRIPT_EXPAND_TURNS + ? boundaries[boundaries.length - TRANSCRIPT_EXPAND_TURNS]! + : 0; + + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + if (!isExpandable(child)) continue; + child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); + } + // Expanding/collapsing shifts content above the viewport; the clamped + // differential render would paint a second copy below the stale one in + // scrollback. This is a deliberate user action (like /clear), so do a + // destructive full render: scrollback holds exactly one copy and the + // expanded output can be read by scrolling up. + this.state.ui.requestRender(true); + } + + toggleTodoPanelExpansion(): void { + this.state.todoPanel.toggleExpanded(); this.state.ui.requestRender(); } - // Returns true when at least one card toggled, so the caller can consume the keystroke. - togglePlanExpansion(): boolean { - const next = !this.state.planExpanded; - let toggled = false; - for (const child of this.state.transcriptContainer.children) { - if (isPlanExpandable(child) && child.setPlanExpanded(next)) { - toggled = true; + private async detachRunningShellCommand(): Promise<void> { + // Only one `!` command runs at a time (input is queued while busy). + const next = this.shellOutputStreams.entries().next(); + if (next.done) { + this.showDetachHint('No shell command running.'); + return; + } + const [commandId, stream] = next.value; + if (stream.taskId === undefined) { + this.showDetachHint('Command is still starting — try again.'); + return; + } + const session = this.session; + if (session === undefined) return; + try { + const info = await session.detachBackgroundTask(stream.taskId); + if (info === undefined) { + this.showDetachHint('Command already finished.'); + return; + } + } catch (error) { + this.showError(`Failed to move to background: ${formatErrorMessage(error)}`); + return; + } + // Finalize the card as backgrounded and drop the stream so the eventual + // runShellCommand resolution (which carries background metadata) is a no-op + // instead of overwriting this view. + stream.component.finishBackgrounded(); + stream.entry.content = 'Moved to background.'; + this.shellOutputStreams.delete(commandId); + // The backgrounded command's notification turn (started by agent-core via + // appendSystemReminderAndNotify) owns the streaming phase and drains the + // queue when it completes, so we intentionally leave both untouched here. + this.showDetachHint('Moved to background. /tasks to view.'); + } + + async detachCurrentForegroundTask(): Promise<void> { + // A running `!` shell command takes priority over agent foreground tasks. + if (this.shellOutputStreams.size > 0) { + await this.detachRunningShellCommand(); + return; + } + + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + let tasks: readonly BackgroundTaskInfo[]; + try { + // activeOnly defaults to true; foreground running tasks are non-terminal + // and therefore included. We filter to `detached === false` ourselves. + tasks = await session.listBackgroundTasks(); + } catch (error) { + this.showError(`Failed to list tasks: ${formatErrorMessage(error)}`); + return; + } + + const targets = pickForegroundTasks(tasks); + if (targets.length === 0) { + this.showDetachHint('No foreground task running.'); + return; + } + + let detached = 0; + let alreadyFinished = 0; + for (const target of targets) { + try { + const info = await session.detachBackgroundTask(target.taskId); + if (info === undefined) alreadyFinished++; + else detached++; + } catch (error) { + this.showError(`Failed to detach ${target.taskId}: ${formatErrorMessage(error)}`); } } - if (!toggled) return false; - this.state.planExpanded = next; + + let hint: string; + if (detached === 0 && alreadyFinished > 0) { + hint = alreadyFinished === 1 ? 'Task already finished.' : 'Tasks already finished.'; + } else if (detached === targets.length) { + hint = + detached === 1 ? 'Moved 1 task to background.' : `Moved ${detached} tasks to background.`; + } else { + hint = `Moved ${detached} of ${targets.length} tasks to background.`; + } + if (detached > 0) hint = `${hint} /tasks to view.`; + this.showDetachHint(hint); + } + + /** Show a one-shot footer hint that auto-clears after DETACH_HINT_DISPLAY_MS. */ + private showDetachHint(hint: string): void { + if (this.detachHintClearTimer !== undefined) { + clearTimeout(this.detachHintClearTimer); + this.detachHintClearTimer = undefined; + } + this.state.footer.setTransientHint(hint); + this.detachHintClearTimer = setTimeout(() => { + this.detachHintClearTimer = undefined; + // Don't clobber a newer transient hint (e.g. the exit-confirmation + // prompt) that took over while this timer was pending. + if (this.state.footer.getTransientHint() !== hint) return; + this.state.footer.setTransientHint(null); + this.state.ui.requestRender(); + }, DETACH_HINT_DISPLAY_MS); this.state.ui.requestRender(); - return true; } updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); - const colorToken = - this.state.appState.planMode || trimmed.startsWith('/') - ? this.state.theme.colors.primary - : this.state.theme.colors.border; - this.state.editor.borderColor = (s: string) => chalk.hex(colorToken)(s); + const isBash = this.state.appState.inputMode === 'bash'; + const highlighted = this.state.appState.planMode || isBash || trimmed.startsWith('/'); + this.state.editor.borderHighlighted = highlighted; + // Shell mode gets its own hue; plan-mode and slash context stay primary. + const borderToken = isBash ? 'shellMode' : highlighted ? 'primary' : 'border'; + this.state.editor.borderColor = (s: string) => currentTheme.fg(borderToken, s); this.state.ui.requestRender(); } - applyTheme(theme: Theme, resolved?: ResolvedTheme): void { - const nextTheme = createKimiTUIThemeBundle(theme, resolved); - Object.assign(this.state.theme.colors, nextTheme.colors); - this.state.theme.resolvedTheme = nextTheme.resolvedTheme; - this.state.theme.styles = nextTheme.styles; - this.state.theme.markdownTheme = nextTheme.markdownTheme; - this.setAppState({ theme }); + async applyTheme(themeName: ThemeName, resolved?: ResolvedTheme): Promise<void> { + const palette = await getColorPalette(themeName === 'auto' ? (resolved ?? 'dark') : themeName); + currentTheme.setPalette(palette); + this.setAppState({ theme: themeName }); this.updateEditorBorderHighlight(); + // Force every historical message to re-render so Markdown/Text caches + // (which hold old ANSI colour codes) are cleared. + this.state.transcriptContainer.invalidate(); this.state.ui.requestRender(true); } refreshTerminalThemeTracking(): void { this.stopTerminalThemeTracking(); - if (this.state.appState.theme !== 'auto') return; + if (!isBuiltInTheme(this.state.appState.theme) || this.state.appState.theme !== 'auto') return; this.terminalThemeTrackingDispose = installTerminalThemeTracking(this.state, (resolved) => { - this.applyResolvedAutoTheme(resolved); + void this.applyResolvedAutoTheme(resolved); }); } @@ -1538,10 +2597,16 @@ export class KimiTUI { this.terminalThemeTrackingDispose = undefined; } - private applyResolvedAutoTheme(resolved: ResolvedTheme): void { + private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise<void> { if (this.state.appState.theme !== 'auto') return; - if (this.state.theme.resolvedTheme === resolved) return; - this.applyTheme('auto', resolved); + const palette = getBuiltInPalette(resolved); + if (currentTheme.palette === palette) return; + currentTheme.setPalette(palette); + this.updateEditorBorderHighlight(); + // Repaint already-rendered transcript entries (status/markdown caches hold + // old ANSI codes), matching applyTheme()'s behaviour. + this.state.transcriptContainer.invalidate(); + this.state.ui.requestRender(true); } private shouldShowTerminalProgress(effectiveMode: EffectiveActivityPaneMode): boolean { @@ -1554,7 +2619,21 @@ export class KimiTUI { ); } + private shouldPlaceActivitySpinnerInAgentSwarm( + effectiveMode: EffectiveActivityPaneMode, + ): boolean { + return ( + this.sessionEventHandler.hasActiveAgentSwarmToolCall() && + (effectiveMode === 'waiting' || effectiveMode === 'tool') + ); + } + + private syncAgentSwarmActivitySpinner(spinner: MoonLoader | undefined): void { + this.sessionEventHandler.syncAgentSwarmActivitySpinner(spinner); + } + private syncTerminalProgress(active: boolean): void { + if (!this.state.terminalState.supportsProgress) return; if (this.state.terminalState.progressActive === active) return; this.state.terminal.setProgress(active); this.state.terminalState.progressActive = active; @@ -1604,6 +2683,24 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); + // Measure overflow against the restored tree (editor mounted), not the tall + // panel just removed — otherwise a short session with a tall panel looks like + // it overflows and we take a full clear/home that yanks the editor to the top. + // Treat an exact one-screen fill as overflowing too: a full redraw is safe + // there (no blank tail) and clears a stale viewport offset after a shrink. + const { columns, rows } = this.state.terminal; + const overflowsViewport = this.state.ui.render(columns).length >= rows; + // Force a full re-render after replacing a tall panel with the shorter editor: + // differential rendering leaves the editor shifted up when the bottom-anchored + // region shrinks in place. Skip under tmux (its own reflow handles the shrink) + // and when content fits on one screen (a full clear would pull the editor up). + this.state.ui.requestRender(!this.state.terminalState.insideTmux && overflowsViewport); + } + + restoreInputText(text: string): void { + this.restoreEditor(); + this.state.editor.setText(text); + this.updateEditorBorderHighlight(text); this.state.ui.requestRender(); } @@ -1613,7 +2710,6 @@ export class KimiTUI { plan, sourceHome: plan.sourceHome, targetHome: this.harness.homeDir, - colors: this.state.theme.colors, skipDecisionStep: this.migrateOnly, requestRender: () => { this.state.ui.requestRender(); @@ -1629,11 +2725,7 @@ export class KimiTUI { // Persist the skip marker `detectPendingMigration` checks, so "Never ask // again" actually stops the prompt from reappearing every launch. try { - writeFileSync( - join(this.harness.homeDir, '.skip-migration-from-kimi-cli'), - '', - 'utf-8', - ); + writeFileSync(join(this.harness.homeDir, '.skip-migration-from-kimi-cli'), '', 'utf-8'); } catch { // Non-blocking: a failed marker write must never crash startup. } @@ -1646,7 +2738,6 @@ export class KimiTUI { this.mountEditorReplacement( new HelpPanelComponent({ commands: this.getSlashCommands(), - colors: this.state.theme.colors, onClose: () => { this.hideHelpPanel(); }, @@ -1659,46 +2750,151 @@ export class KimiTUI { this.restoreEditor(); } + private sessionPickerOptions: { + readonly applyStartupModes: boolean; + readonly closeOnCancel: boolean; + readonly forwardEditorExit: boolean; + } = { + applyStartupModes: false, + closeOnCancel: false, + forwardEditorExit: false, + }; + private sessionPickerScopeRequestToken = 0; + async showSessionPicker(): Promise<void> { - await this.fetchSessions(); - this.mountSessionPicker(() => { - this.hideSessionPicker(); + await this.openSessionPicker({ + applyStartupModes: false, + closeOnCancel: false, + forwardEditorExit: false, }); } private async bootstrapFromPicker(): Promise<void> { - await this.fetchSessions(); - this.mountSessionPicker(() => { - this.hideSessionPicker(); - void this.stop(); + await this.openSessionPicker({ + applyStartupModes: true, + closeOnCancel: true, + forwardEditorExit: true, + }); + } + + private async openSessionPicker(options: { + readonly applyStartupModes: boolean; + readonly closeOnCancel: boolean; + readonly forwardEditorExit: boolean; + }): Promise<void> { + this.sessionPickerOptions = options; + await this.fetchSessions('cwd'); + this.mountSessionPicker({ + applyStartupModes: options.applyStartupModes, + onCancel: () => { + this.hideSessionPicker(); + if (options.closeOnCancel) void this.stop(); + }, + onCtrlC: options.forwardEditorExit + ? () => { + this.state.editor.onCtrlC?.(); + } + : undefined, + onCtrlD: options.forwardEditorExit + ? () => { + this.state.editor.onCtrlD?.(); + } + : undefined, + }); + } + + private async toggleSessionPickerScope(selectedSessionId: string): Promise<void> { + const requestToken = ++this.sessionPickerScopeRequestToken; + const nextScope = this.state.sessionsScope === 'cwd' ? 'all' : 'cwd'; + await this.fetchSessions(nextScope); + if (requestToken !== this.sessionPickerScopeRequestToken) return; + if (this.state.activeDialog !== 'session-picker') return; + this.mountSessionPicker({ + initialSelectedSessionId: selectedSessionId, + applyStartupModes: this.sessionPickerOptions.applyStartupModes, + onCancel: () => { + this.hideSessionPicker(); + if (this.sessionPickerOptions.closeOnCancel) void this.stop(); + }, + onCtrlC: this.sessionPickerOptions.forwardEditorExit + ? () => { + this.state.editor.onCtrlC?.(); + } + : undefined, + onCtrlD: this.sessionPickerOptions.forwardEditorExit + ? () => { + this.state.editor.onCtrlD?.(); + } + : undefined, }); } hideSessionPicker(): void { + this.sessionPickerScopeRequestToken += 1; + this.editorKeyboard.clearPendingExit(); this.state.activeDialog = null; this.restoreEditor(); } - private mountSessionPicker(onCancel: () => void): void { + openUndoSelector(): void { + void slashCommands.handleUndoCommand(this, ''); + } + + private mountSessionPicker(options: { + readonly onCancel: () => void; + readonly onCtrlC?: () => void; + readonly onCtrlD?: () => void; + readonly initialSelectedSessionId?: string; + // CLI mode flags (--auto/--yolo/--plan) target the session picked at + // startup (bare --session); later /sessions switches keep the picked + // session's own persisted modes. + readonly applyStartupModes?: boolean; + }): void { this.state.activeDialog = 'session-picker'; this.mountEditorReplacement( new SessionPickerComponent({ sessions: this.state.sessions, loading: this.state.loadingSessions, currentSessionId: this.state.appState.sessionId, - colors: this.state.theme.colors, - onSelect: (sessionId: string) => { - void this.resumeSession(sessionId).then((switched) => { - if (switched) { - this.hideSessionPicker(); - } - }); + scope: this.state.sessionsScope, + initialSelectedSessionId: options.initialSelectedSessionId, + pageSize: 50, + onSelect: (session: SessionRow) => { + void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( + (error) => { + this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); + }, + ); + }, + onCancel: options.onCancel, + onCtrlC: options.onCtrlC, + onCtrlD: options.onCtrlD, + onToggleScope: (selectedSessionId: string) => { + void this.toggleSessionPickerScope(selectedSessionId); }, - onCancel, }), ); } + private async handleSessionPickerSelect( + session: SessionRow, + applyStartupModes: boolean, + ): Promise<void> { + if (resolve(session.work_dir) !== resolve(this.state.appState.workDir)) { + await this.showResumeOtherWorkDirHint(session); + if (applyStartupModes) await this.stop(0); + return; + } + + const switched = await this.resumeSession(session.id); + if (!switched) return; + if (applyStartupModes) { + await this.applyStartupModesToResumedSession(this.requireSession()); + this.applyStartupPermissionAndPlanToAppState(); + } + this.hideSessionPicker(); + } + private showApprovalPanel(payload: ApprovalPanelData): void { this.patchLivePane({ pendingApproval: { data: payload } }); notifyTerminalOnce(this.state, `approval:${payload.id}`, { @@ -1710,13 +2906,9 @@ export class KimiTUI { (response: ApprovalPanelResponse) => { this.approvalController.respond(adaptPanelResponse(response)); }, - this.state.theme.colors, () => { this.toggleToolOutputExpansion(); }, - () => { - this.togglePlanExpansion(); - }, (block) => { this.openApprovalPreview(panel, block); }, @@ -1745,7 +2937,6 @@ export class KimiTUI { const viewer = new ApprovalPreviewViewer( { block, - colors: this.state.theme.colors, onClose: () => { this.closeApprovalPreview(); }, @@ -1782,14 +2973,10 @@ export class KimiTUI { (response) => { this.questionController.respond(response); }, - this.state.theme.colors, - undefined, + 6, () => { this.toggleToolOutputExpansion(); }, - () => { - this.togglePlanExpansion(); - }, ); this.mountEditorReplacement(dialog); } @@ -1798,5 +2985,4 @@ export class KimiTUI { this.patchLivePane({ pendingQuestion: null }); this.restoreEditor(); } - } diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts index a17e60586..373690592 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts @@ -1,6 +1,7 @@ import type { ApprovalRequest, ApprovalResponse, ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; import type { ApprovalPanelResponse } from '#/tui/components/dialogs/approval-panel'; +import { goalStartOptions } from '#/tui/components/dialogs/goal-start-permission-prompt'; import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/reverse-rpc/types'; const DEFAULT_APPROVAL_CHOICES: ApprovalPanelChoice[] = [ @@ -176,6 +177,8 @@ function describeApproval(display: ToolInputDisplay, action: string): string { switch (display.kind) { case 'plan_review': return ''; + case 'goal_start': + return 'Start a goal?'; case 'generic': if (typeof display.detail === 'string' && display.detail.length > 0) { return display.detail; @@ -199,7 +202,7 @@ function describeApproval(display: ToolInputDisplay, action: string): string { return `search: ${display.query ?? ''}`.trim(); case 'todo_list': return `update todo list (${String(display.items?.length ?? 0)} items)`; - case 'background_task': + case 'task': return `${display.status ?? 'background'} task ${display.task_id ?? ''}: ${ display.description ?? '' }`.trim(); @@ -320,11 +323,18 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] { ]; case 'plan_review': return []; + case 'goal_start': { + const lines = [`Start goal: ${display.objective}`]; + if (typeof display.completionCriterion === 'string' && display.completionCriterion.length > 0) { + lines.push(`Done when: ${display.completionCriterion}`); + } + return [{ type: 'brief', text: lines.join('\n') }]; + } case 'generic': return []; case 'todo_list': return []; - case 'background_task': + case 'task': return []; default: return []; @@ -335,10 +345,36 @@ function adaptChoices(toolName: string, display: ToolInputDisplay): ApprovalPane if (toolName === 'ExitPlanMode' || display.kind === 'plan_review') { return adaptPlanReviewChoices(display); } + if (display.kind === 'goal_start') { + return adaptGoalStartChoices(display); + } return DEFAULT_APPROVAL_CHOICES.map((choice) => cloneChoice(choice)); } +function adaptGoalStartChoices( + display: Extract<ToolInputDisplay, { kind: 'goal_start' }>, +): ApprovalPanelChoice[] { + // Reuse the exact options the /goal start menu shows. Each mode option starts + // the goal under that permission mode (the policy reads selected_label); "Do + // not start" declines so no goal is created. + return goalStartOptions(display.mode).map((option) => + option.value === 'cancel' + ? { + label: option.label, + response: 'cancelled', + selected_label: 'cancel', + description: option.description, + } + : { + label: option.label, + response: 'approved', + selected_label: option.value, + description: option.description, + }, + ); +} + function adaptPlanReviewChoices(display: ToolInputDisplay): ApprovalPanelChoice[] { const optionChoices = display.kind === 'plan_review' && display.options !== undefined && display.options.length >= 2 diff --git a/apps/kimi-code/src/tui/reverse-rpc/types.ts b/apps/kimi-code/src/tui/reverse-rpc/types.ts index c23c938f0..2a41f0df2 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/types.ts +++ b/apps/kimi-code/src/tui/reverse-rpc/types.ts @@ -103,6 +103,9 @@ export interface ApprovalPanelChoice { response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; selected_label?: string | undefined; requires_feedback?: boolean | undefined; + // Optional helper text shown dim beneath the label. Omitted/empty renders + // exactly as a plain label-only choice. + description?: string | undefined; } // ── Approval / Question view payloads ──────────────────────────────── diff --git a/apps/kimi-code/src/tui/theme/bundle.ts b/apps/kimi-code/src/tui/theme/bundle.ts deleted file mode 100644 index 8cfd0e921..000000000 --- a/apps/kimi-code/src/tui/theme/bundle.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { MarkdownTheme } from '@earendil-works/pi-tui'; - -import { getColorPalette, type ColorPalette, type ResolvedTheme } from './colors'; -import { createMarkdownTheme } from './pi-tui-theme'; -import { createThemeStyles, type ThemeStyles } from './styles'; -import { resolveThemeSync, type Theme } from './index'; - -export interface KimiTUIThemeBundle { - resolvedTheme: ResolvedTheme; - colors: ColorPalette; - styles: ThemeStyles; - markdownTheme: MarkdownTheme; -} - -export function createKimiTUIThemeBundle( - theme: Theme, - resolvedTheme?: ResolvedTheme, -): KimiTUIThemeBundle { - const actualTheme = resolvedTheme ?? resolveThemeSync(theme); - const colors = { ...getColorPalette(actualTheme) }; - return { - resolvedTheme: actualTheme, - colors, - styles: createThemeStyles(colors), - markdownTheme: createMarkdownTheme(colors), - }; -} diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index 4a9b37bad..66f9819c3 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -1,148 +1,141 @@ /** * Color palette definitions for dark and light themes. * - * Two layers: - * - private `dark` / `light` raw palettes — unsemantic constants reused - * across multiple semantic tokens to avoid hex literal duplication. - * - exported `darkColors` / `lightColors` — the semantic `ColorPalette` - * consumed by every UI component via chalk.hex(...). + * `darkColors` / `lightColors` are the semantic `ColorPalette` consumed by + * every UI component via the global Theme singleton. Each token holds its hex + * value directly — see the per-token docs on `ColorPalette` for what each one + * controls. * * Light palette values are tuned for ≥ 4.5:1 contrast against #FFFFFF * for text tokens and ≥ 3:1 for chrome (border / large text), matching * WCAG AA. */ -const dark = { - blue400: '#4FA8FF', - cyan400: '#5BC0BE', - gray50: '#F5F5F5', - gray100: '#E0E0E0', - gray500: '#888888', - gray600: '#6B6B6B', - gray700: '#5A5A5A', - green400: '#4EC87E', - green300: '#7AD99B', - red400: '#E85454', - red300: '#F08585', - amber400: '#E8A838', - orange300: '#FFCB6B', -} as const; - -const light = { - blue600: '#1565C0', - cyan700: '#00838F', - gray900: '#1A1A1A', - gray700: '#454545', - gray600: '#5F5F5F', - gray500: '#737373', - green700: '#0E7A38', - red700: '#B91C1C', - amber800: '#92660A', - orange700: '#9A4A00', -} as const; - +// Each token below documents where it is actually consumed, so theme authors +// know what changing it affects. "Widely" means the token is read across most +// dialogs/messages rather than in one specific place. export interface ColorPalette { - // Brand + // ── Brand ── + /** Dominant interactive/brand colour: links & inline code, the selected item + * in nearly every dialog, the focused editor border, plan/"running" badges, + * spinners. The most widely used token. */ primary: string; + /** Secondary highlight: approval "▶" prefix, device-code box, image + * placeholder, BTW / queue panes, custom-registry import. */ accent: string; - // Text + // ── Text ── + /** Default body text: dialog bodies, todo titles, footer model label, + * markdown headings, tool/read output, and assistant-side message bullets + * (assistant / tool / agent / read) plus markdown list bullets. */ text: string; + /** Emphasised / bold text: input dialogs, status messages. */ textStrong: string; + /** Secondary, dimmed text (the most widely used dim shade): thinking blocks, + * hints, descriptions, completed todos, markdown quotes, and the footer + * status bar (cwd path, git badge). */ textDim: string; + /** Faintest text: counters, scroll info, descriptions, markdown link URLs, + * code-block borders. */ textMuted: string; - // Surface + // ── Surface ── + /** Borders: pane & editor borders, markdown horizontal rule. */ border: string; + /** Focus / attention border — currently only the approval panel. */ borderFocus: string; - // State + // ── State ── + /** Success: ✓ marks, "enabled", completed states. */ success: string; + /** Warning: auto/yolo badges, stale markers, plan-mode hint. */ warning: string; + /** Error: error messages, failed tool output. */ error: string; - // Diff + // ── Diff (all consumed by components/media/diff-preview.ts) ── + /** Added lines. */ diffAdded: string; + /** Removed lines. */ diffRemoved: string; + /** Added lines — intra-line changed words (bold). */ diffAddedStrong: string; + /** Removed lines — intra-line changed words (bold). */ diffRemovedStrong: string; + /** Line-number gutter (also approval panel/preview). */ diffGutter: string; + /** Meta / hunk headers. */ diffMeta: string; - // Roles + // ── Roles ── + /** User message: bullet & text, skill-activation name. The one role colour + * with its own hue — assistant/thinking/status bullets reuse text/textDim. */ roleUser: string; - roleAssistant: string; - roleThinking: string; - roleTool: string; - // Status - status: string; + // ── Shell mode ── + /** Shell mode (`!`): the `!` prompt symbol, bash-mode editor border, and the + * echoed `$ command` line. Its own hue (violet), distinct from + * plan-mode (primary) and the user role (roleUser). */ + shellMode: string; } export const darkColors: ColorPalette = { - primary: dark.blue400, - accent: dark.cyan400, + primary: '#4FA8FF', + accent: '#5BC0BE', - text: dark.gray100, - textStrong: dark.gray50, - textDim: dark.gray500, - textMuted: dark.gray600, + text: '#E0E0E0', + textStrong: '#F5F5F5', + textDim: '#888888', + textMuted: '#6B6B6B', - border: dark.gray700, - borderFocus: dark.amber400, + border: '#5A5A5A', + borderFocus: '#E8A838', - success: dark.green400, - warning: dark.amber400, - error: dark.red400, + success: '#4EC87E', + warning: '#E8A838', + error: '#E85454', - diffAdded: dark.green400, - diffRemoved: dark.red400, - diffAddedStrong: dark.green300, - diffRemovedStrong: dark.red300, - diffGutter: dark.gray600, - diffMeta: dark.gray500, + diffAdded: '#4EC87E', + diffRemoved: '#E85454', + diffAddedStrong: '#7AD99B', + diffRemovedStrong: '#F08585', + diffGutter: '#6B6B6B', + diffMeta: '#888888', - roleUser: dark.orange300, - roleAssistant: dark.gray100, - roleThinking: dark.gray500, - roleTool: dark.amber400, - - status: dark.gray500, + roleUser: '#FFCB6B', + shellMode: '#BD93F9', }; export const lightColors: ColorPalette = { - primary: light.blue600, - accent: light.cyan700, + primary: '#1565C0', + accent: '#00838F', - text: light.gray900, - textStrong: light.gray900, - textDim: light.gray700, - textMuted: light.gray600, + text: '#1A1A1A', + textStrong: '#1A1A1A', + textDim: '#454545', + textMuted: '#5F5F5F', - border: light.gray500, - borderFocus: light.amber800, + border: '#737373', + borderFocus: '#92660A', - success: light.green700, - warning: light.amber800, - error: light.red700, + success: '#0E7A38', + warning: '#92660A', + error: '#B91C1C', - diffAdded: light.green700, - diffRemoved: light.red700, - diffAddedStrong: light.green700, - diffRemovedStrong: light.red700, - diffGutter: light.gray500, - diffMeta: light.gray600, + diffAdded: '#0E7A38', + diffRemoved: '#B91C1C', + diffAddedStrong: '#0E7A38', + diffRemovedStrong: '#B91C1C', + diffGutter: '#737373', + diffMeta: '#5F5F5F', - roleUser: light.orange700, - roleAssistant: light.gray900, - roleThinking: light.gray700, - roleTool: light.amber800, - - status: light.gray700, + roleUser: '#9A4A00', + shellMode: '#7C3AED', }; export type ResolvedTheme = 'dark' | 'light'; -export function getColorPalette(theme: ResolvedTheme): ColorPalette { - return theme === 'dark' ? darkColors : lightColors; +/** Synchronous palette lookup for built-in themes only. */ +export function getBuiltInPalette(resolved: ResolvedTheme): ColorPalette { + return resolved === 'dark' ? darkColors : lightColors; } diff --git a/apps/kimi-code/src/tui/theme/custom-theme-loader.ts b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts new file mode 100644 index 000000000..cc0b07cbf --- /dev/null +++ b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts @@ -0,0 +1,97 @@ +/** + * Custom theme loader — reads JSON files from `~/.kimi-code/themes/`. + */ + +import { readdirSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { z } from 'zod'; + +import { getDataDir } from '#/utils/paths'; +import type { ColorPalette, ResolvedTheme } from './colors'; +import { getBuiltInPalette } from './colors'; + +export const CustomThemeSchema = z.object({ + name: z.string().min(1), + displayName: z.string().optional(), + /** Built-in palette that unspecified tokens fall back to. Defaults to `dark`. */ + base: z.enum(['dark', 'light']).optional(), + colors: z.record(z.string(), z.string()).optional(), +}); + +export type CustomThemeDefinition = z.infer<typeof CustomThemeSchema>; + +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +/** + * Names reserved for built-in themes. A `dark.json` / `light.json` / + * `auto.json` file would collide with the built-in value, so it can never be + * selected as a custom theme — hide it from listings. + */ +const RESERVED_THEME_NAMES: ReadonlySet<string> = new Set(['dark', 'light', 'auto']); + +export function getCustomThemesDir(): string { + return join(getDataDir(), 'themes'); +} + +interface ParsedCustomTheme { + readonly base: ResolvedTheme; + readonly colors: Partial<ColorPalette>; +} + +async function readCustomTheme(name: string): Promise<ParsedCustomTheme | null> { + try { + const content = await readFile(join(getCustomThemesDir(), `${name}.json`), 'utf-8'); + const parsed = CustomThemeSchema.parse(JSON.parse(content)); + + // Invalid hex values are dropped (the token falls back to the base + // palette). We intentionally do not print here: this loader can run while + // pi-tui owns the terminal, where raw stdout/stderr writes corrupt the + // rendered screen. Authoring-time validation lives in the JSON schema. + const colors = Object.fromEntries( + Object.entries(parsed.colors ?? {}).filter(([, v]) => HEX_COLOR_REGEX.test(v)), + ) as Partial<ColorPalette>; + + return { base: parsed.base ?? 'dark', colors }; + } catch { + return null; + } +} + +export async function loadCustomTheme(name: string): Promise<Partial<ColorPalette> | null> { + return (await readCustomTheme(name))?.colors ?? null; +} + +/** Load a custom theme and merge it onto its base palette (dark unless `base` says otherwise). */ +export async function loadCustomThemeMerged(name: string): Promise<ColorPalette | null> { + const parsed = await readCustomTheme(name); + if (parsed === null) return null; + return { ...getBuiltInPalette(parsed.base), ...parsed.colors }; +} + +function toThemeNames(files: readonly string[]): string[] { + return files + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .filter((name) => !RESERVED_THEME_NAMES.has(name)); +} + +export async function listCustomThemes(): Promise<string[]> { + try { + const entries = await readdir(getCustomThemesDir(), { withFileTypes: true }); + return toThemeNames(entries.filter((e) => e.isFile()).map((e) => e.name)); + } catch { + return []; + } +} + +/** Synchronous variant for UI paths (e.g. the `/theme` picker) that cannot await. */ +export function listCustomThemesSync(): string[] { + try { + const entries = readdirSync(getCustomThemesDir(), { withFileTypes: true }); + return toThemeNames(entries.filter((e) => e.isFile()).map((e) => e.name)); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/tui/theme/gradient-text.ts b/apps/kimi-code/src/tui/theme/gradient-text.ts new file mode 100644 index 000000000..f0d12d9d9 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/gradient-text.ts @@ -0,0 +1,39 @@ +import chalk from 'chalk'; + +interface RgbColor { + readonly red: number; + readonly green: number; + readonly blue: number; +} + +export function gradientText(text: string, fromHex: string, toHex: string, accentBias = 1): string { + const chars = Array.from(text); + const from = parseHexColor(fromHex); + const to = parseHexColor(toHex); + if (chars.length <= 1 || from === undefined || to === undefined) { + return chalk.hex(fromHex).bold(text); + } + const safeAccentBias = Number.isFinite(accentBias) ? Math.max(0, accentBias) : 1; + return chars.map((char, index) => { + const ratio = Math.min(1, (index / (chars.length - 1)) * safeAccentBias); + return chalk.hex(interpolateHexColor(from, to, ratio)).bold(char); + }).join(''); +} + +function parseHexColor(hex: string): RgbColor | undefined { + const match = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex); + if (match === null) return undefined; + return { + red: Number.parseInt(match[1]!, 16), + green: Number.parseInt(match[2]!, 16), + blue: Number.parseInt(match[3]!, 16), + }; +} + +function interpolateHexColor(from: RgbColor, to: RgbColor, ratio: number): string { + const mix = (start: number, end: number): string => + Math.round(start + (end - start) * ratio) + .toString(16) + .padStart(2, '0'); + return `#${mix(from.red, to.red)}${mix(from.green, to.green)}${mix(from.blue, to.blue)}`; +} diff --git a/apps/kimi-code/src/tui/theme/index.ts b/apps/kimi-code/src/tui/theme/index.ts index a9a143633..e016def5d 100644 --- a/apps/kimi-code/src/tui/theme/index.ts +++ b/apps/kimi-code/src/tui/theme/index.ts @@ -2,45 +2,62 @@ * Theme system public API. */ -import type { ResolvedTheme } from './colors'; +import { getBuiltInPalette } from './colors'; +import type { ColorPalette, ResolvedTheme } from './colors'; +import { loadCustomThemeMerged } from './custom-theme-loader'; import { detectTerminalTheme } from './detect'; -export { darkColors, lightColors, getColorPalette } from './colors'; +export { currentTheme, Theme } from './theme'; +export type { ColorToken } from './theme'; +export { darkColors, lightColors, getBuiltInPalette } from './colors'; export type { ColorPalette, ResolvedTheme } from './colors'; -export { createThemeStyles } from './styles'; -export type { ThemeStyles } from './styles'; -export { createMarkdownTheme, createEditorTheme } from './pi-tui-theme'; export { detectTerminalTheme } from './detect'; +export { loadCustomTheme, loadCustomThemeMerged, listCustomThemes } from './custom-theme-loader'; /** - * User-facing theme preference. `'auto'` defers to terminal background - * detection at startup; `'dark'` / `'light'` are explicit overrides that - * never trigger detection. The persisted value in `tui.toml` is always - * one of these three; the detected `ResolvedTheme` is computed at - * startup and held only in memory. + * User-facing theme preference. + * `'auto'` defers to terminal background detection at startup. + * `'dark'` / `'light'` are explicit built-in overrides. + * Any other string is treated as a custom theme name looked up in + * `~/.kimi-code/themes/<name>.json`. */ -export type Theme = 'dark' | 'light' | 'auto'; +export type BuiltInTheme = 'dark' | 'light' | 'auto'; +export type ThemeName = BuiltInTheme | (string & {}); -export function isTheme(value: string): value is Theme { +export function isBuiltInTheme(value: string): value is BuiltInTheme { return value === 'dark' || value === 'light' || value === 'auto'; } -/** - * Resolve a user preference to a concrete palette key. `'auto'` triggers - * terminal background detection (OSC 11 with COLORFGBG / dark fallback); - * explicit choices pass through. - */ -export async function resolveTheme(theme: Theme): Promise<ResolvedTheme> { - if (theme === 'auto') return detectTerminalTheme(); - return theme; +export function isThemeName(_value: string): _value is ThemeName { + return true; // any string is a valid theme name (custom themes) } /** - * Synchronous fallback used by paths that cannot wait on terminal probes - * (initial state construction, in-TUI theme switches). `'auto'` collapses - * to `'dark'`; explicit choices pass through. + * Resolve a user preference to a concrete palette. + * + * - `'auto'` triggers terminal background detection. + * - `'dark'` / `'light'` return the built-in palette. + * - Any other string loads a custom theme from `~/.kimi-code/themes/`; + * missing / invalid files fall back to dark palette. */ -export function resolveThemeSync(theme: Theme): ResolvedTheme { - if (theme === 'auto') return 'dark'; - return theme; +export async function getColorPalette(theme: ThemeName): Promise<ColorPalette> { + if (theme === 'light') return getBuiltInPalette('light'); + if (theme === 'dark') return getBuiltInPalette('dark'); + if (theme === 'auto') { + const detected = await detectTerminalTheme(); + return getBuiltInPalette(detected); + } + // custom theme + const custom = await loadCustomThemeMerged(theme); + return custom ?? getBuiltInPalette('dark'); +} + +/** + * Synchronous fallback used by paths that cannot wait on terminal probes. + * `'auto'` collapses to `'dark'`; explicit choices pass through. + * Custom themes are not supported here — falls back to dark. + */ +export function getColorPaletteSync(theme: ThemeName): ColorPalette { + if (theme === 'light') return getBuiltInPalette('light'); + return getBuiltInPalette('dark'); } diff --git a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts index dc3b1b9ad..1b53bce0c 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -1,15 +1,18 @@ /** - * Pi-tui theme adapters — MarkdownTheme and EditorTheme from our ColorPalette. + * Pi-tui theme adapters — MarkdownTheme and EditorTheme backed by the + * global `currentTheme` singleton. * - * All chalk calls route through `ColorPalette` tokens so themes flip - * cleanly. No raw `chalk.gray` / `chalk.dim` / `chalk.white` here. + * All colour lookups route through `currentTheme.color(token)` so that + * switching themes is instantaneous: old components hold old + * MarkdownTheme/EditorTheme instances, but every method call on those + * instances reads the *current* palette via the singleton. */ -import type { MarkdownTheme, EditorTheme } from '@earendil-works/pi-tui'; +import type { MarkdownTheme, EditorTheme } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { highlight, supportsLanguage } from 'cli-highlight'; -import type { ColorPalette } from './colors'; +import { currentTheme } from './theme'; // pi-tui's renderer emits literal "### " / "#### " / ... markers for h3-h6 // headings (h1/h2 are rendered without the `#` prefix). The prefix arrives @@ -19,30 +22,31 @@ import type { ColorPalette } from './colors'; // eslint-disable-next-line no-control-regex -- intentionally matches the ESC byte that opens ANSI SGR sequences. const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/; -export function createMarkdownTheme(colors: ColorPalette): MarkdownTheme { +export function createMarkdownTheme(options?: { transient?: boolean }): MarkdownTheme { + const transient = options?.transient === true; const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); - const muted = chalk.hex(colors.textMuted); - const dim = chalk.hex(colors.textDim); - const border = chalk.hex(colors.border); + return { - heading: (text) => chalk.bold.hex(colors.text)(stripHash(text)), - link: (text) => chalk.hex(colors.primary)(text), - linkUrl: (text) => muted(text), - code: (text) => chalk.hex(colors.primary)(text), + heading: (text) => chalk.bold.hex(currentTheme.color('text'))(stripHash(text)), + link: (text) => chalk.hex(currentTheme.color('primary'))(text), + linkUrl: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + code: (text) => chalk.hex(currentTheme.color('primary'))(text), codeBlock: (text) => text, - codeBlockBorder: (text) => muted(text), - quote: (text) => dim(text), - quoteBorder: (text) => dim(text), - hr: (text) => border(text), + codeBlockBorder: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + quote: (text) => chalk.hex(currentTheme.color('textDim'))(text), + quoteBorder: (text) => chalk.hex(currentTheme.color('textDim'))(text), + hr: (text) => chalk.hex(currentTheme.color('border'))(text), // Match the assistant-message bullet so list markers read like a reply - // prefix. Ordered lists arrive as `"1. "` / `"2. "` and are left + // prefix. Ordered lists arrive as "1. " / "2. " and are left // untouched by the leading-dash anchor. - listBullet: (text) => chalk.hex(colors.roleAssistant)(text.replace(/^-/, '•')), + listBullet: (text) => chalk.hex(currentTheme.color('text'))(text.replace(/^-/, '•')), bold: (text) => chalk.bold(text), italic: (text) => chalk.italic(text), strikethrough: (text) => chalk.strikethrough(text), underline: (text) => chalk.underline(text), highlightCode: (code: string, lang?: string) => { + if (transient) return code.split('\n'); + const normalizedLang = lang?.trim().toLowerCase(); const language = normalizedLang !== undefined && supportsLanguage(normalizedLang) ? normalizedLang : 'text'; @@ -56,16 +60,15 @@ export function createMarkdownTheme(colors: ColorPalette): MarkdownTheme { }; } -export function createEditorTheme(colors: ColorPalette): EditorTheme { - const muted = chalk.hex(colors.textMuted); +export function createEditorTheme(): EditorTheme { return { - borderColor: (s) => chalk.hex(colors.border)(s), + borderColor: (s) => chalk.hex(currentTheme.color('border'))(s), selectList: { - selectedPrefix: (s) => chalk.hex(colors.primary)(s), - selectedText: (s) => chalk.hex(colors.primary)(s), - description: (s) => muted(s), - scrollInfo: (s) => muted(s), - noMatch: (s) => muted(s), + selectedPrefix: (s) => chalk.hex(currentTheme.color('primary'))(s), + selectedText: (s) => chalk.hex(currentTheme.color('primary'))(s), + description: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + scrollInfo: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + noMatch: (s) => chalk.hex(currentTheme.color('textMuted'))(s), }, }; } diff --git a/apps/kimi-code/src/tui/theme/styles.ts b/apps/kimi-code/src/tui/theme/styles.ts deleted file mode 100644 index 625302e45..000000000 --- a/apps/kimi-code/src/tui/theme/styles.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Theme-aware style helpers built on chalk. Components hold a reference - * to a `ThemeStyles` instance via `state.theme.styles` and never reach into - * raw chalk color names — that keeps theme switches consistent and lets - * every visual token route through `ColorPalette`. - */ - -import chalk from 'chalk'; - -import type { ColorPalette } from './colors'; - -export interface ThemeStyles { - colors: ColorPalette; - - /** Brand primary (links, focus, slash highlight). */ - primary(text: string): string; - /** Secondary brand accent (command operators, approval labels). */ - accent(text: string): string; - /** Dimmed text — secondary but still readable. */ - dim(text: string): string; - /** Muted text — most faded; for unchanged-line counters, scroll info. */ - muted(text: string): string; - /** Body text — same color as default but explicit for theming. */ - text(text: string): string; - /** Strong / emphasized text — paths, URLs, command bodies. */ - strong(text: string): string; - - error(text: string): string; - warning(text: string): string; - success(text: string): string; - - /** Bold + dim, for label cells. */ - label(text: string): string; - /** Body color, for value cells. */ - value(text: string): string; - - diffAdd(text: string): string; - diffDel(text: string): string; - diffAddBold(text: string): string; - diffDelBold(text: string): string; - diffGutter(text: string): string; - diffMeta(text: string): string; -} - -export function createThemeStyles(colors: ColorPalette): ThemeStyles { - return { - colors, - primary: (s) => chalk.hex(colors.primary)(s), - accent: (s) => chalk.hex(colors.accent)(s), - dim: (s) => chalk.hex(colors.textDim)(s), - muted: (s) => chalk.hex(colors.textMuted)(s), - text: (s) => chalk.hex(colors.text)(s), - strong: (s) => chalk.hex(colors.textStrong)(s), - error: (s) => chalk.hex(colors.error)(s), - warning: (s) => chalk.hex(colors.warning)(s), - success: (s) => chalk.hex(colors.success)(s), - label: (s) => chalk.bold.hex(colors.textDim)(s), - value: (s) => chalk.hex(colors.text)(s), - diffAdd: (s) => chalk.hex(colors.diffAdded)(s), - diffDel: (s) => chalk.hex(colors.diffRemoved)(s), - diffAddBold: (s) => chalk.bold.hex(colors.diffAddedStrong)(s), - diffDelBold: (s) => chalk.bold.hex(colors.diffRemovedStrong)(s), - diffGutter: (s) => chalk.hex(colors.diffGutter)(s), - diffMeta: (s) => chalk.hex(colors.diffMeta)(s), - }; -} diff --git a/apps/kimi-code/src/tui/theme/theme-schema.json b/apps/kimi-code/src/tui/theme/theme-schema.json new file mode 100644 index 000000000..a411088f9 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme-schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/moonshot-ai/kimi-code/blob/main/apps/kimi-code/src/tui/theme/theme-schema.json", + "title": "Kimi Code Custom Theme", + "description": "Schema for Kimi Code TUI custom theme definitions", + "type": "object", + "required": ["name"], + "properties": { + "$schema": { + "type": "string", + "description": "URL to this JSON Schema" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Machine-readable theme identifier (kebab-case recommended)" + }, + "displayName": { + "type": "string", + "description": "Human-readable theme name shown in UI" + }, + "base": { + "type": "string", + "enum": ["dark", "light"], + "description": "Built-in palette that unspecified tokens fall back to (default: dark)" + }, + "colors": { + "type": "object", + "description": "Color overrides. Omitted tokens fall back to the dark theme defaults.", + "properties": { + "primary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Primary brand color" }, + "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Accent / highlight color" }, + "text": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Default text color" }, + "textStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Bold / emphasized text" }, + "textDim": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Secondary / muted text" }, + "textMuted": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Most faded text; for counters, scroll info" }, + "border": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Border color" }, + "borderFocus": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Focused border color" }, + "success": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Success state color" }, + "warning": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Warning state color" }, + "error": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Error state color" }, + "diffAdded": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines" }, + "diffRemoved": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines" }, + "diffAddedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines (strong)" }, + "diffRemovedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines (strong)" }, + "diffGutter": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff gutter color" }, + "diffMeta": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff meta color" }, + "roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" }, + "shellMode": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Shell mode (`!`) prompt, editor border, and the echoed `$ command` line" } + }, + "additionalProperties": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Any valid ColorPalette token" + } + } + }, + "additionalProperties": false +} diff --git a/apps/kimi-code/src/tui/theme/theme.ts b/apps/kimi-code/src/tui/theme/theme.ts new file mode 100644 index 000000000..3b7a377fe --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme.ts @@ -0,0 +1,93 @@ +/** + * Theme class + global singleton. + * + * Components import `currentTheme` and call methods like + * `currentTheme.fg('primary', text)` at render time. When the user switches + * themes we call `currentTheme.setPalette(newPalette)` — the same singleton + * instance stays alive, so every component (including already-rendered + * transcript entries) sees the new colours on the next render frame. + */ + +import chalk from 'chalk'; + +import type { ColorPalette } from './colors'; +import { darkColors } from './colors'; + +export type ColorToken = keyof ColorPalette; + +export class Theme { + private _palette: ColorPalette; + + constructor(palette: ColorPalette) { + this._palette = palette; + } + + get palette(): ColorPalette { + return this._palette; + } + + setPalette(palette: ColorPalette): void { + this._palette = palette; + } + + color(token: ColorToken): string { + return this._palette[token]; + } + + /* ── Foreground helpers ── */ + + fg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token])(text); + } + + boldFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).bold(text); + } + + dimFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).dim(text); + } + + italicFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).italic(text); + } + + underlineFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).underline(text); + } + + strikethroughFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).strikethrough(text); + } + + /* ── Background helpers ── */ + + bg(token: ColorToken, text: string): string { + return chalk.bgHex(this._palette[token])(text); + } + + /* ── Standalone style helpers ── */ + + bold(text: string): string { + return chalk.bold(text); + } + + dim(text: string): string { + return chalk.dim(text); + } + + italic(text: string): string { + return chalk.italic(text); + } + + underline(text: string): string { + return chalk.underline(text); + } + + strikethrough(text: string): string { + return chalk.strikethrough(text); + } +} + +/** Global singleton. Initialise with dark palette; switch via `setPalette`. */ +export const currentTheme = new Theme(darkColors); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 1fb1f4445..d9665c9e3 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -2,7 +2,7 @@ import { Container, ProcessTerminal, TUI, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { FooterComponent } from './components/chrome/footer'; import { GutterContainer } from './components/chrome/gutter-container'; @@ -10,9 +10,10 @@ import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; import { TodoPanelComponent } from './components/chrome/todo-panel'; import type { SessionRow } from './components/dialogs/session-picker'; import { CustomEditor } from './components/editor/custom-editor'; +import { DEFAULT_TUI_CONFIG } from './config'; import { CHROME_GUTTER } from './constant/rendering'; import type { TasksBrowserState } from './controllers/tasks-browser'; -import { createKimiTUIThemeBundle, type KimiTUIThemeBundle } from './theme/bundle'; +import { currentTheme, type Theme } from './theme'; import { createTerminalState, type TerminalState } from './utils/terminal-state'; import { INITIAL_LIVE_PANE, @@ -32,10 +33,11 @@ export interface TUIState { todoPanelContainer: Container; todoPanel: TodoPanelComponent; queueContainer: Container; + btwPanelContainer: Container; editorContainer: Container; footer: FooterComponent; editor: CustomEditor; - theme: KimiTUIThemeBundle; + theme: Theme; appState: AppState; startupState: TUIStartupState; livePane: LivePaneState; @@ -43,18 +45,26 @@ export interface TUIState { terminalState: TerminalState; activitySpinner: { instance: MoonLoader; style: SpinnerStyle } | null; toolOutputExpanded: boolean; - planExpanded: boolean; sessions: SessionRow[]; loadingSessions: boolean; + sessionsScope: 'cwd' | 'all'; activeDialog: 'session-picker' | 'help' | null; tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; queuedMessages: QueuedMessage[]; + /** + * True while a queued user message has been shifted out of + * {@link queuedMessages} but its deferred send has not run yet. The queue + * looks empty during this window, so queued-goal promotion must also check + * this flag to avoid starting a goal ahead of the user's earlier message. + */ + queuedMessageDispatchPending: boolean; + swarmModeEntry: 'manual' | 'task' | undefined; } export function createTUIState(options: KimiTUIOptions): TUIState { const initialAppState = options.initialAppState; - const theme = createKimiTUIThemeBundle(initialAppState.theme, options.resolvedTheme); + const theme = currentTheme; const terminal = new ProcessTerminal(); const ui = new TUI(terminal); @@ -62,11 +72,14 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const todoPanel = new TodoPanelComponent(theme.colors); + const todoPanel = new TodoPanelComponent(); const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const editor = new CustomEditor(ui, theme.colors); - const footer = new FooterComponent({ ...initialAppState }, theme.colors, () => { + const editor = new CustomEditor(ui, { + disablePasteBurst: initialAppState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + }); + const footer = new FooterComponent({ ...initialAppState }, () => { ui.requestRender(); }); @@ -78,9 +91,10 @@ export function createTUIState(options: KimiTUIOptions): TUIState { todoPanelContainer, todoPanel, queueContainer, + btwPanelContainer, editorContainer, - footer, editor, + footer, theme, appState: { ...initialAppState }, startupState: 'pending', @@ -89,12 +103,14 @@ export function createTUIState(options: KimiTUIOptions): TUIState { terminalState: createTerminalState(), activitySpinner: null, toolOutputExpanded: false, - planExpanded: false, sessions: [], loadingSessions: false, + sessionsScope: 'cwd', activeDialog: null, tasksBrowser: undefined, externalEditorRunning: false, queuedMessages: [], + queuedMessageDispatchPending: false, + swarmModeEntry: undefined, }; } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 5a1a8ee42..e79d2c8b4 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -1,37 +1,65 @@ import type { + GoalChange, + GoalSnapshot, ModelAlias, PermissionMode, ProviderConfig, PromptPart, + ThinkingEffort, ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; -import type { NotificationsConfig } from './config'; +import type { NotificationsConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; -import type { Theme } from './theme'; -import type { ResolvedTheme } from './theme/colors'; +import type { ColorToken, ThemeName } from './theme'; + +export type BannerDisplay = 'always' | 'once' | 'cooldown'; + +export interface BannerState { + key: string; + tag: string | null; + mainText: string; + subText: string | null; + display: BannerDisplay; + ttlHours?: number; +} export interface AppState { model: string; workDir: string; + additionalDirs: readonly string[]; sessionId: string; permissionMode: PermissionMode; planMode: boolean; - thinking: boolean; + /** 'bash' when the editor is in `!` shell-command mode. */ + inputMode: 'prompt' | 'bash'; + swarmMode: boolean; + /** Live thinking effort of the active session (e.g. 'off', 'on', 'high'); + * mirrors the runtime. The single source of truth for the thinking state in + * the TUI. */ + thinkingEffort: ThinkingEffort; contextUsage: number; contextTokens: number; maxContextTokens: number; isCompacting: boolean; isReplaying: boolean; - streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; + streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; streamingStartTime: number; - theme: Theme; + theme: ThemeName; version: string; editorCommand: string | null; + /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ + disablePasteBurst?: boolean; notifications: NotificationsConfig; + upgrade: UpgradePreferences; availableModels: Record<string, ModelAlias>; availableProviders: Record<string, ProviderConfig>; sessionTitle: string | null; + /** Current goal snapshot for the footer badge; null/undefined when no active goal. */ + goal?: GoalSnapshot | null; + mcpServersSummary: string | null; + /** Optional banner shown below the welcome panel; null means no banner to render. */ + banner?: BannerState | null; } export interface ToolCallBlockData { @@ -90,6 +118,8 @@ export interface BackgroundAgentStatusData { } export interface CompactionTranscriptData { + readonly result?: 'cancelled'; + readonly summary?: string; readonly tokensBefore?: number; readonly tokensAfter?: number; readonly instruction?: string; @@ -104,6 +134,10 @@ export interface CronTranscriptData { readonly missedCount?: number; } +export type GoalTranscriptData = + | { readonly kind: 'created' } + | { readonly kind: 'lifecycle'; readonly change: GoalChange }; + export type TranscriptEntryKind = | 'welcome' | 'user' @@ -112,7 +146,19 @@ export type TranscriptEntryKind = | 'thinking' | 'status' | 'skill_activation' - | 'cron'; + | 'plugin_command' + | 'cron' + | 'goal'; + +export type SkillActivationTrigger = 'user-slash' | 'model-tool' | 'nested-skill'; + +export interface PluginCommandTranscriptData { + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly args?: string; + readonly trigger: 'user-slash'; +} export interface TranscriptEntry { id: string; @@ -120,16 +166,21 @@ export interface TranscriptEntry { turnId?: string; renderMode: 'markdown' | 'plain' | 'notice'; content: string; - color?: string; + color?: ColorToken; detail?: string; + /** Optional override for the leading bullet of a 'user' message entry. An empty string suppresses the bullet entirely (used by shell-command echoes so `$` replaces the sparkles marker). */ + bullet?: string; toolCallData?: ToolCallBlockData; backgroundAgentStatus?: BackgroundAgentStatusData; compactionData?: CompactionTranscriptData; cronData?: CronTranscriptData; + goalData?: GoalTranscriptData; imageAttachmentIds?: readonly number[]; skillActivationId?: string; skillName?: string; skillArgs?: string; + skillTrigger?: SkillActivationTrigger; + pluginCommandData?: PluginCommandTranscriptData; } export type LivePaneMode = @@ -150,6 +201,21 @@ export interface QueuedMessage { readonly agentId?: string; readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; + /** `bash` for a `!` shell command queued while another command is running; + * undefined (=`prompt`) for a normal message. */ + readonly mode?: 'prompt' | 'bash'; +} + +/** + * One unit of Ctrl-S steer input: a queued message or the editor draft, + * with the media parts extracted at submit/paste time so images and video + * tags survive the steer path (which accepts full prompt parts, not just + * text). + */ +export interface SteerInputItem { + readonly text: string; + readonly parts?: readonly PromptPart[]; + readonly imageAttachmentIds?: readonly number[]; } export const INITIAL_LIVE_PANE: LivePaneState = { @@ -177,7 +243,6 @@ export type TUIStartupState = 'pending' | 'ready' | 'picker'; export interface KimiTUIOptions { initialAppState: AppState; startup: TUIStartupOptions; - resolvedTheme?: ResolvedTheme; } export interface PendingExit { @@ -187,6 +252,7 @@ export interface PendingExit { export interface LoginProgressSpinnerHandle { stop(opts: { ok: boolean; label: string }): void; + setLabel(label: string): void; } export type ProgressSpinnerHandle = LoginProgressSpinnerHandle; diff --git a/apps/kimi-code/src/tui/utils/background-task-status.ts b/apps/kimi-code/src/tui/utils/background-task-status.ts index def7a39c5..10f579ad9 100644 --- a/apps/kimi-code/src/tui/utils/background-task-status.ts +++ b/apps/kimi-code/src/tui/utils/background-task-status.ts @@ -2,9 +2,9 @@ * Format a `BackgroundTaskInfo` snapshot into the transcript card data * consumed by `BackgroundAgentStatusComponent`. * - * Background tasks have six statuses (running / awaiting_approval / - * completed / failed / killed / lost) but the transcript card only - * renders three visual phases (started / completed / failed). The + * Background tasks have several statuses (running / completed / failed / + * timed_out / killed / lost) but the transcript card only renders three + * visual phases (started / completed / failed). The * mapping packs the extra nuance — exit code, kill reason, lost-reason * — into the dim detail line so the user still sees it. */ @@ -28,32 +28,34 @@ export type BackgroundTaskTranscriptPhase = 'started' | 'updated' | 'terminal'; function phaseFromStatus(status: BackgroundTaskStatus): BackgroundAgentStatusPhase { switch (status) { case 'running': - case 'awaiting_approval': return 'started'; case 'completed': return 'completed'; case 'failed': + case 'timed_out': case 'killed': case 'lost': return 'failed'; } } -function subjectFor(taskId: string): string { - return taskId.startsWith('agent-') ? 'agent task' : 'bash task'; +function subjectFor(info: BackgroundTaskInfo): string { + if (info.kind === 'agent') return 'agent task'; + if (info.kind === 'question') return 'question task'; + return 'bash task'; } function headlineFor(info: BackgroundTaskInfo): string { - const subject = subjectFor(info.taskId); + const subject = subjectFor(info); switch (info.status) { case 'running': return `${subject} started in background`; - case 'awaiting_approval': - return `${subject} awaiting approval`; case 'completed': return `${subject} completed in background`; case 'failed': return `${subject} failed in background`; + case 'timed_out': + return `${subject} timed out`; case 'killed': return `${subject} stopped`; case 'lost': @@ -67,7 +69,7 @@ function detailFor(info: BackgroundTaskInfo): string | undefined { if (description !== undefined) parts.push(description); if (info.status === 'completed' || info.status === 'failed') { - if (info.exitCode !== null && info.exitCode !== undefined) { + if (info.kind === 'process' && info.exitCode !== null) { parts.push(`exit ${info.exitCode}`); } } @@ -75,14 +77,14 @@ function detailFor(info: BackgroundTaskInfo): string | undefined { const reason = truncate(info.stopReason); parts.push(reason !== undefined ? `stopped — ${reason}` : 'stopped'); } - if (info.status === 'awaiting_approval') { - const reason = truncate(info.approvalReason); - if (reason !== undefined) parts.push(`awaiting: ${reason}`); + if (info.status === 'failed') { + const reason = truncate(info.stopReason); + if (reason !== undefined) parts.push(reason); } + if (info.status === 'timed_out') parts.push('timed out'); if (info.status === 'lost') { parts.push('session restarted before completion'); } - if (info.timedOut === true) parts.push('timed out'); return parts.length > 0 ? parts.join(' · ') : undefined; } diff --git a/apps/kimi-code/src/tui/utils/component-capabilities.ts b/apps/kimi-code/src/tui/utils/component-capabilities.ts index 5f1f0ba97..5b4f81356 100644 --- a/apps/kimi-code/src/tui/utils/component-capabilities.ts +++ b/apps/kimi-code/src/tui/utils/component-capabilities.ts @@ -2,12 +2,6 @@ export interface Expandable { setExpanded(expanded: boolean): void; } -export interface PlanExpandable { - // Returns true iff the component actually owns a plan preview and - // applied the new state. - setPlanExpanded(expanded: boolean): boolean; -} - export interface Disposable { dispose(): void; } @@ -21,15 +15,6 @@ export function isExpandable(obj: unknown): obj is Expandable { ); } -export function isPlanExpandable(obj: unknown): obj is PlanExpandable { - return ( - typeof obj === 'object' && - obj !== null && - 'setPlanExpanded' in obj && - typeof (obj as PlanExpandable).setPlanExpanded === 'function' - ); -} - export function hasDispose(value: unknown): value is Disposable { return ( typeof value === 'object' && diff --git a/apps/kimi-code/src/tui/utils/connect-catalog.ts b/apps/kimi-code/src/tui/utils/connect-catalog.ts deleted file mode 100644 index dfd86bda5..000000000 --- a/apps/kimi-code/src/tui/utils/connect-catalog.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { DEFAULT_CATALOG_URL } from '@moonshot-ai/kimi-code-sdk'; - -const BARE_HTTP_URL_RE = /^https?:\/\/\S+$/; - -export interface ConnectCatalogRequest { - readonly url: string; - readonly preferBuiltIn: boolean; - readonly allowBuiltInFallback: boolean; -} - -export type ConnectCatalogResolution = - | { readonly kind: 'ok'; readonly request: ConnectCatalogRequest } - | { readonly kind: 'error'; readonly message: string }; - -export function resolveConnectCatalogRequest(args: string): ConnectCatalogResolution { - const trimmed = args.trim(); - - if (trimmed === '') { - return { - kind: 'ok', - request: { - url: DEFAULT_CATALOG_URL, - preferBuiltIn: true, - allowBuiltInFallback: true, - }, - }; - } - - const tokens = trimmed.split(/\s+/).filter(Boolean); - let explicitUrl: string | undefined; - let refreshRequested = false; - - for (const token of tokens) { - if (token.toLowerCase() === 'refresh') { - refreshRequested = true; - continue; - } - - if (BARE_HTTP_URL_RE.test(token)) { - if (explicitUrl !== undefined) { - return { - kind: 'error', - message: `Only one catalog URL can be provided. Got "${explicitUrl}" and "${token}".`, - }; - } - explicitUrl = token; - continue; - } - - if (token.startsWith('--')) { - return { - kind: 'error', - message: `Unexpected flag "${token}". Use /connect [url] [refresh] instead.`, - }; - } - - return { - kind: 'error', - message: `Unknown argument "${token}". Usage: /connect [url] [refresh]`, - }; - } - - if (explicitUrl !== undefined) { - return { - kind: 'ok', - request: { - url: explicitUrl, - preferBuiltIn: false, - allowBuiltInFallback: false, - }, - }; - } - - return { - kind: 'ok', - request: { - url: DEFAULT_CATALOG_URL, - preferBuiltIn: !refreshRequested, - allowBuiltInFallback: true, - }, - }; -} diff --git a/apps/kimi-code/src/tui/utils/event-payload.ts b/apps/kimi-code/src/tui/utils/event-payload.ts index 51de71f80..088409ffd 100644 --- a/apps/kimi-code/src/tui/utils/event-payload.ts +++ b/apps/kimi-code/src/tui/utils/event-payload.ts @@ -90,10 +90,48 @@ export function isTodoItemShape( } export function formatErrorMessage(error: unknown): string { - if (isKimiError(error)) return `[${error.code}] ${error.message}`; + if (isKimiError(error)) { + return formatErrorPayload({ + code: error.code, + message: error.message, + details: error.details, + }); + } return error instanceof Error ? error.message : String(error); } +interface ErrorPayloadLike { + readonly code: string; + readonly message: string; + readonly details?: Record<string, unknown>; +} + +export function formatErrorPayload(error: ErrorPayloadLike): string { + const filteredMessage = formatProviderFilteredMessage(error.details); + if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`; + return `[${error.code}] ${error.message}`; +} + +function formatProviderFilteredMessage( + details: Record<string, unknown> | undefined, +): string | undefined { + const finishReason = stringDetail(details, 'finishReason'); + const rawFinishReason = stringDetail(details, 'rawFinishReason'); + if (finishReason !== 'filtered' && rawFinishReason !== 'content_filter') return undefined; + + const normalizedFinishReason = finishReason ?? 'filtered'; + const raw = rawFinishReason === undefined ? '' : `, rawFinishReason=${rawFinishReason}`; + return `Provider filtered the response before visible output (finishReason=${normalizedFinishReason}${raw}).`; +} + +function stringDetail( + details: Record<string, unknown> | undefined, + key: string, +): string | undefined { + const value = details?.[key]; + return typeof value === 'string' ? value : undefined; +} + export function stringValue(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } diff --git a/apps/kimi-code/src/tui/utils/foreground-task.ts b/apps/kimi-code/src/tui/utils/foreground-task.ts new file mode 100644 index 000000000..ba2ac811b --- /dev/null +++ b/apps/kimi-code/src/tui/utils/foreground-task.ts @@ -0,0 +1,32 @@ +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; + +function isDetachableForegroundTask(t: BackgroundTaskInfo): boolean { + return ( + t.detached === false && + t.status === 'running' && + (t.kind === 'process' || t.kind === 'agent') + ); +} + +/** + * Pick all foreground tasks that `Ctrl+B` should detach: `detached === false`, + * currently-running Bash (`process`) or subagent (`agent`) tasks, most recently + * started first. + */ +export function pickForegroundTasks( + tasks: readonly BackgroundTaskInfo[], +): BackgroundTaskInfo[] { + return tasks + .filter(isDetachableForegroundTask) + .sort((a, b) => b.startedAt - a.startedAt); +} + +/** + * Pick the single most recently started foreground task. Kept for callers that + * only need one; `Ctrl+B` uses {@link pickForegroundTasks} to detach them all. + */ +export function pickForegroundTask( + tasks: readonly BackgroundTaskInfo[], +): BackgroundTaskInfo | undefined { + return pickForegroundTasks(tasks)[0]; +} diff --git a/apps/kimi-code/src/tui/utils/goal-completion.ts b/apps/kimi-code/src/tui/utils/goal-completion.ts new file mode 100644 index 000000000..f4c802b4a --- /dev/null +++ b/apps/kimi-code/src/tui/utils/goal-completion.ts @@ -0,0 +1,40 @@ +import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; + +interface GoalCompletionStats { + readonly terminalReason?: string | undefined; + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; +} + +/** + * Deterministic goal-completion text rendered by the TUI when the model marks a + * goal `complete`. It is built from the final snapshot, so the figures + * (turns / tokens / time) are exact and do not depend on model prose. + */ +export function buildGoalCompletionMessage(goal: GoalSnapshot): string { + return buildGoalCompletionMessageFromStats(goal); +} + +export function buildGoalCompletionMessageFromStats(goal: GoalCompletionStats): string { + const head = `✓ Goal complete${goal.terminalReason ? ` — ${goal.terminalReason}` : ''}.`; + const turns = `${goal.turnsUsed} turn${goal.turnsUsed === 1 ? '' : 's'}`; + const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokens(goal.tokensUsed)} tokens.`; + return `${head}\n${stats}`; +} + +function formatElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return `${minutes}m${seconds.toString().padStart(2, '0')}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h${(minutes % 60).toString().padStart(2, '0')}m`; +} + +function formatTokens(tokens: number): string { + if (tokens < 1000) return String(tokens); + if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(1)}k`; + return `${(tokens / 1_000_000).toFixed(1)}M`; +} diff --git a/apps/kimi-code/src/tui/utils/image-attachment-store.ts b/apps/kimi-code/src/tui/utils/image-attachment-store.ts index bac4ab8fb..8d653159a 100644 --- a/apps/kimi-code/src/tui/utils/image-attachment-store.ts +++ b/apps/kimi-code/src/tui/utils/image-attachment-store.ts @@ -6,7 +6,9 @@ * (640×480)]` / `[video #2 sample.mov]`). The placeholder is what the * user sees in the input field; on submit, `extractMediaAttachments` * walks the text and expands image placeholders to image content parts - * and video placeholders to file-path tags for `ReadMediaFile`. + * (preceded by a compression caption when paste-time compression shrank + * the bytes — see `ImageAttachment.original`) and video placeholders to + * file-path tags for `ReadMediaFile`. * * Scope is per-`KimiTUI` instance. Reloads (`/new`, `/clear`, * session switch) call `clear()` so ids restart from 1 and stale @@ -15,6 +17,18 @@ * `--resume` wouldn't know how to materialize the files anyway. */ +export interface ImageAttachmentOriginal { + /** + * Where the pre-compression bytes were persisted for readback + * (ReadMediaFile + region); null when persistence failed. + */ + readonly path: string | null; + readonly width: number; + readonly height: number; + readonly byteLength: number; + readonly mime: string; +} + export interface ImageAttachment { readonly id: number; readonly kind: 'image'; @@ -22,6 +36,12 @@ export interface ImageAttachment { readonly mime: string; readonly width: number; readonly height: number; + /** + * Pre-compression original, recorded when paste-time compression changed + * the bytes. Drives the compression caption emitted on submit so the model + * knows it received a downsampled copy. Absent for untouched pastes. + */ + readonly original?: ImageAttachmentOriginal | undefined; /** Rendered placeholder string, e.g. `[image #1 (640×480)]`. */ readonly placeholder: string; } @@ -43,7 +63,13 @@ export class ImageAttachmentStore { private nextId = 1; private readonly byId = new Map<number, MediaAttachment>(); - addImage(bytes: Uint8Array, mime: string, width: number, height: number): ImageAttachment { + addImage( + bytes: Uint8Array, + mime: string, + width: number, + height: number, + original?: ImageAttachmentOriginal, + ): ImageAttachment { const id = this.nextId; this.nextId += 1; const attachment: ImageAttachment = { @@ -53,6 +79,7 @@ export class ImageAttachmentStore { mime, width, height, + original, placeholder: formatPlaceholder(id, width, height), }; this.byId.set(id, attachment); @@ -88,6 +115,19 @@ export class ImageAttachmentStore { this.nextId = 1; } + /** + * Drop a single attachment, releasing its bytes. Used to reclaim image + * memory once the transcript entry that references it is trimmed. + */ + remove(id: number): void { + this.byId.delete(id); + } + + /** Drop many attachments at once. See {@link remove}. */ + removeMany(ids: Iterable<number>): void { + for (const id of ids) this.byId.delete(id); + } + size(): number { return this.byId.size; } diff --git a/apps/kimi-code/src/tui/utils/image-placeholder.ts b/apps/kimi-code/src/tui/utils/image-placeholder.ts index 11c401f2f..56edcee88 100644 --- a/apps/kimi-code/src/tui/utils/image-placeholder.ts +++ b/apps/kimi-code/src/tui/utils/image-placeholder.ts @@ -8,14 +8,23 @@ * the text (we can't hallucinate files for it). * - Order is preserved for text/image/video segments. Image placeholders * expand to image content parts so the prompt reaches the provider - * without relying on a model tool call. Video placeholders still expand - * to file-path tags so `ReadMediaFile` can own video upload behavior. + * without relying on a model tool call. Video placeholders are copied + * into the shared cache (`getCacheDir()`) and expand to file-path tags, + * so `ReadMediaFile` — and the provider's `VideoUploader` — own video + * upload behavior instead of base64-inlining here. * - Adjacent text segments are flattened — empty / whitespace-only * segments drop out so we never emit `{type:'text', text:' '}` * noise between two media parts. */ +import { randomUUID } from 'node:crypto'; +import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + import type { PromptPart } from '@moonshot-ai/kimi-code-sdk'; +import { buildImageCompressionCaption } from '@moonshot-ai/kimi-code-sdk'; + +import { getCacheDir } from '#/utils/paths'; import type { ImageAttachment, @@ -62,10 +71,15 @@ export function extractMediaAttachments( const before = text.slice(cursor, match.index); pushText(parts, before); if (attachment.kind === 'video') { - const mediaText = tagTextForVideo(attachment); - pushText(parts, mediaText); + const cachePath = materializeVideoToCache(attachment); + pushText(parts, formatMediaTag('video', cachePath)); videoAttachmentIds.push(id); } else { + // Paste-time compression is announced next to the image so the model + // knows it received a downsampled copy and where the original lives. + if (attachment.original !== undefined) { + pushText(parts, captionForCompressedImage(attachment)); + } parts.push(imagePartForAttachment(attachment)); imageAttachmentIds.push(id); } @@ -87,6 +101,78 @@ export function extractMediaAttachments( }; } +export interface MediaTagRewriteResult { + /** Input text with resolved placeholders replaced by media references. */ + text: string; + hasMedia: boolean; + imageAttachmentIds: number[]; + videoAttachmentIds: number[]; +} + +/** + * How a resolved placeholder is rendered into command args: + * - `'tag'`: the `<image|video path="…"></…>` convention, for channels + * that pass args through verbatim (plugin commands). + * - `'plain'`: a plain-text file reference with no XML tag/attribute + * boundary characters, for channels that XML-escape args (`/skill` + * args are escaped by both `renderSkillAttributes` and + * `expandSkillParameters`, which would mangle the tag form). + */ +export type MediaReferenceStyle = 'tag' | 'plain'; + +/** + * Rewrite media placeholders in slash-command args (`/skill:foo …`, + * plugin commands) into references pointing at cache-dir copies. Command + * args are a plain-text channel — unlike `extractMediaAttachments`, which + * inlines image parts for the prompt endpoint — so the model reaches the + * media through `ReadMediaFile` instead, the same way it already handles + * pasted videos. + * + * Surrounding text is preserved verbatim (args are user content, not + * LLM parts), and unresolved placeholders stay literal. + */ +export function rewriteMediaPlaceholders( + text: string, + store: ImageAttachmentStore, + style: MediaReferenceStyle = 'tag', +): MediaTagRewriteResult { + const imageAttachmentIds: number[] = []; + const videoAttachmentIds: number[] = []; + let cursor = 0; + let out = ''; + + PLACEHOLDER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { + const [literal, kind, idStr] = match; + if (kind !== 'image' && kind !== 'video') continue; + if (idStr === undefined) continue; + const id = Number.parseInt(idStr, 10); + const attachment = store.get(id); + if (attachment === undefined) continue; // stale / user-typed — leave as text + if (attachment.kind !== kind) continue; + out += text.slice(cursor, match.index); + if (attachment.kind === 'video') { + const path = materializeVideoToCache(attachment, style === 'plain'); + out += style === 'plain' ? formatMediaReference('video', path) : formatMediaTag('video', path); + videoAttachmentIds.push(id); + } else { + const path = materializeImageToCache(attachment); + out += style === 'plain' ? formatMediaReference('image', path) : formatMediaTag('image', path); + imageAttachmentIds.push(id); + } + cursor = match.index + literal.length; + } + + const hasMedia = imageAttachmentIds.length + videoAttachmentIds.length > 0; + return { + text: hasMedia ? out + text.slice(cursor) : text, + hasMedia, + imageAttachmentIds, + videoAttachmentIds, + }; +} + function pushText(parts: PromptPart[], segment: string): void { if (segment.length === 0) return; // Keep whitespace-only segments only when they sit between non-empty @@ -109,14 +195,72 @@ function imagePartForAttachment(att: ImageAttachment): PromptPart { }; } -function tagTextForVideo(att: VideoAttachment): string { - return formatMediaTag('video', att.sourcePath); +function materializeVideoToCache(att: VideoAttachment, escapeProofName = false): string { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + // The label permits XML boundary chars (`<>&"`); plain references go + // through skill-arg escaping, where they would no longer match the file + // on disk, so strip them from the cache name in that mode. + const label = escapeProofName ? att.label.replaceAll(/[<>&"]/g, '_') : att.label; + const target = join(cacheDir, `${randomUUID()}-${label}`); + copyFileSync(att.sourcePath, target); + return target; +} + +const IMAGE_MIME_EXTENSION: Readonly<Record<string, string>> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/tiff': 'tif', +}; + +function materializeImageToCache(att: ImageAttachment): string { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + // ReadMediaFile sniffs the real format from the bytes, so the extension + // only needs to be a reasonable hint. + const ext = IMAGE_MIME_EXTENSION[att.mime.trim().toLowerCase()] ?? 'img'; + const target = join(cacheDir, `${randomUUID()}.${ext}`); + writeFileSync(target, att.bytes); + return target; +} + +function captionForCompressedImage(att: ImageAttachment): string { + const original = att.original; + if (original === undefined) return ''; + return buildImageCompressionCaption({ + original: { + width: original.width, + height: original.height, + byteLength: original.byteLength, + mimeType: original.mime, + }, + final: { + width: att.width, + height: att.height, + byteLength: att.bytes.length, + mimeType: att.mime, + }, + originalPath: original.path, + }); } function formatMediaTag(tag: 'image' | 'video', path: string): string { return `<${tag} path="${escapeAttribute(path)}"></${tag}>`; } +/** + * Plain-text media reference for channels that XML-escape args (`/skill`). + * Free of `& < > "` (UUID image names; boundary chars stripped from video + * cache names — see materializeVideoToCache) so it survives + * `escapeXml`/`escapeXmlTags` untouched. + */ +function formatMediaReference(kind: 'image' | 'video', path: string): string { + return `Attached ${kind} file: ${path} (open it with ReadMediaFile)`; +} + function escapeAttribute(value: string): string { return value .replaceAll('&', '&') diff --git a/apps/kimi-code/src/tui/utils/mcp-server-status.ts b/apps/kimi-code/src/tui/utils/mcp-server-status.ts index 09fda3989..53e469488 100644 --- a/apps/kimi-code/src/tui/utils/mcp-server-status.ts +++ b/apps/kimi-code/src/tui/utils/mcp-server-status.ts @@ -29,15 +29,14 @@ export function selectMcpStartupStatusRows( } export function formatMcpStartupStatusSummary( - hidden: readonly McpServerStatusSnapshot[], - visibleCount: number, + servers: readonly McpServerStatusSnapshot[], ): string { let failed = 0; let needsAuth = 0; let connecting = 0; let connected = 0; let disabled = 0; - for (const server of hidden) { + for (const server of servers) { switch (server.status) { case 'failed': failed++; @@ -63,9 +62,7 @@ export function formatMcpStartupStatusSummary( if (connecting > 0) parts.push(`${connecting} connecting`); if (connected > 0) parts.push(`${connected} connected`); if (disabled > 0) parts.push(`${disabled} disabled`); - const detail = parts.join(', '); - if (visibleCount === 0) return `MCP servers: ${detail}`; - return `MCP servers: ${hidden.length} more (${detail})`; + return parts.join(', '); } export function mcpServerStatusKey(server: McpServerStatusSnapshot): string { diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 8b83186dd..99441472b 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -11,6 +11,7 @@ import type { import type { AppState, BackgroundAgentMetadata, + SkillActivationTrigger, ToolCallBlockData, TranscriptEntry, } from '#/tui/types'; @@ -31,6 +32,7 @@ export interface ReplayRenderContext { toolCalls: Map<string, ToolCallBlockData>; completedToolCallIds: Set<string>; skillActivationIds: Set<string>; + pluginCommandActivationIds: Set<string>; suppressNextPlanModeOffNotice: boolean; } @@ -38,6 +40,15 @@ export interface SkillActivationProjection { readonly activationId: string; readonly skillName: string; readonly skillArgs?: string; + readonly trigger: SkillActivationTrigger; +} + +export interface PluginCommandProjection { + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string; + readonly trigger: 'user-slash'; } export interface ReplayBackgroundProjection { @@ -54,6 +65,7 @@ export function appStateFromResumeAgent(agent: ResumedAgentState): Partial<AppSt maxContextTokens, contextUsage, planMode: agent.plan !== null, + swarmMode: agent.swarmMode ?? false, permissionMode: agent.permission.mode, }; } @@ -62,6 +74,7 @@ export function isTerminalBackgroundTask(info: BackgroundTaskInfo): boolean { return ( info.status === 'completed' || info.status === 'failed' || + info.status === 'timed_out' || info.status === 'killed' || info.status === 'lost' ); @@ -75,7 +88,7 @@ export function countActiveBackgroundTasks(tasks: ReadonlyMap<string, Background let agentTasks = 0; for (const info of tasks.values()) { if (isTerminalBackgroundTask(info)) continue; - if (info.taskId.startsWith('agent-')) { + if (info.kind === 'agent') { agentTasks += 1; } else { bashTasks += 1; @@ -89,10 +102,11 @@ export function replayBackgroundProjection( ): ReplayBackgroundProjection { const backgroundAgentMetadata = new Map<string, BackgroundAgentMetadata>(); for (const info of background) { - if (!info.taskId.startsWith('agent-')) continue; + if (info.kind !== 'agent') continue; if (isTerminalBackgroundTask(info)) continue; - backgroundAgentMetadata.set(info.taskId, { - agentId: info.taskId, + const agentId = info.agentId ?? info.taskId; + backgroundAgentMetadata.set(agentId, { + agentId, parentToolCallId: info.taskId, description: info.description, }); @@ -109,6 +123,7 @@ export function createReplayRenderContext(): ReplayRenderContext { toolCalls: new Map(), completedToolCallIds: new Set(), skillActivationIds: new Set(), + pluginCommandActivationIds: new Set(), suppressNextPlanModeOffNotice: false, }; } @@ -130,7 +145,7 @@ export function replayEntry( kind: TranscriptEntry['kind'], content: string, renderMode: TranscriptEntry['renderMode'], - extras: { detail?: string } = {}, + extras: { detail?: string; bullet?: string } = {}, ): TranscriptEntry { return { id: nextTranscriptId(), @@ -139,6 +154,7 @@ export function replayEntry( renderMode, content, detail: extras.detail, + bullet: extras.bullet, }; } @@ -203,6 +219,20 @@ export function skillActivationFromOrigin( activationId: origin.activationId, skillName: origin.skillName, skillArgs: origin.skillArgs, + trigger: origin.trigger, + }; +} + +export function pluginCommandFromOrigin( + origin: PromptOrigin | undefined, +): PluginCommandProjection | undefined { + if (origin?.kind !== 'plugin_command') return undefined; + return { + activationId: origin.activationId, + pluginId: origin.pluginId, + commandName: origin.commandName, + commandArgs: origin.commandArgs, + trigger: origin.trigger, }; } @@ -244,12 +274,18 @@ function isReplayUserTurnRecord(record: AgentReplayRecord): boolean { return true; case 'skill_activation': return message.origin.trigger === 'user-slash'; + case 'plugin_command': + return message.origin.trigger === 'user-slash'; + case 'shell_command': + // A `!` command's input is a user-turn anchor; its output is not. + return message.origin.phase === 'input'; case 'background_task': case 'compaction_summary': case 'cron_job': case 'cron_missed': case 'hook_result': case 'injection': + case 'retry': case 'system_trigger': return false; } diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts index eaddeae65..d475313ae 100644 --- a/apps/kimi-code/src/tui/utils/plugin-source-label.ts +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -50,6 +50,26 @@ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel { } } +/** + * Returns true only for install sources that are unambiguously Kimi-built + * official plugins — an https URL under the official Kimi CDN plugin path. + * Everything else (local paths, GitHub repos, curated or third-party URLs) + * is treated as unofficial and should be confirmed before install. + */ +export function isOfficialPluginSource(source: string): boolean { + const trimmed = source.trim(); + if (!trimmed.startsWith('https://')) return false; + try { + const url = new URL(trimmed); + return ( + url.hostname === 'code.kimi.com' && + url.pathname.startsWith('/kimi-code/plugins/official/') + ); + } catch { + return false; + } +} + function hostFromUrl(raw: string): string | undefined { try { const url = new URL(raw); diff --git a/apps/kimi-code/src/tui/utils/printable-key.ts b/apps/kimi-code/src/tui/utils/printable-key.ts index 7daa36ab6..d165d52a4 100644 --- a/apps/kimi-code/src/tui/utils/printable-key.ts +++ b/apps/kimi-code/src/tui/utils/printable-key.ts @@ -20,7 +20,7 @@ * `tui/components/**` and rejects bare-literal comparisons. */ -import { decodeKittyPrintable } from '@earendil-works/pi-tui'; +import { decodeKittyPrintable } from '@moonshot-ai/pi-tui'; export function printableChar(data: string): string { return decodeKittyPrintable(data) ?? data; diff --git a/apps/kimi-code/src/tui/utils/proctitle.ts b/apps/kimi-code/src/tui/utils/proctitle.ts deleted file mode 100644 index c9c30d823..000000000 --- a/apps/kimi-code/src/tui/utils/proctitle.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Terminal window title synchronization. - * - * Uses the session title when present, capped at 80 characters to keep tabs - * readable. New or unnamed sessions fall back to `Kimi Code`. - * - * Writes both `process.title`, for process listings, and OSC 0/2 escape - * sequences, which most terminals use for window/tab titles. Non-TTY stdout - * skips the OSC write. - */ -import { PRODUCT_NAME } from '#/constant/app'; -import { MAX_PROCESS_TITLE_LENGTH } from '#/tui/constant/terminal'; - -export function setProcessTitle(title: string | null, _sessionId: string): void { - const trimmed = title?.trim() ?? ''; - const label = trimmed.length > 0 ? trimmed.slice(0, MAX_PROCESS_TITLE_LENGTH) : PRODUCT_NAME; - try { - process.title = label; - } catch { - /* noop */ - } - try { - if (process.stdout.isTTY) { - process.stdout.write(`\u001B]0;${label}\u0007`); - } - } catch { - /* noop */ - } -} diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index 3bbda132a..0801575f9 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -1,291 +1,46 @@ import { - KIMI_CODE_PROVIDER_NAME, - applyManagedKimiCodeConfig, - applyOpenPlatformConfig, - applyCustomRegistryProvider, - clearManagedKimiCodeConfig, - fetchCustomRegistry, - fetchManagedKimiCodeModels, - fetchOpenPlatformModels, - filterModelsByPrefix, - getOpenPlatformById, - isOpenPlatformId, - removeCustomRegistryProvider, - removeOpenPlatformConfig, - type CustomRegistrySource, - type ManagedKimiConfigShape, + refreshProviderModels, + type ProviderChange, + type RefreshProviderOptions, + type RefreshProviderScope, + type RefreshResult, } from '@moonshot-ai/kimi-code-oauth'; -import type { KimiConfig, KimiConfigPatch, OAuthRef, ProviderConfig } from '@moonshot-ai/kimi-code-sdk'; +import type { KimiConfig, KimiConfigPatch, OAuthRef } from '@moonshot-ai/kimi-code-sdk'; +/** + * CLI-side host for provider-model refresh. Kept on the SDK's full config types + * so existing TUI callers (and tests) don't change; the daemon uses the oauth + * package's `ManagedKimiConfigShape`-typed host directly. + */ export interface RefreshProviderHost { getConfig(): Promise<KimiConfig>; removeProvider(providerId: string): Promise<KimiConfig>; setConfig(patch: KimiConfigPatch): Promise<KimiConfig>; resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise<string>; + /** Product User-Agent sent on custom-registry (api.json) fetches. */ + readonly userAgent?: string; } -export interface ProviderChange { - readonly providerId: string; - /** User-facing name when available. */ - readonly providerName: string; - readonly added: number; - readonly removed: number; -} - -export interface RefreshResult { - /** Providers whose model list actually changed. */ - readonly changed: readonly ProviderChange[]; - /** Providers whose model list stayed identical after refresh. */ - readonly unchanged: readonly string[]; - readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>; -} - -function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySource | undefined { - const source = provider.source; - if (typeof source !== 'object' || source === null) return undefined; - const candidate = source as Record<string, unknown>; - if (candidate['kind'] !== 'apiJson') return undefined; - const url = candidate['url']; - const apiKey = candidate['apiKey']; - if (typeof url !== 'string' || url.length === 0) return undefined; - if (typeof apiKey !== 'string') return undefined; - return { kind: 'apiJson', url, apiKey }; -} - -function asManaged(config: KimiConfig): ManagedKimiConfigShape { - return config as unknown as ManagedKimiConfigShape; -} - -function collectModelIdsForProvider(config: KimiConfig, providerId: string): Set<string> { - const ids = new Set<string>(); - for (const alias of Object.values(config.models ?? {})) { - if (alias.provider === providerId && alias.model.length > 0) { - ids.add(alias.model); - } - } - return ids; -} - -function setsEqual<T>(a: Set<T>, b: Set<T>): boolean { - if (a.size !== b.size) return false; - for (const item of a) { - if (!b.has(item)) return false; - } - return true; -} - -function computeChanges(oldIds: Set<string>, newIds: Set<string>): { added: number; removed: number } { - let added = 0; - for (const id of newIds) { - if (!oldIds.has(id)) added++; - } - let removed = 0; - for (const id of oldIds) { - if (!newIds.has(id)) removed++; - } - return { added, removed }; -} - -function pickDefaultModel(config: KimiConfig, providerId: string, models: Array<{ id: string }>): string { - const firstModel = models[0]; - if (firstModel === undefined) return ''; - - const existingDefault = config.defaultModel; - if (existingDefault !== undefined) { - const alias = config.models?.[existingDefault]; - if (alias !== undefined && alias.provider === providerId) { - const stillAvailable = models.find((m) => m.id === alias.model); - if (stillAvailable !== undefined) { - return stillAvailable.id; - } - } - } - return firstModel.id; -} - -export async function refreshAllProviderModels(host: RefreshProviderHost): Promise<RefreshResult> { - const changed: ProviderChange[] = []; - const unchanged: string[] = []; - const failed: Array<{ provider: string; reason: string }> = []; - - let config = await host.getConfig(); - - // ------------------------------------------------------------------------- - // 1. Managed Kimi Code (OAuth) - // ------------------------------------------------------------------------- - const managedProvider = config.providers[KIMI_CODE_PROVIDER_NAME]; - if ( - managedProvider !== undefined && - managedProvider.type === 'kimi' && - managedProvider.oauth !== undefined - ) { - try { - const accessToken = await host.resolveOAuthToken( - KIMI_CODE_PROVIDER_NAME, - managedProvider.oauth, - ); - const models = await fetchManagedKimiCodeModels({ - accessToken, - baseUrl: managedProvider.baseUrl, - }); - if (models.length > 0) { - const beforeIds = collectModelIdsForProvider(config, KIMI_CODE_PROVIDER_NAME); - const newIds = new Set(models.map((m) => m.id)); - - if (setsEqual(beforeIds, newIds)) { - unchanged.push(KIMI_CODE_PROVIDER_NAME); - } else { - const { added, removed } = computeChanges(beforeIds, newIds); - config = await host.removeProvider(KIMI_CODE_PROVIDER_NAME); - clearManagedKimiCodeConfig(asManaged(config)); - applyManagedKimiCodeConfig(asManaged(config), { - models, - baseUrl: managedProvider.baseUrl, - preserveDefaultModel: true, - }); - await host.setConfig({ - providers: config.providers, - models: config.models, - defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, - }); - changed.push({ - providerId: KIMI_CODE_PROVIDER_NAME, - providerName: 'Kimi Code', - added, - removed, - }); - } - } - } catch (error) { - failed.push({ - provider: KIMI_CODE_PROVIDER_NAME, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - - // ------------------------------------------------------------------------- - // 2. Open Platforms (moonshot-cn, moonshot-ai, …) - // ------------------------------------------------------------------------- - const openPlatformIds = Object.keys(config.providers).filter((id) => isOpenPlatformId(id)); - for (const providerId of openPlatformIds) { - const platform = getOpenPlatformById(providerId); - if (platform === undefined) continue; - - const providerConfig = config.providers[providerId]; - if (providerConfig === undefined) continue; - const apiKey = providerConfig.apiKey; - if (typeof apiKey !== 'string' || apiKey.length === 0) continue; - - try { - let models = await fetchOpenPlatformModels(platform, apiKey); - models = filterModelsByPrefix(models, platform); - if (models.length === 0) continue; - - const beforeIds = collectModelIdsForProvider(config, providerId); - const newIds = new Set(models.map((m) => m.id)); - - if (setsEqual(beforeIds, newIds)) { - unchanged.push(providerId); - } else { - const { added, removed } = computeChanges(beforeIds, newIds); - const selectedModelId = pickDefaultModel(config, providerId, models); - const selectedModel = models.find((m) => m.id === selectedModelId); - if (selectedModel === undefined) continue; - - config = await host.removeProvider(providerId); - removeOpenPlatformConfig(asManaged(config), providerId); - applyOpenPlatformConfig(asManaged(config), { - platform, - models, - selectedModel, - thinking: false, - apiKey, - }); - await host.setConfig({ - providers: config.providers, - models: config.models, - defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, - }); - changed.push({ - providerId, - providerName: platform.name, - added, - removed, - }); - } - } catch (error) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - - // ------------------------------------------------------------------------- - // 3. Custom Registry providers (grouped by {url, apiKey}) - // ------------------------------------------------------------------------- - const customSources = new Map<string, { source: CustomRegistrySource; providerIds: string[] }>(); - for (const [providerId, providerConfig] of Object.entries(config.providers)) { - if (providerId === KIMI_CODE_PROVIDER_NAME) continue; - if (isOpenPlatformId(providerId)) continue; - const source = readCustomRegistrySource(providerConfig); - if (source === undefined) continue; - const key = `${source.url}${source.apiKey}`; - const entry = customSources.get(key); - if (entry !== undefined) { - entry.providerIds.push(providerId); - } else { - customSources.set(key, { source, providerIds: [providerId] }); - } - } - - for (const { source, providerIds } of customSources.values()) { - try { - const entries = await fetchCustomRegistry(source); - let changedAny = false; - - for (const providerId of providerIds) { - const entry = entries[providerId]; - if (entry === undefined) continue; - - const beforeIds = collectModelIdsForProvider(config, providerId); - const newIds = new Set(Object.values(entry.models).map((m) => m.id)); - - if (setsEqual(beforeIds, newIds)) { - unchanged.push(providerId); - } else { - const { added, removed } = computeChanges(beforeIds, newIds); - config = await host.removeProvider(providerId); - removeCustomRegistryProvider(asManaged(config), providerId); - applyCustomRegistryProvider(asManaged(config), entry, source); - changedAny = true; - changed.push({ - providerId, - providerName: entry.name || providerId, - added, - removed, - }); - } - } - - if (changedAny) { - await host.setConfig({ - providers: config.providers, - models: config.models, - }); - } - } catch (error) { - for (const providerId of providerIds) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - } - - return { changed, unchanged, failed }; +export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult }; + +/** + * Refresh remote model metadata for the configured providers. Thin adapter over + * the shared `refreshProviderModels` orchestrator in `@moonshot-ai/kimi-code-oauth` + * (which is also what the daemon's scheduled/manual refresh uses). + */ +export async function refreshAllProviderModels( + host: RefreshProviderHost, + options: RefreshProviderOptions = {}, +): Promise<RefreshResult> { + return refreshProviderModels( + { + getConfig: () => host.getConfig(), + removeProvider: (providerId) => host.removeProvider(providerId), + setConfig: (patch) => host.setConfig(patch as unknown as KimiConfigPatch), + resolveOAuthToken: (providerName, oauthRef) => + host.resolveOAuthToken(providerName, oauthRef as unknown as OAuthRef), + userAgent: host.userAgent, + }, + options, + ); } diff --git a/apps/kimi-code/src/tui/utils/render-cache.ts b/apps/kimi-code/src/tui/utils/render-cache.ts new file mode 100644 index 000000000..628d80fae --- /dev/null +++ b/apps/kimi-code/src/tui/utils/render-cache.ts @@ -0,0 +1,28 @@ +/** + * Render-cache toggle for TUI message components. + * + * The transcript re-renders the entire component tree on every frame, and + * most message components rebuild their `render(width)` output from scratch + * even when their content has not changed. Caching the rendered lines (keyed + * on width + a dirty flag) turns an unchanged message's render into an O(1) + * array reference return, which is the dominant per-frame cost once the + * transcript grows long. + * + * The cache is on by default and can be disabled with + * `KIMI_TUI_NO_RENDER_CACHE=1` as an escape hatch (and to let benchmarks + * compare cached vs. uncached runs in the same process). + */ + +let enabled = process.env['KIMI_TUI_NO_RENDER_CACHE'] !== '1'; + +export function isRenderCacheEnabled(): boolean { + return enabled; +} + +/** + * Override the cache at runtime. Intended for benchmarks / tests only; + * production code should not call this. + */ +export function setRenderCacheEnabled(value: boolean): void { + enabled = value; +} diff --git a/apps/kimi-code/src/tui/utils/searchable-list.ts b/apps/kimi-code/src/tui/utils/searchable-list.ts index b5b7343a7..00a920e1f 100644 --- a/apps/kimi-code/src/tui/utils/searchable-list.ts +++ b/apps/kimi-code/src/tui/utils/searchable-list.ts @@ -8,7 +8,7 @@ * everywhere: ↑/↓, PgUp/PgDn, and search editing. */ -import { fuzzyFilter, Key, matchesKey } from '@earendil-works/pi-tui'; +import { fuzzyFilter, Key, matchesKey } from '@moonshot-ai/pi-tui'; import { pageView, type PageView } from './paging'; import { isPrintableChar, printableChar } from './printable-key'; diff --git a/apps/kimi-code/src/tui/utils/shell-output.ts b/apps/kimi-code/src/tui/utils/shell-output.ts new file mode 100644 index 000000000..3a482feb7 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/shell-output.ts @@ -0,0 +1,71 @@ +import { currentTheme } from '#/tui/theme'; + +// Captured command output can contain terminal control sequences — colours, +// cursor moves, alternate-screen switches, hyperlinks, `\r` spinners, bells, … +// We render through pi-tui, which passes strings straight to the terminal, so +// any sequence left intact is executed by the terminal and fights with pi-tui's +// own cursor control (the "blank screen + leftover characters" symptom). Strip +// everything a terminal would interpret as a command rather than printable text, +// keeping only `\n` and `\t` (which the renderer understands). + +// ESC [ <params> <intermediates> <final> — colours, cursor moves, clear, and +// private modes such as ESC[?1049h (alt screen) / ESC[?25l (hide cursor). +const CSI_PATTERN = /\u001B\[[0-9:;<=>?]*[ -/]*[@-~]/g; +// ESC ] … <BEL> or ESC ] … ESC \ — window titles and OSC 8 hyperlinks. +const OSC_PATTERN = /\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g; +// ESC <char> (and ESC <intermediate> <char>) — charset/keypad selection, +// save/restore cursor (ESC 7 / ESC 8), full reset (ESC c), etc. Runs after the +// CSI/OSC patterns, so it only catches sequences they didn't already consume. +const ESC_SINGLE_PATTERN = /\u001B(?:[ -/][0-~]|[0-~])/g; +// C0 control characters except \n (0x0A) and \t (0x09): NUL, BEL, \b, \r, … +// plus a lone ESC (0x1B) that wasn't part of a sequence recognised above. +const C0_CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u001B\u001C-\u001F]/g; + +/** + * Strip every terminal control sequence from captured command output so it is + * safe to render via pi-tui (which does not sanitize on its own). + * + * Never throws: a bad or pathological input falls back to stripping only the + * C0 control characters, so rendering can never crash the TUI. + */ +export function sanitizeShellOutput(text: string): string { + if (typeof text !== 'string') return ''; + if (text.length === 0) return text; + try { + return text + .replace(OSC_PATTERN, '') + .replace(CSI_PATTERN, '') + .replace(ESC_SINGLE_PATTERN, '') + .replace(C0_CONTROL_PATTERN, ''); + } catch { + return text.replace(C0_CONTROL_PATTERN, ''); + } +} + +/** + * Format captured stdout/stderr for the transcript. Sanitizes both streams and + * dims them; stderr is red only on actual failure. + * + * Never throws: if anything goes wrong (theme lookup, huge input, …) it falls + * back to a best-effort plain view so a render error can never crash the TUI. + */ +export function formatBashOutputForDisplay(stdout: string, stderr: string, isError?: boolean): string { + try { + const dim = (s: string): string => currentTheme.fg('textDim', s); + const parts: string[] = []; + const cleanStdout = sanitizeShellOutput(stdout).trimEnd(); + if (cleanStdout.length > 0) parts.push(dim(cleanStdout)); + const cleanStderr = sanitizeShellOutput(stderr).trimEnd(); + if (cleanStderr.length > 0) { + // Dim grey normally; red only on actual failure (so warnings on a + // successful command are not mistaken for errors). + parts.push(isError ? currentTheme.fg('error', cleanStderr) : dim(cleanStderr)); + } + return parts.length > 0 ? parts.join('\n') : dim('(no output)'); + } catch { + const plain = [sanitizeShellOutput(String(stdout ?? '')), sanitizeShellOutput(String(stderr ?? ''))] + .filter((s) => s.length > 0) + .join('\n'); + return plain.length > 0 ? plain : '(no output)'; + } +} diff --git a/apps/kimi-code/src/tui/utils/tab-strip.ts b/apps/kimi-code/src/tui/utils/tab-strip.ts new file mode 100644 index 000000000..a4b5099a7 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/tab-strip.ts @@ -0,0 +1,94 @@ +/** + * Shared tab strip renderer for tabbed dialogs (model selector, plugin + * marketplace, …). The active tab is filled with the brand background, inactive + * tabs are muted — matching the AskUserQuestion dialog. See + * .agents/skills/write-tui/DESIGN.md §5. + * + * When the strip is wider than the terminal, it scrolls to keep the active tab + * visible, framed by `<`/`>` markers. + */ + +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface RenderTabStripOptions { + readonly labels: readonly string[]; + readonly activeIndex: number; + readonly width: number; + readonly colors: ColorPalette; +} + +/** Style one tab cell. Active and inactive cells have the same visible width so + * switching never shifts the layout. */ +function styleTab(label: string, isActive: boolean, colors: ColorPalette): string { + const cell = ` ${label} `; + return isActive + ? chalk.bgHex(colors.primary).hex(colors.text).bold(cell) + : chalk.hex(colors.textMuted)(cell); +} + +export function renderTabStrip(opts: RenderTabStripOptions): string { + const { labels, activeIndex, width, colors } = opts; + const segments = labels.map((label, i) => styleTab(label, i === activeIndex, colors)); + + // If everything fits with a leading space, show the whole strip. Account for + // the single spaces `segments.join(' ')` inserts between tabs — otherwise the + // strip is declared to fit at widths where the joined line is actually wider + // and gets truncated instead of showing the `<`/`>` scroll markers. + const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0); + const fullSeparatorWidth = Math.max(0, segments.length - 1); + if (1 + totalSegmentWidth + fullSeparatorWidth <= width) { + return ' ' + segments.join(' '); + } + + // Scrolling needed. Find the widest window that contains activeIndex. + const segmentWidths = segments.map((s) => visibleWidth(s)); + let start = activeIndex; + let end = activeIndex + 1; + let contentWidth = segmentWidths[activeIndex] ?? 0; + + const fits = (s: number, e: number, cw: number): boolean => { + const needLeft = s > 0; + const needRight = e < segments.length; + const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0); + const separators = Math.max(0, e - s - 1); + return cw + separators + frameWidth <= width; + }; + + while (true) { + const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity; + const rightW = end < segments.length ? segmentWidths[end]! : Infinity; + if (leftW === Infinity && rightW === Infinity) break; + + if (leftW <= rightW) { + if (fits(start - 1, end, contentWidth + leftW)) { + contentWidth += leftW; + start--; + } else if (fits(start, end + 1, contentWidth + rightW)) { + contentWidth += rightW; + end++; + } else { + break; + } + } else if (fits(start, end + 1, contentWidth + rightW)) { + contentWidth += rightW; + end++; + } else if (fits(start - 1, end, contentWidth + leftW)) { + contentWidth += leftW; + start--; + } else { + break; + } + } + + const hasLeft = start > 0; + const hasRight = end < segments.length; + let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' '; + strip += segments.slice(start, end).join(' '); + if (hasRight) { + strip += chalk.hex(colors.textMuted)(' >'); + } + return strip; +} diff --git a/apps/kimi-code/src/tui/utils/terminal-notification.ts b/apps/kimi-code/src/tui/utils/terminal-notification.ts index 2a0247cc7..c71f41df9 100644 --- a/apps/kimi-code/src/tui/utils/terminal-notification.ts +++ b/apps/kimi-code/src/tui/utils/terminal-notification.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import { BEL, ESC, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH, ST } from '#/tui/constant/terminal'; import type { TUIState } from '#/tui/tui-state'; @@ -110,6 +110,25 @@ export function supportsOsc9Notification(env: NodeJS.ProcessEnv = process.env): return false; } +/** + * Best-effort detection of ConEmu-style OSC 9;4 progress support, driven + * off well-known environment variables like `supportsOsc9Notification`. + * The two allow-lists must stay separate: iTerm2 posts a desktop + * notification for ANY `OSC 9;<payload>` it receives, so sending the 9;4 + * progress sequence there pops a "4;3" notification every keepalive tick. + * Terminals outside this list simply get no progress reporting, which is + * always safe. + */ +export function supportsTerminalProgress(env: NodeJS.ProcessEnv = process.env): boolean { + if ((env['WT_SESSION'] ?? '').length > 0) return true; + if (env['ConEmuANSI'] === 'ON') return true; + const termProgram = env['TERM_PROGRAM'] ?? ''; + if (termProgram === 'ghostty' || termProgram === 'WezTerm') return true; + const term = env['TERM'] ?? ''; + if (term === 'xterm-ghostty') return true; + return false; +} + export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean { const tmux = env['TMUX'] ?? ''; return tmux.length > 0; diff --git a/apps/kimi-code/src/tui/utils/terminal-state.ts b/apps/kimi-code/src/tui/utils/terminal-state.ts index 86d2bab9f..29127ea02 100644 --- a/apps/kimi-code/src/tui/utils/terminal-state.ts +++ b/apps/kimi-code/src/tui/utils/terminal-state.ts @@ -1,9 +1,14 @@ -import { isInsideTmux, supportsOsc9Notification } from './terminal-notification'; +import { + isInsideTmux, + supportsOsc9Notification, + supportsTerminalProgress, +} from './terminal-notification'; export interface TerminalState { notificationKeys: Set<string>; focused: boolean; supportsOsc9: boolean; + supportsProgress: boolean; insideTmux: boolean; progressActive: boolean; } @@ -13,6 +18,7 @@ export function createTerminalState(): TerminalState { notificationKeys: new Set<string>(), focused: true, supportsOsc9: supportsOsc9Notification(), + supportsProgress: supportsTerminalProgress(), insideTmux: isInsideTmux(), progressActive: false, }; diff --git a/apps/kimi-code/src/tui/utils/thinking-config.ts b/apps/kimi-code/src/tui/utils/thinking-config.ts new file mode 100644 index 000000000..2e8411ef9 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/thinking-config.ts @@ -0,0 +1,36 @@ +import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; + +/** Whether a thinking effort represents "thinking enabled" (anything but 'off'). */ +export function isThinkingOn(effort: ThinkingEffort): boolean { + return effort !== 'off'; +} + +/** + * Project a thinking effort to the `[thinking]` config patch persisted to + * config.toml. `'off'` disables thinking; a concrete effort enables thinking + * and records it as the global effort preference. `'on'` is the boolean-model + * on-signal rather than a declared effort, so it only persists `enabled` — + * boolean models resolve back to `'on'` at runtime via `defaultThinkingEffortFor`. + */ +export function thinkingEffortToConfig(effort: ThinkingEffort): { + enabled: boolean; + effort?: string; +} { + if (effort === 'off') return { enabled: false }; + if (effort === 'on') return { enabled: true }; + return { enabled: true, effort }; +} + +/** + * Inverse of {@link thinkingEffortToConfig}: derive the runtime thinking effort + * to activate a model with from the persisted `[thinking]` config. Returns + * `'off'` when thinking is disabled, the configured concrete effort when set, + * and `undefined` when thinking is enabled without a concrete effort so the + * model's own default applies. + */ +export function thinkingEffortFromConfig( + config: { enabled?: boolean; effort?: string } | undefined, +): ThinkingEffort | undefined { + if (config?.enabled === false) return 'off'; + return config?.effort; +} diff --git a/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts new file mode 100644 index 000000000..94cb45693 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts @@ -0,0 +1,15 @@ +import type { Component } from '@moonshot-ai/pi-tui'; + +import type { TranscriptEntry } from '../types'; + +const componentEntries = new WeakMap<Component, TranscriptEntry>(); + +export function markTranscriptComponent(component: Component, entry: TranscriptEntry): void { + componentEntries.set(component, entry); +} + +export function getTranscriptComponentEntry( + component: Component, +): TranscriptEntry | undefined { + return componentEntries.get(component); +} diff --git a/apps/kimi-code/src/tui/utils/transcript-window.ts b/apps/kimi-code/src/tui/utils/transcript-window.ts new file mode 100644 index 000000000..ed0083b6f --- /dev/null +++ b/apps/kimi-code/src/tui/utils/transcript-window.ts @@ -0,0 +1,109 @@ +/** + * Sliding window for the TUI transcript. + * + * The transcript grows unbounded as the conversation goes on. To keep the TUI + * responsive and bounded, we only keep the most recent N *turns* (a turn = a + * user prompt plus everything the assistant does in response, identified by a + * shared `turnId`), and destroy older turns wholesale (component + entry). + * + * All threshold logic here is pure so it can be unit-tested in isolation; the + * constants are the production defaults passed in by the TUI. + */ + +import type { TranscriptEntry } from '../types'; + +/** + * Read a non-negative integer env var, falling back to `fallback` when it is + * unset, empty, negative, or not an integer. `0` is a valid value (call sites + * treat it as "feature disabled"). + */ +export function readEnvInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) return fallback; + return value; +} + +/** Master switch for the sliding window. */ +export const TRANSCRIPT_WINDOW_ENABLED = true; + +/** Keep the most recent N turns. `0` disables trimming. */ +export const TRANSCRIPT_MAX_TURNS = readEnvInt('KIMI_CODE_TUI_MAX_TURNS', 15); + +/** Only the most recent E turns are allowed to expand (Ctrl+O). `0` disables expanding. */ +export const TRANSCRIPT_EXPAND_TURNS = readEnvInt('KIMI_CODE_TUI_EXPAND_TURNS', 3); + +/** Only trim once the window exceeds maxTurns by this much (avoids churn). */ +export const TRANSCRIPT_HYSTERESIS = readEnvInt('KIMI_CODE_TUI_HYSTERESIS', 5); + +/** Keep this many recent steps untouched inside a turn; older steps are merged into a summary. `0` disables merging. */ +export const TRANSCRIPT_KEEP_RECENT_STEPS = readEnvInt('KIMI_CODE_TUI_KEEP_RECENT_STEPS', 30); + +export interface TranscriptTurn { + readonly turnId: string | undefined; + readonly entries: TranscriptEntry[]; +} + +/** + * Group consecutive entries into turns by `turnId`. Entries with the same + * non-undefined `turnId` that are adjacent belong to the same turn. + * + * Entries with an undefined `turnId` are buffered and attached to the *next* + * defined turn. This matters because a user message is appended (with + * `turnId: undefined`) before its turn actually starts, so without this + * buffering every user message would become its own single-entry turn at the + * front and get trimmed first. Any undefined entries left at the tail (no + * following turn) become their own turn. + */ +export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[] { + const turns: TranscriptTurn[] = []; + let current: TranscriptTurn | undefined; + let pendingUndefined: TranscriptEntry[] = []; + + for (const entry of entries) { + const turnId = entry.turnId; + if (turnId === undefined) { + pendingUndefined.push(entry); + continue; + } + if (current !== undefined && current.turnId === turnId) { + current.entries.push(entry); + } else { + current = { turnId, entries: [...pendingUndefined, entry] }; + pendingUndefined = []; + turns.push(current); + } + } + + if (pendingUndefined.length > 0) { + turns.push({ turnId: undefined, entries: pendingUndefined }); + } + + return turns; +} + +/** + * Decide which entries to destroy so the remaining turns fit within + * `maxTurns`. Returns an empty set when the turn count is within + * `maxTurns + hysteresis`. Oldest turns are removed first; the most recent + * turn is never removed (it is the active / just-finished turn). + */ +export function turnsToTrim( + turns: readonly TranscriptTurn[], + maxTurns: number, + hysteresis: number, +): Set<TranscriptEntry> { + const toRemove = new Set<TranscriptEntry>(); + + if (turns.length <= maxTurns + hysteresis) return toRemove; + + let remaining = turns.length; + // `turns.length - 1` keeps the most recent turn off-limits. + for (let i = 0; i < turns.length - 1 && remaining > maxTurns; i++) { + const turn = turns[i]!; + for (const entry of turn.entries) toRemove.add(entry); + remaining--; + } + return toRemove; +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts new file mode 100644 index 000000000..dc0f60886 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts @@ -0,0 +1,158 @@ +import { readFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; + +import type { ClipboardModule } from './clipboard-native'; + +export type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; +export type RunCommand = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => { stdout: Buffer; ok: boolean }; +export type RunCommandAsync = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => Promise<{ stdout: Buffer; ok: boolean }>; + +export const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; + +export const DEFAULT_LIST_TIMEOUT_MS = 1000; +export const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; + +export function baseMimeType(raw: string): string { + return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); +} + +export function isSupportedImageMimeType(mime: string): boolean { + const base = baseMimeType(mime); + return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); +} + +export function parseTargetList(output: Buffer): string[] { + return output + .toString('utf-8') + .split(/\r?\n/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +export function runCommand( + command: string, + args: string[], + options?: RunCommandOptions, +): { stdout: Buffer; ok: boolean } { + const result = spawnSync(command, args, { + timeout: options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS, + maxBuffer: DEFAULT_MAX_BUFFER_BYTES, + env: options?.env, + }); + if (result.error !== undefined || result.status !== 0) { + return { ok: false, stdout: Buffer.alloc(0) }; + } + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); + return { ok: true, stdout }; +} + +/** + * Non-blocking counterpart of `runCommand`. Used by the clipboard image probe + * on the startup path so a slow or wedged helper (notably `powershell.exe` on + * WSL, or a stuck `wl-paste`/`xclip`) cannot freeze the event loop. The child + * is killed and the promise resolves with `ok: false` once `timeoutMs` elapses + * or the captured stdout exceeds `DEFAULT_MAX_BUFFER_BYTES`. + */ +export function runCommandAsync( + command: string, + args: string[], + options?: RunCommandOptions, +): Promise<{ stdout: Buffer; ok: boolean }> { + const timeoutMs = options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS; + return new Promise((resolve) => { + let child; + try { + child = spawn(command, args, { + env: options?.env, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + + const chunks: Buffer[] = []; + let totalBytes = 0; + let settled = false; + let timer: ReturnType<typeof setTimeout>; + + // Marks the promise as settled and clears the timeout. Returns true only for + // the first caller, so each event handler below resolves at most once. + const claim = (): boolean => { + if (settled) return false; + settled = true; + clearTimeout(timer); + return true; + }; + + timer = setTimeout(() => { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > DEFAULT_MAX_BUFFER_BYTES) { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + chunks.push(chunk); + }); + + child.on('error', () => { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }); + + child.on('close', (code) => { + if (code !== 0) { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + if (claim()) resolve({ ok: true, stdout: Buffer.concat(chunks) }); + }); + }); +} + +export function isWaylandSession(env: NodeJS.ProcessEnv): boolean { + return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; +} + +export function isWSL(env: NodeJS.ProcessEnv): boolean { + if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; + try { + return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); + } catch { + return false; + } +} + +export function isFileLikeNativeFormat(format: string): boolean { + const f = format.toLowerCase(); + const base = baseMimeType(format); + return ( + f.includes('file-url') || + f.includes('file url') || + f.includes('nsfilenames') || + f.includes('com.apple.finder') || + base === 'text/uri-list' || + base === 'public.url' + ); +} + +export function safeAvailableFormats(clip: ClipboardModule | null): string[] { + if (clip?.availableFormats === undefined) return []; + try { + return clip.availableFormats(); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts new file mode 100644 index 000000000..8c344d02e --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts @@ -0,0 +1,44 @@ +import { isFileLikeNativeFormat, safeAvailableFormats } from './clipboard-common'; +import { clipboard, type ClipboardModule } from './clipboard-native'; + +async function hasImageViaNative(clip: ClipboardModule | null): Promise<boolean> { + if (clip === null) return false; + + // Finder exposes file icons/thumbnails as image data when a non-image file + // is copied. Treat file-like clipboard contents as "not a pasteable image" + // to match the read path in clipboard-image.ts. + const formats = safeAvailableFormats(clip); + if (formats.some(isFileLikeNativeFormat)) return false; + + try { + return clip.hasImage(); + } catch { + return false; + } +} + +export async function clipboardHasImage(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + clipboard?: ClipboardModule | null; +}): Promise<boolean> { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + const clip = options?.clipboard ?? clipboard; + + if (env['TERMUX_VERSION'] !== undefined) return false; + + // The focus-driven clipboard-image hint does not probe on Linux. The probe + // would spawn wl-paste / xclip, which on Wayland perturbs seat focus and + // re-triggers the terminal's focus event, creating a focus feedback loop + // (window repeatedly gains/loses focus, IME candidate window cannot stay + // focused — see issue #1090). macOS and Windows are fine: both use the + // in-process native module, which neither spawns a subprocess nor perturbs + // focus. + // + // Image *paste* is unaffected on all platforms: it reads the clipboard + // through readClipboardMedia() on the explicit paste path, not here. + if (platform !== 'darwin' && platform !== 'win32') return false; + + return hasImageViaNative(clip); +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts index 0bb8d3c76..6aae761c4 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts @@ -16,7 +16,6 @@ * supported, or every fallback fails. */ -import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { readFileSync, statSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -25,6 +24,20 @@ import { fileURLToPath } from 'node:url'; import { parseImageMeta } from '#/utils/image/image-mime'; +import { + DEFAULT_LIST_TIMEOUT_MS, + SUPPORTED_IMAGE_MIME_TYPES, + baseMimeType, + isFileLikeNativeFormat, + isSupportedImageMimeType, + isWaylandSession, + isWSL, + parseTargetList, + runCommand as runCommandBase, + safeAvailableFormats, + type RunCommand, + type RunCommandOptions, +} from './clipboard-common'; import { clipboard, type ClipboardModule } from './clipboard-native'; export interface ClipboardImage { @@ -49,14 +62,6 @@ export class ClipboardMediaError extends Error { } } -type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; -type RunCommand = ( - command: string, - args: string[], - options?: RunCommandOptions, -) => { stdout: Buffer; ok: boolean }; - -const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; const MAX_VIDEO_BYTES = 100 * 1024 * 1024; const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({ @@ -75,10 +80,8 @@ const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({ '.3g2': 'video/3gpp2', }); -const DEFAULT_LIST_TIMEOUT_MS = 1000; const DEFAULT_READ_TIMEOUT_MS = 3000; const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000; -const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; const MACOS_FILE_PATH_SCRIPT = String.raw` ObjC.import('AppKit'); @@ -115,28 +118,6 @@ if (String(pb) !== '[id nil]') { out.join('\n'); `.trim(); -function isWaylandSession(env: NodeJS.ProcessEnv): boolean { - return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; -} - -function isWSL(env: NodeJS.ProcessEnv): boolean { - if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; - try { - return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); - } catch { - return false; - } -} - -function baseMimeType(raw: string): string { - return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); -} - -function isSupportedImageMimeType(mime: string): boolean { - const base = baseMimeType(mime); - return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); -} - function selectPreferredImageMimeType(candidates: string[]): string | null { const normalized = candidates .map((t) => t.trim()) @@ -253,29 +234,11 @@ function readMediaFromText(text: string): ClipboardMedia | null { return readMediaFromPaths(parseClipboardPaths(text)); } -function runCommand( - command: string, - args: string[], - options?: RunCommandOptions, -): { stdout: Buffer; ok: boolean } { - const result = spawnSync(command, args, { - timeout: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, - maxBuffer: DEFAULT_MAX_BUFFER_BYTES, +function runCommand(command: string, args: string[], options?: RunCommandOptions): { stdout: Buffer; ok: boolean } { + return runCommandBase(command, args, { + timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, env: options?.env, }); - if (result.error !== undefined || result.status !== 0) { - return { ok: false, stdout: Buffer.alloc(0) }; - } - const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); - return { ok: true, stdout }; -} - -function parseTargetList(output: Buffer): string[] { - return output - .toString('utf-8') - .split(/\r?\n/) - .map((t) => t.trim()) - .filter((t) => t.length > 0); } function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { @@ -394,28 +357,6 @@ function readClipboardFilePathsViaMacOs(run: RunCommand): string[] { return parseClipboardPaths(result.stdout.toString('utf-8')); } -function isFileLikeNativeFormat(format: string): boolean { - const f = format.toLowerCase(); - const base = baseMimeType(format); - return ( - f.includes('file-url') || - f.includes('file url') || - f.includes('nsfilenames') || - f.includes('com.apple.finder') || - base === 'text/uri-list' || - base === 'public.url' - ); -} - -function safeAvailableFormats(clip: ClipboardModule | null): string[] { - if (clip?.availableFormats === undefined) return []; - try { - return clip.availableFormats(); - } catch { - return []; - } -} - async function readClipboardFileMediaViaNativeText( clip: ClipboardModule | null, ): Promise<{ media: ClipboardMedia | null; lookedFileLike: boolean }> { diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-native.ts b/apps/kimi-code/src/utils/clipboard/clipboard-native.ts index 051e9af46..cd136b403 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-native.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-native.ts @@ -18,6 +18,7 @@ export interface ClipboardModule { availableFormats?(): string[]; hasText?(): boolean; getText?(): Promise<string>; + setText?(text: string): Promise<void>; hasImage(): boolean; getImageBinary(): Promise<Array<number>>; } diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-text.ts b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts new file mode 100644 index 000000000..8a8295f57 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts @@ -0,0 +1,55 @@ +import { spawnSync } from 'node:child_process'; + +import { clipboard } from './clipboard-native'; + +function runClipboardCommand(command: string, args: readonly string[], input: string): void { + const result = spawnSync(command, args, { encoding: 'utf8', input }); + if (result.error) throw result.error; + if (result.status === 0) return; + + const detail = result.stderr.trim(); + throw new Error( + detail.length > 0 + ? `${command} exited with code ${String(result.status)}: ${detail}` + : `${command} exited with code ${String(result.status)}`, + ); +} + +async function copyWithPlatformCommand(text: string): Promise<void> { + const commands = + process.platform === 'darwin' + ? [{ command: 'pbcopy', args: [] as string[] }] + : process.platform === 'win32' + ? [{ command: 'clip.exe', args: [] as string[] }] + : [ + { command: 'wl-copy', args: [] as string[] }, + { command: 'xclip', args: ['-selection', 'clipboard'] }, + ]; + + let lastError: unknown; + for (const candidate of commands) { + try { + runClipboardCommand(candidate.command, candidate.args, text); + return; + } catch (error) { + lastError = error; + } + } + + if (lastError instanceof Error) throw lastError; + throw new Error('No clipboard command is available.'); +} + +export async function copyTextToClipboard(text: string): Promise<void> { + const clipboardModule = clipboard; + if (clipboardModule?.setText !== undefined) { + try { + await clipboardModule.setText(text); + return; + } catch { + // Fall back to platform clipboard commands below. + } + } + + await copyWithPlatformCommand(text); +} diff --git a/apps/kimi-code/src/utils/experimental-features.ts b/apps/kimi-code/src/utils/experimental-features.ts new file mode 100644 index 000000000..b14e083fb --- /dev/null +++ b/apps/kimi-code/src/utils/experimental-features.ts @@ -0,0 +1,10 @@ +import type { + ExperimentalFeatureState, + ExperimentalFlagMap, +} from '@moonshot-ai/kimi-code-sdk'; + +export function experimentalFeatureMap( + features: readonly Pick<ExperimentalFeatureState, 'id' | 'enabled'>[], +): ExperimentalFlagMap { + return Object.fromEntries(features.map((feature) => [feature.id, feature.enabled])); +} diff --git a/apps/kimi-code/src/utils/git/git-ls-files.ts b/apps/kimi-code/src/utils/git/git-ls-files.ts deleted file mode 100644 index a68613284..000000000 --- a/apps/kimi-code/src/utils/git/git-ls-files.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Git-aware file listing + relevance signals with a short-TTL cache. - * Used as the cross-directory `@file` completion source when `fd` is - * not installed. - * - * Tracks three things per snapshot, all refreshed atomically: - * - `files` deduped (tracked + untracked-not-ignored), capped at 1000 - * - `mtimeByPath` absolute-path → fs mtime (ms), for recency ranking - * - `recencyOrder` file path → position in recent git history (0-indexed; smaller = more recent) - * - * Rebuild strategy: 2s TTL plus `.git/index` mtime invalidation so - * rapid edits surface without paying the full spawn+stat cost on every - * keystroke. When outside a git worktree, - * `getSnapshot()` returns `null` and callers fall back further. - */ - -import { spawnSync } from 'node:child_process'; -import { existsSync, statSync } from 'node:fs'; -import { join } from 'node:path'; - -const TTL_MS = 2000; -const MAX_ENTRIES = 1000; -// Number of most-recent commits to scan for "recently edited" hotness. -// 200 is enough to cover a week of active work in a typical repo while -// staying fast (<100ms even on large repos). -const RECENT_COMMIT_DEPTH = 200; - -export interface GitSnapshot { - readonly files: readonly string[]; - /** Absolute path → mtime (ms). Missing entries = stat failed. */ - readonly mtimeByPath: ReadonlyMap<string, number>; - /** Path → 0-indexed recency rank (earlier = more recent). */ - readonly recencyOrder: ReadonlyMap<string, number>; -} - -export interface GitLsFilesCache { - /** Full snapshot, or `null` when the work dir is not a git repo. */ - getSnapshot(): GitSnapshot | null; - /** Convenience shortcut; identical to `getSnapshot()?.files ?? null`. */ - list(): string[] | null; - isGitRepo(): boolean; -} - -interface SnapshotState { - snapshot: GitSnapshot; - fetchedAt: number; - indexMtime: number; -} - -export function createGitLsFilesCache(workDir: string): GitLsFilesCache { - const gitRoot = resolveGitRoot(workDir); - const indexPath = gitRoot === null ? null : join(gitRoot, '.git', 'index'); - let state: SnapshotState | undefined; - - return { - isGitRepo: () => gitRoot !== null, - getSnapshot: () => { - if (gitRoot === null) return null; - - const now = Date.now(); - const currentIndexMtime = indexMtime(indexPath); - const fresh = - state !== undefined && - now - state.fetchedAt < TTL_MS && - state.indexMtime === currentIndexMtime; - if (fresh) return state!.snapshot; - - const snapshot = fetchSnapshot(gitRoot); - if (snapshot === null) return null; // transient git failure — retry next call - - state = { snapshot, fetchedAt: now, indexMtime: currentIndexMtime }; - return snapshot; - }, - list: function listCompat() { - return this.getSnapshot()?.files.slice() ?? null; - }, - }; -} - -function resolveGitRoot(workDir: string): string | null { - try { - const result = spawnSync('git', ['-C', workDir, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', - }); - if (result.status !== 0) return null; - const stdout = result.stdout.trim(); - return stdout.length > 0 ? stdout : null; - } catch { - return null; - } -} - -function indexMtime(indexPath: string | null): number { - if (indexPath === null || !existsSync(indexPath)) return 0; - try { - return statSync(indexPath).mtimeMs; - } catch { - return 0; - } -} - -function fetchSnapshot(gitRoot: string): GitSnapshot | null { - const tracked = runLsFiles(gitRoot, ['-z']); - if (tracked === null) return null; - const untracked = runLsFiles(gitRoot, ['-z', '--others', '--exclude-standard']); - if (untracked === null) return null; - - const seen = new Set<string>(); - for (const path of tracked) seen.add(path); - for (const path of untracked) seen.add(path); - const merged = [...seen].toSorted(); - const files = merged.length > MAX_ENTRIES ? merged.slice(0, MAX_ENTRIES) : merged; - - const mtimeByPath = collectMtimes(gitRoot, files); - const recencyOrder = collectRecencyOrder(gitRoot, new Set(files)); - - return { files, mtimeByPath, recencyOrder }; -} - -function runLsFiles(gitRoot: string, args: readonly string[]): string[] | null { - try { - const result = spawnSync('git', ['-C', gitRoot, 'ls-files', ...args], { - encoding: 'utf8', - maxBuffer: 16 * 1024 * 1024, - }); - if (result.status !== 0) return null; - return result.stdout.split('\0').filter((entry) => entry.length > 0); - } catch { - return null; - } -} - -function collectMtimes(gitRoot: string, files: readonly string[]): Map<string, number> { - const result = new Map<string, number>(); - for (const path of files) { - try { - const stat = statSync(join(gitRoot, path)); - result.set(path, stat.mtimeMs); - } catch { - // File was deleted between ls-files and stat, or permission error. - // Missing entry → ranker treats it as "no mtime signal". - } - } - return result; -} - -/** - * Walk the last RECENT_COMMIT_DEPTH commits and record the first time - * each path is seen (a file touched in HEAD wins over a file touched - * 50 commits ago). Runs on the whole repo, not the work dir, so rename - * tracking stays consistent even when the user cd's into a subdir. - * - * `trackedSet` filters out paths that were renamed away / deleted — we - * only care about files that still appear in `ls-files`, since those - * are the ones we could actually complete. - */ -function collectRecencyOrder(gitRoot: string, trackedSet: Set<string>): Map<string, number> { - const result = new Map<string, number>(); - try { - const proc = spawnSync( - 'git', - [ - '-C', - gitRoot, - 'log', - `-n`, - String(RECENT_COMMIT_DEPTH), - '--name-only', - '--pretty=format:', - '--no-renames', - ], - { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, - ); - if (proc.status !== 0) return result; - let rank = 0; - for (const raw of proc.stdout.split('\n')) { - const line = raw.trim(); - if (line.length === 0) continue; - if (result.has(line)) continue; // keep the earliest (most recent) occurrence - if (!trackedSet.has(line)) continue; // drop deleted / renamed-away paths - result.set(line, rank); - rank += 1; - } - } catch { - // Fall through with whatever we've collected — an incomplete - // recency map just means fewer entries get a hotness boost. - } - return result; -} diff --git a/apps/kimi-code/src/utils/history/input-history.ts b/apps/kimi-code/src/utils/history/input-history.ts index 950542563..cade00bec 100644 --- a/apps/kimi-code/src/utils/history/input-history.ts +++ b/apps/kimi-code/src/utils/history/input-history.ts @@ -3,6 +3,10 @@ * * Semantics: * - One JSON object per line (`InputHistoryEntry { content }`) + * - `content` is the raw input. Shell commands are stored with a leading `!` + * (e.g. `!ls -la`) so ↑ recall can distinguish them from prompts and restore + * bash mode; the `!` is stripped again when the entry is recalled. Plain + * prompts (and legacy entries without a leading `!`) are normal prompts. * - Append-only writes * - Skip empty entries * - Skip when same as last entry (consecutive deduplication) diff --git a/apps/kimi-code/src/tui/utils/open-url.ts b/apps/kimi-code/src/utils/open-url.ts similarity index 100% rename from apps/kimi-code/src/tui/utils/open-url.ts rename to apps/kimi-code/src/utils/open-url.ts diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index b0375d376..83b681f1c 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -10,11 +10,18 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { + KIMI_CODE_BANNER_DIR_NAME, + KIMI_CODE_BANNER_STATE_FILE_NAME, + KIMI_CODE_BIN_DIR_NAME, + KIMI_CODE_CACHE_DIR_NAME, KIMI_CODE_DATA_DIR_NAME, KIMI_CODE_HOME_ENV, KIMI_CODE_INPUT_HISTORY_DIR_NAME, KIMI_CODE_LOG_DIR_NAME, + KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, + KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME, KIMI_CODE_UPDATE_DIR_NAME, + KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME, KIMI_CODE_UPDATE_STATE_FILE_NAME, } from '#/constant/app'; @@ -38,6 +45,20 @@ export function getLogDir(): string { return join(getDataDir(), KIMI_CODE_LOG_DIR_NAME); } +/** + * Return the CLI cache directory: `<dataDir>/cache/`. + */ +export function getCacheDir(): string { + return join(getDataDir(), KIMI_CODE_CACHE_DIR_NAME); +} + +/** + * Return the managed tools directory: `<dataDir>/bin/`. + */ +export function getBinDir(): string { + return join(getDataDir(), KIMI_CODE_BIN_DIR_NAME); +} + /** * Return the update cache file: `<dataDir>/updates/latest.json`. */ @@ -45,6 +66,34 @@ export function getUpdateStateFile(): string { return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_STATE_FILE_NAME); } +/** + * Return the update install state file: `<dataDir>/updates/install.json`. + */ +export function getUpdateInstallStateFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME); +} + +/** + * Return the update install lock file: `<dataDir>/updates/install.lock`. + */ +export function getUpdateInstallLockFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME); +} + +/** + * Return the rollout decision log: `<dataDir>/updates/rollout.log`. + */ +export function getUpdateRolloutLogFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME); +} + +/** + * Return the banner display state file: `<dataDir>/cache/banner/state.json`. + */ +export function getBannerStateFile(): string { + return join(getCacheDir(), KIMI_CODE_BANNER_DIR_NAME, KIMI_CODE_BANNER_STATE_FILE_NAME); +} + /** * Return the user input history file for a given working directory. * Layout: `<share_dir>/user-history/<md5(cwd)>.jsonl`. diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index d11748a55..be55e798e 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -1,8 +1,10 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gt, valid } from 'semver'; + import { KIMI_CODE_PLUGIN_MARKETPLACE_URL, KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, @@ -29,6 +31,36 @@ export interface PluginMarketplace { readonly plugins: readonly PluginMarketplaceEntry[]; } +export type PluginUpdateStatus = + | { readonly kind: 'not-installed' } + | { readonly kind: 'up-to-date'; readonly version?: string } + | { readonly kind: 'update'; readonly local: string; readonly latest: string }; + +/** + * Compare a marketplace entry's (latest) version against the locally installed + * version. Only reports `update` when both are valid semver and latest > local, + * so a stale or non-semver version never produces a spurious or downgrading prompt. + */ +export function computeUpdateStatus( + latest: string | undefined, + local: string | undefined, + installed: boolean, +): PluginUpdateStatus { + if (!installed) return { kind: 'not-installed' }; + if ( + latest !== undefined && + local !== undefined && + valid(latest) !== null && + valid(local) !== null && + gt(latest, local) + ) { + return { kind: 'update', local, latest }; + } + // Report only the actual installed version. When it is unknown, don't borrow the + // marketplace version — that would falsely claim "up to date" and hide future updates. + return { kind: 'up-to-date', version: local }; +} + interface MarketplaceLocation { readonly raw: string; readonly kind: 'remote' | 'local'; @@ -44,12 +76,37 @@ export interface LoadPluginMarketplaceOptions { export async function loadPluginMarketplace( options: LoadPluginMarketplaceOptions, ): Promise<PluginMarketplace> { + const configuredSource = options.source ?? process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; const location = resolveMarketplaceLocation( - options.source ?? process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL, + configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL, options.workDir, ); - const raw = await readMarketplaceText(location, options.fetchImpl ?? fetch); - return parsePluginMarketplace(raw, location); + const fetchImpl = options.fetchImpl ?? fetch; + let raw: string; + try { + raw = await readMarketplaceText(location, fetchImpl); + } catch (error) { + const fallback = + configuredSource === undefined ? await getSourceCheckoutMarketplaceLocation() : undefined; + if (fallback === undefined) throw error; + raw = await readMarketplaceText(fallback, fetchImpl); + return withLatestVersions(parsePluginMarketplace(raw, fallback), fetchImpl); + } + return withLatestVersions(parsePluginMarketplace(raw, location), fetchImpl); +} + +async function withLatestVersions( + marketplace: PluginMarketplace, + fetchImpl: typeof fetch, +): Promise<PluginMarketplace> { + const plugins = await Promise.all( + marketplace.plugins.map(async (entry) => { + if (entry.version !== undefined) return entry; + const latest = await resolveLatestGithubRelease(entry.source, fetchImpl); + return latest === undefined ? entry : { ...entry, version: latest }; + }), + ); + return { ...marketplace, plugins }; } export function parsePluginMarketplace(raw: string, location: MarketplaceLocation): PluginMarketplace { @@ -92,6 +149,14 @@ function resolveMarketplaceLocation(source: string, workDir: string): Marketplac return { raw: trimmed, kind: 'local', resolved: resolveLocalPath(trimmed, workDir) }; } +async function getSourceCheckoutMarketplaceLocation(): Promise<MarketplaceLocation | undefined> { + const sourceDir = dirname(fileURLToPath(import.meta.url)); + const marketplacePath = resolve(sourceDir, '../../../../plugins/marketplace.json'); + const info = await stat(marketplacePath).catch(() => undefined); + if (info?.isFile() !== true) return undefined; + return { raw: marketplacePath, kind: 'local', resolved: marketplacePath }; +} + async function readMarketplaceText( location: MarketplaceLocation, fetchImpl: typeof fetch, @@ -115,24 +180,39 @@ function parseMarketplaceEntry( throw new TypeError(`Plugin marketplace entry ${index + 1} must be an object.`); } const id = requiredString(value, 'id', index); + validateMarketplaceEntryType(value, id); const source = stringField(value, 'source') ?? stringField(value, 'url') ?? stringField(value, 'downloadUrl'); if (source === undefined) { throw new Error(`Plugin marketplace entry ${id} must define "source".`); } + const resolvedSource = resolveEntrySource(source, location); return { id, displayName: stringField(value, 'displayName') ?? stringField(value, 'name') ?? id, - source: resolveEntrySource(source, location), + source: resolvedSource, tier: parseMarketplaceTier(value, id), - version: stringField(value, 'version'), + version: stringField(value, 'version') ?? deriveVersionFromGithubSource(resolvedSource), description: stringField(value, 'description') ?? stringField(value, 'shortDescription'), homepage: stringField(value, 'homepage') ?? stringField(value, 'websiteURL'), keywords: stringArrayField(value, 'keywords'), }; } +function validateMarketplaceEntryType(value: Record<string, unknown>, id: string): void { + const raw = value['type']; + if (raw === undefined) return; + if (typeof raw !== 'string') { + throw new TypeError(`Plugin marketplace entry ${id} "type" must be a string.`); + } + const type = raw.trim(); + if (type === 'plugin' || type === 'managed' || type === 'guide') return; + throw new Error( + `Plugin marketplace entry ${id} "type" must be "plugin". Legacy aliases "managed" and "guide" are also accepted.`, + ); +} + function parseMarketplaceTier( value: Record<string, unknown>, id: string, @@ -170,6 +250,105 @@ function resolveEntrySource(source: string, location: MarketplaceLocation): stri return resolve(dirname(location.resolved), trimmed); } +/** + * Best-effort derivation of a semver version from a GitHub source URL that pins + * a specific ref. Lets a marketplace entry omit `version` when the source + * already encodes the release (for example `/releases/tag/v6.0.3`), keeping the + * source URL the single source of truth and avoiding drift between the two. + * + * Only refs shaped like semver (`v6.0.3`, `6.0.3`, `6.0.3-rc.1`) are accepted; + * bare repo URLs, branch names and commit SHAs yield `undefined`, so update + * detection degrades to "unknown" instead of comparing meaningless values. + */ +function deriveVersionFromGithubSource(source: string): string | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') { + return undefined; + } + // Pathname shape: /<owner>/<repo>/<tail...>. Recognized tails: + // releases/tag/<tag> + // tree/<ref> + // commit/<sha> + const [, , kind, a, b] = url.pathname.split('/').filter(Boolean); + const ref = + kind === 'releases' && a === 'tag' ? b : kind === 'tree' || kind === 'commit' ? a : undefined; + if (ref === undefined) return undefined; + let decoded: string; + try { + decoded = decodeURIComponent(ref); + } catch { + decoded = ref; + } + const candidate = decoded.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; +} + +async function resolveLatestGithubRelease( + source: string, + fetchImpl: typeof fetch, +): Promise<string | undefined> { + const repo = parseGithubRepo(source); + if (repo === undefined) return undefined; + try { + const tag = await fetchLatestReleaseTag(repo.owner, repo.repo, fetchImpl); + if (tag === undefined) return undefined; + const candidate = tag.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; + } catch { + return undefined; + } +} + +function parseGithubRepo(source: string): { owner: string; repo: string } | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined; + // Only bare repo URLs (/<owner>/<repo>) qualify — URLs with a ref tail are + // already handled by deriveVersionFromGithubSource. + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length !== 2) return undefined; + const [owner, repo] = segments; + return { owner: owner!, repo: repo! }; +} + +async function fetchLatestReleaseTag( + owner: string, + repo: string, + fetchImpl: typeof fetch, +): Promise<string | undefined> { + // Avoid api.github.com: its anonymous quota is shared with the user's browser + // and other tools, and a first-time lookup failing because something else + // burned the budget is unacceptable. The /releases/latest UI route 302s to + // the tag and is not part of the API quota. + const url = `https://github.com/${owner}/${repo}/releases/latest`; + const resp = await fetchImpl(url, { redirect: 'manual' }); + if (resp.status === 404) return undefined; + if (resp.status !== 301 && resp.status !== 302) { + throw new Error( + `Could not look up latest release of ${owner}/${repo}: HTTP ${resp.status} (${url}).`, + ); + } + const location = resp.headers.get('location'); + if (location === null) return undefined; + const match = /\/releases\/tag\/([^/?#]+)/.exec(location); + const tag = match?.[1]; + if (tag === undefined) return undefined; + try { + return decodeURIComponent(tag); + } catch { + return tag; + } +} + function resolveLocalPath(input: string, workDir: string): string { if (input === '~') return homedir(); if (input.startsWith('~/')) return join(homedir(), input.slice(2)); diff --git a/apps/kimi-code/src/utils/process/external-editor.ts b/apps/kimi-code/src/utils/process/external-editor.ts index 7aa895d3c..7881238b6 100644 --- a/apps/kimi-code/src/utils/process/external-editor.ts +++ b/apps/kimi-code/src/utils/process/external-editor.ts @@ -13,6 +13,8 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { quoteShellArg } from '#/utils/shell-quote'; + export function resolveEditorCommand(configured?: string | null): string | undefined { const candidates = [configured, process.env['VISUAL'], process.env['EDITOR']]; for (const c of candidates) { @@ -28,8 +30,8 @@ export function resolveEditorCommand(configured?: string | null): string | undef * with `initialText`. Returns the edited contents on success, or * `undefined` if the editor exited non-zero / the file disappeared. * - * The command is passed to `/bin/sh -c "<cmd> <tmpfile>"` so users can - * supply argv-style strings like `"code --wait"` or `"nvim +set ft=markdown"`. + * The command is passed to the system shell (`shell: true`) so users can + * supply argv-style strings like `code --wait` or `nvim +"set ft=markdown"`. */ export async function editInExternalEditor( initialText: string, @@ -39,10 +41,13 @@ export async function editInExternalEditor( const file = join(dir, 'prompt.md'); await writeFile(file, initialText, 'utf-8'); try { + const shellCmd = `${command} ${quoteShellArg(file)}`; const code = await new Promise<number>((resolve, reject) => { - const shellCmd = `${command} ${shellQuote(file)}`; - const child = spawn('/bin/sh', ['-c', shellCmd], { stdio: 'inherit' }); - child.on('exit', (c) =>{ resolve(c ?? 0); }); + const child = spawn(shellCmd, { + stdio: 'inherit', + shell: true, + }); + child.on('exit', (c) => { resolve(c ?? 0); }); child.on('error', reject); }); if (code !== 0) return undefined; @@ -54,7 +59,3 @@ export async function editInExternalEditor( } } -function shellQuote(path: string): string { - // Single-quote and escape any embedded single quotes. - return `'${path.replaceAll('\'', "'\\''")}'`; -} diff --git a/apps/kimi-code/src/utils/process/fd-detect.ts b/apps/kimi-code/src/utils/process/fd-detect.ts index d8fc662b5..ed97a0000 100644 --- a/apps/kimi-code/src/utils/process/fd-detect.ts +++ b/apps/kimi-code/src/utils/process/fd-detect.ts @@ -1,27 +1,205 @@ -/** - * Probe for the `fd` binary so the pi-tui `CombinedAutocompleteProvider` - * can enable its cross-directory fuzzy file search. - * - * Naming differs across distros: - * - Homebrew / Arch / most Linuxes: `fd` - * - Debian / Ubuntu: `fdfind` - * - * We use `spawnSync(..., { stdio: 'ignore' })` rather than shelling out - * to `which` so the check doesn't depend on the parent shell's PATH - * resolution semantics and stays cheap (~ms) on startup. - */ - +import { createHash } from 'node:crypto'; import { spawnSync } from 'node:child_process'; +import { + chmodSync, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, +} from 'node:fs'; +import { arch, platform } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { KIMI_CODE_CDN_BASE } from '#/constant/app'; +import { getBinDir } from '#/utils/paths'; const CANDIDATES = ['fd', 'fdfind']; +const FD_BASE_URL = `${KIMI_CODE_CDN_BASE}/fd`; +const DOWNLOAD_TIMEOUT_MS = 120_000; + +const FD_ARCHIVE_SHA256: Record<string, string> = { + 'fd-v10.4.2-aarch64-apple-darwin.tar.gz': + '623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a', + 'fd-v10.3.0-x86_64-apple-darwin.tar.gz': + '50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3', + 'fd-v10.4.2-aarch64-unknown-linux-gnu.tar.gz': + '6c51f7c5446b3338b1e401ff15dc194c590bb2fa64fd43ff3278300f073adec5', + 'fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz': + 'e3257d48e29a6be965187dbd24ce9af564e0fe67b3e73c9bdcd180f4ec11bdde', + 'fd-v10.4.2-aarch64-pc-windows-msvc.zip': + '4f9110c2d5b33a7f760bfa5510f4c113d828109f7277d421b1053a9943c0fc92', + 'fd-v10.4.2-x86_64-pc-windows-msvc.zip': + 'b2816e506390a89941c63c9187d58a3cc10e9a55f2ef0685f9ea0eccaf7c98c8', +}; export function detectFdPath(): string | null { + const managed = getManagedFdPath(); + if (managed !== null) return managed; + return detectSystemFdPath(); +} + +export async function ensureFdPath(): Promise<string | null> { + const existing = detectFdPath(); + if (existing !== null) return existing; + + try { + return await downloadFd(); + } catch { + return null; + } +} + +function detectSystemFdPath(): string | null { for (const name of CANDIDATES) { try { const result = spawnSync(name, ['--version'], { stdio: 'ignore' }); if (result.status === 0) return name; } catch { - // ENOENT, EACCES, etc. — try next candidate + // ENOENT, EACCES, etc. — try next candidate. + } + } + return null; +} + +function getManagedFdPath(): string | null { + const binaryPath = getManagedFdBinaryPath(); + if (!existsSync(binaryPath)) return null; + try { + const result = spawnSync(binaryPath, ['--version'], { stdio: 'ignore' }); + return result.status === 0 ? binaryPath : null; + } catch { + return null; + } +} + +function getManagedFdBinaryPath(): string { + return join(getBinDir(), platform() === 'win32' ? 'fd.exe' : 'fd'); +} + +export function getFdAssetName(plat = platform(), architecture = arch()): string | null { + if (plat === 'darwin') { + if (architecture === 'arm64') return 'fd-v10.4.2-aarch64-apple-darwin.tar.gz'; + if (architecture === 'x64') return 'fd-v10.3.0-x86_64-apple-darwin.tar.gz'; + return null; + } + if (plat === 'linux') { + if (architecture === 'arm64') return 'fd-v10.4.2-aarch64-unknown-linux-gnu.tar.gz'; + if (architecture === 'x64') return 'fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz'; + return null; + } + if (plat === 'win32') { + if (architecture === 'arm64') return 'fd-v10.4.2-aarch64-pc-windows-msvc.zip'; + if (architecture === 'x64') return 'fd-v10.4.2-x86_64-pc-windows-msvc.zip'; + return null; + } + return null; +} + +async function downloadFd(): Promise<string | null> { + const assetName = getFdAssetName(); + if (assetName === null) return null; + const expectedSha256 = FD_ARCHIVE_SHA256[assetName]; + if (expectedSha256 === undefined) return null; + + const binDir = getBinDir(); + mkdirSync(binDir, { recursive: true }); + + const binaryPath = getManagedFdBinaryPath(); + const extractDir = join( + binDir, + `fd_extract_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + ); + mkdirSync(extractDir, { recursive: true }); + const archivePath = join(extractDir, assetName); + + try { + const downloadUrl = `${FD_BASE_URL}/${assetName}`; + await downloadFile(downloadUrl, archivePath); + verifyArchive(archivePath, expectedSha256); + extractArchive(archivePath, extractDir, assetName); + + const binaryName = platform() === 'win32' ? 'fd.exe' : 'fd'; + const extractedBinary = findBinaryRecursively(extractDir, binaryName); + if (extractedBinary === null) return null; + + rmSync(binaryPath, { force: true }); + renameSync(extractedBinary, binaryPath); + if (platform() !== 'win32') { + chmodSync(binaryPath, 0o755); + } + return binaryPath; + } finally { + rmSync(extractDir, { recursive: true, force: true }); + } +} + +function verifyArchive(path: string, expectedSha256: string): void { + const actualSha256 = createHash('sha256').update(readFileSync(path)).digest('hex'); + if (actualSha256 !== expectedSha256) { + throw new Error(`fd archive checksum mismatch: ${actualSha256} !== ${expectedSha256}`); + } +} + +async function downloadFile(url: string, dest: string): Promise<void> { + const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`Failed to download fd: ${response.status}`); + } + if (response.body === null) { + throw new Error('Failed to download fd: empty response body'); + } + await pipeline(Readable.fromWeb(response.body), createWriteStream(dest)); +} + +function extractArchive(archivePath: string, extractDir: string, assetName: string): void { + if (assetName.endsWith('.tar.gz')) { + runExtractionCommand('tar', ['xzf', archivePath, '-C', extractDir]); + return; + } + if (assetName.endsWith('.zip')) { + if (platform() === 'win32') { + runExtractionCommand(getWindowsTarCommand(), ['xf', archivePath, '-C', extractDir]); + return; + } + runExtractionCommand('unzip', ['-q', archivePath, '-d', extractDir]); + return; + } + throw new Error(`Unsupported fd archive format: ${assetName}`); +} + +function runExtractionCommand(command: string, args: readonly string[]): void { + const result = spawnSync(command, [...args], { stdio: 'pipe' }); + if (!result.error && result.status === 0) return; + const stderr = result.stderr.toString().trim(); + const detail = + result.error?.message ?? (stderr.length > 0 ? stderr : `exit status ${String(result.status)}`); + throw new Error(`Failed to extract fd with ${command}: ${detail}`); +} + +function getWindowsTarCommand(): string { + const systemRoot = process.env['SystemRoot'] ?? process.env['WINDIR']; + if (systemRoot !== undefined) { + const systemTar = join(systemRoot, 'System32', 'tar.exe'); + if (existsSync(systemTar)) return systemTar; + } + return 'tar.exe'; +} + +function findBinaryRecursively(rootDir: string, binaryName: string): string | null { + const stack = [rootDir]; + while (stack.length > 0) { + const currentDir = stack.pop(); + if (currentDir === undefined) continue; + const entries = readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + if (entry.isFile() && entry.name === binaryName) return fullPath; + if (entry.isDirectory()) stack.push(fullPath); } } return null; diff --git a/apps/kimi-code/src/utils/process/proctitle.ts b/apps/kimi-code/src/utils/process/proctitle.ts deleted file mode 100644 index b731233af..000000000 --- a/apps/kimi-code/src/utils/process/proctitle.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Early-startup process name initialization. - * - * Sets the process title so `ps`/`top` and the terminal tab show - * `Kimi Code` from the moment the binary launches — before Commander - * parses argv, before any preflight, even on `--help`/`--version`. - * - * OSC is written to stderr (not stdout) so it still reaches the terminal - * when stdout is piped, e.g. `kimi --print | grep ...`. - */ -import { PRODUCT_NAME } from '#/constant/app'; -import { BEL, ESC } from '#/constant/terminal'; - -export function setProcessTitle(label: string): void { - try { - process.title = label; - } catch { - /* noop */ - } - try { - if (process.stderr.isTTY) { - process.stderr.write(`${ESC}]0;${label}${BEL}`); - } - } catch { - /* noop */ - } -} - -export function initProcessName(name: string = PRODUCT_NAME): void { - setProcessTitle(name); -} diff --git a/apps/kimi-code/src/utils/shell-quote.ts b/apps/kimi-code/src/utils/shell-quote.ts new file mode 100644 index 000000000..eafe1c1e8 --- /dev/null +++ b/apps/kimi-code/src/utils/shell-quote.ts @@ -0,0 +1,11 @@ +export function quoteShellArg(value: string): string { + return process.platform === 'win32' ? quoteCmdArg(value) : quotePosixArg(value); +} + +function quotePosixArg(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function quoteCmdArg(value: string): string { + return `"${value.replaceAll('"', '\\"')}"`; +} diff --git a/apps/kimi-code/src/utils/terminal-restore.ts b/apps/kimi-code/src/utils/terminal-restore.ts new file mode 100644 index 000000000..5a93f3821 --- /dev/null +++ b/apps/kimi-code/src/utils/terminal-restore.ts @@ -0,0 +1,31 @@ +/** + * Best-effort terminal restoration for crash / emergency-exit paths. + * + * The normal shutdown path goes through pi-tui's `TUI.stop()`, which restores + * raw mode, the cursor, bracketed paste, and the Kitty / modifyOtherKeys + * keyboard protocols. When we bail out without running `TUI.stop()` — an + * uncaught exception, a SIGTERM whose cleanup throws, or a SIGHUP — the + * terminal would otherwise be left stuck in raw mode with a hidden cursor, and + * the user's shell would look broken afterwards. Writing these sequences lets + * the terminal recover. + * + * Every step is wrapped: the terminal may already be dead (EIO), and an exit + * path must never throw. + */ + +// Show cursor (`?25h`), disable bracketed paste (`?2004l`), pop the Kitty +// keyboard protocol (`<u`), and reset modifyOtherKeys (`>4;0m`). +const TERMINAL_RESTORE_SEQUENCE = '\u001B[?25h\u001B[?2004l\u001B[<u\u001B[>4;0m'; + +export function restoreTerminalModes(): void { + try { + process.stdin.setRawMode(false); + } catch { + // ignore — raw mode may not be active, or stdin may not be a TTY. + } + try { + process.stdout.write(TERMINAL_RESTORE_SEQUENCE); + } catch { + // ignore — the terminal may already be dead (EIO). + } +} diff --git a/apps/kimi-code/src/utils/usage/debug-timing.ts b/apps/kimi-code/src/utils/usage/debug-timing.ts index 76d400506..87f72696c 100644 --- a/apps/kimi-code/src/utils/usage/debug-timing.ts +++ b/apps/kimi-code/src/utils/usage/debug-timing.ts @@ -1,23 +1,107 @@ -export interface StepTimingInput { - readonly llmFirstTokenLatencyMs?: number | undefined; - readonly llmStreamDurationMs?: number | undefined; - readonly usage?: { readonly output: number } | undefined; +import { formatTokenCount } from './usage-format'; + +interface DebugTokenUsage { + readonly inputOther?: number; + readonly inputCacheRead?: number; + readonly inputCacheCreation?: number; + readonly output?: number; } +export interface StepTimingInput { + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; + /** + * Split of `llmFirstTokenLatencyMs` into the client-side request-build + * portion (`llmRequestBuildMs`) and the network + API-server portion + * (`llmServerFirstTokenMs`). Both present together or not at all. + */ + readonly llmRequestBuildMs?: number; + readonly llmServerFirstTokenMs?: number; + /** + * Split of `llmStreamDurationMs` (the decode window) into server time spent + * awaiting parts (`llmServerDecodeMs`) and client time spent processing parts + * (`llmClientConsumeMs`). Both present together or not at all. + */ + readonly llmServerDecodeMs?: number; + readonly llmClientConsumeMs?: number; + readonly usage?: DebugTokenUsage; +} + +// Decode TPS is only meaningful when the output actually streamed over a +// measurable window. Below this threshold the duration is dominated by +// `Date.now()`'s ~1ms quantization (short / single-chunk tool-call turns can +// drain in 1ms), so dividing output tokens by it would report inflated rates +// like tens of thousands of tok/s. In that case we report the raw counts +// instead of a meaningless ratio. +const MIN_STREAM_MS_FOR_TPS = 50; + export function formatStepDebugTiming(input: StepTimingInput): string | undefined { const latency = input.llmFirstTokenLatencyMs; const streamMs = input.llmStreamDurationMs; if (latency === undefined || streamMs === undefined) return undefined; - const parts: string[] = [`TTFT: ${formatDuration(latency)}`]; + const parts: string[] = [`TTFT: ${formatTtft(input)}`]; const outputTokens = input.usage?.output; - if (outputTokens !== undefined && outputTokens > 0 && streamMs > 0) { - const tps = (outputTokens / (streamMs / 1000)).toFixed(1); - parts.push(`TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)})`); + if (outputTokens !== undefined && outputTokens > 0) { + if (streamMs >= MIN_STREAM_MS_FOR_TPS) { + const tps = (outputTokens / (streamMs / 1000)).toFixed(1); + parts.push( + `TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)}${formatDecodeSplit(input)})`, + ); + } else { + parts.push( + `${outputTokens} tokens in ${formatDuration(streamMs)} (stream too short for TPS)`, + ); + } } + + const inputTokens = usageInputTotal(input.usage); + const hasInputUsage = + input.usage !== undefined && + (input.usage.inputOther !== undefined || + input.usage.inputCacheRead !== undefined || + input.usage.inputCacheCreation !== undefined); + if (hasInputUsage && (inputTokens > 0 || (outputTokens ?? 0) > 0)) { + const cacheReadTokens = input.usage.inputCacheRead ?? 0; + const cacheCreationTokens = input.usage.inputCacheCreation ?? 0; + const cacheHitRate = inputTokens > 0 ? Math.round((cacheReadTokens / inputTokens) * 100) : 0; + const cacheParts = [`cache read ${formatTokenCount(cacheReadTokens)} (${cacheHitRate}%)`]; + if (cacheCreationTokens > 0) { + cacheParts.push(`write ${formatTokenCount(cacheCreationTokens)}`); + } + parts.push(`tokens in ${formatTokenCount(inputTokens)}`); + parts.push(cacheParts.join(' / ')); + } + return `[Debug] ${parts.join(' | ')}`; } +function usageInputTotal(usage: DebugTokenUsage | undefined): number { + if (usage === undefined) return 0; + return (usage.inputOther ?? 0) + (usage.inputCacheRead ?? 0) + (usage.inputCacheCreation ?? 0); +} + +// Render TTFT, splitting the latency into the network + API-server portion and +// the in-process request-build portion when the provider reported the +// boundary. Falls back to the bare total otherwise. +function formatTtft(input: StepTimingInput): string { + const total = formatDuration(input.llmFirstTokenLatencyMs ?? 0); + const build = input.llmRequestBuildMs; + const server = input.llmServerFirstTokenMs; + if (build === undefined || server === undefined) return total; + return `${total} (api ${formatDuration(server)} + client ${formatDuration(build)})`; +} + +// Render the decode-window split as a trailing clause, e.g. +// `; server 4.6s + client 0.4s`. A large client share means the host's per-part +// processing is throttling decode. Empty when the provider did not report it. +function formatDecodeSplit(input: StepTimingInput): string { + const server = input.llmServerDecodeMs; + const client = input.llmClientConsumeMs; + if (server === undefined || client === undefined) return ''; + return `; server ${formatDuration(server)} + client ${formatDuration(client)}`; +} + function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms / 1000).toFixed(1)}s`; diff --git a/apps/kimi-code/test/cli/acp.test.ts b/apps/kimi-code/test/cli/acp.test.ts new file mode 100644 index 000000000..85366252e --- /dev/null +++ b/apps/kimi-code/test/cli/acp.test.ts @@ -0,0 +1,166 @@ +/** + * `kimi acp` + * + * Verifies that the ACP sub-command is registered on the program and + * that the action wires the harness into `@moonshot-ai/acp-adapter`'s + * `runAcpServer` (the real server is stubbed so the test doesn't + * actually take over stdio). + */ + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@moonshot-ai/acp-adapter', () => ({ + ACP_BUILTIN_SLASH_COMMANDS: [], + runAcpServer: vi.fn(async () => undefined), +})); + +import { runAcpServer } from '@moonshot-ai/acp-adapter'; + +import { registerAcpCommand } from '#/cli/sub/acp'; + +class ExitCalled extends Error { + constructor(public code: number | string | null | undefined) { + super(`process.exit(${String(code)})`); + } +} + +describe('kimi acp', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let stderrSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + vi.mocked(runAcpServer).mockClear(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { + throw new ExitCalled(code); + }) as never); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it('registers an `acp` subcommand on the program', () => { + const program = new Command('kimi'); + registerAcpCommand(program); + + const acp = program.commands.find((c) => c.name() === 'acp'); + expect(acp).toBeDefined(); + expect(acp?.description()).toMatch(/Agent Client Protocol/); + }); + + it('invokes runAcpServer with a constructed harness and exits 0 on success', async () => { + const program = new Command('kimi').exitOverride(); + registerAcpCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); + + expect(runAcpServer).toHaveBeenCalledTimes(1); + const harnessArg = vi.mocked(runAcpServer).mock.calls[0]?.[0]; + expect(harnessArg).toBeDefined(); + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1]; + expect(optsArg).toEqual( + expect.objectContaining({ + agentInfo: { name: 'Kimi Code CLI', version: expect.any(String) }, + }), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('forwards KIMI_CODE_HOME to terminalAuthEnv when set', async () => { + const previous = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = '/tmp/kimi-debug'; + try { + const program = new Command('kimi').exitOverride(); + registerAcpCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1]; + expect(optsArg).toEqual( + expect.objectContaining({ + terminalAuthEnv: { KIMI_CODE_HOME: '/tmp/kimi-debug' }, + }), + ); + } finally { + if (previous === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = previous; + } + } + }); + + it('omits terminalAuthEnv when KIMI_CODE_HOME is unset', async () => { + const previous = process.env['KIMI_CODE_HOME']; + delete process.env['KIMI_CODE_HOME']; + try { + const program = new Command('kimi').exitOverride(); + registerAcpCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1] as { + terminalAuthEnv?: unknown; + }; + expect(optsArg.terminalAuthEnv).toBeUndefined(); + } finally { + if (previous !== undefined) { + process.env['KIMI_CODE_HOME'] = previous; + } + } + }); + + it('forwards process.argv[1] as terminalAuthLegacyCommand', async () => { + const program = new Command('kimi').exitOverride(); + registerAcpCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1] as { + terminalAuthLegacyCommand?: string; + }; + // process.argv[1] points at the test runner entry — non-empty + // absolute-ish path, exactly what we want forwarded. + expect(typeof optsArg.terminalAuthLegacyCommand).toBe('string'); + expect((optsArg.terminalAuthLegacyCommand ?? '').length).toBeGreaterThan(0); + expect(optsArg.terminalAuthLegacyCommand).toBe(process.argv[1]); + }); + + it('exits without starting the ACP server when --login is passed', async () => { + // Stub the harness module so runLoginFlow doesn't hit a real OAuth + // endpoint: harness.auth.login resolves immediately and triggers exit 0. + // `importOriginal` preserves the other named exports (`ErrorCodes`, etc.) + // that constant/app.ts depends on at module load. + const loginStub = vi.fn(async () => ({ providerName: 'kimi-code' })); + vi.doMock(import('@moonshot-ai/kimi-code-sdk'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createKimiHarness: () => + ({ + auth: { login: loginStub }, + }) as unknown as ReturnType<typeof actual.createKimiHarness>, + }; + }); + vi.resetModules(); + const { registerAcpCommand: freshRegister } = await import('#/cli/sub/acp'); + try { + const program = new Command('kimi').exitOverride(); + freshRegister(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp', '--login'])).rejects.toThrow( + ExitCalled, + ); + + expect(loginStub).toHaveBeenCalledTimes(1); + expect(runAcpServer).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + vi.doUnmock('@moonshot-ai/kimi-code-sdk'); + vi.resetModules(); + } + }); +}); diff --git a/apps/kimi-code/test/cli/doctor.test.ts b/apps/kimi-code/test/cli/doctor.test.ts new file mode 100644 index 000000000..afbda67c0 --- /dev/null +++ b/apps/kimi-code/test/cli/doctor.test.ts @@ -0,0 +1,270 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + handleDoctor, + registerDoctorCommand, + type DoctorDeps, +} from '#/cli/sub/doctor'; + +let dir: string; + +beforeEach(async () => { + dir = join(tmpdir(), `kimi-doctor-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(dir, { recursive: true }); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function makeDeps(): { + deps: DoctorDeps; + stdout: string[]; + stderr: string[]; + exitCodes: number[]; +} { + const stdout: string[] = []; + const stderr: string[] = []; + const exitCodes: number[] = []; + return { + deps: { + cwd: () => dir, + defaultConfigPath: () => join(dir, 'config.toml'), + defaultTuiConfigPath: () => join(dir, 'tui.toml'), + stdout: { write: (chunk) => stdout.push(chunk) > 0 }, + stderr: { write: (chunk) => stderr.push(chunk) > 0 }, + exit: (code) => { + exitCodes.push(code); + throw new Error(`exit ${String(code)}`); + }, + }, + stdout, + stderr, + exitCodes, + }; +} + +async function writeValidConfig(path = join(dir, 'config.toml')): Promise<void> { + await writeFile( + path, + ` +[providers.kimi] +type = "kimi" +base_url = "https://api.example.com/v1" +api_key = "YOUR_API_KEY" + +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = 262144 +`, + 'utf-8', + ); +} + +async function writeValidTuiConfig(path = join(dir, 'tui.toml')): Promise<void> { + await writeFile( + path, + ` +theme = "dark" + +[editor] +command = "code --wait" + +[notifications] +enabled = true +notification_condition = "unfocused" + +[upgrade] +auto_install = true +`, + 'utf-8', + ); +} + +describe('kimi doctor', () => { + it('skips missing default config files without failing', async () => { + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, {}); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain('SKIP config.toml'); + expect(out).toContain('SKIP tui.toml'); + expect(out).toContain('built-in defaults will apply'); + }); + + it('checks only config.toml when the config target is selected', async () => { + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain('SKIP config.toml'); + expect(out).not.toContain('tui.toml'); + }); + + it('checks only tui.toml when the tui target is selected', async () => { + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'tui' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain('SKIP tui.toml'); + expect(out).not.toContain('config.toml'); + }); + + it('treats a missing explicit target path as an error', async () => { + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config', path: './missing.toml' }); + + expect(code).toBe(1); + expect(stdout.join('')).toBe(''); + const err = stderr.join(''); + expect(err).toContain('Kimi doctor found 1 issue.'); + expect(err).toContain(`ERROR config.toml ${resolve(dir, 'missing.toml')}`); + expect(err).toContain('File does not exist.'); + expect(err).not.toContain('tui.toml'); + }); + + it('checks a valid explicit config path routed through commander', async () => { + const configPath = join(dir, 'candidate-config.toml'); + await writeValidConfig(configPath); + const { deps, stdout, stderr, exitCodes } = makeDeps(); + const program = new Command('kimi'); + registerDoctorCommand(program, deps); + + await program.parseAsync(['node', 'kimi', 'doctor', 'config', './candidate-config.toml']); + + expect(exitCodes).toEqual([]); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain(`OK config.toml ${configPath}`); + expect(out).not.toContain('tui.toml'); + expect(out).toContain('All checked config files are valid.'); + }); + + it('does not resolve the default config path when an explicit config path is provided', async () => { + const configPath = join(dir, 'candidate-config.toml'); + await writeValidConfig(configPath); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor( + { + ...deps, + defaultConfigPath: () => { + throw new Error('default config path should not be resolved'); + }, + }, + { target: 'config', path: './candidate-config.toml' }, + ); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(stdout.join('')).toContain(`OK config.toml ${configPath}`); + }); + + it('checks a valid explicit tui path routed through commander', async () => { + const tuiConfigPath = join(dir, 'candidate-tui.toml'); + await writeValidTuiConfig(tuiConfigPath); + const { deps, stdout, stderr, exitCodes } = makeDeps(); + const program = new Command('kimi'); + registerDoctorCommand(program, deps); + + await program.parseAsync(['node', 'kimi', 'doctor', 'tui', './candidate-tui.toml']); + + expect(exitCodes).toEqual([]); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain(`OK tui.toml ${tuiConfigPath}`); + expect(out).not.toContain('config.toml'); + expect(out).toContain('All checked config files are valid.'); + }); + + it('aggregates config.toml and tui.toml parse errors', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[providers.kimi] +type = "kimi" + +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = 0 +`, + 'utf-8', + ); + await writeFile(join(dir, 'tui.toml'), 'editor = 123\n', 'utf-8'); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, {}); + + expect(code).toBe(1); + expect(stdout.join('')).toBe(''); + const err = stderr.join(''); + expect(err).toContain('Kimi doctor found 2 issues.'); + expect(err).toContain(`ERROR config.toml ${join(dir, 'config.toml')}`); + expect(err).toContain('max_context_size'); + expect(err).toContain(`ERROR tui.toml ${join(dir, 'tui.toml')}`); + expect(err).toContain('editor'); + }); + + it('formats Zod validation issues with field paths for tui.toml', async () => { + await writeFile( + join(dir, 'tui.toml'), + ` +editor = 123 + +[notifications] +enabled = "yes" +`, + 'utf-8', + ); + const { deps, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'tui' }); + + expect(code).toBe(1); + const err = stderr.join(''); + expect(err).toContain('Validation issues:'); + expect(err).toContain('editor:'); + expect(err).toContain('notifications.enabled:'); + }); + + it('formats wrapped Zod validation issues with TOML-style field paths for config.toml', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[providers.kimi] +type = "kimi" + +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = "large" +`, + 'utf-8', + ); + const { deps, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(1); + const err = stderr.join(''); + expect(err).toContain('Validation issues:'); + expect(err).toContain('models.kimi.max_context_size:'); + }); +}); diff --git a/apps/kimi-code/test/cli/export.test.ts b/apps/kimi-code/test/cli/export.test.ts index 934c41ccd..14fdc0190 100644 --- a/apps/kimi-code/test/cli/export.test.ts +++ b/apps/kimi-code/test/cli/export.test.ts @@ -52,24 +52,23 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { return { ...actual, resolveKimiHome: mocks.resolveKimiHome, - KimiHarness: class { - homeDir: string; - auth = { - getCachedAccessToken: mocks.harnessGetCachedAccessToken, - }; - ensureConfigFile = mocks.harnessEnsureConfigFile; - getConfig = mocks.harnessGetConfig; - track = mocks.harnessTrack; - constructor(...args: unknown[]) { - const options = args[0] as { readonly homeDir?: string } | undefined; - this.homeDir = options?.homeDir ?? '/tmp/kimi-export-home'; - if (mocks.harnessCreatesDeviceIdOnConstruction) { - mocks.createKimiDeviceId(this.homeDir); - } - mocks.kimiHarnessConstructor(...args); + createKimiHarness: (...args: unknown[]) => { + const options = args[0] as { readonly homeDir?: string } | undefined; + const homeDir = options?.homeDir ?? '/tmp/kimi-export-home'; + if (mocks.harnessCreatesDeviceIdOnConstruction) { + mocks.createKimiDeviceId(homeDir); } - - exportSession = mocks.harnessExportSession; + mocks.kimiHarnessConstructor(...args); + return { + homeDir, + auth: { + getCachedAccessToken: mocks.harnessGetCachedAccessToken, + }, + ensureConfigFile: mocks.harnessEnsureConfigFile, + getConfig: mocks.harnessGetConfig, + track: mocks.harnessTrack, + exportSession: mocks.harnessExportSession, + }; }, }; }); @@ -383,6 +382,7 @@ describe('kimi export', () => { exit: ((code: number) => { throw new ExitCalled(code); }) as ExportDeps['exit'], + getShellEnv: () => ({ term: 'xterm-256color', shell: '/bin/zsh' }), }); await program.parseAsync(['node', 'kimi', 'export', 'ses_telemetry', '--output', output], { @@ -412,6 +412,7 @@ describe('kimi export', () => { version: expect.any(String), uiMode: 'shell', model: 'k2', + sessionId: undefined, getAccessToken: expect.any(Function), }); expect(mocks.initializeTelemetry.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts new file mode 100644 index 000000000..89770deef --- /dev/null +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -0,0 +1,386 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + GOAL_EXIT_CODES, + formatGoalSummaryText, + goalExitCode, + goalSummaryJson, + parseHeadlessGoalCreate, +} from '#/cli/goal-prompt'; +import { runPrompt } from '#/cli/run-prompt'; + +function snapshot(overrides: Record<string, unknown> = {}) { + return { + goalId: 'g1', + objective: 'work', + status: 'complete', + turnsUsed: 2, + tokensUsed: 120, + wallClockMs: 0, + budget: {} as never, + ...overrides, + }; +} + +describe('goalExitCode', () => { + it('maps final statuses to distinct codes', () => { + expect(goalExitCode('complete')).toBe(GOAL_EXIT_CODES.complete); + expect(goalExitCode('blocked')).toBe(GOAL_EXIT_CODES.blocked); + expect(goalExitCode('paused')).toBe(GOAL_EXIT_CODES.paused); + expect(goalExitCode(undefined)).toBe(0); + // Folded-away statuses map to success (treated as complete/absent). + expect(goalExitCode('impossible')).toBe(0); + // The distinct codes are unique across the statuses. + expect(new Set(Object.values(GOAL_EXIT_CODES)).size).toBe(Object.values(GOAL_EXIT_CODES).length); + }); +}); + +describe('parseHeadlessGoalCreate', () => { + it('parses a create command into objective + replace', () => { + const result = parseHeadlessGoalCreate('/goal Ship feature X'); + expect(result).toEqual({ objective: 'Ship feature X', replace: false }); + }); + + it('returns undefined for non-goal prompts and non-create subcommands', () => { + expect(parseHeadlessGoalCreate('say hello')).toBeUndefined(); + expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined(); + expect(parseHeadlessGoalCreate('/goal pause')).toBeUndefined(); + }); + + it('rejects malformed goal create prompts instead of falling through', () => { + expect(() => parseHeadlessGoalCreate(`/goal ${'x'.repeat(4001)}`)).toThrow( + 'Goal objective is too long', + ); + }); +}); + +describe('goal summary', () => { + it('includes id, status, reason, and usage', () => { + const summary = goalSummaryJson( + snapshot({ + status: 'blocked', + terminalReason: 'need creds', + }) as never, + ); + expect(summary).toMatchObject({ + type: 'goal.summary', + goalId: 'g1', + status: 'blocked', + reason: 'need creds', + turnsUsed: 2, + tokensUsed: 120, + }); + }); + + it('renders a null goal', () => { + expect(goalSummaryJson(null).status).toBeNull(); + expect(formatGoalSummaryText(null)).toContain('no goal'); + }); +}); + +// --- Integration: runPrompt headless goal path ----------------------------- + +const mocks = vi.hoisted(() => { + const eventHandlers = new Set<(event: any) => void>(); + const mainEvent = (event: Record<string, unknown>) => ({ sessionId: 'ses_goal', agentId: 'main', ...event }); + const session = { + id: 'ses_goal', + setModel: vi.fn(), + setPermission: vi.fn(), + setApprovalHandler: vi.fn(), + setQuestionHandler: vi.fn(), + getStatus: vi.fn(async () => ({ permission: 'auto', model: 'k2' })), + createGoal: vi.fn(async () => snapshot({ status: 'active' })), + getGoal: vi.fn(async () => ({ goal: snapshot({ status: 'complete' }) })), + getCronTasks: vi.fn(async () => ({ tasks: [] })), + onEvent: vi.fn((handler: (event: any) => void) => { + eventHandlers.add(handler); + return () => eventHandlers.delete(handler); + }), + prompt: vi.fn(async () => { + for (const handler of eventHandlers) { + handler(mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'done' })); + handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + }), + waitForBackgroundTasksOnPrint: vi.fn(async () => {}), + }; + return { + session, + eventHandlers, + mainEvent, + experimentalFeatures: [{ id: 'micro_compaction', enabled: true }], + sessions: [] as Array<{ readonly id: string; readonly workDir: string }>, + }; +}); + +vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { + const actual = await importOriginal<typeof import('@moonshot-ai/kimi-code-sdk')>(); + return { + ...actual, + createKimiHarness: () => ({ + homeDir: '/tmp/kimi-goal-home', + auth: { getCachedAccessToken: vi.fn() }, + ensureConfigFile: vi.fn(), + getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'k2', telemetry: true })), + getConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), + getExperimentalFeatures: vi.fn(async () => mocks.experimentalFeatures), + createSession: vi.fn(async () => mocks.session), + resumeSession: vi.fn(async () => mocks.session), + listSessions: vi.fn(async () => mocks.sessions), + close: vi.fn(), + track: vi.fn(), + }), + }; +}); + +vi.mock('@moonshot-ai/kimi-telemetry', () => ({ + initializeTelemetry: vi.fn(), + setCrashPhase: vi.fn(), + shutdownTelemetry: vi.fn(), + track: vi.fn(), + setTelemetryContext: vi.fn(), + withTelemetryContext: vi.fn(() => ({ track: vi.fn() })), +})); + +function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) { + return { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: '/goal Ship feature X', + skillsDirs: [], + ...overrides, + } as Parameters<typeof runPrompt>[0]; +} + +function writer() { + let text = ''; + return { write: (chunk: string) => ((text += chunk), true), text: () => text }; +} + +describe('runPrompt headless goal mode', () => { + let savedExitCode: typeof process.exitCode; + + beforeEach(() => { + // Pin the experimental engine flag off so runPrompt stays on the v1 path + // this suite mocks, regardless of the host environment (matches + // run-prompt.test.ts). With the flag on, runPrompt dispatches to the + // native v2 runner, which ignores these mocks and hangs the test. + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', ''); + savedExitCode = process.exitCode; + mocks.experimentalFeatures = [{ id: 'micro_compaction', enabled: true }]; + mocks.sessions = []; + mocks.session.createGoal.mockClear(); + mocks.session.prompt.mockClear(); + mocks.session.waitForBackgroundTasksOnPrint.mockClear(); + mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never); + mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'complete' }) } as never); + mocks.session.getCronTasks.mockResolvedValue({ tasks: [] } as never); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + process.exitCode = savedExitCode; + }); + + it('creates the goal, runs the turn, and emits a JSON summary on completion', async () => { + const stdout = writer(); + const stderr = writer(); + await runPrompt(opts({ outputFormat: 'stream-json' }), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(mocks.session.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature X' }), + ); + expect(stdout.text()).toContain('"type":"goal.summary"'); + expect(stdout.text()).toContain('"status":"complete"'); + }); + + it('sets a distinct exit code for a non-complete final status', async () => { + mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'blocked' }) } as never); + const stdout = writer(); + const stderr = writer(); + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + expect(process.exitCode).toBe(GOAL_EXIT_CODES.blocked); + }); + + it('uses the completion event snapshot when the goal has already been cleared', async () => { + const completed = snapshot({ status: 'complete', turnsUsed: 4, tokensUsed: 240 }); + mocks.session.getGoal.mockResolvedValue({ goal: null } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }), + ); + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toContain('"status":"complete"'); + expect(stdout.text()).toContain('"turnsUsed":4'); + expect(stdout.text()).not.toContain('"goalId":null'); + }); + + it('creates a headless goal without reading experimental features', async () => { + mocks.experimentalFeatures = []; + const stdout = writer(); + const stderr = writer(); + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + expect(mocks.session.createGoal).toHaveBeenCalled(); + expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X'); + }); + + it('keeps listening across continuation turns until the goal is terminal', async () => { + const active = snapshot({ status: 'active', turnsUsed: 1, tokensUsed: 80 }); + const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 }); + mocks.session.getGoal.mockResolvedValueOnce({ goal: active } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: '1' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + await Promise.resolve(); + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: '2' })); + handler( + mocks.mainEvent({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }), + ); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('• 1\n\n• 2\n\n'); + expect(stderr.text()).toContain('Goal [complete]'); + expect(stderr.text()).toContain('turns: 2'); + }); + + it('ignores stale goal checks once a continuation turn has started', async () => { + const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 }); + let resolveFirstGoal: ((value: { goal: null }) => void) | undefined; + const firstGoal = new Promise<{ goal: null }>((resolve) => { + resolveFirstGoal = resolve; + }); + mocks.session.getGoal + .mockImplementationOnce(() => firstGoal as never) + .mockResolvedValue({ goal: null } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + const emit = (event: Record<string, unknown>) => { + for (const handler of [...mocks.eventHandlers]) { + handler(mocks.mainEvent(event)); + } + }; + emit({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }); + emit({ type: 'assistant.delta', turnId: 1, delta: '1' }); + emit({ type: 'turn.ended', turnId: 1, reason: 'completed' }); + emit({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }); + emit({ type: 'assistant.delta', turnId: 2, delta: '2' }); + emit({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }); + resolveFirstGoal?.({ goal: null }); + await Promise.resolve(); + emit({ type: 'assistant.delta', turnId: 2, delta: ' tail' }); + emit({ type: 'turn.ended', turnId: 2, reason: 'completed' }); + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('• 1\n\n• 2 tail\n\n'); + expect(stderr.text()).toContain('Goal [complete]'); + }); + + it('does not send an invalid goal create prompt as a normal prompt', async () => { + const stdout = writer(); + const stderr = writer(); + + await expect( + runPrompt(opts({ prompt: `/goal ${'x'.repeat(4001)}` }), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }), + ).rejects.toThrow('Goal objective is too long'); + + expect(mocks.session.createGoal).not.toHaveBeenCalled(); + expect(mocks.session.prompt).not.toHaveBeenCalled(); + }); + + it('validates the resumed session model before creating a headless goal', async () => { + mocks.sessions = [{ id: 'ses_goal', workDir: process.cwd() }]; + mocks.session.getStatus.mockResolvedValueOnce({ permission: 'auto', model: '' } as never); + const stdout = writer(); + const stderr = writer(); + + await expect( + runPrompt(opts({ session: 'ses_goal' }), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }), + ).rejects.toThrow('No model configured'); + + expect(mocks.session.createGoal).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/cli/headless-exit.test.ts b/apps/kimi-code/test/cli/headless-exit.test.ts new file mode 100644 index 000000000..a977a70d2 --- /dev/null +++ b/apps/kimi-code/test/cli/headless-exit.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Writable } from 'node:stream'; + +import { drainStdio, finalizeHeadlessRun, scheduleHeadlessForceExit } from '#/cli/headless-exit'; + +describe('scheduleHeadlessForceExit', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('force-exits with the lazily-resolved exit code after the grace period', () => { + vi.useFakeTimers(); + const exit = vi.fn(); + let code = 0; + const handle = scheduleHeadlessForceExit({ exit }, () => code, 2000); + // The exit code can be set after scheduling (e.g. a goal turn maps its + // terminal status to process.exitCode); it must be read at fire time. + code = 7; + + expect(exit).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1999); + expect(exit).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(exit).toHaveBeenCalledWith(7); + + clearTimeout(handle); + }); + + it('schedules an unref\'d timer so a healthy run still exits naturally', () => { + // Real timers: an un-unref'd guard would itself keep the event loop alive, + // turning the fix into a regression (every healthy run would wait the full + // grace before exiting). hasRef() must be false. + const exit = vi.fn(); + const handle = scheduleHeadlessForceExit({ exit }, () => 0, 60_000); + expect((handle as { hasRef?: () => boolean }).hasRef?.()).toBe(false); + clearTimeout(handle); + }); + + it('does not fire once cancelled via clearTimeout', () => { + vi.useFakeTimers(); + const exit = vi.fn(); + const handle = scheduleHeadlessForceExit({ exit }, () => 0, 2000); + clearTimeout(handle); + vi.advanceTimersByTime(5000); + expect(exit).not.toHaveBeenCalled(); + }); +}); + +describe('drainStdio', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves once buffered output has flushed', async () => { + let flush: (() => void) | undefined; + const stream = { + write: vi.fn((_chunk: string, cb: () => void) => { + flush = cb; + return false; + }), + } as unknown as Writable; + + let resolved = false; + const done = drainStdio([stream], 5000).then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(false); // still draining + + flush?.(); // consumer caught up + await done; + expect(resolved).toBe(true); + }); + + it('gives up after the timeout when the consumer never drains', async () => { + vi.useFakeTimers(); + // write() never invokes its flush callback — a permanently-stuck consumer. + const stream = { write: vi.fn(() => false) } as unknown as Writable; + + let resolved = false; + const done = drainStdio([stream], 3000).then(() => { + resolved = true; + }); + await vi.advanceTimersByTimeAsync(2999); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(resolved).toBe(true); + }); +}); + +describe('finalizeHeadlessRun', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('flushes stdio before arming the force-exit so buffered output is not truncated', async () => { + vi.useFakeTimers(); + let flush: (() => void) | undefined; + const stream = { + write: vi.fn((_chunk: string, cb: () => void) => { + flush = cb; + return false; + }), + } as unknown as Writable; + const exit = vi.fn(); + + const done = finalizeHeadlessRun({ exit }, [stream], () => 0, { + drainTimeoutMs: 5000, + graceMs: 2000, + }); + + // Output is still draining: even well past the force-exit grace, we must NOT + // have armed/fired the exit — doing so would truncate the buffered output. + await vi.advanceTimersByTimeAsync(4000); + expect(exit).not.toHaveBeenCalled(); + + // Consumer catches up → drain completes → only now is the backstop armed. + flush?.(); + await done; + expect(exit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2000); + expect(exit).toHaveBeenCalledWith(0); + }); +}); diff --git a/apps/kimi-code/test/cli/login.test.ts b/apps/kimi-code/test/cli/login.test.ts new file mode 100644 index 000000000..6644c7f21 --- /dev/null +++ b/apps/kimi-code/test/cli/login.test.ts @@ -0,0 +1,177 @@ +/** + * `kimi login` + * + * Verifies that the login sub-command is registered on the program and + * that the action drives `harness.auth.login`, prints the device code to + * stderr, and exits with the right code on success / failure. + */ + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockLogin = vi.fn(); + +vi.mock('@moonshot-ai/kimi-code-sdk', async () => { + const actual = await vi.importActual<typeof import('@moonshot-ai/kimi-code-sdk')>( + '@moonshot-ai/kimi-code-sdk', + ); + return { + ...actual, + createKimiHarness: vi.fn(() => ({ + auth: { + login: mockLogin, + }, + })), + }; +}); + +vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); + +import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { registerLoginCommand } from '#/cli/sub/login'; +import { openUrl } from '#/utils/open-url'; + +class ExitCalled extends Error { + constructor(public code: number | string | null | undefined) { + super(`process.exit(${String(code)})`); + } +} + +describe('kimi login', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let stderrSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + mockLogin.mockReset(); + vi.mocked(openUrl).mockReset(); + vi.mocked(createKimiHarness).mockClear(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { + throw new ExitCalled(code); + }) as never); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it('registers a `login` subcommand on the program', () => { + const program = new Command('kimi'); + registerLoginCommand(program); + + const login = program.commands.find((c) => c.name() === 'login'); + expect(login).toBeDefined(); + expect(login?.description()).toMatch(/[Aa]uthenticat/); + }); + + it('invokes harness.auth.login and exits 0 on success', async () => { + mockLogin.mockResolvedValue({ providerName: 'kimi-code', ok: true }); + + const program = new Command('kimi').exitOverride(); + registerLoginCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'login'])).rejects.toThrow(ExitCalled); + + expect(mockLogin).toHaveBeenCalledTimes(1); + expect(mockLogin).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + signal: expect.any(AbortSignal), + onDeviceCode: expect.any(Function), + }), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('prints device code prompt to stderr', async () => { + mockLogin.mockImplementation( + async ( + _providerName: string | undefined, + options: { + onDeviceCode?: (data: { + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresIn: number | null; + }) => void | Promise<void>; + }, + ) => { + await options.onDeviceCode?.({ + userCode: 'ABCD-EFGH', + verificationUri: 'https://example.com/v', + verificationUriComplete: 'https://example.com/v?code=ABCD-EFGH', + expiresIn: 600, + }); + return { providerName: 'kimi-code', ok: true }; + }, + ); + + const program = new Command('kimi').exitOverride(); + registerLoginCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'login'])).rejects.toThrow(ExitCalled); + + const writtenChunks = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(writtenChunks.some((chunk: string) => chunk.includes('ABCD-EFGH'))).toBe(true); + expect(writtenChunks.some((chunk: string) => chunk.includes('https://example.com/v'))).toBe( + true, + ); + expect(openUrl).toHaveBeenCalledWith('https://example.com/v?code=ABCD-EFGH'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('still prints device code prompt when opening the browser fails', async () => { + vi.mocked(openUrl).mockImplementation(() => { + throw new Error('no browser'); + }); + mockLogin.mockImplementation( + async ( + _providerName: string | undefined, + options: { + onDeviceCode?: (data: { + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresIn: number | null; + }) => void | Promise<void>; + }, + ) => { + await options.onDeviceCode?.({ + userCode: 'ABCD-EFGH', + verificationUri: 'https://example.com/v', + verificationUriComplete: 'https://example.com/v?code=ABCD-EFGH', + expiresIn: 600, + }); + return { providerName: 'kimi-code', ok: true }; + }, + ); + + const program = new Command('kimi').exitOverride(); + registerLoginCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'login'])).rejects.toThrow(ExitCalled); + + const writtenChunks = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(writtenChunks.some((chunk: string) => chunk.includes('ABCD-EFGH'))).toBe(true); + expect(writtenChunks.some((chunk: string) => chunk.includes('https://example.com/v'))).toBe( + true, + ); + expect(openUrl).toHaveBeenCalledWith('https://example.com/v?code=ABCD-EFGH'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits 1 when auth.login throws', async () => { + mockLogin.mockRejectedValue(new Error('boom')); + + const program = new Command('kimi').exitOverride(); + registerLoginCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'login'])).rejects.toThrow(ExitCalled); + + const writtenChunks = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(writtenChunks.some((chunk: string) => chunk.includes('boom'))).toBe(true); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index ab02ed025..31411e8b1 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -8,7 +8,7 @@ import { runPrompt } from '#/cli/run-prompt'; import { runShell } from '#/cli/run-shell'; import { formatStartupError } from '#/cli/startup-error'; import { runUpdatePreflight } from '#/cli/update/preflight'; -import { handleMainCommand, main } from '#/main'; +import { handleMainCommand, handleUpgradeCommand, main } from '#/main'; const mocks = vi.hoisted(() => { const parse = vi.fn(); @@ -21,21 +21,94 @@ const mocks = vi.hoisted(() => { runShell: vi.fn(), runPrompt: vi.fn(), installCrashHandlers: vi.fn(), + track: vi.fn(), + setTelemetryContext: vi.fn(), + withTelemetryContext: vi.fn(), + shutdownTelemetry: vi.fn(), + createCliTelemetryBootstrap: vi.fn(() => ({ + homeDir: '/tmp/kimi-home', + deviceId: 'device-id', + firstLaunch: false, + })), + initializeCliTelemetry: vi.fn(), + handleUpgrade: vi.fn(), + flushDiagnosticLogs: vi.fn(), + finalizeHeadlessRun: vi.fn(), + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + harness: { + homeDir: '/tmp/kimi-home', + ensureConfigFile: vi.fn(), + getConfig: vi.fn(), + close: vi.fn(), + track: vi.fn(), + }, + KimiHarness: vi.fn(), + createKimiHarness: vi.fn(), }; }); vi.mock('@moonshot-ai/kimi-telemetry', () => ({ installCrashHandlers: mocks.installCrashHandlers, - track: vi.fn(), + track: mocks.track, + setTelemetryContext: mocks.setTelemetryContext, + withTelemetryContext: mocks.withTelemetryContext, + shutdownTelemetry: mocks.shutdownTelemetry, +})); + +vi.mock('@moonshot-ai/kimi-code-sdk', async () => { + const actual = await vi.importActual<typeof import('@moonshot-ai/kimi-code-sdk')>( + '@moonshot-ai/kimi-code-sdk', + ); + class MockKimiHarness { + readonly homeDir = mocks.harness.homeDir; + readonly ensureConfigFile = mocks.harness.ensureConfigFile; + readonly getConfig = mocks.harness.getConfig; + readonly close = mocks.harness.close; + readonly track = mocks.harness.track; + + constructor(...args: unknown[]) { + mocks.KimiHarness(...args); + } + } + return { + ...actual, + createKimiHarness: (...args: unknown[]) => { + mocks.createKimiHarness(...args); + return mocks.harness; + }, + flushDiagnosticLogs: mocks.flushDiagnosticLogs, + KimiHarness: MockKimiHarness, + log: mocks.log, + }; +}); + +vi.mock('../../src/cli/telemetry', () => ({ + createCliTelemetryBootstrap: mocks.createCliTelemetryBootstrap, + initializeCliTelemetry: mocks.initializeCliTelemetry, +})); + +vi.mock('../../src/cli/sub/upgrade', () => ({ + handleUpgrade: mocks.handleUpgrade, })); vi.mock('../../src/cli/commands', () => ({ createProgram: mocks.createProgram, })); -vi.mock('../../src/cli/version', () => ({ - getVersion: mocks.getVersion, -})); +vi.mock('../../src/cli/version', async () => { + const actual = await vi.importActual<typeof import('../../src/cli/version.js')>( + '../../src/cli/version.js', + ); + return { + ...actual, + getVersion: mocks.getVersion, + }; +}); vi.mock('../../src/cli/options', async () => { const actual = await vi.importActual<typeof OptionsModule>('../../src/cli/options.js'); @@ -57,6 +130,10 @@ vi.mock('../../src/cli/run-prompt', () => ({ runPrompt: mocks.runPrompt, })); +vi.mock('../../src/cli/headless-exit', () => ({ + finalizeHeadlessRun: mocks.finalizeHeadlessRun, +})); + class ExitCalled extends Error { constructor(readonly code: number) { super(`exit(${code})`); @@ -77,6 +154,20 @@ function defaultOpts(): CLIOptions { }; } +async function waitForAssertion(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + async function runHandleMainCommand(opts: CLIOptions): Promise<number | null> { const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { throw new ExitCalled(Number(code ?? 0)); @@ -94,6 +185,23 @@ async function runHandleMainCommand(opts: CLIOptions): Promise<number | null> { } } +async function runHandleUpgradeCommand(): Promise<number> { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new ExitCalled(Number(code ?? 0)); + }); + try { + await handleUpgradeCommand('0.0.1-alpha.2'); + throw new Error('expected process.exit'); + } catch (error) { + if (error instanceof ExitCalled) { + return error.code; + } + throw error; + } finally { + exitSpy.mockRestore(); + } +} + describe('main entry command handling', () => { afterEach(() => { vi.clearAllMocks(); @@ -101,6 +209,15 @@ describe('main entry command handling', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.harness.ensureConfigFile.mockResolvedValue(undefined); + mocks.harness.getConfig.mockResolvedValue({ + defaultModel: 'kimi-k2', + telemetry: true, + }); + mocks.harness.close.mockResolvedValue(undefined); + mocks.shutdownTelemetry.mockResolvedValue(undefined); + mocks.handleUpgrade.mockResolvedValue(0); + mocks.flushDiagnosticLogs.mockResolvedValue(undefined); }); it('runs update preflight before starting the shell', async () => { @@ -140,6 +257,81 @@ describe('main entry command handling', () => { expect(runShell).not.toHaveBeenCalled(); }); + it('does not force-exit from the reusable handler in print mode', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockResolvedValue(void 0); + + const outcome = await handleMainCommand(opts, '0.0.1-alpha.2'); + + // Process disposition belongs to the entrypoint, never to this reusable, + // unit-tested handler: arming a process.exit here would kill the test runner + // or any embedding host. The handler only reports what ran. + expect(mocks.finalizeHeadlessRun).not.toHaveBeenCalled(); + expect(outcome).toEqual({ headlessCompleted: true }); + }); + + it('reports no headless completion for interactive (shell) mode', async () => { + const opts = defaultOpts(); + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runShell.mockResolvedValue(void 0); + + const outcome = await handleMainCommand(opts, '0.0.1-alpha.2'); + + expect(outcome).toEqual({ headlessCompleted: false }); + expect(mocks.finalizeHeadlessRun).not.toHaveBeenCalled(); + }); + + it('arms the force-exit fallback at the entrypoint after a completed headless run', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockResolvedValue(void 0); + mocks.finalizeHeadlessRun.mockResolvedValue(void 0); + + main(); + const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const mainAction = programArgs[1] as (opts: CLIOptions) => void; + mainAction(opts); + + await waitForAssertion(() => { + expect(mocks.finalizeHeadlessRun).toHaveBeenCalledTimes(1); + }); + // The exit code is resolved lazily so a goal turn that sets process.exitCode wins. + const forceExitArgs = mocks.finalizeHeadlessRun.mock.calls[0] as unknown as unknown[]; + expect(typeof forceExitArgs[2]).toBe('function'); + }); + + it('sets the failure exit code before awaiting startup failure logging', async () => { + const originalExitCode = process.exitCode; + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockRejectedValue(new Error('provider failed')); + mocks.flushDiagnosticLogs.mockImplementation(() => new Promise(() => {})); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new ExitCalled(Number(code ?? 0)); + }); + + try { + main(); + const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const mainAction = programArgs[1] as (opts: CLIOptions) => void; + mainAction(opts); + + await waitForAssertion(() => { + expect(mocks.flushDiagnosticLogs).toHaveBeenCalledTimes(1); + }); + expect(process.exitCode).toBe(1); + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + process.exitCode = originalExitCode; + } + }); + it('keeps shell mode update preflight interactive by default', async () => { const opts = defaultOpts(); mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); @@ -165,6 +357,18 @@ describe('main entry command handling', () => { expect(mocks.parse).toHaveBeenCalledWith(process.argv); }); + it('sets the process title during startup', () => { + const originalTitle = process.title; + try { + process.title = 'kimi-test-runner'; + main(); + + expect(process.title).toBe('kimi-code'); + } finally { + process.title = originalTitle; + } + }); + it('exits early when update preflight requests process exit', async () => { const opts = defaultOpts(); mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); @@ -177,6 +381,44 @@ describe('main entry command handling', () => { expect(runShell).not.toHaveBeenCalled(); }); + it('initializes and flushes telemetry around the upgrade command', async () => { + const exitCode = await runHandleUpgradeCommand(); + + expect(exitCode).toBe(0); + expect(mocks.createCliTelemetryBootstrap).toHaveBeenCalledTimes(1); + expect(mocks.createKimiHarness).toHaveBeenCalledWith(expect.objectContaining({ + homeDir: '/tmp/kimi-home', + telemetry: { + track: mocks.track, + withContext: mocks.withTelemetryContext, + setContext: mocks.setTelemetryContext, + }, + })); + expect(mocks.harness.ensureConfigFile).toHaveBeenCalledTimes(1); + expect(mocks.initializeCliTelemetry).toHaveBeenCalledWith(expect.objectContaining({ + harness: expect.objectContaining({ + homeDir: '/tmp/kimi-home', + }), + bootstrap: { + homeDir: '/tmp/kimi-home', + deviceId: 'device-id', + firstLaunch: false, + }, + config: { + defaultModel: 'kimi-k2', + telemetry: true, + }, + version: '0.0.1-alpha.2', + uiMode: 'shell', + })); + expect(mocks.handleUpgrade).toHaveBeenCalledWith('0.0.1-alpha.2', { + track: mocks.track, + logger: mocks.log, + }); + expect(mocks.shutdownTelemetry).toHaveBeenCalledWith({ timeoutMs: 3000 }); + expect(mocks.harness.close).toHaveBeenCalledTimes(1); + }); + it('formats Kimi startup errors with structured fields', () => { const error = new KimiError( ErrorCodes.SHELL_GIT_BASH_NOT_FOUND, diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 0a8fd7748..b15ed315f 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createProgram } from '#/cli/commands'; import type { CLIOptions } from '#/cli/options'; -import { OptionConflictError, validateOptions } from '#/cli/options'; +import { OptionConflictError, OUTPUT_FORMAT_ENV, resolveOutputFormat, validateOptions } from '#/cli/options'; function parse(argv: string[]): CLIOptions { let captured: CLIOptions | undefined; @@ -41,13 +41,18 @@ describe('CLI options parsing', () => { expect(opts.outputFormat).toBeUndefined(); expect(opts.prompt).toBeUndefined(); expect(opts.skillsDirs).toEqual([]); + expect(opts.addDirs).toEqual([]); }); }); describe('--version', () => { it('prints the version string and exits', () => { let output = ''; - const program = createProgram('1.2.3', () => {}, () => {}); + const program = createProgram( + '1.2.3', + () => {}, + () => {}, + ); program.exitOverride(); program.configureOutput({ writeOut: (s) => { @@ -61,7 +66,11 @@ describe('CLI options parsing', () => { it('supports -V as a short alias', () => { let output = ''; - const program = createProgram('4.5.6', () => {}, () => {}); + const program = createProgram( + '4.5.6', + () => {}, + () => {}, + ); program.exitOverride(); program.configureOutput({ writeOut: (s) => { @@ -103,9 +112,7 @@ describe('CLI options parsing', () => { '--flag', ]); - expect(pluginRunnerCalls).toEqual([ - { entry: '/plugin/tool.mjs', args: ['query', '--flag'] }, - ]); + expect(pluginRunnerCalls).toEqual([{ entry: '/plugin/tool.mjs', args: ['query', '--flag'] }]); }); }); @@ -148,6 +155,10 @@ describe('CLI options parsing', () => { expect(parse(['-C']).continue).toBe(true); }); + it('-c is an alias for --continue', () => { + expect(parse(['-c']).continue).toBe(true); + }); + it('--continue and --session combined raises a conflict', () => { const opts = parse(['--continue', '--session', 'abc123']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); @@ -161,6 +172,50 @@ describe('CLI options parsing', () => { }); }); + describe('--auto / --yolo / --plan with --session / --continue', () => { + it('allows --auto with --continue', () => { + const opts = parse(['--auto', '--continue']); + expect(opts.auto).toBe(true); + expect(opts.continue).toBe(true); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --auto with an explicit session id', () => { + const opts = parse(['--auto', '--session', 'ses_123']); + expect(opts.auto).toBe(true); + expect(opts.session).toBe('ses_123'); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --yolo with --continue', () => { + const opts = parse(['--yolo', '--continue']); + expect(opts.yolo).toBe(true); + expect(opts.continue).toBe(true); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --yolo with an explicit session id', () => { + const opts = parse(['--yolo', '--session', 'ses_123']); + expect(opts.yolo).toBe(true); + expect(opts.session).toBe('ses_123'); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --plan with --continue', () => { + const opts = parse(['--plan', '--continue']); + expect(opts.plan).toBe(true); + expect(opts.continue).toBe(true); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --plan with an explicit session id', () => { + const opts = parse(['--plan', '--session', 'ses_123']); + expect(opts.plan).toBe(true); + expect(opts.session).toBe('ses_123'); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + }); + describe('--model / -m', () => { it('parses -m as a model override', () => { expect(parse(['-m', 'kimi-code/k2']).model).toBe('kimi-code/k2'); @@ -211,7 +266,9 @@ describe('CLI options parsing', () => { it('rejects prompt mode with bare --session picker', () => { const opts = parse(['-p', 'resume here', '--session']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); - expect(() => validateOptions(opts)).toThrow('Cannot use --session without an id in prompt mode.'); + expect(() => validateOptions(opts)).toThrow( + 'Cannot use --session without an id in prompt mode.', + ); }); it('rejects prompt mode with --yolo because prompt mode always uses auto permission', () => { @@ -246,6 +303,84 @@ describe('CLI options parsing', () => { }); }); + describe('KIMI_MODEL_OUTPUT_FORMAT', () => { + it('defaults to text when unset in prompt mode', () => { + expect(resolveOutputFormat({ prompt: 'run this', outputFormat: undefined }, {})).toBe('text'); + }); + + it('uses stream-json from the env in prompt mode', () => { + expect( + resolveOutputFormat( + { prompt: 'run this', outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: 'stream-json' }, + ), + ).toBe('stream-json'); + }); + + it('uses text from the env in prompt mode', () => { + expect( + resolveOutputFormat( + { prompt: 'run this', outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: 'text' }, + ), + ).toBe('text'); + }); + + it('trims surrounding whitespace from the env value', () => { + expect( + resolveOutputFormat( + { prompt: 'run this', outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: ' stream-json ' }, + ), + ).toBe('stream-json'); + }); + + it('lets the --output-format flag override the env', () => { + expect( + resolveOutputFormat( + { prompt: 'run this', outputFormat: 'text' }, + { [OUTPUT_FORMAT_ENV]: 'stream-json' }, + ), + ).toBe('text'); + }); + + it('ignores the env outside prompt mode', () => { + expect( + resolveOutputFormat( + { prompt: undefined, outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: 'stream-json' }, + ), + ).toBe('text'); + }); + + it('rejects an invalid env value', () => { + expect(() => + resolveOutputFormat( + { prompt: 'run this', outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: 'json' }, + ), + ).toThrow(OptionConflictError); + expect(() => + resolveOutputFormat( + { prompt: 'run this', outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: 'json' }, + ), + ).toThrow('Invalid KIMI_MODEL_OUTPUT_FORMAT value "json"'); + }); + + it('fails validation fast for an invalid env value in prompt mode', () => { + const opts = parse(['-p', 'run this']); + expect(() => validateOptions(opts, { [OUTPUT_FORMAT_ENV]: 'json' })).toThrow( + OptionConflictError, + ); + }); + + it('does not validate the env outside prompt mode', () => { + const opts = parse([]); + expect(() => validateOptions(opts, { [OUTPUT_FORMAT_ENV]: 'json' })).not.toThrow(); + }); + }); + describe('--skills-dir', () => { it('collects repeated skill directories', () => { expect(parse(['--skills-dir', '/one', '--skills-dir=/two']).skillsDirs).toEqual([ @@ -255,13 +390,86 @@ describe('CLI options parsing', () => { }); }); + describe('--add-dir', () => { + it('parses one additional workspace directory', () => { + expect(parse(['--add-dir', '/shared']).addDirs).toEqual(['/shared']); + }); + + it('parses repeated additional workspace directories', () => { + expect(parse(['--add-dir', '/one', '--add-dir=/two']).addDirs).toEqual(['/one', '/two']); + }); + }); + describe('sub-commands', () => { - it('registers the diagnostic sub-commands during alpha', () => { - const program = createProgram('0.0.0', () => {}, () => {}); + it('routes upgrade without calling the main action', () => { + let upgradeCalls = 0; + const program = createProgram( + '0.0.0', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => { + upgradeCalls += 1; + }, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', 'upgrade']); + + expect(upgradeCalls).toBe(1); + }); + + it('routes update alias to the upgrade handler', () => { + let upgradeCalls = 0; + const program = createProgram( + '0.0.0', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => { + upgradeCalls += 1; + }, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', 'update']); + + expect(upgradeCalls).toBe(1); + }); + + it('registers the visible sub-commands', () => { + const program = createProgram( + '0.0.0', + () => {}, + () => {}, + ); const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) .map((command) => command.name()); - expect(commandNames).toEqual(['export', 'migrate']); + expect(commandNames).toEqual([ + 'export', + 'provider', + 'acp', + 'server', + 'web', + 'login', + 'doctor', + 'vis', + 'migrate', + 'upgrade', + ]); }); }); @@ -276,7 +484,6 @@ describe('CLI options parsing', () => { '--print', '--wire', '--agent=default', - '--add-dir=/', '--raw-model', '--config-file=x', '--quiet', diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts new file mode 100644 index 000000000..2e4aeece4 --- /dev/null +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -0,0 +1,915 @@ +/** + * `kimi provider` CLI unit tests. The handlers receive an injected `getHarness` + * + capturing stdout/stderr, so we test the wiring end-to-end without booting + * a real harness or hitting the network. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; +import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; + +import { + handleCatalogAdd, + handleCatalogList, + handleProviderAdd, + handleProviderList, + handleProviderRemove, + registerProviderCommand, + type ProviderDeps, +} from '#/cli/sub/provider'; + +class ExitCalled extends Error { + constructor(public readonly code: number) { + super(`exit(${code})`); + } +} + +interface FakeHarness { + ensureConfigFile: () => Promise<void>; + getConfig: () => Promise<KimiConfig>; + setConfig: (patch: Partial<KimiConfig>) => Promise<KimiConfig>; + removeProvider: (providerId: string) => Promise<KimiConfig>; +} + +function makeHarness(initial: KimiConfig): { + harness: FakeHarness; + current: () => KimiConfig; + setConfigCalls: Array<Partial<KimiConfig>>; + removeCalls: string[]; +} { + // `persisted` simulates the on-disk config; the real RPC's `removeProvider` + // reads from / writes to disk on every call (see + // `packages/agent-core/src/rpc/core-impl.ts removeKimiProvider`). Tests must + // model this: anything the handler builds up in its in-memory `config` + // object disappears unless it is flushed via `setConfig` BEFORE the next + // `removeProvider`. + let persisted: KimiConfig = structuredClone(initial); + const setConfigCalls: Array<Partial<KimiConfig>> = []; + const removeCalls: string[] = []; + const harness: FakeHarness = { + ensureConfigFile: async () => {}, + getConfig: async () => structuredClone(persisted), + setConfig: async (patch) => { + setConfigCalls.push(structuredClone(patch)); + // Mirror the real `setKimiConfig`: deep-merge with undefined keys + // skipped (see `agent-core/src/config/merge.ts deepMerge`). This is + // load-bearing for tests that assert `setConfig({defaultModel: + // undefined})` does NOT wipe a key from disk — only `removeProvider` + // can. + const next: Record<string, unknown> = { ...persisted }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + next[key] = value; + } + persisted = next as KimiConfig; + return structuredClone(persisted); + }, + removeProvider: async (providerId) => { + removeCalls.push(providerId); + const nextProviders = { ...persisted.providers }; + delete nextProviders[providerId]; + const nextModels = { ...persisted.models }; + let removedDefault = false; + for (const [alias, model] of Object.entries(nextModels)) { + if (model.provider === providerId) { + delete nextModels[alias]; + if (persisted.defaultModel === alias) removedDefault = true; + } + } + persisted = { ...persisted, providers: nextProviders, models: nextModels }; + if (removedDefault) persisted = { ...persisted, defaultModel: undefined }; + return structuredClone(persisted); + }, + }; + return { + harness, + current: () => persisted, + setConfigCalls, + removeCalls, + }; +} + +function makeDeps( + harness: FakeHarness, + overrides: Partial<ProviderDeps> = {}, +): { + deps: ProviderDeps; + stdout: string[]; + stderr: string[]; + exitCodes: number[]; +} { + const stdout: string[] = []; + const stderr: string[] = []; + const exitCodes: number[] = []; + const deps: ProviderDeps = { + getHarness: () => harness as unknown as ProviderDeps extends { getHarness: () => infer R } + ? R + : never, + stdout: { + write: (chunk: string) => { + stdout.push(chunk); + return true; + }, + }, + stderr: { + write: (chunk: string) => { + stderr.push(chunk); + return true; + }, + }, + env: {}, + exit: ((code: number) => { + exitCodes.push(code); + throw new ExitCalled(code); + }) as ProviderDeps['exit'], + ...overrides, + }; + return { deps, stdout, stderr, exitCodes }; +} + +async function tryRun<T>(fn: () => Promise<T>): Promise<T | undefined> { + try { + return await fn(); + } catch (error) { + if (error instanceof ExitCalled) return undefined; + throw error; + } +} + +const REGISTRY_URL = 'https://registry.example.test/v1/models/api.json'; +const REGISTRY_BODY = { + kohub: { + id: 'kohub', + name: 'KoHub Anthropic', + api: 'https://registry.example.test', + type: 'anthropic', + models: { + 'claude-opus-4-7': { id: 'claude-opus-4-7', name: 'Claude Opus 4-7', tool_call: true }, + }, + }, + 'kohub-responses': { + id: 'kohub-responses', + name: 'KoHub Responses', + api: 'https://registry.example.test/v1', + type: 'openai_responses', + models: { + 'gpt-5.5': { id: 'gpt-5.5', name: 'GPT 5.5', reasoning: true }, + }, + }, +}; + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function mockRegistryFetch(body: unknown = REGISTRY_BODY, status = 200): ReturnType<typeof vi.fn> { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + return fetchMock; +} + +const CATALOG_BODY = { + anthropic: { + id: 'anthropic', + name: 'Anthropic', + npm: '@ai-sdk/anthropic', + api: 'https://api.anthropic.com', + env: ['ANTHROPIC_API_KEY'], + models: { + 'claude-opus-4-7': { + id: 'claude-opus-4-7', + name: 'Claude Opus 4.7', + limit: { context: 200_000, output: 64_000 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + 'claude-haiku-4-5': { + id: 'claude-haiku-4-5', + name: 'Claude Haiku 4.5', + limit: { context: 200_000, output: 16_000 }, + tool_call: true, + modalities: { input: ['text'], output: ['text'] }, + }, + }, + }, + openai: { + id: 'openai', + name: 'OpenAI', + npm: '@ai-sdk/openai', + api: 'https://api.openai.com/v1', + env: ['OPENAI_API_KEY'], + models: { + 'gpt-5.5': { + id: 'gpt-5.5', + name: 'GPT 5.5', + limit: { context: 1_048_576, output: 128_000 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + }, + }, +}; + +describe('kimi provider add', () => { + it('imports providers and models from a custom registry, persisting source on each provider', async () => { + const fetchMock = mockRegistryFetch(); + const { harness, current, setConfigCalls } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleProviderAdd(deps, REGISTRY_URL, { apiKey: 'sk-test-token' }), + ); + + expect(exitCodes).toEqual([]); + expect(stderr.join('')).toBe(''); + expect(fetchMock).toHaveBeenCalledWith( + REGISTRY_URL, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer sk-test-token' }), + }), + ); + + const finalConfig = current(); + expect(Object.keys(finalConfig.providers).toSorted()).toEqual(['kohub', 'kohub-responses']); + const kohub = finalConfig.providers['kohub']!; + expect(kohub.type).toBe('anthropic'); + expect(kohub.baseUrl).toBe('https://registry.example.test'); + expect(kohub.apiKey).toBe('sk-test-token'); + expect(kohub.source).toEqual({ + kind: 'apiJson', + url: REGISTRY_URL, + apiKey: 'sk-test-token', + }); + + expect(finalConfig.models?.['kohub/claude-opus-4-7']).toMatchObject({ + provider: 'kohub', + model: 'claude-opus-4-7', + }); + expect(finalConfig.models?.['kohub-responses/gpt-5.5']).toMatchObject({ + provider: 'kohub-responses', + model: 'gpt-5.5', + }); + + // The single setConfig patch should carry both providers and models. + expect(setConfigCalls).toHaveLength(1); + expect(Object.keys(setConfigCalls[0]?.providers ?? {}).toSorted()).toEqual([ + 'kohub', + 'kohub-responses', + ]); + + const output = stdout.join(''); + expect(output).toContain('Imported 2 providers (2 models)'); + expect(output).toContain('- kohub'); + expect(output).toContain('- kohub-responses'); + }); + + it('drops a stale provider before re-applying when the id already exists', async () => { + mockRegistryFetch(); + const initial: KimiConfig = { + providers: { + kohub: { + type: 'kimi', + baseUrl: 'https://stale.example.test', + apiKey: 'old', + }, + }, + models: { + 'kohub/stale-model': { + provider: 'kohub', + model: 'stale-model', + maxContextSize: 1024, + capabilities: [], + }, + }, + } as unknown as KimiConfig; + const { harness, removeCalls, current } = makeHarness(initial); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleProviderAdd(deps, REGISTRY_URL, { apiKey: 'sk-new' }), + ); + + expect(exitCodes).toEqual([]); + expect(removeCalls).toContain('kohub'); + // The stale model alias must be gone; the registry's alias must be in. + expect(current().models?.['kohub/stale-model']).toBeUndefined(); + expect(current().models?.['kohub/claude-opus-4-7']).toBeDefined(); + }); + + it('preserves newly-imported providers when a later registry entry replaces an existing id', async () => { + // Regression test for the codex P1: `harness.removeProvider` re-reads + // from disk on each call, so applying the loop body without flushing + // would silently drop providers added earlier in the same iteration. + // The handler now removes every stale id up front in a single batch. + mockRegistryFetch(); + const initial: KimiConfig = { + providers: { + // The registry will replace this one. + 'kohub-responses': { + type: 'openai_responses', + baseUrl: 'https://stale.example.test/v1', + apiKey: 'old', + }, + }, + models: { + 'kohub-responses/legacy-model': { + provider: 'kohub-responses', + model: 'legacy-model', + maxContextSize: 1024, + capabilities: [], + }, + }, + } as unknown as KimiConfig; + const { harness, current } = makeHarness(initial); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleProviderAdd(deps, REGISTRY_URL, { apiKey: 'sk-fresh' }), + ); + + expect(exitCodes).toEqual([]); + const final = current(); + // BOTH providers must end up in the final config — `kohub` was newly + // added in the loop, `kohub-responses` was replaced. The old bug dropped + // `kohub` because the second iteration's `removeProvider` reloaded a + // disk-backed config that had not yet been persisted with `kohub`. + expect(final.providers['kohub']).toBeDefined(); + expect(final.providers['kohub-responses']).toBeDefined(); + expect(final.providers['kohub-responses']?.apiKey).toBe('sk-fresh'); + expect(final.models?.['kohub/claude-opus-4-7']).toBeDefined(); + expect(final.models?.['kohub-responses/gpt-5.5']).toBeDefined(); + expect(final.models?.['kohub-responses/legacy-model']).toBeUndefined(); + }); + + it('reads the api key from KIMI_REGISTRY_API_KEY when --api-key is omitted', async () => { + const fetchMock = mockRegistryFetch(); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, exitCodes } = makeDeps(harness, { + env: { KIMI_REGISTRY_API_KEY: 'sk-env-token' }, + }); + + await tryRun(() => handleProviderAdd(deps, REGISTRY_URL, {})); + + expect(exitCodes).toEqual([]); + expect(fetchMock).toHaveBeenCalledWith( + REGISTRY_URL, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer sk-env-token' }), + }), + ); + }); + + it('exits 1 with a clear message when no api key is supplied anywhere', async () => { + const fetchMock = mockRegistryFetch(); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleProviderAdd(deps, REGISTRY_URL, {})); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toMatch(/missing api key/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('exits 1 when the registry fetch fails with an HTTP error', async () => { + mockRegistryFetch({ message: 'invalid token' }, 401); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleProviderAdd(deps, REGISTRY_URL, { apiKey: 'sk-bad' }), + ); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toMatch(/HTTP 401/); + }); +}); + +describe('kimi provider remove', () => { + it('removes a provider and reports success', async () => { + const initial: KimiConfig = { + providers: { + kohub: { type: 'anthropic', baseUrl: 'https://x', apiKey: 'k' }, + }, + models: { + 'kohub/m': { + provider: 'kohub', + model: 'm', + maxContextSize: 1024, + capabilities: [], + }, + }, + } as unknown as KimiConfig; + const { harness, removeCalls, current } = makeHarness(initial); + const { deps, stdout, exitCodes } = makeDeps(harness); + + await tryRun(() => handleProviderRemove(deps, 'kohub')); + + expect(exitCodes).toEqual([]); + expect(removeCalls).toEqual(['kohub']); + expect(current().providers['kohub']).toBeUndefined(); + expect(stdout.join('')).toContain('Removed provider "kohub"'); + }); + + it('exits 1 when the provider id does not exist', async () => { + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleProviderRemove(deps, 'nope')); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('Provider "nope" not found'); + }); +}); + +describe('kimi provider list', () => { + const config: KimiConfig = { + providers: { + kohub: { + type: 'anthropic', + baseUrl: 'https://x', + apiKey: 'k', + source: { kind: 'apiJson', url: REGISTRY_URL, apiKey: 'k' }, + }, + 'managed:kimi-code': { + type: 'kimi', + baseUrl: 'https://api.kimi.com/coding/v1', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + manual: { type: 'openai', baseUrl: 'https://y', apiKey: 'm' }, + }, + models: { + 'kohub/a': { + provider: 'kohub', + model: 'a', + maxContextSize: 1024, + capabilities: [], + }, + 'kohub/b': { + provider: 'kohub', + model: 'b', + maxContextSize: 1024, + capabilities: [], + }, + 'manual/x': { + provider: 'manual', + model: 'x', + maxContextSize: 1024, + capabilities: [], + }, + }, + defaultModel: 'kohub/a', + } as unknown as KimiConfig; + + it('renders one row per provider with counts and source labels', async () => { + const { harness } = makeHarness(config); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleProviderList(deps, { json: false })); + + const out = stdout.join(''); + expect(out).toMatch(/kohub\s+type=anthropic\s+models=2\s+source=apiJson\(/); + expect(out).toMatch(/managed:kimi-code\s+type=kimi\s+models=0\s+source=oauth/); + expect(out).toMatch(/manual\s+type=openai\s+models=1\s+source=inline/); + expect(out).toContain('Default model: kohub/a'); + }); + + it('prints a friendly message when nothing is configured', async () => { + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleProviderList(deps, { json: false })); + + expect(stdout.join('')).toContain('No providers configured'); + }); + + it('emits parseable JSON with --json', async () => { + const { harness } = makeHarness(config); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleProviderList(deps, { json: true })); + + const parsed = JSON.parse(stdout.join('')) as { + providers: Record<string, unknown>; + models: Record<string, unknown>; + }; + expect(Object.keys(parsed.providers).toSorted()).toEqual([ + 'kohub', + 'managed:kimi-code', + 'manual', + ]); + expect(Object.keys(parsed.models)).toContain('kohub/a'); + }); +}); + +describe('registerProviderCommand', () => { + it('describes the user-facing subcommand and routes flags through commander', async () => { + const fetchMock = mockRegistryFetch(); + const { harness, current } = makeHarness({ providers: {} } as KimiConfig); + const { deps, exitCodes, stdout } = makeDeps(harness); + + const program = new Command('kimi'); + registerProviderCommand(program, deps); + + const providerCmd = program.commands.find((c) => c.name() === 'provider'); + expect(providerCmd?.description()).toMatch(/Manage LLM providers/i); + + await tryRun(() => + program.parseAsync( + ['node', 'kimi', 'provider', 'add', REGISTRY_URL, '--api-key', 'sk-cli'], + { from: 'node' }, + ), + ); + + expect(exitCodes).toEqual([]); + expect(fetchMock).toHaveBeenCalledWith( + REGISTRY_URL, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer sk-cli' }), + }), + ); + expect(Object.keys(current().providers).toSorted()).toEqual(['kohub', 'kohub-responses']); + expect(stdout.join('')).toContain('Imported 2 providers'); + }); + + it('reports write failures on stderr and exits 1 instead of crashing', async () => { + const { harness } = makeHarness({ + providers: { kimi: { type: 'kimi' } }, + } as unknown as KimiConfig); + // Simulate the strict write path rejecting because config.toml is invalid. + harness.removeProvider = async () => { + throw new Error( + 'Cannot change settings while config.toml is invalid — fix it first (run `kimi doctor` for details).', + ); + }; + const { deps, stderr, exitCodes } = makeDeps(harness); + + const program = new Command('kimi'); + registerProviderCommand(program, deps); + + await tryRun(() => + program.parseAsync(['node', 'kimi', 'provider', 'remove', 'kimi'], { from: 'node' }), + ); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('Cannot change settings'); + expect(stderr.join('')).not.toContain(' at '); // no stack trace dump + }); +}); + +describe('kimi provider catalog list', () => { + it('lists catalog providers with wire/model counts, sorted by id', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogList(deps, undefined, { json: false })); + + expect(exitCodes).toEqual([]); + const out = stdout.join(''); + expect(out).toMatch(/^anthropic\s+wire=anthropic\s+models=2\s+Anthropic\n/); + expect(out).toMatch(/openai\s+wire=openai\s+models=1\s+OpenAI/); + // anthropic before openai (alphabetical). + expect(out.indexOf('anthropic')).toBeLessThan(out.indexOf('openai')); + }); + + it('filters case-insensitively by id and name substring', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleCatalogList(deps, undefined, { json: false, filter: 'open' })); + + const out = stdout.join(''); + expect(out).toContain('openai'); + expect(out).not.toContain('anthropic'); + }); + + it('drills into a specific providerId and lists its models with capabilities', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleCatalogList(deps, 'anthropic', { json: false })); + + const out = stdout.join(''); + expect(out).toMatch(/^Anthropic \(anthropic\)/); + expect(out).toMatch(/claude-opus-4-7\s+ctx=200000.*tool_use.*thinking.*image_in/); + expect(out).toMatch(/claude-haiku-4-5\s+ctx=200000.*tool_use/); + }); + + it('exits 1 when the requested providerId is missing from the catalog', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogList(deps, 'unknown', { json: false })); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('Provider "unknown" not found in catalog'); + }); + + it('emits parseable JSON for the providerId view', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleCatalogList(deps, 'openai', { json: true })); + + const parsed = JSON.parse(stdout.join('')) as { + providerId: string; + models: Array<{ id: string }>; + }; + expect(parsed.providerId).toBe('openai'); + expect(parsed.models.map((m) => m.id)).toEqual(['gpt-5.5']); + }); + + it('honors --url override when supplied', async () => { + const fetchMock = mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps } = makeDeps(harness); + + await tryRun(() => + handleCatalogList(deps, undefined, { json: true, url: 'https://example.test/catalog.json' }), + ); + + expect(fetchMock).toHaveBeenCalledWith('https://example.test/catalog.json', expect.any(Object)); + }); +}); + +describe('kimi provider catalog add', () => { + it('imports a provider from the catalog without changing the default model', async () => { + mockRegistryFetch(CATALOG_BODY); + const initial: KimiConfig = { + providers: { + other: { type: 'kimi', baseUrl: 'https://x', apiKey: 'k' }, + }, + models: { + 'other/main': { + provider: 'other', + model: 'main', + maxContextSize: 1024, + capabilities: [], + }, + }, + defaultModel: 'other/main', + thinking: { enabled: true }, + } as unknown as KimiConfig; + const { harness, current, setConfigCalls } = makeHarness(initial); + const { deps, stdout, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-ant-token' }), + ); + + expect(exitCodes).toEqual([]); + const finalConfig = current(); + expect(finalConfig.providers['anthropic']).toMatchObject({ + type: 'anthropic', + apiKey: 'sk-ant-token', + }); + // Catalog import populates the model aliases. + expect(finalConfig.models?.['anthropic/claude-opus-4-7']).toMatchObject({ + provider: 'anthropic', + model: 'claude-opus-4-7', + }); + expect(finalConfig.models?.['anthropic/claude-haiku-4-5']).toBeDefined(); + // The unrelated provider's model survives, and remains the default. + expect(finalConfig.models?.['other/main']).toBeDefined(); + expect(finalConfig.defaultModel).toBe('other/main'); + expect(finalConfig.thinking?.enabled).toBe(true); + // The patch sent over `setConfig` must explicitly carry the preserved default. + expect(setConfigCalls[0]?.defaultModel).toBe('other/main'); + expect(stdout.join('')).toContain('Imported Anthropic (anthropic)'); + }); + + it('sets default_model when --default-model is supplied and the model exists', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness, current, setConfigCalls } = makeHarness({ + providers: {}, + } as KimiConfig); + const { deps, stdout, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant-token', + defaultModel: 'claude-opus-4-7', + }), + ); + + expect(exitCodes).toEqual([]); + expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); + expect(setConfigCalls[0]?.defaultModel).toBe('anthropic/claude-opus-4-7'); + expect(stdout.join('')).toContain('Default model set to anthropic/claude-opus-4-7'); + }); + + it('rejects an unknown --default-model with a helpful hint', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant-token', + defaultModel: 'does-not-exist', + }), + ); + + expect(exitCodes).toEqual([1]); + const err = stderr.join(''); + expect(err).toContain('"does-not-exist" is not in provider "anthropic"'); + expect(err).toContain('kimi provider catalog list anthropic'); + }); + + it('preserves an existing default_model when re-importing the same provider without --default-model', async () => { + // Regression test for the codex P2: `removeProvider` clears + // `defaultModel` if it pointed at one of the provider's aliases. The + // handler must capture the previous default BEFORE calling + // `removeProvider`, otherwise rotating the api key on an already- + // configured provider would silently wipe the user's chosen default. + mockRegistryFetch(CATALOG_BODY); + const initial: KimiConfig = { + providers: { + anthropic: { + type: 'anthropic', + baseUrl: 'https://api.anthropic.com', + apiKey: 'sk-old', + }, + }, + models: { + 'anthropic/claude-opus-4-7': { + provider: 'anthropic', + model: 'claude-opus-4-7', + maxContextSize: 200_000, + capabilities: ['tool_use', 'thinking', 'image_in'], + }, + }, + defaultModel: 'anthropic/claude-opus-4-7', + thinking: { enabled: true }, + } as unknown as KimiConfig; + const { harness, current } = makeHarness(initial); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-rotated' }), + ); + + expect(exitCodes).toEqual([]); + expect(current().providers['anthropic']?.apiKey).toBe('sk-rotated'); + // Previous default and thinking flag must survive the re-import. + expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); + expect(current().thinking?.enabled).toBe(true); + }); + + it('preserves thinking.enabled when --default-model is supplied to a thinking-capable model', async () => { + // Regression test for the codex P2: `applyCatalogProvider` always + // assigns `thinking.enabled` from `options.thinking`. Hardcoding `false` + // silently disabled thinking even when the user previously had it on + // and is just importing a known provider. The handler now threads the + // previous value through. + mockRegistryFetch(CATALOG_BODY); + const initial: KimiConfig = { + providers: {}, + thinking: { enabled: true }, + } as unknown as KimiConfig; + const { harness, current, setConfigCalls } = makeHarness(initial); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant', + defaultModel: 'claude-opus-4-7', + }), + ); + + expect(exitCodes).toEqual([]); + expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); + expect(current().thinking?.enabled).toBe(true); + expect(setConfigCalls[0]?.thinking?.enabled).toBe(true); + }); + + it('does not persist thinking.enabled=false for first-time setup with --default-model', async () => { + // Regression test for codex P2 follow-up: previously the handler fell + // back to `false` when `thinking.enabled` was unset, but + // `resolveThinkingEffort` treats `thinking.enabled === false` as an + // explicit "off" request. A fresh `kimi provider catalog add + // anthropic --default-model claude-opus-4-7` must NOT silently disable + // thinking — it should leave `thinking.enabled` unset so the runtime + // uses the per-model default. + mockRegistryFetch(CATALOG_BODY); + // Note: `thinking.enabled` is omitted on purpose to model a fresh user. + const { harness, current, setConfigCalls } = makeHarness({ + providers: {}, + } as KimiConfig); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant', + defaultModel: 'claude-opus-4-7', + }), + ); + + expect(exitCodes).toEqual([]); + expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); + // Must NOT be `false`. `undefined` lets the runtime resolver pick the + // per-model default; `false` would force `'off'`. + expect(current().thinking?.enabled).toBeUndefined(); + expect(setConfigCalls[0]?.thinking?.enabled).toBeUndefined(); + }); + + it('drops a stale default_model when the catalog refresh no longer contains it', async () => { + // Regression test for codex P2: when the user previously chose + // `anthropic/legacy` as default and a refresh of the same provider no + // longer ships that model, restoring the previous default would point + // `default_model` at a non-existent alias and break the next session. + // The handler now checks whether the alias still resolves and clears + // it otherwise. + mockRegistryFetch(CATALOG_BODY); + const initial: KimiConfig = { + providers: { + anthropic: { + type: 'anthropic', + baseUrl: 'https://api.anthropic.com', + apiKey: 'sk-old', + }, + }, + models: { + 'anthropic/legacy-claude': { + provider: 'anthropic', + model: 'legacy-claude', + maxContextSize: 200_000, + capabilities: [], + }, + }, + defaultModel: 'anthropic/legacy-claude', + } as unknown as KimiConfig; + const { harness, current } = makeHarness(initial); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-rotated' }), + ); + + expect(exitCodes).toEqual([]); + // The legacy alias must have been replaced by the catalog's models. + expect(current().models?.['anthropic/legacy-claude']).toBeUndefined(); + expect(current().models?.['anthropic/claude-opus-4-7']).toBeDefined(); + // The dangling default must NOT have been restored — it would point at + // a non-existent alias. The handler clears it instead. + expect(current().defaultModel).toBeUndefined(); + }); + + it('falls back to KIMI_REGISTRY_API_KEY when --api-key is omitted', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness, current } = makeHarness({ providers: {} } as KimiConfig); + const { deps, exitCodes } = makeDeps(harness, { + env: { KIMI_REGISTRY_API_KEY: 'sk-env' }, + }); + + await tryRun(() => handleCatalogAdd(deps, 'openai', {})); + + expect(exitCodes).toEqual([]); + expect(current().providers['openai']).toMatchObject({ apiKey: 'sk-env' }); + }); + + it('exits 1 when the api key is missing and skips the network', async () => { + const fetchMock = mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogAdd(deps, 'anthropic', {})); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toMatch(/missing api key/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('exits 1 when the providerId is missing from the catalog', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'no-such-id', { apiKey: 'sk-x' }), + ); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('Provider "no-such-id" not found in catalog'); + }); +}); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index b62cf8e4c..8fbe0d9d5 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -1,7 +1,8 @@ import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runPrompt } from '#/cli/run-prompt'; +import { PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; type CreateKimiDeviceId = typeof createKimiDeviceIdFn; @@ -38,6 +39,10 @@ const mocks = vi.hoisted(() => { handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); } }), + waitForBackgroundTasksOnPrint: vi.fn(async () => {}), + getGoal: vi.fn(async () => ({ goal: null })), + getCronTasks: vi.fn(async () => ({ tasks: [] })), + handlePrintMainTurnCompleted: vi.fn(async (): Promise<'finish' | 'continue'> => 'finish'), }; return { @@ -54,12 +59,50 @@ const mocks = vi.hoisted(() => { telemetry: true, }), ), + harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), + harnessGetExperimentalFeatures: vi.fn(async () => []), harnessCreateSession: vi.fn(async () => session), harnessResumeSession: vi.fn(async () => session), harnessListSessions: vi.fn(async () => [{ id: 'ses_previous', workDir: process.cwd() }]), harnessClose: vi.fn(), harnessTrack: vi.fn(), harnessGetCachedAccessToken: vi.fn(), + runV2Print: vi.fn( + async ( + opts: { readonly outputFormat?: string }, + version: string, + io?: { + readonly stdout?: { write(chunk: string): boolean }; + readonly stderr?: { write(chunk: string): boolean }; + }, + ) => { + // Mirror the native runner's output protocol so the version-banner + // assertions stay meaningful: version first, then the assistant + // message, then the resume hint — in the active output format. + const stdout = io?.stdout ?? process.stdout; + const stderr = io?.stderr ?? process.stderr; + const outputFormat = opts?.outputFormat ?? 'text'; + if (outputFormat === 'stream-json') { + stdout.write( + `${JSON.stringify({ role: 'meta', type: 'system.version', version })}\n`, + ); + stdout.write(`${JSON.stringify({ role: 'assistant', content: 'hello world' })}\n`); + stdout.write( + `${JSON.stringify({ + role: 'meta', + type: 'session.resume_hint', + session_id: 'ses_prompt', + command: 'kimi -r ses_prompt', + content: 'To resume this session: kimi -r ses_prompt', + })}\n`, + ); + return; + } + stderr.write(`kimi version ${version}\n`); + stdout.write('• hello world\n\n'); + stderr.write('To resume this session: kimi -r ses_prompt\n'); + }, + ), initializeTelemetry: vi.fn(), setCrashPhase: vi.fn(), shutdownTelemetry: vi.fn(), @@ -78,25 +121,26 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { return { ...actual, resolveKimiHome: mocks.resolveKimiHome, - KimiHarness: class { - homeDir: string; - auth = { getCachedAccessToken: mocks.harnessGetCachedAccessToken }; - ensureConfigFile = mocks.harnessEnsureConfigFile; - getConfig = mocks.harnessGetConfig; - createSession = mocks.harnessCreateSession; - resumeSession = mocks.harnessResumeSession; - listSessions = mocks.harnessListSessions; - close = mocks.harnessClose; - track = mocks.harnessTrack; - - constructor(...args: unknown[]) { - const options = args[0] as { readonly homeDir?: string } | undefined; - this.homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; - if (mocks.harnessCreatesDeviceIdOnConstruction) { - mocks.createKimiDeviceId(this.homeDir); - } - mocks.kimiHarnessConstructor(...args); + createKimiHarness: (...args: unknown[]) => { + const options = args[0] as { readonly homeDir?: string } | undefined; + const homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; + if (mocks.harnessCreatesDeviceIdOnConstruction) { + mocks.createKimiDeviceId(homeDir); } + mocks.kimiHarnessConstructor(...args); + return { + homeDir, + auth: { getCachedAccessToken: mocks.harnessGetCachedAccessToken }, + ensureConfigFile: mocks.harnessEnsureConfigFile, + getConfig: mocks.harnessGetConfig, + getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, + getExperimentalFeatures: mocks.harnessGetExperimentalFeatures, + createSession: mocks.harnessCreateSession, + resumeSession: mocks.harnessResumeSession, + listSessions: mocks.harnessListSessions, + close: mocks.harnessClose, + track: mocks.harnessTrack, + }; }, }; }); @@ -121,6 +165,14 @@ vi.mock('@moonshot-ai/kimi-telemetry', () => ({ withTelemetryContext: mocks.withTelemetryContext, })); +// The experimental v2 engine is loaded via a dynamic import from run-prompt.ts +// when KIMI_CODE_EXPERIMENTAL_FLAG is set. Mock the native v2 runner so tests +// that flip that flag can exercise the dispatch without pulling in the real +// agent-core-v2 graph. +vi.mock('../../src/cli/v2/run-v2-print', () => ({ + runV2Print: mocks.runV2Print, +})); + function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) { return { session: undefined, @@ -132,6 +184,7 @@ function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) { outputFormat: undefined, prompt: 'say hello', skillsDirs: [], + addDirs: [], ...overrides, }; } @@ -179,8 +232,17 @@ async function waitForAssertion(assertion: () => void): Promise<void> { } describe('runPrompt', () => { + beforeEach(() => { + // Pin the experimental engine flag off so the default v1 path is + // deterministic regardless of the host environment. Tests that exercise the + // experimental path opt back in explicitly with `vi.stubEnv(..., '1')`. + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', ''); + vi.stubEnv('KIMI_MODEL_OUTPUT_FORMAT', ''); + }); + afterEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); mocks.eventHandlers.clear(); mocks.createKimiDeviceId.mockImplementation(() => 'device-1'); mocks.resolveKimiHome.mockImplementation( @@ -202,6 +264,8 @@ describe('runPrompt', () => { workDir: process.cwd(), model: 'k2', permission: 'auto', + additionalDirs: undefined, + drainAgentTasksOnStop: true, }); expect(mocks.session.setPermission).not.toHaveBeenCalled(); expect(mocks.session.setApprovalHandler).toHaveBeenCalledWith(expect.any(Function)); @@ -209,10 +273,116 @@ describe('runPrompt', () => { expect(mocks.session.prompt).toHaveBeenCalledWith('say hello'); expect(stdout.text()).toBe('• hello world\n\n'); expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'ses_prompt' }), + ); expect(mocks.shutdownTelemetry).toHaveBeenCalled(); expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('completes even if harness.close() never resolves (cleanup is time-bounded)', async () => { + vi.useFakeTimers(); + try { + const stdout = writer(); + const stderr = writer(); + // Simulate a shutdown step that hangs (e.g. a wedged SessionEnd hook or a + // blackholed connection in a firewalled sandbox). A completed headless run + // must not stay alive forever waiting on cleanup. + mocks.harnessClose.mockReturnValueOnce(new Promise<void>(() => {})); + + let settled = false; + const done = runPrompt(opts(), '1.2.3-test', { + stdout, + stderr, + process: fakeProcess(), + }).then(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(PROMPT_CLEANUP_TIMEOUT_MS + 100); + await done; + + expect(settled).toBe(true); + expect(mocks.harnessClose).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('propagates a cleanup failure that settles before the timeout', async () => { + const stdout = writer(); + const stderr = writer(); + // A cleanup step that fails fast (e.g. a permission restore or harness close + // hitting a persistence error) must surface — not be silently swallowed by + // the timeout guard — otherwise the run reports success while shutdown + // actually failed (e.g. a resumed session left in `auto`). + mocks.harnessClose.mockRejectedValueOnce(new Error('close failed')); + + await expect( + runPrompt(opts(), '1.2.3-test', { stdout, stderr, process: fakeProcess() }), + ).rejects.toThrow('close failed'); + }); + + it('ignores a cleanup rejection that lands after the timeout', async () => { + vi.useFakeTimers(); + try { + const stdout = writer(); + const stderr = writer(); + // Cleanup overruns the bound and only rejects later. The run already gave + // up waiting and resolved; that late rejection must not flip it to a + // failure (nor surface as an unhandled rejection). + mocks.harnessClose.mockReturnValueOnce( + new Promise<void>((_, reject) => { + const timer = setTimeout( + () => reject(new Error('late close')), + PROMPT_CLEANUP_TIMEOUT_MS + 5000, + ); + timer.unref?.(); + }), + ); + + let settled: 'resolved' | 'rejected' | undefined; + const done = runPrompt(opts(), '1.2.3-test', { + stdout, + stderr, + process: fakeProcess(), + }).then( + () => { + settled = 'resolved'; + }, + () => { + settled = 'rejected'; + }, + ); + + await vi.advanceTimersByTimeAsync(PROMPT_CLEANUP_TIMEOUT_MS + 100); + await done; + expect(settled).toBe('resolved'); + + await vi.advanceTimersByTimeAsync(5000); + await Promise.resolve(); + expect(settled).toBe('resolved'); + } finally { + vi.useRealTimers(); + } + }); + + it('stops prompt startup when session creation fails', async () => { + const stdout = writer(); + const stderr = writer(); + mocks.harnessCreateSession.mockRejectedValueOnce(new Error('Git Bash missing')); + + await expect(runPrompt(opts(), '1.2.3-test', { stdout, stderr })).rejects.toThrow( + 'Git Bash missing', + ); + + expect(mocks.harnessEnsureConfigFile).toHaveBeenCalledOnce(); + expect(mocks.harnessGetConfig).toHaveBeenCalledOnce(); + expect(mocks.harnessCreateSession).toHaveBeenCalledOnce(); + expect(mocks.session.prompt).not.toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalledOnce(); + }); + it('uses the CLI model override when creating a fresh prompt session', async () => { await runPrompt(opts({ model: 'kimi-code/k2.5' }), '1.2.3-test', { stdout: { write: vi.fn(() => true) }, @@ -223,12 +393,29 @@ describe('runPrompt', () => { workDir: process.cwd(), model: 'kimi-code/k2.5', permission: 'auto', + additionalDirs: undefined, + drainAgentTasksOnStop: true, }); expect(mocks.initializeTelemetry).toHaveBeenCalledWith( expect.objectContaining({ model: 'kimi-code/k2.5' }), ); }); + it('passes the CLI additional directory when creating a fresh prompt session', async () => { + await runPrompt(opts({ addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessCreateSession).toHaveBeenCalledWith({ + workDir: process.cwd(), + model: 'k2', + permission: 'auto', + additionalDirs: ['../shared', '/tmp/extra'], + drainAgentTasksOnStop: true, + }); + }); + it('tracks first launch in prompt mode before harness construction can create the device id', async () => { mocks.harnessCreatesDeviceIdOnConstruction = true; const createdHomes = new Set<string>(); @@ -423,6 +610,45 @@ describe('runPrompt', () => { expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); }); + it('passes the CLI additional directories when resuming a concrete session', async () => { + await runPrompt( + opts({ session: 'ses_existing', addDirs: ['../shared', '/tmp/extra'] }), + '1.2.3-test', + { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }, + ); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ + id: 'ses_existing', + additionalDirs: ['../shared', '/tmp/extra'], + }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + }); + + it('allows resuming a concrete session when Windows workdir uses backslashes', async () => { + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(String.raw`C:\Users\kimi\project`); + mocks.harnessListSessions.mockResolvedValueOnce([ + { id: 'ses_existing', workDir: 'C:/Users/kimi/project' }, + ]); + + try { + await runPrompt(opts({ session: 'ses_existing' }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + } finally { + cwd.mockRestore(); + } + + expect(mocks.harnessListSessions).toHaveBeenCalledWith({ + sessionId: 'ses_existing', + workDir: String.raw`C:\Users\kimi\project`, + }); + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_existing' }); + }); + it('applies the CLI model override to resumed prompt sessions', async () => { await runPrompt(opts({ session: 'ses_existing', model: 'kimi-code/k2.5' }), '1.2.3-test', { stdout: { write: vi.fn(() => true) }, @@ -496,6 +722,140 @@ describe('runPrompt', () => { ); }); + it('emits a stream-json meta line on retry and discards the failed attempt output', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'partial attempt' })); + handler( + mocks.mainEvent({ + type: 'turn.step.retrying', + turnId: 10, + step: 1, + stepId: 'step-uuid', + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 3, + delayMs: 300, + errorName: 'APIProviderRateLimitError', + errorMessage: 'llmproxy/openai/responses/resp_abc.json status_code=429', + statusCode: 429, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'final answer' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr }); + + const retryMeta = JSON.stringify({ + role: 'meta', + type: 'turn.step.retrying', + failed_attempt: 1, + next_attempt: 2, + max_attempts: 3, + delay_ms: 300, + error_name: 'APIProviderRateLimitError', + error_message: 'llmproxy/openai/responses/resp_abc.json status_code=429', + status_code: 429, + }); + expect(stdout.text()).toBe( + [ + retryMeta, + '{"role":"assistant","content":"final answer"}', + '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}', + '', + ].join('\n'), + ); + // The failed attempt's partial text must not leak as an assistant line. + expect(stdout.text()).not.toContain('partial attempt'); + expect(stderr.text()).toBe(''); + }); + + it('flushes stream-json assistant output before waiting for background tasks', async () => { + let releaseWait: () => void = () => {}; + const waitGate = new Promise<void>((resolve) => { + releaseWait = resolve; + }); + mocks.session.waitForBackgroundTasksOnPrint.mockImplementationOnce(async () => waitGate); + + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 9, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 9, delta: 'final answer' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 9, reason: 'completed' })); + } + }); + + const stdout = writer(); + const stderr = writer(); + const runPromise = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr, + }); + + // The assistant message must be flushed even while the background wait is pending. + await waitForAssertion(() => { + expect(stdout.text()).toContain('{"role":"assistant","content":"final answer"}'); + }); + + releaseWait(); + await runPromise; + }); + + it('follows a background-steered second main turn before finishing in steer mode', async () => { + // First end-of-turn: stay alive (a background task is still pending). + // Second end-of-turn: finish. + mocks.session.handlePrintMainTurnCompleted + .mockResolvedValueOnce('continue') + .mockResolvedValueOnce('finish'); + + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'first' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' })); + } + }); + + const stdout = writer(); + const stderr = writer(); + const runPromise = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr, + }); + + // The first turn's assistant message must be flushed and the end-of-turn + // policy consulted, while the run stays alive (action === 'continue'). + await waitForAssertion(() => { + expect(mocks.session.handlePrintMainTurnCompleted).toHaveBeenCalledTimes(1); + expect(stdout.text()).toContain('{"role":"assistant","content":"first"}'); + }); + + // Simulate a background-task completion steering the main agent into a new + // turn (the runtime does this via turn.steer; here we drive the events + // directly to verify the driver follows and finishes only after it). + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 11, + origin: { kind: 'background_task' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 11, delta: 'second' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 11, reason: 'completed' })); + } + + await runPromise; + + expect(mocks.session.handlePrintMainTurnCompleted).toHaveBeenCalledTimes(2); + expect(stdout.text()).toContain('{"role":"assistant","content":"second"}'); + }); + it('resumes a concrete session without a configured default model', async () => { mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); @@ -526,6 +886,19 @@ describe('runPrompt', () => { expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); }); + it('passes the CLI additional directories when continuing the previous session', async () => { + await runPrompt(opts({ continue: true, addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ + id: 'ses_previous', + additionalDirs: ['../shared', '/tmp/extra'], + }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + }); + it('continues a previous session without a configured default model', async () => { mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); @@ -617,6 +990,47 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalledTimes(1); }); + it.each([ + ['SIGTERM' as NodeJS.Signals, 143], + ['SIGHUP' as NodeJS.Signals, 129], + ])('cleans up prompt mode before exiting on %s', async (signal, exitCode) => { + let releasePrompt!: () => void; + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 7, origin: { kind: 'user' } }), + ); + } + await new Promise<void>((resolve) => { + releasePrompt = resolve; + }); + }); + const processMock = fakeProcess(); + const run = runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + process: processMock, + } as Parameters<typeof runPrompt>[2] & { process: ReturnType<typeof fakeProcess> }); + + await waitForAssertion(() => { + expect(processMock.listener(signal)).toBeDefined(); + }); + + await processMock.listener(signal)?.(); + + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalled(); + expect(processMock.exit).toHaveBeenCalledWith(exitCode); + + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 7, reason: 'completed' })); + } + releasePrompt(); + await run; + + expect(mocks.harnessClose).toHaveBeenCalledTimes(1); + }); + it('waits for the pending auto permission write before signal restore', async () => { let releaseAutoPermission!: () => void; let releasePrompt!: () => void; @@ -725,6 +1139,37 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('rejects with a friendly message when the provider filters the response', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); + handler( + mocks.mainEvent({ + type: 'turn.ended', + turnId: 2, + reason: 'failed', + error: { + code: 'provider.filtered', + message: 'Provider safety policy blocked the response.', + name: 'ProviderFilteredError', + retryable: false, + }, + }), + ); + } + }); + + await expect( + runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }), + ).rejects.toThrow('Provider safety policy blocked the response.'); + + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalled(); + }); + it('approval fallback approves if an unexpected approval request reaches SDK', async () => { await runPrompt(opts(), '1.2.3-test', { stdout: { write: vi.fn(() => true) }, @@ -744,4 +1189,213 @@ describe('runPrompt', () => { const handler = mocks.session.setQuestionHandler.mock.calls[0]![0] as () => unknown; expect(handler()).toBeNull(); }); + + it('emits the version first in text mode when the experimental flag is enabled', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + // The experimental engine is selected and the version banner is the very + // first write, ahead of any assistant output or the resume hint. + expect(mocks.runV2Print).toHaveBeenCalled(); + expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled(); + expect(stderr.write).toHaveBeenNthCalledWith(1, 'kimi version 1.2.3-test\n'); + expect(stderr.text().startsWith('kimi version 1.2.3-test\n')).toBe(true); + expect(stdout.text()).toBe('• hello world\n\n'); + }); + + it('emits the version first in stream-json mode when the experimental flag is enabled', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr, + }); + + expect(mocks.runV2Print).toHaveBeenCalled(); + expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled(); + const lines = stdout.text().split('\n'); + expect(lines[0]).toBe( + '{"role":"meta","type":"system.version","version":"1.2.3-test"}', + ); + expect(stderr.text()).toBe(''); + }); + + it('does not emit the version when the experimental flag is disabled', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + expect(mocks.runV2Print).not.toHaveBeenCalled(); + expect(mocks.kimiHarnessConstructor).toHaveBeenCalled(); + expect(stderr.text()).not.toContain('kimi version'); + }); + + it('does not settle on end_turn while a goal is still active', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'created a goal' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + }); + // First evaluation (after turn 1) sees an active goal; the continuation + // turn's evaluation sees the goal gone (completed → record cleared). + mocks.session.getGoal.mockResolvedValueOnce({ goal: { status: 'active' } } as never); + + const stdout = writer(); + const stderr = writer(); + let settled = false; + const run = runPrompt(opts(), '1.2.3-test', { stdout, stderr }).then(() => { + settled = true; + }); + + await waitForAssertion(() => { + expect(mocks.session.getGoal).toHaveBeenCalledTimes(1); + }); + expect(settled).toBe(false); + + // The goal driver launches the continuation turn on its own; the run + // streams it and settles only once no goal is active anymore. + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: 'goal work' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' })); + } + + await run; + expect(settled).toBe(true); + expect(stdout.text()).toContain('goal work'); + }); + + it('settles when the goal reaches a terminal state between turns with no trailing turn.ended', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'working' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + }); + // Turn 1's evaluation sees the goal still active; the terminal + // goal.updated (e.g. the driver blocked it on a hard budget) arrives with + // no further turn.ended and must settle the run itself. + mocks.session.getGoal + .mockResolvedValueOnce({ goal: { status: 'active' } } as never) + .mockResolvedValue({ goal: { status: 'blocked' } } as never); + + const stdout = writer(); + const stderr = writer(); + let settled = false; + const run = runPrompt(opts(), '1.2.3-test', { stdout, stderr }).then(() => { + settled = true; + }); + + await waitForAssertion(() => { + expect(mocks.session.getGoal).toHaveBeenCalledTimes(1); + }); + expect(settled).toBe(false); + + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'goal.updated', + snapshot: { status: 'blocked' }, + change: { kind: 'blocked' }, + }), + ); + } + + await run; + expect(settled).toBe(true); + }); + + it('does not settle on end_turn while a cron task is pending, then lets the fire drive a turn', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'scheduled a reminder' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + }); + // Turn 1 leaves a pending one-shot cron task; its fire steers turn 2, and + // by turn 2's evaluation the task has fired and been removed. + mocks.session.getCronTasks + .mockResolvedValueOnce({ + tasks: [ + { + id: '3f9a1c2e', + cron: '*/5 * * * *', + recurring: false, + createdAt: 1, + lastFiredAt: undefined, + nextFireAt: Date.now() + 60_000, + }, + ], + } as never) + .mockResolvedValue({ tasks: [] } as never); + + const stdout = writer(); + const stderr = writer(); + let settled = false; + const run = runPrompt(opts(), '1.2.3-test', { stdout, stderr }).then(() => { + settled = true; + }); + + await waitForAssertion(() => { + expect(mocks.session.getCronTasks).toHaveBeenCalledTimes(1); + }); + expect(settled).toBe(false); + + // The cron fire steers a fresh turn; the run streams it and settles once + // no pending tasks remain. + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'cron_job' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: 'cron ran' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' })); + } + + await run; + expect(settled).toBe(true); + expect(stdout.text()).toContain('cron ran'); + }); + + it('does not wait for cron tasks whose expression has no future fire', async () => { + mocks.session.getCronTasks.mockResolvedValue({ + tasks: [ + { + id: '3f9a1c2e', + cron: '0 0 31 2 *', + recurring: true, + createdAt: 1, + lastFiredAt: undefined, + nextFireAt: null, + }, + ], + } as never); + + const stdout = writer(); + const stderr = writer(); + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + expect(stdout.text()).toBe('• hello world\n\n'); + expect(mocks.harnessClose).toHaveBeenCalled(); + }); }); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 8aa72d945..eafc4a9e0 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -37,6 +37,7 @@ const mocks = vi.hoisted(() => { defaultModel: 'k2', telemetry: true, })), + harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), harnessGetCachedAccessToken: vi.fn(), harnessClose: vi.fn(), detectPendingMigration: vi.fn<() => Promise<unknown>>(async () => null), @@ -68,24 +69,24 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { return { ...actual, resolveKimiHome: mocks.resolveKimiHome, - KimiHarness: class { - homeDir: string; - auth = { - getCachedAccessToken: mocks.harnessGetCachedAccessToken, - }; - ensureConfigFile = mocks.harnessEnsureConfigFile; - getConfig = mocks.harnessGetConfig; - close = mocks.harnessClose; - track = mocks.harnessTrack; - - constructor(...args: unknown[]) { - const options = args[0] as { readonly homeDir?: string } | undefined; - this.homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; - if (mocks.harnessCreatesDeviceIdOnConstruction) { - mocks.createKimiDeviceId(this.homeDir); - } - mocks.kimiHarnessConstructor(...args); + createKimiHarness: (...args: unknown[]) => { + const options = args[0] as { readonly homeDir?: string } | undefined; + const homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; + if (mocks.harnessCreatesDeviceIdOnConstruction) { + 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, + }; }, }; }); @@ -180,6 +181,7 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + addDirs: ['../shared', '/tmp/extra'], }; await runShell(cliOptions, '1.2.3-test'); @@ -190,13 +192,14 @@ describe('runShell', () => { userAgentProduct: 'kimi-code-cli', version: '1.2.3-test', }), + sessionStartedProperties: { yolo: true, auto: false, plan: true, afk: false }, }), ); expect(mocks.harnessEnsureConfigFile).toHaveBeenCalledOnce(); expect(mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]).toBeLessThan( mocks.harnessGetConfig.mock.invocationCallOrder[0]!, ); - expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: 'ignore' }); + expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] }); expect(mocks.kimiTuiConstructor).toHaveBeenCalledTimes(1); expect(mocks.createKimiDeviceId).toHaveBeenCalledWith( '/tmp/kimi-code-test-home', @@ -210,6 +213,7 @@ describe('runShell', () => { version: '1.2.3-test', uiMode: 'shell', model: 'k2', + sessionId: undefined, getAccessToken: expect.any(Function), }); expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime'); @@ -218,6 +222,7 @@ describe('runShell', () => { expect(harness).toBeTypeOf('object'); expect(startupInput).toMatchObject({ cliOptions, + additionalDirs: ['../shared', '/tmp/extra'], tuiConfig: { theme: 'dark', editorCommand: null, @@ -225,18 +230,9 @@ describe('runShell', () => { }, version: '1.2.3-test', workDir: process.cwd(), - resolvedTheme: 'dark', }); expect(mocks.tuiStart).toHaveBeenCalledOnce(); - expect(mocks.harnessTrack).not.toHaveBeenCalledWith('started', expect.anything()); expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' }); - expect(mocks.lifecycleTrack).toHaveBeenCalledWith('started', { - resumed: false, - yolo: true, - auto: false, - plan: true, - afk: false, - }); expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', { duration_ms: expect.any(Number), config_ms: expect.any(Number), @@ -245,6 +241,34 @@ describe('runShell', () => { }); }); + it('forwards skillsDirs from CLI options to the harness', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: ['/skills'], + }, + '1.2.3-test', + ); + + expect(mocks.kimiHarnessConstructor).toHaveBeenCalledWith( + expect.objectContaining({ skillDirs: ['/skills'] }), + ); + }); + it('tracks first launch when device id creation reports first launch', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -327,39 +351,6 @@ describe('runShell', () => { expect(mocks.harnessTrack).toHaveBeenCalledWith('first_launch'); }); - it('marks resumed lifecycle starts from session flags', async () => { - mocks.loadTuiConfig.mockResolvedValue({ - theme: 'dark', - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' }, - }); - mocks.tuiStart.mockResolvedValue(undefined); - mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); - - await runShell( - { - session: 'ses-1', - continue: false, - yolo: false, - auto: false, - plan: false, - model: undefined, - outputFormat: undefined, - prompt: undefined, - skillsDirs: [], - }, - '1.2.3-test', - ); - - expect(mocks.lifecycleTrack).toHaveBeenCalledWith('started', { - resumed: true, - yolo: false, - auto: false, - plan: false, - afk: false, - }); - }); - it('binds startup_perf to the session captured before MCP metrics resolve', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -389,9 +380,9 @@ describe('runShell', () => { '1.2.3-test', ); - expect(mocks.withTelemetryContext).toHaveBeenNthCalledWith(1, { sessionId: 'ses-startup' }); - expect(mocks.withTelemetryContext).toHaveBeenNthCalledWith(2, { sessionId: 'ses-startup' }); - expect(mocks.lifecycleTrack).toHaveBeenNthCalledWith(2, 'startup_perf', { + expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' }); + expect(mocks.withTelemetryContext).not.toHaveBeenCalledWith({ sessionId: 'ses-later' }); + expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', { duration_ms: expect.any(Number), config_ms: expect.any(Number), init_ms: expect.any(Number), @@ -436,13 +427,13 @@ describe('runShell', () => { harnessOptions.onOAuthRefresh({ success: false, reason: 'unauthorized' }); harnessOptions.onOAuthRefresh({ success: false, reason: 'network_or_other' }); - expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { success: true }); + expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { outcome: 'success' }); expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { - success: false, + outcome: 'error', reason: 'unauthorized', }); expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { - success: false, + outcome: 'error', reason: 'network_or_other', }); }); @@ -477,7 +468,6 @@ describe('runShell', () => { const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; expect(startupInput).toMatchObject({ startupNotice: 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.', - resolvedTheme: 'light', tuiConfig: { theme: 'auto', editorCommand: 'vim', @@ -486,6 +476,38 @@ describe('runShell', () => { }); }); + it('forwards config.toml diagnostics as startup notices', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.harnessGetConfigDiagnostics.mockResolvedValue({ + warnings: ['Ignored invalid config in config.toml: loop_control.'], + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: '', + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; + expect(startupInput).toMatchObject({ + startupNotice: 'Ignored invalid config in config.toml: loop_control.', + }); + }); + it('closes the harness when TUI startup fails', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -500,7 +522,7 @@ describe('runShell', () => { session: undefined, continue: false, yolo: false, - auto: false, + auto: false, plan: false, model: undefined, outputFormat: undefined, @@ -512,7 +534,7 @@ describe('runShell', () => { ).rejects.toThrow('boom'); expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); - expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_s: expect.any(Number) }); + expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_ms: expect.any(Number) }); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); expect(mocks.harnessClose).toHaveBeenCalledOnce(); }); @@ -537,7 +559,7 @@ describe('runShell', () => { session: undefined, continue: false, yolo: false, - auto: false, + auto: false, plan: false, model: undefined, outputFormat: undefined, @@ -558,7 +580,7 @@ describe('runShell', () => { expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-1' }); expect(mocks.lifecycleTrack).toHaveBeenCalledWith('exit', { - duration_s: expect.any(Number), + duration_ms: expect.any(Number), }); expect(mocks.harnessTrack).not.toHaveBeenCalledWith('exit', expect.anything()); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); @@ -571,6 +593,53 @@ describe('runShell', () => { } }); + it('prints the opened web URL from the TUI exit handler when set', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); + mocks.tuiHasSessionContent.mockReturnValue(true); + + const stdout = captureProcessWrite('stdout'); + const stderr = captureProcessWrite('stderr'); + const exitSpy = mockProcessExit(); + + try { + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!; + const openedUrl = 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1'; + (tui as { exitOpenUrl?: string }).exitOpenUrl = openedUrl; + + await expect((tui as { onExit: () => Promise<void> }).onExit()).rejects.toBeInstanceOf( + ExitCalled, + ); + + expect(stderr.text()).toContain(' To resume this session: kimi -r ses-1'); + expect(stderr.text()).toContain('open '); + expect(stderr.text()).toContain(openedUrl); + } finally { + exitSpy.mockRestore(); + stdout.restore(); + stderr.restore(); + } + }); + it('surfaces an invalid target config as an error for kimi migrate, not silently', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -590,7 +659,7 @@ describe('runShell', () => { session: undefined, continue: false, yolo: false, - auto: false, + auto: false, plan: false, model: undefined, outputFormat: undefined, diff --git a/apps/kimi-code/test/cli/run-v2-print.test.ts b/apps/kimi-code/test/cli/run-v2-print.test.ts new file mode 100644 index 000000000..6e7178509 --- /dev/null +++ b/apps/kimi-code/test/cli/run-v2-print.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + applyPrintBackgroundPolicy, + createPrintTurnEndings, + PrintSteeredTurnFailedError, + type PrintTurnEnding, + type PrintTurnEndings, +} from '#/cli/v2/run-v2-print'; + +function ending( + turnId: number, + reason: PrintTurnEnding['reason'] = 'completed', +): PrintTurnEnding { + return { type: 'turn.ended', turnId, reason }; +} + +interface ScriptedEntry { + readonly event: PrintTurnEnding; + /** Side effect applied when this entry is consumed (e.g. mutate pending). */ + readonly apply?: () => void; +} + +/** + * Scripted `PrintTurnEndings`: replays queued endings (honouring `skipTurnId`), + * then resolves `null` once the script is exhausted (the wait "timed out"). + */ +function scriptedTurnEndings(entries: ScriptedEntry[]): PrintTurnEndings { + const queue = [...entries]; + return { + next: async (_remainingMs: number, skipTurnId: number) => { + while (queue.length > 0) { + const entry = queue.shift()!; + if (entry.event.turnId === skipTurnId) continue; + entry.apply?.(); + return entry.event; + } + return null; + }, + }; +} + +describe('applyPrintBackgroundPolicy', () => { + it('exit returns immediately without draining or waiting', async () => { + const drain = vi.fn(async () => {}); + const countPending = vi.fn(() => 1); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 60, + maxTurns: 50, + countPending, + drain, + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }); + expect(drain).not.toHaveBeenCalled(); + expect(countPending).not.toHaveBeenCalled(); + }); + + it('drain drains once and returns', async () => { + const drain = vi.fn(async () => {}); + await applyPrintBackgroundPolicy({ + mode: 'drain', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain, + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }); + expect(drain).toHaveBeenCalledTimes(1); + }); + + it('steer returns once background tasks are quiescent', async () => { + let pending = 1; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => pending, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + // The main turn's own buffered ending is skipped. + { event: ending(1) }, + // A background task completed and steered a new turn; it finished and + // no tasks remain. + { event: ending(2), apply: () => { pending = 0; } }, + ]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('steer finishes with a warning when max turns is reached', async () => { + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 2, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([{ event: ending(2) }, { event: ending(3) }]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('max turns'); + }); + + it('steer finishes with a warning when the ceiling is reached', async () => { + let now = 0; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 10, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { event: ending(2), apply: () => { now = 10_001; } }, + ]), + skipTurnId: 1, + warn, + now: () => now, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('ceiling'); + }); + + it('steer returns when the wait times out with tasks still pending', async () => { + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + // Empty script: no further turn ends before the deadline. + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('steer throws when a steered turn does not complete', async () => { + await expect( + applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { + event: { + type: 'turn.ended', + turnId: 2, + reason: 'failed', + error: { code: 'provider.overloaded', message: 'try later' }, + } as PrintTurnEnding, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }), + ).rejects.toThrow(PrintSteeredTurnFailedError); + }); +}); + +describe('createPrintTurnEndings', () => { + it('buffers events pushed before next() and skips the given turn id', async () => { + const endings = createPrintTurnEndings(); + endings.push(ending(1)); + endings.push(ending(2)); + await expect(endings.next(1000, 1)).resolves.toMatchObject({ turnId: 2 }); + }); + + it('delivers a pushed event to a pending next()', async () => { + const endings = createPrintTurnEndings(); + const pending = endings.next(1000, 1); + endings.push(ending(3)); + await expect(pending).resolves.toMatchObject({ turnId: 3 }); + }); + + it('resolves null when the remaining time elapses', async () => { + const endings = createPrintTurnEndings(); + await expect(endings.next(5, 1)).resolves.toBeNull(); + }); + + it('keeps waiting when only the skipped turn ends', async () => { + const endings = createPrintTurnEndings(); + const pending = endings.next(1000, 1); + endings.push(ending(1)); + endings.push(ending(4)); + await expect(pending).resolves.toMatchObject({ turnId: 4 }); + }); +}); diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts new file mode 100644 index 000000000..300e3afd6 --- /dev/null +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -0,0 +1,1770 @@ +/** + * Tests for `kimi server run` and `kimi web` Commander wiring. + * + * These tests don't actually start the server — they verify the parsed shape + * (option flags, --open default) and that the `web` alias defers to the same + * underlying handler with `defaultOpen` flipped to true. + * + * Foreground startup behavior is exercised end-to-end in `server-e2e/`. + */ + +import type { ChildProcess } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer, type Server } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +import chalk, { Chalk } from 'chalk'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerServerCommand } from '#/cli/sub/server'; +import { addLifecycleCommands } from '#/cli/sub/server/lifecycle'; +import type { KillCommandDeps } from '#/cli/sub/server/kill'; +import { darkColors } from '#/tui/theme/colors'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:child_process')>(); + return { ...actual, spawn: vi.fn() }; +}); + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function makeProgram(): Command { + // `commander` exitOverride avoids killing the test runner when --help/error fires. + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + return program; +} + +describe('kimi server', () => { + it('registers the expected `server` subcommands while lifecycle commands are hidden', () => { + const program = makeProgram(); + const server = program.commands.find((c) => c.name() === 'server'); + expect(server).toBeDefined(); + const subs = server?.commands.map((c) => c.name()).toSorted(); + expect(subs).toEqual(['kill', 'ps', 'rotate-token', 'run']); + }); + + it('`server run` exposes local-only foreground options', () => { + const program = makeProgram(); + const run = program.commands + .find((c) => c.name() === 'server') + ?.commands.find((c) => c.name() === 'run'); + expect(run).toBeDefined(); + const longs = run!.options.map((o) => o.long).filter(Boolean); + expect(longs).toContain('--host'); + expect(longs).toContain('--port'); + expect(longs).toContain('--log-level'); + expect(longs).toContain('--debug-endpoints'); + expect(longs).toContain('--insecure-no-tls'); + expect(longs).toContain('--allow-remote-shutdown'); + expect(longs).toContain('--allow-remote-terminals'); + expect(longs).toContain('--foreground'); + // run defaults to NOT opening the browser → option is the positive --open + expect(longs).toContain('--open'); + }); + + it('`server install` exposes local-only service options', () => { + // Lifecycle commands are no longer registered via `registerServerCommand`, + // but the builder still lives in `./lifecycle` — exercise it directly. + const server = new Command('server'); + addLifecycleCommands(server); + const install = server.commands.find((c) => c.name() === 'install'); + expect(install).toBeDefined(); + const longs = install!.options.map((o) => o.long).filter(Boolean); + expect(longs).not.toContain('--host'); + expect(longs).toContain('--port'); + expect(longs).toContain('--log-level'); + expect(longs).toContain('--force'); + expect(longs).toContain('--no-open'); + expect(longs).toContain('--json'); + }); + + it('the top-level `kimi web` alias is registered and defaults to opening the browser', () => { + const program = makeProgram(); + const web = program.commands.find((c) => c.name() === 'web'); + expect(web).toBeDefined(); + const longs = web!.options.map((o) => o.long).filter(Boolean); + // web defaults to opening → the option is the negative form --no-open + expect(longs).toContain('--no-open'); + expect(longs).toContain('--host'); + expect(longs).toContain('--port'); + }); +}); + +describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported platforms', () => { + it('the dispatcher returns a friendly error manager for unknown platforms', async () => { + // darwin / linux / win32 have real backends (launchd / systemd / schtasks). + // The remaining platforms fall through to the stub that throws + // `ServiceUnsupportedError` — pin that contract so a future addition + // (freebsd, etc.) needs a deliberate decision instead of silently working. + const { resolveServiceManager, ServiceUnsupportedError } = await import('@moonshot-ai/kap-server'); + const mgr = resolveServiceManager('freebsd'); + await expect( + mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }), + ).rejects.toBeInstanceOf(ServiceUnsupportedError); + await expect(mgr.status()).rejects.toBeInstanceOf(ServiceUnsupportedError); + }); +}); + +describe('`kimi server` lifecycle handles unavailable service managers', () => { + it('prints a friendly JSON error and exits 2', async () => { + const { ServiceUnavailableError } = await import('@moonshot-ai/kap-server'); + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + let stderr = ''; + const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { + throw new Error(`process.exit(${String(code)})`); + }) as typeof process.exit); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async () => { + throw new ServiceUnavailableError( + 'linux', + 'systemd --user is not available in this environment.', + ); + }, + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'unused' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ platform: 'linux', installed: false, running: false }), + }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + stderr += String(chunk); + return true; + }, + }, + }); + + await expect( + program.parseAsync(['node', 'kimi', 'server', 'install', '--json']), + ).rejects.toThrow('process.exit(2)'); + + exit.mockRestore(); + expect(stderr).toBe(''); + expect(JSON.parse(stdout)).toMatchObject({ + ok: false, + action: 'unavailable', + platform: 'linux', + message: expect.stringContaining('server run --port <port>'), + }); + }); +}); + +describe('`kimi server` lifecycle output', () => { + it('install passes --force/--port, prints the URL, and opens it when running', async () => { + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + let stderr = ''; + let installArgs: unknown; + const openUrl = vi.fn(); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async (args) => { + installArgs = args; + return { + status: 'replaced', + message: 'Kimi server LaunchAgent replaced at /tmp/kimi.plist (port 9999).', + plistPath: '/tmp/kimi.plist', + }; + }, + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'unused' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ + platform: 'darwin', + installed: true, + running: true, + host: '127.0.0.1', + port: 9999, + logPath: '/tmp/server.log', + label: 'ai.moonshot.kimi-server', + }), + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + stderr += String(chunk); + return true; + }, + }, + }); + + await program.parseAsync([ + 'node', + 'kimi', + 'server', + 'install', + '--force', + '--port', + '9999', + ]); + + expect(stderr).toBe(''); + expect(installArgs).toMatchObject({ port: 9999, force: true }); + expect(stdout).toContain('URL: http://127.0.0.1:9999'); + expect(stdout).toContain('Status: running'); + expect(stdout).toContain('Log: /tmp/server.log'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:9999'); + }); + + it('start prints URL and diagnostics when launchd did not keep the service running', async () => { + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + const openUrl = vi.fn(); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async () => ({ status: 'installed', message: 'unused' }), + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'Kimi server started (ai.moonshot.kimi-server).' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ + platform: 'darwin', + installed: true, + running: false, + host: '127.0.0.1', + port: 58627, + logPath: '/tmp/server.log', + label: 'ai.moonshot.kimi-server', + notes: ['launchd state: spawn scheduled', 'last exit code: 78 EX_CONFIG'], + }), + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }); + + await program.parseAsync(['node', 'kimi', 'server', 'start']); + + expect(stdout).toContain('URL: http://127.0.0.1:58627'); + expect(stdout).toContain('Status: not running'); + expect(stdout).toContain('launchd state: spawn scheduled'); + expect(stdout).toContain('last exit code: 78 EX_CONFIG'); + expect(openUrl).not.toHaveBeenCalled(); + }); +}); + +describe('`kimi server run` background start', () => { + it('defaults the daemon log level to silent', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(parsed).toMatchObject({ logLevel: 'silent' }); + }); + + it('passes --log-level through to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', logLevel: 'debug' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(parsed).toMatchObject({ logLevel: 'debug' }); + }); + + it('warns and uses the running server host when a daemon is reused', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + // The user asks for a public bind, but a loopback daemon is already up. + await handleRunCommand( + { port: '58627', host: '0.0.0.0' }, + { + startServerBackground: async () => ({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + }), + resolveToken: () => 'tok', + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const plain = stripAnsi(stdout); + // A clear notice that a server was already running and options were ignored. + expect(plain).toContain('A server is already running'); + expect(plain).toContain('kimi server kill'); + // The banner uses the *actual* host (loopback), not the requested 0.0.0.0 — + // so it shows a Local URL plus the "network disabled" hint, NOT real + // Network addresses (which would be misleading since nothing binds them). + expect(plain).toContain('http://127.0.0.1:58627/#token=tok'); + expect(plain).toContain('use --host to enable'); + expect(plain).not.toContain('Network: http'); + }); + + it('keeps the token and skips the bypass notice when a daemon is reused', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + // The user requests bypass, but a daemon is already running — so the + // requested flag is NOT applied to the server actually serving requests. + await handleRunCommand( + { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true, open: true }, + { + startServerBackground: async () => ({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + }), + resolveToken: () => 'tok', + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const plain = stripAnsi(stdout); + // No false "bypass" claim for a server whose real auth mode is unknown. + expect(plain).not.toContain('DANGER'); + // The token is preserved so the browser can auto-authenticate to the + // reused (token-protected) daemon. + expect(plain).toContain('#token=tok'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok'); + }); + + it('prints a TUI-style ready panel once the daemon is up', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { port: '58627', host: '127.0.0.1' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('Kimi server ready'); + expect(plain).toContain('Local:'); + expect(plain).toContain('http://127.0.0.1:58627/'); + // Loopback bind shows a Network hint for enabling network access. + expect(plain).toContain('Network:'); + expect(plain).toContain('use --host to enable'); + expect(plain).toContain('Logs:'); + expect(plain).toContain('off'); + expect(plain).toContain('Stop:'); + expect(plain).toContain('kimi server kill'); + // Version sits on the title line; no separate Ready:/Version: rows and no + // startup-time metric. + expect(plain).not.toContain('Ready:'); + expect(plain).not.toContain('Version:'); + expect(plain).not.toContain(' ms'); + // No bordered panel (the token URL must print in full for copying), but + // the Kimi sprite stays next to the title. + expect(plain).not.toContain('╭'); + expect(plain).not.toContain('╰'); + expect(plain).toContain('▐█▛█▛█▌'); + expect(plain).toContain('▐█████▌'); + expect(plain).not.toContain('➜'); + expect(plain).not.toContain('Kimi server:'); + + // Title is above the URLs; Logs/Stop are at the bottom. + expect(plain.indexOf('Kimi server ready')).toBeLessThan(plain.indexOf('Local:')); + expect(plain.indexOf('Logs:')).toBeLessThan(plain.indexOf('Stop:')); + }); + + it('uses the TUI dark palette for the ready banner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + await handleRunCommand( + { port: '58627', host: '127.0.0.1' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + } finally { + chalk.level = previousChalkLevel; + } + + const color = new Chalk({ level: 3 }); + expect(stdout).toContain(color.hex(darkColors.primary)('▐█▛█▛█▌')); + expect(stdout).toContain(color.bold.hex(darkColors.primary)('Kimi server ready')); + expect(stdout).toContain(color.hex(darkColors.accent)('http://127.0.0.1:58627/')); + expect(stdout).toContain(color.bold.hex(darkColors.textDim)('Local: ')); + expect(stdout).toContain(color.hex(darkColors.textMuted)('off')); + }); + + it('prints a red danger notice and suppresses the token when auth is bypassed', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + await handleRunCommand( + { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true, open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => 'tok', + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + const plain = stripAnsi(stdout); + // Red, impossible-to-miss danger notice. + expect(plain).toContain('DANGER: authentication is DISABLED'); + expect(plain).toContain('--dangerous-bypass-auth'); + expect(plain).toContain('kimi server kill'); + // The token is irrelevant when bypassed — neither printed nor carried in + // any URL (so it cannot leak via copy/paste of the banner). + expect(plain).not.toContain('tok'); + expect(plain).not.toContain('#token='); + // The opened browser URL carries no token fragment either. + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); + + it('renders the bypass danger notice in the error color', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + await handleRunCommand( + { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + } finally { + chalk.level = previousChalkLevel; + } + + const color = new Chalk({ level: 3 }); + expect(stdout).toContain( + color.bold.hex(darkColors.error)('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).'), + ); + }); +}); + +describe('`kimi server run --foreground`', () => { + it('runs the server in-process instead of spawning a background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + let backgroundCalled = false; + + await handleRunCommand( + { port: '58627', foreground: true }, + { + startServerBackground: async () => { + backgroundCalled = true; + return { origin: 'http://127.0.0.1:58627' }; + }, + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(backgroundCalled).toBe(false); + expect(foregroundOptions).toMatchObject({ port: 58627, logLevel: 'silent' }); + }); + + it('prints the ready banner and opens the browser once listening', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + await handleRunCommand( + { port: '58627', host: '127.0.0.1', foreground: true, open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + startServerForeground: async (options, hooks) => { + void options; + hooks?.onReady?.('http://127.0.0.1:58627'); + return undefined as unknown as never; + }, + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('Kimi server ready'); + expect(plain).toContain('http://127.0.0.1:58627/'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); +}); + +describe('`kimi server` does not register a legacy `daemon` command', () => { + it('hard-deletes the old name', () => { + const program = makeProgram(); + const daemon = program.commands.find((c) => c.name() === 'daemon'); + expect(daemon).toBeUndefined(); + }); +}); + +describe('shared parsers stay strict', () => { + it('rejects out-of-range --port', async () => { + const { parsePort } = await import('#/cli/sub/server/shared'); + expect(() => parsePort('99999', '--port', 58627)).toThrow(/invalid --port/); + expect(() => parsePort('-1', '--port', 58627)).toThrow(/invalid --port/); + expect(parsePort(undefined, '--port', 58627)).toBe(58627); + expect(parsePort('8080', '--port', 58627)).toBe(8080); + }); + + it('rejects unknown --log-level values', async () => { + const { parseLogLevel } = await import('#/cli/sub/server/shared'); + expect(() => parseLogLevel('shout')).toThrow(/invalid --log-level/); + expect(parseLogLevel(undefined)).toBe('info'); + expect(parseLogLevel('debug')).toBe('debug'); + }); +}); + +describe('server web asset directory resolution', () => { + it('uses extracted SEA web assets when available', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); + expect(resolveServerWebAssetsDir('/cache/kimi/dist-web')).toBe('/cache/kimi/dist-web'); + }); + + it('falls back to package dist-web outside SEA mode', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); + expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/); + }); +}); + +function listenOnce(host: string, port: number): Promise<Server> { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen({ host, port }, () => { + resolve(server); + }); + }); +} + +function closeServer(server: Server): Promise<void> { + return new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); +} + +async function allocateFreePort(host = '127.0.0.1'): Promise<number> { + const server = await listenOnce(host, 0); + const address = server.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + await closeServer(server); + return port; +} + +/** + * Find the start of a run of `count` consecutive free ports + * (`start`, `start + 1`, …, `start + count - 1` all bindable). + */ +async function allocateAdjacentFreeRun(count: number, host = '127.0.0.1'): Promise<number> { + for (let i = 0; i < 50; i++) { + const start = await allocateFreePort(host); + if (start <= 0 || start + count - 1 > 65535) continue; + const held: Server[] = []; + let ok = true; + for (let offset = 1; offset < count; offset++) { + const probe = await listenOnce(host, start + offset).catch(() => null); + if (probe === null) { + ok = false; + break; + } + held.push(probe); + } + for (const server of held) await closeServer(server); + if (ok) return start; + } + throw new Error('could not allocate a run of adjacent free ports'); +} + +describe('--host threading (M6.2)', () => { + it('passes --host through to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', host: '0.0.0.0' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ host: '0.0.0.0', port: 58627 }); + }); + + it('passes --host through to the foreground runner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { port: '58627', host: '0.0.0.0', foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ host: '0.0.0.0' }); + }); +}); + +describe('default bind (M6.3)', () => { + it('defaults host to 127.0.0.1 and insecureNoTls to true when no flags are passed', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ host: '127.0.0.1', insecureNoTls: true }); + }); + + it('treats a bare --host as the default LAN host', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', host: true }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ host: '0.0.0.0', insecureNoTls: true }); + }); +}); + +describe('--allowed-host threading', () => { + it('parses comma-separated --allowed-host values', async () => { + const { parseAllowedHostArgs } = await import('#/cli/sub/server/shared'); + expect(parseAllowedHostArgs(['.example.com, app.example.com'])).toEqual([ + '.example.com', + 'app.example.com', + ]); + }); + + it('threads --allowed-host to the background daemon options', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', allowedHost: ['.example.com'] }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ allowedHosts: ['.example.com'] }); + }); +}); + +describe('--keep-alive (no 60s idle-kill)', () => { + it('defaults to off for the plain loopback daemon', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({}).keepAlive).toBe(false); + }); + + it('is implied by a bare --host (default LAN host)', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({ host: true }).keepAlive).toBe(true); + }); + + it('is implied by an explicit --host value', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({ host: '0.0.0.0' }).keepAlive).toBe(true); + expect(parseServerOptions({ host: '192.168.1.5' }).keepAlive).toBe(true); + }); + + it('stays off for an explicit loopback --host with no allowed-hosts', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({ host: '127.0.0.1' }).keepAlive).toBe(false); + }); + + it('is implied by --allowed-host (proxy/tunnel)', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({ allowedHost: ['.example.com'] }).keepAlive).toBe(true); + }); + + it('can be set explicitly on a loopback daemon', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({ keepAlive: true }).keepAlive).toBe(true); + }); + + it('is forced on in --foreground mode even on the default loopback host', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { port: '58627', foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ keepAlive: true }); + }); + + it('threads keepAlive to the foreground runner when implied by --host', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { port: '58627', host: '0.0.0.0', foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ keepAlive: true }); + }); +}); + +describe('lockConnectHost (M6.2 connect side)', () => { + it('maps a 0.0.0.0 bind to 127.0.0.1 so the CLI connects over loopback', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + // The daemon binds 0.0.0.0 (all interfaces), but the local CLI must + // connect over loopback — 0.0.0.0 is not a connectable address. The token + // then rides on that loopback connection (covered by the M5.4 kill/ps + // Authorization tests). + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '0.0.0.0' })).toBe( + '127.0.0.1', + ); + }); + + it('preserves a loopback / concrete bind host', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '127.0.0.1' })).toBe( + '127.0.0.1', + ); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '192.168.1.5' })).toBe( + '192.168.1.5', + ); + }); + + it('falls back to 127.0.0.1 when the lock has no host', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627 })).toBe('127.0.0.1'); + }); +}); + +describe('--insecure-no-tls threading (M6.3)', () => { + it('threads --insecure-no-tls to the foreground runner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true, foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ host: '0.0.0.0', insecureNoTls: true }); + }); + + it('threads --insecure-no-tls to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ insecureNoTls: true }); + }); +}); + +describe('ready banner reflects the bind class (M6.3)', () => { + it('lists Local + Network addresses for a 0.0.0.0 bind (Vite-style)', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + resolveToken: () => 'tok-xyz', + networkAddresses: [ + { address: '192.168.98.66', family: 'IPv4' }, + { address: '10.8.12.216', family: 'IPv4' }, + ], + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const raw = stripAnsi(stdout); + expect(raw).toContain('Kimi server ready'); + expect(raw).toContain('Local:'); + expect(raw).toContain('Network:'); + // Full token-bearing URLs are printed plainly (no box, no truncation) so + // they are easy to copy. + expect(raw).toContain('http://localhost:58627/#token=tok-xyz'); + expect(raw).toContain('http://192.168.98.66:58627/#token=tok-xyz'); + expect(raw).toContain('http://10.8.12.216:58627/#token=tok-xyz'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-xyz'); + expect(raw).not.toContain('╭'); + }); + + it('prints the Local URL and token for a 127.0.0.1 bind', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { host: '127.0.0.1' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => 'tok-loop', + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const raw = stripAnsi(stdout); + expect(raw).toContain('Kimi server ready'); + expect(raw).toContain('Local:'); + // Full token-bearing URL, printed plainly for copying. + expect(raw).toContain('http://127.0.0.1:58627/#token=tok-loop'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-loop'); + expect(raw).not.toContain('╭'); + }); +}); + +describe('resolveDaemonPort', () => { + it('returns the preferred port when it is free', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const free = await allocateFreePort(); + await expect(resolveDaemonPort('127.0.0.1', free)).resolves.toBe(free); + }); + + it('falls back to a different free port when the preferred port is busy', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const busy = await allocateFreePort(); + const holder = await listenOnce('127.0.0.1', busy); + try { + const port = await resolveDaemonPort('127.0.0.1', busy); + expect(port).not.toBe(busy); + expect(port).toBeGreaterThan(0); + } finally { + await closeServer(holder); + } + }); + + it('walks to preferred+1 when only the preferred port is busy', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const start = await allocateAdjacentFreeRun(2); + const holder = await listenOnce('127.0.0.1', start); + try { + const port = await resolveDaemonPort('127.0.0.1', start); + expect(port).toBe(start + 1); + } finally { + await closeServer(holder); + } + }); + + it('skips past a run of busy ports to the first free one', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const start = await allocateAdjacentFreeRun(3); + // Hold both `start` and `start+1`; the resolver should land on `start+2`. + const holderA = await listenOnce('127.0.0.1', start); + const holderB = await listenOnce('127.0.0.1', start + 1); + try { + const port = await resolveDaemonPort('127.0.0.1', start); + expect(port).toBe(start + 2); + } finally { + await closeServer(holderA); + await closeServer(holderB); + } + }); +}); + +describe('resolveDaemonProgram', () => { + it('uses the absolute script path outside SEA mode', async () => { + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['node', '/opt/kimi/dist/cli.mjs'], '/tmp', '/usr/bin/node', false)).toBe('/opt/kimi/dist/cli.mjs'); + }); + + it('normalizes a relative executable path against cwd outside SEA mode', async () => { + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['node', './kimi'], '/tmp/kimi-bin', '/usr/bin/node', false)).toBe(resolve('/tmp/kimi-bin', './kimi')); + }); + + it('returns execPath in SEA mode when argv[1] is a bare command name', async () => { + // Reproduces `kimi web` from the shell: argv[1] is the invoked command + // name (`kimi`), not a path. Resolving it against cwd produced `<cwd>/kimi` + // and crashed the spawn with ENOENT. + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['/Users/x/.kimi-code/bin/kimi', 'kimi', 'web'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); + }); + + it('returns execPath in SEA mode for a spawned `server` child', async () => { + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['/Users/x/.kimi-code/bin/kimi', 'server', 'run'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); + }); +}); + +describe('spawnDaemonChild', () => { + let workDir: string; + let prevHome: string | undefined; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-cwd-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = workDir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(workDir, { recursive: true, force: true }); + }); + + it('spawns the daemon with cwd set to the server log directory', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild, daemonLogPath } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ port: 58627, logLevel: 'info' }); + + expect(spawnMock).toHaveBeenCalledOnce(); + const [program, args, options] = spawnMock.mock.calls[0]!; + expect(program).toBeTruthy(); + expect(args).toEqual(expect.arrayContaining(['server', 'run', '--daemon'])); + expect(options).toMatchObject({ detached: true, cwd: dirname(daemonLogPath()) }); + expect(options?.cwd).not.toBe(process.cwd()); + }); + + it('passes --host through to the daemon child args (M6.2)', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info' }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--host', '0.0.0.0'])); + }); + + it('passes --insecure-no-tls through to the daemon child args (M6.3)', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info', insecureNoTls: true }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--insecure-no-tls'])); + }); + + it('passes --allowed-host through to the daemon child args', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ port: 58627, logLevel: 'info', allowedHosts: ['.example.com'] }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--allowed-host', '.example.com'])); + }); + + it('passes --keep-alive through to the daemon child args', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ port: 58627, logLevel: 'info', keepAlive: true }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--keep-alive'])); + }); +}); + +describe('ensureDaemon surfaces boot failures via early exit', () => { + let workDir: string; + let prevHome: string | undefined; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-ensure-exit-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = workDir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(workDir, { recursive: true, force: true }); + }); + + it('rejects fast with the exit reason and a log tail when the daemon exits early', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); + const { daemonLogPath, ensureDaemon } = await import('#/cli/sub/server/daemon'); + + // Seed the daemon log with the kind of line a failing boot writes, so we + // can assert it is surfaced to the user instead of a generic timeout. + mkdirSync(dirname(daemonLogPath()), { recursive: true }); + writeSync(daemonLogPath(), 'fatal: Refusing to bind a non-loopback host without TLS.\n'); + + // Fake child that exits with code 1 shortly after the 'exit' listener is + // attached — simulating a daemon that fails during boot. + const fakeChild = { + unref: vi.fn(), + once: vi.fn((event: string, cb: (...a: unknown[]) => void) => { + if (event === 'exit') { + setTimeout(() => { + cb(1, null); + }, 5); + } + return fakeChild; + }), + }; + spawnMock.mockReturnValueOnce(fakeChild as unknown as ChildProcess); + + const start = Date.now(); + let caught: unknown; + try { + await ensureDaemon({ port: 0 }); + } catch (error) { + caught = error; + } + const elapsed = Date.now() - start; + + expect(caught).toBeInstanceOf(Error); + const message = (caught as Error).message; + expect(message).toMatch(/exited with code 1/); + expect(message).toContain('Refusing to bind'); + // Must fail fast — nowhere near the 20s spawn timeout. + expect(elapsed).toBeLessThan(5000); + }); +}); + +describe('createIdleShutdownHandler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not arm before any client connects', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); + handler.onConnectionCountChange(0); + vi.advanceTimersByTime(2000); + expect(onIdle).not.toHaveBeenCalled(); + }); + + it('fires onIdle after the grace once the last client leaves', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); + handler.onConnectionCountChange(1); + handler.onConnectionCountChange(0); + vi.advanceTimersByTime(999); + expect(onIdle).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending exit when a client reconnects during the grace', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); + handler.onConnectionCountChange(1); + handler.onConnectionCountChange(0); + vi.advanceTimersByTime(500); + handler.onConnectionCountChange(1); // reconnect + vi.advanceTimersByTime(2000); + expect(onIdle).not.toHaveBeenCalled(); + }); + + it('only the final drop to zero arms the timer with multiple clients', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 500, onIdle }); + handler.onConnectionCountChange(1); + handler.onConnectionCountChange(2); + handler.onConnectionCountChange(1); // still one connected + vi.advanceTimersByTime(1000); + expect(onIdle).not.toHaveBeenCalled(); + handler.onConnectionCountChange(0); // now none + vi.advanceTimersByTime(500); + expect(onIdle).toHaveBeenCalledTimes(1); + }); +}); + +describe('kimi web (shares `server run` call stack)', () => { + it('prints the ready banner and opens the browser by default', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(stripAnsi(stdout)).toContain('Kimi server ready'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); + + it('does not open the browser when open is false', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:9000' }), + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it('rejects an invalid --log-level before touching the daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const startServerBackground = vi.fn(); + await expect( + handleRunCommand( + { logLevel: 'shout' }, + { + startServerBackground, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ), + ).rejects.toThrow(/invalid --log-level/); + expect(startServerBackground).not.toHaveBeenCalled(); + }); +}); + +function makeKillDeps(overrides: Partial<KillCommandDeps> = {}): { + deps: KillCommandDeps; + writes: string[]; + signals: Array<{ pid: number; signal: NodeJS.Signals }>; + state: { shutdownCalls: number }; + clock: { t: number }; +} { + const writes: string[] = []; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const state = { shutdownCalls: 0 }; + const clock = { t: 0 }; + const deps: KillCommandDeps = { + getLiveLock: () => undefined, + requestShutdown: async () => { + state.shutdownCalls += 1; + }, + resolveToken: () => undefined, + signalPid: (pid, signal) => { + signals.push({ pid, signal }); + return true; + }, + pidAlive: () => false, + sleep: async (ms) => { + clock.t += ms; + }, + stdout: { + write(chunk: string | Uint8Array) { + writes.push(String(chunk)); + return true; + }, + }, + now: () => clock.t, + ...overrides, + }; + return { deps, writes, signals, state, clock }; +} + +describe('`kimi server kill`', () => { + const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 }; + + it('prints "No running Kimi server." and sends no signal when no live lock exists', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps, writes, signals } = makeKillDeps({ getLiveLock: () => undefined }); + + await handleKillCommand(deps); + + expect(writes.join('')).toContain('No running Kimi server.'); + expect(signals).toEqual([]); + }); + + it('attempts the API shutdown, then stops after SIGTERM when the pid exits promptly', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps, writes, signals, state, clock } = makeKillDeps({ + getLiveLock: () => liveLock, + pidAlive: () => clock.t < 50, + }); + + await handleKillCommand(deps); + + expect(state.shutdownCalls).toBe(1); + expect(signals).toEqual([{ pid: 1234, signal: 'SIGTERM' }]); + expect(writes.join('')).toContain('pid 1234'); + expect(writes.join('')).toContain('stopped.'); + }); + + it('escalates to SIGKILL when the pid survives SIGTERM', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps, writes, signals, clock } = makeKillDeps({ + getLiveLock: () => ({ ...liveLock, pid: 5678 }), + // Survives the 3s SIGTERM grace, dies during the 2s SIGKILL grace. + pidAlive: () => clock.t < 3100, + }); + + await handleKillCommand(deps); + + expect(signals.map((s) => s.signal)).toEqual(['SIGTERM', 'SIGKILL']); + expect(writes.join('')).toContain('pid 5678'); + expect(writes.join('')).toContain('killed.'); + }); + + it('throws a permissions error when the pid survives SIGKILL', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps } = makeKillDeps({ + getLiveLock: () => ({ ...liveLock, pid: 9999 }), + pidAlive: () => true, + }); + + await expect(handleKillCommand(deps)).rejects.toThrow(/insufficient permissions/); + }); +}); + +describe('resolveServerToken', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-server-token-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads the token from <homeDir>/server.token', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + writeFileSync(join(dir, 'server.token'), 'secret-token\n'); + expect(resolveServerToken(dir)).toBe('secret-token'); + }); + + it('trims surrounding whitespace', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + writeFileSync(join(dir, 'server.token'), ' tok \n'); + expect(resolveServerToken(dir)).toBe('tok'); + }); + + it('throws a clear error when the token file is missing', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + expect(() => resolveServerToken(dir)).toThrow(/unable to read server token/); + }); +}); + +describe('authHeaders', () => { + it('builds a Bearer Authorization header', async () => { + const { authHeaders } = await import('#/cli/sub/server/shared'); + expect(authHeaders('abc')).toEqual({ Authorization: 'Bearer abc' }); + }); +}); + +describe('`kimi server kill` carries the bearer token', () => { + const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 }; + + it('passes the resolved token to requestShutdown', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + let seenToken: string | undefined = 'unset'; + const { deps } = makeKillDeps({ + getLiveLock: () => liveLock, + resolveToken: () => 'tok-123', + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => false, + }); + + await handleKillCommand(deps); + + expect(seenToken).toBe('tok-123'); + }); + + it('passes undefined when the token cannot be read (best-effort)', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + let seenToken: string | undefined = 'unset'; + const { deps } = makeKillDeps({ + getLiveLock: () => liveLock, + resolveToken: () => undefined, + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => false, + }); + + await handleKillCommand(deps); + + expect(seenToken).toBeUndefined(); + }); +}); + +describe('buildWebUrl', () => { + it('carries the token in the URL fragment (not path or query)', async () => { + const { buildWebUrl } = await import('#/cli/sub/server/run'); + const url = buildWebUrl('http://127.0.0.1:58627', 'abc123'); + expect(url).toBe('http://127.0.0.1:58627/#token=abc123'); + const parsed = new URL(url); + expect(parsed.hash).toBe('#token=abc123'); + // The token is client-side only: it must NOT appear in the path or query + // (which WOULD be sent to the server and logged). + expect(parsed.pathname).not.toContain('abc123'); + expect(parsed.search).not.toContain('abc123'); + }); + + it('normalizes a trailing slash', async () => { + const { buildWebUrl } = await import('#/cli/sub/server/run'); + expect(buildWebUrl('http://127.0.0.1:58627/', 't')).toBe( + 'http://127.0.0.1:58627/#token=t', + ); + }); +}); + +describe('accessUrlLines', () => { + it('returns Local + Network lines for a wildcard bind', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('0.0.0.0', 58627, 'tok', [ + { address: '192.168.1.5', family: 'IPv4' }, + ]); + expect(lines).toEqual([ + { label: 'Local: ', url: 'http://localhost:58627/#token=tok' }, + { label: 'Network: ', url: 'http://192.168.1.5:58627/#token=tok' }, + ]); + }); + + it('returns a single Local line for a loopback bind', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('127.0.0.1', 58627, 'tok'); + expect(lines).toEqual([ + { label: 'Local: ', url: 'http://127.0.0.1:58627/#token=tok' }, + ]); + }); + + it('returns a single URL line for a specific host (no token)', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('192.168.1.5', 58627, undefined); + expect(lines).toEqual([{ label: 'URL: ', url: 'http://192.168.1.5:58627/' }]); + }); + + it('splitTokenFragment splits off the #token= fragment', async () => { + const { splitTokenFragment } = await import('#/cli/sub/server/access-urls'); + expect(splitTokenFragment('http://h:1/#token=abc')).toEqual(['http://h:1/', '#token=abc']); + expect(splitTokenFragment('http://h:1/')).toEqual(['http://h:1/', '']); + }); +}); + +describe('`kimi web` / `server run --open` token fragment (M5.5)', () => { + it('opens the Web UI URL with the token fragment when a token is resolvable', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => 'tok-xyz', + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok-xyz'); + }); + + it('opens the plain origin when no token is resolvable', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => undefined, + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); +}); + +describe('`kimi server rotate-token`', () => { + let dir: string; + let prevHome: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-rotate-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = dir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(dir, { recursive: true, force: true }); + }); + + it('writes a new token to server.token and prints it', async () => { + const { registerServerCommand } = await import('#/cli/sub/server'); + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']); + writeSpy.mockRestore(); + + const token = readFileSync(join(dir, 'server.token'), 'utf8').trim(); + expect(token.length).toBeGreaterThan(20); + expect(stdout).toContain('New server token'); + expect(stdout).toContain(token); + }); + + it('re-prints the access links with the new token when a server is running', async () => { + const { registerServerCommand } = await import('#/cli/sub/server'); + const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); + // Fake a live lock pointing at this (alive) process so getLiveLock() finds + // the running server and the command can re-print its links. + mkdirSync(join(dir, 'server'), { recursive: true }); + writeSync( + join(dir, 'server', 'lock'), + JSON.stringify({ + pid: process.pid, + started_at: new Date().toISOString(), + port: 58627, + host: '127.0.0.1', + }), + ); + + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']); + writeSpy.mockRestore(); + + const token = readFileSync(join(dir, 'server.token'), 'utf8').trim(); + expect(stdout).toContain('New server token'); + expect(stdout).toContain(`http://127.0.0.1:58627/#token=${token}`); + // Token line sits between the note and the links. + expect(stdout.indexOf('picks up the new token')).toBeLessThan( + stdout.indexOf('New server token'), + ); + expect(stdout.indexOf('New server token')).toBeLessThan( + stdout.indexOf(`http://127.0.0.1:58627/#token=${token}`), + ); + }); +}); + +describe('formatHostForUrl', () => { + it('bracket-wraps IPv6 and leaves IPv4 as-is', async () => { + const { formatHostForUrl } = await import('#/cli/sub/server/networks'); + expect(formatHostForUrl('192.168.1.5', 'IPv4')).toBe('192.168.1.5'); + expect(formatHostForUrl('fe80::1', 'IPv6')).toBe('[fe80::1]'); + }); +}); + +describe('filterDisplayAddresses', () => { + it('drops IPv6 link-local, de-duplicates, and orders IPv4 before IPv6', async () => { + const { filterDisplayAddresses } = await import('#/cli/sub/server/networks'); + const out = filterDisplayAddresses([ + { address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' }, + { address: '192.168.1.5', family: 'IPv4' }, + { address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' }, + { address: '10.0.0.1', family: 'IPv4' }, + { address: 'fe80::1', family: 'IPv6' }, + { address: '2001:db8::1', family: 'IPv6' }, + ]); + expect(out).toEqual([ + { address: '192.168.1.5', family: 'IPv4' }, + { address: '10.0.0.1', family: 'IPv4' }, + { address: '2001:db8::1', family: 'IPv6' }, + ]); + }); +}); + +// Silence vi import for cases where the file is built before tests reference vi. +void vi; diff --git a/apps/kimi-code/test/cli/telemetry.test.ts b/apps/kimi-code/test/cli/telemetry.test.ts new file mode 100644 index 000000000..8b0655862 --- /dev/null +++ b/apps/kimi-code/test/cli/telemetry.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for the CLI telemetry bootstrap helpers, focusing on the + * `kimi web` / `kimi server run` host wiring added in `cli/telemetry.ts`. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + initializeTelemetry: vi.fn(), + createKimiDeviceId: vi.fn(() => 'device-123'), + resolveKimiHome: vi.fn(() => '/home/.kimi-code'), + resolveConfigPath: vi.fn(() => '/home/.kimi-code/config.toml'), + loadRuntimeConfigSafe: vi.fn( + (): { + config: { defaultModel?: string; telemetry?: boolean }; + fileError: Error | undefined; + } => ({ + config: { defaultModel: 'kimi-k2', telemetry: true }, + fileError: undefined, + }), + ), + getCachedAccessToken: vi.fn(async () => 'tok'), +})); + +vi.mock('@moonshot-ai/kimi-telemetry', () => ({ + initializeTelemetry: mocks.initializeTelemetry, + setTelemetryContext: vi.fn(), + track: vi.fn(), + withTelemetryContext: vi.fn(), +})); + +vi.mock('@moonshot-ai/kimi-code-oauth', () => ({ + createKimiDeviceId: mocks.createKimiDeviceId, + KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', +})); + +vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { + const actual = await importOriginal<typeof import('@moonshot-ai/kimi-code-sdk')>(); + return { + ...actual, + resolveKimiHome: mocks.resolveKimiHome, + resolveConfigPath: mocks.resolveConfigPath, + loadRuntimeConfigSafe: mocks.loadRuntimeConfigSafe, + KimiAuthFacade: vi.fn(function () { + return { getCachedAccessToken: mocks.getCachedAccessToken }; + }), + }; +}); + +describe('initializeServerTelemetry', () => { + beforeEach(() => { + mocks.initializeTelemetry.mockClear(); + mocks.loadRuntimeConfigSafe.mockClear(); + mocks.loadRuntimeConfigSafe.mockReturnValue({ + config: { defaultModel: 'kimi-k2', telemetry: true }, + fileError: undefined, + }); + }); + + it('configures the sink with ui_mode="web" and the CLI product identity', async () => { + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + const client = initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ + appName: 'kimi-code-cli', + version: '1.2.3', + uiMode: 'web', + model: 'kimi-k2', + enabled: true, + deviceId: 'device-123', + homeDir: '/home/.kimi-code', + }), + ); + // The returned client wraps the module functions so core + the host share + // the same underlying client. + expect(client).toEqual( + expect.objectContaining({ + track: expect.any(Function), + withContext: expect.any(Function), + setContext: expect.any(Function), + }), + ); + }); + + it('disables telemetry when config.toml sets telemetry = false', async () => { + mocks.loadRuntimeConfigSafe.mockReturnValue({ + config: { defaultModel: 'kimi-k2', telemetry: false }, + fileError: undefined, + }); + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('degrades to enabled with no model when config is unreadable', async () => { + mocks.loadRuntimeConfigSafe.mockReturnValue({ + config: {}, + fileError: new Error('bad toml'), + }); + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true, model: undefined }), + ); + }); +}); diff --git a/apps/kimi-code/test/cli/update/cache.test.ts b/apps/kimi-code/test/cli/update/cache.test.ts index a1ba436bc..3009c6709 100644 --- a/apps/kimi-code/test/cli/update/cache.test.ts +++ b/apps/kimi-code/test/cli/update/cache.test.ts @@ -4,9 +4,14 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + emptyUpdateInstallState, + readUpdateInstallState, + writeUpdateInstallState, +} from '#/cli/update/install-state'; import { readUpdateCache, writeUpdateCache } from '#/cli/update/cache'; -import { emptyUpdateCache } from '#/cli/update/types'; -import { getUpdateStateFile } from '#/utils/paths'; +import { emptyUpdateCache, type UpdateInstallState } from '#/cli/update/types'; +import { getUpdateInstallStateFile, getUpdateStateFile } from '#/utils/paths'; const originalEnv = { ...process.env }; @@ -52,6 +57,7 @@ describe('update cache', () => { source: 'cdn', checkedAt: '2026-04-23T08:00:00.000Z', latest: '0.5.0', + manifest: null, } as const; await writeUpdateCache(cache); @@ -59,4 +65,103 @@ describe('update cache', () => { expect(getUpdateStateFile()).toBe(join(dir, 'updates', 'latest.json')); await expect(readUpdateCache()).resolves.toEqual(cache); }); + + it('writes and reads back a cache carrying a rollout manifest', async () => { + const cache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: { + version: '0.5.0', + publishedAt: '2026-04-23T07:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], + }, + } as const; + + await writeUpdateCache(cache); + + await expect(readUpdateCache()).resolves.toEqual(cache); + }); + + it('reads a legacy cache file without a manifest field as manifest null', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync( + getUpdateStateFile(), + JSON.stringify({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + }), + 'utf-8', + ); + + await expect(readUpdateCache()).resolves.toEqual({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: null, + }); + }); + + it('keeps latest and treats a malformed manifest field as null', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync( + getUpdateStateFile(), + JSON.stringify({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: { version: 'not-semver', publishedAt: 'nope', rollout: 'bad' }, + }), + 'utf-8', + ); + + await expect(readUpdateCache()).resolves.toEqual({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: null, + }); + }); +}); + +describe('update install state', () => { + it('returns an empty install state when the file is missing', async () => { + await expect(readUpdateInstallState()).resolves.toEqual(emptyUpdateInstallState()); + }); + + it('falls back to an empty install state when the file is corrupt', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync(getUpdateInstallStateFile(), '{"broken"', 'utf-8'); + await expect(readUpdateInstallState()).resolves.toEqual(emptyUpdateInstallState()); + }); + + it('writes and reads back the install state from updates/install.json', async () => { + const state: UpdateInstallState = { + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: '2026-04-23T08:00:00.000Z', + }, + lastFailure: { + version: '0.4.0', + failedAt: '2026-04-22T08:00:00.000Z', + attempts: 1, + }, + lastSuccess: { + version: '0.3.0', + installedAt: '2026-04-21T08:00:00.000Z', + notifiedAt: null, + }, + }; + + await writeUpdateInstallState(state); + + expect(getUpdateInstallStateFile()).toBe(join(dir, 'updates', 'install.json')); + await expect(readUpdateInstallState()).resolves.toEqual(state); + }); }); diff --git a/apps/kimi-code/test/cli/update/cdn.test.ts b/apps/kimi-code/test/cli/update/cdn.test.ts index 3127bd71d..7eba81080 100644 --- a/apps/kimi-code/test/cli/update/cdn.test.ts +++ b/apps/kimi-code/test/cli/update/cdn.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { fetchLatestVersionFromCdn } from '#/cli/update/cdn'; -import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; +import { fetchLatestFromCdn, fetchLatestVersionFromCdn } from '#/cli/update/cdn'; +import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; function mockFetchOk(body: string): typeof fetch { return vi.fn(async () => ({ @@ -19,11 +19,44 @@ function mockFetchStatus(status: number): typeof fetch { })) as unknown as typeof fetch; } +type Route = { readonly status?: number; readonly body?: string } | Error; + +/** URL-routed fetch mock: unrouted URLs return 404. */ +function mockRoutedFetch(routes: Record<string, Route>): typeof fetch { + return vi.fn(async (input: string | URL) => { + const route = routes[String(input)]; + if (route === undefined) { + return { ok: false, status: 404, text: async () => '' }; + } + if (route instanceof Error) throw route; + const status = route.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + text: async () => route.body ?? '', + }; + }) as unknown as typeof fetch; +} + +const MANIFEST_BODY = JSON.stringify({ + schemaVersion: 1, + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], +}); + describe('fetchLatestVersionFromCdn', () => { it('returns the trimmed semver returned by CDN /latest', async () => { const f = mockFetchOk(' 0.5.0\n'); await expect(fetchLatestVersionFromCdn(f)).resolves.toBe('0.5.0'); - expect(f).toHaveBeenCalledWith(KIMI_CODE_CDN_LATEST_URL); + expect(f).toHaveBeenCalledWith( + KIMI_CODE_CDN_LATEST_URL, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); }); it('throws when response is non-2xx', async () => { @@ -47,3 +80,154 @@ describe('fetchLatestVersionFromCdn', () => { await expect(fetchLatestVersionFromCdn(f)).rejects.toThrow(/network down/); }); }); + +describe('fetchLatestFromCdn', () => { + it('parses latest.json and returns the manifest', async () => { + const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body: MANIFEST_BODY } }); + await expect(fetchLatestFromCdn(f)).resolves.toEqual({ + latest: '2.0.0', + manifest: { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], + }, + }); + expect(f).toHaveBeenCalledWith( + KIMI_CODE_CDN_LATEST_JSON_URL, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(f).toHaveBeenCalledTimes(1); + }); + + it('ignores unknown manifest fields (lenient parsing)', async () => { + const body = JSON.stringify({ + schemaVersion: 99, + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + futureField: { nested: true }, + }); + const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchLatestFromCdn(f); + expect(result.manifest).toEqual({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + }); + }); + + it('defaults a missing rollout to an empty plan (fully rolled out)', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + }); + const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchLatestFromCdn(f); + expect(result.manifest?.rollout).toEqual([]); + }); + + const fallbackCases: ReadonlyArray<readonly [string, Route]> = [ + ['latest.json is missing (HTTP 404)', { status: 404 }], + ['latest.json fetch throws', new Error('network down')], + ['body is not valid JSON', { body: 'not json {' }], + ['version is not semver', { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }], + ['publishedAt is unparseable', { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }], + ['a batch percent is out of range', { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 150, delaySeconds: 0 }], + }), + }], + ['a batch delay is negative', { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: -1 }], + }), + }], + ]; + + for (const [name, route] of fallbackCases) { + it(`falls back to plain /latest when ${name}`, async () => { + const f = mockRoutedFetch({ + [KIMI_CODE_CDN_LATEST_JSON_URL]: route, + [KIMI_CODE_CDN_LATEST_URL]: { body: '1.9.0\n' }, + }); + await expect(fetchLatestFromCdn(f)).resolves.toEqual({ + latest: '1.9.0', + manifest: null, + }); + }); + } + + it('throws when both latest.json and plain /latest fail', async () => { + const f = mockRoutedFetch({ + [KIMI_CODE_CDN_LATEST_JSON_URL]: { status: 500 }, + [KIMI_CODE_CDN_LATEST_URL]: { status: 500 }, + }); + await expect(fetchLatestFromCdn(f)).rejects.toThrow(/HTTP 500/); + }); + + it('propagates the plain /latest error when the fallback also breaks', async () => { + const f = mockRoutedFetch({ + [KIMI_CODE_CDN_LATEST_JSON_URL]: new Error('json down'), + [KIMI_CODE_CDN_LATEST_URL]: { body: 'not-a-version' }, + }); + await expect(fetchLatestFromCdn(f)).rejects.toThrow(/invalid semver/); + }); + + it('falls back to plain /latest when latest.json hangs past the request timeout', async () => { + vi.useFakeTimers(); + try { + const f = vi.fn(async (input: string | URL, init?: RequestInit) => { + if (String(input) === KIMI_CODE_CDN_LATEST_JSON_URL) { + return new Promise<Response>((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new Error('aborted')); + }, { once: true }); + }); + } + if (String(input) === KIMI_CODE_CDN_LATEST_URL) { + return { ok: true, status: 200, text: async () => '1.9.0\n' }; + } + return { ok: false, status: 404, text: async () => '' }; + }) as unknown as typeof fetch; + + const result = fetchLatestFromCdn(f); + await vi.advanceTimersByTimeAsync(3_000); + + await expect(result).resolves.toEqual({ + latest: '1.9.0', + manifest: null, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('rejects when plain /latest also hangs past the request timeout', async () => { + vi.useFakeTimers(); + try { + const f = vi.fn(async (_input: string | URL, init?: RequestInit) => { + return new Promise<Response>((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new Error('aborted')); + }, { once: true }); + }); + }) as unknown as typeof fetch; + + const result = fetchLatestFromCdn(f); + const expectation = expect(result).rejects.toThrow(/aborted/); + await vi.advanceTimersByTimeAsync(6_000); + + await expectation; + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/kimi-code/test/cli/update/install-lock.test.ts b/apps/kimi-code/test/cli/update/install-lock.test.ts new file mode 100644 index 000000000..fd7b568f8 --- /dev/null +++ b/apps/kimi-code/test/cli/update/install-lock.test.ts @@ -0,0 +1,50 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { tryAcquireUpdateInstallLock } from '#/cli/update/install-lock'; +import { getUpdateInstallLockFile } from '#/utils/paths'; + +const originalEnv = { ...process.env }; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-update-install-lock-')); + process.env['KIMI_CODE_HOME'] = dir; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe('update install lock', () => { + it('allows only one holder until the lock is released', async () => { + const first = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + expect(first).not.toBeNull(); + expect(getUpdateInstallLockFile()).toBe(join(dir, 'updates', 'install.lock')); + + const second = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + expect(second).toBeNull(); + + await first?.release(); + + const third = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + expect(third).not.toBeNull(); + await third?.release(); + }); + + it('recovers from a corrupt lock file', async () => { + const filePath = getUpdateInstallLockFile(); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, '{', 'utf-8'); + + const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + + expect(lock).not.toBeNull(); + await lock?.release(); + }); +}); diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index e497cba53..ae6dd3bc0 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -2,22 +2,40 @@ import type * as ChildProcess from 'node:child_process'; import { spawnSync } from 'node:child_process'; import { EventEmitter } from 'node:events'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readUpdateCache } from '#/cli/update/cache'; +import { + emptyUpdateInstallState, + readUpdateInstallState, + writeUpdateInstallState, +} from '#/cli/update/install-state'; import { runUpdatePreflight, spawnForSource } from '#/cli/update/preflight'; -import { promptForInstallConfirmation } from '#/cli/update/prompt'; +import { promptForInstallChoice } from '#/cli/update/prompt'; import type * as PromptModule from '#/cli/update/prompt'; import { refreshUpdateCache } from '#/cli/update/refresh'; import type * as RefreshModule from '#/cli/update/refresh'; +import type * as RolloutModule from '#/cli/update/rollout'; import { detectInstallSource } from '#/cli/update/source'; -import { emptyUpdateCache, type UpdateCache } from '#/cli/update/types'; +import { + emptyUpdateCache, + type UpdateCache, + type UpdateInstallState, + type UpdateManifest, +} from '#/cli/update/types'; +import type { TuiConfig } from '#/tui/config'; const mocks = vi.hoisted(() => ({ readUpdateCache: vi.fn(), + readUpdateInstallState: vi.fn(), + writeUpdateInstallState: vi.fn(), + tryAcquireUpdateInstallLock: vi.fn(), + loadTuiConfig: vi.fn(), detectInstallSource: vi.fn(), - promptForInstallConfirmation: vi.fn(), + promptForInstallChoice: vi.fn(), refreshUpdateCache: vi.fn(), + resolveUpdateDeviceId: vi.fn(), + appendRolloutDecisionLog: vi.fn(), spawn: vi.fn(), })); @@ -25,6 +43,32 @@ vi.mock('../../../src/cli/update/cache', () => ({ readUpdateCache: mocks.readUpdateCache, })); +vi.mock('../../../src/cli/update/install-lock', () => ({ + tryAcquireUpdateInstallLock: mocks.tryAcquireUpdateInstallLock, +})); + +vi.mock('../../../src/cli/update/install-state', () => ({ + emptyUpdateInstallState: () => ({ + active: null, + lastFailure: null, + lastSuccess: null, + }), + readUpdateInstallState: mocks.readUpdateInstallState, + writeUpdateInstallState: mocks.writeUpdateInstallState, +})); + +vi.mock('../../../src/tui/config', () => ({ + loadTuiConfig: mocks.loadTuiConfig, + TuiConfigParseError: class TuiConfigParseError extends Error { + readonly fallback: TuiConfig; + + constructor(fallback: TuiConfig) { + super('Invalid client preferences in ~/.kimi-code/tui.toml; using defaults.'); + this.fallback = fallback; + } + }, +})); + vi.mock('../../../src/cli/update/source', () => ({ detectInstallSource: mocks.detectInstallSource, })); @@ -33,7 +77,7 @@ vi.mock('../../../src/cli/update/prompt', async () => { const actual = await vi.importActual<typeof PromptModule>('../../../src/cli/update/prompt.js'); return { ...actual, - promptForInstallConfirmation: mocks.promptForInstallConfirmation, + promptForInstallChoice: mocks.promptForInstallChoice, }; }); @@ -45,6 +89,16 @@ vi.mock('../../../src/cli/update/refresh', async () => { }; }); +vi.mock('../../../src/cli/update/rollout', async () => { + const actual = await vi.importActual<typeof RolloutModule>('../../../src/cli/update/rollout.js'); + return { + ...actual, + resolveUpdateDeviceId: mocks.resolveUpdateDeviceId, + // Stubbed so preflight tests never write a real rollout.log. + appendRolloutDecisionLog: mocks.appendRolloutDecisionLog, + }; +}); + vi.mock('node:child_process', async () => { const actual = await vi.importActual<typeof ChildProcess>('node:child_process'); return { @@ -58,9 +112,67 @@ function cacheWith(version: string): UpdateCache { source: 'cdn', checkedAt: '2026-04-23T08:00:00.000Z', latest: version, + manifest: null, }; } +function manifestFor(version: string, overrides: Partial<UpdateManifest> = {}): UpdateManifest { + return { + version, + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [], + ...overrides, + }; +} + +function cacheWithManifest(manifest: UpdateManifest): UpdateCache { + return { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: manifest.version, + manifest, + }; +} + +/** Every bucket delayed by 24h and the clock just started: nobody is eligible. */ +function heldForEveryone(version: string): UpdateManifest { + return manifestFor(version, { + publishedAt: new Date(Date.now() - 1_000).toISOString(), + rollout: [{ percent: 100, delaySeconds: 86_400 }], + }); +} + +/** Every bucket immediate and publishedAt long past: everybody is eligible. */ +function releasedForEveryone(version: string): UpdateManifest { + return manifestFor(version, { + rollout: [{ percent: 100, delaySeconds: 0 }], + }); +} + +function installState(overrides: Partial<UpdateInstallState> = {}): UpdateInstallState { + return { + active: null, + lastFailure: null, + lastSuccess: null, + ...overrides, + }; +} + +function tuiConfig(overrides: Partial<TuiConfig> = {}): TuiConfig { + return { + theme: 'auto', + disablePasteBurst: false, + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + ...overrides, + }; +} + +function disableAutoInstall(): void { + mocks.loadTuiConfig.mockResolvedValue(tuiConfig({ upgrade: { autoInstall: false } })); +} + function captureOutput(): { stdout: string[]; stderr: string[]; @@ -83,26 +195,116 @@ function captureOutput(): { }; } +type TestLogFn = ReturnType<typeof vi.fn<(message: string, payload?: unknown) => void>>; + +function captureLogger(): { + info: TestLogFn; + warn: TestLogFn; + error: TestLogFn; + debug: TestLogFn; +} { + return { + info: vi.fn<(message: string, payload?: unknown) => void>(), + warn: vi.fn<(message: string, payload?: unknown) => void>(), + error: vi.fn<(message: string, payload?: unknown) => void>(), + debug: vi.fn<(message: string, payload?: unknown) => void>(), + }; +} + function mockSpawnExit(code: number, signal: NodeJS.Signals | null = null): void { mocks.spawn.mockImplementation(() => { - const child = new EventEmitter(); + const child = Object.assign(new EventEmitter(), { unref: vi.fn() }); queueMicrotask(() => { child.emit('exit', code, signal); }); return child; }); } -describe('runUpdatePreflight', () => { - afterEach(() => { vi.clearAllMocks(); }); +async function flushBackgroundInstall(): Promise<void> { + await new Promise<void>((resolve) => { + setImmediate(resolve); + }); +} - it('continues on first launch with empty cache, still refreshes in background', async () => { - mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); - mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache()); +describe('runUpdatePreflight', () => { + beforeEach(() => { + // Pin the experimental flag off so rollout gating is deterministic + // regardless of the host environment (the flag bypasses batch holds). + // Tests that exercise the bypass opt back in with `vi.stubEnv(..., '1')`. + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', ''); + mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); + mocks.writeUpdateInstallState.mockResolvedValue(undefined); + mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + mocks.resolveUpdateDeviceId.mockReturnValue('test-device'); + mocks.appendRolloutDecisionLog.mockResolvedValue(undefined); + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ + filePath: '/tmp/kimi-update-install.lock', + release: vi.fn().mockResolvedValue(undefined), + }); + }); + + afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); }); + + it('skips all update work when KIMI_CODE_NO_AUTO_UPDATE is set', async () => { + vi.stubEnv('KIMI_CODE_NO_AUTO_UPDATE', '1'); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); const { options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(readUpdateCache).not.toHaveBeenCalled(); + expect(refreshUpdateCache).not.toHaveBeenCalled(); + expect(detectInstallSource).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('also honors the legacy KIMI_CLI_NO_AUTO_UPDATE alias', async () => { + vi.stubEnv('KIMI_CLI_NO_AUTO_UPDATE', 'true'); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(readUpdateCache).not.toHaveBeenCalled(); + expect(detectInstallSource).not.toHaveBeenCalled(); + }); + + it('starts an automatic update from the first fresh check when the cache is empty', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + expect(readUpdateCache).toHaveBeenCalledTimes(1); expect(refreshUpdateCache).toHaveBeenCalledTimes(1); - expect(detectInstallSource).not.toHaveBeenCalled(); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(detectInstallSource).toHaveBeenCalledTimes(1); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + }); + + it('does not start a fresh-check background install when automatic updates are disabled', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + expect(detectInstallSource).toHaveBeenCalledTimes(1); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); }); it('skips when non-interactive', async () => { @@ -115,16 +317,33 @@ describe('runUpdatePreflight', () => { expect(detectInstallSource).not.toHaveBeenCalled(); }); - it('npm-global: prompts and spawns npm install -g', async () => { + it('does not start a fresh-check background install when non-interactive', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + const { options } = captureOutput(); + + await expect( + runUpdatePreflight('0.4.0', { ...options, isTTY: false }), + ).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + expect(detectInstallSource).not.toHaveBeenCalled(); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('npm-global: prompts and spawns npm install -g when automatic updates are disabled', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(0); const { stdout, options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); - expect(mocks.promptForInstallConfirmation).toHaveBeenCalledWith( + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( expect.objectContaining({ installCommand: 'npm install -g @moonshot-ai/kimi-code@0.5.0', installSource: 'npm-global', @@ -138,11 +357,63 @@ describe('runUpdatePreflight', () => { expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); }); + it('refreshes a stale cached target before showing the foreground install prompt', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.7.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(0); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('exit'); + + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ + target: { version: '0.7.0' }, + installCommand: 'npm install -g @moonshot-ai/kimi-code@0.7.0', + }), + ); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.7.0'], + { stdio: 'inherit' }, + ); + expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.7.0'); + }); + + it('falls back to the cached foreground prompt target when the refresh hangs', async () => { + vi.useFakeTimers(); + try { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + mocks.refreshUpdateCache.mockReturnValue(new Promise(() => {})); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + + const result = runUpdatePreflight('0.5.0', options); + await vi.advanceTimersByTimeAsync(1_000); + + await expect(result).resolves.toBe('continue'); + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ + target: { version: '0.6.0' }, + installCommand: 'npm install -g @moonshot-ai/kimi-code@0.6.0', + }), + ); + } finally { + vi.useRealTimers(); + } + }); + it('pnpm-global: spawns pnpm add -g', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('pnpm-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(0); const { options } = captureOutput(); await runUpdatePreflight('0.4.0', options); @@ -153,11 +424,34 @@ describe('runUpdatePreflight', () => { ); }); + it('pnpm-global on win32: spawns pnpm.cmd through a shell', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('pnpm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(0); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const { options } = captureOutput(); + await runUpdatePreflight('0.4.0', options); + expect(mocks.spawn).toHaveBeenCalledWith( + 'pnpm.cmd', + ['add', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { stdio: 'inherit', shell: true }, + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('yarn-global: spawns yarn global add', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('yarn-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(0); const { options } = captureOutput(); await runUpdatePreflight('0.4.0', options); @@ -169,10 +463,11 @@ describe('runUpdatePreflight', () => { }); it('bun-global: spawns bun add -g', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('bun-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(0); const { options } = captureOutput(); await runUpdatePreflight('0.4.0', options); @@ -183,11 +478,23 @@ describe('runUpdatePreflight', () => { ); }); + it('homebrew: prints manual brew upgrade command, does not spawn', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('homebrew'); + const { stdout, options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(stdout.join('')).toContain('brew upgrade kimi-code'); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + it('native on darwin: spawns bash -c with pipefail-guarded curl|bash', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('native'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(0); const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'darwin' }); @@ -219,7 +526,7 @@ describe('runUpdatePreflight', () => { const { stdout, options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); expect(stdout.join('')).toContain('irm https://code.kimi.com/kimi-code/install.ps1 | iex'); - expect(promptForInstallConfirmation).not.toHaveBeenCalled(); + expect(promptForInstallChoice).not.toHaveBeenCalled(); expect(mocks.spawn).not.toHaveBeenCalled(); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform }); @@ -237,20 +544,22 @@ describe('runUpdatePreflight', () => { }); it('declined install continues without spawn', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(false); + mocks.promptForInstallChoice.mockResolvedValue('skip'); const { options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); expect(mocks.spawn).not.toHaveBeenCalled(); }); it('warns and continues when spawn exits non-zero, without claiming success', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(true); + mocks.promptForInstallChoice.mockResolvedValue('install'); mockSpawnExit(1); const { stdout, stderr, options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); @@ -259,21 +568,544 @@ describe('runUpdatePreflight', () => { expect(stdout.join('')).not.toContain('Updated @moonshot-ai/kimi-code'); }); + it('starts an automatic update in the background by default', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + version: '0.5.0', + source: 'npm-global', + startedAt: expect.any(String), + }), + lastFailure: null, + })); + + await flushBackgroundInstall(); + + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + active: null, + lastFailure: null, + lastSuccess: expect.objectContaining({ + version: '0.5.0', + installedAt: expect.any(String), + notifiedAt: null, + }), + })); + }); + + it('win32 background auto-update hides the console window', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const { options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(mocks.spawn).toHaveBeenCalledWith( + 'npm.cmd', + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore', shell: true, windowsHide: true }, + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + + it('tracks and logs successful background update installs', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + const track = vi.fn(); + const logger = captureLogger(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track, logger })).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ + current_version: '0.4.0', + target_version: '0.5.0', + source: 'npm-global', + })); + expect(track).toHaveBeenCalledWith('update_background_install_succeeded', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(logger.info).toHaveBeenCalledWith('background update install started', expect.objectContaining({ + currentVersion: '0.4.0', + targetVersion: '0.5.0', + source: 'npm-global', + })); + expect(logger.info).toHaveBeenCalledWith('background update install succeeded', expect.objectContaining({ + targetVersion: '0.5.0', + source: 'npm-global', + })); + }); + + it('defaults to automatic background updates when client preferences cannot be loaded', async () => { + mocks.loadTuiConfig.mockRejectedValue(new Error('broken tui.toml')); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + }); + + it('starts only one background update when two sessions preflight concurrently', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + let acquired = false; + mocks.tryAcquireUpdateInstallLock.mockImplementation(async () => { + if (acquired) return null; + acquired = true; + return { + filePath: '/tmp/kimi-update-install.lock', + release: vi.fn().mockResolvedValue(undefined), + }; + }); + mockSpawnExit(0); + const first = captureOutput(); + const second = captureOutput(); + + await expect(Promise.all([ + runUpdatePreflight('0.4.0', first.options), + runUpdatePreflight('0.4.0', second.options), + ])).resolves.toEqual(['continue', 'continue']); + + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it('records the first background failure silently so the next launch can retry', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(1); + const { stderr, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(stderr.join('')).toBe(''); + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + failedAt: expect.any(String), + }), + lastSuccess: null, + })); + }); + + it('tracks and logs background update install failures without writing stderr', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(1); + const { stderr, options } = captureOutput(); + const track = vi.fn(); + const logger = captureLogger(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track, logger })).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(stderr.join('')).toBe(''); + expect(track).toHaveBeenCalledWith('update_background_install_failed', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + attempts: 1, + })); + expect(logger.warn).toHaveBeenCalledWith('background update install failed', expect.objectContaining({ + targetVersion: '0.5.0', + source: 'npm-global', + attempts: 1, + })); + }); + + it('retries automatic update once after the first background failure', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastFailure: { + version: '0.5.0', + failedAt: '2026-04-23T08:00:00.000Z', + attempts: 1, + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(1); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 2, + }), + })); + }); + + it('prompts for manual foreground install after two background failures', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastFailure: { + version: '0.5.0', + failedAt: '2026-04-23T08:00:00.000Z', + attempts: 2, + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(promptForInstallChoice).toHaveBeenCalledWith(expect.objectContaining({ + target: { version: '0.5.0' }, + installSource: 'npm-global', + })); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('shows a one-shot notice after a background update succeeds and the new version starts', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastSuccess: { + version: '0.5.0', + installedAt: '2026-04-23T08:00:00.000Z', + notifiedAt: null, + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache()); + const { stdout, options } = captureOutput(); + const track = vi.fn(); + const logger = captureLogger(); + + await expect(runUpdatePreflight('0.5.0', { ...options, track, logger })).resolves.toBe('continue'); + + const rendered = stdout.join(''); + expect(rendered).toContain('Kimi Code updated to v0.5.0'); + expect(rendered).toContain( + 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html', + ); + expect(track).toHaveBeenCalledWith('update_success_notice_shown', expect.objectContaining({ + version: '0.5.0', + inferred_from_active: false, + })); + expect(logger.info).toHaveBeenCalledWith('background update success notice shown', expect.objectContaining({ + version: '0.5.0', + inferredFromActive: false, + })); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + lastSuccess: expect.objectContaining({ + version: '0.5.0', + notifiedAt: expect.any(String), + }), + })); + expect(detectInstallSource).not.toHaveBeenCalled(); + }); + + it('infers a background update success notice when the active install version is now running', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: '2026-04-23T08:00:00.000Z', + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache()); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); + + expect(stdout.join('')).toContain('Kimi Code updated to v0.5.0'); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: null, + lastSuccess: expect.objectContaining({ + version: '0.5.0', + notifiedAt: expect.any(String), + }), + })); + }); + it('tracks update_prompted telemetry', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallConfirmation.mockResolvedValue(false); + mocks.promptForInstallChoice.mockResolvedValue('skip'); const { options } = captureOutput(); const track = vi.fn(); await runUpdatePreflight('0.4.0', { ...options, track }); expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - current: '0.4.0', - latest: '0.5.0', + current_version: '0.4.0', + target_version: '0.5.0', decision: 'prompt-install', source: 'npm-global', })); }); + + describe('rollout gating', () => { + it('hides a cached update whose batch is not yet eligible', async () => { + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(detectInstallSource).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + // The launch still refreshes the cache in the background so the device + // flips to eligible purely by time passing. + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + // Both checks of this launch are recorded in the rollout log. + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'held', + current: '0.4.0', + latest: '0.5.0', + bucket: expect.any(Number), + delaySeconds: 86_400, + eligibleAt: expect.any(String), + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'background-refresh', + reason: 'held', + })); + }); + + it('starts the background install once the device batch is eligible', async () => { + const released = cacheWithManifest(releasedForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(released); + mocks.refreshUpdateCache.mockResolvedValue(released); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ + target_version: '0.5.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 0, + rollout_from_manifest: true, + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'eligible', + target: '0.5.0', + })); + }); + + it('prompts with rollout telemetry when eligible and auto-install is disabled', async () => { + disableAutoInstall(); + const released = cacheWithManifest(releasedForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(released); + mocks.refreshUpdateCache.mockResolvedValue(released); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.5.0' } }), + ); + expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ + target_version: '0.5.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 0, + rollout_from_manifest: true, + })); + }); + + it('uses the refreshed manifest for rollout telemetry when the prompt target changes', async () => { + disableAutoInstall(); + const cached = cacheWithManifest(manifestFor('0.6.0', { + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: 0 }], + })); + const refreshed = cacheWithManifest(manifestFor('0.7.0', { + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: 43_200 }], + })); + mocks.readUpdateCache.mockResolvedValue(cached); + mocks.refreshUpdateCache.mockResolvedValue(refreshed); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.5.0', { ...options, track })).resolves.toBe('continue'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.7.0' } }), + ); + expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ + target_version: '0.7.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 43_200, + rollout_from_manifest: true, + })); + }); + + it('suppresses the manual-command notice while a homebrew device batch is held', async () => { + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('homebrew'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(stdout.join('')).toBe(''); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('does not start a fresh-check background install while the refreshed manifest is held', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.5.0'))); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + expect(detectInstallSource).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('stays silent when the user-visible refresh reveals a held newer version', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(releasedForEveryone('0.6.0'))); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.7.0'))); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); + + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('KIMI_CODE_EXPERIMENTAL_FLAG bypasses the rollout: held devices still update', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ + target_version: '0.5.0', + rollout_bypassed: true, + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'experimental', + target: '0.5.0', + })); + }); + + it('KIMI_CODE_NO_AUTO_UPDATE still wins over the experimental flag', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); + vi.stubEnv('KIMI_CODE_NO_AUTO_UPDATE', '1'); + mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(releasedForEveryone('0.5.0'))); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(readUpdateCache).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('treats any plan older than 24h as fully rolled out', async () => { + disableAutoInstall(); + const staleRollout = manifestFor('0.5.0', { + publishedAt: new Date(Date.now() - 25 * 3_600 * 1_000).toISOString(), + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], + }); + mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout)); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout)); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.5.0' } }), + ); + }); + }); }); describe('spawnForSource native', () => { diff --git a/apps/kimi-code/test/cli/update/prompt.test.ts b/apps/kimi-code/test/cli/update/prompt.test.ts index 864492689..126440493 100644 --- a/apps/kimi-code/test/cli/update/prompt.test.ts +++ b/apps/kimi-code/test/cli/update/prompt.test.ts @@ -6,7 +6,7 @@ import { createInstallPromptChoices, getDefaultInstallPromptSelection, moveInstallPromptSelection, - promptForInstallConfirmation, + promptForInstallChoice, } from '#/cli/update/prompt'; describe('install prompt helpers', () => { @@ -32,7 +32,7 @@ describe('install prompt helpers', () => { }); }); -describe('promptForInstallConfirmation', () => { +describe('promptForInstallChoice', () => { it('renders changelog hyperlink in the prompt output', async () => { const CHANGELOG_URL = 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html'; @@ -51,7 +51,7 @@ describe('promptForInstallConfirmation', () => { }, } as NodeJS.WriteStream; - const promptPromise = promptForInstallConfirmation({ + const promptPromise = promptForInstallChoice({ currentVersion: '0.4.0', target: { version: '0.5.0' }, installCommand: 'npm install -g @moonshot-ai/kimi-code@0.5.0', diff --git a/apps/kimi-code/test/cli/update/refresh.test.ts b/apps/kimi-code/test/cli/update/refresh.test.ts index 16a18d2f3..ceb1306f7 100644 --- a/apps/kimi-code/test/cli/update/refresh.test.ts +++ b/apps/kimi-code/test/cli/update/refresh.test.ts @@ -1,12 +1,23 @@ import { describe, expect, it, vi } from 'vitest'; import { refreshUpdateCache } from '#/cli/update/refresh'; +import type { UpdateManifest } from '#/cli/update/types'; + +const MANIFEST: UpdateManifest = { + version: '0.5.0', + publishedAt: '2026-05-20T12:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], +}; describe('refreshUpdateCache', () => { - it('writes a fresh cache on successful fetch', async () => { + it('writes a fresh cache carrying the manifest on successful fetch', async () => { const writeCache = vi.fn(async () => {}); const result = await refreshUpdateCache({ - fetchLatest: async () => '0.5.0', + fetchLatest: async () => ({ latest: '0.5.0', manifest: MANIFEST }), writeCache, now: () => new Date('2026-05-20T12:34:56.000Z'), }); @@ -15,12 +26,26 @@ describe('refreshUpdateCache', () => { source: 'cdn', checkedAt: '2026-05-20T12:34:56.000Z', latest: '0.5.0', + manifest: MANIFEST, }); - expect(writeCache).toHaveBeenCalledWith({ + expect(writeCache).toHaveBeenCalledWith(result); + }); + + it('writes a null manifest when the fetch fell back to plain text', async () => { + const writeCache = vi.fn(async () => {}); + const result = await refreshUpdateCache({ + fetchLatest: async () => ({ latest: '0.5.0', manifest: null }), + writeCache, + now: () => new Date('2026-05-20T12:34:56.000Z'), + }); + + expect(result).toEqual({ source: 'cdn', checkedAt: '2026-05-20T12:34:56.000Z', latest: '0.5.0', + manifest: null, }); + expect(writeCache).toHaveBeenCalledWith(result); }); it('propagates fetch errors and skips writeCache so the cache is preserved', async () => { diff --git a/apps/kimi-code/test/cli/update/rollout.test.ts b/apps/kimi-code/test/cli/update/rollout.test.ts new file mode 100644 index 000000000..615906148 --- /dev/null +++ b/apps/kimi-code/test/cli/update/rollout.test.ts @@ -0,0 +1,366 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + appendRolloutDecisionLog, + decidePassiveUpdateTarget, + isRolloutBypassedByExperimentalEnv, + isRolloutEligible, + MAX_ROLLOUT_DELAY_SECONDS, + rolloutBucket, + rolloutDelayForBucket, + rolloutDelaySeconds, + resolveUpdateDeviceId, + selectPassiveUpdateTarget, +} from '#/cli/update/rollout'; +import type { RolloutBatch, UpdateManifest } from '#/cli/update/types'; + +const STANDARD_ROLLOUT: readonly RolloutBatch[] = [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, +]; + +const PUBLISHED_AT = '2026-06-12T00:00:00.000Z'; +const PUBLISHED_AT_MS = Date.parse(PUBLISHED_AT); + +function makeManifest(overrides: Partial<UpdateManifest> = {}): UpdateManifest { + return { + version: '2.0.0', + publishedAt: PUBLISHED_AT, + rollout: STANDARD_ROLLOUT, + ...overrides, + }; +} + +function secondsAfterPublish(seconds: number): Date { + return new Date(PUBLISHED_AT_MS + seconds * 1000); +} + +describe('rolloutBucket', () => { + it('is deterministic and within 0-99', () => { + for (let i = 0; i < 200; i++) { + const bucket = rolloutBucket(`device-${i}`, '2.0.0'); + expect(bucket).toBe(rolloutBucket(`device-${i}`, '2.0.0')); + expect(bucket).toBeGreaterThanOrEqual(0); + expect(bucket).toBeLessThan(100); + expect(Number.isInteger(bucket)).toBe(true); + } + }); + + it('matches pinned vectors (regression guard for the hash layout)', () => { + expect(rolloutBucket('device-a', '1.0.0')).toBe(65); + expect(rolloutBucket('device-b', '1.0.0')).toBe(76); + expect(rolloutBucket('fixed-device', '2.0.0')).toBe(26); + }); + + it('reshuffles buckets when the version changes', () => { + expect(rolloutBucket('device-a', '1.0.1')).toBe(79); + expect(rolloutBucket('device-a', '1.0.1')).not.toBe(rolloutBucket('device-a', '1.0.0')); + }); +}); + +describe('rolloutDelayForBucket', () => { + it('maps buckets to batches at the exact boundaries', () => { + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 0)).toBe(0); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 29)).toBe(0); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 30)).toBe(43_200); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 59)).toBe(43_200); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 60)).toBe(86_400); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 99)).toBe(86_400); + }); + + it('clamps oversized delays to 24h', () => { + const rollout: readonly RolloutBatch[] = [{ percent: 100, delaySeconds: 999_999 }]; + expect(rolloutDelayForBucket(rollout, 50)).toBe(MAX_ROLLOUT_DELAY_SECONDS); + }); + + it('assigns buckets not covered by the plan to the slowest cohort', () => { + const rollout: readonly RolloutBatch[] = [{ percent: 30, delaySeconds: 0 }]; + expect(rolloutDelayForBucket(rollout, 29)).toBe(0); + expect(rolloutDelayForBucket(rollout, 30)).toBe(MAX_ROLLOUT_DELAY_SECONDS); + expect(rolloutDelayForBucket(rollout, 99)).toBe(MAX_ROLLOUT_DELAY_SECONDS); + }); + + it('tolerates percents summing past 100', () => { + const rollout: readonly RolloutBatch[] = [ + { percent: 60, delaySeconds: 0 }, + { percent: 60, delaySeconds: 43_200 }, + ]; + expect(rolloutDelayForBucket(rollout, 59)).toBe(0); + expect(rolloutDelayForBucket(rollout, 99)).toBe(43_200); + }); + + it('treats an empty plan as fully rolled out', () => { + expect(rolloutDelayForBucket([], 0)).toBe(0); + expect(rolloutDelayForBucket([], 99)).toBe(0); + }); +}); + +describe('rolloutDelaySeconds', () => { + it('splits 10k devices roughly 30/30/40 across the standard plan', () => { + const counts = new Map<number, number>([ + [0, 0], + [43_200, 0], + [86_400, 0], + ]); + const manifest = makeManifest(); + for (let i = 0; i < 10_000; i++) { + const delay = rolloutDelaySeconds(manifest, `device-${i}`); + counts.set(delay, (counts.get(delay) ?? 0) + 1); + } + expect(counts.get(0)).toBeGreaterThanOrEqual(2_700); + expect(counts.get(0)).toBeLessThanOrEqual(3_300); + expect(counts.get(43_200)).toBeGreaterThanOrEqual(2_700); + expect(counts.get(43_200)).toBeLessThanOrEqual(3_300); + expect(counts.get(86_400)).toBeGreaterThanOrEqual(3_700); + expect(counts.get(86_400)).toBeLessThanOrEqual(4_300); + }); +}); + +describe('isRolloutEligible', () => { + const delayedForEveryone = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 43_200 }], + }); + + it('is not eligible before publishedAt + delay', () => { + const justBefore = new Date(PUBLISHED_AT_MS + 43_200 * 1000 - 1); + expect(isRolloutEligible(delayedForEveryone, 'device-a', justBefore)).toBe(false); + }); + + it('is eligible exactly at publishedAt + delay', () => { + expect(isRolloutEligible(delayedForEveryone, 'device-a', secondsAfterPublish(43_200))).toBe( + true, + ); + }); + + it('is not eligible while publishedAt is still in the future', () => { + const manifest = makeManifest({ rollout: [] }); + expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(-3_600))).toBe(false); + }); + + it('is always eligible 24h after publish regardless of the plan', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 999_999 }] }); + expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(86_400))).toBe(true); + }); + + it('fails open when publishedAt cannot be parsed', () => { + const manifest = makeManifest({ publishedAt: 'not-a-date' }); + expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(-999_999))).toBe(true); + }); +}); + +describe('selectPassiveUpdateTarget', () => { + const now = secondsAfterPublish(60); + + it('falls back to plain latest when manifest is null', () => { + expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now)).toEqual({ + version: '2.0.0', + }); + expect(selectPassiveUpdateTarget('1.0.0', null, null, 'device-a', now)).toBeNull(); + }); + + it('returns null when the manifest version is not newer', () => { + const manifest = makeManifest({ rollout: [] }); + expect(selectPassiveUpdateTarget('2.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull(); + expect(selectPassiveUpdateTarget('3.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull(); + }); + + it('returns the target once the device batch is eligible', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 0 }] }); + expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now)).toEqual({ + version: '2.0.0', + }); + }); + + it('hides the target while the device batch is not yet eligible', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull(); + }); +}); + +describe('decidePassiveUpdateTarget', () => { + const now = secondsAfterPublish(60); + + it('reports no-latest when nothing is known yet', () => { + const decision = decidePassiveUpdateTarget('1.0.0', null, null, 'device-a', now); + expect(decision).toMatchObject({ target: null, reason: 'no-latest' }); + }); + + it('reports not-newer when the known version is not an upgrade', () => { + expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', null, 'device-a', now)).toMatchObject({ + target: null, + reason: 'not-newer', + }); + const manifest = makeManifest({ rollout: [] }); + expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', manifest, 'device-a', now)).toMatchObject({ + target: null, + reason: 'not-newer', + }); + }); + + it('reports no-manifest legacy visibility when only plain latest is known', () => { + const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'no-manifest', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }); + }); + + it('reports held with bucket, delay and eligibleAt while the batch is gated', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + const decision = decidePassiveUpdateTarget( + '1.0.0', + '2.0.0', + manifest, + 'device-a', + secondsAfterPublish(60), + ); + expect(decision).toMatchObject({ + target: null, + reason: 'held', + bucket: rolloutBucket('device-a', '2.0.0'), + delaySeconds: 86_400, + eligibleAt: new Date(PUBLISHED_AT_MS + 86_400 * 1000).toISOString(), + }); + }); + + it('reports eligible once the batch delay has passed', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 43_200 }] }); + const decision = decidePassiveUpdateTarget( + '1.0.0', + '2.0.0', + manifest, + 'device-a', + secondsAfterPublish(43_200), + ); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'eligible', + delaySeconds: 43_200, + }); + }); +}); + +describe('appendRolloutDecisionLog', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-rollout-log-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('appends one JSON line per decision', async () => { + const file = join(dir, 'updates', 'rollout.log'); + await appendRolloutDecisionLog({ phase: 'startup-cache', reason: 'held' }, file); + await appendRolloutDecisionLog({ phase: 'prompt-refresh', reason: 'eligible' }, file); + + const lines = readFileSync(file, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0] ?? '')).toMatchObject({ phase: 'startup-cache', reason: 'held' }); + expect(JSON.parse(lines[1] ?? '')).toMatchObject({ phase: 'prompt-refresh', reason: 'eligible' }); + }); + + it('resets the file once it grows past the size cap', async () => { + const file = join(dir, 'rollout.log'); + writeFileSync(file, 'x'.repeat(300 * 1024), 'utf-8'); + await appendRolloutDecisionLog({ reason: 'eligible' }, file); + + const content = readFileSync(file, 'utf-8'); + expect(content.length).toBeLessThan(1024); + expect(JSON.parse(content.trim())).toMatchObject({ reason: 'eligible' }); + }); + + it('never throws on unwritable paths', async () => { + await expect( + appendRolloutDecisionLog({ reason: 'held' }, '/dev/null/nope/rollout.log'), + ).resolves.toBeUndefined(); + }); +}); + +describe('resolveUpdateDeviceId', () => { + const originalEnv = { ...process.env }; + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-rollout-device-id-')); + process.env['KIMI_CODE_HOME'] = dir; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + rmSync(dir, { recursive: true, force: true }); + }); + + it('does not create the telemetry device id file when one is missing', () => { + const deviceId = resolveUpdateDeviceId(); + + expect(deviceId).toMatch(/^[0-9a-f-]+$/); + expect(existsSync(join(dir, 'device_id'))).toBe(false); + }); + + it('reuses an existing telemetry device id without rewriting it', () => { + writeFileSync(join(dir, 'device_id'), 'existing-device-id', 'utf-8'); + + expect(resolveUpdateDeviceId()).toBe('existing-device-id'); + expect(readFileSync(join(dir, 'device_id'), 'utf-8')).toBe('existing-device-id'); + }); +}); + +describe('experimental flag bypass', () => { + const now = secondsAfterPublish(60); + const heldManifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + + it('bypasses a held rollout and reports experimental', () => { + const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', heldManifest, 'device-a', now, true); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'experimental', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }); + }); + + it('still reports not-newer / no-latest under bypass', () => { + expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', heldManifest, 'device-a', now, true)).toMatchObject({ + target: null, + reason: 'not-newer', + }); + expect(decidePassiveUpdateTarget('1.0.0', null, null, 'device-a', now, true)).toMatchObject({ + target: null, + reason: 'no-latest', + }); + }); + + it('marks plain-latest visibility as experimental when bypassing', () => { + expect(decidePassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now, true)).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'experimental', + }); + }); +}); + +describe('isRolloutBypassedByExperimentalEnv', () => { + it('is on for the usual truthy values of KIMI_CODE_EXPERIMENTAL_FLAG', () => { + for (const value of ['1', 'true', 'YES', ' on ']) { + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: value })).toBe(true); + } + }); + + it('is off when unset, blank, or falsy', () => { + expect(isRolloutBypassedByExperimentalEnv({})).toBe(false); + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: '' })).toBe(false); + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: '0' })).toBe(false); + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: 'off' })).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/cli/update/source.test.ts b/apps/kimi-code/test/cli/update/source.test.ts index 20bba5cd1..dd88d32c3 100644 --- a/apps/kimi-code/test/cli/update/source.test.ts +++ b/apps/kimi-code/test/cli/update/source.test.ts @@ -47,6 +47,24 @@ describe('classifyByPathHeuristic', () => { ).toBe('bun-global'); }); + it('detects homebrew on macOS (Cellar path)', () => { + expect( + classifyByPathHeuristic('/opt/homebrew/Cellar/kimi-code/0.5.0/libexec/lib/node_modules/@moonshot-ai/kimi-code'), + ).toBe('homebrew'); + }); + + it('detects homebrew on Linux (Linuxbrew)', () => { + expect( + classifyByPathHeuristic('/home/linuxbrew/.linuxbrew/Cellar/kimi-code/0.5.0/libexec/lib/node_modules/@moonshot-ai/kimi-code'), + ).toBe('homebrew'); + }); + + it('does not treat npm-global under Homebrew prefix as homebrew', () => { + expect( + classifyByPathHeuristic('/opt/homebrew/lib/node_modules/@moonshot-ai/kimi-code'), + ).toBeNull(); + }); + it('returns null for an unknown layout', () => { expect(classifyByPathHeuristic('/Users/me/dev/@moonshot-ai/kimi-code')).toBeNull(); }); @@ -112,6 +130,18 @@ describe('detectInstallSource', () => { ).resolves.toBe('npm-global'); }); + it('returns homebrew when packageRoot matches Cellar heuristic', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => + '/opt/homebrew/Cellar/kimi-code/0.5.0/libexec/lib/node_modules/@moonshot-ai/kimi-code', + getGlobalPrefix: async () => '/usr/local', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('homebrew'); + }); + it('returns native when SEA isSea() is true (highest priority)', async () => { await expect( detectInstallSource({ diff --git a/apps/kimi-code/test/cli/upgrade.test.ts b/apps/kimi-code/test/cli/upgrade.test.ts new file mode 100644 index 000000000..31b53b1d9 --- /dev/null +++ b/apps/kimi-code/test/cli/upgrade.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleUpgrade } from '#/cli/sub/upgrade'; +import type { InstallPromptChoiceValue } from '#/cli/update/prompt'; +import type { InstallSource, UpdateCache } from '#/cli/update/types'; + +function cacheWith( + version: string | null, + manifest: UpdateCache['manifest'] = null, +): UpdateCache { + return { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: version, + manifest, + }; +} + +function captureOutput(): { + stdout: string[]; + stderr: string[]; + writable: { + stdout: { write(chunk: string): boolean }; + stderr: { write(chunk: string): boolean }; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + return { + stdout, + stderr, + writable: { + stdout: { write: (chunk: string) => { stdout.push(chunk); return true; } }, + stderr: { write: (chunk: string) => { stderr.push(chunk); return true; } }, + }, + }; +} + +function createDeps(overrides: { + readonly latest?: string | null; + readonly manifest?: UpdateCache['manifest']; + readonly source?: InstallSource; + readonly isInteractive?: boolean; + readonly promptForInstallChoice?: () => Promise<InstallPromptChoiceValue>; + readonly installUpdate?: (source: InstallSource, version: string, platform: NodeJS.Platform) => Promise<void>; +} = {}) { + const installUpdate = + overrides.installUpdate ?? + vi.fn<( + source: InstallSource, + version: string, + platform: NodeJS.Platform, + ) => Promise<void>>().mockResolvedValue(undefined); + + return { + refreshUpdateCache: vi + .fn() + .mockResolvedValue(cacheWith(overrides.latest ?? '0.5.0', overrides.manifest ?? null)), + detectInstallSource: vi.fn().mockResolvedValue(overrides.source ?? 'npm-global'), + promptForInstallChoice: + overrides.promptForInstallChoice ?? vi.fn().mockResolvedValue('install'), + installUpdate, + track: vi.fn(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + platform: 'darwin' as NodeJS.Platform, + isInteractive: overrides.isInteractive ?? true, + }; +} + +describe('handleUpgrade', () => { + it('prompts before installing the latest version when the install source supports it', async () => { + const { stdout, stderr, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'npm-global' }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.refreshUpdateCache).toHaveBeenCalledTimes(1); + expect(deps.detectInstallSource).toHaveBeenCalledTimes(1); + expect(deps.promptForInstallChoice).toHaveBeenCalledWith({ + currentVersion: '0.4.0', + target: { version: '0.5.0' }, + installCommand: 'npm install -g @moonshot-ai/kimi-code@0.5.0', + installSource: 'npm-global', + }); + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_prompted', expect.objectContaining({ + current_version: '0.4.0', + target_version: '0.5.0', + source: 'npm-global', + })); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_install_selected', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_succeeded', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(deps.logger.info).toHaveBeenCalledWith('manual upgrade install succeeded', expect.objectContaining({ + targetVersion: '0.5.0', + source: 'npm-global', + })); + expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); + expect(stderr.join('')).toBe(''); + }); + + it('skips the foreground install when the update prompt is declined', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + promptForInstallChoice: vi.fn().mockResolvedValue('skip'), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.promptForInstallChoice).toHaveBeenCalledTimes(1); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_skipped', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(stdout.join('')).toBe(''); + }); + + it('prints up-to-date status without detecting the install source when no newer version exists', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ latest: '0.4.0' }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.detectInstallSource).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_no_update', expect.objectContaining({ + current_version: '0.4.0', + })); + expect(stdout.join('')).toContain('Kimi Code is already up to date (v0.4.0).'); + }); + + it('prints the manual update command when the install source cannot be auto-installed', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'unsupported' }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_manual_command', expect.objectContaining({ + target_version: '0.5.0', + source: 'unsupported', + })); + expect(stdout.join('')).toContain('To update manually, run: npm install -g @moonshot-ai/kimi-code@0.5.0'); + }); + + it('prints the manual update command without prompting when not interactive', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'npm-global', isInteractive: false }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_manual_command', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(stdout.join('')).toContain('To update manually, run: npm install -g @moonshot-ai/kimi-code@0.5.0'); + }); + + it('returns a failing exit code when the foreground install fails', async () => { + const { stderr, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + installUpdate: vi.fn().mockRejectedValue(new Error('npm exited with code 1')), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(stderr.join('')).toContain( + 'warning: failed to install @moonshot-ai/kimi-code@0.5.0: npm exited with code 1', + ); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + stage: 'install', + })); + expect(deps.logger.warn).toHaveBeenCalledWith('manual upgrade install failed', expect.objectContaining({ + targetVersion: '0.5.0', + source: 'npm-global', + })); + }); + + it('returns a failing exit code when checking the latest version fails', async () => { + const { stderr, writable } = captureOutput(); + const deps = { + ...createDeps(), + refreshUpdateCache: vi.fn().mockRejectedValue(new Error('cdn unavailable')), + }; + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(deps.detectInstallSource).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ + current_version: '0.4.0', + stage: 'refresh', + })); + expect(stderr.join('')).toContain('error: failed to check for updates: cdn unavailable'); + }); + + it('ignores rollout gating: installs the latest version while every batch is still held', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + // Published seconds ago with every device delayed by 24h — passive + // update surfaces would hide this version, manual upgrade must not. + manifest: { + version: '0.5.0', + publishedAt: new Date(Date.now() - 1_000).toISOString(), + rollout: [{ percent: 100, delaySeconds: 86_400 }], + }, + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); + }); +}); diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts new file mode 100644 index 000000000..8a74961fc --- /dev/null +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + IAgentGoalService, + IAgentLifecycleService, + IAgentPermissionModeService, + IAgentProfileService, + IAgentPromptService, + IAgentTaskService, + IAuthSummaryService, + IBootstrapService, + IConfigService, + IEventBus, + IFileSystemStorageService, + IOAuthToolkit, + ISessionIndex, + ISessionLifecycleService, + ISkillCatalogRuntimeOptions, + ITelemetryService, + type DomainEvent, + type ScopeSeed, +} from '@moonshot-ai/agent-core-v2'; + +import { runV2Print } from '../../src/cli/v2/run-v2-print'; + +const mocks = vi.hoisted(() => ({ + bootstrap: vi.fn(), + ensureMainAgent: vi.fn(), + createKimiDefaultHeaders: vi.fn(() => ({})), + resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'), + createKimiDeviceId: vi.fn(() => 'device-1'), +})); + +vi.mock('@moonshot-ai/agent-core-v2', async (importOriginal) => { + const actual = await importOriginal<typeof import('@moonshot-ai/agent-core-v2')>(); + return { + ...actual, + bootstrap: mocks.bootstrap, + ensureMainAgent: mocks.ensureMainAgent, + }; +}); + +vi.mock('@moonshot-ai/kimi-code-oauth', async () => { + const actual = await vi.importActual<typeof import('@moonshot-ai/kimi-code-oauth')>( + '@moonshot-ai/kimi-code-oauth', + ); + return { + ...actual, + createKimiDefaultHeaders: mocks.createKimiDefaultHeaders, + createKimiDeviceId: mocks.createKimiDeviceId, + }; +}); + +vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { + const actual = await importOriginal<typeof import('@moonshot-ai/kimi-code-sdk')>(); + return { + ...actual, + resolveKimiHome: mocks.resolveKimiHome, + }; +}); + +vi.mock('@moonshot-ai/kimi-telemetry', () => ({ + initializeTelemetry: vi.fn(), + setCrashPhase: vi.fn(), + shutdownTelemetry: vi.fn(), + track: vi.fn(), + setTelemetryContext: vi.fn(), + withTelemetryContext: vi.fn(() => ({ track: vi.fn() })), +})); + +interface FakeScope { + readonly id: string; + readonly accessor: { readonly get: (token: unknown) => unknown }; + readonly dispose: ReturnType<typeof vi.fn>; +} + +function fakeScope(id: string, services: Map<unknown, unknown>): FakeScope { + return { + id, + accessor: { + get: (token: unknown) => { + if (!services.has(token)) throw new Error(`unexpected service request: ${String(token)}`); + return services.get(token); + }, + }, + dispose: vi.fn(), + }; +} + +function writer() { + let text = ''; + return { + write: vi.fn((chunk: string) => { + text += chunk; + return true; + }), + text: () => text, + }; +} + +function opts(overrides: Record<string, unknown> = {}) { + return { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: 'say hello', + skillsDirs: [], + addDirs: [], + ...overrides, + } as const; +} + +function makeFakeHarness() { + // Native event listeners registered on the main agent's IEventBus; the turn + // emits a streaming assistant delta before completing. + const eventListeners = new Set<(event: DomainEvent) => void>(); + + const agentServices = new Map<unknown, unknown>([ + [IAgentProfileService, { setModel: vi.fn(async () => ({ model: 'k2' })), getModel: () => 'k2' }], + [IAgentPermissionModeService, { mode: 'auto', setMode: vi.fn() }], + [IAuthSummaryService, { ensureReady: vi.fn(async () => {}) }], + [ + IEventBus, + { + subscribe: vi.fn((handler: (event: DomainEvent) => void) => { + eventListeners.add(handler); + return { dispose: () => eventListeners.delete(handler) }; + }), + }, + ], + [ + IAgentPromptService, + { + enqueue: vi.fn(async () => { + // Emit a native assistant delta on the main agent bus, then complete. + for (const listener of [...eventListeners]) { + listener({ type: 'assistant.delta', turnId: 1, delta: 'hello world' } as DomainEvent); + } + return { + launched: Promise.resolve({ + id: 1, + result: Promise.resolve({ type: 'completed' }), + }), + }; + }), + }, + ], + [IAgentTaskService, { list: vi.fn(() => []) }], + [IAgentGoalService, { createGoal: vi.fn(), getGoal: vi.fn() }], + ]); + const agent = fakeScope('main', agentServices); + + const sessionServices = new Map<unknown, unknown>([ + // drain enumerates agents; empty → no background work to wait on. + [IAgentLifecycleService, { list: vi.fn(() => []) }], + ]); + const session = fakeScope('ses_v2', sessionServices); + + const appServices = new Map<unknown, unknown>([ + [ + IConfigService, + { + ready: Promise.resolve(), + get: vi.fn((section: string) => (section === 'defaultModel' ? 'k2' : undefined)), + diagnostics: vi.fn(() => []), + }, + ], + [ + ISessionLifecycleService, + { + create: vi.fn(async () => session), + resume: vi.fn(async () => session), + }, + ], + [ISessionIndex, { list: vi.fn(async () => ({ items: [] })) }], + [ + IBootstrapService, + { + platform: 'linux', + arch: 'x64', + clientVersion: '1.2.3-test', + getEnv: () => undefined, + }, + ], + [IOAuthToolkit, { getCachedAccessToken: vi.fn(async () => undefined) }], + [IFileSystemStorageService, {}], + [ + ITelemetryService, + (() => { + const svc = { + setAppender: vi.fn(), + setContext: vi.fn(), + track: vi.fn(), + track2: vi.fn(), + shutdown: vi.fn(async () => {}), + withContext: vi.fn(() => svc), + }; + return svc; + })(), + ], + ]); + const app = fakeScope('app', appServices); + return { app, agent, session, agentServices }; +} + +describe('runV2Print', () => { + beforeEach(() => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); + vi.stubEnv('KIMI_MODEL_OUTPUT_FORMAT', ''); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it('submits a prompt, renders native events, awaits completion, and drains', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent, agentServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); + + const promptService = agentServices.get(IAgentPromptService) as { enqueue: ReturnType<typeof vi.fn> }; + expect(promptService.enqueue).toHaveBeenCalledWith({ + message: { + role: 'user', + content: [{ type: 'text', text: 'say hello' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }); + // Version banner is first, then the rendered assistant output. + expect(stderr.write).toHaveBeenNthCalledWith(1, 'kimi version 1.2.3-test\n'); + expect(stdout.text()).toContain('hello world'); + expect(app.dispose).toHaveBeenCalled(); + }); + + it('seeds explicit skill dirs from --skillsDir into bootstrap', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts({ skillsDirs: ['/skills'] }) as never, '1.2.3-test', { + stdout, + stderr, + }); + + const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; + const seeded = seeds.find(([id]) => id === ISkillCatalogRuntimeOptions); + expect(seeded?.[1]).toMatchObject({ explicitDirs: ['/skills'] }); + }); + + it('leaves the skill runtime options unseeded when --skillsDir is empty', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); + + const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; + expect(seeds.some(([id]) => id === ISkillCatalogRuntimeOptions)).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/cli/version.test.ts b/apps/kimi-code/test/cli/version.test.ts index f17240eff..073d5c727 100644 --- a/apps/kimi-code/test/cli/version.test.ts +++ b/apps/kimi-code/test/cli/version.test.ts @@ -1,10 +1,11 @@ import { readFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { buildKimiDefaultHeaders, + createKimiCodeUserAgent, getHostPackageJsonPath, getHostPackageRoot, getVersion, @@ -15,7 +16,7 @@ describe('cli version helpers', () => { const pkgPath = getHostPackageJsonPath(); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; - expect(pkgPath.endsWith('/apps/kimi-code/package.json')).toBe(true); + expect(pkgPath.endsWith(join('apps', 'kimi-code', 'package.json'))).toBe(true); expect(getHostPackageRoot()).toBe(dirname(pkgPath)); expect(getVersion()).toBe(pkg.version); }); @@ -25,4 +26,8 @@ describe('cli version helpers', () => { expect(headers['User-Agent']).toBe('kimi-code-cli/1.2.3'); }); + + it('builds the product user-agent for ad-hoc fetches', () => { + expect(createKimiCodeUserAgent('1.2.3')).toBe('kimi-code-cli/1.2.3'); + }); }); diff --git a/apps/kimi-code/test/cli/vis.test.ts b/apps/kimi-code/test/cli/vis.test.ts new file mode 100644 index 000000000..b611e6a37 --- /dev/null +++ b/apps/kimi-code/test/cli/vis.test.ts @@ -0,0 +1,114 @@ +/** + * `kimi vis` + * + * Verifies the CLI layer for the session visualizer: home + auto-port + * resolution, browser open vs `--no-open`, and the session deep-link path. + * Uses injected deps so no real port is bound and the real vis server is + * never started. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { handleVis, type VisDeps } from '#/cli/sub/vis'; + +function makeDeps(over: Partial<VisDeps> = {}): { + deps: VisDeps; + opened: string[]; + out: string[]; +} { + const opened: string[] = []; + const out: string[] = []; + const deps: VisDeps = { + getHomeDir: () => '/home/k', + startVisServer: vi.fn(async (o) => ({ + port: 41234, + host: '127.0.0.1', + url: 'http://127.0.0.1:41234/', + close: async () => {}, + _opts: o, + })) as unknown as VisDeps['startVisServer'], + openUrl: async (u: string) => { + opened.push(u); + }, + waitForShutdown: async () => {}, + stdout: { + write: (s: string) => { + out.push(s); + return true; + }, + }, + stderr: { write: () => true }, + exit: vi.fn() as unknown as VisDeps['exit'], + ...over, + }; + return { deps, opened, out }; +} + +describe('handleVis', () => { + it('starts the server with the home dir + auto port and opens the browser', async () => { + const { deps, opened, out } = makeDeps(); + await handleVis(deps, { open: true }); + expect(deps.startVisServer).toHaveBeenCalledWith( + expect.objectContaining({ homeDir: '/home/k', port: 0 }), + ); + expect(opened).toEqual(['http://127.0.0.1:41234/']); + expect(out.join('')).toContain('http://127.0.0.1:41234/'); + }); + + it('does not open the browser when open is false', async () => { + const { deps, opened } = makeDeps(); + await handleVis(deps, { open: false }); + expect(opened).toEqual([]); + }); + + it('deep-links to a session when sessionId is given', async () => { + const { deps, opened } = makeDeps(); + await handleVis(deps, { open: true, sessionId: 'sess_abc' }); + expect(opened[0]).toBe('http://127.0.0.1:41234/sessions/sess_abc'); + }); + + it('uses the explicit port when provided', async () => { + const { deps } = makeDeps(); + await handleVis(deps, { open: false, port: 4321 }); + expect(deps.startVisServer).toHaveBeenCalledWith( + expect.objectContaining({ homeDir: '/home/k', port: 4321 }), + ); + }); + + it('closes the server after shutdown', async () => { + const close = vi.fn(async () => {}); + const { deps } = makeDeps({ + startVisServer: vi.fn(async () => ({ + port: 41234, + host: '127.0.0.1', + url: 'http://127.0.0.1:41234/', + close, + })) as unknown as VisDeps['startVisServer'], + }); + await handleVis(deps, { open: false }); + expect(close).toHaveBeenCalledOnce(); + }); + + it('reports a clean error and exits when the server fails to start', async () => { + const errored: string[] = []; + const { deps, opened } = makeDeps({ + startVisServer: vi.fn(async () => { + throw new Error('listen EADDRINUSE: address already in use 127.0.0.1:4321'); + }) as unknown as VisDeps['startVisServer'], + stderr: { + write: (s: string) => { + errored.push(s); + return true; + }, + }, + waitForShutdown: vi.fn(async () => {}), + }); + await handleVis(deps, { open: true, port: 4321 }); + expect(errored.join('')).toContain('Failed to start kimi vis'); + expect(errored.join('')).toContain('EADDRINUSE'); + expect(deps.exit).toHaveBeenCalledWith(1); + // Nothing past the failed start should run. + expect(opened).toEqual([]); + expect(deps.waitForShutdown).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/e2e/local-logging-export.e2e.test.ts b/apps/kimi-code/test/e2e/local-logging-export.e2e.test.ts index 8421105f0..1bb985f26 100644 --- a/apps/kimi-code/test/e2e/local-logging-export.e2e.test.ts +++ b/apps/kimi-code/test/e2e/local-logging-export.e2e.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { registerExportCommand } from '#/cli/sub/export'; import { createKimiCodeHostIdentity } from '#/cli/version'; -import { KimiHarness, log } from '@moonshot-ai/kimi-code-sdk'; +import { createKimiHarness, log } from '@moonshot-ai/kimi-code-sdk'; import { __resetRootLoggerForTest } from '../../../../packages/agent-core/src/logging/logger'; const SESSION_LOG = 'logs/kimi-code.log'; @@ -19,13 +19,16 @@ const ENABLED = process.env['KIMI_E2E'] === '1'; let homeDir: string; let workDir: string; let oldHome: string | undefined; +let oldLogLevel: string | undefined; beforeEach(async () => { await __resetRootLoggerForTest(); homeDir = await mkdtemp(join(tmpdir(), 'kimi-cli-log-home-')); workDir = await mkdtemp(join(tmpdir(), 'kimi-cli-log-work-')); oldHome = process.env['KIMI_CODE_HOME']; + oldLogLevel = process.env['KIMI_LOG_LEVEL']; process.env['KIMI_CODE_HOME'] = homeDir; + process.env['KIMI_LOG_LEVEL'] = 'info'; }); afterEach(async () => { @@ -35,13 +38,18 @@ afterEach(async () => { } else { process.env['KIMI_CODE_HOME'] = oldHome; } + if (oldLogLevel === undefined) { + delete process.env['KIMI_LOG_LEVEL']; + } else { + process.env['KIMI_LOG_LEVEL'] = oldLogLevel; + } await rm(homeDir, { recursive: true, force: true }); await rm(workDir, { recursive: true, force: true }); }); describe.skipIf(!ENABLED)('local logging export e2e', () => { it('exports session log and global log by default, and allows skipping global log', async () => { - const harness = new KimiHarness({ + const harness = createKimiHarness({ homeDir, identity: createKimiCodeHostIdentity('0.1.1'), }); diff --git a/apps/kimi-code/test/e2e/real-llm-smoke.e2e.test.ts b/apps/kimi-code/test/e2e/real-llm-smoke.e2e.test.ts index ffa93ba06..b31faefd0 100644 --- a/apps/kimi-code/test/e2e/real-llm-smoke.e2e.test.ts +++ b/apps/kimi-code/test/e2e/real-llm-smoke.e2e.test.ts @@ -14,7 +14,7 @@ import { mkdirSync } from 'node:fs'; import process from 'node:process'; -import { KimiHarness, type Event } from '@moonshot-ai/kimi-code-sdk'; +import { createKimiHarness, type Event } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, test } from 'vitest'; import { createKimiCodeHostIdentity, getVersion } from '#/cli/version'; @@ -41,7 +41,7 @@ describe.skipIf(!ENABLED)('SDK e2e — real LLM smoke', () => { `[smoke] prompt=${JSON.stringify(prompt)}\n`, ); - const harness = new KimiHarness({ + const harness = createKimiHarness({ identity: createKimiCodeHostIdentity(version), }); diff --git a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts new file mode 100644 index 000000000..8903d1626 --- /dev/null +++ b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -0,0 +1,406 @@ +import { execFile } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, mkdir, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { removeStaleFeedbackUploads } from '../../../src/feedback/archive'; +import { packageCodebase, scanCodebase } from '../../../src/feedback/codebase'; +import { uploadArchive } from '../../../src/feedback/upload'; + +const execFileAsync = promisify(execFile); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('uploadArchive', () => { + it('requests upload parts, PUTs each part, and completes with etags', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-direct-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn( + async () => new Response('', { status: 200, headers: { ETag: '"etag-1"' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip' }, + ); + + expect(api.createUploadUrl).toHaveBeenCalledWith({ + feedbackId: 3, + filename: 'repo.zip', + size: 5, + sha256: 'hash', + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://example.test/part1'); + expect(init.method).toBe('PUT'); + expect(init.body).toBeInstanceOf(ReadableStream); + expect((init as { duplex?: string }).duplex).toBe('half'); + expect(new Headers(init.headers).get('content-length')).toBe('5'); + // Drain the stream so the underlying file handle is released. + expect(await new Response(init.body as ReadableStream).text()).toBe('hello'); + expect(api.completeUpload).toHaveBeenCalledWith({ + uploadId: 28, + parts: [{ partNumber: 1, etag: '"etag-1"' }], + }); + } finally { + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('uses the backend-provided part upload method', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-method-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn( + async () => new Response('', { status: 200, headers: { ETag: '"etag-1"' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'POST', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip' }, + ); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(init.method).toBe('POST'); + expect(await new Response(init.body as ReadableStream).text()).toBe('hello'); + } finally { + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('aborts a stalled part PUT and does not mark upload complete', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-stalled-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn((_url: string, init?: RequestInit) => + new Promise<Response>((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + return; + } + signal?.addEventListener( + 'abort', + () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }, + { once: true }, + ); + }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip', timeoutMs: 25, maxRetries: 0 }, + ); + const expectation = expect(upload).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(25); + await expectation; + expect(api.completeUpload).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('retries a failed part and completes once it succeeds', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-retry-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + let attempt = 0; + const fetchMock = vi.fn(async () => { + attempt += 1; + if (attempt === 1) return new Response('server error', { status: 500 }); + return new Response('', { status: 200, headers: { ETag: '"etag-1"' } }); + }); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip', timeoutMs: 10_000 }, + ); + await vi.advanceTimersByTimeAsync(1_000); + await upload; + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(api.completeUpload).toHaveBeenCalledWith({ + uploadId: 28, + parts: [{ partNumber: 1, etag: '"etag-1"' }], + }); + } finally { + vi.useRealTimers(); + await rm(workRoot, { recursive: true, force: true }); + } + }); +}); + +describe('packageCodebase', () => { + it('rejects empty codebase archives instead of uploading an empty zip', async () => { + const archivePath = join(tmpdir(), 'feedback-empty-codebase.zip'); + try { + await expect( + packageCodebase( + { + root: tmpdir(), + files: [], + fingerprint: 'empty-codebase', + usedGitIgnore: false, + }, + archivePath, + ), + ).rejects.toThrow(/empty/i); + await expect(stat(archivePath)).rejects.toThrow(); + } finally { + await rm(archivePath, { force: true }); + } + }); +}); + + +describe('scanCodebase filtering', () => { + it('rejects when the scan signal is already aborted', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-aborted-')); + const controller = new AbortController(); + controller.abort(); + try { + await expect(scanCodebase(root, { signal: controller.signal })).rejects.toMatchObject({ + name: 'AbortError', + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips dependency and build directories outside a git work tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-no-git-')); + try { + await mkdir(join(root, 'node_modules', 'pkg'), { recursive: true }); + await mkdir(join(root, 'dist')); + await writeFile(join(root, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1;\n'); + await writeFile(join(root, 'dist', 'bundle.js'), 'built\n'); + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(false); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('filters sensitive files even when tracked by git', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-git-')); + try { + await writeFile(join(root, '.env'), 'SECRET=1\n'); + await writeFile(join(root, '.envrc'), 'export AWS_SECRET_ACCESS_KEY=secret\n'); + await writeFile(join(root, '.npmrc'), '//registry.npmjs.org/:_authToken=secret\n'); + await writeFile(join(root, '.yarnrc.yml'), 'npmAuthToken: secret\n'); + await writeFile(join(root, 'id_rsa'), 'private-key\n'); + await writeFile(join(root, 'app.ts'), 'export const app = 1;\n'); + await execFileAsync('git', ['init'], { cwd: root }); + await execFileAsync('git', ['add', '-A'], { cwd: root }); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(true); + const paths = scan.files.map((file) => file.path); + expect(paths).toContain('app.ts'); + expect(paths).not.toContain('.env'); + expect(paths).not.toContain('.envrc'); + expect(paths).not.toContain('.npmrc'); + expect(paths).not.toContain('.yarnrc.yml'); + expect(paths).not.toContain('id_rsa'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('filters sensitive files by glob outside a git work tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-sensitive-')); + try { + await mkdir(join(root, '.ssh')); + await writeFile(join(root, '.env.production'), 'SECRET=1\n'); + await writeFile(join(root, 'tls.pem'), 'cert\n'); + await writeFile(join(root, '.ssh', 'config'), 'Host *\n'); + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + + const scan = await scanCodebase(root); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips individual files larger than the per-file limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-large-file-')); + try { + await writeFile(join(root, 'big.bin'), randomBytes(256)); + await writeFile(join(root, 'small.txt'), 'hello\n'); + + const scan = await scanCodebase(root, { limits: { maxFileSize: 128 } }); + expect(scan.files.map((file) => file.path)).toEqual(['small.txt']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips tracked files that were deleted from the working tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-deleted-')); + try { + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + await writeFile(join(root, 'deleted.ts'), 'export const gone = 1;\n'); + await execFileAsync('git', ['init'], { cwd: root }); + await execFileAsync('git', ['add', '-A'], { cwd: root }); + // Remove only from the working tree; the index still lists it, so + // `git ls-files` reports a path that no longer exists on disk. + await rm(join(root, 'deleted.ts')); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(true); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('marks exceedsLimit when file count reaches the limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-limit-')); + try { + await writeFile(join(root, 'a.txt'), 'a\n'); + await writeFile(join(root, 'b.txt'), 'b\n'); + await writeFile(join(root, 'c.txt'), 'c\n'); + + const scan = await scanCodebase(root, { limits: { maxFiles: 2 } }); + expect(scan.files).toHaveLength(2); + expect(scan.exceedsLimit).toEqual({ reason: 'file-count', limit: 2 }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('marks exceedsLimit when cumulative file size reaches the archive limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-total-size-')); + try { + await writeFile(join(root, 'a.txt'), 'a'.repeat(100)); + await writeFile(join(root, 'b.txt'), 'b'.repeat(100)); + await writeFile(join(root, 'c.txt'), 'c'.repeat(100)); + + // 250 bytes fits any two files (200) but not the third (300). + const scan = await scanCodebase(root, { limits: { maxArchiveSize: 250 } }); + expect(scan.files).toHaveLength(2); + expect(scan.exceedsLimit).toEqual({ reason: 'total-size', limit: 250 }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe('removeStaleFeedbackUploads', () => { + it('removes archive dirs older than the cutoff and keeps recent ones', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-uploads-gc-')); + try { + const staleDir = join(root, 'stale'); + const freshDir = join(root, 'fresh'); + await mkdir(staleDir); + await mkdir(freshDir); + await writeFile(join(staleDir, 'repo.zip'), 'old'); + await writeFile(join(freshDir, 'repo.zip'), 'new'); + + const now = Date.now(); + const twoDaysAgoSec = (now - 2 * 24 * 60 * 60 * 1000) / 1000; + await utimes(staleDir, twoDaysAgoSec, twoDaysAgoSec); + + await removeStaleFeedbackUploads({ now, dir: root }); + + await expect(stat(staleDir)).rejects.toThrow(); + await expect(stat(freshDir)).resolves.toBeDefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('is a no-op when the cache dir does not exist', async () => { + const missing = join(tmpdir(), 'feedback-uploads-gc-missing-' + String(Date.now())); + await expect(removeStaleFeedbackUploads({ dir: missing })).resolves.toBeUndefined(); + }); +}); diff --git a/apps/kimi-code/test/migration/migration-screen.test.ts b/apps/kimi-code/test/migration/migration-screen.test.ts index 240cea9a5..da70d5309 100644 --- a/apps/kimi-code/test/migration/migration-screen.test.ts +++ b/apps/kimi-code/test/migration/migration-screen.test.ts @@ -35,7 +35,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); const out = render(c); @@ -55,7 +54,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); const out = render(c); @@ -69,7 +67,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { result = r; }, @@ -85,7 +82,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, runMigration: async (input) => { captured = input; return makeReport(); @@ -104,7 +100,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, runMigration: async (input) => { captured = input; return makeReport(); @@ -123,7 +118,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan({ totalSessions: 1365 }), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c.handleInput('\r'); // ask1: Migrate now -> ask2 @@ -140,7 +134,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan({ totalSessions: 0 }), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c.handleInput('\r'); // ask1 -> ask2 @@ -155,7 +148,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, onComplete: () => {}, }); @@ -171,7 +163,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, runMigration: async (input) => { captured = input; @@ -191,7 +182,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); // expose progress rendering via the test hook (see Step 5.2) @@ -211,7 +201,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, // A migration that never settles keeps the screen in the progress // phase so the spinner animation can be observed. @@ -236,7 +225,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testEnterProgress(); @@ -312,7 +300,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport()); @@ -327,7 +314,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -361,7 +347,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { result = r; }, @@ -377,7 +362,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); // config skipped (e.g. a malformed legacy config.toml). @@ -413,7 +397,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -454,7 +437,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -496,7 +478,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport({ sessionsSkippedEmpty: 3 })); @@ -511,7 +492,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -544,7 +524,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport({}, {}, { mcpOauthServersRequiringReauth: ['srv-a', 'srv-b'] })); @@ -561,7 +540,6 @@ describe('MigrationScreenComponent — execution wiring', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { onCompleteResult = r; }, @@ -578,12 +556,11 @@ describe('MigrationScreenComponent — execution wiring', () => { expect(onCompleteResult?.migrated).toBe(true); }); - it('lands on the failure screen when the runner rejects', async () => { + it('lands on the failure screen with the runner rejection reason', async () => { const c = new MigrationScreenComponent({ plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, runMigration: async () => { throw new Error('boom'); @@ -592,6 +569,8 @@ describe('MigrationScreenComponent — execution wiring', () => { c.handleInput('\r'); // ask1: Migrate now c.handleInput('\r'); // ask2: Config only -> begins migration await new Promise((res) => setTimeout(res, 0)); - expect(c.render(80).join('\n')).toContain('Migration failed'); + const out = c.render(80).join('\n'); + expect(out).toContain('Migration failed'); + expect(out).toContain('Reason: boom'); }); }); diff --git a/apps/kimi-code/test/native/web-assets.test.ts b/apps/kimi-code/test/native/web-assets.test.ts new file mode 100644 index 000000000..abe3801fe --- /dev/null +++ b/apps/kimi-code/test/native/web-assets.test.ts @@ -0,0 +1,113 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + getNativeWebAssetsDir, + getWebAssetCacheRoot, + WEB_ASSET_MANIFEST_VERSION, + type WebAssetManifest, + type WebAssetSource, +} from '#/native/web-assets'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function fakeWebAssets(files: Record<string, string>): { + manifest: WebAssetManifest; + source: WebAssetSource; +} { + const manifest: WebAssetManifest = { + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + files: Object.entries(files).map(([relativePath, content]) => ({ + assetKey: `web/test-target/dist-web/${relativePath}`, + relativePath, + sha256: sha256(content), + })), + }; + const assets = new Map<string, Buffer>([ + ['web/test-target/manifest.json', Buffer.from(JSON.stringify(manifest))], + ...Object.entries(files).map(([relativePath, content]) => [ + `web/test-target/dist-web/${relativePath}`, + Buffer.from(content), + ] as const), + ]); + return { + manifest, + source: { + getAssetKeys: () => [...assets.keys()], + getRawAsset: (assetKey) => { + const asset = assets.get(assetKey); + if (asset === undefined) throw new Error(`missing test asset: ${assetKey}`); + return asset; + }, + }, + }; +} + +describe('web assets', () => { + it('extracts embedded web assets into a dist-web cache directory', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-runtime-')); + try { + const { manifest, source } = fakeWebAssets({ + 'index.html': '<div id="app"></div>\n', + 'assets/app.js': 'console.log("ok");\n', + }); + + const webDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(webDir).toBe(getWebAssetCacheRoot(manifest, { cacheBase: dir, version: 'test' })); + expect(readFileSync(join(webDir ?? '', 'index.html'), 'utf-8')).toBe('<div id="app"></div>\n'); + expect(readFileSync(join(webDir ?? '', 'assets', 'app.js'), 'utf-8')).toBe( + 'console.log("ok");\n', + ); + expect(existsSync(join(dir, 'web', 'test', 'test-target'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('repairs corrupted extracted files on the next lookup', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-repair-')); + try { + const { manifest, source } = fakeWebAssets({ + 'index.html': '<html></html>', + }); + + const webDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + writeFileSync(join(webDir ?? '', 'index.html'), 'broken'); + + const repairedDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(repairedDir).toBe(webDir); + expect(readFileSync(join(repairedDir ?? '', 'index.html'), 'utf-8')).toBe('<html></html>'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns null when no SEA web asset source is available', () => { + expect(getNativeWebAssetsDir({ source: null })).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/native-deps.test.ts b/apps/kimi-code/test/scripts/native/native-deps.test.ts index c96b642e6..980f1c771 100644 --- a/apps/kimi-code/test/scripts/native/native-deps.test.ts +++ b/apps/kimi-code/test/scripts/native/native-deps.test.ts @@ -41,7 +41,7 @@ describe('resolveTargetDeps', () => { const names = deps.map((d) => d.resolvedName); expect(names).toContain('@mariozechner/clipboard'); expect(names).toContain('@mariozechner/clipboard-darwin-arm64'); - expect(names).toContain('koffi'); + expect(names).toContain('@moonshot-ai/pi-tui'); }); it('picks the right clipboard subpackage per target', () => { @@ -56,13 +56,23 @@ describe('resolveTargetDeps', () => { ).toContain('@mariozechner/clipboard-win32-arm64-msvc'); }); - it('encodes koffi native file path with target triplet', () => { - const linuxKoffi = resolveTargetDeps('linux-arm64').find((d) => d.resolvedName === 'koffi'); - expect(linuxKoffi?.nativeFileRelatives).toEqual(['build/koffi/linux_arm64/koffi.node']); - const macKoffi = resolveTargetDeps('darwin-x64').find((d) => d.resolvedName === 'koffi'); - expect(macKoffi?.nativeFileRelatives).toEqual(['build/koffi/darwin_x64/koffi.node']); - const winArmKoffi = resolveTargetDeps('win32-arm64').find((d) => d.resolvedName === 'koffi'); - expect(winArmKoffi?.nativeFileRelatives).toEqual(['build/koffi/win32_arm64/koffi.node']); + it('encodes pi-tui native file path per target', () => { + const linuxPiTui = resolveTargetDeps('linux-arm64').find( + (d) => d.resolvedName === '@moonshot-ai/pi-tui', + ); + expect(linuxPiTui?.nativeFileRelatives).toEqual([]); + const macPiTui = resolveTargetDeps('darwin-x64').find( + (d) => d.resolvedName === '@moonshot-ai/pi-tui', + ); + expect(macPiTui?.nativeFileRelatives).toEqual([ + 'native/darwin/prebuilds/darwin-x64/darwin-modifiers.node', + ]); + const winArmPiTui = resolveTargetDeps('win32-arm64').find( + (d) => d.resolvedName === '@moonshot-ai/pi-tui', + ); + expect(winArmPiTui?.nativeFileRelatives).toEqual([ + 'native/win32/prebuilds/win32-arm64/win32-console-mode.node', + ]); }); it('throws on unsupported target', () => { @@ -82,9 +92,9 @@ describe('nativeDeps registry shape', () => { expect(target?.parent).toBe('clipboard-host'); }); - it('has koffi (collect=js-and-native-file, parent=pi-tui)', () => { - const koffi = nativeDeps.find((d) => d.id === 'koffi'); - expect(koffi?.collect).toBe('js-and-native-file'); - expect(koffi?.parent).toBe('pi-tui'); + it('has pi-tui (collect=native-file-only, no parent)', () => { + const piTui = nativeDeps.find((d) => d.id === 'pi-tui'); + expect(piTui?.collect).toBe('native-file-only'); + expect(piTui?.parent).toBe(null); }); }); diff --git a/apps/kimi-code/test/scripts/native/paths.test.ts b/apps/kimi-code/test/scripts/native/paths.test.ts index 80209f457..826ade0ba 100644 --- a/apps/kimi-code/test/scripts/native/paths.test.ts +++ b/apps/kimi-code/test/scripts/native/paths.test.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { @@ -18,6 +20,10 @@ import { SEA_SENTINEL_FUSE, } from '../../../scripts/native/paths.mjs'; +// paths.mjs builds every path with node:path.resolve (backslashes on Windows). +// Build expectations the same way so they match on every platform. +const p = (...segments: string[]): string => resolve(appRoot, ...segments); + describe('targetTriple', () => { it('returns platform-arch when env unset', () => { expect(targetTriple({ platform: 'darwin', arch: 'arm64', env: {} })).toBe('darwin-arm64'); @@ -49,27 +55,27 @@ describe('executableName', () => { describe('path helpers', () => { it('returns absolute intermediates dir under app root', () => { - expect(nativeIntermediatesDir()).toBe(`${appRoot}/dist-native/intermediates`); + expect(nativeIntermediatesDir()).toBe(p('dist-native/intermediates')); }); it('returns absolute bin dir per target', () => { - expect(nativeBinDir('darwin-arm64')).toBe(`${appRoot}/dist-native/bin/darwin-arm64`); + expect(nativeBinDir('darwin-arm64')).toBe(p('dist-native/bin/darwin-arm64')); }); it('returns absolute bin path with executable name', () => { expect(nativeBinPath('darwin-arm64', 'darwin')).toBe( - `${appRoot}/dist-native/bin/darwin-arm64/kimi`, + p('dist-native/bin/darwin-arm64/kimi'), ); expect(nativeBinPath('win32-x64', 'win32')).toBe( - `${appRoot}/dist-native/bin/win32-x64/kimi.exe`, + p('dist-native/bin/win32-x64/kimi.exe'), ); }); it('returns intermediate artifact paths', () => { - expect(nativeJsBundlePath()).toBe(`${appRoot}/dist-native/intermediates/main.cjs`); - expect(nativeBlobPath()).toBe(`${appRoot}/dist-native/intermediates/kimi.blob`); + expect(nativeJsBundlePath()).toBe(p('dist-native/intermediates/main.cjs')); + expect(nativeBlobPath()).toBe(p('dist-native/intermediates/kimi.blob')); expect(nativeSeaConfigPath()).toBe( - `${appRoot}/dist-native/intermediates/sea-config.json`, + p('dist-native/intermediates/sea-config.json'), ); }); @@ -78,21 +84,21 @@ describe('path helpers', () => { }); it('returns native dist root', () => { - expect(nativeDistRoot()).toBe(`${appRoot}/dist-native`); + expect(nativeDistRoot()).toBe(p('dist-native')); }); it('returns manifest dir for target', () => { expect(nativeManifestDir('darwin-arm64')).toBe( - `${appRoot}/dist-native/intermediates/native-assets/darwin-arm64`, + p('dist-native/intermediates/native-assets/darwin-arm64'), ); }); it('returns artifacts dir', () => { - expect(nativeArtifactsDir()).toBe(`${appRoot}/dist-native/artifacts`); + expect(nativeArtifactsDir()).toBe(p('dist-native/artifacts')); }); it('returns smoke home', () => { - expect(nativeSmokeHome()).toBe(`${appRoot}/dist-native/smoke-home`); + expect(nativeSmokeHome()).toBe(p('dist-native/smoke-home')); }); it('has correct SEA sentinel fuse value', () => { diff --git a/apps/kimi-code/test/scripts/native/web-assets.test.ts b/apps/kimi-code/test/scripts/native/web-assets.test.ts new file mode 100644 index 000000000..2b5bfa920 --- /dev/null +++ b/apps/kimi-code/test/scripts/native/web-assets.test.ts @@ -0,0 +1,89 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + collectWebAssets, + webAssetManifestKey, + WEB_ASSET_MANIFEST_VERSION, +} from '../../../scripts/native/web-assets.mjs'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +describe('collectWebAssets', () => { + it('collects dist-web files into deterministic SEA asset keys', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-build-')); + try { + mkdirSync(join(appRoot, 'dist-web', 'assets'), { recursive: true }); + writeFileSync(join(appRoot, 'dist-web', 'index.html'), '<div id="app"></div>\n'); + writeFileSync(join(appRoot, 'dist-web', 'assets', 'app.js'), 'console.log("ok");\n'); + + const { manifest, manifestJson, assets } = await collectWebAssets({ + appRoot, + target: 'test-target', + }); + + expect(webAssetManifestKey('test-target')).toBe('web/test-target/manifest.json'); + expect(manifest).toEqual({ + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + files: [ + { + assetKey: 'web/test-target/dist-web/assets/app.js', + relativePath: 'assets/app.js', + sha256: sha256('console.log("ok");\n'), + }, + { + assetKey: 'web/test-target/dist-web/index.html', + relativePath: 'index.html', + sha256: sha256('<div id="app"></div>\n'), + }, + ], + }); + expect(JSON.parse(manifestJson) as unknown).toEqual(manifest); + expect(assets).toEqual({ + 'web/test-target/dist-web/assets/app.js': join(appRoot, 'dist-web', 'assets', 'app.js'), + 'web/test-target/dist-web/index.html': join(appRoot, 'dist-web', 'index.html'), + }); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); + + it('fails clearly when dist-web has not been built', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-missing-')); + try { + await expect(collectWebAssets({ appRoot, target: 'test-target' })).rejects.toThrow( + /Kimi web build output was not found/, + ); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); + + it('keeps manifest JSON parseable and stable', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-json-')); + try { + mkdirSync(join(appRoot, 'dist-web'), { recursive: true }); + writeFileSync(join(appRoot, 'dist-web', 'index.html'), '<html></html>'); + + const { manifestJson } = await collectWebAssets({ appRoot, target: 'test-target' }); + + expect(readFileSync(join(appRoot, 'dist-web', 'index.html'), 'utf-8')).toBe('<html></html>'); + expect(manifestJson.endsWith('\n')).toBe(true); + expect(JSON.parse(manifestJson)).toMatchObject({ + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + }); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index ec954e77e..0bcae749a 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; +import { AgentSwarmProgressComponent } from '#/tui/components/messages/agent-swarm-progress'; +import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; interface ActivityDriver { state: TUIState; + sessionEventHandler: SessionEventHandler; updateActivityPane(): void; } +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + function makeStartupInput(): KimiTUIStartupInput { return { cliOptions: { @@ -22,12 +29,13 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } @@ -40,9 +48,36 @@ function makeDriverWithTerminalProgress(): { const driver = new KimiTUI({} as never, makeStartupInput()) as unknown as ActivityDriver; vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); driver.state.terminal = { columns: 80, setProgress } as unknown as TUIState['terminal']; + driver.state.terminalState.supportsProgress = true; return { driver, state: driver.state, setProgress }; } +function startSwarmProgress(driver: ActivityDriver, state: TUIState): AgentSwarmProgressComponent { + const handler = driver.sessionEventHandler.subAgentEventHandler; + handler.handleAgentSwarmToolCallStarted('call_swarm', { + description: 'Review changed files', + }); + handler.handleLifecycleEvent({ + type: 'subagent.spawned', + subagentId: 'agent-1', + subagentName: 'coder', + parentToolCallId: 'call_swarm', + description: 'Review changed files #1 (coder)', + swarmIndex: 1, + runInBackground: false, + } as Parameters<typeof handler.handleLifecycleEvent>[0]); + handler.handleLifecycleEvent({ + type: 'subagent.started', + subagentId: 'agent-1', + } as Parameters<typeof handler.handleLifecycleEvent>[0]); + + const progress = state.transcriptContainer.children.find( + (child): child is AgentSwarmProgressComponent => child instanceof AgentSwarmProgressComponent, + ); + if (progress === undefined) throw new Error('expected AgentSwarm progress'); + return progress; +} + describe('updateActivityPane terminal progress', () => { it('toggles terminal progress when the activity pane enters and leaves work mode', () => { vi.useFakeTimers(); @@ -67,6 +102,24 @@ describe('updateActivityPane terminal progress', () => { } }); + it('never emits terminal progress when the terminal does not support OSC 9;4', () => { + vi.useFakeTimers(); + try { + const { driver, state, setProgress } = makeDriverWithTerminalProgress(); + state.terminalState.supportsProgress = false; + + state.livePane = { ...state.livePane, mode: 'waiting' }; + driver.updateActivityPane(); + state.livePane = { ...state.livePane, mode: 'idle' }; + driver.updateActivityPane(); + + expect(setProgress).not.toHaveBeenCalled(); + expect(state.terminalState.progressActive).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it('keeps compaction visible as terminal progress even though the pane is hidden', () => { const { driver, state, setProgress } = makeDriverWithTerminalProgress(); state.appState.isCompacting = true; @@ -110,4 +163,57 @@ describe('updateActivityPane terminal progress', () => { vi.useRealTimers(); } }); + + it('moves the moon spinner into the AgentSwarm progress row while active', () => { + vi.useFakeTimers(); + try { + const { driver, state, setProgress } = makeDriverWithTerminalProgress(); + const progress = startSwarmProgress(driver, state); + state.livePane = { ...state.livePane, mode: 'tool' }; + + driver.updateActivityPane(); + + expect(setProgress).toHaveBeenCalledTimes(1); + expect(setProgress).toHaveBeenLastCalledWith(true); + expect(state.activitySpinner).not.toBeNull(); + expect(state.activityContainer.children).toHaveLength(0); + expect(strip(progress.render(80).join('\n'))).toContain('🌑 Working...'); + + state.activitySpinner?.instance.stop(); + driver.sessionEventHandler.clearAgentSwarmProgress(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps ended AgentSwarm progress on a placeholder instead of the moon spinner', () => { + vi.useFakeTimers(); + try { + const { driver, state } = makeDriverWithTerminalProgress(); + const progress = startSwarmProgress(driver, state); + driver.sessionEventHandler.subAgentEventHandler.handleAgentSwarmToolResult( + 'call_swarm', + { + tool_call_id: 'call_swarm', + output: 'Done', + is_error: false, + }, + false, + ); + state.livePane = { ...state.livePane, mode: 'tool' }; + + driver.updateActivityPane(); + + expect(state.activitySpinner).not.toBeNull(); + expect(state.activityContainer.children).toHaveLength(1); + const output = strip(progress.render(80).join('\n')); + expect(output).toContain(' Working...'); + expect(output).not.toContain('🌑 Working...'); + + state.activitySpinner?.instance.stop(); + driver.sessionEventHandler.clearAgentSwarmProgress(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/kimi-code/test/tui/background-task-status.test.ts b/apps/kimi-code/test/tui/background-task-status.test.ts index e8033d45f..8b7981ac6 100644 --- a/apps/kimi-code/test/tui/background-task-status.test.ts +++ b/apps/kimi-code/test/tui/background-task-status.test.ts @@ -4,17 +4,34 @@ import { describe, expect, it } from 'vitest'; import { formatBackgroundTaskTranscript } from '@/tui/utils/background-task-status'; function task(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { - return { - taskId: 'bash-abcd1234', - command: 'npm run dev', + const taskId = overrides.taskId ?? 'bash-abcd1234'; + const kind = overrides.kind ?? (taskId.startsWith('agent-') ? 'agent' : 'process'); + const base = { + taskId, + kind, description: 'dev server', status: 'running', - pid: 1234, - exitCode: null, startedAt: Date.now() - 1000, endedAt: null, ...overrides, }; + if (kind === 'agent') { + return { + ...base, + kind: 'agent', + agentId: 'agent-child', + subagentType: 'coder', + ...overrides, + } as BackgroundTaskInfo; + } + return { + ...base, + kind: 'process', + command: 'npm run dev', + pid: 1234, + exitCode: null, + ...overrides, + } as BackgroundTaskInfo; } describe('formatBackgroundTaskTranscript', () => { @@ -32,6 +49,18 @@ describe('formatBackgroundTaskTranscript', () => { expect(data.headline).toContain('agent task started'); }); + it('renders a question started entry', () => { + const data = formatBackgroundTaskTranscript( + task({ + taskId: 'question-deadbeef', + kind: 'question', + questionCount: 1, + status: 'running', + }), + ); + expect(data.headline).toContain('question task started'); + }); + it('renders a completed entry with exit code in detail', () => { const data = formatBackgroundTaskTranscript( task({ status: 'completed', exitCode: 0, endedAt: Date.now() }), @@ -66,22 +95,11 @@ describe('formatBackgroundTaskTranscript', () => { expect(data.detail).toContain('session restarted'); }); - it('surfaces awaiting_approval reason', () => { - const data = formatBackgroundTaskTranscript( - task({ status: 'awaiting_approval', approvalReason: 'needs network' }), - ); - expect(data.phase).toBe('started'); - expect(data.headline).toContain('awaiting'); - expect(data.detail).toContain('needs network'); - }); - - it('surfaces timedOut for agent deadlines', () => { + it('surfaces timeout stop reason for agent deadlines', () => { const data = formatBackgroundTaskTranscript( task({ taskId: 'agent-aaaaaaaa', - status: 'failed', - exitCode: 1, - timedOut: true, + status: 'timed_out', endedAt: Date.now(), }), ); @@ -91,9 +109,9 @@ describe('formatBackgroundTaskTranscript', () => { it('handles every BackgroundTaskStatus without throwing', () => { const statuses: BackgroundTaskStatus[] = [ 'running', - 'awaiting_approval', 'completed', 'failed', + 'timed_out', 'killed', 'lost', ]; diff --git a/apps/kimi-code/test/tui/banner/banner-provider.test.ts b/apps/kimi-code/test/tui/banner/banner-provider.test.ts new file mode 100644 index 000000000..0fce5984a --- /dev/null +++ b/apps/kimi-code/test/tui/banner/banner-provider.test.ts @@ -0,0 +1,605 @@ +import { describe, expect, it } from 'vitest'; + +import { + selectBannerState, + selectDisplayableBanner, + shouldDisplayBanner, +} from '#/tui/banner/banner-provider'; +import type { BannerState } from '#/tui/types'; + +describe('selectBannerState', () => { + const now = new Date('2026-06-15T12:00:00+08:00'); + + function expectAlwaysBanner( + result: BannerState | null, + expected: Pick<BannerState, 'tag' | 'mainText' | 'subText'>, + ): BannerState { + expect(result).not.toBeNull(); + const banner = result!; + expect(banner).toMatchObject({ ...expected, display: 'always' }); + expect(banner.key).toEqual(expect.any(String)); + expect(banner.ttlHours).toBeUndefined(); + return banner; + } + + it('returns the active banner when enabled and no time window is set', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'New', + banner_maintext: 'Active', + banner_subtext: 'Details', + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: 'New', mainText: 'Active', subText: 'Details' }); + }); + + it('returns null when the active banner is outside its time window', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'Old', + banner_maintext: 'Expired', + banner_start_time: '2026-05-01T00:00:00+08:00', + banner_end_time: '2026-05-31T00:00:00+08:00', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('filters out the active banner when the client version is too low', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'New', + banner_min_version: '0.15.0', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('picks a random enabled fallback when the active banner is not shown', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { enabled: true, banner_title: 'Tip', banner_maintext: 'First' }, + { enabled: true, banner_title: 'Tip', banner_maintext: 'Second' }, + ], + }, + '0.14.0', + now, + () => 0.75, + ); + expectAlwaysBanner(result, { tag: 'Tip', mainText: 'Second', subText: null }); + }); + + it('filters out fallback entries when the client version is too low', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { enabled: true, banner_maintext: 'Old tip' }, + { enabled: true, banner_maintext: 'New tip', banner_min_version: '0.15.0' }, + ], + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Old tip', subText: null }); + }); + + it('returns null when no enabled fallback entries exist', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: false, banner_maintext: 'Hidden' }], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('returns null for malformed input fields', () => { + expect(selectBannerState({ weird: true }, '0.14.0', now, () => 0)).toBeNull(); + }); + + it('falls back to the fallback list when banner_enabled is missing', () => { + const result = selectBannerState( + { + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: true, banner_maintext: 'Fallback' }], + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Fallback', subText: null }); + }); + + it('treats an empty tag as null while still showing the banner', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: '', + banner_maintext: 'No tag', + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'No tag', subText: null }); + }); + + it('makes the active banner unavailable when mainText is empty', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'New', + banner_maintext: '', + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: true, banner_maintext: 'Fallback' }], + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Fallback', subText: null }); + }); + + it('treats missing subtext as null', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'Main only', + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Main only', subText: null }); + }); + + it('treats empty time fields as always valid', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'Always on', + banner_start_time: '', + banner_end_time: null, + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Always on', subText: null }); + }); + + it('falls back to UTC when timestamps have no timezone', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'UTC fallback', + banner_start_time: '2026-06-15T04:00:00', + banner_end_time: '2026-06-15T20:00:00', + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'UTC fallback', subText: null }); + }); + + it('returns null when the fallback list is empty', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('returns null when the fallback list is missing', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('uses banner_id as the banner key when present', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ key: 'active-1', display: 'always' }); + }); + + it('generates a stable hash key when banner_id is missing', () => { + const json = { + banner_enabled: true, + banner_title: 'New', + banner_maintext: 'Active', + banner_subtext: 'Details', + }; + + const first = selectBannerState(json, '0.14.0', now, () => 0); + const second = selectBannerState(json, '0.14.0', now, () => 0); + const changedDisplay = selectBannerState( + { + ...json, + banner_display: 'cooldown', + banner_display_ttl_hours: 72, + }, + '0.14.0', + now, + () => 0, + ); + + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(changedDisplay).not.toBeNull(); + expect(first!.key).toMatch(/^[0-9a-f]{32}$/); + expect(second!.key).toBe(first!.key); + expect(changedDisplay!.key).not.toBe(first!.key); + }); + + it('parses cooldown display and ttl hours', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 72, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ + key: 'active-1', + display: 'cooldown', + ttlHours: 72, + }); + }); + + it('falls back to 24 hours when cooldown ttl is invalid', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 0, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ display: 'cooldown', ttlHours: 24 }); + }); + + it('falls back to always for unknown display values', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + banner_display: '24h', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ display: 'always' }); + expect(result?.ttlHours).toBeUndefined(); + }); + + it('supports fallback display and ttl fields', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback-1', + banner_maintext: 'Fallback', + banner_display: 'cooldown', + banner_display_ttl_hours: 168, + }, + ], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ + key: 'fallback-1', + display: 'cooldown', + ttlHours: 168, + }); + }); +}); + +describe('shouldDisplayBanner', () => { + const now = new Date('2026-06-16T12:00:00.000Z'); + + const banner: BannerState = { + key: 'always', + tag: null, + mainText: 'Always', + subText: null, + display: 'always', + }; + + it('returns true for always banners even when they were shown before', () => { + expect( + shouldDisplayBanner( + banner, + { + version: 1, + shown: { + always: { lastShownAt: '2026-06-16T11:59:59.000Z' }, + }, + }, + now, + ), + ).toBe(true); + }); + + it('returns true for once banners without a shown record', () => { + expect(shouldDisplayBanner({ ...banner, key: 'once', display: 'once' }, { version: 1, shown: {} }, now)).toBe( + true, + ); + }); + + it('returns false for once banners with a shown record', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'once', display: 'once' }, + { + version: 1, + shown: { + once: { lastShownAt: '2026-06-16T11:59:59.000Z' }, + }, + }, + now, + ), + ).toBe(false); + }); + + it('treats an invalid shown record as not shown', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'once', display: 'once' }, + { + version: 1, + shown: { + once: { lastShownAt: 'not-a-date' }, + }, + }, + now, + ), + ).toBe(true); + }); + + it('returns false during cooldown ttl', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 24 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }, + now, + ), + ).toBe(false); + }); + + it('returns true at the cooldown ttl boundary', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 24 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-15T12:00:00.000Z' }, + }, + }, + now, + ), + ).toBe(true); + }); + + it('supports custom cooldown ttl values', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 1 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-16T11:30:00.000Z' }, + }, + }, + now, + ), + ).toBe(false); + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 168 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-09T12:00:01.000Z' }, + }, + }, + now, + ), + ).toBe(false); + }); +}); + +describe('selectDisplayableBanner', () => { + const now = new Date('2026-06-16T12:00:00.000Z'); + + it('falls back when the active once banner was already shown', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: true, + banner_id: 'active', + banner_maintext: 'Active', + banner_display: 'once', + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback', + banner_maintext: 'Fallback', + banner_display: 'once', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + active: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'fallback', display: 'once' }); + }); + + it('falls back when active cooldown is within ttl', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: true, + banner_id: 'active', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 1, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback', + banner_maintext: 'Fallback', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + active: { lastShownAt: '2026-06-16T11:30:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'fallback', display: 'always' }); + }); + + it('returns active cooldown after ttl instead of fallback', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: true, + banner_id: 'active', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 24, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback', + banner_maintext: 'Fallback', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + active: { lastShownAt: '2026-06-15T12:00:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'active', display: 'cooldown', ttlHours: 24 }); + }); + + it('randomly chooses only displayable fallback candidates', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback-once', + banner_maintext: 'Fallback once', + banner_display: 'once', + }, + { + enabled: true, + banner_id: 'fallback-always', + banner_maintext: 'Fallback always', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + 'fallback-once': { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'fallback-always', display: 'always' }); + }); +}); diff --git a/apps/kimi-code/test/tui/banner/state.test.ts b/apps/kimi-code/test/tui/banner/state.test.ts new file mode 100644 index 000000000..0824e6fd9 --- /dev/null +++ b/apps/kimi-code/test/tui/banner/state.test.ts @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + emptyBannerDisplayState, + readBannerDisplayState, + writeBannerDisplayState, +} from '#/tui/banner/state'; +import { getBannerStateFile } from '#/utils/paths'; + +const originalEnv = { ...process.env }; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-banner-state-')); + process.env['KIMI_CODE_HOME'] = dir; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe('banner display state cache', () => { + it('returns an empty state when the file is missing', async () => { + await expect(readBannerDisplayState()).resolves.toEqual(emptyBannerDisplayState()); + }); + + it('falls back to an empty state when the file is corrupt', async () => { + mkdirSync(join(dir, 'cache', 'banner'), { recursive: true }); + writeFileSync(getBannerStateFile(), '{"broken"', 'utf-8'); + await expect(readBannerDisplayState()).resolves.toEqual(emptyBannerDisplayState()); + }); + + it('falls back to an empty state for an unknown future version', async () => { + mkdirSync(join(dir, 'cache', 'banner'), { recursive: true }); + writeFileSync( + getBannerStateFile(), + JSON.stringify({ + version: 2, + shown: {}, + }), + 'utf-8', + ); + await expect(readBannerDisplayState()).resolves.toEqual(emptyBannerDisplayState()); + }); + + it('writes and reads back the state from cache/banner/state.json', async () => { + const state = { + version: 1 as const, + shown: { + active: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }; + + await writeBannerDisplayState(state); + + expect(getBannerStateFile()).toBe(join(dir, 'cache', 'banner', 'state.json')); + await expect(readBannerDisplayState()).resolves.toEqual(state); + }); + + it('drops invalid shown records when reading', async () => { + mkdirSync(join(dir, 'cache', 'banner'), { recursive: true }); + writeFileSync( + getBannerStateFile(), + JSON.stringify({ + version: 1, + shown: { + valid: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + invalid: { lastShownAt: 'not-a-date' }, + malformed: { shownAt: '2026-06-16T00:00:00.000Z' }, + }, + }), + 'utf-8', + ); + + await expect(readBannerDisplayState()).resolves.toEqual({ + version: 1, + shown: { + valid: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }); + }); +}); diff --git a/apps/kimi-code/test/tui/chalk-named-color-guard.test.ts b/apps/kimi-code/test/tui/chalk-named-color-guard.test.ts new file mode 100644 index 000000000..e37a11871 --- /dev/null +++ b/apps/kimi-code/test/tui/chalk-named-color-guard.test.ts @@ -0,0 +1,74 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const SRC_ROOT = join(__dirname, '..', '..', 'src'); + +const NAMED_COLORS = [ + 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', + 'white', 'gray', 'grey', 'black', + 'blackBright', 'whiteBright', 'redBright', 'greenBright', + 'yellowBright', 'blueBright', 'magentaBright', 'cyanBright', +]; + +const CHALK_NAMED_PATTERN = new RegExp( + `chalk\\.(${NAMED_COLORS.join('|')})\\(`, +); + +function walk(dir: string, files: string[] = []): string[] { + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) { + walk(p, files); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') && !entry.name.endsWith('.spec.ts')) { + files.push(p); + } + } + } catch { /* skip */ } + return files; +} + +describe('chalk named color guard', () => { + it('forbids chalk named colors in production source code', () => { + const offenders: { file: string; line: number; snippet: string }[] = []; + const files = walk(SRC_ROOT); + let inBlockComment = false; + for (const file of files) { + const content = readFileSync(file, 'utf8'); + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ''; + const trimmed = line.trimStart(); + + if (inBlockComment) { + if (trimmed.includes('*/')) inBlockComment = false; + continue; + } + + if (trimmed.startsWith('/*')) { + if (!trimmed.includes('*/')) inBlockComment = true; + continue; + } + + if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue; + + CHALK_NAMED_PATTERN.lastIndex = 0; + const m = CHALK_NAMED_PATTERN.exec(line); + if (m) { + offenders.push({ + file: relative(SRC_ROOT, file), + line: i + 1, + snippet: line.trim(), + }); + } + } + } + expect( + offenders, + `Found chalk named color usages. Use chalk.hex(colors.<token>) or theme styles instead.\n` + + offenders.map((o) => ` ${o.file}:${String(o.line)} ${o.snippet}`).join('\n'), + ).toEqual([]); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/add-dir.test.ts b/apps/kimi-code/test/tui/commands/add-dir.test.ts new file mode 100644 index 000000000..0c3381a87 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/add-dir.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleAddDirCommand } from '#/tui/commands/add-dir'; +import { dispatchInput, type SlashCommandHost } from '#/tui/commands/dispatch'; + +type MountedPanel = { + handleInput: (data: string) => void; + render: (width: number) => string[]; +}; + +const ANSI_SGR = /\u001B\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function makeHost(additionalDirs: readonly string[] = []) { + const state = { + appState: { + additionalDirs, + streamingPhase: 'idle', + isCompacting: false, + }, + }; + let mountedPanel: MountedPanel | null = null; + const session = { + id: 'session-1', + summary: { + additionalDirs, + }, + addAdditionalDir: vi.fn(async (path: string, options: { persist: boolean }) => ({ + additionalDirs: [...additionalDirs, path], + projectRoot: '/repo', + configPath: '/repo/.kimi-code/local.toml', + persisted: options.persist, + })), + }; + const host = { + state, + session, + skillCommandMap: new Map<string, string>(), + setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(state.appState, patch)), + refreshSlashCommandAutocomplete: vi.fn(), + appendTranscriptEntry: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + sendNormalUserInput: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mountedPanel = panel; + }), + restoreEditor: vi.fn(() => { + mountedPanel = null; + }), + } as unknown as SlashCommandHost & { + session: typeof session; + state: typeof state; + setAppState: ReturnType<typeof vi.fn>; + refreshSlashCommandAutocomplete: ReturnType<typeof vi.fn>; + appendTranscriptEntry: ReturnType<typeof vi.fn>; + showError: ReturnType<typeof vi.fn>; + showStatus: ReturnType<typeof vi.fn>; + sendNormalUserInput: ReturnType<typeof vi.fn>; + mountEditorReplacement: ReturnType<typeof vi.fn>; + restoreEditor: ReturnType<typeof vi.fn>; + }; + return { + host, + session, + getMountedPanel: () => mountedPanel, + }; +} + +describe('handleAddDirCommand', () => { + it('shows the empty message when no additional dirs are configured', async () => { + const { host } = makeHost(); + + await handleAddDirCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith('No additional directories configured.'); + }); + + it('lists current additional dirs for no args', async () => { + const { host } = makeHost(['/repo/shared', '/repo/docs']); + + await handleAddDirCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith( + 'Additional directories:\n /repo/shared\n /repo/docs', + ); + }); + + it('lists current additional dirs for the list subcommand', async () => { + const { host } = makeHost(['/repo/shared']); + + await handleAddDirCommand(host, 'list'); + + expect(host.showStatus).toHaveBeenCalledWith('Additional directories:\n /repo/shared'); + }); + + it('renders the add-dir confirmation without option descriptions', async () => { + const { host, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + + const rendered = getMountedPanel()?.render(120).map(strip).join('\n') ?? ''; + expect(rendered).toContain('Add directory to workspace: ../shared'); + expect(rendered).toContain('Yes, for this session'); + expect(rendered).toContain('Yes, and remember this directory'); + expect(rendered).toContain('No'); + expect(rendered).not.toContain('Use this directory in the current session only'); + expect(rendered).not.toContain('Save this directory to the project workspace config'); + expect(rendered).not.toContain('Do not add this directory.'); + }); + + it('adds a workspace dir for this session only after confirmation', async () => { + const { host, session, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: false }); + }); + expect(host.restoreEditor).toHaveBeenCalledOnce(); + expect(host.setAppState).toHaveBeenCalledWith({ + additionalDirs: ['../shared'], + }); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n For this session only', + 'success', + ); + }); + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); + }); + + it('adds a remembered workspace dir after confirmation', async () => { + const { host, session, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: true }); + }); + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n Saved to:\n /repo/.kimi-code/local.toml', + 'success', + ); + }); + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); + }); + + it('does not add a workspace dir when the confirmation is cancelled', async () => { + const { host, session, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput(' '); + + expect(session.addAdditionalDir).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Did not add ../shared as a working directory.'); + }); + + it('routes /add-dir errors through the slash-command dispatcher error handler', async () => { + const { host, session, getMountedPanel } = makeHost(); + session.addAdditionalDir.mockRejectedValueOnce(new Error('workspace.additional_dir must exist and be a directory')); + + dispatchInput(host, '/add-dir ../other'); + await vi.waitFor(() => { + expect(getMountedPanel()).not.toBeNull(); + }); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + 'workspace.additional_dir must exist and be a directory', + ); + }); + + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.refreshSlashCommandAutocomplete).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/experiments.test.ts b/apps/kimi-code/test/tui/commands/experiments.test.ts new file mode 100644 index 000000000..89bf62da6 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/experiments.test.ts @@ -0,0 +1,116 @@ +import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { SlashCommandHost } from '#/tui/commands'; +import { + applyExperimentalFeatureChanges, +} from '#/tui/commands/config'; +import { + isExperimentalFlagEnabled, + setExperimentalFeatures, +} from '#/tui/commands/experimental-flags'; +import { darkColors } from '#/tui/theme/colors'; + +function feature( + overrides: Partial<ExperimentalFeatureState> = {}, +): ExperimentalFeatureState { + return { + id: 'micro_compaction', + title: 'Micro compaction', + description: 'Trim older tool results.', + surface: 'core', + env: 'KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION', + defaultEnabled: true, + enabled: true, + source: 'default', + ...overrides, + }; +} + +function makeHost() { + const session = { + id: 'ses-experiments', + reloadSession: vi.fn(async () => ({})), + }; + const host = { + state: { + theme: { palette: darkColors }, + ui: { requestRender: vi.fn() }, + }, + harness: { + setConfig: vi.fn(async () => ({ providers: {} })), + getExperimentalFeatures: vi.fn(async () => [ + feature({ enabled: false, source: 'config', configValue: false }), + ]), + }, + session, + refreshSlashCommandAutocomplete: vi.fn(), + reloadCurrentSessionView: vi.fn(async () => {}), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + track: vi.fn(), + } as unknown as SlashCommandHost & { + harness: { + setConfig: ReturnType<typeof vi.fn>; + getExperimentalFeatures: ReturnType<typeof vi.fn>; + }; + refreshSlashCommandAutocomplete: ReturnType<typeof vi.fn>; + reloadCurrentSessionView: ReturnType<typeof vi.fn>; + mountEditorReplacement: ReturnType<typeof vi.fn>; + restoreEditor: ReturnType<typeof vi.fn>; + showStatus: ReturnType<typeof vi.fn>; + showError: ReturnType<typeof vi.fn>; + track: ReturnType<typeof vi.fn>; + session: typeof session; + }; + return host; +} + +describe('experimental feature command handlers', () => { + afterEach(() => { + setExperimentalFeatures([]); + }); + + it('persists config overrides, refreshes command flags, closes the panel, and reloads', async () => { + const host = makeHost(); + + await applyExperimentalFeatureChanges(host, [ + { id: 'micro_compaction', enabled: false }, + ]); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + experimental: { 'micro_compaction': false }, + }); + expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); + expect(isExperimentalFlagEnabled('micro_compaction')).toBe(false); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalled(); + expect(host.restoreEditor).toHaveBeenCalled(); + expect(host.session.reloadSession).toHaveBeenCalledOnce(); + expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( + host.session, + 'Experimental features updated. Session reloaded.', + ); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.track).toHaveBeenCalledWith('experimental_features_apply', { + changed: 1, + }); + expect(host.showStatus).not.toHaveBeenCalledWith( + 'Experimental features updated.', + darkColors.success, + ); + }); + + it('does not write config when there are no drafted changes', async () => { + const host = makeHost(); + + await applyExperimentalFeatureChanges(host, []); + + expect(host.harness.setConfig).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith( + 'No experimental feature changes to apply.', + 'textMuted', + ); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts new file mode 100644 index 000000000..ea59d4fae --- /dev/null +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -0,0 +1,783 @@ +import { ErrorCodes, KimiError } from '@moonshot-ai/kimi-code-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + dispatchInput, + goalArgumentCompletions, + handleGoalCommand, + parseGoalCommand, + setExperimentalFeatures, +} from '#/tui/commands/index'; +import { + appendGoalQueueItem, + moveGoalQueueItem, + readGoalQueue, + removeGoalQueueItem, + updateGoalQueueItem, +} from '#/tui/goal-queue-store'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { getBuiltInPalette } from '#/tui/theme'; + +vi.mock('#/tui/goal-queue-store', () => ({ + appendGoalQueueItem: vi.fn(async () => ({ + goals: [{ id: 'q1', objective: 'obj', createdAt: '', updatedAt: '' }], + })), + readGoalQueue: vi.fn(async () => ({ + goals: [ + { id: 'q1', objective: 'First queued goal', createdAt: '', updatedAt: '' }, + { id: 'q2', objective: 'Second queued goal', createdAt: '', updatedAt: '' }, + ], + })), + moveGoalQueueItem: vi.fn(async () => ({ + goals: [ + { id: 'q2', objective: 'Second queued goal', createdAt: '', updatedAt: '' }, + { id: 'q1', objective: 'First queued goal', createdAt: '', updatedAt: '' }, + ], + })), + removeGoalQueueItem: vi.fn(async () => ({ + goals: [{ id: 'q2', objective: 'Second queued goal', createdAt: '', updatedAt: '' }], + })), + updateGoalQueueItem: vi.fn(async () => ({ + goals: [ + { id: 'q1', objective: 'First queued goal updated', createdAt: '', updatedAt: '' }, + { id: 'q2', objective: 'Second queued goal', createdAt: '', updatedAt: '' }, + ], + })), +})); + +const ENTER = '\r'; +const ESCAPE = '\u001B'; +const UP = '\u001B[A'; +const DOWN = '\u001B[B'; + +function fakeSnapshot() { + return { + goalId: 'g1', + objective: 'obj', + status: 'active' as const, + turnsUsed: 0, + tokensUsed: 0, + wallClockMs: 0, + budget: { + tokenBudget: null, + turnBudget: 20, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: 20, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + }; +} + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function makeHost( + overrides: { + model?: string; + hasSession?: boolean; + streaming?: boolean; + permissionMode?: 'manual' | 'auto' | 'yolo'; + } = {}, +) { + const session = { + setPermission: vi.fn(async () => {}), + createGoal: vi.fn(async () => fakeSnapshot()), + getGoal: vi.fn(async (): Promise<{ goal: ReturnType<typeof fakeSnapshot> | null }> => ({ + goal: null, + })), + pauseGoal: vi.fn(async () => fakeSnapshot()), + resumeGoal: vi.fn(async () => fakeSnapshot()), + cancelGoal: vi.fn(async () => fakeSnapshot()), + cancel: vi.fn(async () => {}), + }; + const hasSession = overrides.hasSession ?? true; + const transcriptContainer = { addChild: vi.fn() }; + const host = { + state: { + appState: { + model: overrides.model ?? 'kimi-model', + permissionMode: overrides.permissionMode ?? 'auto', + streamingPhase: overrides.streaming ? 'streaming' : 'idle', + isCompacting: false, + }, + transcriptContainer, + ui: { requestRender: vi.fn() }, + theme: { palette: getBuiltInPalette('dark') }, + }, + session: hasSession ? session : undefined, + skillCommandMap: new Map<string, string>(), + requireSession: () => session, + setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(host.state.appState, patch)), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + sendNormalUserInput: vi.fn(), + requestQueuedGoalPromotion: vi.fn(), + cancelInFlight: vi.fn(), + track: vi.fn(), + } as unknown as SlashCommandHost; + return { host, session }; +} + +interface TestPicker { + handleInput(data: string): void; + render(width: number): string[]; +} + +function mountedPicker(host: SlashCommandHost): TestPicker { + const mock = host.mountEditorReplacement as ReturnType<typeof vi.fn>; + return mock.mock.calls[0]?.[0] as TestPicker; +} + +function latestMountedPicker(host: SlashCommandHost): TestPicker { + const mock = host.mountEditorReplacement as ReturnType<typeof vi.fn>; + return mock.mock.calls.at(-1)?.[0] as TestPicker; +} + +describe('parseGoalCommand', () => { + it('treats empty and status as status', () => { + expect(parseGoalCommand('')).toEqual({ kind: 'status' }); + expect(parseGoalCommand('status')).toEqual({ kind: 'status' }); + }); + + it('parses control subcommands', () => { + expect(parseGoalCommand('pause')).toEqual({ kind: 'pause' }); + expect(parseGoalCommand('resume')).toEqual({ kind: 'resume' }); + expect(parseGoalCommand('cancel')).toEqual({ kind: 'cancel' }); + }); + + it('treats `clear` as an objective, not a subcommand (cancel is the remove action)', () => { + expect(parseGoalCommand('clear')).toMatchObject({ kind: 'create', objective: 'clear' }); + }); + + it('parses a plain objective', () => { + expect(parseGoalCommand('Ship feature X')).toMatchObject({ + kind: 'create', + objective: 'Ship feature X', + replace: false, + }); + }); + + it('keeps option-looking tokens as part of the objective (no goal flags)', () => { + // Goal command flags are not parsed after `/goal`; stop conditions go in the + // objective as natural language, so option-looking text stays objective text. + expect(parseGoalCommand('--retry-strategy Ship feature X')).toMatchObject({ + kind: 'create', + objective: '--retry-strategy Ship feature X', + }); + }); + + it('treats text after -- as the objective', () => { + expect(parseGoalCommand('-- --leading-option is part of the goal')).toMatchObject({ + kind: 'create', + objective: '--leading-option is part of the goal', + }); + expect(parseGoalCommand('-- cancel')).toMatchObject({ kind: 'create', objective: 'cancel' }); + }); + + it('parses replace as the first argument', () => { + expect(parseGoalCommand('replace Ship feature Y')).toMatchObject({ + kind: 'create', + objective: 'Ship feature Y', + replace: true, + }); + }); + + it('parses next as an upcoming-goal command', () => { + expect(parseGoalCommand('next Ship release notes')).toEqual({ + kind: 'next-add', + objective: 'Ship release notes', + }); + expect(parseGoalCommand('next manage')).toEqual({ kind: 'next-manage' }); + expect(parseGoalCommand('next -- manage release notes')).toEqual({ + kind: 'next-add', + objective: 'manage release notes', + }); + }); + + it('shows a hint for /goal next without an objective', () => { + expect(parseGoalCommand('next')).toEqual({ + kind: 'error', + severity: 'hint', + message: + 'Provide an upcoming goal objective, e.g. `/goal next Ship feature X`, or use `/goal next manage`.', + }); + }); + + it('rejects objectives longer than 4000 characters', () => { + expect(parseGoalCommand('x'.repeat(4001))).toMatchObject({ kind: 'error' }); + }); +}); + +describe('handleGoalCommand', () => { + let host: SlashCommandHost; + let session: ReturnType<typeof makeHost>['session']; + + beforeEach(() => { + const made = makeHost(); + host = made.host; + session = made.session; + vi.mocked(appendGoalQueueItem).mockClear(); + vi.mocked(readGoalQueue).mockClear(); + vi.mocked(moveGoalQueueItem).mockClear(); + vi.mocked(removeGoalQueueItem).mockClear(); + vi.mocked(updateGoalQueueItem).mockClear(); + }); + + it('/goal calls getGoal and does not send input', async () => { + await handleGoalCommand(host, ''); + expect(session.getGoal).toHaveBeenCalledOnce(); + expect(host.track).toHaveBeenCalledWith('goal_status', { status: 'none' }); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('/goal status calls getGoal and does not send input', async () => { + await handleGoalCommand(host, 'status'); + expect(session.getGoal).toHaveBeenCalledOnce(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('/goal <objective> creates a goal and sends the objective as input', async () => { + await handleGoalCommand(host, 'Ship feature X'); + expect(session.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature X', replace: false }), + ); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + expect(host.sendNormalUserInput).not.toHaveBeenCalledWith('/goal Ship feature X'); + }); + + it('/goal <objective> keeps the sendNormalUserInput host receiver', async () => { + const calls: Array<{ receiver: unknown; text: string }> = []; + host.sendNormalUserInput = function (this: unknown, text: string): void { + calls.push({ receiver: this, text }); + }; + + await handleGoalCommand(host, 'Ship feature X'); + + expect(calls).toEqual([{ receiver: host, text: 'Ship feature X' }]); + }); + + it('asks before starting a goal in Manual mode', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + + await handleGoalCommand(manualHost, 'Ship feature X'); + + expect(manualHost.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(s.createGoal).not.toHaveBeenCalled(); + expect(manualHost.sendNormalUserInput).not.toHaveBeenCalled(); + const text = stripAnsi(mountedPicker(manualHost).render(80).join('\n')); + expect(text).toContain('Manual mode is not suitable for unattended goal work'); + expect(text).toContain('Return to the input box with your goal command'); + }); + + it('defaults to Auto when confirming a Manual-mode goal start', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + + await handleGoalCommand(manualHost, 'Ship feature X'); + mountedPicker(manualHost).handleInput(ENTER); + + await vi.waitFor(() => { + expect(s.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature X' }), + ); + }); + expect(s.setPermission).toHaveBeenCalledWith('auto'); + expect(manualHost.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(manualHost.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('can start a Manual-mode goal without changing permission', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + + await handleGoalCommand(manualHost, 'Ship feature X'); + const picker = mountedPicker(manualHost); + picker.handleInput(DOWN); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(s.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature X' }), + ); + }); + expect(s.setPermission).not.toHaveBeenCalled(); + expect(manualHost.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('can switch to YOLO when starting a Manual-mode goal', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + + await handleGoalCommand(manualHost, 'Ship feature X'); + const picker = mountedPicker(manualHost); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(s.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature X' }), + ); + }); + expect(s.setPermission).toHaveBeenCalledWith('yolo'); + expect(manualHost.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); + }); + + it('restores the previous permission mode when the goal fails to start', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + s.createGoal = vi.fn(async () => { + throw new KimiError(ErrorCodes.GOAL_ALREADY_EXISTS, 'A goal already exists'); + }); + + await handleGoalCommand(manualHost, 'Ship feature X'); + const picker = mountedPicker(manualHost); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + // Switched to YOLO to run the goal, then restored to Manual on failure. + expect(s.setPermission).toHaveBeenLastCalledWith('manual'); + }); + expect(s.setPermission).toHaveBeenCalledWith('yolo'); + expect(manualHost.setAppState).toHaveBeenLastCalledWith({ permissionMode: 'manual' }); + }); + + it('returns the command to the input box when a Manual-mode goal start is cancelled', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + + await handleGoalCommand(manualHost, 'Ship feature X'); + mountedPicker(manualHost).handleInput(ESCAPE); + + expect(manualHost.restoreInputText).toHaveBeenCalledWith('/goal Ship feature X'); + expect(manualHost.showStatus).toHaveBeenCalledWith('Goal not started.'); + expect(s.createGoal).not.toHaveBeenCalled(); + }); + + it('returns the command to the input box when Do not start is selected', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + + await handleGoalCommand(manualHost, 'replace Ship feature Y'); + const picker = mountedPicker(manualHost); + picker.handleInput(DOWN); + picker.handleInput(DOWN); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + expect(manualHost.restoreInputText).toHaveBeenCalledWith('/goal replace Ship feature Y'); + expect(s.createGoal).not.toHaveBeenCalled(); + }); + + it('asks before starting a goal in YOLO mode', async () => { + const { host: yoloHost, session: s } = makeHost({ permissionMode: 'yolo' }); + + await handleGoalCommand(yoloHost, 'Ship feature X'); + + expect(yoloHost.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(s.createGoal).not.toHaveBeenCalled(); + expect(yoloHost.sendNormalUserInput).not.toHaveBeenCalled(); + const text = stripAnsi(mountedPicker(yoloHost).render(80).join('\n')); + expect(text).toContain('YOLO mode can still stop for questions'); + expect(text).toContain('Keep YOLO and start'); + expect(text).not.toContain('Start in Manual'); + }); + + it('defaults to Auto when confirming a YOLO-mode goal start', async () => { + const { host: yoloHost, session: s } = makeHost({ permissionMode: 'yolo' }); + + await handleGoalCommand(yoloHost, 'Ship feature X'); + mountedPicker(yoloHost).handleInput(ENTER); + + await vi.waitFor(() => { + expect(s.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature X' }), + ); + }); + expect(s.setPermission).toHaveBeenCalledWith('auto'); + expect(yoloHost.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(yoloHost.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('can keep YOLO when starting a YOLO-mode goal', async () => { + const { host: yoloHost, session: s } = makeHost({ permissionMode: 'yolo' }); + + await handleGoalCommand(yoloHost, 'Ship feature X'); + const picker = mountedPicker(yoloHost); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(s.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature X' }), + ); + }); + expect(s.setPermission).not.toHaveBeenCalled(); + expect(yoloHost.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('returns the command to the input box when a YOLO-mode goal start is cancelled', async () => { + const { host: yoloHost, session: s } = makeHost({ permissionMode: 'yolo' }); + + await handleGoalCommand(yoloHost, 'replace Ship feature Y'); + const picker = mountedPicker(yoloHost); + picker.handleInput(DOWN); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + expect(yoloHost.restoreInputText).toHaveBeenCalledWith('/goal replace Ship feature Y'); + expect(s.createGoal).not.toHaveBeenCalled(); + }); + + it('does not pass budget limits (flags were removed)', async () => { + await handleGoalCommand(host, 'Ship feature X'); + const arg = (session.createGoal as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as Record< + string, + unknown + >; + expect(arg).not.toHaveProperty('budgetLimits'); + }); + + it('rejects too-long objectives before any SDK call', async () => { + await handleGoalCommand(host, 'x'.repeat(4001)); + expect(host.showError).toHaveBeenCalled(); + expect(session.createGoal).not.toHaveBeenCalled(); + }); + + it('/goal replace passes replace: true', async () => { + await handleGoalCommand(host, 'replace Ship feature Y'); + expect(session.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature Y', replace: true }), + ); + }); + + it('/goal next queues an upcoming goal and does not send it to the agent', async () => { + session.getGoal.mockResolvedValueOnce({ goal: fakeSnapshot() }); + + await handleGoalCommand(host, 'next Ship release notes'); + + expect(session.getGoal).toHaveBeenCalledOnce(); + expect(appendGoalQueueItem).toHaveBeenCalledWith(session, { + objective: 'Ship release notes', + }); + expect(host.track).toHaveBeenCalledWith('goal_queue_append'); + expect(host.showStatus).not.toHaveBeenCalledWith( + 'Upcoming goal added. It will start after the current goal is complete.', + ); + const addChild = host.state.transcriptContainer.addChild as ReturnType<typeof vi.fn>; + const message = addChild.mock.calls[0]?.[0] as { render(width: number): string[] }; + expect(stripAnsi(message.render(80).join('\n'))).toBe( + '\n● Upcoming goal added. It will start after the current goal is complete.', + ); + expect(host.state.ui.requestRender).toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(session.createGoal).not.toHaveBeenCalled(); + }); + + it('/goal next starts immediately when there is no current goal', async () => { + await handleGoalCommand(host, 'next Ship release notes'); + + expect(session.getGoal).toHaveBeenCalledOnce(); + expect(appendGoalQueueItem).not.toHaveBeenCalled(); + expect(session.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship release notes', replace: false }), + ); + expect(host.showStatus).toHaveBeenCalledWith( + 'No active goal. Starting this goal now.', + ); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship release notes'); + }); + + it('/goal next queues instead of starting immediately while streaming with no current goal', async () => { + const { host: streamingHost, session: s } = makeHost({ streaming: true }); + + await handleGoalCommand(streamingHost, 'next Ship release notes'); + + expect(s.getGoal).toHaveBeenCalledOnce(); + expect(appendGoalQueueItem).toHaveBeenCalledWith(s, { + objective: 'Ship release notes', + }); + expect(streamingHost.requestQueuedGoalPromotion).toHaveBeenCalledOnce(); + expect(s.createGoal).not.toHaveBeenCalled(); + expect(streamingHost.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('/goal next follows the normal goal-start prompt when there is no current goal', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + + await handleGoalCommand(manualHost, 'next Ship release notes'); + + expect(s.getGoal).toHaveBeenCalledOnce(); + expect(appendGoalQueueItem).not.toHaveBeenCalled(); + expect(manualHost.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(s.createGoal).not.toHaveBeenCalled(); + + mountedPicker(manualHost).handleInput(ESCAPE); + expect(manualHost.restoreInputText).toHaveBeenCalledWith('/goal next Ship release notes'); + }); + + it('/goal next does not require a configured model when queueing after a current goal', async () => { + const { host: noModelHost, session: s } = makeHost({ model: '' }); + s.getGoal.mockResolvedValueOnce({ goal: fakeSnapshot() }); + + await handleGoalCommand(noModelHost, 'next Ship release notes'); + + expect(appendGoalQueueItem).toHaveBeenCalledWith(s, { + objective: 'Ship release notes', + }); + expect(noModelHost.showError).not.toHaveBeenCalled(); + }); + + it('/goal next requires a configured model when it starts immediately', async () => { + const { host: noModelHost, session: s } = makeHost({ model: '' }); + + await handleGoalCommand(noModelHost, 'next Ship release notes'); + + expect(s.getGoal).toHaveBeenCalledOnce(); + expect(appendGoalQueueItem).not.toHaveBeenCalled(); + expect(s.createGoal).not.toHaveBeenCalled(); + expect(noModelHost.showError).toHaveBeenCalled(); + }); + + it('/goal next manage opens the upcoming goal manager without sending input', async () => { + await handleGoalCommand(host, 'next manage'); + + expect(readGoalQueue).toHaveBeenCalledWith(session); + expect(host.track).toHaveBeenCalledWith('goal_queue_manage'); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + const text = stripAnsi(mountedPicker(host).render(100).join('\n')); + expect(text).toContain('Upcoming goals'); + expect(text).toContain('First queued goal'); + expect(text).toContain('Second queued goal'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(session.createGoal).not.toHaveBeenCalled(); + }); + + it('/goal next manage reorders goals through the queue store', async () => { + await handleGoalCommand(host, 'next manage'); + const manager = mountedPicker(host); + + manager.handleInput(DOWN); + manager.handleInput(' '); + manager.handleInput(UP); + + await vi.waitFor(() => { + expect(moveGoalQueueItem).toHaveBeenCalledWith(session, { + goalId: 'q2', + direction: 'up', + }); + }); + }); + + it('/goal next manage removes goals through the queue store', async () => { + await handleGoalCommand(host, 'next manage'); + + mountedPicker(host).handleInput('d'); + + await vi.waitFor(() => { + expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); + }); + }); + + it('/goal next manage edits goals through the queue store', async () => { + await handleGoalCommand(host, 'next manage'); + + mountedPicker(host).handleInput('e'); + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2); + }); + const editDialog = latestMountedPicker(host); + editDialog.handleInput(' updated'); + editDialog.handleInput(ENTER); + + await vi.waitFor(() => { + expect(updateGoalQueueItem).toHaveBeenCalledWith(session, { + goalId: 'q1', + objective: 'First queued goal updated', + }); + }); + }); + + it('surfaces duplicate-goal errors with replace guidance', async () => { + session.createGoal.mockRejectedValueOnce( + new KimiError(ErrorCodes.GOAL_ALREADY_EXISTS, 'exists'), + ); + await handleGoalCommand(host, 'Ship feature X'); + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('/goal replace')); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('/goal pause calls pauseGoal and does not send input', async () => { + await handleGoalCommand(host, 'pause'); + expect(session.pauseGoal).toHaveBeenCalledOnce(); + expect(host.track).toHaveBeenCalledWith('goal_pause'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('/goal pause cancels an active stream', async () => { + const { host: streamingHost, session: s } = makeHost({ streaming: true }); + await handleGoalCommand(streamingHost, 'pause'); + expect(s.pauseGoal).toHaveBeenCalledOnce(); + expect(s.cancel).toHaveBeenCalledOnce(); + }); + + it('/goal resume calls resumeGoal and sends a resume input', async () => { + await handleGoalCommand(host, 'resume'); + expect(session.resumeGoal).toHaveBeenCalledOnce(); + expect(host.track).toHaveBeenCalledWith('goal_resume'); + expect(host.showStatus).not.toHaveBeenCalledWith('Goal resumed.'); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Resume the active goal.'); + }); + + it('/goal cancel calls cancelGoal and does not send input', async () => { + await handleGoalCommand(host, 'cancel'); + expect(session.cancelGoal).toHaveBeenCalledOnce(); + expect(host.track).toHaveBeenCalledWith('goal_cancel'); + expect(host.showNotice).toHaveBeenCalledWith('Goal cancelled.'); + expect(host.showStatus).not.toHaveBeenCalledWith('Goal cancelled.'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('/goal cancel cancels an active stream', async () => { + const { host: streamingHost, session: s } = makeHost({ streaming: true }); + await handleGoalCommand(streamingHost, 'cancel'); + expect(s.cancelGoal).toHaveBeenCalledOnce(); + expect(s.cancel).toHaveBeenCalledOnce(); + }); + + // No-goal control commands all read as calm status messages, never red errors. + it('pausing with no goal shows a friendly status, not an error', async () => { + session.pauseGoal.mockRejectedValueOnce(new KimiError(ErrorCodes.GOAL_NOT_FOUND, 'No current goal')); + await handleGoalCommand(host, 'pause'); + expect(host.showStatus).toHaveBeenCalledWith('No goal to pause.'); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('resuming with no goal shows a friendly status, not an error', async () => { + session.resumeGoal.mockRejectedValueOnce(new KimiError(ErrorCodes.GOAL_NOT_FOUND, 'No current goal')); + await handleGoalCommand(host, 'resume'); + expect(host.showStatus).toHaveBeenCalledWith('No goal to resume.'); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('`replace` with no objective is a hint (status), not an error', async () => { + await handleGoalCommand(host, 'replace'); + expect(host.showStatus).toHaveBeenCalledWith(expect.stringContaining('Provide a goal objective')); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('status/pause/cancel work without a configured model', async () => { + const { host: noModelHost, session: s } = makeHost({ model: '' }); + await handleGoalCommand(noModelHost, 'status'); + await handleGoalCommand(noModelHost, 'pause'); + await handleGoalCommand(noModelHost, 'cancel'); + expect(s.getGoal).toHaveBeenCalled(); + expect(s.pauseGoal).toHaveBeenCalled(); + expect(s.cancelGoal).toHaveBeenCalled(); + expect(noModelHost.showError).not.toHaveBeenCalled(); + }); + + it('resume without a configured model does not activate the goal', async () => { + const { host: noModelHost, session: s } = makeHost({ model: '' }); + await handleGoalCommand(noModelHost, 'resume'); + expect(noModelHost.showError).toHaveBeenCalled(); + expect(s.resumeGoal).not.toHaveBeenCalled(); + expect(noModelHost.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('creation without a configured model shows LLM_NOT_SET_MESSAGE', async () => { + const { host: noModelHost, session: s } = makeHost({ model: '' }); + await handleGoalCommand(noModelHost, 'Ship feature X'); + expect(noModelHost.showError).toHaveBeenCalled(); + expect(s.createGoal).not.toHaveBeenCalled(); + }); + + it('creation without an active session shows LLM_NOT_SET_MESSAGE', async () => { + const { host: noSessionHost, session: s } = makeHost({ hasSession: false }); + await handleGoalCommand(noSessionHost, 'Ship feature X'); + expect(noSessionHost.showError).toHaveBeenCalled(); + expect(s.createGoal).not.toHaveBeenCalled(); + }); +}); + +describe('dispatchInput /goal integration', () => { + afterEach(() => { + setExperimentalFeatures([]); + }); + + it('routes /goal through the real resolver, creates the goal, and sends the objective', async () => { + const { host, session } = makeHost(); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature X' }), + ); + }); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + expect(host.sendNormalUserInput).not.toHaveBeenCalledWith('/goal Ship feature X'); + }); +}); + +describe('goalArgumentCompletions', () => { + function values(prefix: string): string[] | null { + const items = goalArgumentCompletions(prefix); + return items === null ? null : items.map((i) => i.value); + } + + function labels(prefix: string): string[] | null { + const items = goalArgumentCompletions(prefix); + return items === null ? null : items.map((i) => i.label); + } + + it('offers every subcommand for an empty prefix', () => { + expect(values('')).toEqual(['status', 'pause', 'resume', 'cancel', 'replace', 'next']); + }); + + it('prefix-filters subcommands case-insensitively', () => { + expect(values('pa')).toEqual(['pause']); + expect(values('RE')).toEqual(['resume', 'replace']); + }); + + it('returns items whose value/label are the token itself', () => { + const items = goalArgumentCompletions('paus'); + expect(items).toEqual([ + { value: 'pause', label: 'pause', description: 'Pause the active goal' }, + ]); + }); + + it('suppresses the menu once a token is fully typed and unambiguous', () => { + // `status` is the sole match and equals the prefix exactly, so there is + // nothing left to complete: the menu hides and Enter submits `/goal status` + // instead of confirming a no-op completion. + expect(values('status')).toBeNull(); + expect(values('pause')).toBeNull(); + // `re` still has two completions, so the menu stays open. + expect(values('re')).toEqual(['resume', 'replace']); + }); + + it('stops completing once past the first token (space typed)', () => { + expect(values('pause ')).toBeNull(); + expect(values('replace Ship feature')).toBeNull(); + expect(values('next Ship feature')).toBeNull(); + }); + + it('completes /goal next manage as the second token', () => { + expect(values('next ')).toEqual(['next manage']); + expect(values('next m')).toEqual(['next manage']); + expect(values('next MA')).toEqual(['next manage']); + expect(labels('next m')).toEqual(['manage']); + expect(values('next manage')).toBeNull(); + }); + + it('returns null when nothing matches', () => { + expect(values('zzz')).toBeNull(); + expect(values('next ship')).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/plugin-commands.test.ts b/apps/kimi-code/test/tui/commands/plugin-commands.test.ts new file mode 100644 index 000000000..eae75a099 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/plugin-commands.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPluginSlashCommands, pluginCommandName } from '#/tui/commands/plugin-commands'; + +describe('pluginCommandName', () => { + it('namespaces a command with its plugin id', () => { + expect(pluginCommandName('my-plugin', 'deploy')).toBe('my-plugin:deploy'); + }); +}); + +describe('buildPluginSlashCommands', () => { + it('namespaces commands and maps them to their bodies', () => { + const { commands, commandMap } = buildPluginSlashCommands([ + { + pluginId: 'my-plugin', + name: 'deploy', + description: 'Deploy', + body: 'Deploy $ARGUMENTS', + path: '/p/deploy.md', + }, + ]); + expect(commands).toEqual([{ name: 'my-plugin:deploy', aliases: [], description: 'Deploy' }]); + expect(commandMap.get('my-plugin:deploy')).toBe('Deploy $ARGUMENTS'); + }); + + it('returns empty commands for no defs', () => { + const { commands, commandMap } = buildPluginSlashCommands([]); + expect(commands).toEqual([]); + expect(commandMap.size).toBe(0); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 74737fb5d..bc4c5894f 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -3,7 +3,9 @@ import { findBuiltInSlashCommand, parseSlashInput, resolveSlashCommandAvailability, + addDirArgumentCompletions, sortSlashCommands, + swarmArgumentCompletions, type KimiSlashCommand, } from '#/tui/commands/index'; import { describe, expect, it } from 'vitest'; @@ -32,6 +34,7 @@ describe('built-in slash command registry', () => { expect(findBuiltInSlashCommand('quit')?.name).toBe('exit'); expect(findBuiltInSlashCommand('q')?.name).toBe('exit'); expect(findBuiltInSlashCommand('clear')?.name).toBe('new'); + expect(findBuiltInSlashCommand('btw')?.name).toBe('btw'); expect(findBuiltInSlashCommand('mcp')?.name).toBe('mcp'); expect(findBuiltInSlashCommand('status')?.name).toBe('status'); expect(findBuiltInSlashCommand('usage')?.aliases).not.toContain('status'); @@ -46,6 +49,52 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(plan!, 'clear')).toBe('idle-only'); }); + it('keeps swarm mode changes and swarm tasks idle-only', () => { + const swarm = findBuiltInSlashCommand('swarm'); + expect(swarm).toBeDefined(); + expect((swarm as KimiSlashCommand).experimentalFlag).toBeUndefined(); + expect(resolveSlashCommandAvailability(swarm!, 'on')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(swarm!, 'off')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(swarm!, 'Ship feature X')).toBe('idle-only'); + }); + + it('offers swarm subcommand argument completions', () => { + const values = (prefix: string): string[] | null => { + const items = swarmArgumentCompletions(prefix); + return items === null ? null : items.map((item) => item.value); + }; + + expect(values('')).toEqual(['on', 'off']); + expect(values('O')).toEqual(['on', 'off']); + expect(swarmArgumentCompletions('of')).toEqual([ + { value: 'off', label: 'off', description: 'Turn swarm mode off' }, + ]); + expect(values('on')).toBeNull(); + expect(values('off')).toBeNull(); + expect(values('Ship feature X')).toBeNull(); + }); + + it('offers add-dir list and directory argument completions', () => { + const values = (prefix: string): string[] | null => { + const items = addDirArgumentCompletions(prefix); + return items === null ? null : items.map((item) => item.value); + }; + + expect(values('')).toEqual(['list']); + expect(values('L')).toEqual(['list']); + expect(values('list')).toBeNull(); + const directoryCompletions = values('/') ?? []; + expect(directoryCompletions.length).toBeGreaterThan(0); + expect(directoryCompletions.every((value) => value.startsWith('/') && value.endsWith('/'))).toBe(true); + expect(directoryCompletions.some((value) => value.startsWith('/.'))).toBe(false); + expect(values('/.')).toBeNull(); + const homeCompletions = values('~/') ?? []; + expect(homeCompletions.length).toBeGreaterThan(0); + expect(homeCompletions.every((value) => value.startsWith('~/') && value.endsWith('/'))).toBe(true); + expect(homeCompletions.some((value) => value.startsWith('~/.'))).toBe(false); + expect(homeCompletions.some((value) => value.startsWith('~/sers/'))).toBe(false); + }); + it('defaults commands without explicit availability to idle-only', () => { const command: KimiSlashCommand = { name: 'example', @@ -72,13 +121,36 @@ describe('built-in slash command registry', () => { ]); }); + it('registers goal with subcommand-aware availability', () => { + const goal = findBuiltInSlashCommand('goal'); + expect(goal).toBeDefined(); + expect((goal as KimiSlashCommand).experimentalFlag).toBeUndefined(); + expect(resolveSlashCommandAvailability(goal!, '')).toBe('always'); + expect(resolveSlashCommandAvailability(goal!, 'status')).toBe('always'); + expect(resolveSlashCommandAvailability(goal!, 'pause')).toBe('always'); + expect(resolveSlashCommandAvailability(goal!, 'cancel')).toBe('always'); + expect(resolveSlashCommandAvailability(goal!, 'next')).toBe('always'); + expect(resolveSlashCommandAvailability(goal!, 'next Ship feature Y')).toBe('always'); + expect(resolveSlashCommandAvailability(goal!, 'next manage')).toBe('always'); + expect(resolveSlashCommandAvailability(goal!, 'status report')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(goal!, 'pause the rollout')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(goal!, 'cancel the migration')).toBe('idle-only'); + // `clear` is no longer a subcommand; it parses as an objective -> idle-only. + expect(resolveSlashCommandAvailability(goal!, 'clear')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(goal!, 'resume')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(goal!, 'Ship feature X')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(goal!, 'replace Ship feature Y')).toBe('idle-only'); + }); + it('contains the expected command names once', () => { const names = BUILTIN_SLASH_COMMANDS.map((command) => command.name); expect(new Set(names).size).toBe(names.length); expect(names).toEqual( expect.arrayContaining([ + 'add-dir', 'compact', + 'btw', 'editor', 'exit', 'export-debug-zip', @@ -92,15 +164,28 @@ describe('built-in slash command registry', () => { 'new', 'permission', 'plan', + 'reload', + 'reload-tui', 'sessions', 'settings', 'status', 'theme', 'title', + 'undo', 'usage', 'version', 'yolo', ]), ); }); + + it('keeps TUI reload always available and full reload idle-only', () => { + const reload = findBuiltInSlashCommand('reload'); + const reloadTui = findBuiltInSlashCommand('reload-tui'); + + expect(reload).toBeDefined(); + expect(reloadTui).toBeDefined(); + expect(resolveSlashCommandAvailability(reload!, '')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); + }); }); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts new file mode 100644 index 000000000..77f582a1b --- /dev/null +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -0,0 +1,182 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + handleReloadCommand, + handleReloadTuiCommand, +} from '#/tui/commands/reload'; +import { currentTheme } from '#/tui/theme'; +import type { SlashCommandHost } from '#/tui/commands'; +import { + isExperimentalFlagEnabled, + setExperimentalFeatures, +} from '#/tui/commands/experimental-flags'; + +const tempDirs: string[] = []; +const originalKimiCodeHome = process.env['KIMI_CODE_HOME']; + +afterEach(async () => { + setExperimentalFeatures([]); + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + if (originalKimiCodeHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = originalKimiCodeHome; + } +}); + +describe('reload slash commands', () => { + it('reloads tui.toml without touching Core session state', async () => { + await writeTuiConfig(` +theme = "light" + +[editor] +command = "vim" + +[notifications] +enabled = false +notification_condition = "always" + +[upgrade] +auto_install = false +`); + const session = { reloadSession: vi.fn() }; + const host = makeHost({ session }); + + await handleReloadTuiCommand(host); + + expect(host.harness.getConfig).not.toHaveBeenCalled(); + expect(host.harness.getExperimentalFeatures).not.toHaveBeenCalled(); + expect(session.reloadSession).not.toHaveBeenCalled(); + expect(host.state.appState).toMatchObject({ + theme: 'light', + editorCommand: 'vim', + notifications: { enabled: false, condition: 'always' }, + upgrade: { autoInstall: false }, + }); + expect(host.showStatus).toHaveBeenCalledWith( + 'TUI config reloaded.', + 'success', + ); + }); + + it('reloads the active session, refreshes runtime config, and applies tui.toml', async () => { + await writeTuiConfig('theme = "light"\n'); + const session = { id: 'ses-1', reloadSession: vi.fn(async () => ({})) }; + const host = makeHost({ session }); + + await handleReloadCommand(host); + + expect(session.reloadSession).toHaveBeenCalledWith({ + forcePluginSessionStartReminder: true, + }); + expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( + session, + 'Session reloaded.', + ); + expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true }); + expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + expect(isExperimentalFlagEnabled('micro_compaction')).toBe(true); + expect(host.state.appState.theme).toBe('light'); + expect(host.state.appState.availableModels).toEqual({ + fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 }, + }); + }); + + it('awaits the async theme application before refreshing terminal tracking', async () => { + await writeTuiConfig('theme = "auto"\n'); + const host = makeHost(); + const mutable = host as unknown as { + applyTheme: (theme: string) => Promise<void>; + refreshTerminalThemeTracking: () => void; + state: { appState: { theme: string } }; + }; + + let themeWhenTracked: string | undefined; + // Theme application resolves on a later microtask, mirroring the real + // async palette load; tracking must observe the *new* theme. + mutable.applyTheme = vi.fn(async (theme: string) => { + await Promise.resolve(); + mutable.state.appState.theme = theme; + }); + mutable.refreshTerminalThemeTracking = vi.fn(() => { + themeWhenTracked = mutable.state.appState.theme; + }); + + await handleReloadTuiCommand(host); + + expect(themeWhenTracked).toBe('auto'); + }); +}); + +async function writeTuiConfig(text: string): Promise<void> { + const dir = join(tmpdir(), `kimi-tui-reload-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDirs.push(dir); + await mkdir(dir, { recursive: true }); + process.env['KIMI_CODE_HOME'] = dir; + await writeFile(join(dir, 'tui.toml'), text, 'utf-8'); +} + +function makeHost({ + session, +}: { + readonly session?: Record<string, unknown>; +} = {}) { + const state = { + appState: { + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + availableModels: {}, + availableProviders: {}, + }, + editor: { + setDisablePasteBurst: vi.fn(), + }, + theme: { + palette: { + success: '#00ff00', + }, + }, + }; + return { + state, + session, + harness: { + getConfig: vi.fn(async () => ({ + models: { + fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 }, + }, + providers: { + test: { type: 'kimi', apiKey: 'test-key' }, + }, + })), + getExperimentalFeatures: vi.fn(async () => [{ id: 'micro_compaction', enabled: true }]), + }, + setAppState: vi.fn((patch: Record<string, unknown>) => { + Object.assign(state.appState, patch); + }), + applyTheme: vi.fn((theme: string) => { + state.appState.theme = theme; + }), + refreshTerminalThemeTracking: vi.fn(), + refreshSlashCommandAutocomplete: vi.fn(), + reloadCurrentSessionView: vi.fn(async () => {}), + showStatus: vi.fn(), + } as unknown as SlashCommandHost & { + readonly harness: { + readonly getConfig: ReturnType<typeof vi.fn>; + readonly getExperimentalFeatures: ReturnType<typeof vi.fn>; + }; + readonly refreshSlashCommandAutocomplete: ReturnType<typeof vi.fn>; + readonly reloadCurrentSessionView: ReturnType<typeof vi.fn>; + readonly showStatus: ReturnType<typeof vi.fn>; + }; +} diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index a02f6f087..614553bb4 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -1,10 +1,11 @@ import { resolveSkillCommand, resolveSlashCommandInput, + setExperimentalFeatures, slashBusyMessage, slashCommandBusyReason, } from '#/tui/commands/index'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; function resolve( input: string, @@ -13,6 +14,7 @@ function resolve( return resolveSlashCommandInput({ input, skillCommandMap: new Map<string, string>(), + pluginCommandMap: new Map<string, string>(), isStreaming: false, isCompacting: false, ...overrides, @@ -20,6 +22,10 @@ function resolve( } describe('resolveSlashCommandInput', () => { + afterEach(() => { + setExperimentalFeatures([]); + }); + it('returns not-command for normal text', () => { expect(resolve('hello')).toEqual({ kind: 'not-command' }); }); @@ -34,7 +40,27 @@ describe('resolveSlashCommandInput', () => { name: 'title', args: 'New title', }); + expect(resolve('/add-dir list')).toMatchObject({ + kind: 'builtin', + name: 'add-dir', + args: 'list', + }); expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' }); + expect(resolve('/btw')).toMatchObject({ + kind: 'builtin', + name: 'btw', + args: '', + }); + expect(resolve('/btw what are you doing?')).toMatchObject({ + kind: 'builtin', + name: 'btw', + args: 'what are you doing?', + }); + expect(resolve('/experiments')).toMatchObject({ + kind: 'builtin', + name: 'experiments', + args: '', + }); }); it('blocks idle-only built-ins while streaming', () => { @@ -58,6 +84,36 @@ describe('resolveSlashCommandInput', () => { commandName: 'resume', reason: 'streaming', }); + expect(resolve('/undo', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'undo', + reason: 'streaming', + }); + expect(resolve('/reload', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'reload', + reason: 'streaming', + }); + expect(resolve('/add-dir ../shared', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'streaming', + }); + expect(resolve('/experiments', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'experiments', + reason: 'streaming', + }); + expect(resolve('/swarm on', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'swarm', + reason: 'streaming', + }); + expect(resolve('/swarm off', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'swarm', + reason: 'streaming', + }); }); it('blocks model and session pickers while compacting', () => { @@ -71,6 +127,31 @@ describe('resolveSlashCommandInput', () => { commandName: 'resume', reason: 'compacting', }); + expect(resolve('/reload', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'reload', + reason: 'compacting', + }); + expect(resolve('/add-dir ../shared', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'compacting', + }); + expect(resolve('/experiments', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'experiments', + reason: 'compacting', + }); + expect(resolve('/swarm on', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'swarm', + reason: 'compacting', + }); + expect(resolve('/swarm off', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'swarm', + reason: 'compacting', + }); }); it('allows always-available built-ins while streaming', () => { @@ -89,6 +170,21 @@ describe('resolveSlashCommandInput', () => { name: 'mcp', args: '', }); + expect(resolve('/reload-tui', { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'reload-tui', + args: '', + }); + expect(resolve('/reload-tui', { isCompacting: true })).toMatchObject({ + kind: 'builtin', + name: 'reload-tui', + args: '', + }); + expect(resolve('/btw side question', { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'btw', + args: 'side question', + }); }); it('blocks plan clear while compacting because it is idle-only', () => { @@ -115,6 +211,33 @@ describe('resolveSlashCommandInput', () => { }); }); + it('resolves unprefixed built-in skill commands and blocks them while busy', () => { + const skillCommandMap = new Map([['mcp-config', 'mcp-config']]); + + expect(resolve('/mcp-config', { skillCommandMap })).toEqual({ + kind: 'skill', + commandName: 'mcp-config', + skillName: 'mcp-config', + args: '', + }); + expect(resolve('/mcp-config', { skillCommandMap, isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'mcp-config', + reason: 'compacting', + }); + }); + + it('resolves unprefixed sub-skill commands with dotted names', () => { + const skillCommandMap = new Map([['outer.inner', 'outer.inner']]); + + expect(resolve('/outer.inner src/app.ts', { skillCommandMap })).toEqual({ + kind: 'skill', + commandName: 'outer.inner', + skillName: 'outer.inner', + args: 'src/app.ts', + }); + }); + it('returns message for unknown slash input', () => { expect(resolve('/does-not-exist arg')).toEqual({ kind: 'message', @@ -122,14 +245,61 @@ describe('resolveSlashCommandInput', () => { }); }); + it('resolves /swarm without an experimental flag', () => { + expect(resolve('/swarm Ship feature X')).toMatchObject({ + kind: 'builtin', + name: 'swarm', + args: 'Ship feature X', + }); + }); + +}); + +describe('goal command resolution', () => { + afterEach(() => { + setExperimentalFeatures([]); + }); + + it('resolves /goal to the builtin command without an experimental flag', () => { + expect(resolve('/goal Ship feature X')).toMatchObject({ + kind: 'builtin', + name: 'goal', + args: 'Ship feature X', + }); + }); + + it('blocks goal creation while streaming', () => { + expect(resolve('/goal Ship feature X', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'goal', + reason: 'streaming', + }); + }); + + it('does not block status/pause/cancel/bare goal while streaming', () => { + for (const sub of ['status', 'pause', 'cancel']) { + expect(resolve(`/goal ${sub}`, { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'goal', + }); + } + expect(resolve('/goal', { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'goal', + }); + }); }); describe('slash command busy helpers', () => { it('resolves skill command aliases with and without skill prefix', () => { - const map = new Map([['skill:review', 'review']]); + const map = new Map([ + ['skill:review', 'review'], + ['mcp-config', 'mcp-config'], + ]); expect(resolveSkillCommand(map, 'skill:review')).toBe('review'); expect(resolveSkillCommand(map, 'review')).toBe('review'); + expect(resolveSkillCommand(map, 'mcp-config')).toBe('mcp-config'); }); it('formats busy messages', () => { @@ -138,4 +308,33 @@ describe('slash command busy helpers', () => { expect(slashBusyMessage('new', 'streaming')).toContain('Cannot /new while streaming'); expect(slashBusyMessage('new', 'compacting')).toContain('Cannot /new while compacting'); }); + + it('resolves a namespaced plugin command to a plugin-command intent', () => { + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy $ARGUMENTS']]); + expect(resolve('/my-plugin:deploy prod', { pluginCommandMap })).toEqual({ + kind: 'plugin-command', + commandName: 'deploy', + pluginId: 'my-plugin', + args: 'prod', + }); + }); + + it('resolves a nested plugin command whose name contains a slash', () => { + const pluginCommandMap = new Map([['my-plugin:frontend/component', 'body']]); + expect(resolve('/my-plugin:frontend/component spin', { pluginCommandMap })).toEqual({ + kind: 'plugin-command', + commandName: 'frontend/component', + pluginId: 'my-plugin', + args: 'spin', + }); + }); + + it('blocks a plugin command while streaming', () => { + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy']]); + expect(resolve('/my-plugin:deploy', { pluginCommandMap, isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'my-plugin:deploy', + reason: 'streaming', + }); + }); }); diff --git a/apps/kimi-code/test/tui/commands/skills.test.ts b/apps/kimi-code/test/tui/commands/skills.test.ts index 81a176813..b9cf46bd6 100644 --- a/apps/kimi-code/test/tui/commands/skills.test.ts +++ b/apps/kimi-code/test/tui/commands/skills.test.ts @@ -27,22 +27,58 @@ describe('skill slash commands', () => { expect(isUserActivatableSkill(skill('agent', 'agent'))).toBe(false); }); - it('builds slash commands and command map entries with skill prefixes', () => { + it('builds slash commands and command map entries with skill prefixes for non-built-in skills', () => { const built = buildSkillSlashCommands([ skill('review', 'prompt'), + skill('nested-review', 'prompt', { + description: 'Nested review skill', + path: '/skills/parent/nested-review/SKILL.md', + }), skill('agent-only', 'agent'), skill('commit', 'flow'), ]); - expect(built.commands.map((command) => command.name)).toEqual(['skill:review', 'skill:commit']); + expect(built.commands.map((command) => command.name)).toEqual([ + 'skill:commit', + 'skill:nested-review', + 'skill:review', + ]); expect(built.commands[0]).toMatchObject({ - name: 'skill:review', + name: 'skill:commit', aliases: [], - description: 'review skill', + description: 'commit skill', + }); + expect(built.commands[1]).toMatchObject({ + name: 'skill:nested-review', + aliases: [], + description: 'Nested review skill', }); expect([...built.commandMap.entries()]).toEqual([ - ['skill:review', 'review'], ['skill:commit', 'commit'], + ['skill:nested-review', 'nested-review'], + ['skill:review', 'review'], + ]); + }); + + it('sorts built-in skill slash commands before external skill commands', () => { + const built = buildSkillSlashCommands([ + skill('zeta', 'prompt', { source: 'user' }), + skill('alpha', 'prompt', { source: 'project' }), + skill('update-config', 'inline', { source: 'builtin' }), + skill('mcp-config', 'inline', { source: 'builtin' }), + ]); + + expect(built.commands.map((command) => command.name)).toEqual([ + 'mcp-config', + 'update-config', + 'skill:alpha', + 'skill:zeta', + ]); + expect([...built.commandMap.entries()]).toEqual([ + ['mcp-config', 'mcp-config'], + ['update-config', 'update-config'], + ['skill:alpha', 'alpha'], + ['skill:zeta', 'zeta'], ]); }); @@ -51,7 +87,19 @@ describe('skill slash commands', () => { skill('mcp-config', 'inline', { disableModelInvocation: true, source: 'builtin' }), ]); - expect(built.commands.map((command) => command.name)).toEqual(['skill:mcp-config']); - expect(built.commandMap.get('skill:mcp-config')).toBe('mcp-config'); + expect(built.commands.map((command) => command.name)).toEqual(['mcp-config']); + expect(built.commandMap.get('mcp-config')).toBe('mcp-config'); + }); + + it('keeps sub-skills slash-invocable', () => { + const built = buildSkillSlashCommands([ + skill('outer.inner', 'prompt', { + isSubSkill: true, + source: 'project', + }), + ]); + + expect(built.commands.map((command) => command.name)).toEqual(['outer.inner']); + expect(built.commandMap.get('outer.inner')).toBe('outer.inner'); }); }); diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts new file mode 100644 index 000000000..3e3c9b11a --- /dev/null +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleSwarmCommand } from '#/tui/commands/index'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { currentTheme } from '#/tui/theme'; + +const ENTER = '\r'; +const ESCAPE = '\u001B'; +const DOWN = '\u001B[B'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +interface TestComponent { + render(width: number): string[]; +} + +function makeHost( + overrides: { + model?: string; + hasSession?: boolean; + permissionMode?: 'manual' | 'auto' | 'yolo'; + swarmMode?: boolean; + } = {}, +) { + const session = { + setPermission: vi.fn(async () => {}), + setSwarmMode: vi.fn(async () => {}), + }; + const hasSession = overrides.hasSession ?? true; + const host = { + state: { + appState: { + model: overrides.model ?? 'kimi-model', + permissionMode: overrides.permissionMode ?? 'auto', + swarmMode: overrides.swarmMode ?? false, + }, + theme: currentTheme, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: hasSession ? session : undefined, + requireSession: () => session, + setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(host.state.appState, patch)), + showError: vi.fn(), + showStatus: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + sendNormalUserInput: vi.fn(), + } as unknown as SlashCommandHost; + return { host, session }; +} + +interface TestPicker { + handleInput(data: string): void; + render(width: number): string[]; +} + +function mountedPicker(host: SlashCommandHost): TestPicker { + const mock = host.mountEditorReplacement as ReturnType<typeof vi.fn>; + return mock.mock.calls[0]?.[0] as TestPicker; +} + +function markerAddChild(host: SlashCommandHost): ReturnType<typeof vi.fn> { + return host.state.transcriptContainer.addChild as ReturnType<typeof vi.fn>; +} + +function expectSwarmMarker(host: SlashCommandHost, text: string): void { + const components = markerAddChild(host).mock.calls.map(([component]) => component as TestComponent); + const rendered = stripAnsi(components.at(-1)?.render(80).join('\n') ?? ''); + expect(rendered).toContain(text); +} + +describe('handleSwarmCommand', () => { + it('sends the swarm prompt as a normal prompt after enabling swarm mode', async () => { + const { host, session } = makeHost({ permissionMode: 'auto' }); + + await handleSwarmCommand(host, 'Ship feature X'); + + expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(host.state.swarmModeEntry).toBe('task'); + expectSwarmMarker(host, 'Swarm activated'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('sends the swarm prompt without re-entering swarm mode when already on', async () => { + const { host, session } = makeHost({ permissionMode: 'auto', swarmMode: true }); + + await handleSwarmCommand(host, 'Ship feature X'); + + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(host.state.swarmModeEntry).toBeUndefined(); + expectSwarmMarker(host, 'Swarm activated'); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('turns swarm mode on without sending a prompt', async () => { + const { host, session } = makeHost({ model: '' }); + + await handleSwarmCommand(host, 'on'); + + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('manual'); + expectSwarmMarker(host, 'Swarm activated'); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('asks before turning swarm mode on in Manual mode', async () => { + const { host, session } = makeHost({ model: '', permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'on'); + + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + const text = stripAnsi(mountedPicker(host).render(80).join('\n')); + expect(text).toContain('Manual mode can block swarm work'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'manual'); + }); + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('manual'); + expectSwarmMarker(host, 'Swarm activated'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('turns swarm mode on when called without args while swarm mode is off', async () => { + const { host, session } = makeHost({ model: '', swarmMode: false }); + + await handleSwarmCommand(host, ''); + + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('manual'); + expectSwarmMarker(host, 'Swarm activated'); + expect(host.showError).not.toHaveBeenCalled(); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not call the session when swarm mode is already on', async () => { + const { host, session } = makeHost({ model: '', swarmMode: true }); + + await handleSwarmCommand(host, 'on'); + + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalledWith({ swarmMode: true }); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Swarm mode is already on.'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('turns swarm mode off without sending a prompt', async () => { + const { host, session } = makeHost({ model: '', swarmMode: true }); + + await handleSwarmCommand(host, 'off'); + + expect(session.setSwarmMode).toHaveBeenCalledWith(false, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); + expect(host.state.swarmModeEntry).toBeUndefined(); + expectSwarmMarker(host, 'Swarm deactivated'); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('turns swarm mode off when called without args while swarm mode is on', async () => { + const { host, session } = makeHost({ model: '', swarmMode: true }); + + await handleSwarmCommand(host, ''); + + expect(session.setSwarmMode).toHaveBeenCalledWith(false, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); + expect(host.state.swarmModeEntry).toBeUndefined(); + expectSwarmMarker(host, 'Swarm deactivated'); + expect(host.showError).not.toHaveBeenCalled(); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not call the session when swarm mode is already off', async () => { + const { host, session } = makeHost({ model: '', swarmMode: false }); + + await handleSwarmCommand(host, 'off'); + + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalledWith({ swarmMode: false }); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Swarm mode is already off.'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('asks before starting a swarm task in Manual mode', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + const text = stripAnsi(mountedPicker(host).render(80).join('\n')); + expect(text).toContain('Manual mode can block swarm work'); + expect(text).toContain('Switch to YOLO and start'); + expect(text).not.toContain('Do not start'); + }); + + it('defaults to Auto when confirming a Manual-mode swarm start', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('task'); + expectSwarmMarker(host, 'Swarm activated'); + }); + + it('can start a Manual-mode swarm task without changing permission', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + const picker = mountedPicker(host); + picker.handleInput(DOWN); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); + expect(host.state.swarmModeEntry).toBe('task'); + expectSwarmMarker(host, 'Swarm activated'); + }); + + it('can start a Manual-mode swarm task after switching to YOLO', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + const picker = mountedPicker(host); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('task'); + expectSwarmMarker(host, 'Swarm activated'); + }); + + it('returns the command to the input box when a Manual-mode swarm start is cancelled', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ESCAPE); + + expect(host.restoreInputText).toHaveBeenCalledWith('/swarm Ship feature X'); + expect(host.showStatus).toHaveBeenCalledWith('Swarm task not started.'); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not start when permission update fails', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + session.setPermission.mockRejectedValueOnce(new Error('denied')); + + await handleSwarmCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to set permission mode'), + ); + }); + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not send from Manual mode when enabling swarm mode fails after confirmation', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + session.setSwarmMode.mockRejectedValueOnce(new Error('denied')); + + await handleSwarmCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to enable swarm mode'), + ); + }); + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not send a prompt when enabling swarm mode fails', async () => { + const { host, session } = makeHost({ permissionMode: 'auto' }); + session.setSwarmMode.mockRejectedValueOnce(new Error('denied')); + + await handleSwarmCommand(host, 'Ship feature X'); + + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to enable swarm mode'), + ); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts new file mode 100644 index 000000000..b584c33d6 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { applyUpdatePreferenceChoice } from '#/tui/commands/config'; +import { darkColors } from '#/tui/theme/colors'; + +const mocks = vi.hoisted(() => ({ + saveTuiConfig: vi.fn(), +})); + +vi.mock('../../../src/tui/config', async () => { + const actual = await vi.importActual<typeof import('../../../src/tui/config.js')>( + '../../../src/tui/config.js', + ); + return { + ...actual, + saveTuiConfig: mocks.saveTuiConfig, + }; +}); + +describe('update preference commands', () => { + it('saves automatic update preference changes to tui.toml', async () => { + const setAppState = vi.fn(); + const showStatus = vi.fn(); + const track = vi.fn(); + const host = { + state: { + appState: { + theme: 'auto' as const, + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' as const }, + upgrade: { autoInstall: true }, + }, + theme: { palette: darkColors }, + }, + setAppState, + showStatus, + track, + }; + + await applyUpdatePreferenceChoice(host, false); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith({ + theme: 'auto', + editorCommand: null, + disablePasteBurst: false, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: false }, + }); + expect(setAppState).toHaveBeenCalledWith({ upgrade: { autoInstall: false } }); + expect(track).toHaveBeenCalledWith('upgrade_preference_changed', { auto_install: false }); + expect(showStatus).toHaveBeenCalledWith('Automatic updates disabled.'); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts new file mode 100644 index 000000000..cbd6e5eec --- /dev/null +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { handleWebCommand, webSessionUrl } from '#/tui/commands/web'; + +const mocks = vi.hoisted(() => ({ + ensureDaemon: vi.fn(), + tryResolveServerToken: vi.fn(), + getDataDir: vi.fn(() => '/tmp/kimi-home'), + openUrl: vi.fn(), +})); + +vi.mock('#/cli/sub/server/daemon', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/cli/sub/server/daemon')>(); + return { ...actual, ensureDaemon: mocks.ensureDaemon }; +}); + +vi.mock('#/cli/sub/server/shared', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/cli/sub/server/shared')>(); + return { ...actual, tryResolveServerToken: mocks.tryResolveServerToken }; +}); + +vi.mock('#/utils/open-url', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/utils/open-url')>(); + return { ...actual, openUrl: mocks.openUrl }; +}); + +vi.mock('#/utils/paths', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/utils/paths')>(); + return { ...actual, getDataDir: mocks.getDataDir }; +}); + +type MountedPanel = { + handleInput: (data: string) => void; + render: (width: number) => string[]; +}; + +function makeHost() { + let mountedPanel: MountedPanel | null = null; + const host = { + session: { id: 'ses-1' }, + showStatus: vi.fn(), + showError: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mountedPanel = panel; + }), + restoreEditor: vi.fn(), + setExitOpenUrl: vi.fn(), + stop: vi.fn(async () => {}), + } as unknown as SlashCommandHost & { + showStatus: ReturnType<typeof vi.fn>; + showError: ReturnType<typeof vi.fn>; + mountEditorReplacement: ReturnType<typeof vi.fn>; + restoreEditor: ReturnType<typeof vi.fn>; + setExitOpenUrl: ReturnType<typeof vi.fn>; + stop: ReturnType<typeof vi.fn>; + }; + return { host, getMountedPanel: () => mountedPanel }; +} + +describe('web slash command', () => { + it('is registered as an always-available built-in', () => { + const command = findBuiltInSlashCommand('web'); + expect(command).toBeDefined(); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); +}); + +describe('handleWebCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getDataDir.mockReturnValue('/tmp/kimi-home'); + mocks.ensureDaemon.mockResolvedValue({ + origin: 'http://127.0.0.1:58627', + reused: false, + host: '127.0.0.1', + port: 58627, + }); + }); + + it('shows the token in green and opens the deep link carrying the token fragment', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + 'success', + ); + expect(host.showStatus).toHaveBeenCalledWith('Token: tok-1', 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.setExitOpenUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.stop).toHaveBeenCalledOnce(); + }); + + it('skips the token line and fragment when no token is available', async () => { + mocks.tryResolveServerToken.mockReturnValue(undefined); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1', + 'success', + ); + expect(host.showStatus).not.toHaveBeenCalledWith(expect.stringContaining('Token:'), 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + expect(host.setExitOpenUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + }); +}); + +describe('webSessionUrl', () => { + it('deep-links to the session under the origin', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123')).toBe( + 'http://127.0.0.1:58627/sessions/abc123', + ); + }); + + it('strips a trailing slash from the origin', () => { + expect(webSessionUrl('http://127.0.0.1:58627/', 'abc123')).toBe( + 'http://127.0.0.1:58627/sessions/abc123', + ); + }); + + it('encodes session ids so the web UI can decode them', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'a/b c')).toBe( + 'http://127.0.0.1:58627/sessions/a%2Fb%20c', + ); + }); + + it('carries the bearer token in the fragment so the browser authenticates on load', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123', 'tok-1')).toBe( + 'http://127.0.0.1:58627/sessions/abc123#token=tok-1', + ); + }); + + it('omits the fragment when no token is available', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123', undefined)).toBe( + 'http://127.0.0.1:58627/sessions/abc123', + ); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/banner.test.ts b/apps/kimi-code/test/tui/components/chrome/banner.test.ts new file mode 100644 index 000000000..aecf815d9 --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/banner.test.ts @@ -0,0 +1,204 @@ +import chalk from 'chalk'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; + +import { BannerComponent } from '#/tui/components/chrome/banner'; +import { currentTheme } from '#/tui/theme'; +import type { BannerState } from '#/tui/types'; + +function makeBannerState(overrides: Partial<BannerState> = {}): BannerState { + return { + key: 'component-banner', + tag: null, + mainText: '', + subText: null, + display: 'always', + ...overrides, + }; +} + +const banner: BannerState = makeBannerState({ + tag: "What's new:", + mainText: 'This is the main banner message for testing purposes.', + subText: 'This is a short subtext line.', +}); + +describe('BannerComponent', () => { + const previousChalkLevel = chalk.level; + + beforeEach(() => { + chalk.level = 3; + }); + + afterEach(() => { + chalk.level = previousChalkLevel; + }); + + it('renders star tag, main text, and subtext', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines.length).toBe(3); + expect(lines[0]).toContain('✦'); + expect(lines[0]).toContain("What's new:"); + expect(lines[0]).toContain('This is the main banner message'); + expect(lines[1]).toContain('This is a short subtext'); + expect(lines[2]).toBe(''); + }); + + it('does not add an extra colon to the tag', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[0]).not.toContain("What's new::"); + }); + + it('renders without a tag when tag is empty', () => { + const lines = new BannerComponent(makeBannerState({ mainText: 'Hello' })).render(80); + expect(lines.length).toBe(2); + expect(lines[0]).not.toContain('✦'); + expect(lines[0]).toContain('Hello'); + expect(lines[1]).toBe(''); + }); + + it('wraps long main text to fit available width', () => { + const width = 30; + const lines = new BannerComponent(banner).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + expect(lines.some((line) => line.includes('…'))).toBe(false); + const mainContentLines = lines.filter((line) => + /main|banner|message|testing|purposes/.test(line), + ); + expect(mainContentLines.length).toBeGreaterThan(1); + }); + + it('wraps long subtext to fit available width', () => { + const width = 30; + const state = makeBannerState({ + mainText: 'Short', + subText: 'Short subtext line one plus subtext line two for wrapping tests.', + }); + const lines = new BannerComponent(state).render(width); + expect(lines[0]).toContain('Short'); + const subContentLines = lines.filter((line) => + /Short subtext|line one|plus|subtext|line two|for|wrapping|tests/.test(line), + ); + expect(subContentLines.length).toBeGreaterThan(1); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('keeps every line within terminal width on very narrow terminals', () => { + for (const width of [0, 1, 2, 3, 5, 10]) { + const lines = new BannerComponent(banner).render(width); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(Math.max(0, width)); + } + } + }); + + it('shows tag only on the first wrapped line', () => { + const width = 40; + const state = makeBannerState({ + tag: 'New:', + mainText: 'This is a very long main text line that should wrap automatically.', + }); + const lines = new BannerComponent(state).render(width); + const mainRows = lines.slice(0, -1); + let tagCount = 0; + for (const line of mainRows) { + if (line.includes('✦ New:')) tagCount += 1; + } + expect(tagCount).toBe(1); + expect(mainRows.length).toBeGreaterThan(1); + const firstIndex = lines.findIndex((line) => line.includes('✦ New:')); + expect(firstIndex).toBe(0); + }); + + it('continues main text under the tag column and keeps subtext aligned with the tag text', () => { + const width = 80; + const lines = new BannerComponent(banner).render(width); + const firstLine = lines[0]!; + const mainStartIndex = firstLine.indexOf('This is the main banner message'); + const tagPrefixVisibleWidth = visibleWidth(firstLine.slice(0, mainStartIndex)); + const subLine = lines[1]!; + const subStartIndex = subLine.indexOf('This is a short subtext'); + const subIndentVisibleWidth = visibleWidth(subLine.slice(0, subStartIndex)); + // The subtext starts two columns after the left edge ("✦ "), which aligns + // with the tag text itself rather than the main-text column. + expect(subIndentVisibleWidth).toBe(visibleWidth('✦ ')); + expect(tagPrefixVisibleWidth).toBeGreaterThan(visibleWidth('✦ ')); + }); + + it('drops the tag when it does not fit', () => { + const width = 5; + const lines = new BannerComponent(banner).render(width); + expect(lines[0]).not.toContain('✦'); + expect(lines[0]).not.toContain("What's new"); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('still renders the tag when it fits', () => { + const width = 40; + const lines = new BannerComponent(banner).render(width); + expect(lines[0]).toContain('✦'); + expect(lines[0]).toContain("What's new"); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('does not render subtext when empty', () => { + const lines = new BannerComponent(makeBannerState({ tag: 'Tip', mainText: 'Use /help' })).render(80); + expect(lines.length).toBe(2); + expect(lines[0]).toContain('Use /help'); + expect(lines[1]).toBe(''); + }); + + it('supports explicit newlines in main text', () => { + const lines = new BannerComponent(makeBannerState({ mainText: 'Line 1\nLine 2' })).render(80); + expect(lines.length).toBe(3); + expect(lines[0]).toContain('Line 1'); + expect(lines[1]).toContain('Line 2'); + expect(lines[2]).toBe(''); + }); + + it('styles tag, main text, and subtext with theme colors', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[0]).toContain(currentTheme.boldFg('primary', "✦ What's new:")); + expect(lines[0]).toContain( + currentTheme.boldFg('textStrong', 'This is the main banner message for testing purposes.'), + ); + expect(lines[1]).toContain(currentTheme.fg('textDim', 'This is a short subtext line.')); + }); + + it('does not stack the dim modifier on top of the textDim color', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[1]).toContain('This is a short subtext'); + expect(lines[1]).not.toContain(''); + expect(lines[2]).toBe(''); + }); + + it('keeps subsequent main lines indented to the main-text column and subtext aligned with the tag text', () => { + const width = 20; + const lines = new BannerComponent( + makeBannerState({ + tag: 'New:', + mainText: 'Line 1 with a lot of content', + subText: 'Sub text', + }), + ).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + expect(lines[0]).toContain('✦ New:'); + const firstLine = lines[0]!; + const mainTextStart = visibleWidth(firstLine.slice(0, firstLine.indexOf('Line 1'))); + const continuationLine = lines.find((line) => line.includes('lot of'))!; + expect(visibleWidth(continuationLine.slice(0, continuationLine.indexOf('lot of')))).toBe(mainTextStart); + const subLine = lines.find((line) => line.includes('Sub text'))!; + expect(visibleWidth(subLine.slice(0, subLine.indexOf('Sub text')))).toBe(visibleWidth('✦ ')); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts index f7cea9b92..c0d4e929f 100644 --- a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts @@ -1,3 +1,4 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { DeviceCodeBoxComponent } from '#/tui/components/chrome/device-code-box'; @@ -19,7 +20,6 @@ describe('DeviceCodeBoxComponent', () => { url, code, hint, - colors: darkColors, }); const lines = component.render(80).map(strip); @@ -42,7 +42,6 @@ describe('DeviceCodeBoxComponent', () => { title, url, code, - colors: darkColors, }); const lines = component.render(40).map(strip); @@ -57,10 +56,24 @@ describe('DeviceCodeBoxComponent', () => { title, url, code, - colors: darkColors, }); const joined = component.render(80).map(strip).join('\n'); expect(joined).not.toContain('Press Ctrl-C'); }); + + it('keeps every line within narrow widths', () => { + const component = new DeviceCodeBoxComponent({ + title, + url, + code, + hint, + }); + + for (const width of [39, 20, 10, 4]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index ea9c89355..2fe6f3e52 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import type { AppState } from '#/tui/types'; const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; @@ -34,11 +35,12 @@ function setDanceView(colored: boolean, phase: number): void { const appState: AppState = { version: '1.2.3', workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, model: 'kimi-k2', permissionMode: 'manual', - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -47,11 +49,15 @@ const appState: AppState = { 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, }; describe('FooterComponent', () => { @@ -68,7 +74,7 @@ describe('FooterComponent', () => { it('paints the model name in rainbow while colored', () => { setDanceView(true, 0); - const footer = new FooterComponent(appState, darkColors); + const footer = new FooterComponent(appState); const codes = truecolorCodes(footer.render(120).join('\n')); @@ -79,11 +85,105 @@ describe('FooterComponent', () => { }); it('renders the model name in its normal color when not dancing', () => { - const footer = new FooterComponent(appState, darkColors); + const footer = new FooterComponent(appState); const codes = truecolorCodes(footer.render(120).join('\n')); expect(codes.has(RAINBOW_CYAN)).toBe(false); expect(codes.has(RAINBOW_GREEN)).toBe(false); }); + + it('repaints from the active palette on the next render (no setColors needed)', () => { + const footer = new FooterComponent(appState); + const before = footer.render(120).join('\n'); + + currentTheme.setPalette(lightColors); + try { + const after = footer.render(120).join('\n'); + // Reads currentTheme live, so a palette swap changes the emitted colours. + expect(after).not.toBe(before); + } finally { + currentTheme.setPalette(darkColors); + } + }); + + it('shows the effort for an effort-capable model', () => { + const effortModel: ModelAlias = { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }; + const state: AppState = { + ...appState, + thinkingEffort: 'max', + availableModels: { 'kimi-k2': effortModel }, + }; + const footer = new FooterComponent(state); + + expect(footer.render(120).join('\n')).toContain('thinking: max'); + }); + + it('does not show the effort for a legacy boolean model', () => { + const plainModel: ModelAlias = { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + capabilities: ['thinking'], + }; + const state: AppState = { + ...appState, + thinkingEffort: 'high', + availableModels: { 'kimi-k2': plainModel }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(120).join('\n'); + + expect(rendered).toContain('thinking'); + expect(rendered).not.toContain('thinking:high'); + }); +}); + +describe('FooterComponent overrides', () => { + it('shows the overridden effort list', () => { + const effortModelWithOverride: ModelAlias = { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + overrides: { supportEfforts: ['low', 'high'], defaultEffort: 'high' }, + }; + const state: AppState = { + ...appState, + thinkingEffort: 'high', + availableModels: { 'kimi-k2': effortModelWithOverride }, + }; + const footer = new FooterComponent(state); + + expect(footer.render(120).join('\n')).toContain('thinking: high'); + }); +}); + +describe('FooterComponent displayName override', () => { + it('renders the overridden display name', () => { + const state: AppState = { + ...appState, + model: 'kimi-k2', + availableModels: { + 'kimi-k2': { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + displayName: 'Remote Name', + overrides: { displayName: 'Custom Name' }, + }, + }, + }; + const footer = new FooterComponent(state); + + expect(footer.render(120).join('\n')).toContain('Custom Name'); + expect(footer.render(120).join('\n')).not.toContain('Remote Name'); + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts index c26e97a17..295363a74 100644 --- a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { GutterContainer } from '#/tui/components/chrome/gutter-container'; diff --git a/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts new file mode 100644 index 000000000..ffdc5bcbe --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts @@ -0,0 +1,42 @@ +import type { TUI } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { MoonLoader } from '#/tui/components/chrome/moon-loader'; + +// MoonLoader starts a real setInterval in its constructor, so every loader +// created in these tests must be stopped to avoid leaving live timers behind. +const loaders: MoonLoader[] = []; + +function createLoader(): MoonLoader { + const ui = { requestRender() {} } as unknown as TUI; + const loader = new MoonLoader(ui, 'moon'); + loaders.push(loader); + return loader; +} + +afterEach(() => { + for (const loader of loaders) loader.stop(); + loaders.length = 0; +}); + +describe('MoonLoader', () => { + it('keeps the tip out of renderInline so it does not squeeze against the swarm progress bar', () => { + const loader = createLoader(); + loader.setTip(' · Tip: ctrl+s: steer mid-turn'); + loader.setAvailableWidth(80); + + const inline = loader.renderInline(); + expect(inline).not.toContain('Tip'); + expect(inline).not.toContain('steer'); + expect(inline.trim().length).toBeGreaterThan(0); + }); + + it('still shows the tip on its own row when width allows', () => { + const loader = createLoader(); + loader.setTip(' · Tip: ctrl+s: steer mid-turn'); + loader.setAvailableWidth(80); + + const row = loader.render(80).join('\n'); + expect(row).toContain('Tip: ctrl+s: steer mid-turn'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index 418d56679..bc1b754fb 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -1,3 +1,4 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -11,11 +12,12 @@ const TRUECOLOR_PATTERN = /\u001B\[38;2;(\d+);(\d+);(\d+)m/g; const appState: AppState = { version: '1.2.3', workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, model: 'kimi-k2', permissionMode: 'manual', - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -24,11 +26,15 @@ const appState: AppState = { 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, }; function truecolorCodes(text: string): Set<string> { @@ -68,7 +74,7 @@ describe('WelcomeComponent', () => { }); it('renders the banner in a single brand color by default', () => { - const codes = truecolorCodes(headerOf(new WelcomeComponent(appState, darkColors).render(80))); + const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); // No rainbow by default — just the brand primary (plus the dim tagline). expect(codes.size).toBeLessThanOrEqual(2); @@ -76,16 +82,24 @@ describe('WelcomeComponent', () => { it('paints the banner in rainbow while colored', () => { setDanceView(true, 0); - const codes = truecolorCodes(headerOf(new WelcomeComponent(appState, darkColors).render(80))); + const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); expect(codes.size).toBeGreaterThanOrEqual(5); }); it('renders exactly the default banner when not colored', () => { - const base = headerOf(new WelcomeComponent(appState, darkColors).render(80)); + const base = headerOf(new WelcomeComponent(appState).render(80)); setDanceView(false, 5); - const off = headerOf(new WelcomeComponent(appState, darkColors).render(80)); + const off = headerOf(new WelcomeComponent(appState).render(80)); expect(off).toBe(base); }); + + it('keeps every line within the requested width on narrow terminals', () => { + for (const width of [0, 1, 2, 4, 10, 39, 80]) { + for (const line of new WelcomeComponent(appState).render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts new file mode 100644 index 000000000..610ea23e2 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts @@ -0,0 +1,21 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; + +describe('ApiKeyInputDialogComponent', () => { + it('keeps every line within narrow widths', () => { + const dialog = new ApiKeyInputDialogComponent( + 'Kimi Code', + ['Paste your API key below.', 'It will be stored locally.'], + () => {}, + ); + dialog.focused = true; + + for (const width of [39, 20, 10]) { + for (const line of dialog.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts index 0b844ee7c..d02df500e 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts @@ -1,4 +1,4 @@ -import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; @@ -7,12 +7,9 @@ import type { FileContentDisplayBlock, PendingApproval, } from '#/tui/reverse-rpc/types'; -import { getColorPalette } from '#/tui/theme/colors'; import { captureProcessWrite } from '../../../helpers/process'; -const COLORS = getColorPalette('dark'); - function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -52,7 +49,6 @@ function makeDialog(): { const dialog = new ApprovalPanelComponent( makePending(), (response) => responses.push(response), - COLORS, ); return { dialog, responses }; } @@ -65,6 +61,33 @@ describe('ApprovalPanelComponent', () => { expect(out).not.toContain('y/a/n/f'); }); + it('renders choice descriptions beneath the label when present', () => { + const pending: PendingApproval = { + data: { + id: 'approval_goal', + tool_call_id: 'tool_goal', + tool_name: 'CreateGoal', + action: 'Creating a goal', + description: '', + display: [], + choices: [ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: 'Tools are approved automatically, and questions are skipped.', + }, + { label: 'Do not start', response: 'cancelled', selected_label: 'cancel' }, + ], + }, + }; + const out = strip(new ApprovalPanelComponent(pending, () => {}).render(80).join('\n')); + expect(out).toContain('1. Switch to Auto and start'); + expect(out).toContain('Tools are approved automatically, and questions are skipped.'); + // A choice without a description stays label-only — no stray blank helper line. + expect(out).toContain('2. Do not start'); + }); + it('renders dangerous shell warnings with simple copy and no icon', () => { const pending: PendingApproval = { data: { @@ -84,7 +107,7 @@ describe('ApprovalPanelComponent', () => { choices: [{ label: 'Approve once', response: 'approved' }], }, }; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const dialog = new ApprovalPanelComponent(pending, () => {}); const out = strip(dialog.render(80).join('\n')); expect(out).toContain('Dangerous: recursive delete'); @@ -92,6 +115,38 @@ describe('ApprovalPanelComponent', () => { expect(out).not.toContain('⚠'); }); + it('wraps a long single-line shell command instead of truncating it', () => { + const head = 'approve-long-command-head'; + const tail = 'approve-long-command-tail'; + const command = `printf ${head}_${'x'.repeat(220)}_${tail}`; + const pending: PendingApproval = { + data: { + id: 'approval_long_command', + tool_call_id: 'tool_long_command', + tool_name: 'Bash', + action: 'run', + description: '', + display: [ + { + type: 'shell', + language: 'bash', + command, + }, + ], + choices: [{ label: 'Approve once', response: 'approved' }], + }, + }; + const dialog = new ApprovalPanelComponent(pending, () => {}); + + const rendered = dialog.render(60); + const out = strip(rendered.join('\n')); + expect(rendered.length).toBeGreaterThan(8); + expect(out).toContain(head); + expect(out).toContain(tail); + expect(out).not.toContain('...'); + expect(out).not.toContain('…'); + }); + it('numeric shortcuts still drive approval actions', () => { const { dialog, responses } = makeDialog(); dialog.handleInput('2'); @@ -184,7 +239,7 @@ describe('ApprovalPanelComponent', () => { ], }, }; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const dialog = new ApprovalPanelComponent(pending, () => {}); const out = strip(dialog.render(80).join('\n')); expect(out).toContain('Ready to build with this plan?'); @@ -228,14 +283,11 @@ describe('ApprovalPanelComponent', () => { }, }; let toolOutputToggles = 0; - let planToggles = 0; const previewCalls: Array<DiffDisplayBlock | FileContentDisplayBlock> = []; const dialog = new ApprovalPanelComponent( pending, (r) => responses.push(r), - COLORS, () => toolOutputToggles++, - () => planToggles++, (block) => previewCalls.push(block), ); @@ -252,8 +304,7 @@ describe('ApprovalPanelComponent', () => { expect(after).not.toContain('new30'); expect(after).toContain('ctrl+e preview'); expect(previewCalls).toEqual([diffBlock]); - // The unrelated forward-only callbacks must not fire for ctrl+e. - expect(planToggles).toBe(0); + // The unrelated forward-only callback must not fire for ctrl+e. expect(toolOutputToggles).toBe(0); expect(responses).toEqual([]); }); @@ -278,7 +329,7 @@ describe('ApprovalPanelComponent', () => { }, }; let globalToggleCalls = 0; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS, () => globalToggleCalls++); + const dialog = new ApprovalPanelComponent(pending, () => {}, () => globalToggleCalls++); dialog.handleInput('\u000F'); // Ctrl+O @@ -288,10 +339,7 @@ describe('ApprovalPanelComponent', () => { expect(after).not.toContain('new30'); }); - // When there is no diff / file_content block to preview (e.g. plan_review - // with an empty display), ctrl+e falls through to the legacy global plan - // expand toggle so plan mode keeps working. - it('falls through to onTogglePlanExpand when there is nothing to preview', () => { + it('does nothing on ctrl+e when there is nothing to preview', () => { const pending: PendingApproval = { data: { id: 'approval_plan_only', @@ -303,19 +351,15 @@ describe('ApprovalPanelComponent', () => { choices: [{ label: 'Approve', response: 'approved' }], }, }; - let planToggles = 0; const previewCalls: Array<DiffDisplayBlock | FileContentDisplayBlock> = []; const dialog = new ApprovalPanelComponent( pending, () => {}, - COLORS, undefined, - () => planToggles++, (block) => previewCalls.push(block), ); dialog.handleInput('\u0005'); // Ctrl+E - expect(planToggles).toBe(1); expect(previewCalls).toEqual([]); }); @@ -343,8 +387,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (r) => responses.push(r), - COLORS, - undefined, undefined, (block) => previewCalls.push(block), ); @@ -386,8 +428,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, () => {}, - COLORS, - undefined, undefined, (block) => previewCalls.push(block), ); @@ -430,7 +470,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (response) => responses.push(response), - COLORS, ); dialog.handleInput('2'); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts index e3f216a1e..316b5f89d 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts @@ -1,14 +1,10 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApprovalPreviewViewer, type ApprovalPreviewBlock, } from '#/tui/components/dialogs/approval-preview'; -import { getColorPalette } from '#/tui/theme/colors'; - -const COLORS = getColorPalette('dark'); - const ANSI_SGR = /\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); @@ -49,7 +45,6 @@ function makeViewer(opts: { return new ApprovalPreviewViewer( { block: opts.block, - colors: COLORS, onClose: opts.onClose ?? (() => {}), }, fakeTerminal(opts.rows ?? 24, opts.columns ?? 100), @@ -143,6 +138,26 @@ describe('ApprovalPreviewViewer', () => { expect(text).toContain('BETA'); }); + it('shows surrounding context lines for a diff block', () => { + const viewer = makeViewer({ + block: { + type: 'diff', + path: 'src/foo.ts', + old_text: ['before1', 'before2', 'old', 'after1', 'after2'].join('\n'), + new_text: ['before1', 'before2', 'new', 'after1', 'after2'].join('\n'), + }, + rows: 24, + }); + + const text = strip(viewer.render(100).join('\n')); + expect(text).toContain('before1'); + expect(text).toContain('before2'); + expect(text).toContain('old'); + expect(text).toContain('new'); + expect(text).toContain('after1'); + expect(text).toContain('after2'); + }); + // Sanity: rendering is a pure slice — repeated render() calls without // input changes produce the same output, no incremental state drift. it('renders deterministically across repeated calls', () => { diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index 7f47bbe12..ec590e252 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -1,20 +1,50 @@ import { describe, expect, it, vi } from 'vitest'; -import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; +import { ChoicePickerComponent, type ChoiceOption } from '#/tui/components/dialogs/choice-picker'; import { EditorSelectorComponent } from '#/tui/components/dialogs/editor-selector'; -import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { PermissionSelectorComponent } from '#/tui/components/dialogs/permission-selector'; import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '#/tui/components/dialogs/theme-selector'; +import { UpdatePreferenceSelectorComponent } from '#/tui/components/dialogs/update-preference-selector'; +import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; -const ANSI_SGR = /\u001B\[[0-9;]*m/g; +const ANSI_SGR = /\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } describe('ChoicePickerComponent', () => { + it('uses the model-dialog header vocabulary (capitalized keys, "type to search")', () => { + const picker = new ChoicePickerComponent({ + title: 'Add provider', + options: [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Beta' }, + ], + searchable: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(120).map(strip); + + const titleIdx = lines.findIndex((l) => l.includes('Add provider')); + expect(titleIdx).toBeGreaterThanOrEqual(0); + // Title carries the same "(type to search)" suffix as /model and /provider. + expect(lines[titleIdx]).toContain('(type to search)'); + expect(lines[titleIdx]).not.toContain('type to filter'); + // Hint sits directly under the title and uses lowercase key vocabulary. + const hint = lines[titleIdx + 1]; + expect(hint).toContain('↑↓ navigate'); + expect(hint).toContain('Enter select'); + expect(hint).toContain('Esc cancel'); + expect(hint).not.toContain('enter select'); + expect(hint).not.toContain('esc cancel'); + // Blank line separates the hint from the body, like the model dialog. + expect(lines[titleIdx + 2]).toBe(''); + }); + it('renders optional descriptions below choice labels', () => { const picker = new ChoicePickerComponent({ title: 'Select permission mode', @@ -31,7 +61,6 @@ describe('ChoicePickerComponent', () => { }, ], currentValue: 'manual', - colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -49,36 +78,13 @@ describe('ChoicePickerComponent', () => { const editor = new EditorSelectorComponent({ currentValue: 'vim', - colors: darkColors, onSelect, onCancel, }); expect(editor.render(120).map(strip)).toContain(' ❯ Vim ← current'); - const model = new ModelSelectorComponent({ - models: { - kimi: { - provider: 'managed:kimi-code', - model: 'kimi-k2', - maxContextSize: 200_000, - displayName: 'Kimi K2', - capabilities: ['thinking'], - }, - }, - currentValue: 'kimi', - currentThinking: true, - colors: darkColors, - onSelect, - onCancel, - }); - const modelOutput = model.render(120).map(strip); - expect(modelOutput).toContain(' ❯ Kimi K2 (Kimi Code) ← current'); - expect(modelOutput).toContain(' Thinking (/ to toggle)'); - expect(modelOutput).toContain(' [ On ] Off '); - const theme = new ThemeSelectorComponent({ currentValue: 'light', - colors: darkColors, onSelect, onCancel, }); @@ -86,115 +92,88 @@ describe('ChoicePickerComponent', () => { const permission = new PermissionSelectorComponent({ currentValue: 'manual', - colors: darkColors, onSelect, onCancel, }); expect(permission.render(120).map(strip)).toContain(' ❯ Manual ← current'); const settings = new SettingsSelectorComponent({ - colors: darkColors, onSelect, onCancel, }); const settingsOutput = settings.render(120).map(strip); expect(settingsOutput).toContain(' ❯ Model'); expect(settingsOutput).toContain(' Switch the active model and thinking mode.'); + expect(settingsOutput).toContain(' Turn automatic CLI updates on or off.'); + + const upgradePreference = new UpdatePreferenceSelectorComponent({ + currentValue: true, + onSelect, + onCancel, + }); + const upgradePreferenceOutput = upgradePreference.render(120).map(strip); + expect(upgradePreferenceOutput).toContain(' ❯ On ← current'); + expect(upgradePreferenceOutput).toContain(' Install new versions in the background.'); }); - it('submits the selected model and inline thinking state', () => { + it('routes Space into the query for searchable lists instead of selecting', () => { const onSelect = vi.fn(); - const picker = new ModelSelectorComponent({ - models: { - kimi: { - provider: 'managed:kimi-code', - model: 'kimi-k2', - maxContextSize: 200_000, - displayName: 'Kimi K2', - capabilities: ['thinking'], - }, - }, - currentValue: 'kimi', - currentThinking: true, - colors: darkColors, + const picker = new ChoicePickerComponent({ + title: 'Select a provider', + options: [ + { value: 'openai', label: 'OpenAI' }, + { value: 'azure', label: 'Azure OpenAI' }, + ], + searchable: true, onSelect, onCancel: vi.fn(), }); - picker.handleInput('/'); - picker.handleInput('\r'); - - expect(onSelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: false }); + picker.handleInput(' '); + expect(onSelect).not.toHaveBeenCalled(); }); - it('forces always-thinking models on and unsupported models off', () => { + it('selects on Space when the list is not searchable', () => { const onSelect = vi.fn(); - const picker = new ModelSelectorComponent({ - models: { - always: { - provider: 'managed:kimi-code', - model: 'kimi-thinking', - maxContextSize: 200_000, - displayName: 'Kimi Thinking', - capabilities: ['always_thinking'], - }, - plain: { - provider: 'managed:kimi-code', - model: 'kimi-plain', - maxContextSize: 200_000, - displayName: 'Kimi Plain', - capabilities: ['tool_use'], - }, - }, - currentValue: 'always', - currentThinking: false, - colors: darkColors, + const picker = new ChoicePickerComponent({ + title: 'Pick one', + options: [{ value: 'a', label: 'Alpha' }], onSelect, onCancel: vi.fn(), }); - expect(picker.render(120).map(strip)).toContain(' [ Always on ]'); - picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); - - picker.handleInput('\u001B[B'); - expect(picker.render(120).map(strip)).toContain(' [ Off ] unsupported'); - picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); + picker.handleInput(' '); + expect(onSelect).toHaveBeenCalledWith('a'); }); - it('keeps the thinking draft when moving across models', () => { - const onSelect = vi.fn(); - const picker = new ModelSelectorComponent({ - models: { - plain: { - provider: 'managed:kimi-code', - model: 'kimi-plain', - maxContextSize: 200_000, - displayName: 'Kimi Plain', - capabilities: ['tool_use'], - }, - thinking: { - provider: 'managed:kimi-code', - model: 'kimi-thinking', - maxContextSize: 200_000, - displayName: 'Kimi Thinking', - capabilities: ['thinking'], - }, + it('renders the selected option description in descriptionTone, others in textMuted', () => { + const options: ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: 'Include your codebase for deeper diagnosis.', + descriptionTone: 'warning', }, - currentValue: 'plain', - currentThinking: false, - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); + ]; - picker.handleInput('\u001B[B'); - picker.handleInput('/'); - picker.handleInput('\u001B[A'); - picker.handleInput('\u001B[B'); - picker.handleInput('\r'); + const renderDescLine = (currentValue: string): string | undefined => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info?', + options, + currentValue, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + return picker.render(120).find((line) => strip(line).includes('Include your codebase')); + }; - expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: true }); + const warningLine = currentTheme.fg('warning', ' Include your codebase for deeper diagnosis.'); + const mutedLine = currentTheme.fg('textMuted', ' Include your codebase for deeper diagnosis.'); + + // Selected option: description uses the configured tone. + expect(renderDescLine('logs+codebase')).toBe(warningLine); + // Unselected option: description falls back to textMuted. + expect(renderDescLine('none')).toBe(mutedLine); }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts index b49501978..4f415bc32 100644 --- a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import chalk from 'chalk'; +import { afterEach, describe, expect, it } from 'vitest'; import { CompactionComponent } from '#/tui/components/dialogs/compaction'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +afterEach(() => { + currentTheme.setPalette(darkColors); +}); function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -9,7 +14,7 @@ function strip(text: string): string { describe('CompactionComponent', () => { it('renders the custom instruction below the compacting label', () => { - const component = new CompactionComponent(darkColors, undefined, 'keep the recent files only'); + const component = new CompactionComponent(undefined, 'keep the recent files only'); try { const lines = component.render(120).map(strip); @@ -22,8 +27,37 @@ describe('CompactionComponent', () => { } }); + it('renders a tip suffix while compacting', () => { + const component = new CompactionComponent(undefined, undefined, 'ctrl+s: steer mid-turn'); + + try { + const lines = component.render(120).map(strip); + const text = lines.join('\n'); + + expect(text).toContain('Compacting context... · Tip: ctrl+s: steer mid-turn'); + } finally { + component.dispose(); + } + }); + + it('does not render a tip after compaction completes', () => { + const component = new CompactionComponent(undefined, undefined, 'ctrl+s: steer mid-turn'); + + try { + component.markDone(1000, 500); + const lines = component.render(120).map(strip); + const text = lines.join('\n'); + + expect(text).toContain('Compaction complete'); + expect(text).not.toContain('Tip:'); + expect(text).not.toContain('Ctrl-O'); + } finally { + component.dispose(); + } + }); + it('renders a cancelled terminal state', () => { - const component = new CompactionComponent(darkColors); + const component = new CompactionComponent(); try { component.markCanceled(); @@ -36,4 +70,109 @@ describe('CompactionComponent', () => { component.dispose(); } }); + + it('keeps the completed compaction summary hidden until expanded', () => { + const component = new CompactionComponent(); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + const collapsed = component.render(120).map(strip).join('\n'); + + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).toContain('120 → 24 tokens'); + expect(collapsed).toContain('Ctrl-O to show compaction summary'); + expect(collapsed).not.toContain('Keep the src/tui compaction notes.'); + + component.setExpanded(true); + const expanded = component.render(120).map(strip).join('\n'); + + expect(expanded).toContain('Compaction complete'); + expect(expanded).toContain('Ctrl-O to hide compaction summary'); + expect(expanded).toContain('Keep the src/tui compaction notes.'); + } finally { + component.dispose(); + } + }); + + it('hides the compaction summary again when collapsed', () => { + const component = new CompactionComponent(); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + component.setExpanded(false); + const text = component.render(120).map(strip).join('\n'); + + expect(text).toContain('Compaction complete'); + expect(text).toContain('Ctrl-O to show compaction summary'); + expect(text).not.toContain('Ctrl-O to hide compaction summary'); + expect(text).not.toContain('Keep the src/tui compaction notes.'); + } finally { + component.dispose(); + } + }); + + it('preserves the expanded summary when invalidating with an instruction', () => { + const component = new CompactionComponent(undefined, 'keep the recent files only'); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + component.invalidate(); + const text = component.render(120).map(strip).join('\n'); + + expect(text).toContain('keep the recent files only'); + expect(text).toContain('Keep the src/tui compaction notes.'); + expect(text.match(/keep the recent files only/g)).toHaveLength(1); + } finally { + component.dispose(); + } + }); + + it('keeps expanded summary child order on invalidate', () => { + const component = new CompactionComponent(undefined, 'keep the recent files only'); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + currentTheme.setPalette(lightColors); + component.invalidate(); + const text = component.render(120).map(strip).join('\n'); + + expect(text).toContain('Keep the src/tui compaction notes.'); + expect(text.indexOf('keep the recent files only')).toBeLessThan( + text.indexOf('Keep the src/tui compaction notes.'), + ); + } finally { + component.dispose(); + } + }); + + it('repaints the header with the active palette on invalidate', () => { + // Force truecolor so palette differences surface as ANSI codes even when + // the test runner has no TTY. + const previousLevel = chalk.level; + chalk.level = 3; + const component = new CompactionComponent(); + + try { + const headerOf = (): string => { + const line = component.render(120).find((l) => strip(l).includes('Compacting context...')); + if (line === undefined) throw new Error('header line not found'); + return line; + }; + const before = headerOf(); + + currentTheme.setPalette(lightColors); + component.invalidate(); + const after = headerOf(); + + // Same visible text, different ANSI colour codes. + expect(strip(after)).toBe(strip(before)); + expect(after).not.toBe(before); + } finally { + chalk.level = previousLevel; + component.dispose(); + } + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts new file mode 100644 index 000000000..68c79974d --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts @@ -0,0 +1,81 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { describe, expect, it, vi } from 'vitest'; + +import { + CustomRegistryImportDialogComponent, + type CustomRegistryImportResult, +} from '#/tui/components/dialogs/custom-registry-import'; +import { darkColors } from '#/tui/theme/colors'; + +const ANSI = /\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); +const ESC = String.fromCodePoint(27); +const DOWN = `${ESC}[B`; +const UP = `${ESC}[A`; + +function plain(component: CustomRegistryImportDialogComponent, width = 80): string { + return component.render(width).map(strip).join('\n'); +} + +function makeDialog(defaultUrl = 'https://example.com/api.json'): { + dialog: CustomRegistryImportDialogComponent; + onDone: ReturnType<typeof vi.fn>; +} { + const onDone = vi.fn(); + const dialog = new CustomRegistryImportDialogComponent( + onDone as unknown as (r: CustomRegistryImportResult) => void, + defaultUrl, + ); + dialog.focused = true; + return { dialog, onDone }; +} + +describe('CustomRegistryImportDialogComponent', () => { + it('advances from the URL field to the token field on Enter instead of submitting', () => { + const { dialog, onDone } = makeDialog(); + expect(plain(dialog)).toContain('next field'); + + dialog.handleInput('\r'); + + expect(onDone).not.toHaveBeenCalled(); + expect(plain(dialog)).toContain('Enter to submit'); + }); + + it('switches fields with Up / Down arrows', () => { + const { dialog } = makeDialog(); + dialog.handleInput(DOWN); + expect(plain(dialog)).toContain('Enter to submit'); + dialog.handleInput(UP); + expect(plain(dialog)).toContain('next field'); + }); + + it('requires a non-empty Bearer token before submitting', () => { + const { dialog, onDone } = makeDialog(); + dialog.handleInput('\r'); // url -> token + dialog.handleInput('\r'); // attempt submit with an empty token + expect(onDone).not.toHaveBeenCalled(); + expect(plain(dialog)).toContain('Bearer token cannot be empty'); + }); + + it('submits the url and token once both are provided', () => { + const { dialog, onDone } = makeDialog(); + dialog.handleInput('\r'); // url -> token + for (const ch of 'sk-tok') dialog.handleInput(ch); + dialog.handleInput('\r'); // submit from the token field + + expect(onDone).toHaveBeenCalledWith({ + kind: 'ok', + value: { url: 'https://example.com/api.json', apiKey: 'sk-tok' }, + }); + }); + + it('keeps every line within narrow widths', () => { + const { dialog } = makeDialog('https://example.com/very/long/registry/path.json'); + + for (const width of [39, 35, 20, 10]) { + for (const line of dialog.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts new file mode 100644 index 000000000..e74fa7aa1 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; + +const ANSI = /\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); +const ESC = String.fromCodePoint(27); +const LEFT = `${ESC}[D`; +const RIGHT = `${ESC}[C`; + +function text(component: EffortSelectorComponent, width = 120): string { + return component.render(width).map(strip).join('\n'); +} + +describe('EffortSelectorComponent', () => { + it('renders efforts as horizontal segments with the active one bracketed', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const out = text(picker); + // All efforts are rendered on a single row. + expect(out).toContain('Off'); + expect(out).toContain('Low'); + expect(out).toContain('High'); + expect(out).toContain('Max'); + // The active level is wrapped in brackets; the rest are not. + expect(out).toContain('[ High ]'); + expect(out).not.toContain('[ Off ]'); + expect(out).not.toContain('[ Max ]'); + }); + + it('invokes onSelect with the chosen effort on Enter', () => { + const onSelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onCancel: vi.fn(), + }); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('high'); + }); + + it('moves the active segment with Left/Right and stops at the edges', () => { + const onSelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onCancel: vi.fn(), + }); + + // index 2 (high) -> 3 (max). + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('max'); + + // Already at the right edge — another Right stays put. + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('max'); + + // Walk back to the left edge (max -> high -> low -> off). + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('off'); + + // Already at the left edge — another Left stays put. + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('off'); + }); + + it('invokes onSessionOnlySelect on Alt+S instead of onSelect', () => { + const onSelect = vi.fn(); + const onSessionOnlySelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onSessionOnlySelect, + onCancel: vi.fn(), + }); + picker.handleInput(`${ESC}s`); + expect(onSessionOnlySelect).toHaveBeenCalledWith('high'); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('cancels on Escape', () => { + const onCancel = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel, + }); + picker.handleInput(ESC); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts new file mode 100644 index 000000000..f80c3a3da --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts @@ -0,0 +1,123 @@ +import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { + ExperimentsSelectorComponent, + type ExperimentalFeatureDraftChange, +} from '#/tui/components/dialogs/experiments-selector'; + + +const ANSI = /\u001B\[[0-9;]*m/g; +const ESC = String.fromCodePoint(27); +const ENTER = '\r'; + +function strip(text: string): string { + return text.replaceAll(ANSI, ''); +} + +function feature( + overrides: Partial<ExperimentalFeatureState> = {}, +): ExperimentalFeatureState { + return { + id: 'micro_compaction', + title: 'Micro compaction', + description: 'Trim older tool results.', + surface: 'core', + env: 'KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION', + defaultEnabled: true, + enabled: true, + source: 'default', + ...overrides, + }; +} + +function text(component: ExperimentsSelectorComponent, width = 120): string { + return component.render(width).map(strip).join('\n'); +} + +describe('ExperimentsSelectorComponent', () => { + it('renders searchable feature toggles with source details', () => { + const selector = new ExperimentsSelectorComponent({ + features: [ + feature({ enabled: true, source: 'config', configValue: true }), + ], + onApply: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(selector); + + expect(out).toContain(' Experimental features (type to search)'); + expect(out).toContain(' ↑↓ navigate · Space toggle · Enter apply · Esc cancel'); + expect(out).toContain(' ❯ Micro compaction enabled'); + expect(out).toContain(' id micro_compaction · config · KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION'); + expect(out).toContain(' Trim older tool results.'); + expect(out).toContain(' [ Apply changes and reload ] no changes'); + }); + + it('drafts changes with Space and applies them with Enter', () => { + const onApply = vi.fn<(changes: readonly ExperimentalFeatureDraftChange[]) => void>(); + const first = feature(); + const selector = new ExperimentsSelectorComponent({ + features: [first], + onApply, + onCancel: vi.fn(), + }); + + selector.handleInput(' '); + + expect(onApply).not.toHaveBeenCalled(); + expect(text(selector)).toContain(' ❯ Micro compaction disabled'); + expect(text(selector)).toContain( + ' id micro_compaction · default · KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION · modified', + ); + expect(text(selector)).toContain(' [ Apply changes and reload ] 1 change'); + + selector.handleInput(ENTER); + + expect(onApply).toHaveBeenCalledWith([ + { id: 'micro_compaction', enabled: false }, + ]); + }); + + it('does not draft changes for env-locked features', () => { + const onApply = vi.fn<(changes: readonly ExperimentalFeatureDraftChange[]) => void>(); + const selector = new ExperimentsSelectorComponent({ + features: [ + feature({ + enabled: true, + source: 'env', + }), + ], + onApply, + onCancel: vi.fn(), + }); + + selector.handleInput(' '); + selector.handleInput(ENTER); + + expect(text(selector)).toContain(' ❯ Micro compaction enabled'); + expect(text(selector)).toContain(' [ Apply changes and reload ] no changes'); + expect(onApply).not.toHaveBeenCalled(); + }); + + it('filters by typing and clears the query before cancelling', () => { + const onCancel = vi.fn(); + const selector = new ExperimentsSelectorComponent({ + features: [feature()], + onApply: vi.fn(), + onCancel, + }); + + selector.handleInput('m'); + selector.handleInput('i'); + selector.handleInput('c'); + expect(text(selector)).toContain('Search: mic'); + expect(text(selector)).toContain('Micro compaction'); + + selector.handleInput(ESC); + expect(onCancel).not.toHaveBeenCalled(); + selector.handleInput(ESC); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts index 13fcdd589..7ab1b3312 100644 --- a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts @@ -1,3 +1,4 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -26,7 +27,7 @@ function makeDialog(): { const collected: FeedbackInputDialogResult[] = []; const dialog = new FeedbackInputDialogComponent((result) => { collected.push(result); - }, darkColors); + }); dialog.focused = true; return { dialog, collected }; } @@ -55,6 +56,16 @@ describe('FeedbackInputDialogComponent', () => { expect(rendered).toContain(`${ansiOpen}╰`); }); + it('keeps every line within narrow widths', () => { + const { dialog } = makeDialog(); + + for (const width of [39, 20, 10, 4]) { + for (const line of dialog.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + it('typing then pressing Enter submits the trimmed value', () => { const { dialog, collected } = makeDialog(); for (const ch of 'hello world ') { diff --git a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts new file mode 100644 index 000000000..ec0f9d03b --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts @@ -0,0 +1,286 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { describe, expect, it, vi } from 'vitest'; + +import { + GoalQueueEditDialogComponent, + GoalQueueManagerComponent, + type GoalQueueManagerAction, +} from '#/tui/components/dialogs/goal-queue-manager'; +import type { GoalQueueSnapshot, UpcomingGoal } from '#/tui/goal-queue-store'; + +const ANSI = /\u001B\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); +const ESC = String.fromCodePoint(27); +const CTRL_J = '\u001B[106;5u'; +const BRACKET_PASTE_START = '\u001B[200~'; +const BRACKET_PASTE_END = '\u001B[201~'; +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; + +function goal(id: string, objective: string): UpcomingGoal { + return { + id, + objective, + createdAt: '2026-06-03T00:00:00.000Z', + updatedAt: '2026-06-03T00:00:00.000Z', + }; +} + +function snapshot(goals: readonly UpcomingGoal[]): GoalQueueSnapshot { + return { goals }; +} + +function text(component: GoalQueueManagerComponent | GoalQueueEditDialogComponent, width = 100) { + return component.render(width).map(strip).join('\n'); +} + +describe('GoalQueueManagerComponent', () => { + it('renders the upcoming goals and the management hint', () => { + const manager = new GoalQueueManagerComponent({ + goals: [goal('g1', 'Ship queued goal')], + onAction: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(manager); + expect(out).toContain('Upcoming goals'); + expect(out).toContain('↑↓ navigate · Space select · E edit · D delete · Esc cancel'); + expect(out).toContain('❯ 1. Ship queued goal'); + }); + + it('uses Space to enter move mode and reorders with Up/Down', async () => { + const first = goal('g1', 'First queued goal'); + const second = goal('g2', 'Second queued goal'); + const onAction = vi.fn(async (action: GoalQueueManagerAction) => { + expect(action).toEqual({ kind: 'move', goalId: 'g2', direction: 'up' }); + return snapshot([second, first]); + }); + const manager = new GoalQueueManagerComponent({ + goals: [first, second], + onAction, + onCancel: vi.fn(), + }); + + manager.handleInput(DOWN); + manager.handleInput(' '); + expect(text(manager)).toContain('↑↓ reorder · Space done · E edit · D delete · Esc cancel'); + manager.handleInput(UP); + + await vi.waitFor(() => { + expect(onAction).toHaveBeenCalledOnce(); + }); + const out = text(manager); + expect(out.indexOf('Second queued goal')).toBeLessThan(out.indexOf('First queued goal')); + }); + + it('deletes the selected goal and keeps the list open', async () => { + const first = goal('g1', 'First queued goal'); + const second = goal('g2', 'Second queued goal'); + const onAction = vi.fn(async (action: GoalQueueManagerAction) => { + expect(action).toEqual({ kind: 'delete', goalId: 'g1' }); + return snapshot([second]); + }); + const manager = new GoalQueueManagerComponent({ + goals: [first, second], + onAction, + onCancel: vi.fn(), + }); + + manager.handleInput('d'); + + await vi.waitFor(() => { + expect(onAction).toHaveBeenCalledOnce(); + }); + const out = text(manager); + expect(out).not.toContain('First queued goal'); + expect(out).toContain('1. Second queued goal'); + }); + + it('invalidates after an async queue action updates the list', async () => { + const first = goal('g1', 'First queued goal'); + const second = goal('g2', 'Second queued goal'); + let resolveAction: (value: GoalQueueSnapshot) => void; + const onAction = vi.fn( + () => + new Promise<GoalQueueSnapshot>((resolve) => { + resolveAction = resolve; + }), + ); + const manager = new GoalQueueManagerComponent({ + goals: [first, second], + onAction, + onCancel: vi.fn(), + }); + const invalidate = vi.spyOn(manager, 'invalidate'); + + manager.handleInput('d'); + resolveAction!(snapshot([second])); + + await vi.waitFor(() => { + expect(invalidate).toHaveBeenCalled(); + }); + }); + + it('emits an edit action for the selected goal', () => { + const onAction = vi.fn(); + const manager = new GoalQueueManagerComponent({ + goals: [goal('g1', 'First queued goal')], + onAction, + onCancel: vi.fn(), + }); + + manager.handleInput('e'); + + expect(onAction).toHaveBeenCalledWith({ kind: 'edit', goalId: 'g1' }); + }); + + it('cancels with Esc', () => { + const onCancel = vi.fn(); + const manager = new GoalQueueManagerComponent({ + goals: [], + onAction: vi.fn(), + onCancel, + }); + + manager.handleInput(ESC); + + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('never renders a line wider than the terminal', () => { + const manager = new GoalQueueManagerComponent({ + goals: [goal('g1', 'A very long queued goal objective that should be truncated cleanly')], + onAction: vi.fn(), + onCancel: vi.fn(), + }); + + for (const width of [24, 40, 80]) { + for (const line of manager.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('renders multiline objectives as a single selectable row', () => { + const manager = new GoalQueueManagerComponent({ + goals: [goal('g1', 'First line\nSecond line')], + onAction: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = manager.render(100).map(strip); + + expect(lines.some((line) => line.includes('❯ 1. First line Second line'))).toBe(true); + expect(lines.some((line) => line.trim() === 'Second line')).toBe(false); + }); +}); + +describe('GoalQueueEditDialogComponent', () => { + it('submits the edited objective', () => { + const onDone = vi.fn(); + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'Ship queued goal'), + onDone, + }); + + dialog.handleInput(' safely'); + dialog.handleInput('\r'); + + expect(onDone).toHaveBeenCalledWith({ + kind: 'save', + goalId: 'g1', + objective: 'Ship queued goal safely', + }); + }); + + it('supports multiline objective edits', () => { + const onDone = vi.fn(); + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'Ship queued goal'), + onDone, + }); + + dialog.handleInput(CTRL_J); + dialog.handleInput('with a second line'); + dialog.handleInput('\r'); + + expect(onDone).toHaveBeenCalledWith({ + kind: 'save', + goalId: 'g1', + objective: 'Ship queued goal\nwith a second line', + }); + }); + + it('sanitizes bracketed paste while preserving newlines', () => { + const onDone = vi.fn(); + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'Ship queued goal'), + onDone, + }); + + dialog.handleInput( + `${BRACKET_PASTE_START} \u001B[31mred\u001B[0m\nnext\u0007 line${BRACKET_PASTE_END}`, + ); + dialog.handleInput('\r'); + + expect(onDone).toHaveBeenCalledWith({ + kind: 'save', + goalId: 'g1', + objective: 'Ship queued goal red\nnext line', + }); + }); + + it('renders multiline edits inside the dialog width', () => { + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'First line\nSecond line'), + onDone: vi.fn(), + }); + + const out = text(dialog, 36); + + expect(out).toContain('> First line'); + expect(out).toContain(' Second line'); + for (const line of dialog.render(36)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(36); + } + }); + + it('keeps the edit dialog within narrow widths', () => { + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'A very long queued objective for width testing'), + onDone: vi.fn(), + }); + + for (const width of [24, 20, 10]) { + for (const line of dialog.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('keeps accepting input after save returns control to the mounted dialog', () => { + const onDone = vi.fn(); + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'Ship queued goal'), + onDone, + }); + + dialog.handleInput('\r'); + dialog.handleInput(ESC); + + expect(onDone).toHaveBeenLastCalledWith({ kind: 'cancel', goalId: 'g1' }); + }); + + it('shows an empty objective hint instead of submitting', () => { + const onDone = vi.fn(); + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', ''), + onDone, + }); + + dialog.handleInput('\r'); + + expect(onDone).not.toHaveBeenCalled(); + expect(text(dialog)).toContain('Goal objective cannot be empty.'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts new file mode 100644 index 000000000..ba1499e04 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -0,0 +1,446 @@ +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { describe, expect, it, vi } from 'vitest'; + +import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; +import { currentTheme } from '#/tui/theme'; +import { darkColors } from '#/tui/theme/colors'; + +const ANSI = /\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); +const ESC = String.fromCodePoint(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const LEFT = `${ESC}[D`; +const RIGHT = `${ESC}[C`; + +function model(displayName: string, capabilities: string[] = ['thinking']): ModelAlias { + return { + provider: 'managed:kimi-code', + model: displayName.toLowerCase().replaceAll(' ', '-'), + maxContextSize: 200_000, + displayName, + capabilities, + } as unknown as ModelAlias; +} + +function effortModel( + displayName: string, + supportEfforts: string[], + defaultEffort?: string, + capabilities: string[] = ['thinking'], +): ModelAlias { + return { + provider: 'managed:kimi-code', + model: displayName.toLowerCase().replaceAll(' ', '-'), + maxContextSize: 200_000, + displayName, + capabilities, + supportEfforts, + defaultEffort, + } as unknown as ModelAlias; +} + +function text(component: ModelSelectorComponent, width = 120): string { + return component.render(width).map(strip).join('\n'); +} + +describe('ModelSelectorComponent', () => { + it('lays out the provider as a right column and marks the current model', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + // Model name on the left, provider on the right, with the current marker. + expect(out).toMatch(/❯ Kimi K2\s+Kimi Code ← current/); + // Provider is no longer inlined in parentheses next to the name. + expect(out).not.toContain('Kimi K2 (Kimi Code)'); + }); + + it('toggles thinking with Left/Right (not with "/")', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect, + onCancel: vi.fn(), + }); + + // "/" no longer toggles thinking (it used to); here it is simply ignored. + picker.handleInput('/'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); + + // Right arrow flips the draft (true -> false). + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'off' }); + + // Left arrow flips it back. + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); + }); + + it('shows the Left/Right thinking hint only for toggleable models', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinkingEffort: 'off', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + expect(text(picker)).toContain('Thinking (←→ to switch)'); + }); + + it('forces always-thinking models on and unsupported models off', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + always: model('Kimi Thinking', ['always_thinking']), + plain: model('Kimi Plain', ['tool_use']), + }, + currentValue: 'always', + currentThinkingEffort: 'off', + onSelect, + onCancel: vi.fn(), + }); + + // Always-on: On selected, Off greyed out with an explanation. + const alwaysOut = text(picker); + expect(alwaysOut).toContain('[ On ]'); + expect(alwaysOut).toContain('Off (Unsupported)'); + expect(alwaysOut).not.toContain('Always on'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: 'on' }); + + // Unsupported: Off selected, On greyed out — same style, mirrored. + picker.handleInput(DOWN); + const plainOut = text(picker); + expect(plainOut).toContain('On (Unsupported)'); + expect(plainOut).toContain('[ Off ]'); + expect(plainOut).not.toContain('] unsupported'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: 'off' }); + }); + + it('ignores Left/Right on always-on and unsupported models', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + always: model('Kimi Thinking', ['always_thinking']), + plain: model('Kimi Plain', ['tool_use']), + }, + currentValue: 'always', + currentThinkingEffort: 'on', + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: 'on' }); + + picker.handleInput(DOWN); + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: 'off' }); + }); + + it('renders the unavailable thinking segment muted', () => { + const picker = new ModelSelectorComponent({ + models: { always: model('Kimi Thinking', ['always_thinking']) }, + currentValue: 'always', + currentThinkingEffort: 'on', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const raw = picker.render(120).join('\n'); + expect(raw).toContain(currentTheme.fg('textMuted', ' Off (Unsupported) ')); + }); + + it('keeps the thinking draft when moving across models', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + plain: model('Kimi Plain', ['tool_use']), + thinking: model('Kimi Thinking', ['thinking']), + }, + currentValue: 'plain', + currentThinkingEffort: 'off', + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(DOWN); // -> thinking model (defaults On) + picker.handleInput(RIGHT); // toggle -> Off + picker.handleInput(UP); // -> plain + picker.handleInput(DOWN); // -> thinking (the Off override persists) + picker.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: 'off' }); + }); + + it('defaults a thinking-capable model to On but keeps the current model state', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + current: model('Kimi Current', ['thinking']), + other: model('Kimi Other', ['thinking']), + }, + currentValue: 'current', + currentThinkingEffort: 'off', // thinking deliberately off on the active model + onSelect, + onCancel: vi.fn(), + }); + + // The active model reflects its live (off) state. + expect(text(picker)).toContain('[ Off ]'); + picker.handleInput(DOWN); // -> the other thinking-capable model + // A capable, non-active model defaults to On without any toggle. + expect(text(picker)).toContain('[ On ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'on' }); + }); + + it('fuzzy-filters by typing and reports a match count', () => { + const onCancel = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { k2: model('Kimi K2'), turbo: model('Kimi Turbo') }, + currentValue: 'k2', + currentThinkingEffort: 'off', + searchable: true, + onSelect: vi.fn(), + onCancel, + }); + + picker.handleInput('t'); + picker.handleInput('u'); + const out = text(picker); + expect(out).toContain('Search: tu'); + expect(out).toContain('Kimi Turbo'); + expect(out).not.toContain('Kimi K2'); + expect(out).toContain('1 / 2'); + + // First Esc clears the query, second Esc cancels. + picker.handleInput(ESC); + expect(onCancel).not.toHaveBeenCalled(); + picker.handleInput(ESC); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('shows a "more" indicator when the list overflows a page', () => { + const models: Record<string, ModelAlias> = {}; + for (let i = 0; i < 12; i++) models[`m${String(i)}`] = model(`Model ${String(i)}`); + const picker = new ModelSelectorComponent({ + models, + currentValue: 'm0', + currentThinkingEffort: 'off', + searchable: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + // Default page size is 8, so 4 of the 12 models sit below the fold. + expect(text(picker)).toContain('▼ 4 more'); + }); + + it('never renders a line wider than the terminal', () => { + const picker = new ModelSelectorComponent({ + models: { + long: model('A Very Long Model Display Name That Should Be Truncated Hard'), + cjk: model('超长的中文模型名称需要被正确截断处理'), + }, + currentValue: 'long', + currentThinkingEffort: 'off', + searchable: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + for (const width of [20, 40, 80, 120]) { + for (const line of picker.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('invokes onSessionOnlySelect on Alt+S with the effective thinking state', () => { + const onSelect = vi.fn(); + const onSessionOnlySelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect, + onSessionOnlySelect, + onCancel: vi.fn(), + }); + + // Toggle thinking Off, then Alt+S applies the choice to the session only. + picker.handleInput(RIGHT); + picker.handleInput(`${ESC}s`); + expect(onSessionOnlySelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: 'off' }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ignores Alt+S and hides its hint when onSessionOnlySelect is not provided', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(`${ESC}s`); + expect(onSelect).not.toHaveBeenCalled(); + expect(text(picker)).not.toContain('Alt+S session-only'); + }); + + it('shows the Alt+S session-only hint when onSessionOnlySelect is provided', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect: vi.fn(), + onSessionOnlySelect: vi.fn(), + onCancel: vi.fn(), + }); + expect(text(picker)).toContain('Alt+S session-only'); + }); + + it('renders effort segments with the default effort highlighted', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: effortModel('Kimi K2', ['low', 'high', 'max'], 'high') }, + currentValue: 'kimi', + currentThinkingEffort: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + // The default effort (high) is the active segment. + expect(out).toContain('[ High ]'); + // All declared efforts plus the Off entry are present. + expect(out).toContain('Low'); + expect(out).toContain('Max'); + expect(out).toContain('Off'); + // Multi-segment control advertises the switch hint. + expect(out).toContain('Thinking (←→ to switch)'); + }); + + it('cycles efforts with Left/Right and clamps at the ends', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: effortModel('Kimi K2', ['low', 'high', 'max'], 'high') }, + currentValue: 'kimi', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + // high -> max (Right), then clamp on a second Right. + picker.handleInput(RIGHT); + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'max' }); + + // max -> high -> low -> off (Left x3), then clamp on another Left. + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'off' }); + }); + + it('always-on effort models hide Off and clamp selection at the last effort', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + kimi: effortModel('Kimi K2', ['low', 'high', 'max'], 'high', ['always_thinking']), + }, + currentValue: 'kimi', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + const raw = picker.render(120).join('\n'); + // Off is not surfaced at all — the selectable segments are effort-only. + expect(raw).not.toContain('Off (Unsupported)'); + // The active effort is still highlighted. + expect(strip(raw)).toContain('[ High ]'); + + // Cycling clamps at the last effort and never reaches Off. + picker.handleInput(RIGHT); // high -> max + picker.handleInput(RIGHT); // clamp at max + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'max' }); + }); + + it('defaults an effort model without a current level to its defaultEffort', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Kimi Other', ['low', 'high', 'max'], 'max'), + }, + currentValue: 'current', + currentThinkingEffort: 'off', + onSelect, + onCancel: vi.fn(), + }); + + // Non-current effort model falls back to its declared defaultEffort. + expect(text(picker)).toContain('[ Max ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'max' }); + }); + + it('falls back to the middle effort when an effort model has no defaultEffort', () => { + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Kimi Other', ['low', 'medium', 'high']), + }, + currentValue: 'current', + currentThinkingEffort: 'off', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + // support_efforts present but default_effort absent -> default to the + // middle entry (medium), not a hardcoded level. + expect(text(picker)).toContain('[ Medium ]'); + }); +}); + +describe('ModelSelectorComponent overrides', () => { + it('uses overridden support_efforts for selectable efforts', () => { + const picker = new ModelSelectorComponent({ + models: { + kimi: { + ...effortModel('Kimi K2', ['low', 'high', 'max'], 'max'), + overrides: { supportEfforts: ['low', 'high'] }, + }, + }, + currentValue: 'kimi', + currentThinkingEffort: 'max', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + expect(out).toContain('Low'); + expect(out).toContain('High'); + expect(out).not.toContain('Max'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 078feceda..4c379820a 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -2,20 +2,20 @@ import { describe, expect, it, vi } from 'vitest'; import chalk from 'chalk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, type PluginRemoveConfirmResult, + type PluginsPanelSelection, } from '#/tui/components/dialogs/plugins-selector'; -import { darkColors } from '#/tui/theme/colors'; -import { pluginTrustLabel } from '#/tui/utils/plugin-source-label'; +import { currentTheme } from '#/tui/theme'; +import { darkColors, lightColors } from '#/tui/theme/colors'; +import { isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; -const ANSI_SGR = /\[[0-9;]*m/g; -const SGR_SEQUENCE = String.raw`\[[0-9;]*m`; -const HIGHLIGHTED_D_REMOVE = new RegExp(`${SGR_SEQUENCE}(?:${SGR_SEQUENCE})*D(?:${SGR_SEQUENCE})+ remove`, 'g'); -const MID = '\u00B7'; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?'); @@ -35,14 +35,61 @@ function renderRaw(component: { render(width: number): string[] }, width = 120): return withAnsiColors(() => component.render(width).join('\n')); } -function primaryShortcut(text: string): string { - return withAnsiColors(() => chalk.hex(darkColors.primary).bold(text)); -} - function dangerShortcut(text: string): string { return withAnsiColors(() => chalk.hex(darkColors.error).bold(text)); } +function warningMark(): string { + // Opening ANSI escape for the warning color; the install-trust notice is the + // only element in that dialog using it, so its presence confirms the tone. + return withAnsiColors(() => chalk.hex(darkColors.warning)('\u0001').split('\u0001')[0]!); +} + +const superpowers = { + id: 'superpowers', + displayName: 'Superpowers', + version: '5.1.0', + enabled: true, + state: 'ok' as const, + skillCount: 14, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'local-path' as const, +}; + +const officialEntries = [ + { id: 'kimi-datasource', tier: 'official' as const, displayName: 'Kimi Datasource', version: '3.1.1', source: 'https://x/d.zip' }, +]; +const thirdPartyEntries = [ + { id: 'superpowers', tier: 'curated' as const, displayName: 'Superpowers', source: 'https://x/s.zip' }, +]; +const marketplaceEntries = [...officialEntries, ...thirdPartyEntries]; + +function makePanel(opts: { + installed?: readonly (typeof superpowers)[]; + initialTab?: 'installed' | 'official' | 'third-party' | 'custom'; + selectedId?: string; + pluginHint?: { id: string; text: string }; +}) { + const installed = opts.installed ?? []; + const onSelect = vi.fn<(s: PluginsPanelSelection) => void>(); + const onRequestMarketplace = vi.fn(); + const panel = new PluginsPanelComponent({ + installed, + installedIds: new Set(installed.map((p) => p.id)), + initialTab: opts.initialTab, + selectedId: opts.selectedId, + pluginHint: opts.pluginHint, + onSelect, + onCancel: vi.fn(), + onRequestMarketplace, + }); + return { panel, onSelect, onRequestMarketplace }; +} + describe('plugins selector dialogs', () => { it('trusts only built-in Kimi CDN plugin paths', () => { expect(pluginTrustLabel({ @@ -53,6 +100,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', @@ -65,6 +114,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip', @@ -77,6 +128,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/demo.zip', @@ -89,205 +142,357 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'local-path', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/local', })).toBe('third-party'); }); - it('renders installed plugins as selectable overview entries', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 2, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); - - const raw = renderRaw(picker); - const out = strip(raw); - expect(out).toContain('Installed plugins (1)'); - expect(out).toContain('Actions'); - expect(out).toContain('? Kimi Datasource enabled'); - expect(out).toContain(`id kimi-datasource ${MID} 2 skills ${MID} MCP 1/1`); - expect(out).not.toContain('Space disable'); - expect(out).not.toContain('Enter info'); - expect(raw.match(HIGHLIGHTED_D_REMOVE)).toHaveLength(1); - expect(raw).toContain(primaryShortcut('Space')); - expect(raw).toContain(primaryShortcut('M')); - expect(raw).toContain(dangerShortcut('D')); - expect(raw).toContain(primaryShortcut('Enter')); - expect(out).toContain('Marketplace'); - expect(out).toContain('Summary'); - - picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ kind: 'info', id: 'kimi-datasource' }); + it('treats only the official Kimi CDN path as a trusted install source', () => { + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true); + // Curated and other Kimi CDN paths are not "official" for the install gate. + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false); + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false); + // Non-Kimi hosts, non-https schemes, local paths, and GitHub sources are unofficial. + expect(isOfficialPluginSource('https://example.test/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('http://code.kimi.com/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('./plugins/kimi-datasource')).toBe(false); + expect(isOfficialPluginSource('/abs/path/to/plugin')).toBe(false); + expect(isOfficialPluginSource('github.com/owner/repo')).toBe(false); + expect(isOfficialPluginSource('not a url')).toBe(false); }); - it('renders marketplace plugins separately from marketplace actions', () => { - const onSelect = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - tier: 'curated', - displayName: 'Superpowers', - version: '5.1.0', - description: 'Workflow skills', - source: 'https://example.com/superpowers.zip', - keywords: ['workflow'], - }, - ], - installedIds: new Set(), - source: '/tmp/marketplace.json', - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); + it('opens on the Installed tab with the four panel tabs', () => { + const { panel } = makePanel({ installed: [superpowers] }); + const out = strip(renderRaw(panel)); + expect(out).toContain('Plugins'); + expect(out).toContain('Installed'); + expect(out).toContain('Official'); + expect(out).toContain('Third-party'); + expect(out).toContain('Custom'); + expect(out).toContain('? Superpowers enabled'); + expect(out).toContain('Space toggle'); + expect(out).toContain('1 installed'); + }); - const raw = renderRaw(picker); - const out = strip(raw); - expect(out).toContain('Marketplace (1)'); - expect(out).toContain('? Superpowers install v5.1.0'); - expect(out).toContain( - `Workflow skills ${MID} id superpowers ${MID} v5.1.0 ${MID} Curated plugin ${MID} workflow`, - ); - expect(raw).toContain(primaryShortcut('Enter')); - expect(raw).toContain(primaryShortcut('Space')); - expect(out).toContain('Actions'); - expect(out).toContain('Back to installed plugins'); + it('repaints from the current theme palette without remounting', () => { + const { panel } = makePanel({ installed: [superpowers] }); + const previous = currentTheme.palette; + try { + currentTheme.setPalette(darkColors); + const darkOut = renderRaw(panel); + currentTheme.setPalette(lightColors); + const lightOut = renderRaw(panel); + // A palette snapshot cached at construction would render identically + // after the switch; reading currentTheme.palette at render time must + // produce different ANSI output for the same panel instance. + expect(darkOut).not.toBe(lightOut); + } finally { + currentTheme.setPalette(previous); + } + }); - picker.handleInput(' '); + it('toggles an installed plugin with Space', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers] }); + panel.handleInput(' '); + expect(onSelect).toHaveBeenCalledWith({ kind: 'toggle', id: 'superpowers', enabled: false }); + }); + + it('routes D / M / R / Enter to remove / mcp / reload / details on the Installed tab', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers] }); + panel.handleInput('d'); + panel.handleInput('m'); + panel.handleInput('r'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'superpowers' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'superpowers' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'reload' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('Enter on an installed plugin with an available update installs it', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'install', entry: expect.objectContaining({ id: 'superpowers' }), }); }); - it('issues install for installed marketplace entries (update path)', () => { - const onSelect = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - displayName: 'Superpowers', - source: 'https://example.com/superpowers.zip', - }, - ], - installedIds: new Set(['superpowers']), - source: '/tmp/marketplace.json', - colors: darkColors, - onSelect, - onCancel: vi.fn(), + it('Enter on an up-to-date installed plugin opens details', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('I on an installed plugin opens details even when an update is available', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('i'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('renders the inline plugin hint on the installed row', () => { + const datasource = { ...superpowers, id: 'kimi-datasource', displayName: 'Kimi Datasource', skillCount: 1 }; + const { panel } = makePanel({ + installed: [datasource], + selectedId: 'kimi-datasource', + pluginHint: { id: 'kimi-datasource', text: 'pending /new' }, }); + const out = strip(renderRaw(panel)); + expect(out).toContain('? Kimi Datasource enabled pending /new'); + }); - const out = picker.render(120).map(strip).join('\n'); - expect(out).toContain('? Superpowers installed'); - expect(out).toContain(`Plugin ${MID} id superpowers`); + it('lazily loads the Official catalog, then lists installed entries first', () => { + const { panel, onRequestMarketplace } = makePanel({ installed: [superpowers] }); + panel.handleInput('\t'); // → Official + expect(onRequestMarketplace).toHaveBeenCalledTimes(1); + expect(strip(renderRaw(panel))).toContain('Loading marketplace'); - picker.handleInput('\r'); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi Datasource install'); + expect(out).toContain('0 installed · 1 available'); + }); + + it('renders the hardcoded Web Bridge entry on the Official tab while loading', () => { + const { panel } = makePanel({ initialTab: 'official' }); + // The catalog is still loading, but the built-in Web Bridge entry is shown + // immediately because it is baked into the TUI, not fetched. + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge open in browser'); + expect(out).toContain('Loading marketplace'); + }); + + it('keeps the Web Bridge entry visible when the Official catalog errors', () => { + const { panel } = makePanel({ initialTab: 'official' }); + panel.setMarketplaceError('fetch failed'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge open in browser'); + expect(out).toContain('Marketplace unavailable: fetch failed'); + }); + + it('opens the Web Bridge webpage on Enter instead of installing', () => { + const { panel, onSelect } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + // Web Bridge is pinned at index 0, so Enter selects it directly. + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'open-url', + url: 'https://www.kimi.com/features/webbridge#local-agent', + label: 'Kimi WebBridge', + }); + }); + + it('installs a catalog official entry after navigating past Web Bridge', () => { + const { panel, onSelect } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\u001B[B'); // ↓ → kimi-datasource + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'kimi-datasource' }), + }); + }); + + it('does not duplicate Web Bridge when the catalog also lists it', () => { + const entries = [ + { + id: 'kimi-webbridge', + tier: 'official' as const, + displayName: 'Kimi WebBridge', + source: 'https://x/w.zip', + }, + ...officialEntries, + ]; + const { panel } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + // The label should appear exactly once — the hardcoded row wins, the + // catalog copy is filtered out. + expect(out.split('Kimi WebBridge').length - 1).toBe(1); + }); + + it('installs a Third-party entry whose id matches the pinned WebBridge', () => { + // A curated/custom marketplace entry can legitimately reuse the + // kimi-webbridge id; on the Third-party tab it must install normally, not + // open the WebBridge page (that shortcut is reserved for the pinned row). + const entries = [ + { + id: 'kimi-webbridge', + tier: 'curated' as const, + displayName: 'Kimi WebBridge', + source: 'https://x/w.zip', + }, + ]; + const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge install'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'https://x/w.zip' }), + }); + }); + + it('installs the selected Third-party entry on Enter', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'install', entry: expect.objectContaining({ id: 'superpowers' }), }); }); - it('toggles an installed plugin from the overview with space', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', - }, - ], - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); - - picker.handleInput(' '); + it('renders an installing state while an install is in progress', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.setInstalling('Superpowers'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Installing Superpowers from marketplace'); + }); + it('keeps a valid selection if ↓ is pressed while the catalog is loading', () => { + const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); + // Catalog still loading (entries empty); pressing ↓ must not drive the + // selection negative, or the later Enter would read entries[-1]. + panel.handleInput('\u001B[B'); // ↓ + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ - kind: 'toggle', - id: 'kimi-datasource', - enabled: false, + kind: 'install', + entry: expect.objectContaining({ id: 'superpowers' }), }); }); - it('issues a remove request from the overview on D', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', - }, - ], - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); - - picker.handleInput('d'); - - expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'kimi-datasource' }); + it('shows untiered marketplace entries on the Third-party tab', () => { + const untiered = [ + { id: 'custom-plugin', displayName: 'Custom Plugin', source: 'https://x/c.zip' }, + ]; + const { panel } = makePanel({ initialTab: 'third-party' }); + panel.setMarketplace(untiered, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Custom Plugin install'); }); - it('opens MCP server management from the overview on M', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - colors: darkColors, - onSelect, - onCancel: vi.fn(), + it('shows an update badge when the marketplace version is newer than installed', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed, initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers update 4.0.0 → 5.0.0'); + }); + + it('shows an update badge on the Installed tab when the marketplace version is newer', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers enabled update 4.0.0 → 5.0.0'); + }); + + it('does not show an update badge on the Installed tab before the marketplace loads', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const { panel } = makePanel({ installed }); + // The marketplace has not been loaded yet, so the badge stays hidden rather + // than guessing. + const out = strip(renderRaw(panel)); + expect(out).not.toContain('update'); + }); + + it('shows installed · v<version> when the installed plugin is up to date', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed, initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers installed · v5.0.0'); + }); + + it('shows an inline error when the Official catalog fails', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.handleInput('\t'); // → Official + panel.setMarketplaceError('fetch failed'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Marketplace unavailable: fetch failed'); + expect(out).toContain('Use the Custom tab'); + }); + + it('installs from a URL typed on the Custom tab', () => { + const { panel, onSelect } = makePanel({ initialTab: 'custom' }); + const out = strip(renderRaw(panel)); + expect(out).toContain('Install from a GitHub URL'); + expect(out).toContain('╭'); + + for (const ch of 'https://github.com/owner/repo') { + panel.handleInput(ch); + } + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install-source', + source: 'https://github.com/owner/repo', }); - - picker.handleInput('m'); - - expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'kimi-datasource' }); }); it('toggles MCP servers from the MCP selector', () => { @@ -302,6 +507,8 @@ describe('plugins selector dialogs', () => { skillCount: 1, mcpServerCount: 1, enabledMcpServerCount: 1, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'local-path', installedAt: '2026-05-29T00:00:00.000Z', @@ -320,7 +527,6 @@ describe('plugins selector dialogs', () => { ], diagnostics: [], }, - colors: darkColors, onSelect: (selection) => { selections.push(selection); }, @@ -331,8 +537,7 @@ describe('plugins selector dialogs', () => { const out = strip(raw); expect(out).toContain('MCP servers (1/1 enabled)'); expect(out).toContain('? data enabled'); - expect(raw).toContain(primaryShortcut('Enter')); - expect(raw).toContain(primaryShortcut('Space')); + expect(out).toContain('Enter/Space enable/disable'); picker.handleInput(' '); @@ -341,40 +546,11 @@ describe('plugins selector dialogs', () => { ]); }); - it('renders plugin action hints inline on the overview row', () => { - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', - }, - ], - selectedId: 'kimi-datasource', - pluginHint: { id: 'kimi-datasource', text: 'pending /new' }, - colors: darkColors, - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - - const out = picker.render(120).map(strip).join('\n'); - - expect(out).toContain('? Kimi Datasource enabled pending /new'); - }); - it('defaults plugin removal confirmation to cancel', () => { const results: PluginRemoveConfirmResult[] = []; const picker = new PluginRemoveConfirmComponent({ id: 'kimi-datasource', displayName: 'Kimi Datasource', - colors: darkColors, onDone: (result) => { results.push(result); }, @@ -395,20 +571,64 @@ describe('plugins selector dialogs', () => { const picker = new PluginRemoveConfirmComponent({ id: 'kimi-datasource', displayName: 'Kimi Datasource', - colors: darkColors, onDone: (result) => { results.push(result); }, }); - picker.handleInput(''); + picker.handleInput('\u001B[B'); const raw = renderRaw(picker); - expect(raw).toContain(primaryShortcut('Enter')); - expect(raw).toContain(primaryShortcut('Space')); + expect(strip(raw)).toContain('Enter/Space select'); + // The destructive option label keeps its danger styling (error + bold). expect(raw).toContain(dangerShortcut('Remove plugin')); picker.handleInput('\r'); expect(results).toEqual([{ kind: 'confirm' }]); }); + + it('defaults the third-party install trust prompt to exit', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, + }); + + const raw = renderRaw(picker); + const out = raw.split('\n').map(strip); + expect(out).toContain(' Install third-party plugin Superpowers?'); + expect(out).toContain(' ? Exit'); + expect(out).toContain(' Cancel the installation.'); + expect(out).toContain(' Install this third-party plugin anyway.'); + // The warning explains why confirmation is required and uses the + // design-system warning color rather than muted/default text. + expect(out.some((line) => line.includes('Kimi has not reviewed'))).toBe(true); + expect(out.some((line) => line.includes('trust the source'))).toBe(true); + expect(raw).toContain(warningMark()); + + picker.handleInput('\r'); + expect(results).toEqual([{ kind: 'cancel' }]); + }); + + it('installs a third-party plugin only after switching to trust', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, + }); + + picker.handleInput('\u001B[B'); + const raw = renderRaw(picker); + expect(strip(raw)).toContain('Enter/Space select'); + // The opt-in option keeps its danger styling (error + bold). + expect(raw).toContain(dangerShortcut('Trust and install')); + + picker.handleInput('\r'); + + expect(results).toEqual([{ kind: 'confirm' }]); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts new file mode 100644 index 000000000..6596cf705 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts @@ -0,0 +1,137 @@ +import type { ProviderConfig } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { + ProviderManagerComponent, + type ProviderManagerOptions, +} from '#/tui/components/dialogs/provider-manager'; +import { darkColors } from '#/tui/theme/colors'; + +// Truecolor SGR fragments for the darkColors tokens we assert on +// (see theme/colors.ts). Forcing chalk.level below guarantees they appear. +const PRIMARY = '38;2;79;168;255'; // colors.primary #4FA8FF +const MUTED = '38;2;107;107;107'; // colors.textMuted #6B6B6B +const BOLD = '[1m'; +const ESC = String.fromCodePoint(27); + +const SGR = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); + +function rendered(component: ProviderManagerComponent, width = 120): string { + return component.render(width).join('\n').replaceAll(SGR, ''); +} + +function makeComponent(overrides: Partial<ProviderManagerOptions> = {}): ProviderManagerComponent { + return new ProviderManagerComponent({ + providers: {} as Record<string, ProviderConfig>, + onAdd: vi.fn(), + onDeleteSource: vi.fn(), + onClose: vi.fn(), + ...overrides, + }); +} + +function addRowLine(component: ProviderManagerComponent, width = 120): string | undefined { + return component.render(width).find((line) => line.includes('Add New Platform')); +} + +describe('ProviderManagerComponent', () => { + let previousLevel: typeof chalk.level; + beforeAll(() => { + previousLevel = chalk.level; + chalk.level = 3; + }); + afterAll(() => { + chalk.level = previousLevel; + }); + + it('renders [ Add New Platform ] in the brand color, never muted, when not selected', () => { + // A configured provider occupies row 0 (selected); the add row sits below + // it and is therefore not the highlighted row. + const component = makeComponent({ + providers: { + acme: { baseUrl: 'https://acme.test' }, + } as unknown as Record<string, ProviderConfig>, + activeProviderId: 'acme', + }); + const line = addRowLine(component); + expect(line).toBeDefined(); + expect(line).toContain(PRIMARY); + expect(line).not.toContain(MUTED); + }); + + it('bolds [ Add New Platform ] when it is the selected row', () => { + // With no configured providers the synthetic add row is the only row, so it + // starts as the highlighted selection. + const component = makeComponent(); + const line = addRowLine(component); + expect(line).toBeDefined(); + expect(line).toContain(BOLD); + expect(line).toContain(PRIMARY); + }); + + it('marks the active provider with the shared "← current" marker, not a bullet', () => { + const component = makeComponent({ + providers: { + acme: { baseUrl: 'https://acme.test' }, + } as unknown as Record<string, ProviderConfig>, + activeProviderId: 'acme', + }); + const plain = component + .render(120) + .join('\n') + .replaceAll(/\[[0-9;]*m/g, ''); + expect(plain).toContain('← current'); + expect(plain).not.toContain('●'); + }); + + it('uses the same header shape as the model dialog (one top border, title, hint, no inner border)', () => { + const component = makeComponent({ + providers: { + acme: { baseUrl: 'https://acme.test' }, + } as unknown as Record<string, ProviderConfig>, + activeProviderId: 'acme', + }); + const lines = component.render(120).map((l) => l.replaceAll(SGR, '')); + const isBorder = (l: string | undefined): boolean => /^─+$/.test((l ?? '').trim()); + + const titleIdx = lines.findIndex((l) => l.includes('Providers')); + expect(titleIdx).toBeGreaterThanOrEqual(0); + // The line directly under the title is the hint, never an inner border (the + // old `border · title · border` sandwich is gone). + expect(isBorder(lines[titleIdx + 1])).toBe(false); + expect(lines[titleIdx + 1]).toContain('navigate'); + expect(lines[titleIdx + 1]).toContain('Esc cancel'); + // Blank line separates the hint from the body, exactly like the model dialog. + expect(lines[titleIdx + 2]).toBe(''); + // Only the top and bottom full-width borders remain — two, not three. + expect(lines.filter(isBorder).length).toBe(2); + }); + + it('deletes the highlighted provider via the D key with a y/N confirm', () => { + const onDeleteSource = vi.fn(); + const component = makeComponent({ + providers: { + acme: { baseUrl: 'https://acme.test' }, + } as unknown as Record<string, ProviderConfig>, + activeProviderId: 'acme', + onDeleteSource, + }); + component.handleInput('D'); + expect(rendered(component)).toContain('[y/N]'); + component.handleInput('y'); + expect(onDeleteSource).toHaveBeenCalledWith(['acme']); + }); + + it('closes on Esc', () => { + const onClose = vi.fn(); + const component = makeComponent({ + providers: { + acme: { baseUrl: 'https://acme.test' }, + } as unknown as Record<string, ProviderConfig>, + onClose, + }); + component.handleInput(ESC); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index a015e4176..812ac0d94 100644 --- a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -1,10 +1,10 @@ -import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; import { QuestionDialogComponent } from '#/tui/components/dialogs/question-dialog'; import type { PendingQuestion } from '#/tui/reverse-rpc/types'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -36,7 +36,6 @@ function makePending( function makeDialog( pending: PendingQuestion, onToggleToolOutput?: () => void, - onTogglePlanExpand?: () => void, ): { dialog: QuestionDialogComponent; collected: string[][]; @@ -50,10 +49,8 @@ function makeDialog( collected.push(response.answers); methods.push(response.method); }, - darkColors, 6, onToggleToolOutput, - onTogglePlanExpand, ); return { dialog, collected, methods }; } @@ -88,9 +85,9 @@ describe('QuestionDialogComponent', () => { expect(review).not.toContain('? Ready to submit your answers?'); expect(review).not.toContain('Please answer all questions before submitting.'); expect(reviewRaw).toContain( - chalk.hex(darkColors.text).bold(' Review your answer before submit'), + currentTheme.boldFg('text', ' Review your answer before submit'), ); - expect(reviewRaw).toContain(chalk.hex(darkColors.text)(' Ready to submit your answers?')); + expect(reviewRaw).toContain(currentTheme.fg('text', ' Ready to submit your answers?')); expect(review).toContain('B1'); expect(review).toContain('A2'); @@ -295,8 +292,8 @@ describe('QuestionDialogComponent', () => { dialog.handleInput('\u001B[D'); const out = dialog.render(80).join('\n'); - expect(out).toContain(chalk.hex(darkColors.success).bold(' → [1] A')); - expect(out).not.toContain(chalk.hex(darkColors.primary)(' → [1] A')); + expect(out).toContain(currentTheme.boldFg('success', ' → [1] A')); + expect(out).not.toContain(currentTheme.fg('primary', ' → [1] A')); }); it('stretches the border to the full available width', () => { @@ -356,7 +353,9 @@ describe('QuestionDialogComponent', () => { const { dialog } = makeDialog(pending); const out = dialog.render(80).join('\n'); - expect(out).toContain(chalk.bgHex(darkColors.primary).hex(darkColors.text).bold(' First ')); + expect(out).toContain( + chalk.bgHex(currentTheme.color('primary')).hex(currentTheme.color('text')).bold(' First '), + ); expect(out).not.toContain('(●) First'); }); @@ -431,17 +430,6 @@ describe('QuestionDialogComponent', () => { expect(collected).toEqual([]); }); - it('forwards ctrl+e to the global plan-expand toggle without answering', () => { - let planToggles = 0; - const pending = makePending([ - { question: 'Q?', multi_select: false, options: [{ label: 'A' }] }, - ]); - const { dialog, collected } = makeDialog(pending, undefined, () => planToggles++); - dialog.handleInput('\u0005'); // Ctrl+E - expect(planToggles).toBe(1); - expect(collected).toEqual([]); - }); - describe('long-content wrapping', () => { const longQuestion = 'Please confirm whether this dangerous shell command should really be executed in the current workspace, including all of its side effects on the filesystem and the network.'; diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index c8e919637..3c885488b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -1,8 +1,7 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SessionPickerComponent } from '#/tui/components/dialogs/session-picker'; -import { getColorPalette } from '#/tui/theme/colors'; function stripAnsi(text: string): string { return text.replaceAll(/\[[0-?]*[ -/]*[@-~]/g, ''); @@ -12,11 +11,34 @@ function renderPlain(component: SessionPickerComponent, width = 120): string { return stripAnsi(component.render(width).join('\n')); } +const BACKSPACE = String.fromCodePoint(127); +const ESC = String.fromCodePoint(27); + describe('SessionPickerComponent', () => { afterEach(() => { vi.restoreAllMocks(); }); + it('forwards Ctrl-C and Ctrl-D to optional host shortcuts', () => { + const onCtrlC = vi.fn(); + const onCtrlD = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onCtrlC, + onCtrlD, + }); + + component.handleInput('\u0003'); + component.handleInput('\u0004'); + + expect(onCtrlC).toHaveBeenCalledOnce(); + expect(onCtrlD).toHaveBeenCalledOnce(); + }); + it('renders millisecond updated_at timestamps as relative times', () => { const now = new Date('2026-05-11T12:00:00.000Z').getTime(); vi.spyOn(Date, 'now').mockReturnValue(now); @@ -38,7 +60,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -66,7 +87,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -96,7 +116,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -123,7 +142,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -136,7 +154,7 @@ describe('SessionPickerComponent', () => { expect(promptLine).not.toContain(longPrompt); }); - it('marks the current session with a (current) badge', () => { + it('marks the current session with a "← current" badge', () => { const now = new Date('2026-05-11T12:00:00.000Z').getTime(); vi.spyOn(Date, 'now').mockReturnValue(now); @@ -157,7 +175,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_current', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -165,8 +182,8 @@ describe('SessionPickerComponent', () => { const lines = component.render(120).map((line) => stripAnsi(line)); const currentLine = lines.find((line) => line.includes('this is current')); const otherLine = lines.find((line) => line.includes('not current')); - expect(currentLine).toContain('(current)'); - expect(otherLine).not.toContain('(current)'); + expect(currentLine).toContain('← current'); + expect(otherLine).not.toContain('← current'); }); it('places the relative time on the same line as the title, not right-aligned', () => { @@ -184,7 +201,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -220,7 +236,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -249,7 +264,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_cjk_long_session_id_value', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -261,4 +275,438 @@ describe('SessionPickerComponent', () => { } } }); + + // Regression for #240: a long session id, the inline time + "(current)" + // badge, and a long prompt all used to be appended past the terminal edge, + // which crashed the renderer with "Rendered line exceeds terminal width" on + // very narrow terminals. + it('never renders a line wider than the terminal, even on tiny widths (#240)', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const id = 'ses_fbe574f3-572d-487f-9fa0-d09694f599d4'; + const component = new SessionPickerComponent({ + sessions: [ + { + id, + title: 'refactor the sessions list so the UI looks much nicer than before', + last_prompt: 'please redesign the picker UI to be much nicer than before', + work_dir: '/Users/getlong/Development/cesiumdb', + updated_at: now - 5 * 60 * 1000, + metadata: { imported_from_kimi_cli: true }, + }, + ], + loading: false, + currentSessionId: id, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + for (let width = 10; width <= 60; width++) { + const lines = component.render(width); + for (const [idx, line] of lines.entries()) { + expect(visibleWidth(line), `width=${String(width)} line#${String(idx)}`).toBeLessThanOrEqual( + width, + ); + } + } + }); + + it('calls onToggleScope with the selected session id when Ctrl+A is pressed', () => { + const onToggleScope = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_a', + title: 'Session A', + work_dir: '/tmp/project-a', + updated_at: 1, + }, + { + id: 'ses_b', + title: 'Session B', + work_dir: '/tmp/project-b', + updated_at: 2, + }, + ], + loading: false, + currentSessionId: '', + scope: 'cwd', + onSelect: vi.fn(), + onCancel: vi.fn(), + onToggleScope, + }); + + component.handleInput('\u001B[B'); + component.handleInput('\u0001'); + + expect(onToggleScope).toHaveBeenCalledOnce(); + expect(onToggleScope).toHaveBeenCalledWith('ses_b'); + }); + + it('calls onToggleScope with the current session id when Ctrl+A is pressed with no sessions', () => { + const onToggleScope = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [], + loading: false, + currentSessionId: 'ses_current', + scope: 'cwd', + onSelect: vi.fn(), + onCancel: vi.fn(), + onToggleScope, + }); + + component.handleInput('\u0001'); + + expect(onToggleScope).toHaveBeenCalledOnce(); + expect(onToggleScope).toHaveBeenCalledWith('ses_current'); + }); + + it('renders the Ctrl+A all-sessions hint when the current cwd has no sessions', () => { + const component = new SessionPickerComponent({ + sessions: [], + loading: false, + currentSessionId: 'ses_current', + scope: 'cwd', + onSelect: vi.fn(), + onCancel: vi.fn(), + onToggleScope: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('No sessions found.'); + expect(output).toContain('Ctrl+A all'); + }); + + it('renders all-sessions scope header and Ctrl+A current-cwd hint', () => { + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_all', + title: 'All scope session', + work_dir: '/tmp/project', + updated_at: 1, + }, + ], + loading: false, + currentSessionId: '', + scope: 'all', + onSelect: vi.fn(), + onCancel: vi.fn(), + onToggleScope: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('All sessions'); + expect(output).toContain('↑↓ navigate · Ctrl+A current cwd · Enter select · Esc cancel'); + }); + + it('selects the full session row on Enter', () => { + const onSelect = vi.fn(); + const session = { + id: 'ses_row', + title: 'Row session', + work_dir: '/tmp/project-row', + updated_at: 1, + }; + const component = new SessionPickerComponent({ + sessions: [session], + loading: false, + currentSessionId: '', + scope: 'cwd', + onSelect, + onCancel: vi.fn(), + }); + + component.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith(session); + }); + + it('loads the next 50 sessions after moving past the loaded page', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + const component = new SessionPickerComponent({ + sessions: Array.from({ length: 120 }, (_, index) => ({ + id: `ses_${String(index).padStart(4, '0')}`, + title: `Session ${String(index).padStart(4, '0')}`, + work_dir: '/tmp/project', + updated_at: now - index * 1000, + })), + loading: false, + currentSessionId: '', + scope: 'all', + pageSize: 50, + maxVisibleSessions: 4, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + for (let i = 0; i < 50; i++) { + component.handleInput('\u001B[B'); + } + + const output = renderPlain(component); + + expect(output).toContain('Session 0050'); + expect(output).toContain('Showing 49-52 of 100 loaded / 120 sessions'); + }); + + it('keeps initial selected session id and loads enough pages for it', () => { + const component = new SessionPickerComponent({ + sessions: Array.from({ length: 80 }, (_, index) => ({ + id: `ses_${String(index).padStart(4, '0')}`, + title: `Session ${String(index).padStart(4, '0')}`, + work_dir: '/tmp/project', + updated_at: index, + })), + loading: false, + currentSessionId: '', + scope: 'all', + initialSelectedSessionId: 'ses_0070', + pageSize: 50, + maxVisibleSessions: 4, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('Session 0070'); + expect(output).toContain('Showing 69-72 of 80 sessions'); + }); + + it('shows type-to-search copy only when the query is empty', () => { + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_search_copy', + title: 'Search copy session', + work_dir: '/tmp/project', + updated_at: 1, + }, + ], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('Sessions (type to search)'); + expect(output).not.toContain('Search:'); + + component.handleInput('x'); + const searchOutput = renderPlain(component); + + expect(searchOutput).toContain('Search: x'); + expect(searchOutput).not.toContain('Sessions (type to search)'); + }); + + it('fuzzy-filters by session name only when typing', () => { + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_alpha', + title: 'Alpha session', + last_prompt: 'needleprompt do not match', + work_dir: '/tmp/needleprompt', + updated_at: 1, + }, + { + id: 'ses_beta', + title: 'Beta session', + last_prompt: 'other prompt', + work_dir: '/tmp/other', + updated_at: 2, + }, + { + id: 'ses_fuzzy', + title: 'N1e2e3d4l5e session', + last_prompt: 'prompt only', + work_dir: '/tmp/project', + updated_at: 3, + }, + ], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('n'); + component.handleInput('e'); + component.handleInput('e'); + component.handleInput('d'); + component.handleInput('l'); + component.handleInput('e'); + + const output = renderPlain(component); + + expect(output).toContain('Search: needle'); + expect(output).toContain('N1e2e3d4l5e session'); + expect(output).not.toContain('Alpha session'); + expect(output).not.toContain('Beta session'); + }); + + it('clears the query on Backspace and cancels on Esc only after the query is empty', () => { + const onCancel = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_alpha', + title: 'Alpha session', + work_dir: '/tmp/project', + updated_at: 1, + }, + { + id: 'ses_beta', + title: 'Beta session', + work_dir: '/tmp/project', + updated_at: 2, + }, + ], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel, + }); + + component.handleInput('z'); + expect(renderPlain(component)).toContain('Search: z'); + + component.handleInput(BACKSPACE); + expect(renderPlain(component)).not.toContain('Search:'); + expect(onCancel).not.toHaveBeenCalled(); + + component.handleInput('z'); + expect(renderPlain(component)).toContain('Search: z'); + + component.handleInput(ESC); + expect(renderPlain(component)).not.toContain('Search:'); + expect(onCancel).not.toHaveBeenCalled(); + + component.handleInput(ESC); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('selects the filtered session row on Enter', () => { + const onSelect = vi.fn(); + const target = { + id: 'ses_gamma', + title: 'Gamma session', + work_dir: '/tmp/project-gamma', + updated_at: 3, + }; + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_alpha', + title: 'Alpha session', + work_dir: '/tmp/project-alpha', + updated_at: 1, + }, + { + id: 'ses_beta', + title: 'Beta session', + work_dir: '/tmp/project-beta', + updated_at: 2, + }, + target, + ], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + }); + + component.handleInput('g'); + component.handleInput('a'); + component.handleInput('m'); + component.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith(target); + }); + + it('loads the next 50 matching sessions after moving past the filtered page', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + const component = new SessionPickerComponent({ + sessions: [ + ...Array.from({ length: 80 }, (_, index) => ({ + id: `ses_needle_${String(index).padStart(4, '0')}`, + title: `Needle ${String(index).padStart(4, '0')}`, + work_dir: '/tmp/project', + updated_at: now - index * 1000, + })), + ...Array.from({ length: 40 }, (_, index) => ({ + id: `ses_other_${String(index).padStart(4, '0')}`, + title: `Other ${String(index).padStart(4, '0')}`, + work_dir: '/tmp/project', + updated_at: now - (80 + index) * 1000, + })), + ], + loading: false, + currentSessionId: '', + pageSize: 50, + maxVisibleSessions: 4, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('n'); + component.handleInput('e'); + component.handleInput('e'); + component.handleInput('d'); + component.handleInput('l'); + component.handleInput('e'); + for (let i = 0; i < 50; i++) { + component.handleInput('\u001B[B'); + } + + const output = renderPlain(component); + + expect(output).toContain('Needle 0050'); + expect(output).toContain('Showing 49-52 of 80 loaded / 80 matches'); + }); + + it('calls onToggleScope with the selected filtered session id when Ctrl+A is pressed', () => { + const onToggleScope = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_alpha', + title: 'Alpha session', + work_dir: '/tmp/project-a', + updated_at: 1, + }, + { + id: 'ses_beta', + title: 'Beta session', + work_dir: '/tmp/project-b', + updated_at: 2, + }, + ], + loading: false, + currentSessionId: '', + scope: 'cwd', + onSelect: vi.fn(), + onCancel: vi.fn(), + onToggleScope, + }); + + component.handleInput('b'); + component.handleInput('e'); + component.handleInput('t'); + component.handleInput('a'); + component.handleInput('\u0001'); + + expect(onToggleScope).toHaveBeenCalledOnce(); + expect(onToggleScope).toHaveBeenCalledWith('ses_beta'); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts new file mode 100644 index 000000000..28fc4210f --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -0,0 +1,134 @@ +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; +import { currentTheme } from '#/tui/theme'; +import { darkColors, lightColors } from '#/tui/theme/colors'; + +const ESC = String.fromCodePoint(27); +const SGR = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); +const strip = (s: string): string => s.replaceAll(SGR, ''); +const TAB = '\t'; +const RIGHT = `${ESC}[C`; +// chalk.bgHex(colors.primary) → background truecolor for #4FA8FF. +const PRIMARY_BG = '48;2;79;168;255'; + +function model(displayName: string, provider: string): ModelAlias { + return { + provider, + model: displayName.toLowerCase().replaceAll(' ', '-'), + maxContextSize: 200_000, + displayName, + capabilities: ['thinking'], + } as unknown as ModelAlias; +} + +function make(): { + component: TabbedModelSelectorComponent; + onSelect: ReturnType<typeof vi.fn>; +} { + const onSelect = vi.fn(); + const component = new TabbedModelSelectorComponent({ + models: { + k2: model('Kimi K2', 'managed:kimi-code'), + gpt: model('GPT-5', 'openai'), + }, + currentValue: 'k2', + currentThinkingEffort: 'off', + onSelect, + onCancel: vi.fn(), + }); + component.focused = true; + return { component, onSelect }; +} + +describe('TabbedModelSelectorComponent', () => { + let previousLevel: typeof chalk.level; + const previousPalette = currentTheme.palette; + beforeAll(() => { + previousLevel = chalk.level; + chalk.level = 3; + currentTheme.setPalette(darkColors); + }); + afterAll(() => { + chalk.level = previousLevel; + currentTheme.setPalette(previousPalette); + }); + + it('renders an "All" + per-provider tab strip', () => { + const out = strip(make().component.render(120).join('\n')); + expect(out).toContain('All'); + expect(out).toContain('Kimi Code'); + expect(out).toContain('openai'); + }); + + it('highlights the active tab with a filled background (AskUserQuestion style)', () => { + // currentValue k2 → the active tab is "Kimi Code"; its cell carries the + // primary background SGR. + const raw = make().component.render(120).join('\n'); + expect(raw).toContain(PRIMARY_BG); + }); + + it('repaints the tab strip from the current theme palette without remounting', () => { + const { component } = make(); + const stripLine = (lines: string[]): string => + lines.find((l) => l.includes('All') && l.includes('openai')) ?? ''; + const previous = currentTheme.palette; + try { + currentTheme.setPalette(darkColors); + const darkStrip = stripLine(component.render(120)); + currentTheme.setPalette(lightColors); + const lightStrip = stripLine(component.render(120)); + // The strip is drawn from currentTheme.palette at render time; a + // construction-time palette snapshot would render the same strip after + // the switch. + expect(darkStrip).not.toBe(lightStrip); + } finally { + currentTheme.setPalette(previous); + } + }); + + it('opens on the All tab by default (showing every provider\'s models)', () => { + const out = strip(make().component.render(120).join('\n')); + expect(out).toContain('Kimi K2'); + expect(out).toContain('GPT-5'); + }); + + it('cycles provider tabs with Tab', () => { + const { component } = make(); + // tabs = [All, Kimi Code, openai]; active starts on All. + // Two Tabs → openai, whose list shows GPT-5 and not Kimi K2. + component.handleInput(TAB); + component.handleInput(TAB); + const out = strip(component.render(120).join('\n')); + expect(out).toContain('GPT-5'); + expect(out).not.toContain('Kimi K2'); + }); + + it('forwards thinking toggle (←/→) and selection (Enter) to the active tab', () => { + const { component, onSelect } = make(); + component.handleInput(RIGHT); // toggle thinking on for k2 + component.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'k2', thinking: 'on' }); + }); + + it('frames the tab strip with a blank line above and below it', () => { + const lines = make().component.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('navigate') && l.includes('Esc cancel')); + const stripIdx = lines.findIndex((l) => l.includes('All') && l.includes('openai')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toBe(''); // blank between hint and tabs + expect(stripIdx).toBe(hintIdx + 2); + expect(lines[stripIdx + 1]).toBe(''); // blank between tabs and list + }); + + it('mentions the Tab provider switch first in the hint line', () => { + const lines = make().component.render(120).map(strip); + const hint = lines.find((l) => l.includes('navigate') && l.includes('Esc cancel')); + expect(hint).toBeDefined(); + expect(hint).toContain('Tab toggle provider'); + // It comes first, before the navigation hint. + expect(hint!.indexOf('Tab toggle provider')).toBeLessThan(hint!.indexOf('↑↓ navigate')); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 6545af266..96598f6f5 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -3,17 +3,19 @@ import type { AutocompleteProvider, AutocompleteSuggestions, TUI, -} from '@earendil-works/pi-tui'; -import { describe, expect, it, vi } from 'vitest'; +} from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; -import { getColorPalette } from '#/tui/theme/index'; +import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; function makeEditor(): CustomEditor { const tui = { requestRender: vi.fn(), + render: vi.fn(() => []), + terminal: { rows: 40, cols: 120 }, } as unknown as TUI; - return new CustomEditor(tui, { ...getColorPalette('dark') }); + return new CustomEditor(tui); } async function flushAutocomplete(): Promise<void> { @@ -28,6 +30,22 @@ function providerReturning(items: AutocompleteItem[]): AutocompleteProvider { }; } +function providerRecordingForce(items: AutocompleteItem[]): { + provider: AutocompleteProvider; + calls: Array<{ force: boolean | undefined; text: string }>; +} { + const calls: Array<{ force: boolean | undefined; text: string }> = []; + const provider: AutocompleteProvider = { + getSuggestions: vi.fn(async (lines, cursorLine, cursorCol, options) => { + const text = (lines[cursorLine] ?? '').slice(0, cursorCol); + calls.push({ force: options?.force, text }); + return { items, prefix: text }; + }), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), + }; + return { provider, calls }; +} + describe('CustomEditor autocomplete Escape handling', () => { it('escape closes a visible slash command menu without firing app-level escape', async () => { const editor = makeEditor(); @@ -54,7 +72,9 @@ describe('CustomEditor autocomplete Escape handling', () => { getSuggestions: vi.fn( () => new Promise<AutocompleteSuggestions | null>((resolve) => { - resolveSuggestions = (items) =>{ resolve({ items, prefix: '/' }); }; + resolveSuggestions = (items) => { + resolve({ items, prefix: '/' }); + }; }), ), applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), @@ -73,6 +93,368 @@ describe('CustomEditor autocomplete Escape handling', () => { }); }); +describe('CustomEditor onNonEscapeInput', () => { + it('fires for a printable key and not for a lone Escape', () => { + const editor = makeEditor(); + const onNonEscapeInput = vi.fn(); + editor.onNonEscapeInput = onNonEscapeInput; + + editor.handleInput('a'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); + + editor.handleInput('\u001B'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); + }); + + it('fires for control keys so they break a pending double-Esc', () => { + const editor = makeEditor(); + const onNonEscapeInput = vi.fn(); + editor.onNonEscapeInput = onNonEscapeInput; + + editor.handleInput('\u0003'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); + }); +}); + +describe('CustomEditor slash argument completion refresh', () => { + it('reopens /add-dir directory completions after tab completion and entering slash', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'add-dir', + description: 'Add directory', + getArgumentCompletions: (prefix) => + prefix === '/' ? [{ value: '/tmp/shared/', label: 'shared/' }] : null, + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await flushAutocomplete(); + + editor.handleInput('/'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/add-dir /'); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('reopens the next directory level after tab-accepting a directory', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'add-dir', + description: 'Add directory', + getArgumentCompletions: (prefix) => { + if (prefix === '/') return [{ value: '/tmp/shared/', label: 'shared/' }]; + if (prefix === '/tmp/shared/') + return [{ value: '/tmp/shared/child/', label: 'child/' }]; + return null; + }, + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await flushAutocomplete(); + + editor.handleInput('/'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/add-dir /tmp/shared/'); + expect(editor.isShowingAutocomplete()).toBe(true); + }); +}); + +describe('CustomEditor slash command name Tab-accept', () => { + it('reopens subcommand completions after Tab-accepting a slash command name', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'goal', + description: 'Manage goals', + getArgumentCompletions: (prefix) => + prefix === '' + ? [ + { value: 'status', label: 'status' }, + { value: 'pause', label: 'pause' }, + ] + : null, + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/go') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/goal '); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('does not fall back to file completions for a command without subcommands', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'compact', + description: 'Compact context', + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/comp') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/compact '); + expect(editor.isShowingAutocomplete()).toBe(false); + }); +}); + +describe('CustomEditor @ mention completion refresh', () => { + it('reopens the next directory level after tab-accepting an @ directory', async () => { + const editor = makeEditor(); + const provider: AutocompleteProvider = { + getSuggestions: vi.fn( + async ( + lines: string[], + cursorLine: number, + cursorCol: number, + ): Promise<AutocompleteSuggestions> => { + const text = (lines[cursorLine] ?? '').slice(0, cursorCol); + if (text === '@') { + return { items: [{ value: '@shared/', label: 'shared/' }], prefix: '@' }; + } + if (text === '@shared/') { + return { items: [{ value: '@shared/child/', label: 'child/' }], prefix: '@shared/' }; + } + return { items: [], prefix: '' }; + }, + ), + applyCompletion: vi.fn( + ( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ) => { + const line = lines[cursorLine] ?? ''; + const beforePrefix = line.slice(0, cursorCol - prefix.length); + const afterCursor = line.slice(cursorCol); + const newLine = beforePrefix + item.value + afterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + item.value.length, + }; + }, + ), + }; + editor.setAutocompleteProvider(provider); + + editor.handleInput('@'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('@shared/'); + expect(editor.isShowingAutocomplete()).toBe(true); + }); +}); + +describe('CustomEditor Tab key handling', () => { + it('does not open autocomplete when Tab is pressed with the dropdown closed', async () => { + const editor = makeEditor(); + const provider = providerReturning([{ value: '@src/file.ts', label: 'file.ts' }]); + editor.setAutocompleteProvider(provider); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + expect(provider.getSuggestions).not.toHaveBeenCalled(); + expect(editor.isShowingAutocomplete()).toBe(false); + }); +}); + +describe('CustomEditor slash argument hint', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('renders the argument hint after a command with a trailing space', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).toContain('[list] | <path>'); + }); + + it('renders the argument hint after a command without a trailing space', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).toContain('[list] | <path>'); + }); + + it('hides the hint once an argument is typed', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/add-dir foo') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).not.toContain('[list] | <path>'); + }); + + it('does not render a hint for an unknown command', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/unknown ') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).not.toContain('[list] | <path>'); + }); + + it('does not render the argument hint in bash mode', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + editor.inputMode = 'bash'; + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).not.toContain('[list] | <path>'); + }); + + it('does not highlight the slash token in bash mode', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const contentLine = editor.render(90)[1] ?? ''; + const tokenIdx = contentLine.indexOf('/add-dir'); + expect(tokenIdx).toBeGreaterThan(-1); + // Prompt mode wraps `/add-dir` in a primary-colour ANSI sequence; in bash + // mode the token is plain text, so the byte right before it is a space. + expect(contentLine[tokenIdx - 1]).toBe(' '); + }); +}); + +describe('CustomEditor slash menu description wrapping', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('wraps long slash command descriptions to at most two lines with an ellipsis', async () => { + const editor = makeEditor(); + const description = 'word '.repeat(60).trim(); + editor.setAutocompleteProvider( + providerReturning([{ value: 'deep', label: 'deep', description }]), + ); + + editor.handleInput('/'); + await flushAutocomplete(); + + const plain = editor.render(90).map(stripAnsi); + const descriptionLines = plain.filter((line) => line.includes('word')); + expect(descriptionLines).toHaveLength(2); + expect(descriptionLines[1]).toContain('…'); + }); + + it('keeps non-slash autocomplete descriptions on a single line', async () => { + const editor = makeEditor(); + const description = 'path '.repeat(60).trim(); + const provider: AutocompleteProvider = { + getSuggestions: vi.fn(async () => ({ + items: [{ value: '@src/file.ts', label: 'file.ts', description }], + prefix: '@f', + })), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ + lines, + cursorLine, + cursorCol, + })), + }; + editor.setAutocompleteProvider(provider); + + editor.handleInput('@'); + // @-mention requests are debounced (20ms), unlike slash menus. + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + const plain = editor.render(90).map(stripAnsi); + const descriptionLines = plain.filter((line) => line.includes('path')); + expect(descriptionLines).toHaveLength(1); + expect(plain.join('\n')).not.toContain('…'); + }); +}); + describe('CustomEditor Kitty key release handling', () => { it('ignores Kitty key release events instead of inserting their CSI-u payload', () => { const editor = makeEditor(); @@ -85,8 +467,8 @@ describe('CustomEditor Kitty key release handling', () => { }); describe('CustomEditor paste marker expansion', () => { - const PASTE_START = '\x1b[200~'; - const PASTE_END = '\x1b[201~'; + const PASTE_START = '\u001B[200~'; + const PASTE_END = '\u001B[201~'; function simulateLargePaste(editor: CustomEditor, content: string): void { editor.handleInput(`${PASTE_START}${content}${PASTE_END}`); @@ -151,7 +533,7 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toMatch(/\[paste #1/); - editor.handleInput('\x16'); + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); expect(editor.getText()).not.toContain('[paste #'); expect(editor.getText()).toContain(longText); @@ -195,7 +577,7 @@ describe('CustomEditor paste marker expansion', () => { // Split: PASTE_START in chunk 1, paste-end split across chunk 2 and 3 editor.handleInput(`${PASTE_START}data`); - editor.handleInput('\x1b[20'); + editor.handleInput('\u001B[20'); editor.handleInput('1~'); expect(editor.getText()).toContain(longText); @@ -208,19 +590,6 @@ describe('CustomEditor paste marker expansion', () => { }); describe('CustomEditor shortcut telemetry hooks', () => { - it('reports newline shortcuts, including Ctrl-J, before delegating to the base editor', () => { - const editor = makeEditor(); - const onInsertNewline = vi.fn(); - editor.onInsertNewline = onInsertNewline; - - editor.handleInput('a'); - editor.handleInput('\n'); - editor.handleInput('\u001B[106;5u'); - - expect(onInsertNewline).toHaveBeenCalledTimes(2); - expect(editor.getText()).toBe('a\n\n'); - }); - it('reports undo shortcuts before delegating to the base editor', () => { const editor = makeEditor(); const onUndo = vi.fn(); @@ -231,4 +600,287 @@ describe('CustomEditor shortcut telemetry hooks', () => { expect(onUndo).toHaveBeenCalledOnce(); }); + + it('invokes onToggleTodoExpand on Ctrl+T', () => { + const editor = makeEditor(); + const onToggleTodoExpand = vi.fn().mockReturnValue(true); + editor.onToggleTodoExpand = onToggleTodoExpand; + + editor.handleInput('\u0014'); + + expect(onToggleTodoExpand).toHaveBeenCalledOnce(); + }); +}); + +describe('CustomEditor bash mode border label', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('shows "! shell mode" on the top border in bash mode', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + const top = stripAnsi(editor.render(90)[0] ?? ''); + expect(top.startsWith('╭')).toBe(true); + expect(top).toContain('! shell mode'); + expect(top.endsWith('╮')).toBe(true); + }); + + it('does not show the shell mode label in prompt mode', () => { + const editor = makeEditor(); + const top = stripAnsi(editor.render(90)[0] ?? ''); + expect(top).not.toContain('! shell mode'); + }); + + it('keeps the top border at full width when the label is present', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + const width = 90; + const top = stripAnsi(editor.render(width)[0] ?? ''); + expect(top).toHaveLength(width); + }); +}); + +describe('CustomEditor bash mode via paste', () => { + const PASTE_START = '\u001B[200~'; + const PASTE_END = '\u001B[201~'; + + it('enters bash mode and strips the leading ! when !cmd is pasted into an empty prompt', () => { + const editor = makeEditor(); + const modes: Array<'prompt' | 'bash'> = []; + editor.onInputModeChange = (mode) => modes.push(mode); + + editor.handleInput(`${PASTE_START}!ls${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe('ls'); + expect(modes).toEqual(['bash']); + }); + + it('enters bash mode on a bare pasted ! with an empty buffer', () => { + const editor = makeEditor(); + editor.handleInput(`${PASTE_START}!${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); + + it('does not enter bash mode when pasting !cmd into a non-empty prompt', () => { + const editor = makeEditor(); + editor.handleInput('hello'); + editor.handleInput(`${PASTE_START}!ls${PASTE_END}`); + + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toContain('hello'); + expect(editor.getText()).toContain('!ls'); + }); + + it('does not enter bash mode for a pasted command without a leading !', () => { + const editor = makeEditor(); + editor.handleInput(`${PASTE_START}ls${PASTE_END}`); + + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toBe('ls'); + }); + + it('keeps the typed ! behaviour (bash mode, empty buffer)', () => { + const editor = makeEditor(); + editor.handleInput('!'); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); + + it('enters bash mode on a CSI-u encoded ! keystroke (Kitty/VSCode terminals)', () => { + const editor = makeEditor(); + editor.handleInput('\u001B[33u'); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); +}); + +describe('CustomEditor bash mode file completion', () => { + it('triggers file completion (force:true) for a leading / in bash mode, not the slash menu', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'auto', label: 'auto' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; + + editor.handleInput('/'); + await flushAutocomplete(); + + expect(calls).toContainEqual(expect.objectContaining({ force: true, text: '/' })); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('triggers file completion (force:true) for an inline / in bash mode', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'etc', label: 'etc' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; + + for (const char of 'ls /') { + editor.handleInput(char); + } + await flushAutocomplete(); + + expect(calls).toContainEqual(expect.objectContaining({ force: true, text: 'ls /' })); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('keeps force:false (slash menu) for a leading / in prompt mode', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'help', label: 'help' }]); + editor.setAutocompleteProvider(provider); + // inputMode defaults to 'prompt' + + editor.handleInput('/'); + await flushAutocomplete(); + + expect(calls).toContainEqual(expect.objectContaining({ force: false, text: '/' })); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('never falls back to force:false for a slash-shaped command in bash mode', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'list', label: 'list' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + // A force:false request would let pi-tui's own slash-command handling pop + // up subcommand completions for `/add-dir `. Bash mode must only ever + // request force:true path completion. + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.force === true)).toBe(true); + }); +}); + +describe('CustomEditor full re-render on autocomplete close', () => { + function makeEditorWithRenderSpy(contentLines: number): { + editor: CustomEditor; + requestRender: ReturnType<typeof vi.fn>; + } { + const requestRender = vi.fn(); + const tui = { + requestRender, + terminal: { rows: 40, cols: 120 }, + render: vi.fn(() => Array.from({ length: contentLines }, () => '')), + } as unknown as TUI; + return { editor: new CustomEditor(tui), requestRender }; + } + + // Drive one render frame so the render-edge detector observes the menu state. + function renderFrame(editor: CustomEditor): void { + editor.render(120); + } + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('forces a full re-render on the render frame after Escape closes the menu (content overflows)', async () => { + vi.stubEnv('TMUX', ''); + const { editor, requestRender } = makeEditorWithRenderSpy(50); + editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + renderFrame(editor); // record wasShowing = true + + editor.handleInput(''); + expect(editor.isShowingAutocomplete()).toBe(false); + + renderFrame(editor); // close edge -> schedule helper + await flushAutocomplete(); + expect(requestRender).toHaveBeenCalledWith(true); + }); + + it('keeps differential rendering when the content fits on one screen', async () => { + vi.stubEnv('TMUX', ''); + const { editor, requestRender } = makeEditorWithRenderSpy(10); + editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + renderFrame(editor); + + editor.handleInput(''); + expect(editor.isShowingAutocomplete()).toBe(false); + + renderFrame(editor); + await flushAutocomplete(); + expect(requestRender).not.toHaveBeenCalledWith(true); + }); + + it('forces a full re-render when the content exactly fills one screen', async () => { + vi.stubEnv('TMUX', ''); + const { editor, requestRender } = makeEditorWithRenderSpy(40); + editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + renderFrame(editor); + + editor.handleInput(''); + expect(editor.isShowingAutocomplete()).toBe(false); + + renderFrame(editor); + await flushAutocomplete(); + expect(requestRender).toHaveBeenCalledWith(true); + }); + + it('does not force a full re-render inside tmux', async () => { + vi.stubEnv('TMUX', '/tmp/tmux-501/default,1234,0'); + const { editor, requestRender } = makeEditorWithRenderSpy(50); + editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + renderFrame(editor); + + editor.handleInput(''); + expect(editor.isShowingAutocomplete()).toBe(false); + + renderFrame(editor); + await flushAutocomplete(); + expect(requestRender).not.toHaveBeenCalledWith(true); + }); + + it('forces a full re-render when Backspace deletes the slash and the menu closes asynchronously', async () => { + vi.stubEnv('TMUX', ''); + const { editor, requestRender } = makeEditorWithRenderSpy(50); + const provider: AutocompleteProvider = { + getSuggestions: vi.fn(async (lines, cursorLine, cursorCol) => { + const text = (lines[cursorLine] ?? '').slice(0, cursorCol); + if (!text.startsWith('/')) return { items: [], prefix: text }; + return { items: [{ value: 'help', label: 'help' }], prefix: '/' }; + }), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), + }; + editor.setAutocompleteProvider(provider); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + renderFrame(editor); // record wasShowing = true + + editor.handleInput(''); // Backspace deletes the '/' + await flushAutocomplete(); + await new Promise((resolve) => setTimeout(resolve, 0)); // let async cancelAutocomplete settle + expect(editor.isShowingAutocomplete()).toBe(false); + + renderFrame(editor); // close edge -> schedule helper + await flushAutocomplete(); + expect(requestRender).toHaveBeenCalledWith(true); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index 496aa4ce3..b53b49a83 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -1,26 +1,11 @@ -import { describe, it, expect } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; -import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; - -function stubGitCache( - files: string[] | null, - opts: { mtimes?: Record<string, number>; recency?: string[] } = {}, -): GitLsFilesCache { - const snapshot: GitSnapshot | null = - files === null - ? null - : { - files, - mtimeByPath: new Map(Object.entries(opts.mtimes ?? {})), - recencyOrder: new Map((opts.recency ?? []).map((p, i) => [p, i])), - }; - return { - isGitRepo: () => files !== null, - getSnapshot: () => snapshot, - list: () => (files === null ? null : files.slice()), - }; -} function ctrl(): AbortSignal { return new AbortController().signal; @@ -28,188 +13,631 @@ function ctrl(): AbortSignal { const NO_FD = null; -describe('FileMentionProvider — @ prefix detection + git-backed suggestions', () => { - it('returns null when there is no @ mention and the dir is empty', async () => { - const provider = new FileMentionProvider([], '/nonexistent', NO_FD, stubGitCache([])); +function resolveFdPath(): string | null { + const command = process.platform === 'win32' ? 'where' : 'which'; + const result = spawnSync(command, ['fd'], { encoding: 'utf-8' }); + if (result.status !== 0 || !result.stdout) return null; + const firstLine = result.stdout.split(/\r?\n/).find(Boolean); + return firstLine ? firstLine.trim() : null; +} + +const FD_PATH = resolveFdPath(); +const IS_FD_INSTALLED = Boolean(FD_PATH); +const GOAL_COMMAND = { + name: 'goal', + description: 'Start or manage a goal', + getArgumentCompletions: (prefix: string) => + prefix.length === 0 + ? [ + { + value: 'status', + label: 'status', + }, + ] + : null, +}; + +const NEW_COMMAND = { + name: 'new', + aliases: ['clear'], + description: 'Start a fresh session in the current workspace', +}; + +const LARK_CALENDAR_COMMAND = { + name: 'skill:lark-calendar', + aliases: [], + description: 'Manage Lark calendars', +}; + +const HELP_COMMAND = { + name: 'help', + aliases: ['h'], + description: 'Show help', +}; + +const HELP_FULL_COMMAND = { + name: 'help', + aliases: ['h', '?'], + description: 'Show help', +}; + +const ADD_DIR_COMMAND = { + name: 'add-dir', + description: 'Add or list an additional workspace directory', + getArgumentCompletions: (prefix: string) => + prefix === '/' + ? [ + { + value: '/tmp/shared/', + label: 'shared/', + description: '/tmp/shared', + }, + ] + : null, +}; + +describe('FileMentionProvider', () => { + let workDir: string; + let extraDirs: string[]; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-')); + extraDirs = []; + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + for (const extraDir of extraDirs) { + rmSync(extraDir, { recursive: true, force: true }); + } + }); + + function createExtraDir(): string { + const extraDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-extra-')); + extraDirs.push(extraDir); + return extraDir; + } + + it('returns null when there is no completable prefix', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions(['hello world'], 0, 11, { signal: ctrl() }); - // pi-tui inner will also return null for non-path plain text. expect(result).toBeNull(); }); - it('bare @ surfaces the first files as a starting list', async () => { - const files = ['a.ts', 'b.ts', 'src/c.ts']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + it('does not complete slash arguments before existing free text', async () => { + const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); + const line = '/goal Fix the checkout docs'; + const result = await provider.getSuggestions([line], 0, '/goal '.length, { signal: ctrl() }); + expect(result).toBeNull(); + }); + + it('opens @ file mention when typed in the middle of a slash command argument', async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); + // Cursor sits in the middle of the /goal argument text, right after a + // freshly typed `@`. The slash-argument guard must not suppress the @ + // file list here. + const line = '/goal Fix the @checkout docs'; + const result = await provider.getSuggestions([line], 0, '/goal Fix the @'.length, { + signal: ctrl(), + }); expect(result).not.toBeNull(); expect(result!.prefix).toBe('@'); - expect(result!.items.map((i) => i.value)).toEqual(['@a.ts', '@b.ts', '@src/c.ts']); + expect(result!.items.map((item) => item.value)).toContain('@README.md'); }); - it('ranks basename-prefix > substring > fuzzy', async () => { - const files = [ - 'docs/readme.md', // basename starts with "read" - 'src/readability.ts', // basename starts with "read" - 'lib/threader.ts', // basename contains "read" (substring) - ]; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() }); + it('still completes slash arguments at the end of an empty argument', async () => { + const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); + const line = '/goal '; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); expect(result).not.toBeNull(); - const values = result!.items.map((i) => i.value); - const readabilityIdx = values.indexOf('@src/readability.ts'); - const readmeIdx = values.indexOf('@docs/readme.md'); - const threadIdx = values.indexOf('@lib/threader.ts'); - // Both starts-with entries rank ahead of the substring entry. - expect(readabilityIdx).toBeGreaterThanOrEqual(0); - expect(readmeIdx).toBeGreaterThanOrEqual(0); - expect(threadIdx).toBeGreaterThan(Math.max(readabilityIdx, readmeIdx)); + expect(result!.prefix).toBe(''); + expect(result!.items.map((item) => item.value)).toEqual(['status']); }); - it('empty query prefers recently-edited files over everything else', async () => { - const files = ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts']; - const provider = new FileMentionProvider( - [], - '/repo', - NO_FD, - stubGitCache(files, { - recency: ['d.ts', 'b.ts'], // d most recent, then b - }), - ); - const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); - const values = result!.items.map((i) => i.value); - // Recency layer fills first, then alphabetical layer. - expect(values.slice(0, 2)).toEqual(['@d.ts', '@b.ts']); - expect(values.slice(2)).toEqual(['@a.ts', '@c.ts', '@e.ts']); - }); + it('opens add-dir directory completions after slash command completion and entering slash', async () => { + const provider = new FileMentionProvider([ADD_DIR_COMMAND], workDir, NO_FD); + const command = ADD_DIR_COMMAND; + const completed = provider.applyCompletion(['/add'], 0, 4, { value: command.name, label: command.name }, '/add'); + const completedLine = completed.lines[0]!; + const line = `${completedLine}/`; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); - it('empty query falls back to mtime when no recency info', async () => { - const files = ['old.ts', 'newer.ts', 'newest.ts']; - const provider = new FileMentionProvider( - [], - '/repo', - NO_FD, - stubGitCache(files, { - mtimes: { 'old.ts': 1000, 'newer.ts': 2000, 'newest.ts': 3000 }, - }), - ); - const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); - const values = result!.items.map((i) => i.value); - expect(values).toEqual(['@newest.ts', '@newer.ts', '@old.ts']); - }); - - it('empty query falls back to basename alphabetical when no signals', async () => { - const files = ['zoo/apple.ts', 'banana.ts', 'cherry.ts']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); - const values = result!.items.map((i) => i.value); - // Sorted by basename alphabetical: apple, banana, cherry - expect(values).toEqual(['@zoo/apple.ts', '@banana.ts', '@cherry.ts']); - }); - - it('hides dot-dir files from the default list', async () => { - const files = ['.agents/skills/x.md', '.github/workflows/y.yml', 'src/a.ts', 'README.md']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); - const values = result!.items.map((i) => i.value); - expect(values).toContain('@README.md'); - expect(values).toContain('@src/a.ts'); - expect(values).not.toContain('@.agents/skills/x.md'); - expect(values).not.toContain('@.github/workflows/y.yml'); - }); - - it('shows dot-dir files when the query explicitly opts in (starts with .)', async () => { - const files = ['.agents/skills/foo.md', '.agents/README.md', 'src/a.ts']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@.agents'], 0, 8, { signal: ctrl() }); + expect(completedLine).toBe('/add-dir '); expect(result).not.toBeNull(); - const values = result!.items.map((i) => i.value); - expect(values.some((v) => v.startsWith('@.agents/'))).toBe(true); + expect(result!.prefix).toBe('/'); + expect(result!.items.map((item) => item.value)).toEqual(['/tmp/shared/']); }); - it('within a category, recency ranks higher than mtime', async () => { - const files = ['older-recent.ts', 'never-touched-but-new.ts']; - const provider = new FileMentionProvider( - [], - '/repo', - NO_FD, - stubGitCache(files, { - mtimes: { 'older-recent.ts': 1000, 'never-touched-but-new.ts': 9999 }, - recency: ['older-recent.ts'], - }), - ); - // Query hits both via fuzzy (they both contain letters from 'nr'). - // Use basename-startswith shared prefix to force cat 0 tie. - const tied = ['aa-recent.ts', 'aa-newer.ts']; - const provider2 = new FileMentionProvider( - [], - '/repo', - NO_FD, - stubGitCache(tied, { - mtimes: { 'aa-recent.ts': 1000, 'aa-newer.ts': 9999 }, - recency: ['aa-recent.ts'], - }), - ); - const result = await provider2.getSuggestions(['@aa'], 0, 3, { signal: ctrl() }); - const values = result!.items.map((i) => i.value); - expect(values[0]).toBe('@aa-recent.ts'); - expect(values[1]).toBe('@aa-newer.ts'); - void provider; // silence unused - }); + it('searches slash command aliases and displays aliases in the command label', async () => { + const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD); + const line = '/clear'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); - it('scoped @src/ limits to files under src/', async () => { - const files = ['src/a.ts', 'src/b.ts', 'lib/c.ts']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@src/'], 0, 5, { signal: ctrl() }); expect(result).not.toBeNull(); - const values = result!.items.map((i) => i.value); - // Empty query after src/ shouldn't match lib/c.ts via basename ranking; - // but our git-backed path doesn't apply scope directly — the query is - // "src/" and we fall back to fuzzy on the raw path. Both src/ paths - // contain "src/" and rank higher than lib/c.ts. - expect(values[0]).toMatch(/^@src\//); - expect(values[1]).toMatch(/^@src\//); + expect(result!.prefix).toBe('/clear'); + expect(result!.items[0]).toMatchObject({ + value: 'new', + label: 'new (clear)', + }); + }); + + it('prefers exact alias matches over fuzzy skill matches', async () => { + const provider = new FileMentionProvider( + [NEW_COMMAND, LARK_CALENDAR_COMMAND], + workDir, + NO_FD, + ); + const line = '/clear'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'new', + label: 'new (clear)', + }); + expect(result!.items[0]?.value).not.toBe('skill:lark-calendar'); + }); + + it('does not show aliases when the primary name already matches', async () => { + const provider = new FileMentionProvider([HELP_COMMAND], workDir, NO_FD); + const line = '/h'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'help', + label: 'help', + }); + }); + + it('does not show aliases in labels when query is empty', async () => { + const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD); + const line = '/'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'new', + label: 'new', + }); + }); + + it('includes the argument hint in the description like the inner provider does', async () => { + const provider = new FileMentionProvider( + [{ name: 'goal', description: 'Start or manage a goal', argumentHint: '<objective>' }], + workDir, + NO_FD, + ); + + const result = await provider.getSuggestions(['/go'], 0, 3, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'goal', + description: '<objective> — Start or manage a goal', + }); + }); + + it('joins multiple aliases with an ASCII comma in the label', async () => { + const provider = new FileMentionProvider([HELP_FULL_COMMAND], workDir, NO_FD); + // '?' only matches the alias, not the primary name, so the label must + // list the aliases. + const result = await provider.getSuggestions(['/?'], 0, 2, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'help', + label: 'help (h, ?)', + }); + }); + + it('returns null for a bare slash when no commands are registered', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions(['/'], 0, 1, { signal: ctrl() }); + + expect(result).toBeNull(); + }); + + it('ranks primary-name matches above alias matches with equal scores', async () => { + const provider = new FileMentionProvider( + [ + { name: 'bar', aliases: ['foo'], description: 'Bar command' }, + { name: 'foo', aliases: [], description: 'Foo command' }, + ], + workDir, + NO_FD, + ); + + const result = await provider.getSuggestions(['/foo'], 0, 4, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]?.value).toBe('foo'); + expect(result!.items[1]).toMatchObject({ + value: 'bar', + label: 'bar (foo)', + }); + }); + + it('does not turn leading-whitespace slash into root path completion', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + const result = await provider.getSuggestions([' /'], 0, 2, { signal: ctrl() }); + expect(result).toBeNull(); + }); + + it('still allows forced root path completion after leading whitespace', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + const result = await provider.getSuggestions([' /'], 0, 2, { signal: ctrl(), force: true }); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/'); }); it('does not trigger the @ branch when @ is preceded by a non-delimiter', async () => { - // "email@example" — @ is not at a token boundary; our extractAtPrefix - // returns null and the inner provider handles the text. - const provider = new FileMentionProvider([], '/nonexistent', NO_FD, stubGitCache(['a.ts'])); + const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions(['email@example'], 0, 13, { signal: ctrl() }); - // Inner provider returns null for this kind of free text. expect(result).toBeNull(); }); - it('handles multiple @ mentions on one line by completing the last one', async () => { - const files = ['alpha.ts', 'beta.ts']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - // "read @alpha.ts and @bet" — cursor at end, inside the second @. - const line = 'read @alpha.ts and @bet'; - const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + it('uses a filesystem fallback for @ mentions when fd is not available', async () => { + mkdirSync(join(workDir, 'src', 'components'), { recursive: true }); + writeFileSync(join(workDir, 'src', 'components', 'Button.tsx'), 'export {};'); + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions(['@but'], 0, 4, { signal: ctrl() }); + expect(result).not.toBeNull(); - expect(result!.prefix).toBe('@bet'); - const values = result!.items.map((i) => i.value); - expect(values).toContain('@beta.ts'); - expect(values).not.toContain('@alpha.ts'); + expect(result!.prefix).toBe('@but'); + expect(result!.items.map((item) => item.value)).toContain('@src/components/Button.tsx'); }); - it('applyCompletion delegates to inner (replaces prefix with value)', () => { - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(['src/a.ts'])); - const out = provider.applyCompletion( - ['hey @src'], - 0, - 8, // cursor just after @src - { value: '@src/a.ts', label: 'a.ts' }, - '@src', + it('uses the filesystem fallback for additionalDirs when fd is unavailable', async () => { + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd'), [extraDir]); + + const result = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`, ); - // pi-tui appends a trailing space after a non-directory completion - // so the user can type the next token immediately. - expect(out.lines[0]).toBe('hey @src/a.ts '); }); - it('falls through to inner when the git cache is null (non-git dir)', async () => { - const provider = new FileMentionProvider([], '/nonexistent', NO_FD, stubGitCache(null)); - // No files visible via readdir either, but it shouldn't throw. - const result = await provider.getSuggestions(['@foo'], 0, 4, { signal: ctrl() }); - // pi-tui readdir on a nonexistent basePath returns [] → null. - expect(result).toBeNull(); + it.runIf(IS_FD_INSTALLED)( + 'uses fd for additionalDirs even when cwd is large enough to exhaust the fallback scanner', + async () => { + // Fill cwd with enough entries to push the filesystem fallback past its + // 2000-entry scan cap, so it would never reach the additional root. fd + // searches each root independently and still finds the deep target. + for (let i = 0; i < 2000; i++) { + writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};'); + } + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'deep'), { recursive: true }); + writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, FD_PATH!, [extraDir]); + + const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`, + ); + }, + ); + + it.runIf(IS_FD_INSTALLED)( + 'treats a bare fd command name as executable and resolves it via PATH', + async () => { + // A bare "fd" (system PATH lookup) must not be mistaken for unavailable; + // otherwise the large cwd would push the fallback scanner past its cap + // and hide the deep target in the additional root. + for (let i = 0; i < 2000; i++) { + writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};'); + } + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'deep'), { recursive: true }); + writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, 'fd', [extraDir]); + + const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`, + ); + }, + ); + + it('keeps cwd @ mention values relative and additionalDir values absolute', async () => { + mkdirSync(join(workDir, 'src'), { recursive: true }); + writeFileSync(join(workDir, 'src', 'Cwd.ts'), 'export {};'); + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const cwdResult = await provider.getSuggestions(['@cwd'], 0, 4, { signal: ctrl() }); + expect(cwdResult).not.toBeNull(); + expect(cwdResult!.items.map((item) => item.value)).toContain('@src/Cwd.ts'); + + const additionalResult = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); + expect(additionalResult).not.toBeNull(); + expect(additionalResult!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`, + ); + }); + + it('deduplicates cwd and additionalDir candidates by absolute path', async () => { + const extraDir = join(workDir, 'extra'); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Overlap.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const result = await provider.getSuggestions(['@overlap'], 0, 8, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const overlapItems = result!.items.filter( + (item) => item.description === join(extraDir, 'src', 'Overlap.ts').replaceAll('\\', '/'), + ); + expect(overlapItems).toHaveLength(1); + }); + + it.runIf(IS_FD_INSTALLED)( + 'does not bypass fd filtering with filesystem suggestions when fd returns no matches', + async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([], workDir, FD_PATH!); + + const result = await provider.getSuggestions(['@zzz-no-match-xyz'], 0, '@zzz-no-match-xyz'.length, { + signal: ctrl(), + }); + + expect(result).toBeNull(); + }, + ); + + it('filesystem fallback returns folders and excludes .git', async () => { + mkdirSync(join(workDir, 'src')); + mkdirSync(join(workDir, '.git')); + writeFileSync(join(workDir, '.git', 'config'), 'secret'); + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((item) => item.value); + expect(values).toContain('@src/'); + expect(values.some((value) => value.startsWith('@.git'))).toBe(false); + }); + + it('filesystem fallback quotes paths with spaces', async () => { + mkdirSync(join(workDir, 'my folder')); + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions(['@my'], 0, 3, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain('@"my folder/"'); + }); + + it('filesystem fallback does not recurse into symlinked directories', async () => { + writeFileSync(join(workDir, 'target.txt'), 'target'); + symlinkSync('.', join(workDir, 'current'), 'dir'); + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions(['@target'], 0, 7, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((item) => item.value); + expect(values).toContain('@target.txt'); + expect(values.some((value) => value.startsWith('@current/'))).toBe(false); + }); + + it('delegates path suggestions to pi-tui for regular path completion', async () => { + mkdirSync(join(workDir, 'src')); + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions([''], 0, 0, { signal: ctrl(), force: true }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toEqual(['src/', 'README.md']); + }); + + it('applyCompletion delegates file and directory insertion to pi-tui', () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + + const file = provider.applyCompletion( + ['hey @read'], + 0, + 9, + { value: '@README.md', label: 'README.md' }, + '@read', + ); + expect(file.lines[0]).toBe('hey @README.md '); + + const dir = provider.applyCompletion( + ['hey @sr'], + 0, + 7, + { value: '@src/', label: 'src/' }, + '@sr', + ); + expect(dir.lines[0]).toBe('hey @src/'); + }); + + describe('bash-mode path completion dotfile filtering', () => { + it('hides dot-prefixed entries (matching /add-dir) in bash mode', async () => { + mkdirSync(join(workDir, '.hidden')); + mkdirSync(join(workDir, 'visible')); + writeFileSync(join(workDir, '.dotfile'), ''); + writeFileSync(join(workDir, 'normal.txt'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('visible/'); + expect(labels).toContain('normal.txt'); + expect(labels).not.toContain('.hidden/'); + expect(labels).not.toContain('.dotfile'); + }); + + it('keeps dot-prefixed entries in prompt mode', async () => { + mkdirSync(join(workDir, '.hidden')); + writeFileSync(join(workDir, '.dotfile'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('.hidden/'); + expect(labels).toContain('.dotfile'); + }); + }); + + describe('bash-mode path applyCompletion', () => { + it('does not double the leading slash for a bare / path', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: '/Applications/', label: 'Applications/' }, + '/', + ); + expect(result.lines[0]).toBe('/Applications/'); + expect(result.cursorCol).toBe('/Applications/'.length); + }); + + it('replaces the path prefix after a command without a trailing space', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /App'], + 0, + 7, + { value: '/Applications/', label: 'Applications/' }, + '/App', + ); + expect(result.lines[0]).toBe('cd /Applications/'); + expect(result.cursorCol).toBe('cd /Applications/'.length); + }); + + it('keeps the cursor inside the closing quote for a spaced directory', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /tmp/My'], + 0, + 10, + { value: '"/tmp/My Dir/"', label: 'My Dir/' }, + '/tmp/My', + ); + expect(result.lines[0]).toBe('cd "/tmp/My Dir/"'); + // Cursor sits before the closing quote so the next `/` continues inside it. + expect(result.cursorCol).toBe('cd "/tmp/My Dir/'.length); + }); + + it('keeps pi-tui slash-command behaviour in prompt mode', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: 'help', label: 'help' }, + '/', + ); + // pi-tui's slash-command branch: beforePrefix + '/' + value + ' ' + expect(result.lines[0]).toBe('/help '); + }); + }); + + describe('bash-mode slash argument completion suppression', () => { + it('does not invoke slash argument completions for an absolute path in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [ + { value: '/should-not-appear/', label: 'should-not-appear/' }, + ]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + const text = '/add-dir/tmp/'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('should-not-appear/'); + }); + + it('does not invoke slash argument completions for a trailing-space command in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: 'list', label: 'list' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + // `/add-dir ` (trailing space) used to be re-triggered with force:false, + // which let pi-tui's own slash-command handling return subcommand + // completions. Bash mode now only ever triggers force:true path + // completion, so the argument completer must not run. + const text = '/add-dir '; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('list'); + }); + + it('keeps slash argument completion in prompt mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: '/shared/', label: 'shared/' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'prompt', + ); + + const text = '/add-dir /'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: false, + }); + + expect(getArgumentCompletions).toHaveBeenCalled(); + expect(result?.items.map((item) => item.label)).toContain('shared/'); + }); }); }); diff --git a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts index 11801aa71..6d3145b14 100644 --- a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts +++ b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts @@ -10,6 +10,14 @@ describe('wrapWithSideBorders', () => { expect(out[0]).toBe('╭────────╮'); }); + it('turns the top horizontal border into connectors when connected above', () => { + const out = wrapWithSideBorders(['──────────', ' hi ', '──────────'], id, { + connectedAbove: true, + }); + expect(out[0]).toBe('├────────┤'); + expect(out[2]).toBe('╰────────╯'); + }); + it('turns the bottom horizontal border into a ╰…╯ run', () => { const out = wrapWithSideBorders(['──────────', ' hi ', '──────────'], id); expect(out[2]).toBe('╰────────╯'); @@ -67,4 +75,32 @@ describe('wrapWithSideBorders', () => { // first column was a space → replaced with │; last column was 'c' → kept expect(out[1]).toBe('│ abc'); }); + + it('overlays a label on the top border, replacing leading dashes', () => { + const top = '─'.repeat(30); + const out = wrapWithSideBorders([top, ' x ', top], id, { label: ' ! shell mode ' }); + expect(out[0]).toBe(`╭ ! shell mode ${'─'.repeat(14)}╮`); + // width is preserved: corner + label + dashes + corner == input width + expect(out[0]).toHaveLength(top.length); + // bottom border is untouched + expect(out[2]).toBe(`╰${'─'.repeat(28)}╯`); + }); + + it('does not inject the label when it is wider than the top border', () => { + const out = wrapWithSideBorders(['──────', ' x ', '──────'], id, { + label: ' ! shell mode ', + }); + // falls back to a plain border — label must not leak or overflow + expect(out[0]).toBe('╭────╮'); + expect(out[0]).not.toContain('shell mode'); + }); + + it('does not inject the label onto a scroll-indicator top border', () => { + const top = '─── ↑ 5 more ────'; + const out = wrapWithSideBorders([top, ' x ', '─── ↓ 3 more ────'], id, { + label: ' ! shell mode ', + }); + expect(out[0]).toContain('↑ 5 more'); + expect(out[0]).not.toContain('shell mode'); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts index 04826e604..d47f29b56 100644 --- a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts +++ b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts @@ -14,30 +14,52 @@ function strip(s: string): string { return s.replaceAll(/\u001B\[[0-9;]*m/g, ''); } +function expectHighlighted(out: string, token: string): void { + expect(out).toMatch(new RegExp(`\\u001B\\[[0-9;]*m${token}\\u001B\\[`)); +} + describe('highlightFirstSlashToken', () => { it('colours /cmd when line starts with a slash', () => { - const out = highlightFirstSlashToken(' /help rest of input', '#ff00aa'); + const out = highlightFirstSlashToken(' /help rest of input', 'primary'); expect(out).toBeDefined(); // Visible text unchanged expect(strip(out!)).toBe(' /help rest of input'); // SGR escapes surround /help - expect(out!).toMatch(/\u001B\[.*m\/help\u001B\[/); + expectHighlighted(out!, '/help'); + }); + + it('colours next in /goal next', () => { + const out = highlightFirstSlashToken('/goal next Ship feature X', 'primary'); + expect(out).toBeDefined(); + expect(strip(out!)).toBe('/goal next Ship feature X'); + expectHighlighted(out!, '/goal'); + expectHighlighted(out!, 'next'); + expect(out!).toContain(' Ship feature X'); + }); + + it('colours manage in /goal next manage', () => { + const out = highlightFirstSlashToken('/goal next manage', 'primary'); + expect(out).toBeDefined(); + expect(strip(out!)).toBe('/goal next manage'); + expectHighlighted(out!, '/goal'); + expectHighlighted(out!, 'next'); + expectHighlighted(out!, 'manage'); }); it('returns undefined when the line has no slash', () => { - expect(highlightFirstSlashToken('hello world', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken('hello world', 'primary')).toBeUndefined(); }); it('returns undefined when slash is not at the leading position', () => { - expect(highlightFirstSlashToken(' hello /not-cmd', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken(' hello /not-cmd', 'primary')).toBeUndefined(); }); it('returns undefined for path-like slash tokens', () => { - expect(highlightFirstSlashToken('/user/desktop/ foo', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken('/user/desktop/ foo', 'primary')).toBeUndefined(); }); it('handles /token at end of line (no trailing whitespace)', () => { - const out = highlightFirstSlashToken('/exit', '#ff00aa'); + const out = highlightFirstSlashToken('/exit', 'primary'); expect(out).toBeDefined(); expect(strip(out!)).toBe('/exit'); }); @@ -46,7 +68,7 @@ describe('highlightFirstSlashToken', () => { // Simulate pi-tui Editor inserting an inverse-video cursor marker // somewhere after the slash token. const line = '/help x\u001B[7m \u001B[0m'; - const out = highlightFirstSlashToken(line, '#ff00aa'); + const out = highlightFirstSlashToken(line, 'primary'); expect(out).toBeDefined(); // Stripped visible content unchanged expect(strip(out!)).toBe(strip(line)); @@ -55,11 +77,11 @@ describe('highlightFirstSlashToken', () => { }); it('only paints the first token, not other slashes further along', () => { - const out = highlightFirstSlashToken('/a /b', '#ff00aa'); + const out = highlightFirstSlashToken('/a /b', 'primary'); expect(out).toBeDefined(); // Count the SGR opens — should be exactly one for /a. const opens = (out!.match(/\u001B\[[0-9;]+m/g) ?? []).length; - expect(opens).toBeGreaterThanOrEqual(2); // chalk hex+bold open and reset(s) + expect(opens).toBeGreaterThanOrEqual(2); // chalk bold+fg open and reset(s) // /b should remain plain — the substring " /b" exists verbatim. expect(out!).toContain(' /b'); }); diff --git a/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts b/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts new file mode 100644 index 000000000..c4147ab40 --- /dev/null +++ b/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts @@ -0,0 +1,141 @@ +import { visibleWidth, type SelectItem, type SelectListTheme } from '@moonshot-ai/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { WrappingSelectList } from '#/tui/components/editor/wrapping-select-list'; + +/** Marker theme so assertions can see which style hook painted each part. */ +const MARKER_THEME: SelectListTheme = { + selectedPrefix: (s) => s, + selectedText: (s) => `[S]${s}`, + description: (s) => `[D]${s}`, + scrollInfo: (s) => `[I]${s}`, + noMatch: (s) => `[N]${s}`, +}; + +const IDENTITY_THEME: SelectListTheme = { + selectedPrefix: (s) => s, + selectedText: (s) => s, + description: (s) => s, + scrollInfo: (s) => s, + noMatch: (s) => s, +}; + +/** Mirrors pi-tui's slash command layout (editor.js). */ +const SLASH_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 32 }; + +// With two 4-char labels and SLASH_LAYOUT at width 80, the primary column is +// 12 wide: prefix(2) + label(4) + spacing(8) puts descriptions at column 14 +// with 64 columns of room (80 - 14 - 2 safety). +const DESCRIPTION_INDENT = ' '.repeat(14); + +function makeList(items: SelectItem[], maxVisible = 5): WrappingSelectList { + return new WrappingSelectList(items, maxVisible, MARKER_THEME, SLASH_LAYOUT); +} + +describe('WrappingSelectList', () => { + it('renders short descriptions on a single line', () => { + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { value: 'init', label: 'init', description: 'Second command' }, + ]).render(80); + + expect(lines).toEqual([ + '[S]→ goal First command', + ' init[D] Second command', + ]); + }); + + it('wraps a long description onto a second indented line without an ellipsis', () => { + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { + value: 'init', + label: 'init', + description: + 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt', + }, + ]).render(80); + + expect(lines).toEqual([ + '[S]→ goal First command', + ' init[D] lorem ipsum dolor sit amet consectetur adipiscing elit sed do', + `[D]${DESCRIPTION_INDENT}eiusmod tempor incididunt`, + ]); + }); + + it('caps descriptions at two lines and ellipsizes the overflow', () => { + const description = 'lorem ipsum dolor sit amet consectetur adipiscing elit '.repeat(4).trim(); + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { value: 'init', label: 'init', description }, + ]).render(80); + + expect(lines).toHaveLength(3); + expect(lines[1]).toMatch(/^ {2}init\[D\] {8}lorem ipsum/); + expect(lines[2]).toMatch(new RegExp(`^\\[D\\]${DESCRIPTION_INDENT}`)); + expect(lines[2]!.endsWith('…')).toBe(true); + }); + + it('paints every line of the selected item with the selected style', () => { + const description = 'lorem ipsum dolor sit amet consectetur adipiscing elit '.repeat(4).trim(); + const lines = makeList([ + { value: 'goal', label: 'goal', description }, + { value: 'init', label: 'init', description: 'Second command' }, + ]).render(80); + + expect(lines[0]).toMatch(/^\[S\]→ goal {8}lorem ipsum/); + expect(lines[1]).toMatch(new RegExp(`^\\[S\\]${DESCRIPTION_INDENT}`)); + expect(lines[2]).toBe(' init[D] Second command'); + }); + + it('falls back to primary-only single lines on narrow widths', () => { + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { value: 'init', label: 'init', description: 'Second command' }, + ]).render(40); + + expect(lines).toEqual(['[S]→ goal', ' init']); + }); + + it('keeps the scroll indicator when items overflow maxVisible', () => { + const items = Array.from({ length: 7 }, (_, i) => ({ + value: `cmd${i}`, + label: `cmd${i}`, + description: 'Short', + })); + const lines = makeList(items, 5).render(80); + + expect(lines).toHaveLength(6); + expect(lines[5]).toBe('[I] (1/7)'); + }); + + it('does not leak ANSI resets into themed lines when the primary name is truncated', () => { + const description = 'Use when about to claim work is complete fixed or passing before committing'; + const lines = makeList([ + { value: 'verify', label: 'skill:verification-before-completion', description }, + { value: 'init', label: 'skill:another-very-long-command-name', description }, + ]).render(80); + + // truncateToWidth appends [0m when it truncates; embedded inside the + // selected/description colouring it would reset the rest of the line. + for (const line of lines) { + expect(line).not.toContain('\u001B'); + } + }); + + it('never emits a line wider than the requested width, including CJK text', () => { + const list = new WrappingSelectList( + [ + { value: 'lark', label: 'skill:lark-calendar', description: '管理飞书日历的技能描述'.repeat(8) }, + { value: 'init', label: 'init', description: 'word '.repeat(60).trim() }, + ], + 5, + IDENTITY_THEME, + SLASH_LAYOUT, + ); + + for (const line of list.render(80)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(80); + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts index f2a43aabd..d355bb7ed 100644 --- a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts +++ b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts @@ -5,9 +5,6 @@ import { renderDiffLines, renderDiffLinesClustered, } from '#/tui/components/media/diff-preview'; -import { getColorPalette } from '#/tui/theme/colors'; - -const COLORS = getColorPalette('dark'); function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -46,7 +43,7 @@ describe('computeDiffLines', () => { describe('renderDiffLines', () => { it('does not show removed count for suppressed trailing deletes', () => { - const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', COLORS, true, 1, 1); + const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', true, 1, 1); const text = stripAnsi(output.join('\n')); expect(text).toContain('test.ts'); expect(text).not.toContain('-2'); @@ -59,7 +56,7 @@ describe('renderDiffLines', () => { }); it('shows removed count for complete diffs', () => { - const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', COLORS, false, 1, 1); + const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', false, 1, 1); const text = stripAnsi(output.join('\n')); expect(text).toContain('-2'); expect(text).toContain('C'); @@ -69,7 +66,7 @@ describe('renderDiffLines', () => { describe('renderDiffLinesClustered', () => { it('renders header with file path and counts', () => { - const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts', COLORS); + const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts'); const text = stripAnsi(out[0]!); expect(text).toContain('+1'); expect(text).toContain('-1'); @@ -77,7 +74,7 @@ describe('renderDiffLinesClustered', () => { }); it('returns header only when there are no changes', () => { - const out = renderDiffLinesClustered('A\nB', 'A\nB', 'foo.ts', COLORS); + const out = renderDiffLinesClustered('A\nB', 'A\nB', 'foo.ts'); expect(out).toHaveLength(1); expect(stripAnsi(out[0]!)).toContain('foo.ts'); }); @@ -87,7 +84,7 @@ describe('renderDiffLinesClustered', () => { const oldText = ['L1', 'L2', 'L3', 'L4', 'L5'].join('\n'); const newText = ['L1', 'L2', 'L3X', 'L4', 'L5'].join('\n'); const text = stripAnsi( - renderDiffLinesClustered(oldText, newText, 'f.ts', COLORS, { contextLines: 1 }).join('\n'), + renderDiffLinesClustered(oldText, newText, 'f.ts', { contextLines: 1 }).join('\n'), ); expect(text).toContain('L2'); expect(text).toContain('L3'); @@ -104,7 +101,7 @@ describe('renderDiffLinesClustered', () => { newLines[1] = 'L2X'; // change near top newLines[28] = 'L29X'; // change near bottom const text = stripAnsi( - renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, }).join('\n'), ); @@ -121,7 +118,7 @@ describe('renderDiffLinesClustered', () => { const newLines = oldLines.slice(); newLines[2] = 'L3X'; newLines[5] = 'L6X'; // gap of 2 lines between change indices 2 and 5 → merges with contextLines=2 (mergeGap=4) - const out = renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + const out = renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, }).join('\n'); const text = stripAnsi(out); @@ -144,7 +141,6 @@ describe('renderDiffLinesClustered', () => { oldLines.join('\n'), newLines.join('\n'), 'big.ts', - COLORS, { contextLines: 3, maxLines: 10, @@ -159,6 +155,22 @@ describe('renderDiffLinesClustered', () => { expect(text).toContain('ctrl+o to expand'); }); + it('respects oldStart and newStart for line numbers', () => { + const text = stripAnsi( + renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'f.ts', { + contextLines: 1, + oldStart: 10, + newStart: 20, + }).join('\n'), + ); + // Context lines keep the new (post-edit) line numbers from newStart; + // deleted lines use oldStart; added lines use newStart. + expect(text).toContain(' 20 A'); + expect(text).toContain(' 11 - B'); + expect(text).toContain(' 21 + X'); + expect(text).toContain(' 22 C'); + }); + it('truncates at cluster boundary and appends the ctrl+o footer when maxLines is set', () => { const oldLines: string[] = []; for (let i = 1; i <= 50; i++) oldLines.push(`L${String(i)}`); @@ -167,7 +179,7 @@ describe('renderDiffLinesClustered', () => { newLines[20] = 'L21X'; newLines[40] = 'L41X'; const text = stripAnsi( - renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, maxLines: 6, }).join('\n'), diff --git a/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts new file mode 100644 index 000000000..b6378f389 --- /dev/null +++ b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts @@ -0,0 +1,47 @@ +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; +import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; + +const image: ImageAttachment = { + id: 1, + kind: 'image', + bytes: new Uint8Array([137, 80, 78, 71]), + mime: 'image/png', + width: 800, + height: 600, + placeholder: '[image #1 (800×600)]', +}; + +describe('ImageThumbnail', () => { + afterEach(() => { + resetCapabilitiesCache(); + vi.restoreAllMocks(); + }); + + it('keeps rendered output within narrow widths', () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + + const component = new ImageThumbnail(image); + + for (const width of [39, 20, 3, 1]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('does not rebuild inline image children on repeated same-width renders', () => { + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + + const bufferFrom = vi.spyOn(Buffer, 'from'); + const component = new ImageThumbnail(image); + bufferFrom.mockClear(); + + component.render(80); + component.render(80); + + expect(bufferFrom).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts new file mode 100644 index 000000000..adf4d3b0b --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -0,0 +1,233 @@ +import type { TUI } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; + +const ESC = String.fromCodePoint(0x1b); +const BEL = String.fromCodePoint(0x07); + +function strip(text: string): string { + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); +} + +function stubTui(): TUI { + return { + terminal: { rows: 40 }, + requestRender: vi.fn(), + } as unknown as TUI; +} + +function renderText(component: AgentGroupComponent, width = 120): string { + return strip(component.render(width).join('\n')); +} + +function createAgent( + id: string, + description: string, + agentName: string, + ui: TUI, +): ToolCallComponent { + const component = new ToolCallComponent( + { + id, + name: 'Agent', + args: { description }, + }, + undefined, + ui, + ); + component.onSubagentSpawned({ + agentId: `sub_${id}`, + agentName, + runInBackground: false, + }); + return component; +} + +function startAgent(component: ToolCallComponent, id: string, agentName: string): void { + component.onSubagentStarted({ + agentId: `sub_${id}`, + agentName, + runInBackground: false, + }); +} + +describe('AgentGroupComponent', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows explicit active breakdown, row state, and waiting fallback', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + running.appendSubToolCall({ + id: 'sub_call_agent_1:read', + name: 'Read', + args: { path: 'src/a.ts' }, + }); + + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + const output = renderText(group); + expect(output).toContain('Running 2 agents (1 running, 1 waiting) · 0s'); + expect(output).toContain('explore · inspect project · 0 tools · 0s · Running'); + expect(output).toContain('Using Read (src/a.ts)'); + expect(output).toContain('coder · write tests · 0 tools · 0s · Waiting'); + expect(output).toContain('Waiting to start…'); + expect(output).not.toContain('Initializing…'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('shows the Ctrl+B hint while agents are running and hides it once all are backgrounded', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const b = createAgent('call_agent_2', 'write tests', 'coder', ui); + startAgent(a, 'call_agent_1', 'explore'); + startAgent(b, 'call_agent_2', 'coder'); + group.attach('call_agent_1', a); + group.attach('call_agent_2', b); + + expect(renderText(group)).toContain('Press Ctrl+B to run in background'); + + a.markBackgrounded(); + expect(renderText(group)).toContain('Press Ctrl+B to run in background'); + + b.markBackgrounded(); + expect(renderText(group)).not.toContain('Press Ctrl+B to run in background'); + + group.dispose(); + a.dispose(); + b.dispose(); + }); + + it('uses still-working fallback for running agents without recent activity', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + const output = renderText(group); + expect(output).toContain('Still working…'); + expect(output).toContain('Waiting to start…'); + expect(output).not.toContain('Initializing…'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('refreshes grouped elapsed time from child subagent timers', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + expect(renderText(group)).toContain('Running 2 agents (1 running, 1 waiting) · 0s'); + vi.mocked(ui.requestRender).mockClear(); + + vi.advanceTimersByTime(1_200); + + expect(ui.requestRender).toHaveBeenCalled(); + expect(renderText(group)).toContain('Running 2 agents (1 running, 1 waiting) · 1s'); + expect(renderText(group)).toContain('explore · inspect project · 0 tools · 1s · Running'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('keeps terminal rows explicit while mixed agents are still running', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const done = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const running = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(done, 'call_agent_1', 'explore'); + startAgent(running, 'call_agent_2', 'coder'); + group.attach('call_agent_1', done); + group.attach('call_agent_2', running); + + vi.setSystemTime(12_000); + done.onSubagentCompleted({ resultSummary: 'done' }); + + const mixed = renderText(group); + expect(mixed).toContain('Running 2 agents (1 done, 1 running) · 12s'); + expect(mixed).toContain('explore · inspect project · 0 tools · 12s · ✓ Completed'); + expect(mixed).toContain('coder · write tests · 0 tools · 12s · Running'); + + vi.setSystemTime(15_000); + running.onSubagentFailed({ error: 'review failed' }); + + const terminal = renderText(group); + expect(terminal).toContain('2 agents finished · 15s'); + expect(terminal).toContain('✗ Failed'); + expect(terminal).toContain('Error: review failed'); + expect(terminal).not.toContain('Still working…'); + + group.dispose(); + done.dispose(); + running.dispose(); + }); + + it('renders a detached foreground subagent as backgrounded in the group, even after its ToolResult lands', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const b = createAgent('call_agent_2', 'write tests', 'coder', ui); + startAgent(a, 'call_agent_1', 'explore'); + startAgent(b, 'call_agent_2', 'coder'); + group.attach('call_agent_1', a); + group.attach('call_agent_2', b); + + // Detach `a` (Ctrl+B), then its spawn-success ToolResult lands. + a.markBackgrounded(); + a.setResult({ + tool_call_id: 'call_agent_1', + output: 'agent_id: sub_call_agent_1\nactual_subagent_type: explore\n', + is_error: false, + }); + + const out = renderText(group); + // `a` must show as backgrounded, NOT completed. + expect(out).toContain('◐ backgrounded'); + expect(out).not.toContain('✓ Completed'); + // `b` is still running. + expect(out).toContain('Running'); + + group.dispose(); + a.dispose(); + b.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts new file mode 100644 index 000000000..ad6389418 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -0,0 +1,979 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { + AgentSwarmProgressComponent, + type AgentSwarmProgressOptions, + agentSwarmDescriptionFromArgs, + agentSwarmGridHeightForTerminalRows, + agentSwarmItemsFromArgs, + agentSwarmPartialItemsCountFromArguments, + agentSwarmPartialItemsFromArguments, + calculateAgentSwarmGridLayout, +} from '#/tui/components/messages/agent-swarm-progress'; +import { AgentSwarmProgressEstimator } from '#/tui/components/messages/agent-swarm-progress-estimator'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +const DEFAULT_DESCRIPTION = 'Review changed files'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function createComponent( + options: Partial<AgentSwarmProgressOptions> = {}, +): AgentSwarmProgressComponent { + return new AgentSwarmProgressComponent({ + description: options.description ?? DEFAULT_DESCRIPTION, + requestRender: options.requestRender, + availableGridHeight: options.availableGridHeight, + }); +} + +function renderText(component: AgentSwarmProgressComponent, width = 100): string { + return strip(component.render(width).join('\n')); +} + +function renderLines(component: AgentSwarmProgressComponent, width = 100): string[] { + return renderText(component, width).split('\n'); +} + +function registerSubagents(component: AgentSwarmProgressComponent, count: number): void { + for (let index = 1; index <= count; index += 1) { + component.registerSubagent({ + agentId: `agent-${String(index)}`, + description: `${DEFAULT_DESCRIPTION} #${String(index)} (coder)`, + }); + } +} + +function startSubagents(component: AgentSwarmProgressComponent, count: number): void { + component.markInputComplete(); + for (let index = 1; index <= count; index += 1) { + component.markStarted(`agent-${String(index)}`); + } +} + +afterEach(() => { + vi.useRealTimers(); + currentTheme.setPalette(darkColors); +}); + +describe('calculateAgentSwarmGridLayout', () => { + it('uses a text grid when labels fit within the available height', () => { + const layout = calculateAgentSwarmGridLayout({ + width: 100, + height: 3, + count: 9, + }); + + expect(layout).toMatchObject({ + renderText: true, + columns: 3, + rows: 3, + }); + expect(layout.barCells).toBeGreaterThanOrEqual(6); + expect(layout.cellWidth).toBeGreaterThanOrEqual(22); + }); + + it('adds text columns before falling back to compact bars', () => { + const textLayout = calculateAgentSwarmGridLayout({ + width: 120, + height: 4, + count: 20, + }); + const compactLayout = calculateAgentSwarmGridLayout({ + width: 117, + height: 4, + count: 20, + }); + + expect(textLayout).toMatchObject({ + renderText: true, + columns: 5, + rows: 4, + }); + expect(compactLayout).toMatchObject({ + renderText: false, + columns: 5, + rows: 4, + }); + expect(compactLayout.barCells).toBeGreaterThan(textLayout.barCells); + }); + + it('uses compact bars to satisfy tight height budgets', () => { + const layout = calculateAgentSwarmGridLayout({ + width: 100, + height: 5, + count: 30, + }); + + expect(layout).toMatchObject({ + renderText: false, + columns: 6, + rows: 5, + }); + expect(layout.barCells).toBeGreaterThan(0); + }); + + it('keeps compact rows within the available height even when bars are narrow', () => { + const layout = calculateAgentSwarmGridLayout({ + width: 100, + height: 4, + count: 40, + }); + + expect(layout).toMatchObject({ + renderText: false, + columns: 10, + rows: 4, + }); + expect(layout.barCells).toBe(1); + }); + + it('keeps at least one bar cell when no rows are available', () => { + const layout = calculateAgentSwarmGridLayout({ + width: 20, + height: 0, + count: 4, + }); + + expect(layout).toMatchObject({ + renderText: false, + columns: 2, + rows: 2, + }); + expect(layout.barCells).toBeGreaterThan(0); + }); + + it('derives the grid height left inside the AgentSwarm block', () => { + expect(agentSwarmGridHeightForTerminalRows(undefined)).toBeUndefined(); + expect(agentSwarmGridHeightForTerminalRows(10)).toBe(4); + expect(agentSwarmGridHeightForTerminalRows(20, 5)).toBe(9); + expect(agentSwarmGridHeightForTerminalRows(4)).toBe(0); + }); +}); + +describe('AgentSwarmProgressComponent', () => { + it('renders an orchestrating panel before subagents spawn', () => { + const component = createComponent(); + + const output = renderText(component); + + expect(output).toContain('Agent Swarm'); + expect(output).toContain('Review changed files'); + expect(output).toContain('Orchestrating...'); + expect(output).not.toContain('01'); + }); + + it('repaints from the active palette when the theme changes', () => { + const previousLevel = chalk.level; + chalk.level = 3; // force truecolor so palette differences surface as ANSI + try { + const component = createComponent(); + const titleOf = (): string => { + const line = component.render(100).find((l) => strip(l).includes('Agent Swarm')); + if (line === undefined) throw new Error('title line not found'); + return line; + }; + const before = titleOf(); + + currentTheme.setPalette(lightColors); + const after = titleOf(); + + // Same visible text, different ANSI colours (reads currentTheme live). + expect(strip(after)).toBe(strip(before)); + expect(after).not.toBe(before); + } finally { + chalk.level = previousLevel; + } + }); + + it('renders blank padding around the block without a bottom divider', () => { + const component = createComponent(); + + registerSubagents(component, 1); + const lines = renderLines(component); + + expect(lines[0]).toBe(' '); + expect(lines[1]).toContain('Agent Swarm'); + expect(lines.at(-1)).toBe(' '); + expect(lines.at(-2)).not.toMatch(/^─+$/); + }); + + it('reserves one blank column on the right edge', () => { + const component = createComponent(); + + registerSubagents(component, 1); + startSubagents(component, 1); + + const rendered = component.render(80).map(strip); + const gridLine = rendered.find((line) => line.includes('001 [')); + + expect(rendered.every((line) => visibleWidth(line) <= 79)).toBe(true); + expect(rendered.some((line) => line.includes('Agent Swarm'))).toBe(true); + expect(gridLine).toBeDefined(); + expect(visibleWidth(gridLine ?? '')).toBeLessThanOrEqual(79); + }); + + it('renders spawned subagents as queued rows without empty progress bars', () => { + const component = createComponent(); + + registerSubagents(component, 2); + + const output = renderText(component); + + expect(output).toContain('001 Queued...'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('001 ['); + expect(output).not.toContain('002 ['); + expect(output).not.toContain('agents=2'); + }); + + it('fits three queued columns with the narrower gap and minimum cell width', () => { + const component = createComponent(); + + registerSubagents(component, 3); + + const lines = renderLines(component, 97); + const queuedLine = lines.find((line) => line.includes('001 Queued...')); + + expect(queuedLine).toBeDefined(); + expect(queuedLine).toContain('002 Queued...'); + expect(queuedLine).toContain('003 Queued...'); + }); + + it('omits subagent text when the compact grid is needed to fit available height', () => { + const component = createComponent({ + availableGridHeight: () => 5, + }); + + registerSubagents(component, 30); + startSubagents(component, 30); + + const lines = renderLines(component, 102); + const gridLines = lines.filter((line) => /\b\d{3} \[/.test(line)); + + expect(gridLines).toHaveLength(5); + expect(gridLines[0]).toContain('001 ['); + expect(gridLines[0]).toContain('006 ['); + expect(gridLines.join('\n')).not.toContain('Running'); + }); + + it('keeps streamed pending items as text even when compact layout is selected', () => { + const component = createComponent({ + availableGridHeight: () => 5, + }); + + component.updateArgs({ + items: Array.from({ length: 30 }, (_item, index) => `f${String(index + 1)}.ts`), + }); + + const output = renderText(component, 102); + + expect(output).toContain('001 f1.ts'); + expect(output).toContain('006 f6.ts'); + expect(output).not.toContain('001 ['); + }); + + it('prefixes a cancelled running subagent label with the aborted mark without changing the text', () => { + const component = createComponent(); + + registerSubagents(component, 1); + startSubagents(component, 1); + component.appendModelDelta({ agentId: 'agent-1', delta: 'Inspecting src/a.ts' }); + component.markCancelled('agent-1'); + + const output = renderText(component); + const cellLine = output.split('\n').find((line) => line.includes('001 [')); + + expect(cellLine).toBeDefined(); + expect(cellLine).toContain('⊘ Inspecting src/a.ts'); + expect(cellLine).not.toContain('⊘ Aborted.'); + }); + + it('shows a cancelled label without a progress bar for queued subagents', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markInputComplete(); + component.markCancelled('agent-1'); + + const output = renderText(component); + const cellLine = output.split('\n').find((line) => line.includes('001 ')); + + expect(cellLine).toBeDefined(); + expect(cellLine).toContain('⊘ Cancelled.'); + expect(cellLine).not.toContain('['); + expect(cellLine).not.toContain('⊘ Aborted.'); + }); + + it('renders terminal marks against compact bars when subagent text is hidden', () => { + const component = createComponent({ + availableGridHeight: () => 5, + }); + + registerSubagents(component, 30); + startSubagents(component, 30); + component.markCompleted('agent-1'); + component.markFailed('agent-2', 'Agent timed out'); + component.markCancelled('agent-3'); + + const lines = renderLines(component, 102); + const gridLine = lines.find((line) => line.includes('001 [')); + + expect(gridLine).toBeDefined(); + expect(gridLine).toMatch(/001 \[[^\]]+\]✓ +002 \[[^\]]+\]✗ +003 \[[^\]]+\]⊘/); + expect(gridLine).not.toContain('Completed'); + expect(gridLine).not.toContain('Failed'); + expect(gridLine).not.toContain('Aborted'); + }); + + it('advances from queued when a subagent tool call starts and marks terminal states', () => { + const component = createComponent(); + + registerSubagents(component, 2); + component.recordToolCall({ agentId: 'agent-1', toolCallId: 'call-read' }); + + let output = renderText(component); + expect(output).toContain('001 ['); + expect(output).toContain('Running'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('002 ['); + + component.markCompleted('agent-1'); + component.markFailed('agent-2'); + + output = renderText(component); + expect(output).toContain('001 ['); + expect(output).toContain('✓'); + expect(output).toContain('Completed.'); + expect(output).toContain('002 ['); + expect(output).toContain('Failed'); + }); + + it('renders completed subagent output with a success mark', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markCompleted('agent-1', 'Reviewed imports and found no regressions'); + + const output = renderText(component); + + expect(output).toContain('✓ Reviewed imports and found no regressions'); + expect(output).toContain('Completed.'); + }); + + it('renders failure details from live subagent failures', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markFailed('agent-1', 'Provider request failed\nRetry budget exhausted'); + + const output = renderText(component); + + expect(output).toContain('✗ Provider request failed Retry budget exhausted'); + expect(output).not.toContain('Failed:'); + }); + + it('renders suspended subagents as rate limited and clears the state when they start again', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markStarted('agent-1'); + component.markSuspended({ + agentId: 'agent-1', + reason: 'Provider rate limit; subagent requeued for retry.', + }); + + let output = renderText(component); + expect(output).toContain('Rate limited...'); + expect(output).not.toContain('Queued...'); + expect(output).not.toContain('Provider rate limit'); + expect(output).not.toContain('Failed'); + + component.markStarted('agent-1'); + + output = renderText(component); + expect(output).toContain('Running'); + expect(output).not.toContain('Rate limited...'); + }); + + it('renders rate-limited subagents as cancelled when cancelled', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markStarted('agent-1'); + component.markSuspended({ + agentId: 'agent-1', + reason: 'Provider rate limit; subagent requeued for retry.', + }); + component.markCancelled('agent-1'); + + const cellLine = renderLines(component) + .find((line) => line.includes('001 [')); + + expect(cellLine).toBeDefined(); + expect(cellLine).toContain('⊘ Cancelled.'); + expect(cellLine).not.toContain('Rate limited...'); + }); + + it('renders failure details from AgentSwarm result output', () => { + const component = createComponent(); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts'], + }); + component.applyResult([ + '<agent_swarm_result>', + '<summary>failed: 1</summary>', + '<subagent index="1" agent_id="agent-1" outcome="failed">Agent timed out after 30s.</subagent>', + '</agent_swarm_result>', + ].join('\n')); + + const output = renderText(component); + + expect(output).toContain('✗ Agent timed out after 30s.'); + expect(output).not.toContain('Failed:'); + }); + + it('applies no-index AgentSwarm result statuses by tag order', () => { + const component = createComponent(); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }); + const applied = component.applyResult([ + '<agent_swarm_result>', + '<summary>failed: 1, aborted: 1</summary>', + '<subagent agent_id="agent-1" item="src/a.ts" outcome="failed">' + + 'Agent timed out after 30s.</subagent>', + '<subagent agent_id="agent-2" item="src/b.ts" outcome="aborted">' + + 'User interrupted.</subagent>', + '</agent_swarm_result>', + ].join('\n')); + + const output = renderText(component, 120); + + expect(applied).toBe(true); + expect(output).toContain('✗ Agent timed out after 30s.'); + expect(output).toContain('⊘ Cancelled.'); + expect(output).not.toContain('002 ['); + expect(output).not.toContain('Completed.'); + }); + + it('strips nested AgentSwarm prefixes from failure details', () => { + const component = createComponent(); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts'], + }); + component.applyResult([ + '<agent_swarm_result>', + '<summary>failed: 1</summary>', + '<subagent index="1" agent_id="agent-1" outcome="failed">agent_swarm: failed', + 'description: Nested review', + 'items: 1', + 'completed: 0', + 'failed: 1', + '', + '[agent 1]', + 'status: failed', + '', + 'subagent error: [provider.rate_limit] 429 request reached user+model max RPM.</subagent>', + '</agent_swarm_result>', + ].join('\n')); + + const output = renderText(component, 120); + + expect(output).toContain('✗ [provider.rate_limit] 429 request reached user+model max RPM.'); + expect(output).not.toContain('agent_swarm:'); + expect(output).not.toContain('Failed:'); + }); + + it('renders completed summaries from AgentSwarm result output', () => { + const component = createComponent(); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts'], + }); + component.applyResult([ + '<agent_swarm_result>', + '<summary>completed: 1</summary>', + '<subagent index="1" agent_id="agent-1" outcome="completed">Reviewed src/a.ts and confirmed imports are stable.</subagent>', + '</agent_swarm_result>', + ].join('\n')); + + const output = renderText(component); + + expect(output).toContain('✓ Reviewed src/a.ts and confirmed imports are stable.'); + expect(output).toContain('Completed.'); + }); + + it('shows completed total status when only some subagents fail', () => { + const component = createComponent(); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }); + component.applyResult([ + '<agent_swarm_result>', + '<summary>completed: 1, failed: 1</summary>', + '<subagent index="1" agent_id="agent-1" outcome="completed">Reviewed src/a.ts and confirmed imports are stable.</subagent>', + '<subagent index="2" agent_id="agent-2" outcome="failed">Agent timed out after 30s.</subagent>', + '</agent_swarm_result>', + ].join('\n')); + + const output = renderText(component, 120); + const totalStatusLine = output.split('\n').find((line) => line.includes('Completed.')); + + expect(totalStatusLine).toBeDefined(); + expect(totalStatusLine).not.toContain('Failed.'); + expect(output).toContain('✓ Reviewed src/a.ts'); + expect(output).toContain('✗ Agent timed out after 30s.'); + }); + + it('uses the latest assistant line as completed output when no summary is available', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.appendModelDelta({ + agentId: 'agent-1', + delta: 'Reviewing src/a.ts\nImports look stable', + }); + component.markCompleted('agent-1'); + + const output = renderText(component); + + expect(output).toContain('✓ Imports look stable'); + expect(output).toContain('Completed.'); + }); + + it('shows latest assistant text after the progress bar with ellipsis truncation', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markInputComplete(); + component.recordToolCall({ agentId: 'agent-1', toolCallId: 'call-read' }); + component.appendModelDelta({ + agentId: 'agent-1', + delta: 'Reviewing src/a.ts and checking imports for regressions in detail', + }); + + const output = renderText(component, 44); + expect(output).toContain('001 ['); + expect(output).toContain('Reviewing'); + expect(output).toContain('…'); + }); + + it('uses natural status label width for prompting text', () => { + const prompting = createComponent({ + description: '', + }); + prompting.updateArgs({}, { + streamingArguments: '{"prompt_template":"Review the changed TypeScript files carefully', + }); + + const promptLine = renderLines(prompting, 80) + .find((line) => line.includes('Prompting...')); + expect(promptLine).toBeDefined(); + + const working = createComponent(); + registerSubagents(working, 1); + startSubagents(working, 1); + + const workingLine = renderLines(working, 80) + .find((line) => line.includes('Working...')); + expect(workingLine).toBeDefined(); + + const promptTextIndex = promptLine?.indexOf('Review the changed') ?? -1; + const progressBarIndex = workingLine?.indexOf('━') ?? -1; + expect(promptTextIndex).toBeGreaterThan(0); + expect(progressBarIndex).toBeGreaterThan(0); + expect(promptTextIndex).toBe(visibleWidth(' Prompting... ')); + expect(progressBarIndex).toBe(visibleWidth(' Working... ')); + }); + + it('renders the activity spinner before the total status line', () => { + const component = createComponent(); + + registerSubagents(component, 1); + startSubagents(component, 1); + component.setActivitySpinnerText(() => '🌗'); + + const statusLine = renderLines(component, 80) + .find((line) => line.includes('Working...')); + + expect(statusLine).toBeDefined(); + expect(statusLine?.startsWith(' 🌗 Working...')).toBe(true); + }); + + it('keeps a two-cell placeholder after the AgentSwarm tool call ends', () => { + const component = createComponent(); + + registerSubagents(component, 1); + startSubagents(component, 1); + component.setActivitySpinnerText(() => '🌗'); + component.markToolCallEnded(); + component.setActivitySpinnerText(() => '🌘'); + + const statusLine = renderLines(component, 80) + .find((line) => line.includes('Working...')); + + expect(statusLine).toBeDefined(); + expect(statusLine?.startsWith(' Working...')).toBe(true); + expect(statusLine).not.toContain('🌗'); + expect(statusLine).not.toContain('🌘'); + }); + + it('renders terminal total status lines after the tool call ends', () => { + const completed = createComponent(); + registerSubagents(completed, 1); + completed.markInputComplete(); + completed.markCompleted('agent-1', 'Imports are stable'); + completed.markToolCallEnded(); + + expect(renderLines(completed, 80).some((line) => line.startsWith(' ✓ Completed.'))).toBe(true); + + const failed = createComponent(); + registerSubagents(failed, 1); + failed.markInputComplete(); + failed.markFailed('agent-1', 'Agent timed out'); + failed.markToolCallEnded(); + + expect(renderLines(failed, 80).some((line) => line.startsWith(' ✗ Failed.'))).toBe(true); + + const aborted = createComponent(); + registerSubagents(aborted, 1); + aborted.markInputComplete(); + aborted.markStarted('agent-1'); + aborted.markActiveCancelled(); + aborted.markToolCallEnded(); + + const abortedOutput = renderText(aborted, 80); + expect(abortedOutput).toContain('⊘ Aborted.'); + expect(abortedOutput).not.toContain('Cancelled.'); + }); + + it('reserves one trailing cell for prompting streaming text', () => { + const prompting = createComponent({ + description: '', + }); + prompting.updateArgs({}, { + streamingArguments: '{"prompt_template":"Review every changed TypeScript file and summarize regressions carefully before reporting', + }); + + const promptLine = renderLines(prompting, 50) + .find((line) => line.includes('Prompting...')); + + expect(promptLine).toBeDefined(); + expect(visibleWidth(promptLine ?? '')).toBeLessThan(50); + }); + + it('renders boosted fractional progress ticks without leaking undefined cells', () => { + vi.useFakeTimers(); + const component = createComponent(); + + vi.setSystemTime(0); + registerSubagents(component, 1); + component.markStarted('agent-1'); + for (let index = 0; index < 10; index += 1) { + vi.setSystemTime(1_000 + index * 1_000); + component.recordToolCall({ agentId: 'agent-1', toolCallId: `done-${index}` }); + } + vi.setSystemTime(40_000); + component.markCompleted('agent-1'); + + component.registerSubagent({ + agentId: 'agent-2', + description: `${DEFAULT_DESCRIPTION} #2 (coder)`, + }); + component.markStarted('agent-2'); + for (let index = 0; index < 3; index += 1) { + vi.setSystemTime(45_000 + index * 5_000); + component.recordToolCall({ agentId: 'agent-2', toolCallId: `running-${index}` }); + } + + vi.setSystemTime(60_000); + component.render(100); + vi.setSystemTime(61_000); + const output = renderText(component); + + expect(output).toContain('002 ['); + expect(output).not.toContain('undefined'); + }); + + it('creates pending rows from streamed args items', () => { + const component = createComponent({ + description: '', + }); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }); + const output = renderText(component); + + expect(output).toContain('Agent Swarm'); + expect(output).toContain('Review changed files'); + expect(output).toContain('001 src/a.ts'); + expect(output).toContain('002 src/b.ts'); + }); + + it('creates pending rows from resume_agent_ids before streamed args items', () => { + const component = createComponent({ + description: '', + }); + + component.updateArgs({ + description: 'Review changed files', + resume_agent_ids: { + 'agent-old-1': 'continue', + 'agent-old-2': 'continue', + }, + items: ['src/a.ts'], + }); + const output = renderText(component); + + expect(output).toContain('001 (resumed)'); + expect(output).toContain('002 (resumed)'); + expect(output).toContain('003 src/a.ts'); + expect(output).not.toContain('001 ['); + }); + + it('counts partial items before each string is complete', () => { + expect( + agentSwarmPartialItemsCountFromArguments('{"items":["src/a.ts","src/b'), + ).toBe(2); + expect( + agentSwarmPartialItemsCountFromArguments('{"items":["src/a.ts","src/\\"b.ts","src/c'), + ).toBe(3); + expect( + agentSwarmPartialItemsFromArguments('{"items":["src/a.ts","src/\\"b.ts","src/c'), + ).toEqual(['src/a.ts', 'src/"b.ts', 'src/c']); + }); + + it('creates pending rows from partial streaming arguments', () => { + const component = createComponent({ + description: '', + }); + + component.updateArgs({}, { + streamingArguments: '{"description":"Review changed files","items":["src/a.ts","src/b', + }); + const output = renderText(component); + + expect(output).toContain('001 src/a.ts'); + expect(output).toContain('002 src/b'); + }); + + it('creates pending rows from partial streaming resume_agent_ids', () => { + const component = createComponent({ + description: '', + }); + + component.updateArgs({}, { + streamingArguments: + '{"description":"Resume reviews","resume_agent_ids":{"agent-old-1":"continue","agent-old-2":"cont', + }); + const output = renderText(component); + + expect(output).toContain('001 (resumed)'); + expect(output).toContain('002 (resumed)'); + expect(output).not.toContain('003'); + }); + + it('adds subagent rows incrementally as spawn events arrive', () => { + const component = createComponent(); + + registerSubagents(component, 1); + let output = renderText(component); + expect(output).toContain('001 Queued...'); + expect(output).not.toContain('001 ['); + expect(output).not.toContain('002'); + + component.registerSubagent({ + agentId: 'agent-2', + description: `${DEFAULT_DESCRIPTION} #2 (coder)`, + }); + output = renderText(component); + expect(output).toContain('001 Queued...'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('001 ['); + expect(output).not.toContain('002 ['); + + component.markInputComplete(); + output = renderText(component); + expect(output).toContain('001 Queued...'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('001 ['); + }); + + it('maps subagents by structured swarm indexes when descriptions include issue references', () => { + const component = createComponent({ + description: 'Fix #123', + }); + + component.updateArgs({ + description: 'Fix #123', + items: ['src/a.ts', 'src/b.ts'], + }); + component.registerSubagent({ + agentId: 'agent-2', + description: 'Fix #123 #2 (coder)', + swarmIndex: 2, + }); + component.markStarted('agent-2'); + + const output = renderText(component); + + expect(output).toContain('001 src/a.ts'); + expect(output).toContain('002 ['); + expect(output).not.toContain('123 ['); + }); + + it('extracts description and item list from AgentSwarm args', () => { + const args = { + description: 'Review changed files', + items: ['src/a.ts', 123], + }; + + expect(agentSwarmDescriptionFromArgs(args)).toBe('Review changed files'); + expect(agentSwarmItemsFromArgs(args)).toEqual(['src/a.ts', '123']); + }); +}); + +describe('AgentSwarmProgressEstimator', () => { + it('counts a started subagent as one progress tick before tool calls arrive', () => { + const estimator = new AgentSwarmProgressEstimator(); + + estimator.markStarted('001', 0); + const estimate = estimator.estimate({ + memberKey: '001', + phase: 'running', + capacityTicks: 56, + nowMs: 1_000, + }); + + expect(estimate.rawTicks).toBe(1); + expect(estimate.displayTicks).toBe(1); + }); + + it('keeps raw tool-call ticks without completed samples and deduplicates calls', () => { + const estimator = new AgentSwarmProgressEstimator(); + + estimator.markStarted('001', 0); + expect( + estimator.recordToolCall({ memberKey: '001', toolCallId: 'read', nowMs: 1_000 }), + ).toEqual({ accepted: true, rawTicks: 2 }); + expect( + estimator.recordToolCall({ memberKey: '001', toolCallId: 'read', nowMs: 2_000 }), + ).toEqual({ accepted: false, rawTicks: 2 }); + + const estimate = estimator.estimate({ + memberKey: '001', + phase: 'running', + capacityTicks: 56, + nowMs: 3_000, + }); + + expect(estimate.rawTicks).toBe(2); + expect(estimate.displayTicks).toBe(2); + expect(estimate.estimatedTotalToolCalls).toBeUndefined(); + expect(estimate.boosted).toBe(false); + }); + + it('does not catch up progress using queued wait before start', () => { + const estimator = new AgentSwarmProgressEstimator({ + catchupTimeMs: 1_000, + maxCatchupTicksPerSecond: 100, + }); + + estimator.markStarted('001', 0); + for (let index = 0; index < 10; index += 1) { + estimator.recordToolCall({ + memberKey: '001', + toolCallId: `done-${index}`, + nowMs: 1_000 + index * 1_000, + }); + } + estimator.markCompleted('001', 40_000); + + estimator.ensureMember('002', 0); + estimator.estimate({ + memberKey: '002', + phase: 'queued', + capacityTicks: 56, + nowMs: 0, + }); + estimator.markStarted('002', 60_000); + + const estimate = estimator.estimate({ + memberKey: '002', + phase: 'running', + capacityTicks: 56, + nowMs: 60_000, + }); + + expect(estimate.rawTicks).toBe(1); + expect(estimate.displayTicks).toBe(1); + expect(estimate.targetTicks).toBeGreaterThan(1); + expect(estimate.boosted).toBe(false); + }); + + it('smoothly catches up toward completed-agent estimates without jumping to them', () => { + const estimator = new AgentSwarmProgressEstimator({ + catchupTimeMs: 1_000, + maxCatchupTicksPerSecond: 100, + }); + + estimator.markStarted('001', 0); + for (let index = 0; index < 10; index += 1) { + estimator.recordToolCall({ + memberKey: '001', + toolCallId: `done-${index}`, + nowMs: 1_000 + index * 1_000, + }); + } + estimator.markCompleted('001', 40_000); + + estimator.markStarted('002', 0); + for (let index = 0; index < 3; index += 1) { + estimator.recordToolCall({ + memberKey: '002', + toolCallId: `running-${index}`, + nowMs: 5_000 + index * 5_000, + }); + } + + const first = estimator.estimate({ + memberKey: '002', + phase: 'running', + capacityTicks: 56, + nowMs: 20_000, + }); + + expect(first.rawTicks).toBe(4); + expect(first.displayTicks).toBe(4); + expect(first.estimatedTotalToolCalls).toBeGreaterThan(4); + expect(first.targetTicks).toBeGreaterThan(4); + expect(estimator.hasPendingCatchup()).toBe(true); + + const second = estimator.estimate({ + memberKey: '002', + phase: 'running', + capacityTicks: 56, + nowMs: 21_000, + }); + + expect(second.displayTicks).toBeGreaterThan(4); + expect(second.displayTicks).toBeLessThan(second.targetTicks ?? 0); + expect(second.boosted).toBe(true); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index 47d0836a9..e078e6dd2 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -1,13 +1,21 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { Markdown, visibleWidth } from '@moonshot-ai/pi-tui'; +import * as cliHighlight from 'cli-highlight'; +import { describe, expect, it, vi } from 'vitest'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; +vi.mock('cli-highlight', async () => { + const actual = await vi.importActual<typeof import('cli-highlight')>('cli-highlight'); + return { + ...actual, + highlight: vi.fn(actual.highlight), + }; +}); + function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -19,7 +27,7 @@ describe('AssistantMessageComponent', () => { }); it('uses the stable status bullet without stealing content width', () => { - const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + const component = new AssistantMessageComponent(); component.updateContent('abcdef'); @@ -28,10 +36,21 @@ describe('AssistantMessageComponent', () => { expect(visibleWidth(lines[1] ?? '')).toBe(8); }); + it('keeps assistant lines within very narrow widths', () => { + const component = new AssistantMessageComponent(); + component.updateContent('abcdef'); + + for (const width of [1, 2, 4, 10, 39]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + it('renders unknown markdown fence languages as plain text without stderr noise', () => { const stderr = captureProcessWrite('stderr'); try { - const theme = createMarkdownTheme(darkColors); + const theme = createMarkdownTheme(); expect(theme.highlightCode?.('hello\nworld', 'abcxyz')).toEqual(['hello', 'world']); expect(stderr.text()).not.toContain('Could not find the language'); } finally { @@ -40,7 +59,7 @@ describe('AssistantMessageComponent', () => { }); it('preserves literal hook result XML in normal assistant text', () => { - const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + const component = new AssistantMessageComponent(); component.updateContent('<hook_result hook_event="UserPromptSubmit">\n{}\n</hook_result>'); @@ -50,4 +69,60 @@ describe('AssistantMessageComponent', () => { expect(text).toContain('</hook_result>'); expect(text).not.toContain('UserPromptSubmit hook'); }); + + it('reuses the same Markdown child across streaming text updates', () => { + const component = new AssistantMessageComponent(); + + component.updateContent('hello'); + const first = (component as any).contentContainer.children[0]; + expect(first).toBeInstanceOf(Markdown); + + component.updateContent('hello world'); + const second = (component as any).contentContainer.children[0]; + + expect(second).toBe(first); + expect(strip(component.render(80).join('\n'))).toContain('hello world'); + }); + + it('does not recreate the Markdown child when the text is unchanged', () => { + const component = new AssistantMessageComponent(); + + component.updateContent('hello'); + const first = (component as any).contentContainer.children[0]; + expect(first).toBeInstanceOf(Markdown); + + component.updateContent('hello'); + const second = (component as any).contentContainer.children[0]; + + expect(second).toBe(first); + }); + + it('rebuilds the Markdown child when transient changes so final render can highlight code', () => { + const component = new AssistantMessageComponent(); + const code = '```ts\nconst x = 1\n```'; + + component.updateContent(code, { transient: true }); + const streaming = (component as any).contentContainer.children[0]; + expect(streaming).toBeInstanceOf(Markdown); + + component.updateContent(code, { transient: false }); + const finalized = (component as any).contentContainer.children[0]; + expect(finalized).toBeInstanceOf(Markdown); + + expect(finalized).not.toBe(streaming); + }); + + it('skips synchronous syntax highlighting in transient markdown themes', () => { + const highlightSpy = vi.mocked(cliHighlight.highlight); + highlightSpy.mockClear(); + const streamingTheme = createMarkdownTheme({ transient: true }); + const finalTheme = createMarkdownTheme(); + const code = 'const x = 1'; + + expect(streamingTheme.highlightCode?.(code, 'typescript')).toEqual([code]); + expect(highlightSpy).not.toHaveBeenCalled(); + + finalTheme.highlightCode?.(code, 'typescript'); + expect(highlightSpy).toHaveBeenCalled(); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts index 89def6690..e8f395ec8 100644 --- a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts @@ -1,8 +1,8 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,30 +10,21 @@ function strip(text: string): string { describe('BackgroundAgentStatusComponent', () => { it('renders started/completed with the shared bullet and failed with a red x marker', () => { - const started = new BackgroundAgentStatusComponent( - { - phase: 'started', - headline: 'explore agent started in background', - detail: 'Explore project structure', - }, - darkColors, - ); - const completed = new BackgroundAgentStatusComponent( - { - phase: 'completed', - headline: 'explore agent completed in background', - detail: 'Explore project structure', - }, - darkColors, - ); - const failed = new BackgroundAgentStatusComponent( - { - phase: 'failed', - headline: 'explore agent failed in background', - detail: 'Explore project structure · boom', - }, - darkColors, - ); + const started = new BackgroundAgentStatusComponent({ + phase: 'started', + headline: 'explore agent started in background', + detail: 'Explore project structure', + }); + const completed = new BackgroundAgentStatusComponent({ + phase: 'completed', + headline: 'explore agent completed in background', + detail: 'Explore project structure', + }); + const failed = new BackgroundAgentStatusComponent({ + phase: 'failed', + headline: 'explore agent failed in background', + detail: 'Explore project structure · boom', + }); const startedLines = started.render(120).map((line) => strip(line).trimEnd()); const completedLines = completed.render(120).map((line) => strip(line).trimEnd()); @@ -53,4 +44,18 @@ describe('BackgroundAgentStatusComponent', () => { '✗ explore agent failed in background (Explore project structure · boom)', ); }); + + it('keeps status lines within very narrow widths', () => { + const component = new BackgroundAgentStatusComponent({ + phase: 'started', + headline: 'explore agent started in background', + detail: 'Explore project structure', + }); + + for (const width of [1, 2, 4, 10, 39]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts new file mode 100644 index 000000000..f098cc931 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts @@ -0,0 +1,122 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { SwarmModeMarkerComponent } from '#/tui/components/messages/swarm-markers'; +import { buildGoalMarker, GoalMarkerComponent } from '#/tui/components/messages/goal-markers'; +import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; + +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(lines: string[]): string { + return lines.join('\n').replaceAll(ANSI_SGR, ''); +} + +describe('buildGoalMarker', () => { + it('builds lifecycle markers for paused / resumed / blocked', () => { + const paused = buildGoalMarker({ kind: 'lifecycle', status: 'paused' } as GoalChange, false); + const resumed = buildGoalMarker({ kind: 'lifecycle', status: 'active' } as GoalChange, false); + const blocked = buildGoalMarker({ kind: 'lifecycle', status: 'blocked' } as GoalChange, false); + expect(strip(paused!.render(80))).toContain('Goal paused'); + expect(strip(resumed!.render(80))).toContain('Goal resumed'); + expect(strip(blocked!.render(80))).toContain('Goal blocked'); + }); + + it('renders user interruption pause and user resume as prominent markers', () => { + const paused = buildGoalMarker( + { kind: 'lifecycle', status: 'paused', reason: 'Paused after interruption' } as GoalChange, + false, + 'runtime', + ); + const resumed = buildGoalMarker( + { kind: 'lifecycle', status: 'active' } as GoalChange, + false, + 'user', + ); + + expect(strip(paused!.render(80))).toBe("\n● Goal paused due to user's interruption"); + expect(strip(resumed!.render(80))).toBe('\n● Goal resumed by the user.'); + expect(strip([...paused!.render(80), ...resumed!.render(80)])).toBe( + "\n● Goal paused due to user's interruption\n\n● Goal resumed by the user.", + ); + }); + + it('does not repeat paused for runtime pause reasons', () => { + const marker = buildGoalMarker( + { kind: 'lifecycle', status: 'paused', reason: 'Paused after runtime error: socket hang up' } as GoalChange, + false, + 'runtime', + ); + + expect(strip(marker!.render(80))).toBe('\n● Goal paused after runtime error: socket hang up'); + }); + + it('keeps long provider pause markers within the terminal width', () => { + const reason = + 'Paused after provider API error: 400 {"error":{"message":"request id: 456043b9-6491-11f1-9425-2221bb1af97c, \\"thinking.enabled\\" is not supported for this model. Use \\"thinking.adaptive\\" and \\"output_config.effort\\" to control thinking behavior.","type":"invalid_request_error"}}'; + const marker = buildGoalMarker( + { kind: 'lifecycle', status: 'paused', reason } as GoalChange, + false, + 'runtime', + ); + + const width = 80; + expect(strip(marker!.render(width))).toContain('Goal paused after provider API error'); + for (const line of marker!.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('attributes model pause and resume markers to the agent', () => { + const paused = buildGoalMarker( + { kind: 'lifecycle', status: 'paused' } as GoalChange, + false, + 'model', + ); + const resumed = buildGoalMarker( + { kind: 'lifecycle', status: 'active' } as GoalChange, + false, + 'model', + ); + + expect(strip(paused!.render(80))).toBe('\n● Goal paused by the agent.'); + expect(strip(resumed!.render(80))).toBe('\n● Goal resumed by the agent.'); + }); + + it('returns null for a completion change (it posts its own message)', () => { + expect( + buildGoalMarker({ kind: 'completion', status: 'complete' } as GoalChange, false), + ).toBeNull(); + }); +}); + +describe('GoalMarkerComponent', () => { + it('hides the reason until expanded, with a ctrl+o hint', () => { + const marker = new GoalMarkerComponent('Goal: no progress', 'still spinning', 'warning'); + const collapsed = strip(marker.render(80)); + expect(collapsed).toContain('Goal: no progress'); + expect(collapsed).toContain('(ctrl+o)'); + expect(collapsed).not.toContain('still spinning'); + + marker.setExpanded(true); + const expanded = strip(marker.render(80)); + expect(expanded).toContain('still spinning'); + expect(expanded).not.toContain('(ctrl+o)'); + }); + + it('renders a single line when there is no reason', () => { + const marker = new GoalMarkerComponent('Goal paused', undefined, 'textDim'); + expect(marker.render(80)).toHaveLength(1); + expect(strip(marker.render(80))).not.toContain('(ctrl+o)'); + }); +}); + +describe('SwarmModeMarkerComponent', () => { + it('keeps marker lines within very narrow widths', () => { + const marker = new SwarmModeMarkerComponent('active'); + + for (const width of [1, 2, 10, 39]) { + for (const line of marker.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts new file mode 100644 index 000000000..ed69d3a85 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -0,0 +1,194 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + buildGoalReportLines, + GoalCompletionMessageComponent, + GoalSetMessageComponent, + GoalStatusMessageComponent, + UpcomingGoalAddedMessageComponent, + goalPanelTitle, +} from '#/tui/components/messages/goal-panel'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { darkColors } from '#/tui/theme/colors'; +import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; + +const previousChalkLevel = chalk.level; +beforeAll(() => { + chalk.level = 3; +}); +afterAll(() => { + chalk.level = previousChalkLevel; +}); + +const ANSI_SGR = /\u001B\[[0-9;]*m/g; +function strip(lines: string[]): string { + return lines.join('\n').replaceAll(ANSI_SGR, ''); +} + +function goal(overrides: Partial<GoalSnapshot> = {}): GoalSnapshot { + return { + goalId: 'g1', + objective: 'Ship the goal status box', + status: 'active', + turnsUsed: 7, + tokensUsed: 128_400, + wallClockMs: 252_000, // 4m12s + budget: { + turnBudget: null, + tokenBudget: null, + wallClockBudgetMs: null, + }, + ...overrides, + } as GoalSnapshot; +} + +function lines(g: GoalSnapshot): string { + return strip(buildGoalReportLines(g)); +} + +describe('buildGoalReportLines', () => { + it('renders the objective as a blockquote and key counters for an active goal', () => { + const out = lines(goal()); + expect(out).toContain('▌ Ship the goal status box'); + expect(out).toContain('Running'); + expect(out).toContain('4m 12s'); + expect(out).toContain('Turns'); + expect(out).toContain('128.4k'); // formatTokenCount + }); + + it('shows a no-stop-condition note for an unbounded active goal', () => { + expect(lines(goal())).toContain('No stop condition — runs until evaluated complete.'); + }); + + it('shows a Stop row with progress when a turn budget is set', () => { + const out = lines(goal({ budget: { turnBudget: 20, tokenBudget: null, wallClockBudgetMs: null } } as Partial<GoalSnapshot>)); + expect(out).toContain('Stop'); + expect(out).toContain('after 20 turns (7/20)'); + expect(out).not.toContain('No stop condition'); + }); + + it('includes the completion criterion when present', () => { + const out = lines(goal({ completionCriterion: 'tests pass' })); + expect(out).toContain('✓ tests pass'); + }); + + it('renders a terminal goal with a Status row and no Stop row', () => { + const out = lines(goal({ status: 'complete', terminalReason: 'all done' })); + expect(out).toContain('Status'); + expect(out).toContain('complete — all done'); + expect(out).not.toContain('No stop condition'); + expect(out).not.toMatch(/^Stop/m); + }); + + it('shows the reason for a paused goal when one exists', () => { + const out = lines(goal({ status: 'paused', terminalReason: 'Paused after provider rate limit' })); + expect(out).toContain('Status'); + expect(out).toContain('paused — Paused after provider rate limit'); + }); + + it('titles the box with the status', () => { + expect(goalPanelTitle(goal())).toBe(' Goal · active '); + expect(goalPanelTitle(goal({ status: 'complete' }))).toBe(' Goal · complete '); + }); + + it('truncates a very long objective with an ellipsis', () => { + const long = 'word '.repeat(200).trim(); + const out = lines(goal({ objective: long })); + expect(out).toContain('…'); + }); +}); + +describe('GoalSetMessageComponent', () => { + it('renders a marker-style lifecycle line without repeating the objective', () => { + const rendered = new GoalSetMessageComponent().render(60); + // Leading blank line separates it from the line above. + expect(rendered[0]).toBe(''); + expect(strip(rendered)).toBe('\n● Goal set'); + }); + + it('renders the marker and label in the primary accent', () => { + const rendered = new GoalSetMessageComponent().render(60); + + expect(rendered[1]).toBe( + chalk.hex(darkColors.primary).bold(STATUS_BULLET) + + chalk.hex(darkColors.primary).bold('Goal set'), + ); + }); + + it('keeps the lifecycle line within narrow widths', () => { + for (const width of [39, 20, 10, 4]) { + for (const line of new GoalSetMessageComponent().render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); + +describe('UpcomingGoalAddedMessageComponent', () => { + it('renders the upcoming-goal confirmation like the goal-set lifecycle line', () => { + const rendered = new UpcomingGoalAddedMessageComponent().render(80); + + expect(strip(rendered)).toBe( + '\n● Upcoming goal added. It will start after the current goal is complete.', + ); + expect(rendered[1]).toBe( + chalk.hex(darkColors.primary).bold(STATUS_BULLET) + + chalk.hex(darkColors.primary).bold( + 'Upcoming goal added. It will start after the current goal is complete.', + ), + ); + }); + + it('wraps the upcoming-goal confirmation within narrow widths', () => { + for (const width of [39, 20, 10, 4]) { + for (const line of new UpcomingGoalAddedMessageComponent().render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); + +describe('GoalStatusMessageComponent', () => { + it('adds a blank line before the status box', () => { + const rendered = new GoalStatusMessageComponent(goal()).render(80); + + expect(rendered[0]).toBe(''); + expect(strip([rendered[1]!])).toContain('╭ Goal · active '); + }); + + it('wraps objective blockquotes without clipping them at 80 columns', () => { + const rendered = new GoalStatusMessageComponent( + goal({ objective: 'word '.repeat(30).trim() }), + ).render(80); + + expect(strip(rendered)).not.toContain('...'); + }); + + it('keeps the status box within narrow widths', () => { + const rendered = new GoalStatusMessageComponent(goal({ objective: '管理飞书日历的技能描述 '.repeat(4).trim() })); + + for (const width of [39, 24, 20, 10]) { + for (const line of rendered.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); + +describe('GoalCompletionMessageComponent', () => { + it('renders the completion headline in green and keeps the stats line indented', () => { + const message = '✓ Goal complete.\nWorked 1 turn over 2m28s, using 766.9k tokens.'; + const rendered = new GoalCompletionMessageComponent(message).render(80); + + expect(rendered[0]).toBe(''); + expect(rendered[1]?.trimEnd()).toBe( + chalk.hex(darkColors.success).bold(STATUS_BULLET) + + chalk.hex(darkColors.success).bold('✓ Goal complete.'), + ); + expect(strip([rendered[2]!]).trimEnd()).toBe( + ' Worked 1 turn over 2m28s, using 766.9k tokens.', + ); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts new file mode 100644 index 000000000..63b716208 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMcpStatusReportLines } from '#/tui/components/messages/mcp-status-panel'; + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +describe('buildMcpStatusReportLines', () => { + it('folds a multi-line server error onto one row so the panel box stays intact', () => { + const lines = buildMcpStatusReportLines({ + servers: [ + { + name: 'ghidra', + transport: 'stdio', + status: 'failed', + toolCount: 0, + error: + 'MCP error -32000: Connection closed\nstderr: usage: bridge_mcp_ghidra.py [-h] [--mcp-host MCP_HOST]', + }, + ], + }).map(strip); + + // The box renderer (UsagePanelComponent.render) treats each returned string + // as exactly one row, so an embedded newline would punch through the border. + for (const line of lines) { + expect(line).not.toContain('\n'); + } + + const errorLine = lines.find((line) => line.includes('error:')); + expect(errorLine).toContain( + 'MCP error -32000: Connection closed stderr: usage: bridge_mcp_ghidra.py [-h] [--mcp-host MCP_HOST]', + ); + }); + + it('trims and keeps a single-line error intact', () => { + const lines = buildMcpStatusReportLines({ + servers: [ + { + name: 'ida', + transport: 'http', + status: 'failed', + toolCount: 0, + error: ' fetch failed ', + }, + ], + }).map(strip); + + const errorLine = lines.find((line) => line.includes('error:')); + expect(errorLine).toContain('error: fetch failed'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/notice.test.ts b/apps/kimi-code/test/tui/components/messages/notice.test.ts index a7a9f05e3..09f727556 100644 --- a/apps/kimi-code/test/tui/components/messages/notice.test.ts +++ b/apps/kimi-code/test/tui/components/messages/notice.test.ts @@ -1,7 +1,11 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; -import { NoticeMessageComponent } from '#/tui/components/messages/status-message'; -import { darkColors } from '#/tui/theme/colors'; +import { CronMessageComponent } from '#/tui/components/messages/cron-message'; +import { + NoticeMessageComponent, + StatusMessageComponent, +} from '#/tui/components/messages/status-message'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -12,7 +16,6 @@ describe('NoticeComponent', () => { const component = new NoticeMessageComponent( 'Plan mode: ON', 'Plan will be created here: /tmp/plans/test-plan.md', - darkColors, ); const lines = component.render(120).map((line) => strip(line)); @@ -21,3 +24,37 @@ describe('NoticeComponent', () => { expect(lines[2]).toContain('Plan will be created here: /tmp/plans/test-plan.md'); }); }); + +describe('CronMessageComponent', () => { + it('keeps title, detail, and prompt within narrow widths', () => { + const component = new CronMessageComponent('Please investigate the reminder payload and report back.', { + cron: '*/15 * * * *', + jobId: 'job-with-a-very-long-identifier-for-width-testing', + recurring: true, + missedCount: 3, + stale: true, + }); + + for (const width of [39, 20, 10, 4]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); + +describe('StatusMessageComponent', () => { + it('strips carriage returns so a CRLF line does not render blank', () => { + // A trailing `\r` (e.g. from a CRLF server error page) is zero-width for + // the line wrapper, so padding spaces appended after it would otherwise + // overwrite the visible content. The status component strips `\r`. + const component = new StatusMessageComponent('Error: boom\r\nmore\r', 'error'); + const text = component + .render(120) + .map((line) => strip(line)) + .join('\n'); + expect(text).toContain('Error: boom'); + expect(text).toContain('more'); + expect(text).not.toContain('\r'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts index 44684367c..bb501c05b 100644 --- a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts +++ b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts @@ -4,7 +4,6 @@ import { ShellExecutionComponent, shellExecutionResultRenderer, } from '#/tui/components/messages/shell-execution'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -14,7 +13,6 @@ describe('ShellExecutionComponent', () => { it('renders shell command previews with prompt indentation', () => { const component = new ShellExecutionComponent({ command: 'printf hello\nprintf world', - colors: darkColors, showCommand: true, }); @@ -31,7 +29,6 @@ describe('ShellExecutionComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - colors: darkColors, }); const collapsedOutput = collapsed.render(100).map(strip).join('\n'); @@ -46,7 +43,6 @@ describe('ShellExecutionComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - colors: darkColors, expanded: true, }); @@ -60,7 +56,6 @@ describe('ShellExecutionComponent', () => { const cmd = Array.from({ length: 20 }, (_, i) => `step${String(i + 1)}`).join('\n'); const component = new ShellExecutionComponent({ command: cmd, - colors: darkColors, showCommand: true, commandPreviewLines: undefined, }); @@ -70,10 +65,54 @@ describe('ShellExecutionComponent', () => { expect(output).toContain('step20'); }); + it('does not count trailing empty lines toward the preview cap', () => { + const component = new ShellExecutionComponent({ + result: { + tool_call_id: 'call_shell', + output: 'hello\n\n\n', // 1 content line + 2 trailing empty lines + is_error: false, + }, + }); + + const output = component.render(100).map(strip).join('\n'); + expect(output).toContain('hello'); + expect(output).not.toContain('... (2 more lines'); + }); + + it('preserves internal empty lines while trimming only trailing ones', () => { + const component = new ShellExecutionComponent({ + result: { + tool_call_id: 'call_shell', + output: 'a\n\nb\n\n\n', // 1 internal empty line + 2 trailing empty lines + is_error: false, + }, + }); + + const output = component.render(100).map(strip).join('\n'); + expect(output).toContain('a'); + expect(output).toContain('b'); + expect(output).not.toContain('... (2 more lines'); + }); + + it('truncates long single-line output by wrapped visual lines', () => { + const component = new ShellExecutionComponent({ + result: { + tool_call_id: 'call_shell', + output: 'x'.repeat(500), + is_error: false, + }, + }); + + const out = strip(component.render(20).join('\n')); + expect(out).toContain('x'); + expect(out).not.toContain('x'.repeat(500)); + expect(out).toContain('... ('); + }); + describe('shellExecutionResultRenderer', () => { const longCmd = `echo ${'a'.repeat(200)}\necho done`; - it('omits the command preview when collapsed', () => { + it('renders only the result and leaves the command to the call preview', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -85,18 +124,21 @@ describe('ShellExecutionComponent', () => { output: 'ok', is_error: false, }, - { expanded: false, colors: darkColors }, + { expanded: false }, ); const rendered = components .flatMap((c) => c.render(100)) .map(strip) .join('\n'); + // Command is owned by ToolCallComponent.buildCallPreview, not the + // renderer — rendering it here too would duplicate it once the result + // lands. expect(rendered).not.toContain('$ echo'); expect(rendered).toContain('ok'); }); - it('reveals the full multi-line command when expanded', () => { + it('still renders only the result when expanded', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -105,19 +147,19 @@ describe('ShellExecutionComponent', () => { }, { tool_call_id: 'call_1', - output: 'ok', + output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - { expanded: true, colors: darkColors }, + { expanded: true }, ); const rendered = components .flatMap((c) => c.render(300)) .map(strip) .join('\n'); - expect(rendered).toContain(`$ echo ${'a'.repeat(200)}`); - expect(rendered).toContain('echo done'); - expect(rendered).toContain('ok'); + expect(rendered).not.toContain('$ echo'); + expect(rendered).toContain('line4'); + expect(rendered).toContain('line5'); }); }); }); diff --git a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts new file mode 100644 index 000000000..510da06bd --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { ShellRunComponent } from '#/tui/components/messages/shell-run'; + +function stripTheme(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('ShellRunComponent hardening', () => { + let component: ShellRunComponent | undefined; + + afterEach(() => { + // Always clear the 1s timer so it can't keep the test process alive or + // fire requestRender after the test ends. + component?.dispose(); + component = undefined; + }); + + function create(): ShellRunComponent { + component = new ShellRunComponent(() => {}); + return component; + } + + it('caps the running buffer and never throws on huge streaming output', () => { + const c = create(); + const chunk = 'x'.repeat(50_000); + expect(() => { + for (let i = 0; i < 20; i++) c.append(chunk); + c.render(100); + }).not.toThrow(); + }); + + it('finish switches to the final view and ignores later appends', () => { + const c = create(); + c.finish('final output', '', false); + c.append('should be ignored'); + const rendered = stripTheme(c.render(100).join('\n')); + expect(rendered).toContain('final output'); + expect(rendered).not.toContain('should be ignored'); + }); + + it('finishBackgrounded renders the background hint', () => { + const c = create(); + c.finishBackgrounded(); + const rendered = stripTheme(c.render(100).join('\n')); + expect(rendered).toContain('Moved to background.'); + }); + + it('append / finish are no-ops after dispose', () => { + const c = create(); + c.dispose(); + expect(() => { + c.append('late'); + c.finish('late', '', false); + c.finishBackgrounded(); + c.render(100); + }).not.toThrow(); + }); + + it('does not throw when the render callback throws', () => { + const c = new ShellRunComponent(() => { + throw new Error('render failed'); + }); + component = c; + expect(() => { + c.append('output'); + c.render(100); + }).not.toThrow(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 994fbf525..041860896 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildStatusReportLines } from '#/tui/components/messages/status-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,13 +9,12 @@ function strip(text: string): string { describe('status panel report lines', () => { it('formats runtime status, context, and managed usage without account or AGENTS.md rows', () => { const lines = buildStatusReportLines({ - colors: darkColors, version: '1.2.3', model: 'k2', workDir: '/tmp/project', sessionId: 'ses-1', sessionTitle: 'Implement status', - thinking: true, + thinkingEffort: 'on', permissionMode: 'manual', planMode: false, contextUsage: 0.25, @@ -32,7 +30,7 @@ describe('status panel report lines', () => { }, status: { model: 'k2', - thinkingLevel: 'high', + thinkingEffort: 'high', permission: 'auto', planMode: true, contextTokens: 3000, @@ -54,7 +52,7 @@ describe('status panel report lines', () => { const output = lines.join('\n'); expect(output).toContain('>_ Kimi Code (v1.2.3)'); - expect(output).toContain('Model Kimi K2 (thinking on)'); + expect(output).toContain('Model Kimi K2 (thinking high)'); expect(output).toContain('Directory /tmp/project'); expect(output).toContain('Permissions auto'); expect(output).toContain('Plan mode on'); @@ -70,15 +68,52 @@ describe('status panel report lines', () => { expect(output).not.toContain('Runtime'); }); + it('formats extra usage section in status report', () => { + const lines = buildStatusReportLines({ + version: '1.2.3', + model: 'k2', + workDir: '/tmp/project', + sessionId: 'ses-1', + sessionTitle: null, + thinkingEffort: 'off', + permissionMode: 'manual', + planMode: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + availableModels: {}, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 15000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('150.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + }); + it('falls back to app state and shows status load errors as warnings', () => { const lines = buildStatusReportLines({ - colors: darkColors, version: '1.2.3', model: '', workDir: '/tmp/project', sessionId: '', sessionTitle: null, - thinking: false, + thinkingEffort: 'off', permissionMode: 'manual', planMode: false, contextUsage: 0, diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts index 64c1b6955..e615d7f5c 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -1,9 +1,8 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import { visibleWidth, type TUI } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ThinkingComponent } from '#/tui/components/messages/thinking'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -13,7 +12,7 @@ const longThinking = ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'lin describe('ThinkingComponent', () => { it('shows the live spinner header before thinking content', () => { - const component = new ThinkingComponent('working it out', darkColors, true, 'live'); + const component = new ThinkingComponent('working it out', true, 'live'); const out = strip(component.render(80).join('\n')); expect(out).toContain('⠋ thinking...'); @@ -23,7 +22,7 @@ describe('ThinkingComponent', () => { }); it('keeps live thinking height-limited to the tail', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); const out = strip(component.render(80).join('\n')); expect(out).not.toContain('line1'); @@ -37,7 +36,7 @@ describe('ThinkingComponent', () => { it('animates the live spinner and stops on finalize', () => { vi.useFakeTimers(); const requestRender = vi.fn(); - const component = new ThinkingComponent('step', darkColors, true, 'live', { + const component = new ThinkingComponent('step', true, 'live', { requestRender, } as unknown as TUI); @@ -55,7 +54,7 @@ describe('ThinkingComponent', () => { }); it('finalizes in place into a collapsed preview', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); component.finalize(); @@ -68,7 +67,7 @@ describe('ThinkingComponent', () => { }); it('expands and collapses after finalization', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); component.finalize(); component.setExpanded(true); @@ -81,4 +80,13 @@ describe('ThinkingComponent', () => { expect(collapsed).not.toContain('line7'); expect(collapsed).toContain('ctrl+o to expand'); }); + + it('keeps the finalized truncation footer within the requested render width', () => { + const component = new ThinkingComponent(longThinking, true, 'live'); + component.finalize(); + + for (const line of component.render(37)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(37); + } + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index d57e9dfe5..f5ff1fc1e 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -1,10 +1,10 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import { visibleWidth, type TUI } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { darkColors } from '#/tui/theme/colors'; -import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; @@ -41,7 +41,6 @@ describe('ToolCallComponent', () => { output: 'content', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -50,6 +49,100 @@ describe('ToolCallComponent', () => { expect(out).not.toContain(`${String.fromCodePoint(0x23fa, 0xfe0e)} Used Read`); }); + describe('detach hint for long-running foreground Bash/Agent', () => { + it('shows the Ctrl+B hint after 10s for a running Bash call', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_bash_long', name: 'Bash', args: { command: 'sleep 30' } }, + undefined, + stubTui(30), + ); + + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); + + vi.advanceTimersByTime(10_000); + expect(strip(component.render(100).join('\n'))).toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + + it('shows the hint immediately for a running Agent call', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_agent_long', name: 'Agent', args: { description: 'explore' } }, + undefined, + stubTui(30), + ); + + // No timer advancement — Agents advertise Ctrl+B immediately. + expect(strip(component.render(100).join('\n'))).toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + + it('does not show the hint for non-detachable tools', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_read_long', name: 'Read', args: { path: 'foo.ts' } }, + undefined, + stubTui(30), + ); + + vi.advanceTimersByTime(15_000); + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + + it('does not show the hint when the result lands before 10s', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_bash_short', name: 'Bash', args: { command: 'echo hi' } }, + undefined, + stubTui(30), + ); + + vi.advanceTimersByTime(5_000); + component.setResult({ tool_call_id: 'call_bash_short', output: 'hi', is_error: false }); + vi.advanceTimersByTime(10_000); + + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + }); + + it('keeps collapsed tool-call lines within very narrow widths', () => { + const component = new ToolCallComponent( + { + id: 'call_narrow_read', + name: 'Read', + args: { path: 'very/long/path/to/foo.ts' }, + }, + { + tool_call_id: 'call_narrow_read', + output: 'content', + is_error: false, + }, + ); + + for (const width of [1, 2, 4, 10, 39]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + it('keeps collapsed tool results short and expands on demand', () => { const component = new ToolCallComponent( { @@ -62,7 +155,6 @@ describe('ToolCallComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -80,7 +172,118 @@ describe('ToolCallComponent', () => { expect(expanded).not.toContain('ctrl+o to expand'); }); - it('hides tool output bodies that start with a <system tag', () => { + it('renders live Bash output while the command is running', () => { + const component = new ToolCallComponent( + { + id: 'call_shell_live', + name: 'Bash', + args: { command: 'printf output' }, + }, + undefined, + ); + + component.appendLiveOutput('line1\n'); + component.appendLiveOutput('line2\n'); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Running a command'); + expect(out).toContain('line1'); + expect(out).toContain('line2'); + }); + + it('clears live Bash output when the final result arrives', () => { + const component = new ToolCallComponent( + { + id: 'call_shell_live_done', + name: 'Bash', + args: { command: 'printf output' }, + }, + undefined, + ); + + component.appendLiveOutput('streamed-only\n'); + component.setResult({ + tool_call_id: 'call_shell_live_done', + output: 'final-only\n', + is_error: false, + }); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Ran a command'); + expect(out).toContain('final-only'); + expect(out).not.toContain('streamed-only'); + }); + + describe('Bash command preview', () => { + const longCommand = Array.from({ length: 15 }, (_, i) => `echo step${String(i + 1)}`).join( + '\n', + ); + + it('shows the truncated command while running and reveals the rest when expanded', () => { + const component = new ToolCallComponent( + { id: 'call_bash_running', name: 'Bash', args: { command: longCommand } }, + undefined, + ); + + const collapsed = strip(component.render(100).join('\n')); + expect(collapsed).toContain('Running a command'); + expect(collapsed).toContain('echo step1'); + expect(collapsed).toContain('echo step10'); + expect(collapsed).not.toContain('echo step11'); + + component.setExpanded(true); + + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('echo step11'); + expect(expanded).toContain('echo step15'); + }); + + it('keeps the command preview after the result lands to avoid a height collapse', () => { + const component = new ToolCallComponent( + { id: 'call_bash_done', name: 'Bash', args: { command: longCommand } }, + undefined, + ); + + // Sanity: while running, the in-flight preview shows the command. + expect(strip(component.render(100).join('\n'))).toContain('$ echo step1'); + + component.setResult({ tool_call_id: 'call_bash_done', output: 'done', is_error: false }); + + // Collapsed result view still shows the command preview (capped at + // COMMAND_PREVIEW_LINES) so a multi-line command with short output does + // not collapse the card. The command is owned by buildCallPreview, so it + // must appear exactly once — the result renderer no longer renders it. + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Ran a command'); + expect(out).toContain('$ echo step1'); + expect(out).toContain('echo step10'); + expect(out).not.toContain('echo step11'); + expect(out).toContain('done'); + expect(out.split('$ echo step1').length - 1).toBe(1); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('echo step11'); + expect(expanded).toContain('echo step15'); + }); + + it('keeps the command preview when the command produces no output', () => { + const component = new ToolCallComponent( + { id: 'call_bash_empty', name: 'Bash', args: { command: 'mkdir -p a/b/c\necho done' } }, + { tool_call_id: 'call_bash_empty', output: '', is_error: false }, + ); + + // buildContent early-returns on empty output, but the command preview + // (owned by buildCallPreview) must still render so the card does not + // collapse to just the header. + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Ran a command'); + expect(out).toContain('$ mkdir -p a/b/c'); + expect(out).toContain('echo done'); + }); + }); + + it('hides tool output bodies that start with a <system-reminder tag', () => { const reminderOutput = '<system-reminder>\nThe task tools have not been used recently.\n</system-reminder>'; const component = new ToolCallComponent( @@ -94,11 +297,10 @@ describe('ToolCallComponent', () => { output: reminderOutput, is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain(`${STATUS_BULLET}Used Bash`); + expect(collapsed).toContain(`${STATUS_BULLET}Ran a command`); expect(collapsed).not.toContain('system-reminder'); expect(collapsed).not.toContain('task tools'); @@ -108,7 +310,7 @@ describe('ToolCallComponent', () => { expect(expanded).not.toContain('task tools'); }); - it('hides <system-prefixed output even when the tool result is an error', () => { + it('hides <system-reminder-prefixed output even when the tool result is an error', () => { const component = new ToolCallComponent( { id: 'call_hidden_err', @@ -120,7 +322,6 @@ describe('ToolCallComponent', () => { output: '<system-reminder>do not show</system-reminder>', is_error: true, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -128,6 +329,82 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('do not show'); }); + it('renders output that merely starts with a literal <system> tag', () => { + // Tool metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so real output starting with the literal tag — + // a file that contains it, an MCP tool's text — must stay visible. + const component = new ToolCallComponent( + { + id: 'call_literal', + name: 'Bash', + args: { command: 'cat notes.txt' }, + }, + { + tool_call_id: 'call_literal', + output: '<system>literal text from a user file</system>\nsecond line', + is_error: false, + }, + ); + + component.setExpanded(true); + const out = strip(component.render(100).join('\n')); + expect(out).toContain('<system>literal text from a user file</system>'); + expect(out).toContain('second line'); + }); + + it('renders AgentSwarm results as a one-line summary without raw XML', () => { + const output = [ + '<agent_swarm_result>', + '<summary>completed: 1, failed: 1, aborted: 1</summary>', + '<subagent index="1" outcome="completed">Reviewed src/a.ts.</subagent>', + '<subagent index="2" outcome="failed">Agent timed out.</subagent>', + '<subagent index="3" outcome="aborted">User aborted.</subagent>', + '</agent_swarm_result>', + ].join('\n'); + const component = new ToolCallComponent( + { + id: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts', 'src/c.ts'], + }, + }, + { + tool_call_id: 'call_swarm', + output, + is_error: false, + }, + ); + + const out = strip(component.render(120).join('\n')); + + expect(out).toContain('Agent swarm: ✓ 1 completed · ✗ 1 failed · ⊘ 1 aborted'); + expect(out).not.toContain('<agent_swarm_result>'); + expect(out).not.toContain('Reviewed src/a.ts.'); + expect(out).not.toContain('Agent timed out.'); + }); + + it('renders an AgentSwarm fallback summary when the result is not structured', () => { + const component = new ToolCallComponent( + { + id: 'call_swarm_failed', + name: 'AgentSwarm', + args: { description: 'Review changed files' }, + }, + { + tool_call_id: 'call_swarm_failed', + output: 'provider request failed', + is_error: true, + }, + ); + + const out = strip(component.render(120).join('\n')); + + expect(out).toContain('Agent swarm: ✗ Failed.'); + expect(out).not.toContain('provider request failed'); + }); + it('still renders tool output when the body merely contains <system later on', () => { const component = new ToolCallComponent( { @@ -140,7 +417,6 @@ describe('ToolCallComponent', () => { output: 'first line\n<system-reminder>nope</system-reminder>', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -162,12 +438,11 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# File Plan\n\n1. Do the focused fix.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); expect(out).toContain('Current plan'); - expect(out).toContain('# File Plan'); + expect(out).toContain('File Plan'); expect(out).toContain('1. Do the focused fix.'); expect(out).not.toContain('Plan saved to: /tmp/plan.md'); }); @@ -180,9 +455,7 @@ describe('ToolCallComponent', () => { args: {}, }, undefined, - darkColors, undefined, - createMarkdownTheme(darkColors), ); // A fresh tool card only shows the 'Current plan' title; no plan box renders yet. @@ -200,7 +473,7 @@ describe('ToolCallComponent', () => { expect(after).not.toContain('/tmp/refactor.md'); }); - it('caps the plan preview to the terminal height and expands on ctrl+e', () => { + it('renders the full plan preview', () => { const longPlan = `# Refactor session\n\n${Array.from({ length: 40 }, (_, i) => `- step ${String(i + 1)}`).join('\n')}`; const component = new ToolCallComponent( { @@ -209,20 +482,13 @@ describe('ToolCallComponent', () => { args: { plan: longPlan }, }, undefined, - darkColors, stubTui(24), - createMarkdownTheme(darkColors), ); - const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain('step 1'); - expect(collapsed).toMatch(/\.\.\. \(\d+ more lines, ctrl\+e to expand\)/); - expect(collapsed).not.toContain('step 40'); - - expect(component.setPlanExpanded(true)).toBe(true); - const expanded = strip(component.render(100).join('\n')); - expect(expanded).toContain('step 40'); - expect(expanded).not.toContain('ctrl+e to expand'); + const out = strip(component.render(100).join('\n')); + expect(out).toContain('step 1'); + expect(out).toContain('step 40'); + expect(out).not.toContain('more lines'); }); it('plan preview controls are no-ops for non-ExitPlanMode tool calls', () => { @@ -233,12 +499,9 @@ describe('ToolCallComponent', () => { args: { command: 'echo hi' }, }, undefined, - darkColors, undefined, - createMarkdownTheme(darkColors), ); - expect(component.setPlanExpanded(true)).toBe(false); component.setPlanInfo({ plan: 'should be ignored', path: '/etc/hosts' }); const out = strip(component.render(100).join('\n')); @@ -246,7 +509,7 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('plan:'); }); - it('ctrl+o does not affect the plan preview cap', () => { + it('ctrl+o does not affect the full plan preview', () => { const longPlan = `# P\n\n${Array.from({ length: 40 }, (_, i) => `- step ${String(i + 1)}`).join('\n')}`; const component = new ToolCallComponent( { @@ -255,14 +518,12 @@ describe('ToolCallComponent', () => { args: { plan: longPlan }, }, undefined, - darkColors, stubTui(24), - createMarkdownTheme(darkColors), ); component.setExpanded(true); const out = strip(component.render(100).join('\n')); - expect(out).toContain('ctrl+e to expand'); - expect(out).not.toContain('step 40'); + expect(out).toContain('step 40'); + expect(out).not.toContain('more lines'); }); it('header chips an Approved status when ExitPlanMode result indicates approval', () => { @@ -280,7 +541,6 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# Plan body', is_error: false, }, - darkColors, ); const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; @@ -304,13 +564,40 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# body', is_error: false, }, - darkColors, ); const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; expect(header).toContain('Current plan · Approved: Pragmatic refactor'); }); + it('header chips Auto-approved when ExitPlanMode was auto-approved without user review', () => { + const component = new ToolCallComponent( + { + id: 'call_exit_auto', + name: 'ExitPlanMode', + args: {}, + }, + { + tool_call_id: 'call_exit_auto', + output: + 'Exited plan mode. Plan mode deactivated. All tools are now available.\n' + + 'Note: this plan was auto-approved without user review — the user has NOT explicitly approved it.\n' + + 'Plan saved to: /tmp/plan.md\n\n' + + '## Plan (auto-approved, not user-reviewed):\n# Auto Plan\n\n1. Do the thing.', + is_error: false, + }, + ); + + const out = strip(component.render(100).join('\n')); + const header = out.split('\n')[1] ?? ''; + expect(header).toMatch(/Current plan · Auto-approved\s*$/); + // The plan body renders from the auto-approved marker; the engine-side + // note above the marker must not leak into the rendered plan box. + expect(out).toContain('Auto Plan'); + expect(out).toContain('1. Do the thing.'); + expect(out).not.toContain('Note: this plan was auto-approved'); + }); + it('renders Rejected in the plan box title and keeps revise feedback visible', () => { const component = new ToolCallComponent( { @@ -323,9 +610,7 @@ describe('ToolCallComponent', () => { output: 'User rejected the plan. Feedback:\n\nplease rethink step 2', is_error: false, }, - darkColors, undefined, - createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); @@ -346,9 +631,7 @@ describe('ToolCallComponent', () => { output: 'Plan rejected by user. Plan mode remains active.', is_error: true, }, - darkColors, undefined, - createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); @@ -377,7 +660,6 @@ describe('ToolCallComponent', () => { 'Do NOT edit files other than the plan file while plan mode is active.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -399,7 +681,6 @@ describe('ToolCallComponent', () => { output: 'Plan mode is already active. Use ExitPlanMode when the plan is ready.', is_error: true, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -422,7 +703,6 @@ describe('ToolCallComponent', () => { }), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -432,6 +712,171 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('AskUserQuestion'); }); + it('renders background AskUserQuestion as a started task', () => { + const component = new ToolCallComponent( + { + id: 'call_background_question', + name: 'AskUserQuestion', + args: { background: true }, + }, + { + tool_call_id: 'call_background_question', + output: [ + 'task_id: question-aaaaaaaa', + 'description: Which database?', + 'status: running', + ].join('\n'), + is_error: false, + }, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Started background question'); + expect(out).toContain('question-aaaaaaaa'); + expect(out).not.toContain('Collected your answers'); + }); + + it('renders GetGoal as a goal check without raw JSON', () => { + const component = new ToolCallComponent( + { + id: 'call_get_goal', + name: 'GetGoal', + args: {}, + }, + { + tool_call_id: 'call_get_goal', + output: JSON.stringify({ + goal: { + goalId: 'g1', + objective: 'Ship feature X', + status: 'active', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + startedBy: 'model', + updatedBy: 'model', + turnsUsed: 1, + tokensUsed: 800, + wallClockMs: 5000, + budget: { + tokenBudget: null, + turnBudget: null, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: null, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + }, + }), + is_error: false, + }, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Checked goal'); + expect(out).toContain('Goal active: Ship feature X'); + expect(out).not.toContain('Used GetGoal'); + expect(out).not.toContain('"objective"'); + }); + + it('renders SetGoalBudget with a readable budget argument', () => { + const component = new ToolCallComponent( + { + id: 'call_goal_budget', + name: 'SetGoalBudget', + args: { value: 10, unit: 'turns' }, + }, + { + tool_call_id: 'call_goal_budget', + output: 'Goal budget set: 10 turns.', + is_error: false, + }, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Set goal budget (10 turns)'); + expect(out).not.toContain('Set goal budget (10 turns) · 10 turns'); + expect(out).not.toContain('Used SetGoalBudget (turns)'); + expect(out).not.toContain('Goal budget set: 10 turns.'); + }); + + it('renders successful SetGoalBudget headers with the primary goal marker', () => { + const previousLevel = chalk.level; + chalk.level = 3; + try { + const component = new ToolCallComponent( + { + id: 'call_goal_budget', + name: 'SetGoalBudget', + args: { value: 10, unit: 'turns' }, + }, + { + tool_call_id: 'call_goal_budget', + output: 'Goal budget set: 10 turns.', + is_error: false, + }, + ); + + const out = component.render(100).join('\n'); + expect(out).toContain(chalk.hex(darkColors.primary)(STATUS_BULLET)); + expect(out).not.toContain(chalk.hex(darkColors.success)(STATUS_BULLET)); + } finally { + chalk.level = previousLevel; + } + }); + + it('renders UpdateGoal as a model-reported status, not a user lifecycle marker', () => { + const component = new ToolCallComponent( + { + id: 'call_update_goal', + name: 'UpdateGoal', + args: { status: 'blocked' }, + }, + { + tool_call_id: 'call_update_goal', + output: 'Goal marked blocked.', + is_error: false, + }, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Reported goal blocked'); + expect(out).not.toContain('Updated goal (blocked)'); + expect(out).not.toContain('· blocked'); + expect(out).not.toContain('Goal marked blocked.'); + expect(out).not.toContain('● Goal blocked'); + }); + + it('renders successful UpdateGoal report headers entirely in the primary goal color', () => { + const previousLevel = chalk.level; + chalk.level = 3; + try { + for (const status of ['complete', 'blocked']) { + const component = new ToolCallComponent( + { + id: `call_update_goal_${status}`, + name: 'UpdateGoal', + args: { status }, + }, + { + tool_call_id: `call_update_goal_${status}`, + output: `Goal marked ${status}.`, + is_error: false, + }, + ); + + const out = component.render(100).join('\n'); + expect(out).toContain(chalk.hex(darkColors.primary)(STATUS_BULLET)); + expect(out).not.toContain(chalk.hex(darkColors.success)(STATUS_BULLET)); + } + } finally { + chalk.level = previousLevel; + } + }); + it('appends a chip to the header once a result arrives', () => { const component = new ToolCallComponent( { @@ -444,7 +889,6 @@ describe('ToolCallComponent', () => { output: '1\tfoo\n2\tbar\n3\tbaz', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -462,7 +906,6 @@ describe('ToolCallComponent', () => { args: { path: longPath }, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -483,16 +926,16 @@ describe('ToolCallComponent', () => { output: '1\tcontent', is_error: false, }, - darkColors, - undefined, undefined, '/tmp/proj-a', ); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Used Read (apps/kimi-code/src/main.ts)'); + const expectedReadPath = + process.platform === 'win32' ? 'apps\\kimi-code\\src\\main.ts' : 'apps/kimi-code/src/main.ts'; + expect(out).toContain(`Used Read (${expectedReadPath})`); expect(out).not.toContain('/tmp/proj-a/apps'); - expect(component.getReadSnapshot().filePath).toBe('apps/kimi-code/src/main.ts'); + expect(component.getReadSnapshot().filePath).toBe(expectedReadPath); }); it('keeps Read paths outside the active workspace absolute', () => { @@ -503,8 +946,6 @@ describe('ToolCallComponent', () => { args: { path: '/tmp/proj-ab/src/main.ts' }, }, undefined, - darkColors, - undefined, undefined, '/tmp/proj-a', ); @@ -522,7 +963,6 @@ describe('ToolCallComponent', () => { args: { path: 'foo.ts' }, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -540,7 +980,6 @@ describe('ToolCallComponent', () => { args: { description: 'explore project xxx' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ @@ -550,7 +989,7 @@ describe('ToolCallComponent', () => { }); let out = strip(component.render(120).join('\n')); - expect(out).toContain('Explore Agent Starting (explore project xxx) · 0 tools · 0s'); + expect(out).toContain('Explore Agent Queued (explore project xxx) · 0 tools · 0s'); expect(out).not.toContain('Using Agent'); expect(out).not.toContain('Used Agent'); @@ -566,14 +1005,15 @@ describe('ToolCallComponent', () => { out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Running (explore project xxx) · 1 tool · 10s'); expect(out).toContain('Using Read (apps/kimi-code/src/tui/utils/background-agent-status.ts)'); + // Thinking and text are mutually exclusive in the active window: the most + // recently streamed (text) wins, so thinking is hidden entirely. expect(out).not.toContain('think1'); expect(out).not.toContain('think2'); - expect(out).toContain('think3'); - expect(out).toContain('◌ think3'); + expect(out).not.toContain('think3'); expect(out).not.toContain('answer1'); - expect(out).not.toContain('answer2'); + expect(out).toContain('answer2'); expect(out).toContain('answer3'); - expect(out).toContain('└ answer3'); + expect(out).toContain('│ answer3'); vi.setSystemTime(22_000); component.onSubagentCompleted({ resultSummary: 'summary fallback' }); @@ -587,13 +1027,57 @@ describe('ToolCallComponent', () => { out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Completed (explore project xxx) · 1 tool · 12s'); expect(out).not.toContain('think3'); - expect(out).toContain('└ answer3'); + expect(out).toContain('│ answer3'); expect(out).not.toContain('Used Agent'); expect(out).not.toContain('parent duplicate result'); expect(out).not.toContain('summary fallback'); }); - it('keeps the single subagent tool area to the latest four activities', () => { + it('shows Backgrounded after a foreground subagent is detached, even after setResult', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_detach', + name: 'Agent', + args: { description: 'long task' }, + }, + undefined, + stubTui(30), + ); + component.onSubagentSpawned({ + agentId: 'sub_detach_1', + agentName: 'explore', + runInBackground: false, + }); + component.onSubagentStarted({ + agentId: 'sub_detach_1', + agentName: 'explore', + runInBackground: false, + }); + + // Sanity: running before detach. + expect(strip(component.render(120).join('\n'))).toContain('Running'); + + component.markBackgrounded(); + let out = strip(component.render(120).join('\n')); + expect(out).toContain('Backgrounded'); + expect(out).not.toContain('Completed'); + + // The spawn-success ToolResult landing must NOT flip the card to Completed. + component.setResult({ + tool_call_id: 'call_agent_detach', + output: 'agent_id: sub_detach_1\nactual_subagent_type: explore\n', + is_error: false, + }); + out = strip(component.render(120).join('\n')); + expect(out).toContain('Backgrounded'); + expect(out).not.toContain('Completed'); + + component.dispose(); + }); + + it('summarizes subagent tools as a count plus the current tool', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -603,7 +1087,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_tools', @@ -624,16 +1107,17 @@ describe('ToolCallComponent', () => { const out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Running (inspect tools) · 5 tools · 0s'); - expect(out).not.toContain('file1.ts'); - expect(out).toContain('Used Read (file2.ts)'); - expect(out).toContain('Used Read (file3.ts)'); - expect(out).toContain('Used Read (file4.ts)'); - expect(out).not.toContain('… Using Grep (auth)'); - expect(out).toContain('• Using Grep (auth)'); + // Only the current (most recent ongoing) tool appears in the summary line. expect(out).toContain('Using Grep (auth)'); + // No per-tool activity rows are rendered. + expect(out).not.toContain('file1.ts'); + expect(out).not.toContain('file2.ts'); + expect(out).not.toContain('file3.ts'); + expect(out).not.toContain('file4.ts'); + expect(out).not.toContain('Used Read'); }); - it('keeps the single subagent tool window stable when older tools update', () => { + it('keeps the subagent tool summary pinned to the most recent tool', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -643,7 +1127,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_tools', @@ -670,14 +1153,16 @@ describe('ToolCallComponent', () => { }); const out = strip(component.render(120).join('\n')); + // The updated/finished older tool must not surface in the summary. expect(out).not.toContain('file1-updated.ts'); - expect(out).toContain('Using Read (file2.ts)'); - expect(out).toContain('Using Read (file3.ts)'); - expect(out).toContain('Using Read (file4.ts)'); + expect(out).not.toContain('file2.ts'); + expect(out).not.toContain('file3.ts'); + expect(out).not.toContain('file4.ts'); + // Only the most recent ongoing tool is shown. expect(out).toContain('Using Read (file5.ts)'); }); - it('wraps single subagent thinking and output with hanging indentation', () => { + it('wraps the single subagent active window with a hanging gutter', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -687,28 +1172,137 @@ describe('ToolCallComponent', () => { args: { description: 'inspect wrapping' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_wrapped', agentName: 'explore', runInBackground: false, }); - component.appendSubagentText( - 'thinking words that should wrap with a clean hanging indent', - 'thinking', - ); component.appendSubagentText( 'output words that should also wrap with a clean hanging indent', 'text', ); - const lines = strip(component.render(34).join('\n')).split('\n'); - expect(lines).toContain(' ◌ thinking words that should '); - expect(lines).toContain(' wrap with a clean hanging '); - expect(lines).toContain(' indent '); - expect(lines).toContain(' └ output words that should also '); - expect(lines).toContain(' wrap with a clean hanging '); + const joined = strip(component.render(34).join('\n')); + // The two-row window drops the head of the wrapped paragraph. + expect(joined).not.toContain('output words that should'); + // Every kept row carries the `│` gutter as a hanging indent. + expect(joined).toContain('│ wrap with a clean hanging'); + expect(joined).toContain('│ indent'); + }); + + it('scrolls single subagent thinking to the last two display rows', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_scroll', + name: 'Agent', + args: { description: 'long think' }, + }, + undefined, + ); + component.onSubagentSpawned({ + agentId: 'sub_scroll', + agentName: 'explore', + runInBackground: false, + }); + // A single long logical line (no newlines) wraps to many display rows; + // only the last THINKING_PREVIEW_LINES (2) should remain visible. + const segs = Array.from({ length: 30 }, (_, i) => `seg${String(i).padStart(2, '0')}`); + component.appendSubagentText(segs.join(' '), 'thinking'); + + const lines = strip(component.render(40).join('\n')).split('\n'); + const thinkingRows = lines.filter((l) => /seg\d\d/.test(l)); + expect(thinkingRows.length).toBe(2); + expect(lines.join('\n')).toContain('seg29'); + expect(lines.join('\n')).not.toContain('seg00'); + }); + + it('shows a two-row tail of an ongoing subagent Bash output', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_bash_out', + name: 'Agent', + args: { description: 'run bash' }, + }, + undefined, + ); + component.onSubagentSpawned({ + agentId: 'sub_bash', + agentName: 'explore', + runInBackground: false, + }); + component.appendSubToolCall({ + id: 'sub_bash:cmd', + name: 'Bash', + args: { command: 'ls -la' }, + }); + const output = Array.from({ length: 10 }, (_, i) => `bash-line-${String(i)}`).join('\n'); + component.appendSubToolLiveOutput('sub_bash:cmd', output); + + let out = strip(component.render(120).join('\n')); + expect(out).toContain('Using Bash (ls -la)'); + // The active window keeps only the last two rows of live output. + expect(out).toContain('bash-line-8'); + expect(out).toContain('bash-line-9'); + expect(out).not.toContain('bash-line-7'); + // No ctrl+o promise for the subagent window. + expect(out).not.toContain('ctrl+o'); + + // The global ctrl+o expand toggle must NOT expand the window. + component.setExpanded(true); + out = strip(component.render(120).join('\n')); + expect(out).toContain('bash-line-9'); + expect(out).not.toContain('bash-line-7'); + }); + + it('shows live output for generic subagent tools but not for recognized ones', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_mixed', + name: 'Agent', + args: { description: 'mixed tools' }, + }, + undefined, + ); + component.onSubagentSpawned({ + agentId: 'sub_mixed', + agentName: 'explore', + runInBackground: false, + }); + // A finished recognized tool: its output body never reaches the window. + component.appendSubToolCall({ + id: 'sub_mixed:read', + name: 'Read', + args: { path: 'foo.ts' }, + }); + component.finishSubToolCall({ + tool_call_id: 'sub_mixed:read', + output: 'recognized-read-body\nhidden-read-line', + is_error: false, + }); + // An ongoing generic (MCP) tool: its live output is the active stream. + component.appendSubToolCall({ + id: 'sub_mixed:mcp', + name: 'mcp__server__do', + args: {}, + }); + const mcpOut = Array.from({ length: 5 }, (_, i) => `mcp-line-${String(i)}`).join('\n'); + component.appendSubToolLiveOutput('sub_mixed:mcp', mcpOut); + + const out = strip(component.render(120).join('\n')); + // Recognized tool output never appears. + expect(out).not.toContain('recognized-read-body'); + // Generic tool output shows as the two-row active window tail. + expect(out).toContain('mcp-line-3'); + expect(out).toContain('mcp-line-4'); + expect(out).not.toContain('mcp-line-2'); + expect(out).not.toContain('ctrl+o'); }); it('renders failed single subagents with the dedicated header and error text', () => { @@ -721,7 +1315,6 @@ describe('ToolCallComponent', () => { args: { description: 'check failure' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_failed', @@ -734,11 +1327,40 @@ describe('ToolCallComponent', () => { const out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Failed (check failure) · 0 tools · 3s'); - expect(out).toContain('└ subagent exceeded max_steps'); + expect(out).toContain('│ subagent exceeded max_steps'); expect(out).not.toContain('Using Agent'); expect(out).not.toContain('Used Agent'); }); + it('keeps the same card height between running and done', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_height', + name: 'Agent', + args: { description: 'height stable' }, + }, + undefined, + ); + component.onSubagentSpawned({ + agentId: 'sub_height', + agentName: 'explore', + runInBackground: false, + }); + component.appendSubToolCall({ id: 'sub_height:read', name: 'Read', args: { path: 'a.ts' } }); + component.appendSubagentText('short answer', 'text'); + + const runningLines = strip(component.render(120).join('\n')).split('\n').length; + + component.onSubagentCompleted({ resultSummary: 'short answer' }); + component.setResult({ tool_call_id: 'call_agent_height', output: 'done', is_error: false }); + + const doneLines = strip(component.render(120).join('\n')).split('\n').length; + + expect(doneLines).toBe(runningLines); + }); + describe('background agent terminal state vs spawn-success ToolResult', () => { // The Agent tool returns a "task spawned" result the moment a // run_in_background=true call lands. That result is not an error and its @@ -769,7 +1391,6 @@ describe('ToolCallComponent', () => { }, }, spawnSuccessResult, - darkColors, ); component.onSubagentSpawned({ agentId: 'agent-0', @@ -834,7 +1455,6 @@ describe('ToolCallComponent', () => { args: { description: 'background agent A', run_in_background: true }, }, undefined, - darkColors, ); component.setBackgroundTaskTerminalStatus('lost'); // Now the spawn-success result lands. @@ -885,7 +1505,6 @@ describe('ToolCallComponent', () => { args: { description: 'background agent 1', run_in_background: true }, }, spawnSuccessResult, - darkColors, ); // No spawn metadata was wired in — exactly the resume / backgrounded // case we are guarding against. @@ -903,7 +1522,6 @@ describe('ToolCallComponent', () => { args: { description: 'X', run_in_background: true }, }, spawnSuccessResult, - darkColors, ); component.setSubagentMeta('agent-explicit', 'coder'); expect(component.getSubagentAgentId()).toBe('agent-explicit'); @@ -921,7 +1539,6 @@ describe('ToolCallComponent', () => { output: 'agent_id: agent-fake\nstatus: running', is_error: false, }, - darkColors, ); expect(component.getSubagentAgentId()).toBeUndefined(); }); @@ -972,7 +1589,6 @@ describe('ToolCallComponent', () => { streamingArguments: `{"file_path":"foo.ts","content":"${escaped}`, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1001,7 +1617,6 @@ describe('ToolCallComponent', () => { truncated: true, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1040,7 +1655,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1074,7 +1688,6 @@ describe('ToolCallComponent', () => { // No streamingArguments → finalized args; no result yet. }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); expect(out).toContain('line1'); @@ -1096,7 +1709,6 @@ describe('ToolCallComponent', () => { streamingArguments: `{"file_path":"big.txt","content":"${escaped}"}`, }, undefined, - darkColors, ); expect(strip(component.render(100).join('\n'))).toContain('line25'); @@ -1122,7 +1734,6 @@ describe('ToolCallComponent', () => { streamingArguments: '{', }, undefined, - darkColors, ); const before = strip(component.render(100).join('\n')); expect(before).toContain('Using Write'); @@ -1149,7 +1760,6 @@ describe('ToolCallComponent', () => { streamingArguments: '{"file_path":"foo.ts","content":"a\\nb', }, undefined, - darkColors, ); // While streaming, body is rendered live from streamingArguments. expect(strip(component.render(100).join('\n'))).toMatch(/^\s*1\s+a\s*$/m); @@ -1176,7 +1786,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: Date.now(), }, undefined, - darkColors, ); expect(strip(component.render(100).join('\n'))).toContain('Preparing changes'); expect(strip(component.render(100).join('\n'))).not.toMatch(/^\s*\d+\s+[+-]\s/m); @@ -1205,7 +1814,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ui as never, ); @@ -1232,7 +1840,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ui as never, ); ui.requestRender.mockClear(); @@ -1255,7 +1862,6 @@ describe('ToolCallComponent', () => { output: 'Wrote big.txt', is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -1286,7 +1892,6 @@ describe('ToolCallComponent', () => { output: 'Wrote demo.abcxyz', is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 99d253af7..7942cdae8 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -80,6 +80,26 @@ describe('chip registry', () => { expect(pickChip('Think')).toBeUndefined(); }); + it('GetGoal chip shows the current status', () => { + expect(chipFor('GetGoal', {}, result('{"goal":{"status":"active"}}'))).toBe('active'); + }); + + it('GetGoal chip shows when there is no current goal', () => { + expect(chipFor('GetGoal', {}, result('{"goal":null}'))).toBe('no goal'); + }); + + it('CreateGoal chip shows the created status', () => { + expect(chipFor('CreateGoal', { objective: 'Ship feature X' }, result('{"goal":{"status":"active"}}'))).toBe('active'); + }); + + it('SetGoalBudget has no chip because the budget is in the header argument', () => { + expect(pickChip('SetGoalBudget')).toBeUndefined(); + }); + + it('UpdateGoal has no chip because the status is in the header label', () => { + expect(pickChip('UpdateGoal')).toBeUndefined(); + }); + it('Unknown tools have no chip', () => { expect(pickChip('SomethingElse')).toBeUndefined(); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts index ab4a53fd6..691f1d88a 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { @@ -38,7 +38,6 @@ function imageOutput(path: string, b64 = PNG_B64, mime = 'image/png'): string { { type: 'text', text: `<image path="${path}">` }, { type: 'image_url', imageUrl: { url: `data:${mime};base64,${b64}` } }, { type: 'text', text: '</image>' }, - { type: 'text', text: `Loaded image file "${path}" (${mime}, 70 bytes, original size 1x1px).` }, ]); } @@ -58,7 +57,6 @@ describe('parseReadMediaOutput', () => { expect(m?.path).toBe('/tmp/a.png'); expect(m?.mimeType).toBe('image/png'); expect(m?.bytes).toBeGreaterThan(0); - expect(m?.originalSize).toBe('1x1px'); }); it('extracts video kind and mime', () => { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index aebe87435..6570aac46 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -1,7 +1,10 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; -import { pickResultRenderer } from '#/tui/components/messages/tool-renderers/registry'; +import { + isGenericToolResult, + pickResultRenderer, +} from '#/tui/components/messages/tool-renderers/registry'; import { darkColors } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; @@ -24,6 +27,36 @@ function result(output: string, isError = false): ToolResultBlockData { const ctx = { expanded: false, colors: darkColors }; const expandedCtx = { expanded: true, colors: darkColors }; +function goalOutput(overrides: Record<string, unknown> = {}): string { + return JSON.stringify({ + goal: { + goalId: 'g1', + objective: 'Ship feature X', + status: 'active', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + startedBy: 'model', + updatedBy: 'model', + turnsUsed: 2, + tokensUsed: 1234, + wallClockMs: 61000, + budget: { + tokenBudget: null, + turnBudget: null, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: null, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + ...overrides, + }, + }); +} + describe('tool-result registry', () => { it('falls back to truncated renderer for unknown tools', () => { const renderer = pickResultRenderer('SomethingUnknown'); @@ -153,6 +186,43 @@ describe('tool-result registry', () => { expect(out.trim()).toBe(''); }); + it('GetGoal renders a compact goal summary instead of raw JSON', () => { + const renderer = pickResultRenderer('GetGoal'); + const out = strip(joinRender(renderer(call('GetGoal'), result(goalOutput()), ctx))); + expect(out).toContain('Goal active: Ship feature X'); + expect(out).toContain('2 turns'); + expect(out).toContain('1.2k tokens'); + expect(out).toContain('1m 01s'); + expect(out).not.toContain('"objective"'); + expect(out).not.toContain('"budget"'); + }); + + it('GetGoal renders an empty goal without dumping JSON', () => { + const renderer = pickResultRenderer('GetGoal'); + const out = strip(joinRender(renderer(call('GetGoal'), result('{"goal":null}'), ctx))); + expect(out).toContain('No current goal.'); + expect(out).not.toContain('"goal"'); + }); + + it('CreateGoal renders the created goal summary without raw JSON', () => { + const renderer = pickResultRenderer('CreateGoal'); + const out = strip(joinRender(renderer( + call('CreateGoal', { objective: 'Ship feature X' }), + result(goalOutput()), + ctx, + ))); + expect(out).toContain('Goal active: Ship feature X'); + expect(out).not.toContain('"goalId"'); + }); + + it('UpdateGoal success renders no redundant body', () => { + const renderer = pickResultRenderer('UpdateGoal'); + const out = joinRender( + renderer(call('UpdateGoal', { status: 'complete' }), result('Goal marked complete.'), ctx), + ); + expect(out.trim()).toBe(''); + }); + it('Errors always fall back to truncated renderer regardless of tool', () => { const renderer = pickResultRenderer('Read'); const out = strip( @@ -162,4 +232,22 @@ describe('tool-result registry', () => { ); expect(out).toContain('ENOENT: foo.ts not found'); }); + + it('flags only fallback (truncated) tools as generic results', () => { + expect(isGenericToolResult('SomethingUnknown')).toBe(true); + expect(isGenericToolResult('mcp__server__do')).toBe(true); + expect(isGenericToolResult('Bash')).toBe(false); + expect(isGenericToolResult('Read')).toBe(false); + expect(isGenericToolResult('Grep')).toBe(false); + expect(isGenericToolResult('Edit')).toBe(false); + }); + + it('truncates unknown tool output by wrapped visual lines, not raw newlines', () => { + const renderer = pickResultRenderer('SomethingUnknown'); + const longLine = 'x'.repeat(500); + const out = strip(joinRender(renderer(call('SomethingUnknown'), result(longLine), ctx), 20)); + expect(out).toContain('x'); + expect(out).not.toContain(longLine); + expect(out).toContain('... ('); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts new file mode 100644 index 000000000..4c90c0392 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts @@ -0,0 +1,90 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { TruncatedOutputComponent } from '#/tui/components/messages/tool-renderers/truncated'; + + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +describe('TruncatedOutputComponent', () => { + it('indents content and the truncation hint by the configured amount', () => { + const component = new TruncatedOutputComponent(['a', 'b', 'c', 'd', 'e'].join('\n'), { + expanded: false, + isError: false, + maxLines: 2, + indent: 6, + }); + + const lines = strip(component.render(80).join('\n')).split('\n'); + expect(lines[0]?.startsWith(' a')).toBe(true); + expect(lines[1]?.startsWith(' b')).toBe(true); + expect(lines[2]).toBe(' ... (3 more lines, ctrl+o to expand)'); + }); + + it('defaults to a two-space indent for both content and hint', () => { + const component = new TruncatedOutputComponent('x\ny\nz', { + expanded: false, + isError: false, + maxLines: 1, + }); + + const lines = strip(component.render(80).join('\n')).split('\n'); + expect(lines[0]?.startsWith(' x')).toBe(true); + expect(lines[1]).toBe(' ... (2 more lines, ctrl+o to expand)'); + }); + + it('omits the ctrl+o promise when expandHint is false', () => { + const component = new TruncatedOutputComponent('a\nb\nc\nd', { + expanded: false, + isError: false, + maxLines: 2, + indent: 4, + expandHint: false, + }); + + const lines = strip(component.render(80).join('\n')).split('\n'); + expect(lines[2]).toBe(' ... (2 more lines)'); + }); + + it('renders all lines without a hint when expanded', () => { + const component = new TruncatedOutputComponent('a\nb\nc\nd', { + expanded: true, + isError: false, + maxLines: 2, + indent: 4, + }); + + const out = strip(component.render(80).join('\n')); + expect(out).toContain('d'); + expect(out).not.toContain('more lines, ctrl+o'); + }); + + it('keeps the truncation footer within the requested render width', () => { + const output = Array.from({ length: 20 }, (_, i) => `line ${String(i)}`).join('\n'); + const component = new TruncatedOutputComponent(output, { + expanded: false, + isError: false, + maxLines: 3, + indent: 2, + }); + + for (const line of component.render(37)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(37); + } + }); + + it('renders output verbatim, including literal <system> text in file content', () => { + // Tool metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so the renderer must not eat user data that + // merely contains the literal tag. + const component = new TruncatedOutputComponent( + '<system>literal text from a user file</system>\n<image path="/tmp/x.png">', + { expanded: true, isError: false }, + ); + const out = strip(component.render(80).join('\n')); + expect(out).toContain('<system>literal text from a user file</system>'); + expect(out).toContain('<image path="/tmp/x.png">'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 5e3883ca9..cf2598f82 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -1,8 +1,12 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +afterEach(() => { + currentTheme.setPalette(darkColors); +}); function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -11,7 +15,6 @@ function strip(text: string): string { describe('UsagePanelComponent', () => { it('formats session, context, and managed usage sections', () => { const lines = buildUsageReportLines({ - colors: darkColors, sessionUsage: { byModel: { kimi: { @@ -21,7 +24,7 @@ describe('UsagePanelComponent', () => { output: 250, }, }, - } as never, + }, contextUsage: 0.25, contextTokens: 2500, maxContextTokens: 10000, @@ -45,8 +48,144 @@ describe('UsagePanelComponent', () => { expect(lines.join('\n')).toContain('resets tomorrow'); }); + it('formats extra usage with a monthly limit', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(lines).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('100.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + // bar row contains block glyphs but no percentage text + expect(output).toContain('░'); + }); + + it('formats extra usage without a monthly limit and omits the progress bar', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 18208, + totalCents: 40000, + monthlyChargeLimitEnabled: false, + monthlyChargeLimitCents: 0, + monthlyUsedCents: 21792, + currency: 'CNY', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(lines).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('¥182.08'); + expect(output).toContain('Used this month'); + expect(output).toContain('¥217.92'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('Unlimited'); + expect(output).not.toContain('░'); + expect(output).not.toContain('█'); + }); + + it('omits the extra usage section when extraUsage is omitted or null', () => { + for (const extraUsage of [undefined, null]) { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { summary: null, limits: [], extraUsage }, + }).map(strip); + + expect(lines).not.toContain('Extra Usage'); + } + }); + + it('formats extra usage with CNY currency', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'CNY', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Balance'); + expect(output).toContain('100.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + }); + + it('aligns the currency symbol and decimal point across extra usage rows', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 15901, + totalCents: 300000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 300000, + monthlyUsedCents: 24099, + currency: 'CNY', + }, + }, + }).map(strip); + + const extraRows = lines.filter((line) => line.includes('¥')); + expect(extraRows).toHaveLength(3); + // The currency symbol stays in one column... + expect(new Set(extraRows.map((line) => line.indexOf('¥'))).size).toBe(1); + // ...and the right-aligned numeric parts end in the same column, so the + // decimal points line up across rows. + expect(new Set(extraRows.map((line) => line.length)).size).toBe(1); + }); + it('wraps preformatted usage lines in a bordered panel', () => { - const component = new UsagePanelComponent(['Session usage'], darkColors.primary); + const component = new UsagePanelComponent(() => ['Session usage'], 'primary'); const output = component.render(80).map(strip); expect(output[0]).toContain(' Usage '); @@ -55,7 +194,7 @@ describe('UsagePanelComponent', () => { it('truncates lines wider than the terminal so the panel never overflows', () => { const longLine = 'error: ' + 'x'.repeat(200); - const component = new UsagePanelComponent([longLine], darkColors.primary); + const component = new UsagePanelComponent(() => [longLine], 'primary'); const width = 60; const output = component.render(width); @@ -64,4 +203,30 @@ describe('UsagePanelComponent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(width); } }); + + it('keeps the bordered panel within narrow terminal widths', () => { + const component = new UsagePanelComponent(() => ['Session usage', ' kimi input 2.0k'], 'primary'); + + for (const width of [39, 24, 20, 10, 4, 1]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('rebuilds its body from the active palette on invalidate', () => { + // Emit the resolved palette value as visible text so the assertion holds + // regardless of chalk's colour level in the test environment. + const component = new UsagePanelComponent(() => [`text=${currentTheme.color('text')}`], 'primary'); + const bodyOf = (): string => { + const line = component.render(80).map(strip).find((l) => l.includes('text=')); + if (line === undefined) throw new Error('body line not found'); + return line; + }; + + expect(bodyOf()).toContain(darkColors.text); + currentTheme.setPalette(lightColors); + component.invalidate(); + expect(bodyOf()).toContain(lightColors.text); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index ab1d5752b..e6a10a05c 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -1,17 +1,23 @@ -import { describe, expect, it } from 'vitest'; +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; import { UserMessageComponent } from '#/tui/components/messages/user-message'; -import { darkColors } from '#/tui/theme/colors'; +import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } describe('UserMessageComponent', () => { + afterEach(() => { + resetCapabilitiesCache(); + }); + it('renders video placeholders as plain text, not inline image escapes', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const component = new UserMessageComponent( 'please inspect [video #1 sample.mov]', - darkColors, [], ); @@ -21,4 +27,81 @@ describe('UserMessageComponent', () => { expect(out).not.toContain('\u001B_G'); expect(out).not.toContain('\u001B]1337;File='); }); + + it('keeps user lines within very narrow widths', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + + const component = new UserMessageComponent('please inspect the attached output', []); + + for (const width of [1, 2, 4, 10, 39]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('does not truncate inline image escape sequences', () => { + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + + // Minimal 2000x1302 PNG bytes so the inline Kitty sequence is long enough + // to exceed a typical terminal width if treated as visible text. + const pngSignature = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdrLength = new Uint8Array([0x00, 0x00, 0x00, 0x0d]); + const ihdrType = new Uint8Array([0x49, 0x48, 0x44, 0x52]); + const widthBytes = new Uint8Array([ + (2000 >> 24) & 0xff, + (2000 >> 16) & 0xff, + (2000 >> 8) & 0xff, + 2000 & 0xff, + ]); + const heightBytes = new Uint8Array([ + (1302 >> 24) & 0xff, + (1302 >> 16) & 0xff, + (1302 >> 8) & 0xff, + 1302 & 0xff, + ]); + const rest = new Uint8Array([0x08, 0x02, 0x00, 0x00, 0x00]); + const bytes = new Uint8Array([ + ...pngSignature, + ...ihdrLength, + ...ihdrType, + ...widthBytes, + ...heightBytes, + ...rest, + ]); + + const attachment: ImageAttachment = { + id: 1, + kind: 'image', + bytes, + mime: 'image/png', + width: 2000, + height: 1302, + placeholder: '[image #1 (2000×1302)]', + }; + + const component = new UserMessageComponent('', [attachment]); + const lines = component.render(80); + + const imageLine = lines.find((l) => l.includes('\u001B_G')); + expect(imageLine).toBeDefined(); + expect(imageLine).not.toContain('\u001B[0m'); + expect(imageLine).not.toContain('…'); + expect(imageLine).toContain('\u001B\\'); // intact Kitty terminator + }); + + it('omits the sparkles bullet when an empty bullet is provided', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + + const withBullet = stripAnsi(new UserMessageComponent('hello', []).render(80).join('\n')); + expect(withBullet).toContain('✨'); + expect(withBullet).toContain('hello'); + + const lines = new UserMessageComponent('$ ls', [], '').render(80).map(stripAnsi); + const contentLine = lines.find((l) => l.includes('$ ls')); + expect(contentLine).toBeDefined(); + expect(stripAnsi(lines.join('\n'))).not.toContain('✨'); + // The `$` sits at the leading column where the bullet used to be. + expect(contentLine?.startsWith('$ ls')).toBe(true); + }); }); diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index bb846c7eb..101cd1911 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; -import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -13,10 +12,11 @@ function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp/proj', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 200_000, @@ -35,7 +35,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState { describe('FooterComponent — background task / agent badges', () => { it('omits both badges when counts are 0', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); const [line1] = footer.render(120); expect(line1).toBeDefined(); expect(strip(line1!)).not.toMatch(/tasks? running/); @@ -43,7 +43,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders the task badge alone when only bash tasks are running', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); @@ -51,7 +51,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders the agent badge alone when only agent tasks are running', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 agent running\]/); @@ -59,7 +59,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders both badges side by side when both are non-zero', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 3 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[2 tasks running\]/); @@ -69,7 +69,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('pluralizes correctly across both badges', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); @@ -77,7 +77,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('updates badges live via setBackgroundCounts', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 1 }); expect(strip(footer.render(120)[0]!)).toMatch(/\[2 tasks running\]/); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); @@ -87,7 +87,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('clamps negative counts to 0', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: -5, agentTasks: -2 }); const out = strip(footer.render(120)[0]!); expect(out).not.toMatch(/tasks? running/); @@ -95,7 +95,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('drops the badges when terminal is too narrow to fit them', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 4, agentTasks: 3 }); // Extremely narrow width: footer primary content fills the line, so leftLine wins. const [line1] = footer.render(20); diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index db34ee895..2eebee5e3 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -22,10 +22,11 @@ function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -44,7 +45,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState { describe('FooterComponent — context NaN resilience', () => { it('NaN usage → renders 0.0% (never literal "NaN%")', () => { - const fc = new FooterComponent(baseState({ contextUsage: Number.NaN }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: Number.NaN })); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); expect(out).toMatch(/context: 0\.0%/); @@ -53,7 +54,6 @@ describe('FooterComponent — context NaN resilience', () => { it('undefined-ish (coerced) usage → renders 0.0%', () => { const fc = new FooterComponent( baseState({ contextUsage: undefined as unknown as number }), - darkColors, ); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); @@ -61,13 +61,13 @@ describe('FooterComponent — context NaN resilience', () => { }); it('clamps ratios above 1.0 → renders 100.0%', () => { - const fc = new FooterComponent(baseState({ contextUsage: 1.5 }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: 1.5 })); const out = strip(fc.render(120).join('')); expect(out).toMatch(/context: 100\.0%/); }); it('ratio 0.427 → renders 42.7%', () => { - const fc = new FooterComponent(baseState({ contextUsage: 0.427 }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: 0.427 })); const out = strip(fc.render(200).join('')); expect(out).toMatch(/context: 42\.7%/); }); @@ -75,7 +75,6 @@ describe('FooterComponent — context NaN resilience', () => { it('tokens provided but max=0 → falls back to percent-only, no division-by-zero artefact', () => { const fc = new FooterComponent( baseState({ contextUsage: 0, contextTokens: 500, maxContextTokens: 0 }), - darkColors, ); const out = strip(fc.render(200).join('')); expect(out).not.toMatch(/Infinity|NaN/); @@ -85,7 +84,7 @@ describe('FooterComponent — context NaN resilience', () => { }); it('setState updates visible model and context values', () => { - const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 }), darkColors); + const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 })); footer.setState(baseState({ model: 'kimi-k2-5', contextUsage: 0.5 })); @@ -96,15 +95,15 @@ describe('FooterComponent — context NaN resilience', () => { }); it('shows "thinking" label when thinking is enabled, hides it when disabled', () => { - const on = new FooterComponent(baseState({ model: 'k2', thinking: true }), darkColors); - const off = new FooterComponent(baseState({ model: 'k2', thinking: false }), darkColors); + const on = new FooterComponent(baseState({ model: 'k2', thinkingEffort: 'on' })); + const off = new FooterComponent(baseState({ model: 'k2', thinkingEffort: 'off' })); expect(strip(on.render(120)[0]!)).toContain('thinking'); expect(strip(off.render(120)[0]!)).not.toContain('thinking'); }); it('renders transient hints on the context line', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setTransientHint('Press Ctrl-C again to exit'); @@ -134,7 +133,7 @@ describe('FooterComponent — context NaN resilience', () => { ); const primaryIndex = out.indexOf(hexToSgr(darkColors.primary)); - const statusIndex = out.indexOf(hexToSgr(darkColors.status)); + const statusIndex = out.indexOf(hexToSgr(darkColors.textDim)); const badgeIndex = out.indexOf('[PR#6]'); expect(statusIndex).toBeGreaterThanOrEqual(0); expect(primaryIndex).toBeGreaterThanOrEqual(0); diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts new file mode 100644 index 000000000..982be0657 --- /dev/null +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { FooterComponent } from '#/tui/components/chrome/footer'; +import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; +import type { AppState } from '#/tui/types'; + +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function baseState(overrides: Partial<AppState> = {}): AppState { + return { + model: 'k2', + workDir: '/tmp/proj', + additionalDirs: [], + sessionId: 'sess_1', + permissionMode: 'manual', + planMode: false, + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 200_000, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + theme: 'dark', + version: 'test', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + availableModels: {}, + ...overrides, + } as AppState; +} + +function goal(overrides: Partial<GoalSnapshot> = {}): GoalSnapshot { + return { + goalId: 'g1', + objective: 'Ship it', + status: 'active', + turnsUsed: 7, + tokensUsed: 1234, + wallClockMs: 245_000, // 4m05s + budget: { + turnBudget: null, + tokenBudget: null, + wallClockBudgetMs: null, + }, + ...overrides, + } as GoalSnapshot; +} + +describe('FooterComponent — goal badge', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('omits the badge when there is no goal', () => { + const footer = new FooterComponent(baseState({ goal: null })); + expect(strip(footer.render(160)[0]!)).not.toContain('[goal'); + }); + + it('shows status, elapsed, and a raw turn count for an unbounded active goal', () => { + const footer = new FooterComponent(baseState({ goal: goal() })); + const out = strip(footer.render(160)[0]!); + expect(out).toContain('[goal'); + expect(out).toContain('active'); + expect(out).toContain('4m'); + expect(out).toContain('7 turns'); + // No N/M when no turn budget is set. + expect(out).not.toMatch(/\d+\/\d+ turns/); + }); + + it('keeps counting elapsed time for an active goal between snapshots', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + + const footer = new FooterComponent( + baseState({ goal: goal({ wallClockMs: 0, turnsUsed: 0 }) }), + ); + + expect(strip(footer.render(160)[0]!)).toContain('0s'); + vi.setSystemTime(2_500); + expect(strip(footer.render(160)[0]!)).toContain('3s'); + }); + + it('requests a repaint while an active goal timer is visible', () => { + vi.useFakeTimers(); + const onRefresh = vi.fn(); + + new FooterComponent(baseState({ goal: goal({ wallClockMs: 0 }) }), onRefresh); + + vi.advanceTimersByTime(1_000); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('shows used/limit turns only when a turn budget is set', () => { + const footer = new FooterComponent( + baseState({ goal: goal({ budget: { turnBudget: 20, tokenBudget: null, wallClockBudgetMs: null } } as Partial<GoalSnapshot>) }), + ); + expect(strip(footer.render(160)[0]!)).toContain('7/20 turns'); + }); + + it('shows a paused badge', () => { + const footer = new FooterComponent(baseState({ goal: goal({ status: 'paused' }) })); + expect(strip(footer.render(160)[0]!)).toContain('paused'); + }); + + it('shows a blocked badge (resumable, still present)', () => { + const footer = new FooterComponent(baseState({ goal: goal({ status: 'blocked' }) })); + const out = strip(footer.render(160)[0]!); + expect(out).toContain('[goal'); + expect(out).toContain('blocked'); + }); + + it('hides the badge for a completed goal', () => { + const footer = new FooterComponent(baseState({ goal: goal({ status: 'complete' }) })); + expect(strip(footer.render(160)[0]!)).not.toContain('[goal'); + }); + + it('singularizes a single turn', () => { + const footer = new FooterComponent(baseState({ goal: goal({ turnsUsed: 1 }) })); + const out = strip(footer.render(160)[0]!); + expect(out).toContain('1 turn'); + expect(out).not.toContain('1 turns'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/panels/help-panel.test.ts b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts index 329932812..52f94430b 100644 --- a/apps/kimi-code/test/tui/components/panels/help-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from 'vitest'; import type { KimiSlashCommand } from '#/tui/commands/index'; import { HelpPanelComponent } from '#/tui/components/dialogs/help-panel'; -import { darkColors } from '#/tui/theme/colors'; function cmd(name: string, description: string, aliases: string[] = []): KimiSlashCommand { return { @@ -20,7 +19,6 @@ describe('HelpPanelComponent', () => { it('renders keyboard shortcuts + slash commands sections', () => { const panel = new HelpPanelComponent({ commands: [cmd('exit', 'Exit', ['quit', 'q'])], - colors: darkColors, onClose: () => {}, }); const out = strip(panel.render(80).join('\n')); @@ -34,26 +32,31 @@ describe('HelpPanelComponent', () => { expect(out).toMatch(/Exit/); }); - it('sorts slash commands by name', () => { + it('sorts unprefixed commands before skill commands and by name within each group', () => { const panel = new HelpPanelComponent({ - commands: [cmd('zebra', 'Z'), cmd('alpha', 'A'), cmd('mango', 'M')], - colors: darkColors, + commands: [ + cmd('zebra', 'Z'), + cmd('skill:bravo', 'B'), + cmd('alpha', 'A'), + cmd('mcp-config', 'M'), + ], onClose: () => {}, }); const out = strip(panel.render(80).join('\n')); const alphaIdx = out.indexOf('/alpha'); - const mangoIdx = out.indexOf('/mango'); + const mcpConfigIdx = out.indexOf('/mcp-config'); const zebraIdx = out.indexOf('/zebra'); + const skillBravoIdx = out.indexOf('/skill:bravo'); expect(alphaIdx).toBeGreaterThan(-1); - expect(alphaIdx).toBeLessThan(mangoIdx); - expect(mangoIdx).toBeLessThan(zebraIdx); + expect(alphaIdx).toBeLessThan(mcpConfigIdx); + expect(mcpConfigIdx).toBeLessThan(zebraIdx); + expect(zebraIdx).toBeLessThan(skillBravoIdx); }); it('Escape fires onClose', () => { const onClose = vi.fn(); const panel = new HelpPanelComponent({ commands: [], - colors: darkColors, onClose, }); panel.handleInput('\u001B'); // Esc @@ -64,7 +67,6 @@ describe('HelpPanelComponent', () => { const onClose = vi.fn(); const panel = new HelpPanelComponent({ commands: [], - colors: darkColors, onClose, }); panel.handleInput('q'); @@ -76,7 +78,6 @@ describe('HelpPanelComponent', () => { const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, `Desc ${String(i)}`)); const panel = new HelpPanelComponent({ commands: many, - colors: darkColors, onClose: () => {}, maxVisible: 6, }); @@ -88,7 +89,6 @@ describe('HelpPanelComponent', () => { const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, 'd')); const panel = new HelpPanelComponent({ commands: many, - colors: darkColors, onClose: () => {}, maxVisible: 6, }); diff --git a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts index 9b35a481f..1d970bb80 100644 --- a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts +++ b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts @@ -1,3 +1,6 @@ +import { pathToFileURL } from 'node:url'; + +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { PlanBoxComponent } from '#/tui/components/messages/plan-box'; @@ -15,7 +18,7 @@ function strip(text: string): string { .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); } -const theme = createMarkdownTheme(darkColors); +const theme = createMarkdownTheme(); describe('PlanBoxComponent', () => { it('falls back to bare " plan " title when no path is provided', () => { @@ -69,7 +72,7 @@ describe('PlanBoxComponent', () => { it('wraps the basename in an OSC 8 hyperlink targeting file://', () => { const box = new PlanBoxComponent('# Hello', theme, darkColors.success, '/tmp/plan.md'); const top = box.render(60)[0]!; - expect(top).toContain(`${ESC}]8;;file:///tmp/plan.md${BEL}plan.md${ESC}]8;;${BEL}`); + expect(top).toContain(`${ESC}]8;;${pathToFileURL('/tmp/plan.md').href}${BEL}plan.md${ESC}]8;;${BEL}`); // After stripping OSC + CSI, visible width must respect the requested render width. expect(strip(top).length).toBeLessThanOrEqual(60); }); @@ -77,7 +80,7 @@ describe('PlanBoxComponent', () => { it('skips the hyperlink for non-absolute paths but still shows the basename', () => { const box = new PlanBoxComponent('# Hello', theme, darkColors.success, 'relative/plan.md'); const top = box.render(60)[0]!; - expect(top).not.toContain(`${ESC}]8;`); + expect(top).not.toContain(`${ESC}];`); expect(strip(top)).toContain(' plan: plan.md '); }); @@ -89,44 +92,27 @@ describe('PlanBoxComponent', () => { expect(top).not.toContain('plan:'); }); - it('renders all lines when content fits under maxContentLines', () => { - const plan = Array.from({ length: 5 }, (_, i) => `- step ${String(i + 1)}`).join('\n'); - const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, { - maxContentLines: 20, - }); - const out = strip(box.render(80).join('\n')); - expect(out).toContain('step 1'); - expect(out).toContain('step 5'); - expect(out).not.toContain('ctrl+e to expand'); + it('keeps every line within narrow widths', () => { + const box = new PlanBoxComponent( + '# Hello\n\n' + 'step with a fairly long description '.repeat(4), + theme, + darkColors.success, + '/tmp/projects/foo/.kimi-code/plans/very-long-slug-name.md', + ); + + for (const width of [39, 14, 10, 8, 4, 1]) { + for (const line of box.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } }); - it('truncates content over maxContentLines with a footer inside the box', () => { + it('renders all plan lines without a truncation footer', () => { const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n'); - const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, { - maxContentLines: 10, - }); - const rendered = box.render(80); - const out = strip(rendered.join('\n')); - expect(out).toContain('step 1'); - expect(out).toContain('step 9'); - expect(out).not.toContain('step 10'); - expect(out).toMatch(/\.\.\. \(\d+ more lines, ctrl\+e to expand\)/); - // Footer must live inside the bordered box, not after it. - const footerIdx = rendered.findIndex((line) => strip(line).includes('ctrl+e to expand')); - const bottomIdx = rendered.findIndex((line) => strip(line).includes('└')); - expect(footerIdx).toBeGreaterThan(-1); - expect(footerIdx).toBeLessThan(bottomIdx); - expect(strip(rendered[footerIdx]!)).toMatch(/^\s+│.*│\s*$/); - }); - - it('renders the full plan when expanded is true, ignoring maxContentLines', () => { - const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n'); - const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, { - maxContentLines: 10, - expanded: true, - }); + const box = new PlanBoxComponent(plan, theme, darkColors.success); const out = strip(box.render(80).join('\n')); + expect(out).toContain('step 1'); expect(out).toContain('step 30'); - expect(out).not.toContain('ctrl+e to expand'); + expect(out).not.toContain('more lines'); }); }); diff --git a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts index 2382c0a60..ee6dcbb1c 100644 --- a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts @@ -2,10 +2,10 @@ import { describe, it, expect } from 'vitest'; import { TodoPanelComponent, + formatHiddenCounts, selectVisibleTodos, type TodoItem, } from '#/tui/components/chrome/todo-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -13,13 +13,13 @@ function strip(text: string): string { describe('TodoPanelComponent', () => { it('returns no lines when empty (so the layout slot collapses)', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); expect(panel.render(80)).toEqual([]); expect(panel.isEmpty()).toBe(true); }); it('renders a Todo header + one row per entry', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 'Investigate parser', status: 'done' }, { title: 'Add tests', status: 'in_progress' }, @@ -34,7 +34,7 @@ describe('TodoPanelComponent', () => { }); it('setTodos replaces the list (not appends)', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([{ title: 'old', status: 'pending' }]); panel.setTodos([{ title: 'new', status: 'in_progress' }]); const out = strip(panel.render(80).join('\n')); @@ -43,7 +43,7 @@ describe('TodoPanelComponent', () => { }); it('clear() wipes the list and reverts to empty', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([{ title: 'x', status: 'pending' }]); panel.clear(); expect(panel.isEmpty()).toBe(true); @@ -51,7 +51,7 @@ describe('TodoPanelComponent', () => { }); it('defensive copy: external mutation does not leak into the panel', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); const source: TodoItem[] = [{ title: 'foo', status: 'pending' }]; panel.setTodos(source); source[0] = { title: 'hacked', status: 'done' }; @@ -61,7 +61,7 @@ describe('TodoPanelComponent', () => { }); it('renders all todos and no overflow footer when count <= 5', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 'a', status: 'done' }, { title: 'b', status: 'in_progress' }, @@ -76,7 +76,7 @@ describe('TodoPanelComponent', () => { }); it('appends "+N more" footer when count > 5', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 't0', status: 'done' }, { title: 't1', status: 'in_progress' }, @@ -89,6 +89,110 @@ describe('TodoPanelComponent', () => { const out = strip(panel.render(80).join('\n')); expect(out).toMatch(/\+2 more/); }); + + const many = (n: number): TodoItem[] => + Array.from({ length: n }, (_, i) => ({ title: `t${i}`, status: 'pending' as const })); + + it('hasOverflow() is false when count <= 5 and true when count > 5', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(5)); + expect(panel.hasOverflow()).toBe(false); + panel.setTodos(many(6)); + expect(panel.hasOverflow()).toBe(true); + }); + + it('collapsed footer advertises "ctrl+t to expand"', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+2 more/); + expect(out).toMatch(/ctrl\+t to expand/); + }); + + it('collapsed footer shows hidden status distribution', () => { + const panel = new TodoPanelComponent(); + panel.setTodos([ + ...Array.from({ length: 6 }, (_, i) => ({ + title: `ip${i}`, + status: 'in_progress' as const, + })), + ...Array.from({ length: 3 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ...Array.from({ length: 3 }, (_, i) => ({ title: `p${i}`, status: 'pending' as const })), + ]); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+7 more \(3 done · 1 in progress · 3 pending\)/); + expect(out).toMatch(/ctrl\+t to expand/); + }); + + it('collapsed footer omits zero-count statuses', () => { + const panel = new TodoPanelComponent(); + panel.setTodos( + Array.from({ length: 8 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+3 more \(3 done\)/); + expect(out).not.toMatch(/0 in progress/); + expect(out).not.toMatch(/0 pending/); + }); + + it('expanded footer does not include status distribution', () => { + const panel = new TodoPanelComponent(); + panel.setTodos( + Array.from({ length: 8 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ); + panel.setExpanded(true); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/all 8 items · ctrl\+t to collapse/); + expect(out).not.toMatch(/\d+ done ·/); + }); + + it('renders every todo with a collapse hint when expanded', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + panel.setExpanded(true); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/t0/); + expect(out).toMatch(/t6/); + expect(out).not.toMatch(/\+\d+ more/); + expect(out).toMatch(/ctrl\+t to collapse/); + }); + + it('toggleExpanded() flips between collapsed and expanded', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + expect(strip(panel.render(80).join('\n'))).toMatch(/\+2 more/); + panel.toggleExpanded(); + expect(strip(panel.render(80).join('\n'))).toMatch(/ctrl\+t to collapse/); + panel.toggleExpanded(); + expect(strip(panel.render(80).join('\n'))).toMatch(/\+2 more/); + }); + + it('setTodos() keeps the expanded state across list updates', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + panel.setExpanded(true); + panel.setTodos([ + { title: 'u0', status: 'pending' }, + { title: 'u1', status: 'pending' }, + { title: 'u2', status: 'pending' }, + { title: 'u3', status: 'pending' }, + { title: 'u4', status: 'pending' }, + { title: 'u5', status: 'pending' }, + { title: 'u6', status: 'pending' }, + ]); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/u6/); + expect(out).toMatch(/ctrl\+t to collapse/); + }); + + it('clear() resets the expanded state', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + panel.setExpanded(true); + panel.clear(); + panel.setTodos(many(7)); + expect(strip(panel.render(80).join('\n'))).toMatch(/\+2 more/); + }); }); describe('selectVisibleTodos', () => { @@ -239,4 +343,41 @@ describe('selectVisibleTodos', () => { expect(rows.map((r) => r.title)).toEqual(['ip0', 'ip1', 'ip2', 'ip3', 'ip4']); expect(hidden).toBe(2); }); + + it('returns hiddenCounts reflecting the hidden items', () => { + const todos: TodoItem[] = [ + ...Array.from({ length: 6 }, (_, i) => T(`ip${i}`, 'in_progress')), + ...Array.from({ length: 3 }, (_, i) => T(`d${i}`, 'done')), + ...Array.from({ length: 3 }, (_, i) => T(`p${i}`, 'pending')), + ]; + const { hidden, hiddenCounts } = selectVisibleTodos(todos); + expect(hidden).toBe(7); + expect(hiddenCounts).toEqual({ done: 3, in_progress: 1, pending: 3 }); + }); + + it('returns zero hiddenCounts when count <= 5', () => { + const todos: TodoItem[] = [T('a', 'done'), T('b', 'in_progress'), T('c', 'pending')]; + const { hidden, hiddenCounts } = selectVisibleTodos(todos); + expect(hidden).toBe(0); + expect(hiddenCounts).toEqual({ done: 0, in_progress: 0, pending: 0 }); + }); +}); + +describe('formatHiddenCounts', () => { + it('formats all three statuses in done / in progress / pending order', () => { + expect(formatHiddenCounts({ done: 2, in_progress: 1, pending: 3 })).toBe( + '2 done · 1 in progress · 3 pending', + ); + }); + + it('omits zero-count statuses', () => { + expect(formatHiddenCounts({ done: 5, in_progress: 0, pending: 0 })).toBe('5 done'); + expect(formatHiddenCounts({ done: 0, in_progress: 2, pending: 3 })).toBe( + '2 in progress · 3 pending', + ); + }); + + it('returns empty string when all counts are zero', () => { + expect(formatHiddenCounts({ done: 0, in_progress: 0, pending: 0 })).toBe(''); + }); }); diff --git a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts index 383c43693..76acd438c 100644 --- a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts @@ -1,8 +1,31 @@ -import { Text } from '@earendil-works/pi-tui'; +import { Text, visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ActivityPaneComponent } from '#/tui/components/panes/activity-pane'; +function createMockSpinner(initialText = 'working') { + const spinner = new Text(initialText, 0, 0); + let tip = ''; + let availableWidth = 0; + const update = () => { + const fullText = initialText + tip; + spinner.setText(availableWidth > 0 && visibleWidth(fullText) > availableWidth ? initialText : fullText); + }; + return { + spinner: Object.assign(spinner, { + setTip(value: string) { + tip = value; + update(); + }, + setAvailableWidth(width: number) { + availableWidth = width; + update(); + }, + }) as unknown as import('#/tui/components/chrome/moon-loader').MoonLoader, + getTip: () => tip, + }; +} + describe('ActivityPaneComponent', () => { it('renders waiting loader after a spacer', () => { const component = new ActivityPaneComponent({ @@ -22,8 +45,53 @@ describe('ActivityPaneComponent', () => { expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); }); + it.each(['waiting', 'tool', 'composing'] as const)( + 'renders %s spinner with tip after a spacer', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + tip: 'ctrl+s: steer mid-turn', + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual([ + '', + 'working · Tip: ctrl+s: steer mid-turn', + ]); + }, + ); + + it.each(['waiting', 'tool', 'composing'] as const)( + 'does not render a tip for %s when none is provided', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); + }, + ); + it('renders nothing for hidden and thinking modes', () => { expect(new ActivityPaneComponent({ mode: 'hidden' }).render(80)).toEqual([]); expect(new ActivityPaneComponent({ mode: 'thinking' }).render(80)).toEqual([]); }); + + it.each(['waiting', 'tool', 'composing'] as const)( + 'hides the tip for %s when the terminal is too narrow', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + tip: 'ctrl+s: steer mid-turn', + }); + + // Width 8 is exactly the width of "working" (no spinner frame in the mock). + expect(component.render(8).map((line) => line.trimEnd())).toEqual(['', 'working']); + }, + ); }); diff --git a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts index fbb5e4979..0f9a01177 100644 --- a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { QueuePaneComponent } from '#/tui/components/panes/queue-pane'; -import { darkColors } from '#/tui/theme/colors'; function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,7 +9,6 @@ function stripAnsi(text: string): string { describe('QueuePaneComponent', () => { it('renders queued messages with the steer hint', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: false, isStreaming: true, canSteerImmediately: true, @@ -29,7 +27,6 @@ describe('QueuePaneComponent', () => { it('renders compaction hint when waiting for compaction', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: true, isStreaming: false, canSteerImmediately: true, @@ -43,7 +40,6 @@ describe('QueuePaneComponent', () => { it('omits the steer hint when immediate steering is disabled', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: false, isStreaming: true, canSteerImmediately: false, @@ -55,4 +51,72 @@ describe('QueuePaneComponent', () => { expect(output).toContain('will send after current task'); expect(output).not.toContain('ctrl-s to steer immediately'); }); + + it('truncates long messages to a single line', () => { + const longText = 'a'.repeat(200); + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: longText }], + }); + + const lines = component.render(30); + expect(lines).toHaveLength(3); // border + message + hint + const messageLine = stripAnsi(lines[1] as string); + expect(messageLine).not.toContain('a'.repeat(30)); + expect(messageLine.endsWith('…')).toBe(true); + }); + + it('collapses multiline text into a single line', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'line one\nline two\nline three' }], + }); + + const lines = component.render(120); + expect(lines).toHaveLength(3); // border + message + hint + const messageLine = stripAnsi(lines[1] as string); + expect(messageLine).toContain('line one line two line three'); + expect(messageLine).not.toContain('\n'); + }); + + it('renders bash queued items with a $ prompt to distinguish them from text', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: false, + messages: [{ text: 'ls -la', mode: 'bash' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).toContain('❯ $ ls -la'); + }); + + it('omits the steer hint when every queued item is a bash command', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'ls', mode: 'bash' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).not.toContain('ctrl-s to steer immediately'); + expect(output).toContain('will send after current task'); + }); + + it('keeps the steer hint when at least one queued item is steerable', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'ls', mode: 'bash' }, { text: 'focus on tests' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).toContain('ctrl-s to steer immediately'); + }); }); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index a7ddf4509..c4c5035cd 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -32,9 +32,11 @@ describe('TUI config', () => { expect(result).toEqual(DEFAULT_TUI_CONFIG); const text = readFileSync(filePath, 'utf-8'); - expect(text).toContain('Terminal UI preferences for kimi-code.'); + expect(text).toContain('Client preferences for kimi-code.'); expect(text).toContain('theme = "auto"'); expect(text).toContain('command = ""'); + expect(text).toContain('[upgrade]'); + expect(text).toContain('auto_install = true'); expect(text).toContain('[notifications]'); expect(text).toContain('enabled = true'); expect(text).toContain('notification_condition = "unfocused"'); @@ -50,15 +52,29 @@ command = "code --wait" [notifications] enabled = false notification_condition = "always" + +[upgrade] +auto_install = false `); expect(config).toEqual({ theme: 'light', + disablePasteBurst: false, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, + upgrade: { autoInstall: false }, }); }); + it('parses disable_paste_burst', () => { + const config = parseTuiConfig(` +theme = "dark" +disable_paste_burst = true +`); + + expect(config.disablePasteBurst).toBe(true); + }); + it('normalizes an empty editor command to auto-detect', () => { const config = parseTuiConfig(` [editor] @@ -67,8 +83,10 @@ command = " " expect(config).toEqual({ theme: 'auto', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, }); }); @@ -76,6 +94,7 @@ command = " " const config = parseTuiConfig(`theme = "dark"`); expect(config.notifications).toEqual({ enabled: true, condition: 'unfocused' }); + expect(config.upgrade).toEqual({ autoInstall: true }); }); it('throws TuiConfigParseError with fallback when parsing fails, leaving the file untouched', async () => { @@ -96,16 +115,36 @@ command = " " await saveTuiConfig( { theme: 'light', + disablePasteBurst: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, + upgrade: { autoInstall: false }, }, filePath, ); expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', + disablePasteBurst: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, + upgrade: { autoInstall: false }, }); }); + + it('escapes special characters in a custom theme name so the TOML round-trips', async () => { + const theme = 'weird"name\\with-quote'; + await saveTuiConfig( + { + theme, + disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, + editorCommand: null, + notifications: DEFAULT_TUI_CONFIG.notifications, + upgrade: DEFAULT_TUI_CONFIG.upgrade, + }, + filePath, + ); + + expect((await loadTuiConfig(filePath)).theme).toBe(theme); + }); }); diff --git a/apps/kimi-code/test/tui/constant/tips.test.ts b/apps/kimi-code/test/tui/constant/tips.test.ts new file mode 100644 index 000000000..de0f69ef3 --- /dev/null +++ b/apps/kimi-code/test/tui/constant/tips.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { ALL_TIPS, WORKING_TIPS } from '#/tui/constant/tips'; + +describe('tips constants', () => { + it('ALL_TIPS is non-empty', () => { + expect(ALL_TIPS.length).toBeGreaterThan(0); + }); + + it('tip texts are unique across ALL_TIPS', () => { + const texts = ALL_TIPS.map((tip) => tip.text); + expect(new Set(texts).size).toBe(texts.length); + }); + + it('every tip has a non-empty text', () => { + for (const tip of ALL_TIPS) { + expect(tip.text.length).toBeGreaterThan(0); + } + }); + + it('every tip has valid optional properties', () => { + for (const tip of ALL_TIPS) { + if (tip.priority !== undefined) { + expect(tip.priority).toBeGreaterThan(0); + } + if (tip.solo !== undefined) { + expect(typeof tip.solo).toBe('boolean'); + } + } + }); + + it('WORKING_TIPS is non-empty', () => { + expect(WORKING_TIPS.length).toBeGreaterThan(0); + }); + + it('every working tip is included in ALL_TIPS', () => { + for (const workingTip of WORKING_TIPS) { + expect(ALL_TIPS.some((tip) => tip.text === workingTip.text)).toBe(true); + } + }); + + it('shared working tips match ALL_TIPS priority and solo values', () => { + for (const workingTip of WORKING_TIPS) { + const allTip = ALL_TIPS.find((tip) => tip.text === workingTip.text); + expect(allTip).toBeDefined(); + expect(allTip?.priority).toBe(workingTip.priority); + expect(allTip?.solo).toBe(workingTip.solo); + } + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts new file mode 100644 index 000000000..2bc4c5b4e --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts @@ -0,0 +1,672 @@ +import type { TUI } from '@moonshot-ai/pi-tui'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ClipboardImageHintController, + type ClipboardImageHintHost, +} from '#/tui/controllers/clipboard-image-hint'; +import type { FooterComponent } from '#/tui/components/chrome/footer'; +import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '#/tui/utils/terminal-focus'; +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; + +vi.mock('#/utils/clipboard/clipboard-has-image', () => ({ + clipboardHasImage: vi.fn(async () => false), +})); + +type FakeTUI = TUI & { emitInput(data: string): void }; + +interface FakeFooter { + hint: string | null; + setTransientHint(hint: string | null): void; + getTransientHint(): string | null; +} + +function createFakeFooter(): FooterComponent { + const footer: FakeFooter = { + hint: null, + setTransientHint(hint: string | null): void { + this.hint = hint; + }, + getTransientHint(): string | null { + return this.hint; + }, + }; + return footer as unknown as FooterComponent; +} + +function createFakeTUI(): FakeTUI { + const listeners = new Set<(data: string) => { consume?: boolean; data?: string } | undefined>(); + return { + addInputListener: vi.fn((listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + emitInput: (data: string) => { + for (const listener of listeners) { + listener(data); + } + }, + requestRender: vi.fn(), + } as unknown as FakeTUI; +} + +function createFakeTUIWithConsumingFocusTracker(): FakeTUI { + const listeners = new Set<(data: string) => { consume?: boolean; data?: string } | undefined>(); + return { + addInputListener: vi.fn((listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + emitInput: (data: string) => { + for (const listener of listeners) { + const result = listener(data); + if (result?.consume) return; + } + }, + requestRender: vi.fn(), + } as unknown as FakeTUI; +} + +// Drive the controller through its first clipboard observation with an empty +// clipboard. The first observation only establishes a baseline and never shows +// a hint, leaving the controller armed and ready for the next new image. +async function primeEmptyBaseline(ui: FakeTUI): Promise<void> { + vi.mocked(clipboardHasImage).mockResolvedValue(false); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + +// Simulate the user returning to the terminal and let the debounced check fire. +async function focusReturnAndFlush(ui: FakeTUI): Promise<void> { + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + +describe('ClipboardImageHintController', () => { + let platformSpy: ReturnType<typeof vi.spyOn> | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.mocked(clipboardHasImage).mockResolvedValue(false); + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + }); + + afterEach(() => { + platformSpy?.mockRestore(); + vi.useRealTimers(); + }); + + it('does not show a hint for an image already in the clipboard at startup', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Startup baseline observes the image already present; the focus check + // runs too but must stay quiet for that same image. + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + // One call is the startup baseline; the second is the focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('shows hint when a new image is copied during the session', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); + + controller.stop(); + }); + + it('shows hint for the first image copied after startup when startup baseline was empty', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + expect(footer.getTransientHint()).toBeNull(); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); + + controller.stop(); + }); + + it('does not show hint when model does not support images', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => false, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('does not repeat the hint for the same lingering image', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Establish the baseline and show a hint for the first image. + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + // The same image is still in the clipboard: focusing again must not nag. + vi.mocked(clipboardHasImage).mockClear(); + footer.setTransientHint(null); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + + controller.stop(); + }); + + it('shows the hint again for a new image after the clipboard is cleared', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Establish the baseline, then show a hint for the first image. + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + // Clipboard cleared: the empty check re-arms the controller. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + footer.setTransientHint(null); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // A genuinely new image: hint shows again. + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('clears the hint after the display duration', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(4000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('cancels a pending debounced check when focus is lost', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await vi.advanceTimersByTimeAsync(0); + vi.mocked(clipboardHasImage).mockClear(); + + ui.emitInput(TERMINAL_FOCUS_IN); + ui.emitInput(TERMINAL_FOCUS_OUT); + await vi.advanceTimersByTimeAsync(1000); + + expect(clipboardHasImage).not.toHaveBeenCalled(); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('handles rapid focus churn without duplicate checks or hints', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + vi.mocked(clipboardHasImage).mockClear(); + + for (let i = 0; i < 5; i++) { + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + } + + await vi.advanceTimersByTimeAsync(1000); + + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + expect(footer.getTransientHint()).not.toBeNull(); + + controller.stop(); + }); + + it('ignores stale clipboard read result when focus is lost', async () => { + vi.mocked(clipboardHasImage).mockImplementation( + () => new Promise((resolve) => setTimeout(() => { resolve(true); }, 1500)), + ); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + // One call is the startup baseline; the second is the debounced focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + + ui.emitInput(TERMINAL_FOCUS_OUT); + await vi.advanceTimersByTimeAsync(1500); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('ignores a pending clipboard read result after stop', async () => { + let resolveDeferred: (value: boolean) => void = () => {}; + vi.mocked(clipboardHasImage).mockImplementation( + () => new Promise<boolean>((resolve) => { + resolveDeferred = resolve; + }), + ); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + // One call is the startup baseline; the second is the debounced focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + + controller.stop(); + resolveDeferred(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toBeNull(); + }); + + it('clears a displayed hint when stopped', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).not.toBeNull(); + + controller.stop(); + expect(footer.getTransientHint()).toBeNull(); + expect(host.requestRender).toHaveBeenCalled(); + }); + + it('does not clear a hint set by another caller when stopped', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const requestRender = vi.fn(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender, + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // First observation only establishes the baseline and sets no hint. + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + const otherHint = 'Other hint'; + footer.setTransientHint(otherHint); + + const requestRenderCalls = requestRender.mock.calls.length; + controller.stop(); + expect(footer.getTransientHint()).toBe(otherHint); + expect(host.requestRender).toHaveBeenCalledTimes(requestRenderCalls); + }); + + it('uses only the latest clipboard read result after focus churn', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Establish an empty baseline with a normal resolved read first. + await primeEmptyBaseline(ui); + + const deferreds: Array<{ resolve: (value: boolean) => void; promise: Promise<boolean> }> = []; + vi.mocked(clipboardHasImage).mockImplementation(() => { + let resolve: (value: boolean) => void = () => {}; + const promise = new Promise<boolean>((res) => { + resolve = res; + }); + deferreds.push({ resolve, promise }); + return promise; + }); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(deferreds).toHaveLength(1); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(deferreds).toHaveLength(2); + + deferreds[0]!.resolve(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toBeNull(); + + deferreds[1]!.resolve(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('keeps the existing auto-clear timer when a re-check exits early', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).not.toBeNull(); + + // Trigger a re-check that exits early because the clipboard is now empty. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + await focusReturnAndFlush(ui); + + // The previous hint should still be visible because its auto-clear timer + // was preserved through the re-check. + expect(footer.getTransientHint()).not.toBeNull(); + + // Advance the remaining original display duration and verify it expires. + await vi.advanceTimersByTimeAsync(3000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('does not clear a matching hint owned by another caller after auto-clear', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + const hintText = footer.getTransientHint(); + expect(hintText).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(4000); + expect(footer.getTransientHint()).toBeNull(); + + // Another caller sets the same hint text the controller previously used. + footer.setTransientHint(hintText); + + controller.stop(); + expect(footer.getTransientHint()).toBe(hintText); + }); + + it('re-establishes the baseline after stop and restart', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Image already present at start: baseline only, no hint. + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + controller.start(); + + // After restart the image is still present: baseline again, no hint. + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // Clipboard cleared: re-arms the controller. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // A genuinely new image: hint shows. + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('observes focus events even when another listener consumes them', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUIWithConsumingFocusTracker(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Register a second listener that consumes focus events, like installTerminalFocusTracking. + const consumedEvents: string[] = []; + ui.addInputListener((data) => { + if (data === TERMINAL_FOCUS_IN || data === TERMINAL_FOCUS_OUT) { + consumedEvents.push(data); + return { consume: true }; + } + return undefined; + }); + + // Baseline observation (consumed), then a new image on the next focus. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + expect(consumedEvents).toEqual([TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT, TERMINAL_FOCUS_IN]); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('shows Alt+V shortcut on Windows', async () => { + platformSpy?.mockRestore(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Alt\+V/); + + controller.stop(); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts new file mode 100644 index 000000000..b87b2d4d5 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -0,0 +1,299 @@ +/** + * Clipboard image paste → attachment store, with ingestion-time compression. + * + * Tests pin: + * - an oversized pasted image is downsampled while building the attachment, + * so the stored bytes, the `[image #N (W×H)]` placeholder, and the eventual + * submitted image all agree on the compressed size + * - the pre-compression original is persisted and recorded on the + * attachment, so the submitted prompt can announce the compression and + * point the model at the full-fidelity bytes + * - a within-budget paste is stored byte-for-byte (fast path), with no + * original recorded + */ + +import { mkdtemp, readFile, rm, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Jimp } from 'jimp'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + EditorKeyboardController, + type EditorKeyboardHost, +} from '#/tui/controllers/editor-keyboard'; +import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; +import { parseImageMeta } from '#/utils/image/image-mime'; +import { ImageLimits, type KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +// vitest hoists vi.mock/vi.hoisted above the imports above, so the mock still +// applies to the editor-keyboard module that pulls in readClipboardMedia. +const { readClipboardMedia } = vi.hoisted(() => ({ readClipboardMedia: vi.fn() })); + +vi.mock('#/utils/clipboard/clipboard-image', async (importActual) => { + const actual = await importActual<typeof import('#/utils/clipboard/clipboard-image')>(); + return { ...actual, readClipboardMedia }; +}); + +interface PasteHarness { + readonly store: ImageAttachmentStore; + readonly track: ReturnType<typeof vi.fn>; + pasteImage(): Promise<void>; +} + +function createPasteHarness(options: { sessionDir?: string; imageLimits?: ImageLimits } = {}): PasteHarness { + const editor: Record<string, ((...args: never[]) => unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + }; + const store = new ImageAttachmentStore(); + const track = vi.fn(); + const host = { + state: { + editor, + activeDialog: null, + appState: { streamingPhase: 'idle', isCompacting: false }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: + options.sessionDir === undefined + ? undefined + : { summary: { sessionDir: options.sessionDir } }, + btwPanelController: { closeOrCancel: vi.fn(() => false) }, + track, + showError: vi.fn(), + openUndoSelector: vi.fn(), + cancelRunningShellCommand: vi.fn(), + } as unknown as EditorKeyboardHost; + if (options.imageLimits !== undefined) { + (host as unknown as { harness: KimiHarness }).harness = { + imageLimits: options.imageLimits, + } as unknown as KimiHarness; + } + + const controller = new EditorKeyboardController(host, store); + controller.install(); + + return { + store, + track, + async pasteImage() { + const handler = editor['onPasteImage']; + if (handler === undefined) throw new Error('onPasteImage handler not installed'); + await (handler as () => Promise<boolean>)(); + }, + }; +} + +async function solidPng(width: number, height: number): Promise<Uint8Array> { + return new Uint8Array( + await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/png'), + ); +} + +async function solidJpeg(width: number, height: number): Promise<Uint8Array> { + return new Uint8Array( + await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/jpeg', { quality: 90 }), + ); +} + +/** + * Insert a minimal EXIF APP1 segment carrying only an Orientation tag right + * after the JPEG SOI marker (jimp itself never writes EXIF). Mirrors the + * fixture in agent-core's image-compress tests. + */ +function withExifOrientation(jpeg: Uint8Array, orientation: number): Uint8Array { + // TIFF body, little-endian: 8-byte header + IFD0 with a single entry. + const tiff = Buffer.alloc(26); + tiff.write('II', 0, 'latin1'); + tiff.writeUInt16LE(42, 2); + tiff.writeUInt32LE(8, 4); // offset of IFD0 + tiff.writeUInt16LE(1, 8); // one directory entry + tiff.writeUInt16LE(0x0112, 10); // tag: Orientation + tiff.writeUInt16LE(3, 12); // type: SHORT + tiff.writeUInt32LE(1, 14); // count + tiff.writeUInt16LE(orientation, 18); // value, left-aligned in the 4-byte field + tiff.writeUInt32LE(0, 22); // no next IFD + const exifBody = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]); + const app1Header = Buffer.alloc(4); + app1Header.writeUInt16BE(0xff_e1, 0); + app1Header.writeUInt16BE(exifBody.length + 2, 2); + return new Uint8Array( + Buffer.concat([ + Buffer.from(jpeg.subarray(0, 2)), // SOI + app1Header, + exifBody, + Buffer.from(jpeg.subarray(2)), + ]), + ); +} + +describe('clipboard image paste compression', () => { + beforeEach(() => { + readClipboardMedia.mockReset(); + }); + + it('downsamples an oversized pasted image before storing it', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + expect(store.size()).toBe(1); + const att = store.get(1); + expect(att?.kind).toBe('image'); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + + // Stored metadata reflects the compressed size. + expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000); + expect(att.placeholder).toContain('2000×1000'); + + // The stored bytes decode to the compressed dimensions — the thumbnail and + // the submitted image both read from these bytes, so they cannot diverge. + const dims = parseImageMeta(att.bytes); + expect(dims).not.toBeNull(); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(3000); + }); + + it('honors the harness [image] max_edge_px when pasting', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness({ + imageLimits: new ImageLimits(process.env, { maxEdgePx: 800 }), + }); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + // The harness [image] config — not the built-in 2000px — drives ingestion. + expect(Math.max(att.width, att.height)).toBe(800); + expect(att.placeholder).toContain('800×400'); + const dims = parseImageMeta(att.bytes); + expect(dims).not.toBeNull(); + expect(Math.max(dims!.width, dims!.height)).toBe(800); + }); + + it('records and persists the pre-compression original for an oversized paste', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.original).toBeDefined(); + expect(att.original?.width).toBe(3600); + expect(att.original?.height).toBe(1800); + expect(att.original?.byteLength).toBe(big.length); + expect(att.original?.mime).toBe('image/png'); + + // The original bytes are readable back from the persisted path. + expect(att.original?.path).not.toBeNull(); + const persisted = await readFile(att.original!.path!); + expect(new Uint8Array(persisted)).toEqual(big); + await unlink(att.original!.path!).catch(() => undefined); + }); + + it('persists the original into the session media-originals dir when the session is known', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-paste-session-')); + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness({ sessionDir }); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.original?.path).not.toBeNull(); + expect(att.original!.path!.startsWith(join(sessionDir, 'media-originals'))).toBe(true); + const persisted = await readFile(att.original!.path!); + expect(new Uint8Array(persisted)).toEqual(big); + await rm(sessionDir, { recursive: true, force: true }); + }); + + it('stores a within-budget paste byte-for-byte', async () => { + const small = await solidPng(80, 80); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: small, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.width).toBe(80); + expect(att.height).toBe(80); + expect(att.bytes).toBe(small); // identity: no re-encode on the fast path + expect(att.original).toBeUndefined(); + }); + + it( + 'records an EXIF-rotated compressed original in display space', + async () => { + // Orientation 6 (rotate 90° CW): the header says 3600x400, but the image + // decodes to 400x3600 — the space the compressed bytes and any later + // ReadMediaFile region readback live in. The recorded original (which + // drives the submit-time compression caption) must match that space, or + // the caption contradicts the sent image's aspect and region coordinates + // land axis-swapped. (Kept narrow: pure-JS decode+rotate+encode of a + // larger frame can outlast the test timeout on slow CI runners.) + const portrait = withExifOrientation(await solidJpeg(3600, 400), 6); + readClipboardMedia.mockResolvedValue({ + kind: 'image', + bytes: portrait, + mimeType: 'image/jpeg', + }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.original?.width).toBe(400); + expect(att.original?.height).toBe(3600); + // The compressed attachment itself keeps the portrait aspect. + expect(att.width).toBeLessThan(att.height); + await unlink(att.original!.path!).catch(() => undefined); + }, + 15_000, + ); + + it('stores display-space dimensions for an EXIF-rotated untouched paste', async () => { + // Within budgets → sent byte-for-byte, but the placeholder and metadata + // must still describe the display (rotated) space. + const portrait = withExifOrientation(await solidJpeg(120, 80), 6); + readClipboardMedia.mockResolvedValue({ + kind: 'image', + bytes: portrait, + mimeType: 'image/jpeg', + }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.bytes).toBe(portrait); // fast path — untouched + expect(att.original).toBeUndefined(); + expect(att.width).toBe(80); + expect(att.height).toBe(120); + expect(att.placeholder).toContain('80×120'); + }); + + it('emits image_compress telemetry tagged tui_paste through host.track', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { track, pasteImage } = createPasteHarness(); + await pasteImage(); + + const compressCalls = track.mock.calls.filter(([event]) => event === 'image_compress'); + expect(compressCalls).toHaveLength(1); + const props = compressCalls[0]![1] as Record<string, unknown>; + expect(props['source']).toBe('tui_paste'); + expect(props['outcome']).toBe('compressed'); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts new file mode 100644 index 000000000..090d47d50 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DOUBLE_ESC_WINDOW_MS } from '#/tui/constant/kimi-tui'; +import { + EditorKeyboardController, + type EditorKeyboardHost, +} from '#/tui/controllers/editor-keyboard'; +import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; + +interface Harness { + readonly host: EditorKeyboardHost; + readonly editor: Record<string, ((...args: never[]) => unknown) | undefined>; + readonly openUndoSelector: ReturnType<typeof vi.fn>; + readonly cancelRunningShellCommand: ReturnType<typeof vi.fn>; +} + +function createHarness(options: { streamingPhase?: string; isCompacting?: boolean } = {}): Harness { + const editor: Record<string, ((...args: never[]) => unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + setInputMode: vi.fn() as unknown as (...args: never[]) => unknown, + }; + const openUndoSelector = vi.fn(); + const cancelRunningShellCommand = vi.fn(); + const session = { cancel: vi.fn(async () => {}) }; + + const host = { + state: { + editor, + activeDialog: null, + appState: { + streamingPhase: options.streamingPhase ?? 'idle', + isCompacting: options.isCompacting ?? false, + }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session, + btwPanelController: { closeOrCancel: vi.fn(() => false) }, + openUndoSelector, + cancelRunningShellCommand, + } as unknown as EditorKeyboardHost; + + const controller = new EditorKeyboardController( + host, + undefined as unknown as ImageAttachmentStore, + ); + controller.install(); + + return { host, editor, openUndoSelector, cancelRunningShellCommand }; +} + +function pressEscape(editor: Harness['editor']): void { + const handler = editor['onEscape']; + if (handler === undefined) throw new Error('onEscape handler not installed'); + (handler as () => void)(); +} + +function pressNonEscape(editor: Harness['editor']): void { + const handler = editor['onNonEscapeInput']; + if (handler === undefined) throw new Error('onNonEscapeInput handler not installed'); + (handler as () => void)(); +} + +describe('EditorKeyboardController double-Esc undo', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('opens the undo selector when Esc is pressed twice within the window while idle', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + expect(openUndoSelector).not.toHaveBeenCalled(); + + pressEscape(editor); + expect(openUndoSelector).toHaveBeenCalledOnce(); + }); + + it('does nothing for a single Esc while idle', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger when the second Esc arrives after the window expires', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + vi.advanceTimersByTime(DOUBLE_ESC_WINDOW_MS + 1); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger when another key is pressed between the two Esc presses', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + pressNonEscape(editor); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger undo while streaming; Esc cancels the stream instead', () => { + const { editor, host, openUndoSelector, cancelRunningShellCommand } = createHarness({ + streamingPhase: 'waiting', + }); + + pressEscape(editor); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + expect(cancelRunningShellCommand).toHaveBeenCalled(); + const session = host.session as unknown as { cancel: ReturnType<typeof vi.fn> }; + expect(session.cancel).toHaveBeenCalled(); + }); +}); + +describe('EditorKeyboardController shell history recall', () => { + type Recall = (entry: string, direction: 1 | -1) => string | undefined; + type Mock = ReturnType<typeof vi.fn>; + + it('installs a filter that allows shell entries only in bash mode', () => { + const { editor } = createHarness(); + const setHistoryFilter = editor['setHistoryFilter'] as unknown as Mock; + expect(setHistoryFilter).toHaveBeenCalledOnce(); + const [filter] = setHistoryFilter.mock.calls[0] as [(entry: string) => boolean]; + + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + expect(filter('!cmd')).toBe(true); + expect(filter('hello')).toBe(true); + + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + expect(filter('!cmd')).toBe(true); + expect(filter('hello')).toBe(false); + }); + + it('locks the filter to the browse-entry mode once browsing starts', () => { + const { editor } = createHarness(); + const setHistoryFilter = editor['setHistoryFilter'] as unknown as Mock; + const [filter] = setHistoryFilter.mock.calls[0] as [(entry: string) => boolean]; + const save = editor['onHistoryDraftSave'] as unknown as () => unknown; + + // Enter browse from prompt mode, then simulate landing on a shell entry + // (which flips inputMode to bash). The filter should stay locked to prompt + // and keep allowing plain entries. + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + save(); + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + + expect(filter('hello')).toBe(true); + expect(filter('!cmd')).toBe(true); + }); + + it('strips the leading ! and switches to bash mode when recalling a shell entry', () => { + const { editor } = createHarness(); + const onRecall = editor['onRecall'] as unknown as Recall; + + const result = onRecall('!cmd', -1); + + expect(result).toBe('cmd'); + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('bash'); + }); + + it('keeps plain entries as-is and switches to prompt mode', () => { + const { editor } = createHarness(); + const onRecall = editor['onRecall'] as unknown as Recall; + + const result = onRecall('hello', -1); + + expect(result).toBeUndefined(); + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt'); + }); + + it('saves the current input mode as the history draft host state', () => { + const { editor } = createHarness(); + const save = editor['onHistoryDraftSave'] as unknown as () => unknown; + + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + expect(save()).toBe('prompt'); + + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + expect(save()).toBe('bash'); + }); + + it('restores the input mode from the saved draft host state', () => { + const { editor } = createHarness(); + const restore = editor['onHistoryDraftRestore'] as unknown as (state: unknown) => void; + + restore('prompt'); + + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt'); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts new file mode 100644 index 000000000..8b9d5fbdf --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -0,0 +1,538 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; +import { readGoalQueue, removeGoalQueueItem, restoreGoalQueueItem } from '#/tui/goal-queue-store'; + +vi.mock('#/tui/goal-queue-store', () => ({ + readGoalQueue: vi.fn(async () => ({ + goals: [{ id: 'q1', objective: 'Ship queued goal', createdAt: '', updatedAt: '' }], + })), + removeGoalQueueItem: vi.fn(async () => ({ goals: [] })), + restoreGoalQueueItem: vi.fn(async () => ({ + goals: [{ id: 'q1', objective: 'Ship queued goal', createdAt: '', updatedAt: '' }], + })), +})); + +function fakeGoalSnapshot(objective: string, status: 'active' | 'blocked' | 'paused' | 'complete') { + return { + goalId: 'g1', + objective, + status, + turnsUsed: 1, + tokensUsed: 10, + wallClockMs: 100, + budget: { + tokenBudget: null, + turnBudget: 20, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: 19, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + }; +} + +function makeHost(options: { createGoalRejects?: boolean } = {}) { + const session = { + createGoal: vi.fn(async () => { + if (options.createGoalRejects === true) throw new Error('create failed'); + return fakeGoalSnapshot('Ship queued goal', 'active'); + }), + cancelGoal: vi.fn(async () => fakeGoalSnapshot('Ship queued goal', 'active')), + }; + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'waiting', + model: 'kimi-model', + permissionMode: 'auto', + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + hasActiveTurn: vi.fn(() => false), + hasThinkingDraft: vi.fn(() => false), + flushThinkingToTranscript: vi.fn(), + appendAssistantDelta: vi.fn(), + scheduleFlush: vi.fn(), + beginCompaction: vi.fn(), + endCompaction: vi.fn(), + cancelCompaction: vi.fn(), + }, + requireSession: vi.fn(() => session), + setAppState: vi.fn(), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + host.setAppState.mockImplementation((patch: Record<string, unknown>) => { + Object.assign(host.state.appState, patch); + }); + host.streamingUI.finalizeTurn.mockImplementation(() => { + host.setAppState({ streamingPhase: 'idle' }); + }); + return { host: host as any, session }; +} + +function sendQueuedViaHost(host: ReturnType<typeof makeHost>['host'], session: unknown) { + return (item: unknown) => { + host.sendQueuedMessage(session as never, item as never); + }; +} + +function completionEvent() { + return { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: fakeGoalSnapshot('Current goal', 'complete'), + change: { + kind: 'completion', + status: 'complete', + stats: { turnsUsed: 1, tokensUsed: 10, wallClockMs: 100 }, + }, + } as const; +} + +function clearedEvent() { + return { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: null, + } as const; +} + +function turnEndedEvent() { + return { + type: 'turn.ended', + sessionId: 's1', + agentId: 'main', + turnId: 1, + reason: 'completed', + } as const; +} + +function compactionCompletedEvent() { + return { + type: 'compaction.completed', + sessionId: 's1', + agentId: 'main', + result: { + summary: 'summary', + tokensBefore: 100, + tokensAfter: 10, + compactedCount: 1, + }, + } as const; +} + +function modelBlockedEvent() { + return { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: fakeGoalSnapshot('Blocked goal', 'blocked'), + change: { kind: 'lifecycle', status: 'blocked' }, + } as const; +} + +function addedTranscriptText(host: ReturnType<typeof makeHost>['host']): string { + const component = host.state.transcriptContainer.addChild.mock.calls.at(-1)?.[0]; + return component.render(80).join('\n').replaceAll(/\[[0-9;]*m/g, ''); +} + +describe('SessionEventHandler goal queue promotion', () => { + beforeEach(() => { + vi.mocked(readGoalQueue).mockClear(); + vi.mocked(removeGoalQueueItem).mockClear(); + vi.mocked(restoreGoalQueueItem).mockClear(); + }); + + it('starts the next queued goal after the completion turn ends', async () => { + const { host, session } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + expect(session.createGoal).not.toHaveBeenCalled(); + handler.handleEvent(clearedEvent(), vi.fn()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + + handler.handleEvent(turnEndedEvent(), sendQueuedViaHost(host, session)); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); + expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { + text: 'Ship queued goal', + }); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('waits for queued user input to drain before promoting the next queued goal', async () => { + const { host, session } = makeHost(); + host.state.queuedMessages = [{ text: 'queued user turn' }]; + host.setAppState.mockImplementation((patch: Record<string, unknown>) => { + Object.assign(host.state.appState, patch); + }); + host.shiftQueuedMessage.mockImplementation(() => host.state.queuedMessages.shift()); + host.streamingUI.finalizeTurn.mockImplementation((sendQueued: (item: unknown) => void) => { + const next = host.shiftQueuedMessage(); + if (next !== undefined) { + host.setAppState({ streamingPhase: 'idle' }); + setTimeout(() => { + sendQueued(next); + }, 0); + return; + } + host.setAppState({ streamingPhase: 'idle' }); + }); + host.sendQueuedMessage.mockImplementation((_session: unknown, item: { text: string }) => { + if (item.text === 'queued user turn') { + host.setAppState({ streamingPhase: 'waiting' }); + } + }); + const handler = new SessionEventHandler(host); + const sendQueued = sendQueuedViaHost(host, session); + + handler.handleEvent(completionEvent(), sendQueued); + handler.handleEvent(clearedEvent(), sendQueued); + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { text: 'queued user turn' }); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); + }); + + it('defers queued-goal promotion while a queued message is mid-dispatch', async () => { + const { host, session } = makeHost(); + host.state.appState.streamingPhase = 'idle'; + host.state.queuedMessages = []; + // The queue looks empty and the phase is idle, but a shifted queued message + // is still awaiting its deferred send. Promotion must not jump ahead of it. + host.state.queuedMessageDispatchPending = true; + const handler = new SessionEventHandler(host); + + handler.requestQueuedGoalPromotion(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + + // Once the queued message has been dispatched, the flag clears and the + // promotion proceeds on the next retry. + host.state.queuedMessageDispatchPending = false; + handler.retryQueuedGoalPromotion(); + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + }); + + it('waits for a queued user input drained after compaction before promoting the next queued goal', async () => { + const { host, session } = makeHost(); + host.state.appState.isCompacting = true; + host.state.queuedMessages = [{ text: 'queued user turn' }]; + host.shiftQueuedMessage.mockImplementation(() => host.state.queuedMessages.shift()); + const handler = new SessionEventHandler(host); + host.setAppState.mockImplementation((patch: Record<string, unknown>) => { + const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; + Object.assign(host.state.appState, patch); + if (busyChanged) handler.retryQueuedGoalPromotion(); + }); + host.sendQueuedMessage.mockImplementation((_session: unknown, item: { text: string }) => { + if (item.text === 'queued user turn') { + host.setAppState({ streamingPhase: 'waiting' }); + } + }); + const sendQueued = sendQueuedViaHost(host, session); + + handler.requestQueuedGoalPromotion(); + handler.handleEvent(compactionCompletedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { text: 'queued user turn' }); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + const sendQueuedCalls = host.sendQueuedMessage.mock.calls as Array<[unknown, { text?: string }]>; + const userMessageIndex = sendQueuedCalls.findIndex( + ([, item]) => item.text === 'queued user turn', + ); + expect(userMessageIndex).toBeGreaterThanOrEqual(0); + expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); + const userMessageOrder = host.sendQueuedMessage.mock.invocationCallOrder[userMessageIndex]!; + const goalCreateOrder = session.createGoal.mock.invocationCallOrder[0]!; + expect(userMessageOrder).toBeLessThan(goalCreateOrder); + }); + + it('leaves the queued goal in place when the next goal cannot start', async () => { + const { host, session } = makeHost({ createGoalRejects: true }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('create failed')); + }); + expect(removeGoalQueueItem).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + expect(session.createGoal).toHaveBeenCalledOnce(); + }); + + it('retries the queued goal on a later idle event after startup fails', async () => { + const { host, session } = makeHost(); + session.createGoal.mockRejectedValueOnce(new Error('create failed')); + const handler = new SessionEventHandler(host); + const sendQueued = sendQueuedViaHost(host, session); + + handler.handleEvent(completionEvent(), sendQueued); + handler.handleEvent(clearedEvent(), sendQueued); + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('create failed')); + }); + expect(removeGoalQueueItem).not.toHaveBeenCalled(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledTimes(2); + }); + expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); + expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { text: 'Ship queued goal' }); + }); + + it('does not send the queued objective when removal fails after goal creation', async () => { + vi.mocked(removeGoalQueueItem).mockRejectedValueOnce(new Error('remove failed')); + const { host, session } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('could not be removed')); + }); + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + expect(session.cancelGoal).toHaveBeenCalledOnce(); + expect(restoreGoalQueueItem).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + }); + + it('restores the queued goal and cancels the started goal when the session changes before send', async () => { + const { host, session } = makeHost(); + vi.mocked(removeGoalQueueItem).mockImplementationOnce(async () => { + host.session = undefined; + return { goals: [] }; + }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), sendQueuedViaHost(host, session)); + + await vi.waitFor(() => { + expect(restoreGoalQueueItem).toHaveBeenCalledWith(session, { + id: 'q1', + objective: 'Ship queued goal', + createdAt: '', + updatedAt: '', + }); + }); + expect(session.cancelGoal).toHaveBeenCalledOnce(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + }); + + it('restores and cancels when the host becomes busy before sending the promoted goal', async () => { + const { host, session } = makeHost(); + vi.mocked(removeGoalQueueItem).mockImplementationOnce(async () => { + host.setAppState({ streamingPhase: 'waiting' }); + return { goals: [] }; + }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), sendQueuedViaHost(host, session)); + + await vi.waitFor(() => { + expect(restoreGoalQueueItem).toHaveBeenCalledWith(session, { + id: 'q1', + objective: 'Ship queued goal', + createdAt: '', + updatedAt: '', + }); + }); + expect(session.cancelGoal).toHaveBeenCalledOnce(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + }); + + it('shows a notice when a blocked goal has queued goals', async () => { + const { host, session } = makeHost(); + const handler = new SessionEventHandler(host); + const event = { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: fakeGoalSnapshot('Blocked goal', 'blocked'), + change: { kind: 'lifecycle', status: 'blocked', reason: 'waiting for access' }, + } as const; + + handler.handleEvent(event, vi.fn()); + + await vi.waitFor(() => { + expect(host.showNotice).toHaveBeenCalledWith( + 'Goal blocked.', + 'The next queued goal will start only after this goal is complete.', + ); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + }); + + it('does not render a duplicate marker for a model-reported blocked goal', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(modelBlockedEvent(), vi.fn()); + + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); + }); + + it('renders a blocked fallback when the model does not explain the blocked goal', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(modelBlockedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(addedTranscriptText(host)).toBe(' ◦ Goal blocked'); + }); + + it('does not render a blocked fallback after the model explains the blocked goal', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(modelBlockedEvent(), vi.fn()); + handler.handleEvent( + { + type: 'assistant.delta', + sessionId: 's1', + agentId: 'main', + turnId: 1, + delta: 'I am blocked because I need credentials.', + }, + vi.fn(), + ); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); + }); + + it('does not render a blocked fallback after earlier assistant text in the same turn', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent( + { + type: 'assistant.delta', + sessionId: 's1', + agentId: 'main', + turnId: 1, + delta: 'I am blocked because I need credentials.', + }, + vi.fn(), + ); + handler.handleEvent(modelBlockedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); + }); + + it('does not promote on paused or cancelled updates', async () => { + const { host, session } = makeHost(); + const handler = new SessionEventHandler(host); + const paused = { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: fakeGoalSnapshot('Paused goal', 'paused'), + change: { kind: 'lifecycle', status: 'paused' }, + } as const; + + handler.handleEvent(paused, vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index f82d93fee..0899cf070 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -8,10 +8,13 @@ function fakeInitialAppState(): AppState { return { model: 'test-model', workDir: '/tmp/kimi-test', + additionalDirs: [], sessionId: 'sess-1', permissionMode: 'manual', planMode: false, - thinking: false, + inputMode: 'prompt', + swarmMode: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -23,9 +26,11 @@ function fakeInitialAppState(): AppState { version: '0.0.0-test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, availableModels: {}, availableProviders: {}, sessionTitle: null, + mcpServersSummary: null, }; } @@ -53,12 +58,12 @@ describe('createTUIState', () => { expect(state.editor).toBeDefined(); expect(state.footer).toBeDefined(); expect(state.todoPanel).toBeDefined(); - expect(state.theme.colors).toBeDefined(); - expect(state.theme.markdownTheme).toBeDefined(); + expect(state.theme.palette).toBeDefined(); // App state is cloned from initialAppState, not reused by reference. expect(state.appState).not.toBe(opts.initialAppState); expect(state.appState.model).toBe('test-model'); + expect(state.appState.additionalDirs).toEqual([]); expect(state.appState.sessionId).toBe('sess-1'); expect(state.startupState).toBe('pending'); @@ -76,6 +81,7 @@ describe('createTUIState', () => { expect(state.activeDialog).toBeNull(); expect(state.externalEditorRunning).toBe(false); expect(state.loadingSessions).toBe(false); + expect(state.sessionsScope).toBe('cwd'); expect(state.activitySpinner).toBeNull(); }); }); diff --git a/apps/kimi-code/test/tui/easter-eggs/dance.test.ts b/apps/kimi-code/test/tui/easter-eggs/dance.test.ts index 079a66e6e..5406a2998 100644 --- a/apps/kimi-code/test/tui/easter-eggs/dance.test.ts +++ b/apps/kimi-code/test/tui/easter-eggs/dance.test.ts @@ -12,6 +12,7 @@ import { tryHandleDanceCommand, } from '#/tui/easter-eggs/dance'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { darkColors } from '#/tui/theme/colors'; const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; @@ -167,6 +168,7 @@ describe('installRainbowDance', () => { const dispose = installRainbowDance(requestRender); const host = { showStatus: vi.fn(), + state: { theme: { palette: darkColors } }, } as unknown as SlashCommandHost; tryHandleDanceCommand(host, { name: 'dance', args: 'on' }); @@ -200,6 +202,7 @@ function makeHost(): { host: SlashCommandHost; calls: DanceCall[]; status: strin setRainbowDance(rainbowDance); const host = { showStatus: (msg: string) => status.push(msg), + state: { theme: { palette: darkColors } }, } as unknown as SlashCommandHost; return { host, calls, status }; } diff --git a/apps/kimi-code/test/tui/goal-queue-store.test.ts b/apps/kimi-code/test/tui/goal-queue-store.test.ts new file mode 100644 index 000000000..d1efba07b --- /dev/null +++ b/apps/kimi-code/test/tui/goal-queue-store.test.ts @@ -0,0 +1,171 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ErrorCodes, KimiError } from '@moonshot-ai/kimi-code-sdk'; + +import { + appendGoalQueueItem, + moveGoalQueueItem, + readGoalQueue, + removeGoalQueueItem, + restoreGoalQueueItem, + updateGoalQueueItem, +} from '#/tui/goal-queue-store'; + +const QUEUE_FILE = 'upcoming-goals.json'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'kimi-goal-queue-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function session(sessionDir = dir) { + return { + id: 'session_test', + summary: { + sessionDir, + }, + }; +} + +async function readQueueFile() { + return JSON.parse(await readFile(join(dir, QUEUE_FILE), 'utf-8')) as unknown; +} + +describe('goal queue store', () => { + it('reads an empty queue when the file is missing', async () => { + await expect(readGoalQueue(session())).resolves.toEqual({ goals: [] }); + }); + + it('appends a trimmed upcoming goal and writes the session file', async () => { + const snapshot = await appendGoalQueueItem(session(), { objective: ' Ship release notes ' }); + + expect(snapshot.goals).toHaveLength(1); + expect(snapshot.goals[0]).toMatchObject({ objective: 'Ship release notes' }); + expect(snapshot.goals[0]?.id).toEqual(expect.any(String)); + expect(snapshot.goals[0]?.createdAt).toEqual(expect.any(String)); + expect(await readQueueFile()).toMatchObject({ + version: 1, + goals: [{ objective: 'Ship release notes' }], + }); + }); + + it('preserves concurrent appends to the same session queue', async () => { + await Promise.all( + Array.from({ length: 10 }, (_, index) => + appendGoalQueueItem(session(), { objective: `Queued goal ${index + 1}` }), + ), + ); + + const snapshot = await readGoalQueue(session()); + + expect(snapshot.goals.map((goal) => goal.objective).toSorted()).toEqual( + Array.from({ length: 10 }, (_, index) => `Queued goal ${index + 1}`).toSorted(), + ); + }); + + it('updates an upcoming goal objective', async () => { + const first = await appendGoalQueueItem(session(), { objective: 'Draft docs' }); + const goal = first.goals[0]!; + + const updated = await updateGoalQueueItem(session(), { + goalId: goal.id, + objective: ' Publish docs ', + }); + + expect(updated.goals).toHaveLength(1); + expect(updated.goals[0]).toMatchObject({ + id: goal.id, + objective: 'Publish docs', + createdAt: goal.createdAt, + }); + expect(updated.goals[0]?.updatedAt).not.toBe(goal.updatedAt); + }); + + it('removes an upcoming goal by id', async () => { + const first = await appendGoalQueueItem(session(), { objective: 'First' }); + const second = await appendGoalQueueItem(session(), { objective: 'Second' }); + + const snapshot = await removeGoalQueueItem(session(), { goalId: first.goals[0]!.id }); + + expect(snapshot.goals).toEqual([second.goals[1]]); + }); + + it('restores a removed upcoming goal at the front without duplicating it', async () => { + const first = await appendGoalQueueItem(session(), { objective: 'First' }); + await appendGoalQueueItem(session(), { objective: 'Second' }); + const removed = first.goals[0]!; + await removeGoalQueueItem(session(), { goalId: removed.id }); + + const restored = await restoreGoalQueueItem(session(), removed); + expect(restored.goals.map((goal) => goal.objective)).toEqual(['First', 'Second']); + + const deduped = await restoreGoalQueueItem(session(), removed); + expect(deduped.goals.map((goal) => goal.objective)).toEqual(['First', 'Second']); + }); + + it('moves an upcoming goal up and down', async () => { + const first = await appendGoalQueueItem(session(), { objective: 'First' }); + await appendGoalQueueItem(session(), { objective: 'Second' }); + const third = await appendGoalQueueItem(session(), { objective: 'Third' }); + + const movedUp = await moveGoalQueueItem(session(), { + goalId: third.goals[2]!.id, + direction: 'up', + }); + expect(movedUp.goals.map((goal) => goal.objective)).toEqual(['First', 'Third', 'Second']); + + const movedDown = await moveGoalQueueItem(session(), { + goalId: first.goals[0]!.id, + direction: 'down', + }); + expect(movedDown.goals.map((goal) => goal.objective)).toEqual(['Third', 'First', 'Second']); + }); + + it('rejects empty and over-long objectives', async () => { + await expect(appendGoalQueueItem(session(), { objective: ' ' })).rejects.toMatchObject({ + code: ErrorCodes.GOAL_OBJECTIVE_EMPTY, + }); + await expect(appendGoalQueueItem(session(), { objective: 'x'.repeat(4001) })).rejects.toMatchObject({ + code: ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, + }); + }); + + it('normalizes malformed queue files to an empty queue', async () => { + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, QUEUE_FILE), JSON.stringify({ version: 1, goals: [{ bad: true }] }), 'utf-8'); + + await expect(readGoalQueue(session())).resolves.toEqual({ goals: [] }); + await expect(readQueueFile()).resolves.toEqual({ version: 1, goals: [] }); + }); + + it('does not clear the queue file when JSON cannot be parsed', async () => { + const partial = '{"version":1,"goals":['; + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, QUEUE_FILE), partial, 'utf-8'); + + await expect(readGoalQueue(session())).rejects.toThrow('Invalid JSON in goal queue'); + await expect(readFile(join(dir, QUEUE_FILE), 'utf-8')).resolves.toBe(partial); + }); + + it('throws when the session summary does not expose a session directory', async () => { + await expect(readGoalQueue({ id: 'missing', summary: undefined })).rejects.toThrow( + 'Session missing does not expose a session directory', + ); + }); + + it('throws a goal-not-found error when the target item is missing', async () => { + await expect(removeGoalQueueItem(session(), { goalId: 'missing' })).rejects.toBeInstanceOf( + KimiError, + ); + await expect(removeGoalQueueItem(session(), { goalId: 'missing' })).rejects.toMatchObject({ + code: ErrorCodes.GOAL_NOT_FOUND, + }); + }); +}); diff --git a/apps/kimi-code/test/tui/input/image-attachment-store.test.ts b/apps/kimi-code/test/tui/input/image-attachment-store.test.ts index cb3e88c59..6add1e428 100644 --- a/apps/kimi-code/test/tui/input/image-attachment-store.test.ts +++ b/apps/kimi-code/test/tui/input/image-attachment-store.test.ts @@ -59,4 +59,30 @@ describe('ImageAttachmentStore', () => { const next = s.addImage(new Uint8Array(), 'image/png', 10, 10); expect(next.id).toBe(1); }); + + it('remove() drops a single attachment without resetting ids', () => { + const s = new ImageAttachmentStore(); + const a = s.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const b = s.addImage(new Uint8Array([2]), 'image/png', 10, 10); + expect(s.size()).toBe(2); + s.remove(a.id); + expect(s.size()).toBe(1); + expect(s.get(a.id)).toBeUndefined(); + expect(s.get(b.id)).toBe(b); + // Unlike clear(), remove() must not reset the id counter. + const next = s.addImage(new Uint8Array([3]), 'image/png', 10, 10); + expect(next.id).toBe(3); + }); + + it('removeMany() drops many attachments at once', () => { + const s = new ImageAttachmentStore(); + const a = s.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const b = s.addImage(new Uint8Array([2]), 'image/png', 10, 10); + const c = s.addImage(new Uint8Array([3]), 'image/png', 10, 10); + s.removeMany([a.id, c.id]); + expect(s.size()).toBe(1); + expect(s.get(b.id)).toBe(b); + expect(s.get(a.id)).toBeUndefined(); + expect(s.get(c.id)).toBeUndefined(); + }); }); diff --git a/apps/kimi-code/test/tui/input/image-placeholder.test.ts b/apps/kimi-code/test/tui/input/image-placeholder.test.ts index cdc74e913..477727293 100644 --- a/apps/kimi-code/test/tui/input/image-placeholder.test.ts +++ b/apps/kimi-code/test/tui/input/image-placeholder.test.ts @@ -1,7 +1,16 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, it, expect } from 'vitest'; +import { KIMI_CODE_HOME_ENV } from '#/constant/app'; import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; -import { extractMediaAttachments } from '#/tui/utils/image-placeholder'; +import { + extractMediaAttachments, + rewriteMediaPlaceholders, +} from '#/tui/utils/image-placeholder'; +import { getCacheDir } from '#/utils/paths'; function storeWith( bytes: Uint8Array, @@ -13,6 +22,36 @@ function storeWith( return { store, placeholder: att.placeholder }; } +/** Point `getCacheDir()` at a fresh temp home for the duration of a test. */ +function setupTempCache(): { cleanup: () => void } { + const home = mkdtempSync(join(tmpdir(), 'kimi-home-')); + const prev = process.env[KIMI_CODE_HOME_ENV]; + process.env[KIMI_CODE_HOME_ENV] = home; + return { + cleanup: () => { + if (prev === undefined) delete process.env[KIMI_CODE_HOME_ENV]; + else process.env[KIMI_CODE_HOME_ENV] = prev; + rmSync(home, { recursive: true, force: true }); + }, + }; +} + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), 'kimi-src-')); +} + +type TextPart = { type: 'text'; text: string }; + +function videoPathFromParts(parts: unknown[]): string { + const text = parts + .filter((p): p is TextPart => (p as TextPart).type === 'text') + .map((p) => p.text) + .join(''); + const m = /<video path="([^"]+)"><\/video>/.exec(text); + if (!m) throw new Error(`no video tag found in: ${text}`); + return m[1]!; +} + describe('extractMediaAttachments', () => { it('returns no parts and hasMedia=false for plain text', () => { const store = new ImageAttachmentStore(); @@ -52,18 +91,30 @@ describe('extractMediaAttachments', () => { }); it('keeps matched-placeholder order with mixed image and video attachments', () => { - const store = new ImageAttachmentStore(); - const img = store.addImage(new Uint8Array([1]), 'image/png', 10, 10); - const vid = store.addVideo('video/quicktime', '/tmp/clip.mov'); - const text = `first ${img.placeholder} then ${vid.placeholder} end`; - const r = extractMediaAttachments(text, store); - expect(r.imageAttachmentIds).toEqual([1]); - expect(r.videoAttachmentIds).toEqual([2]); - expect(r.parts).toEqual([ - { type: 'text', text: 'first ' }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AQ==' } }, - { type: 'text', text: ' then <video path="/tmp/clip.mov"></video> end' }, - ]); + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'clip.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const img = store.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const vid = store.addVideo('video/quicktime', srcVideo); + const text = `first ${img.placeholder} then ${vid.placeholder} end`; + const r = extractMediaAttachments(text, store); + expect(r.imageAttachmentIds).toEqual([1]); + expect(r.videoAttachmentIds).toEqual([2]); + expect(r.parts[0]).toEqual({ type: 'text', text: 'first ' }); + expect(r.parts[1]).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,AQ==' }, + }); + const cachePath = videoPathFromParts(r.parts); + expect(cachePath.startsWith(getCacheDir())).toBe(true); + expect(readFileSync(cachePath, 'utf8')).toBe('video-bytes'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } }); it('leaves unresolved (typed by hand) placeholders as literal text', () => { @@ -85,20 +136,236 @@ describe('extractMediaAttachments', () => { }); it('escapes media paths in generated tags', () => { - const store = new ImageAttachmentStore(); - const att = store.addVideo('video/mp4', '/tmp/a&"<>.mp4', 'sample.mp4'); - const r = extractMediaAttachments(att.placeholder, store); - expect(r.parts).toEqual([ - { type: 'text', text: '<video path="/tmp/a&"<>.mp4"></video>' }, - ]); + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'source.mp4'); + writeFileSync(srcVideo, 'x'); + const store = new ImageAttachmentStore(); + // The filename drives the cache label; `&` must be escaped in the attribute. + const att = store.addVideo('video/mp4', srcVideo, 'a&b.mp4'); + const r = extractMediaAttachments(att.placeholder, store); + expect(r.parts).toHaveLength(1); + const text = (r.parts[0] as TextPart).text; + expect(text).toMatch(/<video path="[^"]+a&b\.mp4"><\/video>/); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } }); - it('expands video placeholders backed by local files to readMediaFile video tags', () => { + it('copies video placeholders into the cache and emits cache-path tags', () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'sample.mp4'); + writeFileSync(srcVideo, 'video-data'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/mp4', srcVideo); + const r = extractMediaAttachments(att.placeholder, store); + expect(r.hasMedia).toBe(true); + expect(r.videoAttachmentIds).toEqual([1]); + const cachePath = videoPathFromParts(r.parts); + // The tag points at the cache, not the original source path. + expect(cachePath.startsWith(getCacheDir())).toBe(true); + expect(cachePath).not.toBe(srcVideo); + expect(readFileSync(cachePath, 'utf8')).toBe('video-data'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it('inserts a compression caption before an image that was compressed at paste time', () => { const store = new ImageAttachmentStore(); - const att = store.addVideo('video/mp4', '/tmp/sample.mp4'); + const att = store.addImage(new Uint8Array([1, 2, 3]), 'image/png', 2000, 2000, { + path: '/tmp/kimi-code-original-images/abc.png', + width: 2600, + height: 2600, + byteLength: 123456, + mime: 'image/png', + }); + + const r = extractMediaAttachments(`look ${att.placeholder}`, store); + + expect(r.parts).toHaveLength(2); + const caption = r.parts[0]; + if (caption?.type !== 'text') throw new Error('expected leading text part'); + expect(caption.text).toContain('Image compressed'); + expect(caption.text).toContain('2600x2600'); + expect(caption.text).toContain('/tmp/kimi-code-original-images/abc.png'); + expect(r.parts[1]).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,AQID' }, + }); + }); + + it('notes an unpreserved original when persistence failed at paste time', () => { + const store = new ImageAttachmentStore(); + const att = store.addImage(new Uint8Array([1]), 'image/png', 2000, 2000, { + path: null, + width: 2600, + height: 2600, + byteLength: 123456, + mime: 'image/png', + }); + const r = extractMediaAttachments(att.placeholder, store); - expect(r.hasMedia).toBe(true); - expect(r.videoAttachmentIds).toEqual([1]); - expect(r.parts).toEqual([{ type: 'text', text: '<video path="/tmp/sample.mp4"></video>' }]); + + const caption = r.parts[0]; + if (caption?.type !== 'text') throw new Error('expected leading text part'); + expect(caption.text).toMatch(/not preserved/i); + }); + + it('adds no caption for an uncompressed image attachment', () => { + const { store, placeholder } = storeWith(new Uint8Array([0xaa])); + const r = extractMediaAttachments(placeholder, store); + expect(r.parts).toHaveLength(1); + expect(r.parts[0]?.type).toBe('image_url'); + }); +}); + +describe('rewriteMediaPlaceholders', () => { + it('returns plain text untouched with hasMedia=false', () => { + const store = new ImageAttachmentStore(); + const r = rewriteMediaPlaceholders('just some args', store); + expect(r.text).toBe('just some args'); + expect(r.hasMedia).toBe(false); + expect(r.imageAttachmentIds).toEqual([]); + expect(r.videoAttachmentIds).toEqual([]); + }); + + it('rewrites an image placeholder into a cache-path image tag', () => { + const { cleanup } = setupTempCache(); + try { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const { store, placeholder } = storeWith(bytes); + const r = rewriteMediaPlaceholders(`look at ${placeholder} please`, store); + expect(r.hasMedia).toBe(true); + expect(r.imageAttachmentIds).toEqual([1]); + const m = /^look at <image path="([^"]+)"><\/image> please$/.exec(r.text); + if (!m) throw new Error(`no image tag found in: ${r.text}`); + expect(m[1]!.startsWith(getCacheDir())).toBe(true); + expect(m[1]!.endsWith('.png')).toBe(true); + expect(new Uint8Array(readFileSync(m[1]!))).toEqual(bytes); + } finally { + cleanup(); + } + }); + + it('rewrites a video placeholder into a cache-path video tag', () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'clip.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/quicktime', srcVideo); + const r = rewriteMediaPlaceholders(att.placeholder, store); + expect(r.hasMedia).toBe(true); + expect(r.videoAttachmentIds).toEqual([1]); + const m = /<video path="([^"]+)"><\/video>/.exec(r.text); + if (!m) throw new Error(`no video tag found in: ${r.text}`); + expect(m[1]!.startsWith(getCacheDir())).toBe(true); + expect(readFileSync(m[1]!, 'utf8')).toBe('video-bytes'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it('leaves unresolved (typed by hand) placeholders as literal text', () => { + const store = new ImageAttachmentStore(); + const text = 'try [image #999 (1×1)] and [video #42 clip.mov] now'; + const r = rewriteMediaPlaceholders(text, store); + expect(r.text).toBe(text); + expect(r.hasMedia).toBe(false); + }); + + it('preserves surrounding text verbatim across multiple attachments', () => { + const { cleanup } = setupTempCache(); + try { + const store = new ImageAttachmentStore(); + const a = store.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const b = store.addImage(new Uint8Array([2]), 'image/jpeg', 20, 20); + const r = rewriteMediaPlaceholders( + `first ${a.placeholder} then ${b.placeholder} end`, + store, + ); + expect(r.imageAttachmentIds).toEqual([1, 2]); + const tags = [...r.text.matchAll(/<image path="([^"]+)"><\/image>/g)]; + expect(tags).toHaveLength(2); + expect(r.text.startsWith('first <image path=')).toBe(true); + expect(r.text).toContain('> then <image path='); + expect(r.text.endsWith('> end')).toBe(true); + expect(new Uint8Array(readFileSync(tags[0]![1]!))).toEqual(new Uint8Array([1])); + expect(new Uint8Array(readFileSync(tags[1]![1]!))).toEqual(new Uint8Array([2])); + } finally { + cleanup(); + } + }); + + it("rewrites an image placeholder into an escape-proof plain reference in 'plain' style", () => { + const { cleanup } = setupTempCache(); + try { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const { store, placeholder } = storeWith(bytes); + const r = rewriteMediaPlaceholders(`look at ${placeholder}`, store, 'plain'); + expect(r.hasMedia).toBe(true); + expect(r.imageAttachmentIds).toEqual([1]); + // Skill args pass through XML escaping, so the reference must not + // contain any tag/attribute boundary characters. + expect(r.text).not.toMatch(/[<>&"]/); + const m = + /^look at Attached image file: (\S+) \(open it with ReadMediaFile\)$/.exec(r.text); + if (!m) throw new Error(`no plain reference found in: ${r.text}`); + expect(m[1]!.startsWith(getCacheDir())).toBe(true); + expect(new Uint8Array(readFileSync(m[1]!))).toEqual(bytes); + } finally { + cleanup(); + } + }); + + it("rewrites a video placeholder into an escape-proof plain reference in 'plain' style", () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'clip.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/quicktime', srcVideo); + const r = rewriteMediaPlaceholders(att.placeholder, store, 'plain'); + expect(r.hasMedia).toBe(true); + expect(r.videoAttachmentIds).toEqual([1]); + expect(r.text).not.toMatch(/[<>&"]/); + const m = /^Attached video file: (\S+) \(open it with ReadMediaFile\)$/.exec(r.text); + if (!m) throw new Error(`no plain reference found in: ${r.text}`); + expect(readFileSync(m[1]!, 'utf8')).toBe('video-bytes'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it("sanitizes XML boundary chars out of plain-style video cache names", () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + // The video label keeps the original filename, and sanitizeVideoLabel + // allows `<>&"`; skill args are XML-escaped, so the plain reference + // would point at a path that no longer matches the file on disk. + const srcVideo = join(srcDir, 'clip<1>&.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/quicktime', srcVideo); + const r = rewriteMediaPlaceholders(att.placeholder, store, 'plain'); + expect(r.text).not.toMatch(/[<>&"]/); + const m = /^Attached video file: (\S+) \(open it with ReadMediaFile\)$/.exec(r.text); + if (!m) throw new Error(`no plain reference found in: ${r.text}`); + expect(readFileSync(m[1]!, 'utf8')).toBe('video-bytes'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } }); }); 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 7e547d50a..f285be77d 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1,41 +1,74 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { deleteAllKittyImages, resetCapabilitiesCache, setCapabilities, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering'; +import { + AgentSwarmProgressComponent, + agentSwarmGridHeightForTerminalRows, +} from '#/tui/components/messages/agent-swarm-progress'; +import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; +import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; +import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, } from '#/tui/components/dialogs/plugins-selector'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { handleFeedbackCommand } from '#/tui/commands/info'; +import { packageCodebase, scanCodebase } from '../../src/feedback/codebase'; +import { uploadArchive } from '../../src/feedback/upload'; import { + promptFeedbackAttachment, promptFeedbackInput, runModelSelector, + type FeedbackPromptResult, } from '#/tui/commands/prompts'; import type { QueuedMessage } from '#/tui/types'; import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal<typeof import('#/tui/commands/prompts')>(); - return { ...actual, promptFeedbackInput: vi.fn() }; + return { + ...actual, + promptFeedbackInput: vi.fn(), + promptFeedbackAttachment: vi.fn(), + }; }); -vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() })); +vi.mock('../../src/feedback/codebase', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../src/feedback/codebase')>(); + return { + ...actual, + scanCodebase: vi.fn().mockResolvedValue(undefined), + packageCodebase: vi.fn(), + }; +}); + +vi.mock('../../src/feedback/upload', () => ({ + uploadArchive: vi.fn(), +})); + +// /feedback falls back to opening GitHub Issues in a browser when not signed in +// or when submission fails — stub it out so the test suite never spawns a +// browser window. +vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); const ESC = String.fromCodePoint(0x1b); const BEL = String.fromCodePoint(0x07); @@ -56,12 +89,13 @@ interface MessageDriver { init(): Promise<boolean>; handleUserInput(text: string): void; persistInputHistory(text: string): Promise<void>; + sendQueuedMessage(session: unknown, item: QueuedMessage): void; getCurrentSessionId(): string; } interface FeedbackDriver extends MessageDriver { handleFeedbackCommand(): Promise<void>; - promptFeedbackInput(): Promise<string | undefined>; + promptFeedbackInput(): Promise<FeedbackPromptResult | undefined>; } interface ModelSelectorDriver extends MessageDriver { @@ -94,16 +128,19 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } function makeSession(overrides: Record<string, unknown> = {}) { + let model = 'k2'; + let thinkingEffort = 'off'; return { id: 'ses-1', model: 'k2', @@ -111,23 +148,31 @@ function makeSession(overrides: Record<string, unknown> = {}) { prompt: vi.fn(async () => {}), steer: vi.fn(async () => {}), init: vi.fn(async () => {}), + startBtw: vi.fn(async () => 'agent-btw'), + undoHistory: vi.fn(async () => {}), cancel: vi.fn(async () => {}), cancelCompaction: vi.fn(async () => {}), getStatus: vi.fn(async () => ({ - model: 'k2', - thinkingLevel: 'off', + model, + thinkingEffort, permission: 'manual', planMode: false, contextTokens: 0, maxContextTokens: 100, contextUsage: 0, })), + getGoal: vi.fn(async () => ({ goal: null })), setApprovalHandler: vi.fn(), setQuestionHandler: vi.fn(), - setModel: vi.fn(async () => {}), - setThinking: vi.fn(async () => {}), + setModel: vi.fn(async (alias: string) => { + model = alias; + }), + setThinking: vi.fn(async (effort: string) => { + thinkingEffort = effort; + }), setPermission: vi.fn(async () => {}), setPlanMode: vi.fn(async () => {}), + setSwarmMode: vi.fn(async () => {}), onEvent: vi.fn(() => vi.fn()), listMcpServers: vi.fn(async () => []), listSkills: vi.fn(async () => []), @@ -137,7 +182,7 @@ function makeSession(overrides: Record<string, unknown> = {}) { main: { status: { model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, @@ -161,11 +206,14 @@ function makeSession(overrides: Record<string, unknown> = {}) { mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, + source: 'local-path', })), setPluginEnabled: vi.fn(async () => {}), setPluginMcpServerEnabled: vi.fn(async () => {}), removePlugin: vi.fn(async () => {}), reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })), + reloadSession: vi.fn(async () => ({})), + activateSkill: vi.fn(async () => {}), getPluginInfo: vi.fn(async (id: string) => ({ id, displayName: id, @@ -187,6 +235,7 @@ function makeSession(overrides: Record<string, unknown> = {}) { } function makeHarness(session = makeSession(), overrides: Record<string, unknown> = {}) { + const interactiveAgentScope = new AsyncLocalStorage<string>(); return { getConfig: vi.fn(async () => ({ models: { @@ -198,19 +247,33 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> resumeSession: vi.fn(async () => session), forkSession: vi.fn(async () => session), listSessions: vi.fn(async () => []), + exportSession: vi.fn(async () => ({ + zipPath: '/tmp/fake-session.zip', + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + })), close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), - interactiveAgentId: 'main', - getExperimentalFlags: vi.fn(async () => ({})), + get interactiveAgentId() { + return interactiveAgentScope.getStore() ?? 'main'; + }, + withInteractiveAgent: vi.fn((agentId: string, fn: () => unknown) => { + return interactiveAgentScope.run(agentId, fn); + }), + getExperimentalFeatures: vi.fn(async () => []), auth: { status: vi.fn(), login: vi.fn(), logout: vi.fn(), getManagedUsage: vi.fn(), submitFeedback: vi.fn( - async (): Promise<{ kind: 'ok' } | { kind: 'error'; status?: number; message: string }> => ({ + async (): Promise< + { kind: 'ok'; feedbackId: number } | { kind: 'error'; status?: number; message: string } + > => ({ kind: 'ok', + feedbackId: 3, }), ), }, @@ -239,6 +302,55 @@ function renderTranscript(driver: MessageDriver): string { return driver.state.transcriptContainer.render(120).join('\n'); } +async function confirmUndoSelection(driver: MessageDriver): Promise<void> { + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(UndoSelectorComponent); + }); + (driver.state.editorContainer.children[0] as UndoSelectorComponent).handleInput('\r'); +} + +function renderActivity(driver: MessageDriver): string { + return driver.state.activityContainer.render(120).join('\n'); +} + +function renderBtwPanel(driver: MessageDriver): string { + return driver.state.btwPanelContainer.render(120).join('\n'); +} + +function getMountedBtwPanel(driver: MessageDriver): BtwPanelComponent { + const panel = driver.state.btwPanelContainer.children.find( + (child) => child instanceof BtwPanelComponent, + ); + if (panel === undefined) throw new Error('Expected a mounted /btw panel.'); + return panel; +} + +async function openBtwPanel( + driver: MessageDriver, + session: ReturnType<typeof makeSession>, + prompt = 'side question', +): Promise<void> { + driver.handleUserInput(`/btw ${prompt}`); + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + }); +} + +function setTerminalRows(driver: MessageDriver, rows: number): void { + Object.defineProperty(driver.state.terminal, 'rows', { + configurable: true, + get: () => rows, + }); +} + +function setTerminalColumns(driver: MessageDriver, columns: number): void { + Object.defineProperty(driver.state.terminal, 'columns', { + configurable: true, + get: () => columns, + }); +} + function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } @@ -255,6 +367,14 @@ async function makeTempHome(): Promise<string> { return dir; } +async function makeExportedSessionZip(content = 'session zip'): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), 'kimi-code-feedback-export-')); + tempDirs.push(dir); + const zipPath = join(dir, 'session.zip'); + await writeFile(zipPath, content); + return zipPath; +} + afterEach(async () => { resetCapabilitiesCache(); for (const dir of tempDirs.splice(0)) { @@ -287,7 +407,6 @@ describe('KimiTUI message flow', () => { const { driver, harness } = await makeDriver(); harness.track.mockClear(); - driver.state.editor.handleInput('\u001B[106;5u'); driver.state.editor.handleInput('\u001F'); delete process.env['VISUAL']; delete process.env['EDITOR']; @@ -295,7 +414,6 @@ describe('KimiTUI message flow', () => { driver.state.editor.onToggleToolExpand?.(); driver.state.editor.onTextPaste?.(); - expect(harness.track).toHaveBeenCalledWith('shortcut_newline', undefined); expect(harness.track).toHaveBeenCalledWith('undo', undefined); expect(harness.track).toHaveBeenCalledWith('shortcut_editor', undefined); expect(harness.track).toHaveBeenCalledWith('shortcut_expand', undefined); @@ -331,6 +449,57 @@ describe('KimiTUI message flow', () => { expect(harness.track).toHaveBeenCalledWith('theme_switch', { theme: 'light' }); }); + it('dispatches /reload-tui without reloading the active session', async () => { + const homeDir = await makeTempHome(); + process.env['KIMI_CODE_HOME'] = homeDir; + await writeFile( + join(homeDir, 'tui.toml'), + ` +theme = "light" + +[editor] +command = "vim" +`, + 'utf-8', + ); + const { driver, session, harness } = await makeDriver(); + harness.track.mockClear(); + session.reloadSession.mockClear(); + + driver.handleUserInput('/reload-tui'); + + await vi.waitFor(() => { + expect(driver.state.appState.theme).toBe('light'); + }); + expect(driver.state.appState.editorCommand).toBe('vim'); + expect(session.reloadSession).not.toHaveBeenCalled(); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'reload-tui' }); + }); + + it('dispatches /reload through session reload and applies tui.toml', async () => { + const homeDir = await makeTempHome(); + process.env['KIMI_CODE_HOME'] = homeDir; + await writeFile(join(homeDir, 'tui.toml'), 'theme = "light"\n', 'utf-8'); + const { driver, session, harness } = await makeDriver(); + harness.track.mockClear(); + session.reloadSession.mockClear(); + driver.handleUserInput('hello before reload'); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/reload'); + + await vi.waitFor(() => { + expect(session.reloadSession).toHaveBeenCalledOnce(); + }); + await vi.waitFor(() => { + expect(driver.state.appState.theme).toBe('light'); + }); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'reload' }); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('hello before reload'); + expect(transcript).toContain('Session reloaded.'); + }); + it('tracks successful feedback submissions only after the request succeeds', async () => { const { driver, harness } = await makeDriver( makeSession(), @@ -347,8 +516,9 @@ describe('KimiTUI message flow', () => { }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); - harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok' }); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); harness.track.mockClear(); await handleFeedbackCommand(feedbackDriver as any); @@ -362,6 +532,309 @@ describe('KimiTUI message flow', () => { }), ); expect(harness.track).toHaveBeenCalledWith('feedback_submitted', undefined); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + }); + + it('submits text feedback before preparing requested attachments', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + + const zipPath = await makeExportedSessionZip(); + let resolveExport!: () => void; + const exportBlocked = new Promise<{ + zipPath: string; + entries: string[]; + sessionDir: string; + manifest: Record<string, never>; + }>((resolve) => { + resolveExport = () => { + resolve({ + zipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + }; + }); + harness.exportSession.mockImplementationOnce(() => exportBlocked); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ses-1', + includeGlobalLog: true, + version: '0.0.0-test', + }), + ); + }); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.objectContaining({ content: 'useful feedback' }), + ); + expect(harness.auth.submitFeedback.mock.invocationCallOrder[0]).toBeLessThan( + harness.exportSession.mock.invocationCallOrder[0]!, + ); + expect(settled).toBe(false); + + resolveExport(); + await command; + }); + + it('waits for the codebase upload to finish before returning', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([ + { id: 'ses-1', sessionDir: '/tmp/session-a' }, + ] as never); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + const sessionZipPath = await makeExportedSessionZip(); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + + let resolveCodebaseUpload!: () => void; + const codebaseUploadBlocked = new Promise<void>((resolve) => { + resolveCodebaseUpload = resolve; + }); + vi.mocked(uploadArchive).mockImplementation((_api, archive) => { + if (archive.path === sessionZipPath) return Promise.resolve(); + return codebaseUploadBlocked; + }); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(uploadArchive).toHaveBeenCalledTimes(2); + }); + expect(settled).toBe(false); + + resolveCodebaseUpload(); + await command; + expect(settled).toBe(true); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: '/tmp/fake-codebase.zip' }), + 3, + { filename: 'repo.zip' }, + ); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.not.objectContaining({ info: expect.anything() }), + ); + }); + + it('uploads session logs when codebase scanning fails but the session directory is available', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + vi.mocked(scanCodebase).mockRejectedValueOnce(new Error('scan failed')); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses-1', includeGlobalLog: true }), + ); + expect(packageCodebase).not.toHaveBeenCalled(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when feedback is sent but codebase packaging fails', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + vi.mocked(packageCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockRejectedValueOnce(new Error('zip failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + const calls = harness.auth.submitFeedback.mock.calls as unknown as Array<[Record<string, unknown>]>; + expect(calls[0]?.[0]?.['info']).toBeUndefined(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when the codebase upload fails', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + vi.mocked(uploadArchive).mockRejectedValueOnce(new Error('upload failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); }); it('shows feedback API error messages without replacing them with HTTP status text', async () => { @@ -380,7 +853,8 @@ describe('KimiTUI message flow', () => { }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'error', status: 500, @@ -444,7 +918,7 @@ describe('KimiTUI message flow', () => { const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 0, @@ -623,7 +1097,7 @@ describe('KimiTUI message flow', () => { let resolveSnapshot: ( servers: Array<{ name: string; - transport: 'stdio' | 'http'; + transport: 'stdio' | 'http' | 'sse'; status: 'pending' | 'connected' | 'failed' | 'disabled'; toolCount: number; error?: string; @@ -686,6 +1160,388 @@ describe('KimiTUI message flow', () => { ]); }); + it('keeps the transcript intact when undo RPC fails', async () => { + const session = makeSession({ + undoHistory: vi.fn(async () => { + throw new Error('core rpc unavailable'); + }), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Error: Failed to undo: core rpc unavailable', + ); + }); + + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'hello', + }), + ]); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('hello'); + }); + + it('does not duplicate welcome after undoing the only turn', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(driver.state.transcriptEntries).toEqual([]); + }); + + expect( + driver.state.transcriptContainer.children.filter( + (child) => child instanceof WelcomeComponent, + ), + ).toHaveLength(1); + }); + + it('keeps command notices that are not part of the undone context', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/auto on'); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('Auto mode: ON'); + }); + + driver.handleUserInput('/undo 10'); + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Cannot undo 10 prompts; only 1 prompt can be undone in the active context.', + ); + }); + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('Cannot undo 10 prompts'); + expect(transcript).toContain('Auto mode: ON'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('removes turn-scoped background status entries and restores welcome', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + driver.sessionEventHandler.handleEvent( + { + type: 'background.task.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + info: { + kind: 'process', + taskId: 'bash-bg123456', + command: 'npm test', + description: 'Run tests in background', + status: 'running', + pid: 1234, + exitCode: null, + startedAt: Date.now(), + endedAt: null, + }, + } as Event, + () => {}, + ); + + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('bash task started in background'); + expect(transcript).toContain('Run tests in background'); + }); + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + const transcript = stripSgr(renderTranscript(driver)); + expect(driver.state.transcriptEntries).toEqual([]); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('bash task started in background'); + expect(transcript).not.toContain('Run tests in background'); + expect( + driver.state.transcriptContainer.children.filter( + (child) => child instanceof WelcomeComponent, + ), + ).toHaveLength(1); + }); + + it('removes AgentSwarm progress from undone turns', async () => { + const { driver, session } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.handleUserInput('launch swarm'); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('launch swarm'); + expect(transcript).toContain('Agent Swarm'); + expect(transcript).toContain('Review changed files'); + + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('launch swarm'); + expect(transcript).not.toContain('Agent Swarm'); + expect(transcript).not.toContain('Review changed files'); + }); + + it('removes approval notices from undone turns', async () => { + const { driver, session } = await makeDriver(); + const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as + | ((request: ApprovalRequest) => Promise<ApprovalResponse>) + | undefined; + if (approvalHandler === undefined) throw new Error('expected approval handler'); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + const response = approvalHandler({ + turnId: 1, + toolCallId: 'call_bash', + toolName: 'Bash', + action: 'Run shell command', + display: { + kind: 'generic', + summary: 'Run shell command', + detail: { command: 'echo ok', description: 'Run a shell command' }, + }, + }); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApprovalPanelComponent); + }); + (driver.state.editorContainer.children[0] as ApprovalPanelComponent).handleInput('1'); + await expect(response).resolves.toMatchObject({ decision: 'approved' }); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('Approved: Run shell command'); + }); + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('Approved: Run shell command'); + }); + + it('removes debug timing status from undone turns', async () => { + const { driver, session } = await makeDriver(); + const previousDebug = process.env['KIMI_CODE_DEBUG']; + process.env['KIMI_CODE_DEBUG'] = '1'; + try { + driver.handleUserInput('hello'); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.completed', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + llmFirstTokenLatencyMs: 120, + llmStreamDurationMs: 800, + } as Event, + () => {}, + ); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('[Debug]'); + }); + + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('[Debug]'); + } finally { + if (previousDebug === undefined) { + delete process.env['KIMI_CODE_DEBUG']; + } else { + process.env['KIMI_CODE_DEBUG'] = previousDebug; + } + } + }); + + it('undoes multiple turns when a count is provided', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('first'); + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('second'); + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('third'); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/undo 2'); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(2); + }); + + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'first', + }), + ]); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('first'); + expect(transcript).not.toContain('second'); + expect(transcript).not.toContain('third'); + }); + + it('rejects invalid undo counts without changing context', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/undo 0'); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Error: Usage: /undo [count], where count is a positive integer.', + ); + }); + + expect(session.undoHistory).not.toHaveBeenCalled(); + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'hello', + }), + ]); + }); + + it('undoes from the real user turn when the last skill activation came from the model', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.sessionEventHandler.handleEvent( + { + type: 'skill.activated', + agentId: 'main', + activationId: 'act-model', + skillName: 'review', + trigger: 'model-tool', + } as Event, + () => {}, + ); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(driver.state.transcriptEntries).toEqual([]); + }); + + expect(driver.state.transcriptEntries).toEqual([]); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('review'); + }); + + it('keeps user-slash skill activations as undo anchors', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.sessionEventHandler.handleEvent( + { + type: 'skill.activated', + agentId: 'main', + activationId: 'act-user', + skillName: 'review', + trigger: 'user-slash', + } as Event, + () => {}, + ); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'hello', + }), + ]); + }); + + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'hello', + }), + ]); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('hello'); + expect(transcript).not.toContain('review'); + }); + it('sends pasted image placeholders as image content parts', async () => { const { driver, session } = await makeDriver(); const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; @@ -723,12 +1579,32 @@ describe('KimiTUI message flow', () => { const { driver, session } = await makeDriver(); driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft while streaming'); driver.state.editor.onEscape?.(); expect(session.cancel).toHaveBeenCalledTimes(1); + expect(driver.state.editor.getText()).toBe('draft while streaming'); session.cancel.mockClear(); driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText(''); + driver.state.editor.onCtrlC?.(); + + expect(session.cancel).toHaveBeenCalledTimes(1); + }); + + it('clears streaming editor text before cancelling the active turn on Ctrl-C', async () => { + const { driver, session } = await makeDriver(); + + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft while streaming'); + + driver.state.editor.onCtrlC?.(); + + expect(driver.state.editor.getText()).toBe(''); + expect(session.cancel).not.toHaveBeenCalled(); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + driver.state.editor.onCtrlC?.(); expect(session.cancel).toHaveBeenCalledTimes(1); @@ -764,6 +1640,370 @@ describe('KimiTUI message flow', () => { } }); + it('queues bash input with mode bash while a turn is streaming', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('dispatches a queued bash item to runShellCommand instead of prompt', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver } = await makeDriver(session); + + driver.sendQueuedMessage(session, { text: 'ls', mode: 'bash' }); + await Promise.resolve(); + + expect(runShellCommand).toHaveBeenCalledWith( + 'ls', + expect.objectContaining({ commandId: expect.any(String) }), + ); + expect(session.prompt).not.toHaveBeenCalled(); + }); + + it('persists bash input to input history with a leading !', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(driver.persistInputHistory).toHaveBeenCalledWith('!ls'); + }); + + it('persists normal input to input history', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('hello'); + + expect(driver.persistInputHistory).toHaveBeenCalledWith('hello'); + }); + + it('does not steer queued bash commands, keeping them queued', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [ + { text: 'ls', agentId: 'main', mode: 'bash' }, + { text: 'focus on tests', agentId: 'main' }, + ]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).toHaveBeenCalledWith('focus on tests'); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('does not steer while a shell command is running', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'shell'; + driver.state.queuedMessages = [{ text: 'summarize the output', agentId: 'main' }]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'summarize the output', agentId: 'main' }, + ]); + }); + + it('does not steer the editor draft while it is in bash mode', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.inputMode = 'bash'; + driver.state.editor.setText('ls'); + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.editor.getText()).toBe('ls'); + }); + + it('drains a queued image message with its media parts', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + driver.state.appState.streamingPhase = 'waiting'; + + driver.handleUserInput(`describe ${attachment.placeholder}`); + + expect(session.prompt).not.toHaveBeenCalled(); + const queued = driver.state.queuedMessages[0]; + expect(queued?.parts).toEqual([ + { type: 'text', text: 'describe ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ]); + + driver.sendQueuedMessage(session, queued!); + + expect(session.prompt).toHaveBeenCalledWith([ + { type: 'text', text: 'describe ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ]); + }); + + it('steers editor image input as media parts', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + driver.state.editor.setText(`check ${attachment.placeholder}`); + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).toHaveBeenCalledWith([ + { type: 'text', text: 'check ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ]); + }); + + it('steers queued image messages with their media parts', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + driver.state.queuedMessages = [ + { + text: `look ${attachment.placeholder}`, + agentId: 'main', + parts: [ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ], + imageAttachmentIds: [attachment.id], + }, + ]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).toHaveBeenCalledWith([ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ]); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('steers consecutive image-only messages without a whitespace-only separator part', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const first = imageStore.addImage(new Uint8Array([0xaa]), 'image/png', 1, 1); + const second = imageStore.addImage(new Uint8Array([0xbb]), 'image/png', 1, 1); + const imagePart = (bytes: Uint8Array) => ({ + type: 'image_url' as const, + imageUrl: { url: `data:image/png;base64,${Buffer.from(bytes).toString('base64')}` }, + }); + driver.state.queuedMessages = [ + { + text: first.placeholder, + agentId: 'main', + parts: [imagePart(first.bytes)], + imageAttachmentIds: [first.id], + }, + { + text: second.placeholder, + agentId: 'main', + parts: [imagePart(second.bytes)], + imageAttachmentIds: [second.id], + }, + ]; + + driver.state.editor.onCtrlS?.(); + + // normalizePromptInput rejects whitespace-only text parts, so the + // item separator must not become a standalone `{type:'text',text:'\n\n'}` + // between two image parts. + expect(session.steer).toHaveBeenCalledWith([imagePart(first.bytes), imagePart(second.bytes)]); + }); + + it('steers a media item followed by plain text with a blank-line separator', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + driver.state.queuedMessages = [ + { + text: `look ${attachment.placeholder}`, + agentId: 'main', + parts: [ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ], + imageAttachmentIds: [attachment.id], + }, + { text: 'focus on tests', agentId: 'main' }, + ]; + + driver.state.editor.onCtrlS?.(); + + // The historical '\n\n' item separator merges into the following text + // part (legal for normalizePromptInput) instead of vanishing after a + // media part. + expect(session.steer).toHaveBeenCalledWith([ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + { type: 'text', text: '\n\nfocus on tests' }, + ]); + }); + + it('steers plain text followed by a media item with a blank-line separator', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + driver.state.queuedMessages = [ + { text: 'hello', agentId: 'main' }, + { + text: attachment.placeholder, + agentId: 'main', + parts: [{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }], + imageAttachmentIds: [attachment.id], + }, + ]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).toHaveBeenCalledWith([ + { type: 'text', text: 'hello\n\n' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ]); + }); + + it('shows an error instead of throwing when skill media materialization fails', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + // The pasted video's source file vanished before submit — the cache copy + // throws, and it must surface as a TUI error, not an unhandled rejection. + const missing = imageStore.addVideo('video/quicktime', '/tmp/kimi-missing-source.mov'); + + ( + driver as unknown as { + sendSkillActivation(s: unknown, name: string, args: string): void; + } + ).sendSkillActivation(session, 'test', `look ${missing.placeholder}`); + + expect(session.activateSkill).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('Failed to prepare media attachment'); + }); + + it('shows an error instead of throwing when plugin command media materialization fails', async () => { + const activatePluginCommand = vi.fn(async () => {}); + const session = makeSession({ activatePluginCommand }); + const { driver } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const missing = imageStore.addVideo('video/mp4', '/tmp/kimi-missing-source.mp4'); + + ( + driver as unknown as { + activatePluginCommand(s: unknown, pluginId: string, command: string, args: string): void; + } + ).activatePluginCommand(session, 'plug', 'cmd', missing.placeholder); + + expect(activatePluginCommand).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('Failed to prepare media attachment'); + }); + + it('keeps the queue and draft intact when steer media extraction fails', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const missing = imageStore.addVideo('video/quicktime', '/tmp/kimi-missing-source.mov'); + driver.state.queuedMessages = [{ text: 'queued note', agentId: 'main' }]; + driver.state.editor.setText(`look ${missing.placeholder}`); + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([{ text: 'queued note', agentId: 'main' }]); + expect(driver.state.editor.getText()).toBe(`look ${missing.placeholder}`); + expect(stripSgr(renderTranscript(driver))).toContain('Failed to prepare media attachment'); + }); + + it('recalls a queued bash command back into bash mode on Up', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [{ text: 'ls', agentId: 'main', mode: 'bash' }]; + // After a bash command is queued the editor is reset to prompt mode. + driver.state.editor.inputMode = 'prompt'; + driver.state.appState.inputMode = 'prompt'; + + const handled = driver.state.editor.onUpArrowEmpty?.(); + + expect(handled).toBe(true); + expect(driver.state.editor.getText()).toBe('ls'); + expect(driver.state.editor.inputMode).toBe('bash'); + expect(driver.state.appState.inputMode).toBe('bash'); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('recalls a queued prompt message in prompt mode on Up', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [{ text: 'hello', agentId: 'main' }]; + driver.state.editor.inputMode = 'bash'; + driver.state.appState.inputMode = 'bash'; + + const handled = driver.state.editor.onUpArrowEmpty?.(); + + expect(handled).toBe(true); + expect(driver.state.editor.getText()).toBe('hello'); + expect(driver.state.editor.inputMode).toBe('prompt'); + expect(driver.state.appState.inputMode).toBe('prompt'); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('echoes a bash command with a $ prompt in the transcript', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver, harness } = await makeDriver(session); + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + await Promise.resolve(); + + expect(harness.track).toHaveBeenCalledWith('shell_command', undefined); + + const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('$ ls'); + expect(transcript).not.toContain('! ls'); + }); + it('renders cron fired events as distinct transcript entries', async () => { const { driver } = await makeDriver(); @@ -780,7 +2020,7 @@ describe('KimiTUI message flow', () => { coalescedCount: 1, stale: false, }, - prompt: '提醒用户:这是每分钟提醒', + prompt: 'Remind the user: this is a once-per-minute reminder', } as Event, vi.fn(), ); @@ -788,7 +2028,7 @@ describe('KimiTUI message flow', () => { const entry = driver.state.transcriptEntries.at(-1); expect(entry).toMatchObject({ kind: 'cron', - content: '提醒用户:这是每分钟提醒', + content: 'Remind the user: this is a once-per-minute reminder', cronData: { jobId: 'deadbeef', cron: '* * * * *', @@ -800,7 +2040,7 @@ describe('KimiTUI message flow', () => { const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); expect(transcript).toContain('Scheduled reminder fired'); expect(transcript).toContain('* * * * *'); - expect(transcript).toContain('提醒用户:这是每分钟提醒'); + expect(transcript).toContain('Remind the user: this is a once-per-minute reminder'); expect(transcript).not.toContain('<cron-fire'); }); @@ -849,7 +2089,7 @@ describe('KimiTUI message flow', () => { await vi.runOnlyPendingTimersAsync(); expect(updateSpy).toHaveBeenCalledTimes(1); - expect(updateSpy).toHaveBeenLastCalledWith('abc'); + expect(updateSpy).toHaveBeenLastCalledWith('abc', { transient: true }); } finally { vi.useRealTimers(); } @@ -946,6 +2186,30 @@ describe('KimiTUI message flow', () => { expect(session.cancelCompaction).toHaveBeenCalledTimes(1); }); + it('clears editor text before cancelling compaction on Ctrl-C', async () => { + const { driver, session } = await makeDriver(); + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + vi.fn(), + ); + driver.state.editor.setText('draft while compacting'); + + driver.state.editor.onCtrlC?.(); + + expect(driver.state.editor.getText()).toBe(''); + expect(session.cancelCompaction).not.toHaveBeenCalled(); + expect(driver.state.appState.isCompacting).toBe(true); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancelCompaction).toHaveBeenCalledTimes(1); + }); + it('dispatches the next queued message after compaction is cancelled', async () => { vi.useFakeTimers(); try { @@ -984,6 +2248,83 @@ describe('KimiTUI message flow', () => { } }); + it('stores the live compaction summary and expands it with tool output expansion', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.completed', + agentId: 'main', + sessionId: 'ses-1', + result: { + summary: 'Keep the src/tui compaction notes.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + } as Event, + sendQueued, + ); + + const collapsed = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).not.toContain('Keep the src/tui compaction notes.'); + + driver.state.editor.onToggleToolExpand?.(); + + const expanded = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(driver.state.toolOutputExpanded).toBe(true); + expect(expanded).toContain('Keep the src/tui compaction notes.'); + }); + + it('honors existing tool output expansion when a compaction block is created', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.state.editor.onToggleToolExpand?.(); + expect(driver.state.toolOutputExpanded).toBe(true); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.completed', + agentId: 'main', + sessionId: 'ses-1', + result: { + summary: 'Keep the src/tui compaction notes.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + } as Event, + sendQueued, + ); + + const transcript = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(transcript).toContain('Compaction complete'); + expect(transcript).toContain('Keep the src/tui compaction notes.'); + }); + it('renders an error instead of prompting when no model is selected', async () => { const { driver, session } = await makeDriver(); driver.state.appState.model = ''; @@ -1025,6 +2366,723 @@ describe('KimiTUI message flow', () => { expect(harness.track).toHaveBeenCalledWith('init_complete', undefined); }); + it('starts /btw through a forked side agent without changing the main busy state', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + harness.track.mockClear(); + driver.state.appState.streamingPhase = 'composing'; + driver.state.livePane.mode = 'thinking'; + + driver.handleUserInput('/btw What are you working on right now?'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledWith(); + }); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('What are you working on right now?'); + }); + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.appState.streamingPhase).toBe('composing'); + expect(driver.state.livePane.mode).toBe('thinking'); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'btw' }); + }); + + it('opens /btw without a question and sends the first panel input to a side agent', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/btw'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledWith(); + }); + expect(session.prompt).not.toHaveBeenCalled(); + expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question...'); + + driver.handleUserInput('What are you working on right now?'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('What are you working on right now?'); + }); + expect(session.steer).not.toHaveBeenCalled(); + expect(stripSgr(renderBtwPanel(driver))).toContain('Q: What are you working on right now?'); + }); + + it('cancels an unused /btw side agent when closing an empty panel', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/btw'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledWith(); + }); + driver.state.editor.onEscape?.(); + + expect(session.cancel).toHaveBeenCalledOnce(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + }); + + it('renders /btw output in a dedicated panel instead of an Agent tool card', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session, 'What are you working on right now?'); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'I am implementing the dedicated /btw panel.', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + expect(driver.state.btwPanelContainer.render(120)[0]?.trim()).toBe(''); + expect(getMountedBtwPanel(driver).isRunning()).toBe(false); + expect(driver.state.editor.focused).toBe(true); + + const transcript = stripSgr(renderTranscript(driver)); + const panel = stripSgr(renderBtwPanel(driver)); + const editorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(panel).toContain('BTW ─ Esc close'); + expect(panel).not.toContain('ctrl+o expand'); + expect(editorTopBorder.startsWith('├')).toBe(true); + expect(editorTopBorder.endsWith('┤')).toBe(true); + + driver.state.editor.handleInput('/'); + const highlightedEditorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(highlightedEditorTopBorder.startsWith('╭')).toBe(true); + expect(highlightedEditorTopBorder.endsWith('╮')).toBe(true); + expect(panel).not.toContain('BTW done'); + expect(panel).not.toContain('BTW running'); + expect(panel).not.toContain('BTW failed'); + expect(panel).not.toContain('Ask:'); + expect(panel).not.toContain('Type follow-up'); + expect(panel).toContain('Q: What are you working on right now?'); + expect(panel).toContain('I am implementing the dedicated /btw panel.'); + expect(panel).not.toContain('Agent'); + expect(transcript).not.toContain('BTW'); + expect(transcript).not.toContain('Esc close'); + expect(transcript).not.toContain('I am implementing the dedicated /btw panel.'); + }); + + it('keeps the /btw panel closest to the input after later transcript output', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'side answer', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + origin: { kind: 'user' }, + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: 'main answer after btw', + } as Event, + () => {}, + ); + driver.streamingUI.flushNow(); + + const transcript = stripSgr(renderTranscript(driver)); + const panel = stripSgr(renderBtwPanel(driver)); + const rootChildren = driver.state.ui.children; + expect(rootChildren.indexOf(driver.state.btwPanelContainer)).toBe( + rootChildren.indexOf(driver.state.editorContainer) - 1, + ); + expect(transcript).toContain('main answer after btw'); + expect(transcript).not.toContain('side answer'); + expect(panel).toContain('BTW'); + expect(panel).not.toContain('BTW done'); + expect(panel).not.toContain('BTW running'); + expect(panel).not.toContain('BTW failed'); + expect(panel).toContain('side answer'); + expect(panel).not.toContain('main answer after btw'); + }); + + it('renders only the tail of /btw thinking output', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7'].join('\n'), + } as Event, + () => {}, + ); + + const transcript = stripSgr(renderTranscript(driver)); + const panel = stripSgr(renderBtwPanel(driver)); + expect(transcript).not.toContain('line7'); + expect(panel).not.toContain('line1'); + expect(panel).not.toContain('line5'); + expect(panel).toContain('line6'); + expect(panel).toContain('line7'); + }); + + it('renders /btw body at its actual content height when under the cap', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + const lines = getMountedBtwPanel(driver).render(80).map(stripSgr); + expect(lines).toHaveLength(3); + expect(lines.join('\n')).toContain('Q: side question'); + expect(lines.join('\n')).toContain('Waiting for answer...'); + }); + + it('keeps /btw panel height stable when final output is shorter than thinking', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'thinking line 1\nthinking line 2', + } as Event, + () => {}, + ); + + const mountedPanel = getMountedBtwPanel(driver); + const thinkingLines = mountedPanel.render(80).map(stripSgr); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'final answer', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + const finalLines = mountedPanel.render(80).map(stripSgr); + expect(finalLines).toHaveLength(thinkingLines.length); + expect(finalLines.join('\n')).toContain('final answer'); + expect(finalLines.at(-1)).toMatch(/^│\s+│$/); + }); + + it('caps /btw height to one-third of the terminal and supports scrolling', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + setTerminalRows(driver, 15); + await openBtwPanel(driver, session, 'question 1'); + + const panel = getMountedBtwPanel(driver); + panel.appendAnswer('answer 1'); + panel.markDone(); + for (let i = 2; i <= 8; i++) { + panel.submit(`question ${String(i)}`); + panel.appendAnswer(`answer ${String(i)}`); + panel.markDone(); + } + + const collapsed = panel.render(80).map(stripSgr); + expect(collapsed).toHaveLength(5); + expect(collapsed.join('\n')).toContain('BTW ─ Esc close · ↑↓ scroll'); + expect(collapsed.join('\n')).not.toContain('ctrl+o expand'); + expect(collapsed.join('\n')).toContain('question 8'); + expect(collapsed.join('\n')).toContain('answer 8'); + expect(collapsed.join('\n')).not.toContain('question 1'); + + driver.state.editor.setText('draft main input'); + const collapsedWithInput = panel.render(80).map(stripSgr); + expect(collapsedWithInput.join('\n')).toContain('BTW ─ Esc close'); + expect(collapsedWithInput.join('\n')).not.toContain('↑↓ scroll'); + driver.state.editor.setText(''); + + const requestRender = vi.mocked(driver.state.ui.requestRender); + requestRender.mockClear(); + for (let i = 0; i < 20; i++) { + driver.state.editor.handleInput('\u001B[A'); + } + const scrolledUp = panel.render(80).map(stripSgr); + expect(requestRender).toHaveBeenCalled(); + expect(scrolledUp.join('\n')).toContain('question 1'); + expect(scrolledUp.join('\n')).not.toContain('answer 8'); + + panel.appendAnswer('\nstreamed tail while scrolled'); + expect(panel.render(80).map(stripSgr)).toEqual(scrolledUp); + + requestRender.mockClear(); + for (let i = 0; i < 20; i++) { + driver.state.editor.handleInput('\u001B[B'); + } + const scrolledDown = panel.render(80).map(stripSgr); + expect(requestRender).toHaveBeenCalled(); + expect(scrolledDown.join('\n')).toContain('question 8'); + expect(scrolledDown.join('\n')).toContain('answer 8'); + expect(scrolledDown.join('\n')).toContain('streamed tail while scrolled'); + + setTerminalRows(driver, 4); + const tiny = panel.render(80).map(stripSgr); + expect(tiny).toHaveLength(3); + expect(tiny.join('\n')).not.toContain('ctrl+o expand'); + expect(tiny.join('\n')).toContain('answer 8'); + + requestRender.mockClear(); + driver.state.editor.onToggleToolExpand?.(); + expect(driver.state.toolOutputExpanded).toBe(true); + expect(panel.render(80).map(stripSgr)).toEqual(tiny); + }); + + it('cancels and closes a running /btw panel on Escape', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + const panel = getMountedBtwPanel(driver); + expect(panel.isRunning()).toBe(true); + expect(driver.state.editor.focused).toBe(true); + + const requestRender = vi.mocked(driver.state.ui.requestRender); + requestRender.mockClear(); + driver.state.editor.onEscape?.(); + + expect(session.cancel).toHaveBeenCalledOnce(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(requestRender.mock.calls.at(-1)).toEqual([true]); + const editorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(editorTopBorder.startsWith('╭')).toBe(true); + expect(editorTopBorder.endsWith('╮')).toBe(true); + expect(driver.state.editor.focused).toBe(true); + }); + + it('cancels a running /btw panel on Ctrl-C without closing it or cancelling main streaming', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + const cancelledAgentIds: string[] = []; + session.cancel.mockImplementation(async () => { + cancelledAgentIds.push(harness.interactiveAgentId); + }); + await openBtwPanel(driver, session); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft main input'); + + const panel = getMountedBtwPanel(driver); + expect(panel.isRunning()).toBe(true); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancel).toHaveBeenCalledOnce(); + expect(cancelledAgentIds).toEqual(['agent-btw']); + expect(getMountedBtwPanel(driver)).toBe(panel); + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + expect(driver.state.editor.focused).toBe(true); + expect(driver.state.editor.getText()).toBe('draft main input'); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + }); + + it('preserves rendered /btw output when a running panel is cancelled', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'partial side answer', + } as Event, + () => {}, + ); + + driver.state.editor.onCtrlC?.(); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'cancelled', + } as Event, + () => {}, + ); + + const panel = stripSgr(renderBtwPanel(driver)); + expect(panel).toContain('partial side answer'); + expect(panel).toContain('Interrupted by user'); + }); + + it('cancels a running /btw panel when starting a new session clears it', async () => { + const initialSession = makeSession({ id: 'ses-initial' }); + const nextSession = makeSession({ id: 'ses-next' }); + const createSession = vi + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValueOnce(nextSession); + const { driver, harness } = await makeDriver(initialSession, { createSession }); + const cancelledAgentIds: string[] = []; + initialSession.cancel.mockImplementation(async () => { + cancelledAgentIds.push(harness.interactiveAgentId); + }); + await openBtwPanel(driver, initialSession); + + driver.handleUserInput('/new'); + + await vi.waitFor(() => { + expect(driver.getCurrentSessionId()).toBe('ses-next'); + }); + expect(initialSession.cancel).toHaveBeenCalledOnce(); + expect(cancelledAgentIds).toEqual(['agent-btw']); + expect(nextSession.cancel).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + }); + + it('closes a completed /btw panel on Ctrl-C without cancelling main streaming', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft main input'); + + expect(getMountedBtwPanel(driver).isRunning()).toBe(false); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancel).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(driver.state.editor.focused).toBe(true); + expect(driver.state.editor.getText()).toBe('draft main input'); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + }); + + it('closes a completed /btw panel on Escape without cancelling it', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + const panel = getMountedBtwPanel(driver); + expect(panel.isRunning()).toBe(false); + expect(driver.state.editor.focused).toBe(true); + + driver.state.editor.onEscape?.(); + + expect(session.cancel).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(driver.state.editor.focused).toBe(true); + }); + + it('sends follow-up /btw input through ordinary prompt on the same side agent', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session, 'first question'); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + const panel = getMountedBtwPanel(driver); + expect(panel.isRunning()).toBe(false); + driver.handleUserInput('follow up'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('follow up'); + }); + expect(session.prompt).toHaveBeenCalledTimes(2); + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + expect(driver.state.editor.focused).toBe(true); + }); + + it('keeps main input pointed at /btw while the panel is open', async () => { + let resolveBtwPrompt: (() => void) | undefined; + const session = makeSession({ + prompt: vi.fn( + () => + new Promise<void>((resolve) => { + resolveBtwPrompt = resolve; + }), + ), + }); + const { driver, harness } = await makeDriver(session); + + await openBtwPanel(driver, session, 'slow side question'); + + expect(harness.interactiveAgentId).toBe('main'); + driver.handleUserInput('follow-up while btw prompt is pending'); + driver.handleUserInput('another follow-up while btw prompt is pending'); + + expect(session.prompt).toHaveBeenCalledTimes(1); + expect(driver.state.queuedMessages).toEqual([]); + expect(driver.state.editor.getText()).toBe('another follow-up while btw prompt is pending'); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Wait for /btw to finish before sending another question.', + ); + expect( + countOccurrences( + stripSgr(renderBtwPanel(driver)), + 'Wait for /btw to finish before sending another question.', + ), + ).toBe(2); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + expect(stripSgr(renderBtwPanel(driver))).not.toContain( + 'Wait for /btw to finish before sending another question.', + ); + + resolveBtwPrompt?.(); + }); + + it('replaces a running /btw panel when another /btw command is submitted', async () => { + const session = makeSession({ + startBtw: vi.fn() + .mockResolvedValueOnce('agent-btw-1') + .mockResolvedValueOnce('agent-btw-2'), + }); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session, 'first question'); + + const firstPanel = getMountedBtwPanel(driver); + expect(firstPanel.isRunning()).toBe(true); + + driver.handleUserInput('/btw second question'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledTimes(2); + }); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('second question'); + }); + + const secondPanel = getMountedBtwPanel(driver); + expect(secondPanel).not.toBe(firstPanel); + expect(session.cancel).toHaveBeenCalledTimes(1); + expect(session.prompt).toHaveBeenCalledTimes(2); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw-1', + sessionId: 'ses-1', + turnId: 0, + delta: 'answer from old side agent', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw-2', + sessionId: 'ses-1', + turnId: 1, + delta: 'answer from new side agent', + } as Event, + () => {}, + ); + + const renderedPanel = stripSgr(renderBtwPanel(driver)); + expect(renderedPanel).not.toContain('answer from old side agent'); + expect(renderedPanel).toContain('answer from new side agent'); + }); + + it('does not run /btw without a selected model', async () => { + const { driver, session } = await makeDriver(); + + driver.state.appState.model = ''; + driver.handleUserInput('/btw'); + expect(session.startBtw).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); + + driver.handleUserInput('/btw What are you doing now?'); + + expect(session.startBtw).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); + }); + + it('applies the effective thinking effort from status updates', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + model: 'turbo', + thinkingEffort: 'mid', + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('mid'); + }); + + it('renders swarm mode markers from /swarm commands, not tool-triggered status updates', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + swarmMode: true, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.swarmMode).toBe(true); + expect(stripSgr(renderTranscript(driver))).not.toContain('Swarm activated'); + + let transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Swarm activated')).toBe(0); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + swarmMode: false, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.swarmMode).toBe(false); + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('Swarm deactivated'); + expect(transcript).not.toContain('Swarm ended'); + + expect(countOccurrences(transcript, 'Swarm activated')).toBe(0); + expect(countOccurrences(transcript, 'Swarm deactivated')).toBe(0); + expect(countOccurrences(transcript, 'Swarm ended')).toBe(0); + }); + + it('renders an ended marker when a one-shot /swarm task exits', async () => { + const { driver, session } = await makeDriver(undefined); + driver.state.appState.permissionMode = 'auto'; + + driver.handleUserInput('/swarm Ship feature X'); + + await vi.waitFor(() => { + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + }); + await vi.waitFor(() => { + expect(countOccurrences(stripSgr(renderTranscript(driver)), 'Swarm activated')).toBe(1); + }); + let transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Swarm activated')).toBe(1); + expect(transcript).not.toContain('Swarm ended'); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + swarmMode: false, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.swarmMode).toBe(false); + transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Swarm activated')).toBe(1); + expect(countOccurrences(transcript, 'Swarm ended')).toBe(1); + expect(transcript).not.toContain('Swarm deactivated'); + }); + it('queues Ctrl-S input instead of steering while /init is running', async () => { let resolveInit: (() => void) | undefined; const session = makeSession({ @@ -1113,10 +3171,49 @@ describe('KimiTUI message flow', () => { const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('OAuth login expired. Send /login to login.'); expect(transcript).not.toContain('[auth.login_required]'); - expect(transcript).not.toContain('kimi export'); + expect(transcript).not.toContain('/export-debug-zip'); }); - it('appends the kimi export hint beneath session error messages', async () => { + it('shows a programmatic abort reason instead of reporting a user interruption', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.interrupted', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + reason: 'aborted', + message: 'Tool execution timed out', + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Error: Tool execution timed out'); + expect(transcript).not.toContain('Interrupted by user'); + }); + + it('keeps unmessaged aborted events compatible with user interruptions', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.interrupted', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + reason: 'aborted', + } as Event, + vi.fn(), + ); + + expect(stripSgr(renderTranscript(driver))).toContain('Interrupted by user'); + }); + + it('appends the /export-debug-zip hint beneath session error messages', async () => { const { driver } = await makeDriver(); driver.sessionEventHandler.handleEvent( @@ -1133,11 +3230,47 @@ describe('KimiTUI message flow', () => { const transcript = stripSgr(driver.state.transcriptContainer.render(200).join('\n')); expect(transcript).toContain('Error: [compaction.failed]'); - expect(transcript).toContain('If this persists, run `kimi export ses-1`'); + expect(transcript).toContain('If this persists, run `/export-debug-zip`'); expect(transcript).toContain("Please don't share it publicly"); + expect(transcript).not.toContain('kimi export'); }); - it('skips the kimi export hint when no active session id is set', async () => { + it('shows concise provider filter text for filtered session errors', async () => { + const { driver } = await makeDriver(); + const verboseMessage = + 'The API returned a response containing only thinking content without any text or tool calls. ' + + 'This usually indicates the stream was interrupted or the output token budget was exhausted ' + + 'during reasoning. Provider stop details: finishReason=filtered, rawFinishReason=content_filter. ' + + 'The provider filtered the response before visible output was emitted. Provider: example-provider, model: example-model'; + + driver.sessionEventHandler.handleEvent( + { + type: 'error', + agentId: 'main', + sessionId: 'ses-1', + code: 'provider.api_error', + message: verboseMessage, + details: { + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }, + retryable: true, + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(driver.state.transcriptContainer.render(200).join('\n')); + expect(transcript).toContain( + 'Error: [provider.api_error] Provider filtered the response before visible output', + ); + expect(transcript).toContain('finishReason=filtered'); + expect(transcript).toContain('rawFinishReason=content_filter'); + expect(transcript).not.toContain('only thinking content'); + expect(transcript).not.toContain('token budget'); + expect(transcript).not.toContain('stream was interrupted'); + }); + + it('skips the /export-debug-zip hint when no active session id is set', async () => { const { driver } = await makeDriver(); driver.state.appState.sessionId = ''; @@ -1155,7 +3288,7 @@ describe('KimiTUI message flow', () => { const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Error: [compaction.failed]'); - expect(transcript).not.toContain('kimi export'); + expect(transcript).not.toContain('/export-debug-zip'); }); it('shows ExitPlanMode plan only in the current-plan card during approval', async () => { @@ -1212,6 +3345,426 @@ describe('KimiTUI message flow', () => { }); }); + it('renders AgentSwarm progress in the transcript instead of the tool-card body', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_swarm', + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Review changed files #1 (coder)', + swarmIndex: 1, + runInBackground: false, + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_swarm', + subagentId: 'agent-2', + subagentName: 'coder', + description: 'Review changed files #2 (coder)', + swarmIndex: 2, + runInBackground: false, + } as Event, + sendQueued, + ); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'agent-1', + sessionId: 'ses-1', + turnId: 2, + toolCallId: 'call_read', + name: 'Read', + args: { path: 'src/a.ts' }, + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-1', + sessionId: 'ses-1', + turnId: 2, + delta: 'Reviewing src/a.ts and checking imports for regressions in detail', + } as Event, + sendQueued, + ); + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('01 ['); + expect(transcript).toContain('Reviewing src/a.ts'); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.suspended', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + reason: 'Provider rate limit; subagent requeued for retry.', + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('001 ['); + expect(transcript).toContain('Queued...'); + expect(transcript).not.toContain('Provider rate limit'); + expect(transcript).not.toContain('Failed'); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.started', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('01 ['); + expect(transcript).not.toContain('Suspended'); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-1', + sessionId: 'ses-1', + turnId: 2, + reason: 'completed', + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Agent Swarm'); + expect(transcript).toContain('Review changed files'); + expect(transcript).toContain('001 ['); + expect(transcript).toContain('Reviewing src/a.ts'); + expect(transcript).not.toContain('Completed'); + expect(transcript).toContain('002 Queued...'); + expect(transcript).not.toContain('002 ['); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.completed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + resultSummary: 'Imports are stable', + } as Event, + sendQueued, + ); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('✓ Imports are stable'); + expect(transcript).not.toContain('Completed'); + }); + + it('marks only core user-cancellation subagent failures as cancelled', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + + for (const [index, subagentId] of ['agent-1', 'agent-2'].entries()) { + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_swarm', + subagentId, + subagentName: 'coder', + description: `Review changed files #${String(index + 1)} (coder)`, + swarmIndex: index + 1, + runInBackground: false, + } as Event, + sendQueued, + ); + } + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + error: 'Aborted by the user', + } as Event, + sendQueued, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-2', + error: 'The user manually interrupted this subagent x.', + } as Event, + sendQueued, + ); + + const transcript = stripSgr(driver.state.transcriptContainer.render(200).join('\n')); + expect(transcript).toContain('⊘ Cancelled.'); + expect(transcript).toContain('✗ The user manually interrupted this subagent x.'); + }); + + it('does not let later transcript entries reduce the AgentSwarm grid height', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + const terminalColumns = 80; + setTerminalColumns(driver, terminalColumns); + const outerChildren = driver.state.ui.children; + const transcriptIndex = outerChildren.indexOf(driver.state.transcriptContainer); + const rowsAfterTranscript = outerChildren + .slice(transcriptIndex + 1) + .reduce((sum, child) => sum + child.render(terminalColumns).length, 0); + const nonGridRows = 20 - (agentSwarmGridHeightForTerminalRows(20) ?? 0); + setTerminalRows(driver, rowsAfterTranscript + nonGridRows + 2); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts'], + }, + } as Event, + sendQueued, + ); + + const swarmProgress = driver.state.transcriptContainer.children.find( + (child): child is AgentSwarmProgressComponent => child instanceof AgentSwarmProgressComponent, + ); + if (swarmProgress === undefined) throw new Error('expected AgentSwarm progress'); + + const transcriptWidth = Math.max(1, terminalColumns - 2); + const renderSwarm = (): string => + stripSgr(swarmProgress.render(transcriptWidth).join('\n')); + + expect(renderSwarm()).toContain('001 Queued...'); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_read', + name: 'Read', + args: { path: 'src/after.ts' }, + } as Event, + sendQueued, + ); + + const transcriptChildren = driver.state.transcriptContainer.children; + const swarmIndex = transcriptChildren.indexOf( + swarmProgress as (typeof transcriptChildren)[number], + ); + expect(swarmIndex).toBeGreaterThanOrEqual(0); + + const rowsAfterSwarmInTranscript = transcriptChildren + .slice(swarmIndex + 1) + .reduce((sum, child) => sum + child.render(transcriptWidth).length, 0); + expect(rowsAfterSwarmInTranscript).toBeGreaterThan(0); + + expect(renderSwarm()).toContain('001 Queued...'); + const transcript = stripSgr( + driver.state.transcriptContainer.render(terminalColumns).join('\n'), + ); + expect(transcript).toContain('Using Read (src/after.ts)'); + }); + + it('shows AgentSwarm as completed when only some subagents fail', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + output: [ + '<agent_swarm_result>', + '<summary>completed: 1, failed: 1</summary>', + '<subagent index="1" agent_id="agent-1" outcome="completed">Imports are stable.</subagent>', + '<subagent index="2" agent_id="agent-2" outcome="failed">Agent timed out after 30s.</subagent>', + '</agent_swarm_result>', + ].join('\n'), + isError: undefined, + } as Event, + sendQueued, + ); + + const transcript = stripSgr(renderTranscript(driver)); + const totalStatusLine = transcript.split('\n').find((line) => line.includes('Completed.')); + expect(totalStatusLine).toBeDefined(); + expect(totalStatusLine).not.toContain('Failed.'); + expect(transcript).toContain('✓ Imports are stable.'); + expect(transcript).toContain('✗ Agent timed out after 30s.'); + }); + + it('renders AgentSwarm progress while tool args are still streaming', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + argumentsPart: '{"description":"Review changed files', + } as Event, + sendQueued, + ); + + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Agent Swarm'); + expect(transcript).toContain('Orchestrating...'); + expect(transcript).not.toContain('01'); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + argumentsPart: '","items":["src/a.ts","src/b', + } as Event, + sendQueued, + ); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Agent Swarm'); + expect(transcript).toContain('Review changed files'); + expect(transcript).toContain('001 src/a.ts'); + expect(transcript).toContain('002 src/b'); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_swarm', + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Review changed files #1 (coder)', + swarmIndex: 1, + runInBackground: false, + } as Event, + sendQueued, + ); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('001 Queued...'); + expect(transcript).not.toContain('001 ['); + expect(transcript).toContain('002 src/b'); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('001 Queued...'); + expect(transcript).toContain('002 Queued...'); + expect(transcript).not.toContain('001 ['); + expect(transcript).not.toContain('002 ['); + }); + it('shows plan review reject on the plan card without an approval notice', async () => { const planContent = '# Reject Plan\n\n- keep this plan visible after reject'; const session = makeSession({ @@ -1292,7 +3845,7 @@ describe('KimiTUI message flow', () => { const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'high', + thinkingEffort: 'high', permission: 'auto', planMode: true, contextTokens: 25, @@ -1312,7 +3865,7 @@ describe('KimiTUI message flow', () => { expect(output).toContain(' Status '); expect(output).toContain('>_ Kimi Code'); expect(output).toContain('Model'); - expect(output).toContain('thinking on'); + expect(output).toContain('thinking high'); expect(output).toContain('Permissions auto'); expect(output).toContain('Plan mode on'); expect(output).toContain('Context window'); @@ -1436,15 +3989,46 @@ describe('KimiTUI message flow', () => { expect(session.installPlugin).not.toHaveBeenCalled(); }); - it('installs from a positional source on /plugins install', async () => { + it('installs from a positional source on /plugins install after trusting it', async () => { const session = makeSession(); const { driver } = await makeDriver(session); driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith('/tmp/proj-a/plugins/kimi-datasource'); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith( + resolve('/tmp/proj-a', './plugins/kimi-datasource'), + ); + }); + }); + + it('does not install when the third-party trust prompt is dismissed', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\r'); // default option is "Exit" + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + }); + expect(session.installPlugin).not.toHaveBeenCalled(); }); it('loads a local plugin marketplace file and installs from it', async () => { @@ -1456,9 +4040,10 @@ describe('KimiTUI message flow', () => { plugins: [ { id: 'kimi-datasource', + tier: 'official', displayName: 'Kimi Datasource', description: 'Datasource plugin', - source: './kimi-datasource', + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', }, ], }), @@ -1471,21 +4056,194 @@ describe('KimiTUI message flow', () => { driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginMarketplaceSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - picker.handleInput(' '); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + // Official loads its catalog lazily; wait for the entry to render before install. + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + // The pinned Kimi WebBridge row leads the Official tab, so move down to + // the Kimi Datasource entry before installing. + panel.handleInput('\u001B[B'); + panel.handleInput('\r'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'kimi-datasource')); + expect(session.installPlugin).toHaveBeenCalledWith( + 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + ); }); await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Installing or updating Kimi Datasource from marketplace...'); - expect(transcript).toContain('Installed or updated Demo'); + expect(transcript).toContain('Installed Demo'); + expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); }); + // Installing closes the panel so the success notice / reload tip is visible. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + }); + }); + + it('returns to the plugin list when a marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'kimi-datasource', + tier: 'official', + displayName: 'Kimi Datasource', + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + }, + ], + }), + 'utf8', + ); + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = marketplacePath; + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins marketplace'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + panel.handleInput('\r'); + + // The panel must not get stuck on the one-way "Installing…" view; it should + // return to the list so the user can retry. + await vi.waitFor(() => { + const rendered = stripSgr(panel.render(120).join('\n')); + expect(rendered).toContain('Kimi Datasource'); + expect(rendered).not.toContain('Installing'); + }); + }); + + it('prompts for trust before installing a third-party marketplace entry', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + description: 'Curated plugin', + source: './superpowers', + }, + ], + }), + 'utf8', + ); + const session = makeSession(); + const { driver } = await makeDriver(session); + + // Passing the marketplace path opens the panel directly on the Third-party tab. + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'superpowers')); + }); + }); + + it('restores the panel when a third-party marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + source: './superpowers', + }, + ], + }), + 'utf8', + ); + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + // The failed install must return the user to the marketplace panel so they + // can retry, rather than dropping them back at the editor. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(panel); + }); + }); + + it('removes a plugin record without auto-running any cleanup skill', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins remove kimi-webbridge'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginRemoveConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginRemoveConfirmComponent; + confirm.handleInput('\u001B[B'); + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.removePlugin).toHaveBeenCalledWith('kimi-webbridge'); + }); + expect(session.activateSkill).not.toHaveBeenCalled(); }); it('installs default marketplace entries through plain install', async () => { @@ -1508,12 +4266,16 @@ describe('KimiTUI message flow', () => { driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginMarketplaceSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - picker.handleInput(' '); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + // The pinned Kimi WebBridge row leads the Official tab, so move down to + // the Kimi Datasource entry before installing. + panel.handleInput('\u001B[B'); + panel.handleInput('\r'); await vi.waitFor(() => { expect(session.installPlugin).toHaveBeenCalledWith( @@ -1526,7 +4288,41 @@ describe('KimiTUI message flow', () => { } }); - it('toggles plugins from the overview with space', async () => { + it('shows an inline Official error when the marketplace is unreachable, keeping the panel open', async () => { + const originalFetch = globalThis.fetch; + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = 'https://example.test/marketplace.json'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('fetch failed'); + }), + ); + const session = makeSession(); + const { driver } = await makeDriver(session); + + try { + driver.handleUserInput('/plugins'); + + // The panel opens immediately on the Installed tab — no marketplace fetch. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput('\t'); // → Official, which lazily (and unsuccessfully) loads + + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain( + 'Marketplace unavailable: fetch failed', + ); + }); + // The panel stays mounted; the failure does not close /plugins. + expect(driver.state.editorContainer.children[0]).toBe(panel); + } finally { + vi.stubGlobal('fetch', originalFetch); + } + }); + + it('toggles plugins from the Installed tab with space', async () => { let enabled = true; const session = makeSession({ listPlugins: vi.fn(async () => [ @@ -1540,6 +4336,7 @@ describe('KimiTUI message flow', () => { mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, + source: 'local-path', }, ]), setPluginEnabled: vi.fn(async (_id: string, nextEnabled: boolean) => { @@ -1551,25 +4348,25 @@ describe('KimiTUI message flow', () => { driver.handleUserInput('/plugins'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent; - overview.handleInput(' '); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput(' '); + + // Toggling refreshes the panel in place: it must not flash back to the + // editor between the keypress and the refreshed panel mounting. + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); await vi.waitFor(() => { expect(session.setPluginEnabled).toHaveBeenCalledWith('demo', false); }); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + const refreshed = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); + expect(refreshed).toContain('❯ Demo disabled run /reload or /new to apply'); }); - const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ Demo disabled pending /new'); - expect(out).not.toContain('Space enable'); - expect(stripSgr(renderTranscript(driver))).not.toContain('Disabled demo. Run /new to apply.'); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Disabled demo. Run /reload or /new to apply.', + ); }); it('toggles plugin MCP servers from the overview MCP picker', async () => { @@ -1633,12 +4430,10 @@ describe('KimiTUI message flow', () => { driver.handleUserInput('/plugins'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent; - overview.handleInput('m'); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput('m'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( @@ -1660,9 +4455,9 @@ describe('KimiTUI message flow', () => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginMcpSelectorComponent); }); const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ data disabled pending /new'); + expect(out).toContain('❯ data disabled run /reload or /new to apply'); expect(stripSgr(renderTranscript(driver))).not.toContain( - 'Disabled MCP server data for kimi-datasource. Run /new to apply.', + 'Disabled MCP server data for kimi-datasource. Run /reload or /new to apply.', ); }); @@ -1736,25 +4531,28 @@ describe('KimiTUI message flow', () => { }, }, defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, })), setConfig, }); driver.handleUserInput('/model turbo'); + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); const picker = driver.state.editorContainer.children[0]; - expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); const pickerOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); - expect(pickerOutput).toContain('Kimi K2 (Kimi Code) ← current'); - expect(pickerOutput).toContain('❯ Kimi Turbo (Kimi Code)'); + expect(pickerOutput).toMatch(/Kimi K2\s+Kimi Code ← current/); + expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+Kimi Code/); (picker as TabbedModelSelectorComponent).handleInput('t'); (picker as TabbedModelSelectorComponent).handleInput('u'); const filteredOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); expect(filteredOutput).toContain('Search: tu'); - expect(filteredOutput).toContain('Kimi Turbo (Kimi Code)'); - expect(filteredOutput).not.toContain('Kimi K2 (Kimi Code)'); - (picker as TabbedModelSelectorComponent).handleInput('/'); + expect(filteredOutput).toContain('Kimi Turbo'); + expect(filteredOutput).not.toContain('Kimi K2'); + // Turbo is a thinking-capable model that is not the active one, so it + // defaults to thinking on — selecting it applies thinking without a toggle. (picker as TabbedModelSelectorComponent).handleInput('\r'); await vi.waitFor(() => { @@ -1762,11 +4560,117 @@ describe('KimiTUI message flow', () => { expect(session.setThinking).toHaveBeenCalledWith('on'); expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'turbo', - defaultThinking: true, + thinking: { enabled: true }, }); }); expect(driver.state.appState.model).toBe('turbo'); - expect(driver.state.appState.thinking).toBe(true); + expect(driver.state.appState.thinkingEffort).toBe('on'); + }); + + it('applies /model selection to the session only on Alt+S without persisting', async () => { + const session = makeSession(); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + displayName: 'Kimi Turbo', + capabilities: ['thinking'], + }, + }, + defaultModel: 'k2', + thinking: { enabled: false }, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0]; + // /model turbo preselects turbo; Alt+S applies it to the current session only. + (picker as TabbedModelSelectorComponent).handleInput(`${ESC}s`); + + await vi.waitFor(() => { + expect(session.setModel).toHaveBeenCalledWith('turbo'); + expect(session.setThinking).toHaveBeenCalledWith('on'); + }); + expect(setConfig).not.toHaveBeenCalled(); + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('on'); + }); + + it('uses the effective effort returned after a model-switch fallback', async () => { + let switched = false; + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: switched ? 'turbo' : 'k2', + thinkingEffort: switched ? 'mid' : 'ultra', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setModel: vi.fn(async () => { + switched = true; + }), + }); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'ultra'], + defaultEffort: 'ultra', + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'mid', 'high'], + defaultEffort: 'mid', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'ultra' }, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'turbo', + thinking: { enabled: true, effort: 'mid' }, + }); + }); + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('mid'); + expect(renderTranscript(driver)).toContain('Switched to kimi-turbo with thinking mid.'); }); it('persists /model selection even when runtime state is unchanged', async () => { @@ -1784,27 +4688,124 @@ describe('KimiTUI message flow', () => { }, }, defaultModel: 'old-default', - defaultThinking: true, + thinking: { enabled: true }, })), setConfig, }); driver.handleUserInput('/model k2'); + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); const picker = driver.state.editorContainer.children[0]; - expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); (picker as TabbedModelSelectorComponent).handleInput('\r'); await vi.waitFor(() => { expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, }); }); expect(session.setModel).not.toHaveBeenCalled(); expect(session.setThinking).not.toHaveBeenCalled(); }); + it('refreshes only OAuth provider models before opening /model picker', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Old Kimi K2', + capabilities: ['thinking'], + }, + }, + })), + }); + const tui = driver as unknown as KimiTUI; + const refreshProviderModels = vi + .spyOn(tui.authFlow, 'refreshProviderModels') + .mockRejectedValue(new Error('full provider refresh should not run')); + const refreshOAuthProviderModels = vi.fn(async () => { + await Promise.resolve(); + tui.setAppState({ + availableModels: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Fresh Kimi K2', + capabilities: ['thinking'], + }, + }, + }); + return { changed: [], unchanged: ['managed:kimi-code'], failed: [] }; + }); + ( + tui.authFlow as unknown as { + refreshOAuthProviderModels: typeof refreshOAuthProviderModels; + } + ).refreshOAuthProviderModels = refreshOAuthProviderModels; + + driver.handleUserInput('/model'); + + await vi.waitFor(() => { + const picker = driver.state.editorContainer.children[0]; + expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); + const output = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); + expect(output).toContain('Fresh Kimi K2'); + expect(output).not.toContain('Old Kimi K2'); + }); + expect(refreshOAuthProviderModels).toHaveBeenCalledOnce(); + expect(refreshProviderModels).not.toHaveBeenCalled(); + }); + + it('opens /model picker after 2s when OAuth refresh is still pending', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + }, + })), + }); + const tui = driver as unknown as KimiTUI; + const refreshOAuthProviderModels = vi.fn(() => new Promise<never>(() => {})); + ( + tui.authFlow as unknown as { + refreshOAuthProviderModels: typeof refreshOAuthProviderModels; + } + ).refreshOAuthProviderModels = refreshOAuthProviderModels; + + vi.useFakeTimers(); + try { + driver.handleUserInput('/model'); + await Promise.resolve(); + + expect(refreshOAuthProviderModels).toHaveBeenCalledOnce(); + expect(driver.state.editorContainer.children[0]).not.toBeInstanceOf(TabbedModelSelectorComponent); + + await vi.advanceTimersByTimeAsync(1_999); + expect(driver.state.editorContainer.children[0]).not.toBeInstanceOf(TabbedModelSelectorComponent); + + await vi.advanceTimersByTimeAsync(1); + const picker = driver.state.editorContainer.children[0]; + expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); + const output = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); + expect(output).toContain('Kimi K2'); + } finally { + vi.useRealTimers(); + } + }); + it('enables search in the shared model selector helper', async () => { const { driver } = await makeDriver(); const selection = runModelSelector(driver as any, { @@ -1831,8 +4832,8 @@ describe('KimiTUI message flow', () => { const output = stripSgr((picker as ModelSelectorComponent).render(120).join('\n')); expect(output).toContain('Search: tu'); - expect(output).toContain('Kimi Turbo (Kimi Code)'); - expect(output).not.toContain('Kimi Alpha (Kimi Code)'); + expect(output).toContain('Kimi Turbo'); + expect(output).not.toContain('Kimi Alpha'); (picker as ModelSelectorComponent).handleInput('\u001B'); (picker as ModelSelectorComponent).handleInput('\u001B'); @@ -1855,6 +4856,30 @@ describe('KimiTUI message flow', () => { expect(write).toHaveBeenCalledWith(deleteAllKittyImages()); }); + it('updates terminal title through pi-tui without changing process title', async () => { + const originalTitle = process.title; + const { driver } = await makeDriver(makeSession({ id: 'ses-1' })); + const setTitle = vi.spyOn(driver.state.terminal, 'setTitle').mockImplementation(() => {}); + + try { + process.title = 'kimi-test-runner'; + driver.sessionEventHandler.handleEvent( + { + type: 'session.meta.updated', + sessionId: 'ses-1', + agentId: 'main', + title: 'Implement terminal title', + } as Event, + () => {}, + ); + + expect(setTitle).toHaveBeenCalledWith('Implement terminal title'); + expect(process.title).toBe('kimi-test-runner'); + } finally { + process.title = originalTitle; + } + }); + it('forks the active session and switches to the returned session', async () => { const originalTitle = process.title; const source = makeSession({ @@ -1867,8 +4892,10 @@ describe('KimiTUI message flow', () => { }); const forkSession = vi.fn(async () => forked); const { driver, harness } = await makeDriver(source, { forkSession }); + const setTitle = vi.spyOn(driver.state.terminal, 'setTitle').mockImplementation(() => {}); try { + process.title = 'kimi-test-runner'; driver.handleUserInput('/fork ignored args'); await vi.waitFor(() => { @@ -1878,7 +4905,8 @@ describe('KimiTUI message flow', () => { }); expect(driver.getCurrentSessionId()).toBe('ses-fork'); }); - expect(process.title).toBe('Fork: Source title'); + expect(setTitle).toHaveBeenCalledWith('Fork: Source title'); + expect(process.title).toBe('kimi-test-runner'); expect(source.close).toHaveBeenCalledOnce(); expect(forked.onEvent).toHaveBeenCalledOnce(); expect(harness.resumeSession).not.toHaveBeenCalled(); @@ -1928,6 +4956,58 @@ describe('KimiTUI message flow', () => { expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); }); + it('keeps the waiting moon spinner while reasoning streams only empty (encrypted) thinking deltas', async () => { + const { driver } = await makeDriver(); + + // Turn begins -> waiting mode shows the moon spinner. + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + } as Event, + vi.fn(), + ); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.livePane.mode).toBe('waiting'); + + // Encrypted reasoning: thinking.delta events whose visible text is empty. + for (let i = 0; i < 3; i++) { + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: '', + } as Event, + vi.fn(), + ); + } + + // The moon must stay up: still waiting, no orphan thinking component, and + // the activity pane still renders a moon frame (no blank, spinner-less gap). + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.livePane.mode).toBe('waiting'); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); + const activity = stripSgr(renderActivity(driver)); + expect(MOON_SPINNER_FRAMES.some((frame) => activity.includes(frame))).toBe(true); + + // Real thinking text finally arrives -> transition into thinking mode. + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: 'actual reasoning', + } as Event, + vi.fn(), + ); + driver.streamingUI.flushNow(); + expect(driver.state.appState.streamingPhase).toBe('thinking'); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(true); + }); + it('finalizes an orphaned thinking component on turn end', async () => { const { driver } = await makeDriver(); driver.state.appState.streamingPhase = 'thinking'; @@ -1986,7 +5066,7 @@ describe('KimiTUI message flow', () => { const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('t7'); - expect(transcript).not.toContain('ctrl+o to expand'); + expect(transcript).not.toContain('ctrl+o expand'); }); it('renders hook results without XML tags', async () => { @@ -2031,3 +5111,81 @@ describe('KimiTUI message flow', () => { expect(transcript).not.toContain('<hook_result'); }); }); + +describe('/model status displayName override', () => { + it('shows the overridden display name in the switch status', async () => { + const session = makeSession(); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + displayName: 'Remote Turbo', + capabilities: ['thinking'], + overrides: { displayName: 'Custom Turbo' }, + }, + }, + defaultModel: 'k2', + thinking: { enabled: false }, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'turbo', + thinking: { enabled: true }, + }); + }); + + expect(renderTranscript(driver)).toContain('Switched to Custom Turbo with thinking on.'); + expect(renderTranscript(driver)).not.toContain('Remote Turbo'); + }); +}); + +describe('/effort support_efforts override', () => { + it('rejects efforts hidden by support_efforts override', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + overrides: { supportEfforts: ['low', 'high'] }, + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Unsupported thinking effort "max" for k2. Available: off, low, high'); + }); + expect(renderTranscript(driver)).not.toContain('Switched to Kimi K2 with thinking max.'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index de33c6abd..79a36d70f 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1,35 +1,48 @@ -import { describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; -import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; -import { log } from "@moonshot-ai/kimi-code-sdk"; +import { log, type GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; +import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { describe, expect, it, vi } from 'vitest'; -import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui"; -import { - handleLoginCommand, - handleLogoutCommand, -} from "#/tui/commands/auth"; -import { - promptPlatformSelection, - promptLogoutProviderSelection, -} from "#/tui/commands/prompts"; - -vi.mock("#/tui/commands/prompts", async (importOriginal) => { - const actual = await importOriginal<typeof import("#/tui/commands/prompts")>(); - return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; -}); +import { BannerProvider } from '#/tui/banner/banner-provider'; +import { readBannerDisplayState } from '#/tui/banner/state'; +import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; +import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; +import { BannerComponent } from '#/tui/components/chrome/banner'; +import { WelcomeComponent } from '#/tui/components/chrome/welcome'; +import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import { quoteShellArg } from '#/utils/shell-quote'; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, OSC11_QUERY, QUERY_TERMINAL_THEME, TERMINAL_THEME_LIGHT, -} from "#/tui/utils/terminal-theme"; +} from '#/tui/utils/terminal-theme'; + +vi.mock('#/tui/commands/prompts', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/tui/commands/prompts')>(); + return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; +}); +vi.mock('#/utils/clipboard/clipboard-text', () => ({ + copyTextToClipboard: vi.fn(async () => {}), +})); + +const copyTextToClipboardMock = vi.mocked(copyTextToClipboard); interface StartupDriver { state: TUIState; init(): Promise<boolean>; handleLoginCommand(): Promise<void>; handleLogoutCommand(): Promise<void>; + stop(exitCode?: number): Promise<void>; +} + +interface RuntimeStateDriver extends StartupDriver { + closeSession(reason: string): Promise<void>; } interface ThemeTrackingDriver extends StartupDriver { @@ -45,7 +58,7 @@ interface MigrateExitDriver extends StartupDriver { } const MIGRATION_PLAN: MigrationPlan = { - sourceHome: "/x/.kimi", + sourceHome: '/x/.kimi', hasConfig: false, hasMcp: false, hasUserHistory: false, @@ -57,9 +70,8 @@ const MIGRATION_PLAN: MigrationPlan = { }; function makeStartupInput( - cliOptions: Partial<KimiTUIStartupInput["cliOptions"]> = {}, - tuiConfig: Partial<KimiTUIStartupInput["tuiConfig"]> = {}, - resolvedTheme: KimiTUIStartupInput["resolvedTheme"] = "dark", + cliOptions: Partial<KimiTUIStartupInput['cliOptions']> = {}, + tuiConfig: Partial<KimiTUIStartupInput['tuiConfig']> = {}, ): KimiTUIStartupInput { return { cliOptions: { @@ -75,26 +87,27 @@ function makeStartupInput( ...cliOptions, }, tuiConfig: { - theme: "dark", + theme: 'dark', + disablePasteBurst: false, editorCommand: null, - notifications: { enabled: true, condition: "unfocused" }, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, ...tuiConfig, }, - version: "0.0.0-test", - workDir: "/tmp/proj-a", - resolvedTheme, + version: '0.0.0-test', + workDir: '/tmp/proj-a', }; } function makeSession(overrides: Record<string, unknown> = {}) { return { - id: "ses-1", - model: "k2", - summary: { title: "Session title" }, + id: 'ses-1', + model: 'k2', + summary: { title: 'Session title' }, getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "manual", + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -106,16 +119,72 @@ function makeSession(overrides: Record<string, unknown> = {}) { setThinking: vi.fn(async () => {}), setPermission: vi.fn(async () => {}), setPlanMode: vi.fn(async () => {}), + getGoal: vi.fn(async () => ({ goal: null })), onEvent: vi.fn(() => () => {}), + getResumeState: vi.fn(() => null), listSkills: vi.fn(async () => []), close: vi.fn(async () => {}), ...overrides, }; } +function goalSnapshot(overrides: Partial<GoalSnapshot> = {}): GoalSnapshot { + return { + goalId: 'goal-1', + objective: 'Ship feature X', + status: 'paused', + turnsUsed: 2, + tokensUsed: 100, + wallClockMs: 1000, + budget: { + tokenBudget: null, + turnBudget: null, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: null, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + ...overrides, + }; +} + +function createResumeState(overrides: { permissionMode?: string; planMode?: boolean } = {}) { + return { + id: 'ses-latest', + workDir: '/tmp/proj-a', + sessionDir: '/tmp/proj-a/.kimi/sessions/ses-latest', + createdAt: Date.now(), + updatedAt: Date.now(), + sessionMetadata: {}, + agents: { + main: { + type: 'main', + config: { + cwd: '/tmp/proj-a', + modelCapabilities: { max_context_tokens: 100 }, + thinkingEffort: 'off', + systemPrompt: '', + }, + context: { history: [], tokenCount: 10 }, + replay: [], + permission: { mode: overrides.permissionMode ?? 'manual', rules: [] }, + plan: overrides.planMode ? { id: 'plan-1', content: '', path: '/tmp/plan.md' } : null, + swarmMode: false, + usage: {}, + tools: [], + background: [], + }, + }, + } as never; +} + function loginRequiredError(): Error & { readonly code: string } { return Object.assign(new Error('OAuth provider "managed:kimi-code" requires login.'), { - code: "auth.login_required", + code: 'auth.login_required', }); } @@ -123,7 +192,7 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> return { getConfig: vi.fn(async () => ({ models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession: vi.fn(async () => session), @@ -132,7 +201,7 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), - getExperimentalFlags: vi.fn(async () => ({})), + getExperimentalFeatures: vi.fn(async () => []), auth: { status: vi.fn(async () => ({ providers: [] })), login: vi.fn(async () => {}), @@ -145,21 +214,21 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> function makeDriver(harness: ReturnType<typeof makeHarness>, input: KimiTUIStartupInput) { const driver = new KimiTUI(harness as never, input) as unknown as StartupDriver; - vi.spyOn(driver.state.ui, "requestRender").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "setProgress").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); return driver; } -type InputListener = Parameters<TUIState["ui"]["addInputListener"]>[0]; -const DARK_OSC11_REPORT = "\u001B]11;rgb:2828/2c2c/3434\u0007"; -const LIGHT_OSC11_REPORT = "\u001B]11;rgb:fafa/fbfb/fcfc\u0007"; +type InputListener = Parameters<TUIState['ui']['addInputListener']>[0]; +const DARK_OSC11_REPORT = '\u001B]11;rgb:2828/2c2c/3434\u0007'; +const LIGHT_OSC11_REPORT = '\u001B]11;rgb:fafa/fbfb/fcfc\u0007'; function captureInputListeners(driver: StartupDriver) { const listeners: InputListener[] = []; const removeInputListener = vi.fn<() => void>(); - const write = vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + const write = vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); const addInputListener = vi - .spyOn(driver.state.ui, "addInputListener") + .spyOn(driver.state.ui, 'addInputListener') .mockImplementation((listener: InputListener) => { listeners.push(listener); return removeInputListener; @@ -168,13 +237,13 @@ function captureInputListeners(driver: StartupDriver) { return { listeners, removeInputListener, write, addInputListener }; } -describe("KimiTUI startup", () => { - it("creates a fresh session from startup flags and syncs runtime state", async () => { +describe('KimiTUI startup', () => { + it('creates a fresh session from startup flags and syncs runtime state', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "yolo", + model: 'k2', + thinkingEffort: 'off', + permission: 'yolo', planMode: true, contextTokens: 25, maxContextTokens: 200, @@ -187,66 +256,345 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ - workDir: "/tmp/proj-a", - permission: "yolo", + workDir: '/tmp/proj-a', + permission: 'yolo', planMode: true, }); expect(session.setApprovalHandler).toHaveBeenCalledOnce(); expect(session.setQuestionHandler).toHaveBeenCalledOnce(); expect(harness.setTelemetryContext).toHaveBeenCalledWith({ sessionId: null }); - expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: "ses-1" }); - expect(driver.state.startupState).toBe("ready"); + expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: 'ses-1' }); + expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", - permissionMode: "yolo", + sessionId: 'ses-1', + model: 'k2', + permissionMode: 'yolo', planMode: true, contextTokens: 25, maxContextTokens: 200, contextUsage: 0.125, - sessionTitle: "Session title", + sessionTitle: 'Session title', }); }); - it("resumes the latest session for --continue and marks history for replay", async () => { - const session = makeSession({ id: "ses-latest" }); + it('resumes the latest session for --continue and marks history for replay', async () => { + const session = makeSession({ id: 'ses-latest' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }, { id: "ses-old" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }, { id: 'ses-old' }]), }); const driver = makeDriver(harness, makeStartupInput({ continue: true })); await expect(driver.init()).resolves.toBe(true); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-latest" }); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); expect(harness.createSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe("ses-latest"); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe('ses-latest'); }); - it("passes the CLI model override when creating a fresh startup session", async () => { + it('applies --auto permission when resuming a session via --continue', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('applies --yolo permission when resuming a session via --continue', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, yolo: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(driver.state.appState.permissionMode).toBe('yolo'); + }); + + it('applies --plan mode when resuming a session via --continue', async () => { + let planMode = false; + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async (enabled: boolean) => { + planMode = enabled; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).toHaveBeenCalledWith(true); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('skips setPlanMode when the resumed session is already in plan mode', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('forces footer state to reflect --auto even if getStatus lags behind', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async () => {}), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('forces footer state to reflect --plan even if getStatus lags behind', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => {}), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).toHaveBeenCalledWith(true); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('keeps --auto in the footer after session replay hydration', async () => { + const session = makeSession({ + id: 'ses-latest', + getResumeState: vi.fn(() => createResumeState({ permissionMode: 'manual', planMode: false })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + await ( + driver as unknown as { + finishStartup(shouldReplayHistory: boolean): Promise<void>; + } + ).finishStartup(true); + + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('keeps --plan in the footer after session replay hydration', async () => { + const session = makeSession({ + id: 'ses-latest', + getResumeState: vi.fn(() => createResumeState({ permissionMode: 'manual', planMode: false })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + await ( + driver as unknown as { + finishStartup(shouldReplayHistory: boolean): Promise<void>; + } + ).finishStartup(true); + + expect(driver.state.appState.planMode).toBe(true); + }); + + it('applies --auto permission when resuming an explicit session', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-target', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: 'ses-target', auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('syncs a persisted goal when resuming a session', async () => { + const goal = goalSnapshot({ status: 'blocked', terminalReason: 'needs input' }); + const session = makeSession({ + id: 'ses-latest', + getGoal: vi.fn(async () => ({ goal })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + getExperimentalFeatures: vi.fn(async () => [{ id: 'micro_compaction', enabled: true }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.getGoal).toHaveBeenCalledOnce(); + expect(driver.state.appState.goal).toEqual(goal); + }); + + it('syncs goal state regardless of the goal flag', async () => { + const goal = goalSnapshot(); + const session = makeSession({ + getGoal: vi.fn(async () => ({ goal })), + }); + const harness = makeHarness(session); + const driver = makeDriver(harness, makeStartupInput()); + + await expect(driver.init()).resolves.toBe(false); + + expect(session.getGoal).toHaveBeenCalledOnce(); + expect(driver.state.appState.goal).toEqual(goal); + }); + + it('clears goal state when closing the current session', async () => { + const goal = goalSnapshot(); + const session = makeSession({ + getGoal: vi.fn(async () => ({ goal })), + }); + const harness = makeHarness(session, { + getExperimentalFeatures: vi.fn(async () => [{ id: 'micro_compaction', enabled: true }]), + }); + const driver = makeDriver(harness, makeStartupInput()) as unknown as RuntimeStateDriver; + + await expect(driver.init()).resolves.toBe(false); + expect(driver.state.appState.goal).toEqual(goal); + + await driver.closeSession('test close'); + + expect(driver.state.appState.goal).toBeNull(); + }); + + it('passes the CLI model override when creating a fresh startup session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ model: "kimi-code/k2.5" })); + const driver = makeDriver(harness, makeStartupInput({ model: 'kimi-code/k2.5' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ - workDir: "/tmp/proj-a", - model: "kimi-code/k2.5", + workDir: '/tmp/proj-a', + model: 'kimi-code/k2.5', permission: undefined, planMode: undefined, }); }); - it("applies the CLI model override when resuming a startup session", async () => { - let model = "k2"; + it('applies the CLI model override when resuming a startup session', async () => { + let model = 'k2'; const session = makeSession({ setModel: vi.fn(async (nextModel: string) => { model = nextModel; }), getStatus: vi.fn(async () => ({ model, - thinkingLevel: "off", - permission: "manual", + thinkingEffort: 'off', + permission: 'manual', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -254,35 +602,446 @@ describe("KimiTUI startup", () => { })), }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), }); const driver = makeDriver( harness, - makeStartupInput({ continue: true, model: "kimi-code/k2.5" }), + makeStartupInput({ continue: true, model: 'kimi-code/k2.5' }), ); await expect(driver.init()).resolves.toBe(true); - expect(session.setModel).toHaveBeenCalledWith("kimi-code/k2.5"); - expect(driver.state.appState.model).toBe("kimi-code/k2.5"); + expect(session.setModel).toHaveBeenCalledWith('kimi-code/k2.5'); + expect(driver.state.appState.model).toBe('kimi-code/k2.5'); }); - it("enters picker startup for bare --session without creating a session", async () => { + it('enters picker startup for bare --session without creating a session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ session: "" })); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).not.toHaveBeenCalled(); expect(harness.resumeSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("picker"); + expect(driver.state.startupState).toBe('picker'); }); - it("tracks terminal theme reports while auto theme is active", () => { + it('applies --auto after picking a session from bare --session', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-picked', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [ + { + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '', auto: true })); + + await (driver as unknown as { initMainTui(): Promise<boolean> }).initMainTui(); + expect(driver.state.startupState).toBe('picker'); + await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('skips setPlanMode after picking a session already in plan mode', async () => { + const session = makeSession({ + id: 'ses-picked', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [ + { + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '', plan: true })); + + await (driver as unknown as { initMainTui(): Promise<boolean> }).initMainTui(); + expect(driver.state.startupState).toBe('picker'); + await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('toggles the sessions picker from current cwd to all sessions with Ctrl+A', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + const listSessions = vi.fn(async (input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return [currentWorkDirSession]; + return [currentWorkDirSession, otherWorkDirSession]; + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0001'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(listSessions).toHaveBeenNthCalledWith(1, { workDir: '/tmp/proj-a' }); + expect(listSessions).toHaveBeenNthCalledWith(2, {}); + expect(driver.state.sessionsScope).toBe('all'); + expect(driver.state.sessions.map((session) => session.id)).toEqual([ + 'ses-cwd', + 'ses-other-cwd', + ]); + }); + + it('toggles the sessions picker from all sessions back to current cwd with Ctrl+A', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + const listSessions = vi.fn(async (input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return [currentWorkDirSession]; + return [currentWorkDirSession, otherWorkDirSession]; + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const firstPicker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + firstPicker.handleInput('\u0001'); + await new Promise((resolve) => setImmediate(resolve)); + const allPicker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + allPicker.handleInput('\u0001'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(listSessions).toHaveBeenNthCalledWith(3, { workDir: '/tmp/proj-a' }); + expect(driver.state.sessionsScope).toBe('cwd'); + expect(driver.state.sessions.map((session) => session.id)).toEqual(['ses-cwd']); + }); + + it('does not remount the session picker after it is closed while a scope toggle is pending', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + let resolveAllSessions: ((value: unknown[]) => void) | undefined; + const listSessions = vi.fn((input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return Promise.resolve([currentWorkDirSession]); + return new Promise<unknown[]>((resolve) => { + resolveAllSessions = resolve; + }); + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions }); + const driver = makeDriver(harness, makeStartupInput()); + const mountSessionPicker = vi.spyOn( + driver as unknown as { mountSessionPicker(options: unknown): void }, + 'mountSessionPicker', + ); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0001'); + (driver as unknown as { hideSessionPicker(): void }).hideSessionPicker(); + resolveAllSessions?.([currentWorkDirSession, otherWorkDirSession]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(driver.state.activeDialog).toBeNull(); + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + }); + + it('clears the sessions picker search query when toggling scope with Ctrl+A', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + const listSessions = vi.fn(async (input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return [currentWorkDirSession]; + return [currentWorkDirSession, otherWorkDirSession]; + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const firstPicker = driver.state.editorContainer.children[0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + firstPicker.handleInput('c'); + firstPicker.handleInput('w'); + firstPicker.handleInput('d'); + expect(firstPicker.render(160).join('\n')).toContain('Search: cwd'); + + firstPicker.handleInput('\u0001'); + await new Promise((resolve) => setImmediate(resolve)); + + const allPicker = driver.state.editorContainer.children[0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + const output = allPicker.render(160).join('\n'); + + expect(driver.state.sessionsScope).toBe('all'); + expect(output).toContain('All sessions'); + expect(output).toContain('(type to search)'); + expect(output).not.toContain('Search: cwd'); + }); + + it('does not resume a session from a different cwd and shows a cd hint', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + const resumeSession = vi.fn(async () => makeSession({ id: 'ses-other-cwd' })); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { + resumeSession, + listSessions: vi.fn(async () => [currentWorkDirSession, otherWorkDirSession]), + }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + copyTextToClipboardMock.mockClear(); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u001B[B'); + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(resumeSession).not.toHaveBeenCalled(); + expect(driver.state.activeDialog).toBeNull(); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Current session is in a different working directory.'); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); + expect(transcript).toContain('Command copied to clipboard'); + }); + + it('copies a shell-safe resume command for another cwd with metacharacters', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj$(touch /tmp/pwned)', + updatedAt: Date.now() - 1000, + }; + const resumeSession = vi.fn(async () => makeSession({ id: 'ses-other-cwd' })); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { + resumeSession, + listSessions: vi.fn(async () => [currentWorkDirSession, otherWorkDirSession]), + }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + copyTextToClipboardMock.mockClear(); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u001B[B'); + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(resumeSession).not.toHaveBeenCalled(); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj$(touch /tmp/pwned)')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); + }); + + it('exits after picking another cwd from the startup picker', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + const resumeSession = vi.fn(async () => makeSession({ id: 'ses-other-cwd' })); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { + resumeSession, + listSessions: vi.fn(async () => [currentWorkDirSession, otherWorkDirSession]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); + const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined); + copyTextToClipboardMock.mockClear(); + + await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false); + await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u001B[B'); + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(resumeSession).not.toHaveBeenCalled(); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); + expect(stop).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledWith(0); + }); + + it('does not apply startup flags when switching sessions via the /sessions picker', async () => { + const initial = makeSession({ id: 'ses-1' }); + const picked = makeSession({ + id: 'ses-2', + setPermission: vi.fn(async () => {}), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(initial, { + resumeSession: vi.fn(async () => picked), + listSessions: vi.fn(async () => [ + { + id: 'ses-2', + title: 'Other session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ auto: true, plan: true })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(driver.state.appState.sessionId).toBe('ses-2'); + expect(picked.setPermission).not.toHaveBeenCalled(); + expect(picked.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.permissionMode).toBe('manual'); + expect(driver.state.appState.planMode).toBe(false); + }); + + it('clears startup picker exit confirmation before resuming a selected session', async () => { + const session = makeSession({ id: 'ses-picked' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [ + { + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); + const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined); + + await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false); + await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0003'); + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + driver.state.editor.onCtrlC?.(); + + expect(stop).not.toHaveBeenCalled(); + }); + + it('tracks terminal theme reports while auto theme is active', () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }, "dark"), + makeStartupInput({}, { theme: 'auto' }), ) as unknown as ThemeTrackingDriver; const { listeners, write, addInputListener } = captureInputListeners(driver); @@ -297,22 +1056,19 @@ describe("KimiTUI startup", () => { write.mockClear(); expect(listeners[0]?.(TERMINAL_THEME_LIGHT)).toEqual({ consume: true }); expect(write).toHaveBeenCalledWith(OSC11_QUERY); - expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("dark"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(DARK_OSC11_REPORT)).toEqual({ consume: true }); - expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("dark"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(LIGHT_OSC11_REPORT)).toEqual({ consume: true }); - expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("light"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).toHaveBeenCalled(); }); - it("does not track terminal theme reports for explicit themes", () => { + it('does not track terminal theme reports for explicit themes', () => { const harness = makeHarness(); const driver = makeDriver(harness, makeStartupInput()) as unknown as ThemeTrackingDriver; const { write, addInputListener } = captureInputListeners(driver); @@ -323,22 +1079,42 @@ describe("KimiTUI startup", () => { expect(write).not.toHaveBeenCalled(); }); - it("disables terminal theme reports after leaving auto theme", () => { + it('disables terminal theme reports after leaving auto theme', () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }, "dark"), + makeStartupInput({}, { theme: 'auto' }), ) as unknown as ThemeTrackingDriver; const { write, removeInputListener } = captureInputListeners(driver); driver.refreshTerminalThemeTracking(); - driver.state.appState.theme = "dark"; + driver.state.appState.theme = 'dark'; driver.refreshTerminalThemeTracking(); expect(removeInputListener).toHaveBeenCalledOnce(); expect(write).toHaveBeenCalledWith(DISABLE_TERMINAL_THEME_REPORTING); }); + it("only shows provider refresh status for added models", async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput()); + const showStatus = vi.spyOn(driver as any, "showStatus").mockImplementation(() => {}); + vi.spyOn((driver as any).authFlow, "refreshProviderModels").mockResolvedValue({ + changed: [ + { providerId: "new-models", providerName: "New Models", added: 2, removed: 0 }, + { providerId: "removed-models", providerName: "Removed Models", added: 0, removed: 3 }, + { providerId: "metadata-only", providerName: "Metadata Only", added: 0, removed: 0 }, + ], + unchanged: [], + failed: [], + }); + + await (driver as any).refreshProviderModelsInBackground(); + + expect(showStatus).toHaveBeenCalledTimes(1); + expect(showStatus).toHaveBeenCalledWith("New Models · +2 models."); + }); + it("starts TUI without a session when fresh startup needs OAuth login", async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { @@ -349,12 +1125,12 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - expect(driver.state.startupState).toBe("ready"); - expect((driver as any).startupNotice).toContain("OAuth login expired"); + expect(driver.state.startupState).toBe('ready'); + expect((driver as any).startupNotice).toContain('OAuth login expired'); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", - thinking: false, + sessionId: '', + model: '', + thinkingEffort: 'off', contextTokens: 0, maxContextTokens: 0, contextUsage: 0, @@ -362,12 +1138,12 @@ describe("KimiTUI startup", () => { }); }); - it("preserves fresh startup yolo and plan intent after OAuth login", async () => { + it('preserves fresh startup yolo and plan intent after OAuth login', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "yolo", + model: 'k2', + thinkingEffort: 'off', + permission: 'yolo', planMode: true, contextTokens: 10, maxContextTokens: 100, @@ -380,10 +1156,10 @@ describe("KimiTUI startup", () => { .mockResolvedValueOnce(session); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", - defaultThinking: false, + defaultModel: 'k2', + thinking: { enabled: false }, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession, @@ -393,9 +1169,9 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", - permissionMode: "yolo", + sessionId: '', + model: '', + permissionMode: 'yolo', planMode: true, }); @@ -403,31 +1179,31 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(1, { - workDir: "/tmp/proj-a", - permission: "yolo", + workDir: '/tmp/proj-a', + permission: 'yolo', planMode: true, }); expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: "/tmp/proj-a", - model: "k2", - thinking: "off", - permission: "yolo", + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: 'yolo', planMode: true, }); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", - permissionMode: "yolo", + sessionId: 'ses-1', + model: 'k2', + permissionMode: 'yolo', planMode: true, }); }); - it("does not force manual permission after OAuth login without --yolo", async () => { + it('does not force manual permission after OAuth login without --yolo', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "auto", + model: 'k2', + thinkingEffort: 'off', + permission: 'auto', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -440,10 +1216,10 @@ describe("KimiTUI startup", () => { .mockResolvedValueOnce(session); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", - defaultThinking: false, + defaultModel: 'k2', + thinking: { enabled: false }, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession, @@ -455,55 +1231,58 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: "/tmp/proj-a", - model: "k2", - thinking: "off", + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', permission: undefined, planMode: undefined, }); expect(driver.state.appState).toMatchObject({ - permissionMode: "auto", + permissionMode: 'auto', }); }); - it("syncs configured thinking after OAuth login refreshes an active session", async () => { + it('does not override active session thinking when configured thinking is enabled after OAuth login', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", - defaultThinking: true, + defaultModel: 'k2', + thinking: { enabled: true }, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), }); const driver = makeDriver(harness, makeStartupInput()); await expect(driver.init()).resolves.toBe(false); - expect(driver.state.appState.thinking).toBe(false); + expect(driver.state.appState.thinkingEffort).toBe('off'); vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await handleLoginCommand(driver as any); - expect(session.setModel).toHaveBeenCalledWith("k2"); - expect(session.setThinking).toHaveBeenCalledWith("on"); + expect(session.setModel).toHaveBeenCalledWith('k2'); + // `thinking.enabled === true` means "leave the session's current thinking + // level alone" — only an explicit `enabled === false` forces `'off'`. + expect(session.setThinking).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ - model: "k2", - thinking: true, + model: 'k2', + thinkingEffort: 'off', maxContextTokens: 100, }); - expect(harness.track).toHaveBeenCalledWith("login", { - provider: "managed:kimi-code", + expect(harness.track).toHaveBeenCalledWith('login', { + provider: 'managed:kimi-code', + method: 'oauth', already_logged_in: false, }); }); - it("tracks login with already_logged_in when a token already exists", async () => { + it('tracks login with already_logged_in when a token already exists', async () => { const session = makeSession(); const harness = makeHarness(session, { auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -519,22 +1298,23 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - "managed:kimi-code", + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), }), ); - expect(harness.track).toHaveBeenCalledWith("login", { - provider: "managed:kimi-code", + expect(harness.track).toHaveBeenCalledWith('login', { + provider: 'managed:kimi-code', + method: 'oauth', already_logged_in: true, }); }); - it("logs login failures with session context", async () => { - const warn = vi.spyOn(log, "warn").mockImplementation(() => {}); + it('logs login failures with session context', async () => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); const session = makeSession(); - const loginError = new Error("Failed to list Kimi Code models (HTTP 402)."); + const loginError = new Error('Failed to list Kimi Code models (HTTP 402).'); const harness = makeHarness(session, { auth: { status: vi.fn(async () => ({ providers: [] })), @@ -554,20 +1334,20 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - "managed:kimi-code", + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), }), ); expect(warn).toHaveBeenCalledWith( - "login failed", + 'login failed', expect.objectContaining({ - providerName: "managed:kimi-code", + providerName: 'managed:kimi-code', alreadyLoggedIn: false, - sessionId: "ses-1", + sessionId: 'ses-1', error: expect.objectContaining({ - message: "Failed to list Kimi Code models (HTTP 402).", + message: 'Failed to list Kimi Code models (HTTP 402).', }), }), ); @@ -576,18 +1356,18 @@ describe("KimiTUI startup", () => { } }); - it("tracks logout after managed credentials and session state are cleared", async () => { + it('tracks logout after managed credentials and session state are cleared', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, - providers: { "managed:kimi-code": { type: "kimi" } }, + providers: { 'managed:kimi-code': { type: 'kimi' } }, })), auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -599,38 +1379,36 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue( - "managed:kimi-code", - ); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); expect(session.close).toHaveBeenCalledOnce(); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", + sessionId: '', + model: '', sessionTitle: null, }); - expect(harness.track).toHaveBeenCalledWith("logout", { provider: "managed:kimi-code" }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:kimi-code' }); }); - it("keeps the active session when logging out a different provider", async () => { + it('keeps the active session when logging out a different provider', async () => { const session = makeSession(); const removeProvider = vi.fn(async () => {}); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, providers: { - "managed:kimi-code": { type: "kimi" }, - openai: { type: "openai", baseUrl: "https://api.openai.com/v1" }, + 'managed:kimi-code': { type: 'kimi' }, + openai: { type: 'openai', baseUrl: 'https://api.openai.com/v1' }, }, })), removeProvider, auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -642,33 +1420,33 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue("openai"); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('openai'); await handleLogoutCommand(driver as any); - expect(removeProvider).toHaveBeenCalledWith("openai"); + expect(removeProvider).toHaveBeenCalledWith('openai'); expect(harness.auth.logout).not.toHaveBeenCalled(); expect(session.close).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", + sessionId: 'ses-1', + model: 'k2', }); - expect(harness.track).toHaveBeenCalledWith("logout", { provider: "openai" }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'openai' }); }); - it("can log out a stale managed entry even after the OAuth token is gone", async () => { + it('can log out a stale managed entry even after the OAuth token is gone', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, - providers: { "managed:kimi-code": { type: "kimi" } }, + providers: { 'managed:kimi-code': { type: 'kimi' } }, })), auth: { // Token gone (e.g. credentials file deleted) but the managed entry // is still sitting in config.providers. status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: false }], + providers: [{ providerName: 'managed:kimi-code', hasToken: false }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -679,17 +1457,15 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue( - "managed:kimi-code", - ); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); }); - it("starts TUI without replaying when --continue needs OAuth login", async () => { + it('starts TUI without replaying when --continue needs OAuth login', async () => { const harness = makeHarness(makeSession(), { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), resumeSession: vi.fn(async () => { throw loginRequiredError(); }), @@ -698,29 +1474,29 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-latest" }); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); expect(harness.createSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe(""); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe(''); }); - it("starts TUI without replaying when an explicit resume needs OAuth login", async () => { + it('starts TUI without replaying when an explicit resume needs OAuth login', async () => { const harness = makeHarness(makeSession(), { - listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), resumeSession: vi.fn(async () => { throw loginRequiredError(); }), }); - const driver = makeDriver(harness, makeStartupInput({ session: "ses-target" })); + const driver = makeDriver(harness, makeStartupInput({ session: 'ses-target' })); await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-target" }); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe(""); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe(''); }); - it("disposes terminal focus/theme tracking on the kimi migrate exit", async () => { + it('disposes terminal focus/theme tracking on the kimi migrate exit', async () => { const harness = makeHarness(); const driver = makeDriver(harness, { ...makeStartupInput(), @@ -728,11 +1504,11 @@ describe("KimiTUI startup", () => { migrateOnly: true, }) as unknown as MigrateExitDriver; // pi-tui start/stop and focus tracking touch the real TTY — stub the I/O. - vi.spyOn(driver.state.ui, "start").mockImplementation(() => {}); - vi.spyOn(driver.state.ui, "stop").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); // The migration screen would await user input; resolve it immediately. - vi.spyOn(driver, "runMigrationScreen").mockResolvedValue({ decision: "later" }); + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); const onExit = vi.fn(async () => {}); driver.onExit = onExit; @@ -745,40 +1521,40 @@ describe("KimiTUI startup", () => { expect(onExit).toHaveBeenCalledWith(0); }); - it("disposes terminal tracking when post-migration startup fails", async () => { + it('disposes terminal tracking when post-migration startup fails', async () => { const harness = makeHarness(); const driver = makeDriver(harness, { ...makeStartupInput(), migrationPlan: MIGRATION_PLAN, migrateOnly: false, }) as unknown as MigrateExitDriver; - vi.spyOn(driver.state.ui, "start").mockImplementation(() => {}); - vi.spyOn(driver.state.ui, "stop").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); // The migration screen resolves "later"; startup then continues into // initMainTui(), which fails (e.g. a session-resume error). - vi.spyOn(driver, "runMigrationScreen").mockResolvedValue({ decision: "later" }); - vi.spyOn(driver, "initMainTui").mockRejectedValue(new Error("resume boom")); + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); + vi.spyOn(driver, 'initMainTui').mockRejectedValue(new Error('resume boom')); - await expect(driver.start()).rejects.toThrow("resume boom"); + await expect(driver.start()).rejects.toThrow('resume boom'); // The focus tracking installed by startEventLoop() must be torn down // before the error propagates — not left active after the process exits. expect(driver.terminalFocusTrackingDispose).toBeUndefined(); }); - it("keeps non-login startup session errors fatal", async () => { + it('keeps non-login startup session errors fatal', async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { - throw new Error("provider config is invalid"); + throw new Error('provider config is invalid'); }), }); const driver = makeDriver(harness, makeStartupInput()); - await expect(driver.init()).rejects.toThrow("provider config is invalid"); + await expect(driver.init()).rejects.toThrow('provider config is invalid'); }); - it("does not mount the footer when resuming a missing session fails", async () => { + it('does not mount the footer when resuming a missing session fails', async () => { // Regression: a stray pre-startEventLoop render used to paint the footer // (cwd/git + "context:" statusline) to the terminal before the fatal // error, leaving it stranded above the error message. The footer must not @@ -788,23 +1564,21 @@ describe("KimiTUI startup", () => { }); const driver = makeDriver( harness, - makeStartupInput({ session: "missing-session" }), + makeStartupInput({ session: 'missing-session' }), ) as unknown as MigrateExitDriver; - await expect(driver.initMainTui()).rejects.toThrow( - 'Session "missing-session" not found.', - ); + await expect(driver.initMainTui()).rejects.toThrow('Session "missing-session" not found.'); expect(uiContainsFooter(driver)).toBe(false); }); - it("mounts the footer once startup reaches the main TUI", async () => { - const session = makeSession({ id: "ses-target" }); + it('mounts the footer once startup reaches the main TUI', async () => { + const session = makeSession({ id: 'ses-target' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), }); const driver = makeDriver( harness, - makeStartupInput({ session: "ses-target" }), + makeStartupInput({ session: 'ses-target' }), ) as unknown as MigrateExitDriver; // Not mounted until init() succeeds. @@ -814,6 +1588,166 @@ describe("KimiTUI startup", () => { expect(uiContainsFooter(driver)).toBe(true); }); + + it('renders the banner below the welcome message after it loads', async () => { + const banner = { + key: 'new-banner', + tag: 'New', + mainText: 'Banner main', + subText: null, + display: 'always' as const, + }; + const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); + const session = makeSession({ id: 'ses-target' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: 'ses-target' }), + ) as unknown as MigrateExitDriver; + + await driver.initMainTui(); + + await vi.waitFor(() => { + expect( + driver.state.transcriptContainer.children.some((child) => child instanceof BannerComponent), + ).toBe(true); + }); + + // The banner is rendered directly below the welcome panel so it appears + // above later status messages such as MCP server connection summaries. + const welcomeIndex = driver.state.transcriptContainer.children.findIndex( + (child) => child instanceof WelcomeComponent, + ); + const bannerIndex = driver.state.transcriptContainer.children.findIndex( + (child) => child instanceof BannerComponent, + ); + expect(welcomeIndex).toBeGreaterThanOrEqual(0); + expect(bannerIndex).toBe(welcomeIndex + 1); + + loadSpy.mockRestore(); + }); + + it('writes display state after rendering a once banner', async () => { + const originalEnv = { ...process.env }; + const dir = mkdtempSync(join(tmpdir(), 'kimi-startup-banner-')); + process.env['KIMI_CODE_HOME'] = dir; + + try { + const banner = { + key: 'once-banner', + tag: null, + mainText: 'Banner main', + subText: null, + display: 'once' as const, + }; + const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); + const session = makeSession({ id: 'ses-target' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: 'ses-target' }), + ) as unknown as MigrateExitDriver; + + await driver.initMainTui(); + + await vi.waitFor(() => { + expect( + driver.state.transcriptContainer.children.some((child) => child instanceof BannerComponent), + ).toBe(true); + }); + + // writeBannerDisplayState runs after renderBanner; on Windows the atomic + // write can lag behind the render, so wait for the state to land before + // asserting it. + await vi.waitFor( + async () => { + const state = await readBannerDisplayState(); + expect(state.shown['once-banner']?.lastShownAt).toBeDefined(); + }, + { timeout: 5000 }, + ); + await expect(readBannerDisplayState()).resolves.toMatchObject({ + version: 1, + shown: { + 'once-banner': { + lastShownAt: expect.any(String), + }, + }, + }); + + loadSpy.mockRestore(); + } finally { + process.env = { ...originalEnv }; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('does not write display state for an always banner', async () => { + const originalEnv = { ...process.env }; + const dir = mkdtempSync(join(tmpdir(), 'kimi-startup-banner-')); + process.env['KIMI_CODE_HOME'] = dir; + + try { + const banner = { + key: 'always-banner', + tag: null, + mainText: 'Banner main', + subText: null, + display: 'always' as const, + }; + const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); + const session = makeSession({ id: 'ses-target' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: 'ses-target' }), + ) as unknown as MigrateExitDriver; + + await driver.initMainTui(); + + await vi.waitFor(() => { + expect( + driver.state.transcriptContainer.children.some((child) => child instanceof BannerComponent), + ).toBe(true); + }); + + await expect(readBannerDisplayState()).resolves.toEqual({ + version: 1, + shown: {}, + }); + + loadSpy.mockRestore(); + } finally { + process.env = { ...originalEnv }; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resumes a startup session when Windows workdir uses backslashes', async () => { + const session = makeSession({ id: 'ses-target' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: 'C:/Users/kimi/project' }]), + }); + const driver = makeDriver(harness, { + ...makeStartupInput({ session: 'ses-target' }), + workDir: String.raw`C:\Users\kimi\project`, + }); + + await expect(driver.init()).resolves.toBe(true); + + expect(harness.listSessions).toHaveBeenCalledWith({ + sessionId: 'ses-target', + workDir: String.raw`C:\Users\kimi\project`, + }); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(driver.state.appState.sessionId).toBe('ses-target'); + }); }); function uiContainsFooter(driver: StartupDriver): boolean { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index a63a97906..5e4e11670 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -1,7 +1,10 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + import type { AgentReplayRecord, BackgroundTaskInfo, ContentPart, + GoalSnapshot, PromptOrigin, ResumedAgentState, Role, @@ -16,7 +19,15 @@ import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; import { ReadGroupComponent } from '#/tui/components/messages/read-group'; -vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() })); +vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); + +type GoalReplayRecord = Extract<AgentReplayRecord, { type: 'goal_updated' }>; + +const REPLAY_TIME = 1_700_000_000_000; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} interface ReplayDriver { readonly state: TUIState; @@ -41,12 +52,13 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } @@ -61,6 +73,7 @@ function message( } = {}, ): AgentReplayRecord { return { + time: REPLAY_TIME, type: 'message', message: { role, @@ -82,6 +95,44 @@ function toolCall(id: string, name: string, args: Record<string, unknown>): Tool }; } +function goalSnapshot(overrides: Partial<GoalSnapshot> = {}): GoalSnapshot { + const status = overrides.status ?? 'active'; + return { + goalId: 'g1', + objective: 'Ship feature X', + completionCriterion: 'tests pass', + status, + turnsUsed: 0, + tokensUsed: 0, + wallClockMs: 0, + budget: { + tokenBudget: null, + turnBudget: null, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: null, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + ...overrides, + }; +} + +function goalReplay( + snapshot: GoalSnapshot, + change: GoalReplayRecord['change'], +): GoalReplayRecord { + return { + time: REPLAY_TIME, + type: 'goal_updated', + snapshot, + change, + }; +} + function baseAgentState( replay: readonly AgentReplayRecord[], overrides: Partial<ResumedAgentState> = {}, @@ -100,13 +151,14 @@ function baseAgentState( tool_use: true, max_context_tokens: 100, }, - thinkingLevel: 'off', + thinkingEffort: 'off', systemPrompt: '', }, context: { history: [], tokenCount: 0 }, replay, permission: { mode: 'manual', rules: [] }, plan: null, + swarmMode: false, usage: {}, tools: [], toolStore: {}, @@ -126,13 +178,14 @@ function makeSession( summary: { title: null }, getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, maxContextTokens: 100, contextUsage: 0, })), + getGoal: vi.fn(async () => ({ goal: null })), setApprovalHandler: vi.fn(), setQuestionHandler: vi.fn(), setModel: vi.fn(async () => {}), @@ -151,6 +204,7 @@ function makeSession( } function makeHarness(initialSession: Session) { + const interactiveAgentScope = new AsyncLocalStorage<string>(); return { getConfig: vi.fn(async () => ({ models: { @@ -165,13 +219,19 @@ function makeHarness(initialSession: Session) { close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), - interactiveAgentId: 'main', + getExperimentalFeatures: vi.fn(async () => []), + get interactiveAgentId() { + return interactiveAgentScope.getStore() ?? 'main'; + }, + withInteractiveAgent: vi.fn((agentId: string, fn: () => unknown) => { + return interactiveAgentScope.run(agentId, fn); + }), auth: { status: vi.fn(), login: vi.fn(), logout: vi.fn(), getManagedUsage: vi.fn(), - submitFeedback: vi.fn(async () => ({ kind: 'ok' })), + submitFeedback: vi.fn(async () => ({ kind: 'ok', feedbackId: 3 })), }, }; } @@ -203,19 +263,317 @@ function backgroundTask( description: string, status: BackgroundTaskInfo['status'] = 'running', ): BackgroundTaskInfo { + if (taskId.startsWith('agent-')) { + return { + taskId, + kind: 'agent', + agentId: taskId, + subagentType: 'coder', + description, + status, + startedAt: 1, + endedAt: status === 'running' ? null : 2, + }; + } return { taskId, + kind: 'process', command: `[agent] ${description}`, description, status, pid: 0, exitCode: status === 'completed' ? 0 : null, startedAt: 1, - endedAt: status === 'running' || status === 'awaiting_approval' ? null : 2, + endedAt: status === 'running' ? null : 2, }; } describe('KimiTUI resume message replay', () => { + it('does not render legacy goal completion context reminders as transcript messages', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: '<system-reminder>\n✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.3M tokens.\n</system-reminder>', + }, + ], + { origin: { kind: 'system_trigger', name: 'goal_completion' } }, + ), + ]); + + expect(driver.state.transcriptEntries).toEqual([]); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).not.toContain('Goal complete'); + }); + + it('unescapes bash tag delimiters when replaying shell output', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: '<bash-stdout>pre</bash-stdout>post</bash-stdout><bash-stderr></bash-stderr>', + }, + ], + { origin: { kind: 'shell_command', phase: 'output' } }, + ), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('pre</bash-stdout>post'); + }); + + it('does not render neutral goal completion context reminders as transcript messages', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: + '<system-reminder>\n' + + 'The current goal was marked complete and cleared. ' + + 'Handle the next user request normally unless the user starts or resumes a goal.\n' + + '</system-reminder>', + }, + ], + { origin: { kind: 'system_trigger', name: 'goal_completion' } }, + ), + ]); + + expect(driver.state.transcriptEntries).toEqual([]); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).not.toContain('marked complete and cleared'); + }); + + it('does not render fork-cleared goal context reminders as transcript messages', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: + '<system-reminder>\n' + + 'This fork does not have a current goal. ' + + 'Ignore earlier active-goal reminders from the source session. ' + + 'Handle requests normally unless the user starts a new goal.\n' + + '</system-reminder>', + }, + ], + { origin: { kind: 'system_trigger', name: 'goal_fork_cleared' } }, + ), + ]); + + expect(driver.state.transcriptEntries).toEqual([]); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).not.toContain('This fork does not have a current goal'); + }); + + it('renders persisted goal replay records as goal transcript UI', async () => { + const driver = await replayIntoDriver([ + goalReplay(goalSnapshot(), { kind: 'created' }), + goalReplay( + goalSnapshot({ status: 'paused', terminalReason: 'taking a break' }), + { kind: 'lifecycle', status: 'paused', reason: 'taking a break' }, + ), + goalReplay(goalSnapshot({ status: 'active' }), { kind: 'lifecycle', status: 'active' }), + goalReplay( + goalSnapshot({ status: 'blocked', terminalReason: 'needs credentials' }), + { kind: 'lifecycle', status: 'blocked', reason: 'needs credentials' }, + ), + goalReplay( + goalSnapshot({ + status: 'complete', + terminalReason: 'done', + turnsUsed: 1, + tokensUsed: 4300, + wallClockMs: 435000, + }), + { + kind: 'completion', + status: 'complete', + reason: 'done', + stats: { turnsUsed: 1, tokensUsed: 4300, wallClockMs: 435000 }, + }, + ), + ]); + + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'goal') + .map((entry) => entry.content), + ).toEqual(['Goal set', 'Goal paused', 'Goal resumed', 'Goal blocked']); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('Goal set'); + expect(transcript).toContain('Goal paused'); + expect(transcript).toContain('Goal resumed'); + expect(transcript).toContain('Goal blocked'); + expect(transcript).toContain('Goal complete — done'); + expect(transcript).toContain('Worked 1 turn over 7m15s, using 4.3k tokens.'); + }); + + it('filters resume-normalization goal pause markers in TUI replay', async () => { + const driver = await replayIntoDriver([ + goalReplay(goalSnapshot(), { kind: 'created' }), + goalReplay( + goalSnapshot({ status: 'paused', terminalReason: 'Paused after agent resume' }), + { kind: 'lifecycle', status: 'paused', reason: 'Paused after agent resume' }, + ), + ]); + + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'goal') + .map((entry) => entry.content), + ).toEqual(['Goal set']); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('Goal set'); + expect(transcript).not.toContain('Goal paused'); + expect(transcript).not.toContain('Paused after agent resume'); + }); + + it('renders replayed goal completion records as assistant completion messages', async () => { + const driver = await replayIntoDriver([ + goalReplay( + goalSnapshot({ + status: 'complete', + turnsUsed: 1, + tokensUsed: 4_300_000, + wallClockMs: 435_000, + }), + { + kind: 'completion', + status: 'complete', + stats: { turnsUsed: 1, tokensUsed: 4_300_000, wallClockMs: 435_000 }, + }, + ), + ]); + + const entry = driver.state.transcriptEntries.find((item) => + item.content.includes('Goal complete'), + ); + expect(entry).toMatchObject({ + kind: 'assistant', + renderMode: 'markdown', + content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.3M tokens.', + }); + }); + + it('does not replay model-facing goal completion prompts as transcript messages', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: '<system-reminder>\nGoal completed successfully.\nWorked 1 turn over 7m15s, using 4.3M tokens.\n\nWrite a concise final message for the user.\n</system-reminder>', + }, + ], + { origin: { kind: 'system_trigger', name: 'goal_completion' } }, + ), + ]); + + const content = driver.state.transcriptEntries.map((item) => item.content).join('\n'); + expect(content).not.toContain('Goal completed successfully'); + expect(content).not.toContain('Write a concise final message for the user'); + }); + + it('does not replay model-facing goal blocked prompts as transcript messages', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: '<system-reminder>\nGoal blocked.\nWorked 1 turn over 7m15s, using 4.3M tokens.\n\nWrite a concise final message for the user.\n</system-reminder>', + }, + ], + { origin: { kind: 'system_trigger', name: 'goal_blocked' } }, + ), + ]); + + const content = driver.state.transcriptEntries.map((item) => item.content).join('\n'); + expect(content).not.toContain('Goal blocked.'); + expect(content).not.toContain('Write a concise final message for the user'); + }); + + it('does not replay the model-blocked lifecycle marker when the follow-up is replayed', async () => { + const driver = await replayIntoDriver([ + goalReplay( + goalSnapshot({ status: 'blocked' }), + { kind: 'lifecycle', status: 'blocked', actor: 'model' }, + ), + message( + 'user', + [ + { + type: 'text', + text: '<system-reminder>\nGoal blocked.\nWorked 1 turn over 7m15s, using 4.3M tokens.\n\nWrite a concise final message for the user.\n</system-reminder>', + }, + ], + { origin: { kind: 'system_trigger', name: 'goal_blocked' } }, + ), + message( + 'assistant', + [{ type: 'text', text: 'I am blocked because I need credentials.' }], + ), + ]); + + expect(driver.state.transcriptEntries.filter((entry) => entry.kind === 'goal')).toEqual([]); + const content = driver.state.transcriptEntries.map((item) => item.content).join('\n'); + expect(content).not.toContain('Goal blocked'); + expect(content).toContain('I am blocked because I need credentials.'); + }); + + it('does not replay model-blocked lifecycle markers without a follow-up', async () => { + const driver = await replayIntoDriver([ + goalReplay( + goalSnapshot({ status: 'blocked' }), + { kind: 'lifecycle', status: 'blocked', actor: 'model' }, + ), + ]); + + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'goal') + .map((entry) => entry.content), + ).toEqual([]); + }); + + it('keeps replayed blocked lifecycle markers when actor is unavailable', async () => { + const driver = await replayIntoDriver([ + goalReplay( + goalSnapshot({ status: 'blocked' }), + { kind: 'lifecycle', status: 'blocked' }, + ), + ]); + + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'goal') + .map((entry) => entry.content), + ).toEqual(['Goal blocked']); + }); + + it('keeps replayed runtime-blocked lifecycle markers', async () => { + const driver = await replayIntoDriver([ + goalReplay( + goalSnapshot({ status: 'blocked' }), + { kind: 'lifecycle', status: 'blocked', actor: 'runtime' }, + ), + ]); + + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'goal') + .map((entry) => entry.content), + ).toEqual(['Goal blocked']); + }); + it('groups replayed Agent calls from one assistant message using live grouping', async () => { const replay: AgentReplayRecord[] = [ message('user', [{ type: 'text', text: 'run two agents' }]), @@ -246,6 +604,10 @@ describe('KimiTUI resume message replay', () => { expect(group).toBeInstanceOf(AgentGroupComponent); expect((group as AgentGroupComponent).size()).toBe(2); + const output = stripAnsi((group as AgentGroupComponent).render(120).join('\n')); + expect(output).toContain('2 agents finished'); + expect(output).not.toContain('Still working…'); + expect(output).not.toContain('Waiting to start…'); expect(driver.streamingUI.hasPendingAgentGroup()).toBe(false); expect(driver.streamingUI.getToolComponent('call_agent_1')).toBeUndefined(); expect(driver.streamingUI.getToolComponent('call_agent_2')).toBeUndefined(); @@ -280,6 +642,81 @@ describe('KimiTUI resume message replay', () => { expect(driver.streamingUI.getToolComponent('call_read_2')).toBeUndefined(); }); + it('renders replayed AgentSwarm calls as compact result summaries', async () => { + const replay: AgentReplayRecord[] = [ + message('user', [{ type: 'text', text: 'review files with a swarm' }]), + message('assistant', [], { + toolCalls: [ + toolCall('call_swarm', 'AgentSwarm', { + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }), + ], + }), + message( + 'tool', + [{ + type: 'text', + text: [ + '<agent_swarm_result>', + '<summary>completed: 1, failed: 1</summary>', + '<subagent index="1" outcome="completed">Reviewed src/a.ts.</subagent>', + '<subagent index="2" outcome="failed">Agent timed out.</subagent>', + '</agent_swarm_result>', + ].join('\n'), + }], + { toolCallId: 'call_swarm' }, + ), + ]; + + const driver = await replayIntoDriver(replay); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + + expect(transcript).toContain('Agent swarm: ✓ 1 completed · ✗ 1 failed'); + expect(transcript).not.toContain('<agent_swarm_result>'); + expect(transcript).not.toContain('Reviewed src/a.ts.'); + expect(transcript).not.toContain('Agent timed out.'); + }); + + it('does not show no-index replayed AgentSwarm failures as completed', async () => { + const replay: AgentReplayRecord[] = [ + message('user', [{ type: 'text', text: 'review files with a swarm' }]), + message('assistant', [], { + toolCalls: [ + toolCall('call_swarm', 'AgentSwarm', { + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }), + ], + }), + message( + 'tool', + [{ + type: 'text', + text: [ + '<agent_swarm_result>', + '<summary>failed: 1, aborted: 1</summary>', + '<resume_hint>Call AgentSwarm with resume_agent_ids using the agent_id values ' + + 'in this result to continue unfinished work.</resume_hint>', + '<subagent agent_id="agent-1" item="src/a.ts" outcome="failed">' + + 'Agent timed out.</subagent>', + '<subagent agent_id="agent-2" item="src/b.ts" outcome="aborted">' + + 'User interrupted.</subagent>', + '</agent_swarm_result>', + ].join('\n'), + }], + { toolCallId: 'call_swarm' }, + ), + ]; + + const driver = await replayIntoDriver(replay); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + + expect(transcript).toContain('Agent swarm: ✗ 1 failed · ⊘ 1 aborted'); + expect(transcript).not.toContain('Agent swarm: ✓ Completed.'); + expect(transcript).not.toContain('<agent_swarm_result>'); + }); + it('hydrates todo and background snapshot state from resumed main agent', async () => { const driver = await replayIntoDriver([], { toolStore: { @@ -304,6 +741,103 @@ describe('KimiTUI resume message replay', () => { expect(driver.sessionEventHandler.backgroundTaskTranscriptedTerminal.has('bash-bg1')).toBe(true); }); + it('matches completed resumed background agents by agent id when task id differs', async () => { + const driver = await replayIntoDriver([], { + background: [ + { + taskId: 'task-bg1', + kind: 'agent', + agentId: 'agent-bg1', + subagentType: 'coder', + description: 'Review long-running work', + status: 'running', + startedAt: 1, + endedAt: null, + }, + ], + }); + + expect( + driver.sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.has('agent-bg1'), + ).toBe(true); + expect( + driver.sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.has('task-bg1'), + ).toBe(false); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.completed', + agentId: 'main', + sessionId: 'ses-replay', + subagentId: 'agent-bg1', + resultSummary: 'Reviewed the long-running work.', + }, + () => {}, + ); + + const status = driver.state.transcriptEntries.find( + (entry) => entry.backgroundAgentStatus?.phase === 'completed', + ); + + expect( + driver.sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.has('agent-bg1'), + ).toBe(false); + expect(status?.backgroundAgentStatus?.headline).toBe('agent completed in background'); + expect(status?.backgroundAgentStatus?.detail).toContain('Review long-running work'); + }); + + it('keeps timed-out status when an aborted resumed background agent later fails', async () => { + const info: BackgroundTaskInfo = { + taskId: 'task-bg-timeout', + kind: 'agent', + agentId: 'agent-bg-timeout', + subagentType: 'coder', + description: 'Review timeout handling', + status: 'running', + startedAt: 1, + endedAt: null, + timeoutMs: 1000, + }; + const driver = await replayIntoDriver([], { background: [info] }); + const applyTerminalStatus = vi + .spyOn(driver.streamingUI, 'applyBackgroundTaskTerminalStatus') + .mockReturnValue(true); + + driver.sessionEventHandler.handleEvent( + { + type: 'background.task.terminated', + agentId: 'main', + sessionId: 'ses-replay', + info: { ...info, status: 'timed_out', endedAt: 2 }, + }, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 'ses-replay', + subagentId: 'agent-bg-timeout', + error: 'The subagent was aborted.', + }, + () => {}, + ); + + expect(applyTerminalStatus.mock.calls.map(([args]) => args.status)).toEqual(['timed_out']); + expect( + driver.sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.has( + 'agent-bg-timeout', + ), + ).toBe(false); + expect(driver.sessionEventHandler.backgroundTaskTranscriptedTerminal.has('task-bg-timeout')) + .toBe(true); + expect( + driver.state.transcriptEntries.some( + (entry) => entry.backgroundAgentStatus?.phase === 'failed', + ), + ).toBe(false); + }); + it('renders replayed bash background notifications as bash tasks', async () => { const driver = await replayIntoDriver( [ @@ -475,13 +1009,99 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('hook response 2'); }); + it('renders replayed compaction records as completed compaction blocks', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'prompt before compaction' }]), + { + time: REPLAY_TIME, + type: 'compaction', + result: { + summary: 'Compacted transcript summary.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + instruction: 'preserve implementation notes', + }, + message('user', [{ type: 'text', text: 'prompt after compaction' }]), + ]); + + const compactionEntry = driver.state.transcriptEntries.find( + (entry) => entry.compactionData !== undefined, + ); + expect(compactionEntry?.compactionData).toEqual({ + summary: 'Compacted transcript summary.', + tokensBefore: 120, + tokensAfter: 24, + instruction: 'preserve implementation notes', + }); + const collapsed = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).toContain('120 → 24 tokens'); + expect(collapsed).toContain('preserve implementation notes'); + expect(collapsed).not.toContain('Compacted transcript summary.'); + + driver.state.editor.onToggleToolExpand?.(); + const expanded = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(expanded).toContain('Compacted transcript summary.'); + }); + + it('initializes replayed compaction blocks as expanded when tool output is already expanded', async () => { + const initial = makeSession([]); + const resumed = makeSession([ + { + time: REPLAY_TIME, + type: 'compaction', + result: { + summary: 'Compacted transcript summary.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + }, + ]); + const driver = await makeDriver(initial); + driver.state.toolOutputExpanded = true; + await driver.switchToSession(resumed, 'Resumed session (ses-replay).'); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('Compaction complete'); + expect(transcript).toContain('Compacted transcript summary.'); + }); + + it('renders replayed cancelled compaction records as cancelled compaction blocks', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'prompt before cancellation' }]), + { + time: REPLAY_TIME, + type: 'compaction', + result: 'cancelled', + instruction: 'preserve implementation notes', + }, + message('user', [{ type: 'text', text: 'prompt after cancellation' }]), + ]); + + const compactionEntry = driver.state.transcriptEntries.find( + (entry) => entry.compactionData !== undefined, + ); + expect(compactionEntry?.compactionData).toEqual({ + result: 'cancelled', + instruction: 'preserve implementation notes', + }); + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('Compaction cancelled'); + expect(transcript).toContain('preserve implementation notes'); + expect(transcript).not.toContain('Compaction complete'); + }); + it('renders plan permission and approval replay notices', async () => { const driver = await replayIntoDriver([ - { type: 'plan_updated', enabled: true }, - { type: 'permission_updated', mode: 'auto' }, - { type: 'permission_updated', mode: 'yolo' }, - { type: 'permission_updated', mode: 'manual' }, + { time: REPLAY_TIME, type: 'plan_updated', enabled: true }, + { time: REPLAY_TIME, type: 'permission_updated', mode: 'auto' }, + { time: REPLAY_TIME, type: 'permission_updated', mode: 'yolo' }, + { time: REPLAY_TIME, type: 'permission_updated', mode: 'manual' }, { + time: REPLAY_TIME, type: 'approval_result', record: { turnId: 0, @@ -495,7 +1115,7 @@ describe('KimiTUI resume message replay', () => { }, }, }, - { type: 'plan_updated', enabled: false }, + { time: REPLAY_TIME, type: 'plan_updated', enabled: false }, ]); const transcript = driver.state.transcriptContainer.render(120).join('\n'); @@ -514,6 +1134,7 @@ describe('KimiTUI resume message replay', () => { toolCalls: [toolCall('call_exit_reject', 'ExitPlanMode', {})], }), { + time: REPLAY_TIME, type: 'approval_result', record: { turnId: 0, @@ -531,6 +1152,7 @@ describe('KimiTUI resume message replay', () => { toolCalls: [toolCall('call_exit_final', 'ExitPlanMode', {})], }), { + time: REPLAY_TIME, type: 'approval_result', record: { turnId: 1, @@ -553,7 +1175,7 @@ describe('KimiTUI resume message replay', () => { ], { toolCallId: 'call_exit_final' }, ), - { type: 'plan_updated', enabled: false }, + { time: REPLAY_TIME, type: 'plan_updated', enabled: false }, ]); const transcript = driver.state.transcriptContainer.render(120).join('\n'); diff --git a/apps/kimi-code/test/tui/render-memo.bench.ts b/apps/kimi-code/test/tui/render-memo.bench.ts new file mode 100644 index 000000000..63bf79df6 --- /dev/null +++ b/apps/kimi-code/test/tui/render-memo.bench.ts @@ -0,0 +1,115 @@ +/** + * Benchmark for the message-component render cache (Phase 1 + 1.5). + * + * Measures the cost of re-rendering a long transcript when *nothing* has + * changed — the common steady-state frame. With the render cache enabled + * ("cached (warm)") every message returns its previously computed lines, and + * the GutterContainer returns its cached concatenation, so the cost is roughly + * O(number of messages). With it disabled ("uncached") every message rebuilds + * its output (Markdown, Text, truncation) and the container rebuilds the full + * line array, which is O(total rendered lines) and dominates CPU as the + * transcript grows. + * + * Run: + * pnpm --filter @moonshot-ai/kimi-code exec vitest bench test/tui/render-memo.bench.ts + */ + +import { bench, describe } from 'vitest'; + +import type { Component } from '@moonshot-ai/pi-tui'; + +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { ThinkingComponent } from '#/tui/components/messages/thinking'; +import { UserMessageComponent } from '#/tui/components/messages/user-message'; +import { setRenderCacheEnabled } from '#/tui/utils/render-cache'; + +const WIDTH = 100; +const TRANSCRIPT_TURNS = 200; +const GUTTER = 2; + +const USER_TEXT = + 'Can you refactor the streaming renderer so that finalized assistant messages stop being re-rendered on every frame? Please keep the diff minimal and avoid touching the engine.'; + +const ASSISTANT_TEXT = [ + 'Here is a summary of the change:', + '', + '- cache the rendered lines per message component', + '- invalidate the cache when content, theme, or width changes', + '- keep the diff renderer untouched', + '', + '```ts', + 'render(width: number): string[] {', + ' if (this.cache && this.cache.width === width) return this.cache.lines;', + ' const lines = this.compute(width);', + ' this.cache = { width, lines };', + ' return lines;', + '}', + '```', + '', + 'This keeps the steady-state frame cheap while preserving correctness.', +].join('\n'); + +const THINKING_TEXT = [ + 'Let me reason through the invalidation paths carefully.', + 'The cache must be cleared on content changes, theme switches, and width changes.', + 'Width changes already trigger a full repaint, so they fall out naturally.', + 'Theme switches flow through invalidate(), so that is the hook to clear the cache.', + 'Streaming updates go through updateContent/setText, which already short-circuit when unchanged.', +].join('\n'); + +function buildMessages(turns: number): Component[] { + const components: Component[] = []; + for (let i = 0; i < turns; i++) { + components.push(new UserMessageComponent(`[${i}] ${USER_TEXT}`)); + + const assistant = new AssistantMessageComponent(); + assistant.updateContent(`[${i}] ${ASSISTANT_TEXT}`); + components.push(assistant); + + components.push(new ThinkingComponent(`[${i}] ${THINKING_TEXT}`, true, 'finalized')); + } + return components; +} + +function buildGutter(turns: number): GutterContainer { + const gutter = new GutterContainer(GUTTER, GUTTER); + for (const message of buildMessages(turns)) gutter.addChild(message); + return gutter; +} + +describe('render memo — flat child render', () => { + const messages = buildMessages(TRANSCRIPT_TURNS); + + // Warm up: populate every component's cache so the "cached" case measures + // steady-state cache hits rather than first-render cost. + setRenderCacheEnabled(true); + for (const message of messages) message.render(WIDTH); + + bench('cached (warm)', () => { + setRenderCacheEnabled(true); + for (const message of messages) message.render(WIDTH); + }); + + bench('uncached', () => { + setRenderCacheEnabled(false); + for (const message of messages) message.render(WIDTH); + }); +}); + +describe('render memo — via GutterContainer', () => { + const gutter = buildGutter(TRANSCRIPT_TURNS); + + setRenderCacheEnabled(true); + gutter.render(WIDTH); + + bench('cached (warm)', () => { + setRenderCacheEnabled(true); + gutter.render(WIDTH); + }); + + bench('uncached', () => { + setRenderCacheEnabled(false); + gutter.render(WIDTH); + }); +}); diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts index 997230fec..cdc8709c3 100644 --- a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts +++ b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts @@ -211,6 +211,97 @@ describe('approval adapter', () => { ]); }); + it('renders the /goal start menu for a CreateGoal approval in manual mode', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-goal', + toolName: 'CreateGoal', + action: 'Creating a goal', + display: { + kind: 'goal_start', + objective: 'Fix the failing auth tests', + completionCriterion: 'npm test -- auth exits 0', + mode: 'manual', + }, + }); + + // Objective + criterion are previewed as a brief block. + expect(adapted.display).toEqual([ + { + type: 'brief', + text: 'Start goal: Fix the failing auth tests\nDone when: npm test -- auth exits 0', + }, + ]); + // Choices mirror the manual-mode /goal start menu; mode options approve and + // carry the mode in selected_label, "Do not start" cancels. Each keeps the + // /goal menu's description. + expect(adapted.choices).toEqual([ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + label: 'Switch to YOLO and start', + response: 'approved', + selected_label: 'yolo', + description: + 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + }, + { + label: 'Start in Manual', + response: 'approved', + selected_label: 'manual', + description: + 'Keep approvals on. Kimi Code will ask before risky actions, so the goal may stop and wait for you.', + }, + { + label: 'Do not start', + response: 'cancelled', + selected_label: 'cancel', + description: 'Return to the input box with your goal command.', + }, + ]); + }); + + it('renders the yolo-mode /goal start menu for a CreateGoal approval', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-goal-yolo', + toolName: 'CreateGoal', + action: 'Creating a goal', + display: { + kind: 'goal_start', + objective: 'Ship the feature', + mode: 'yolo', + }, + }); + + expect(adapted.display).toEqual([{ type: 'brief', text: 'Start goal: Ship the feature' }]); + expect(adapted.choices).toEqual([ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + label: 'Keep YOLO and start', + response: 'approved', + selected_label: 'yolo', + description: + 'Tools and plan changes stay approved automatically. Kimi Code may still ask you questions.', + }, + { + label: 'Do not start', + response: 'cancelled', + selected_label: 'cancel', + description: 'Return to the input box with your goal command.', + }, + ]); + }); + it('maps approved-for-session responses into core approval payloads', () => { expect( adaptPanelResponse({ diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index f240f70fd..92a91e063 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { KimiTUI, type KimiTUIStartupInput } from '#/tui/kimi-tui'; +import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; interface SignalDriver { + state: TUIState; registerSignalHandlers(): void; unregisterSignalHandlers(): void; emergencyTerminalExit(): never; @@ -24,12 +25,13 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-signals', - resolvedTheme: 'dark', }; } @@ -298,6 +300,27 @@ describe('KimiTUI signal handlers', () => { expect(process.listenerCount('SIGTERM')).toBe(beforeSigterm); }); + it('stop() drains terminal input before stopping the UI and exiting', async () => { + const { driver, tui } = makeDriver(); + const events: string[] = []; + const drainInput = vi.spyOn(driver.state.terminal, 'drainInput').mockImplementation(async () => { + events.push('drain'); + }); + const uiStop = vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => { + events.push('ui.stop'); + }); + tui.onExit = vi.fn(async () => { + events.push('exit'); + }); + + await tui.stop(); + + expect(drainInput).toHaveBeenCalledOnce(); + expect(uiStop).toHaveBeenCalledOnce(); + expect(tui.onExit).toHaveBeenCalledOnce(); + expect(events).toEqual(['drain', 'ui.stop', 'exit']); + }); + it('start() unregisters signal handlers when initialization throws', async () => { const { tui } = makeDriver(); // Force the very first awaited call inside start() to reject. We don't diff --git a/apps/kimi-code/test/tui/task-output-viewer.test.ts b/apps/kimi-code/test/tui/task-output-viewer.test.ts index 2cb998b42..5948ec6c8 100644 --- a/apps/kimi-code/test/tui/task-output-viewer.test.ts +++ b/apps/kimi-code/test/tui/task-output-viewer.test.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; @@ -39,6 +39,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal { function info(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { return { taskId: 'bash-aaaaaaaa', + kind: 'process', command: 'npm run dev', description: 'dev server', status: 'running', @@ -47,7 +48,7 @@ function info(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { startedAt: Date.now() - 60_000, endedAt: null, ...overrides, - }; + } as BackgroundTaskInfo; } function makeViewer(opts: { @@ -62,7 +63,6 @@ function makeViewer(opts: { taskId: opts.taskInfo?.taskId ?? 'bash-aaaaaaaa', info: opts.taskInfo ?? info(), output: opts.output, - colors: darkColors, onClose: opts.onClose ?? (() => {}), }, fakeTerminal(opts.rows ?? 30, opts.columns ?? 120), @@ -144,6 +144,24 @@ describe('TaskOutputViewer — scrolling', () => { expect(out).not.toContain('line-001'); }); + it('Ctrl+D scrolls a page down', () => { + const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); + viewer.handleInput('\u0004'); // Ctrl+D + const out = strip(viewer.render(120).join('\n')); + // Same page size as PageDown: body has 8 viewable rows, page = 7 lines. + expect(out).toContain('line-008'); + expect(out).not.toContain('line-001'); + }); + + it('Ctrl+U scrolls a page up', () => { + const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); + viewer.handleInput('G'); // jump to bottom first + viewer.handleInput('\u0015'); // Ctrl+U + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-036'); + expect(out).not.toContain('line-050'); + }); + it('G jumps to the bottom', () => { const viewer = makeViewer({ output: bigOutput(100), rows: 14 }); viewer.handleInput('G'); @@ -218,7 +236,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: makeOutput(50), - colors: darkColors, onClose: () => {}, }); const out = strip(viewer.render(120).join('\n')); @@ -234,7 +251,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: makeOutput(200), - colors: darkColors, onClose: () => {}, }); const out = strip(viewer.render(120).join('\n')); @@ -251,7 +267,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: same, - colors: darkColors, onClose: () => {}, }); const after = strip(viewer.render(120).join('\n')); diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index fd973c8fa..dacf75f5f 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; @@ -44,6 +44,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal { function task(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { return { taskId: 'bash-abcd1234', + kind: 'process', command: 'npm run dev', description: 'dev server', status: 'running', @@ -52,7 +53,7 @@ function task(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { startedAt: Date.now() - 60_000, endedAt: null, ...overrides, - }; + } as BackgroundTaskInfo; } function makeProps(overrides: Partial<TasksBrowserProps> = {}): TasksBrowserProps { @@ -63,7 +64,6 @@ function makeProps(overrides: Partial<TasksBrowserProps> = {}): TasksBrowserProp tailOutput: undefined, tailLoading: false, flashMessage: undefined, - colors: darkColors, onSelect: vi.fn(), onToggleFilter: vi.fn(), onRefresh: vi.fn(), @@ -152,6 +152,30 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(out).toContain('long running task'); }); + it('shows question task details in the Detail pane', () => { + const out = strip( + makeApp({ + tasks: [ + task({ + taskId: 'question-aaaaaaaa', + kind: 'question', + description: 'Which database?', + questionCount: 1, + toolCallId: 'call_question', + }), + ], + selectedTaskId: 'question-aaaaaaaa', + }) + .render(120) + .join('\n'), + ); + expect(out).toContain('question-aaaaaaaa'); + expect(out).toContain('Questions:'); + expect(out).toContain('1'); + expect(out).toContain('Tool call:'); + expect(out).toContain('call_question'); + }); + it('renders tail output in the Preview Output pane', () => { const out = strip( makeApp({ @@ -194,10 +218,44 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(out).not.toContain('bash-bbbbbbbb'); }); + it('filters out foreground tasks (detached === false)', () => { + const tasks = [ + task({ taskId: 'bash-foreground', detached: false, status: 'running' }), + task({ taskId: 'bash-background', detached: true, status: 'running' }), + ]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).not.toContain('bash-foreground'); + expect(out).toContain('bash-background'); + }); + + it('keeps background tasks with detached === true even when terminal', () => { + const tasks = [task({ taskId: 'bash-done', detached: true, status: 'completed' })]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).toContain('bash-done'); + }); + + it('keeps ghost tasks whose detached field is undefined', () => { + // task() leaves `detached` undefined by default, mimicking reconcile ghosts. + const tasks = [task({ taskId: 'bash-ghost', status: 'lost' })]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).toContain('bash-ghost'); + }); + + it('applies active filter after excluding foreground tasks', () => { + const tasks = [ + task({ taskId: 'bash-fg-running', detached: false, status: 'running' }), + task({ taskId: 'bash-bg-running', detached: true, status: 'running' }), + task({ taskId: 'bash-bg-done', detached: true, status: 'completed' }), + ]; + const out = strip(makeApp({ tasks, filter: 'active' }).render(120).join('\n')); + expect(out).not.toContain('bash-fg-running'); + expect(out).toContain('bash-bg-running'); + expect(out).not.toContain('bash-bg-done'); + }); + it('renders without throwing for every BackgroundTaskStatus', () => { const statuses: BackgroundTaskStatus[] = [ 'running', - 'awaiting_approval', 'completed', 'failed', 'killed', diff --git a/apps/kimi-code/test/tui/terminal-notification.test.ts b/apps/kimi-code/test/tui/terminal-notification.test.ts index 2d5d904ee..cef7b8086 100644 --- a/apps/kimi-code/test/tui/terminal-notification.test.ts +++ b/apps/kimi-code/test/tui/terminal-notification.test.ts @@ -8,6 +8,7 @@ import { isInsideTmux, notifyTerminalOnce, supportsOsc9Notification, + supportsTerminalProgress, } from '#/tui/utils/terminal-notification'; function makeNotificationState(args: { @@ -215,6 +216,32 @@ describe('supportsOsc9Notification', () => { }); }); +describe('supportsTerminalProgress', () => { + it('detects Windows Terminal / ConEmu via env flags', () => { + expect(supportsTerminalProgress({ WT_SESSION: 'abc-123' })).toBe(true); + expect(supportsTerminalProgress({ ConEmuANSI: 'ON' })).toBe(true); + }); + + it('detects Ghostty / WezTerm via TERM_PROGRAM and TERM', () => { + expect(supportsTerminalProgress({ TERM_PROGRAM: 'ghostty' })).toBe(true); + expect(supportsTerminalProgress({ TERM: 'xterm-ghostty' })).toBe(true); + expect(supportsTerminalProgress({ TERM_PROGRAM: 'WezTerm' })).toBe(true); + }); + + it('rejects terminals that show every OSC 9 payload as a notification', () => { + // iTerm2 treats any OSC 9 payload as a desktop notification, so the + // ConEmu-style 9;4 progress sequence must never be sent there. + expect(supportsTerminalProgress({ TERM_PROGRAM: 'iTerm.app' })).toBe(false); + expect(supportsTerminalProgress({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false); + expect(supportsTerminalProgress({ TERM_PROGRAM: 'WarpTerminal' })).toBe(false); + expect(supportsTerminalProgress({ TERM: 'xterm-kitty' })).toBe(false); + expect(supportsTerminalProgress({ TERM: 'xterm-256color' })).toBe(false); + expect(supportsTerminalProgress({ ConEmuANSI: 'OFF' })).toBe(false); + expect(supportsTerminalProgress({ WT_SESSION: '' })).toBe(false); + expect(supportsTerminalProgress({})).toBe(false); + }); +}); + describe('isInsideTmux', () => { it('detects tmux via the TMUX env var', () => { expect(isInsideTmux({ TMUX: '/private/tmp/tmux-501/default,1234,0' })).toBe(true); diff --git a/apps/kimi-code/test/tui/terminal-theme.test.ts b/apps/kimi-code/test/tui/terminal-theme.test.ts index 68a1818b2..3159f8025 100644 --- a/apps/kimi-code/test/tui/terminal-theme.test.ts +++ b/apps/kimi-code/test/tui/terminal-theme.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { TUIState } from "#/tui/kimi-tui"; +import { darkColors, lightColors } from "#/tui/theme/colors"; +import { getBuiltInPalette } from "#/tui/theme"; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -158,3 +160,16 @@ describe("terminal theme tracking", () => { expect(state.terminal.write).toHaveBeenCalledWith(DISABLE_TERMINAL_THEME_REPORTING); }); }); + +describe('ColorPalette warning token', () => { + it('has a defined warning color in both themes', () => { + expect(darkColors.warning).toBeTruthy(); + expect(lightColors.warning).toBeTruthy(); + expect(darkColors.warning).not.toBe(lightColors.warning); + }); + + it('resolves the correct palette by theme name', () => { + expect(getBuiltInPalette('dark')).toBe(darkColors); + expect(getBuiltInPalette('light')).toBe(lightColors); + }); +}); diff --git a/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts b/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts new file mode 100644 index 000000000..226901338 --- /dev/null +++ b/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts @@ -0,0 +1,86 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getCustomThemesDir, + listCustomThemes, + listCustomThemesSync, + loadCustomTheme, + loadCustomThemeMerged, +} from '#/tui/theme/custom-theme-loader'; +import { darkColors, lightColors } from '#/tui/theme'; + +let home: string; +const originalHome = process.env['KIMI_CODE_HOME']; + +beforeEach(() => { + home = join(tmpdir(), `kimi-themes-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(home, 'themes'), { recursive: true }); + process.env['KIMI_CODE_HOME'] = home; +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); + if (originalHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = originalHome; + } +}); + +function writeTheme(name: string, body: unknown): void { + writeFileSync(join(getCustomThemesDir(), `${name}.json`), JSON.stringify(body), 'utf-8'); +} + +describe('custom theme loader', () => { + it('excludes reserved built-in names from the listing', async () => { + writeTheme('dark', { name: 'dark', colors: {} }); + writeTheme('light', { name: 'light', colors: {} }); + writeTheme('auto', { name: 'auto', colors: {} }); + writeTheme('solarized', { name: 'solarized', colors: { primary: '#268bd2' } }); + + expect(await listCustomThemes()).toEqual(['solarized']); + expect(listCustomThemesSync()).toEqual(['solarized']); + }); + + it('filters invalid hex values without writing to the terminal', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + writeTheme('mixed', { + name: 'mixed', + colors: { primary: '#268bd2', text: 'not-a-hex', accent: '#ff0000' }, + }); + + const loaded = await loadCustomTheme('mixed'); + expect(loaded).toEqual({ primary: '#268bd2', accent: '#ff0000' }); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('returns null for a missing theme file', async () => { + expect(await loadCustomTheme('does-not-exist')).toBeNull(); + }); + + it('falls back to the dark palette for unspecified tokens by default', async () => { + writeTheme('solar-dark', { name: 'solar-dark', colors: { primary: '#268bd2' } }); + const merged = await loadCustomThemeMerged('solar-dark'); + expect(merged?.primary).toBe('#268bd2'); + expect(merged?.text).toBe(darkColors.text); + }); + + it('falls back to the light palette when base is "light"', async () => { + writeTheme('solar-light', { + name: 'solar-light', + base: 'light', + colors: { primary: '#268bd2' }, + }); + const merged = await loadCustomThemeMerged('solar-light'); + expect(merged?.primary).toBe('#268bd2'); + expect(merged?.text).toBe(lightColors.text); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/connect-catalog.test.ts b/apps/kimi-code/test/tui/utils/connect-catalog.test.ts deleted file mode 100644 index 98ece0d29..000000000 --- a/apps/kimi-code/test/tui/utils/connect-catalog.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { DEFAULT_CATALOG_URL, loadBuiltInCatalog } from '@moonshot-ai/kimi-code-sdk'; -import { describe, expect, it } from 'vitest'; - -import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog'; -import { resolveConnectCatalogRequest } from '#/tui/utils/connect-catalog'; - -import { builtInCatalogDefine } from '../../../scripts/built-in-catalog.mjs'; - -describe('resolveConnectCatalogRequest', () => { - it('prefers the built-in catalog by default and keeps online fetch as fallback', () => { - expect(resolveConnectCatalogRequest('')).toEqual({ - kind: 'ok', - request: { - url: DEFAULT_CATALOG_URL, - preferBuiltIn: true, - allowBuiltInFallback: true, - }, - }); - }); - - it('forces an online fetch when refresh is requested', () => { - expect(resolveConnectCatalogRequest('refresh')).toEqual({ - kind: 'ok', - request: { - url: DEFAULT_CATALOG_URL, - preferBuiltIn: false, - allowBuiltInFallback: true, - }, - }); - expect(resolveConnectCatalogRequest(' refresh ')).toEqual({ - kind: 'ok', - request: { - url: DEFAULT_CATALOG_URL, - preferBuiltIn: false, - allowBuiltInFallback: true, - }, - }); - }); - - it('treats explicit catalog URLs as authoritative and ignores refresh on them', () => { - expect(resolveConnectCatalogRequest('https://internal.example/catalog.json')).toEqual({ - kind: 'ok', - request: { - url: 'https://internal.example/catalog.json', - preferBuiltIn: false, - allowBuiltInFallback: false, - }, - }); - expect( - resolveConnectCatalogRequest('refresh https://internal.example/catalog.json'), - ).toEqual({ - kind: 'ok', - request: { - url: 'https://internal.example/catalog.json', - preferBuiltIn: false, - allowBuiltInFallback: false, - }, - }); - expect( - resolveConnectCatalogRequest('https://internal.example/catalog.json refresh'), - ).toEqual({ - kind: 'ok', - request: { - url: 'https://internal.example/catalog.json', - preferBuiltIn: false, - allowBuiltInFallback: false, - }, - }); - }); - - it('rejects unsupported flags', () => { - const flagMessage = (flag: string) => - `Unexpected flag "${flag}". Use /connect [url] [refresh] instead.`; - expect(resolveConnectCatalogRequest('--refresh')).toEqual({ - kind: 'error', - message: flagMessage('--refresh'), - }); - expect(resolveConnectCatalogRequest('--url=https://internal.example/catalog.json')).toEqual({ - kind: 'error', - message: flagMessage('--url=https://internal.example/catalog.json'), - }); - expect(resolveConnectCatalogRequest('--url https://internal.example/catalog.json')).toEqual({ - kind: 'error', - message: flagMessage('--url'), - }); - }); - - it('rejects non-URL bare tokens', () => { - expect(resolveConnectCatalogRequest('ignored text')).toEqual({ - kind: 'error', - message: 'Unknown argument "ignored". Usage: /connect [url] [refresh]', - }); - }); - - it('rejects multiple URLs', () => { - expect( - resolveConnectCatalogRequest('https://a.com/x.json https://b.com/y.json'), - ).toEqual({ - kind: 'error', - message: 'Only one catalog URL can be provided. Got "https://a.com/x.json" and "https://b.com/y.json".', - }); - }); -}); - -describe('built-in connect catalog injection', () => { - it('keeps the source placeholder empty so generated catalog data is not committed', () => { - expect(BUILT_IN_CATALOG_JSON).toBeUndefined(); - expect(loadBuiltInCatalog(BUILT_IN_CATALOG_JSON)).toBeUndefined(); - }); - - it('embeds a generated catalog file through the tsdown define value', async () => { - const catalog = { - openai: { - id: 'openai', - npm: '@ai-sdk/openai', - models: { - 'gpt-test': { - id: 'gpt-test', - limit: { context: 1000, output: 100 }, - modalities: { input: ['text'], output: ['text'] }, - }, - }, - }, - }; - const dir = await mkdtemp(join(tmpdir(), 'kimi-built-in-catalog-')); - try { - const file = join(dir, 'catalog.json'); - const text = JSON.stringify(catalog); - await writeFile(file, text, 'utf-8'); - - const defineValue = builtInCatalogDefine({ KIMI_CODE_BUILT_IN_CATALOG_FILE: file }); - expect(JSON.parse(defineValue)).toBe(text); - expect(loadBuiltInCatalog(JSON.parse(defineValue))).toEqual(catalog); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/kimi-code/test/tui/utils/event-payload.test.ts b/apps/kimi-code/test/tui/utils/event-payload.test.ts index f5d9f04c2..1f8ff22f2 100644 --- a/apps/kimi-code/test/tui/utils/event-payload.test.ts +++ b/apps/kimi-code/test/tui/utils/event-payload.test.ts @@ -1,8 +1,11 @@ +import { ErrorCodes, KimiError } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it } from 'vitest'; import { STREAMING_ARGS_PREVIEW_MAX_CHARS } from '#/tui/constant/streaming'; import { appendStreamingArgsPreview, + formatErrorMessage, + formatErrorPayload, parseStreamingArgs, } from '#/tui/utils/event-payload'; @@ -28,3 +31,41 @@ describe('streaming tool argument payload helpers', () => { expect(parseStreamingArgs(oversized)).toEqual({ command: 'echo ok' }); }); }); + +describe('error payload formatting', () => { + const filteredThinkOnlyMessage = + 'The API returned a response containing only thinking content without any text or tool calls. ' + + 'This usually indicates the stream was interrupted or the output token budget was exhausted ' + + 'during reasoning. Provider stop details: finishReason=filtered, rawFinishReason=content_filter. ' + + 'The provider filtered the response before visible output was emitted. Provider: example-provider, model: example-model'; + const conciseFilteredMessage = + '[provider.api_error] Provider filtered the response before visible output ' + + '(finishReason=filtered, rawFinishReason=content_filter).'; + + it('shows concise provider filter text from structured error payload details', () => { + const formatted = formatErrorPayload({ + code: ErrorCodes.PROVIDER_API_ERROR, + message: filteredThinkOnlyMessage, + details: { + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }, + }); + + expect(formatted).toBe(conciseFilteredMessage); + expect(formatted).not.toContain('only thinking content'); + expect(formatted).not.toContain('token budget'); + expect(formatted).not.toContain('stream was interrupted'); + }); + + it('shows concise provider filter text from KimiError details', () => { + const error = new KimiError(ErrorCodes.PROVIDER_API_ERROR, filteredThinkOnlyMessage, { + details: { + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }, + }); + + expect(formatErrorMessage(error)).toBe(conciseFilteredMessage); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/foreground-task.test.ts b/apps/kimi-code/test/tui/utils/foreground-task.test.ts new file mode 100644 index 000000000..c7d0e2079 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/foreground-task.test.ts @@ -0,0 +1,92 @@ +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { pickForegroundTask, pickForegroundTasks } from '@/tui/utils/foreground-task'; + +function task(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { + return { + taskId: 'bash-aaaaaaaa', + kind: 'process', + command: 'sleep 10', + description: 'Bash: sleep 10', + status: 'running', + detached: false, + pid: 1234, + exitCode: null, + startedAt: 1000, + endedAt: null, + ...overrides, + } as BackgroundTaskInfo; +} + +describe('pickForegroundTask', () => { + it('returns undefined for an empty list', () => { + expect(pickForegroundTask([])).toBeUndefined(); + }); + + it('returns undefined when all tasks are detached (already background)', () => { + expect(pickForegroundTask([task({ detached: true })])).toBeUndefined(); + }); + + it('returns undefined when foreground tasks are not running', () => { + expect(pickForegroundTask([task({ status: 'completed' })])).toBeUndefined(); + expect(pickForegroundTask([task({ status: 'killed' })])).toBeUndefined(); + }); + + it('excludes question tasks', () => { + const question = task({ + kind: 'question', + questionCount: 1, + } as Partial<BackgroundTaskInfo>); + expect(pickForegroundTask([question])).toBeUndefined(); + }); + + it('returns the most recently started foreground running task', () => { + const older = task({ taskId: 'bash-old', startedAt: 1000 }); + const newer = task({ taskId: 'bash-new', startedAt: 2000 }); + expect(pickForegroundTask([older, newer])?.taskId).toBe('bash-new'); + }); + + it('ignores detached running tasks even if newer', () => { + const fg = task({ taskId: 'bash-fg', detached: false, startedAt: 1000 }); + const bg = task({ taskId: 'bash-bg', detached: true, startedAt: 9999 }); + expect(pickForegroundTask([bg, fg])?.taskId).toBe('bash-fg'); + }); + + it('accepts agent (subagent) foreground tasks', () => { + const agent = task({ + taskId: 'agent-aaaaaaaa', + kind: 'agent', + agentId: 'child-1', + subagentType: 'coder', + } as Partial<BackgroundTaskInfo>); + expect(pickForegroundTask([agent])?.taskId).toBe('agent-aaaaaaaa'); + }); +}); + +describe('pickForegroundTasks', () => { + it('returns all foreground running tasks, most recently started first', () => { + const a = task({ taskId: 'bash-a', startedAt: 1000 }); + const b = task({ taskId: 'agent-b', kind: 'agent', startedAt: 3000 }); + const c = task({ taskId: 'bash-c', startedAt: 2000 }); + expect(pickForegroundTasks([a, b, c]).map((t) => t.taskId)).toEqual([ + 'agent-b', + 'bash-c', + 'bash-a', + ]); + }); + + it('excludes detached, terminal, and question tasks', () => { + const fg = task({ taskId: 'bash-fg' }); + const detached = task({ taskId: 'bash-bg', detached: true }); + const done = task({ taskId: 'bash-done', status: 'completed' }); + const question = task({ taskId: 'q', kind: 'question' } as Partial<BackgroundTaskInfo>); + expect(pickForegroundTasks([fg, detached, done, question]).map((t) => t.taskId)).toEqual([ + 'bash-fg', + ]); + }); + + it('returns an empty array when nothing matches', () => { + expect(pickForegroundTasks([task({ detached: true })])).toEqual([]); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/goal-completion.test.ts b/apps/kimi-code/test/tui/utils/goal-completion.test.ts new file mode 100644 index 000000000..0ef499e19 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/goal-completion.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { buildGoalCompletionMessage } from '#/tui/utils/goal-completion'; +import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; + +function snapshot(overrides: Partial<GoalSnapshot> = {}): GoalSnapshot { + return { + objective: 'work', + status: 'complete', + turnsUsed: 3, + tokensUsed: 12_500, + wallClockMs: 260_000, + terminalReason: 'all tests pass', + ...overrides, + } as GoalSnapshot; +} + +describe('buildGoalCompletionMessage', () => { + it('includes the reason, exact turns, tokens, and time', () => { + const text = buildGoalCompletionMessage(snapshot()); + expect(text).toContain('Goal complete — all tests pass.'); + expect(text).toContain('3 turns'); + expect(text).toContain('12.5k tokens'); + expect(text).toContain('4m20s'); + }); + + it('omits the dash when there is no reason and singularizes one turn', () => { + const text = buildGoalCompletionMessage( + snapshot({ terminalReason: undefined, turnsUsed: 1, tokensUsed: 800, wallClockMs: 5000 }), + ); + expect(text).toContain('Goal complete.'); + expect(text).not.toContain('—'); + expect(text).toContain('1 turn '); + expect(text).toContain('800 tokens'); + expect(text).toContain('5s'); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts new file mode 100644 index 000000000..ef78085c7 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts @@ -0,0 +1,771 @@ +import { + KIMI_CODE_PROVIDER_NAME, + resolveKimiCodeOAuthKey, + resolveKimiCodeOAuthRef, +} from '@moonshot-ai/kimi-code-oauth'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { refreshAllProviderModels } from '../../../src/tui/utils/refresh-providers'; +import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; + +type FetchMock = ( + input: Parameters<typeof fetch>[0], + init?: Parameters<typeof fetch>[1], +) => Promise<Response>; + +function fetchInputUrl(input: Parameters<typeof fetch>[0]): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.href; + return input.url; +} + +function makeRefreshHost(initial: KimiConfig): { + current: () => KimiConfig; + removeProvider: ReturnType<typeof vi.fn<(providerId: string) => Promise<KimiConfig>>>; + setConfig: ReturnType<typeof vi.fn<(patch: Partial<KimiConfig>) => Promise<KimiConfig>>>; +} { + let persisted = structuredClone(initial); + const removeProvider = vi.fn(async (providerId: string) => { + const providers = { ...persisted.providers }; + delete providers[providerId]; + const models = { ...persisted.models }; + let defaultRemoved = false; + for (const [alias, model] of Object.entries(models)) { + if (model.provider !== providerId) continue; + delete models[alias]; + if (persisted.defaultModel === alias) defaultRemoved = true; + } + persisted = { ...persisted, providers, models }; + if (defaultRemoved) persisted = { ...persisted, defaultModel: undefined }; + return structuredClone(persisted); + }); + const setConfig = vi.fn(async (patch: Partial<KimiConfig>) => { + persisted = { ...persisted, ...patch }; + return structuredClone(persisted); + }); + return { + current: () => structuredClone(persisted), + removeProvider, + setConfig, + }; +} + +describe('refreshAllProviderModels', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('refreshes managed Kimi Code against environment endpoints over persisted config', async () => { + const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; + const envBaseUrl = 'https://api.env.example.test/coding/v1'; + const envOauthHost = 'https://auth.env.example.test'; + const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl }); + const envOauthRef = resolveKimiCodeOAuthRef({ + oauthHost: envOauthHost, + baseUrl: envBaseUrl, + }); + const config: KimiConfig = { + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + baseUrl: configuredBaseUrl, + apiKey: '', + oauth: { + storage: 'file', + key: configuredOauthKey, + oauthHost: 'https://auth.kimi.com', + }, + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + }, + }, + defaultModel: 'kimi-code/kimi-for-coding', + telemetry: true, + }; + vi.stubEnv('KIMI_CODE_BASE_URL', envBaseUrl); + vi.stubEnv('KIMI_CODE_OAUTH_HOST', envOauthHost); + const resolveOAuthToken = vi.fn(async (_providerName, oauthRef) => { + expect(oauthRef).toEqual(envOauthRef); + return 'env-access-token'; + }); + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${envBaseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer env-access-token'); + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => config, + removeProvider: vi.fn(), + setConfig: vi.fn(), + resolveOAuthToken, + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual([KIMI_CODE_PROVIDER_NAME]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(resolveOAuthToken).toHaveBeenCalledWith(KIMI_CODE_PROVIDER_NAME, envOauthRef); + }); + + it('can refresh only the managed OAuth provider without fetching third-party registries', async () => { + const baseUrl = 'https://api.example.test/coding/v1'; + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const config: KimiConfig = { + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + baseUrl, + apiKey: '', + oauth: { + storage: 'file', + key: resolveKimiCodeOAuthKey({ baseUrl }), + }, + }, + custom: { + type: 'openai', + baseUrl: 'https://custom.example.test/v1', + apiKey: 'sk-test-token', + source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + displayName: 'Old Kimi', + }, + 'custom/m1': { + provider: 'custom', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'Custom M1', + }, + }, + defaultModel: 'kimi-code/kimi-for-coding', + telemetry: true, + }; + const host = makeRefreshHost(config); + const resolveOAuthToken = vi.fn(async () => 'oauth-access-token'); + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer oauth-access-token'); + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh Kimi', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels( + { + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken, + }, + { scope: 'oauth' }, + ); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { + providerId: KIMI_CODE_PROVIDER_NAME, + providerName: 'Kimi Code', + added: 0, + removed: 0, + }, + ]); + expect(result.unchanged).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.current().models?.['kimi-code/kimi-for-coding']?.displayName).toBe('Fresh Kimi'); + expect(host.current().models?.['custom/m1']?.displayName).toBe('Custom M1'); + }); + + it('refreshes custom-registry model capabilities even when model ids are unchanged', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const providerId = 'example_chat-completions'; + const siblingProviderId = 'example_messages'; + const modelId = 'reasoner-pro'; + const modelAlias = `${providerId}/${modelId}`; + const siblingModelAlias = `${siblingProviderId}/${modelId}`; + const userAlias = 'my-reasoner'; + const userAliasModel = { + provider: providerId, + model: modelId, + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'My Reasoner', + }; + const host = makeRefreshHost({ + providers: { + [providerId]: { + type: 'openai', + baseUrl: 'https://api.example.test/v1', + apiKey: 'sk-test-token', + source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, + }, + [siblingProviderId]: { + type: 'anthropic', + baseUrl: 'https://messages.example.test', + apiKey: 'sk-test-token', + source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, + }, + }, + models: { + [modelAlias]: { + provider: providerId, + model: modelId, + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Reasoner Pro', + }, + [siblingModelAlias]: { + provider: siblingProviderId, + model: modelId, + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Reasoner Pro', + }, + [userAlias]: userAliasModel, + }, + defaultModel: modelAlias, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); + return new Response( + JSON.stringify({ + [providerId]: { + id: providerId, + name: 'Example Chat Completions', + api: 'https://api.example.test/v1', + type: 'openai', + models: { + [modelId]: { + id: modelId, + name: 'Reasoner Pro', + limit: { context: 262144, output: 262144 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + }, + }, + }, + [siblingProviderId]: { + id: siblingProviderId, + name: 'Example Messages', + api: 'https://messages.example.test', + type: 'anthropic', + models: { + [modelId]: { + id: modelId, + name: 'Reasoner Pro', + limit: { context: 262144, output: 262144 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + }, + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual([]); + expect(result.changed).toEqual([ + { + providerId, + providerName: 'Example Chat Completions', + added: 0, + removed: 0, + }, + { + providerId: siblingProviderId, + providerName: 'Example Messages', + added: 0, + removed: 0, + }, + ]); + expect(host.removeProvider).toHaveBeenCalledWith(providerId); + expect(host.removeProvider).toHaveBeenCalledWith(siblingProviderId); + expect(host.setConfig).toHaveBeenCalledTimes(1); + expect(host.current().models?.[modelAlias]?.capabilities).toEqual([ + 'tool_use', + 'thinking', + 'image_in', + 'video_in', + ]); + expect(host.current().models?.[siblingModelAlias]?.capabilities).toEqual([ + 'tool_use', + 'thinking', + 'image_in', + 'video_in', + ]); + expect(host.current().models?.[userAlias]).toEqual(userAliasModel); + }); + + it('adds custom-registry providers that appear under an existing source URL', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const apiKey = 'sk-test-token'; + const source = { kind: 'apiJson', url: registryUrl, apiKey }; + const host = makeRefreshHost({ + providers: { + a: { + type: 'openai', + baseUrl: 'https://a.example.test/v1', + apiKey, + source, + }, + }, + models: { + 'a/m1': { + provider: 'a', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); + return new Response( + JSON.stringify({ + a: { + id: 'a', + name: 'Provider A', + api: 'https://a.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + b: { + id: 'b', + name: 'Provider B', + api: 'https://b.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['a']); + expect(result.changed).toEqual([ + { + providerId: 'b', + providerName: 'Provider B', + added: 1, + removed: 0, + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.removeProvider).not.toHaveBeenCalled(); + expect(host.setConfig).toHaveBeenCalledTimes(1); + expect(Object.keys(host.current().providers).toSorted()).toEqual(['a', 'b']); + expect(host.current().providers['b']).toMatchObject({ + type: 'openai', + baseUrl: 'https://b.example.test/v1', + apiKey, + source, + }); + expect(host.current().models?.['b/m1']).toEqual({ + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }); + }); + + it('removes custom-registry providers that disappear from an existing source URL', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const apiKey = 'sk-test-token'; + const source = { kind: 'apiJson', url: registryUrl, apiKey }; + const host = makeRefreshHost({ + providers: { + a: { + type: 'openai', + baseUrl: 'https://a.example.test/v1', + apiKey, + source, + }, + b: { + type: 'openai', + baseUrl: 'https://b.example.test/v1', + apiKey, + source, + }, + }, + models: { + 'a/m1': { + provider: 'a', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + 'b/m1': { + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + 'my-b': { + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'My B', + }, + }, + defaultModel: 'my-b', + thinking: { enabled: true }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); + return new Response( + JSON.stringify({ + a: { + id: 'a', + name: 'Provider A', + api: 'https://a.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['a']); + expect(result.changed).toEqual([ + { + providerId: 'b', + providerName: 'b', + added: 0, + removed: 1, + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.removeProvider).toHaveBeenCalledWith('b'); + expect(host.setConfig).toHaveBeenCalledTimes(1); + expect(Object.keys(host.current().providers)).toEqual(['a']); + expect(host.current().models?.['a/m1']).toBeDefined(); + expect(host.current().models?.['b/m1']).toBeUndefined(); + expect(host.current().models?.['my-b']).toBeUndefined(); + expect(host.current().defaultModel).toBeUndefined(); + expect(host.current().thinking).toBeUndefined(); + }); + + it('coalesces duplicate custom-registry source URLs without reporting config-only changes', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const oldSource = { kind: 'apiJson', url: registryUrl, apiKey: 'sk-old-token' }; + const newSource = { kind: 'apiJson', url: registryUrl, apiKey: 'sk-new-token' }; + const host = makeRefreshHost({ + providers: { + a: { + type: 'openai', + baseUrl: 'https://a.example.test/v1', + apiKey: 'sk-old-token', + source: oldSource, + }, + b: { + type: 'openai', + baseUrl: 'https://b.example.test/v1', + apiKey: 'sk-new-token', + source: newSource, + }, + }, + models: { + 'a/m1': { + provider: 'a', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + 'b/m1': { + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + const authorization = new Headers(init?.headers).get('authorization'); + if (authorization === 'Bearer sk-old-token') { + return new Response(JSON.stringify({ message: 'expired token' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + expect(authorization).toBe('Bearer sk-new-token'); + return new Response( + JSON.stringify({ + a: { + id: 'a', + name: 'Provider A', + api: 'https://a.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + b: { + id: 'b', + name: 'Provider B', + api: 'https://b.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' }, m2: { id: 'm2' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['a']); + expect(result.changed).toEqual([ + { + providerId: 'b', + providerName: 'Provider B', + added: 1, + removed: 0, + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(host.removeProvider).toHaveBeenCalledWith('a'); + expect(host.removeProvider).toHaveBeenCalledWith('b'); + expect(host.setConfig).toHaveBeenCalledTimes(1); + expect(host.current().providers['a']?.source).toEqual(newSource); + expect(host.current().providers['b']?.source).toEqual(newSource); + expect(host.current().providers['a']?.apiKey).toBe('sk-new-token'); + expect(host.current().providers['b']?.apiKey).toBe('sk-new-token'); + expect(host.current().models?.['b/m2']).toEqual({ + provider: 'b', + model: 'm2', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm2', + }); + }); + + it('ignores user-defined aliases when custom-registry metadata is unchanged', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const providerId = 'example_chat-completions'; + const modelId = 'reasoner-pro'; + const modelAlias = `${providerId}/${modelId}`; + const userAlias = 'my-reasoner'; + const richCapabilities = ['tool_use', 'thinking', 'image_in']; + const userAliasModel = { + provider: providerId, + model: modelId, + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'My Reasoner', + }; + const host = makeRefreshHost({ + providers: { + [providerId]: { + type: 'openai', + baseUrl: 'https://api.example.test/v1', + apiKey: 'sk-test-token', + source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, + }, + }, + models: { + [modelAlias]: { + provider: providerId, + model: modelId, + maxContextSize: 262144, + capabilities: richCapabilities, + displayName: 'Reasoner Pro', + }, + [userAlias]: userAliasModel, + }, + defaultModel: userAlias, + thinking: { enabled: false }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); + return new Response( + JSON.stringify({ + [providerId]: { + id: providerId, + name: 'Example Chat Completions', + api: 'https://api.example.test/v1', + type: 'openai', + models: { + [modelId]: { + id: modelId, + name: 'Reasoner Pro', + limit: { context: 262144, output: 262144 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([]); + expect(result.unchanged).toEqual([providerId]); + expect(host.removeProvider).not.toHaveBeenCalled(); + expect(host.setConfig).not.toHaveBeenCalled(); + expect(host.current().models?.[userAlias]).toEqual(userAliasModel); + expect(host.current().defaultModel).toBe(userAlias); + expect(host.current().thinking?.enabled).toBe(false); + }); + + it('forces default thinking on when the refreshed default model cannot disable thinking', async () => { + const host = makeRefreshHost({ + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + apiKey: '', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }, + models: { + 'kimi-code/kimi-deep-coder': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-deep-coder', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + }, + }, + defaultModel: 'kimi-code/kimi-deep-coder', + thinking: { enabled: false }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-deep-coder', + context_length: 262144, + supports_reasoning: true, + supports_thinking_type: 'only', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(async () => 'oauth-access-token'), + }); + + expect(result.failed).toEqual([]); + expect(host.current().models?.['kimi-code/kimi-deep-coder']?.capabilities).toEqual([ + 'thinking', + 'always_thinking', + 'tool_use', + ]); + expect(host.current().defaultModel).toBe('kimi-code/kimi-deep-coder'); + expect(host.current().thinking?.enabled).toBe(true); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/shell-output.test.ts b/apps/kimi-code/test/tui/utils/shell-output.test.ts new file mode 100644 index 000000000..e7a724b43 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/shell-output.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; + +const ESC = '\u001B'; +const BEL = '\u0007'; + +function stripTheme(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('sanitizeShellOutput', () => { + it('leaves plain text untouched', () => { + expect(sanitizeShellOutput('hello\nworld')).toBe('hello\nworld'); + }); + + it('strips SGR colour sequences', () => { + expect(sanitizeShellOutput(`${ESC}[31mred${ESC}[0m`)).toBe('red'); + expect(sanitizeShellOutput(`${ESC}[1;32mbold green${ESC}[0m`)).toBe('bold green'); + }); + + it('strips CSI private modes (alt screen, cursor visibility)', () => { + expect(sanitizeShellOutput(`${ESC}[?1049h${ESC}[?25l`)).toBe(''); + expect(sanitizeShellOutput(`before${ESC}[?2004hafter`)).toBe('beforeafter'); + }); + + it('strips clear-screen and cursor-movement sequences', () => { + expect(sanitizeShellOutput(`${ESC}[2J${ESC}[Hhello`)).toBe('hello'); + expect(sanitizeShellOutput(`${ESC}[10;5Hhi`)).toBe('hi'); + }); + + it('strips OSC window titles', () => { + expect(sanitizeShellOutput(`${ESC}]0;my title${BEL}text`)).toBe('text'); + }); + + it('strips OSC 8 hyperlinks but keeps the link text', () => { + const link = `${ESC}]8;;https://example.com${ESC}\\click here${ESC}]8;;${ESC}\\`; + expect(sanitizeShellOutput(link)).toBe('click here'); + }); + + it('strips carriage returns (spinner redraw)', () => { + expect(sanitizeShellOutput('frame1\rframe2\rframe3')).toBe('frame1frame2frame3'); + expect(sanitizeShellOutput('line\r\nnext')).toBe('line\nnext'); + }); + + it('strips backspace, bell and NUL', () => { + expect(sanitizeShellOutput(`a\u0008b${BEL}c\u0000d`)).toBe('abcd'); + }); + + it('preserves newlines and tabs', () => { + expect(sanitizeShellOutput('a\nb\tc')).toBe('a\nb\tc'); + }); + + it('strips single-char ESC commands (reset, save/restore cursor)', () => { + expect(sanitizeShellOutput(`${ESC}c${ESC}7${ESC}8text`)).toBe('text'); + }); + + it('never throws and returns "" for non-string input', () => { + expect(sanitizeShellOutput(undefined as unknown as string)).toBe(''); + expect(sanitizeShellOutput(null as unknown as string)).toBe(''); + expect(sanitizeShellOutput(42 as unknown as string)).toBe(''); + }); + + it('handles huge input without throwing', () => { + const huge = `${ESC}[31m${'x'.repeat(2_000_000)}\r${ESC}[0m`; + expect(() => sanitizeShellOutput(huge)).not.toThrow(); + }); + + it('cleans a realistic TUI/dev-server burst down to printable text', () => { + const messy = + `${ESC}[?1049h${ESC}[?25l${ESC}[2J${ESC}[H` + + `${ESC}[1m${ESC}[32mVITE${ESC}[0m ready in 120ms\r\n` + + `${ESC}]0;dev server${BEL}` + + ` Local: http://localhost:5173/`; + const result = sanitizeShellOutput(messy); + expect(result).not.toContain(ESC); + expect(result).not.toContain('\r'); + expect(result).toContain('VITE ready in 120ms'); + expect(result).toContain('Local: http://localhost:5173/'); + }); +}); + +describe('formatBashOutputForDisplay', () => { + it('shows "(no output)" when both streams are empty', () => { + expect(stripTheme(formatBashOutputForDisplay('', ''))).toBe('(no output)'); + }); + + it('strips control sequences from stdout before rendering', () => { + const result = stripTheme(formatBashOutputForDisplay(`${ESC}[?1049h${ESC}[31mhi${ESC}[0m\r`, '')); + expect(result).not.toContain(ESC); + expect(result).not.toContain('\r'); + expect(result).toContain('hi'); + }); + + it('strips control sequences from stderr before rendering', () => { + const result = stripTheme(formatBashOutputForDisplay('', `err${BEL}\r`, true)); + expect(result).not.toContain(ESC); + expect(result).not.toContain(BEL); + expect(result).not.toContain('\r'); + expect(result).toContain('err'); + }); + + it('never throws on malformed / non-string input', () => { + expect(() => + formatBashOutputForDisplay(undefined as unknown as string, null as unknown as string), + ).not.toThrow(); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/tab-strip.test.ts b/apps/kimi-code/test/tui/utils/tab-strip.test.ts new file mode 100644 index 000000000..e4ae2d2aa --- /dev/null +++ b/apps/kimi-code/test/tui/utils/tab-strip.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import chalk from 'chalk'; + +import { darkColors } from '#/tui/theme/colors'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; + +const ANSI_SGR = /\u001b\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function render(labels: readonly string[], width: number, activeIndex = 0): string { + const previousChalkLevel = chalk.level; + chalk.level = 3; + try { + return strip(renderTabStrip({ labels, activeIndex, width, colors: darkColors })); + } finally { + chalk.level = previousChalkLevel; + } +} + +describe('renderTabStrip', () => { + const labels = ['Installed', 'Official', 'Third-party', 'Custom']; + // Cell widths: ` ${label} ` → 11 / 10 / 13 / 8 = 42, plus 3 separators and a + // leading space → 46 columns total. + const FULL_WIDTH = 46; + + it('shows the full strip when it exactly fits', () => { + const out = render(labels, FULL_WIDTH); + expect(out).toContain('Installed'); + expect(out).toContain('Custom'); + expect(out).not.toContain('<'); + expect(out).not.toContain('>'); + }); + + it('scrolls (shows markers) when one column narrower than full fit', () => { + const out = render(labels, FULL_WIDTH - 1, 0); + expect(out).toContain('>'); + expect(out).not.toContain('Custom'); + }); + + it('does not truncate the last tab when separators just barely fit', () => { + // Regression: the old fit check summed only cell widths and ignored the + // three inter-tab spaces, so at 43–45 columns it declared a fit while the + // joined line was wider and the trailing tab got truncated. + const out = render(labels, FULL_WIDTH); + expect(out.endsWith(' Custom ')).toBe(true); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/thinking-config.test.ts b/apps/kimi-code/test/tui/utils/thinking-config.test.ts new file mode 100644 index 000000000..bd951d251 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/thinking-config.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { + isThinkingOn, + thinkingEffortFromConfig, + thinkingEffortToConfig, +} from '@/tui/utils/thinking-config'; + +describe('thinkingEffortToConfig', () => { + it.each([ + ['off', { enabled: false }], + // 'on' is the boolean-model on-signal, not a declared effort. It must not + // be persisted as `thinking.effort` — boolean models have no effort concept + // and resolve back to 'on' at runtime via defaultThinkingEffortFor. + ['on', { enabled: true }], + ['low', { enabled: true, effort: 'low' }], + ['high', { enabled: true, effort: 'high' }], + ['max', { enabled: true, effort: 'max' }], + ] as const)('maps %s → %o', (effort, expected) => { + expect(thinkingEffortToConfig(effort)).toEqual(expected); + }); +}); + +describe('isThinkingOn', () => { + it.each([ + ['off', false], + ['on', true], + ['low', true], + ['high', true], + ['max', true], + ] as const)('%s → %s', (effort, expected) => { + expect(isThinkingOn(effort)).toBe(expected); + }); +}); + +describe('thinkingEffortFromConfig', () => { + it.each([ + [undefined, undefined], + [{}, undefined], + // enabled with no concrete effort → let the model's own default apply. + [{ enabled: true }, undefined], + [{ enabled: false }, 'off'], + [{ enabled: true, effort: 'high' }, 'high'], + // effort is honored even when enabled is not explicitly set. + [{ effort: 'max' }, 'max'], + ] as const)('%o → %s', (config, expected) => { + expect(thinkingEffortFromConfig(config)).toBe(expected); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/transcript-window.test.ts b/apps/kimi-code/test/tui/utils/transcript-window.test.ts new file mode 100644 index 000000000..4fbc23fec --- /dev/null +++ b/apps/kimi-code/test/tui/utils/transcript-window.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { TranscriptEntry } from '#/tui/types'; +import { groupTurns, readEnvInt, turnsToTrim } from '#/tui/utils/transcript-window'; + +let seq = 0; +function makeEntry( + turnId: string | undefined, + kind: TranscriptEntry['kind'] = 'assistant', +): TranscriptEntry { + return { id: String(++seq), kind, turnId, renderMode: 'markdown', content: '' }; +} +function tool(turnId: string): TranscriptEntry { + return makeEntry(turnId, 'tool_call'); +} +function msg(turnId: string | undefined): TranscriptEntry { + return makeEntry(turnId, 'assistant'); +} + +describe('groupTurns', () => { + it('groups consecutive entries with the same turnId', () => { + const turns = groupTurns([msg('a'), tool('a'), msg('b')]); + expect(turns.map((t) => t.turnId)).toEqual(['a', 'b']); + expect(turns[0]!.entries).toHaveLength(2); + expect(turns[1]!.entries).toHaveLength(1); + }); + + it('attaches leading undefined turnId entries to the following turn', () => { + // A user message (undefined turnId) followed by its response should be one turn. + const turns = groupTurns([msg(undefined), tool('1'), msg('1')]); + expect(turns).toHaveLength(1); + expect(turns[0]!.turnId).toBe('1'); + expect(turns[0]!.entries).toHaveLength(3); + }); + + it('attaches multiple consecutive undefined entries to the following turn', () => { + const turns = groupTurns([msg(undefined), msg(undefined), msg('a')]); + expect(turns).toHaveLength(1); + expect(turns[0]!.turnId).toBe('a'); + expect(turns[0]!.entries).toHaveLength(3); + }); + + it('makes trailing undefined entries their own turn', () => { + const turns = groupTurns([msg('a'), msg(undefined)]); + expect(turns).toHaveLength(2); + expect(turns[0]!.turnId).toBe('a'); + expect(turns[1]!.turnId).toBeUndefined(); + expect(turns[1]!.entries).toHaveLength(1); + }); +}); + +describe('turnsToTrim', () => { + it('returns empty when turn count is within maxTurns', () => { + const turns = groupTurns([msg('a'), msg('b'), msg('c')]); // 3 turns + expect(turnsToTrim(turns, 5, 1).size).toBe(0); + }); + + it('does not trim within the hysteresis band', () => { + const turns = groupTurns([msg('a'), msg('b'), msg('c')]); // 3 turns + expect(turnsToTrim(turns, 2, 1).size).toBe(0); // 3 <= 2 + 1 + }); + + it('trims oldest turns first', () => { + const entries = [msg('a'), msg('b'), msg('c'), msg('d')]; // 4 turns + const turns = groupTurns(entries); + const removed = turnsToTrim(turns, 2, 0); + expect(removed.has(entries[0]!)).toBe(true); + expect(removed.has(entries[1]!)).toBe(true); + expect(removed.has(entries[2]!)).toBe(false); + expect(removed.has(entries[3]!)).toBe(false); + }); + + it('never trims the most recent turn', () => { + // A single turn is never removed, even if it is huge. + const entries = Array.from({ length: 200 }, () => tool('solo')); + const turns = groupTurns(entries); // 1 turn + const removed = turnsToTrim(turns, 2, 0); + expect(removed.size).toBe(0); + }); +}); + +describe('readEnvInt', () => { + const KEY = 'KIMI_CODE_TUI_TEST_INT'; + afterEach(() => { + delete process.env[KEY]; + }); + + it('returns fallback when unset', () => { + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('reads a valid integer', () => { + process.env[KEY] = '42'; + expect(readEnvInt(KEY, 7)).toBe(42); + }); + + it('accepts 0', () => { + process.env[KEY] = '0'; + expect(readEnvInt(KEY, 7)).toBe(0); + }); + + it('falls back on negative', () => { + process.env[KEY] = '-1'; + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('falls back on non-integer', () => { + process.env[KEY] = 'abc'; + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('falls back on empty/whitespace', () => { + process.env[KEY] = ' '; + expect(readEnvInt(KEY, 7)).toBe(7); + }); +}); diff --git a/apps/kimi-code/test/tui/working-tips.test.ts b/apps/kimi-code/test/tui/working-tips.test.ts new file mode 100644 index 000000000..e2cf96c54 --- /dev/null +++ b/apps/kimi-code/test/tui/working-tips.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + WORKING_TIPS, + currentWorkingTip, + pickRandomWorkingTip, +} from '#/tui/components/chrome/working-tips'; + +describe('currentWorkingTip', () => { + it('returns a tip from WORKING_TIPS', () => { + const now = Date.now(); + const tip = currentWorkingTip(now); + expect(tip).toBeDefined(); + expect(WORKING_TIPS.some((t) => t.text === tip!.text)).toBe(true); + }); + + it('returns the same tip for the same timestamp', () => { + const now = 1_000_000; + const first = currentWorkingTip(now); + const second = currentWorkingTip(now); + expect(first).toBe(second); + }); +}); + +describe('pickRandomWorkingTip', () => { + it('returns a tip from WORKING_TIPS', () => { + const tip = pickRandomWorkingTip(); + expect(tip).toBeDefined(); + expect(WORKING_TIPS.some((t) => t.text === tip!.text)).toBe(true); + }); + + it('avoids the excluded text when possible', () => { + const first = pickRandomWorkingTip()!; + let different = false; + for (let i = 0; i < 50; i++) { + const next = pickRandomWorkingTip(first.text); + if (next !== undefined && next.text !== first.text) { + different = true; + break; + } + } + if (WORKING_TIPS.length > 1) { + expect(different).toBe(true); + } + }); + + it('falls back to the rotation when every tip would be excluded', () => { + // If all working tips share the same text, exclusion cannot be satisfied. + const onlyTip = WORKING_TIPS[0]; + if (onlyTip !== undefined && WORKING_TIPS.every((t) => t.text === onlyTip.text)) { + expect(pickRandomWorkingTip(onlyTip.text)).toBeDefined(); + } + }); +}); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts new file mode 100644 index 000000000..c6aba57a4 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { runCommandAsync } from '#/utils/clipboard/clipboard-common'; + +describe('runCommandAsync', () => { + it('resolves with stdout for a successful command', async () => { + const result = await runCommandAsync(process.execPath, ['-e', 'process.stdout.write("hello")']); + expect(result.ok).toBe(true); + expect(result.stdout.toString('utf-8')).toBe('hello'); + }); + + it('resolves ok:false for a non-zero exit', async () => { + const result = await runCommandAsync(process.execPath, ['-e', 'process.exit(3)']); + expect(result.ok).toBe(false); + }); + + it('does not block when the command exceeds the timeout', async () => { + const timeoutMs = 100; + const start = Date.now(); + // The child would idle for 30s if left running; runCommandAsync must kill + // it and resolve well before that so a wedged helper cannot freeze launch. + const result = await runCommandAsync(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { + timeoutMs, + }); + const elapsed = Date.now() - start; + + expect(result.ok).toBe(false); + expect(elapsed).toBeLessThan(5000); + }); +}); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts new file mode 100644 index 000000000..94e841101 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; +import type { ClipboardModule } from '#/utils/clipboard/clipboard-native'; + +function fakeClipboard(overrides: Partial<ClipboardModule>): ClipboardModule { + return { + hasImage: vi.fn(() => false), + getImageBinary: vi.fn(async () => []), + ...overrides, + }; +} + +describe('clipboardHasImage', () => { + it('returns false on Termux', async () => { + const result = await clipboardHasImage({ env: { TERMUX_VERSION: '0.118' }, platform: 'linux' }); + expect(result).toBe(false); + }); + + it('returns true when native clipboard reports an image on macOS', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(true); + }); + + it('returns false on macOS when native clipboard reports no image', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + }); + + it('returns false on macOS when native clipboard throws', async () => { + const clip = fakeClipboard({ + hasImage: vi.fn(() => { + throw new Error('native error'); + }), + }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + }); + + it('returns false on macOS when clipboard contains a file-like native format', async () => { + const clip = fakeClipboard({ + hasImage: vi.fn(() => true), + availableFormats: vi.fn(() => ['public.file-url', 'public.png']), + }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + expect(clip.hasImage).not.toHaveBeenCalled(); + }); + + // The focus-driven hint must not probe the clipboard on Linux: spawning + // wl-paste / xclip on Wayland perturbs seat focus and re-triggers the + // terminal focus event, creating a focus feedback loop (issue #1090). + it('returns false on Linux without reading the clipboard', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const result = await clipboardHasImage({ + platform: 'linux', + env: { WAYLAND_DISPLAY: 'wayland-1' }, + clipboard: clip, + }); + expect(result).toBe(false); + expect(clip.hasImage).not.toHaveBeenCalled(); + }); + + it('returns true on Windows when native clipboard reports an image', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const result = await clipboardHasImage({ platform: 'win32', clipboard: clip }); + expect(result).toBe(true); + }); + + it('returns false on Windows when native clipboard reports no image', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); + const result = await clipboardHasImage({ platform: 'win32', clipboard: clip }); + expect(result).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts new file mode 100644 index 000000000..72b036352 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts @@ -0,0 +1,56 @@ +import { spawnSync } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clipboard } from '#/utils/clipboard/clipboard-native'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; + +vi.mock('node:child_process', () => ({ + spawnSync: vi.fn(), +})); + +vi.mock('#/utils/clipboard/clipboard-native', () => ({ + clipboard: { + setText: vi.fn(), + }, +})); + +const clipboardMock = clipboard as unknown as { setText: ReturnType<typeof vi.fn> }; +const spawnSyncMock = vi.mocked(spawnSync); + +afterEach(() => { + vi.clearAllMocks(); +}); + +beforeEach(() => { + spawnSyncMock.mockImplementation(() => { + throw new Error('platform clipboard fallback should not run'); + }); +}); + +describe('copyTextToClipboard', () => { + it('copies text with the native clipboard when available', async () => { + clipboardMock.setText.mockResolvedValue(undefined); + + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined(); + expect(clipboardMock.setText).toHaveBeenCalledWith('cd "/tmp/proj-b"'); + }); + + it('keeps native clipboard method context when copying text', async () => { + clipboardMock.setText.mockImplementation(function (this: unknown, text: string): void { + expect(this).toBe(clipboardMock); + expect(text).toBe('cd "/tmp/proj-b"'); + }); + + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined(); + }); + + it('throws an Error when all platform clipboard commands fail', async () => { + clipboardMock.setText = undefined as unknown as ReturnType<typeof vi.fn>; + spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType<typeof spawnSync>); + + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).rejects.toBeInstanceOf(Error); + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).rejects.toThrow( + /(?:clip\.exe|pbcopy|wl-copy|xclip) exited with code 1: missing/, + ); + }); +}); diff --git a/apps/kimi-code/test/utils/git/git-ls-files.test.ts b/apps/kimi-code/test/utils/git/git-ls-files.test.ts deleted file mode 100644 index d7b193a5c..000000000 --- a/apps/kimi-code/test/utils/git/git-ls-files.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync, utimesSync, statSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; - -import { createGitLsFilesCache } from '#/utils/git/git-ls-files'; - -function git(cwd: string, ...args: string[]): void { - execFileSync('git', args, { cwd, stdio: 'ignore' }); -} - -function commit(cwd: string, message: string): void { - git(cwd, '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', message); -} - -describe('createGitLsFilesCache', () => { - let dir: string; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'git-ls-files-test-')); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - it('returns null for a non-git directory', () => { - const cache = createGitLsFilesCache(dir); - expect(cache.isGitRepo()).toBe(false); - expect(cache.list()).toBeNull(); - }); - - it('lists tracked files in a git repo', () => { - git(dir, 'init'); - writeFileSync(join(dir, 'a.ts'), ''); - mkdirSync(join(dir, 'src')); - writeFileSync(join(dir, 'src/b.ts'), ''); - git(dir, 'add', '.'); - commit(dir, 'init'); - - const cache = createGitLsFilesCache(dir); - expect(cache.isGitRepo()).toBe(true); - const snap = cache.getSnapshot(); - expect(snap).not.toBeNull(); - expect(snap!.files).toContain('a.ts'); - expect(snap!.files).toContain('src/b.ts'); - expect(snap!.mtimeByPath.has('a.ts')).toBe(true); - expect(snap!.mtimeByPath.get('a.ts')!).toBeGreaterThan(0); - expect(cache.getSnapshot()).toBe(snap); - }); - - it('includes untracked-but-not-ignored files', () => { - git(dir, 'init'); - writeFileSync(join(dir, '.gitignore'), 'ignored.ts\n'); - writeFileSync(join(dir, 'tracked.ts'), ''); - git(dir, 'add', 'tracked.ts'); - commit(dir, 'init'); - - // Create both an untracked file and one that matches .gitignore. - writeFileSync(join(dir, 'new.ts'), ''); - writeFileSync(join(dir, 'ignored.ts'), ''); - - const cache = createGitLsFilesCache(dir); - const files = cache.list()!; - expect(files).toContain('tracked.ts'); - expect(files).toContain('new.ts'); - expect(files).not.toContain('ignored.ts'); - }); - - it('builds a recency order from recent commits', () => { - git(dir, 'init'); - writeFileSync(join(dir, 'old.ts'), ''); - git(dir, 'add', 'old.ts'); - commit(dir, 'old'); - writeFileSync(join(dir, 'new.ts'), ''); - git(dir, 'add', 'new.ts'); - commit(dir, 'new'); - - const cache = createGitLsFilesCache(dir); - const snap = cache.getSnapshot()!; - const newRank = snap.recencyOrder.get('new.ts'); - const oldRank = snap.recencyOrder.get('old.ts'); - expect(newRank).toBeDefined(); - expect(oldRank).toBeDefined(); - expect(newRank!).toBeLessThan(oldRank!); - }); - - it('invalidates when .git/index mtime changes', () => { - git(dir, 'init'); - writeFileSync(join(dir, 'a.ts'), ''); - git(dir, 'add', '.'); - commit(dir, 'init'); - - const cache = createGitLsFilesCache(dir); - const first = cache.list()!; - expect(first).toContain('a.ts'); - - // Touch .git/index forward to simulate a new commit / add. - const indexPath = join(dir, '.git', 'index'); - const s = statSync(indexPath); - const future = new Date(s.mtimeMs + 5000); - utimesSync(indexPath, future, future); - - writeFileSync(join(dir, 'b.ts'), ''); - git(dir, 'add', 'b.ts'); - - const second = cache.list()!; - expect(second).not.toBe(first); // new snapshot - expect(second).toContain('b.ts'); - }); -}); diff --git a/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts b/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts index 3219cc80d..456ee3007 100644 --- a/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts +++ b/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; +import { resolveKimiCodeOAuthKey } from '@moonshot-ai/kimi-code-oauth'; import { describe, expect, it } from 'vitest'; const REPO_ROOT = join(import.meta.dirname, '../../../..'); @@ -114,12 +115,14 @@ describe('kimi-datasource MCP server', () => { await expect(readFile(blockedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); expect(requests).toEqual([ { + authorization: 'Bearer test-token', method: 'call_data_source_tool', params: { data_source_name: 'world_bank_open_data', api_name: 'world_bank_open_data', params: { filepath: textFile }, }, + url: '/', }, ]); } finally { @@ -129,8 +132,196 @@ describe('kimi-datasource MCP server', () => { await rm(tempDir, { recursive: true, force: true }); } }); + + it('uses env-scoped credentials and derives the datasource URL from KIMI_CODE_BASE_URL', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'kimi-datasource-plugin-')); + const kimiHome = join(tempDir, 'kimi-home'); + const requests: unknown[] = []; + let child: ChildProcessWithoutNullStreams | undefined; + + const server = createServer((request, response) => { + void handleMockDatasourceRequest(request, response, { + requests, + textFile: join(tempDir, 'unused.csv'), + binaryFile: join(tempDir, 'unused_payload.csv'), + blockedFile: join(tempDir, 'blocked.csv'), + }); + }); + + try { + await listen(server); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected an ephemeral TCP port for the test server.'); + } + + const baseUrl = `http://127.0.0.1:${address.port}/coding/v1`; + const oauthHost = 'https://auth.dev.example.test'; + const scopedCredential = kimiCodeEnvCredentialName({ oauthHost, baseUrl }); + + await mkdir(join(kimiHome, 'credentials'), { recursive: true }); + await writeFile( + join(kimiHome, 'credentials', 'kimi-code.json'), + JSON.stringify({ access_token: 'expired-prod-token', expires_at: 1 }), + 'utf8', + ); + await writeFile( + join(kimiHome, 'credentials', `${scopedCredential}.json`), + JSON.stringify({ access_token: 'scoped-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + KIMI_CODE_HOME: kimiHome, + KIMI_CODE_BASE_URL: baseUrl, + KIMI_CODE_OAUTH_HOST: oauthHost, + KIMI_DATASOURCE_API_URL: undefined, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/call', { + name: 'get_data_source_desc', + arguments: { + name: 'arxiv', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining('assistant complete result'), + }, + ], + }); + expect(requests).toEqual([ + { + authorization: 'Bearer scoped-token', + method: 'get_data_source_desc', + params: { name: 'arxiv' }, + url: '/coding/v1/tools', + }, + ]); + } finally { + child?.stdin.end(); + child?.kill(); + await closeServer(server); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('registers yuandian_law in the get_data_source_desc enum', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'kimi-datasource-plugin-')); + const kimiHome = join(tempDir, 'kimi-home'); + let child: ChildProcessWithoutNullStreams | undefined; + + try { + await mkdir(join(kimiHome, 'credentials'), { recursive: true }); + await writeFile( + join(kimiHome, 'credentials', 'kimi-code.json'), + JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { ...process.env, KIMI_CODE_HOME: kimiHome }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/list', {}); + + const tools = ( + result.result as { + tools: Array<{ name: string; inputSchema: { properties: { name: { enum: string[] } } } }>; + } + ).tools; + const desc = tools.find((tool) => tool.name === 'get_data_source_desc'); + expect(desc?.inputSchema.properties.name.enum).toContain('yuandian_law'); + } finally { + child?.stdin.end(); + child?.kill(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('appends a request-id / tool-call-id trace line to tool results', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'kimi-datasource-plugin-')); + const kimiHome = join(tempDir, 'kimi-home'); + let child: ChildProcessWithoutNullStreams | undefined; + + const server = createServer((request, response) => { + request.on('data', () => {}); + request.on('end', () => { + response.setHeader('x-request-id', 'backend-req-test'); + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ is_success: true, result: { assistant: [{ type: 'text', text: 'ok' }] } }), + ); + }); + }); + + try { + await mkdir(join(kimiHome, 'credentials'), { recursive: true }); + await writeFile( + join(kimiHome, 'credentials', 'kimi-code.json'), + JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + await listen(server); + + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected an ephemeral TCP port for the test server.'); + } + + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + KIMI_CODE_HOME: kimiHome, + KIMI_DATASOURCE_API_URL: `http://127.0.0.1:${address.port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/call', { + name: 'get_data_source_desc', + arguments: { name: 'yuandian_law' }, + }); + + const text = (result.result as { content: Array<{ text: string }> }).content[0]!.text; + expect(text).toContain('[kimi-datasource] request-id: backend-req-test · tool-call-id:'); + } finally { + child?.stdin.end(); + child?.kill(); + await closeServer(server); + await rm(tempDir, { recursive: true, force: true }); + } + }); }); +// Pin the expected credential file name to the canonical OAuth-key resolver so +// this test fails if the plugin's standalone digest drifts from the source of +// truth in @moonshot-ai/kimi-code-oauth. The credential file name is the OAuth +// key with its `oauth/` prefix stripped. +function kimiCodeEnvCredentialName(options: { + readonly oauthHost: string; + readonly baseUrl: string; +}): string { + return resolveKimiCodeOAuthKey(options).replace(/^oauth\//, ''); +} + async function readJson(request: IncomingMessage): Promise<unknown> { let body = ''; for await (const chunk of request) { @@ -150,7 +341,11 @@ async function handleMockDatasourceRequest( }, ): Promise<void> { try { - options.requests.push(await readJson(request)); + options.requests.push({ + ...(await readJson(request) as Record<string, unknown>), + authorization: request.headers.authorization, + url: request.url, + }); response.setHeader('Content-Type', 'application/json'); response.end( JSON.stringify({ diff --git a/apps/kimi-code/test/utils/paths.test.ts b/apps/kimi-code/test/utils/paths.test.ts index 5c9d3dc03..3ebeb3711 100644 --- a/apps/kimi-code/test/utils/paths.test.ts +++ b/apps/kimi-code/test/utils/paths.test.ts @@ -4,7 +4,14 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getDataDir, getInputHistoryFile, getLogDir, getUpdateStateFile } from '#/utils/paths'; +import { + getBinDir, + getDataDir, + getInputHistoryFile, + getLogDir, + getUpdateInstallStateFile, + getUpdateStateFile, +} from '#/utils/paths'; const originalEnv = { ...process.env }; @@ -43,6 +50,17 @@ describe('getLogDir', () => { }); }); +describe('getBinDir', () => { + it('returns <dataDir>/bin', () => { + expect(getBinDir()).toBe(join(homedir(), '.kimi-code', 'bin')); + }); + + it('respects KIMI_CODE_HOME', () => { + process.env['KIMI_CODE_HOME'] = '/custom-bin-home'; + expect(getBinDir()).toBe(join('/custom-bin-home', 'bin')); + }); +}); + describe('getUpdateStateFile', () => { it('returns <dataDir>/updates/latest.json', () => { expect(getUpdateStateFile()).toBe(join(homedir(), '.kimi-code', 'updates', 'latest.json')); @@ -54,6 +72,19 @@ describe('getUpdateStateFile', () => { }); }); +describe('getUpdateInstallStateFile', () => { + it('returns <dataDir>/updates/install.json', () => { + expect(getUpdateInstallStateFile()).toBe( + join(homedir(), '.kimi-code', 'updates', 'install.json'), + ); + }); + + it('respects KIMI_CODE_HOME', () => { + process.env['KIMI_CODE_HOME'] = '/updates-home'; + expect(getUpdateInstallStateFile()).toBe(join('/updates-home', 'updates', 'install.json')); + }); +}); + describe('getInputHistoryFile', () => { it('returns <dataDir>/user-history/<md5(workDir)>.jsonl', () => { const workDir = '/home/user/project'; diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 0df71a811..9c220b868 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -5,11 +5,62 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; -import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; -import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { + KIMI_CODE_PLUGIN_MARKETPLACE_URL, + KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, +} from '#/constant/app'; +import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../..'); +describe('computeUpdateStatus', () => { + it('reports not-installed when the plugin is absent', () => { + expect(computeUpdateStatus('1.0.0', undefined, false)).toEqual({ kind: 'not-installed' }); + }); + + it('reports an update when the marketplace version is newer', () => { + expect(computeUpdateStatus('5.1.0', '5.0.0', true)).toEqual({ + kind: 'update', + local: '5.0.0', + latest: '5.1.0', + }); + }); + + it('reports up-to-date when versions match', () => { + expect(computeUpdateStatus('5.1.0', '5.1.0', true)).toEqual({ + kind: 'up-to-date', + version: '5.1.0', + }); + }); + + it('does not offer a downgrade when the local version is ahead', () => { + expect(computeUpdateStatus('3.1.1', '3.2.0', true)).toEqual({ + kind: 'up-to-date', + version: '3.2.0', + }); + }); + + it('never reports an update for non-semver versions', () => { + expect(computeUpdateStatus('latest', '5.0.0', true).kind).toBe('up-to-date'); + expect(computeUpdateStatus('5.1.0', 'dev', true).kind).toBe('up-to-date'); + }); + + it('shows the local version even when the marketplace omits one', () => { + expect(computeUpdateStatus(undefined, '5.0.0', true)).toEqual({ + kind: 'up-to-date', + version: '5.0.0', + }); + }); + + it('does not claim the marketplace version as installed when the local version is unknown', () => { + // No spurious `installed · v<latest>`, and no permanent suppression of updates. + expect(computeUpdateStatus('5.1.0', undefined, true)).toEqual({ + kind: 'up-to-date', + version: undefined, + }); + }); +}); + describe('loadPluginMarketplace', () => { it('loads a local marketplace file and resolves relative plugin sources', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); @@ -74,9 +125,22 @@ describe('loadPluginMarketplace', () => { }); it('includes Superpowers in the repository marketplace fixture', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/releases/latest')) { + return { + status: 302, + headers: new Headers({ + location: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + }), + } as Response; + } + return { status: 404, headers: new Headers() } as Response; + }) as unknown as typeof fetch; const marketplace = await loadPluginMarketplace({ workDir: REPO_ROOT, source: join(REPO_ROOT, 'plugins/marketplace.json'), + fetchImpl, }); expect(marketplace.plugins).toContainEqual( @@ -84,7 +148,8 @@ describe('loadPluginMarketplace', () => { id: 'superpowers', displayName: 'Superpowers', tier: 'curated', - source: join(REPO_ROOT, 'plugins/curated/superpowers'), + source: 'https://github.com/obra/superpowers', + version: '6.0.3', }), ); expect(marketplace.plugins).toContainEqual( @@ -131,6 +196,247 @@ describe('loadPluginMarketplace', () => { ); }); + it('falls back to the source checkout marketplace when the default CDN cannot be fetched', async () => { + const previous = process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + delete process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + const fetchImpl = vi.fn(async () => { + throw new Error('fetch failed'); + }) as unknown as typeof fetch; + + try { + const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', fetchImpl }); + + expect(fetchImpl).toHaveBeenCalledWith(KIMI_CODE_PLUGIN_MARKETPLACE_URL); + expect(marketplace.source).toBe(join(REPO_ROOT, 'plugins/marketplace.json')); + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'superpowers', + source: 'https://github.com/obra/superpowers', + }), + ); + } finally { + if (previous === undefined) { + delete process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + } else { + process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] = previous; + } + } + }); + + it('does not use the source checkout fallback for explicit marketplace sources', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('fetch failed'); + }) as unknown as typeof fetch; + + await expect(loadPluginMarketplace({ + workDir: '/tmp/work', + source: KIMI_CODE_PLUGIN_MARKETPLACE_URL, + fetchImpl, + })).rejects.toThrow(/fetch failed/); + }); + + describe('version derivation from a GitHub source', () => { + async function loadEntry(source: string, version?: string) { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + source, + version, + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file }); + return marketplace.plugins[0]!; + } + + it('derives a version from a /releases/tag/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('derives a version from a /tree/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/tree/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('accepts a tag without a leading v', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('does not derive a version from a commit SHA', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/commit/abc1234'); + expect(entry.version).toBeUndefined(); + }); + + it('does not derive a version from a non-GitHub URL', async () => { + const entry = await loadEntry('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip'); + expect(entry.version).toBeUndefined(); + }); + + it('lets an explicit version override the derived one', async () => { + const entry = await loadEntry( + 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + '9.9.9', + ); + expect(entry.version).toBe('9.9.9'); + }); + }); + + describe('latest release resolution for bare GitHub sources', () => { + async function loadWithLatest(source: string, fetchImpl: typeof fetch) { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ plugins: [{ id: 'demo', displayName: 'Demo', source }] }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + return marketplace.plugins[0]!; + } + + function redirectFetch(location: string): typeof fetch { + return vi.fn(async () => ({ + status: 302, + headers: new Headers({ location }), + })) as unknown as typeof fetch; + } + + it('fills the version from /releases/latest for a bare repo URL', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/v6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); + }); + + it('strips a leading v from the resolved latest tag', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); + }); + + it('leaves version undefined when the repo has no release', async () => { + const fetchImpl = vi.fn(async () => ({ + status: 404, + headers: new Headers(), + })) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); + }); + + it('degrades gracefully when the latest lookup throws', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); + }); + + it('does not query latest when the source already pins a ref', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest( + 'https://github.com/owner/repo/releases/tag/v6.0.3', + fetchImpl, + ); + expect(entry.version).toBe('6.0.3'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('keeps an explicit version without querying latest', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + version: '9.9.9', + source: 'https://github.com/owner/repo', + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + expect(marketplace.plugins[0]?.version).toBe('9.9.9'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + }); + + it('accepts legacy marketplace type aliases as normal plugins', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'kimi-webbridge', + type: 'guide', + displayName: 'Kimi WebBridge', + source: './kimi-webbridge', + installSkill: 'install', + removeSkill: 'remove', + }, + { + id: 'demo-managed', + type: 'managed', + source: './demo-managed', + }, + ], + }), + 'utf8', + ); + + const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source: file }); + + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'kimi-webbridge', + source: join(dir, 'kimi-webbridge'), + }), + ); + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'demo-managed', + source: join(dir, 'demo-managed'), + }), + ); + }); + + it('rejects an entry without a source', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ plugins: [{ id: 'broken', displayName: 'Broken' }] }), + 'utf8', + ); + + await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow( + /must define "source"/, + ); + }); + it('loads an explicit remote marketplace with injectable fetch', async () => { const source = 'https://example.com/plugins/marketplace.json'; const fetchImpl = vi.fn(async () => ({ @@ -179,4 +485,21 @@ describe('loadPluginMarketplace', () => { /"tier" must be one of/, ); }); + + it('rejects unknown marketplace entry types', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [{ id: 'demo', type: 'integration', source: './demo' }], + }), + 'utf8', + ); + + await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow( + /Legacy aliases "managed" and "guide" are also accepted/, + ); + }); + }); diff --git a/apps/kimi-code/test/utils/process/external-editor.test.ts b/apps/kimi-code/test/utils/process/external-editor.test.ts index 3e135d019..e3c8ce699 100644 --- a/apps/kimi-code/test/utils/process/external-editor.test.ts +++ b/apps/kimi-code/test/utils/process/external-editor.test.ts @@ -27,12 +27,6 @@ vi.mock('node:fs/promises', async () => { import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; -function shellPath(cmd: string): string { - const match = cmd.match(/'([^']+)'$/); - if (!match) throw new Error(`Could not parse temp path from: ${cmd}`); - return match[1]!; -} - afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); @@ -50,9 +44,12 @@ describe('external-editor helpers', () => { }); it('returns the edited contents on success and cleans up the temp directory', async () => { - mocks.spawn.mockImplementation((_cmd: string, args: string[]) => { + mocks.spawn.mockImplementation((cmd: string, _opts: Record<string, unknown>) => { const child = new EventEmitter(); - void writeFile(shellPath(args[1]!), 'edited text', 'utf8').then(() => { + // Extract the file path from the shell command (last argument after quoting). + const match = cmd.match(/'([^']+prompt\.md)'/) || cmd.match(/"([^"]+prompt\.md)"/); + const tmpFile = match![1]!; + void writeFile(tmpFile, 'edited text', 'utf8').then(() => { child.emit('exit', 0); }); return child as never; @@ -60,9 +57,8 @@ describe('external-editor helpers', () => { await expect(editInExternalEditor('seed', 'code --wait')).resolves.toBe('edited text'); expect(mocks.spawn).toHaveBeenCalledWith( - '/bin/sh', - ['-c', expect.stringMatching(/^code --wait /)], - { stdio: 'inherit' }, + expect.stringContaining('code --wait'), + { stdio: 'inherit', shell: true }, ); expect(mocks.rmCalls).toHaveBeenCalled(); }); diff --git a/apps/kimi-code/test/utils/process/fd-detect.test.ts b/apps/kimi-code/test/utils/process/fd-detect.test.ts new file mode 100644 index 000000000..cd6fd249c --- /dev/null +++ b/apps/kimi-code/test/utils/process/fd-detect.test.ts @@ -0,0 +1,63 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { detectFdPath, getFdAssetName } from '#/utils/process/fd-detect'; +import { getBinDir } from '#/utils/paths'; + +const originalEnv = { ...process.env }; +let tempHome: string | undefined; + +afterEach(() => { + if (tempHome !== undefined) { + rmSync(tempHome, { recursive: true, force: true }); + tempHome = undefined; + } + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); +}); + +describe('getFdAssetName', () => { + it('returns the macOS arm64 asset name', () => { + expect(getFdAssetName('darwin', 'arm64')).toBe('fd-v10.4.2-aarch64-apple-darwin.tar.gz'); + }); + + it('returns the macOS x64 asset name pinned to the available upstream release', () => { + expect(getFdAssetName('darwin', 'x64')).toBe('fd-v10.3.0-x86_64-apple-darwin.tar.gz'); + }); + + it('returns the Linux x64 musl asset name', () => { + expect(getFdAssetName('linux', 'x64')).toBe('fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz'); + }); + + it('returns the Windows x64 asset name', () => { + expect(getFdAssetName('win32', 'x64')).toBe('fd-v10.4.2-x86_64-pc-windows-msvc.zip'); + }); + + it('returns null for unsupported platforms or architectures', () => { + expect(getFdAssetName('freebsd', 'x64')).toBeNull(); + expect(getFdAssetName('darwin', 'arm')).toBeNull(); + }); +}); + +describe('detectFdPath', () => { + it('prefers the managed fd binary under KIMI_CODE_HOME', () => { + tempHome = mkdtempSync(join(tmpdir(), 'kimi-fd-home-')); + process.env['KIMI_CODE_HOME'] = tempHome; + mkdirSync(getBinDir(), { recursive: true }); + + const binaryPath = join(getBinDir(), process.platform === 'win32' ? 'fd.exe' : 'fd'); + if (process.platform === 'win32') { + // Creating a real Windows PE executable in a unit test is not practical; + // the asset-name tests still cover Windows selection logic. + return; + } + + writeFileSync(binaryPath, '#!/bin/sh\necho fd 10.4.2\n'); + chmodSync(binaryPath, 0o755); + + expect(detectFdPath()).toBe(binaryPath); + }); +}); diff --git a/apps/kimi-code/test/utils/usage/debug-timing.test.ts b/apps/kimi-code/test/utils/usage/debug-timing.test.ts index b986f08e6..353b10ee5 100644 --- a/apps/kimi-code/test/utils/usage/debug-timing.test.ts +++ b/apps/kimi-code/test/utils/usage/debug-timing.test.ts @@ -27,6 +27,58 @@ describe('formatStepDebugTiming', () => { expect(result).toBe('[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s)'); }); + it('formats input tokens and cache read/write counts', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + usage: { + inputOther: 700, + inputCacheRead: 1200, + inputCacheCreation: 100, + output: 200, + }, + }); + expect(result).toBe( + '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2.0k | cache read 1.2k (60%) / write 100', + ); + }); + + it('omits cache write count when it is zero', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + usage: { + inputOther: 1000, + inputCacheRead: 0, + inputCacheCreation: 0, + output: 200, + }, + }); + expect(result).toContain('tokens in 1.0k'); + expect(result).toContain('cache read 0 (0%)'); + expect(result).not.toContain('/ write 0'); + }); + + it('omits TPS when the streamed window is too short to measure', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 1200, + llmStreamDurationMs: 1, + usage: { output: 44 }, + }); + expect(result).toBe( + '[Debug] TTFT: 1.2s | 44 tokens in 1ms (stream too short for TPS)', + ); + }); + + it('computes TPS once the streamed window reaches the reliability threshold', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 200, + llmStreamDurationMs: 50, + usage: { output: 20 }, + }); + expect(result).toBe('[Debug] TTFT: 200ms | TPS: 400.0 tok/s (20 tokens in 50ms)'); + }); + it('formats durations under 1s as milliseconds', () => { const result = formatStepDebugTiming({ llmFirstTokenLatencyMs: 50, @@ -37,6 +89,52 @@ describe('formatStepDebugTiming', () => { expect(result).toContain('900ms'); }); + it('splits TTFT into api-server and client portions when both are present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 2500, + llmStreamDurationMs: 5000, + llmServerFirstTokenMs: 2400, + llmRequestBuildMs: 100, + usage: { output: 200 }, + }); + expect(result).toBe( + '[Debug] TTFT: 2.5s (api 2.4s + client 100ms) | TPS: 40.0 tok/s (200 tokens in 5.0s)', + ); + }); + + it('falls back to the bare TTFT when only one split component is present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerFirstTokenMs: 700, + usage: { output: 0 }, + }); + expect(result).toBe('[Debug] TTFT: 800ms'); + }); + + it('appends the decode wait/consume split to the TPS clause', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerDecodeMs: 4600, + llmClientConsumeMs: 400, + usage: { output: 200 }, + }); + expect(result).toBe( + '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s; server 4.6s + client 400ms)', + ); + }); + + it('omits the decode split when only one component is present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerDecodeMs: 4600, + usage: { output: 200 }, + }); + expect(result).toBe('[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s)'); + }); + it('formats durations at or above 1s as seconds', () => { const result = formatStepDebugTiming({ llmFirstTokenLatencyMs: 1500, diff --git a/apps/kimi-code/tsconfig.dev.json b/apps/kimi-code/tsconfig.dev.json new file mode 100644 index 000000000..9e4df279a --- /dev/null +++ b/apps/kimi-code/tsconfig.dev.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + }, + "include": [ + "src", + "test", + "../../packages/*/src/**/*.ts", + "../../packages/*/src/**/*.tsx", + "../../packages/*/test/**/*.ts", + "../../packages/agent-core/src/prompt-modules.d.ts" + ] +} diff --git a/apps/kimi-code/tsconfig.json b/apps/kimi-code/tsconfig.json index ea6828176..10388dd08 100644 --- a/apps/kimi-code/tsconfig.json +++ b/apps/kimi-code/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "allowJs": true, + "experimentalDecorators": true, "paths": { "@/*": ["./src/*"] } diff --git a/apps/kimi-code/tsdown.config.ts b/apps/kimi-code/tsdown.config.ts index 918b62199..858aeeb48 100644 --- a/apps/kimi-code/tsdown.config.ts +++ b/apps/kimi-code/tsdown.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ format: ['esm'], outDir: 'dist', clean: true, + dts: false, + hash: false, banner: { js: [ '#!/usr/bin/env node', @@ -29,7 +31,10 @@ export default defineConfig({ [BUILT_IN_CATALOG_DEFINE]: builtInCatalogDefine(), }, deps: { - alwaysBundle: [/^@moonshot-ai\//], - neverBundle: [], + onlyBundle: false, + }, + outputOptions: { + codeSplitting: false, + entryFileNames: 'main.mjs', }, }); diff --git a/apps/kimi-code/tsdown.native.config.ts b/apps/kimi-code/tsdown.native.config.ts index bf4e16fe9..c1008cb61 100644 --- a/apps/kimi-code/tsdown.native.config.ts +++ b/apps/kimi-code/tsdown.native.config.ts @@ -21,6 +21,9 @@ const optionalNativeDependencies = new Set(['cpu-features']); function shouldAlwaysBundle(id: string): boolean { if (builtins.has(id) || id.startsWith('node:')) return false; if (optionalNativeDependencies.has(id)) return false; + // Everything else is force-bundled, which covers `@moonshot-ai/*` (incl. + // vis-server for `kimi vis`) plus its transitive `hono` / `@hono/node-server` + // — so the SEA bundle is self-contained (check-bundle.mjs enforces this). return true; } diff --git a/apps/kimi-code/vitest.config.ts b/apps/kimi-code/vitest.config.ts index 49cedd3dd..350417bc2 100644 --- a/apps/kimi-code/vitest.config.ts +++ b/apps/kimi-code/vitest.config.ts @@ -2,12 +2,9 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; -import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; - const appRoot = import.meta.dirname; export default defineConfig({ - plugins: [rawTextPlugin()], resolve: { alias: { '@': resolve(appRoot, 'src'), @@ -15,6 +12,9 @@ export default defineConfig({ }, test: { name: 'cli', + env: { + KIMI_LOG_LEVEL: 'off', + }, include: ['test/**/*.test.ts', 'test/**/*.test.tsx'], }, }); diff --git a/apps/kimi-desktop/.gitignore b/apps/kimi-desktop/.gitignore new file mode 100644 index 000000000..66de381f3 --- /dev/null +++ b/apps/kimi-desktop/.gitignore @@ -0,0 +1,3 @@ +out/ +dist-app/ +resources-stage/ diff --git a/apps/kimi-desktop/README.md b/apps/kimi-desktop/README.md new file mode 100644 index 000000000..32ca3ee64 --- /dev/null +++ b/apps/kimi-desktop/README.md @@ -0,0 +1,107 @@ +# Kimi Code Desktop + +An Electron desktop client for Kimi Code (product name **Kimi Code Desktop**; +workspace package `@moonshot-ai/kimi-desktop`). It is a thin **shell + process manager** +around the existing web UI (`apps/kimi-web`): it does not reimplement any UI or +backend, it just opens a native window onto the local Kimi server. + +## How it works + +The web UI cannot run on its own — it needs the Kimi Code **server** (REST + WS +under `/api/v1`). That server already ships as a self-contained single-file +executable (SEA) built from `apps/kimi-code`, with the web UI bundled inside it. + +On launch the app: + +1. Runs the bundled SEA's `server run`, which reuses a live shared daemon if one + is already running, or starts one — exactly the same `ensureDaemon` flow the + CLI (`kimi web`) uses. The daemon binds the well-known port (`58627`) and + writes `~/.kimi-code/server/lock`, so the CLI, the browser and the TUI all + share the **same** server. +2. Reads that lock file for the real port and loads the web UI from the daemon's + origin (e.g. `http://127.0.0.1:58627`) — same-origin, no CORS, no preload. + +On quit the daemon is **left running**; it self-exits ~60s after the last client +disconnects, so closing the desktop app never tears down a server another client +is still using. + +Key files: + +- `src/main/ensure-server.ts` — run the SEA, read the lock, confirm `/healthz`. +- `src/main/sea-path.ts` — resolve the bundled SEA path (dev vs packaged). +- `src/main/index.ts` — window, native menu, window-state, loading/error screens. + +## Develop + +The dev build loads the SEA from `apps/kimi-code/dist-native/bin/<target>/`, so +build the backend once for your platform first: + +```bash +# one-time (rebuild when kimi-code / kimi-web change): +pnpm --filter @moonshot-ai/kimi-web run build +node apps/kimi-code/scripts/copy-web-assets.mjs +pnpm --filter @moonshot-ai/kimi-code run build:native:sea + +# then run the desktop app (builds the main process, launches Electron): +pnpm -C apps/kimi-desktop run dev # or: pnpm dev:desktop (from repo root) +``` + +Checks: + +```bash +pnpm -C apps/kimi-desktop run typecheck +``` + +## Package + +`dist` builds the main process and runs electron-builder for the **current** +platform. `scripts/before-pack.cjs` stages the matching-platform SEA into the +app's resources (`<resources>/bin/<target>/`). + +```bash +# unsigned local build (for your own machine): +CSC_IDENTITY_AUTO_DISCOVERY=false pnpm -C apps/kimi-desktop run dist +# -> apps/kimi-desktop/dist-app/ +``` + +> Do **not** rename a built `.app` bundle — renaming invalidates its code +> signature and macOS will report it as "damaged". + +Cross-platform installers are produced in CI (`.github/workflows/desktop-build.yml`), +which builds the SEA on each platform runner and packages there. SEA injection +is per-platform (the blob is injected into the host Node binary), so each OS must +be built on its own runner. + +### macOS signing + notarization + +An **unsigned** macOS build shows *"app is damaged and can't be opened"* once it +has been transferred to another Mac (Gatekeeper quarantine). To distribute it, +the app must be signed with a **Developer ID Application** certificate and +notarized by Apple. The config (`electron-builder.config.cjs`) applies the +hardened runtime + entitlements (`build/entitlements.mac.plist`) to the app and +the nested SEA, and signing/notarization are environment-driven: + +```bash +KIMI_DESKTOP_NOTARIZE=true \ +CSC_NAME="Developer ID Application: … (TEAMID)" \ +APPLE_API_KEY=/path/AuthKey_XXX.p8 APPLE_API_KEY_ID=XXXX APPLE_API_ISSUER=…uuid… \ +pnpm -C apps/kimi-desktop run dist +``` + +In CI, run the **desktop-build** workflow with `sign-macos: true`; it reuses the +same Apple secrets / keychain action as the TUI native build +(`APPLE_CERTIFICATE_P12`, `APPLE_NOTARIZATION_KEY_*`). The resulting `.dmg` opens +on any Mac without warnings. + +> An `Apple Development` certificate is **not** enough — it can sign for your own +> machine but cannot be notarized. You need a `Developer ID Application` cert. + +## v1 scope / not done yet + +- **Auto-update**: not implemented (v2). +- **Windows / Linux signing**: unsigned in v1 (Windows shows a SmartScreen + prompt). Only macOS is signed + notarized. +- **App icon**: builds ship the Kimi logo (sourced from the docs site art) on + macOS, Windows, and Linux. +- **First launch may need network**: the SEA resolves its native sidecars + (clipboard / koffi) the same way the installed CLI does. diff --git a/apps/kimi-desktop/build/entitlements.mac.plist b/apps/kimi-desktop/build/entitlements.mac.plist new file mode 100644 index 000000000..949a28088 --- /dev/null +++ b/apps/kimi-desktop/build/entitlements.mac.plist @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" + "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <!-- Electron/V8 and the bundled Node SEA both need JIT pages. --> + <key>com.apple.security.cs.allow-jit</key> + <true/> + + <!-- V8 writes to executable memory during codegen. --> + <key>com.apple.security.cs.allow-unsigned-executable-memory</key> + <true/> + + <!-- The bundled server dlopen's koffi.node / clipboard.node at runtime; + they are third-party and not signed by our Team. Without this the + hardened runtime rejects them. (Same as the TUI's native build.) --> + <key>com.apple.security.cs.disable-library-validation</key> + <true/> + + <!-- Some users set NODE_OPTIONS / DYLD_* envs; allow them. --> + <key>com.apple.security.cs.allow-dyld-environment-variables</key> + <true/> +</dict> +</plist> diff --git a/apps/kimi-desktop/build/icon.icns b/apps/kimi-desktop/build/icon.icns new file mode 100644 index 000000000..2c9de6605 Binary files /dev/null and b/apps/kimi-desktop/build/icon.icns differ diff --git a/apps/kimi-desktop/build/icon.ico b/apps/kimi-desktop/build/icon.ico new file mode 100644 index 000000000..c4c168ba1 Binary files /dev/null and b/apps/kimi-desktop/build/icon.ico differ diff --git a/apps/kimi-desktop/build/icon.png b/apps/kimi-desktop/build/icon.png new file mode 100644 index 000000000..5b41bb609 Binary files /dev/null and b/apps/kimi-desktop/build/icon.png differ diff --git a/apps/kimi-desktop/electron-builder.config.cjs b/apps/kimi-desktop/electron-builder.config.cjs new file mode 100644 index 000000000..a0e80ee4b --- /dev/null +++ b/apps/kimi-desktop/electron-builder.config.cjs @@ -0,0 +1,80 @@ +'use strict'; + +// electron-builder configuration. +// +// Signing / notarization are environment-driven so the same config produces +// either an unsigned local build or a fully signed + notarized distributable: +// +// unsigned (default / local): +// CSC_IDENTITY_AUTO_DISCOVERY=false -> no signing, no notarization +// +// signed + notarized (CI, with a Developer ID cert in the keychain): +// KIMI_DESKTOP_NOTARIZE=true +// APPLE_API_KEY=<path to .p8> APPLE_API_KEY_ID=<id> APPLE_API_ISSUER=<id> +// +// The entitlements (hardened runtime) are applied to the app AND every nested +// Mach-O — including the bundled Kimi SEA backend — via entitlementsInherit, so +// the whole bundle passes notarization. Mirrors the TUI's native entitlements. + +const notarize = process.env.KIMI_DESKTOP_NOTARIZE === 'true'; + +// Internal-testing artifact name: +// KCD-beta-alpha-crazy-internal-v50-<arch>-<MMDD>.<ext> +// The date is MMDD in UTC+8, computed at build time. `v50` is a fixed label +// (not a version number) — edit it here to bump the internal build label. +function mmddUTC8() { + const utc8 = new Date(Date.now() + 8 * 60 * 60 * 1000); + const mm = String(utc8.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(utc8.getUTCDate()).padStart(2, '0'); + return mm + dd; +} +const artifactName = 'KCD-beta-alpha-crazy-internal-v50-${arch}-' + mmddUTC8() + '.${ext}'; + +module.exports = { + appId: 'ai.moonshot.kimi.desktop', + productName: 'Kimi Code Desktop', + copyright: 'Copyright © Moonshot AI', + + directories: { + output: 'dist-app', + }, + + // No native node modules in the Electron app itself; the backend is the + // prebuilt SEA staged by before-pack.cjs. + npmRebuild: false, + asar: true, + + files: ['out/**', 'package.json'], + + beforePack: './scripts/before-pack.cjs', + extraResources: [{ from: 'resources-stage/bin', to: 'bin' }], + + mac: { + category: 'public.app-category.developer-tools', + hardenedRuntime: true, + gatekeeperAssess: false, + entitlements: 'build/entitlements.mac.plist', + entitlementsInherit: 'build/entitlements.mac.plist', + target: ['dmg', 'zip'], + artifactName, + notarize, + }, + + win: { + target: ['nsis'], + artifactName, + }, + + nsis: { + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + }, + + linux: { + category: 'Development', + target: ['AppImage', 'deb'], + artifactName, + maintainer: 'Moonshot AI', + }, +}; diff --git a/apps/kimi-desktop/package.json b/apps/kimi-desktop/package.json new file mode 100644 index 000000000..6501c6ea6 --- /dev/null +++ b/apps/kimi-desktop/package.json @@ -0,0 +1,24 @@ +{ + "name": "@moonshot-ai/kimi-desktop", + "version": "0.1.1-internal.0", + "private": true, + "license": "MIT", + "description": "Kimi Code desktop client — an Electron shell around the Kimi web UI.", + "author": "Moonshot AI", + "homepage": "https://github.com/MoonshotAI/kimi-code", + "type": "module", + "main": "out/main.cjs", + "scripts": { + "build": "tsdown", + "start": "electron .", + "dev": "tsdown && electron .", + "typecheck": "tsc --noEmit", + "dist": "tsdown && electron-builder --config electron-builder.config.cjs" + }, + "devDependencies": { + "electron": "33.4.11", + "electron-builder": "25.1.8", + "tsdown": "0.22.0", + "typescript": "6.0.2" + } +} diff --git a/apps/kimi-desktop/scripts/before-pack.cjs b/apps/kimi-desktop/scripts/before-pack.cjs new file mode 100644 index 000000000..3b28402ed --- /dev/null +++ b/apps/kimi-desktop/scripts/before-pack.cjs @@ -0,0 +1,42 @@ +'use strict'; + +// electron-builder `beforePack` hook. +// +// Each electron-builder run targets one (platform, arch). We stage the matching +// prebuilt Kimi SEA backend into `resources-stage/bin/<target>/` so that the +// `extraResources` rule copies exactly that one binary into the packaged app's +// resources. sea-path.ts resolves `<resources>/bin/<target>/kimi[.exe]` at +// runtime, where <target> is `${process.platform}-${process.arch}`. + +const { existsSync, rmSync, mkdirSync, cpSync } = require('node:fs'); +const { join, resolve } = require('node:path'); + +// electron-builder Arch enum -> Node `process.arch` name. +const ARCH_NAMES = { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }; + +exports.default = async function beforePack(context) { + const platform = context.electronPlatformName; // 'darwin' | 'win32' | 'linux' + const archName = ARCH_NAMES[context.arch]; + if (archName === undefined) { + throw new Error(`Unsupported arch for packaging: ${String(context.arch)}`); + } + const target = `${platform}-${archName}`; + const exe = platform === 'win32' ? 'kimi.exe' : 'kimi'; + + const desktopRoot = resolve(__dirname, '..'); + const seaDir = resolve(desktopRoot, '..', 'kimi-code', 'dist-native', 'bin', target); + const seaExe = join(seaDir, exe); + if (!existsSync(seaExe)) { + throw new Error( + `Bundled Kimi server not found for ${target} at ${seaExe}. ` + + `Build it for this platform first: \`pnpm -C apps/kimi-code build:native:sea\` ` + + `(CI builds the SEA on each platform runner before packaging).`, + ); + } + + const stageDir = resolve(desktopRoot, 'resources-stage', 'bin', target); + rmSync(resolve(desktopRoot, 'resources-stage'), { recursive: true, force: true }); + mkdirSync(stageDir, { recursive: true }); + cpSync(seaDir, stageDir, { recursive: true }); + console.log(`[before-pack] staged Kimi server (${target}) -> ${stageDir}`); +}; diff --git a/apps/kimi-desktop/src/main/ensure-server.ts b/apps/kimi-desktop/src/main/ensure-server.ts new file mode 100644 index 000000000..075ae3606 --- /dev/null +++ b/apps/kimi-desktop/src/main/ensure-server.ts @@ -0,0 +1,129 @@ +import { execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** Overall budget for the bundled `kimi server run` to finish ensuring a daemon. */ +const RUN_TIMEOUT_MS = 30_000; +/** How long to keep polling `/healthz` before declaring the daemon unhealthy. */ +const HEALTH_TIMEOUT_MS = 20_000; +const HEALTH_POLL_MS = 200; + +/** Subset of the server lock JSON we read (apps/kimi-code writes the full shape). */ +interface LockContents { + pid: number; + host?: string; + port: number; +} + +/** `<KIMI_CODE_HOME>` or `~/.kimi-code` — must match the server's `resolveKimiHome`. */ +export function kimiHome(): string { + const override = process.env['KIMI_CODE_HOME']; + if (override !== undefined && override.trim().length > 0) { + return override; + } + return join(homedir(), '.kimi-code'); +} + +function lockPath(): string { + return join(kimiHome(), 'server', 'lock'); +} + +/** Background daemon log written by the SEA — surfaced in the error screen / menu. */ +export function serverLogPath(): string { + return join(kimiHome(), 'server', 'server.log'); +} + +function readLock(): LockContents | null { + try { + const parsed = JSON.parse(readFileSync(lockPath(), 'utf-8')) as Partial<LockContents>; + if (typeof parsed.port === 'number' && typeof parsed.pid === 'number') { + return { + pid: parsed.pid, + port: parsed.port, + host: typeof parsed.host === 'string' ? parsed.host : undefined, + }; + } + return null; + } catch { + return null; + } +} + +function originFromLock(lock: LockContents): string { + const host = lock.host !== undefined && lock.host !== '0.0.0.0' ? lock.host : '127.0.0.1'; + return `http://${host}:${lock.port}`; +} + +async function isHealthy(origin: string, timeoutMs: number): Promise<boolean> { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + try { + const res = await fetch(`${origin}/api/v1/healthz`, { signal: controller.signal }); + if (!res.ok) { + return false; + } + const body = (await res.json()) as { code?: unknown }; + return body.code === 0; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +/** + * Run the bundled SEA's `server run`, which reuses a live shared daemon or + * spawns one and exits once it is healthy. All discovery / port / lock logic + * lives in apps/kimi-code's `ensureDaemon`; we do not reimplement it. + */ +function runServerRun(seaPath: string): Promise<void> { + return new Promise((resolve, reject) => { + execFile( + seaPath, + ['server', 'run', '--log-level', 'error'], + { timeout: RUN_TIMEOUT_MS }, + (error, _stdout, stderr) => { + if (error) { + reject(new Error(`kimi server run failed: ${error.message}\n${stderr}`.trim())); + return; + } + resolve(); + }, + ); + }); +} + +export interface EnsureServerResult { + origin: string; +} + +/** + * Ensure the shared kimi-code daemon is running and return its origin. + * + * The desktop app participates in the same local-server ecosystem as the CLI, + * the browser and the TUI: it reuses a running daemon or starts one that the + * others can reuse — never a private, app-only server. + */ +export async function ensureServer(seaPath: string): Promise<EnsureServerResult> { + await runServerRun(seaPath); + + const lock = readLock(); + if (lock === null) { + throw new Error(`Kimi server lock not found at ${lockPath()} after starting the server.`); + } + const origin = originFromLock(lock); + + const deadline = Date.now() + HEALTH_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await isHealthy(origin, 500)) { + return { origin }; + } + await new Promise((resolve) => { + setTimeout(resolve, HEALTH_POLL_MS); + }); + } + throw new Error(`Kimi server at ${origin} did not become healthy within ${HEALTH_TIMEOUT_MS}ms.`); +} diff --git a/apps/kimi-desktop/src/main/index.ts b/apps/kimi-desktop/src/main/index.ts new file mode 100644 index 000000000..2a765274b --- /dev/null +++ b/apps/kimi-desktop/src/main/index.ts @@ -0,0 +1,316 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { app, BrowserWindow, Menu, nativeTheme, shell } from 'electron'; +import type { MenuItemConstructorOptions } from 'electron'; + +import { ensureServer, kimiHome, serverLogPath } from './ensure-server'; +import { resolveSeaPath } from './sea-path'; + +let mainWindow: BrowserWindow | null = null; + +// --- window state persistence ------------------------------------------------- + +interface WindowBounds { + width: number; + height: number; + x?: number; + y?: number; +} + +const DEFAULT_BOUNDS: WindowBounds = { width: 1280, height: 860 }; + +function stateFile(): string { + return join(app.getPath('userData'), 'window-state.json'); +} + +function loadBounds(): WindowBounds { + try { + const parsed = JSON.parse(readFileSync(stateFile(), 'utf-8')) as Partial<WindowBounds>; + if (typeof parsed.width === 'number' && typeof parsed.height === 'number') { + return { + width: parsed.width, + height: parsed.height, + x: typeof parsed.x === 'number' ? parsed.x : undefined, + y: typeof parsed.y === 'number' ? parsed.y : undefined, + }; + } + } catch { + // No saved state yet, or it is unreadable — fall back to defaults. + } + return DEFAULT_BOUNDS; +} + +function saveBounds(win: BrowserWindow): void { + try { + const bounds = win.getBounds(); + mkdirSync(dirname(stateFile()), { recursive: true }); + writeFileSync( + stateFile(), + JSON.stringify({ width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y }), + ); + } catch { + // Best-effort; losing window position is not worth surfacing an error. + } +} + +// --- startup screens (no separate renderer files; inline data URLs) ----------- + +function dataUrl(html: string): string { + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} + +const SCREEN_STYLE = ` + <style> + html, body { height: 100%; margin: 0; } + body { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 18px; background: #0b0b0c; color: #e7e7ea; font: 14px/1.5 system-ui, sans-serif; + -webkit-user-select: none; user-select: none; text-align: center; padding: 0 32px; + } + .spinner { + width: 34px; height: 34px; border-radius: 50%; + border: 3px solid #2a2a2e; border-top-color: #7c8cff; animation: spin 0.9s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + h1 { font-size: 15px; font-weight: 600; margin: 0; } + p { margin: 0; color: #9a9aa2; max-width: 560px; } + code { color: #c8c8d0; word-break: break-all; } + </style> +`; + +function loadingHtml(): string { + return `<!doctype html><meta charset="utf-8">${SCREEN_STYLE} + <div class="spinner"></div> + <h1>正在启动 Kimi 本地服务…</h1> + <p>首次启动可能需要几秒。</p>`; +} + +function errorHtml(message: string): string { + const safe = message.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + return `<!doctype html><meta charset="utf-8">${SCREEN_STYLE} + <h1>无法启动本地服务</h1> + <p>${safe}</p> + <p>查看日志:<code>${serverLogPath()}</code></p> + <p>菜单 → Kimi Code Desktop → 重试连接,或先检查日志。</p>`; +} + +// --- server auth token -------------------------------------------------------- + +/** On-disk filename of the daemon's persistent bearer token (under KIMI_CODE_HOME). */ +const SERVER_TOKEN_FILE = 'server.token'; + +/** + * Read the daemon's bearer token so the web UI can authenticate without showing + * the manual token dialog on a fresh launch. Returns undefined when the token + * cannot be read (the web UI then falls back to the dialog). + */ +function readServerToken(): string | undefined { + try { + const token = readFileSync(join(kimiHome(), SERVER_TOKEN_FILE), 'utf-8').trim(); + return token.length > 0 ? token : undefined; + } catch { + return undefined; + } +} + +// --- connect flow ------------------------------------------------------------- + +async function connect(win: BrowserWindow): Promise<void> { + await win.loadURL(dataUrl(loadingHtml())); + try { + const { origin } = await ensureServer(resolveSeaPath()); + process.stdout.write(`[kimi-desktop] connected to ${origin}\n`); + if (!win.isDestroyed()) { + // Append a desktop marker so the web UI shows the internal-build banner + // even when it is served by an already-running shared daemon (the desktop + // reuses the local daemon rather than starting a private one). Carry the + // server token in the `#token=` fragment — like `kimi web` does — so the + // web UI can authenticate without falling into the manual token dialog on + // a fresh launch. + const token = readServerToken(); + const fragment = token === undefined ? '' : `#token=${encodeURIComponent(token)}`; + await win.loadURL( + `${origin}/?kimi_desktop=1&platform=${process.platform}${fragment}`, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`[kimi-desktop] ensureServer failed: ${message}\n`); + if (!win.isDestroyed()) { + await win.loadURL(dataUrl(errorHtml(message))); + } + } +} + +function createWindow(): void { + const win = new BrowserWindow({ + ...loadBounds(), + minWidth: 720, + minHeight: 480, + backgroundColor: '#0b0b0c', + title: 'Kimi Code Desktop', + // macOS: hide the native title bar and float the traffic lights over the + // content; the web UI reserves a draggable strip at the top to clear them. + // 'hidden' (not 'hiddenInset') so trafficLightPosition can pin the lights + // to the vertical center of the web UI's 48px header row (y 18 + 12px + // button height / 2 = 24 = the header's midline — same line as the + // sidebar-expand button and the conversation title). + // 'default' on other platforms (they keep their native title bar). + titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', + trafficLightPosition: { x: 16, y: 18 }, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + }); + mainWindow = win; + // Keep the window title as the product name. The web page sets document.title + // ("Kimi Code Web"), which would otherwise replace it. + win.webContents.on('page-title-updated', (event) => { + event.preventDefault(); + }); + // macOS traffic lights. + // + // 1) Visibility across transitions: with titleBarStyle 'hidden' + a custom + // trafficLightPosition, the buttons can vanish (or lose their custom + // position) after a full-screen round-trip or on re-focus. Re-assert both + // on those transitions (observed on Electron 33; belt-and-braces). + // + // 2) Blur is NOT such a case: unfocused traffic lights are merely DIMMED by + // AppKit, and the dimmed color follows the WINDOW appearance, not the + // page (electron#27295) — with the OS in dark mode but the web UI on a + // light theme, the light-gray dimmed dots become invisible against the + // light sidebar. That is fixed by the theme sync below, which keeps the + // window appearance aligned with the web UI's <html data-color-scheme>. + if (process.platform === 'darwin') { + const showTrafficLights = (): void => { + if (win.isDestroyed()) return; + win.setWindowButtonPosition({ x: 16, y: 18 }); + win.setWindowButtonVisibility(true); + }; + win.on('enter-full-screen', showTrafficLights); + win.on('leave-full-screen', showTrafficLights); + win.on('focus', showTrafficLights); + + // Theme sync: no preload/IPC channel exists, so inject a tiny observer + // that reports <html data-color-scheme> ('light' | 'dark' | 'system') + // through a tagged console message, and mirror it into + // nativeTheme.themeSource (same three states). The startup/error screens + // (data: URLs) have no such attribute and harmlessly report 'system'. + const THEME_TAG = '__kimi_desktop_theme__:'; + win.webContents.on('console-message', (_event, _level, message) => { + if (!message.startsWith(THEME_TAG)) return; + const scheme = message.slice(THEME_TAG.length); + if (scheme === 'light' || scheme === 'dark' || scheme === 'system') { + nativeTheme.themeSource = scheme; + } + }); + win.webContents.on('did-finish-load', () => { + win.webContents + .executeJavaScript( + `(() => { + const report = () => { + const v = document.documentElement.dataset.colorScheme; + console.info(${JSON.stringify(THEME_TAG)} + (v === 'light' || v === 'dark' ? v : 'system')); + }; + new MutationObserver(report).observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-color-scheme'], + }); + report(); + })();`, + ) + .catch(() => { + // Navigation can tear the page down mid-injection; theme sync is + // cosmetic, so ignore. + }); + }); + } + win.on('close', () => { + saveBounds(win); + }); + win.on('closed', () => { + if (mainWindow === win) { + mainWindow = null; + } + }); + void connect(win); +} + +// --- native menu -------------------------------------------------------------- + +function buildMenu(): void { + const isMac = process.platform === 'darwin'; + const appMenu: MenuItemConstructorOptions = { + label: 'Kimi Code Desktop', + submenu: [ + ...(isMac ? [{ role: 'about' as const }, { type: 'separator' as const }] : []), + { + label: '重试连接', + click: () => { + if (mainWindow !== null) { + void connect(mainWindow); + } else { + createWindow(); + } + }, + }, + { + label: '打开服务日志', + click: () => { + void shell.openPath(serverLogPath()); + }, + }, + { type: 'separator' }, + isMac ? { role: 'quit' } : { role: 'close' }, + ], + }; + + const template: MenuItemConstructorOptions[] = [ + appMenu, + { role: 'editMenu' }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { role: 'windowMenu' }, + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +// --- app lifecycle ------------------------------------------------------------ + +function main(): void { + // The shared daemon is deliberately left running on quit — it self-exits ~60s + // after the last client disconnects, so we never tear down a server another + // client (CLI / browser / TUI) may still be using. + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + void app.whenReady().then(() => { + buildMenu(); + createWindow(); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); + }); +} + +main(); diff --git a/apps/kimi-desktop/src/main/sea-path.ts b/apps/kimi-desktop/src/main/sea-path.ts new file mode 100644 index 000000000..8703d72b1 --- /dev/null +++ b/apps/kimi-desktop/src/main/sea-path.ts @@ -0,0 +1,45 @@ +import { join } from 'node:path'; + +import { app } from 'electron'; + +// The bundled backend targets the same 6 platform/arch pairs the kimi-code +// native SEA build supports (apps/kimi-code/scripts/native/native-deps.mjs). +const SUPPORTED_TARGETS = new Set([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', +]); + +/** `<platform>-<arch>` triple for the current process, validated against the SEA targets. */ +export function currentTarget(): string { + const target = `${process.platform}-${process.arch}`; + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`No bundled Kimi server for this platform: ${target}`); + } + return target; +} + +function executableName(): string { + return process.platform === 'win32' ? 'kimi.exe' : 'kimi'; +} + +/** + * Absolute path to the bundled SEA backend executable. + * + * - packaged: `<resources>/bin/<target>/kimi[.exe]` — placed there by + * electron-builder `extraResources`. + * - dev: `apps/kimi-code/dist-native/bin/<target>/kimi[.exe]` — produced by + * `pnpm -C apps/kimi-code build:native:sea`. In dev `app.getAppPath()` is + * `apps/kimi-desktop`, so the sibling app is one level up. + */ +export function resolveSeaPath(): string { + const target = currentTarget(); + const exe = executableName(); + if (app.isPackaged) { + return join(process.resourcesPath, 'bin', target, exe); + } + return join(app.getAppPath(), '..', 'kimi-code', 'dist-native', 'bin', target, exe); +} diff --git a/apps/kimi-desktop/tsconfig.json b/apps/kimi-desktop/tsconfig.json new file mode 100644 index 000000000..596e2cf72 --- /dev/null +++ b/apps/kimi-desktop/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/apps/kimi-desktop/tsdown.config.ts b/apps/kimi-desktop/tsdown.config.ts new file mode 100644 index 000000000..c58e8866d --- /dev/null +++ b/apps/kimi-desktop/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown'; + +// The Electron main process is loaded as CommonJS (`out/main.cjs`). All sources +// under src/main are bundled into a single file; `electron` stays external +// (provided by the Electron runtime) and Node built-ins are external by default. +export default defineConfig({ + entry: { main: 'src/main/index.ts' }, + format: ['cjs'], + platform: 'node', + target: 'node20', + outDir: 'out', + clean: true, + dts: false, + fixedExtension: true, + deps: { neverBundle: ['electron'] }, +}); diff --git a/apps/kimi-web/AGENTS.md b/apps/kimi-web/AGENTS.md new file mode 100644 index 000000000..3e7dc6299 --- /dev/null +++ b/apps/kimi-web/AGENTS.md @@ -0,0 +1,63 @@ +# kimi-web Agent Guide + +Package-local rules for `apps/kimi-web` (`@moonshot-ai/kimi-web`). + +## What it is + +The browser web UI for Kimi Code — a peer to the TUI in `apps/kimi-code`. It talks to the local server over REST + WebSocket under `/api/v1`. Stack: Vue 3 + Vite 6 + TypeScript (strict) + vue-i18n v11. (Tailwind was removed; all styling is via design tokens in `src/style.css` + scoped component styles.) There is no client router and no Pinia; state lives in composables/refs and provide/inject. + +## Design system (normative — required when modifying the UI) + +- **Before changing any component, style, layout, or theme, read the design system view at `src/views/DesignSystemView.vue` (open it as an overlay: long-press the sidebar logo).** It is the canonical design system and visual spec for this app (tokens §02, primitives §03, chat §04, theme rules §05, style rules §06). It consumes the product tokens from `src/style.css` directly, so it stays in sync with the app. New and modified UI must match it. +- **Use the primitives in `src/components/ui/`.** The library covers Button, IconButton, Badge, Pill, Card, Input/Select/Textarea/Field, Dialog, Spinner, MoonSpinner, Link, Menu/MenuItem, SegmentedControl, Tabs, Switch, Checkbox, Avatar, EmptyState, Divider, Tooltip, Banner, Sheet, Skeleton, CommandBar, TopBar. One semantic = one component — do not hand-roll a bespoke button/badge/dialog/input for a single screen. When a primitive replaces an element, **delete the old scoped CSS** (do not append override blocks). +- **Use the tokens, not ad-hoc values.** Colors, fonts, radii, spacing, shadows, z-index, and motion come from the CSS custom properties in `src/style.css` (catalogued in the design-system view §02). Canonical names are `--color-*` / `--radius-*` / `--space-*` / `--text-*` / `--font-*` / `--z-*` / `--shadow-*` / `--ease-*` / `--duration-*` / `--weight-*` / `--leading-*`. A small set of layout/focus tokens keep the `--p-` prefix: `--p-focus-ring`, `--p-selection`, `--p-ic-sm/md/lg`, `--p-sidebar-w`, `--p-content-max/-wide`, `--p-bp-sm/-md`. +- **The moon spinner (🌑…🌘) is reserved** for the chat "waiting for the agent's first response" state only, and is rendered solely by `ui/MoonSpinner.vue`; every other loading state uses the plain `Spinner`. +- **Run `pnpm --filter @moonshot-ai/kimi-web check:style`** (`scripts/check-style.mjs`) — it enforces the §06 anti-pattern rules (no-gradient, no-glassmorphism except TopBar `frost`, no-emoji-icon except moon, no-hardcoded-hex/font, radius/z/weight from scale). Do not add new violations. +- **Verify visually.** For any UI change, render it in the browser (light + dark, plus hover/focus states) and confirm it matches the design-system view and introduces no regression before considering it done. Build/typecheck/check-style are necessary but not sufficient. + +## Layout (`src/`) + +- `main.ts` — bootstrap (creates the app, installs i18n, mounts `#app`). `App.vue` — root component, holds most app state. +- `api/` — server client. `index.ts` exposes the `getKimiWebApi()` singleton; `config.ts` builds REST/WS URLs; `daemon/` holds the wire client (`http.ts`, `ws.ts`, `wire.ts`, `mappers.ts`, `agentEventProjector.ts`, `eventReducer.ts`). +- `components/` — SFCs grouped by area: `chat/` (conversation/chat UI), `settings/` (settings & configuration), `dialogs/` (modal dialogs & sheets), `mobile/` (mobile-specific shell), `ui/` (design-system primitives — see "Design system" above), plus shared layout components at the top level. +- `composables/` — reusable state logic, `useX` naming (`useKimiWebClient`, `useIsDark`, `usePaneLayout`, …). +- `lib/` — pure helpers (`parseDiff`, `slashCommands`, `sessionRoute`, `toolMeta`, …). +- `i18n/` — vue-i18n setup plus locale namespaces. +- `debug/` — `DebugPanel.vue` and `trace.ts` for client error/trace capture. + +## Vue conventions (normative) + +- SFCs use **`<script setup lang="ts">`** + the Composition API. Component files are **PascalCase** (`ChatHeader.vue`). +- Type props with the generic form `defineProps<{ ... }>()`; type emits with `defineEmits<{ evt: [arg: Type] }>()`. +- Shared components go in `src/components/`; reusable logic goes in `src/composables/` with a `use` prefix. +- There is **no auto-import plugin** and **no path alias** — `#/` and `@/` are intentionally unused. Write relative imports (`../i18n`, `./config`). + +## i18n (normative — keeping locales in sync is manual) + +- Setup: `src/i18n/index.ts`, vue-i18n in Composition mode (`legacy: false`), fallback `en`. The active locale is persisted in `localStorage` under `kimi-locale`. +- Locale files: `src/i18n/locales/{en,zh}/<namespace>.ts`, each `export default { ... } as const`. New namespaces are registered in `src/i18n/locales/index.ts`. +- Reference with `const { t } = useI18n()` and `t('namespace.key')` (same form in templates). +- **Adding a key:** add it to **both** `en/<ns>.ts` and `zh/<ns>.ts`. **Adding a namespace:** create the file in both locales **and** register it in `locales/index.ts`. +- There is **no automated missing-key or en/zh parity check**. Keeping the two locales in sync is a manual responsibility — do not leave a key present in only one locale. + +## Commands + +All via `pnpm --filter @moonshot-ai/kimi-web …`: + +- `dev` — Vite dev server (port `WEB_PORT`, default 5175; proxies `/api/v1` to `KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). +- `build` — production build into `dist/`. +- `typecheck` — `vue-tsc --noEmit`. +- `test` — `vitest run` (pure logic tests only; no jsdom / component tests). +- `check:style` — design-system §06 anti-pattern guard (`scripts/check-style.mjs`). +- There is **no `lint` script** in this package; linting runs at the repo root via oxlint. + +Debugging against kap-server instances: start one from the repo root with `pnpm dev:server` (port 58627), optionally a second with `pnpm dev:v2` (`KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1`, port 58628 — both can run at once). The dev server proxies `/api/v1` to the `default` preset; the Sidebar brand row carries a dev-only backend pill (engine generation `v1`/`v2` from `GET /api/v1/meta`'s `backend` field + endpoint) whose menu repoints the proxy at runtime — no Vite restart. Presets default to `http://127.0.0.1:58627` / `:58628`, overridable via `KIMI_BACKEND_DEFAULT_URL` / `KIMI_BACKEND_MULTI_URL`; the switcher endpoints (`GET/POST /__kimi-dev/backend`, dev-only, see `backendSwitcherPlugin` in `vite.config.ts`) drive the menu. + +## Gotchas / hard rules + +- **Do not depend on `@moonshot-ai/agent-core`** (mirrors the CLI/SDK rule). The web app is decoupled from core/protocol; wire types are re-implemented locally in `src/api/daemon/wire.ts`. Keep it that way. +- **Same-origin by default:** the browser only talks to its own origin; Vite proxies `/api/v1` for both HTTP and WS. Set `VITE_KIMI_SERVER_HTTP_URL` only when you intentionally want direct (CORS) mode. +- Vite-injected globals (`__KIMI_DEV_PROXY_TARGET__`, `__KIMI_DEV_BACKENDS__`, `__KIMI_WEB_VERSION__`, `__KIMI_WEB_COMMIT__`) are declared in `src/env.d.ts` and defined in `vite.config.ts`. Do not hand-edit `dist/`. +- **Theming:** the root element carries `data-color-scheme` (`light` | `dark` | `system`); react to it through `useIsDark()`, not by reading the DOM directly. +- Keep the Vite **dev** proxy and **`preview`** proxy in sync — both are defined in `vite.config.ts` (shared `apiProxyOptions`). +- The shared proxy strips the browser `Origin` header on forwarded requests: `changeOrigin` rewrites `Host` to the server but leaves `Origin` pointing at the Vite origin, and kap-server's WS upgrade path rejects that mismatch with 403. An Origin-less request is treated as a non-browser client. If you add another proxied path, route it through the same options. diff --git a/apps/kimi-web/CHANGELOG.md b/apps/kimi-web/CHANGELOG.md new file mode 100644 index 000000000..0f53dc5c4 --- /dev/null +++ b/apps/kimi-web/CHANGELOG.md @@ -0,0 +1,7 @@ +# @moonshot-ai/kimi-web + +## 0.1.2 + +### Patch Changes + +- [#1085](https://github.com/MoonshotAI/kimi-code/pull/1085) [`f1fad72`](https://github.com/MoonshotAI/kimi-code/commit/f1fad7222ccd3f66c1cae6c5b9c009230227cd2f) - Fix stuttery streaming in the web chat by coalescing rapid token updates into a single render per frame. diff --git a/apps/kimi-web/README.md b/apps/kimi-web/README.md new file mode 100644 index 000000000..0519a441e --- /dev/null +++ b/apps/kimi-web/README.md @@ -0,0 +1,121 @@ +# Kimi Web + +A browser client for Kimi Code — a peer to the TUI (`apps/kimi-code`) that talks +to a local **server** over REST + WebSocket. Vue 3 + Vite + TypeScript. + +--- + +## Quick start + +```bash +# Against a REAL server (the server must be running and reachable) +WEB_PORT=5197 KIMI_SERVER_URL=http://192.168.97.91:58627 pnpm -C apps/kimi-web run dev +# …or from the repo root: pnpm dev:web (uses the defaults below) + +# checks +pnpm -C apps/kimi-web run typecheck # vue-tsc --noEmit +pnpm -C apps/kimi-web run test # vitest (pure logic only) +pnpm -C apps/kimi-web run build # vite build +``` + +### How it connects to the server + +The browser cannot reach the server cross-origin (no CORS), so Vite **same-origin +proxies** `/api/v1` (HTTP + WS) to the server (`vite.config.ts`): + +| env var | default | meaning | +| ----------------- | ------------------------ | ---------------------------------------- | +| `WEB_PORT` | `5175` | port the dev server listens on | +| `KIMI_SERVER_URL` | `http://127.0.0.1:58627` | where `/api/v1` (and `/api/v1/ws`) is forwarded | + +> Behind a corporate HTTP proxy, also set `NO_PROXY=<server-host>` (for example, +> `NO_PROXY=127.0.0.1,localhost`) so the proxy forward reaches the server directly. + +--- + +## Architecture + +A strict one-direction data flow; components never touch the network or the +reducer — they consume computed view props and call actions. + +``` +server (REST + WS) + └─ src/api/daemon/client.ts REST adapter (envelope → AppX types) + └─ src/api/daemon/ws.ts WS frames → classify → projector/reducer + └─ agentEventProjector.ts RAW agent-core events → AppEvent[] + └─ eventReducer.ts AppEvent[] → state + └─ src/composables/useKimiWebClient.ts the ONLY place that imports api + state; + exposes computed view props + actions + └─ src/components/*.vue render props, emit intents (no api access) +``` + +> The directory name `src/api/daemon/` is historical and kept to minimise +> diff churn; conceptually it is the **server** adapter. + +- **Adapter** (`src/api/`): wire types are snake_case; `AppX` types are camelCase. + `config.ts` builds `/api/v1` URLs. +- **Event projector** (`agentEventProjector.ts`): the server streams **raw + agent-core events** (no `event.` prefix). `classifyFrame` routes raw vs + protocol (`event.*`) frames; the projector converts them to `AppEvent`s. +- **i18n** (`src/i18n/`): vue-i18n, en/zh, per-namespace flat camelCase keys. + Detect order: `localStorage('kimi-locale')` → `navigator.language` → `en`. +--- + +## Server contract — non-obvious notes + +The server's wire protocol has a few things that will bite you if forgotten: + +- **Envelope:** every response is `{ code, msg, data, request_id }` and the HTTP + status is **always 200** — check `code` (0 = ok), not the status. +- **Prompts require five fields.** `POST /sessions/{id}/prompts` must carry + `{ content, model, thinking, permission_mode, plan_mode }`. The web fills these + from settings (model ← session/`default_model`, thinking/permission/plan ← the + StatusLine controls). Sending only `{ content }` → `40001 model …`. +- **Creating a session needs a *registered* workspace.** `workspace_id` must be a + `wd_<slug>_<hash>` id that exists in the server's registry. Sessions get one + auto-assigned by cwd, but it isn't *registered* until you `POST /workspaces + { root }` (idempotent). The web registers on demand before `createSession` + (otherwise: `workspace not found: wd_…`). +- **Persisted sessions are directly promptable** — selecting an old session and + sending a message just works; there is **no `:activate` step**. +- **Workspaces** = real folders. `GET/POST/PATCH/DELETE /workspaces`, + `GET /fs:browse?path=`, `GET /fs:home` back the rail + folder picker. + +## Release & deployment + +Kimi Web is **not published as a standalone package**. It ships as the built-in +web UI of the `kimi` CLI (`apps/kimi-code`). + +### Current release flow + +1. **Develop** — `pnpm dev:web` (or `pnpm -C apps/kimi-web run dev`). +2. **Build** — `pnpm -C apps/kimi-web run build` produces `apps/kimi-web/dist`. +3. **Bundle into CLI** — `pnpm -C apps/kimi-code run build` runs + `scripts/copy-web-assets.mjs`, which copies `apps/kimi-web/dist` into + `apps/kimi-code/dist-web`. +4. **Publish** — the root `.github/workflows/release.yml` publishes + `@moonshot-ai/kimi-code` to npm; `dist-web` is listed in the package `files` + array, so the built web assets travel with the CLI package. +5. **Serve** — `kimi server run` / `kimi web` serves `dist-web` from the + installed package. + +The web UI does not display its own package version or build commit. It is +bundled into the CLI package and follows the published `@moonshot-ai/kimi-code` +release. + +### Suggested improvements + +- **Keep the current coupling for now.** Because Kimi Code is primarily a local + CLI/server product, bundling the web UI into the CLI package keeps installs + self-contained and avoids cross-origin/CORS complexity. +- **Add an independent web-deploy workflow only when needed.** If a public + standalone web deployment is required later, create + `.github/workflows/web-deploy.yml` that builds `apps/kimi-web` and uploads + `dist/` to the chosen static host (S3/CloudFront, Cloudflare Pages, Vercel, + etc.). Until then, do not maintain a separate deploy target. +- **Keep versioning owned by the CLI release.** `apps/kimi-web/package.json` + remains internal workspace metadata; do not surface it as a separate user + version unless the web app becomes an independently published product. +- **Ensure the web build is exercised in CI.** The root `build` script already + builds every workspace, so `pnpm run build` in CI covers `apps/kimi-web`. + Keep it that way; do not bypass the web build in release pipelines. diff --git a/apps/kimi-web/index.html b/apps/kimi-web/index.html new file mode 100644 index 000000000..dff0375e4 --- /dev/null +++ b/apps/kimi-web/index.html @@ -0,0 +1,31 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" href="/favicon.ico" sizes="64x64" /> + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" /> + <meta name="color-scheme" content="light dark" /> + <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> + <meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)" /> + <!-- Apply persisted display prefs BEFORE the bundle loads: without this, + users can get a color-scheme/font flash before useKimiWebClient mirrors + the data attributes. Mirrors applyColorSchemeToDocument. --> + <script> + (function () { + try { + var v = localStorage.getItem('kimi-web.color-scheme'); + if (v === 'light' || v === 'dark' || v === 'system') { + document.documentElement.dataset.colorScheme = v; + } + } catch (e) { + /* ignore */ + } + })(); + </script> + <title>Kimi Code Web + + +
+ + + diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json new file mode 100644 index 000000000..85dc3ac22 --- /dev/null +++ b/apps/kimi-web/package.json @@ -0,0 +1,39 @@ +{ + "name": "@moonshot-ai/kimi-web", + "version": "0.1.2", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run", + "check:style": "node scripts/check-style.mjs" + }, + "dependencies": { + "@chenglou/pretext": "0.0.8", + "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "katex": "^0.17.0", + "markstream-vue": "^1.0.4", + "mermaid": "^11.15.0", + "shiki": "^4.3.0", + "stream-markdown": "^0.0.16", + "vue": "^3.5.35", + "vue-i18n": "^11.4.5" + }, + "devDependencies": { + "@iconify-json/ri": "^1.2.10", + "@iconify-json/tabler": "^1.2.35", + "@vitejs/plugin-vue": "^5.2.4", + "typescript": "6.0.2", + "unplugin-icons": "^23.0.0", + "vite": "^6.3.3", + "vitest": "4.1.4", + "vue-tsc": "~3.2.0", + "ws": "^8.18.0" + } +} diff --git a/apps/kimi-web/public/favicon.ico b/apps/kimi-web/public/favicon.ico new file mode 100644 index 000000000..9b4870b72 Binary files /dev/null and b/apps/kimi-web/public/favicon.ico differ diff --git a/apps/kimi-web/scripts/check-style.mjs b/apps/kimi-web/scripts/check-style.mjs new file mode 100644 index 000000000..81480efe9 --- /dev/null +++ b/apps/kimi-web/scripts/check-style.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +// check-style.mjs — design-system §06 anti-pattern guard for apps/kimi-web. +// +// Scans src/** for the rules in the design system (§06 of the DesignSystemView spec): +// no-gradient-text, no-glassmorphism (.frost exempt), no-color-glow, +// icon-from-registry (hand-written ; Icon/Spinner/MoonSpinner + the +// 32x22 brand mark exempt), no-emoji-icon (moon in MoonSpinner exempt), +// no-hardcoded-hex (DiffView/DiffLines/Terminal domain colors + var() +// fallbacks exempt), no-hardcoded-font (token definitions exempt), +// radius-from-scale, z-from-scale, weight-from-scale. +// +// Default mode: report a baseline and exit 0 (warnings only). Pass --strict +// to exit 1 when any finding exists (flipped on in P3 enforcement). + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const SRC = path.join(ROOT, 'src'); +const STRICT = process.argv.includes('--strict'); + +const DOMAIN_HEX_EXEMPT = new Set([ + 'chat/DiffView.vue', + 'chat/DiffLines.vue', + 'Terminal.vue', +]); + +// Files that legitimately render their own : bespoke data-viz / colored +// illustrations, the spinner, and brand marks (the Kimi wordmark on the loading +// screen). Everything else should use lib/icons.ts via /iconSvg(). The +// 32x22 Kimi eye logo is also exempted inline (matched by viewBox). The icon +// primitive (components/ui/Icon.vue) itself renders no hand-written , so it +// is not exempted here. +const ICON_EXEMPT = new Set([ + 'components/ui/Spinner.vue', + 'components/ui/MoonSpinner.vue', + 'components/ui/ContextRing.vue', + 'components/ui/AuthStateIcon.vue', + 'components/GlobalLoading.vue', +]); + +// Files entirely exempt from the §06 scan. The design-system showcase view is +// documentation/demo CSS (forced-dark previews, syntax-highlighting palettes, +// illustrative mockups) rather than product UI, so the anti-pattern rules do not +// apply to it. +const FILE_EXEMPT = new Set(['views/DesignSystemView.vue']); + +const RADIUS_SCALE = new Set([4, 6, 8, 12, 16, 20, 999]); +const WEIGHT_OK = new Set([ + '400', '500', + 'normal', 'bolder', 'lighter', + 'inherit', 'initial', 'unset', 'revert', +]); +const Z_OK = new Set(['0', '1', '-1', 'auto']); + +/** @type {{ rule: string, file: string, line: number, detail: string }[]} */ +const findings = []; + +function walk(dir, out = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, out); + else if (/\.(vue|css)$/.test(entry.name)) out.push(full); + } + return out; +} + +function rel(abs) { + return path.relative(SRC, abs).replaceAll(path.sep, '/'); +} + +function lineOf(text, index) { + let n = 1; + for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++; + return n; +} + +function add(rule, file, line, detail) { + findings.push({ rule, file, line, detail }); +} + +function stripVarSpans(line) { + // Remove var(...) substrings so var() fallbacks don't trip hex checks. + return line.replace(/var\([^()]*(?:\([^()]*\)[^()]*)*\)/g, ''); +} + +function extractStyleBlocks(content) { + // For .vue: return [{text, baseLine}] for each
+ +
+
+ +
+

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

+

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

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