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..e0af433ce --- /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 registry (`registerErrorDomain` / `errorInfo` / `isErrorCode`), and `CoreErrors` (`internal`, `not_implemented`). The `ErrorCode` union type is derived by `#/errors` from the aggregated domain definitions. +- `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 derived from the aggregated domain definitions (`ErrorCode` in `#/errors` is computed from the `ErrorCodes` aggregate): **add new codes to the owning domain's `errors.ts`** — registration throws on cross-domain collisions. 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 owning domain 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..82b9188f1 --- /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/kap-server/src/protocol/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 **`packages/kap-server/src/protocol`** under `rest-.ts` (e.g. `promptSubmissionSchema`, `promptListResponseSchema`, `configResponseSchema`) — or in the owning `agent-core-v2` domain contract when the engine's service speaks the shape. 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** → add it to `packages/kap-server/src/protocol/rest-.ts` first (or to the owning v2 domain contract if its service speaks the shape), then consume it from the route. The shared schema is the source of truth; server-v2 never re-declares a v1 wire schema inline. +- **Schema exists but only v1 uses it** → keep it in `packages/kap-server/src/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: the owning `agent-core-v2` domain contract or `packages/kap-server/src/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 the v1 wire schema (kap-server/src/protocol) +import type { PromptSubmitResult, PromptSubmission } from '../../protocol/rest-prompt'; +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 the v1 wire schema homes** (the owning v2 domain contract or `kap-server/src/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 kap-server/src/protocol + params: sessionIdParamSchema, + success: { data: promptSubmitResultSchema }, // ← from kap-server/src/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/kap-server/src/protocol/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/kap-server test` — server routes green (incl. any wire-schema guards). +- `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/klient test` (optionally with `KIMI_SERVER_URL` for the live legacy suites) 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 the shared v1 wire schemas (see `packages/kap-server/src/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/kap-server/src/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 their owning home (the `agent-core-v2` domain contract or `packages/kap-server/src/protocol`); 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/kap-server/src/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; wire-shape guards added/updated where the route mirrors v1. +- [ ] `lint:domain` passes; the LegacyService did not invert scope or domain direction. + +## Red lines (this subskill) + +- One wire schema, one home: the owning `agent-core-v2` domain contract or `packages/kap-server/src/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 established wire schema (in its owning home — the `agent-core-v2` domain contract or `packages/kap-server/src/protocol`) 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/kap-server/src/protocol/error-codes.ts`; 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 71df61bfb..800d0c3d7 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. +6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. `@moonshot-ai/kimi-inspect` (a private dev app that never ships) is likewise ignored and must never appear in a changeset frontmatter. ## Workflow -1. List the packages that were actually changed. +1. List the changed packages and check whether each one is ignored by `.changeset/config.json`. 2. Choose a bump level for each package. -3. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`. +3. If an ignored internal package change enters the CLI bundle, put `@moonshot-ai/kimi-code` in frontmatter instead of mixing the ignored package into the same changeset. 4. Create a short kebab-case file under `.changeset/`. 5. Split unrelated changes into separate changesets; keep one logical change in one file. @@ -43,10 +46,12 @@ Format: | Level | When to use | |---|---| -| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades | -| `minor` | New backwards-compatible features or capabilities | +| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades; small improvements to existing features with limited user-facing impact (e.g. a new keyboard shortcut, a flag alias, a minor UX tweak) | +| `minor` | A substantial new user-facing feature, such as a new slash command, a new built-in tool, or a new mode | | `major` | Breaking changes: incompatible config changes, renamed or removed commands/arguments, behavior semantics changes, and similar | +When in doubt between `patch` and `minor`: if the change improves an existing feature and the user-facing impact is small, choose `patch` even when the change is technically "new". Reserve `minor` for a substantial new capability that introduces something users could not do before. + ### Major Rule Never write `major` on your own. @@ -56,31 +61,72 @@ If you believe a change qualifies as major, stop first, explain why, and ask the ## Wording Rules - Changelog entries **must be written in English**. -- **Keep it short — ideally a single sentence that states what was done.** Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change. +- **Keep the whole entry concise.** Aim for one short sentence that states what was done; at most a short sentence plus a one-line usage hint. Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change. +- **For new user-facing features, append a brief usage hint** so users know how to try it. Keep it to a single short line — a command name, a subcommand, a flag, or a one-line "how to use". Do not explain design rationale or list edge cases. Skip the hint for bug fixes, internal changes, and refactors. + - Slash command: `Add the /foo slash command to list active sessions. Run /foo to see them.` + - CLI subcommand: `Add the kimi web subcommand to open the web UI. Run kimi web to launch it.` + - Flag: `Add a --bar flag to skip confirmation prompts. Pass --bar to skip.` + - Too long: `Add the /foo command to list active sessions. It accepts an optional --all flag to include background sessions, supports filtering by name with /foo , and writes the result to the transcript...` - User-facing CLI wording should only be used when CLI users can perceive the change. - Internal changes that do not affect CLI users can still share a changeset with the CLI, but the wording must describe the real change honestly and must not present it as a user-facing feature. - Do not mention file names, class names, function names, PR numbers, or commit hashes. - Do not include real internal endpoints, key names, account names, or service names. If an example is needed, use neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY`. - Avoid vague words such as `refactor`, `optimize`, and `improve`. Describe the actual change, or use more specific wording. +## When You Are Unsure About a Change + +Generate the changeset from what the diff clearly shows. If part of a change is unclear and you cannot confidently describe what it does for users, do not guess or pad the entry with vague wording. + +1. Finish the changeset for the parts that are clear. +2. Then ask the user once, in a short list: name the specific change(s) you do not understand, and ask whether you may dig into the repository (read related source, tests, or call sites) to describe it more accurately. +3. Only read more code after the user agrees. If the user says no or does not reply, keep the concise wording you already have and do not invent detail. + ## Common Examples An internal package fixes a bug visible to CLI users: ```markdown --- -"@moonshot-ai/agent-core": patch "@moonshot-ai/kimi-code": patch --- Fix occasional loss of tool call results in long conversations. ``` +A new user-facing slash command (note the short usage hint): + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /foo slash command to list active sessions. Run /foo to see them. +``` + +A new CLI subcommand: + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the kimi web subcommand to open the web UI. Run kimi web to launch it. +``` + +A new flag on an existing command: + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +Add a --bar flag to skip confirmation prompts. Pass --bar to skip. +``` + An internal package has an internal-only change, but it enters the CLI bundle: ```markdown --- -"@moonshot-ai/agent-core": patch "@moonshot-ai/kimi-code": patch --- @@ -97,12 +143,83 @@ Only SDK source changed, and the CLI does not use it: Clarify session status typing for internal SDK callers. ``` +## Web app changes + +`@moonshot-ai/kimi-web` is ignored by changesets and must **never** appear in a changeset frontmatter. Because the web app is bundled into the CLI release artifact, any web change that ships must list `@moonshot-ai/kimi-code` instead and describe the actual web-facing change in the text. + +- Prefix the changelog entry text with `web: ` (for example `web: Fix the chat not scrolling to the bottom after sending a message.`) so the synced docs changelog can mark web UI entries. Apply this whenever the change is to the web project (`@moonshot-ai/kimi-web`). +- If a PR ships a web UI feature backed by server API changes that exist solely to power that feature, prefer a single `web:` entry describing what the web user gets. Do not add a separate server-API changeset unless the API has independent user value (a public endpoint that SDK or server consumers call directly). The docs changelog sync also deduplicates this pattern, but catching it here avoids duplicate changesets. +- Do not enumerate every micro-tweak; keep it to one sentence that captures what the web user gets. + +Web-only fix: + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix the chat not scrolling to the bottom after sending a message. +``` + +Web UI plus backing server APIs in the same PR (prefer a single `web:` entry; the API is plumbing): + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +web: Add the server-hosted web UI, including chat layout and session list behaviors. +``` + +Split into two changesets only when the API has independent user value on its own (for example, a public endpoint SDK consumers call directly). In that case add the web entry above plus a separate one such as `Add a public REST API to list archived sessions for SDK consumers.` + +## `@moonshot-ai/pi-tui` changes + +`@moonshot-ai/pi-tui` is a vendored fork that lives in `packages/pi-tui`. It is `private: true` and is never published, but it is **not** ignored by changesets: changesets versions it and writes `packages/pi-tui/CHANGELOG.md` so the fork keeps its own history. Because it is bundled into the CLI like other internal packages, it is an exception to Core Rule 4 — do **not** list `@moonshot-ai/kimi-code` for a change that only touches pi-tui. + +- Changes that only affect pi-tui (build, package, strict-mode cleanup, renderer fixes): list `@moonshot-ai/pi-tui` only. No CLI changeset. +- If the same change is also user-visible in the CLI (for example a terminal rendering fix that CLI users can see), add a **separate** changeset that lists `@moonshot-ai/kimi-code` with CLI-focused wording, in addition to the pi-tui changeset. Do not mix both packages in one frontmatter — the two changelogs need different wording. + +pi-tui-only change: + +```markdown +--- +"@moonshot-ai/pi-tui": patch +--- + +Export the package manifest so the bundled binary can locate its native assets. +``` + +pi-tui change that is also visible in the CLI (two separate changesets): + +```markdown +--- +"@moonshot-ai/pi-tui": patch +--- + +Clamp the differential render to the visible viewport so scrolling up during streaming no longer jumps to the top. +``` + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the transcript jumping to the top when scrolling up through history during streaming output. +``` + ## Red Flags - You are about to write `major` without asking the user. +- A new user-facing feature entry has no usage hint, or the hint runs to multiple lines and explains design rationale. +- You guessed wording for a change you do not understand instead of asking the user whether you may dig into the repo. - Internal package source enters the CLI bundle, but `@moonshot-ai/kimi-code` is missing. +- A changeset frontmatter mixes ignored internal packages with non-ignored packages. - `packages/node-sdk` was not changed, but `@moonshot-ai/kimi-code-sdk` was listed for "internal package sync". - The changelog entry is in Chinese. - The wording claims more than the diff actually did. - The CLI wording mentions internal package names, class names, or PR numbers. - The entry includes real internal identifiers instead of neutral placeholders. +- A change that only touches `@moonshot-ai/pi-tui` lists `@moonshot-ai/kimi-code` instead of `@moonshot-ai/pi-tui`, or mixes both packages in one frontmatter. +- A web app change entry is missing the `web: ` prefix. +- A server/API changeset exists only to back a web feature that a `web:` changeset already describes (use one `web:` entry instead, unless the API has independent user value). diff --git a/.agents/skills/pre-changelog/SKILL.md b/.agents/skills/pre-changelog/SKILL.md new file mode 100644 index 000000000..adde7c1b3 --- /dev/null +++ b/.agents/skills/pre-changelog/SKILL.md @@ -0,0 +1,67 @@ +--- +name: pre-changelog +description: Use before merging a kimi-code release PR to preview the user-facing CLI changelog in Chinese. Reads the changelog that changesets pre-generated in the release PR, then reuses sync-changelog's strip / classify / translate logic to render a Chinese preview. Writes no files. +--- + +# Pre-Changelog + +Preview the user-facing **Chinese** changelog of an open `kimi-code` release PR **before** it is merged. Read-only: this skill writes no files and commits nothing. + +This skill reuses `sync-changelog`'s strip / classify / translate rules. Read `sync-changelog` first; only the data source (release PR diff instead of a published `CHANGELOG.md`) and the output (preview instead of docs files) differ. + +## Workflow + +### 1. Locate the release PR + +```bash +gh pr list --state open --search "ci: release packages in:title" \ + --json number,title,url,headRefName,baseRefName +``` + +Pick the one with `headRefName: changeset-release/main`; record `number`, `url` as ``. If none is open, nothing to preview — stop. + +### 2. Read the pre-generated CLI changelog block + +changesets already pre-generates `apps/kimi-code/CHANGELOG.md` inside the release PR. Extract the new version block from the diff: + +```bash +gh api repos/MoonshotAI/kimi-code/pulls//files \ + --jq '.[] | select(.filename=="apps/kimi-code/CHANGELOG.md") | .patch' +``` + +Take the added lines (`+`) from the top `## ` down to (but not including) the next `## `. That is the version block to preview. + +If the CLI changelog is not in the diff (for example an SDK-only release), stop and tell the user — there is no user-facing CLI changelog to preview. + +### 3. Render the Chinese preview (reuse `sync-changelog`) + +Process the version block exactly as `sync-changelog` does for the docs site, but only in memory: + +- **Strip** (`sync-changelog` step 3): drop the H1, the `### Patch Changes` / `### Minor Changes` / `### Major Changes` subheadings, PR links, and commit-hash links; keep only each entry's body text. The `Thanks [@user](...)!` credit (including the multi-author form) must be removed every time. Within each entry, drop SDK-only and provider-internal sentences (SDK capability mapping / API exposure, provider wire-format mechanics, internal XML markers) and keep only the user-facing effect and required constraints. +- **Merge and deduplicate** (`sync-changelog` step 4): merge micro-tweaks to the same surface into one higher-level entry; when three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry (do not merge broad or genuinely distinct fixes); and drop a server/API entry that only backs a web feature already listed. +- **Classify** (`sync-changelog` step 4): bucket into Features / Bug Fixes / Polish / Refactors / Other; order within each section by reader value (in Polish, user-visible improvements before protocol/internal adjustments). +- **Translate** (`sync-changelog` step 6): translate entry bodies to Chinese; keep one sentence per entry with a parallel rhythm within a section; section headings become 新功能 / 修复 / 优化 / 重构 / 其他. + +If an upstream entry is not in English, flag it and stop (changeset entries must be English). + +### 4. Output + +Print the preview directly. Use `(预览)` as the heading because the version is not released yet. Write `无` for empty sections. Do not write any file. + +``` +发版 PR: + +## (预览) + +### 新功能 +- ... + +### 修复 +- ... +``` + +## Rules + +- Read-only. Never write `CHANGELOG.md`, docs files, or commit anything. +- Classification, ordering, and translation follow `sync-changelog` exactly — do not reword or reclassify beyond what it specifies. +- If the release PR has no CLI changelog diff, report it and stop. diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md index 7dd35ad7c..25e542d4b 100644 --- a/.agents/skills/sync-changelog/SKILL.md +++ b/.agents/skills/sync-changelog/SKILL.md @@ -1,6 +1,6 @@ --- name: sync-changelog -description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md. +description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md, then open a PR on a dedicated branch. --- # Sync Changelog @@ -15,7 +15,7 @@ apps/kimi-code/CHANGELOG.md This file is the **only upstream source** for the documentation-site changelog. Internal package changelogs such as `packages/*/CHANGELOG.md` do not go into the documentation site. -After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site and translate the English increment into Chinese. +After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site, translate the English increment into Chinese, wait for an optional human review, then commit on a dedicated branch and open a PR. ## When To Use @@ -41,39 +41,65 @@ Before editing, confirm: - The released version exists on npm (`npm view @moonshot-ai/kimi-code versions --json`) or has a matching GitHub Release tag. - The top of `apps/kimi-code/CHANGELOG.md` is that new version. -- The current branch is clean, or you are on a dedicated docs-sync branch. If any condition is not true, stop and confirm with the user. +Do **not** edit or commit directly on `main`. All sync work happens on a dedicated branch created in step 1. + ## Workflow -### 1. Find The Version Range +### 1. Prepare Branch + +Start from an up-to-date default branch: ```bash -# Upstream versions -rg '^## ' apps/kimi-code/CHANGELOG.md | head -20 +git fetch origin +git checkout main +git pull --ff-only origin main +``` -# Latest version already synced into the English docs page +Before creating the branch, peek at the version range so the branch name matches the newest version being synced: + +```bash +rg '^## ' apps/kimi-code/CHANGELOG.md | head -5 rg '^## ' docs/en/release-notes/changelog.md | head -5 ``` +Name the branch after the newest upstream version that is not yet in the English docs page: + +```text +docs/changelog-sync- +``` + +Example: syncing `0.2.1` only → `docs/changelog-sync-0.2.1`. + +```bash +git checkout -b docs/changelog-sync- +``` + +If the branch already exists locally or on the remote, stop and confirm with the user instead of reusing it. + +### 2. Find The Version Range + +Use the same version lists from step 1. Confirm: + - First sync: copy all upstream version blocks into the English page. - Incremental sync: copy every upstream version block above the latest version already present in the English page. Use upstream order: newest version first. -### 2. Strip Decorations And Extract Entry Text +### 3. Strip Decorations And Extract Entry Text Upstream entries look like this: ```markdown -- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) - Clean up lint warnings ... +- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) Thanks [@user](https://github.com/...)! - Clean up lint warnings ... ``` -Keep: +Changesets may add a `Thanks ...!` credit, but it must be removed every time. Keep: - Version headings such as `## 0.2.0`. -- Only the body text of each entry, after the PR/hash decoration. +- Only the body text of each entry, after the PR/hash decoration and any `Thanks ...!` credit have been removed. Remove: @@ -81,26 +107,49 @@ Remove: - Changesets subheadings such as `### Patch Changes`, `### Minor Changes`, and `### Major Changes`. - PR links such as `[#317](...)`. - Commit hash links such as ``[`2f51db4`](...)``. +- The `Thanks [@user](...)!` credit, including the multi-author form `Thanks [@a](...), [@b](...)!`. Drop the whole `Thanks ...!` segment every time, regardless of whether the feature is enabled. -After stripping, each entry should be only: +After stripping, each entry is `- `. + +Drop SDK-only and provider-internal detail. This changelog serves `@moonshot-ai/kimi-code` CLI and web users. Within an entry, keep only what CLI/web users can perceive, and remove sentences that document internals instead of user-visible behavior. Apply this on both the English and Chinese pages: + +- Drop sentences about how the SDK maps a capability, builds model aliases, or exposes a flag through an API such as `getExperimentalFeatures()` — that belongs in the SDK changelog, not here. +- Drop provider / wire-format implementation mechanics (XML markers like ``, protocol field explanations, "the wire protocol is unchanged", cache-hit mechanics) unless they are the behavior a user perceives. +- Keep the user-facing effect and any constraints users must follow (for example "question texts must be unique"). + +Do not change facts or drop a real user-facing behavior — only trim the internal-only scaffolding. For over-long, internal-heavy entries, this trim applies on the English page too, not only in translation. + +Web UI prefix: if the entry is a web UI change, prefix the body text with `web: ` so readers can tell it affects the web UI: ```markdown -- +- web: ``` +An entry counts as a web UI change when its upstream commit touches `apps/kimi-web/`. Check with `git show --name-only ` (the commit hash is the one stripped above). `gen-changesets` writes this prefix for web changes, so it is usually already present in upstream — preserve it when it is there, and add it when a web entry lacks it. When a commit touches both web and non-web code, use `web:` only if the user-facing change described by the entry is in the web UI. Keep the `web:` prefix on the Chinese page too — it is a scope marker, not translated text. + Upstream language rule: `gen-changesets` requires changelog entries to be English. If the upstream CLI changelog contains a non-English entry, stop and report it to the user. Do not silently rewrite it while syncing docs. Public-text rule: do not copy real internal endpoints, key names, account names, or service names into docs changelogs. Replace examples with neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY` while preserving the user-visible meaning. -### 3. Classify Entries +### 4. Merge, Deduplicate, And Classify Entries + +Before classifying, merge related entries and drop redundant ones from the user-facing changelog: + +- **Merge micro-tweaks to the same surface.** Collapse several small tweaks to the same UI area or feature into one concise entry at the higher level. For example, "change the composer's default height" and "change the composer's default font" merge into "Polish the composer's default styling." Use the most specific common ancestor (composer, settings page, tool card, and so on). Classify the merged entry by its combined effect, and keep the `web:` prefix if the combined change is still web-facing. +- **Merge same-surface or same-kind fixes when you have three or more.** The `Bug Fixes` section tends to accumulate many narrow UI/polish fixes that read as noise when listed one by one. When three or more fixes target the same area (for example several tool cards in the TUI, or the web session/conversation surface) or the same class of problem (for example several "jumping/flickering/collapsing during streaming" fixes), merge them into one higher-level entry. Examples: + - "Fix the Bash tool card collapsing...", "Fix the Edit tool card jumping in height...", "Fix the Edit tool card flickering while its result streams in" → "Fix several TUI tool cards jumping, flickering, or collapsing in height when results stream in or end with short output." + - "Fix the collapsed sidebar not hiding...", "Stop the chat history from replaying its entrance animation...", "Fix tool components jumping the conversation when expanded/collapsed" → "web: Fix several layout and display glitches when switching sessions, including the collapsed sidebar not hiding, the chat history replaying its entrance animation, and tool components jumping the conversation." + - Keep `web:` if the merged fixes are all web-facing. Classify as `Bug Fixes`. + - **Do not over-merge.** Leave a fix standalone when it is broad, high-value, or genuinely distinct (for example model/provider tool-calling bugs, session-list corruption, file-completion gaps). Merging is for low-reader-value, similar-shape fixes that read as a wall of similar bullets. +- **Drop server/API plumbing covered by a web entry.** If one entry adds a web UI feature (for example, an Archived sessions page) and another entry only adds the server or REST/WebSocket endpoints that exist solely to power that web feature, keep the `web:` entry and drop the API entry. CLI and web users perceive the web page; the backing API is implementation detail with no independent user value on this changelog. Keep the API entry only when it has independent user value — a new public endpoint that SDK or server consumers call directly, or a capability usable outside the web feature. When unsure, keep both and let the reviewer decide. The docs changelog uses five section types: | English section | Chinese section | Meaning | |---|---|---| | `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before | -| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | | `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities | +| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | | `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames | | `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts | @@ -114,6 +163,8 @@ Classification process: Features vs. Polish: ask whether the entry introduces something the user could not do before. If yes (new command, flag, mode, viewer, or capability), use `Features`. If it only improves an existing surface (a UI panel that already existed, an existing prompt, an existing tool card, an existing payload pipeline), use `Polish`. Verbs like `Add` do not automatically mean `Features` — a small visual addition to an existing UI is still polish. +Default-behavior changes: changing the default value of an existing capability (for example flipping a feature on by default) is usually `Polish`, because the capability already existed. Use `Features` only when the new default materially changes the out-of-box experience for most users in a way they could not get before. When genuinely ambiguous, flag it and confirm with the reviewer rather than guessing. + Keyword hints: - **Features**: `Add ... command/flag/option/mode/viewer`, `Introduce`, `Support`, `Allow`, `Enable`, `Implement`, `New ... command/flag/option` @@ -125,18 +176,19 @@ Keyword hints: Within each version, section order is: ```text -Features → Bug Fixes → Polish → Refactors → Other +Features → Polish → Bug Fixes → Refactors → Other ``` Omit empty sections. Within each section, order entries by reader value, not upstream order: 1. Put the most valuable, obvious, and larger changes first. 2. Prefer broad user-visible features, workflow-changing fixes, high-frequency bugs, and large cross-cutting improvements over small polish, narrow edge cases, and internal cleanup. -3. If entries have similar value, preserve upstream order. +3. Within `Polish`, put directly user-visible UX or performance improvements (something users can see or feel) before protocol or internal-behavior adjustments (something that makes the model or pipeline behave more reliably but is invisible to users). +4. If entries have similar value, preserve upstream order. Do not reword or exaggerate entries just to make them look more important; only reorder existing entries. -### 4. Write The English Page +### 5. Write The English Page Never change the English page header: @@ -177,7 +229,7 @@ Example: - Update the native release workflow to use current GitHub artifact actions. ``` -### 5. Translate The Increment Into Chinese +### 6. Translate The Increment Into Chinese After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`. @@ -205,7 +257,54 @@ Chinese page requirements: - Translate only entry body text. Do not add entries that are not present in English. - Follow `docs/AGENTS.md` for Chinese typography: full-width punctuation, spaces between Chinese and English, and the glossary. -### 6. Verify +#### Chinese wording style + +Structural fidelity does not mean literal translation. The Chinese entries should read like a concise, idiomatic Chinese changelog. Keep the same facts as the English entry, but rephrase for natural Chinese prose. + +Guidelines: + +- **One entry, one sentence.** Avoid chaining multiple effects with commas or semicolons. If the English entry is long, split it into shorter sentences or keep only the most important effect. +- **Drop SDK-only and provider-internal detail.** Apply the trim from step 3 while translating: keep the user-facing effect and required constraints, drop SDK-mapping sentences, provider / wire-format mechanics, and internal XML markers. A long internal entry should collapse to one short Chinese sentence about what the user gets. +- **Prefer common changelog verbs**: 新增、支持、修复、优化、改进、调整. +- **Avoid indirect "through... make..." structures**. Do not write "通过 X,使 Y"; prefer direct cause-effect or just state the result. + - Bad: `通过缓存已渲染消息行,使终端在长篇对话中保持响应。` + - Better: `缓存已渲染消息行,提升长对话下终端的响应速度。` +- **Be specific, not vague**. Prefer concrete actions over abstract quality words. + - Bad: `加固默认系统提示词和内置工具描述。` + - Better: `优化默认系统提示词与内置工具描述,避免 Agent 阻塞后台任务。` +- **Name concrete files or config keys when it helps clarity**. + - Bad: `插件现在可以在其清单中声明 hooks。` + - Better: `插件现支持在 kimi.plugin.json 中声明生命周期 hooks。` +- **Include required argument placeholders in CLI options**. + - Bad: `--allowed-host` + - Better: `--allowed-host ` +- **Keep usage hints to one short clause**. + - Bad: `传入 --allowed-host 以允许额外的 host。例如 ... (多句展开)` + - Better: `例如 kimi web --allowed-host example.com。` +- **Do not translate technical identifiers**: keep command names, flag names, file names, env vars, config keys, and the `web:` scope prefix as-is. +- **Keep parallel rhythm within a section.** When several entries fix similar web surfaces (layout, animation, sizing), phrase them with a consistent structure (for example 修复 <问题>,现 <行为>) so the section reads as a tidy list rather than a mix of shapes. + +Example — translating a feature entry: + +English source: + +```markdown +- Add a --allowed-host flag to kimi web that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host to allow an extra host. +``` + +Before (literal, wordy): + +```markdown +- 为 `kimi web` 新增 `--allowed-host` 标志,允许额外的 Host 请求头值通过 DNS 重绑定检查,并在 403 错误消息中包含允许指引。传入 `--allowed-host ` 以允许额外的 host。例如 `kimi web --allowed-host example.com`。 +``` + +After (concise, idiomatic): + +```markdown +- `kimi web` 新增 `--allowed-host ` 选项,可将指定 Host 加入 DNS 重绑定白名单;403 错误会提示如何通过 `--allowed-host` 或 `KIMI_CODE_ALLOWED_HOSTS` 放行,例如 `kimi web --allowed-host example.com`。 +``` + +### 7. Verify Review: @@ -221,6 +320,7 @@ Check: - Each section has the same number of entries on both pages. - Within each section, the most valuable, obvious, and larger entries appear before smaller or narrower entries. - PR links and commit hashes were stripped. +- No `Thanks ...!` credit remains (remove it every time). - Real internal identifiers were replaced with neutral placeholders. - There are no empty sections. - Markdown indentation and blank lines are intact. @@ -231,7 +331,36 @@ Then run the docs build: pnpm --filter docs run build ``` -### 7. Commit +### 8. Human Review Checkpoint + +After verification passes, **before committing**, ask the user whether they want to review the sync result. Use `AskQuestion` with options such as: + +- **Review first** — show the diff and wait for the user to finish checking. +- **Skip review, commit and open PR** — proceed directly to steps 9 and 10. + +If the user chooses review: + +1. Show the uncommitted diff: + + ```bash + git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md + ``` + +2. Summarize synced versions, section counts, and anything that needed manual classification. +3. Tell the user to reply when they are done reviewing, or to ask for edits. +4. Do **not** commit, push, or open a PR until the user explicitly says review is complete, or asks to proceed. + +If the user requests edits during review, make the changes, re-run verification from step 7, and return to this checkpoint. + +### 9. Commit + +Only run this step when the user skipped review or confirmed review is complete. + +Stage only the changelog docs files: + +```bash +git add docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md +``` Use a neutral docs-sync commit message: @@ -241,12 +370,66 @@ docs(changelog): sync from apps/kimi-code/CHANGELOG.md Do **not** create a changeset for changelog docs sync. Docs sync does not enter the bundle. +### 10. Push And Open PR + +Run immediately after step 9. + +Push the branch: + +```bash +git push -u origin HEAD +``` + +Create the PR with `gh pr create`. Title follows Conventional Commits: + +```text +docs(changelog): sync from apps/kimi-code/CHANGELOG.md +``` + +Fill in `.github/pull_request_template.md`. For changelog sync PRs: + +- **Related Issue**: write `N/A — post-release docs maintenance` (no issue required). +- **Problem**: the docs-site changelog is behind the published CLI release(s). +- **What changed**: list synced version(s), note English source + Chinese translation, and mention verification (`pnpm --filter docs run build`). +- **Checklist**: check CONTRIBUTING; explain no issue, no tests, no changeset, and that `gen-docs` is not needed because this is the dedicated changelog sync flow. + +Example body: + +```markdown +## Related Issue + +N/A — post-release docs maintenance + +## Problem + +The docs-site changelog has not yet been synced for `` after the npm release. + +## What changed + +- Synced `` from `apps/kimi-code/CHANGELOG.md` into `docs/en/release-notes/changelog.md` +- Translated the new English increment into `docs/zh/release-notes/changelog.md` +- Verified with `pnpm --filter docs run build` + +## Checklist + +- [x] I have read the CONTRIBUTING document. +- [x] I have linked a related issue, or explained the problem above. +- [ ] I have added tests that prove my feature works. (N/A — docs-only sync) +- [x] Ran `gen-changesets` skill, or this PR needs no changeset. (No changeset — docs sync is out of bundle) +- [x] Ran `gen-docs` skill, or this PR needs no doc update. (This PR is the dedicated changelog sync) +``` + +Return the PR URL to the user when done. + ## Rules - The English docs changelog is the source of truth. - Never edit upstream `apps/kimi-code/CHANGELOG.md`. - Do not backfill unreleased `.changeset/*.md` drafts into the docs site. +- Prefix web UI entries with `web: ` (when the upstream commit touches `apps/kimi-web/`), and keep the prefix on both the English and Chinese pages. - If upstream wording is wrong, leave upstream alone and fix it in a future changeset. +- Always sync on a `docs/changelog-sync-*` branch and open a PR; never push changelog docs sync directly to `main`. +- Wait for the human review checkpoint before committing, pushing, or opening a PR. ## Common Mistakes @@ -254,6 +437,10 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter |---|---| | Adding entries directly to the English docs page without reading upstream | Use `apps/kimi-code/CHANGELOG.md` as the source | | Copying PR links or commit hashes into docs | Strip them; keep only body text | +| Leaving the `Thanks ...!` credit in docs | Remove it every time, including the multi-author form | +| Leaving near-duplicate micro-tweaks as separate bullets | Merge small tweaks to the same surface into one higher-level entry (e.g. composer height + font → composer's default styling) | +| Listing many narrow fixes to the same surface as separate bullets | When three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry; keep genuinely distinct or high-value fixes standalone | +| Listing a server/API entry that only backs a web feature already listed | Drop the API entry and keep the `web:` entry, unless the API has independent user value | | Rewording upstream English entries | Upstream is frozen; copy the body text unless the user explicitly asks otherwise | | Leaving English text untranslated in the Chinese page | The Chinese page must be fully Chinese except preserved technical terms | | Editing upstream changelog text | Do not edit upstream | @@ -268,8 +455,11 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter | Putting everything under Other for convenience | Classify what can be classified first | | Translating tool names, command names, or config keys | Keep them as written | | Creating a changeset for docs sync | Do not create one | +| Committing or pushing directly on `main` | Create `docs/changelog-sync-`, commit there, then open a PR | +| Committing or opening a PR before the user skips review or confirms review is done | Wait at the human review checkpoint | | Using curly quotes or half-width Chinese punctuation | Follow `docs/AGENTS.md` | | Omitting the release date from a version heading, or guessing it | Add ` (YYYY-MM-DD)` (full-width `()` in Chinese) taken from the published tag | +| Forgetting or translating the `web:` prefix on web UI entries | Prefix web UI entries (commit touches `apps/kimi-web/`) with `web: ` on both pages; keep the prefix as-is when translating | ## Stop Signals @@ -279,3 +469,5 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter - English and Chinese versions, entry counts, or section sets do not match. - A section is empty. - A Chinese term is uncertain and `docs/AGENTS.md` does not answer it. +- A `docs/changelog-sync-*` branch already exists for the same version and you cannot confirm whether it is stale. +- The user asked to review but has not yet confirmed review is complete. diff --git a/.changeset/README.md b/.changeset/README.md index 9dfaa2c8e..3afeb3bdf 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -15,11 +15,15 @@ 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/vis` - `@moonshot-ai/vis-server` - `@moonshot-ai/vis-web` diff --git a/.changeset/config.json b/.changeset/config.json index af6143de9..9aaa6ce29 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": [], @@ -9,7 +9,8 @@ "ignore": [ "@moonshot-ai/vis", "@moonshot-ai/vis-server", - "@moonshot-ai/vis-web" + "@moonshot-ai/vis-web", + "@moonshot-ai/kimi-inspect" ], "snapshot": { "useCalculatedVersion": true, diff --git a/.changeset/fix-afk-naming.md b/.changeset/fix-afk-naming.md new file mode 100644 index 000000000..864b62139 --- /dev/null +++ b/.changeset/fix-afk-naming.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Rename the stale "afk" reference to "auto" in the built-in MCP config skill guidance. diff --git a/.changeset/fix-permission-copy-cli-acp.md b/.changeset/fix-permission-copy-cli-acp.md new file mode 100644 index 000000000..e1cef2793 --- /dev/null +++ b/.changeset/fix-permission-copy-cli-acp.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Correct the YOLO and Auto permission mode descriptions in CLI --help output and in the ACP session mode selector shown by IDE clients. diff --git a/.changeset/fix-permission-copy-web.md b/.changeset/fix-permission-copy-web.md new file mode 100644 index 000000000..21a939306 --- /dev/null +++ b/.changeset/fix-permission-copy-web.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Correct the YOLO and Auto permission mode descriptions in the slash command list and the mobile permission sheet. diff --git a/.changeset/fix-permission-mode-copy.md b/.changeset/fix-permission-mode-copy.md new file mode 100644 index 000000000..6144060ec --- /dev/null +++ b/.changeset/fix-permission-mode-copy.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the YOLO and Auto permission mode descriptions to match their actual behavior: YOLO auto-approves tool actions but the agent may still ask questions, while Auto is fully autonomous and never asks. diff --git a/.changeset/fix-yolo-replay-copy.md b/.changeset/fix-yolo-replay-copy.md new file mode 100644 index 000000000..cd7c14ba6 --- /dev/null +++ b/.changeset/fix-yolo-replay-copy.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Correct the YOLO mode notice shown when replaying a session: tool actions are auto-approved, but the agent may still ask questions. 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..07da0bd4f 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,11 @@ jobs: echo "Typechecking ${config}" pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit done + - name: Typecheck VS Code extension + run: pnpm --filter kimi-code run typecheck + - 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/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 86de76975..fcce360b5 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -36,6 +36,9 @@ jobs: - name: Build package dependencies run: pnpm run build:packages + - name: Build Kimi web assets + run: pnpm --filter @moonshot-ai/kimi-web run build + - name: Generate Kimi Code built-in catalog shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f74dc9d9..7b96653ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Upgrade npm for Trusted Publishing - run: npm install -g npm@latest + run: npm install -g npm@11 - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index df529a2c6..e62f42128 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,46 @@ 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/ +!apps/vscode/.vscode/ +!apps/vscode/.vscode/*.json +apps/vscode/artifacts/ + +Dockerfile +docker-compose.yml +.dockerignore + +docs/superpowers/ +reports/ +.superpowers/ +/plan/ + +# Agent scratch / throwaway files - do not commit +.tmp/ +HANDOVER*.md +HANDOFF*.md +handoff.md +handover.md +*-designs.html +*-design.html +*-mockup.html +*-demo.html +*-demos.html diff --git a/.oxlintrc.json b/.oxlintrc.json index 77a86ac9a..003359f31 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,7 +150,7 @@ "node_modules/", "apps/*/scripts/", "docs/smoke-archive/", - "plugins/curated/superpowers/", + "packages/pi-tui/", "*.generated.ts" ] } diff --git a/AGENTS.md b/AGENTS.md index ffe93c6aa..671555887 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,13 +15,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`. 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. +- `apps/kimi-inspect`: web inspector for the v2 (kap-server) `/api/v2` surface — workspace/session browser, per-session chat, and live Service panels (data + trigger buttons) for the Session and Agent scopes. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v2`, `WsChannel` over the shared `/api/v2/ws` socket for events), typed by `agent-core-v2` Service interfaces; `GET {rpcBasePath}/channels` loads every wire protocol 1:1 — probing `/api/v1/debug` first (dev, whitelist-free) and falling back to `/api/v2`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. +- `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`. With `--debug-endpoints` on a loopback bind it additionally mounts `/api/v1/debug/*` — the same reflection dispatcher as `/api/v2` but without the channel whitelist (every scoped Service callable, `src/transport/registerDebugRoutes.ts`); internal only, repo dev scripts pass the flag. +- `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/http|ipc|memory`); all three return the same `Klient`. The package also hosts the e2e suites: dual-backend session/agent suites (`test/e2e/dual/`, in-memory + in-process server), `/api/v2` wire tests (`test/e2e/v2/`), the legacy `/api/v1` live suites (`test/e2e/legacy/`), and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. ## Environment Requirements @@ -32,9 +36,11 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Monorepo Workspace Maintenance - `pnpm-workspace.yaml` is the source of truth for workspace membership, but `flake.nix` also contains **hardcoded** `workspacePaths` and `workspaceNames` lists. -- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix`.** +- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix` — for every package, including leaf / test / e2e packages that nothing depends on.** + - `pnpm-workspace.yaml` uses globs (`packages/*`, `apps/*`), so most packages land there automatically; `flake.nix` is fully manual and is where omissions happen. - Missing a path in `flake.nix`'s `workspacePaths` will silently drop files from the Nix build's `src` fileset. - Missing a name in `flake.nix`'s `workspaceNames` will break `pnpmConfigHook` because dependencies for that workspace will not be fetched. +- The automated "Check flake.nix workspace sync" (`scripts/check-nix-workspace.mjs`) only validates the transitive dependency **closure of `@moonshot-ai/kimi-code`**. A leaf package outside that closure (e.g. an e2e package nobody imports) slips through even when it is missing from `flake.nix`. A green check is therefore NOT proof that `flake.nix` is fully in sync — keep it updated by hand on every add/remove, do not rely on the check to catch omissions. ## General Coding Rules @@ -72,3 +78,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - After finishing a task and before submitting a PR, you must run the `gen-changesets` skill (see `.agents/skills/gen-changesets/SKILL.md`) and generate a changeset under `.changeset/` according to its rules. - When generating a changeset, **never** decide on a `major` bump on your own. When you judge a change to meet the major criteria (breaking changes, incompatible user configuration, renamed or removed commands/arguments, changed behavior semantics, etc.), you must stop and explain it to the user and ask for confirmation. **Only write `major` after the user has explicitly agreed.** Otherwise default to `minor` (and fall back to `patch` if `minor` is unclear). See the "Hard rule: confirm with the user before writing `major`" section in `.agents/skills/gen-changesets/SKILL.md` for details. - Prefer importing via `import ... from '#/...'`, which serves the same purpose as `import ... from '@/...'`. +- Do not commit throwaway scratch or exploratory files. Never stage: + - Agent working notes or handoff/summary documents (e.g. `HANDOVER-*.md`, `HANDOFF-*.md`, `handoff.md`). + - Throwaway UI/UX prototypes or design mockups (e.g. `*-designs.html`, `*-mockup.html`, `*-demo(s).html`) at the repo root or under a `design/` folder. The only tracked `.html` files should be Vite `index.html` entrypoints. + Before committing or opening a PR, run `git status` and `git diff --staged --stat` and remove anything matching these patterns. Put scratch work under `.tmp/` (gitignored) instead of the repo root or the source tree. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/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/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore index c559b54c0..901b7a6d2 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -6,3 +6,6 @@ agents/ # next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh # clone (before any build has produced the `.ts`). src/generated/vis-web-asset.ts + +# Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs +native/ diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 1d6fb5fac..ef5c9e17a 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,1095 @@ # @moonshot-ai/kimi-code +## 0.27.0 + +### Minor Changes + +- [#1822](https://github.com/MoonshotAI/kimi-code/pull/1822) [`a5c568d`](https://github.com/MoonshotAI/kimi-code/commit/a5c568dc7a84962bae70a16858709c453fc90a07) Thanks [@liruifengv](https://github.com/liruifengv)! - Add the /copy slash command to copy the last assistant message to the clipboard. + +- [#1824](https://github.com/MoonshotAI/kimi-code/pull/1824) [`bfecd01`](https://github.com/MoonshotAI/kimi-code/commit/bfecd0128fe7d88971a84095e24ef8a56ba34e71) Thanks [@liruifengv](https://github.com/liruifengv)! - Using an API key for Kimi coding models now also fetches the latest model list automatically. + +### Patch Changes + +- [#1811](https://github.com/MoonshotAI/kimi-code/pull/1811) [`cec15e2`](https://github.com/MoonshotAI/kimi-code/commit/cec15e2188b24e0f904e5ca660a2e72c06364647) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix Esc and Ctrl+C cancelling compaction instead of closing an open /btw panel. + +- [#1806](https://github.com/MoonshotAI/kimi-code/pull/1806) [`9b49694`](https://github.com/MoonshotAI/kimi-code/commit/9b496946dcb3c7fa9507e6d5c251c1941e44a316) Thanks [@sailist](https://github.com/sailist)! - Mount the dev-only /api/v1/debug RPC surface behind the --debug-endpoints flag, exposing every scoped service for local debugging on loopback binds. Pass --debug-endpoints to kimi server run to enable it. + +- [#1788](https://github.com/MoonshotAI/kimi-code/pull/1788) [`365ba00`](https://github.com/MoonshotAI/kimi-code/commit/365ba0001de206863ff1de8e106c85d7f187c192) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix `/export-debug-zip` and `kimi export` overwriting the previous ZIP archive when run repeatedly on the same session; the default export filename now includes a timestamp. + +- [#1840](https://github.com/MoonshotAI/kimi-code/pull/1840) [`fa7e4ba`](https://github.com/MoonshotAI/kimi-code/commit/fa7e4ba4218703bb1ef3112ab2493496983b0539) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix AGENTS.md files installed as symbolic links being ignored by the web backend. + +- [#1829](https://github.com/MoonshotAI/kimi-code/pull/1829) [`1b907b0`](https://github.com/MoonshotAI/kimi-code/commit/1b907b07cdcc0e9cba5203fe40dacae85a4b768d) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix whitespace-only thinking content rendering as a blank bullet line in the transcript, both while streaming and when replaying session history. + +- [#1809](https://github.com/MoonshotAI/kimi-code/pull/1809) [`56a321d`](https://github.com/MoonshotAI/kimi-code/commit/56a321d4d127c0b4cf7a3e15e2959ebf3eded192) Thanks [@sailist](https://github.com/sailist)! - web: Fix duplicate workspace groups on Windows when the same folder is opened with different path spellings, such as a different drive-letter casing; all of the folder's sessions now list under the single merged group. + +- [#1847](https://github.com/MoonshotAI/kimi-code/pull/1847) [`56ba8e0`](https://github.com/MoonshotAI/kimi-code/commit/56ba8e0196a3053ad1115a7e8f8b8c4c0cd1b320) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix LaTeX formulas rendering as garbled overlapping text when the web UI is accessed over the network; the server's content security policy now allows the inline styles that math and code highlighting rely on, while scripts remain strictly restricted. + +- [#1816](https://github.com/MoonshotAI/kimi-code/pull/1816) [`44f3341`](https://github.com/MoonshotAI/kimi-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Harden the embedded key-value engine's durability: WAL compaction now always terminates under sustained write storms instead of chasing the tail forever, a committed write can no longer slip through a compaction rotation undetected, torn WAL tails no longer misplace later disk-mode value pointers, read-only opens never create or modify database files or compact under a live writer, corrupt index-definition files no longer force a full rebuild, stale compaction temp files are cleaned on open, and the process lock can no longer be taken over by several processes at once. + +- [#1816](https://github.com/MoonshotAI/kimi-code/pull/1816) [`44f3341`](https://github.com/MoonshotAI/kimi-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Speed up the embedded key-value engine under stress: queries with skip/limit now stream candidates instead of decoding every match first, LRU eviction picks victims in O(1) instead of scanning every key, bursts of simultaneously expired TTL keys are drained within seconds, existence checks and size counting no longer read values when they only need metadata, and one oversized token can no longer poison the full-text index. + +- [#1816](https://github.com/MoonshotAI/kimi-code/pull/1816) [`44f3341`](https://github.com/MoonshotAI/kimi-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Cluster readers of the embedded key-value engine now catch up incrementally by replaying only newly appended WAL frames after another process writes, instead of fully reopening the shard on every read; cross-process read latency drops by orders of magnitude at larger shard sizes, and readers still fall back to a full reopen after WAL rotation or truncation. + +- [#1816](https://github.com/MoonshotAI/kimi-code/pull/1816) [`44f3341`](https://github.com/MoonshotAI/kimi-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Keep the embedded key-value engine writable when a WAL compaction rotation fails mid-way instead of wedging it until reopen, stop a rolled-back write from erasing a concurrently committed value for the same key, let the RESP server survive aborted connections, recover after oversized requests, and answer each pipelined command independently, and keep the previous full-text index intact when a postings rebuild fails. + +- [#1808](https://github.com/MoonshotAI/kimi-code/pull/1808) [`b53e00d`](https://github.com/MoonshotAI/kimi-code/commit/b53e00db91872efc602743d07d2283f7938eaea2) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Include the underlying network cause (DNS failure, refused connection, TLS or timeout errors) in OAuth connection error messages instead of a bare "fetch failed". + +- [#1790](https://github.com/MoonshotAI/kimi-code/pull/1790) [`373abb0`](https://github.com/MoonshotAI/kimi-code/commit/373abb02f03ef817e2e1937e1cdc4423ef0cd149) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix repeated request rejections after an interrupted model response by recording tool calls that never ran and closing them with an interrupted result. + +- [#1791](https://github.com/MoonshotAI/kimi-code/pull/1791) [`3144972`](https://github.com/MoonshotAI/kimi-code/commit/31449728b72df94e22bcb2de350a1e7624895e30) Thanks [@sailist](https://github.com/sailist)! - Fix the built-in URL fetch tool's network safeguards: crafted domains and redirect chains can no longer reach loopback or internal network services. + +- [#1787](https://github.com/MoonshotAI/kimi-code/pull/1787) [`319001a`](https://github.com/MoonshotAI/kimi-code/commit/319001ae5cde6df383579214a126564b9ed2b114) Thanks [@sailist](https://github.com/sailist)! - web: Remove per-workspace git repo badges and branch labels; branch, PR, and diff status remain shown for the active session. + +- [#1838](https://github.com/MoonshotAI/kimi-code/pull/1838) [`9e12484`](https://github.com/MoonshotAI/kimi-code/commit/9e1248416faa22d9f0b777b91ad092bbf1e19182) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Remember the thinking level per model, fixing an empty and unresponsive thinking picker when the active model does not support a previously stored level. + +- [#1833](https://github.com/MoonshotAI/kimi-code/pull/1833) [`03021b6`](https://github.com/MoonshotAI/kimi-code/commit/03021b6db7166c750dd34043edaa85c423d3202f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix queued messages silently re-sending previously uploaded files when a session is reopened. + +## 0.26.0 + +### Minor Changes + +- [#1776](https://github.com/MoonshotAI/kimi-code/pull/1776) [`ffaf0b9`](https://github.com/MoonshotAI/kimi-code/commit/ffaf0b98ca76bb90ba9c989256441dceb468d85f) Thanks [@sailist](https://github.com/sailist)! - Expand the coder subagent tool set to include background tasks, todo lists, plan mode, skill invocation, and nested agents, mirroring the main agent's capabilities; a subagent run also waits for its background tasks to settle before reporting completion. Applies automatically to coder subagents launched through the Agent tool. + +### Patch Changes + +- [#1771](https://github.com/MoonshotAI/kimi-code/pull/1771) [`b513975`](https://github.com/MoonshotAI/kimi-code/commit/b5139757e2df1b5b8723d4bab5137266f5eb0f01) Thanks [@liruifengv](https://github.com/liruifengv)! - Optimize the unit formatting of the context usage display. + +- [#1765](https://github.com/MoonshotAI/kimi-code/pull/1765) [`d531398`](https://github.com/MoonshotAI/kimi-code/commit/d531398d0143cd3b0a2f4a099ff537894c9245e9) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix Kimi-provider models routed through the Anthropic protocol incorrectly showing reasoning effort options. Effort choices now come only from the model's declared metadata, and the inferred fallback profile applies solely to non-Kimi Anthropic-compatible providers. + +- [#1774](https://github.com/MoonshotAI/kimi-code/pull/1774) [`3d5d630`](https://github.com/MoonshotAI/kimi-code/commit/3d5d630c12ea71fb7066e8018dfa2cb6d42da3e8) Thanks [@RealKai42](https://github.com/RealKai42)! - Honor an explicit thinking "off" on OpenAI-compatible (chat completions) providers: it used to be indistinguishable from "never configured", so the history-based auto `reasoning_effort` injection kept the model reasoning (and could leak the field to models that reject it). The provider now also reports the actual current thinking effort ("on"/"off") instead of recording "off" for both. + +- [#1766](https://github.com/MoonshotAI/kimi-code/pull/1766) [`7042af3`](https://github.com/MoonshotAI/kimi-code/commit/7042af3571dbfbf5600535a56692434b84afb4ce) Thanks [@kermanx](https://github.com/kermanx)! - web: Fix the sidebar resize handle being covered by the chat composer background. + +- [#1769](https://github.com/MoonshotAI/kimi-code/pull/1769) [`d1ca65e`](https://github.com/MoonshotAI/kimi-code/commit/d1ca65e1de189617e9edbc54010e62d472a1de3d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Keep legacy migrations idempotent across multiple Kimi homes and report damaged or unmapped sessions instead of silently skipping them. + +- [#1763](https://github.com/MoonshotAI/kimi-code/pull/1763) [`81414b6`](https://github.com/MoonshotAI/kimi-code/commit/81414b6ad5eddb64bbd959a8754ffb6c20b4f6fe) Thanks [@liruifengv](https://github.com/liruifengv)! - Warn in the /model and /effort pickers that switching invalidates the existing prompt cache, and hint to use /new to avoid extra token costs. + +- [#1773](https://github.com/MoonshotAI/kimi-code/pull/1773) [`1169a6d`](https://github.com/MoonshotAI/kimi-code/commit/1169a6d5fdafca4c1455c3ac4889586ef42f4435) Thanks [@RealKai42](https://github.com/RealKai42)! - Replay empty thinking content verbatim instead of substituting a placeholder space on Anthropic-compatible and Kimi preserved-thinking endpoints. + +- [#1781](https://github.com/MoonshotAI/kimi-code/pull/1781) [`09e8554`](https://github.com/MoonshotAI/kimi-code/commit/09e855401be62431b967dcb3b7caf1bcc9705df5) Thanks [@kermanx](https://github.com/kermanx)! - Report when users stop tasks and preserve other stop reasons in model context. + +- [#1784](https://github.com/MoonshotAI/kimi-code/pull/1784) [`d465591`](https://github.com/MoonshotAI/kimi-code/commit/d465591eb3fdb30c0c0348d6edb6f4d3d2f72698) Thanks [@sailist](https://github.com/sailist)! - Fix a resumed session being marked as just updated and jumping to the top of the session list without any new activity. + +- [#1759](https://github.com/MoonshotAI/kimi-code/pull/1759) [`9e3e670`](https://github.com/MoonshotAI/kimi-code/commit/9e3e6700f9276f4ab60219897b297fc96be2355a) Thanks [@sailist](https://github.com/sailist)! - Fix a race where resuming a background subagent right after it was manually stopped could fail with an "already running" error. + +- [#1782](https://github.com/MoonshotAI/kimi-code/pull/1782) [`072eed4`](https://github.com/MoonshotAI/kimi-code/commit/072eed476b5fe7599d994783649a21083320df58) Thanks [@sailist](https://github.com/sailist)! - Fix the context size indicator under-reporting the model's actual context usage. + +- [#1769](https://github.com/MoonshotAI/kimi-code/pull/1769) [`d1ca65e`](https://github.com/MoonshotAI/kimi-code/commit/d1ca65e1de189617e9edbc54010e62d472a1de3d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Support in-process editor hosts with session lifecycle, context, MCP configuration, and cross-platform session storage APIs. + +- [#1772](https://github.com/MoonshotAI/kimi-code/pull/1772) [`78967e2`](https://github.com/MoonshotAI/kimi-code/commit/78967e283d28238337e6437b4824f67b2c3cea7d) Thanks [@sailist](https://github.com/sailist)! - web: Refresh the model catalog for all providers when opening the model picker, so newly available models always show up. + +## 0.25.0 + +### Minor Changes + +- [#1731](https://github.com/MoonshotAI/kimi-code/pull/1731) [`0b790cd`](https://github.com/MoonshotAI/kimi-code/commit/0b790cdc056475593abd572f657d010504caf752) Thanks [@sailist](https://github.com/sailist)! - web: Allow attaching any file type in chat; files the model cannot consume inline (documents, SVG images, archives, …) are uploaded to the server and given to the model as a file path it can read on demand. + +### Patch Changes + +- [#1746](https://github.com/MoonshotAI/kimi-code/pull/1746) [`918c135`](https://github.com/MoonshotAI/kimi-code/commit/918c1354d9ff4a7dc66a02ede3a504d19e1f53d1) Thanks [@RealKai42](https://github.com/RealKai42)! - Honor adaptive_thinking = false on Anthropic-compatible models by limiting thinking efforts to the legacy budget set and omitting the effort parameter from requests. + +- [#1746](https://github.com/MoonshotAI/kimi-code/pull/1746) [`918c135`](https://github.com/MoonshotAI/kimi-code/commit/918c1354d9ff4a7dc66a02ede3a504d19e1f53d1) Thanks [@RealKai42](https://github.com/RealKai42)! - Apply official Anthropic effort profiles and a 128k output fallback for unknown models. Preserve compatible-provider thinking history across session resumes and model switches, normalize incomplete stream events, and warn on unlisted efforts. + +- [#1746](https://github.com/MoonshotAI/kimi-code/pull/1746) [`918c135`](https://github.com/MoonshotAI/kimi-code/commit/918c1354d9ff4a7dc66a02ede3a504d19e1f53d1) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix custom-named models on Anthropic-compatible providers starting new sessions with thinking effort off instead of the model default, and not showing the thinking control in ACP clients. + +- [#1757](https://github.com/MoonshotAI/kimi-code/pull/1757) [`f0c8a10`](https://github.com/MoonshotAI/kimi-code/commit/f0c8a103c620b4a66761c0f34c1a8cc7ece9b86c) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix the diagnostic log missing the actual error when the CLI exits unexpectedly. + +- [#1731](https://github.com/MoonshotAI/kimi-code/pull/1731) [`0b790cd`](https://github.com/MoonshotAI/kimi-code/commit/0b790cdc056475593abd572f657d010504caf752) Thanks [@sailist](https://github.com/sailist)! - Fix the Content-Security-Policy on non-loopback server binds blocking the web UI's theme bootstrap script and bundled fonts, and tighten the policy with explicit form-action, base-uri, and frame-ancestors directives. + +- [#1758](https://github.com/MoonshotAI/kimi-code/pull/1758) [`1d7c205`](https://github.com/MoonshotAI/kimi-code/commit/1d7c205e8397983d3d79e59704db3f67a0c72937) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix the CLI exiting unexpectedly when reading an image from the clipboard fails; it now falls back to pasting text. + +- [#1753](https://github.com/MoonshotAI/kimi-code/pull/1753) [`d8ddabb`](https://github.com/MoonshotAI/kimi-code/commit/d8ddabb605c1f6fdcfa9fade8cc09b5f8c93651f) Thanks [@sailist](https://github.com/sailist)! - Fix the web server bearer-token check being bypassed by percent-encoded API paths (e.g. `/%61pi/v1/…`), which allowed unauthenticated access to every API route. + +- [#1758](https://github.com/MoonshotAI/kimi-code/pull/1758) [`1d7c205`](https://github.com/MoonshotAI/kimi-code/commit/1d7c205e8397983d3d79e59704db3f67a0c72937) Thanks [@RealKai42](https://github.com/RealKai42)! - Report crash telemetry for unhandled promise rejections, so exits they cause are no longer invisible. + +- [#1753](https://github.com/MoonshotAI/kimi-code/pull/1753) [`d8ddabb`](https://github.com/MoonshotAI/kimi-code/commit/d8ddabb605c1f6fdcfa9fade8cc09b5f8c93651f) Thanks [@sailist](https://github.com/sailist)! - Fix the session filesystem API following symlinks that point outside the workspace, which allowed reading, listing, creating, and downloading host files beyond the session directory through a planted symlink. + +- [#1753](https://github.com/MoonshotAI/kimi-code/pull/1753) [`d8ddabb`](https://github.com/MoonshotAI/kimi-code/commit/d8ddabb605c1f6fdcfa9fade8cc09b5f8c93651f) Thanks [@sailist](https://github.com/sailist)! - Fix sessions failing to be created when the workspace directory is given through a symlink, which the v2 engine rejected as "not a directory". + +- [#1754](https://github.com/MoonshotAI/kimi-code/pull/1754) [`1186686`](https://github.com/MoonshotAI/kimi-code/commit/11866865544b8ec88330372b8582e97a35113308) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix completed background subagents losing their final output after a session reload, and retry the output backfill when a transient fetch failure occurs. + +- [#1755](https://github.com/MoonshotAI/kimi-code/pull/1755) [`4f99114`](https://github.com/MoonshotAI/kimi-code/commit/4f99114342da11ebf7a403e3af6e0cf2c8cca431) Thanks [@kermanx](https://github.com/kermanx)! - Move the server's v1 wire schema definitions into the engine domains and the server package, removing the shared schema package from the v2 server stack with no behavior change. + +- [#1731](https://github.com/MoonshotAI/kimi-code/pull/1731) [`0b790cd`](https://github.com/MoonshotAI/kimi-code/commit/0b790cdc056475593abd572f657d010504caf752) Thanks [@sailist](https://github.com/sailist)! - web: Show every attachment a user sends — files, images, and videos — as chips in the message bubble, and let files be attached by dropping them anywhere in the window. + +- [#1744](https://github.com/MoonshotAI/kimi-code/pull/1744) [`b89d385`](https://github.com/MoonshotAI/kimi-code/commit/b89d385fa56915f067d656160086bc3c3126f8a3) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix Enter not confirming modal confirmation dialogs in dev builds, and keep the dialog open with a loading state until the confirmed action (such as archiving a session) completes. + +- [#1756](https://github.com/MoonshotAI/kimi-code/pull/1756) [`e885aec`](https://github.com/MoonshotAI/kimi-code/commit/e885aec7ffa9ee62d122908b68837c5e010d5d04) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show full diagnostics for model request failures — a semantic title, the provider's raw message, and expandable details (error code, HTTP status, request ID) with copy support — instead of a bare "Connection error" toast. + +- [#1751](https://github.com/MoonshotAI/kimi-code/pull/1751) [`df75a0f`](https://github.com/MoonshotAI/kimi-code/commit/df75a0f5c2f2e2dd3291c8adaba96a832ee1f179) Thanks [@kermanx](https://github.com/kermanx)! - web: Keep session activity indicators in sync with agent work, prevent duplicate streamed content after session activation races or LLM retries, and flush durable session events promptly. + +- [#1754](https://github.com/MoonshotAI/kimi-code/pull/1754) [`1186686`](https://github.com/MoonshotAI/kimi-code/commit/11866865544b8ec88330372b8582e97a35113308) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix a background subagent showing up as two identical rows in the agents dock panel during streaming. + +## 0.24.2 + +### Patch Changes + +- [#1704](https://github.com/MoonshotAI/kimi-code/pull/1704) [`38a2363`](https://github.com/MoonshotAI/kimi-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - Align the print-mode run lifecycle 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, and `kimi -p "/goal ..."` now stays alive until the goal reaches a terminal state instead of exiting after the first turn. + +- [#1704](https://github.com/MoonshotAI/kimi-code/pull/1704) [`38a2363`](https://github.com/MoonshotAI/kimi-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - 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. + +- [#1727](https://github.com/MoonshotAI/kimi-code/pull/1727) [`286d3e7`](https://github.com/MoonshotAI/kimi-code/commit/286d3e7aca40a778cc4136eb377e14f14c70141c) Thanks [@liruifengv](https://github.com/liruifengv)! - Add a builtin `check-kimi-code-docs` skill that answers Kimi Code product questions (CLI usage, configuration, membership, error codes) against the official documentation with source links. It triggers automatically on product questions, or run `/check-kimi-code-docs`. + +- [#1707](https://github.com/MoonshotAI/kimi-code/pull/1707) [`8490c3e`](https://github.com/MoonshotAI/kimi-code/commit/8490c3e36b6a6cc3ba5c0f15d93b87347ce23878) Thanks [@sailist](https://github.com/sailist)! - Add the number of messages dropped during compaction retries to the session wire log's LLM request traces. + +- [#1740](https://github.com/MoonshotAI/kimi-code/pull/1740) [`a74ab44`](https://github.com/MoonshotAI/kimi-code/commit/a74ab44ac7d5656e2dd9cf93b8e484936b05a0c8) Thanks [@sailist](https://github.com/sailist)! - Increase the default per-step LLM retry budget from 3 to 10 attempts, so transient provider failures (429 / overload) are retried with exponential backoff for a few minutes before the turn fails. Tune with `loop_control.max_retries_per_step` in config.toml. + +- [#1707](https://github.com/MoonshotAI/kimi-code/pull/1707) [`8490c3e`](https://github.com/MoonshotAI/kimi-code/commit/8490c3e36b6a6cc3ba5c0f15d93b87347ce23878) Thanks [@sailist](https://github.com/sailist)! - 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. + +- [#1698](https://github.com/MoonshotAI/kimi-code/pull/1698) [`722694a`](https://github.com/MoonshotAI/kimi-code/commit/722694adf99c53dc608d417ea6d8c90a5712c33f) Thanks [@chengluyu](https://github.com/chengluyu)! - Enforce goal wall-clock budgets while model or tool work is still running. + +- [#1730](https://github.com/MoonshotAI/kimi-code/pull/1730) [`72f425e`](https://github.com/MoonshotAI/kimi-code/commit/72f425e18d0264010e1442af67ee8d9acf5f0659) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix tool call id collisions across turns for Gemini-protocol models, which merged separate swarm runs into a single card in the web UI. + +- [#1695](https://github.com/MoonshotAI/kimi-code/pull/1695) [`5c0f17c`](https://github.com/MoonshotAI/kimi-code/commit/5c0f17cfcf99c27eb697be11ae9b61243d993e4a) Thanks [@chengluyu](https://github.com/chengluyu)! - Preserve active goal elapsed time across crash recovery. + +- [#1743](https://github.com/MoonshotAI/kimi-code/pull/1743) [`481b28b`](https://github.com/MoonshotAI/kimi-code/commit/481b28b8f4d527c43c640c4d742c52aa006c3bb0) Thanks [@chengluyu](https://github.com/chengluyu)! - Correct the guidance text shown when a goal cannot be paused or resumed. + +- [#1692](https://github.com/MoonshotAI/kimi-code/pull/1692) [`e53cd79`](https://github.com/MoonshotAI/kimi-code/commit/e53cd799572db6b2c73f6938703d586b83013cec) Thanks [@chengluyu](https://github.com/chengluyu)! - Allow goals to use every configured turn before the turn budget stops further work. + +- [#1719](https://github.com/MoonshotAI/kimi-code/pull/1719) [`b24a347`](https://github.com/MoonshotAI/kimi-code/commit/b24a347e20a3efa7bba948316784a76439ed7cf5) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Restore the AgentSwarm member list after a page refresh on the v2 backend. + +- [#1704](https://github.com/MoonshotAI/kimi-code/pull/1704) [`38a2363`](https://github.com/MoonshotAI/kimi-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - 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. + +- [#1708](https://github.com/MoonshotAI/kimi-code/pull/1708) [`ddfdfb0`](https://github.com/MoonshotAI/kimi-code/commit/ddfdfb0b09b59d95888eca7e9ddb7bb63be5e204) Thanks [@wbxl2000](https://github.com/wbxl2000)! - 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. + +- [#1714](https://github.com/MoonshotAI/kimi-code/pull/1714) [`20b6972`](https://github.com/MoonshotAI/kimi-code/commit/20b69724aafc8fb0b56a414988eb762a8b8a3ed1) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix code block copy buttons when the web UI is served over plain HTTP. + +- [#1643](https://github.com/MoonshotAI/kimi-code/pull/1643) [`d8d4e8c`](https://github.com/MoonshotAI/kimi-code/commit/d8d4e8ceb55d7a5cae7ce9b579996c9ff5601914) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Prevent long streaming responses from stalling after a tab is backgrounded. + +- [#1715](https://github.com/MoonshotAI/kimi-code/pull/1715) [`de493ae`](https://github.com/MoonshotAI/kimi-code/commit/de493aeec973623bc0e258d6598f6d9215693a5f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Use an upward chevron for the expand button on minimized plan review and question cards so the icon matches the direction the cards open. + +- [#1641](https://github.com/MoonshotAI/kimi-code/pull/1641) [`b6ae0a1`](https://github.com/MoonshotAI/kimi-code/commit/b6ae0a1054635fc71efde61dafa03da8a8b0c4c8) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show session list loading failures without discarding sessions that are still available. + +- [#1719](https://github.com/MoonshotAI/kimi-code/pull/1719) [`b24a347`](https://github.com/MoonshotAI/kimi-code/commit/b24a347e20a3efa7bba948316784a76439ed7cf5) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Expand the AgentSwarm card by default while its subagents are still running. + +- [#1693](https://github.com/MoonshotAI/kimi-code/pull/1693) [`7de218a`](https://github.com/MoonshotAI/kimi-code/commit/7de218a909d8f3e676ea3c160834090c9f19ca54) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Resume paused goals when you select Resume. + +- [#1700](https://github.com/MoonshotAI/kimi-code/pull/1700) [`3107f96`](https://github.com/MoonshotAI/kimi-code/commit/3107f963a532de88d0affd0a08c60749455c5013) Thanks [@chengluyu](https://github.com/chengluyu)! - Prevent late activity from replaced goals from changing or consuming the budget of replacement goals. + +- [#1459](https://github.com/MoonshotAI/kimi-code/pull/1459) [`6eb8e13`](https://github.com/MoonshotAI/kimi-code/commit/6eb8e13417f28a553b4183f113e5b96eb31e4211) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix mobile safe-area handling, including the composer floating above the on-screen keyboard on iOS, doubled landscape insets, the PWA top bar under the notch, and toasts overlapping the composer as it grows. + +- [#1696](https://github.com/MoonshotAI/kimi-code/pull/1696) [`b781e8c`](https://github.com/MoonshotAI/kimi-code/commit/b781e8cbcfac2cd0e73e3b1b79fa3386c632fa5b) Thanks [@chengluyu](https://github.com/chengluyu)! - Preserve final status messages when automatic goal continuations reach a budget or report a blocker. + +- [#1722](https://github.com/MoonshotAI/kimi-code/pull/1722) [`3703d03`](https://github.com/MoonshotAI/kimi-code/commit/3703d0346e79e42f18b5097f5606e6ef7b0ff2dd) Thanks [@sailist](https://github.com/sailist)! - In print mode (`kimi -p`), keep the run alive by default while background tasks are pending and feed each completion back to the main agent as a new turn, with an effectively unbounded wait ceiling and turn cap and a 72-hour subagent timeout. Set `print_background_mode = "exit"` (or `"drain"`) to restore the previous exit-after-one-turn behavior. + +- [#1737](https://github.com/MoonshotAI/kimi-code/pull/1737) [`5d6ff02`](https://github.com/MoonshotAI/kimi-code/commit/5d6ff022b1a3732cf0b12d1a87497870def52c0c) Thanks [@sailist](https://github.com/sailist)! - In print mode (`kimi -p`), background Bash tasks and subagents no longer have a timeout by default — they run until they finish or the model stops them, and a foreground Bash command that times out is moved to the background without a new deadline. Interactive defaults are unchanged; tune per mode with `bash_task_timeout_s` under `[background]` or `timeout_ms` under `[subagent]` (`0` = no timeout). + +- [#1697](https://github.com/MoonshotAI/kimi-code/pull/1697) [`2bf009f`](https://github.com/MoonshotAI/kimi-code/commit/2bf009fe27d1b0259e90f285e94264a8bf6b5832) Thanks [@chengluyu](https://github.com/chengluyu)! - Reject subagent goal requests consistently instead of starting goals they cannot finish. + +- [#1711](https://github.com/MoonshotAI/kimi-code/pull/1711) [`9eff230`](https://github.com/MoonshotAI/kimi-code/commit/9eff230f976c6bd8cc757678293276d8dec013d8) Thanks [@wbxl2000](https://github.com/wbxl2000)! - 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. + +- [#1704](https://github.com/MoonshotAI/kimi-code/pull/1704) [`38a2363`](https://github.com/MoonshotAI/kimi-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - 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. + +- [#1741](https://github.com/MoonshotAI/kimi-code/pull/1741) [`8a3f1ff`](https://github.com/MoonshotAI/kimi-code/commit/8a3f1ffa6fbd7855fd0b10d96587afc6b690ebe3) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Fix the session title not being generated when the first message is a skill slash command. + +- [#1694](https://github.com/MoonshotAI/kimi-code/pull/1694) [`513f374`](https://github.com/MoonshotAI/kimi-code/commit/513f374aa08bd86b428f62697c1ca12594d533e9) Thanks [@chengluyu](https://github.com/chengluyu)! - Reject malformed persisted goal records during session recovery. + +- [#1704](https://github.com/MoonshotAI/kimi-code/pull/1704) [`38a2363`](https://github.com/MoonshotAI/kimi-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - web: Show each message's actual send time in chat history after reloading a session, instead of the session creation time. + +- [#1711](https://github.com/MoonshotAI/kimi-code/pull/1711) [`9eff230`](https://github.com/MoonshotAI/kimi-code/commit/9eff230f976c6bd8cc757678293276d8dec013d8) Thanks [@wbxl2000](https://github.com/wbxl2000)! - 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. + +- [#1701](https://github.com/MoonshotAI/kimi-code/pull/1701) [`07c3632`](https://github.com/MoonshotAI/kimi-code/commit/07c3632415fa77972c49c39d7171ee5a4790bd01) Thanks [@sailist](https://github.com/sailist)! - 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. + +## 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 diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e6654f7a3..e2c207c79 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.16.0", + "version": "0.27.0", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", @@ -27,6 +27,8 @@ }, "files": [ "dist", + "dist-web", + "native", "scripts/postinstall.mjs", "scripts/postinstall", "README.md" @@ -34,19 +36,22 @@ "type": "module", "imports": { "#/tui/theme": "./src/tui/theme/index.ts", - "#/*": [ - "./src/*.ts", - "./src/*/index.ts", - "./src/*.d.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": "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", - "build": "tsdown", "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", @@ -58,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 --debug-endpoints", + "dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints", + "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 --debug-endpoints", + "dev:server:restart": "node scripts/dev-server-restart.mjs", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", "dev:prod": "node dist/main.mjs", @@ -69,16 +78,19 @@ "postinstall": "node scripts/postinstall.mjs" }, "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.2", - "koffi": "^2.16.0" + "@mariozechner/clipboard": "^0.3.9", + "node-pty": "^1.1.0" }, "devDependencies": { - "@earendil-works/pi-tui": "^0.74.0", "@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", @@ -86,6 +98,7 @@ "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "commander": "^13.1.0", + "jimp": "^1.6.1", "pathe": "^2.0.3", "postject": "1.0.0-alpha.6", "semver": "^7.7.4", diff --git a/apps/kimi-code/scripts/copy-native-assets.mjs b/apps/kimi-code/scripts/copy-native-assets.mjs new file mode 100644 index 000000000..dad365a06 --- /dev/null +++ b/apps/kimi-code/scripts/copy-native-assets.mjs @@ -0,0 +1,38 @@ +import { cp, mkdir, rm, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = resolve(appRoot, '../..'); +const source = resolve(repoRoot, 'packages/pi-tui/native'); +const target = resolve(appRoot, 'native'); + +// pi-tui ships platform-specific native helpers only for darwin/win32; +// Linux has no native helper, so there is nothing to copy for it. +const PLATFORMS = ['darwin', 'win32']; + +async function assertPrebuilds(platform) { + const dir = resolve(source, platform, 'prebuilds'); + try { + const info = await stat(dir); + if (!info.isDirectory()) { + throw new Error('not a directory'); + } + } catch { + throw new Error( + `pi-tui native prebuilds were not found at ${dir}. Build or restore packages/pi-tui first.`, + ); + } + return dir; +} + +await rm(target, { recursive: true, force: true }); +await mkdir(target, { recursive: true }); + +for (const platform of PLATFORMS) { + const srcPrebuilds = await assertPrebuilds(platform); + const dstPrebuilds = resolve(target, platform, 'prebuilds'); + await cp(srcPrebuilds, dstPrebuilds, { recursive: true }); +} + +console.log(`Copied pi-tui native prebuilds to ${target}`); diff --git a/apps/kimi-code/scripts/copy-web-assets.mjs b/apps/kimi-code/scripts/copy-web-assets.mjs new file mode 100644 index 000000000..d82f40de0 --- /dev/null +++ b/apps/kimi-code/scripts/copy-web-assets.mjs @@ -0,0 +1,27 @@ +import { cp, rm, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = resolve(appRoot, '../..'); +const source = resolve(repoRoot, 'apps/kimi-web/dist'); +const target = resolve(appRoot, 'dist-web'); + +async function assertBuiltWeb() { + try { + const info = await stat(resolve(source, 'index.html')); + if (!info.isFile()) { + throw new Error('index.html is not a file'); + } + } catch { + throw new Error( + `Kimi web build output was not found at ${source}. Run \`pnpm --filter @moonshot-ai/kimi-web run build\` first.`, + ); + } +} + +await assertBuiltWeb(); +await rm(target, { recursive: true, force: true }); +await cp(source, target, { recursive: true }); + +console.log(`Copied Kimi web assets to ${target}`); diff --git a/apps/kimi-code/scripts/dev-server-restart.mjs b/apps/kimi-code/scripts/dev-server-restart.mjs new file mode 100644 index 000000000..8169925cf --- /dev/null +++ b/apps/kimi-code/scripts/dev-server-restart.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Press-Enter-to-restart wrapper for the local server. No file watcher. +// +// Spawns `tsx ./src/main.ts server run …extraArgs` once, then on each newline +// read from stdin SIGTERMs the child and respawns after it has cleanly exited. +// SIGTERM triggers the server's own `shutdown()` handler +// (apps/kimi-code/src/cli/sub/server/run.ts) which releases the port lock and +// closes WS conns before exit, so a fresh start can re-acquire 58627 without a +// stale-lock fight. +// +// CLI args after `--` (or any extras) are passed straight through, so: +// pnpm dev:server:restart -- --host 0.0.0.0 --port 58627 --log-level debug +// is equivalent to `pnpm dev:server` with that arg list, but with the restart +// loop on top. + +import { spawn } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = resolve(SCRIPT_DIR, '..'); + +const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx'; + +const cliArgs = process.argv.slice(2); +if (cliArgs[0] === '--') cliArgs.shift(); + +const tsxArgs = [ + '--tsconfig', + './tsconfig.dev.json', + '--import', + '../../build/register-raw-text-loader.mjs', + './src/main.ts', + 'server', + 'run', + ...cliArgs, +]; + +let child = null; +let restarting = false; +let shuttingDown = false; +let killTimer = null; + +function start() { + console.error('[dev:server:restart] starting server…'); + child = spawn(tsxBin, tsxArgs, { + cwd: APP_ROOT, + env: process.env, + // Server does not read stdin; keep ours free for the Enter trigger. + stdio: ['ignore', 'inherit', 'inherit'], + }); + + child.on('error', (err) => { + 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 3cc2d0a58..3f50b969c 100644 --- a/apps/kimi-code/scripts/dev.mjs +++ b/apps/kimi-code/scripts/dev.mjs @@ -2,13 +2,16 @@ import { spawn } from 'node:child_process'; import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { startPluginMarketplaceServer } from './dev-plugin-marketplace-server.mjs'; const require = createRequire(import.meta.url); const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const APP_ROOT = resolve(SCRIPT_DIR, '..'); +// Monorepo root. Used as the dev CLI's working directory so `make dev` opens +// the whole repo instead of just apps/kimi-code. +const REPO_ROOT = resolve(APP_ROOT, '../..'); // Runtime variable the CLI reads to locate the marketplace JSON. const MARKETPLACE_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; // Opt-in for dev: point this run at an external marketplace instead of a local one. @@ -42,9 +45,20 @@ const cliArgs = process.argv.slice(2); if (cliArgs[0] === '--') cliArgs.shift(); const child = spawn( process.execPath, - [tsxCli, '--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', ...cliArgs], + [ + 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/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/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 bdc886c4d..960c707a6 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -8,6 +8,7 @@ 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; @@ -43,9 +44,10 @@ 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('-y, --yolo', 'Automatically approve all actions.', false) - .option('--auto', 'Start in auto permission mode.', false) + .option('-c, --continue', 'Continue the previous session for the working directory.', false) + .addOption(new Option('-C').hideHelp().default(false)) + .option('-y, --yolo', 'Auto-approve regular tool calls; the agent may still ask questions.', false) + .option('--auto', 'Start in auto permission mode: fully autonomous, the agent will not ask questions.', false) .addOption( new Option( '-m, --model ', @@ -72,6 +74,14 @@ export function createProgram( .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) .default([]), ) + .addOption( + new Option( + '--add-dir

', + 'Add an additional workspace directory for this session. Can be repeated.', + ) + .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) + .default([]), + ) .addOption(new Option('--yes').hideHelp().default(false)) .addOption(new Option('--auto-approve').hideHelp().default(false)) .option('--plan', 'Start in plan mode.', false); @@ -79,12 +89,14 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); registerAcpCommand(program); + registerServerCommand(program); registerLoginCommand(program); registerDoctorCommand(program); registerVisCommand(program); registerMigrateCommand(program, onMigrate); program .command('upgrade') + .alias('update') .description('Upgrade Kimi Code to the latest version.') .action(async () => { await onUpgrade(); @@ -113,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, @@ -121,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 index 5ab0cfe85..57f223ad4 100644 --- a/apps/kimi-code/src/cli/goal-prompt.ts +++ b/apps/kimi-code/src/cli/goal-prompt.ts @@ -46,13 +46,18 @@ const GOAL_PREFIX = /^\/goal(\s|$)/; * Parses a headless prompt into a goal-create request, or `undefined` when the * prompt is not a `/goal` create command (so the caller runs it as a normal * prompt). Non-create goal subcommands are not supported headless and fall - * through to normal prompt handling. + * through to normal prompt handling. Malformed create commands throw instead of + * falling through, so validation errors are reported before anything is sent to + * the model. */ export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined { const trimmed = prompt.trim(); if (!GOAL_PREFIX.test(trimmed)) return undefined; const args = trimmed.replace(/^\/goal/, '').trim(); const parsed = parseGoalCommand(args); + if (parsed.kind === 'error') { + throw new Error(parsed.message); + } if (parsed.kind !== 'create') return undefined; return { objective: parsed.objective, replace: parsed.replace }; } diff --git a/apps/kimi-code/src/cli/headless-exit.ts b/apps/kimi-code/src/cli/headless-exit.ts new file mode 100644 index 000000000..180572844 --- /dev/null +++ b/apps/kimi-code/src/cli/headless-exit.ts @@ -0,0 +1,96 @@ +import type { Writable } from 'node:stream'; + +import { HEADLESS_FORCE_EXIT_GRACE_MS, HEADLESS_STDIO_DRAIN_TIMEOUT_MS } from '#/constant/app'; + +/** Minimal process surface needed to force a headless run to terminate. */ +export interface ExitableProcess { + exit(code?: number): void; +} + +/** + * Schedule a best-effort force-exit for a completed headless (`kimi -p`) run. + * + * Print mode does not call `process.exit()`; it relies on the Node event loop + * draining once the run is done. If a stray ref'd handle survives shutdown — a + * lingering socket (e.g. a connection blackholed by a restrictive firewall, or + * an HTTP/2 session kept alive by PING), an un-cleared timer, or a child whose + * pipes stay open — the loop never empties and the process hangs until an + * external timeout kills it. + * + * This arms an **unref'd** fallback timer: a healthy run drains and exits + * naturally before it fires (so behaviour is unchanged), and the timer itself + * never keeps the loop alive. It only force-exits a run whose loop is already + * wedged. The exit code is read lazily at fire time so callers may set + * `process.exitCode` after scheduling (e.g. a goal turn mapping its terminal + * status to a non-zero code). + * + * Returns the timer handle so callers/tests can `clearTimeout` it. + */ +export function scheduleHeadlessForceExit( + proc: ExitableProcess, + getExitCode: () => number, + graceMs: number = HEADLESS_FORCE_EXIT_GRACE_MS, +): NodeJS.Timeout { + const timer = setTimeout(() => { + proc.exit(getExitCode()); + }, graceMs); + timer.unref?.(); + return timer; +} + +/** Resolve once a stream's currently-buffered writes have flushed to its sink. */ +function flushStream(stream: Writable): Promise { + return new Promise((resolve) => { + try { + // An empty write's callback fires after all previously-queued writes have + // been flushed (writes are ordered), which is the documented way to know a + // stream's buffer has drained. + stream.write('', () => resolve()); + } catch { + resolve(); + } + }); +} + +/** + * Wait for buffered output on the given streams to flush, bounded by `timeoutMs`. + * + * A slow or piped consumer that hasn't read all of stdout/stderr yet leaves the + * pipe as a legitimate ref'd handle keeping the loop alive. Flushing before any + * force-exit prevents truncating output from an otherwise-successful run. The + * wait is bounded so a permanently-stuck consumer can't re-introduce the hang. + */ +export async function drainStdio( + streams: readonly Writable[], + timeoutMs: number = HEADLESS_STDIO_DRAIN_TIMEOUT_MS, +): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + timer.unref?.(); + }); + try { + await Promise.race([Promise.all(streams.map(flushStream)).then(() => undefined), timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +/** + * Finalize a completed headless run: flush stdio, then arm the force-exit + * backstop. + * + * Draining first means in-flight legitimate output is fully written before the + * backstop can fire, and — since drained stdio no longer holds the loop — only a + * genuinely leaked handle can keep it alive afterwards, which is exactly what + * the backstop is for. + */ +export async function finalizeHeadlessRun( + proc: ExitableProcess, + streams: readonly Writable[], + getExitCode: () => number, + options: { drainTimeoutMs?: number; graceMs?: number } = {}, +): Promise { + await drainStdio(streams, options.drainTimeoutMs ?? HEADLESS_STDIO_DRAIN_TIMEOUT_MS); + scheduleHeadlessForceExit(proc, getExitCode, options.graceMs); +} diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 98f4cb196..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,5 +92,8 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions { if (opts.yolo && opts.auto) { throw new OptionConflictError('Cannot combine --yolo with --auto.'); } + // 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 f7cef067d..abee29962 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -11,16 +11,15 @@ import { log, type Event, type GoalSnapshot, - type HookResultEvent, - type KimiHarness, - type Session, 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, @@ -29,21 +28,64 @@ import { 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; @@ -51,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 = { @@ -70,7 +121,7 @@ export async function runPrompt( withContext: withTelemetryContext, setContext: setTelemetryContext, }; - const harness = createKimiHarness({ + const harness = await createPromptHarness({ homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), uiMode: PROMPT_UI_MODE, @@ -78,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, @@ -95,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 { @@ -104,8 +156,13 @@ 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); @@ -115,7 +172,7 @@ export async function runPrompt( for (const warning of (await harness.getConfigDiagnostics()).warnings) { stderr.write(`Warning: ${warning}\n`); } - const { session, resumed, restorePermission, telemetryModel, goalModel } = + const { session, restorePermission, telemetryModel, goalModel } = await resolvePromptSession( harness, opts, @@ -135,17 +192,10 @@ export async function runPrompt( version, uiMode: PROMPT_UI_MODE, model: telemetryModel, + sessionId: session.id, }); setCrashPhase('runtime'); - withTelemetryContext({ sessionId: session.id }).track('started', { - resumed, - yolo: false, - plan: false, - afk: true, - }); - - const outputFormat = opts.outputFormat ?? 'text'; // Headless goal mode: `kimi -p "/goal "`. The goal driver keeps // the turn-run alive across continuation turns, so the normal prompt-turn // waiter blocks until the goal is terminal; we then emit a summary and set a @@ -154,20 +204,34 @@ export async function runPrompt( if (goalCreate !== undefined) { await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr); } else { - await runPromptTurn(session, opts.prompt!, outputFormat, stdout, stderr); + 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: Session, + session: PromptSession, goal: HeadlessGoalCreate, model: string | undefined, outputFormat: PromptOutputFormat, @@ -193,7 +257,13 @@ async function runHeadlessGoal( try { // The objective is sent as the normal prompt; goal continuation keeps the // turn alive until a terminal state is reached. - await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr); + await runPromptTurn( + session as PrintTurnSession, + goal.objective, + outputFormat, + stdout, + stderr, + ); } finally { unsubscribeGoalEvents(); const snapshot = completedSnapshot ?? (await session.getGoal()).goal; @@ -211,7 +281,7 @@ async function runHeadlessGoal( } interface ResolvedPromptSession { - readonly session: Session; + readonly session: PromptSession; readonly resumed: boolean; readonly restorePermission: () => Promise; readonly telemetryModel?: string; @@ -219,7 +289,7 @@ interface ResolvedPromptSession { } async function resolvePromptSession( - harness: KimiHarness, + harness: PromptHarness, opts: CLIOptions, workDir: string, defaultModel: string | undefined, @@ -243,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, @@ -267,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, @@ -290,7 +366,13 @@ async function resolvePromptSession( } const model = requireConfiguredModel(opts.model, defaultModel); - const session = await harness.createSession({ workDir, model, permission: 'auto' }); + const session = await harness.createSession({ + workDir, + model, + permission: 'auto', + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + drainAgentTasksOnStop: true, + }); installHeadlessHandlers(session); return { session, @@ -302,7 +384,7 @@ async function resolvePromptSession( } async function forcePromptPermission( - session: Session, + session: PromptSession, previousPermission: SessionStatus['permission'], setRestorePermission: (restorePermission: () => Promise) => void, ): Promise<() => Promise> { @@ -321,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( @@ -331,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 { @@ -367,14 +449,17 @@ function installPromptTerminationCleanup( }; } -function signalExitCode(signal: NodeJS.Signals): number { +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, @@ -388,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) { @@ -402,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) { @@ -410,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; } @@ -418,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 || @@ -434,6 +576,7 @@ function runPromptTurn( return; case 'turn.step.retrying': outputWriter.discardAssistant(); + outputWriter.writeRetrying(event); return; case 'assistant.delta': outputWriter.writeAssistantDelta(event.delta); @@ -462,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))); @@ -485,7 +631,6 @@ function runPromptTurn( case 'subagent.started': case 'subagent.suspended': case 'tool.list.updated': - case 'turn.started': case 'turn.step.completed': case 'warning': return; @@ -495,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 e5bdfef24..47d97a828 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -1,7 +1,14 @@ -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, + flushDiagnosticLogsSync, + log, + type KimiHarness, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; import { setCrashPhase, setTelemetryContext, @@ -9,12 +16,6 @@ import { track, withTelemetryContext, } from '@moonshot-ai/kimi-telemetry'; -import { - createKimiHarness, - log, - type KimiHarness, - type TelemetryClient, -} from '@moonshot-ai/kimi-code-sdk'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; import { detectPendingMigration } from '#/migration/index'; @@ -24,6 +25,8 @@ import { CHROME_GUTTER } from '#/tui/constant/rendering'; import { KimiTUI } from '#/tui/index'; import { currentTheme, getColorPalette } from '#/tui/theme'; import { combineStartupNotice } from '#/tui/utils/startup'; +import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; +import { restoreTerminalModes } from '#/utils/terminal-restore'; import type { CLIOptions } from './options'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; @@ -60,17 +63,19 @@ export async function runShell( const harness = createKimiHarness({ homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), + skillDirs: opts.skillsDirs, telemetry: telemetryClient, onOAuthRefresh: (outcome) => { if (outcome.success) { - track('oauth_refresh', { success: true }); + track('oauth_refresh', { outcome: 'success' }); return; } track('oauth_refresh', { - success: false, + outcome: 'error', reason: outcome.reason, }); }, + sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false }, }); log.info('kimi-code starting', { version, @@ -98,6 +103,7 @@ export async function runShell( const configMs = Date.now() - configStartedAt; const tui = new KimiTUI(harness, { cliOptions: opts, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, tuiConfig, version, workDir, @@ -115,7 +121,6 @@ export async function runShell( }); setCrashPhase('runtime'); - const resumed = opts.continue || opts.session !== undefined; const trackLifecycleForSession = ( sessionId: string, event: string, @@ -131,35 +136,93 @@ 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 => { + // The crash log above is only enqueued into the async sink; flush it + // synchronously or the `process.exit()` below would drop the one line that + // explains why we crashed. Best-effort: an exit path must never throw. + try { + flushDiagnosticLogsSync(); + } catch { + /* ignore */ + } + 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', { @@ -169,8 +232,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/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index cd30c1957..509ec2d22 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -35,7 +35,7 @@ import { } from '@moonshot-ai/kimi-code-sdk'; import type { Command } from 'commander'; -import { createKimiCodeHostIdentity } from '#/cli/version'; +import { createKimiCodeHostIdentity, createKimiCodeUserAgent } from '#/cli/version'; interface WritableLike { write(chunk: string): boolean; @@ -99,7 +99,7 @@ export async function handleProviderAdd( let entries: Awaited>; try { - entries = await fetchCustomRegistry(source); + 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`); @@ -340,7 +340,7 @@ export async function handleCatalogAdd( // already-configured provider would lose the user's previously-set default // even when `--default-model` is not supplied. const previousDefaultModel = config.defaultModel; - const previousDefaultThinking = config.defaultThinking; + const previousThinking = config.thinking; if (config.providers[providerId] !== undefined) { config = await harness.removeProvider(providerId); @@ -348,7 +348,7 @@ export async function handleCatalogAdd( const baseUrl = catalogBaseUrl(entry, wire); // `applyCatalogProvider` always overwrites both `defaultModel` and - // `defaultThinking`. The values we pass here are temporary; we restore + // `[thinking]`. The values we pass here are temporary; we restore // a consistent state in the post-apply block below. applyCatalogProvider(config, { providerId, @@ -373,18 +373,18 @@ export async function handleCatalogAdd( config.defaultModel = stillResolves ? previousDefaultModel : undefined; } - // Always restore `defaultThinking` from what was there before — including - // `undefined`. Persisting `false` when the user never set it would make - // `resolveThinkingLevel` (agent-core/src/agent/config/thinking.ts) treat - // it as an explicit "off" request and silently disable thinking, even - // for thinking-capable models. - config.defaultThinking = previousDefaultThinking; + // Always restore `[thinking]` from what was there before — including + // `undefined`. Persisting `enabled: false` when the user never set it would + // make `resolveThinkingEffort` (agent-core/src/agent/config/thinking.ts) treat + // it as an explicit "off" request and silently disable thinking, even for + // thinking-capable models. + config.thinking = previousThinking; await harness.setConfig({ providers: config.providers, models: config.models, defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, + thinking: config.thinking, }); const displayName = entry.name ?? providerId; @@ -398,7 +398,7 @@ export async function handleCatalogAdd( async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise { try { - return await fetchCatalog(url); + 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`); 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/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/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index fe893a50a..5fda96d6a 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -430,8 +430,6 @@ function trackUpdatePrompted( rolloutTelemetry: RolloutTelemetry, ): void { trackUpdateEvent(track, 'update_prompted', { - current: currentVersion, - latest: target.version, current_version: currentVersion, target_version: target.version, source, @@ -490,7 +488,14 @@ export async function installUpdate( ): Promise { const { cmd, args } = spawnForSource(source, version, platform); await new Promise((resolve, reject) => { - const child = spawn(cmd, [...args], { stdio: 'inherit' }); + // Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the + // CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without + // a shell, so run through the shell on win32. The version is a validated + // semver and the package name is a constant, so args are shell-safe. + const child = spawn(cmd, [...args], { + stdio: 'inherit', + shell: platform === 'win32' ? true : undefined, + }); child.once('error', reject); child.once('exit', (code, signal) => { if (code === 0) { @@ -598,7 +603,15 @@ async function startBackgroundInstall( }); }; - const child = spawn(cmd, [...args], { detached: true, stdio: 'ignore' }); + const child = spawn(cmd, [...args], { + detached: true, + stdio: 'ignore', + shell: platform === 'win32' ? true : undefined, + // On Windows a detached child gets its own console window; with shell:true + // that window would flash during a passive background update. Hide it so + // the silent updater stays silent. + windowsHide: platform === 'win32' ? true : undefined, + }); child.once('error', () => { finish(false); }); child.once('exit', (code) => { finish(code === 0); }); child.unref(); diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts new file mode 100644 index 000000000..b284511ee --- /dev/null +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -0,0 +1,728 @@ +/** + * 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; +/** Re-check `goalActive` at least this often while waiting for goal turns. */ +const GOAL_WAIT_POLL_MS = 250; + +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 { + // Install the appender BEFORE resolving the session: `session_started` and + // `session_load_failed` fire inside create()/resume(), so an appender wired + // up only after resolveNativeSession() would drop them to the null appender. + // The model below is the best known up front; a resumed session's real + // model is reconciled via setContext once resolved. + telemetryService = app.accessor.get(ITelemetryService); + if (telemetryEnabled) { + telemetryService.setAppender( + createCloudAppender(app.accessor, { + deviceId, + appName: CLI_USER_AGENT_PRODUCT, + uiMode: PROMPT_UI_MODE, + model: opts.model ?? defaultModel, + getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null, + }), + ); + } + + const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); + restorePermission = resolved.restorePermission; + + telemetryService.setContext({ sessionId: resolved.session.id, model: resolved.telemetryModel }); + 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); + const goalService = agent.accessor.get(IAgentGoalService); + 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(), + goalActive: () => goalService.getGoal().goal?.status === 'active', + }); + } 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 = Number.isFinite(ms) + ? setTimeout(() => { + settle(null); + }, ms) + : undefined; + 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; + /** + * Reports whether an agent goal is still `active`. v2 drives goal + * continuation as new turns (v1 keeps a single turn alive), so a `-p` goal + * run must stay alive until the goal leaves `active`, independent of the + * background policy. + */ + readonly goalActive?: () => boolean; +} + +/** + * Apply the print-mode (`kimi -p`) background-task policy after the main turn + * completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`: + * - goal : while a goal is `active`, keep waiting for its continuation + * turns (bounded by `ceilingS` as a safety net), regardless of + * the background mode; the goal summary drives the exit code. + * - '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.goalActive !== undefined) { + const goalDeadline = input.now() + input.ceilingS * 1000; + while (input.goalActive()) { + // Also wake on a short poll: a goal can leave `active` without any + // further turn.ended (budget block at a turn boundary, or a pause after + // a continuation-launch failure), which would otherwise hang the run + // until the ceiling. + const ended = await input.turnEndings.next( + Math.min(goalDeadline - input.now(), GOAL_WAIT_POLL_MS), + input.skipTurnId, + ); + if (ended === null && input.now() >= goalDeadline) { + input.warn(`print goal wait ceiling reached (${input.ceilingS}s), finishing`); + return; + } + // A continuation turn that does not complete pauses/blocks the goal, so + // the loop condition exits on the next check. + } + } + 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 ec66a4a2f..e38ea6a68 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -7,10 +7,33 @@ export const PROCESS_NAME = 'kimi-code'; // Used in telemetry app names and HTTP User-Agent headers. export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli'; export const CLI_UI_MODE = 'shell'; +// Telemetry ui_mode for the `kimi web` / `kimi server run` host. Same product +// as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode. +export const WEB_UI_MODE = 'web'; // Give telemetry a short flush window without making CLI exit feel stuck. export const CLI_SHUTDOWN_TIMEOUT_MS = 3000; +// Upper bound on headless (`kimi -p`) shutdown. A wedged cleanup step (e.g. a +// SessionEnd hook, an MCP shutdown, or a connection blackholed by a restrictive +// firewall) must not keep a completed run alive indefinitely — once this elapses +// we stop waiting on cleanup and let the run return. +export const PROMPT_CLEANUP_TIMEOUT_MS = 8000; + +// Grace after a headless run has fully completed (turn done, cleanup attempted) +// before force-exiting. `kimi -p` otherwise relies on the event loop draining to +// exit; a stray ref'd handle (socket/timer/child) left over from the run would +// wedge it. The guard timer is unref'd, so a healthy run still exits naturally +// well before this fires. +export const HEADLESS_FORCE_EXIT_GRACE_MS = 2000; + +// Max time to wait for buffered stdout/stderr to flush before arming the +// force-exit fallback. A slow/piped consumer's still-draining stdio is a +// legitimate ref'd handle — flushing first prevents the fallback from +// truncating completed output. Bounded so a permanently-stuck consumer can't +// re-introduce the hang. +export const HEADLESS_STDIO_DRAIN_TIMEOUT_MS = 10000; + // Published npm package name; this can differ from the executable command. export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code'; 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/main.ts b/apps/kimi-code/src/main.ts index e94472590..860c4423d 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -23,6 +23,7 @@ import { } from '@moonshot-ai/kimi-telemetry'; import { createProgram } from './cli/commands'; +import { finalizeHeadlessRun } from './cli/headless-exit'; import type { CLIOptions } from './cli/options'; import { OptionConflictError, validateOptions } from './cli/options'; import { runPrompt } from './cli/run-prompt'; @@ -38,7 +39,22 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; -export async function handleMainCommand(opts: CLIOptions, version: string): Promise { +/** + * Outcome of a CLI command run, reported back to the process entrypoint. + * + * `handleMainCommand` is a reusable, unit-tested handler — it must not terminate + * the process itself. It reports here whether a headless (`kimi -p`) run + * completed so the entrypoint (the only place that owns the process) can arm the + * force-exit fallback. + */ +export interface MainCommandOutcome { + readonly headlessCompleted: boolean; +} + +export async function handleMainCommand( + opts: CLIOptions, + version: string, +): Promise { let validated: ReturnType; try { validated = validateOptions(opts); @@ -60,10 +76,11 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom if (validated.uiMode === 'print') { await runPrompt(validated.options, version); - return; + return { headlessCompleted: true }; } await runShell(validated.options, version); + return { headlessCompleted: false }; } /** `kimi migrate`: launch the migration screen only, then exit. */ @@ -139,17 +156,42 @@ export function main(): void { const program = createProgram( version, (opts) => { - void handleMainCommand(opts, version).catch(async (error: unknown) => { - const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell'; - await logStartupFailure(operation, error); - process.stderr.write( - formatStartupError(error, { - operation, - }), - ); - process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); - process.exit(1); - }); + void handleMainCommand(opts, version) + .then(async (outcome) => { + // Only the process entrypoint disposes of the process. Print mode + // relies on the event loop draining to exit; flush any buffered output + // and then arm an unref'd fallback so a stray ref'd handle left over + // from the run can't wedge a completed `kimi -p` until an external + // timeout. A healthy run drains and exits before the fallback fires. + if (outcome.headlessCompleted) { + await finalizeHeadlessRun( + process, + [process.stdout, process.stderr], + () => Number(process.exitCode) || 0, + ); + } + }) + .catch(async (error: unknown) => { + // Set the failure exit code synchronously, before any `await`. The + // terminal `process.exit(1)` below is our intended exit, but it sits + // behind `await logStartupFailure(...)`; by the time we reach that + // await, the failed run's `finally` cleanup has already torn down its + // ref'd handles (sockets, timers, background tasks). If the event loop + // drains during the await, Node exits on its own with the DEFAULT code + // 0 and `process.exit(1)` never runs — headless (`kimi -p`) failures + // would then exit 0 nondeterministically. Setting `process.exitCode` + // up front makes that drain-exit report failure too. + process.exitCode = 1; + const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell'; + await logStartupFailure(operation, error); + process.stderr.write( + formatStartupError(error, { + operation, + }), + ); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); }, () => { void handleMigrateCommand(version).catch(async (error: unknown) => { diff --git a/apps/kimi-code/src/migration/detect-pending.ts b/apps/kimi-code/src/migration/detect-pending.ts index 48435d2bc..cf121bf60 100644 --- a/apps/kimi-code/src/migration/detect-pending.ts +++ b/apps/kimi-code/src/migration/detect-pending.ts @@ -3,10 +3,13 @@ * shown. Cheap, synchronous-ish, no TTY required. Returns the MigrationPlan to * drive the screen, or null when there is nothing to offer. */ -import { existsSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; -import { detectMigration, type MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { + detectMigration, + shouldSuppressMigration, + type MigrationPlan, +} from '@moonshot-ai/migration-legacy'; export interface DetectPendingInput { readonly sourceHome: string; @@ -24,11 +27,11 @@ export async function detectPendingMigration( ): Promise { const { sourceHome, targetHome } = input; if (!existsSync(sourceHome)) return null; - if (input.ignoreMarker !== true) { - if (migrationAlreadyTargeted(join(sourceHome, '.migrated-to-kimi-code'), targetHome)) { - return null; - } - if (existsSync(join(targetHome, '.skip-migration-from-kimi-cli'))) return null; + if ( + input.ignoreMarker !== true && + shouldSuppressMigration({ sourceHome, targetHome }) + ) { + return null; } let plan: MigrationPlan; @@ -52,21 +55,3 @@ export async function detectPendingMigration( return plan; } - -/** - * True when the legacy `.migrated-to-kimi-code` marker records a migration - * into *this* target home. A marker written for a different `KIMI_CODE_HOME` - * must not suppress the prompt — that target has never received migrated data. - * An unreadable/old marker without `target_path` is treated as "matches" - * (conservative: do not re-prompt when the marker exists but is ambiguous). - */ -function migrationAlreadyTargeted(markerPath: string, targetHome: string): boolean { - if (!existsSync(markerPath)) return false; - try { - const parsed = JSON.parse(readFileSync(markerPath, 'utf-8')) as { target_path?: unknown }; - if (typeof parsed.target_path !== 'string') return true; - return resolve(parsed.target_path) === resolve(targetHome); - } catch { - return true; - } -} diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index b1ebfecfd..d4c4fee7e 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -11,7 +11,7 @@ * This file implements the ask, progress, and result phases. `beginMigration` * drives the real runMigration flow (injectable for tests). */ -import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui'; +import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import type { ColorPalette } from '#/tui/theme/colors'; diff --git a/apps/kimi-code/src/native/module-hook.ts b/apps/kimi-code/src/native/module-hook.ts index bbef5d4cb..bc8a1a67b 100644 --- a/apps/kimi-code/src/native/module-hook.ts +++ b/apps/kimi-code/src/native/module-hook.ts @@ -1,6 +1,8 @@ +import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; +import { join } from 'node:path'; -import { loadNativePackage } from './native-require'; +import { getNativePackageRoot } from './native-assets'; type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown; @@ -10,7 +12,16 @@ interface ModuleWithLoad { const nodeRequire = createRequire(import.meta.url); let installed = false; -let loadingNativePackage = false; + +// pi-tui loads its platform-specific native helpers via an absolute-path +// require() computed from import.meta.url / process.execPath +// (see pi-tui dist/terminal.js and dist/native-modifiers.js). In a SEA binary +// those .node files live in the native-asset cache, so redirect any absolute +// require of a pi-tui native helper to the cached copy. +// +// Path shape: native//prebuilds//.node — note the +// two path segments after "prebuilds", so ".+" (not "[^/]+") is required. +const PI_TUI_NATIVE_PATTERN = /native[\\/](?:win32|darwin)[\\/]prebuilds[\\/].+\.node$/; export function installNativeModuleHook(): void { if (installed) return; @@ -26,13 +37,18 @@ export function installNativeModuleHook(): void { parent: unknown, isMain: boolean, ): unknown { - if (request === 'koffi' && !loadingNativePackage) { - loadingNativePackage = true; - try { - const pkg = loadNativePackage('koffi'); - if (pkg !== null) return pkg; - } finally { - loadingNativePackage = false; + if ( + typeof request === 'string' && + PI_TUI_NATIVE_PATTERN.test(request) && + !existsSync(request) + ) { + const pkgRoot = getNativePackageRoot('@moonshot-ai/pi-tui'); + if (pkgRoot !== null) { + const match = request.match(PI_TUI_NATIVE_PATTERN); + if (match !== null) { + const redirected = join(pkgRoot, match[0]); + return originalLoad.call(this, redirected, parent, isMain); + } } } return originalLoad.call(this, request, parent, isMain); diff --git a/apps/kimi-code/src/native/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/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts new file mode 100644 index 000000000..90636cea5 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -0,0 +1,91 @@ +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; +import type { SlashCommandHost } from './dispatch'; + +type AddDirChoice = 'session' | 'remember' | 'cancel'; + +export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise { + const input = args.trim(); + const session = host.session; + + if (input.length === 0 || input.toLowerCase() === 'list') { + const additionalDirs = session?.summary?.additionalDirs ?? []; + if (additionalDirs.length === 0) { + host.showStatus('No additional directories configured.'); + return; + } + host.showStatus(formatAdditionalDirsStatus(additionalDirs)); + return; + } + + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + host.mountEditorReplacement( + new ChoicePickerComponent({ + title: `Add directory to workspace: ${input}`, + hint: '↑↓ navigate · Enter confirm · Esc cancel', + options: [ + { + value: 'session', + label: 'Yes, for this session', + }, + { + value: 'remember', + label: 'Yes, and remember this directory', + }, + { + value: 'cancel', + label: 'No', + }, + ], + onSelect: (value) => { + void handleAddDirChoice(host, session.id, input, value as AddDirChoice); + }, + onCancel: () => { + host.restoreEditor(); + host.showStatus(`Did not add ${input} as a working directory.`); + }, + }), + ); +} + +function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string { + return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n'); +} + +async function handleAddDirChoice( + host: SlashCommandHost, + sessionId: string, + path: string, + choice: AddDirChoice, +): Promise { + host.restoreEditor(); + + if (choice === 'cancel') { + host.showStatus(`Did not add ${path} as a working directory.`); + return; + } + + const session = host.session; + if (session === undefined || session.id !== sessionId) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + try { + const result = await session.addAdditionalDir(path, { persist: choice === 'remember' }); + host.setAppState({ additionalDirs: result.additionalDirs }); + host.refreshSlashCommandAutocomplete(); + host.showStatus( + choice === 'remember' + ? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}` + : `Added workspace directory:\n ${path}\n For this session only`, + 'success', + ); + } catch (error) { + host.showError(error instanceof Error ? error.message : String(error)); + } +} diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index 8064c089b..773638485 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -70,6 +70,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { } host.track('login', { provider: DEFAULT_OAUTH_PROVIDER_NAME, + method: 'oauth', already_logged_in: alreadyLoggedIn, }); if (alreadyLoggedIn) { @@ -158,7 +159,11 @@ async function handleOpenPlatformLogin( platform, models, selectedModel: selection.model, - thinking: selection.thinking, + thinking: selection.thinking !== 'off', + effort: + selection.thinking !== 'off' && selection.thinking !== 'on' + ? selection.thinking + : undefined, apiKey, }); @@ -166,7 +171,7 @@ async function handleOpenPlatformLogin( providers: config.providers, models: config.models, defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, + thinking: config.thinking, }); await host.authFlow.refreshConfigAfterLogin(); diff --git a/apps/kimi-code/src/tui/commands/complete-args.ts b/apps/kimi-code/src/tui/commands/complete-args.ts index 75f76271d..e377c9bb0 100644 --- a/apps/kimi-code/src/tui/commands/complete-args.ts +++ b/apps/kimi-code/src/tui/commands/complete-args.ts @@ -1,4 +1,4 @@ -import type { AutocompleteItem } from '@earendil-works/pi-tui'; +import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; /** * A completable token (subcommand or flag) for a slash command's argument diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 9b91d4ba0..5cab011ea 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,25 +1,30 @@ -import type { - ExperimentalFeatureState, - FlagId, - PermissionMode, - Session, +import { + effectiveModelAlias, + type ExperimentalFeatureState, + type ModelAlias, + type PermissionMode, + type Session, + type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; +import { EffortSelectorComponent } from '../components/dialogs/effort-selector'; import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, } from '../components/dialogs/experiments-selector'; +import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; -import { saveTuiConfig } from '../config'; +import { 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 { 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'; @@ -30,6 +35,39 @@ import type { SlashCommandHost } from './dispatch'; const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000; +const MODEL_SWITCH_CACHE_WARNING = + 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; +const EFFORT_SWITCH_CACHE_WARNING = + 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + +/** True once the conversation has at least one user message: a switch from + * then on resends the accumulated context, losing the cache. Shell-command + * echoes are also 'user' transcript entries but carry an empty `bullet`, so + * they're excluded. */ +function hasConversationHistory(host: SlashCommandHost): boolean { + return host.state.transcriptEntries.some( + (entry) => entry.kind === 'user' && entry.bullet !== '', + ); +} + +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, + }; +} + +function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias { + const providerType = host.state.appState.availableProviders[model.provider]?.type; + // Flat models (no named provider, e.g. inline base_url served by a v2 + // backend) have no provider entry to look up; their own protocol declaration + // plays the provider-identity role, mirroring the resolver. + return effectiveModelAlias(model, providerType ?? model.protocol); +} + export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; if (session === undefined) { @@ -92,7 +130,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', 'Tool actions auto-approved; the agent may still ask you questions.'); return; } @@ -115,7 +153,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', 'Tool actions auto-approved; the agent may still ask you questions.'); } } @@ -136,7 +174,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', 'All actions auto-approved; the agent will not ask you questions.'); return; } @@ -159,7 +197,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', 'All actions auto-approved; the agent will not ask you questions.'); } } @@ -212,6 +250,66 @@ export async function handleModelCommand(host: SlashCommandHost, args: string): 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 = effectiveModelForHost(host, model); + const segments = segmentsFor(effective); + const arg = args.trim().toLowerCase(); + if (arg.length === 0) { + showEffortPicker(host, effective, segments); + return; + } + if (!segments.includes(arg)) { + const providerType = host.state.appState.availableProviders[effective.provider]?.type; + const protocol = effective.protocol ?? providerType; + if (protocol !== 'anthropic') { + host.showError( + `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, + ); + return; + } + const knownEfforts = effective.supportEfforts?.join(', ') ?? 'none declared'; + host.showStatus( + `Thinking effort "${arg}" is not listed for ${alias} (known: ${knownEfforts}). Sending "${arg}" unchanged; the configured provider will validate it.`, + 'warning', + ); + } + 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, + warning: hasConversationHistory(host) ? EFFORT_SWITCH_CACHE_WARNING : undefined, + 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 // --------------------------------------------------------------------------- @@ -273,10 +371,8 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise const editorCommand = value.length > 0 ? value : null; try { await saveTuiConfig({ - theme: host.state.appState.theme, + ...currentTuiConfig(host), editorCommand, - notifications: host.state.appState.notifications, - upgrade: host.state.appState.upgrade, }); } catch (error) { host.showStatus( @@ -295,7 +391,13 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise } export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { - const entries = Object.entries(host.state.appState.availableModels); + const models = Object.fromEntries( + Object.entries(host.state.appState.availableModels).map(([alias, model]) => [ + alias, + effectiveModelForHost(host, model), + ]), + ); + const entries = Object.entries(models); if (entries.length === 0) { host.showNotice( 'No models configured', @@ -305,13 +407,18 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = } host.mountEditorReplacement( new TabbedModelSelectorComponent({ - models: host.state.appState.availableModels, + models, currentValue: host.state.appState.model, selectedValue, - currentThinking: host.state.appState.thinking, + currentThinkingEffort: host.state.appState.thinkingEffort, + warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined, 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(); @@ -320,28 +427,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); @@ -349,41 +467,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}.`; + 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; } @@ -423,10 +575,8 @@ async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promi try { await saveTuiConfig({ + ...currentTuiConfig(host), theme, - editorCommand: host.state.appState.editorCommand, - notifications: host.state.appState.notifications, - upgrade: host.state.appState.upgrade, }); } catch (error) { host.showStatus( @@ -499,7 +649,7 @@ export async function applyExperimentalFeatureChanges( return; } - const experimental: Partial> = {}; + const experimental: Record = {}; for (const change of changes) { experimental[change.id] = change.enabled; } @@ -566,9 +716,7 @@ export async function applyUpdatePreferenceChoice( const upgrade = { autoInstall }; try { await saveTuiConfig({ - theme: host.state.appState.theme, - editorCommand: host.state.appState.editorCommand, - notifications: host.state.appState.notifications, + ...currentTuiConfig(host as unknown as SlashCommandHost), upgrade, }); } catch (error) { diff --git a/apps/kimi-code/src/tui/commands/copy.ts b/apps/kimi-code/src/tui/commands/copy.ts new file mode 100644 index 000000000..bb77b2515 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/copy.ts @@ -0,0 +1,41 @@ +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import type { TranscriptEntry } from '../types'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +/** + * Visible text of the last assistant transcript entry, newest first; empty + * string when none. Sourced from the rendered transcript rather than the + * model context so it survives compaction and session resume: after + * `/compact` the context keeps user messages plus a user-role summary only, + * while the last reply is still on screen. Only entries tagged `modelText` + * count — hook-result and goal-completion cards share kind 'assistant' but + * are not replies. + */ +export function findLastAssistantText(entries: readonly TranscriptEntry[]): string { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry === undefined || entry.kind !== 'assistant' || entry.modelText !== true) continue; + if (entry.content.trim().length > 0) return entry.content; + } + return ''; +} + +export async function handleCopyCommand(host: SlashCommandHost): Promise { + const text = findLastAssistantText(host.state.transcriptEntries); + if (text.length === 0) { + host.showStatus('No assistant message to copy.', 'warning'); + return; + } + + try { + const method = await copyTextToClipboard(text); + host.showStatus( + method === 'native' + ? `Copied to clipboard (${String(text.length)} characters).` + : `Copied via terminal escape sequence (unverified, ${String(text.length)} characters).`, + ); + } catch (error) { + host.showError(`Failed to copy to clipboard: ${formatErrorMessage(error)}`); + } +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index ed67da39c..e1b3fb616 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -1,38 +1,32 @@ -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 { ColorToken, ThemeName } from '#/tui/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 { 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 { 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 type { TUIState } from '../tui-state'; - +import { formatErrorMessage } from '../utils/event-payload'; import { handleLoginCommand, handleLogoutCommand } from './auth'; import { handleBtwCommand } from './btw'; -import { tryHandleDanceCommand } from '../easter-eggs/dance'; +import { handleCopyCommand } from './copy'; import { handleAutoCommand, handleCompactCommand, handleEditorCommand, + handleEffortCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, @@ -43,11 +37,14 @@ import { showSettingsSelector, } from './config'; import { handleGoalCommand } from './goal'; -import { handleProviderCommand } from './provider'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handleAddDirCommand } from './add-dir'; +import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; +import { handleProviderCommand } from './provider'; +import type { BuiltinSlashCommandName } from './registry'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; -import { handleSwarmCommand } from './swarm'; +import { resolveSlashCommandInput, slashBusyMessage } from './resolve'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -55,21 +52,23 @@ import { handleInitCommand, handleTitleCommand, } from './session'; +import { handleSwarmCommand } from './swarm'; import { handleUndoCommand } from './undo'; +import { handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Re-exports — keep existing consumers working // --------------------------------------------------------------------------- -export { - handleLoginCommand, - handleLogoutCommand, -} from './auth'; +export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; +export { handleCopyCommand } from './copy'; +export { handleAddDirCommand } from './add-dir'; export { handleAutoCommand, handleCompactCommand, handleEditorCommand, + handleEffortCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, @@ -80,12 +79,7 @@ export { showSettingsSelector, } from './config'; export { handleSwarmCommand } from './swarm'; -export { - handleFeedbackCommand, - showMcpServers, - showStatusReport, - showUsage, -} from './info'; +export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand } from './goal'; @@ -97,6 +91,7 @@ export { handleTitleCommand, } from './session'; export { handleUndoCommand } from './undo'; +export { handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Host interface @@ -141,12 +136,20 @@ export interface SlashCommandHost { // 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; @@ -172,6 +175,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, }); @@ -203,6 +207,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 @@ -257,6 +275,9 @@ async function handleBuiltInSlashCommand( case 'plugins': void handlePluginsCommand(host, args); return; + case 'add-dir': + await handleAddDirCommand(host, args); + return; case 'experiments': await showExperimentsPanel(host); return; @@ -275,6 +296,9 @@ async function handleBuiltInSlashCommand( case 'model': await handleModelCommand(host, args); return; + case 'effort': + await handleEffortCommand(host, args); + return; case 'provider': await handleProviderCommand(host); return; @@ -329,6 +353,9 @@ async function handleBuiltInSlashCommand( case 'export-debug-zip': await handleExportDebugZipCommand(host); return; + case 'copy': + await handleCopyCommand(host); + return; case 'login': await handleLoginCommand(host); return; @@ -338,6 +365,9 @@ async function handleBuiltInSlashCommand( case 'undo': await handleUndoCommand(host, args); return; + case 'web': + await handleWebCommand(host); + return; default: host.showError(`Unknown slash command: /${String(name)}`); return; diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index 3dca323d7..de790b906 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -372,10 +372,19 @@ async function startGoalWithPermission( choice: GoalStartPermissionChoice, options: GoalStartOptions, ): Promise { - if (choice !== host.state.appState.permissionMode && (choice === 'auto' || choice === 'yolo')) { + const previousMode = host.state.appState.permissionMode; + const switched = + choice !== previousMode && (choice === 'auto' || choice === 'yolo'); + if (switched) { if (!(await setPermissionForGoal(host, choice))) return; } - await startGoal(host, parsed, options); + const started = await startGoal(host, parsed, options); + // The permission switch only exists to run this goal. If creation fails + // (e.g. a goal already exists and `replace` was not given), restore the + // previous mode so the session is not left more permissive than before. + if (!started && switched) { + await setPermissionForGoal(host, previousMode); + } } async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise { @@ -412,7 +421,6 @@ async function startGoal( if (options.beforeSend !== undefined && !(await options.beforeSend())) { return false; } - host.track('goal_create', { replace: parsed.replace }); host.state.transcriptContainer.addChild(new GoalSetMessageComponent()); host.state.ui.requestRender(); if (options.sendInput !== undefined) { diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 769571a62..7449dba9b 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -3,14 +3,13 @@ export * from './parse'; export * from './registry'; export * from './resolve'; export * from './skills'; +export * from './plugin-commands'; export * from './types'; export { dispatchInput, type SlashCommandHost } from './dispatch'; -export { - handleLoginCommand, - handleLogoutCommand, -} from './auth'; +export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; +export { handleCopyCommand } from './copy'; export { handleCompactCommand, handleEditorCommand, @@ -24,22 +23,14 @@ export { showSettingsSelector, } from './config'; export { handleSwarmCommand } from './swarm'; -export { - handleFeedbackCommand, - showMcpServers, - showStatusReport, - showUsage, -} from './info'; +export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand, parseGoalCommand } from './goal'; export { goalArgumentCompletions } from './registry'; -export { - handleForkCommand, - handleInitCommand, - handleTitleCommand, -} from './session'; +export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; export { handleUndoCommand } from './undo'; +export { handleWebCommand } from './web'; export { promptApiKey, promptCatalogProviderSelection, diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 73cc99824..fd5d397f4 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -9,17 +9,21 @@ import { FEEDBACK_ISSUE_URL, FEEDBACK_STATUS_CANCELLED, FEEDBACK_STATUS_FALLBACK, + FEEDBACK_STATUS_NETWORK_ERROR, FEEDBACK_STATUS_NOT_SIGNED_IN, FEEDBACK_STATUS_SUBMITTING, FEEDBACK_STATUS_SUCCESS, + FEEDBACK_STATUS_UPLOAD_FAILED, FEEDBACK_TELEMETRY_EVENT, + feedbackIdLine, feedbackSessionLine, withFeedbackVersionPrefix, } from '../constant/feedback'; import { isManagedUsageProvider } from '../constant/kimi-tui'; +import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments'; import { formatErrorMessage } from '../utils/event-payload'; import { openUrl } from '#/utils/open-url'; -import { promptFeedbackInput } from './prompts'; +import { promptFeedbackAttachment, promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -39,30 +43,60 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise 0 ? host.state.appState.model : null, - }); - - if (res.kind === 'ok') { - spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); - host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); - host.track(FEEDBACK_TELEMETRY_EVENT); + // Stage 2: ask whether to attach diagnostics (logs / codebase). + const level = await promptFeedbackAttachment(host); + if (level === undefined) { + host.showStatus(FEEDBACK_STATUS_CANCELLED); return; } - spinner.stop({ ok: false, label: res.message }); - fallback(FEEDBACK_STATUS_FALLBACK); + const version = withFeedbackVersionPrefix(host.state.appState.version); + const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); + // Guarantee the spinner's underlying setInterval is always cleared, even when + // submitFeedback or submitFeedbackWithAttachments throws — otherwise the + // interval (and its per-frame requestRender) leaks for the rest of the session. + let stopped = false; + const stopSpinner = (opts: { ok: boolean; label: string }): void => { + if (stopped) return; + stopped = true; + spinner.stop(opts); + }; + try { + const res = await host.harness.auth.submitFeedback({ + content: input.value, + sessionId: host.state.appState.sessionId, + version, + os: `${osType()} ${osRelease()}`, + model: host.state.appState.model.length > 0 ? host.state.appState.model : null, + }); + + if (res.kind !== 'ok') { + stopSpinner({ ok: false, label: res.message }); + fallback(FEEDBACK_STATUS_FALLBACK); + return; + } + + // Stage 3: prepare and upload each requested attachment independently. + const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level); + + stopSpinner({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); + host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); + host.showStatus(feedbackIdLine(res.feedbackId)); + host.track(FEEDBACK_TELEMETRY_EVENT); + if (attachmentFailed) { + host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED); + } + } catch (error) { + stopSpinner({ ok: false, label: FEEDBACK_STATUS_NETWORK_ERROR }); + throw error; + } } // --------------------------------------------------------------------------- @@ -113,7 +147,7 @@ export async function showStatusReport(host: SlashCommandHost): Promise { workDir: appState.workDir, sessionId: appState.sessionId, sessionTitle: appState.sessionTitle, - thinking: appState.thinking, + thinkingEffort: appState.thinkingEffort, permissionMode: appState.permissionMode, planMode: appState.planMode, contextUsage: appState.contextUsage, @@ -179,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 cbfed5dcb..b69f0724a 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -4,14 +4,15 @@ import { isAbsolute, join, resolve } from 'node:path'; import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, - type PluginMarketplaceSelection, type PluginRemoveConfirmResult, - type PluginsOverviewSelection, + type PluginsPanelSelection, + type PluginsPanelTabId, } from '../components/dialogs/plugins-selector'; import { buildPluginsInfoLines, @@ -19,8 +20,9 @@ import { } from '../components/messages/plugins-status-panel'; import { UsagePanelComponent } from '../components/messages/usage-panel'; import { formatErrorMessage } from '../utils/event-payload'; -import { formatPluginSourceLabel } from '../utils/plugin-source-label'; +import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label'; import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { openUrl } from '#/utils/open-url'; import type { SlashCommandHost } from './dispatch'; interface ShowPluginsPickerOptions { @@ -29,6 +31,8 @@ interface ShowPluginsPickerOptions { readonly id: string; readonly text: string; }; + readonly initialTab?: PluginsPanelTabId; + readonly marketplaceSource?: string; } interface PluginMcpServerHint { @@ -62,6 +66,10 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showError('Usage: /plugins install '); return; } + if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) { + host.showStatus('Install cancelled.'); + return; + } const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`); try { await installPluginFromSource(host, source); @@ -73,7 +81,15 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri return; } if (sub === 'marketplace') { - await showPluginMarketplacePicker(host, rest.join(' ').trim() || undefined); + const marketplaceSource = rest.join(' ').trim() || undefined; + await showPluginsPicker(host, { + // Custom marketplaces often omit `tier`, so their entries land on the + // Third-party tab (entry.tier !== 'official'). Open there when a custom + // source is supplied; otherwise the default catalog's official entries + // make Official the right landing tab. + initialTab: marketplaceSource === undefined ? 'official' : 'third-party', + marketplaceSource, + }); return; } if (sub === 'info') { @@ -95,7 +111,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri } await session.setPluginMcpServerEnabled(id, server, action === 'enable'); host.showStatus( - `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`, + `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /reload or /new to apply.`, ); return; } @@ -118,8 +134,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showStatus(`Remove cancelled: ${id}.`); return; } - await session.removePlugin(id); - host.showStatus(`Removed ${id} (plugin files left in place).`); + await removePlugin(host, id); return; } if (sub === 'reload') { @@ -149,55 +164,58 @@ async function showPluginsPicker( return; } - host.mountEditorReplacement( - new PluginsOverviewSelectorComponent({ - plugins, - selectedId: options?.selectedId, - pluginHint: options?.pluginHint, - onSelect: (selection) => { - // Each branch of the handler either mounts the next view or restores - // the editor itself, so do not pre-restore here — that would flash the - // editor for in-place actions like toggling a plugin. - void handlePluginsOverviewSelection(host, selection).catch((error: unknown) => { - host.showError(`/plugins failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); + const panel = new PluginsPanelComponent({ + installed: plugins, + installedIds: new Set(plugins.map((plugin) => plugin.id)), + initialTab: options?.initialTab, + selectedId: options?.selectedId, + pluginHint: options?.pluginHint, + onSelect: (selection) => { + // Each branch of the handler either mounts the next view or restores the + // editor itself, so do not pre-restore here — that would flash the editor + // for in-place actions like toggling a plugin. + void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => { + host.showError(`/plugins failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + // Every tab except Custom needs the catalog: Official/Third-party list it, + // and Installed uses it to show update badges. The Installed/Custom tabs + // keep working even when the marketplace is unreachable (badges simply stay + // hidden until data arrives). + onRequestMarketplace: () => { + void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); + }, + }); + host.mountEditorReplacement(panel); + // Kick off the catalog fetch for any tab that needs it: Installed uses it for + // update badges, Official/Third-party list it. Custom never reads the catalog, + // so skip the fetch there. Done here (after `panel` is initialized) rather + // than inside the component constructor, because the callback above closes + // over `panel`. + if (options?.initialTab !== 'custom') { + panel.setMarketplaceLoading(); + void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); + } } -async function showPluginMarketplacePicker(host: SlashCommandHost, source?: string): Promise { +async function loadMarketplaceCatalog( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source?: string, +): Promise { try { - const [marketplace, installed] = await Promise.all([ - loadPluginMarketplace({ workDir: host.state.appState.workDir, source }), - host.requireSession().listPlugins(), - ]); - host.mountEditorReplacement( - new PluginMarketplaceSelectorComponent({ - entries: marketplace.plugins, - installed: new Map( - installed.map((plugin): [string, string | undefined] => [plugin.id, plugin.version]), - ), - source: marketplace.source, - onSelect: (selection) => { - // Every marketplace action re-mounts a picker, so let the handler do - // the mounting — pre-restoring the editor here would flash. - void handlePluginMarketplaceSelection(host, selection).catch((error: unknown) => { - host.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - host.restoreEditor(); - void showPluginsPicker(host); - }, - }), - ); + const marketplace = await loadPluginMarketplace({ + workDir: host.state.appState.workDir, + source, + }); + panel.setMarketplace(marketplace.plugins, marketplace.source); } catch (error) { - host.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`); + panel.setMarketplaceError(formatErrorMessage(error)); } + host.state.ui.requestRender(); } async function showPluginMcpPicker( @@ -255,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, @@ -274,54 +353,71 @@ async function applyPluginEnabled( ? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} .` : ''; if (showStatus) { - host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`); + host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /reload or /new to apply.${mcpHint}`); } const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : ''; return `${pluginInlineChangeHint()}${inlineMcpHint}`; } -async function handlePluginsOverviewSelection( +async function handlePluginsPanelSelection( host: SlashCommandHost, - selection: PluginsOverviewSelection, + panel: PluginsPanelComponent, + selection: PluginsPanelSelection, ): Promise { - const session = host.requireSession(); switch (selection.kind) { - case 'marketplace': - await showPluginMarketplacePicker(host); - return; - case 'reload': - await reloadPlugins(host); - await showPluginsPicker(host); - return; - case 'show-list': - host.restoreEditor(); - await renderPluginsList(host); - return; case 'toggle': { const hint = await applyPluginEnabled(host, selection.id, selection.enabled, false); await showPluginsPicker(host, { + initialTab: 'installed', selectedId: selection.id, pluginHint: { id: selection.id, text: hint }, }); return; } - case 'mcp': - await showPluginMcpPicker(host, selection.id); - return; case 'remove': if (!(await confirmRemovePlugin(host, selection.id))) { host.showStatus(`Remove cancelled: ${selection.id}.`); - await showPluginsPicker(host, { selectedId: selection.id }); + await showPluginsPicker(host, { initialTab: 'installed', selectedId: selection.id }); return; } - await session.removePlugin(selection.id); - host.showStatus(`Removed ${selection.id} (plugin files left in place).`); - await showPluginsPicker(host); + await removePlugin(host, selection.id); + await showPluginsPicker(host, { initialTab: 'installed' }); return; - case 'info': + case 'mcp': + await showPluginMcpPicker(host, selection.id); + return; + case 'details': host.restoreEditor(); await renderPluginInfo(host, selection.id); return; + case 'reload': + await reloadPlugins(host); + await showPluginsPicker(host, { initialTab: 'installed' }); + return; + case 'install': + await installFromPanel( + host, + panel, + selection.entry.source, + selection.entry.displayName, + isOfficialPluginSource(selection.entry.source), + ); + return; + case 'install-source': + await installFromPanel( + host, + panel, + selection.source, + selection.source, + isOfficialPluginSource(selection.source), + ); + return; + case 'open-url': + host.restoreEditor(); + openUrl(selection.url); + host.showStatus(`Opening the ${selection.label} page in your browser…`, 'success'); + host.showStatus(`If it did not open, visit ${selection.url}`); + return; } } @@ -350,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( @@ -397,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'; @@ -424,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( @@ -445,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 { @@ -482,5 +561,5 @@ function resolvePluginInstallSource(source: string, workDir: string): string { } function pluginInlineChangeHint(): string { - return 'require run /new to apply'; + return 'run /reload or /new to apply'; } diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index 67fd89a29..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 { @@ -57,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); + 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, @@ -124,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}`] = { @@ -145,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); @@ -159,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 ?? []; @@ -167,7 +210,7 @@ export function runModelSelector( const selector = new ModelSelectorComponent({ models: modelDict, currentValue: firstAlias, - currentThinking: initialThinking, + 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 242252bfb..994cae8b3 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -13,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, @@ -27,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, @@ -158,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) { @@ -233,7 +239,7 @@ 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, + currentThinkingEffort: host.state.appState.thinkingEffort, initialTabId: providerId, onSelect: ({ alias, thinking }) => { host.restoreEditor(); @@ -251,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 { @@ -274,7 +280,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise let entries: Awaited>; try { - entries = await fetchCustomRegistry(source); + entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() }); } catch (error) { host.showError(`Failed to import registry: ${formatErrorMessage(error)}`); return false; @@ -323,7 +329,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise models: stateModels, currentValue: host.state.appState.model, selectedValue: firstNewAlias, - currentThinking: host.state.appState.thinking, + currentThinkingEffort: host.state.appState.thinkingEffort, initialTabId: firstNewProvider, onSelect: ({ alias, thinking }) => { host.restoreEditor(); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 464cc770d..34556f7e6 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -1,4 +1,8 @@ -import type { AutocompleteItem } from '@earendil-works/pi-tui'; +import { readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'pathe'; + +import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; import type { KimiSlashCommand, SlashCommandAvailability } from './types'; @@ -22,6 +26,10 @@ const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ { value: 'off', description: 'Turn swarm mode off' }, ]; +const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'list', description: 'Show configured additional workspace directories' }, +]; + /** Argument autocompletion for the `/goal` command (subcommands). */ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i); @@ -41,19 +49,102 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); } +/** Argument autocompletion for the `/add-dir` command. */ +export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + if (isPathLikeAddDirArgument(argumentPrefix)) { + return completeAddDirPath(argumentPrefix); + } + return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix); +} + +function isPathLikeAddDirArgument(argumentPrefix: string): boolean { + return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~'); +} + +function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null { + const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix; + const expandedPrefix = expandHomePrefix(normalizedPrefix); + const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix); + const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix); + const parentDir = resolveDirectoryCompletionParent(parentInput); + let entries; + try { + entries = readdirSync(parentDir, { withFileTypes: true }); + } catch { + return null; + } + + const items: AutocompleteItem[] = []; + for (const entry of entries) { + if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue; + if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue; + const absolutePath = join(parentDir, entry.name); + if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue; + const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name); + items.push({ + value, + label: `${entry.name}/`, + description: absolutePath, + }); + } + + return items.length > 0 ? items : null; +} + +function expandHomePrefix(argumentPrefix: string): string { + if (argumentPrefix === '~') return homedir(); + if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2)); + return argumentPrefix; +} + +function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string { + if (argumentPrefix === '/') return '/'; + if (argumentPrefix === '~/') return homedir(); + if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1); + return dirname(expandedPrefix); +} + +function resolveDirectoryCompletionParent(parentInput: string): string { + if (parentInput === '~') return homedir(); + if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2)); + return resolve(parentInput); +} + +function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean { + if (isDirectory) return true; + if (!isSymlink) return false; + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string { + if (argumentPrefix.startsWith('~/')) { + const home = homedir(); + const homeRelative = relative(home, parentInput); + return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`; + } + if (argumentPrefix.startsWith('/')) { + return `${join(parentInput, entryName)}/`; + } + return `${join(parentInput, entryName)}/`; +} + export const BUILTIN_SLASH_COMMANDS = [ { name: 'yolo', aliases: ['yes'], - description: 'Toggle auto-approve mode', - priority: 100, + description: 'Toggle YOLO mode: auto-approve tool actions, but the agent may still ask questions.', + priority: 101, availability: 'always', }, { name: 'auto', aliases: [], - description: 'Toggle auto permission mode', - priority: 100, + description: 'Toggle Auto mode: fully autonomous, agent decides everything without asking.', + priority: 99, availability: 'always', }, { @@ -82,6 +173,7 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: [], description: 'Toggle swarm mode or run one task in swarm mode', priority: 100, + argumentHint: '[on|off] | ', completeArgs: swarmArgumentCompletions, availability: 'idle-only', }, @@ -92,6 +184,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: 'always', }, + { + name: 'effort', + aliases: ['thinking'], + description: 'Switch thinking effort', + priority: 95, + availability: 'always', + }, { name: 'provider', aliases: ['providers'], @@ -146,6 +245,15 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'add-dir', + aliases: [], + description: 'Add or list an additional workspace directory', + priority: 60, + availability: 'idle-only', + argumentHint: '[list] | ', + completeArgs: addDirArgumentCompletions, + }, { name: 'experiments', aliases: ['experimental'], @@ -172,16 +280,14 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: [], description: 'Compact the conversation context', priority: 80, + argumentHint: '', }, { name: 'goal', aliases: [], description: 'Start or manage an autonomous goal', priority: 80, - // No argumentHint: the menu description stays as short as every other - // command's. The subcommands (status/pause/resume/cancel/replace) surface in - // the argument autocomplete list once the user types `/goal ` (see - // completeArgs), so they don't need to be spelled out inline. + argumentHint: '[status|pause|resume|cancel|replace|next] | ', completeArgs: goalArgumentCompletions, // status / pause / cancel are always available; creation, replacement, and // resume start (or restart) a turn and so are idle-only. @@ -209,6 +315,7 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: ['rename'], description: 'Set or show session title', priority: 60, + argumentHint: '', availability: 'always', }, { @@ -277,6 +384,19 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Export current session as a debug ZIP archive', priority: 40, }, + { + name: 'copy', + aliases: [], + description: 'Copy the last assistant message to the clipboard', + priority: 40, + }, + { + name: 'web', + aliases: [], + description: 'Open the current session in the Web UI and exit the terminal', + priority: 40, + availability: 'always', + }, { name: 'exit', aliases: ['quit', 'q'], diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index a8700d95e..6f28a16da 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -16,7 +16,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise<void> const session = host.session; if (session !== undefined) { - await session.reloadSession(); + await session.reloadSession({ forcePluginSessionStartReminder: true }); await host.reloadCurrentSessionView(session, 'Session reloaded.'); } @@ -45,9 +45,11 @@ export async function applyReloadedTuiConfig( host.refreshTerminalThemeTracking(); host.setAppState({ editorCommand: config.editorCommand, + disablePasteBurst: config.disablePasteBurst, notifications: config.notifications, upgrade: config.upgrade, }); + host.state.editor.setDisablePasteBurst(config.disablePasteBurst); } function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void { diff --git a/apps/kimi-code/src/tui/commands/resolve.ts b/apps/kimi-code/src/tui/commands/resolve.ts index a47f11409..e67457a94 100644 --- a/apps/kimi-code/src/tui/commands/resolve.ts +++ b/apps/kimi-code/src/tui/commands/resolve.ts @@ -26,6 +26,12 @@ export type SlashCommandIntent = readonly skillName: string; readonly args: string; } + | { + readonly kind: 'plugin-command'; + readonly commandName: string; + readonly pluginId: string; + readonly args: string; + } | { readonly kind: 'message'; readonly input: string } | { readonly kind: 'blocked'; @@ -41,6 +47,7 @@ export type SlashCommandIntent = export interface ResolveSlashCommandInput { readonly input: string; readonly skillCommandMap: ReadonlyMap<string, string>; + readonly pluginCommandMap: ReadonlyMap<string, string>; readonly isStreaming: boolean; readonly isCompacting: boolean; } @@ -92,6 +99,26 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla }; } + if (options.pluginCommandMap.has(parsed.name)) { + const busyReason = slashCommandBusyReason(options); + if (busyReason !== undefined) { + return { + kind: 'blocked', + commandName: parsed.name, + reason: busyReason, + }; + } + const separator = parsed.name.indexOf(':'); + const pluginId = separator === -1 ? parsed.name : parsed.name.slice(0, separator); + const commandName = separator === -1 ? '' : parsed.name.slice(separator + 1); + return { + kind: 'plugin-command', + commandName, + pluginId, + args: parsed.args.trim(), + }; + } + return { kind: 'message', input: options.input, diff --git a/apps/kimi-code/src/tui/commands/types.ts b/apps/kimi-code/src/tui/commands/types.ts index 6ee0a172f..1ec3c6835 100644 --- a/apps/kimi-code/src/tui/commands/types.ts +++ b/apps/kimi-code/src/tui/commands/types.ts @@ -1,4 +1,4 @@ -import type { AutocompleteItem, SlashCommand } from '@earendil-works/pi-tui'; +import type { AutocompleteItem, SlashCommand } from '@moonshot-ai/pi-tui'; import type { FlagId } from '@moonshot-ai/kimi-code-sdk'; export type SlashCommandAvailability = 'always' | 'idle-only'; diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 5a1c963d7..bd427277d 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; @@ -15,6 +15,7 @@ import { BackgroundAgentStatusComponent } from '../components/messages/backgroun import { CronMessageComponent } from '../components/messages/cron-message'; import { ReadGroupComponent } from '../components/messages/read-group'; import { SkillActivationComponent } from '../components/messages/skill-activation'; +import { PluginCommandComponent } from '../components/messages/plugin-command'; import { ThinkingComponent } from '../components/messages/thinking'; import { ToolCallComponent } from '../components/messages/tool-call'; import { UserMessageComponent } from '../components/messages/user-message'; @@ -237,6 +238,9 @@ function isContextUndoAnchor(message: ContextMessage): boolean { if (origin.kind === 'skill_activation') { return origin.trigger === 'user-slash'; } + if (origin.kind === 'plugin_command') { + return origin.trigger === 'user-slash'; + } return false; } @@ -295,6 +299,9 @@ function formatUndoChoiceLabel( 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; @@ -314,9 +321,19 @@ function formatUndoChoiceInput(entry: TranscriptEntry): string { 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(); } @@ -374,7 +391,8 @@ function undoLimitFromError( function isUndoAnchorEntry(entry: TranscriptEntry): boolean { return ( entry.kind === 'user' || - (entry.kind === 'skill_activation' && entry.skillTrigger === 'user-slash') + (entry.kind === 'skill_activation' && entry.skillTrigger === 'user-slash') || + entry.kind === 'plugin_command' ); } @@ -400,6 +418,7 @@ function isUndoContextEntry(entry: TranscriptEntry): boolean { case 'tool_call': case 'thinking': case 'skill_activation': + case 'plugin_command': case 'cron': return true; case 'status': @@ -440,7 +459,8 @@ function removeUndoContextComponents( function isUndoAnchorComponent(child: Component): boolean { return ( child instanceof UserMessageComponent || - (child instanceof SkillActivationComponent && child.trigger === 'user-slash') + (child instanceof SkillActivationComponent && child.trigger === 'user-slash') || + child instanceof PluginCommandComponent ); } @@ -459,6 +479,7 @@ function isUndoContextComponent(child: Component): boolean { child instanceof AgentSwarmProgressComponent || child instanceof ReadGroupComponent || child instanceof SkillActivationComponent || + child instanceof PluginCommandComponent || child instanceof BackgroundAgentStatusComponent || child instanceof CronMessageComponent ); 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 index 265f0b02e..58b6faa58 100644 --- a/apps/kimi-code/src/tui/components/chrome/banner.ts +++ b/apps/kimi-code/src/tui/components/chrome/banner.ts @@ -1,5 +1,5 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui'; +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'; 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 9cc7104b1..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,8 +6,8 @@ * 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 type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 127009506..a2e8d7ade 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -3,13 +3,15 @@ * * Layout: * Line 1: [yolo] [plan] <model> <cwd> <git-badge> <shortcut hints> - * Line 2: context: XX.X% (tokens/max) + * Line 2: context: N% (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'; @@ -21,7 +23,11 @@ import { type GitStatus, type GitStatusCache, } from '#/utils/git/git-status'; -import { safeUsageRatio } from '#/utils/usage/usage-format'; +import { + formatTokenCount, + usagePercent, + usagePercentFromRatio, +} from '#/utils/usage/usage-format'; const MAX_CWD_SEGMENTS = 3; const GOAL_TIMER_INTERVAL_MS = 1_000; @@ -31,46 +37,9 @@ const GOAL_TIMER_INTERVAL_MS = 1_000; // important enough to take the whole slot on their own. A `priority` weight // makes a tip recur more often in the rotation (default 1). Width is always // the final arbiter (a pair that doesn't fit falls back to its first tip). -// -// This is deliberately code-level configuration: edit the interval and the -// TOOLBAR_TIPS array below to change what the footer advertises. const TIP_ROTATE_INTERVAL_MS = 10_000; const TIP_SEPARATOR = ' | '; -export interface ToolbarTip { - readonly text: string; - /** - * Long/important tips render on their own. They never pair with a - * neighbour and never appear as the second half of someone else's pair. - */ - readonly solo?: boolean; - /** - * Rotation weight: a higher value makes the tip recur more often. Defaults - * to 1. Used to give newer/important features more airtime. - */ - readonly priority?: number; -} - -const TOOLBAR_TIPS: readonly ToolbarTip[] = [ - { text: 'shift+tab: plan mode' }, - { text: '/model: switch model' }, - { text: 'ctrl+s: steer mid-turn', priority: 2 }, - { text: '/compact: compact context', priority: 2 }, - { text: 'ctrl+o: expand tool output' }, - { text: '/tasks: background tasks' }, - { text: 'shift+enter: newline' }, - { text: '/init: generate AGENTS.md', priority: 2 }, - { text: '@: mention files' }, - { text: 'ctrl+c: cancel' }, - { text: '/theme: switch theme' }, - { text: '/auto: auto permission mode' }, - { text: '/yolo: toggle yolo' }, - { text: '/help: show commands' }, - { text: '/dance: rainbow mode, because why not' }, - { text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 }, - { text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, -]; - /** * Expand tips into a rotation sequence using smooth weighted round-robin * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while @@ -98,7 +67,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); @@ -168,7 +137,8 @@ function formatBadgeElapsed(ms: number): string { function modelDisplayName(state: AppState): string { const model = state.availableModels[state.model]; - return model?.displayName ?? model?.model ?? state.model; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? state.model; } function shortenCwd(path: string): string { @@ -188,22 +158,18 @@ function shortenCwd(path: string): string { return `…/${tail}`; } -function formatTokenCount(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - -function safeUsage(usage: number): number { - return safeUsageRatio(usage); -} - +/** + * Footer context readout. Percent comes from the exact token counts when + * both are known (the ratio can lag a step behind); otherwise it falls + * back to the precomputed ratio. Counts use the shared 1024-based + * formatter. + */ function formatContextStatus(usage: number, tokens?: number, maxTokens?: number): string { - const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`; - if (maxTokens && maxTokens > 0 && tokens !== undefined) { - return `context: ${pct} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`; + if (maxTokens !== undefined && maxTokens > 0 && tokens !== undefined) { + const pct = String(usagePercent(tokens, maxTokens)); + return `context: ${pct}% (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`; } - return `context: ${pct}`; + return `context: ${String(usagePercentFromRatio(usage))}%`; } export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { @@ -264,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 @@ -294,7 +264,18 @@ export class FooterComponent implements Component { const model = modelDisplayName(state); if (model) { - const thinkingLabel = state.thinking ? ' thinking' : ''; + const effort = state.thinkingEffort; + const rawCurrentModel = state.availableModels[state.model]; + const currentModel = rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel); + // Only effort-capable models (those declaring support_efforts) show the + // concrete effort; legacy boolean models keep the plain "thinking" suffix. + const hasEfforts = (currentModel?.supportEfforts?.length ?? 0) > 0; + const thinkingLabel = + effort !== 'off' + ? hasEfforts && effort !== 'on' + ? ` thinking: ${effort}` + : ' thinking' + : ''; const modelLabel = `${model}${thinkingLabel}`; let renderedModelLabel = chalk.hex(colors.text)(modelLabel); if (isRainbowDancing()) { @@ -402,6 +383,13 @@ export class FooterComponent implements Component { } } + dispose(): void { + if (this.goalTimer !== null) { + clearInterval(this.goalTimer); + this.goalTimer = null; + } + } + private goalWallClockMs(goal: AppState['goal']): number | undefined { if (goal === null || goal === undefined) return undefined; if (goal.status !== 'active') return goal.wallClockMs; diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts index 39ed97c49..72b1e94b1 100644 --- a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -9,9 +9,21 @@ * the edge and adding them would just churn the diff renderer. */ -import { Container } from '@earendil-works/pi-tui'; +import { Container } from '@moonshot-ai/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; + +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +interface TranscriptRenderCache { + width: number; + childRefs: Component[]; + childRenderRefs: string[][]; + prefixed: string[][]; + out: string[]; +} export class GutterContainer extends Container { + private renderCache: TranscriptRenderCache | undefined; constructor( private readonly leftPad: number, private readonly rightPad: number, @@ -19,15 +31,56 @@ export class GutterContainer extends Container { super(); } + override invalidate(): void { + this.renderCache = undefined; + super.invalidate(); + } + override render(width: number): string[] { const inner = Math.max(1, width - this.leftPad - this.rightPad); const lead = ' '.repeat(this.leftPad); - const out: string[] = []; + + const cache = this.renderCache; + const cacheValid = + isRenderCacheEnabled() && + cache !== undefined && + cache.width === width && + cache.childRefs.length === this.children.length; + + const childRefs: Component[] = []; + const childRenderRefs: string[][] = []; + const prefixed: string[][] = []; + let allReused = cacheValid; + + let i = 0; for (const child of this.children) { - for (const line of child.render(inner)) { - out.push(lead + line); + const lines = child.render(inner); + childRefs.push(child); + childRenderRefs.push(lines); + const reused = cacheValid && cache.childRefs[i] === child && cache.childRenderRefs[i] === lines; + if (reused) { + prefixed.push(cache.prefixed[i]!); + } else { + allReused = false; + prefixed.push(lines.map((line) => lead + line)); + } + i++; + } + + let out: string[]; + if (allReused) { + out = cache!.out; + } else { + out = []; + for (const lines of prefixed) { + for (const line of lines) out.push(line); } } + + if (isRenderCacheEnabled()) { + this.renderCache = { width, childRefs, childRenderRefs, prefixed, out }; + } + return out; } } diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts index 0f9c971ea..a9d21452e 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -1,5 +1,5 @@ -import { Text } from '@earendil-works/pi-tui'; -import type { TUI } from '@earendil-works/pi-tui'; +import { Text, visibleWidth } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { BRAILLE_SPINNER_FRAMES, @@ -7,6 +7,7 @@ import { MOON_SPINNER_FRAMES, MOON_SPINNER_INTERVAL_MS, } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; export type SpinnerStyle = 'moon' | 'braille'; @@ -19,6 +20,14 @@ export class MoonLoader extends Text { private colorFn?: (s: string) => string; private label: string; private displayText = ''; + // Inline text used when the spinner is embedded into another line (e.g. the + // agent-swarm progress status line). It intentionally excludes the tip: the + // tip is only rendered when the loader sits on its own row in the activity + // pane, otherwise it would get squeezed against whatever follows the inline + // spinner (like the swarm progress bar). + private inlineText = ''; + private tip: string = ''; + private availableWidth = 0; constructor( ui: TUI, @@ -50,6 +59,10 @@ export class MoonLoader extends Text { } } + dispose(): void { + this.stop(); + } + setLabel(label: string): void { this.label = label; this.updateDisplay(); @@ -60,14 +73,34 @@ export class MoonLoader extends Text { this.updateDisplay(); } + setTip(tip: string): void { + this.tip = tip; + this.updateDisplay(); + } + + setAvailableWidth(width: number): void { + if (this.availableWidth === width) return; + this.availableWidth = width; + this.updateDisplay(); + } + renderInline(): string { - return this.displayText; + return this.inlineText; } private updateDisplay(): void { const frame = this.frames[this.currentFrame]!; const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; - this.displayText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + const baseText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + this.inlineText = baseText; + let text = baseText; + if (this.tip) { + const withTip = baseText + currentTheme.fg('textDim', this.tip); + if (this.availableWidth === 0 || visibleWidth(withTip) <= this.availableWidth) { + text = withTip; + } + } + this.displayText = text; this.setText(this.displayText); this.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts index 9e02e2fbf..b101b6d6c 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -9,8 +9,8 @@ * is issued. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { currentTheme } from '#/tui/theme'; @@ -28,6 +28,7 @@ const MAX_VISIBLE = 5; export interface VisibleTodos { readonly rows: readonly TodoItem[]; readonly hidden: number; + readonly hiddenCounts: Record<TodoStatus, number>; } /** @@ -49,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[] = []; @@ -91,14 +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 expanded = false; setTodos(todos: readonly TodoItem[]): void { this.todos = todos.map((t) => ({ title: t.title, status: t.status })); @@ -110,27 +125,57 @@ export class TodoPanelComponent implements Component { clear(): void { this.todos = []; + this.expanded = false; } isEmpty(): boolean { return this.todos.length === 0; } + /** 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 = currentTheme.palette; - const { rows, hidden } = selectVisibleTodos(this.todos); 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)); @@ -164,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 3db1de3cf..10cdecbdb 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -3,10 +3,12 @@ * Renders a round-bordered box with the logo, session, model, and version. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; + import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance'; import type { AppState } from '#/tui/types'; import { currentTheme } from '#/tui/theme'; @@ -25,6 +27,7 @@ export class WelcomeComponent implements Component { const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s); const isLoggedOut = !this.state.model; const activeModel = this.state.availableModels[this.state.model]; + const effectiveActiveModel = activeModel === undefined ? undefined : effectiveModelAlias(activeModel); if (safeWidth < 24) { const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'); @@ -33,7 +36,7 @@ export class WelcomeComponent implements Component { : chalk.hex(currentTheme.palette.textDim)('Send /help for help information.'); const model = isLoggedOut ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') - : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); return ['', title, prompt, `Model: ${model}`].map((line) => truncateToWidth(line, safeWidth, '…'), ); @@ -71,7 +74,7 @@ export class WelcomeComponent implements Component { const modelValue = isLoggedOut ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') - : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); const infoLines = [ labelStyle('Directory: ') + this.state.workDir, diff --git a/apps/kimi-code/src/tui/components/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 f837dc046..d95d9ff93 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -6,7 +6,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts index 1f1a55403..e18f5709b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -14,7 +14,7 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; @@ -379,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(''); diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts index 15974959f..044884792 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -24,10 +24,10 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; -import { renderDiffLines } from '#/tui/components/media/diff-preview'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/reverse-rpc/types'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; @@ -218,17 +218,19 @@ function buildBody(block: ApprovalPreviewBlock): BuiltBody { } function buildDiffBody(block: DiffDisplayBlock): BuiltBody { - // renderDiffLines emits a `+N -M path` header on its first line followed - // by every changed line. We pull the header out into the viewer chrome so - // the body is purely scrollable diff content; this also means we don't - // double-render the path. - const rendered = renderDiffLines( + // renderDiffLinesClustered emits a `+N -M path` header on its first line + // followed by every changed line plus surrounding context. We pull the + // header out into the viewer chrome so the body is purely scrollable diff + // content; this also means we don't double-render the path. + const rendered = renderDiffLinesClustered( block.old_text, block.new_text, block.path, - false, - block.old_start ?? 1, - block.new_start ?? 1, + { + contextLines: 3, + oldStart: block.old_start ?? 1, + newStart: block.new_start ?? 1, + }, ); const [header = '', ...rest] = rendered; return { lines: rest, title: stripLeadingSpace(header) }; diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index c03444f9b..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,9 +15,9 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import { currentTheme } from '#/tui/theme'; +import { currentTheme, type ColorToken } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -30,6 +30,9 @@ 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 { @@ -37,6 +40,8 @@ export interface ChoicePickerOptions { readonly hint?: 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; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ @@ -44,6 +49,9 @@ export interface ChoicePickerOptions { /** 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; } @@ -94,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(); @@ -129,15 +142,26 @@ export class ChoicePickerComponent extends Container implements Focusable { const titleSuffix = searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintLines = hint.split(/\r?\n/); const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix, - this.opts.formatHint === undefined - ? currentTheme.fg('textMuted', ` ${hint}`) - : this.opts.formatHint(` ${hint}`), ]; + for (const hintLine of hintLines) { + lines.push( + this.opts.formatHint === undefined + ? currentTheme.fg('textMuted', ` ${hintLine}`) + : this.opts.formatHint(` ${hintLine}`), + ); + } if (this.opts.notice !== undefined) { - lines.push(currentTheme.fg('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) { @@ -161,8 +185,10 @@ export class ChoicePickerComponent extends Container implements Focusable { 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(currentTheme.fg('textMuted', ` ${descLine}`)); + lines.push(currentTheme.fg(descriptionColor, ` ${descLine}`)); } } } diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts index f2ef1a75c..9ade9350c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/compaction.ts +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -13,8 +13,8 @@ * reads the same "work in progress" signal across the UI. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; -import type { TUI } from '@earendil-works/pi-tui'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; @@ -24,18 +24,24 @@ const BLINK_INTERVAL = 500; export class CompactionComponent extends Container { private readonly ui: TUI | undefined; private readonly headerText: Text; + private instructionText: Text | undefined; private readonly instruction: string | undefined; + private readonly tip: string | undefined; private blinkOn = true; 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(ui?: TUI, instruction?: string | undefined) { + constructor(ui?: TUI, instruction?: string | undefined, tip?: string) { super(); 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.). @@ -49,32 +55,48 @@ export class CompactionComponent extends Container { private addInstructionChild(): void { if (this.instruction !== undefined) { - this.addChild(new Text(currentTheme.dim(` ${this.instruction}`), 0, 0)); + this.instructionText = new Text(currentTheme.dim(` ${this.instruction}`), 0, 0); + this.addChild(this.instructionText); } } + private removeInstructionChild(): void { + if (this.instructionText === undefined) return; + const index = this.children.indexOf(this.instructionText); + if (index !== -1) { + this.children.splice(index, 1); + } + this.instructionText = undefined; + } + override invalidate(): void { // Repaint the header with the active palette (it caches ANSI codes). this.headerText.setText(this.buildHeader()); - // Rebuild instruction line with fresh theme colours. - if (this.instruction !== undefined) { - // Remove the last child if it is the instruction line (it is always - // added after headerText and Spacer). - if (this.children.length > 2) { - this.children.pop(); - } - this.addInstructionChild(); + // Rebuild instruction and summary text with fresh theme colours, preserving + // header → instruction → summary child order. + const expanded = this.expanded; + this.removeInstructionChild(); + if (expanded) { + this.removeSummaryChild(); + } + this.addInstructionChild(); + if (expanded) { + this.addSummaryChild(); } super.invalidate(); } - markDone(tokensBefore?: number, tokensAfter?: number): void { + markDone(tokensBefore?: number, tokensAfter?: number, summary?: string): void { if (this.done || this.canceled) return; this.done = true; this.tokensBefore = tokensBefore; this.tokensAfter = tokensAfter; + this.summary = summary; this.stopBlink(); this.headerText.setText(this.buildHeader()); + if (this.expanded) { + this.addSummaryChild(); + } this.ui?.requestRender(); } @@ -86,6 +108,39 @@ export class CompactionComponent extends Container { this.ui?.requestRender(); } + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + if (expanded) { + this.addSummaryChild(); + } else { + this.removeSummaryChild(); + } + this.headerText.setText(this.buildHeader()); + this.ui?.requestRender(); + } + + private addSummaryChild(): void { + if (this.summaryText !== undefined || this.summary === undefined || this.summary.length === 0) { + return; + } + const indentedSummary = this.summary + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + this.summaryText = new Text(currentTheme.dim(indentedSummary), 0, 0); + this.addChild(this.summaryText); + } + + private removeSummaryChild(): void { + if (this.summaryText === undefined) return; + const index = this.children.indexOf(this.summaryText); + if (index !== -1) { + this.children.splice(index, 1); + } + this.summaryText = undefined; + } + dispose(): void { this.stopBlink(); } @@ -98,7 +153,11 @@ export class CompactionComponent extends Container { this.tokensBefore !== undefined && this.tokensAfter !== undefined ? currentTheme.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) : ''; - return `${bullet}${label}${detail}`; + const shortcutHint = + this.summary !== undefined && this.summary.length > 0 + ? currentTheme.dim(` (Ctrl-O to ${this.expanded ? 'hide' : 'show'} compaction summary)`) + : ''; + return `${bullet}${label}${detail}${shortcutHint}`; } if (this.canceled) { const bullet = currentTheme.fg('warning', STATUS_BULLET); @@ -107,7 +166,8 @@ export class CompactionComponent extends Container { } const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; const label = currentTheme.boldFg('primary', 'Compacting context...'); - return `${bullet}${label}`; + 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 8a3150bf4..aa4aa910b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts +++ b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts @@ -17,7 +17,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts new file mode 100644 index 000000000..bba324306 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts @@ -0,0 +1,104 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + wrapTextWithAnsi, + 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; + /** When set, rendered as warning-colored lines directly below the key-hint + * line; wraps instead of truncating when it exceeds the width (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; +} + +/** + * 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(' · ')}`), + ]; + if (this.opts.warning !== undefined) { + for (const line of wrapTextWithAnsi(this.opts.warning, Math.max(1, width - 1))) { + lines.push(currentTheme.fg('warning', ` ${line}`)); + } + } + lines.push(''); + + const segments = this.opts.efforts.map((effort, index) => { + const label = effortLabel(effort); + return index === this.activeIndex + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); + }); + lines.push(` ${segments.join(' ')}`); + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts index 44042f057..1e466f873 100644 --- a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts @@ -5,7 +5,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts index 3fefe86c0..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,7 +19,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; export type FeedbackInputDialogResult = @@ -83,7 +87,15 @@ export class FeedbackInputDialogComponent extends Container implements Focusable 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, '…'))]; diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts index c35d55399..b5c2e7ac1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -6,7 +6,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index c7cc39923..e60d85ce0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -11,7 +11,7 @@ export interface GoalStartPermissionPromptOptions { readonly onCancel: () => void; } -const MANUAL_OPTIONS: readonly StartPermissionOption[] = [ +export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -37,7 +37,7 @@ const MANUAL_OPTIONS: readonly StartPermissionOption[] = [ }, ]; -const YOLO_OPTIONS: readonly StartPermissionOption[] = [ +export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -57,6 +57,14 @@ const YOLO_OPTIONS: readonly StartPermissionOption[] = [ }, ]; +export function goalStartOptions(mode: 'manual' | 'yolo'): readonly StartPermissionOption[] { + return mode === 'yolo' ? GOAL_START_YOLO_OPTIONS : GOAL_START_MANUAL_OPTIONS; +} + +const MANUAL_OPTIONS = GOAL_START_MANUAL_OPTIONS; + +const YOLO_OPTIONS = GOAL_START_YOLO_OPTIONS; + const MANUAL_NOTICE_LINES = [ 'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', 'Manual mode is not suitable for unattended goal work.', 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 1bbb743a0..10fd5d5fe 100644 --- a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts @@ -15,7 +15,7 @@ import { decodeKittyPrintable, type Focusable, truncateToWidth, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; export interface KeyboardShortcut { @@ -33,7 +33,8 @@ export interface HelpPanelCommand { export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ { keys: 'Shift-Tab', description: 'Toggle plan mode' }, { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, - { keys: 'Ctrl-O', description: 'Toggle tool output expansion' }, + { keys: 'Ctrl-O', description: 'Toggle tool output / compaction summary expansion' }, + { keys: 'Ctrl-T', description: 'Expand / collapse the todo list (when truncated)' }, { keys: 'Ctrl-S', description: 'Steer — inject a follow-up during streaming' }, { keys: 'Shift-Enter / Ctrl-J', description: 'Insert newline' }, { keys: 'Ctrl-C', description: 'Interrupt stream / clear input' }, 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 f223ba50b..64646e022 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -1,12 +1,13 @@ -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, + wrapTextWithAnsi, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; @@ -30,11 +31,15 @@ interface ModelChoice { export interface ModelSelection { readonly alias: string; - readonly thinking: boolean; + /** Chosen thinking effort: 'off', or a concrete effort such as 'low' / + * 'high' / 'max'. Boolean 'on' is normalized to the model's default effort + * before the selection is committed (see commitEffort). */ + readonly thinking: ThinkingEffort; } export function modelDisplayName(alias: string, model: ModelAlias | undefined): string { - return model?.displayName ?? model?.model ?? alias; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? alias; } export function providerDisplayName(provider: string): string { @@ -46,17 +51,22 @@ export function providerDisplayName(provider: string): string { export function createModelChoiceOptions( models: Record<string, ModelAlias>, ): readonly ChoiceOption[] { - return Object.entries(models).map(([alias, cfg]) => ({ - value: alias, - label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, - })); + return Object.entries(models).map(([alias, cfg]) => { + const effective = effectiveModelAlias(cfg); + return { + value: alias, + label: `${modelDisplayName(alias, effective)} (${providerDisplayName(effective.provider)})`, + }; + }); } export interface ModelSelectorOptions { readonly models: Record<string, ModelAlias>; readonly currentValue: string; readonly selectedValue?: string; - readonly currentThinking: boolean; + /** 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). */ @@ -64,30 +74,82 @@ export interface ModelSelectorOptions { /** When true, the hint line mentions the Tab provider switch — set by * TabbedModelSelectorComponent so the inner list advertises the tab keys. */ readonly providerSwitchHint?: boolean; + /** When set, rendered as warning-colored lines directly below the key-hint + * line; wraps instead of truncating when it exceeds the width (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; readonly onSelect: (selection: ModelSelection) => void; + /** When provided, Alt+S invokes this instead of onSelect — used to apply the + * choice to the current session only, without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } function createModelChoices(models: Record<string, ModelAlias>): readonly ModelChoice[] { return Object.entries(models).map(([alias, cfg]) => { - const name = modelDisplayName(alias, cfg); - const provider = providerDisplayName(cfg.provider); - return { alias, model: cfg, name, provider, label: `${name} (${provider})` }; + const effective = effectiveModelAlias(cfg); + const name = modelDisplayName(alias, effective); + const provider = providerDisplayName(effective.provider); + return { alias, model: effective, name, provider, label: `${name} (${provider})` }; }); } -function thinkingAvailability(model: ModelAlias): ThinkingAvailability { +export function thinkingAvailability(model: ModelAlias): ThinkingAvailability { const caps = model.capabilities ?? []; if (caps.includes('always_thinking')) return 'always-on'; if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle'; return 'unsupported'; } -function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean { +export function effortsOf(model: ModelAlias): readonly string[] { + return model.supportEfforts ?? []; +} + +/** + * Ordered list of selectable thinking efforts for a model. Effort-capable models + * expose their declared efforts (with an 'off' entry when the model is not + * always-on); legacy boolean models expose 'on'/'off'; single-segment lists + * mean the control is effectively locked. + */ +export function segmentsFor(model: ModelAlias): readonly string[] { + const efforts = effortsOf(model); const availability = thinkingAvailability(model); - if (availability === 'always-on') return true; - if (availability === 'unsupported') return false; - return thinkingDraft; + if (efforts.length > 0) { + return availability === 'always-on' ? efforts : ['off', ...efforts]; + } + if (availability === 'always-on') return ['on']; + if (availability === 'unsupported') return ['off']; + return ['on', 'off']; +} + +export function effortLabel(effort: string): string { + if (effort.length === 0) return effort; + return effort.charAt(0).toUpperCase() + effort.slice(1); +} + +/** + * Default thinking effort for a model: declared `default_effort`, else the + * middle `support_efforts` entry, else `'on'` for boolean models, `'off'` when + * thinking is unsupported. + */ +function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { + if (thinkingAvailability(model) === 'unsupported') return 'off'; + const efforts = effortsOf(model); + if (efforts.length > 0) { + return model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; + } + return 'on'; +} + +/** + * Normalize a draft effort before committing a selection. A boolean `'on'` + * never leaks past the UI boundary — it becomes the model's default effort + * (a concrete effort for effort-capable models, `'on'` only for genuine + * boolean models). + */ +function commitEffort(choice: ModelChoice, draft: ThinkingEffort): ThinkingEffort { + if (draft === 'on') return defaultThinkingEffortFor(choice.model); + return draft; } /** @@ -102,8 +164,8 @@ export class ModelSelectorComponent extends Container implements Focusable { focused = false; private readonly opts: ModelSelectorOptions; private readonly list: SearchableList<ModelChoice>; - /** Per-model thinking override set by ←/→; absent → the capability default. */ - private readonly thinkingOverrides = new Map<string, boolean>(); + /** Per-model thinking-effort override set by ←/→; absent → the default. */ + private readonly thinkingOverrides = new Map<string, string>(); constructor(opts: ModelSelectorOptions) { super(); @@ -121,15 +183,31 @@ export class ModelSelectorComponent extends Container implements Focusable { } /** - * Thinking draft for a model: an explicit ←/→ override when set, otherwise - * the live thinking state for the active model, otherwise On for any other - * thinking-capable model (a capable model should default to thinking on). + * Thinking effort for a model: an explicit ←/→ override when set, otherwise + * the live effort for the active model, otherwise the model's default effort + * (effort-capable) or 'on' (other thinking-capable models). */ - private draftFor(choice: ModelChoice): boolean { + private draftFor(choice: ModelChoice): string { const override = this.thinkingOverrides.get(choice.alias); if (override !== undefined) return override; - if (choice.alias === this.opts.currentValue) return this.opts.currentThinking; - return thinkingAvailability(choice.model) !== 'unsupported'; + if (choice.alias === this.opts.currentValue) return this.opts.currentThinkingEffort; + const efforts = effortsOf(choice.model); + if (efforts.length > 0) { + // A model with support_efforts but no default_effort defaults to the + // middle entry of its supported efforts. + const def = choice.model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]; + if (def !== undefined && efforts.includes(def)) return def; + return efforts[0]!; + } + return thinkingAvailability(choice.model) !== 'unsupported' ? 'on' : 'off'; + } + + /** Draft coerced onto the model's segment list so rendering/selection never + * reference a effort the model cannot actually select. */ + private effectiveEffort(choice: ModelChoice): string { + const draft = this.draftFor(choice); + const segments = segmentsFor(choice.model); + return segments.includes(draft) ? draft : segments[0]!; } handleInput(data: string): void { @@ -144,11 +222,27 @@ export class ModelSelectorComponent extends Container implements Focusable { return; } - // Left/Right toggle the thinking draft for models that support it. + // Left/Right move the active thinking effort within the model's segments. if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) { const selected = this.selectedChoice(); - if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') { - this.thinkingOverrides.set(selected.alias, !this.draftFor(selected)); + if (selected !== undefined) { + const segments = segmentsFor(selected.model); + if (segments.length > 1) { + const current = this.effectiveEffort(selected); + const idx = segments.indexOf(current); + // The two-segment case is the legacy boolean On/Off control: both + // arrows flip it. With more segments (efforts), ←/→ step. + let next: number; + if (segments.length === 2) { + next = idx === 0 ? 1 : 0; + } else { + const delta = matchesKey(data, Key.left) ? -1 : 1; + next = Math.max(0, Math.min(segments.length - 1, idx + delta)); + } + if (next !== idx) { + this.thinkingOverrides.set(selected.alias, segments[next]!); + } + } } return; } @@ -158,7 +252,17 @@ export class ModelSelectorComponent extends Container implements Focusable { if (selected === undefined) return; this.opts.onSelect({ alias: selected.alias, - thinking: effectiveThinking(selected.model, this.draftFor(selected)), + thinking: commitEffort(selected, this.effectiveEffort(selected)), + }); + return; + } + + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const selected = this.selectedChoice(); + if (selected === undefined) return; + this.opts.onSessionOnlySelect({ + alias: selected.alias, + thinking: commitEffort(selected, this.effectiveEffort(selected)), }); } } @@ -179,14 +283,21 @@ export class ModelSelectorComponent extends Container implements Focusable { if (this.opts.providerSwitchHint) hintParts.push('Tab toggle provider'); hintParts.push('↑↓ navigate'); if (searchable && view.query.length > 0) hintParts.push('Backspace clear'); - hintParts.push('Enter select', 'Esc cancel'); + hintParts.push('Enter select'); + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), currentTheme.boldFg('primary', ' Select a model') + titleSuffix, currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), - '', ]; + if (this.opts.warning !== undefined) { + for (const line of wrapTextWithAnsi(this.opts.warning, Math.max(1, width - 1))) { + lines.push(currentTheme.fg('warning', ` ${line}`)); + } + } + lines.push(''); if (searchable && view.query.length > 0) { lines.push(currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query)); @@ -240,8 +351,8 @@ export class ModelSelectorComponent extends Container implements Focusable { lines.push(''); const selected = this.selectedChoice(); if (selected !== undefined) { - const availability = thinkingAvailability(selected.model); - const thinkingHeader = availability === 'toggle' ? ' Thinking (←→ to switch)' : ' Thinking'; + 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)); } @@ -264,16 +375,20 @@ export class ModelSelectorComponent extends Container implements Focusable { const unavailable = (label: string): string => currentTheme.fg('textMuted', ` ${label} (Unsupported) `); - // On stays left and Off right in all three states so the control never - // shifts while the cursor moves across models. + // Non-effort always-on / unsupported models keep the original On/Off layout + // so the control never shifts while moving across legacy models. + const efforts = effortsOf(choice.model); const availability = thinkingAvailability(choice.model); - if (availability === 'always-on') { + if (efforts.length === 0 && availability === 'always-on') { return ` ${segment('On', true)} ${unavailable('Off')}`; } - if (availability === 'unsupported') { + if (efforts.length === 0 && availability === 'unsupported') { return ` ${unavailable('On')} ${segment('Off', true)}`; } - const draft = this.draftFor(choice); - return ` ${segment('On', draft)} ${segment('Off', !draft)}`; + + const segments = segmentsFor(choice.model); + const active = this.effectiveEffort(choice); + const rendered = segments.map((effort) => segment(effortLabel(effort), effort === active)); + return ` ${rendered.join(' ')}`; } } diff --git a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts index 91668b4f0..c13098783 100644 --- a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -6,20 +6,17 @@ const PERMISSION_OPTIONS: readonly ChoiceOption[] = [ { value: 'manual', label: 'Manual', - description: - 'Ask before commands, edits, and other risky actions. Read/search tools run directly; session approval rules are respected.', - }, - { - 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: 'Approve every action yourself.', }, { 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: 'Auto-approve tool actions, but the agent may still ask questions.', + }, + { + value: 'auto', + label: 'Auto', + description: 'Fully autonomous — agent decides everything without asking.', }, ]; 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 d2bcc8620..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 { 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,252 +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 onSelect: (selection: PluginsOverviewSelection) => void; - readonly onCancel: () => void; -} - -export class PluginsOverviewSelectorComponent extends Container implements Focusable { - focused = false; - - private readonly opts: PluginsOverviewSelectorOptions; - private readonly items: readonly PluginsOverviewItem[]; - private selectedIndex = 0; - - constructor(opts: PluginsOverviewSelectorOptions) { - super(); - this.opts = opts; - this.items = buildOverviewItems(opts.plugins); - const selectedIndex = this.items.findIndex( - (item) => item.value === `${OVERVIEW_PLUGIN_PREFIX}${opts.selectedId}`, - ); - this.selectedIndex = Math.max(0, selectedIndex); - } - - handleInput(data: string): void { - if (matchesKey(data, Key.escape)) { - this.opts.onCancel(); - return; - } - if (matchesKey(data, Key.up)) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return; - } - if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); - return; - } - const chosen = this.items[this.selectedIndex]; - if (chosen === undefined) return; - const pluginId = overviewItemPluginId(chosen); - const decoded = printableChar(data); - if (matchesKey(data, Key.space) || decoded === ' ') { - if (pluginId === undefined) return; - const plugin = this.opts.plugins.find((item) => item.id === pluginId); - if (plugin !== undefined) { - this.opts.onSelect({ kind: 'toggle', id: pluginId, enabled: !plugin.enabled }); - } - return; - } - if (decoded === 'd' || decoded === 'D') { - if (pluginId !== undefined) this.opts.onSelect({ kind: 'remove', id: pluginId }); - return; - } - if (decoded === 'm' || decoded === 'M') { - if (pluginId === undefined) return; - const plugin = this.opts.plugins.find((item) => item.id === pluginId); - if (plugin !== undefined && plugin.mcpServerCount > 0) { - this.opts.onSelect({ kind: 'mcp', id: pluginId }); - } - return; - } - if (matchesKey(data, Key.enter)) { - if (pluginId !== undefined) { - this.opts.onSelect({ kind: 'info', id: pluginId }); - return; - } - const selection = parseOverviewSelection(chosen.value); - if (selection !== undefined) this.opts.onSelect(selection); - } - } - - override render(width: number): string[] { - const { plugins } = this.opts; - const hint = - '↑↓ navigate · Space toggle · M MCP servers · D remove · Enter details · Esc cancel'; - const pluginItems = this.items.filter((item) => item.kind === 'plugin'); - const actionItems = this.items.filter((item) => item.kind === 'action'); - const lines: string[] = [ - currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ' Plugins'), - mutedHintLine(` ${hint}`), - '', - sectionLabel(`Installed plugins (${plugins.length})`), - ]; - - if (pluginItems.length === 0) { - lines.push(currentTheme.fg('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')); - for (let i = 0; i < actionItems.length; i++) { - lines.push(...this.renderItem(actionItems[i]!, pluginItems.length + i, width)); - } - - lines.push(''); - lines.push(currentTheme.fg('primary', '─'.repeat(width))); - return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); - } - - private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const selected = index === this.selectedIndex; - const pointer = selected ? SELECT_POINTER : ' '; - const labelStyle = selected - ? (text: string) => currentTheme.boldFg('primary', text) - : (text: string) => currentTheme.fg('text', text); - const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); - let line = prefix + labelStyle(item.label); - if (item.status !== undefined) { - line += ' ' + statusStyle(item)(item.status); - } - const pluginId = overviewItemPluginId(item); - if (pluginId !== undefined && this.opts.pluginHint?.id === pluginId) { - line += ' ' + currentTheme.fg('warning', this.opts.pluginHint.text); - } - - const descriptionWidth = Math.max(1, width - 4); - const lines = [line]; - for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`)); - } - return lines; - } -} - -export type PluginMarketplaceSelection = - | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } - | { readonly kind: 'back' }; - -export interface PluginMarketplaceSelectorOptions { - readonly entries: readonly PluginMarketplaceEntry[]; - readonly installed: ReadonlyMap<string, string | undefined>; - readonly source: string; - 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.installed); - } - - handleInput(data: string): void { - if (matchesKey(data, Key.escape)) { - this.opts.onCancel(); - return; - } - if (matchesKey(data, Key.up)) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return; - } - if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); - return; - } - if (matchesKey(data, Key.enter)) { - const chosen = this.items[this.selectedIndex]; - if (chosen === undefined) return; - if (chosen.value === 'back') { - this.opts.onSelect({ kind: 'back' }); - return; - } - const entry = this.opts.entries.find((item) => item.id === chosen.value); - if (entry === undefined) return; - this.opts.onSelect({ kind: 'install', entry }); - } - } - - override render(width: number): string[] { - const entries = this.items.filter((item) => item.kind === 'plugin'); - const actions = this.items.filter((item) => item.kind === 'action'); - const lines: string[] = [ - currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ' Official plugins'), - mutedHintLine(' ↑↓ navigate · Enter install/update · Esc cancel'), - currentTheme.fg('textMuted', ` Source: ${this.opts.source}`), - '', - sectionLabel(`Marketplace (${entries.length})`), - ]; - - if (entries.length === 0) { - lines.push(currentTheme.fg('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')); - for (let i = 0; i < actions.length; i++) { - lines.push(...this.renderItem(actions[i]!, entries.length + i, width)); - } - - lines.push(''); - lines.push(currentTheme.fg('primary', '─'.repeat(width))); - return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); - } - - private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const selected = index === this.selectedIndex; - const pointer = selected ? SELECT_POINTER : ' '; - const labelStyle = selected - ? (text: string) => currentTheme.boldFg('primary', text) - : (text: string) => currentTheme.fg('text', text); - const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); - let line = prefix + labelStyle(item.label); - if (item.status !== undefined) { - line += ' ' + statusStyle(item)(item.status); - } - const descriptionWidth = Math.max(1, width - 4); - const lines = [line]; - for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`)); - } - return lines; - } -} - export type PluginMcpSelection = | { readonly kind: 'toggle'; readonly pluginId: string; readonly server: string; readonly enabled: boolean } | { readonly kind: 'back'; readonly pluginId: string }; @@ -347,18 +124,19 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { override render(width: number): string[] { 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[] = [ - currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ` MCP servers · ${info.displayName}`), - mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel'), + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(` MCP servers · ${info.displayName}`), + mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel', colors), '', - sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`), + sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors), ]; if (serverItems.length === 0) { - lines.push(currentTheme.fg('textMuted', ' No MCP servers declared.')); + lines.push(chalk.hex(colors.textMuted)(' No MCP servers declared.')); } else { for (let i = 0; i < serverItems.length; i++) { lines.push(...this.renderItem(serverItems[i]!, i, width)); @@ -366,35 +144,34 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } lines.push(''); - lines.push(sectionLabel('Actions')); + lines.push(sectionLabel('Actions', colors)); for (let i = 0; i < actionItems.length; i++) { lines.push(...this.renderItem(actionItems[i]!, serverItems.length + i, width)); } lines.push(''); - lines.push(currentTheme.fg('primary', '─'.repeat(width))); + 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 = currentTheme.palette; const selected = index === this.selectedIndex; const pointer = selected ? SELECT_POINTER : ' '; - const labelStyle = selected - ? (text: string) => currentTheme.boldFg('primary', text) - : (text: string) => currentTheme.fg('text', text); - const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); let line = prefix + labelStyle(item.label); if (item.status !== undefined) { - line += ' ' + statusStyle(item)(item.status); + line += ' ' + statusStyle(item, colors)(item.status); } const serverName = mcpItemServerName(item); if (serverName !== undefined && this.opts.serverHint?.server === serverName) { - line += ' ' + currentTheme.fg('warning', this.opts.serverHint.text); + line += ' ' + chalk.hex(colors.warning)(this.opts.serverHint.text); } const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`)); + lines.push(mutedHintLine(` ${descLine}`, colors)); } return lines; } @@ -439,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 { @@ -483,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[], - installed: ReadonlyMap<string, string | undefined>, -): PluginsOverviewItem[] { - const items: PluginsOverviewItem[] = entries.map((entry) => ({ - value: entry.id, - kind: 'plugin', - label: entry.displayName, - status: marketplaceItemStatus(entry, installed), - 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[] { @@ -556,13 +775,13 @@ function mcpItemServerName(item: PluginsOverviewItem): string | undefined { function marketplaceEntryDescription(entry: PluginMarketplaceEntry): string { const tier = marketplaceTierLabel(entry.tier); const description = entry.description ?? tier; + const version = entry.version !== undefined ? ` · v${entry.version}` : ''; const keywords = entry.keywords !== undefined && entry.keywords.length > 0 ? ` · ${entry.keywords.join(', ')}` : ''; const tierSuffix = entry.description !== undefined ? ` · ${tier}` : ''; - // The version now lives in the status badge, so it is omitted here to avoid duplication. - return `${description} · id ${entry.id}${tierSuffix}${keywords}`; + return `${description} · id ${entry.id}${version}${tierSuffix}${keywords}`; } function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { @@ -571,7 +790,11 @@ function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { return 'Plugin'; } -function marketplaceItemStatus( +function installStatus(entry: PluginMarketplaceEntry): string { + return entry.version === undefined ? 'install' : `install v${entry.version}`; +} + +function marketplaceEntryStatus( entry: PluginMarketplaceEntry, installed: ReadonlyMap<string, string | undefined>, ): string { @@ -582,27 +805,30 @@ function marketplaceItemStatus( case 'up-to-date': return status.version === undefined ? 'installed' : `installed · v${status.version}`; case 'not-installed': - return entry.version === undefined ? 'install' : `install v${entry.version}`; + return installStatus(entry); } } -function sectionLabel(label: string): string { - return currentTheme.boldFg('textDim', ` ${label}`); +function sectionLabel(label: string, colors: ColorPalette): string { + return chalk.hex(colors.textDim).bold(` ${label}`); } function statusStyle( item: PluginsOverviewItem, + colors: ColorPalette, ): (text: string) => string { - if (item.kind === 'action') return (text) => currentTheme.fg('textDim', text); - if (item.status?.startsWith('update')) return (text) => currentTheme.fg('warning', text); - if (item.status === 'enabled' || item.status?.startsWith('installed')) return (text) => currentTheme.fg('success', text); - if (item.status?.startsWith('install')) return (text) => currentTheme.fg('primary', text); - if (item.status === 'disabled') return (text) => currentTheme.fg('textDim', text); - if (item.status !== undefined && /^\d/.test(item.status)) return (text) => currentTheme.fg('textDim', text); - return (text) => currentTheme.fg('warning', text); + if (item.kind === 'action') return chalk.hex(colors.textDim); + if (item.status === 'enabled' || item.status === 'installed') return chalk.hex(colors.success); + if (item.status?.startsWith('install')) return chalk.hex(colors.primary); + if (item.status === 'disabled') return chalk.hex(colors.textDim); + if (item.status !== undefined && /^\d/.test(item.status)) return chalk.hex(colors.textDim); + return chalk.hex(colors.warning); } -function mutedHintLine(text: string): string { +function mutedHintLine(text: string, colors?: ColorPalette): string { + if (colors !== undefined) { + return chalk.hex(colors.textMuted)(text); + } return currentTheme.fg('textMuted', text); } diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts index 8805c24a9..7ae0da03d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -42,7 +42,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index ba14033f7..6764b88d8 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -15,7 +15,7 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index bb5ec512f..c8bd9017b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -9,7 +9,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { formatSessionLabel } from '#/migration/index'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts index 38c1da2bb..341ced723 100644 --- a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts @@ -5,7 +5,7 @@ import { visibleWidth, type Component, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 747072a5c..8adc5efa1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -19,11 +19,11 @@ import { Key, matchesKey, truncateToWidth, - visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; import { ModelSelectorComponent, @@ -39,11 +39,18 @@ export interface TabbedModelSelectorOptions { readonly models: Record<string, ModelAlias>; readonly currentValue: string; readonly selectedValue?: string; - readonly currentThinking: boolean; + readonly currentThinkingEffort: string; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; + /** Forwarded to each inner selector; when set, warning-colored lines are + * rendered directly below the key-hint line, wrapping as needed (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: 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; } @@ -97,19 +104,20 @@ export class TabbedModelSelectorComponent extends Container implements Focusable if (this.tabs.length <= 1) { return inner.map((line) => truncateToWidth(line, width)); } - // Layout: divider, title, hint, blank, tab strip, blank, then the model - // list. The inner selector's blank line (inner[3]) separates the hint from - // the tab strip; an extra blank separates the tabs from their list. - const stripLine = this.renderTabStrip(width); - const out: string[] = [ - inner[0] ?? '', - inner[1] ?? '', - inner[2] ?? '', - inner[3] ?? '', - stripLine, - '', - ]; - for (let i = 4; i < inner.length; i++) out.push(inner[i]!); + // Layout: divider, title, hint, optional warning, blank, tab strip, blank, + // then the model list. The header ends at its first blank line — keep that + // blank above the strip, and separate the tabs from the list with another + // blank. + const stripLine = renderTabStrip({ + labels: this.tabs.map((tab) => tab.label), + activeIndex: this.activeIndex, + width, + colors: currentTheme.palette, + }); + const headerEnd = inner.findIndex((line) => line === ''); + const splitAt = headerEnd === -1 ? 3 : headerEnd; + const out: string[] = [...inner.slice(0, splitAt + 1), stripLine, '']; + for (let i = splitAt + 1; i < inner.length; i++) out.push(inner[i]!); return out.map((line) => truncateToWidth(line, width)); } @@ -126,81 +134,6 @@ export class TabbedModelSelectorComponent extends Container implements Focusable tab.selector.focused = this.focused && i === this.activeIndex; } } - - /** Style a tab segment. The active tab is filled with the brand background - * (matching the AskUserQuestion dialog); inactive tabs are muted. Both have - * the same visible width so switching never shifts the layout. */ - private styleTab(label: string, isActive: boolean): string { - const cell = ` ${label} `; - return isActive - ? currentTheme.bg('primary', currentTheme.boldFg('text', cell)) - : currentTheme.fg('textMuted', cell); - } - - private renderTabStrip(width: number): string { - const segments: string[] = []; - for (let i = 0; i < this.tabs.length; i++) { - const tab = this.tabs[i]!; - segments.push(this.styleTab(tab.label, i === this.activeIndex)); - } - - // If everything fits with a leading space, show the whole strip. The - // provider-switch hint lives in the inner selector's hint line, not here. - const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0); - if (1 + totalSegmentWidth <= width) { - return ' ' + segments.join(' '); - } - - // Scrolling needed. Find the widest window that contains activeIndex. - const segmentWidths = segments.map((s) => visibleWidth(s)); - let start = this.activeIndex; - let end = this.activeIndex + 1; - let contentWidth = segmentWidths[this.activeIndex]!; - - const fits = (s: number, e: number, cw: number): boolean => { - const needLeft = s > 0; - const needRight = e < segments.length; - const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0); - return cw + frameWidth <= width; - }; - - while (true) { - const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity; - const rightW = end < segments.length ? segmentWidths[end]! : Infinity; - if (leftW === Infinity && rightW === Infinity) break; - - if (leftW <= rightW) { - if (fits(start - 1, end, contentWidth + leftW)) { - contentWidth += leftW; - start--; - } else if (fits(start, end + 1, contentWidth + rightW)) { - contentWidth += rightW; - end++; - } else { - break; - } - } else { - if (fits(start, end + 1, contentWidth + rightW)) { - contentWidth += rightW; - end++; - } else if (fits(start - 1, end, contentWidth + leftW)) { - contentWidth += leftW; - start--; - } else { - break; - } - } - } - - const hasLeft = start > 0; - const hasRight = end < segments.length; - let strip = hasLeft ? currentTheme.fg('textMuted', '< ') : ' '; - strip += segments.slice(start, end).join(' '); - if (hasRight) { - strip += currentTheme.fg('textMuted', ' >'); - } - return strip; - } } function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] { @@ -246,10 +179,12 @@ function makeSelector( models: subset, currentValue: opts.currentValue, ...(selectedValue !== undefined ? { selectedValue } : {}), - currentThinking: opts.currentThinking, + currentThinkingEffort: opts.currentThinkingEffort, searchable: true, providerSwitchHint: true, + warning: opts.warning, 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 a1718a5eb..c0f647f67 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -17,7 +17,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; import { currentTheme } from '#/tui/theme'; @@ -125,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; } @@ -240,7 +249,7 @@ export class TaskOutputViewer extends Container implements Focusable { ); const keys = `${key('↑↓')} ${dim('line')} ` + - `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('PgUp/PgDn/Ctrl+U/D')} ${dim('page')} ` + `${key('g/G')} ${dim('top/bot')} ` + `${key('Q/Esc')} ${dim('cancel')}`; const left = ` ${keys}`; diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 7863c493c..7902e81e1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -21,7 +21,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; import { SELECT_POINTER } from '@/tui/constant/symbols'; @@ -130,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 { @@ -333,7 +338,11 @@ export class TasksBrowserApp extends Container implements Focusable { '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(currentTheme.fg('success', ` ${String(counts.running)} running `)); @@ -343,7 +352,7 @@ export class TasksBrowserApp extends Container implements Focusable { countSegments.push( currentTheme.fg('error', ` ${String(counts.terminalFailed)} interrupted `), ); - const totals = currentTheme.fg('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); diff --git a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts index 77d82ccdd..37320f92b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts @@ -5,7 +5,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index ded2af648..b1677e87b 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -8,13 +8,17 @@ import { matchesKey, Key, SelectList, + visibleWidth, type SelectItem, type TUI, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { isInsideTmux } from '#/tui/utils/terminal-notification'; +import { extractAtPrefix } from './file-mention-provider'; import { WrappingSelectList } from './wrapping-select-list'; // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences @@ -45,6 +49,11 @@ 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 = { @@ -105,21 +114,27 @@ function stripSgr(s: string): string { return s.replace(ANSI_SGR, ''); } -function getNewlineInput(data: string): string | undefined { - if (data === '\n' || data === '\u001B\r' || data === '\u001B[13;2~') return data; - if (matchesKey(data, Key.ctrl('j'))) return '\n'; - return undefined; +interface CustomEditorOptions { + disablePasteBurst?: boolean; } export class CustomEditor extends Editor { public onEscape?: () => void; + /** + * Fired for every input that is not a lone Escape. Used to disarm a pending + * double-Esc so only two consecutive Escape presses trigger the shortcut. + */ + public onNonEscapeInput?: () => void; public onCtrlD?: () => void; public onCtrlC?: () => void; public onToggleToolExpand?: () => void; public onOpenExternalEditor?: () => void; public onCtrlS?: () => void; + /** Return `true` to consume Ctrl+B; return `false`/`undefined` to fall through to the editor default (cursor-left). */ + public onCtrlB?: () => boolean; + /** Return `true` to consume Ctrl+T (the todo list had overflow to toggle); return `false`/`undefined` to fall through to the editor default. */ + public onToggleTodoExpand?: () => boolean; public onUndo?: () => void; - public onInsertNewline?: () => void; public onTextPaste?: () => void; /** * Called when ↑ is pressed in an empty editor. Return `true` to consume @@ -129,6 +144,10 @@ export class CustomEditor extends Editor { public onUpArrowEmpty?: () => boolean; public onDownArrowEmpty?: () => boolean; public onShiftTab?: () => void; + /** 'bash' when entering a `!` shell command. The `!` is never part of the + * text buffer — it is a separate mode + prompt symbol (see handleInput). */ + public inputMode: 'prompt' | 'bash' = 'prompt'; + public onInputModeChange?: (mode: 'prompt' | 'bash') => void; public connectedAbove = false; public borderHighlighted = false; /** @@ -143,15 +162,21 @@ export class CustomEditor extends Editor { private consumingPaste = false; private consumeBuffer = ''; + private argumentHints: ReadonlyMap<string, string> = new Map(); + private autocompleteWasShowing = false; - constructor(tui: TUI) { + 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. const theme = createEditorTheme(); - super(tui, theme, { paddingX: 4 }); + super(tui, theme, { paddingX: 4, disablePasteBurst: options.disablePasteBurst }); // pi-tui keeps `createAutocompleteList` private; shadow it with an // instance property so slash command menus render descriptions wrapped @@ -171,6 +196,27 @@ export class CustomEditor extends Editor { } 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 { @@ -212,12 +258,44 @@ 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]; @@ -228,9 +306,20 @@ export class CustomEditor extends Editor { } } } + 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; } @@ -241,15 +330,40 @@ export class CustomEditor extends Editor { // side bars through the same hook to stay in sync. return wrapWithSideBorders(lines, (s) => this.borderColor(s), { connectedAbove: this.connectedAbove && !this.borderHighlighted, + label: isBash ? ` ${currentTheme.boldFg('shellMode', '! shell mode')} ` : undefined, }); } + private computeArgumentHint(): string | undefined { + // Argument hints describe slash commands, which do not exist in bash mode. + if (this.inputMode === 'bash') return undefined; + const text = this.getText(); + const match = /^\/(\S+)( ?)$/.exec(text); + if (match === null) return undefined; + const cmd = match[1]; + const trailingSpace = match[2] ?? ''; + if (cmd === undefined) return undefined; + const hint = this.argumentHints.get(cmd); + if (hint === undefined) return undefined; + const { line, col } = this.getCursor(); + if (line !== 0) return undefined; + const currentLine = this.getLines()[0] ?? ''; + if (col !== currentLine.length) return undefined; + return trailingSpace.length > 0 ? hint : ` ${hint}`; + } + override handleInput(data: string): void { const normalized = normalizeCapsLockedCtrl(data); if (isKeyRelease(normalized)) { return; } + // Any input other than a lone Escape breaks a pending double-Esc sequence, + // so the shortcut only fires for two consecutive Escape presses. + if (!matchesKey(normalized, Key.escape)) { + this.onNonEscapeInput?.(); + } + // When a paste marker was just expanded, discard the trailing bracketed // paste data that the terminal sends alongside the Ctrl-V keystroke. if (this.consumingPaste) { @@ -283,12 +397,21 @@ export class CustomEditor extends Editor { } if (this.onPasteImage !== undefined) { const handler = this.onPasteImage; - void handler().then((handled) => { - if (!handled) { - this.onTextPaste?.(); - super.handleInput.call(this, normalized); - } - }); + const pasteAsText = (): void => { + this.onTextPaste?.(); + super.handleInput.call(this, normalized); + }; + void handler().then( + (handled) => { + if (!handled) pasteAsText(); + }, + () => { + // A rejecting image-paste handler must not leak an unhandled + // rejection (the CLI turns those into a silent exit) — treat it + // the same as "no image available" and fall back to text paste. + pasteAsText(); + }, + ); return; } } @@ -320,6 +443,19 @@ export class CustomEditor extends Editor { return; } + if (matchesKey(normalized, Key.ctrl('b'))) { + // Only consume the key when the handler actually detached something; + // otherwise fall through so readline's backward-char still works at the + // idle prompt. + if (this.onCtrlB?.() === true) return; + } + + if (matchesKey(normalized, Key.ctrl('t'))) { + // Only consume the key when the todo list actually has overflow to + // expand/collapse; otherwise fall through to the editor default. + if (this.onToggleTodoExpand?.() === true) return; + } + if (matchesKey(normalized, 'shift+tab')) { this.onShiftTab?.(); return; @@ -329,10 +465,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; } @@ -358,7 +500,96 @@ export class CustomEditor extends Editor { return; } + // Swallow Tab while the autocomplete dropdown is closed so it does not + // trigger pi-tui's built-in file completion. When the dropdown is open, + // fall through so pi-tui can still accept the selected item with Tab. + if (matchesKey(normalized, Key.tab) && !this.isShowingAutocomplete()) { + return; + } + + // Enter bash mode: typing `!` at the start of an empty prompt. The `!` is + // not inserted into the buffer — it becomes the mode + prompt symbol, so the + // cursor never has to skip over it and submit never has to strip it. + if ( + this.inputMode === 'prompt' && + printableChar(normalized) === '!' && + this.getText().length === 0 + ) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + return; + } + + const emptyPromptBeforeInput = this.inputMode === 'prompt' && this.getText().length === 0; super.handleInput(normalized); + + // Enter bash mode when `!...` is pasted into an empty prompt. The typed path + // above handles the single `!` keystroke; this catches bracketed / Ctrl-V + // pastes whose content starts with `!`. Strip the leading `!` so the buffer + // holds only the command, exactly like the typed path. + if (emptyPromptBeforeInput && this.inputMode === 'prompt' && this.getText().startsWith('!')) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + this.setText(this.getText().slice(1)); + } + + this.reopenAutocompleteAfterInput(); + } + + private reopenAutocompleteAfterInput(): void { + if (this.isShowingAutocomplete()) return; + const { line, col } = this.getCursor(); + const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? ''; + const editor = this as unknown as { + requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void; + }; + if (editor.requestAutocomplete === undefined) return; + const trigger = (): void => { + // Use force:false so slash-aware logic runs: commands with argument + // completions return their subcommands, commands without them return + // null. force:true would bypass the slash branch and fall through to + // path completion, wrongly popping up the file list. + editor.requestAutocomplete?.({ force: false, explicitTab: false }); + }; + + // Reopen path / argument completion right after a `/` is typed + // (e.g. `/add-dir /` or an `@dir/` mention). + if (textBeforeCursor.endsWith('/')) { + const isAtMention = extractAtPrefix(textBeforeCursor) !== null; + if (isAtMention) { + trigger(); + } else if (this.inputMode === 'bash') { + // In bash mode `/` is a path separator, not a slash command. A bare + // leading `/` is already handled by the tryTriggerAutocomplete shadow + // in the constructor; this branch covers the inline case (e.g. `ls /`, + // `cat /etc/`, `/add-dir/`) that pi-tui never auto-triggers. force:true + // is required so pi-tui's own slash-command handling is bypassed — + // force:false would let it pop up subcommand completions. + if (textBeforeCursor.trimStart() !== '/') { + editor.requestAutocomplete?.({ force: true, explicitTab: false }); + } + } else { + const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); + if (isSlashArgument) { + trigger(); + } + } + return; + } + + // After accepting a slash command name via Tab, pi-tui inserts a trailing + // space and closes the menu without triggering argument completion. Reopen + // it so subcommands (e.g. `/goal ` → status/pause/…) show immediately. + // Skipped in bash mode: `/` is a path there, and force:false would let + // pi-tui's own slash-command handling pop up subcommand completions. + if ( + this.inputMode !== 'bash' && + textBeforeCursor.endsWith(' ') && + textBeforeCursor.startsWith('/') && + textBeforeCursor.includes(' ') + ) { + trigger(); + } } } @@ -410,10 +641,7 @@ function goalCommandPathRanges( return ranges; } -function readTokenRange( - visible: string, - start: number, -): { start: number; end: number } | null { +function readTokenRange(visible: string, start: number): { start: number; end: number } | null { let tokenStart = start; while (tokenStart < visible.length && isTokenSpace(visible[tokenStart])) tokenStart++; if (tokenStart >= visible.length) return null; @@ -443,6 +671,53 @@ function highlightVisibleRanges( return out + line.slice(rawCursor); } +// Mirrors the editor's paddingX (see constructor). The hint is spliced into +// the first content line, which starts with this many spaces of left padding. +const EDITOR_LEFT_PADDING = 4; +// pi-tui renders the end-of-input cursor as an inverse-video space. +const CURSOR_BLOCK = '\u001B[7m \u001B[0m'; + +/** + * Splice a dimmed argument-hint ghost string into the first content line. + * + * The hint is purely visual: it is appended after the typed command (and + * after the cursor block when one is rendered) so the cursor stays at the + * end of the real input. It consumes trailing padding space, so the line + * width is preserved; if it would overflow the box it is truncated with an + * ellipsis. Returns the line unchanged when there is no room for a hint. + */ +function injectArgumentHint( + line: string, + hint: string, + realTextLength: number, + width: number, +): string { + const cursorIdx = line.indexOf(CURSOR_BLOCK); + const cursorPresent = cursorIdx !== -1; + const contentWidth = Math.max(1, width - EDITOR_LEFT_PADDING * 2); + // Room left in the content area after the typed text (and cursor). The hint + // must fit within this so the rendered line keeps its width. + const available = contentWidth - realTextLength - (cursorPresent ? 1 : 0); + const trimmed = truncateHint(hint, available); + if (trimmed.length === 0) return line; + const colored = currentTheme.fg('textDim', trimmed); + const insertAt = cursorPresent + ? cursorIdx + CURSOR_BLOCK.length + : mapVisibleIdxToRaw(line, EDITOR_LEFT_PADDING + realTextLength); + // Everything after the insertion point is trailing padding + right padding + // (plain spaces). Replace it with the hint followed by the remaining spaces + // so the visible line width is preserved. + const trailing = line.length - insertAt; + return line.slice(0, insertAt) + colored + ' '.repeat(Math.max(0, trailing - trimmed.length)); +} + +function truncateHint(hint: string, maxLen: number): string { + if (maxLen <= 0) return ''; + if (hint.length <= maxLen) return hint; + if (maxLen === 1) return '…'; + return `${hint.slice(0, maxLen - 1)}…`; +} + /** * Overlay a terminal-style `> ` prompt symbol on the first content line. * Column 0 is reserved for the left vertical border (overlaid later by @@ -453,12 +728,17 @@ function highlightVisibleRanges( * default foreground colour renders the symbol. Returns `undefined` if the * line is too short or doesn't begin with the expected padding. */ -export function injectPromptSymbol(line: string): string | undefined { +export function injectPromptSymbol( + line: string, + symbol = '>', + paint?: (s: string) => string, +): string | undefined { if (line.length < 4) return undefined; for (let i = 0; i < 4; i++) { if (line[i] !== ' ') return undefined; } - return ' > ' + line.slice(4); + const rendered = paint ? paint(symbol) : symbol; + return ' ' + rendered + ' ' + line.slice(4); } /** @@ -472,29 +752,44 @@ export function injectPromptSymbol(line: string): string | undefined { * inner SGR intact; only column 0 and the last column are overlaid, and * only if they're literal spaces — that protects the cursor-overflow * case where the rightmost column is an SGR-tagged inverse cursor. + * + * When `options.label` is set, it is overlaid on the left of the top border + * (e.g. the `! shell mode` badge), replacing the leading dashes. It is only + * applied to a plain dash run, never to a `↑/↓ N more` scroll indicator. */ export function wrapWithSideBorders( lines: string[], paint: (s: string) => string, - options: { readonly connectedAbove?: boolean } = {}, + options: { readonly connectedAbove?: boolean; readonly label?: string } = {}, ): string[] { let seenTop = false; return lines.map((line) => { const plain = stripSgr(line); if (plain.length > 0 && plain[0] === '─') { + const isTop = !seenTop; const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭'; const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮'; seenTop = true; if (plain.length === 1) return paint(leftCorner); const middle = plain.slice(1, -1); + if (isTop && options.label !== undefined && /^─+$/.test(middle)) { + const labelWidth = visibleWidth(options.label); + if (labelWidth <= middle.length) { + return ( + paint(leftCorner) + + options.label + + paint('─'.repeat(middle.length - labelWidth)) + + paint(rightCorner) + ); + } + } return paint(leftCorner + middle + rightCorner); } if (line.length === 0) return line; const firstCh = line[0]; const lastCh = line.at(-1); const head = firstCh === ' ' ? paint('│') : (firstCh ?? ''); - const tail = - line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? ''); + const tail = line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? ''); if (line.length === 1) return head; return head + line.slice(1, -1) + tail; }); diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index fc9dc43be..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,5 +1,5 @@ -import { readdirSync, statSync } from 'node:fs'; -import { basename, join } from 'node:path'; +import { accessSync, constants as fsConstants, readdirSync, statSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; import { CombinedAutocompleteProvider, @@ -8,7 +8,7 @@ import { type AutocompleteProvider, type AutocompleteSuggestions, type SlashCommand, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); const MAX_FALLBACK_SCAN = 2000; @@ -20,26 +20,33 @@ export interface SlashAutocompleteCommand extends SlashCommand { interface FsMentionCandidate { readonly path: string; + readonly absolutePath: string; readonly isDirectory: boolean; } /** * Kimi wrapper around pi-tui's combined autocomplete provider. * - * File / folder mention behavior uses pi-tui's fd-backed provider when fd is - * available. While managed fd is downloading (or when it is unavailable), a - * small filesystem fallback keeps basic `@` file and folder completion usable. - * Ordinary path completion is still handled by pi-tui's readdir-backed path - * completer. This wrapper also keeps Kimi-specific slash-command guards. + * File / folder mention behavior uses pi-tui's fd-backed provider whenever fd + * is available, fanning out across the working directory and any additional + * roots so `@` completion pushes the query down to fd instead of enumerating + * every file. A small filesystem fallback is used only while managed fd is + * downloading, when it is unavailable, or if fd fails to spawn. Ordinary path + * completion is still handled by pi-tui's readdir-backed path completer. This + * wrapper also keeps Kimi-specific slash-command guards. */ export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; + private readonly additionalDirs: readonly string[]; constructor( private readonly slashCommands: SlashAutocompleteCommand[], private readonly workDir: string, private readonly fdPath: string | null, + additionalDirs: readonly string[] = [], + private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt', ) { + 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[] = []; @@ -49,7 +56,7 @@ export class FileMentionProvider implements AutocompleteProvider { expanded.push({ ...cmd, name: alias }); } } - this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath); + this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath, this.additionalDirs); } async getSuggestions( @@ -61,6 +68,38 @@ export class FileMentionProvider implements AutocompleteProvider { const currentLine = lines[cursorLine] ?? ''; const textBeforeCursor = currentLine.slice(0, cursorCol); + // `@` file / folder mentions take priority over the slash-command guards + // below. Without this, typing `@` inside a slash command's argument text + // (e.g. `/goal Fix the @|checkout docs`) would be swallowed by + // `shouldSuppressSlashArgumentCompletion` before the mention branch ever + // runs, so the file list never opens. + const atPrefix = extractAtPrefix(textBeforeCursor); + if (atPrefix !== null) { + // fd backs `@` completion across every root (cwd + additional dirs). Fall + // back to the filesystem scanner when fd is unavailable, not executable + // (e.g. the managed binary was removed or lost execute permission), or if + // spawning it fails below. A genuine fd no-match still returns null. + if (this.fdPath === null || !isExecutableFd(this.fdPath)) { + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); + } + try { + return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } catch { + // If fd fails to spawn unexpectedly, keep @ completion usable. + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); + } + } + if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) { return null; } @@ -75,19 +114,6 @@ export class FileMentionProvider implements AutocompleteProvider { return null; } - const atPrefix = extractAtPrefix(textBeforeCursor); - if (atPrefix !== null) { - if (this.fdPath === null) { - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); - } - try { - return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); - } catch { - // If fd fails to spawn unexpectedly, keep @ completion usable. - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); - } - } - // Handle slash-command name completion ourselves so that aliases are // searchable and visible in the label. if (!options.force && textBeforeCursor.startsWith('/')) { @@ -148,8 +174,26 @@ export class FileMentionProvider implements AutocompleteProvider { } } + // In bash mode `/` is a path separator, not a slash command. Skip slash + // command argument handling so an absolute path that happens to start with + // a command name (e.g. `/add-dir/...`) completes inside the path instead of + // returning the command's argument completions. + if (this.getInputMode() !== 'bash') { + const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor); + if (slashArgumentSuggestions !== null) { + return slashArgumentSuggestions; + } + } + try { - return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + const inner = await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if (inner === null || this.getInputMode() !== 'bash') { + return inner; + } + // In bash mode `/` is a path separator; hide dot-prefixed entries to + // match the `/add-dir` directory completer (registry.ts skips any name + // starting with `.`). Ordinary prompt-mode path completion is left as-is. + return { ...inner, items: inner.items.filter((item) => !isDotPrefixedEntry(item)) }; } catch { return null; } @@ -162,11 +206,20 @@ export class FileMentionProvider implements AutocompleteProvider { item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number } { + // In bash mode a leading `/` is a path, but pi-tui's applyCompletion + // mistakes it for a slash command (prefix starts with `/`, nothing before + // it, no second `/`) and prepends another `/`, producing e.g. + // `//Applications/ ` with a trailing space that also blocks further + // completion. Handle path completion ourselves so the value replaces the + // prefix verbatim. `@` mentions keep pi-tui's behaviour. + if (this.getInputMode() === 'bash' && prefix.startsWith('/')) { + return applyPathCompletion(lines, cursorLine, cursorCol, item, prefix); + } return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix); } } -function extractAtPrefix(text: string): string | null { +export function extractAtPrefix(text: string): string | null { let tokenStart = 0; for (let i = text.length - 1; i >= 0; i -= 1) { if (PATH_DELIMITERS.has(text[i] ?? '')) { @@ -178,15 +231,73 @@ function extractAtPrefix(text: string): string | null { return text.slice(tokenStart); } +function isExecutableFd(fdPath: string): boolean { + // Bare command names (for example "fd" discovered on the system PATH) are + // trusted: spawn resolves them through PATH. Only absolute/relative paths are + // probed, which is how the managed fd is referenced and which can go stale. + if (!fdPath.includes('/') && !fdPath.includes('\\')) { + return true; + } + try { + accessSync(fdPath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Match the `/add-dir` directory completer, which skips every entry whose name + * starts with `.` (see registry.ts). pi-tui's path completer sets `label` to + * the entry basename, with a trailing `/` for directories. + */ +function isDotPrefixedEntry(item: AutocompleteItem): boolean { + const name = item.label.endsWith('/') ? item.label.slice(0, -1) : item.label; + return name.startsWith('.'); +} + +/** + * Replace `prefix` with `item.value` verbatim, mirroring pi-tui's file-path + * branch (no trailing space, so a completed directory can be extended with the + * next `/`). Used in bash mode to avoid pi-tui's slash-command branch, which + * would prepend an extra `/` to a bare leading `/` path. For a quoted + * directory value (path contains spaces), the cursor stays inside the closing + * quote so follow-up `/` completion keeps working. + */ +function applyPathCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, +): { lines: string[]; cursorLine: number; cursorCol: number } { + const currentLine = lines[cursorLine] ?? ''; + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const afterCursor = currentLine.slice(cursorCol); + const newLine = beforePrefix + item.value + afterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + const isDirectory = item.label.endsWith('/'); + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = + isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset, + }; +} + function getFsMentionSuggestions( workDir: string, + additionalDirs: readonly string[], atPrefix: string, signal: AbortSignal, ): AutocompleteSuggestions | null { if (signal.aborted) return null; const query = atPrefix.slice(1); - const candidates = collectFsMentionCandidates(workDir, signal); + const candidates = collectFsMentionCandidates(workDir, additionalDirs, signal); if (candidates.length === 0 || signal.aborted) return null; const ranked = rankFsMentionCandidates(candidates, query).slice(0, MAX_FALLBACK_SUGGESTIONS); @@ -198,44 +309,69 @@ function getFsMentionSuggestions( }; } -function collectFsMentionCandidates(workDir: string, signal: AbortSignal): FsMentionCandidate[] { - const result: FsMentionCandidate[] = []; - const stack = ['']; +function collectFsMentionCandidates( + workDir: string, + additionalDirs: readonly string[], + signal: AbortSignal, +): FsMentionCandidate[] { + const candidatesByAbsolutePath = new Map<string, FsMentionCandidate>(); + const roots = [ + { root: normalizePath(resolve(workDir)), isAdditionalDir: false }, + ...additionalDirs.map((dir) => ({ + root: normalizePath(resolve(workDir, dir)), + isAdditionalDir: true, + })), + ]; + let scanned = 0; - while (stack.length > 0 && result.length < MAX_FALLBACK_SCAN) { - if (signal.aborted) break; - const relativeDir = stack.pop() ?? ''; - const absoluteDir = relativeDir.length === 0 ? workDir : join(workDir, relativeDir); - let entries; - try { - entries = readdirSync(absoluteDir, { withFileTypes: true }); - } catch { - continue; - } + for (const { root, isAdditionalDir } of roots) { + const stack = ['']; - for (const entry of entries) { - if (signal.aborted || result.length >= MAX_FALLBACK_SCAN) break; - if (entry.name === '.git') continue; - - const relativePath = normalizePath(relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name)); - const isSymlink = entry.isSymbolicLink(); - let isDirectory = entry.isDirectory(); - if (!isDirectory && isSymlink) { - try { - isDirectory = statSync(join(workDir, relativePath)).isDirectory(); - } catch { - // Broken symlink or permission error — keep it as a file candidate. - } + while (stack.length > 0 && scanned < MAX_FALLBACK_SCAN) { + if (signal.aborted) break; + const relativeDir = stack.pop() ?? ''; + const absoluteDir = relativeDir.length === 0 ? root : join(root, relativeDir); + let entries; + try { + entries = readdirSync(absoluteDir, { withFileTypes: true }); + } catch { + continue; } - result.push({ path: relativePath, isDirectory }); - if (isDirectory && !isSymlink) { - stack.push(relativePath); + for (const entry of entries) { + if (signal.aborted || scanned >= MAX_FALLBACK_SCAN) break; + if (entry.name === '.git') continue; + + const relativePath = normalizePath( + relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name), + ); + const absolutePath = normalizePath(join(absoluteDir, entry.name)); + const isSymlink = entry.isSymbolicLink(); + let isDirectory = entry.isDirectory(); + if (!isDirectory && isSymlink) { + try { + isDirectory = statSync(absolutePath).isDirectory(); + } catch { + // Broken symlink or permission error — keep it as a file candidate. + } + } + + scanned += 1; + if (!candidatesByAbsolutePath.has(absolutePath)) { + candidatesByAbsolutePath.set(absolutePath, { + path: isAdditionalDir ? absolutePath : relativePath, + absolutePath, + isDirectory, + }); + } + if (isDirectory && !isSymlink) { + stack.push(relativePath); + } } } } - return result; + return [...candidatesByAbsolutePath.values()]; } function rankFsMentionCandidates( @@ -285,7 +421,7 @@ function toMentionItem(candidate: FsMentionCandidate): AutocompleteItem { return { value, label, - description: valuePath, + description: candidate.absolutePath, }; } @@ -293,6 +429,52 @@ function normalizePath(path: string): string { return path.replaceAll('\\', '/'); } +async function getSlashArgumentSuggestions( + slashCommands: readonly SlashAutocompleteCommand[], + textBeforeCursor: string, +): Promise<AutocompleteSuggestions | null> { + const parsed = parseSlashArgumentContext(textBeforeCursor, slashCommands); + if (parsed === null) return null; + + const items = await parsed.command.getArgumentCompletions?.(parsed.argumentPrefix); + if (items === undefined || items === null || items.length === 0) return null; + + return { + prefix: parsed.argumentPrefix, + items, + }; +} + +function parseSlashArgumentContext( + textBeforeCursor: string, + slashCommands: readonly SlashAutocompleteCommand[], +): { command: SlashAutocompleteCommand; argumentPrefix: string } | null { + const whitespaceMatch = textBeforeCursor.match(/^\/(\S+)\s+(\S*)$/); + if (whitespaceMatch !== null) { + const [, commandName = '', argumentPrefix = ''] = whitespaceMatch; + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + if (!textBeforeCursor.endsWith(' ') && argumentPrefix.length === 0) return null; + return { command, argumentPrefix }; + } + + const pathLikeMatch = textBeforeCursor.match(/^\/([^/\s]+)(\/.*)$/); + const commandName = pathLikeMatch?.[1]; + const argumentPrefix = pathLikeMatch?.[2]; + if (commandName === undefined || argumentPrefix === undefined) return null; + + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + return { command, argumentPrefix }; +} + +function findSlashCommand( + slashCommands: readonly SlashAutocompleteCommand[], + commandName: string, +): SlashAutocompleteCommand | undefined { + return slashCommands.find((cmd) => cmd.name === commandName || (cmd.aliases ?? []).includes(commandName)); +} + function shouldSuppressLeadingWhitespaceSlashPath( textBeforeCursor: string, force: boolean | undefined, diff --git a/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts index d9d16936d..b6969c630 100644 --- a/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts +++ b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts @@ -6,7 +6,7 @@ import { type SelectItem, type SelectListLayoutOptions, type SelectListTheme, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; // Mirror pi-tui's private select-list layout constants // (dist/components/select-list.js); keep in sync when bumping pi-tui. diff --git a/apps/kimi-code/src/tui/components/media/diff-preview.ts b/apps/kimi-code/src/tui/components/media/diff-preview.ts index 00ede4a26..1fec48b26 100644 --- a/apps/kimi-code/src/tui/components/media/diff-preview.ts +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -156,6 +156,8 @@ export interface ClusteredDiffOptions { readonly maxLines?: number; readonly isIncomplete?: boolean; readonly expandKeyHint?: string; + readonly oldStart?: number; + readonly newStart?: number; } interface Cluster { @@ -239,7 +241,13 @@ export function renderDiffLinesClustered( const s = makeDiffStyles(); const contextLines = opts.contextLines ?? 3; const maxLines = opts.maxLines; - const diffLines = computeDiffLines(oldText, newText, 1, 1, opts.isIncomplete ?? false); + const diffLines = computeDiffLines( + oldText, + newText, + opts.oldStart ?? 1, + opts.newStart ?? 1, + opts.isIncomplete ?? false, + ); const { clusters, changedCount, addedCount, removedCount } = buildClusters( diffLines, contextLines, diff --git a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts index cc8ef4d56..04e80f534 100644 --- a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts +++ b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts @@ -12,7 +12,7 @@ * the viewport; pi-tui handles proportional scaling internally. */ -import { Container, Image, Text, type ImageTheme, getCapabilities } from '@earendil-works/pi-tui'; +import { Container, Image, Text, type ImageTheme, getCapabilities } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index ec8d32d2f..7e4752945 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -15,16 +15,19 @@ * - Ungrouping is not implemented. Once formed, a group stays grouped. */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; +import { formatTokenCount } from '#/utils/usage/usage-format'; 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; @@ -131,6 +134,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) => { @@ -203,6 +209,21 @@ export class AgentGroupComponent extends Container { 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) { @@ -351,7 +372,5 @@ function formatElapsed(seconds: number): string { } function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`; - return `${String(n)} tok`; + return `${formatTokenCount(n)} tok`; } diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index 173be9951..75c7266a2 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -1,4 +1,4 @@ -import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 7b002ef66..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,46 +5,88 @@ * to align after the bullet. */ -import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; 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 markdown: Markdown | undefined; + private markdownTransient = false; private lastText = ''; + private lastTransient = false; private showBullet: boolean; + 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, createMarkdownTheme())); + 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 { // 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. + // 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.contentContainer.addChild( - new Markdown(this.lastText.trim(), 0, 0, createMarkdownTheme()), + this.markdown = new Markdown( + this.lastText.trim(), + 0, + 0, + createMarkdownTheme({ transient: this.lastTransient }), ); + this.markdownTransient = this.lastTransient; + this.contentContainer.addChild(this.markdown); } } @@ -54,6 +96,14 @@ export class AssistantMessageComponent implements Component { 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, safeWidth - visibleWidth(prefix)); const contentLines = this.contentContainer.render(contentWidth); @@ -64,6 +114,10 @@ export class AssistantMessageComponent implements Component { i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; lines.push(p + contentLines[i]); } - return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + 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 3d968bad8..9c1a3d815 100644 --- a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -1,4 +1,4 @@ -import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/messages/cron-message.ts b/apps/kimi-code/src/tui/components/messages/cron-message.ts index 5ca2acf02..8cb81f5d3 100644 --- a/apps/kimi-code/src/tui/components/messages/cron-message.ts +++ b/apps/kimi-code/src/tui/components/messages/cron-message.ts @@ -1,5 +1,5 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Spacer, Text, visibleWidth } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/goal-markers.ts b/apps/kimi-code/src/tui/components/messages/goal-markers.ts index 0cff92d50..f4482941f 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -7,7 +7,7 @@ * the richer completion card (the `/goal` box), not this marker. */ -import { truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index be9df81af..d01f580fa 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -19,7 +19,7 @@ import { visibleWidth, wrapTextWithAnsi, type Component, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index d6b2c6207..d1eeec03c 100644 --- a/apps/kimi-code/src/tui/components/messages/plan-box.ts +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@earendil-works/pi-tui'; +import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; diff --git a/apps/kimi-code/src/tui/components/messages/plugin-command.ts b/apps/kimi-code/src/tui/components/messages/plugin-command.ts 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/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts index 3910be1ab..141562e4c 100644 --- a/apps/kimi-code/src/tui/components/messages/read-group.ts +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -20,8 +20,8 @@ * src/missing.ts · failed */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index 1a28c7c64..cb6f95dcd 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -1,5 +1,5 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Container, Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Container, Text } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; @@ -48,8 +48,15 @@ export class ShellExecutionComponent extends Container { const allLines = command.split('\n'); const lines = previewLines === undefined ? allLines : allLines.slice(0, previewLines); for (const [i, line] of lines.entries()) { - const prefix = i === 0 ? '$ ' : ' '; - this.addChild(new Text(currentTheme.dim(prefix + line), 2, 0)); + // Distinguish the command (input) from the result (output): the `$` + // prompt uses the dedicated shell-mode hue, the command body uses + // `textDim`, and the result below is rendered one step dimmer in + // `textMuted` so the two stay separable without a connecting glyph. + const text = + i === 0 + ? currentTheme.fg('shellMode', '$ ') + currentTheme.dim(line) + : ` ${currentTheme.dim(line)}`; + this.addChild(new Text(text, 2, 0)); } } @@ -68,24 +75,23 @@ export class ShellExecutionComponent extends Container { maxLines: previewLines, tail: tailOutput, expandHint, + color: 'textMuted', }), ); } } export const shellExecutionResultRenderer: ResultRenderer = ( - toolCall: ToolCallBlockData, + _toolCall: ToolCallBlockData, result: ToolResultBlockData, ctx, ): Component[] => [ + // Result only. The command preview is owned by ToolCallComponent's + // buildCallPreview across the whole lifecycle (streaming, running, and + // done); rendering it here too would duplicate the command once the result + // lands. new ShellExecutionComponent({ - command: typeof toolCall.args['command'] === 'string' ? toolCall.args['command'] : '', result, expanded: ctx.expanded, - // Header truncates long bash commands to 60 chars. When the user expands - // the card with ctrl+o, reveal the full command (no line cap) so they - // can read what actually ran. - showCommand: ctx.expanded, - commandPreviewLines: undefined, }), ]; diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts 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 907e91e9d..f977d21d9 100644 --- a/apps/kimi-code/src/tui/components/messages/skill-activation.ts +++ b/apps/kimi-code/src/tui/components/messages/skill-activation.ts @@ -12,7 +12,7 @@ * metadata. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { SkillActivationTrigger } from '#/tui/types'; diff --git a/apps/kimi-code/src/tui/components/messages/status-message.ts b/apps/kimi-code/src/tui/components/messages/status-message.ts index 7377a3f52..f88c1861b 100644 --- a/apps/kimi-code/src/tui/components/messages/status-message.ts +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -1,4 +1,4 @@ -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ColorToken } from '#/tui/theme'; @@ -12,20 +12,35 @@ export class StatusMessageComponent extends Container { super(); this.content = content; this.color = color; - const text = color === undefined - ? currentTheme.fg('textDim', content) - : currentTheme.fg(color, content); - this.textComponent = new Text(` ${text}`, 0, 0); + 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 { - const text = this.color === undefined - ? currentTheme.fg('textDim', this.content) - : currentTheme.fg(this.color, this.content); - this.textComponent.setText(` ${text}`); + 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 { 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 9007b8f97..4c6799e03 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -5,7 +5,13 @@ * separate from the TUI orchestration layer. */ -import type { ModelAlias, PermissionMode, SessionStatus } from '@moonshot-ai/kimi-code-sdk'; +import { + effectiveModelAlias, + type ModelAlias, + type PermissionMode, + type SessionStatus, + type ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; import { PRODUCT_NAME } from '#/constant/app'; import { currentTheme } from '#/tui/theme'; @@ -14,9 +20,14 @@ import { ratioSeverity, renderProgressBar, safeUsageRatio, + usagePercent, } 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; @@ -30,7 +41,7 @@ export interface StatusReportOptions { 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; @@ -47,17 +58,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( @@ -124,7 +134,7 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { const bar = renderProgressBar(safeRatio, 20); const barColoured = currentTheme.fg(severityToken(ratioSeverity(safeRatio)), bar); lines.push( - ` ${barColoured} ${value(`${(safeRatio * 100).toFixed(1)}%`.padStart(6, ' '))} ` + + ` ${barColoured} ${value(`${String(usagePercent(tokens, maxTokens))}%`.padStart(6, ' '))} ` + muted(`(${formatTokenCount(tokens)} / ${formatTokenCount(maxTokens)})`), ); } else { @@ -140,5 +150,16 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { lines.push(...managedSection); } + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } diff --git a/apps/kimi-code/src/tui/components/messages/step-summary.ts b/apps/kimi-code/src/tui/components/messages/step-summary.ts new file mode 100644 index 000000000..325ae6eb2 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/step-summary.ts @@ -0,0 +1,32 @@ +import type { Component } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +/** + * A collapsed summary of older steps within a turn. Accumulates counts of + * merged steps (thinking blocks and tool calls) and renders them as a single + * muted line, e.g. `… thinking 5 times, call 50 tools`. + */ +export class StepSummaryComponent implements Component { + private thinking = 0; + private tool = 0; + + get isEmpty(): boolean { + return this.thinking === 0 && this.tool === 0; + } + + addCounts(thinking: number, tool: number): void { + this.thinking += thinking; + this.tool += tool; + } + + invalidate(): void {} + + render(_width: number): string[] { + const parts: string[] = []; + if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`); + if (this.tool > 0) parts.push(`call ${this.tool} tools`); + if (parts.length === 0) return []; + return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)]; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts index 0308fada7..4fee9629f 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -1,4 +1,4 @@ -import { truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index 6ff652d4f..23a038c70 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -5,7 +5,7 @@ * Supports expand/collapse via Ctrl+O (shared with tool output). */ -import { Text, truncateToWidth, type Component, type TUI } from '@earendil-works/pi-tui'; +import { Text, truncateToWidth, type Component, type TUI } from '@moonshot-ai/pi-tui'; import { BRAILLE_SPINNER_FRAMES, @@ -15,6 +15,7 @@ import { } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export type ThinkingRenderMode = 'live' | 'finalized'; @@ -32,6 +33,8 @@ 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, showMarker: boolean = true, @@ -48,13 +51,19 @@ export class ThinkingComponent implements Component { } } + 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)); } @@ -64,6 +73,7 @@ export class ThinkingComponent implements Component { finalize(): void { this.mode = 'finalized'; + this.markRenderDirty(); this.stopSpinner(); } @@ -74,12 +84,22 @@ 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 @@ -89,39 +109,45 @@ export class ThinkingComponent implements Component { 'textDim', `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, ); - return [ + rendered = [ '', 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 ? currentTheme.fg('textDim', 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; - 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, '…')), - ); - 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 4bd2c61d0..9ffed46b4 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -5,11 +5,13 @@ import { isAbsolute, relative, sep } from 'node:path'; -import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import type { Component, TUI } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import type { Component, TUI } from '@moonshot-ai/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, COMMAND_PREVIEW_LINES, RESULT_PREVIEW_LINES, THINKING_PREVIEW_LINES, @@ -25,6 +27,8 @@ 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 { formatTokenCount } from '#/utils/usage/usage-format'; import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress'; import { PlanBoxComponent } from './plan-box'; @@ -32,20 +36,23 @@ import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { buildGoalToolHeader } from './tool-renderers/goal'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; -import { TruncatedOutputComponent } from './tool-renderers/truncated'; const MAX_ARG_LENGTH = 60; const MAX_SUB_TOOL_CALLS_SHOWN = 4; -const MAX_SINGLE_SUBAGENT_TOOL_ROWS = 4; -// Hanging indent for a sub-tool's previewed output, nested under its activity row. -const SUBAGENT_SUBTOOL_OUTPUT_INDENT = 6; +// Cap the Agent `description` in the single-subagent header so a long prompt +// cannot wrap the header onto a second row and break the card's stable height. +const MAX_SUBAGENT_DESCRIPTION_LENGTH = 60; const APPROVED_PLAN_MARKER = '## Approved Plan:'; +const 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'; @@ -132,8 +139,7 @@ function str(v: unknown): string { function formatSubagentContextTokens(contextTokens: number | undefined): string | undefined { if (contextTokens === undefined || contextTokens <= 0) return undefined; - const formatted = contextTokens >= 1000 ? `${(contextTokens / 1000).toFixed(1)}k` : String(contextTokens); - return `${formatted} tok`; + return `${formatTokenCount(contextTokens)} tok`; } function usageInputTotal(usage: TokenUsage): number { @@ -148,8 +154,7 @@ function usageTotal(usage: TokenUsage | undefined): number { function formatSubagentTokens(usage: TokenUsage | undefined): string | undefined { const total = usageTotal(usage); if (total <= 0) return undefined; - const formatted = total >= 1000 ? `${(total / 1000).toFixed(1)}k` : String(total); - return `${formatted} tok`; + return `${formatTokenCount(total)} tok`; } function formatByteSize(bytes: number): string { @@ -166,13 +171,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; @@ -188,11 +196,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>'. @@ -212,6 +226,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 @@ -227,7 +246,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) ); } @@ -409,7 +429,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; @@ -418,8 +438,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); } @@ -459,6 +479,8 @@ function tailNonEmptyLines(text: string, maxLines: number): string[] { } class PrefixedWrappedLine implements Component { + private renderCache: { width: number; lines: string[] } | undefined; + constructor( private readonly firstPrefix: string, private readonly continuationPrefix: string, @@ -467,14 +489,24 @@ class PrefixedWrappedLine implements Component { // unwrapped paragraph scrolls within a fixed window instead of growing // unbounded. The first kept row still gets `firstPrefix`. private readonly tailLines?: number, + // When set, the output is padded with empty continuation rows until it + // reaches this many display rows, so a short paragraph still fills a + // fixed-height window. Applied after `tailLines`. + private readonly minLines?: number, ) { } - invalidate(): void { } + invalidate(): void { + this.renderCache = undefined; + } render(width: number): string[] { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; + if (isRenderCacheEnabled() && this.renderCache?.width === safeWidth) { + return this.renderCache.lines; + } + const prefixWidth = Math.max( visibleWidth(this.firstPrefix), visibleWidth(this.continuationPrefix), @@ -485,11 +517,18 @@ class PrefixedWrappedLine implements Component { this.tailLines !== undefined && wrapped.length > this.tailLines ? wrapped.slice(wrapped.length - this.tailLines) : wrapped; - return lines + 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; } } @@ -531,8 +570,19 @@ export class ToolCallComponent extends Container { */ private subagentText = ''; private subagentThinkingText = ''; + /** Tracks whether the child agent's latest streamed delta was text or thinking, + * so the active window can follow whichever is currently live. */ + private lastSubagentStreamKind: SubagentTextKind = 'text'; // ── Subagent lifecycle state from subagent.spawned/started/completed/failed ── private subagentPhase: SubagentPhase | undefined; + /** + * Distinguishes a foreground subagent that the user detached via Ctrl+B from + * one that started in the background. Both set `subagentPhase = 'backgrounded'`, + * but only the detached one should keep showing `◐ backgrounded` after its + * spawn-success ToolResult lands — a started-in-background agent reads as + * `done` once its result arrives. + */ + private detachedFromForeground = false; /** * Authoritative terminal phase for a backgrounded subagent. Set from * `BackgroundTaskInfo.status` via `setBackgroundTaskTerminalStatus` once @@ -552,6 +602,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 ────────────────────────────────────────── // @@ -565,6 +616,13 @@ export class ToolCallComponent extends Container { 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 * `ReadGroupComponent`) when this component is borrowed as a hidden state @@ -598,9 +656,52 @@ export class ToolCallComponent extends Container { 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(); @@ -624,6 +725,8 @@ export class ToolCallComponent extends Container { // 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(); @@ -682,6 +785,7 @@ export class ToolCallComponent extends Container { dispose(): void { this.stopStreamingProgressTimer(); this.stopSubagentElapsedTimer(); + this.stopDetachHintTimer(); } /** @@ -783,14 +887,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 { @@ -904,6 +1005,46 @@ export class ToolCallComponent extends Container { this.streamingProgressTimer = undefined; } + /** Only foreground Bash/Agent calls can be detached via Ctrl+B. */ + private isDetachHintEligible(): boolean { + return this.toolCall.name === 'Bash' || this.toolCall.name === 'Agent'; + } + + private startDetachHintTimer(): void { + if (!this.isDetachHintEligible()) return; + if (this.result !== undefined) return; + if (this.ui === undefined) return; + if (this.toolCall.name === 'Agent') { + // Subagents are long-running by nature; advertise Ctrl+B immediately + // instead of waiting out the delay used for short Bash commands. + if (this.detachHintVisible) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); + return; + } + if (this.detachHintTimer !== undefined) return; + this.detachHintTimer = setTimeout(() => { + this.detachHintTimer = undefined; + if (this.result !== undefined) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); + }, DETACH_HINT_DELAY_MS); + } + + private stopDetachHintTimer(): void { + if (this.detachHintTimer === undefined) return; + clearTimeout(this.detachHintTimer); + this.detachHintTimer = undefined; + } + + private buildDetachHintBlock(): void { + if (!this.detachHintVisible) return; + if (this.result !== undefined) return; + this.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } + private syncSubagentElapsedTimer(): void { const phase = this.getDerivedSubagentPhase(); const shouldTick = @@ -921,11 +1062,14 @@ export class ToolCallComponent extends Container { this.stopSubagentElapsedTimer(); return; } + // Drives both the braille spinner in the header and the elapsed-seconds + // refresh. Only the header text changes on a tick, so we avoid rebuilding + // the body (which would defeat the per-component render caches). + this.subagentSpinnerFrame = (this.subagentSpinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; this.headerText.setText(this.buildHeader()); - this.invalidate(); this.notifySnapshotChange(); this.ui?.requestRender(); - }, SUBAGENT_ELAPSED_INTERVAL_MS); + }, BRAILLE_SPINNER_INTERVAL_MS); } private stopSubagentElapsedTimer(): void { @@ -1091,6 +1235,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 @@ -1129,6 +1289,7 @@ export class ToolCallComponent extends Container { } appendSubagentText(text: string, kind: SubagentTextKind = 'text'): void { + this.lastSubagentStreamKind = kind; if (kind === 'thinking') { this.subagentThinkingText += text; } else { @@ -1280,6 +1441,11 @@ export class ToolCallComponent extends Container { : 'Approved'; 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; } @@ -1298,6 +1464,20 @@ export class ToolCallComponent extends Container { return `${bullet}${currentTheme.boldFg(tone, label)}`; } + if (toolCall.name === 'Bash') { + // The command itself is rendered in the body (with a `$` prompt), so the + // header only names the action — repeating the command in parentheses + // would duplicate the body. Wording mirrors the other label-only headers + // (e.g. AskUserQuestion): the whole label takes the tone colour. + if (isTruncated) { + return `${bullet}${currentTheme.fg('error', 'Truncated')} ${currentTheme.boldFg('primary', 'Bash')}`; + } + const label = isFinished ? 'Ran a command' : 'Running a command'; + const tone = isError ? 'error' : 'primary'; + const chipStr = isFinished && result !== undefined ? this.buildHeaderChip(result) : ''; + return `${bullet}${currentTheme.boldFg(tone, label)}${chipStr}`; + } + const goalHeader = buildGoalToolHeader({ toolCall, result, @@ -1340,6 +1520,7 @@ export class ToolCallComponent extends Container { this.children.pop(); } this.buildProgressBlock(); + this.buildDetachHintBlock(); this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); @@ -1352,6 +1533,7 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildDetachHintBlock(); this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); @@ -1550,31 +1732,38 @@ export class ToolCallComponent extends Container { if (this.backgroundTaskTerminalPhase !== undefined) { return this.backgroundTaskTerminalPhase; } + // A foreground subagent detached via Ctrl+B keeps showing `backgrounded` + // even after its spawn-success ToolResult lands, so the card doesn't flip + // to `✓ Completed` and look like the work actually finished. Agents that + // started in the background (`detachedFromForeground === false`) read as + // `done` once their result lands. + if (this.detachedFromForeground && this.subagentPhase === 'backgrounded') { + return 'backgrounded'; + } if (this.result !== undefined) return this.result.is_error ? 'failed' : 'done'; return this.subagentPhase; } private buildSingleSubagentHeader(): string { const phase = this.getDerivedSubagentPhase(); - const isFailed = phase === 'failed'; const isDone = phase === 'done'; - const bullet = isFailed - ? currentTheme.fg('error', '✗ ') - : isDone - ? currentTheme.fg('success', STATUS_BULLET) - : currentTheme.fg('text', STATUS_BULLET); + const marker = this.buildSingleSubagentMarker(phase); const labelText = formatSubagentLabel(this.subagentAgentName); const label = currentTheme.boldFg('primary', labelText); const status = this.formatSingleSubagentStatus(phase); - const description = str(this.toolCall.args['description']); + const rawDescription = str(this.toolCall.args['description']); + const description = + rawDescription.length > MAX_SUBAGENT_DESCRIPTION_LENGTH + ? `${rawDescription.slice(0, MAX_SUBAGENT_DESCRIPTION_LENGTH - 1)}…` + : rawDescription; const descriptionPlain = description.length > 0 ? ` (${description})` : ''; const descriptionText = descriptionPlain.length > 0 ? currentTheme.dim(descriptionPlain) : ''; const statsText = this.formatSingleSubagentStatsText(); if (isDone) { - return `${bullet}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; + return `${marker}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; } const stats = currentTheme.dim(statsText); - return `${bullet}${label} ${status}${descriptionText}${stats}`; + return `${marker}${label} ${status}${descriptionText}${stats}`; } private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string { @@ -1617,92 +1806,133 @@ export class ToolCallComponent extends Container { return Math.max(0, Math.floor((end - this.subagentStartedAtMs) / 1000)); } - private buildSingleSubagentBlock(): void { - for (const activity of this.getRecentSubToolActivities()) { - const mark = - activity.phase === 'failed' - ? currentTheme.fg('error', '✗') - : activity.phase === 'done' - ? currentTheme.fg('success', '•') - : currentTheme.fg('text', '•'); - const verb = activity.phase === 'ongoing' ? 'Using' : 'Used'; - this.addChild(new Text(` ${mark} ${this.formatSubToolActivity(verb, activity)}`, 0, 0)); - this.addSubToolOutputPreview(activity); - } - - if (this.getDerivedSubagentPhase() === 'failed' && this.subagentError !== undefined) { - const errorLine = tailNonEmptyLines(this.subagentError, 1).at(-1); - if (errorLine !== undefined) { - this.addChild( - new PrefixedWrappedLine( - ` ${currentTheme.fg('error', '└')} `, - ' ', - currentTheme.fg('error', errorLine), - ), - ); - } - return; - } - - const outputLine = tailNonEmptyLines(this.subagentText, 1).at(-1); - if ( - this.getDerivedSubagentPhase() !== 'done' && - this.subagentThinkingText.trim().length > 0 - ) { - // Scroll thinking within a fixed two-row window (width-aware), matching - // the main agent's live thinking instead of growing without bound. - this.addChild( - new PrefixedWrappedLine( - ` ${currentTheme.dim('◌')} `, - ' ', - currentTheme.dim(this.subagentThinkingText.trimEnd()), - THINKING_PREVIEW_LINES, - ), - ); - } - if (outputLine !== undefined) { - this.addChild( - new PrefixedWrappedLine( - ` ${currentTheme.fg('text', '└')} `, - ' ', - currentTheme.fg('text', outputLine), - ), - ); - } + private buildSingleSubagentMarker(phase: SubagentPhase | undefined): string { + if (phase === 'failed') return currentTheme.fg('error', '✗ '); + if (phase === 'done') return currentTheme.fg('success', STATUS_BULLET); + if (phase === 'backgrounded') return currentTheme.dim('◐ '); + // Active (queued / spawning / running): a braille spinner reads as alive + // where a static bullet looked frozen. + const frame = BRAILLE_SPINNER_FRAMES[this.subagentSpinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]; + return currentTheme.fg('primary', `${frame} `); } - private addSubToolOutputPreview(activity: SubToolActivity): void { - const output = activity.output; - if (output === undefined || output.trim().length === 0) return; - // Mirror the main agent: Bash and any tool without a dedicated renderer - // (every MCP tool included) get a truncated output preview. Recognized - // tools keep their compact activity row only. - if (activity.name !== 'Bash' && !isGenericToolResult(activity.name)) return; - this.addChild( - new TruncatedOutputComponent(output, { - // Subagent output is always fixed-truncated; it does not take part in - // the ctrl+o expand toggle, so don't advertise it either. - expanded: false, - expandHint: false, - isError: activity.phase === 'failed', - maxLines: RESULT_PREVIEW_LINES, - indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT, - tail: activity.phase === 'ongoing', - }), + private buildSingleSubagentBlock(): void { + const phase = this.getDerivedSubagentPhase(); + + // Every state shares the same skeleton — header, a one-line tool summary, + // and a fixed two-row content window — so the card height is identical + // while running and after it finishes (no end-of-run shrink). + this.addChild(new Text(this.buildSingleSubagentSummaryLine(), 0, 0)); + + if (phase === 'failed') { + this.addChild(this.buildSingleSubagentResultWindow('error')); + return; + } + if (phase === 'done' || phase === 'backgrounded') { + this.addChild(this.buildSingleSubagentResultWindow('output')); + return; + } + this.addChild(this.buildSingleSubagentActiveWindow()); + } + + /** Most-recently-started sub-tool, preferring one that is still running. */ + private getCurrentSubToolActivity(): SubToolActivity | undefined { + let latestOngoing: SubToolActivity | undefined; + let latest: SubToolActivity | undefined; + for (const activity of this.subToolActivities.values()) { + if (latest === undefined || activity.orderSeq > latest.orderSeq) latest = activity; + if ( + activity.phase === 'ongoing' && + (latestOngoing === undefined || activity.orderSeq > latestOngoing.orderSeq) + ) { + latestOngoing = activity; + } + } + return latestOngoing ?? latest; + } + + /** + * The single live stream shown in the active window. A running sub-tool with + * previewable output (Bash or any tool without a dedicated renderer) wins; + * otherwise the most-recently-updated of the child agent's text / thinking. + */ + private getActiveSubagentContent(): { text: string; tone: 'text' | 'thinking' } | undefined { + const current = this.getCurrentSubToolActivity(); + if ( + current?.phase === 'ongoing' && + current.output !== undefined && + current.output.trim().length > 0 && + (current.name === 'Bash' || isGenericToolResult(current.name)) + ) { + return { text: current.output, tone: 'text' }; + } + if (this.lastSubagentStreamKind === 'thinking' && this.subagentThinkingText.trim().length > 0) { + return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' }; + } + if (this.subagentText.trim().length > 0) { + return { text: this.subagentText, tone: 'text' }; + } + if (this.subagentThinkingText.trim().length > 0) { + return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' }; + } + return undefined; + } + + private buildSingleSubagentSummaryLine(): string { + const toolCount = this.subToolActivities.size; + const countLabel = `${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`; + const current = this.getCurrentSubToolActivity(); + if (current === undefined) { + return currentTheme.dim(` · ${countLabel}`); + } + const verb = current.phase === 'ongoing' ? 'Using' : 'Used'; + const keyArg = extractKeyArgument(current.name, current.args, this.workspaceDir); + const nameCol = currentTheme.fg('primary', current.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; + const mark = + current.phase === 'failed' + ? currentTheme.fg('error', ' ✗') + : current.phase === 'done' + ? currentTheme.fg('success', ' ✓') + : ''; + return `${currentTheme.dim(` · ${countLabel} · `)}${verb} ${nameCol}${argCol}${mark}`; + } + + private buildSingleSubagentActiveWindow(): Component { + const gutter = currentTheme.dim('│'); + const content = this.getActiveSubagentContent(); + // Keep both tones muted: a bright `fg('text')` here flashed white whenever + // the window flipped between thinking and a brief text/tool-output segment. + const styled = + content === undefined + ? currentTheme.dim('…') + : content.tone === 'thinking' + ? currentTheme.dim(content.text) + : currentTheme.fg('textDim', content.text); + // Always exactly two rows (padded when short) so the live window matches + // the finished card's height. + return new PrefixedWrappedLine( + ` ${gutter} `, + ` ${gutter} `, + styled, + THINKING_PREVIEW_LINES, + THINKING_PREVIEW_LINES, ); } - private getRecentSubToolActivities(): SubToolActivity[] { - return [...this.subToolActivities.values()] - .toSorted((a, b) => a.orderSeq - b.orderSeq) - .slice(-MAX_SINGLE_SUBAGENT_TOOL_ROWS); - } - - private formatSubToolActivity(verb: string, activity: SubToolActivity): string { - const keyArg = extractKeyArgument(activity.name, activity.args, this.workspaceDir); - const nameCol = currentTheme.fg('primary', activity.name); - const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; - return `${verb} ${nameCol}${argCol}`; + private buildSingleSubagentResultWindow(kind: 'output' | 'error'): Component { + const gutter = currentTheme.dim('│'); + const source = kind === 'error' ? this.subagentError : this.subagentText; + const text = source === undefined ? '' : tailNonEmptyLines(source, 2).join('\n'); + const styled = + kind === 'error' ? currentTheme.fg('error', text) : currentTheme.fg('text', text); + return new PrefixedWrappedLine( + ` ${gutter} `, + ` ${gutter} `, + styled, + THINKING_PREVIEW_LINES, + THINKING_PREVIEW_LINES, + ); } private buildCallPreview(): void { @@ -1725,7 +1955,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; @@ -1766,6 +2003,24 @@ export class ToolCallComponent extends Container { 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, + }), + ); } } @@ -1829,7 +2084,7 @@ export class ToolCallComponent extends Container { new ShellExecutionComponent({ command: cmd, showCommand: true, - commandPreviewLines: COMMAND_PREVIEW_LINES, + commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, }), ); } @@ -1895,10 +2150,14 @@ export class ToolCallComponent extends Container { return; } - // Outputs that start with a `<system…>` tag are harness-injected - // reminders piggy-backing on a tool result. They are noise for the - // user, so suppress the body while keeping the header chip intact. - if (result.output.trimStart().startsWith('<system')) { + // Outputs that start with a `<system-reminder>` tag are harness-injected + // reminders piggy-backing on a tool result (e.g. a finalize hook rewrote + // the output). They are noise for the user, so suppress the body while + // keeping the header chip intact. Match the full reminder tag only: tool + // metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so real output starting with a literal `<system>` + // is user data and must stay visible. + if (result.output.trimStart().startsWith('<system-reminder>')) { return; } @@ -2058,9 +2317,7 @@ function computeLatestActivity( } function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`; - return `${String(n)} tok`; + return `${formatTokenCount(n)} tok`; } function formatActivityLine( diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts index 04f20dc35..1b38fd278 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -1,4 +1,4 @@ -import { Text } from '@earendil-works/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts index fd753cd27..b798cc8e5 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts @@ -13,8 +13,8 @@ * message. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import type { ChipProvider } from './chip'; @@ -27,11 +27,9 @@ export interface ReadMediaSummary { mimeType?: string; bytes?: number; url?: string; - originalSize?: string; } const PATH_TAG_RE = /^<(image|video)\s+path="([^"]+)">$/; -const ORIGINAL_SIZE_RE = /original size\s+(\d+x\d+px)/; const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s; function bytesFromBase64(b64: string): number { @@ -55,7 +53,6 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { let mimeType: string | undefined; let bytes: number | undefined; let url: string | undefined; - let originalSize: string | undefined; let foundMedia = false; for (const raw of parsed) { @@ -64,15 +61,11 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { const type = part['type']; if (type === 'text' && typeof part['text'] === 'string') { - const text = part['text']; - const tag = PATH_TAG_RE.exec(text); + const tag = PATH_TAG_RE.exec(part['text']); if (tag) { kind = tag[1] as 'image' | 'video'; path = tag[2]; - continue; } - const size = ORIGINAL_SIZE_RE.exec(text); - if (size) originalSize = size[1]; continue; } @@ -103,7 +96,6 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { if (mimeType !== undefined) summary.mimeType = mimeType; if (bytes !== undefined) summary.bytes = bytes; if (url !== undefined) summary.url = url; - if (originalSize !== undefined) summary.originalSize = originalSize; return summary; } @@ -117,7 +109,6 @@ function metaSegments(summary: ReadMediaSummary): string[] { const segs: string[] = []; if (summary.mimeType !== undefined) segs.push(summary.mimeType); if (summary.bytes !== undefined) segs.push(formatBytes(summary.bytes)); - if (summary.originalSize !== undefined) segs.push(summary.originalSize); return segs; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index a3f929dcc..ac31cec8e 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -10,8 +10,8 @@ * sees the actual error message, not a synthetic summary. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { renderTruncated } from './truncated'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index e619ff19d..dc1066bec 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -1,6 +1,7 @@ -import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; @@ -44,6 +45,10 @@ export class TruncatedOutputComponent implements Component { // When true, collapsed rendering keeps the latest visual rows instead of // the first rows. This is useful for live output from a running command. tail?: boolean; + // Foreground colour for successful (non-error) output. Defaults to + // `textDim`; Bash passes `textMuted` so its result sits one shade below + // the `textDim` command. Error output always uses `error`. + color?: keyof ColorPalette; }, ) { this.expanded = options.expanded; @@ -52,8 +57,9 @@ export class TruncatedOutputComponent implements Component { this.expandHint = options.expandHint ?? true; this.tail = options.tail ?? false; const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); + const successColor = options.color ?? 'textDim'; this.textComponent = new Text( - options.isError ? currentTheme.fg('error', cleaned) : currentTheme.dim(cleaned), + options.isError ? currentTheme.fg('error', cleaned) : currentTheme.fg(successColor, cleaned), this.indent, 0, ); diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts index 94161d1a8..da3dc3a5a 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { RESULT_PREVIEW_LINES } from '#/tui/constant/rendering'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 1eeba55e2..71d6e1b5b 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -4,8 +4,8 @@ * the pattern stays consistent across command-triggered panels. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { @@ -13,6 +13,7 @@ import { ratioSeverity, renderProgressBar, safeUsageRatio, + usagePercent, } from '#/utils/usage/usage-format'; import { currentTheme } from '#/tui/theme'; import type { ColorToken } from '#/tui/theme'; @@ -30,9 +31,19 @@ export interface ManagedUsageRow { readonly resetHint?: string; } +export interface BoosterWalletInfo { + readonly balanceCents: number; + readonly totalCents: number; + readonly monthlyChargeLimitEnabled: boolean; + readonly monthlyChargeLimitCents: number; + readonly monthlyUsedCents: number; + readonly currency: string; +} + export interface ManagedUsageReport { readonly summary: ManagedUsageRow | null; readonly limits: readonly ManagedUsageRow[]; + readonly extraUsage?: BoosterWalletInfo | null; } export interface UsageReportOptions { @@ -121,8 +132,7 @@ function buildManagedUsageSection( r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); - const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => - sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; + const out: string[] = [accent('Plan usage')]; for (const row of rows) { const ratioUsed = usedRatio(row); @@ -136,6 +146,91 @@ function buildManagedUsageSection( return out; } +function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' { + return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; +} + +function currencySymbol(currency: string): string { + switch (currency.toUpperCase()) { + case 'CNY': + return '¥'; + case 'USD': + return '$'; + default: + return ''; + } +} + +interface CurrencyParts { + readonly symbol: string; + readonly number: string; +} + +function formatCurrencyParts(cents: number, currency: string): CurrencyParts { + const symbol = currencySymbol(currency); + const main = cents / 100; + const formatted = main.toFixed(2); + return symbol.length > 0 + ? { symbol, number: formatted } + : { symbol: '', number: `${formatted} ${currency}` }; +} + +export function buildExtraUsageSection( + extraUsage: BoosterWalletInfo | undefined | null, + accent: Colorize, + value: Colorize, + muted: Colorize, +): string[] { + if (extraUsage === undefined || extraUsage === null) return []; + + const hasMonthlyLimit = + extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0; + + const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency); + const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency); + const rows: Array<{ label: string; symbol: string; number: string }> = []; + let barLine: string | null = null; + + if (hasMonthlyLimit) { + const ratio = Math.max( + 0, + Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1), + ); + const bar = renderProgressBar(ratio, 20); + barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`; + const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency); + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', ...limit }); + rows.push({ label: 'Balance', ...balance }); + } else { + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' }); + rows.push({ label: 'Balance', ...balance }); + } + + // `Used this month` is the longest label; size the column to the widest label + // so the currency symbol starts in the same column on every row. + const labelWidth = Math.max(...rows.map((r) => r.label.length)); + // Right-align the numeric part of currency rows against each other so the + // decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as + // `Unlimited` carry no currency symbol, so they must not widen the numeric + // column — otherwise money values get padded with stray spaces. + const numberWidth = Math.max( + 0, + ...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)), + ); + const row = (label: string, symbol: string, number: string): string => { + const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number; + return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`; + }; + + const lines: string[] = [accent('Extra Usage')]; + if (barLine !== null) lines.push(barLine); + for (const r of rows) lines.push(row(r.label, r.symbol, r.number)); + + return lines; +} + export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { const accent = (text: string) => currentTheme.boldFg('primary', text); const value = (text: string) => currentTheme.fg('text', text); @@ -157,8 +252,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { const value = (text: string) => currentTheme.fg('text', text); const muted = (text: string) => currentTheme.fg('textDim', text); const errorStyle = (text: string) => currentTheme.fg('error', text); - const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => - sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const lines: string[] = [ accent('Session usage'), @@ -174,7 +267,7 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { if (options.maxContextTokens > 0) { const ratio = safeUsageRatio(options.contextUsage); const bar = renderProgressBar(ratio, 20); - const pct = `${(ratio * 100).toFixed(1)}%`; + const pct = `${String(usagePercent(options.contextTokens, options.maxContextTokens))}%`; const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); lines.push(''); lines.push(accent('Context window')); @@ -197,6 +290,17 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { lines.push(...managedSection); } + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index 778a0407a..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,25 +2,35 @@ * Renders a user message in the transcript. */ -import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; 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 text: string; + private readonly bullet?: string; private spacerComponent: Spacer; private imageThumbnails: ImageThumbnail[]; - constructor(text: string, images?: ImageAttachment[]) { + 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)) ?? []; } + private markRenderDirty(): void { + this.renderCache = undefined; + } + invalidate(): void { + this.markRenderDirty(); for (const img of this.imageThumbnails) { img.invalidate?.(); } @@ -30,7 +40,16 @@ export class UserMessageComponent implements Component { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; - const bullet = currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET); + 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, safeWidth - bulletWidth); @@ -41,7 +60,8 @@ export class UserMessageComponent implements Component { lines.push(line); } - // Text — re-dye on every render so theme switches are reflected + // 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++) { @@ -57,6 +77,22 @@ export class UserMessageComponent implements Component { } } - return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + const rendered = lines.map((line) => { + // Inline image sequences (Kitty / iTerm2) carry their own placement + // information and have zero visible width, but pi-tui's truncateToWidth + // treats the embedded base64 payload as visible text and would chop the + // escape sequence in half, leaving garbage like "0m...". Skip truncation + // for those lines; the image itself already respects maxWidthCells. + if (isImageLine(line)) return line; + return truncateToWidth(line, safeWidth, '…'); + }); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } + +function isImageLine(line: string): boolean { + return line.includes('\u001B_G') || line.includes('\u001B]1337;File='); +} diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts index 2a1d8ea1d..22e6f3bc5 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -1,29 +1,38 @@ -import { Container, Spacer } from '@earendil-works/pi-tui'; +import { Container, Spacer } from '@moonshot-ai/pi-tui'; -import type { MoonLoader } from '../chrome/moon-loader'; +import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool'; export interface ActivityPaneOptions { readonly mode: ActivityPaneMode; readonly spinner?: MoonLoader; + readonly tip?: string; } export class ActivityPaneComponent extends Container { + private spinnerRef?: MoonLoader; + constructor(options: ActivityPaneOptions) { super(); + this.spinnerRef = options.spinner; - if (options.mode === 'waiting' || options.mode === 'tool') { - if (options.spinner !== undefined) { - this.addChild(new Spacer(1)); - this.addChild(options.spinner); - } - return; - } - - if (options.mode === 'composing' && options.spinner !== undefined) { + if ( + (options.mode === 'waiting' || options.mode === 'tool' || options.mode === 'composing') && + options.spinner !== undefined + ) { this.addChild(new Spacer(1)); + if (options.tip) { + options.spinner.setTip(` · Tip: ${options.tip}`); + } this.addChild(options.spinner); } } + + override render(width: number): string[] { + if (this.spinnerRef && 'setAvailableWidth' in this.spinnerRef) { + this.spinnerRef.setAvailableWidth(width); + } + return super.render(width); + } } diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index 9c4936fa2..f32aa9321 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -1,10 +1,10 @@ -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import type { Component, MarkdownTheme } from '@moonshot-ai/pi-tui'; import { Markdown, Text, truncateToWidth, visibleWidth, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 77800b97e..1a2b26d07 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -1,4 +1,4 @@ -import { Container, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import { Container, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '../../constant/symbols'; import type { QueuedMessage } from '../../types'; @@ -22,26 +22,40 @@ export class QueuePaneComponent extends Container { this.messages = options.messages; if (options.messages.length > 0) { + // 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'; + : 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} `; - const availableWidth = Math.max(1, width - visibleWidth(prefix)); - const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); - lines.push(accent(prefix + truncated)); + 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) { diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index fdcd8714e..cd3329b55 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -32,6 +32,7 @@ export const UpgradePreferencesSchema = z.object({ export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), + disable_paste_burst: z.boolean().optional(), editor: z .object({ command: z.string().optional(), @@ -52,6 +53,7 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, + disablePasteBurst: z.boolean(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, @@ -73,6 +75,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', + disablePasteBurst: false, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, @@ -132,6 +135,7 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { const command = config.editor?.command?.trim(); return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, + disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, @@ -150,6 +154,7 @@ export function renderTuiConfig(config: TuiConfig): string { # Agent/runtime settings stay in ~/.kimi-code/config.toml. theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name +disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR diff --git a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts new file mode 100644 index 000000000..eccb3f3fb --- /dev/null +++ b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts @@ -0,0 +1,3 @@ +// Timing constants for the clipboard-image hint controller. +export const FOCUS_DEBOUNCE_MS = 1_000; +export const HINT_DISPLAY_MS = 4_000; diff --git a/apps/kimi-code/src/tui/constant/feedback.ts b/apps/kimi-code/src/tui/constant/feedback.ts index 9e8d621f5..d72a62def 100644 --- a/apps/kimi-code/src/tui/constant/feedback.ts +++ b/apps/kimi-code/src/tui/constant/feedback.ts @@ -16,12 +16,15 @@ export { } from '#/constant/app'; export const FEEDBACK_STATUS_SUBMITTING = 'Submitting feedback…'; +export const FEEDBACK_STATUS_UPLOADING = 'Uploading attachments, this could take a few minutes…'; export const FEEDBACK_STATUS_SUCCESS = 'Feedback submitted, thank you!'; export const FEEDBACK_STATUS_CANCELLED = 'Feedback cancelled.'; export const FEEDBACK_STATUS_NETWORK_ERROR = 'Network error, failed to submit feedback.'; export const FEEDBACK_STATUS_FALLBACK = 'Opening GitHub Issues as fallback…'; export const FEEDBACK_STATUS_NOT_SIGNED_IN = "You're not signed in. Opening GitHub Issues for feedback…"; +export const FEEDBACK_STATUS_UPLOAD_FAILED = + 'Feedback sent; attachment upload failed — see feedback-upload.log.'; export function feedbackHttpErrorMessage(status: number): string { return `Failed to submit feedback (HTTP ${String(status)}).`; @@ -31,6 +34,10 @@ export function feedbackSessionLine(sessionId: string): string { return `Session: ${sessionId}`; } +export function feedbackIdLine(feedbackId: number): string { + return `Feedback ID: ${String(feedbackId)}`; +} + // Hint shown beneath session-level error messages in the TUI to point users // at the `/export-debug-zip` workflow so they can share diagnostics with us. export function errorReportHintLine(): string { diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index ed05d93e1..8c8f9807b 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -9,6 +9,10 @@ export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; +// Time window for treating two consecutive Esc presses as a double-Esc, which +// opens the undo selector. Kept short (double-click feel) so two deliberate +// presses far apart don't accidentally trigger undo. +export const DOUBLE_ESC_WINDOW_MS = 600; export function isManagedUsageProvider( providerKey: string | undefined, diff --git a/apps/kimi-code/src/tui/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts new file mode 100644 index 000000000..f9d0a7f27 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -0,0 +1,50 @@ +export interface ToolbarTip { + readonly text: string; + /** + * Long/important tips render on their own. They never pair with a + * neighbour and never appear as the second half of someone else's pair. + */ + readonly solo?: boolean; + /** + * Rotation weight: a higher value makes the tip recur more often. Defaults + * to 1. Used to give newer/important features more airtime. + */ + readonly priority?: number; +} + +/** + * Subset of toolbar tips shown behind the composing spinner. + */ +export const WORKING_TIPS: readonly ToolbarTip[] = [ + { text: 'ctrl-s to add guidance without waiting for the turn to finish', priority: 2, solo: true }, + { text: '/tasks to check progress and status for background tasks', priority: 2 }, + { text: '/init: generate AGENTS.md', priority: 2 }, + { text: 'Try /dance for a hidden Easter egg' }, + { text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 }, + { + text: '/plugins: manage plugins — try the "Kimi Datasource" for reliable financial, economic, and academic data', + solo: true, + priority: 3, + }, + { text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, + { text: '/sessions to browse and resume earlier sessions', solo: true }, + { text: '/goal for multi-step work with a clear finish line', priority: 2, solo: true }, + { text: '/goal next to queue follow-up work while the current goal keeps running', solo: true }, + { text: '/web: use the Web UI for a better experience', solo: true }, + { text: '@: mention files', priority: 2 }, + { text: '! to run a shell command', priority: 2 }, +]; + +export const ALL_TIPS: readonly ToolbarTip[] = [ + ...WORKING_TIPS, + { text: 'shift+enter: newline' }, + { text: 'ctrl+c: cancel' }, + { text: '/theme to switch the terminal UI theme' }, + { text: '/auto when you want Kimi to handle approvals and keep going unattended' }, + { text: '/yolo to skip most approvals for trusted batch work, only use it in repos you trust' }, + { text: '/help: show commands' }, + { text: '/compact compresses context when it gets long', priority: 2 }, + { text: 'ctrl-o to hide or reveal tool output switching between a clean chat view and full execution details', priority: 2 }, + { text: 'shift-tab to Plan mode to review the approach before Kimi edits files.', priority: 2 }, + { text: '/model: switch model', priority: 2 }, +]; diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index b0d1cc22d..a7acc77ce 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,4 +1,7 @@ -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'; @@ -7,10 +10,15 @@ import { 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; @@ -28,6 +36,7 @@ export interface AuthFlowHost { fetchSessions(): Promise<void>; updateTerminalTitle(): void; refreshSkillCommands(session?: SkillListSession): Promise<void>; + refreshPluginCommands(session?: Session): Promise<void>; } export class AuthFlowController { @@ -46,7 +55,7 @@ export class AuthFlowController { this.host.setAppState({ sessionId: '', model: '', - thinking: false, + thinkingEffort: 'off', contextTokens: 0, maxContextTokens: 0, contextUsage: 0, @@ -56,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, @@ -88,6 +100,7 @@ export class AuthFlowController { void host.fetchSessions(); host.updateTerminalTitle(); void host.refreshSkillCommands(host.session); + void host.refreshPluginCommands(host.session); } async clearActiveSessionAfterLogout(): Promise<void> { @@ -99,6 +112,7 @@ export class AuthFlowController { sessionTitle: null, }); await this.host.refreshSkillCommands(); + await this.host.refreshPluginCommands(); } async refreshConfigAfterLogin(): Promise<void> { @@ -114,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); } @@ -133,7 +144,7 @@ export class AuthFlowController { availableModels: config.models ?? {}, availableProviders: config.providers ?? {}, model: '', - thinking: false, + thinkingEffort: 'off', maxContextTokens: 0, contextUsage: 0, contextTokens: 0, @@ -165,6 +176,7 @@ export class AuthFlowController { const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); return tokenProvider.getAccessToken(); }, + userAgent: createKimiCodeUserAgent(), }, { scope }, ); diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index 21406f329..a8ee45e61 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -1,4 +1,4 @@ -import { Spacer } from '@earendil-works/pi-tui'; +import { Spacer } from '@moonshot-ai/pi-tui'; import type { Event, KimiHarness, @@ -196,11 +196,17 @@ export class BtwPanelController { } function formatBtwTurnEnd(event: TurnEndedEvent): string { - if (event.error !== undefined) { - return `[${event.error.code}] ${event.error.message}`; - } 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 8038be32d..76d0363f8 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -1,4 +1,5 @@ -import type { Session } from '@moonshot-ai/kimi-code-sdk'; +import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; import { parseImageMeta } from '#/utils/image/image-mime'; @@ -7,13 +8,15 @@ 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'; @@ -21,25 +24,42 @@ export interface EditorKeyboardHost { state: TUIState; session: Session | undefined; cancelInFlight: (() => void) | undefined; + /** + * The host's harness (KimiTUI always has one). Its `imageLimits` drives + * paste-time image compression; hosts without one fall back to the + * env/built-in default. + */ + harness?: KimiHarness | undefined; handleUserInput(text: string): void; readonly btwPanelController: BtwPanelController; - steerMessage(session: Session, input: string[]): void; - recallLastQueued(): string | undefined; + 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; + toggleTodoPanelExpansion(): void; + detachCurrentForegroundTask(): void; + cancelRunningShellCommand(): void; hideSessionPicker(): void; + openUndoSelector(): void; stop(exitCode?: number): Promise<void>; handlePlanToggle(next: boolean): void; + handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; } export class EditorKeyboardController { private pendingExit: PendingExit | null = null; + private pendingUndoEsc: { readonly timer: ReturnType<typeof setTimeout> } | null = null; constructor( private readonly host: EditorKeyboardHost, @@ -59,6 +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; @@ -68,12 +129,8 @@ export class EditorKeyboardController { return; } - if (host.state.appState.isCompacting) { - this.clearPendingExit(); - this.cancelCurrentCompaction(); - return; - } - + // The btw panel stacks above the transcript, so Ctrl+C cancels/closes it + // before touching an in-flight compaction or stream. if (host.btwPanelController.cancelRunning()) { this.clearPendingExit(); return; @@ -83,13 +140,19 @@ export class EditorKeyboardController { return; } + if (host.state.appState.isCompacting) { + this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + + this.cancelCurrentCompaction(); + return; + } + if (host.state.appState.streamingPhase !== 'idle') { this.clearPendingExit(); - if (editor.getText().length > 0) { - editor.setText(''); - return; - } + if (this.clearEditorTextIfPresent()) return; this.cancelCurrentStream(); return; @@ -120,18 +183,32 @@ export class EditorKeyboardController { if (this.pendingExit) this.clearPendingExit(); if (host.state.activeDialog === 'session-picker') { host.hideSessionPicker(); + this.clearPendingUndoEsc(); + return; + } + // The btw panel stacks above the transcript, so Esc dismisses it before + // touching an in-flight compaction or stream. + if (host.btwPanelController.closeOrCancel()) { + this.clearPendingUndoEsc(); return; } if (host.state.appState.isCompacting) { this.cancelCurrentCompaction(); - 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 = () => { @@ -145,6 +222,10 @@ export class EditorKeyboardController { host.handlePlanToggle(next); }; + editor.onInputModeChange = (mode) => { + host.handleInputModeChange(mode); + }; + editor.onOpenExternalEditor = () => { host.track('shortcut_editor'); void this.openExternalEditor(); @@ -155,38 +236,96 @@ export class EditorKeyboardController { host.toggleToolOutputExpansion(); }; + editor.onToggleTodoExpand = (): boolean => { + if (!host.state.todoPanel.hasOverflow()) return false; + // Disarm a pending double-press exit confirmation so expanding the + // todo list in between two Ctrl-C presses does not accidentally exit. + this.clearPendingExit(); + host.track('shortcut_todo_expand'); + host.toggleTodoPanelExpansion(); + return true; + }; + editor.onCtrlS = () => { - if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) return; + if ( + host.state.appState.streamingPhase === 'idle' || + host.state.appState.streamingPhase === 'shell' || + host.state.appState.isCompacting + ) + return; const text = editor.getText().trim(); - const queuedTexts = host.state.queuedMessages.map((m) => m.text); - host.clearQueuedMessages(); + const editorIsBash = editor.inputMode === 'bash'; - 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 = () => { @@ -198,7 +337,14 @@ export class EditorKeyboardController { if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false; const recalled = host.recallLastQueued(); if (recalled !== undefined) { - editor.setText(recalled); + editor.setText(recalled.text); + // Restore the queued item's mode so a recalled `!` command runs as a + // shell command again instead of being submitted as a normal prompt. + const mode = recalled.mode ?? 'prompt'; + if (editor.inputMode !== mode) { + editor.inputMode = mode; + editor.onInputModeChange?.(mode); + } host.updateQueueDisplay(); host.state.ui.requestRender(); return true; @@ -218,6 +364,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); @@ -233,7 +400,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(); } @@ -269,7 +446,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 919568191..00d79c3d8 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -1,4 +1,4 @@ -import type { Component, Focusable } from '@earendil-works/pi-tui'; +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; import type { AgentStatusUpdatedEvent, AssistantDeltaEvent, @@ -17,6 +17,7 @@ import type { Session, SessionMetaUpdatedEvent, SkillActivatedEvent, + PluginCommandActivatedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -106,6 +107,8 @@ export interface SessionEventHost { restoreEditor(): void; restoreInputText(text: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void; + handleShellStarted(event: { commandId: string; taskId: string }): void; sendNormalUserInput(text: string): void; updateTerminalTitle(): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; @@ -132,6 +135,7 @@ export class SessionEventHandler { backgroundTaskTranscriptedTerminal: Set<string> = new Set(); renderedSkillActivationIds: Set<string> = new Set(); + renderedPluginCommandActivationIds: Set<string> = new Set(); renderedMcpServerStatusKeys: Map<string, string> = new Map(); mcpServerStatusSpinners: Map<string, MoonLoader> = new Map(); mcpServers: Map<string, McpServerStatusSnapshot> = new Map(); @@ -148,6 +152,7 @@ export class SessionEventHandler { this.backgroundTaskTranscriptedTerminal.clear(); this.subAgentEventHandler.resetRuntimeState(); this.renderedSkillActivationIds.clear(); + this.renderedPluginCommandActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); this.mcpServers.clear(); this.goalCompletionAwaitingClear = false; @@ -242,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; @@ -252,6 +259,7 @@ export class SessionEventHandler { case 'session.meta.updated': this.handleSessionMetaChanged(event); break; case 'goal.updated': this.handleGoalUpdated(event); break; case 'skill.activated': this.handleSkillActivated(event); break; + case 'plugin_command.activated': this.handlePluginCommandActivated(event); break; case 'error': this.handleSessionError(event); break; case 'warning': this.handleSessionWarning(event); break; case 'compaction.started': this.handleCompactionBegin(event); break; @@ -325,6 +333,12 @@ export class SessionEventHandler { 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([]); @@ -356,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( @@ -376,7 +399,14 @@ export class SessionEventHandler { private maybeShowDebugTiming(event: TurnStepCompletedEvent): void { if (process.env['KIMI_CODE_DEBUG'] !== '1') return; const text = formatStepDebugTiming(event); - if (text !== undefined) this.host.showStatus(text); + if (text === undefined) return; + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + turnId: String(event.turnId), + renderMode: 'plain', + content: text, + }); } private markActiveAgentSwarmsCancelled(): void { @@ -385,9 +415,10 @@ export class SessionEventHandler { private isAnthropicSessionActive(): boolean { const { state } = this.host; - const providerKey = state.appState.availableModels[state.appState.model]?.provider; - if (providerKey === undefined) return false; - return state.appState.availableProviders[providerKey]?.type === 'anthropic'; + const model = state.appState.availableModels[state.appState.model]; + if (model === undefined) return false; + if (model.protocol === 'anthropic') return true; + return state.appState.availableProviders[model.provider]?.type === 'anthropic'; } private handleStepInterrupted(event: TurnStepInterruptedEvent): void { @@ -398,7 +429,11 @@ export class SessionEventHandler { if (reason === 'error') return; if (reason === 'aborted' || reason === undefined || reason === '') { this.markActiveAgentSwarmsCancelled(); - this.host.showStatus('Interrupted by user', 'error'); + if (event.message === undefined || event.message === '') { + this.host.showStatus('Interrupted by user', 'error'); + } else { + this.host.showError(event.message); + } return; } this.host.showError( @@ -410,6 +445,15 @@ 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. Models also occasionally stream whitespace- + // only thinking (e.g. a single space). 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.trim().length === 0 && !streamingUI.hasThinkingDraft()) return; streamingUI.appendThinkingDelta(event.delta); this.host.patchLivePane({ mode: 'idle' }); if (state.appState.streamingPhase !== 'thinking') { @@ -572,6 +616,7 @@ export class SessionEventHandler { 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; @@ -702,7 +747,8 @@ export class SessionEventHandler { (session === undefined || this.host.session === session) && !this.host.aborted && this.host.state.appState.streamingPhase === 'idle' && - this.host.state.queuedMessages.length === 0 + this.host.state.queuedMessages.length === 0 && + !this.host.state.queuedMessageDispatchPending ); } @@ -915,6 +961,25 @@ export class SessionEventHandler { }); } + private handlePluginCommandActivated(event: PluginCommandActivatedEvent): void { + if (this.renderedPluginCommandActivationIds.has(event.activationId)) return; + this.renderedPluginCommandActivationIds.add(event.activationId); + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'plugin_command', + turnId: undefined, + renderMode: 'plain', + content: `/${event.pluginId}:${event.commandName}`, + pluginCommandData: { + activationId: event.activationId, + pluginId: event.pluginId, + commandName: event.commandName, + args: event.commandArgs, + trigger: event.trigger, + }, + }); + } + private handleCompactionBegin(event: CompactionStartedEvent): void { this.host.streamingUI.finalizeLiveTextBuffers('waiting'); this.host.setAppState({ @@ -929,7 +994,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); } @@ -944,14 +1013,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); } @@ -986,6 +1059,9 @@ export class SessionEventHandler { if (event.type === 'background.task.started') { if (info.kind === 'agent') { + // A foreground subagent detached via Ctrl+B: flip its card to + // `◐ backgrounded` so it doesn't look like it completed. + this.host.streamingUI.markSubagentBackgrounded(info.agentId); this.syncBackgroundTaskBadge(); this.host.tasksBrowserController.repaint(); return; diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 4a13fe373..ad6e82358 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -10,6 +10,7 @@ import type { } from '@moonshot-ai/kimi-code-sdk'; import { ToolCallComponent } from '../components/messages/tool-call'; +import { currentTheme } from '../theme'; import type { TodoItem } from '../components/chrome/todo-panel'; import type { AppState, @@ -21,6 +22,7 @@ import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload'; import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; import { buildGoalCompletionMessage } from '../utils/goal-completion'; +import { formatBashOutputForDisplay } from '../utils/shell-output'; import { appStateFromResumeAgent, backgroundOrigin, @@ -35,10 +37,12 @@ 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'; @@ -55,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 { @@ -72,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); @@ -249,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; @@ -280,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( @@ -377,6 +429,32 @@ export class SessionReplayRenderer { }); } + private renderPluginCommand( + context: ReplayRenderContext, + command: PluginCommandProjection, + ): void { + const { sessionEventHandler } = this.host; + if (context.pluginCommandActivationIds.has(command.activationId)) return; + if (sessionEventHandler.renderedPluginCommandActivationIds.has(command.activationId)) return; + context.pluginCommandActivationIds.add(command.activationId); + sessionEventHandler.renderedPluginCommandActivationIds.add(command.activationId); + this.host.appendTranscriptEntry({ + ...replayEntry( + context, + 'plugin_command', + `/${command.pluginId}:${command.commandName}`, + 'plain', + ), + pluginCommandData: { + activationId: command.activationId, + pluginId: command.pluginId, + commandName: command.commandName, + args: command.commandArgs, + trigger: command.trigger, + }, + }); + } + private renderCompaction(context: ReplayRenderContext, record: CompactionReplayRecord): void { this.flushAssistant(context); if (record.result === undefined) return; @@ -394,6 +472,7 @@ export class SessionReplayRenderer { this.host.appendTranscriptEntry({ ...replayEntry(context, 'status', 'Compaction complete', 'plain'), compactionData: { + summary: record.result.summary, tokensBefore: record.result.tokensBefore, tokensAfter: record.result.tokensAfter, instruction: record.instruction, @@ -490,7 +569,7 @@ export class SessionReplayRenderer { if (mode === 'yolo') { this.host.appendTranscriptEntry( replayEntry(context, 'status', 'YOLO mode: ON', 'notice', { - detail: 'All actions will be approved automatically. Use with caution.', + detail: 'Tool actions auto-approved; the agent may still ask you questions.', }), ); return; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index beb1b4e16..484b9f02a 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -2,6 +2,7 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; import { AgentGroupComponent } from '../components/messages/agent-group'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; +import { currentWorkingTip } from '../components/chrome/working-tips'; import { CompactionComponent } from '../components/dialogs/compaction'; import { ReadGroupComponent } from '../components/messages/read-group'; import { ThinkingComponent } from '../components/messages/thinking'; @@ -34,6 +35,7 @@ export interface StreamingUIHost { deferUserMessages: boolean; shiftQueuedMessage(): QueuedMessage | undefined; pushTranscriptEntry(entry: TranscriptEntry): void; + mergeCurrentTurnSteps(): void; } export class StreamingUIController { @@ -265,6 +267,41 @@ export class StreamingUIController { return true; } + /** + * Mark a foreground subagent card as detached-to-background (`◐ backgrounded`). + * Routed from a `background.task.started` event whose `info.kind === 'agent'`, + * keyed by `agentId`. Returns true iff a matching component was found. + * + * Gated to cards that are currently foreground-running: `background.task.started` + * also fires for `Agent(run_in_background=true)` launches and for background + * resumes, and those must not mutate older completed rows that happen to share + * the same `agentId` (a resume's new card has no parsed `agent_id` yet, so the + * search can otherwise hit the previous completed card). + */ + markSubagentBackgrounded(agentId: string | undefined): boolean { + if (agentId === undefined) return false; + const visit = (tc: ToolCallComponent): boolean => { + if (tc.getSubagentAgentId() !== agentId) return false; + const phase = tc.getSubagentSnapshot().phase; + if (phase !== 'running' && phase !== 'queued' && phase !== 'spawning') return false; + tc.markBackgrounded(); + return true; + }; + for (const tc of this._pendingToolComponents.values()) { + if (visit(tc)) return true; + } + for (const child of this.host.state.transcriptContainer.children) { + if (child instanceof ToolCallComponent) { + if (visit(child)) return true; + } else if (child instanceof AgentGroupComponent) { + for (const tc of child.getToolComponents()) { + if (visit(tc)) return true; + } + } + } + return false; + } + /** Registers a tool call that arrived via tool.call.started. * Clears any pending streaming state for this id, updates or creates the * component, and returns whether the call was new (no previous entry). */ @@ -499,6 +536,7 @@ export class StreamingUIController { this.disposeAndClearPendingToolComponents(); this._pendingAgentGroup = null; this._pendingReadGroup = null; + this.resetToolCallState(); } resetToolCallState(): void { @@ -522,9 +560,15 @@ export class StreamingUIController { const next = this.host.shiftQueuedMessage(); if (next !== undefined) { + // The message is out of the queue but not yet sent. Mark the dispatch + // pending *before* setAppState — that call synchronously retries + // queued-goal promotion, which would otherwise see an empty queue and an + // idle phase and start a goal ahead of this message. + state.queuedMessageDispatchPending = true; this.host.setAppState({ streamingPhase: 'idle' }); this.host.resetLivePane(); setTimeout(() => { + state.queuedMessageDispatchPending = false; sendQueued(next); }, 0); return; @@ -552,6 +596,7 @@ export class StreamingUIController { turnId: this._currentTurnId, renderMode: 'markdown' as const, content: '', + modelText: true, }; const component = new AssistantMessageComponent(); this._streamingBlock = { component, entry }; @@ -564,17 +609,25 @@ 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; } onThinkingUpdate(fullText: string): void { - if (fullText.length === 0 && this._activeThinkingComponent === undefined) return; + // Skip thinking that carries nothing visible — empty (e.g. encrypted + // reasoning) or whitespace-only (a model occasionally streams a single + // space as thinking). Session replay funnels through here as well, so a + // stored whitespace-only think part never becomes a bare bullet line. + if (fullText.trim().length === 0 && this._activeThinkingComponent === undefined) return; const { state } = this.host; if (this._activeThinkingComponent === undefined) { this._pendingAgentGroup = null; @@ -598,6 +651,7 @@ export class StreamingUIController { this._activeThinkingComponent.finalize(); this._activeThinkingComponent = undefined; this.host.state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); } onToolCallStart(toolCall: ToolCallBlockData): void { @@ -644,6 +698,7 @@ export class StreamingUIController { tc.setResult(result); this._pendingToolComponents.delete(toolCallId); state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); return; } @@ -658,6 +713,7 @@ export class StreamingUIController { state.transcriptContainer.addChild(completed); state.ui.requestRender(); } + this.host.mergeCurrentTurnSteps(); } setTodoList(todos: readonly TodoItem[]): void { @@ -676,16 +732,19 @@ export class StreamingUIController { this._activeCompactionBlock.markDone(); this._activeCompactionBlock = undefined; } - const block = new CompactionComponent(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(); } diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 6d08330c5..deb407bfc 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -2,7 +2,7 @@ import type { BackgroundTaskInfo, Event, } from '@moonshot-ai/kimi-code-sdk'; -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { AgentSwarmProgressComponent, diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 90ed3c99e..6994b13b8 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -1,5 +1,5 @@ import type { BackgroundTaskInfo, Session } from '@moonshot-ai/kimi-code-sdk'; -import type { Component, ProcessTerminal, TUI } from '@earendil-works/pi-tui'; +import type { Component, ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; diff --git a/apps/kimi-code/src/tui/easter-eggs/dance.ts b/apps/kimi-code/src/tui/easter-eggs/dance.ts index 6f638aba0..15e3608f8 100644 --- a/apps/kimi-code/src/tui/easter-eggs/dance.ts +++ b/apps/kimi-code/src/tui/easter-eggs/dance.ts @@ -10,7 +10,7 @@ */ import chalk from 'chalk'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import type { SlashCommandHost } from '../commands/dispatch'; import type { ParsedSlashInput } from '../commands/types'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 330f9c7f1..a05165ca2 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1,13 +1,6 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { - deleteAllKittyImages, - type Component, - type Focusable, - getCapabilities, - Spacer, -} from '@earendil-works/pi-tui'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { ApprovalRequest, @@ -20,6 +13,13 @@ import type { Session, } from '@moonshot-ai/kimi-code-sdk'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { + deleteAllKittyImages, + type Component, + type Focusable, + getCapabilities, + Spacer, +} from '@moonshot-ai/pi-tui'; import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; @@ -30,11 +30,13 @@ import { openUrl } from '#/utils/open-url'; import { getInputHistoryFile } from '#/utils/paths'; import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect'; import { quoteShellArg } from '#/utils/shell-quote'; +import { restoreTerminalModes } from '#/utils/terminal-restore'; import { BannerProvider } from './banner/banner-provider'; import { readBannerDisplayState, writeBannerDisplayState } from './banner/state'; import { BUILTIN_SLASH_COMMANDS, + buildPluginSlashCommands, buildSkillSlashCommands, isExperimentalFlagEnabled, setExperimentalFeatures, @@ -48,6 +50,7 @@ import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; import { GutterContainer } from './components/chrome/gutter-container'; 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, @@ -59,10 +62,7 @@ import { import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; -import { - SessionPickerComponent, - type SessionRow, -} from './components/dialogs/session-picker'; +import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; import { FileMentionProvider, type SlashAutocompleteCommand, @@ -75,11 +75,14 @@ import { GoalCompletionMessageComponent, GoalSetMessageComponent, } from './components/messages/goal-panel'; +import { PluginCommandComponent } from './components/messages/plugin-command'; +import { ShellRunComponent } from './components/messages/shell-run'; import { SkillActivationComponent } from './components/messages/skill-activation'; import { NoticeMessageComponent, StatusMessageComponent, } from './components/messages/status-message'; +import { StepSummaryComponent } from './components/messages/step-summary'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; import { UserMessageComponent } from './components/messages/user-message'; @@ -96,6 +99,7 @@ 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'; @@ -119,24 +123,39 @@ import { type LivePaneState, type LoginProgressSpinnerHandle, type QueuedMessage, + type SteerInputItem, type TranscriptEntry, type TUIStartupOptions, type TUIStartupState, } from './types'; -import { isExpandable } from './utils/component-capabilities'; +import { hasDispose, isExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; +import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; -import { extractMediaAttachments } from './utils/image-placeholder'; +import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; +import { formatBashOutputForDisplay } from './utils/shell-output'; import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; import { installTerminalThemeTracking } from './utils/terminal-theme'; import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard'; -import { markTranscriptComponent } from './utils/transcript-component-metadata'; +import { + getTranscriptComponentEntry, + markTranscriptComponent, +} from './utils/transcript-component-metadata'; import { nextTranscriptId } from './utils/transcript-id'; +import { + TRANSCRIPT_EXPAND_TURNS, + TRANSCRIPT_HYSTERESIS, + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_MAX_TURNS, + TRANSCRIPT_WINDOW_ENABLED, + groupTurns, + turnsToTrim, +} from './utils/transcript-window'; export type { TUIState } from './tui-state'; export { createTUIState } from './tui-state'; @@ -149,6 +168,7 @@ export type { export interface KimiTUIStartupInput { readonly cliOptions: CLIOptions; + readonly additionalDirs?: readonly string[]; readonly tuiConfig: TuiConfig; readonly version: string; readonly workDir: string; @@ -159,6 +179,21 @@ export interface KimiTUIStartupInput { } 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 @@ -169,11 +204,13 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { return { model: '', workDir: input.workDir, + additionalDirs: [...(input.additionalDirs ?? [])], sessionId: '', permissionMode: startupPermission, planMode: input.cliOptions.plan, + inputMode: 'prompt', swarmMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -184,6 +221,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { theme: input.tuiConfig.theme, version: input.version, editorCommand: input.tuiConfig.editorCommand, + disablePasteBurst: input.tuiConfig.disablePasteBurst, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, availableModels: {}, @@ -201,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; @@ -211,6 +296,8 @@ export class KimiTUI { private readonly reverseRpcDisposers: Array<() => void> = []; private skillCommands: readonly KimiSlashCommand[] = []; readonly skillCommandMap = new Map<string, string>(); + private pluginCommands: readonly KimiSlashCommand[] = []; + readonly pluginCommandMap = new Map<string, string>(); private readonly imageStore = new ImageAttachmentStore(); private fdPath: string | null = detectFdPath(); private fdDownloadStarted = false; @@ -220,6 +307,7 @@ export class KimiTUI { aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; + private clipboardImageHintController: ClipboardImageHintController | undefined; private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; @@ -227,7 +315,17 @@ export class KimiTUI { private readonly migrateOnly: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; + private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = + undefined; private lastHistoryContent: string | undefined; + // Live `!` shell output entries, keyed by commandId so concurrent commands + // each update their own card and stale events are dropped. Mutated in place + // as `shell.output` events arrive; removed when the command completes. + // `taskId` (from `shell.started`) lets ctrl+b detach the exact task. + private readonly shellOutputStreams = new Map< + string, + { entry: TranscriptEntry; component: ShellRunComponent; taskId?: string } + >(); readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; readonly btwPanelController: BtwPanelController; @@ -236,6 +334,9 @@ export class KimiTUI { readonly tasksBrowserController: TasksBrowserController; readonly editorKeyboard: EditorKeyboardController; + /** Timer that auto-clears the one-shot "moved to background" footer hint. */ + private detachHintClearTimer: ReturnType<typeof setTimeout> | undefined; + // The currently-mounted approval panel, if any. Kept so the full-screen // preview viewer can restore focus to the exact same instance (and its // selection / feedback state) when it closes. @@ -252,6 +353,9 @@ export class KimiTUI { public onExit?: (exitCode?: number) => Promise<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); } @@ -314,7 +418,7 @@ 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 { @@ -334,8 +438,20 @@ export class KimiTUI { slashCommands, this.state.appState.workDir, this.fdPath, + this.state.appState.additionalDirs, + () => this.state.appState.inputMode, ); this.state.editor.setAutocompleteProvider(provider); + + const argumentHints = new Map<string, string>(); + for (const cmd of slashCommands) { + if (cmd.argumentHint === undefined) continue; + argumentHints.set(cmd.name, cmd.argumentHint); + for (const alias of cmd.aliases ?? []) { + argumentHints.set(alias, cmd.argumentHint); + } + } + this.state.editor.setArgumentHints(argumentHints); } refreshSlashCommandAutocomplete(): void { @@ -365,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 // ========================================================================= @@ -476,11 +615,27 @@ export class KimiTUI { } private startEventLoop(): void { + // Dispose any previous focus/clipboard/theme tracking so re-entering the + // event loop (e.g. a future TUI reconnect) can't stack duplicate listeners. + this.disposeTerminalTracking(); this.state.ui.start(); + this.startClipboardImageHintController(); this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); this.refreshTerminalThemeTracking(); } + private startClipboardImageHintController(): void { + this.clipboardImageHintController = new ClipboardImageHintController({ + ui: this.state.ui, + footer: this.state.footer, + getModelSupportsImage: () => this.supportsCurrentModelCapability('image_in'), + requestRender: () => { + this.state.ui.requestRender(); + }, + }); + this.clipboardImageHintController.start(); + } + private startBackgroundFdAutocomplete(): void { if (this.fdPath !== null || this.fdDownloadStarted) return; this.fdDownloadStarted = true; @@ -501,9 +656,7 @@ export class KimiTUI { const result = await this.authFlow.refreshProviderModels(); for (const c of result.changed) { if (c.added <= 0) continue; - this.showStatus( - `${c.providerName} · +${String(c.added)} model${c.added > 1 ? 's' : ''}.`, - ); + 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}`, 'warning'); @@ -533,12 +686,27 @@ export class KimiTUI { } if (this.session !== undefined) { this.sessionEventHandler.startSubscription(); + void this.showSessionWarnings(this.session); } void this.fetchSessions(); if (this.session !== undefined) { this.updateTerminalTitle(); } void this.refreshSkillCommands(this.session); + void this.refreshPluginCommands(this.session); + } + + private async showSessionWarnings(session: Session): Promise<void> { + try { + const warnings = await session.getSessionWarnings(); + if (this.session !== session) return; + for (const warning of warnings) { + const severity = warning.severity === 'error' ? 'error' : 'warning'; + this.showStatus(`Warning: ${warning.message}`, severity); + } + } catch { + // Best-effort: startup must not block on warning retrieval. + } } private async showTmuxKeyboardWarningIfNeeded(): Promise<void> { @@ -557,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) { @@ -593,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); @@ -640,18 +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(); - await this.state.terminal.drainInput(); - this.state.ui.stop(); + // Restore the terminal even if closing the session / harness throws — a + // SIGTERM during a network or MCP shutdown must not leave the user stuck in + // raw mode with a hidden cursor. + try { + await this.closeSession('shutting down'); + await this.harness.close(); + } finally { + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.uninstallRainbowDance(); + try { + await this.state.terminal.drainInput(); + } catch { + // best effort — the terminal may already be dead (SIGHUP / EIO). + } + try { + this.state.ui.stop(); + } catch { + // best effort terminal restore. + } + } if (this.onExit) { await this.onExit(exitCode); } @@ -715,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; } @@ -755,16 +962,158 @@ export class KimiTUI { void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); } + handleInputModeChange(mode: 'prompt' | 'bash'): void { + this.setAppState({ inputMode: mode }); + this.updateEditorBorderHighlight(); + } + handleUserInput(text: string): void { + const wasBashMode = this.state.appState.inputMode === 'bash'; + if (wasBashMode) { + // A submit always exits bash mode (the `!` is consumed by this command). + this.state.editor.inputMode = 'prompt'; + this.handleInputModeChange('prompt'); + } if (text.trim().length === 0) return; if (this.state.appState.isReplaying) { this.showError('Cannot send input while session history is replaying.'); return; } - void this.persistInputHistory(text); + // Shell commands are stored with a leading `!` so ↑ recall can tell them + // apart from prompts and restore bash mode (see CustomEditor's mode-aware + // history navigation). The `!` is stripped again when the entry is recalled. + const historyText = wasBashMode ? `!${text}` : text; + void this.persistInputHistory(historyText); + if (wasBashMode) { + // Only one foreground action at a time: queue the shell command while + // another shell command is running or an agent turn is in progress. + if (this.state.appState.streamingPhase !== 'idle') { + this.enqueueMessage(text, undefined, 'bash'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + this.runShellCommandFromInput(text); + return; + } slashCommands.dispatchInput(this, text); } + private runShellCommandFromInput(command: string): void { + const session = this.session; + if (session === undefined) { + this.showError('No active session for shell command.'); + return; + } + // Echo the command locally (bash-input) with a `$` prompt. The agent also + // records it for resume; this is the live view. + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: currentTheme.fg('shellMode', `$ ${command}`), + bullet: '', + }); + // Create the live output entry up front. ShellRunComponent owns its own + // rendering (running card → final view) and is mutated in place as output + // streams in and on completion. + const commandId = nextTranscriptId(); + const outputEntry: TranscriptEntry = { + id: commandId, + kind: 'status', + turnId: undefined, + renderMode: 'plain', + content: '', + }; + const outputComponent = new ShellRunComponent(() => this.state.ui.requestRender()); + this.shellOutputStreams.set(commandId, { entry: outputEntry, component: outputComponent }); + this.state.transcriptEntries.push(outputEntry); + markTranscriptComponent(outputComponent, outputEntry); + this.state.transcriptContainer.addChild(outputComponent); + // Treat command execution as a streaming phase so input queues, the activity + // pane shows the moon spinner, and ctrl+b is enabled while it runs. + this.setAppState({ streamingPhase: 'shell' }); + this.state.ui.requestRender(); + + this.track('shell_command'); + + void session.runShellCommand(command, { commandId }).then( + ({ stdout, stderr, isError, backgrounded }) => { + this.finishShellOutput(commandId, stdout, stderr, isError, backgrounded); + }, + (error: unknown) => { + const message = formatErrorMessage(error); + this.finishShellOutput(commandId, '', message, true); + this.showError(`Shell command failed: ${message}`); + }, + ); + } + + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + const text = event.update.text ?? ''; + if (text.length === 0) return; + stream.component.append(text); + } + + handleShellStarted(event: { commandId: string; taskId: string }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + stream.taskId = event.taskId; + } + + cancelRunningShellCommand(): void { + const session = this.session; + if (session === undefined) return; + for (const commandId of this.shellOutputStreams.keys()) { + void session.cancelShellCommand(commandId).catch((error: unknown) => { + this.showError(`Failed to cancel shell command: ${formatErrorMessage(error)}`); + }); + } + } + + private finishShellOutput( + commandId: string, + stdout: string, + stderr: string, + isError?: boolean, + backgrounded?: boolean, + ): void { + const stream = this.shellOutputStreams.get(commandId); + if (stream === undefined) return; + if (backgrounded === true) { + // The command was moved to the background; detachRunningShellCommand owns + // the UI and the model notification, so there is nothing to render here. + return; + } + stream.component.finish(stdout, stderr, isError); + // Keep the transcript entry's metadata in sync for anything that reads it + // (export / copy). The component renders itself. + stream.entry.content = formatBashOutputForDisplay(stdout, stderr, isError); + this.shellOutputStreams.delete(commandId); + // When the last shell command finishes, leave the shell streaming phase, + // release one queued message (if any), and refresh the activity pane. + if (this.shellOutputStreams.size === 0) { + this.setAppState({ streamingPhase: 'idle' }); + this.drainOneQueuedMessage(); + } + } + + private drainOneQueuedMessage(): void { + const item = this.shiftQueuedMessage(); + if (item === undefined) return; + const session = this.session; + if (session === undefined) return; + if (item.mode === 'bash') { + this.runShellCommandFromInput(item.text); + } else { + this.sendQueuedMessage(session, item); + } + this.updateQueueDisplay(); + } + sendNormalUserInput(text: string): void { if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { @@ -791,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 && @@ -846,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, @@ -866,6 +1221,7 @@ export class KimiTUI { options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 ? options.imageAttachmentIds : undefined, + mode, }); this.track('input_queue'); } @@ -894,6 +1250,10 @@ export class KimiTUI { } sendQueuedMessage(session: Session, item: QueuedMessage): void { + 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, @@ -930,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 || @@ -949,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}`); }); @@ -1026,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; @@ -1039,6 +1447,9 @@ export class KimiTUI { setAppState(patch: Partial<AppState>): void { if (!hasPatchChanges(this.state.appState, patch)) return; + const additionalDirsChanged = + 'additionalDirs' in patch && + !sameStringArrays(this.state.appState.additionalDirs, patch.additionalDirs ?? []); const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; Object.assign(this.state.appState, patch); if ('planMode' in patch) this.updateEditorBorderHighlight(); @@ -1048,6 +1459,7 @@ export class KimiTUI { this.updateQueueDisplay(); this.sessionEventHandler.retryQueuedGoalPromotion(); } + if (additionalDirsChanged) this.setupAutocomplete(); this.state.ui.requestRender(); } @@ -1064,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 // ========================================================================= @@ -1080,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> { @@ -1096,6 +1517,7 @@ 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> { @@ -1103,7 +1525,7 @@ export class KimiTUI { 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, @@ -1113,6 +1535,7 @@ export class KimiTUI { sessionTitle: session.summary?.title ?? null, goal: goalResult.goal, }); + this.syncAdditionalDirs(session); } // Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed @@ -1181,6 +1604,7 @@ export class KimiTUI { for (const dispose of this.reverseRpcDisposers) { dispose(); } + this.reverseRpcDisposers.length = 0; } private registerSessionHandlers(session: Session): void { @@ -1283,6 +1707,7 @@ export class KimiTUI { this.updateTerminalTitle(); try { await this.refreshSkillCommands(this.session); + await this.refreshPluginCommands(this.session); } catch { /* keep the switched session usable even if dynamic skills fail */ } @@ -1300,6 +1725,7 @@ export class KimiTUI { this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } this.showStatus(statusMessage); + void this.showSessionWarnings(session); } async reloadCurrentSessionView(session: Session, statusMessage: string): Promise<void> { @@ -1319,6 +1745,7 @@ export class KimiTUI { this.updateTerminalTitle(); try { await this.refreshSkillCommands(session); + await this.refreshPluginCommands(session); } catch { /* keep the reloaded session usable even if dynamic skills fail */ } @@ -1328,6 +1755,7 @@ export class KimiTUI { this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } this.showStatus(statusMessage); + void this.showSessionWarnings(session); } async createNewSession(): Promise<void> { @@ -1359,12 +1787,14 @@ 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(); } @@ -1391,7 +1821,10 @@ export class KimiTUI { if (data.result === 'cancelled') { block.markCanceled(); } else { - block.markDone(data.tokensBefore, data.tokensAfter); + block.markDone(data.tokensBefore, data.tokensAfter, data.summary); + if (this.state.toolOutputExpanded) { + block.setExpanded(true); + } } return block; } @@ -1401,7 +1834,7 @@ 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, images); + return new UserMessageComponent(entry.content, images, entry.bullet); } case 'skill_activation': return new SkillActivationComponent( @@ -1409,6 +1842,11 @@ export class KimiTUI { entry.skillArgs, 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 ?? {}); case 'goal': @@ -1469,6 +1907,10 @@ export class KimiTUI { if (component) { markTranscriptComponent(component, entry); this.state.transcriptContainer.addChild(component); + } + const trimmed = this.trimTranscriptWindow(); + const merged = this.mergeCurrentTurnSteps(); + if (component || trimmed || merged) { this.state.ui.requestRender(); } } @@ -1477,7 +1919,12 @@ export class KimiTUI { request: ApprovalRequest, response: ApprovalResponse, ): void { - if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return; + if ( + request.toolName === 'ExitPlanMode' || + request.display.kind === 'plan_review' || + request.display.kind === 'goal_start' + ) + return; const parts: string[] = []; switch (response.decision) { case 'approved': @@ -1518,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 = []; @@ -1525,6 +1982,7 @@ export class KimiTUI { this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.disposeTranscriptChildren(); this.state.transcriptContainer.clear(); this.btwPanelController.clear(); this.clearTerminalInlineImages(); @@ -1532,6 +1990,237 @@ export class KimiTUI { this.state.todoPanelContainer.clear(); this.imageStore.clear(); this.renderWelcome(); + // Session resets (/new, /clear, session switch) want a pristine screen. + // Force a destructive full render: the renderer's collapse repaint + // intentionally preserves scrollback, which would leave the previous + // session's text above the welcome banner. + this.state.ui.requestRender(true); + } + + private isTurnBoundaryComponent(child: Component): boolean { + 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 { @@ -1566,6 +2255,9 @@ export class KimiTUI { spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`)); this.state.ui.requestRender(); }, + setLabel: (nextLabel) => { + spinner.setLabel(nextLabel); + }, }; } @@ -1589,6 +2281,23 @@ export class KimiTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); + const tipKind = loadingTipKind(effectiveMode); + // Pick a fresh loading tip when the loading kind changes. The same kind + // covers waiting/tool (both moon spinners) and any intermediate thinking + // phase, so a continuous burst of tool calls does not flip tips. Clear the + // cache only when there is no loading UI at all. + if (effectiveMode === 'idle' || effectiveMode === 'session' || effectiveMode === 'hidden') { + this.currentLoadingTip = undefined; + } else if ( + tipKind !== undefined && + (this.currentLoadingTip === undefined || this.currentLoadingTip.kind !== tipKind) + ) { + const previousTip = this.currentLoadingTip?.tip; + this.currentLoadingTip = { + kind: tipKind, + tip: pickRandomWorkingTip(previousTip)?.text, + }; + } this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode); const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}`; @@ -1620,6 +2329,7 @@ export class KimiTUI { new ActivityPaneComponent({ mode: 'waiting', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -1638,6 +2348,7 @@ export class KimiTUI { new ActivityPaneComponent({ mode: 'composing', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -1650,6 +2361,7 @@ export class KimiTUI { new ActivityPaneComponent({ mode: 'tool', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -1658,6 +2370,10 @@ export class KimiTUI { case 'session': { this.stopActivitySpinner(); this.syncAgentSwarmActivitySpinner(undefined); + // Keep a placeholder row so the activity area does not fully shrink + // when the spinner is removed at the end of streaming; combined with + // pi-tui's clamp, this avoids a destructive full redraw (viewport jump). + this.state.activityContainer.addChild(new Spacer(1)); break; } } @@ -1671,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; @@ -1697,20 +2418,157 @@ export class KimiTUI { toggleToolOutputExpansion(): void { this.state.toolOutputExpanded = !this.state.toolOutputExpanded; - for (const child of this.state.transcriptContainer.children) { - if (isExpandable(child)) { - child.setExpanded(this.state.toolOutputExpanded); + const children = this.state.transcriptContainer.children; + + // A component is expandable only if it sits at or after the start of the + // (totalTurns - expandTurns)-th turn — i.e. it belongs to one of the most + // recent `expandTurns` turns. Position-based so it also covers streaming + // components that have no entry in the metadata map. + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + const expandCutoff = + TRANSCRIPT_EXPAND_TURNS <= 0 + ? children.length + : boundaries.length > TRANSCRIPT_EXPAND_TURNS + ? boundaries[boundaries.length - TRANSCRIPT_EXPAND_TURNS]! + : 0; + + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + if (!isExpandable(child)) continue; + child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); + } + // Expanding/collapsing shifts content above the viewport; the clamped + // differential render would paint a second copy below the stale one in + // scrollback. This is a deliberate user action (like /clear), so do a + // destructive full render: scrollback holds exactly one copy and the + // expanded output can be read by scrolling up. + this.state.ui.requestRender(true); + } + + toggleTodoPanelExpansion(): void { + this.state.todoPanel.toggleExpanded(); + this.state.ui.requestRender(); + } + + private async detachRunningShellCommand(): Promise<void> { + // Only one `!` command runs at a time (input is queued while busy). + const next = this.shellOutputStreams.entries().next(); + if (next.done) { + this.showDetachHint('No shell command running.'); + return; + } + const [commandId, stream] = next.value; + if (stream.taskId === undefined) { + this.showDetachHint('Command is still starting — try again.'); + return; + } + const session = this.session; + if (session === undefined) return; + try { + const info = await session.detachBackgroundTask(stream.taskId); + if (info === undefined) { + this.showDetachHint('Command already finished.'); + return; + } + } catch (error) { + this.showError(`Failed to move to background: ${formatErrorMessage(error)}`); + return; + } + // Finalize the card as backgrounded and drop the stream so the eventual + // runShellCommand resolution (which carries background metadata) is a no-op + // instead of overwriting this view. + stream.component.finishBackgrounded(); + stream.entry.content = 'Moved to background.'; + this.shellOutputStreams.delete(commandId); + // The backgrounded command's notification turn (started by agent-core via + // appendSystemReminderAndNotify) owns the streaming phase and drains the + // queue when it completes, so we intentionally leave both untouched here. + this.showDetachHint('Moved to background. /tasks to view.'); + } + + async detachCurrentForegroundTask(): Promise<void> { + // A running `!` shell command takes priority over agent foreground tasks. + if (this.shellOutputStreams.size > 0) { + await this.detachRunningShellCommand(); + return; + } + + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + let tasks: readonly BackgroundTaskInfo[]; + try { + // activeOnly defaults to true; foreground running tasks are non-terminal + // and therefore included. We filter to `detached === false` ourselves. + tasks = await session.listBackgroundTasks(); + } catch (error) { + this.showError(`Failed to list tasks: ${formatErrorMessage(error)}`); + return; + } + + const targets = pickForegroundTasks(tasks); + if (targets.length === 0) { + this.showDetachHint('No foreground task running.'); + return; + } + + let detached = 0; + let alreadyFinished = 0; + for (const target of targets) { + try { + const info = await session.detachBackgroundTask(target.taskId); + if (info === undefined) alreadyFinished++; + else detached++; + } catch (error) { + this.showError(`Failed to detach ${target.taskId}: ${formatErrorMessage(error)}`); } } + + let hint: string; + if (detached === 0 && alreadyFinished > 0) { + hint = alreadyFinished === 1 ? 'Task already finished.' : 'Tasks already finished.'; + } else if (detached === targets.length) { + hint = + detached === 1 ? 'Moved 1 task to background.' : `Moved ${detached} tasks to background.`; + } else { + hint = `Moved ${detached} of ${targets.length} tasks to background.`; + } + if (detached > 0) hint = `${hint} /tasks to view.`; + this.showDetachHint(hint); + } + + /** Show a one-shot footer hint that auto-clears after DETACH_HINT_DISPLAY_MS. */ + private showDetachHint(hint: string): void { + if (this.detachHintClearTimer !== undefined) { + clearTimeout(this.detachHintClearTimer); + this.detachHintClearTimer = undefined; + } + this.state.footer.setTransientHint(hint); + this.detachHintClearTimer = setTimeout(() => { + this.detachHintClearTimer = undefined; + // Don't clobber a newer transient hint (e.g. the exit-confirmation + // prompt) that took over while this timer was pending. + if (this.state.footer.getTransientHint() !== hint) return; + this.state.footer.setTransientHint(null); + this.state.ui.requestRender(); + }, DETACH_HINT_DISPLAY_MS); this.state.ui.requestRender(); } updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); - const highlighted = this.state.appState.planMode || trimmed.startsWith('/'); + const isBash = this.state.appState.inputMode === 'bash'; + const highlighted = this.state.appState.planMode || isBash || trimmed.startsWith('/'); this.state.editor.borderHighlighted = highlighted; - this.state.editor.borderColor = (s: string) => - currentTheme.fg(highlighted ? 'primary' : 'border', s); + // Shell mode gets its own hue; plan-mode and slash context stay primary. + const borderToken = isBash ? 'shellMode' : highlighted ? 'primary' : 'border'; + this.state.editor.borderColor = (s: string) => currentTheme.fg(borderToken, s); this.state.ui.requestRender(); } @@ -1825,7 +2683,18 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); - this.state.ui.requestRender(); + // Measure overflow against the restored tree (editor mounted), not the tall + // panel just removed — otherwise a short session with a tall panel looks like + // it overflows and we take a full clear/home that yanks the editor to the top. + // Treat an exact one-screen fill as overflowing too: a full redraw is safe + // there (no blank tail) and clears a stale viewport offset after a shrink. + const { columns, rows } = this.state.terminal; + const overflowsViewport = this.state.ui.render(columns).length >= rows; + // Force a full re-render after replacing a tall panel with the shorter editor: + // differential rendering leaves the editor shifted up when the bottom-anchored + // region shrinks in place. Skip under tmux (its own reflow handles the shrink) + // and when content fits on one screen (a full clear would pull the editor up). + this.state.ui.requestRender(!this.state.terminalState.insideTmux && overflowsViewport); } restoreInputText(text: string): void { @@ -1967,6 +2836,10 @@ export class KimiTUI { this.restoreEditor(); } + openUndoSelector(): void { + void slashCommands.handleUndoCommand(this, ''); + } + private mountSessionPicker(options: { readonly onCancel: () => void; readonly onCtrlC?: () => void; 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/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index 215c54bbb..66f9819c3 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -71,6 +71,12 @@ export interface ColorPalette { /** User message: bullet & text, skill-activation name. The one role colour * with its own hue — assistant/thinking/status bullets reuse text/textDim. */ roleUser: 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 = { @@ -97,6 +103,7 @@ export const darkColors: ColorPalette = { diffMeta: '#888888', roleUser: '#FFCB6B', + shellMode: '#BD93F9', }; export const lightColors: ColorPalette = { @@ -123,6 +130,7 @@ export const lightColors: ColorPalette = { diffMeta: '#5F5F5F', roleUser: '#9A4A00', + shellMode: '#7C3AED', }; export type ResolvedTheme = 'dark' | 'light'; 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 dec6ab253..1b53bce0c 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -8,7 +8,7 @@ * instances reads the *current* palette via the singleton. */ -import type { MarkdownTheme, EditorTheme } from '@earendil-works/pi-tui'; +import type { MarkdownTheme, EditorTheme } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { highlight, supportsLanguage } from 'cli-highlight'; @@ -22,7 +22,8 @@ import { currentTheme } from './theme'; // 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(): MarkdownTheme { +export function createMarkdownTheme(options?: { transient?: boolean }): MarkdownTheme { + const transient = options?.transient === true; const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); return { @@ -44,6 +45,8 @@ export function createMarkdownTheme(): MarkdownTheme { 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'; diff --git a/apps/kimi-code/src/tui/theme/theme-schema.json b/apps/kimi-code/src/tui/theme/theme-schema.json index 5e320992d..a411088f9 100644 --- a/apps/kimi-code/src/tui/theme/theme-schema.json +++ b/apps/kimi-code/src/tui/theme/theme-schema.json @@ -45,7 +45,8 @@ "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" } + "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", diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 6a9594f01..d9665c9e3 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -2,7 +2,7 @@ import { Container, ProcessTerminal, TUI, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { FooterComponent } from './components/chrome/footer'; import { GutterContainer } from './components/chrome/gutter-container'; @@ -10,6 +10,7 @@ import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; import { TodoPanelComponent } from './components/chrome/todo-panel'; import type { SessionRow } from './components/dialogs/session-picker'; import { CustomEditor } from './components/editor/custom-editor'; +import { DEFAULT_TUI_CONFIG } from './config'; import { CHROME_GUTTER } from './constant/rendering'; import type { TasksBrowserState } from './controllers/tasks-browser'; import { currentTheme, type Theme } from './theme'; @@ -51,6 +52,13 @@ export interface TUIState { tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; queuedMessages: QueuedMessage[]; + /** + * True while a queued user message has been shifted out of + * {@link queuedMessages} but its deferred send has not run yet. The queue + * looks empty during this window, so queued-goal promotion must also check + * this flag to avoid starting a goal ahead of the user's earlier message. + */ + queuedMessageDispatchPending: boolean; swarmModeEntry: 'manual' | 'task' | undefined; } @@ -68,7 +76,9 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const editor = new CustomEditor(ui); + const editor = new CustomEditor(ui, { + disablePasteBurst: initialAppState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + }); const footer = new FooterComponent({ ...initialAppState }, () => { ui.requestRender(); }); @@ -100,6 +110,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { tasksBrowser: undefined, externalEditorRunning: false, queuedMessages: [], + queuedMessageDispatchPending: false, swarmModeEntry: undefined, }; } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6b407f777..d895b11e1 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -5,6 +5,7 @@ import type { PermissionMode, ProviderConfig, PromptPart, + ThinkingEffort, ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; @@ -26,21 +27,29 @@ export interface BannerState { export interface AppState { model: string; workDir: string; + additionalDirs: readonly string[]; sessionId: string; permissionMode: PermissionMode; planMode: boolean; + /** 'bash' when the editor is in `!` shell-command mode. */ + inputMode: 'prompt' | 'bash'; swarmMode: boolean; - thinking: boolean; + /** Live thinking effort of the active session (e.g. 'off', 'on', 'high'); + * mirrors the runtime. The single source of truth for the thinking state in + * the TUI. */ + thinkingEffort: ThinkingEffort; contextUsage: number; contextTokens: number; maxContextTokens: number; isCompacting: boolean; isReplaying: boolean; - streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; + streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; streamingStartTime: number; theme: 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>; @@ -110,6 +119,7 @@ export interface BackgroundAgentStatusData { export interface CompactionTranscriptData { readonly result?: 'cancelled'; + readonly summary?: string; readonly tokensBefore?: number; readonly tokensAfter?: number; readonly instruction?: string; @@ -136,19 +146,37 @@ export type TranscriptEntryKind = | 'thinking' | 'status' | 'skill_activation' + | 'plugin_command' | 'cron' | 'goal'; export type SkillActivationTrigger = 'user-slash' | 'model-tool' | 'nested-skill'; +export interface PluginCommandTranscriptData { + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly args?: string; + readonly trigger: 'user-slash'; +} + export interface TranscriptEntry { id: string; kind: TranscriptEntryKind; turnId?: string; renderMode: 'markdown' | 'plain' | 'notice'; content: string; + /** + * True only for entries holding real model-authored text (created by the + * assistant stream). Derived cards — hook results, goal completions, goal + * reminders — share kind 'assistant' but are not replies, so /copy must + * skip them. + */ + modelText?: boolean; 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; @@ -159,6 +187,7 @@ export interface TranscriptEntry { skillName?: string; skillArgs?: string; skillTrigger?: SkillActivationTrigger; + pluginCommandData?: PluginCommandTranscriptData; } export type LivePaneMode = @@ -179,6 +208,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 = { @@ -215,6 +259,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/event-payload.ts b/apps/kimi-code/src/tui/utils/event-payload.ts index 2508996ea..088409ffd 100644 --- a/apps/kimi-code/src/tui/utils/event-payload.ts +++ b/apps/kimi-code/src/tui/utils/event-payload.ts @@ -1,7 +1,4 @@ -import { - isKimiError, - type KimiErrorPayload, -} from '@moonshot-ai/kimi-code-sdk'; +import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; import { STREAMING_ARGS_FIELD_RE, @@ -103,9 +100,13 @@ export function formatErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export function formatErrorPayload( - error: Pick<KimiErrorPayload, 'code' | 'message' | 'details'>, -): string { +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}`; 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 index f4c802b4a..1e59b27f9 100644 --- a/apps/kimi-code/src/tui/utils/goal-completion.ts +++ b/apps/kimi-code/src/tui/utils/goal-completion.ts @@ -1,5 +1,7 @@ import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; +import { formatTokenCount } from '#/utils/usage/usage-format'; + interface GoalCompletionStats { readonly terminalReason?: string | undefined; readonly turnsUsed: number; @@ -19,7 +21,7 @@ export function buildGoalCompletionMessage(goal: GoalSnapshot): string { 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.`; + const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokenCount(goal.tokensUsed)} tokens.`; return `${head}\n${stats}`; } @@ -32,9 +34,3 @@ function formatElapsed(ms: number): string { 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/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index b1478697c..99441472b 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -32,6 +32,7 @@ export interface ReplayRenderContext { toolCalls: Map<string, ToolCallBlockData>; completedToolCallIds: Set<string>; skillActivationIds: Set<string>; + pluginCommandActivationIds: Set<string>; suppressNextPlanModeOffNotice: boolean; } @@ -42,6 +43,14 @@ export interface SkillActivationProjection { readonly trigger: SkillActivationTrigger; } +export interface PluginCommandProjection { + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string; + readonly trigger: 'user-slash'; +} + export interface ReplayBackgroundProjection { readonly backgroundAgentMetadata: ReadonlyMap<string, BackgroundAgentMetadata>; } @@ -114,6 +123,7 @@ export function createReplayRenderContext(): ReplayRenderContext { toolCalls: new Map(), completedToolCallIds: new Set(), skillActivationIds: new Set(), + pluginCommandActivationIds: new Set(), suppressNextPlanModeOffNotice: false, }; } @@ -135,7 +145,7 @@ export function replayEntry( kind: TranscriptEntry['kind'], content: string, renderMode: TranscriptEntry['renderMode'], - extras: { detail?: string } = {}, + extras: { detail?: string; bullet?: string } = {}, ): TranscriptEntry { return { id: nextTranscriptId(), @@ -144,6 +154,7 @@ export function replayEntry( renderMode, content, detail: extras.detail, + bullet: extras.bullet, }; } @@ -212,6 +223,19 @@ export function skillActivationFromOrigin( }; } +export function pluginCommandFromOrigin( + origin: PromptOrigin | undefined, +): PluginCommandProjection | undefined { + if (origin?.kind !== 'plugin_command') return undefined; + return { + activationId: origin.activationId, + pluginId: origin.pluginId, + commandName: origin.commandName, + commandArgs: origin.commandArgs, + trigger: origin.trigger, + }; +} + export function formatHookResultMessageForTranscript( text: string, fallbackEvent: string, @@ -250,6 +274,11 @@ function isReplayUserTurnRecord(record: AgentReplayRecord): boolean { return true; case 'skill_activation': return message.origin.trigger === 'user-slash'; + case 'plugin_command': + return message.origin.trigger === 'user-slash'; + case 'shell_command': + // A `!` command's input is a user-turn anchor; its output is not. + return message.origin.phase === 'input'; case 'background_task': case 'compaction_summary': case 'cron_job': diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts index eaddeae65..d475313ae 100644 --- a/apps/kimi-code/src/tui/utils/plugin-source-label.ts +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -50,6 +50,26 @@ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel { } } +/** + * Returns true only for install sources that are unambiguously Kimi-built + * official plugins — an https URL under the official Kimi CDN plugin path. + * Everything else (local paths, GitHub repos, curated or third-party URLs) + * is treated as unofficial and should be confirmed before install. + */ +export function isOfficialPluginSource(source: string): boolean { + const trimmed = source.trim(); + if (!trimmed.startsWith('https://')) return false; + try { + const url = new URL(trimmed); + return ( + url.hostname === 'code.kimi.com' && + url.pathname.startsWith('/kimi-code/plugins/official/') + ); + } catch { + return false; + } +} + function hostFromUrl(raw: string): string | undefined { try { const url = new URL(raw); diff --git a/apps/kimi-code/src/tui/utils/printable-key.ts b/apps/kimi-code/src/tui/utils/printable-key.ts index 7daa36ab6..d165d52a4 100644 --- a/apps/kimi-code/src/tui/utils/printable-key.ts +++ b/apps/kimi-code/src/tui/utils/printable-key.ts @@ -20,7 +20,7 @@ * `tui/components/**` and rejects bare-literal comparisons. */ -import { decodeKittyPrintable } from '@earendil-works/pi-tui'; +import { decodeKittyPrintable } from '@moonshot-ai/pi-tui'; export function printableChar(data: string): string { return decodeKittyPrintable(data) ?? data; diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index a25c4b7cf..0801575f9 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -1,565 +1,46 @@ import { - KIMI_CODE_PLATFORM_ID, - KIMI_CODE_PROVIDER_NAME, - applyManagedKimiCodeConfig, - applyOpenPlatformConfig, - applyCustomRegistryProvider, - fetchCustomRegistry, - fetchManagedKimiCodeModels, - fetchOpenPlatformModels, - filterModelsByPrefix, - getOpenPlatformById, - isOpenPlatformId, - removeCustomRegistryProvider, - resolveKimiCodeRuntimeAuth, - type CustomRegistrySource, - type ManagedKimiConfigShape, + refreshProviderModels, + type ProviderChange, + type RefreshProviderOptions, + type RefreshProviderScope, + type RefreshResult, } from '@moonshot-ai/kimi-code-oauth'; -import type { KimiConfig, KimiConfigPatch, ModelAlias, OAuthRef, ProviderConfig } from '@moonshot-ai/kimi-code-sdk'; +import type { KimiConfig, KimiConfigPatch, OAuthRef } from '@moonshot-ai/kimi-code-sdk'; +/** + * CLI-side host for provider-model refresh. Kept on the SDK's full config types + * so existing TUI callers (and tests) don't change; the daemon uses the oauth + * package's `ManagedKimiConfigShape`-typed host directly. + */ export interface RefreshProviderHost { getConfig(): Promise<KimiConfig>; removeProvider(providerId: string): Promise<KimiConfig>; 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 }>; -} - -export type RefreshProviderScope = 'all' | 'oauth'; - -export interface RefreshProviderOptions { - readonly scope?: RefreshProviderScope; -} - -function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySource | undefined { - const source = provider.source; - if (typeof source !== 'object' || source === null) return undefined; - const candidate = source; - 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 customRegistrySourceKey(source: CustomRegistrySource): string { - return JSON.stringify([source.url]); -} - -function customRegistrySourceCredentialKey(source: CustomRegistrySource): string { - return JSON.stringify([source.url, source.apiKey]); -} - -async function fetchCustomRegistryFromSources( - sources: readonly CustomRegistrySource[], -): Promise<{ - readonly entries: Awaited<ReturnType<typeof fetchCustomRegistry>>; - readonly source: CustomRegistrySource; -}> { - let lastError: unknown; - for (const source of sources) { - try { - return { - entries: await fetchCustomRegistry(source), - source, - }; - } catch (error) { - lastError = error; - } - } - if (lastError instanceof Error) throw lastError; - if (typeof lastError === 'string') throw new Error(lastError); - throw new Error('No custom registry sources configured.'); -} - -function asManaged(config: KimiConfig): ManagedKimiConfigShape { - return config as unknown as ManagedKimiConfigShape; -} - -function collectModelIdsForAliases(config: KimiConfig, aliasKeys: ReadonlySet<string>): Set<string> { - const ids = new Set<string>(); - for (const aliasKey of aliasKeys) { - const alias = config.models?.[aliasKey]; - if (alias !== undefined && alias.model.length > 0) { - ids.add(alias.model); - } - } - return ids; -} - -function providerAliasKeys(config: KimiConfig, providerId: string): Set<string> { - const keys = new Set<string>(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId) keys.add(alias); - } - return keys; -} - -function generatedProviderAliasKeys( - config: KimiConfig, - providerId: string, - aliasPrefix: string, -): Set<string> { - const keys = new Set<string>(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId && alias.startsWith(aliasPrefix)) { - keys.add(alias); - } - } - return keys; -} - -function computeChanges(oldIds: Set<string>, newIds: Set<string>): { added: number; removed: number } { - let added = 0; - for (const id of newIds) { - if (!oldIds.has(id)) added++; - } - let removed = 0; - for (const id of oldIds) { - if (!newIds.has(id)) removed++; - } - return { added, removed }; -} - -interface ProviderModelSnapshot { - readonly alias: string; - readonly model: ModelAlias; -} - -// Compare the full model metadata for the relevant aliases, not just model IDs: -// a registry can change capabilities (e.g. enabling reasoning) without changing -// any model ID. Spreading the whole `ModelAlias` keeps this in sync with the -// schema automatically; only `capabilities` needs normalizing because its order -// is not meaningful. -function providerModelSnapshot( - config: KimiConfig, - providerId: string, - aliasKeys: ReadonlySet<string>, -): string { - const snapshots: ProviderModelSnapshot[] = []; - for (const alias of aliasKeys) { - const model = config.models?.[alias]; - if (model === undefined || model.provider !== providerId) continue; - snapshots.push({ - alias, - model: { - ...model, - capabilities: model.capabilities === undefined ? undefined : model.capabilities.toSorted(), - }, - }); - } - snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); - return JSON.stringify(snapshots); -} - -function providerModelsEqual( - config: KimiConfig, - nextConfig: KimiConfig, - providerId: string, - aliasKeys: ReadonlySet<string>, -): boolean { - return ( - providerModelSnapshot(config, providerId, aliasKeys) === - providerModelSnapshot(nextConfig, providerId, aliasKeys) - ); -} - -function providerConfigSnapshot(config: KimiConfig, providerId: string): string { - return JSON.stringify(config.providers[providerId] ?? null); -} - -function providerConfigEqual(config: KimiConfig, nextConfig: KimiConfig, providerId: string): boolean { - return providerConfigSnapshot(config, providerId) === providerConfigSnapshot(nextConfig, providerId); -} - -function providerRefreshAliasKeys( - config: KimiConfig, - nextConfig: KimiConfig, - providerId: string, - aliasPrefix: string, -): Set<string> { - const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix); - for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key); - return keys; -} - -function preserveUserProviderAliases( - config: KimiConfig, - providerId: string, - refreshedAliasKeys: ReadonlySet<string>, -): Record<string, ModelAlias> { - const preserved: Record<string, ModelAlias> = {}; - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider !== providerId || refreshedAliasKeys.has(alias)) continue; - preserved[alias] = structuredClone(model); - } - return preserved; -} - -function restoreProviderAliases(config: KimiConfig, aliases: Record<string, ModelAlias>): void { - if (Object.keys(aliases).length === 0) return; - config.models = { - ...config.models, - ...aliases, - }; -} - -function restoreDefaultSelection( - config: KimiConfig, - defaultModel: string | undefined, - defaultThinking: boolean | undefined, -): void { - if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; - config.defaultModel = defaultModel; - // A refresh may have just learned that the default model cannot disable - // thinking — never restore a stale thinking-off selection onto it. - const capabilities = config.models[defaultModel]?.capabilities ?? []; - config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; -} - -// `apply*` may leave `defaultModel` pointing at an alias that no longer exists -// (e.g. the previously-selected model was dropped from the registry). The host's -// `setConfig` deep-merge cannot clear a key, so the matching `removeProvider` -// call handles disk cleanup while this drops the dangling reference in memory. -function clampDanglingDefault(config: KimiConfig): void { - if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { - config.defaultModel = undefined; - config.defaultThinking = undefined; - } -} - -function clearDefaultThinkingWhenDefaultRemoved( - config: KimiConfig, - previousDefaultModel: string | undefined, -): void { - if (previousDefaultModel !== undefined && config.defaultModel === undefined) { - config.defaultThinking = undefined; - } -} - -function pickDefaultModel(config: KimiConfig, providerId: string, models: Array<{ id: string }>): string { - const firstModel = models[0]; - if (firstModel === undefined) return ''; - - const existingDefault = config.defaultModel; - if (existingDefault !== undefined) { - const alias = config.models?.[existingDefault]; - if (alias !== undefined && alias.provider === providerId) { - const stillAvailable = models.find((m) => m.id === alias.model); - if (stillAvailable !== undefined) { - return stillAvailable.id; - } - } - } - return firstModel.id; -} +export 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> { - const changed: ProviderChange[] = []; - const unchanged: string[] = []; - const failed: Array<{ provider: string; reason: string }> = []; - const scope = options.scope ?? 'all'; - - let config = await host.getConfig(); - - // ------------------------------------------------------------------------- - // 1. Managed Kimi Code (OAuth) - // ------------------------------------------------------------------------- - const managedProvider = config.providers[KIMI_CODE_PROVIDER_NAME]; - if ( - managedProvider !== undefined && - managedProvider.type === 'kimi' && - managedProvider.oauth !== undefined - ) { - try { - const auth = resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: managedProvider.baseUrl, - configuredOAuthRef: managedProvider.oauth, - }); - const accessToken = await host.resolveOAuthToken(KIMI_CODE_PROVIDER_NAME, auth.oauthRef); - const models = await fetchManagedKimiCodeModels({ - accessToken, - baseUrl: auth.baseUrl, - }); - if (models.length > 0) { - const next = structuredClone(config); - applyManagedKimiCodeConfig(asManaged(next), { - models, - baseUrl: auth.baseUrl, - oauthKey: auth.oauthRef.key, - oauthHost: auth.oauthRef.oauthHost, - preserveDefaultModel: true, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - KIMI_CODE_PROVIDER_NAME, - `${KIMI_CODE_PLATFORM_ID}/`, - ); - restoreProviderAliases( - next, - preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), - ); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - - if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { - unchanged.push(KIMI_CODE_PROVIDER_NAME); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(KIMI_CODE_PROVIDER_NAME); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId: KIMI_CODE_PROVIDER_NAME, - providerName: 'Kimi Code', - added, - removed, - }); - } - } - } catch (error) { - failed.push({ - provider: KIMI_CODE_PROVIDER_NAME, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - - if (scope === 'oauth') { - return { changed, unchanged, failed }; - } - - // ------------------------------------------------------------------------- - // 2. Open Platforms (moonshot-cn, moonshot-ai, …) - // ------------------------------------------------------------------------- - const openPlatformIds = Object.keys(config.providers).filter((id) => isOpenPlatformId(id)); - for (const providerId of openPlatformIds) { - const platform = getOpenPlatformById(providerId); - if (platform === undefined) continue; - - const providerConfig = config.providers[providerId]; - if (providerConfig === undefined) continue; - const apiKey = providerConfig.apiKey; - if (typeof apiKey !== 'string' || apiKey.length === 0) continue; - - try { - let models = await fetchOpenPlatformModels(platform, apiKey); - models = filterModelsByPrefix(models, platform); - if (models.length === 0) continue; - - const selectedModelId = pickDefaultModel(config, providerId, models); - const selectedModel = models.find((m) => m.id === selectedModelId); - if (selectedModel === undefined) continue; - const next = structuredClone(config); - applyOpenPlatformConfig(asManaged(next), { - platform, - models, - selectedModel, - thinking: false, - apiKey, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - providerId, - `${providerId}/`, - ); - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - - if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { - unchanged.push(providerId); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(providerId); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId, - providerName: platform.name, - added, - removed, - }); - } - } catch (error) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - - // ------------------------------------------------------------------------- - // 3. Custom Registry providers (grouped by URL, with API-key candidates) - // ------------------------------------------------------------------------- - const customSources = new Map< - string, + return refreshProviderModels( { - readonly sources: CustomRegistrySource[]; - readonly sourceKeys: Set<string>; - readonly 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 = customRegistrySourceKey(source); - const sourceKey = customRegistrySourceCredentialKey(source); - const entry = customSources.get(key); - if (entry !== undefined) { - if (!entry.sourceKeys.has(sourceKey)) { - entry.sources.push(source); - entry.sourceKeys.add(sourceKey); - } - entry.providerIds.push(providerId); - } else { - customSources.set(key, { - sources: [source], - sourceKeys: new Set([sourceKey]), - providerIds: [providerId], - }); - } - } - - for (const { sources, providerIds } of customSources.values()) { - try { - const { entries, source } = await fetchCustomRegistryFromSources(sources); - // Build the whole batch on one clone so that several changed providers - // from the same source do not overwrite each other's aliases, and so the - // config we compare is exactly the config we persist. - const next = structuredClone(config); - const changedProviders: Array<{ - readonly providerId: string; - readonly providerName: string; - readonly added: number; - readonly removed: number; - }> = []; - const providersToRemoveBeforeSet = new Set<string>(); - let hasUnreportedConfigChange = false; - const remoteEntries = Object.values(entries); - const remoteEntriesByProviderId = new Map( - remoteEntries.map((entry) => [entry.id, entry]), - ); - const providerIdsToSync = new Set(providerIds); - for (const entry of remoteEntries) providerIdsToSync.add(entry.id); - - for (const providerId of providerIdsToSync) { - const entry = remoteEntriesByProviderId.get(providerId); - if (entry === undefined) { - const oldIds = collectModelIdsForAliases(config, providerAliasKeys(config, providerId)); - removeCustomRegistryProvider(asManaged(next), providerId); - changedProviders.push({ - providerId, - providerName: providerId, - added: 0, - removed: oldIds.size, - }); - providersToRemoveBeforeSet.add(providerId); - continue; - } - - const existed = config.providers[providerId] !== undefined; - applyCustomRegistryProvider(asManaged(next), entry, source); - const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, `${providerId}/`); - if (existed) { - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - } - - if ( - existed && - providerModelsEqual(config, next, providerId, refreshedAliasKeys) && - providerConfigEqual(config, next, providerId) - ) { - unchanged.push(providerId); - } else if (existed && providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { - unchanged.push(providerId); - providersToRemoveBeforeSet.add(providerId); - hasUnreportedConfigChange = true; - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - changedProviders.push({ - providerId, - providerName: entry.name || providerId, - added, - removed, - }); - if (existed) providersToRemoveBeforeSet.add(providerId); - } - } - - if (changedProviders.length > 0 || hasUnreportedConfigChange) { - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - for (const providerId of providersToRemoveBeforeSet) { - await host.removeProvider(providerId); - } - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - for (const change of changedProviders) { - changed.push({ - providerId: change.providerId, - providerName: change.providerName, - added: change.added, - removed: change.removed, - }); - } - } - } catch (error) { - for (const providerId of providerIds) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - } - - return { changed, unchanged, failed }; + 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 ab6f1bff7..c71f41df9 100644 --- a/apps/kimi-code/src/tui/utils/terminal-notification.ts +++ b/apps/kimi-code/src/tui/utils/terminal-notification.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import { BEL, ESC, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH, ST } from '#/tui/constant/terminal'; import type { TUIState } from '#/tui/tui-state'; diff --git a/apps/kimi-code/src/tui/utils/thinking-config.ts b/apps/kimi-code/src/tui/utils/thinking-config.ts new file mode 100644 index 000000000..2e8411ef9 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/thinking-config.ts @@ -0,0 +1,36 @@ +import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; + +/** Whether a thinking effort represents "thinking enabled" (anything but 'off'). */ +export function isThinkingOn(effort: ThinkingEffort): boolean { + return effort !== 'off'; +} + +/** + * Project a thinking effort to the `[thinking]` config patch persisted to + * config.toml. `'off'` disables thinking; a concrete effort enables thinking + * and records it as the global effort preference. `'on'` is the boolean-model + * on-signal rather than a declared effort, so it only persists `enabled` — + * boolean models resolve back to `'on'` at runtime via `defaultThinkingEffortFor`. + */ +export function thinkingEffortToConfig(effort: ThinkingEffort): { + enabled: boolean; + effort?: string; +} { + if (effort === 'off') return { enabled: false }; + if (effort === 'on') return { enabled: true }; + return { enabled: true, effort }; +} + +/** + * Inverse of {@link thinkingEffortToConfig}: derive the runtime thinking effort + * to activate a model with from the persisted `[thinking]` config. Returns + * `'off'` when thinking is disabled, the configured concrete effort when set, + * and `undefined` when thinking is enabled without a concrete effort so the + * model's own default applies. + */ +export function thinkingEffortFromConfig( + config: { enabled?: boolean; effort?: string } | undefined, +): ThinkingEffort | undefined { + if (config?.enabled === false) return 'off'; + return config?.effort; +} diff --git a/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts index 12151958a..94cb45693 100644 --- a/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts +++ b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import type { TranscriptEntry } from '../types'; diff --git a/apps/kimi-code/src/tui/utils/transcript-window.ts b/apps/kimi-code/src/tui/utils/transcript-window.ts new file mode 100644 index 000000000..ed0083b6f --- /dev/null +++ b/apps/kimi-code/src/tui/utils/transcript-window.ts @@ -0,0 +1,109 @@ +/** + * Sliding window for the TUI transcript. + * + * The transcript grows unbounded as the conversation goes on. To keep the TUI + * responsive and bounded, we only keep the most recent N *turns* (a turn = a + * user prompt plus everything the assistant does in response, identified by a + * shared `turnId`), and destroy older turns wholesale (component + entry). + * + * All threshold logic here is pure so it can be unit-tested in isolation; the + * constants are the production defaults passed in by the TUI. + */ + +import type { TranscriptEntry } from '../types'; + +/** + * Read a non-negative integer env var, falling back to `fallback` when it is + * unset, empty, negative, or not an integer. `0` is a valid value (call sites + * treat it as "feature disabled"). + */ +export function readEnvInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) return fallback; + return value; +} + +/** Master switch for the sliding window. */ +export const TRANSCRIPT_WINDOW_ENABLED = true; + +/** Keep the most recent N turns. `0` disables trimming. */ +export const TRANSCRIPT_MAX_TURNS = readEnvInt('KIMI_CODE_TUI_MAX_TURNS', 15); + +/** Only the most recent E turns are allowed to expand (Ctrl+O). `0` disables expanding. */ +export const TRANSCRIPT_EXPAND_TURNS = readEnvInt('KIMI_CODE_TUI_EXPAND_TURNS', 3); + +/** Only trim once the window exceeds maxTurns by this much (avoids churn). */ +export const TRANSCRIPT_HYSTERESIS = readEnvInt('KIMI_CODE_TUI_HYSTERESIS', 5); + +/** Keep this many recent steps untouched inside a turn; older steps are merged into a summary. `0` disables merging. */ +export const TRANSCRIPT_KEEP_RECENT_STEPS = readEnvInt('KIMI_CODE_TUI_KEEP_RECENT_STEPS', 30); + +export interface TranscriptTurn { + readonly turnId: string | undefined; + readonly entries: TranscriptEntry[]; +} + +/** + * Group consecutive entries into turns by `turnId`. Entries with the same + * non-undefined `turnId` that are adjacent belong to the same turn. + * + * Entries with an undefined `turnId` are buffered and attached to the *next* + * defined turn. This matters because a user message is appended (with + * `turnId: undefined`) before its turn actually starts, so without this + * buffering every user message would become its own single-entry turn at the + * front and get trimmed first. Any undefined entries left at the tail (no + * following turn) become their own turn. + */ +export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[] { + const turns: TranscriptTurn[] = []; + let current: TranscriptTurn | undefined; + let pendingUndefined: TranscriptEntry[] = []; + + for (const entry of entries) { + const turnId = entry.turnId; + if (turnId === undefined) { + pendingUndefined.push(entry); + continue; + } + if (current !== undefined && current.turnId === turnId) { + current.entries.push(entry); + } else { + current = { turnId, entries: [...pendingUndefined, entry] }; + pendingUndefined = []; + turns.push(current); + } + } + + if (pendingUndefined.length > 0) { + turns.push({ turnId: undefined, entries: pendingUndefined }); + } + + return turns; +} + +/** + * Decide which entries to destroy so the remaining turns fit within + * `maxTurns`. Returns an empty set when the turn count is within + * `maxTurns + hysteresis`. Oldest turns are removed first; the most recent + * turn is never removed (it is the active / just-finished turn). + */ +export function turnsToTrim( + turns: readonly TranscriptTurn[], + maxTurns: number, + hysteresis: number, +): Set<TranscriptEntry> { + const toRemove = new Set<TranscriptEntry>(); + + if (turns.length <= maxTurns + hysteresis) return toRemove; + + let remaining = turns.length; + // `turns.length - 1` keeps the most recent turn off-limits. + for (let i = 0; i < turns.length - 1 && remaining > maxTurns; i++) { + const turn = turns[i]!; + for (const entry of turn.entries) toRemove.add(entry); + remaining--; + } + return toRemove; +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts new file mode 100644 index 000000000..dc0f60886 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts @@ -0,0 +1,158 @@ +import { readFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; + +import type { ClipboardModule } from './clipboard-native'; + +export type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; +export type RunCommand = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => { stdout: Buffer; ok: boolean }; +export type RunCommandAsync = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => Promise<{ stdout: Buffer; ok: boolean }>; + +export const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; + +export const DEFAULT_LIST_TIMEOUT_MS = 1000; +export const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; + +export function baseMimeType(raw: string): string { + return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); +} + +export function isSupportedImageMimeType(mime: string): boolean { + const base = baseMimeType(mime); + return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); +} + +export function parseTargetList(output: Buffer): string[] { + return output + .toString('utf-8') + .split(/\r?\n/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +export function runCommand( + command: string, + args: string[], + options?: RunCommandOptions, +): { stdout: Buffer; ok: boolean } { + const result = spawnSync(command, args, { + timeout: options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS, + maxBuffer: DEFAULT_MAX_BUFFER_BYTES, + env: options?.env, + }); + if (result.error !== undefined || result.status !== 0) { + return { ok: false, stdout: Buffer.alloc(0) }; + } + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); + return { ok: true, stdout }; +} + +/** + * Non-blocking counterpart of `runCommand`. Used by the clipboard image probe + * on the startup path so a slow or wedged helper (notably `powershell.exe` on + * WSL, or a stuck `wl-paste`/`xclip`) cannot freeze the event loop. The child + * is killed and the promise resolves with `ok: false` once `timeoutMs` elapses + * or the captured stdout exceeds `DEFAULT_MAX_BUFFER_BYTES`. + */ +export function runCommandAsync( + command: string, + args: string[], + options?: RunCommandOptions, +): Promise<{ stdout: Buffer; ok: boolean }> { + const timeoutMs = options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS; + return new Promise((resolve) => { + let child; + try { + child = spawn(command, args, { + env: options?.env, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + + const chunks: Buffer[] = []; + let totalBytes = 0; + let settled = false; + let timer: ReturnType<typeof setTimeout>; + + // Marks the promise as settled and clears the timeout. Returns true only for + // the first caller, so each event handler below resolves at most once. + const claim = (): boolean => { + if (settled) return false; + settled = true; + clearTimeout(timer); + return true; + }; + + timer = setTimeout(() => { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > DEFAULT_MAX_BUFFER_BYTES) { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + chunks.push(chunk); + }); + + child.on('error', () => { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }); + + child.on('close', (code) => { + if (code !== 0) { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + if (claim()) resolve({ ok: true, stdout: Buffer.concat(chunks) }); + }); + }); +} + +export function isWaylandSession(env: NodeJS.ProcessEnv): boolean { + return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; +} + +export function isWSL(env: NodeJS.ProcessEnv): boolean { + if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; + try { + return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); + } catch { + return false; + } +} + +export function isFileLikeNativeFormat(format: string): boolean { + const f = format.toLowerCase(); + const base = baseMimeType(format); + return ( + f.includes('file-url') || + f.includes('file url') || + f.includes('nsfilenames') || + f.includes('com.apple.finder') || + base === 'text/uri-list' || + base === 'public.url' + ); +} + +export function safeAvailableFormats(clip: ClipboardModule | null): string[] { + if (clip?.availableFormats === undefined) return []; + try { + return clip.availableFormats(); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts new file mode 100644 index 000000000..8c344d02e --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts @@ -0,0 +1,44 @@ +import { isFileLikeNativeFormat, safeAvailableFormats } from './clipboard-common'; +import { clipboard, type ClipboardModule } from './clipboard-native'; + +async function hasImageViaNative(clip: ClipboardModule | null): Promise<boolean> { + if (clip === null) return false; + + // Finder exposes file icons/thumbnails as image data when a non-image file + // is copied. Treat file-like clipboard contents as "not a pasteable image" + // to match the read path in clipboard-image.ts. + const formats = safeAvailableFormats(clip); + if (formats.some(isFileLikeNativeFormat)) return false; + + try { + return clip.hasImage(); + } catch { + return false; + } +} + +export async function clipboardHasImage(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + clipboard?: ClipboardModule | null; +}): Promise<boolean> { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + const clip = options?.clipboard ?? clipboard; + + if (env['TERMUX_VERSION'] !== undefined) return false; + + // The focus-driven clipboard-image hint does not probe on Linux. The probe + // would spawn wl-paste / xclip, which on Wayland perturbs seat focus and + // re-triggers the terminal's focus event, creating a focus feedback loop + // (window repeatedly gains/loses focus, IME candidate window cannot stay + // focused — see issue #1090). macOS and Windows are fine: both use the + // in-process native module, which neither spawns a subprocess nor perturbs + // focus. + // + // Image *paste* is unaffected on all platforms: it reads the clipboard + // through readClipboardMedia() on the explicit paste path, not here. + if (platform !== 'darwin' && platform !== 'win32') return false; + + return hasImageViaNative(clip); +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts index 0bb8d3c76..6aae761c4 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts @@ -16,7 +16,6 @@ * supported, or every fallback fails. */ -import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { readFileSync, statSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -25,6 +24,20 @@ import { fileURLToPath } from 'node:url'; import { parseImageMeta } from '#/utils/image/image-mime'; +import { + DEFAULT_LIST_TIMEOUT_MS, + SUPPORTED_IMAGE_MIME_TYPES, + baseMimeType, + isFileLikeNativeFormat, + isSupportedImageMimeType, + isWaylandSession, + isWSL, + parseTargetList, + runCommand as runCommandBase, + safeAvailableFormats, + type RunCommand, + type RunCommandOptions, +} from './clipboard-common'; import { clipboard, type ClipboardModule } from './clipboard-native'; export interface ClipboardImage { @@ -49,14 +62,6 @@ export class ClipboardMediaError extends Error { } } -type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; -type RunCommand = ( - command: string, - args: string[], - options?: RunCommandOptions, -) => { stdout: Buffer; ok: boolean }; - -const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; const MAX_VIDEO_BYTES = 100 * 1024 * 1024; const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({ @@ -75,10 +80,8 @@ const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({ '.3g2': 'video/3gpp2', }); -const DEFAULT_LIST_TIMEOUT_MS = 1000; const DEFAULT_READ_TIMEOUT_MS = 3000; const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000; -const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; const MACOS_FILE_PATH_SCRIPT = String.raw` ObjC.import('AppKit'); @@ -115,28 +118,6 @@ if (String(pb) !== '[id nil]') { out.join('\n'); `.trim(); -function isWaylandSession(env: NodeJS.ProcessEnv): boolean { - return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; -} - -function isWSL(env: NodeJS.ProcessEnv): boolean { - if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; - try { - return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); - } catch { - return false; - } -} - -function baseMimeType(raw: string): string { - return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); -} - -function isSupportedImageMimeType(mime: string): boolean { - const base = baseMimeType(mime); - return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); -} - function selectPreferredImageMimeType(candidates: string[]): string | null { const normalized = candidates .map((t) => t.trim()) @@ -253,29 +234,11 @@ function readMediaFromText(text: string): ClipboardMedia | null { return readMediaFromPaths(parseClipboardPaths(text)); } -function runCommand( - command: string, - args: string[], - options?: RunCommandOptions, -): { stdout: Buffer; ok: boolean } { - const result = spawnSync(command, args, { - timeout: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, - maxBuffer: DEFAULT_MAX_BUFFER_BYTES, +function runCommand(command: string, args: string[], options?: RunCommandOptions): { stdout: Buffer; ok: boolean } { + return runCommandBase(command, args, { + timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, env: options?.env, }); - if (result.error !== undefined || result.status !== 0) { - return { ok: false, stdout: Buffer.alloc(0) }; - } - const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); - return { ok: true, stdout }; -} - -function parseTargetList(output: Buffer): string[] { - return output - .toString('utf-8') - .split(/\r?\n/) - .map((t) => t.trim()) - .filter((t) => t.length > 0); } function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { @@ -394,28 +357,6 @@ function readClipboardFilePathsViaMacOs(run: RunCommand): string[] { return parseClipboardPaths(result.stdout.toString('utf-8')); } -function isFileLikeNativeFormat(format: string): boolean { - const f = format.toLowerCase(); - const base = baseMimeType(format); - return ( - f.includes('file-url') || - f.includes('file url') || - f.includes('nsfilenames') || - f.includes('com.apple.finder') || - base === 'text/uri-list' || - base === 'public.url' - ); -} - -function safeAvailableFormats(clip: ClipboardModule | null): string[] { - if (clip?.availableFormats === undefined) return []; - try { - return clip.availableFormats(); - } catch { - return []; - } -} - async function readClipboardFileMediaViaNativeText( clip: ClipboardModule | null, ): Promise<{ media: ClipboardMedia | null; lookedFileLike: boolean }> { diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts b/apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts new file mode 100644 index 000000000..f69976849 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts @@ -0,0 +1,40 @@ +const ESC = '\u001B'; +const BEL = '\u0007'; +const ST = '\\'; + +function isInsideTmux(): boolean { + return (process.env['TMUX'] ?? '').length > 0; +} + +/** + * Build an OSC 52 sequence that asks the terminal emulator to put `text` on + * the system clipboard. The sequence reaches the *local* clipboard through + * stdout alone, so it keeps working over SSH and inside containers where no + * native clipboard tool exists. Terminals without OSC 52 support silently + * ignore it. + * + * tmux swallows bare OSC sequences, so inside tmux the sequence is wrapped in + * a DCS passthrough with doubled ESC bytes (same convention as + * `buildTerminalNotificationSequences`). + */ +export function buildClipboardOSC52(text: string, insideTmux = isInsideTmux()): string { + const payload = Buffer.from(text, 'utf8').toString('base64'); + const sequence = `${ESC}]52;c;${payload}${BEL}`; + if (!insideTmux) return sequence; + const escaped = sequence.replaceAll(ESC, `${ESC}${ESC}`); + return `${ESC}Ptmux;${escaped}${ESC}${ST}`; +} + +/** + * Write the OSC 52 sequence to stdout. Returns false when stdout is not a + * terminal (the sequence would pollute piped output) or the write failed. + */ +export function writeClipboardOSC52(text: string): boolean { + if (!process.stdout.isTTY) return false; + try { + process.stdout.write(buildClipboardOSC52(text)); + return true; + } catch { + return false; + } +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-text.ts b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts index 8a8295f57..8738c9ede 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-text.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process'; import { clipboard } from './clipboard-native'; +import { writeClipboardOSC52 } from './clipboard-osc52'; function runClipboardCommand(command: string, args: readonly string[], input: string): void { const result = spawnSync(command, args, { encoding: 'utf8', input }); @@ -40,16 +41,34 @@ async function copyWithPlatformCommand(text: string): Promise<void> { throw new Error('No clipboard command is available.'); } -export async function copyTextToClipboard(text: string): Promise<void> { +/** How the text was delivered: a verified local clipboard tool, or an + * unverified OSC 52 escape emitted to the terminal as a last resort. */ +export type ClipboardCopyMethod = 'native' | 'osc52'; + +export async function copyTextToClipboard(text: string): Promise<ClipboardCopyMethod> { + // OSC 52 travels over stdout to the local terminal emulator, so it reaches + // the clipboard even over SSH or in containers with no native clipboard + // tool. Emit it up front; every failure path below can fall back on it. + const osc52Emitted = writeClipboardOSC52(text); + const clipboardModule = clipboard; if (clipboardModule?.setText !== undefined) { try { await clipboardModule.setText(text); - return; + return 'native'; } catch { // Fall back to platform clipboard commands below. } } - await copyWithPlatformCommand(text); + try { + await copyWithPlatformCommand(text); + return 'native'; + } catch (error) { + // The native clipboard is unreachable (headless server, SSH session, + // missing wl-copy/xclip …) but the terminal may still have delivered the + // text via OSC 52; without a terminal there is nothing left to try. + if (osc52Emitted) return 'osc52'; + throw error; + } } diff --git a/apps/kimi-code/src/utils/history/input-history.ts b/apps/kimi-code/src/utils/history/input-history.ts index 950542563..cade00bec 100644 --- a/apps/kimi-code/src/utils/history/input-history.ts +++ b/apps/kimi-code/src/utils/history/input-history.ts @@ -3,6 +3,10 @@ * * Semantics: * - One JSON object per line (`InputHistoryEntry { content }`) + * - `content` is the raw input. Shell commands are stored with a leading `!` + * (e.g. `!ls -la`) so ↑ recall can distinguish them from prompts and restore + * bash mode; the `!` is stripped again when the entry is recalled. Plain + * prompts (and legacy entries without a leading `!`) are normal prompts. * - Append-only writes * - Skip empty entries * - Skip when same as last entry (consecutive deduplication) diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index 5553e94c1..be55e798e 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -1,4 +1,4 @@ -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'; @@ -76,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 { @@ -124,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, @@ -147,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, @@ -202,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/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/src/utils/usage/usage-format.ts b/apps/kimi-code/src/utils/usage/usage-format.ts index 34db897a2..b44adb3f0 100644 --- a/apps/kimi-code/src/utils/usage/usage-format.ts +++ b/apps/kimi-code/src/utils/usage/usage-format.ts @@ -5,11 +5,40 @@ * command itself chalks the colour afterwards. */ +/** + * Format a token count in 1024-based units: context sizes are powers of + * two, so 262144 reads as "256k", not "262.1k". k values at or above + * 100 are rounded to whole numbers ("977k"). + */ export function formatTokenCount(n: number): string { if (!Number.isFinite(n) || n < 0) return '0'; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(Math.round(n)); + if (n >= 1024 * 1024) return `${trimDecimal(n / (1024 * 1024))}M`; + if (n >= 1024) { + const k = n / 1024; + return `${k >= 100 ? Math.round(k) : trimDecimal(k)}k`; + } + return String(n); +} + +/** One decimal place, dropping a redundant ".0" ("1.0" → "1", "1.5" stays). */ +function trimDecimal(v: number): string { + const s = v.toFixed(1); + return s.endsWith('.0') ? s.slice(0, -2) : s; +} + +/** + * Usage as a whole-number percentage of `max`, ceiled so any non-zero + * usage shows at least 1%, clamped to [0, 100]. A non-positive or + * non-finite `max` reports 0. + */ +export function usagePercent(used: number, max: number): number { + if (!Number.isFinite(max) || max <= 0) return 0; + return Math.min(100, Math.max(0, Math.ceil((used / max) * 100))); +} + +/** `usagePercent` for callers that only know the ratio (NaN-safe). */ +export function usagePercentFromRatio(ratio: number): number { + return Math.min(100, Math.max(0, Math.ceil(safeUsageRatio(ratio) * 100))); } /** diff --git a/apps/kimi-code/test/cli/export.test.ts b/apps/kimi-code/test/cli/export.test.ts index 39963536e..14fdc0190 100644 --- a/apps/kimi-code/test/cli/export.test.ts +++ b/apps/kimi-code/test/cli/export.test.ts @@ -382,6 +382,7 @@ describe('kimi export', () => { exit: ((code: number) => { throw new ExitCalled(code); }) as ExportDeps['exit'], + getShellEnv: () => ({ term: 'xterm-256color', shell: '/bin/zsh' }), }); await program.parseAsync(['node', 'kimi', 'export', 'ses_telemetry', '--output', output], { @@ -411,6 +412,7 @@ describe('kimi export', () => { version: expect.any(String), uiMode: 'shell', model: 'k2', + sessionId: undefined, getAccessToken: expect.any(Function), }); expect(mocks.initializeTelemetry.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index 04780bd26..89770deef 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -46,6 +46,12 @@ describe('parseHeadlessGoalCreate', () => { expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined(); expect(parseHeadlessGoalCreate('/goal pause')).toBeUndefined(); }); + + it('rejects malformed goal create prompts instead of falling through', () => { + expect(() => parseHeadlessGoalCreate(`/goal ${'x'.repeat(4001)}`)).toThrow( + 'Goal objective is too long', + ); + }); }); describe('goal summary', () => { @@ -86,6 +92,7 @@ const mocks = vi.hoisted(() => { 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); @@ -97,6 +104,7 @@ const mocks = vi.hoisted(() => { handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); } }), + waitForBackgroundTasksOnPrint: vi.fn(async () => {}), }; return { session, @@ -160,15 +168,24 @@ 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; }); @@ -243,6 +260,113 @@ describe('runPrompt headless goal mode', () => { expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X'); }); + it('keeps listening across continuation turns until the goal is terminal', async () => { + const active = snapshot({ status: 'active', turnsUsed: 1, tokensUsed: 80 }); + const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 }); + mocks.session.getGoal.mockResolvedValueOnce({ goal: active } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: '1' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + await Promise.resolve(); + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: '2' })); + handler( + mocks.mainEvent({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }), + ); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('• 1\n\n• 2\n\n'); + expect(stderr.text()).toContain('Goal [complete]'); + expect(stderr.text()).toContain('turns: 2'); + }); + + it('ignores stale goal checks once a continuation turn has started', async () => { + const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 }); + let resolveFirstGoal: ((value: { goal: null }) => void) | undefined; + const firstGoal = new Promise<{ goal: null }>((resolve) => { + resolveFirstGoal = resolve; + }); + mocks.session.getGoal + .mockImplementationOnce(() => firstGoal as never) + .mockResolvedValue({ goal: null } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + const emit = (event: Record<string, unknown>) => { + for (const handler of [...mocks.eventHandlers]) { + handler(mocks.mainEvent(event)); + } + }; + emit({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }); + emit({ type: 'assistant.delta', turnId: 1, delta: '1' }); + emit({ type: 'turn.ended', turnId: 1, reason: 'completed' }); + emit({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }); + emit({ type: 'assistant.delta', turnId: 2, delta: '2' }); + emit({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }); + resolveFirstGoal?.({ goal: null }); + await Promise.resolve(); + emit({ type: 'assistant.delta', turnId: 2, delta: ' tail' }); + emit({ type: 'turn.ended', turnId: 2, reason: 'completed' }); + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('• 1\n\n• 2 tail\n\n'); + expect(stderr.text()).toContain('Goal [complete]'); + }); + + it('does not send an invalid goal create prompt as a normal prompt', async () => { + const stdout = writer(); + const stderr = writer(); + + await expect( + runPrompt(opts({ prompt: `/goal ${'x'.repeat(4001)}` }), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }), + ).rejects.toThrow('Goal objective is too long'); + + expect(mocks.session.createGoal).not.toHaveBeenCalled(); + expect(mocks.session.prompt).not.toHaveBeenCalled(); + }); + it('validates the resumed session model before creating a headless goal', async () => { mocks.sessions = [{ id: 'ses_goal', workDir: process.cwd() }]; mocks.session.getStatus.mockResolvedValueOnce({ permission: 'auto', model: '' } as never); diff --git a/apps/kimi-code/test/cli/headless-exit.test.ts b/apps/kimi-code/test/cli/headless-exit.test.ts new file mode 100644 index 000000000..a977a70d2 --- /dev/null +++ b/apps/kimi-code/test/cli/headless-exit.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Writable } from 'node:stream'; + +import { drainStdio, finalizeHeadlessRun, scheduleHeadlessForceExit } from '#/cli/headless-exit'; + +describe('scheduleHeadlessForceExit', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('force-exits with the lazily-resolved exit code after the grace period', () => { + vi.useFakeTimers(); + const exit = vi.fn(); + let code = 0; + const handle = scheduleHeadlessForceExit({ exit }, () => code, 2000); + // The exit code can be set after scheduling (e.g. a goal turn maps its + // terminal status to process.exitCode); it must be read at fire time. + code = 7; + + expect(exit).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1999); + expect(exit).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(exit).toHaveBeenCalledWith(7); + + clearTimeout(handle); + }); + + it('schedules an unref\'d timer so a healthy run still exits naturally', () => { + // Real timers: an un-unref'd guard would itself keep the event loop alive, + // turning the fix into a regression (every healthy run would wait the full + // grace before exiting). hasRef() must be false. + const exit = vi.fn(); + const handle = scheduleHeadlessForceExit({ exit }, () => 0, 60_000); + expect((handle as { hasRef?: () => boolean }).hasRef?.()).toBe(false); + clearTimeout(handle); + }); + + it('does not fire once cancelled via clearTimeout', () => { + vi.useFakeTimers(); + const exit = vi.fn(); + const handle = scheduleHeadlessForceExit({ exit }, () => 0, 2000); + clearTimeout(handle); + vi.advanceTimersByTime(5000); + expect(exit).not.toHaveBeenCalled(); + }); +}); + +describe('drainStdio', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves once buffered output has flushed', async () => { + let flush: (() => void) | undefined; + const stream = { + write: vi.fn((_chunk: string, cb: () => void) => { + flush = cb; + return false; + }), + } as unknown as Writable; + + let resolved = false; + const done = drainStdio([stream], 5000).then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(false); // still draining + + flush?.(); // consumer caught up + await done; + expect(resolved).toBe(true); + }); + + it('gives up after the timeout when the consumer never drains', async () => { + vi.useFakeTimers(); + // write() never invokes its flush callback — a permanently-stuck consumer. + const stream = { write: vi.fn(() => false) } as unknown as Writable; + + let resolved = false; + const done = drainStdio([stream], 3000).then(() => { + resolved = true; + }); + await vi.advanceTimersByTimeAsync(2999); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(resolved).toBe(true); + }); +}); + +describe('finalizeHeadlessRun', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('flushes stdio before arming the force-exit so buffered output is not truncated', async () => { + vi.useFakeTimers(); + let flush: (() => void) | undefined; + const stream = { + write: vi.fn((_chunk: string, cb: () => void) => { + flush = cb; + return false; + }), + } as unknown as Writable; + const exit = vi.fn(); + + const done = finalizeHeadlessRun({ exit }, [stream], () => 0, { + drainTimeoutMs: 5000, + graceMs: 2000, + }); + + // Output is still draining: even well past the force-exit grace, we must NOT + // have armed/fired the exit — doing so would truncate the buffered output. + await vi.advanceTimersByTimeAsync(4000); + expect(exit).not.toHaveBeenCalled(); + + // Consumer catches up → drain completes → only now is the backstop armed. + flush?.(); + await done; + expect(exit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2000); + expect(exit).toHaveBeenCalledWith(0); + }); +}); diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 52aba94b1..31411e8b1 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -32,6 +32,8 @@ const mocks = vi.hoisted(() => { })), initializeCliTelemetry: vi.fn(), handleUpgrade: vi.fn(), + flushDiagnosticLogs: vi.fn(), + finalizeHeadlessRun: vi.fn(), log: { info: vi.fn(), warn: vi.fn(), @@ -79,6 +81,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async () => { mocks.createKimiHarness(...args); return mocks.harness; }, + flushDiagnosticLogs: mocks.flushDiagnosticLogs, KimiHarness: MockKimiHarness, log: mocks.log, }; @@ -127,6 +130,10 @@ vi.mock('../../src/cli/run-prompt', () => ({ runPrompt: mocks.runPrompt, })); +vi.mock('../../src/cli/headless-exit', () => ({ + finalizeHeadlessRun: mocks.finalizeHeadlessRun, +})); + class ExitCalled extends Error { constructor(readonly code: number) { super(`exit(${code})`); @@ -147,6 +154,20 @@ function defaultOpts(): CLIOptions { }; } +async function waitForAssertion(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + async function runHandleMainCommand(opts: CLIOptions): Promise<number | null> { const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { throw new ExitCalled(Number(code ?? 0)); @@ -196,6 +217,7 @@ describe('main entry command handling', () => { mocks.harness.close.mockResolvedValue(undefined); mocks.shutdownTelemetry.mockResolvedValue(undefined); mocks.handleUpgrade.mockResolvedValue(0); + mocks.flushDiagnosticLogs.mockResolvedValue(undefined); }); it('runs update preflight before starting the shell', async () => { @@ -235,6 +257,81 @@ describe('main entry command handling', () => { expect(runShell).not.toHaveBeenCalled(); }); + it('does not force-exit from the reusable handler in print mode', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockResolvedValue(void 0); + + const outcome = await handleMainCommand(opts, '0.0.1-alpha.2'); + + // Process disposition belongs to the entrypoint, never to this reusable, + // unit-tested handler: arming a process.exit here would kill the test runner + // or any embedding host. The handler only reports what ran. + expect(mocks.finalizeHeadlessRun).not.toHaveBeenCalled(); + expect(outcome).toEqual({ headlessCompleted: true }); + }); + + it('reports no headless completion for interactive (shell) mode', async () => { + const opts = defaultOpts(); + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runShell.mockResolvedValue(void 0); + + const outcome = await handleMainCommand(opts, '0.0.1-alpha.2'); + + expect(outcome).toEqual({ headlessCompleted: false }); + expect(mocks.finalizeHeadlessRun).not.toHaveBeenCalled(); + }); + + it('arms the force-exit fallback at the entrypoint after a completed headless run', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockResolvedValue(void 0); + mocks.finalizeHeadlessRun.mockResolvedValue(void 0); + + main(); + const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const mainAction = programArgs[1] as (opts: CLIOptions) => void; + mainAction(opts); + + await waitForAssertion(() => { + expect(mocks.finalizeHeadlessRun).toHaveBeenCalledTimes(1); + }); + // The exit code is resolved lazily so a goal turn that sets process.exitCode wins. + const forceExitArgs = mocks.finalizeHeadlessRun.mock.calls[0] as unknown as unknown[]; + expect(typeof forceExitArgs[2]).toBe('function'); + }); + + it('sets the failure exit code before awaiting startup failure logging', async () => { + const originalExitCode = process.exitCode; + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockRejectedValue(new Error('provider failed')); + mocks.flushDiagnosticLogs.mockImplementation(() => new Promise(() => {})); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new ExitCalled(Number(code ?? 0)); + }); + + try { + main(); + const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const mainAction = programArgs[1] as (opts: CLIOptions) => void; + mainAction(opts); + + await waitForAssertion(() => { + expect(mocks.flushDiagnosticLogs).toHaveBeenCalledTimes(1); + }); + expect(process.exitCode).toBe(1); + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + process.exitCode = originalExitCode; + } + }); + it('keeps shell mode update preflight interactive by default', async () => { const opts = defaultOpts(); mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index f4fb7d7e9..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,6 +41,7 @@ describe('CLI options parsing', () => { expect(opts.outputFormat).toBeUndefined(); expect(opts.prompt).toBeUndefined(); expect(opts.skillsDirs).toEqual([]); + expect(opts.addDirs).toEqual([]); }); }); @@ -154,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); @@ -298,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([ @@ -307,6 +390,16 @@ describe('CLI options parsing', () => { }); }); + describe('--add-dir', () => { + it('parses one additional workspace directory', () => { + expect(parse(['--add-dir', '/shared']).addDirs).toEqual(['/shared']); + }); + + it('parses repeated additional workspace directories', () => { + expect(parse(['--add-dir', '/one', '--add-dir=/two']).addDirs).toEqual(['/one', '/two']); + }); + }); + describe('sub-commands', () => { it('routes upgrade without calling the main action', () => { let upgradeCalls = 0; @@ -332,6 +425,30 @@ describe('CLI options parsing', () => { expect(upgradeCalls).toBe(1); }); + it('routes update alias to the upgrade handler', () => { + let upgradeCalls = 0; + const program = createProgram( + '0.0.0', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => { + upgradeCalls += 1; + }, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', 'update']); + + expect(upgradeCalls).toBe(1); + }); + it('registers the visible sub-commands', () => { const program = createProgram( '0.0.0', @@ -345,6 +462,8 @@ describe('CLI options parsing', () => { 'export', 'provider', 'acp', + 'server', + 'web', 'login', 'doctor', 'vis', @@ -365,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 index 56768a78c..2e4aeece4 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -668,7 +668,7 @@ describe('kimi provider catalog add', () => { }, }, defaultModel: 'other/main', - defaultThinking: true, + thinking: { enabled: true }, } as unknown as KimiConfig; const { harness, current, setConfigCalls } = makeHarness(initial); const { deps, stdout, exitCodes } = makeDeps(harness); @@ -692,7 +692,7 @@ describe('kimi provider catalog add', () => { // The unrelated provider's model survives, and remains the default. expect(finalConfig.models?.['other/main']).toBeDefined(); expect(finalConfig.defaultModel).toBe('other/main'); - expect(finalConfig.defaultThinking).toBe(true); + expect(finalConfig.thinking?.enabled).toBe(true); // The patch sent over `setConfig` must explicitly carry the preserved default. expect(setConfigCalls[0]?.defaultModel).toBe('other/main'); expect(stdout.join('')).toContain('Imported Anthropic (anthropic)'); @@ -760,7 +760,7 @@ describe('kimi provider catalog add', () => { }, }, defaultModel: 'anthropic/claude-opus-4-7', - defaultThinking: true, + thinking: { enabled: true }, } as unknown as KimiConfig; const { harness, current } = makeHarness(initial); const { deps, exitCodes } = makeDeps(harness); @@ -773,19 +773,19 @@ describe('kimi provider catalog add', () => { expect(current().providers['anthropic']?.apiKey).toBe('sk-rotated'); // Previous default and thinking flag must survive the re-import. expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); - expect(current().defaultThinking).toBe(true); + expect(current().thinking?.enabled).toBe(true); }); - it('preserves default_thinking when --default-model is supplied to a thinking-capable model', async () => { + it('preserves thinking.enabled when --default-model is supplied to a thinking-capable model', async () => { // Regression test for the codex P2: `applyCatalogProvider` always - // assigns `defaultThinking` from `options.thinking`. Hardcoding `false` + // assigns `thinking.enabled` from `options.thinking`. Hardcoding `false` // silently disabled thinking even when the user previously had it on // and is just importing a known provider. The handler now threads the // previous value through. mockRegistryFetch(CATALOG_BODY); const initial: KimiConfig = { providers: {}, - defaultThinking: true, + thinking: { enabled: true }, } as unknown as KimiConfig; const { harness, current, setConfigCalls } = makeHarness(initial); const { deps, exitCodes } = makeDeps(harness); @@ -799,20 +799,20 @@ describe('kimi provider catalog add', () => { expect(exitCodes).toEqual([]); expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); - expect(current().defaultThinking).toBe(true); - expect(setConfigCalls[0]?.defaultThinking).toBe(true); + expect(current().thinking?.enabled).toBe(true); + expect(setConfigCalls[0]?.thinking?.enabled).toBe(true); }); - it('does not persist default_thinking=false for first-time setup with --default-model', async () => { + it('does not persist thinking.enabled=false for first-time setup with --default-model', async () => { // Regression test for codex P2 follow-up: previously the handler fell - // back to `false` when `defaultThinking` was unset, but - // `resolveThinkingLevel` treats `defaultThinking === false` as an + // back to `false` when `thinking.enabled` was unset, but + // `resolveThinkingEffort` treats `thinking.enabled === false` as an // explicit "off" request. A fresh `kimi provider catalog add // anthropic --default-model claude-opus-4-7` must NOT silently disable - // thinking — it should leave `defaultThinking` unset so the runtime + // thinking — it should leave `thinking.enabled` unset so the runtime // uses the per-model default. mockRegistryFetch(CATALOG_BODY); - // Note: `defaultThinking` is omitted on purpose to model a fresh user. + // Note: `thinking.enabled` is omitted on purpose to model a fresh user. const { harness, current, setConfigCalls } = makeHarness({ providers: {}, } as KimiConfig); @@ -829,8 +829,8 @@ describe('kimi provider catalog add', () => { expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); // Must NOT be `false`. `undefined` lets the runtime resolver pick the // per-model default; `false` would force `'off'`. - expect(current().defaultThinking).toBeUndefined(); - expect(setConfigCalls[0]?.defaultThinking).toBeUndefined(); + expect(current().thinking?.enabled).toBeUndefined(); + expect(setConfigCalls[0]?.thinking?.enabled).toBeUndefined(); }); it('drops a stale default_model when the catalog refresh no longer contains it', async () => { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index a3620aa35..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 { @@ -62,6 +67,42 @@ const mocks = vi.hoisted(() => { 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(), @@ -124,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, @@ -135,6 +184,7 @@ function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) { outputFormat: undefined, prompt: 'say hello', skillsDirs: [], + addDirs: [], ...overrides, }; } @@ -182,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( @@ -205,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)); @@ -212,10 +273,100 @@ describe('runPrompt', () => { expect(mocks.session.prompt).toHaveBeenCalledWith('say hello'); expect(stdout.text()).toBe('• hello world\n\n'); expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'ses_prompt' }), + ); expect(mocks.shutdownTelemetry).toHaveBeenCalled(); expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('completes even if harness.close() never resolves (cleanup is time-bounded)', async () => { + vi.useFakeTimers(); + try { + const stdout = writer(); + const stderr = writer(); + // Simulate a shutdown step that hangs (e.g. a wedged SessionEnd hook or a + // blackholed connection in a firewalled sandbox). A completed headless run + // must not stay alive forever waiting on cleanup. + mocks.harnessClose.mockReturnValueOnce(new Promise<void>(() => {})); + + let settled = false; + const done = runPrompt(opts(), '1.2.3-test', { + stdout, + stderr, + process: fakeProcess(), + }).then(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(PROMPT_CLEANUP_TIMEOUT_MS + 100); + await done; + + expect(settled).toBe(true); + expect(mocks.harnessClose).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('propagates a cleanup failure that settles before the timeout', async () => { + const stdout = writer(); + const stderr = writer(); + // A cleanup step that fails fast (e.g. a permission restore or harness close + // hitting a persistence error) must surface — not be silently swallowed by + // the timeout guard — otherwise the run reports success while shutdown + // actually failed (e.g. a resumed session left in `auto`). + mocks.harnessClose.mockRejectedValueOnce(new Error('close failed')); + + await expect( + runPrompt(opts(), '1.2.3-test', { stdout, stderr, process: fakeProcess() }), + ).rejects.toThrow('close failed'); + }); + + it('ignores a cleanup rejection that lands after the timeout', async () => { + vi.useFakeTimers(); + try { + const stdout = writer(); + const stderr = writer(); + // Cleanup overruns the bound and only rejects later. The run already gave + // up waiting and resolved; that late rejection must not flip it to a + // failure (nor surface as an unhandled rejection). + mocks.harnessClose.mockReturnValueOnce( + new Promise<void>((_, reject) => { + const timer = setTimeout( + () => reject(new Error('late close')), + PROMPT_CLEANUP_TIMEOUT_MS + 5000, + ); + timer.unref?.(); + }), + ); + + let settled: 'resolved' | 'rejected' | undefined; + const done = runPrompt(opts(), '1.2.3-test', { + stdout, + stderr, + process: fakeProcess(), + }).then( + () => { + settled = 'resolved'; + }, + () => { + settled = 'rejected'; + }, + ); + + await vi.advanceTimersByTimeAsync(PROMPT_CLEANUP_TIMEOUT_MS + 100); + await done; + expect(settled).toBe('resolved'); + + await vi.advanceTimersByTimeAsync(5000); + await Promise.resolve(); + expect(settled).toBe('resolved'); + } finally { + vi.useRealTimers(); + } + }); + it('stops prompt startup when session creation fails', async () => { const stdout = writer(); const stderr = writer(); @@ -242,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>(); @@ -442,6 +610,23 @@ describe('runPrompt', () => { expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); }); + it('passes the CLI additional directories when resuming a concrete session', async () => { + await runPrompt( + opts({ session: 'ses_existing', addDirs: ['../shared', '/tmp/extra'] }), + '1.2.3-test', + { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }, + ); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ + id: 'ses_existing', + additionalDirs: ['../shared', '/tmp/extra'], + }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + }); + it('allows resuming a concrete session when Windows workdir uses backslashes', async () => { const cwd = vi.spyOn(process, 'cwd').mockReturnValue(String.raw`C:\Users\kimi\project`); mocks.harnessListSessions.mockResolvedValueOnce([ @@ -537,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' }); @@ -567,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' }); @@ -807,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) }, @@ -826,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 bab4fb152..cb9478d96 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -58,6 +58,7 @@ const mocks = vi.hoisted(() => { track: lifecycleTrack, })), resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'), + flushDiagnosticLogsSync: vi.fn(), harnessCreatesDeviceIdOnConstruction: false, execSync: vi.fn(), TuiConfigParseError, @@ -69,6 +70,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { return { ...actual, resolveKimiHome: mocks.resolveKimiHome, + flushDiagnosticLogsSync: mocks.flushDiagnosticLogsSync, createKimiHarness: (...args: unknown[]) => { const options = args[0] as { readonly homeDir?: string } | undefined; const homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; @@ -181,6 +183,7 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + addDirs: ['../shared', '/tmp/extra'], }; await runShell(cliOptions, '1.2.3-test'); @@ -191,13 +194,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', @@ -211,6 +215,7 @@ describe('runShell', () => { version: '1.2.3-test', uiMode: 'shell', model: 'k2', + sessionId: undefined, getAccessToken: expect.any(Function), }); expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime'); @@ -219,6 +224,7 @@ describe('runShell', () => { expect(harness).toBeTypeOf('object'); expect(startupInput).toMatchObject({ cliOptions, + additionalDirs: ['../shared', '/tmp/extra'], tuiConfig: { theme: 'dark', editorCommand: null, @@ -228,15 +234,7 @@ describe('runShell', () => { workDir: process.cwd(), }); 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 +243,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 +353,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 +382,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 +429,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', }); }); @@ -517,6 +510,101 @@ describe('runShell', () => { }); }); + it('flushes diagnostic logs synchronously before exiting on a runtime crash', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + const processOnSpy = vi.spyOn(process, 'on'); + const stdout = captureProcessWrite('stdout'); + 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 handler = processOnSpy.mock.calls.find( + ([event]) => event === 'uncaughtException', + )?.[1] as ((error: unknown) => void) | undefined; + expect(handler).toBeDefined(); + + // The async log sink cannot flush before process.exit() runs, so the + // crash handler must force a synchronous flush or the crash reason is + // lost (regression: uncaughtException logs never reached disk). + expect(() => handler?.(new Error('boom'))).toThrow(ExitCalled); + expect(mocks.flushDiagnosticLogsSync).toHaveBeenCalledOnce(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mocks.flushDiagnosticLogsSync.mock.invocationCallOrder[0]!).toBeLessThan( + exitSpy.mock.invocationCallOrder[0]!, + ); + } finally { + processOnSpy.mockRestore(); + exitSpy.mockRestore(); + stdout.restore(); + } + }); + + it('flushes diagnostic logs synchronously before exiting on an unhandled rejection', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + const processOnSpy = vi.spyOn(process, 'on'); + const stdout = captureProcessWrite('stdout'); + 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 handler = processOnSpy.mock.calls.find( + ([event]) => event === 'unhandledRejection', + )?.[1] as ((reason: unknown) => void) | undefined; + expect(handler).toBeDefined(); + + expect(() => handler?.(new Error('boom'))).toThrow(ExitCalled); + expect(mocks.flushDiagnosticLogsSync).toHaveBeenCalledOnce(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mocks.flushDiagnosticLogsSync.mock.invocationCallOrder[0]!).toBeLessThan( + exitSpy.mock.invocationCallOrder[0]!, + ); + } finally { + processOnSpy.mockRestore(); + exitSpy.mockRestore(); + stdout.restore(); + } + }); + it('closes the harness when TUI startup fails', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -531,7 +619,7 @@ describe('runShell', () => { session: undefined, continue: false, yolo: false, - auto: false, + auto: false, plan: false, model: undefined, outputFormat: undefined, @@ -543,7 +631,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(); }); @@ -568,7 +656,7 @@ describe('runShell', () => { session: undefined, continue: false, yolo: false, - auto: false, + auto: false, plan: false, model: undefined, outputFormat: undefined, @@ -589,7 +677,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(); @@ -602,6 +690,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', @@ -621,7 +756,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..2e93c2137 --- /dev/null +++ b/apps/kimi-code/test/cli/run-v2-print.test.ts @@ -0,0 +1,288 @@ +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); + }); + + it('waits for goal continuation turns before applying the mode', async () => { + let active = true; + let consumed = 0; + const drain = vi.fn(async () => {}); + await applyPrintBackgroundPolicy({ + mode: 'drain', + ceilingS: 60, + maxTurns: 50, + countPending: () => 0, + drain, + turnEndings: scriptedTurnEndings([ + { event: ending(2), apply: () => { consumed += 1; } }, + { + event: ending(3), + apply: () => { + consumed += 1; + active = false; + }, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + goalActive: () => active, + }); + // Both continuation turns ended before the mode ('drain') ran. + expect(consumed).toBe(2); + expect(drain).toHaveBeenCalledTimes(1); + }); + + it('warns and returns when the goal wait hits the ceiling', async () => { + let now = 0; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 10, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + // No continuation turn ever ends; the poll interval elapses each time. + turnEndings: { + next: async () => { + now = 10_001; + return null; + }, + }, + skipTurnId: 1, + warn, + now: () => now, + goalActive: () => true, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('goal wait ceiling'); + }); + + it('exits the goal wait promptly when the goal settles without a turn ending', async () => { + let active = true; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + // Poll interval elapses; the goal settles (paused/blocked) mid-wait + // without producing a turn.ended. + turnEndings: { + next: async () => { + active = false; + return null; + }, + }, + skipTurnId: 1, + warn, + now: () => Date.now(), + goalActive: () => active, + }); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +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/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index a96d1445b..ae6dd3bc0 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -161,6 +161,7 @@ function installState(overrides: Partial<UpdateInstallState> = {}): UpdateInstal function tuiConfig(overrides: Partial<TuiConfig> = {}): TuiConfig { return { theme: 'auto', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -226,6 +227,10 @@ async function flushBackgroundInstall(): Promise<void> { 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()); @@ -419,6 +424,28 @@ describe('runUpdatePreflight', () => { ); }); + it('pnpm-global on win32: spawns pnpm.cmd through a shell', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('pnpm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(0); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const { options } = captureOutput(); + await runUpdatePreflight('0.4.0', options); + expect(mocks.spawn).toHaveBeenCalledWith( + 'pnpm.cmd', + ['add', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { stdio: 'inherit', shell: true }, + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('yarn-global: spawns yarn global add', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); @@ -578,6 +605,27 @@ describe('runUpdatePreflight', () => { })); }); + it('win32 background auto-update hides the console window', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const { options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(mocks.spawn).toHaveBeenCalledWith( + 'npm.cmd', + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore', shell: true, windowsHide: true }, + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('tracks and logs successful background update installs', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState()); @@ -828,8 +876,8 @@ describe('runUpdatePreflight', () => { const track = vi.fn(); await runUpdatePreflight('0.4.0', { ...options, track }); expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - current: '0.4.0', - latest: '0.5.0', + current_version: '0.4.0', + target_version: '0.5.0', decision: 'prompt-install', source: 'npm-global', })); @@ -915,7 +963,7 @@ describe('runUpdatePreflight', () => { expect.objectContaining({ target: { version: '0.5.0' } }), ); expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - latest: '0.5.0', + target_version: '0.5.0', rollout_bucket: expect.any(Number), rollout_delay_seconds: 0, rollout_from_manifest: true, @@ -945,7 +993,7 @@ describe('runUpdatePreflight', () => { expect.objectContaining({ target: { version: '0.7.0' } }), ); expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - latest: '0.7.0', + target_version: '0.7.0', rollout_bucket: expect.any(Number), rollout_delay_seconds: 43_200, rollout_from_manifest: true, 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/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/helpers/process.ts b/apps/kimi-code/test/helpers/process.ts index 5a861724b..1da718fac 100644 --- a/apps/kimi-code/test/helpers/process.ts +++ b/apps/kimi-code/test/helpers/process.ts @@ -6,7 +6,7 @@ export class ExitCalled extends Error { } } -export function mockProcessExit(): { mockRestore(): void } { +export function mockProcessExit() { return vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => { throw new ExitCalled(Number(code ?? 0)); }) as never); 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 b719da163..0bcae749a 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -29,6 +29,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/commands/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/copy.test.ts b/apps/kimi-code/test/tui/commands/copy.test.ts new file mode 100644 index 000000000..6e980aa5f --- /dev/null +++ b/apps/kimi-code/test/tui/commands/copy.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { findLastAssistantText, handleCopyCommand } from '#/tui/commands/copy'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; +import type { TranscriptEntry } from '#/tui/types'; + +const mocks = vi.hoisted(() => ({ + copyTextToClipboard: vi.fn(), +})); + +vi.mock('#/utils/clipboard/clipboard-text', () => ({ + copyTextToClipboard: mocks.copyTextToClipboard, +})); + +let nextEntryId = 0; + +function entry(kind: TranscriptEntry['kind'], content: string): TranscriptEntry { + return { + id: `entry-${String(nextEntryId++)}`, + kind, + renderMode: 'markdown', + content, + }; +} + +function assistantEntry(content: string): TranscriptEntry { + return { ...entry('assistant', content), modelText: true }; +} + +function makeHost(entries: TranscriptEntry[]) { + const host = { + state: { transcriptEntries: entries }, + showStatus: vi.fn(), + showError: vi.fn(), + } as unknown as SlashCommandHost & { + showStatus: ReturnType<typeof vi.fn>; + showError: ReturnType<typeof vi.fn>; + }; + return host; +} + +describe('copy slash command', () => { + it('is registered as an idle-only built-in', () => { + const command = findBuiltInSlashCommand('copy'); + expect(command).toBeDefined(); + expect(resolveSlashCommandAvailability(command!, '')).toBe('idle-only'); + }); +}); + +describe('findLastAssistantText', () => { + it('returns an empty string for an empty transcript', () => { + expect(findLastAssistantText([])).toBe(''); + }); + + it('returns the newest assistant entry across later non-assistant entries', () => { + const entries = [ + assistantEntry('first answer'), + entry('user', 'follow-up question'), + assistantEntry('second answer'), + entry('user', 'typing…'), + entry('status', 'Working…'), + ]; + + expect(findLastAssistantText(entries)).toBe('second answer'); + }); + + it('skips assistant entries with empty or whitespace-only content', () => { + const entries = [assistantEntry('real answer'), assistantEntry(' \n ')]; + + expect(findLastAssistantText(entries)).toBe('real answer'); + }); + + it('ignores thinking and other non-visible-reply kinds', () => { + const entries = [ + assistantEntry('visible reply'), + entry('thinking', 'hidden reasoning'), + entry('tool_call', 'Bash ls'), + ]; + + expect(findLastAssistantText(entries)).toBe('visible reply'); + }); + + it('skips synthetic assistant cards like hook results and goal completions', () => { + const entries = [ + assistantEntry('real reply'), + entry('assistant', '*PostToolUse hook* ran something'), + entry('assistant', 'Goal completed: shipped the feature'), + ]; + + expect(findLastAssistantText(entries)).toBe('real reply'); + }); +}); + +describe('handleCopyCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.copyTextToClipboard.mockResolvedValue('native'); + }); + + it('copies the last visible assistant text and reports the character count', async () => { + const host = makeHost([entry('user', 'hi'), assistantEntry('final summary')]); + + await handleCopyCommand(host); + + expect(mocks.copyTextToClipboard).toHaveBeenCalledWith('final summary'); + expect(host.showStatus).toHaveBeenCalledWith( + `Copied to clipboard (${String('final summary'.length)} characters).`, + ); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('marks the copy as unverified when only the terminal escape delivered it', async () => { + mocks.copyTextToClipboard.mockResolvedValue('osc52'); + const host = makeHost([entry('user', 'hi'), assistantEntry('final summary')]); + + await handleCopyCommand(host); + + expect(host.showStatus).toHaveBeenCalledWith( + `Copied via terminal escape sequence (unverified, ${String('final summary'.length)} characters).`, + ); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('warns when there is no assistant message to copy', async () => { + const host = makeHost([entry('user', 'hi')]); + + await handleCopyCommand(host); + + expect(mocks.copyTextToClipboard).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('No assistant message to copy.', 'warning'); + }); + + it('shows an error when the clipboard write fails', async () => { + mocks.copyTextToClipboard.mockRejectedValue(new Error('pbcopy exited')); + const host = makeHost([assistantEntry('final summary')]); + + await handleCopyCommand(host); + + expect(host.showError).toHaveBeenCalledWith('Failed to copy to clipboard: pbcopy exited'); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index bad8e506b..ea59d4fae 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -251,7 +251,6 @@ describe('handleGoalCommand', () => { expect(session.createGoal).toHaveBeenCalledWith( expect.objectContaining({ objective: 'Ship feature X', replace: false }), ); - expect(host.track).toHaveBeenCalledWith('goal_create', { replace: false }); expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); expect(host.sendNormalUserInput).not.toHaveBeenCalledWith('/goal Ship feature X'); }); @@ -331,6 +330,25 @@ describe('handleGoalCommand', () => { expect(manualHost.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); }); + it('restores the previous permission mode when the goal fails to start', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + s.createGoal = vi.fn(async () => { + throw new KimiError(ErrorCodes.GOAL_ALREADY_EXISTS, 'A goal already exists'); + }); + + await handleGoalCommand(manualHost, 'Ship feature X'); + const picker = mountedPicker(manualHost); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + // Switched to YOLO to run the goal, then restored to Manual on failure. + expect(s.setPermission).toHaveBeenLastCalledWith('manual'); + }); + expect(s.setPermission).toHaveBeenCalledWith('yolo'); + expect(manualHost.setAppState).toHaveBeenLastCalledWith({ permissionMode: 'manual' }); + }); + it('returns the command to the input box when a Manual-mode goal start is cancelled', async () => { const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); diff --git a/apps/kimi-code/test/tui/commands/plugin-commands.test.ts b/apps/kimi-code/test/tui/commands/plugin-commands.test.ts new file mode 100644 index 000000000..eae75a099 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/plugin-commands.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPluginSlashCommands, pluginCommandName } from '#/tui/commands/plugin-commands'; + +describe('pluginCommandName', () => { + it('namespaces a command with its plugin id', () => { + expect(pluginCommandName('my-plugin', 'deploy')).toBe('my-plugin:deploy'); + }); +}); + +describe('buildPluginSlashCommands', () => { + it('namespaces commands and maps them to their bodies', () => { + const { commands, commandMap } = buildPluginSlashCommands([ + { + pluginId: 'my-plugin', + name: 'deploy', + description: 'Deploy', + body: 'Deploy $ARGUMENTS', + path: '/p/deploy.md', + }, + ]); + expect(commands).toEqual([{ name: 'my-plugin:deploy', aliases: [], description: 'Deploy' }]); + expect(commandMap.get('my-plugin:deploy')).toBe('Deploy $ARGUMENTS'); + }); + + it('returns empty commands for no defs', () => { + const { commands, commandMap } = buildPluginSlashCommands([]); + expect(commands).toEqual([]); + expect(commandMap.size).toBe(0); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index edfeaa106..bc4c5894f 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -3,6 +3,7 @@ import { findBuiltInSlashCommand, parseSlashInput, resolveSlashCommandAvailability, + addDirArgumentCompletions, sortSlashCommands, swarmArgumentCompletions, type KimiSlashCommand, @@ -73,6 +74,27 @@ describe('built-in slash command registry', () => { expect(values('Ship feature X')).toBeNull(); }); + it('offers add-dir list and directory argument completions', () => { + const values = (prefix: string): string[] | null => { + const items = addDirArgumentCompletions(prefix); + return items === null ? null : items.map((item) => item.value); + }; + + expect(values('')).toEqual(['list']); + expect(values('L')).toEqual(['list']); + expect(values('list')).toBeNull(); + const directoryCompletions = values('/') ?? []; + expect(directoryCompletions.length).toBeGreaterThan(0); + expect(directoryCompletions.every((value) => value.startsWith('/') && value.endsWith('/'))).toBe(true); + expect(directoryCompletions.some((value) => value.startsWith('/.'))).toBe(false); + expect(values('/.')).toBeNull(); + const homeCompletions = values('~/') ?? []; + expect(homeCompletions.length).toBeGreaterThan(0); + expect(homeCompletions.every((value) => value.startsWith('~/') && value.endsWith('/'))).toBe(true); + expect(homeCompletions.some((value) => value.startsWith('~/.'))).toBe(false); + expect(homeCompletions.some((value) => value.startsWith('~/sers/'))).toBe(false); + }); + it('defaults commands without explicit availability to idle-only', () => { const command: KimiSlashCommand = { name: 'example', @@ -126,6 +148,7 @@ describe('built-in slash command registry', () => { expect(new Set(names).size).toBe(names.length); expect(names).toEqual( expect.arrayContaining([ + 'add-dir', 'compact', 'btw', 'editor', diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index ab54a221b..77f582a1b 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -72,7 +72,9 @@ auto_install = false await handleReloadCommand(host); - expect(session.reloadSession).toHaveBeenCalledOnce(); + expect(session.reloadSession).toHaveBeenCalledWith({ + forcePluginSessionStartReminder: true, + }); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( session, 'Session reloaded.', @@ -135,6 +137,9 @@ function makeHost({ availableModels: {}, availableProviders: {}, }, + editor: { + setDisablePasteBurst: vi.fn(), + }, theme: { palette: { success: '#00ff00', diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index e25a1b984..614553bb4 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -14,6 +14,7 @@ function resolve( return resolveSlashCommandInput({ input, skillCommandMap: new Map<string, string>(), + pluginCommandMap: new Map<string, string>(), isStreaming: false, isCompacting: false, ...overrides, @@ -39,6 +40,11 @@ describe('resolveSlashCommandInput', () => { name: 'title', args: 'New title', }); + expect(resolve('/add-dir list')).toMatchObject({ + kind: 'builtin', + name: 'add-dir', + args: 'list', + }); expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' }); expect(resolve('/btw')).toMatchObject({ kind: 'builtin', @@ -88,6 +94,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'streaming', }); + expect(resolve('/add-dir ../shared', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'streaming', + }); expect(resolve('/experiments', { isStreaming: true })).toEqual({ kind: 'blocked', commandName: 'experiments', @@ -121,6 +132,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'compacting', }); + expect(resolve('/add-dir ../shared', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'compacting', + }); expect(resolve('/experiments', { isCompacting: true })).toEqual({ kind: 'blocked', commandName: 'experiments', @@ -292,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/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index fdb64ce46..b584c33d6 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -42,6 +42,7 @@ describe('update preference commands', () => { expect(mocks.saveTuiConfig).toHaveBeenCalledWith({ theme: 'auto', editorCommand: null, + disablePasteBurst: false, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, }); diff --git a/apps/kimi-code/test/tui/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 index 8f5724e5f..aecf815d9 100644 --- a/apps/kimi-code/test/tui/components/chrome/banner.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/banner.test.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { BannerComponent } from '#/tui/components/chrome/banner'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts index 440a4ad06..c0d4e929f 100644 --- a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { DeviceCodeBoxComponent } from '#/tui/components/chrome/device-code-box'; diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index ab0878d6b..2fe6f3e52 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -4,6 +4,7 @@ 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 { currentTheme, darkColors, lightColors } from '#/tui/theme'; +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import type { AppState } from '#/tui/types'; const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; @@ -34,11 +35,12 @@ function setDanceView(colored: boolean, phase: number): void { const appState: AppState = { version: '1.2.3', workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, model: 'kimi-k2', permissionMode: 'manual', - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -47,6 +49,7 @@ const appState: AppState = { streamingPhase: 'idle', streamingStartTime: 0, planMode: false, + inputMode: 'prompt', swarmMode: false, theme: 'dark', editorCommand: null, @@ -103,4 +106,84 @@ describe('FooterComponent', () => { 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 cc3a2ff21..bc1b754fb 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -12,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, @@ -25,6 +26,7 @@ const appState: AppState = { streamingPhase: 'idle', streamingStartTime: 0, planMode: false, + inputMode: 'prompt', swarmMode: false, theme: 'dark', editorCommand: null, diff --git a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts index 939242383..610ea23e2 100644 --- a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts index 473f3261e..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'; @@ -61,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: { diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts index d4ba77fa9..316b5f89d 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { @@ -138,6 +138,26 @@ describe('ApprovalPreviewViewer', () => { expect(text).toContain('BETA'); }); + it('shows surrounding context lines for a diff block', () => { + const viewer = makeViewer({ + block: { + type: 'diff', + path: 'src/foo.ts', + old_text: ['before1', 'before2', 'old', 'after1', 'after2'].join('\n'), + new_text: ['before1', 'before2', 'new', 'after1', 'after2'].join('\n'), + }, + rows: 24, + }); + + const text = strip(viewer.render(100).join('\n')); + expect(text).toContain('before1'); + expect(text).toContain('before2'); + expect(text).toContain('old'); + expect(text).toContain('new'); + expect(text).toContain('after1'); + expect(text).toContain('after2'); + }); + // Sanity: rendering is a pure slice — repeated render() calls without // input changes produce the same output, no incremental state drift. it('renders deterministically across repeated calls', () => { diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index 367a85578..ec590e252 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; -import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; +import { ChoicePickerComponent, type ChoiceOption } from '#/tui/components/dialogs/choice-picker'; import { EditorSelectorComponent } from '#/tui/components/dialogs/editor-selector'; import { PermissionSelectorComponent } from '#/tui/components/dialogs/permission-selector'; import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '#/tui/components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '#/tui/components/dialogs/update-preference-selector'; +import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -144,4 +145,35 @@ describe('ChoicePickerComponent', () => { picker.handleInput(' '); expect(onSelect).toHaveBeenCalledWith('a'); }); + + it('renders the selected option description in descriptionTone, others in textMuted', () => { + const options: ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: 'Include your codebase for deeper diagnosis.', + descriptionTone: 'warning', + }, + ]; + + const renderDescLine = (currentValue: string): string | undefined => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info?', + options, + currentValue, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + return picker.render(120).find((line) => strip(line).includes('Include your codebase')); + }; + + const warningLine = currentTheme.fg('warning', ' Include your codebase for deeper diagnosis.'); + const mutedLine = currentTheme.fg('textMuted', ' Include your codebase for deeper diagnosis.'); + + // Selected option: description uses the configured tone. + expect(renderDescLine('logs+codebase')).toBe(warningLine); + // Unselected option: description falls back to textMuted. + expect(renderDescLine('none')).toBe(mutedLine); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts index 80aaa6e79..4f415bc32 100644 --- a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts @@ -27,6 +27,35 @@ 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(); @@ -42,6 +71,83 @@ describe('CompactionComponent', () => { } }); + it('keeps the completed compaction summary hidden until expanded', () => { + const component = new CompactionComponent(); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + const collapsed = component.render(120).map(strip).join('\n'); + + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).toContain('120 → 24 tokens'); + expect(collapsed).toContain('Ctrl-O to show compaction summary'); + expect(collapsed).not.toContain('Keep the src/tui compaction notes.'); + + component.setExpanded(true); + const expanded = component.render(120).map(strip).join('\n'); + + expect(expanded).toContain('Compaction complete'); + expect(expanded).toContain('Ctrl-O to hide compaction summary'); + expect(expanded).toContain('Keep the src/tui compaction notes.'); + } finally { + component.dispose(); + } + }); + + it('hides the compaction summary again when collapsed', () => { + const component = new CompactionComponent(); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + component.setExpanded(false); + const text = component.render(120).map(strip).join('\n'); + + expect(text).toContain('Compaction complete'); + expect(text).toContain('Ctrl-O to show compaction summary'); + expect(text).not.toContain('Ctrl-O to hide compaction summary'); + expect(text).not.toContain('Keep the src/tui compaction notes.'); + } finally { + component.dispose(); + } + }); + + it('preserves the expanded summary when invalidating with an instruction', () => { + const component = new CompactionComponent(undefined, 'keep the recent files only'); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + component.invalidate(); + const text = component.render(120).map(strip).join('\n'); + + expect(text).toContain('keep the recent files only'); + expect(text).toContain('Keep the src/tui compaction notes.'); + expect(text.match(/keep the recent files only/g)).toHaveLength(1); + } finally { + component.dispose(); + } + }); + + it('keeps expanded summary child order on invalidate', () => { + const component = new CompactionComponent(undefined, 'keep the recent files only'); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + currentTheme.setPalette(lightColors); + component.invalidate(); + const text = component.render(120).map(strip).join('\n'); + + expect(text).toContain('Keep the src/tui compaction notes.'); + expect(text.indexOf('keep the recent files only')).toBeLessThan( + text.indexOf('Keep the src/tui compaction notes.'), + ); + } finally { + component.dispose(); + } + }); + it('repaints the header with the active palette on invalidate', () => { // Force truecolor so palette differences surface as ANSI codes even when // the test runner has no TTY. diff --git a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts index b46ba311c..68c79974d 100644 --- a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { diff --git a/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts new file mode 100644 index 000000000..53ff5b170 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts @@ -0,0 +1,151 @@ +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); + }); + + it('renders the warning line directly below the key-hint line when provided', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + }); + + it('renders no warning line without the warning option', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toBe(''); + }); + + it('wraps a warning longer than the width instead of truncating it', () => { + const warning = + 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + warning, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(40).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(lines[hintIdx + 1]).not.toBe(''); + expect(lines[hintIdx + 2]).not.toBe(''); + // Word-wrapped: nothing dropped — the full warning survives across lines. + const squashed = lines.join('').replaceAll(/\s+/g, ''); + expect(squashed).toContain(warning.replaceAll(/\s+/g, '')); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts index 068b7f00f..7ab1b3312 100644 --- a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; diff --git a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts index 547b30a66..ec0f9d03b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index eec53eca4..8fced4176 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -1,5 +1,5 @@ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; @@ -24,6 +24,23 @@ function model(displayName: string, capabilities: string[] = ['thinking']): Mode } as unknown as ModelAlias; } +function effortModel( + displayName: string, + supportEfforts: string[], + defaultEffort?: string, + capabilities: string[] = ['thinking'], +): ModelAlias { + return { + provider: 'managed:kimi-code', + model: displayName.toLowerCase().replaceAll(' ', '-'), + maxContextSize: 200_000, + displayName, + capabilities, + supportEfforts, + defaultEffort, + } as unknown as ModelAlias; +} + function text(component: ModelSelectorComponent, width = 120): string { return component.render(width).map(strip).join('\n'); } @@ -33,7 +50,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2') }, currentValue: 'kimi', - currentThinking: true, + currentThinkingEffort: 'on', onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -50,7 +67,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', - currentThinking: true, + currentThinkingEffort: 'on', onSelect, onCancel: vi.fn(), }); @@ -58,24 +75,24 @@ describe('ModelSelectorComponent', () => { // "/" no longer toggles thinking (it used to); here it is simply ignored. picker.handleInput('/'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); // Right arrow flips the draft (true -> false). picker.handleInput(RIGHT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: false }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'off' }); // Left arrow flips it back. picker.handleInput(LEFT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); }); it('shows the Left/Right thinking hint only for toggleable models', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', - currentThinking: false, + currentThinkingEffort: 'off', onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -90,7 +107,7 @@ describe('ModelSelectorComponent', () => { plain: model('Kimi Plain', ['tool_use']), }, currentValue: 'always', - currentThinking: false, + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); @@ -101,7 +118,7 @@ describe('ModelSelectorComponent', () => { expect(alwaysOut).toContain('Off (Unsupported)'); expect(alwaysOut).not.toContain('Always on'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: 'on' }); // Unsupported: Off selected, On greyed out — same style, mirrored. picker.handleInput(DOWN); @@ -110,7 +127,7 @@ describe('ModelSelectorComponent', () => { expect(plainOut).toContain('[ Off ]'); expect(plainOut).not.toContain('] unsupported'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: 'off' }); }); it('ignores Left/Right on always-on and unsupported models', () => { @@ -121,26 +138,26 @@ describe('ModelSelectorComponent', () => { plain: model('Kimi Plain', ['tool_use']), }, currentValue: 'always', - currentThinking: true, + currentThinkingEffort: 'on', onSelect, onCancel: vi.fn(), }); picker.handleInput(RIGHT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: 'on' }); picker.handleInput(DOWN); picker.handleInput(LEFT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: 'off' }); }); it('renders the unavailable thinking segment muted', () => { const picker = new ModelSelectorComponent({ models: { always: model('Kimi Thinking', ['always_thinking']) }, currentValue: 'always', - currentThinking: true, + currentThinkingEffort: 'on', onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -157,7 +174,7 @@ describe('ModelSelectorComponent', () => { thinking: model('Kimi Thinking', ['thinking']), }, currentValue: 'plain', - currentThinking: false, + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); @@ -168,7 +185,7 @@ describe('ModelSelectorComponent', () => { picker.handleInput(DOWN); // -> thinking (the Off override persists) picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: false }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: 'off' }); }); it('defaults a thinking-capable model to On but keeps the current model state', () => { @@ -179,7 +196,7 @@ describe('ModelSelectorComponent', () => { other: model('Kimi Other', ['thinking']), }, currentValue: 'current', - currentThinking: false, // thinking deliberately off on the active model + currentThinkingEffort: 'off', // thinking deliberately off on the active model onSelect, onCancel: vi.fn(), }); @@ -190,7 +207,7 @@ describe('ModelSelectorComponent', () => { // A capable, non-active model defaults to On without any toggle. expect(text(picker)).toContain('[ On ]'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: true }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'on' }); }); it('fuzzy-filters by typing and reports a match count', () => { @@ -198,7 +215,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { k2: model('Kimi K2'), turbo: model('Kimi Turbo') }, currentValue: 'k2', - currentThinking: false, + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel, @@ -225,7 +242,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models, currentValue: 'm0', - currentThinking: false, + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -242,7 +259,7 @@ describe('ModelSelectorComponent', () => { cjk: model('超长的中文模型名称需要被正确截断处理'), }, currentValue: 'long', - currentThinking: false, + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -254,4 +271,264 @@ describe('ModelSelectorComponent', () => { } } }); + + it('invokes onSessionOnlySelect on Alt+S with the effective thinking state', () => { + const onSelect = vi.fn(); + const onSessionOnlySelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect, + onSessionOnlySelect, + onCancel: vi.fn(), + }); + + // Toggle thinking Off, then Alt+S applies the choice to the session only. + picker.handleInput(RIGHT); + picker.handleInput(`${ESC}s`); + expect(onSessionOnlySelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: 'off' }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ignores Alt+S and hides its hint when onSessionOnlySelect is not provided', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(`${ESC}s`); + expect(onSelect).not.toHaveBeenCalled(); + expect(text(picker)).not.toContain('Alt+S session-only'); + }); + + it('shows the Alt+S session-only hint when onSessionOnlySelect is provided', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect: vi.fn(), + onSessionOnlySelect: vi.fn(), + onCancel: vi.fn(), + }); + expect(text(picker)).toContain('Alt+S session-only'); + }); + + it('renders effort segments with the default effort highlighted', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: effortModel('Kimi K2', ['low', 'high', 'max'], 'high') }, + currentValue: 'kimi', + currentThinkingEffort: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + // The default effort (high) is the active segment. + expect(out).toContain('[ High ]'); + // All declared efforts plus the Off entry are present. + expect(out).toContain('Low'); + expect(out).toContain('Max'); + expect(out).toContain('Off'); + // Multi-segment control advertises the switch hint. + expect(out).toContain('Thinking (←→ to switch)'); + }); + + it('derives official Anthropic effort segments from the model name', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + opus: { + provider: 'anthropic', + model: 'claude-opus-4-6', + maxContextSize: 200000, + }, + }, + currentValue: 'opus', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + const out = text(picker); + expect(out).toContain('Low'); + expect(out).toContain('[ High ]'); + expect(out).toContain('Max'); + expect(out).toContain('Off'); + expect(out).not.toContain('Xhigh'); + + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'opus', thinking: 'max' }); + }); + + it('derives official always-on Anthropic models without an Off segment', () => { + const picker = new ModelSelectorComponent({ + models: { + fable: { + provider: 'anthropic', + model: 'claude-fable-5', + maxContextSize: 200000, + }, + }, + currentValue: 'fable', + currentThinkingEffort: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + expect(out).toContain('Xhigh'); + expect(out).toContain('Max'); + expect(out).not.toContain('Off'); + }); + + 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 ]'); + }); + + it('renders the warning line directly below the key-hint line when provided', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('↑↓ navigate')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + // Model list is pushed below the inserted warning line, not overlapped. + expect(lines.findIndex((l) => l.includes('Kimi K2'))).toBeGreaterThan(hintIdx + 1); + }); + + it('wraps a warning longer than the width instead of truncating it', () => { + const warning = + 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + warning, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = picker.render(50).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('↑↓ navigate')); + expect(lines[hintIdx + 1]).not.toBe(''); + expect(lines[hintIdx + 2]).not.toBe(''); + // Word-wrapped: nothing dropped — the full warning survives across lines. + const squashed = lines.join('').replaceAll(/\s+/g, ''); + expect(squashed).toContain(warning.replaceAll(/\s+/g, '')); + }); +}); + +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 79d872cd3..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,21 +2,20 @@ import { describe, expect, it, vi } from 'vitest'; import chalk from 'chalk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, type PluginRemoveConfirmResult, + type PluginsPanelSelection, } from '#/tui/components/dialogs/plugins-selector'; -import { darkColors } from '#/tui/theme/colors'; -import { pluginTrustLabel } from '#/tui/utils/plugin-source-label'; +import { currentTheme } from '#/tui/theme'; +import { darkColors, lightColors } from '#/tui/theme/colors'; +import { isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; -const ANSI_SGR = /\[[0-9;]*m/g; -const MID = '\u00B7'; -const ESC = String.fromCodePoint(27); -const RIGHT = `${ESC}[C`; -const LEFT = `${ESC}[D`; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?'); @@ -40,6 +39,57 @@ function dangerShortcut(text: string): string { return withAnsiColors(() => chalk.hex(darkColors.error).bold(text)); } +function warningMark(): string { + // Opening ANSI escape for the warning color; the install-trust notice is the + // only element in that dialog using it, so its presence confirms the tone. + return withAnsiColors(() => chalk.hex(darkColors.warning)('\u0001').split('\u0001')[0]!); +} + +const superpowers = { + id: 'superpowers', + displayName: 'Superpowers', + version: '5.1.0', + enabled: true, + state: 'ok' as const, + skillCount: 14, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'local-path' as const, +}; + +const officialEntries = [ + { id: 'kimi-datasource', tier: 'official' as const, displayName: 'Kimi Datasource', version: '3.1.1', source: 'https://x/d.zip' }, +]; +const thirdPartyEntries = [ + { id: 'superpowers', tier: 'curated' as const, displayName: 'Superpowers', source: 'https://x/s.zip' }, +]; +const marketplaceEntries = [...officialEntries, ...thirdPartyEntries]; + +function makePanel(opts: { + installed?: readonly (typeof superpowers)[]; + initialTab?: 'installed' | 'official' | 'third-party' | 'custom'; + selectedId?: string; + pluginHint?: { id: string; text: string }; +}) { + const installed = opts.installed ?? []; + const onSelect = vi.fn<(s: PluginsPanelSelection) => void>(); + const onRequestMarketplace = vi.fn(); + const panel = new PluginsPanelComponent({ + installed, + installedIds: new Set(installed.map((p) => p.id)), + initialTab: opts.initialTab, + selectedId: opts.selectedId, + pluginHint: opts.pluginHint, + onSelect, + onCancel: vi.fn(), + onRequestMarketplace, + }); + return { panel, onSelect, onRequestMarketplace }; +} + describe('plugins selector dialogs', () => { it('trusts only built-in Kimi CDN plugin paths', () => { expect(pluginTrustLabel({ @@ -50,6 +100,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', @@ -62,6 +114,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip', @@ -74,6 +128,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/demo.zip', @@ -86,319 +142,359 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'local-path', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/local', })).toBe('third-party'); }); - it('renders installed plugins as selectable overview entries', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 2, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - onSelect, - onCancel: vi.fn(), - }); - - const raw = renderRaw(picker); - const out = strip(raw); - expect(out).toContain('Installed plugins (1)'); - expect(out).toContain('Actions'); - expect(out).toContain('? Kimi Datasource enabled'); - expect(out).toContain(`id kimi-datasource ${MID} 2 skills ${MID} MCP 1/1`); - expect(out).not.toContain('Space disable'); - expect(out).not.toContain('Enter info'); - expect(out).toContain('Space toggle · M MCP servers · D remove · Enter details'); - expect(out).toContain('Marketplace'); - expect(out).toContain('Summary'); - - picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ kind: 'info', id: 'kimi-datasource' }); + it('treats only the official Kimi CDN path as a trusted install source', () => { + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true); + // Curated and other Kimi CDN paths are not "official" for the install gate. + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false); + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false); + // Non-Kimi hosts, non-https schemes, local paths, and GitHub sources are unofficial. + expect(isOfficialPluginSource('https://example.test/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('http://code.kimi.com/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('./plugins/kimi-datasource')).toBe(false); + expect(isOfficialPluginSource('/abs/path/to/plugin')).toBe(false); + expect(isOfficialPluginSource('github.com/owner/repo')).toBe(false); + expect(isOfficialPluginSource('not a url')).toBe(false); }); - it('ignores Left/Right arrows in the overview (no enter/exit by arrow)', () => { - const onSelect = vi.fn(); - const onCancel = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 2, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - onSelect, - onCancel, - }); - - picker.handleInput(RIGHT); // must NOT open details - expect(onSelect).not.toHaveBeenCalled(); - picker.handleInput(LEFT); // must NOT cancel/exit - expect(onCancel).not.toHaveBeenCalled(); + it('opens on the Installed tab with the four panel tabs', () => { + const { panel } = makePanel({ installed: [superpowers] }); + const out = strip(renderRaw(panel)); + expect(out).toContain('Plugins'); + expect(out).toContain('Installed'); + expect(out).toContain('Official'); + expect(out).toContain('Third-party'); + expect(out).toContain('Custom'); + expect(out).toContain('? Superpowers enabled'); + expect(out).toContain('Space toggle'); + expect(out).toContain('1 installed'); }); - it('renders marketplace plugins separately from marketplace actions', () => { - const onSelect = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - tier: 'curated', - displayName: 'Superpowers', - version: '5.1.0', - description: 'Workflow skills', - source: 'https://example.com/superpowers.zip', - keywords: ['workflow'], - }, - ], - installed: new Map(), - source: '/tmp/marketplace.json', - onSelect, - onCancel: vi.fn(), - }); + it('repaints from the current theme palette without remounting', () => { + const { panel } = makePanel({ installed: [superpowers] }); + const previous = currentTheme.palette; + try { + currentTheme.setPalette(darkColors); + const darkOut = renderRaw(panel); + currentTheme.setPalette(lightColors); + const lightOut = renderRaw(panel); + // A palette snapshot cached at construction would render identically + // after the switch; reading currentTheme.palette at render time must + // produce different ANSI output for the same panel instance. + expect(darkOut).not.toBe(lightOut); + } finally { + currentTheme.setPalette(previous); + } + }); - const raw = renderRaw(picker); - const out = strip(raw); - expect(out).toContain('Marketplace (1)'); - expect(out).toContain('? Superpowers install v5.1.0'); - expect(out).toContain( - `Workflow skills ${MID} id superpowers ${MID} Curated plugin ${MID} workflow`, - ); - expect(out).toContain('Enter install/update'); - expect(out).toContain('Actions'); - expect(out).toContain('Back to installed plugins'); + it('toggles an installed plugin with Space', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers] }); + panel.handleInput(' '); + expect(onSelect).toHaveBeenCalledWith({ kind: 'toggle', id: 'superpowers', enabled: false }); + }); - picker.handleInput('\r'); + it('routes D / M / R / Enter to remove / mcp / reload / details on the Installed tab', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers] }); + panel.handleInput('d'); + panel.handleInput('m'); + panel.handleInput('r'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'superpowers' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'superpowers' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'reload' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('Enter on an installed plugin with an available update installs it', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'install', entry: expect.objectContaining({ id: 'superpowers' }), }); }); - it('installs only on Enter, not Space, in the marketplace', () => { - const onSelect = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - tier: 'curated', - displayName: 'Superpowers', - version: '5.1.0', - description: 'Workflow skills', - source: 'https://example.com/superpowers.zip', - keywords: ['workflow'], - }, - ], - installed: new Map(), - source: '/tmp/marketplace.json', - onSelect, - onCancel: vi.fn(), - }); + it('Enter on an up-to-date installed plugin opens details', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); - picker.handleInput(' '); // Space must NOT install - expect(onSelect).not.toHaveBeenCalled(); - picker.handleInput('\r'); // Enter installs + it('I on an installed plugin opens details even when an update is available', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('i'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('renders the inline plugin hint on the installed row', () => { + const datasource = { ...superpowers, id: 'kimi-datasource', displayName: 'Kimi Datasource', skillCount: 1 }; + const { panel } = makePanel({ + installed: [datasource], + selectedId: 'kimi-datasource', + pluginHint: { id: 'kimi-datasource', text: 'pending /new' }, + }); + const out = strip(renderRaw(panel)); + expect(out).toContain('? Kimi Datasource enabled pending /new'); + }); + + it('lazily loads the Official catalog, then lists installed entries first', () => { + const { panel, onRequestMarketplace } = makePanel({ installed: [superpowers] }); + panel.handleInput('\t'); // → Official + expect(onRequestMarketplace).toHaveBeenCalledTimes(1); + expect(strip(renderRaw(panel))).toContain('Loading marketplace'); + + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi Datasource install'); + expect(out).toContain('0 installed · 1 available'); + }); + + it('renders the hardcoded Web Bridge entry on the Official tab while loading', () => { + const { panel } = makePanel({ initialTab: 'official' }); + // The catalog is still loading, but the built-in Web Bridge entry is shown + // immediately because it is baked into the TUI, not fetched. + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge open in browser'); + expect(out).toContain('Loading marketplace'); + }); + + it('keeps the Web Bridge entry visible when the Official catalog errors', () => { + const { panel } = makePanel({ initialTab: 'official' }); + panel.setMarketplaceError('fetch failed'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge open in browser'); + expect(out).toContain('Marketplace unavailable: fetch failed'); + }); + + it('opens the Web Bridge webpage on Enter instead of installing', () => { + const { panel, onSelect } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + // Web Bridge is pinned at index 0, so Enter selects it directly. + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'open-url', + url: 'https://www.kimi.com/features/webbridge#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('ignores the Left arrow in the marketplace view (Esc returns instead)', () => { - const onCancel = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - tier: 'curated', - displayName: 'Superpowers', - version: '5.1.0', - description: 'Workflow skills', - source: 'https://example.com/superpowers.zip', - keywords: ['workflow'], - }, - ], - installed: new Map(), - source: '/tmp/marketplace.json', - onSelect: vi.fn(), - onCancel, - }); - - picker.handleInput(LEFT); // must NOT return to the overview - expect(onCancel).not.toHaveBeenCalled(); + it('renders an installing state while an install is in progress', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.setInstalling('Superpowers'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Installing Superpowers from marketplace'); }); - it('issues install for installed marketplace entries (update path)', () => { - const onSelect = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - displayName: 'Superpowers', - source: 'https://example.com/superpowers.zip', - }, - ], - installed: new Map([['superpowers', undefined]]), - source: '/tmp/marketplace.json', - onSelect, - onCancel: vi.fn(), - }); - - const out = picker.render(120).map(strip).join('\n'); - expect(out).toContain('? Superpowers installed'); - expect(out).toContain(`Plugin ${MID} id superpowers`); - - picker.handleInput('\r'); + it('keeps a valid selection if ↓ is pressed while the catalog is loading', () => { + const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); + // Catalog still loading (entries empty); pressing ↓ must not drive the + // selection negative, or the later Enter would read entries[-1]. + panel.handleInput('\u001B[B'); // ↓ + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'install', entry: expect.objectContaining({ id: 'superpowers' }), }); }); - it('shows an update badge when the installed version is older than the marketplace', () => { - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - tier: 'curated', - displayName: 'Superpowers', - version: '5.1.0', - source: 'https://example.com/superpowers.zip', - }, - ], - installed: new Map([['superpowers', '5.0.0']]), - source: '/tmp/marketplace.json', - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - - const out = picker.render(120).map(strip).join('\n'); - expect(out).toContain('? Superpowers update 5.0.0 → 5.1.0'); + 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('shows installed with the version when already up to date', () => { - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - tier: 'curated', - displayName: 'Superpowers', - version: '5.1.0', - source: 'https://example.com/superpowers.zip', - }, - ], - installed: new Map([['superpowers', '5.1.0']]), - source: '/tmp/marketplace.json', - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - - const out = picker.render(120).map(strip).join('\n'); - expect(out).toContain(`? Superpowers installed ${MID} v5.1.0`); + 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('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', - }, - ], - onSelect, - onCancel: vi.fn(), - }); + 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'); + }); - picker.handleInput(' '); + it('does not show an update badge on the Installed tab before the marketplace loads', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const { panel } = makePanel({ installed }); + // The marketplace has not been loaded yet, so the badge stays hidden rather + // than guessing. + const out = strip(renderRaw(panel)); + expect(out).not.toContain('update'); + }); + it('shows installed · v<version> when the installed plugin is up to date', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed, initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers installed · v5.0.0'); + }); + + it('shows an inline error when the Official catalog fails', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.handleInput('\t'); // → Official + panel.setMarketplaceError('fetch failed'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Marketplace unavailable: fetch failed'); + expect(out).toContain('Use the Custom tab'); + }); + + it('installs from a URL typed on the Custom tab', () => { + const { panel, onSelect } = makePanel({ initialTab: 'custom' }); + const out = strip(renderRaw(panel)); + expect(out).toContain('Install from a GitHub URL'); + expect(out).toContain('╭'); + + for (const ch of 'https://github.com/owner/repo') { + panel.handleInput(ch); + } + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ - kind: 'toggle', - id: 'kimi-datasource', - enabled: false, + kind: 'install-source', + source: 'https://github.com/owner/repo', }); }); - it('issues a remove request from the overview on D', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', - }, - ], - onSelect, - onCancel: vi.fn(), - }); - - picker.handleInput('d'); - - expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'kimi-datasource' }); - }); - - it('opens MCP server management from the overview on M', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - onSelect, - onCancel: vi.fn(), - }); - - picker.handleInput('m'); - - expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'kimi-datasource' }); - }); - it('toggles MCP servers from the MCP selector', () => { const selections: PluginMcpSelection[] = []; const picker = new PluginMcpSelectorComponent({ @@ -411,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', @@ -448,33 +546,6 @@ 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' }, - 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({ @@ -505,7 +576,7 @@ describe('plugins selector dialogs', () => { }, }); - picker.handleInput(''); + picker.handleInput('\u001B[B'); const raw = renderRaw(picker); expect(strip(raw)).toContain('Enter/Space select'); // The destructive option label keeps its danger styling (error + bold). @@ -515,4 +586,49 @@ describe('plugins selector dialogs', () => { expect(results).toEqual([{ kind: 'confirm' }]); }); + + it('defaults the third-party install trust prompt to exit', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, + }); + + const raw = renderRaw(picker); + const out = raw.split('\n').map(strip); + expect(out).toContain(' Install third-party plugin Superpowers?'); + expect(out).toContain(' ? Exit'); + expect(out).toContain(' Cancel the installation.'); + expect(out).toContain(' Install this third-party plugin anyway.'); + // The warning explains why confirmation is required and uses the + // design-system warning color rather than muted/default text. + expect(out.some((line) => line.includes('Kimi has not reviewed'))).toBe(true); + expect(out.some((line) => line.includes('trust the source'))).toBe(true); + expect(raw).toContain(warningMark()); + + picker.handleInput('\r'); + expect(results).toEqual([{ kind: 'cancel' }]); + }); + + it('installs a third-party plugin only after switching to trust', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, + }); + + picker.handleInput('\u001B[B'); + const raw = renderRaw(picker); + expect(strip(raw)).toContain('Enter/Space select'); + // The opt-in option keeps its danger styling (error + bold). + expect(raw).toContain(dangerShortcut('Trust and install')); + + picker.handleInput('\r'); + + expect(results).toEqual([{ kind: 'confirm' }]); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index 12ca62ed7..812ac0d94 100644 --- a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -1,4 +1,4 @@ -import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 70f7dfe30..3c885488b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SessionPickerComponent } from '#/tui/components/dialogs/session-picker'; diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index b1a2baf0c..3f4287486 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -3,7 +3,8 @@ import chalk from 'chalk'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { darkColors, lightColors } from '#/tui/theme/colors'; const ESC = String.fromCodePoint(27); const SGR = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); @@ -34,7 +35,7 @@ function make(): { gpt: model('GPT-5', 'openai'), }, currentValue: 'k2', - currentThinking: false, + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); @@ -44,12 +45,15 @@ function make(): { describe('TabbedModelSelectorComponent', () => { let previousLevel: typeof chalk.level; + const previousPalette = currentTheme.palette; beforeAll(() => { previousLevel = chalk.level; chalk.level = 3; + currentTheme.setPalette(darkColors); }); afterAll(() => { chalk.level = previousLevel; + currentTheme.setPalette(previousPalette); }); it('renders an "All" + per-provider tab strip', () => { @@ -66,6 +70,25 @@ describe('TabbedModelSelectorComponent', () => { expect(raw).toContain(PRIMARY_BG); }); + it('repaints the tab strip from the current theme palette without remounting', () => { + const { component } = make(); + const stripLine = (lines: string[]): string => + lines.find((l) => l.includes('All') && l.includes('openai')) ?? ''; + const previous = currentTheme.palette; + try { + currentTheme.setPalette(darkColors); + const darkStrip = stripLine(component.render(120)); + currentTheme.setPalette(lightColors); + const lightStrip = stripLine(component.render(120)); + // The strip is drawn from currentTheme.palette at render time; a + // construction-time palette snapshot would render the same strip after + // the switch. + expect(darkStrip).not.toBe(lightStrip); + } finally { + currentTheme.setPalette(previous); + } + }); + it('opens on the All tab by default (showing every provider\'s models)', () => { const out = strip(make().component.render(120).join('\n')); expect(out).toContain('Kimi K2'); @@ -87,7 +110,7 @@ describe('TabbedModelSelectorComponent', () => { const { component, onSelect } = make(); component.handleInput(RIGHT); // toggle thinking on for k2 component.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'k2', thinking: true }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'k2', thinking: 'on' }); }); it('frames the tab strip with a blank line above and below it', () => { @@ -108,4 +131,26 @@ describe('TabbedModelSelectorComponent', () => { // It comes first, before the navigation hint. expect(hint!.indexOf('Tab toggle provider')).toBeLessThan(hint!.indexOf('↑↓ navigate')); }); + + it('keeps the tab strip between hint and list when a warning line is present', () => { + const component = new TabbedModelSelectorComponent({ + models: { + k2: model('Kimi K2', 'managed:kimi-code'), + gpt: model('GPT-5', 'openai'), + }, + currentValue: 'k2', + currentThinkingEffort: 'off', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = component.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('navigate') && l.includes('Esc cancel')); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + expect(lines[hintIdx + 2]).toBe(''); // blank between warning and tabs + const stripIdx = lines.findIndex((l) => l.includes('All') && l.includes('openai')); + expect(stripIdx).toBe(hintIdx + 3); + expect(lines[stripIdx + 1]).toBe(''); // blank between tabs and list + expect(lines.findIndex((l) => l.includes('Kimi K2'))).toBeGreaterThan(stripIdx); + }); }); 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 fa30293cc..cf7184b3b 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,14 +3,16 @@ import type { AutocompleteProvider, AutocompleteSuggestions, TUI, -} from '@earendil-works/pi-tui'; -import { describe, expect, it, vi } from 'vitest'; +} from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; +import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; function makeEditor(): CustomEditor { const tui = { requestRender: vi.fn(), + render: vi.fn(() => []), terminal: { rows: 40, cols: 120 }, } as unknown as TUI; return new CustomEditor(tui); @@ -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,8 +93,322 @@ 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 (\x1b) is required to match ANSI SGR escape sequences + // 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 () => { @@ -133,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}`); @@ -199,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); @@ -243,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); @@ -253,22 +587,37 @@ describe('CustomEditor paste marker expansion', () => { editor.handleInput('x'); expect(editor.getText()).toContain('x'); }); + + it('falls back to the text paste path when the image paste handler rejects', async () => { + const editor = makeEditor(); + const onTextPaste = vi.fn(); + editor.onTextPaste = onTextPaste; + editor.onPasteImage = vi.fn(async () => { + throw new Error('clipboard backend broken'); + }); + + // Regression: a rejecting onPasteImage must not leak an unhandled + // rejection — the CLI's crash path turns those into a silent exit. + const rejections: unknown[] = []; + const onRejection = (reason: unknown): void => { + rejections.push(reason); + }; + process.on('unhandledRejection', onRejection); + try { + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(onTextPaste).toHaveBeenCalledOnce(); + expect(rejections).toHaveLength(0); + } finally { + process.off('unhandledRejection', onRejection); + } + }); }); 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(); @@ -279,4 +628,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 b898ec89c..b53b49a83 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -1,8 +1,9 @@ +import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; @@ -11,6 +12,17 @@ function ctrl(): AbortSignal { } const NO_FD = null; + +function resolveFdPath(): string | null { + const command = process.platform === 'win32' ? 'where' : 'which'; + const result = spawnSync(command, ['fd'], { encoding: 'utf-8' }); + if (result.status !== 0 || !result.stdout) return null; + const firstLine = result.stdout.split(/\r?\n/).find(Boolean); + return firstLine ? firstLine.trim() : null; +} + +const FD_PATH = resolveFdPath(); +const IS_FD_INSTALLED = Boolean(FD_PATH); const GOAL_COMMAND = { name: 'goal', description: 'Start or manage a goal', @@ -49,17 +61,43 @@ const HELP_FULL_COMMAND = { 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() }); @@ -73,6 +111,21 @@ describe('FileMentionProvider', () => { expect(result).toBeNull(); }); + it('opens @ file mention when typed in the middle of a slash command argument', async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); + // Cursor sits in the middle of the /goal argument text, right after a + // freshly typed `@`. The slash-argument guard must not suppress the @ + // file list here. + const line = '/goal Fix the @checkout docs'; + const result = await provider.getSuggestions([line], 0, '/goal Fix the @'.length, { + signal: ctrl(), + }); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('@'); + expect(result!.items.map((item) => item.value)).toContain('@README.md'); + }); + it('still completes slash arguments at the end of an empty argument', async () => { const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); const line = '/goal '; @@ -82,6 +135,20 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toEqual(['status']); }); + it('opens add-dir directory completions after slash command completion and entering slash', async () => { + const provider = new FileMentionProvider([ADD_DIR_COMMAND], workDir, NO_FD); + const command = ADD_DIR_COMMAND; + const completed = provider.applyCompletion(['/add'], 0, 4, { value: command.name, label: command.name }, '/add'); + const completedLine = completed.lines[0]!; + const line = `${completedLine}/`; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(completedLine).toBe('/add-dir '); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/'); + expect(result!.items.map((item) => item.value)).toEqual(['/tmp/shared/']); + }); + it('searches slash command aliases and displays aliases in the command label', async () => { const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD); const line = '/clear'; @@ -229,15 +296,118 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toContain('@src/components/Button.tsx'); }); - it('does not bypass fd filtering with filesystem suggestions when fd returns no matches', async () => { - writeFileSync(join(workDir, 'README.md'), 'readme'); - const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd')); + it('uses the filesystem fallback for additionalDirs when fd is unavailable', async () => { + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd'), [extraDir]); - const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() }); + const result = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); - expect(result).toBeNull(); + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`, + ); }); + it.runIf(IS_FD_INSTALLED)( + 'uses fd for additionalDirs even when cwd is large enough to exhaust the fallback scanner', + async () => { + // Fill cwd with enough entries to push the filesystem fallback past its + // 2000-entry scan cap, so it would never reach the additional root. fd + // searches each root independently and still finds the deep target. + for (let i = 0; i < 2000; i++) { + writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};'); + } + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'deep'), { recursive: true }); + writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, FD_PATH!, [extraDir]); + + const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`, + ); + }, + ); + + it.runIf(IS_FD_INSTALLED)( + 'treats a bare fd command name as executable and resolves it via PATH', + async () => { + // A bare "fd" (system PATH lookup) must not be mistaken for unavailable; + // otherwise the large cwd would push the fallback scanner past its cap + // and hide the deep target in the additional root. + for (let i = 0; i < 2000; i++) { + writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};'); + } + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'deep'), { recursive: true }); + writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, 'fd', [extraDir]); + + const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`, + ); + }, + ); + + it('keeps cwd @ mention values relative and additionalDir values absolute', async () => { + mkdirSync(join(workDir, 'src'), { recursive: true }); + writeFileSync(join(workDir, 'src', 'Cwd.ts'), 'export {};'); + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const cwdResult = await provider.getSuggestions(['@cwd'], 0, 4, { signal: ctrl() }); + expect(cwdResult).not.toBeNull(); + expect(cwdResult!.items.map((item) => item.value)).toContain('@src/Cwd.ts'); + + const additionalResult = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); + expect(additionalResult).not.toBeNull(); + expect(additionalResult!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`, + ); + }); + + it('deduplicates cwd and additionalDir candidates by absolute path', async () => { + const extraDir = join(workDir, 'extra'); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Overlap.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const result = await provider.getSuggestions(['@overlap'], 0, 8, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const overlapItems = result!.items.filter( + (item) => item.description === join(extraDir, 'src', 'Overlap.ts').replaceAll('\\', '/'), + ); + expect(overlapItems).toHaveLength(1); + }); + + it.runIf(IS_FD_INSTALLED)( + 'does not bypass fd filtering with filesystem suggestions when fd returns no matches', + async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([], workDir, FD_PATH!); + + const result = await provider.getSuggestions(['@zzz-no-match-xyz'], 0, '@zzz-no-match-xyz'.length, { + signal: ctrl(), + }); + + expect(result).toBeNull(); + }, + ); + it('filesystem fallback returns folders and excludes .git', async () => { mkdirSync(join(workDir, 'src')); mkdirSync(join(workDir, '.git')); @@ -307,4 +477,167 @@ describe('FileMentionProvider', () => { ); expect(dir.lines[0]).toBe('hey @src/'); }); + + describe('bash-mode path completion dotfile filtering', () => { + it('hides dot-prefixed entries (matching /add-dir) in bash mode', async () => { + mkdirSync(join(workDir, '.hidden')); + mkdirSync(join(workDir, 'visible')); + writeFileSync(join(workDir, '.dotfile'), ''); + writeFileSync(join(workDir, 'normal.txt'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('visible/'); + expect(labels).toContain('normal.txt'); + expect(labels).not.toContain('.hidden/'); + expect(labels).not.toContain('.dotfile'); + }); + + it('keeps dot-prefixed entries in prompt mode', async () => { + mkdirSync(join(workDir, '.hidden')); + writeFileSync(join(workDir, '.dotfile'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('.hidden/'); + expect(labels).toContain('.dotfile'); + }); + }); + + describe('bash-mode path applyCompletion', () => { + it('does not double the leading slash for a bare / path', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: '/Applications/', label: 'Applications/' }, + '/', + ); + expect(result.lines[0]).toBe('/Applications/'); + expect(result.cursorCol).toBe('/Applications/'.length); + }); + + it('replaces the path prefix after a command without a trailing space', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /App'], + 0, + 7, + { value: '/Applications/', label: 'Applications/' }, + '/App', + ); + expect(result.lines[0]).toBe('cd /Applications/'); + expect(result.cursorCol).toBe('cd /Applications/'.length); + }); + + it('keeps the cursor inside the closing quote for a spaced directory', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /tmp/My'], + 0, + 10, + { value: '"/tmp/My Dir/"', label: 'My Dir/' }, + '/tmp/My', + ); + expect(result.lines[0]).toBe('cd "/tmp/My Dir/"'); + // Cursor sits before the closing quote so the next `/` continues inside it. + expect(result.cursorCol).toBe('cd "/tmp/My Dir/'.length); + }); + + it('keeps pi-tui slash-command behaviour in prompt mode', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: 'help', label: 'help' }, + '/', + ); + // pi-tui's slash-command branch: beforePrefix + '/' + value + ' ' + expect(result.lines[0]).toBe('/help '); + }); + }); + + describe('bash-mode slash argument completion suppression', () => { + it('does not invoke slash argument completions for an absolute path in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [ + { value: '/should-not-appear/', label: 'should-not-appear/' }, + ]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + const text = '/add-dir/tmp/'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('should-not-appear/'); + }); + + it('does not invoke slash argument completions for a trailing-space command in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: 'list', label: 'list' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + // `/add-dir ` (trailing space) used to be re-triggered with force:false, + // which let pi-tui's own slash-command handling return subcommand + // completions. Bash mode now only ever triggers force:true path + // completion, so the argument completer must not run. + const text = '/add-dir '; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('list'); + }); + + it('keeps slash argument completion in prompt mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: '/shared/', label: 'shared/' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'prompt', + ); + + const text = '/add-dir /'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: false, + }); + + expect(getArgumentCompletions).toHaveBeenCalled(); + expect(result?.items.map((item) => item.label)).toContain('shared/'); + }); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts index d137558b9..6d3145b14 100644 --- a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts +++ b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts @@ -75,4 +75,32 @@ describe('wrapWithSideBorders', () => { // first column was a space → replaced with │; last column was 'c' → kept expect(out[1]).toBe('│ abc'); }); + + it('overlays a label on the top border, replacing leading dashes', () => { + const top = '─'.repeat(30); + const out = wrapWithSideBorders([top, ' x ', top], id, { label: ' ! shell mode ' }); + expect(out[0]).toBe(`╭ ! shell mode ${'─'.repeat(14)}╮`); + // width is preserved: corner + label + dashes + corner == input width + expect(out[0]).toHaveLength(top.length); + // bottom border is untouched + expect(out[2]).toBe(`╰${'─'.repeat(28)}╯`); + }); + + it('does not inject the label when it is wider than the top border', () => { + const out = wrapWithSideBorders(['──────', ' x ', '──────'], id, { + label: ' ! shell mode ', + }); + // falls back to a plain border — label must not leak or overflow + expect(out[0]).toBe('╭────╮'); + expect(out[0]).not.toContain('shell mode'); + }); + + it('does not inject the label onto a scroll-indicator top border', () => { + const top = '─── ↑ 5 more ────'; + const out = wrapWithSideBorders([top, ' x ', '─── ↓ 3 more ────'], id, { + label: ' ! shell mode ', + }); + expect(out[0]).toContain('↑ 5 more'); + expect(out[0]).not.toContain('shell mode'); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts b/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts index 8a8a7ba00..c4147ab40 100644 --- a/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts +++ b/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth, type SelectItem, type SelectListTheme } from '@earendil-works/pi-tui'; +import { visibleWidth, type SelectItem, type SelectListTheme } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { WrappingSelectList } from '#/tui/components/editor/wrapping-select-list'; diff --git a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts index 8e7214e11..d355bb7ed 100644 --- a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts +++ b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts @@ -155,6 +155,22 @@ describe('renderDiffLinesClustered', () => { expect(text).toContain('ctrl+o to expand'); }); + it('respects oldStart and newStart for line numbers', () => { + const text = stripAnsi( + renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'f.ts', { + contextLines: 1, + oldStart: 10, + newStart: 20, + }).join('\n'), + ); + // Context lines keep the new (post-edit) line numbers from newStart; + // deleted lines use oldStart; added lines use newStart. + expect(text).toContain(' 20 A'); + expect(text).toContain(' 11 - B'); + expect(text).toContain(' 21 + X'); + expect(text).toContain(' 22 C'); + }); + it('truncates at cluster boundary and appends the ctrl+o footer when maxLines is set', () => { const oldLines: string[] = []; for (let i = 1; i <= 50; i++) oldLines.push(`L${String(i)}`); diff --git a/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts index fb070f6c1..b6378f389 100644 --- a/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts +++ b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts @@ -1,19 +1,9 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; -const getCapabilitiesMock = vi.hoisted(() => vi.fn()); - -vi.mock('@earendil-works/pi-tui', async () => { - const actual = (await vi.importActual('@earendil-works/pi-tui')) as Record<string, unknown>; - return { - ...actual, - getCapabilities: getCapabilitiesMock, - }; -}); - const image: ImageAttachment = { id: 1, kind: 'image', @@ -26,11 +16,13 @@ const image: ImageAttachment = { describe('ImageThumbnail', () => { afterEach(() => { + resetCapabilitiesCache(); vi.restoreAllMocks(); }); it('keeps rendered output within narrow widths', () => { - getCapabilitiesMock.mockReturnValue({ images: undefined } as never); + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + const component = new ImageThumbnail(image); for (const width of [39, 20, 3, 1]) { @@ -41,7 +33,8 @@ describe('ImageThumbnail', () => { }); it('does not rebuild inline image children on repeated same-width renders', () => { - getCapabilitiesMock.mockReturnValue({ images: 'kitty' } as never); + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + const bufferFrom = vi.spyOn(Buffer, 'from'); const component = new ImageThumbnail(image); bufferFrom.mockClear(); diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts index 5018275cc..adf4d3b0b 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -1,4 +1,4 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; @@ -91,6 +91,31 @@ describe('AgentGroupComponent', () => { 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); @@ -173,4 +198,36 @@ describe('AgentGroupComponent', () => { done.dispose(); running.dispose(); }); + + it('renders a detached foreground subagent as backgrounded in the group, even after its ToolResult lands', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const b = createAgent('call_agent_2', 'write tests', 'coder', ui); + startAgent(a, 'call_agent_1', 'explore'); + startAgent(b, 'call_agent_2', 'coder'); + group.attach('call_agent_1', a); + group.attach('call_agent_2', b); + + // Detach `a` (Ctrl+B), then its spawn-success ToolResult lands. + a.markBackgrounded(); + a.setResult({ + tool_call_id: 'call_agent_1', + output: 'agent_id: sub_call_agent_1\nactual_subagent_type: explore\n', + is_error: false, + }); + + const out = renderText(group); + // `a` must show as backgrounded, NOT completed. + expect(out).toContain('◐ backgrounded'); + expect(out).not.toContain('✓ Completed'); + // `b` is still running. + expect(out).toContain('Running'); + + group.dispose(); + a.dispose(); + b.dispose(); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts index 485a171ba..ad6389418 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index 308c3aa21..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,5 +1,6 @@ -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'; @@ -7,6 +8,14 @@ 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, ''); } @@ -60,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 fbc2efbc5..e8f395ec8 100644 --- a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; diff --git a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts index 316c30af7..f098cc931 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { SwarmModeMarkerComponent } from '#/tui/components/messages/swarm-markers'; diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index b5b89ab0d..43e1aa8d1 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -55,7 +55,7 @@ describe('buildGoalReportLines', () => { expect(out).toContain('Running'); expect(out).toContain('4m 12s'); expect(out).toContain('Turns'); - expect(out).toContain('128.4k'); // formatTokenCount + expect(out).toContain('125k'); // formatTokenCount }); it('shows a no-stop-condition note for an unbounded active goal', () => { 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 63c60a12e..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,8 +1,11 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { CronMessageComponent } from '#/tui/components/messages/cron-message'; -import { NoticeMessageComponent } from '#/tui/components/messages/status-message'; +import { + NoticeMessageComponent, + StatusMessageComponent, +} from '#/tui/components/messages/status-message'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -39,3 +42,19 @@ describe('CronMessageComponent', () => { } }); }); + +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 128aa2cdd..bb501c05b 100644 --- a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts +++ b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts @@ -112,7 +112,7 @@ describe('ShellExecutionComponent', () => { describe('shellExecutionResultRenderer', () => { const longCmd = `echo ${'a'.repeat(200)}\necho done`; - it('omits the command preview when collapsed', () => { + it('renders only the result and leaves the command to the call preview', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -131,11 +131,14 @@ describe('ShellExecutionComponent', () => { .flatMap((c) => c.render(100)) .map(strip) .join('\n'); + // Command is owned by ToolCallComponent.buildCallPreview, not the + // renderer — rendering it here too would duplicate it once the result + // lands. expect(rendered).not.toContain('$ echo'); expect(rendered).toContain('ok'); }); - it('reveals the full multi-line command when expanded', () => { + it('still renders only the result when expanded', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -144,7 +147,7 @@ describe('ShellExecutionComponent', () => { }, { tool_call_id: 'call_1', - output: 'ok', + output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, { expanded: true }, @@ -154,9 +157,9 @@ describe('ShellExecutionComponent', () => { .flatMap((c) => c.render(300)) .map(strip) .join('\n'); - expect(rendered).toContain(`$ echo ${'a'.repeat(200)}`); - expect(rendered).toContain('echo done'); - expect(rendered).toContain('ok'); + expect(rendered).not.toContain('$ echo'); + expect(rendered).toContain('line4'); + expect(rendered).toContain('line5'); }); }); }); diff --git a/apps/kimi-code/test/tui/components/messages/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 ca67aded7..34eaaed7d 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 @@ -14,7 +14,7 @@ describe('status panel report lines', () => { workDir: '/tmp/project', sessionId: 'ses-1', sessionTitle: 'Implement status', - thinking: true, + thinkingEffort: 'on', permissionMode: 'manual', planMode: false, contextUsage: 0.25, @@ -30,7 +30,7 @@ describe('status panel report lines', () => { }, status: { model: 'k2', - thinkingLevel: 'high', + thinkingEffort: 'high', permission: 'auto', planMode: true, contextTokens: 3000, @@ -52,15 +52,15 @@ 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'); expect(output).toContain('Session ses-1'); expect(output).toContain('Title Implement status'); expect(output).toContain('Context window'); - expect(output).toContain('25.0%'); - expect(output).toContain('(3.0k / 12.0k)'); + expect(output).toContain('25%'); + expect(output).toContain('(2.9k / 11.7k)'); expect(output).toContain('Plan usage'); expect(output).toContain('8% used'); expect(output).not.toContain('Account'); @@ -68,6 +68,44 @@ describe('status panel report lines', () => { expect(output).not.toContain('Runtime'); }); + it('formats extra usage section in status report', () => { + const lines = buildStatusReportLines({ + version: '1.2.3', + model: 'k2', + workDir: '/tmp/project', + sessionId: 'ses-1', + sessionTitle: null, + thinkingEffort: 'off', + permissionMode: 'manual', + planMode: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + availableModels: {}, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 15000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('150.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + }); + it('falls back to app state and shows status load errors as warnings', () => { const lines = buildStatusReportLines({ version: '1.2.3', @@ -75,7 +113,7 @@ describe('status panel report lines', () => { 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 40f609be1..e615d7f5c 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth, type TUI } from '@earendil-works/pi-tui'; +import { visibleWidth, type TUI } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ThinkingComponent } from '#/tui/components/messages/thinking'; diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index b3fb71e77..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,4 +1,4 @@ -import { visibleWidth, type TUI } from '@earendil-works/pi-tui'; +import { visibleWidth, type TUI } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -49,6 +49,79 @@ 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( { @@ -113,7 +186,7 @@ describe('ToolCallComponent', () => { component.appendLiveOutput('line2\n'); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Using Bash'); + expect(out).toContain('Running a command'); expect(out).toContain('line1'); expect(out).toContain('line2'); }); @@ -136,12 +209,81 @@ describe('ToolCallComponent', () => { }); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Used Bash'); + expect(out).toContain('Ran a command'); expect(out).toContain('final-only'); expect(out).not.toContain('streamed-only'); }); - it('hides tool output bodies that start with a <system tag', () => { + 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( @@ -158,7 +300,7 @@ describe('ToolCallComponent', () => { ); const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain(`${STATUS_BULLET}Used Bash`); + expect(collapsed).toContain(`${STATUS_BULLET}Ran a command`); expect(collapsed).not.toContain('system-reminder'); expect(collapsed).not.toContain('task tools'); @@ -168,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', @@ -187,6 +329,29 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('do not show'); }); + it('renders output that merely starts with a literal <system> tag', () => { + // Tool metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so real output starting with the literal tag — + // a file that contains it, an MCP tool's text — must stay visible. + const component = new ToolCallComponent( + { + id: 'call_literal', + name: 'Bash', + args: { command: 'cat notes.txt' }, + }, + { + tool_call_id: 'call_literal', + output: '<system>literal text from a user file</system>\nsecond line', + is_error: false, + }, + ); + + component.setExpanded(true); + const out = strip(component.render(100).join('\n')); + expect(out).toContain('<system>literal text from a user file</system>'); + expect(out).toContain('second line'); + }); + it('renders AgentSwarm results as a one-line summary without raw XML', () => { const output = [ '<agent_swarm_result>', @@ -405,6 +570,34 @@ describe('ToolCallComponent', () => { 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( { @@ -738,9 +931,11 @@ describe('ToolCallComponent', () => { ); 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', () => { @@ -810,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).toContain('think2'); - expect(out).toContain('think3'); - expect(out).toContain('◌ think2'); + expect(out).not.toContain('think2'); + expect(out).not.toContain('think3'); expect(out).not.toContain('answer1'); - expect(out).not.toContain('answer2'); + expect(out).toContain('answer2'); expect(out).toContain('answer3'); - expect(out).toContain('└ answer3'); + expect(out).toContain('│ answer3'); vi.setSystemTime(22_000); component.onSubagentCompleted({ resultSummary: 'summary fallback' }); @@ -831,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( @@ -867,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( @@ -912,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( @@ -935,24 +1178,17 @@ describe('ToolCallComponent', () => { agentName: 'explore', runInBackground: false, }); - component.appendSubagentText( - 'thinking words that should wrap with a clean hanging indent', - 'thinking', - ); component.appendSubagentText( 'output words that should also wrap with a clean hanging indent', 'text', ); - const lines = strip(component.render(34).join('\n')).split('\n'); - // Thinking is scrolled to its last two display rows, so the head of the - // wrapped paragraph drops and the ◌ marker hangs on the first kept row. - expect(lines.some((l) => l.includes('◌ wrap with a clean hanging'))).toBe(true); - expect(lines.join('\n')).not.toContain('thinking words that should'); - expect(lines).toContain(' indent '); - // Output keeps its full hanging-indent wrap (unchanged behavior). - expect(lines).toContain(' └ output words that should also '); - expect(lines).toContain(' wrap with a clean hanging '); + const joined = strip(component.render(34).join('\n')); + // The two-row window drops the head of the wrapped paragraph. + expect(joined).not.toContain('output words that should'); + // Every kept row carries the `│` gutter as a hanging indent. + expect(joined).toContain('│ wrap with a clean hanging'); + expect(joined).toContain('│ indent'); }); it('scrolls single subagent thinking to the last two display rows', () => { @@ -983,7 +1219,7 @@ describe('ToolCallComponent', () => { expect(lines.join('\n')).not.toContain('seg00'); }); - it('shows and truncates a single subagent Bash tool output', () => { + it('shows a two-row tail of an ongoing subagent Bash output', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -1005,25 +1241,25 @@ describe('ToolCallComponent', () => { args: { command: 'ls -la' }, }); const output = Array.from({ length: 10 }, (_, i) => `bash-line-${String(i)}`).join('\n'); - component.finishSubToolCall({ tool_call_id: 'sub_bash:cmd', output, is_error: false }); + component.appendSubToolLiveOutput('sub_bash:cmd', output); let out = strip(component.render(120).join('\n')); - expect(out).toContain('Used Bash (ls -la)'); - expect(out).toContain('bash-line-0'); - expect(out).toContain('bash-line-2'); - expect(out).not.toContain('bash-line-3'); - expect(out).toContain('... (7 more lines)'); - // Subagent output is fixed-truncated: no ctrl+o promise. + expect(out).toContain('Using Bash (ls -la)'); + // The active window keeps only the last two rows of live output. + expect(out).toContain('bash-line-8'); + expect(out).toContain('bash-line-9'); + expect(out).not.toContain('bash-line-7'); + // No ctrl+o promise for the subagent window. expect(out).not.toContain('ctrl+o'); - // The global ctrl+o expand toggle must NOT expand subagent output. + // The global ctrl+o expand toggle must NOT expand the window. component.setExpanded(true); out = strip(component.render(120).join('\n')); - expect(out).not.toContain('bash-line-9'); - expect(out).toContain('... (7 more lines)'); + expect(out).toContain('bash-line-9'); + expect(out).not.toContain('bash-line-7'); }); - it('truncates unknown subagent tool output but leaves recognized tools as rows', () => { + it('shows live output for generic subagent tools but not for recognized ones', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -1039,6 +1275,7 @@ describe('ToolCallComponent', () => { agentName: 'explore', runInBackground: false, }); + // A finished recognized tool: its output body never reaches the window. component.appendSubToolCall({ id: 'sub_mixed:read', name: 'Read', @@ -1049,23 +1286,22 @@ describe('ToolCallComponent', () => { output: 'recognized-read-body\nhidden-read-line', is_error: false, }); + // An ongoing generic (MCP) tool: its live output is the active stream. component.appendSubToolCall({ id: 'sub_mixed:mcp', name: 'mcp__server__do', args: {}, }); const mcpOut = Array.from({ length: 5 }, (_, i) => `mcp-line-${String(i)}`).join('\n'); - component.finishSubToolCall({ tool_call_id: 'sub_mixed:mcp', output: mcpOut, is_error: false }); + component.appendSubToolLiveOutput('sub_mixed:mcp', mcpOut); const out = strip(component.render(120).join('\n')); - // Recognized tool: activity row only, no output body. - expect(out).toContain('Used Read (foo.ts)'); + // Recognized tool output never appears. expect(out).not.toContain('recognized-read-body'); - // Unknown/MCP tool: truncated output body, no ctrl+o promise. - expect(out).toContain('mcp-line-0'); - expect(out).toContain('mcp-line-2'); - expect(out).not.toContain('mcp-line-3'); - expect(out).toContain('... (2 more lines)'); + // Generic tool output shows as the two-row active window tail. + expect(out).toContain('mcp-line-3'); + expect(out).toContain('mcp-line-4'); + expect(out).not.toContain('mcp-line-2'); expect(out).not.toContain('ctrl+o'); }); @@ -1091,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 diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts index ab4a53fd6..691f1d88a 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { @@ -38,7 +38,6 @@ function imageOutput(path: string, b64 = PNG_B64, mime = 'image/png'): string { { type: 'text', text: `<image path="${path}">` }, { type: 'image_url', imageUrl: { url: `data:${mime};base64,${b64}` } }, { type: 'text', text: '</image>' }, - { type: 'text', text: `Loaded image file "${path}" (${mime}, 70 bytes, original size 1x1px).` }, ]); } @@ -58,7 +57,6 @@ describe('parseReadMediaOutput', () => { expect(m?.path).toBe('/tmp/a.png'); expect(m?.mimeType).toBe('image/png'); expect(m?.bytes).toBeGreaterThan(0); - expect(m?.originalSize).toBe('1x1px'); }); it('extracts video kind and mime', () => { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 7dcd55bed..6570aac46 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts index bcab35a48..4c90c0392 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { TruncatedOutputComponent } from '#/tui/components/messages/tool-renderers/truncated'; @@ -74,4 +74,17 @@ describe('TruncatedOutputComponent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(37); } }); + + it('renders output verbatim, including literal <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 5540b9b75..199031896 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it } from 'vitest'; import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; @@ -24,7 +24,7 @@ describe('UsagePanelComponent', () => { output: 250, }, }, - } as never, + }, contextUsage: 0.25, contextTokens: 2500, maxContextTokens: 10000, @@ -40,14 +40,150 @@ describe('UsagePanelComponent', () => { }).map(strip); expect(lines).toContain('Session usage'); - expect(lines).toContain(' kimi input 2.0k output 250 total 2.3k'); + expect(lines).toContain(' kimi input 2k output 250 total 2.2k'); expect(lines).toContain('Context window'); - expect(lines.join('\n')).toContain('25.0%'); + expect(lines.join('\n')).toContain('25%'); expect(lines).toContain('Plan usage'); expect(lines.join('\n')).toContain('20% used'); expect(lines.join('\n')).toContain('resets tomorrow'); }); + it('formats extra usage with a monthly limit', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(lines).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('100.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + // bar row contains block glyphs but no percentage text + expect(output).toContain('░'); + }); + + it('formats extra usage without a monthly limit and omits the progress bar', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 18208, + totalCents: 40000, + monthlyChargeLimitEnabled: false, + monthlyChargeLimitCents: 0, + monthlyUsedCents: 21792, + currency: 'CNY', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(lines).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('¥182.08'); + expect(output).toContain('Used this month'); + expect(output).toContain('¥217.92'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('Unlimited'); + expect(output).not.toContain('░'); + expect(output).not.toContain('█'); + }); + + it('omits the extra usage section when extraUsage is omitted or null', () => { + for (const extraUsage of [undefined, null]) { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { summary: null, limits: [], extraUsage }, + }).map(strip); + + expect(lines).not.toContain('Extra Usage'); + } + }); + + it('formats extra usage with CNY currency', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'CNY', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Balance'); + expect(output).toContain('100.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + }); + + it('aligns the currency symbol and decimal point across extra usage rows', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 15901, + totalCents: 300000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 300000, + monthlyUsedCents: 24099, + currency: 'CNY', + }, + }, + }).map(strip); + + const extraRows = lines.filter((line) => line.includes('¥')); + expect(extraRows).toHaveLength(3); + // The currency symbol stays in one column... + expect(new Set(extraRows.map((line) => line.indexOf('¥'))).size).toBe(1); + // ...and the right-aligned numeric parts end in the same column, so the + // decimal points line up across rows. + expect(new Set(extraRows.map((line) => line.length)).size).toBe(1); + }); + it('wraps preformatted usage lines in a bordered panel', () => { const component = new UsagePanelComponent(() => ['Session usage'], 'primary'); const output = component.render(80).map(strip); diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index 4c2cb3642..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,15 +1,21 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -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]', [], @@ -23,6 +29,8 @@ describe('UserMessageComponent', () => { }); 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]) { @@ -31,4 +39,69 @@ describe('UserMessageComponent', () => { } } }); + + 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 a9cc544fe..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 @@ -12,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, 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 fb1cfd5fe..100f5f6a3 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, @@ -43,32 +44,50 @@ function baseState(overrides: Partial<AppState> = {}): AppState { } describe('FooterComponent — context NaN resilience', () => { - it('NaN usage → renders 0.0% (never literal "NaN%")', () => { + it('NaN usage → renders 0% (never literal "NaN%")', () => { 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%/); + expect(out).toMatch(/context: 0%/); }); - it('undefined-ish (coerced) usage → renders 0.0%', () => { + it('undefined-ish (coerced) usage → renders 0%', () => { const fc = new FooterComponent( baseState({ contextUsage: undefined as unknown as number }), ); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); - expect(out).toMatch(/context: 0\.0%/); + expect(out).toMatch(/context: 0%/); }); - it('clamps ratios above 1.0 → renders 100.0%', () => { + it('clamps ratios above 1.0 → renders 100%', () => { const fc = new FooterComponent(baseState({ contextUsage: 1.5 })); const out = strip(fc.render(120).join('')); - expect(out).toMatch(/context: 100\.0%/); + expect(out).toMatch(/context: 100%/); }); - it('ratio 0.427 → renders 42.7%', () => { + it('ratio 0.427 → renders 43% (ceiled whole percent)', () => { const fc = new FooterComponent(baseState({ contextUsage: 0.427 })); const out = strip(fc.render(200).join('')); - expect(out).toMatch(/context: 42\.7%/); + expect(out).toMatch(/context: 43%/); + }); + + it('tiny non-zero usage → renders 1% (ceil floor)', () => { + const fc = new FooterComponent(baseState({ contextUsage: 0.0004 })); + const out = strip(fc.render(200).join('')); + expect(out).toMatch(/context: 1%/); + }); + + it('valid tokens/maxTokens → percent from tokens, counts in 1024 units', () => { + const fc = new FooterComponent( + baseState({ + contextUsage: 0.427, + contextTokens: 430_080, + maxContextTokens: 1_048_576, + }), + ); + const out = strip(fc.render(200).join('')); + expect(out).toMatch(/context: 42% \(420k\/1M\)/); }); it('tokens provided but max=0 → falls back to percent-only, no division-by-zero artefact', () => { @@ -77,7 +96,7 @@ describe('FooterComponent — context NaN resilience', () => { ); const out = strip(fc.render(200).join('')); expect(out).not.toMatch(/Infinity|NaN/); - expect(out).toMatch(/context: 0\.0%/); + expect(out).toMatch(/context: 0%/); // With maxTokens=0, token-count annotation is suppressed. expect(out).not.toMatch(/\(500\//); }); @@ -90,12 +109,12 @@ describe('FooterComponent — context NaN resilience', () => { const out = strip(footer.render(200).join('')); expect(out).toContain('kimi-k2-5'); expect(out).not.toContain(' k2 '); - expect(out).toMatch(/context: 50\.0%/); + expect(out).toMatch(/context: 50%/); }); it('shows "thinking" label when thinking is enabled, hides it when disabled', () => { - const on = new FooterComponent(baseState({ model: 'k2', thinking: true })); - const off = new FooterComponent(baseState({ model: 'k2', thinking: false })); + 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'); @@ -108,7 +127,7 @@ describe('FooterComponent — context NaN resilience', () => { const [, line2] = footer.render(120); expect(strip(line2 ?? '')).toContain('Press Ctrl-C again to exit'); - expect(strip(line2 ?? '')).toContain('context: 0.0%'); + expect(strip(line2 ?? '')).toContain('context: 0%'); }); it('highlights the pull request badge separately from git status text', () => { diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts index d04c9c279..982be0657 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -13,10 +13,11 @@ function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp/proj', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 200_000, @@ -57,7 +58,7 @@ describe('FooterComponent — goal badge', () => { it('omits the badge when there is no goal', () => { const footer = new FooterComponent(baseState({ goal: null })); - expect(strip(footer.render(160)[0]!)).not.toMatch(/goal/); + expect(strip(footer.render(160)[0]!)).not.toContain('[goal'); }); it('shows status, elapsed, and a raw turn count for an unbounded active goal', () => { @@ -115,7 +116,7 @@ describe('FooterComponent — goal badge', () => { it('hides the badge for a completed goal', () => { const footer = new FooterComponent(baseState({ goal: goal({ status: 'complete' }) })); - expect(strip(footer.render(160)[0]!)).not.toMatch(/goal/); + expect(strip(footer.render(160)[0]!)).not.toContain('[goal'); }); it('singularizes a single turn', () => { 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 4d079a1e9..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,4 +1,6 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +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'; @@ -70,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); }); 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 04e3f1b88..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,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { TodoPanelComponent, + formatHiddenCounts, selectVisibleTodos, type TodoItem, } from '#/tui/components/chrome/todo-panel'; @@ -88,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', () => { @@ -238,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 ca276a138..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 @@ -82,4 +82,41 @@ describe('QueuePaneComponent', () => { 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 21c4f4b6c..c4c5035cd 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -59,12 +59,22 @@ auto_install = false expect(config).toEqual({ theme: 'light', + disablePasteBurst: false, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, }); }); + it('parses disable_paste_burst', () => { + const config = parseTuiConfig(` +theme = "dark" +disable_paste_burst = true +`); + + expect(config.disablePasteBurst).toBe(true); + }); + it('normalizes an empty editor command to auto-detect', () => { const config = parseTuiConfig(` [editor] @@ -73,6 +83,7 @@ command = " " expect(config).toEqual({ theme: 'auto', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -104,6 +115,7 @@ command = " " await saveTuiConfig( { theme: 'light', + disablePasteBurst: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -113,6 +125,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', + disablePasteBurst: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -124,6 +137,7 @@ command = " " await saveTuiConfig( { theme, + disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, diff --git a/apps/kimi-code/test/tui/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..8f15e6842 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -0,0 +1,287 @@ +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>; + readonly cancelCompaction: ReturnType<typeof vi.fn>; + readonly btwCancelRunning: ReturnType<typeof vi.fn>; + readonly btwCloseOrCancel: 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, + getText: vi.fn(() => '') as unknown as (...args: never[]) => unknown, + setText: vi.fn() as unknown as (...args: never[]) => unknown, + }; + const openUndoSelector = vi.fn(); + const cancelRunningShellCommand = vi.fn(); + const cancelCompaction = vi.fn(async () => {}); + const btwCancelRunning = vi.fn(() => false); + const btwCloseOrCancel = vi.fn(() => false); + const session = { cancel: vi.fn(async () => {}), cancelCompaction }; + + 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: { cancelRunning: btwCancelRunning, closeOrCancel: btwCloseOrCancel }, + openUndoSelector, + cancelRunningShellCommand, + } as unknown as EditorKeyboardHost; + + const controller = new EditorKeyboardController( + host, + undefined as unknown as ImageAttachmentStore, + ); + controller.install(); + + return { + host, + editor, + openUndoSelector, + cancelRunningShellCommand, + cancelCompaction, + btwCancelRunning, + btwCloseOrCancel, + }; +} + +function pressEscape(editor: Harness['editor']): void { + const handler = editor['onEscape']; + if (handler === undefined) throw new Error('onEscape handler not installed'); + (handler as () => void)(); +} + +function pressCtrlC(editor: Harness['editor']): void { + const handler = editor['onCtrlC']; + if (handler === undefined) throw new Error('onCtrlC 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 btw panel priority', () => { + it('Esc closes the btw panel first while compacting, without cancelling compaction', () => { + const { editor, btwCloseOrCancel, cancelCompaction } = createHarness({ isCompacting: true }); + btwCloseOrCancel.mockReturnValue(true); + + pressEscape(editor); + + expect(btwCloseOrCancel).toHaveBeenCalledOnce(); + expect(cancelCompaction).not.toHaveBeenCalled(); + }); + + it('Esc cancels compaction on the next press once the btw panel is gone', () => { + const { editor, btwCloseOrCancel, cancelCompaction } = createHarness({ isCompacting: true }); + btwCloseOrCancel.mockReturnValueOnce(true); + + pressEscape(editor); + expect(cancelCompaction).not.toHaveBeenCalled(); + + pressEscape(editor); + expect(cancelCompaction).toHaveBeenCalledOnce(); + }); + + it('Esc cancels compaction directly when no btw panel is open', () => { + const { editor, btwCloseOrCancel, cancelCompaction } = createHarness({ isCompacting: true }); + + pressEscape(editor); + + expect(btwCloseOrCancel).toHaveBeenCalledOnce(); + expect(cancelCompaction).toHaveBeenCalledOnce(); + }); + + it('Ctrl+C cancels a running btw question first while compacting', () => { + const { editor, btwCancelRunning, cancelCompaction } = createHarness({ isCompacting: true }); + btwCancelRunning.mockReturnValue(true); + + pressCtrlC(editor); + + expect(btwCancelRunning).toHaveBeenCalledOnce(); + expect(cancelCompaction).not.toHaveBeenCalled(); + }); + + it('Ctrl+C closes an idle btw panel while compacting, without cancelling compaction', () => { + const { editor, btwCloseOrCancel, cancelCompaction } = createHarness({ isCompacting: true }); + btwCloseOrCancel.mockReturnValue(true); + + pressCtrlC(editor); + + expect(btwCloseOrCancel).toHaveBeenCalledOnce(); + expect(cancelCompaction).not.toHaveBeenCalled(); + }); + + it('Ctrl+C cancels compaction when no btw panel is open', () => { + const { editor, btwCancelRunning, btwCloseOrCancel, cancelCompaction } = createHarness({ + isCompacting: true, + }); + + pressCtrlC(editor); + + expect(btwCancelRunning).toHaveBeenCalledOnce(); + expect(btwCloseOrCancel).toHaveBeenCalledOnce(); + expect(cancelCompaction).toHaveBeenCalledOnce(); + }); +}); + +describe('EditorKeyboardController shell history recall', () => { + type Recall = (entry: string, direction: 1 | -1) => string | undefined; + type Mock = ReturnType<typeof vi.fn>; + + it('installs a filter that allows shell entries only in bash mode', () => { + const { editor } = createHarness(); + const setHistoryFilter = editor['setHistoryFilter'] as unknown as Mock; + expect(setHistoryFilter).toHaveBeenCalledOnce(); + const [filter] = setHistoryFilter.mock.calls[0] as [(entry: string) => boolean]; + + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + expect(filter('!cmd')).toBe(true); + expect(filter('hello')).toBe(true); + + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + expect(filter('!cmd')).toBe(true); + expect(filter('hello')).toBe(false); + }); + + it('locks the filter to the browse-entry mode once browsing starts', () => { + const { editor } = createHarness(); + const setHistoryFilter = editor['setHistoryFilter'] as unknown as Mock; + const [filter] = setHistoryFilter.mock.calls[0] as [(entry: string) => boolean]; + const save = editor['onHistoryDraftSave'] as unknown as () => unknown; + + // Enter browse from prompt mode, then simulate landing on a shell entry + // (which flips inputMode to bash). The filter should stay locked to prompt + // and keep allowing plain entries. + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + save(); + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + + expect(filter('hello')).toBe(true); + expect(filter('!cmd')).toBe(true); + }); + + it('strips the leading ! and switches to bash mode when recalling a shell entry', () => { + const { editor } = createHarness(); + const onRecall = editor['onRecall'] as unknown as Recall; + + const result = onRecall('!cmd', -1); + + expect(result).toBe('cmd'); + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('bash'); + }); + + it('keeps plain entries as-is and switches to prompt mode', () => { + const { editor } = createHarness(); + const onRecall = editor['onRecall'] as unknown as Recall; + + const result = onRecall('hello', -1); + + expect(result).toBeUndefined(); + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt'); + }); + + it('saves the current input mode as the history draft host state', () => { + const { editor } = createHarness(); + const save = editor['onHistoryDraftSave'] as unknown as () => unknown; + + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + expect(save()).toBe('prompt'); + + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + expect(save()).toBe('bash'); + }); + + it('restores the input mode from the saved draft host state', () => { + const { editor } = createHarness(); + const restore = editor['onHistoryDraftRestore'] as unknown as (state: unknown) => void; + + restore('prompt'); + + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt'); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 4794d1ab4..8b9d5fbdf 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -54,6 +54,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { permissionMode: 'auto', }, queuedMessages: [], + queuedMessageDispatchPending: false, theme: { palette: getBuiltInPalette('dark') }, toolOutputExpanded: false, todoPanel: { getTodos: vi.fn(() => []) }, @@ -68,10 +69,14 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { flushNow: vi.fn(), resetToolUi: vi.fn(), finalizeTurn: vi.fn(), + hasActiveTurn: vi.fn(() => false), hasThinkingDraft: vi.fn(() => false), flushThinkingToTranscript: vi.fn(), appendAssistantDelta: vi.fn(), scheduleFlush: vi.fn(), + beginCompaction: vi.fn(), + endCompaction: vi.fn(), + cancelCompaction: vi.fn(), }, requireSession: vi.fn(() => session), setAppState: vi.fn(), @@ -139,6 +144,20 @@ function turnEndedEvent() { } as const; } +function compactionCompletedEvent() { + return { + type: 'compaction.completed', + sessionId: 's1', + agentId: 'main', + result: { + summary: 'summary', + tokensBefore: 100, + tokensAfter: 10, + compactedCount: 1, + }, + } as const; +} + function modelBlockedEvent() { return { type: 'goal.updated', @@ -185,7 +204,6 @@ describe('SessionEventHandler goal queue promotion', () => { text: 'Ship queued goal', }); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - expect(host.track).toHaveBeenCalledWith('goal_create', { replace: false }); }); it('waits for queued user input to drain before promoting the next queued goal', async () => { @@ -235,6 +253,76 @@ describe('SessionEventHandler goal queue promotion', () => { expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); }); + it('defers queued-goal promotion while a queued message is mid-dispatch', async () => { + const { host, session } = makeHost(); + host.state.appState.streamingPhase = 'idle'; + host.state.queuedMessages = []; + // The queue looks empty and the phase is idle, but a shifted queued message + // is still awaiting its deferred send. Promotion must not jump ahead of it. + host.state.queuedMessageDispatchPending = true; + const handler = new SessionEventHandler(host); + + handler.requestQueuedGoalPromotion(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + + // Once the queued message has been dispatched, the flag clears and the + // promotion proceeds on the next retry. + host.state.queuedMessageDispatchPending = false; + handler.retryQueuedGoalPromotion(); + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + }); + + it('waits for a queued user input drained after compaction before promoting the next queued goal', async () => { + const { host, session } = makeHost(); + host.state.appState.isCompacting = true; + host.state.queuedMessages = [{ text: 'queued user turn' }]; + host.shiftQueuedMessage.mockImplementation(() => host.state.queuedMessages.shift()); + const handler = new SessionEventHandler(host); + host.setAppState.mockImplementation((patch: Record<string, unknown>) => { + const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; + Object.assign(host.state.appState, patch); + if (busyChanged) handler.retryQueuedGoalPromotion(); + }); + host.sendQueuedMessage.mockImplementation((_session: unknown, item: { text: string }) => { + if (item.text === 'queued user turn') { + host.setAppState({ streamingPhase: 'waiting' }); + } + }); + const sendQueued = sendQueuedViaHost(host, session); + + handler.requestQueuedGoalPromotion(); + handler.handleEvent(compactionCompletedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { text: 'queued user turn' }); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + const sendQueuedCalls = host.sendQueuedMessage.mock.calls as Array<[unknown, { text?: string }]>; + const userMessageIndex = sendQueuedCalls.findIndex( + ([, item]) => item.text === 'queued user turn', + ); + expect(userMessageIndex).toBeGreaterThanOrEqual(0); + expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); + const userMessageOrder = host.sendQueuedMessage.mock.invocationCallOrder[userMessageIndex]!; + const goalCreateOrder = session.createGoal.mock.invocationCallOrder[0]!; + expect(userMessageOrder).toBeLessThan(goalCreateOrder); + }); + it('leaves the queued goal in place when the next goal cannot start', async () => { const { host, session } = makeHost({ createGoalRejects: true }); const handler = new SessionEventHandler(host); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 8be57e91c..0899cf070 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -8,11 +8,13 @@ function fakeInitialAppState(): AppState { return { model: 'test-model', workDir: '/tmp/kimi-test', + additionalDirs: [], sessionId: 'sess-1', permissionMode: 'manual', planMode: false, + inputMode: 'prompt', swarmMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -61,6 +63,7 @@ describe('createTUIState', () => { // 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'); 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 da8df93ce..567270558 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,48 +1,75 @@ 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 { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; 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 { ThinkingComponent } from '#/tui/components/messages/thinking'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, } from '#/tui/components/dialogs/plugins-selector'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { handleFeedbackCommand } from '#/tui/commands/info'; +import { packageCodebase, scanCodebase } from '../../src/feedback/codebase'; +import { uploadArchive } from '../../src/feedback/upload'; import { + promptFeedbackAttachment, promptFeedbackInput, runModelSelector, + type FeedbackPromptResult, } from '#/tui/commands/prompts'; import type { QueuedMessage } from '#/tui/types'; import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal<typeof import('#/tui/commands/prompts')>(); - return { ...actual, promptFeedbackInput: vi.fn() }; + return { + ...actual, + promptFeedbackInput: vi.fn(), + promptFeedbackAttachment: vi.fn(), + }; }); +vi.mock('../../src/feedback/codebase', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../src/feedback/codebase')>(); + return { + ...actual, + scanCodebase: vi.fn().mockResolvedValue(undefined), + packageCodebase: vi.fn(), + }; +}); + +vi.mock('../../src/feedback/upload', () => ({ + uploadArchive: vi.fn(), +})); + +// /feedback falls back to opening GitHub Issues in a browser when not signed in +// or when submission fails — stub it out so the test suite never spawns a +// browser window. vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); const ESC = String.fromCodePoint(0x1b); @@ -64,12 +91,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 { @@ -102,6 +130,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -112,6 +141,8 @@ function makeStartupInput(): KimiTUIStartupInput { } function makeSession(overrides: Record<string, unknown> = {}) { + let model = 'k2'; + let thinkingEffort = 'off'; return { id: 'ses-1', model: 'k2', @@ -124,8 +155,8 @@ function makeSession(overrides: Record<string, unknown> = {}) { cancel: vi.fn(async () => {}), cancelCompaction: vi.fn(async () => {}), getStatus: vi.fn(async () => ({ - model: 'k2', - thinkingLevel: 'off', + model, + thinkingEffort, permission: 'manual', planMode: false, contextTokens: 0, @@ -135,8 +166,12 @@ function makeSession(overrides: Record<string, unknown> = {}) { 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 () => {}), @@ -149,7 +184,7 @@ function makeSession(overrides: Record<string, unknown> = {}) { main: { status: { model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, @@ -173,12 +208,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, @@ -212,6 +249,12 @@ 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(), @@ -228,8 +271,11 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> logout: vi.fn(), getManagedUsage: vi.fn(), submitFeedback: vi.fn( - async (): Promise<{ kind: 'ok' } | { kind: 'error'; status?: number; message: string }> => ({ + async (): Promise< + { kind: 'ok'; feedbackId: number } | { kind: 'error'; status?: number; message: string } + > => ({ kind: 'ok', + feedbackId: 3, }), ), }, @@ -323,6 +369,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)) { @@ -355,7 +409,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']; @@ -363,7 +416,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); @@ -466,8 +518,9 @@ command = "vim" }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); - harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok' }); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); harness.track.mockClear(); await handleFeedbackCommand(feedbackDriver as any); @@ -481,6 +534,309 @@ command = "vim" }), ); expect(harness.track).toHaveBeenCalledWith('feedback_submitted', undefined); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + }); + + it('submits text feedback before preparing requested attachments', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + + const zipPath = await makeExportedSessionZip(); + let resolveExport!: () => void; + const exportBlocked = new Promise<{ + zipPath: string; + entries: string[]; + sessionDir: string; + manifest: Record<string, never>; + }>((resolve) => { + resolveExport = () => { + resolve({ + zipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + }; + }); + harness.exportSession.mockImplementationOnce(() => exportBlocked); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ses-1', + includeGlobalLog: true, + version: '0.0.0-test', + }), + ); + }); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.objectContaining({ content: 'useful feedback' }), + ); + expect(harness.auth.submitFeedback.mock.invocationCallOrder[0]).toBeLessThan( + harness.exportSession.mock.invocationCallOrder[0]!, + ); + expect(settled).toBe(false); + + resolveExport(); + await command; + }); + + it('waits for the codebase upload to finish before returning', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([ + { id: 'ses-1', sessionDir: '/tmp/session-a' }, + ] as never); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + const sessionZipPath = await makeExportedSessionZip(); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + + let resolveCodebaseUpload!: () => void; + const codebaseUploadBlocked = new Promise<void>((resolve) => { + resolveCodebaseUpload = resolve; + }); + vi.mocked(uploadArchive).mockImplementation((_api, archive) => { + if (archive.path === sessionZipPath) return Promise.resolve(); + return codebaseUploadBlocked; + }); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(uploadArchive).toHaveBeenCalledTimes(2); + }); + expect(settled).toBe(false); + + resolveCodebaseUpload(); + await command; + expect(settled).toBe(true); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: '/tmp/fake-codebase.zip' }), + 3, + { filename: 'repo.zip' }, + ); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.not.objectContaining({ info: expect.anything() }), + ); + }); + + it('uploads session logs when codebase scanning fails but the session directory is available', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + vi.mocked(scanCodebase).mockRejectedValueOnce(new Error('scan failed')); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses-1', includeGlobalLog: true }), + ); + expect(packageCodebase).not.toHaveBeenCalled(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when feedback is sent but codebase packaging fails', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + vi.mocked(packageCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockRejectedValueOnce(new Error('zip failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + const calls = harness.auth.submitFeedback.mock.calls as unknown as Array<[Record<string, unknown>]>; + expect(calls[0]?.[0]?.['info']).toBeUndefined(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when the codebase upload fails', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + vi.mocked(uploadArchive).mockRejectedValueOnce(new Error('upload failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); }); it('shows feedback API error messages without replacing them with HTTP status text', async () => { @@ -499,7 +855,8 @@ command = "vim" }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'error', status: 500, @@ -563,7 +920,7 @@ command = "vim" const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 0, @@ -1025,6 +1382,49 @@ command = "vim" expect(transcript).not.toContain('Approved: Run shell command'); }); + it('removes debug timing status from undone turns', async () => { + const { driver, session } = await makeDriver(); + const previousDebug = process.env['KIMI_CODE_DEBUG']; + process.env['KIMI_CODE_DEBUG'] = '1'; + try { + driver.handleUserInput('hello'); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.completed', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + llmFirstTokenLatencyMs: 120, + llmStreamDurationMs: 800, + } as Event, + () => {}, + ); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('[Debug]'); + }); + + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('[Debug]'); + } finally { + if (previousDebug === undefined) { + delete process.env['KIMI_CODE_DEBUG']; + } else { + process.env['KIMI_CODE_DEBUG'] = previousDebug; + } + } + }); + it('undoes multiple turns when a count is provided', async () => { const { driver, session } = await makeDriver(); @@ -1242,6 +1642,370 @@ command = "vim" } }); + it('queues bash input with mode bash while a turn is streaming', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('dispatches a queued bash item to runShellCommand instead of prompt', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver } = await makeDriver(session); + + driver.sendQueuedMessage(session, { text: 'ls', mode: 'bash' }); + await Promise.resolve(); + + expect(runShellCommand).toHaveBeenCalledWith( + 'ls', + expect.objectContaining({ commandId: expect.any(String) }), + ); + expect(session.prompt).not.toHaveBeenCalled(); + }); + + it('persists bash input to input history with a leading !', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(driver.persistInputHistory).toHaveBeenCalledWith('!ls'); + }); + + it('persists normal input to input history', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('hello'); + + expect(driver.persistInputHistory).toHaveBeenCalledWith('hello'); + }); + + it('does not steer queued bash commands, keeping them queued', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [ + { text: 'ls', agentId: 'main', mode: 'bash' }, + { text: 'focus on tests', agentId: 'main' }, + ]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).toHaveBeenCalledWith('focus on tests'); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('does not steer while a shell command is running', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'shell'; + driver.state.queuedMessages = [{ text: 'summarize the output', agentId: 'main' }]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'summarize the output', agentId: 'main' }, + ]); + }); + + it('does not steer the editor draft while it is in bash mode', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.inputMode = 'bash'; + driver.state.editor.setText('ls'); + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.editor.getText()).toBe('ls'); + }); + + it('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(); @@ -1327,7 +2091,7 @@ command = "vim" await vi.runOnlyPendingTimersAsync(); expect(updateSpy).toHaveBeenCalledTimes(1); - expect(updateSpy).toHaveBeenLastCalledWith('abc'); + expect(updateSpy).toHaveBeenLastCalledWith('abc', { transient: true }); } finally { vi.useRealTimers(); } @@ -1424,6 +2188,30 @@ command = "vim" expect(session.cancelCompaction).toHaveBeenCalledTimes(1); }); + it('clears editor text before cancelling compaction on Ctrl-C', async () => { + const { driver, session } = await makeDriver(); + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + vi.fn(), + ); + driver.state.editor.setText('draft while compacting'); + + driver.state.editor.onCtrlC?.(); + + expect(driver.state.editor.getText()).toBe(''); + expect(session.cancelCompaction).not.toHaveBeenCalled(); + expect(driver.state.appState.isCompacting).toBe(true); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancelCompaction).toHaveBeenCalledTimes(1); + }); + it('dispatches the next queued message after compaction is cancelled', async () => { vi.useFakeTimers(); try { @@ -1462,6 +2250,83 @@ command = "vim" } }); + it('stores the live compaction summary and expands it with tool output expansion', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.completed', + agentId: 'main', + sessionId: 'ses-1', + result: { + summary: 'Keep the src/tui compaction notes.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + } as Event, + sendQueued, + ); + + const collapsed = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).not.toContain('Keep the src/tui compaction notes.'); + + driver.state.editor.onToggleToolExpand?.(); + + const expanded = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(driver.state.toolOutputExpanded).toBe(true); + expect(expanded).toContain('Keep the src/tui compaction notes.'); + }); + + it('honors existing tool output expansion when a compaction block is created', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.state.editor.onToggleToolExpand?.(); + expect(driver.state.toolOutputExpanded).toBe(true); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.completed', + agentId: 'main', + sessionId: 'ses-1', + result: { + summary: 'Keep the src/tui compaction notes.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + } as Event, + sendQueued, + ); + + const transcript = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(transcript).toContain('Compaction complete'); + expect(transcript).toContain('Keep the src/tui compaction notes.'); + }); + it('renders an error instead of prompting when no model is selected', async () => { const { driver, session } = await makeDriver(); driver.state.appState.model = ''; @@ -2130,6 +2995,24 @@ command = "vim" 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(); @@ -2293,6 +3176,45 @@ command = "vim" expect(transcript).not.toContain('/export-debug-zip'); }); + 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(); @@ -2925,7 +3847,7 @@ command = "vim" const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'high', + thinkingEffort: 'high', permission: 'auto', planMode: true, contextTokens: 25, @@ -2945,11 +3867,11 @@ command = "vim" expect(output).toContain(' Status '); expect(output).toContain('>_ Kimi Code'); expect(output).toContain('Model'); - expect(output).toContain('thinking on'); + expect(output).toContain('thinking high'); expect(output).toContain('Permissions auto'); expect(output).toContain('Plan mode on'); expect(output).toContain('Context window'); - expect(output).toContain('25.0%'); + expect(output).toContain('25%'); }); }); @@ -3069,15 +3991,46 @@ command = "vim" expect(session.installPlugin).not.toHaveBeenCalled(); }); - it('installs from a positional source on /plugins install', async () => { + it('installs from a positional source on /plugins install after trusting it', async () => { const session = makeSession(); const { driver } = await makeDriver(session); driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith('/tmp/proj-a/plugins/kimi-datasource'); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith( + resolve('/tmp/proj-a', './plugins/kimi-datasource'), + ); + }); + }); + + it('does not install when the third-party trust prompt is dismissed', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\r'); // default option is "Exit" + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + }); + expect(session.installPlugin).not.toHaveBeenCalled(); }); it('loads a local plugin marketplace file and installs from it', async () => { @@ -3089,9 +4042,10 @@ command = "vim" plugins: [ { id: 'kimi-datasource', + tier: 'official', displayName: 'Kimi Datasource', description: 'Datasource plugin', - source: './kimi-datasource', + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', }, ], }), @@ -3104,21 +4058,194 @@ command = "vim" driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginMarketplaceSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - picker.handleInput('\r'); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + // Official loads its catalog lazily; wait for the entry to render before install. + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + // The pinned Kimi WebBridge row leads the Official tab, so move down to + // the Kimi Datasource entry before installing. + panel.handleInput('\u001B[B'); + panel.handleInput('\r'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'kimi-datasource')); + expect(session.installPlugin).toHaveBeenCalledWith( + 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + ); }); await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Installing or updating Kimi Datasource from marketplace...'); - expect(transcript).toContain('Installed or updated Demo'); + expect(transcript).toContain('Installed Demo'); + expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); }); + // Installing closes the panel so the success notice / reload tip is visible. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + }); + }); + + it('returns to the plugin list when a marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'kimi-datasource', + tier: 'official', + displayName: 'Kimi Datasource', + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + }, + ], + }), + 'utf8', + ); + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = marketplacePath; + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins marketplace'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + panel.handleInput('\r'); + + // The panel must not get stuck on the one-way "Installing…" view; it should + // return to the list so the user can retry. + await vi.waitFor(() => { + const rendered = stripSgr(panel.render(120).join('\n')); + expect(rendered).toContain('Kimi Datasource'); + expect(rendered).not.toContain('Installing'); + }); + }); + + it('prompts for trust before installing a third-party marketplace entry', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + description: 'Curated plugin', + source: './superpowers', + }, + ], + }), + 'utf8', + ); + const session = makeSession(); + const { driver } = await makeDriver(session); + + // Passing the marketplace path opens the panel directly on the Third-party tab. + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'superpowers')); + }); + }); + + it('restores the panel when a third-party marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + source: './superpowers', + }, + ], + }), + 'utf8', + ); + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + // The failed install must return the user to the marketplace panel so they + // can retry, rather than dropping them back at the editor. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(panel); + }); + }); + + it('removes a plugin record without auto-running any cleanup skill', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins remove kimi-webbridge'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginRemoveConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginRemoveConfirmComponent; + confirm.handleInput('\u001B[B'); + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.removePlugin).toHaveBeenCalledWith('kimi-webbridge'); + }); + expect(session.activateSkill).not.toHaveBeenCalled(); }); it('installs default marketplace entries through plain install', async () => { @@ -3141,12 +4268,16 @@ command = "vim" driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginMarketplaceSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - picker.handleInput('\r'); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + // The pinned Kimi WebBridge row leads the Official tab, so move down to + // the Kimi Datasource entry before installing. + panel.handleInput('\u001B[B'); + panel.handleInput('\r'); await vi.waitFor(() => { expect(session.installPlugin).toHaveBeenCalledWith( @@ -3159,7 +4290,41 @@ command = "vim" } }); - it('toggles plugins from the overview with space', async () => { + it('shows an inline Official error when the marketplace is unreachable, keeping the panel open', async () => { + const originalFetch = globalThis.fetch; + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = 'https://example.test/marketplace.json'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('fetch failed'); + }), + ); + const session = makeSession(); + const { driver } = await makeDriver(session); + + try { + driver.handleUserInput('/plugins'); + + // The panel opens immediately on the Installed tab — no marketplace fetch. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput('\t'); // → Official, which lazily (and unsuccessfully) loads + + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain( + 'Marketplace unavailable: fetch failed', + ); + }); + // The panel stays mounted; the failure does not close /plugins. + expect(driver.state.editorContainer.children[0]).toBe(panel); + } finally { + vi.stubGlobal('fetch', originalFetch); + } + }); + + it('toggles plugins from the Installed tab with space', async () => { let enabled = true; const session = makeSession({ listPlugins: vi.fn(async () => [ @@ -3173,6 +4338,7 @@ command = "vim" mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, + source: 'local-path', }, ]), setPluginEnabled: vi.fn(async (_id: string, nextEnabled: boolean) => { @@ -3184,31 +4350,25 @@ command = "vim" driver.handleUserInput('/plugins'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent; - overview.handleInput(' '); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput(' '); - // Toggling refreshes the picker in place: it must not flash back to the - // editor between the keypress and the refreshed picker mounting. - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + // Toggling refreshes the panel in place: it must not flash back to the + // editor between the keypress and the refreshed panel mounting. + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); await vi.waitFor(() => { expect(session.setPluginEnabled).toHaveBeenCalledWith('demo', false); }); - // The picker stays mounted the whole time (no editor flash), so wait for the - // refreshed render rather than for an instance swap. await vi.waitFor(() => { const refreshed = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(refreshed).toContain('❯ Demo disabled require run /new to apply'); + expect(refreshed).toContain('❯ Demo disabled run /reload or /new to apply'); }); - const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).not.toContain('Space enable'); - expect(stripSgr(renderTranscript(driver))).not.toContain('Disabled demo. Run /new to apply.'); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Disabled demo. Run /reload or /new to apply.', + ); }); it('toggles plugin MCP servers from the overview MCP picker', async () => { @@ -3272,12 +4432,10 @@ command = "vim" driver.handleUserInput('/plugins'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent; - overview.handleInput('m'); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput('m'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( @@ -3299,9 +4457,9 @@ command = "vim" expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginMcpSelectorComponent); }); const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ data disabled require run /new to apply'); + expect(out).toContain('❯ data disabled run /reload or /new to apply'); expect(stripSgr(renderTranscript(driver))).not.toContain( - 'Disabled MCP server data for kimi-datasource. Run /new to apply.', + 'Disabled MCP server data for kimi-datasource. Run /reload or /new to apply.', ); }); @@ -3375,7 +4533,7 @@ command = "vim" }, }, defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, })), setConfig, }); @@ -3404,11 +4562,117 @@ command = "vim" expect(session.setThinking).toHaveBeenCalledWith('on'); expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'turbo', - defaultThinking: true, + thinking: { enabled: true }, }); }); expect(driver.state.appState.model).toBe('turbo'); - expect(driver.state.appState.thinking).toBe(true); + expect(driver.state.appState.thinkingEffort).toBe('on'); + }); + + it('applies /model selection to the session only on Alt+S without persisting', async () => { + const session = makeSession(); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + displayName: 'Kimi Turbo', + capabilities: ['thinking'], + }, + }, + defaultModel: 'k2', + thinking: { enabled: false }, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0]; + // /model turbo preselects turbo; Alt+S applies it to the current session only. + (picker as TabbedModelSelectorComponent).handleInput(`${ESC}s`); + + await vi.waitFor(() => { + expect(session.setModel).toHaveBeenCalledWith('turbo'); + expect(session.setThinking).toHaveBeenCalledWith('on'); + }); + expect(setConfig).not.toHaveBeenCalled(); + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('on'); + }); + + it('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 () => { @@ -3426,7 +4690,7 @@ command = "vim" }, }, defaultModel: 'old-default', - defaultThinking: true, + thinking: { enabled: true }, })), setConfig, }); @@ -3442,7 +4706,7 @@ command = "vim" await vi.waitFor(() => { expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, }); }); expect(session.setModel).not.toHaveBeenCalled(); @@ -3694,6 +4958,117 @@ command = "vim" expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); }); + it('does not create a thinking component for whitespace-only thinking deltas', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: ' ', + } as Event, + vi.fn(), + ); + driver.streamingUI.flushNow(); + + // Nothing to render: no component, and the phase is not hijacked into thinking. + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + + // Real thinking text after the whitespace still starts thinking normally. + 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); + expect(stripSgr(renderTranscript(driver))).toContain('actual reasoning'); + }); + + it('does not create a thinking component for whitespace-only thinking on session replay', async () => { + const { driver } = await makeDriver(); + + // Session replay flushes stored thinking verbatim through onThinkingUpdate + // (see SessionReplayRenderer.flushAssistant), so a persisted whitespace-only + // think part must not become a bare bullet line. + driver.streamingUI.onThinkingUpdate(' '); + driver.streamingUI.onThinkingEnd(); + + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); + expect( + driver.state.transcriptContainer.children.filter( + (child) => child instanceof ThinkingComponent, + ), + ).toHaveLength(0); + + // Real stored thinking still replays normally. + driver.streamingUI.onThinkingUpdate('visible reasoning'); + driver.streamingUI.onThinkingEnd(); + + expect(stripSgr(renderTranscript(driver))).toContain('visible reasoning'); + }); + + 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'; @@ -3797,3 +5172,202 @@ command = "vim" expect(transcript).not.toContain('<hook_result'); }); }); + +describe('/model status displayName override', () => { + it('shows the overridden display name in the switch status', async () => { + const session = makeSession(); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + displayName: 'Remote Turbo', + capabilities: ['thinking'], + overrides: { displayName: 'Custom Turbo' }, + }, + }, + defaultModel: 'k2', + thinking: { enabled: false }, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'turbo', + thinking: { enabled: true }, + }); + }); + + expect(renderTranscript(driver)).toContain('Switched to Custom Turbo with thinking on.'); + expect(renderTranscript(driver)).not.toContain('Remote Turbo'); + }); +}); + +describe('/effort support_efforts override', () => { + it('warns and applies efforts hidden by an Anthropic support_efforts override', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + 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(session.setThinking).toHaveBeenCalledWith('max'); + }); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to max.'); + }); + const transcript = renderTranscript(driver).replaceAll(/\s+/g, ' '); + expect(transcript).toContain( + 'Thinking effort "max" is not listed for k2 (known: low, high). Sending "max" unchanged; the configured provider will validate it.', + ); + expect(transcript).toContain('Thinking set to max.'); + }); + + it('offers the latest Opus efforts for an unknown Anthropic-compatible model', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'anthropic', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).toContain('Max'); + }); + + it('offers no fallback efforts for an unknown model on a Kimi provider using the Anthropic protocol', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).not.toContain('Max'); + }); + + it('offers the latest Opus efforts for a flat providerless Anthropic model', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: {}, + models: { + // v2 flat model shape: no named provider, inline endpoint + protocol. + k2: { + model: 'compatible-model', + baseUrl: 'https://anthropic.example.test', + protocol: 'anthropic', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).toContain('Max'); + }); + + it('keeps rejecting efforts hidden by a Kimi support_efforts override', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + kimi: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'kimi', + model: 'kimi-model', + maxContextSize: 100, + capabilities: ['thinking'], + 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(session.setThinking).not.toHaveBeenCalled(); + }); +}); 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 8bc6f6fa5..79a36d70f 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -14,6 +14,7 @@ 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, @@ -87,6 +88,7 @@ function makeStartupInput( }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -104,7 +106,7 @@ function makeSession(overrides: Record<string, unknown> = {}) { summary: { title: 'Session title' }, getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -164,7 +166,7 @@ function createResumeState(overrides: { permissionMode?: string; planMode?: bool config: { cwd: '/tmp/proj-a', modelCapabilities: { max_context_tokens: 100 }, - thinkingLevel: 'off', + thinkingEffort: 'off', systemPrompt: '', }, context: { history: [], tokenCount: 10 }, @@ -240,7 +242,7 @@ describe('KimiTUI startup', () => { const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'yolo', planMode: true, contextTokens: 25, @@ -296,7 +298,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -324,7 +326,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -352,7 +354,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode, contextTokens: 10, @@ -379,7 +381,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 10, @@ -406,7 +408,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -431,7 +433,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -497,7 +499,7 @@ describe('KimiTUI startup', () => { id: 'ses-target', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -591,7 +593,7 @@ describe('KimiTUI startup', () => { }), getStatus: vi.fn(async () => ({ model, - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -630,7 +632,7 @@ describe('KimiTUI startup', () => { id: 'ses-picked', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -670,7 +672,7 @@ describe('KimiTUI startup', () => { id: 'ses-picked', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 10, @@ -891,17 +893,12 @@ describe('KimiTUI startup', () => { expect(resumeSession).not.toHaveBeenCalled(); expect(driver.state.activeDialog).toBeNull(); - expect(copyTextToClipboardMock).toHaveBeenCalledWith( - "cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'", - ); + 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: cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'", - ); - expect(transcript).toContain( - "To resume, run: cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'", - ); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); expect(transcript).toContain('Command copied to clipboard'); }); @@ -934,13 +931,10 @@ describe('KimiTUI startup', () => { await new Promise((resolve) => setImmediate(resolve)); expect(resumeSession).not.toHaveBeenCalled(); - expect(copyTextToClipboardMock).toHaveBeenCalledWith( - "cd '/tmp/proj$(touch /tmp/pwned)' && kimi --resume 'ses-other-cwd'", - ); + 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: cd '/tmp/proj$(touch /tmp/pwned)' && kimi --resume 'ses-other-cwd'", - ); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); }); it('exits after picking another cwd from the startup picker', async () => { @@ -974,9 +968,8 @@ describe('KimiTUI startup', () => { await new Promise((resolve) => setImmediate(resolve)); expect(resumeSession).not.toHaveBeenCalled(); - expect(copyTextToClipboardMock).toHaveBeenCalledWith( - "cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'", - ); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); expect(stop).toHaveBeenCalledOnce(); expect(stop).toHaveBeenCalledWith(0); }); @@ -1102,7 +1095,27 @@ describe('KimiTUI startup', () => { expect(write).toHaveBeenCalledWith(DISABLE_TERMINAL_THEME_REPORTING); }); - it('starts TUI without a session when fresh startup needs OAuth login', async () => { + 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 () => { throw loginRequiredError(); @@ -1117,7 +1130,7 @@ describe('KimiTUI startup', () => { expect(driver.state.appState).toMatchObject({ sessionId: '', model: '', - thinking: false, + thinkingEffort: 'off', contextTokens: 0, maxContextTokens: 0, contextUsage: 0, @@ -1129,7 +1142,7 @@ describe('KimiTUI startup', () => { const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'yolo', planMode: true, contextTokens: 10, @@ -1144,7 +1157,7 @@ describe('KimiTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, models: { k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, @@ -1189,7 +1202,7 @@ describe('KimiTUI startup', () => { const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'auto', planMode: false, contextTokens: 10, @@ -1204,7 +1217,7 @@ describe('KimiTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, models: { k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, @@ -1229,12 +1242,12 @@ describe('KimiTUI startup', () => { }); }); - 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, + thinking: { enabled: true }, models: { k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, @@ -1243,20 +1256,23 @@ describe('KimiTUI startup', () => { 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'); + // `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, + thinkingEffort: 'off', maxContextTokens: 100, }); expect(harness.track).toHaveBeenCalledWith('login', { provider: 'managed:kimi-code', + method: 'oauth', already_logged_in: false, }); }); @@ -1290,6 +1306,7 @@ describe('KimiTUI startup', () => { ); expect(harness.track).toHaveBeenCalledWith('login', { provider: 'managed:kimi-code', + method: 'oauth', already_logged_in: true, }); }); @@ -1643,6 +1660,16 @@ describe('KimiTUI startup', () => { ).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: { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index f54bac27b..f3e5faa25 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -52,6 +52,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -150,7 +151,7 @@ function baseAgentState( tool_use: true, max_context_tokens: 100, }, - thinkingLevel: 'off', + thinkingEffort: 'off', systemPrompt: '', }, context: { history: [], tokenCount: 0 }, @@ -177,7 +178,7 @@ function makeSession( summary: { title: null }, getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, @@ -230,7 +231,7 @@ function makeHarness(initialSession: Session) { login: vi.fn(), logout: vi.fn(), getManagedUsage: vi.fn(), - submitFeedback: vi.fn(async () => ({ kind: 'ok' })), + submitFeedback: vi.fn(async () => ({ kind: 'ok', feedbackId: 3 })), }, }; } @@ -307,6 +308,24 @@ describe('KimiTUI resume message replay', () => { expect(transcript).not.toContain('Goal complete'); }); + it('unescapes bash tag delimiters when replaying shell output', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: '<bash-stdout>pre</bash-stdout>post</bash-stdout><bash-stderr></bash-stderr>', + }, + ], + { origin: { kind: 'shell_command', phase: 'output' } }, + ), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('pre</bash-stdout>post'); + }); + it('does not render neutral goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( @@ -394,7 +413,7 @@ describe('KimiTUI resume message replay', () => { 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.'); + expect(transcript).toContain('Worked 1 turn over 7m15s, using 4.2k tokens.'); }); it('filters resume-normalization goal pause markers in TUI replay', async () => { @@ -440,7 +459,7 @@ describe('KimiTUI resume message replay', () => { expect(entry).toMatchObject({ kind: 'assistant', renderMode: 'markdown', - content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.3M tokens.', + content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.1M tokens.', }); }); @@ -1011,15 +1030,43 @@ describe('KimiTUI resume message replay', () => { (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('120 → 24 tokens'); - expect(transcript).toContain('preserve implementation notes'); - expect(transcript).not.toContain('Compacted transcript summary.'); + expect(transcript).toContain('Compacted transcript summary.'); }); it('renders replayed cancelled compaction records as cancelled compaction blocks', async () => { 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 9d630a26d..92a91e063 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -25,6 +25,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/task-output-viewer.test.ts b/apps/kimi-code/test/tui/task-output-viewer.test.ts index a7cbfe0df..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'; @@ -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'); diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index 3f0e95fa0..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'; @@ -218,6 +218,41 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(out).not.toContain('bash-bbbbbbbb'); }); + it('filters out foreground tasks (detached === false)', () => { + const tasks = [ + task({ taskId: 'bash-foreground', detached: false, status: 'running' }), + task({ taskId: 'bash-background', detached: true, status: 'running' }), + ]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).not.toContain('bash-foreground'); + expect(out).toContain('bash-background'); + }); + + it('keeps background tasks with detached === true even when terminal', () => { + const tasks = [task({ taskId: 'bash-done', detached: true, status: 'completed' })]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).toContain('bash-done'); + }); + + it('keeps ghost tasks whose detached field is undefined', () => { + // task() leaves `detached` undefined by default, mimicking reconcile ghosts. + const tasks = [task({ taskId: 'bash-ghost', status: 'lost' })]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).toContain('bash-ghost'); + }); + + it('applies active filter after excluding foreground tasks', () => { + const tasks = [ + task({ taskId: 'bash-fg-running', detached: false, status: 'running' }), + task({ taskId: 'bash-bg-running', detached: true, status: 'running' }), + task({ taskId: 'bash-bg-done', detached: true, status: 'completed' }), + ]; + const out = strip(makeApp({ tasks, filter: 'active' }).render(120).join('\n')); + expect(out).not.toContain('bash-fg-running'); + expect(out).toContain('bash-bg-running'); + expect(out).not.toContain('bash-bg-done'); + }); + it('renders without throwing for every BackgroundTaskStatus', () => { const statuses: BackgroundTaskStatus[] = [ 'running', diff --git a/apps/kimi-code/test/tui/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 index 0ef499e19..ac09feb73 100644 --- a/apps/kimi-code/test/tui/utils/goal-completion.test.ts +++ b/apps/kimi-code/test/tui/utils/goal-completion.test.ts @@ -20,7 +20,7 @@ describe('buildGoalCompletionMessage', () => { 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('12.2k tokens'); expect(text).toContain('4m20s'); }); diff --git a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts index aadb8e764..18d43fec7 100644 --- a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts @@ -476,7 +476,7 @@ describe('refreshAllProviderModels', () => { }, }, defaultModel: 'my-b', - defaultThinking: true, + thinking: { enabled: true }, telemetry: true, } as unknown as KimiConfig); @@ -523,7 +523,7 @@ describe('refreshAllProviderModels', () => { expect(host.current().models?.['b/m1']).toBeUndefined(); expect(host.current().models?.['my-b']).toBeUndefined(); expect(host.current().defaultModel).toBeUndefined(); - expect(host.current().defaultThinking).toBeUndefined(); + expect(host.current().thinking).toBeUndefined(); }); it('coalesces duplicate custom-registry source URLs without reporting config-only changes', async () => { @@ -664,7 +664,7 @@ describe('refreshAllProviderModels', () => { [userAlias]: userAliasModel, }, defaultModel: userAlias, - defaultThinking: false, + thinking: { enabled: false }, telemetry: true, } as unknown as KimiConfig); @@ -709,7 +709,7 @@ describe('refreshAllProviderModels', () => { expect(host.setConfig).not.toHaveBeenCalled(); expect(host.current().models?.[userAlias]).toEqual(userAliasModel); expect(host.current().defaultModel).toBe(userAlias); - expect(host.current().defaultThinking).toBe(false); + expect(host.current().thinking?.enabled).toBe(false); }); it('forces default thinking on when the refreshed default model cannot disable thinking', async () => { @@ -730,7 +730,7 @@ describe('refreshAllProviderModels', () => { }, }, defaultModel: 'kimi-code/kimi-deep-coder', - defaultThinking: false, + thinking: { enabled: false }, telemetry: true, } as unknown as KimiConfig); @@ -766,6 +766,488 @@ describe('refreshAllProviderModels', () => { 'tool_use', ]); expect(host.current().defaultModel).toBe('kimi-code/kimi-deep-coder'); - expect(host.current().defaultThinking).toBe(true); + expect(host.current().thinking?.enabled).toBe(true); + }); + + it('refreshes a hand-configured API-key provider pointing at the managed endpoint', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const userAliasModel = { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'My Coding', + }; + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'my-kimi/kimi-for-coding': { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Old Kimi', + }, + 'my-kimi/kimi-old': { + provider: 'my-kimi', + model: 'kimi-old', + maxContextSize: 131072, + capabilities: ['tool_use'], + }, + 'my-fav': userAliasModel, + }, + defaultModel: 'my-kimi/kimi-for-coding', + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-distributed-key'); + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh Kimi', + }, + { id: 'kimi-k2', context_length: 131072 }, + ], + }), + { 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([ + { providerId: 'my-kimi', providerName: 'my-kimi', added: 1, removed: 1 }, + ]); + // The provider record is user-owned and must survive untouched. + expect(host.current().providers['my-kimi']).toEqual({ + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }); + // Upstream-owned fields merge; the dropped model disappears; the new one appears. + expect(host.current().models?.['my-kimi/kimi-for-coding']?.displayName).toBe('Fresh Kimi'); + expect(host.current().models?.['my-kimi/kimi-for-coding']?.capabilities).toEqual([ + 'thinking', + 'tool_use', + ]); + expect(host.current().models?.['my-kimi/kimi-k2']).toBeDefined(); + expect(host.current().models?.['my-kimi/kimi-old']).toBeUndefined(); + // Non-prefix user aliases and the default selection are preserved. + expect(host.current().models?.['my-fav']).toEqual(userAliasModel); + expect(host.current().defaultModel).toBe('my-kimi/kimi-for-coding'); + }); + + it('resolves the API key from the provider env sub-table when api_key is empty', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl, + apiKey: '', + env: { KIMI_API_KEY: 'sk-env-key' }, + }, + }, + models: { + 'my-kimi/kimi-for-coding': { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-env-key'); + 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 () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['my-kimi']); + expect(host.setConfig).not.toHaveBeenCalled(); + }); + + it('matches the managed endpoint even with a trailing slash on the configured baseUrl', async () => { + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://api.managed.example.test/coding/v1'); + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl: 'https://api.managed.example.test/coding/v1/', + apiKey: 'sk-distributed-key', + }, + }, + models: {}, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input) => { + expect(fetchInputUrl(input)).toBe('https://api.managed.example.test/coding/v1/models'); + return new Response( + JSON.stringify({ data: [{ id: 'kimi-for-coding', context_length: 262144 }] }), + { 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([ + { providerId: 'my-kimi', providerName: 'my-kimi', added: 1, removed: 0 }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not refresh API-key providers pointing at non-managed endpoints', async () => { + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://api.managed.example.test/coding/v1'); + const host = makeRefreshHost({ + providers: { + gateway: { + type: 'kimi', + baseUrl: 'https://gateway.example.test/v1', + apiKey: 'sk-gateway-key', + }, + 'moonshot-lookalike': { + type: 'kimi', + baseUrl: 'https://api.moonshot.cn/v1', + apiKey: 'sk-platform-key', + }, + }, + models: {}, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result).toEqual({ changed: [], unchanged: [], failed: [] }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refreshes a hand-written managed:kimi-code provider that uses an API key instead of OAuth', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Old Kimi', + }, + }, + defaultModel: 'kimi-code/kimi-for-coding', + telemetry: true, + } as unknown as KimiConfig); + + 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 sk-distributed-key'); + 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, + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { + providerId: KIMI_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, + added: 0, + removed: 0, + }, + ]); + // The OAuth branch must not run: no token resolution, and the provider + // record keeps the user's API-key shape (no oauth ref, no apiKey reset). + expect(resolveOAuthToken).not.toHaveBeenCalled(); + expect(host.current().providers[KIMI_CODE_PROVIDER_NAME]).toEqual({ + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }); + expect(host.current().services).toBeUndefined(); + expect(host.current().models?.['kimi-code/kimi-for-coding']?.displayName).toBe('Fresh Kimi'); + expect(host.current().defaultModel).toBe('kimi-code/kimi-for-coding'); + }); + + it('reports a failed refresh and keeps config when the managed endpoint rejects the API key', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl, + apiKey: 'sk-revoked-key', + }, + }, + models: { + 'my-kimi/kimi-for-coding': { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>( + async () => + new Response(JSON.stringify({ error: { message: 'invalid key' } }), { + status: 401, + 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.changed).toEqual([]); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]?.provider).toBe('my-kimi'); + expect(result.failed[0]?.reason).toContain('the API key'); + expect(host.setConfig).not.toHaveBeenCalled(); + expect(host.current().models?.['my-kimi/kimi-for-coding']).toBeDefined(); + }); + + it('skips the API-key refresh when the managed endpoint returns no models', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'my-kimi/kimi-for-coding': { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>( + async () => + new Response(JSON.stringify({ data: [] }), { + 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).toEqual({ changed: [], unchanged: [], failed: [] }); + expect(host.setConfig).not.toHaveBeenCalled(); + expect(host.current().models?.['my-kimi/kimi-for-coding']).toBeDefined(); + }); + + it('writes defaultProvider back when refreshing the provider it points at', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'my-kimi/kimi-for-coding': { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Old Kimi', + }, + }, + defaultProvider: 'my-kimi', + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>( + async () => + 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: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toHaveLength(1); + // The v1 removeProvider RPC clears defaultProvider when it points at the + // refreshed provider; the setConfig patch must carry the original value + // back. + expect(host.setConfig).toHaveBeenCalledWith( + expect.objectContaining({ defaultProvider: 'my-kimi' }), + ); + expect(host.current().defaultProvider).toBe('my-kimi'); + }); + + it('leaves registry-sourced providers at the managed base URL to the registry branch', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const host = makeRefreshHost({ + providers: { + custom: { + type: 'kimi', + baseUrl, + apiKey: 'sk-test-token', + source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, + }, + }, + models: { + 'custom/m1': { + provider: 'custom', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + return new Response( + JSON.stringify({ + custom: { + id: 'custom', + name: 'Custom', + api: baseUrl, + type: 'kimi', + 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(['custom']); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.setConfig).not.toHaveBeenCalled(); }); }); 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 index 72b036352..510d60ba5 100644 --- a/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts +++ b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clipboard } from '#/utils/clipboard/clipboard-native'; +import { buildClipboardOSC52 } from '#/utils/clipboard/clipboard-osc52'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; vi.mock('node:child_process', () => ({ @@ -17,7 +18,30 @@ vi.mock('#/utils/clipboard/clipboard-native', () => ({ const clipboardMock = clipboard as unknown as { setText: ReturnType<typeof vi.fn> }; const spawnSyncMock = vi.mocked(spawnSync); +const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + +function restoreIsTTY(): void { + if (originalIsTTYDescriptor !== undefined) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTTYDescriptor); + } +} + +function stubStdoutTTY(isTTY: boolean): void { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + writable: true, + value: isTTY, + }); +} + +function base64(text: string): string { + return Buffer.from(text, 'utf8').toString('base64'); +} + afterEach(() => { + restoreIsTTY(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); vi.clearAllMocks(); }); @@ -31,7 +55,7 @@ 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(); + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBe('native'); expect(clipboardMock.setText).toHaveBeenCalledWith('cd "/tmp/proj-b"'); }); @@ -41,10 +65,11 @@ describe('copyTextToClipboard', () => { expect(text).toBe('cd "/tmp/proj-b"'); }); - await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined(); + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBe('native'); }); it('throws an Error when all platform clipboard commands fail', async () => { + stubStdoutTTY(false); clipboardMock.setText = undefined as unknown as ReturnType<typeof vi.fn>; spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType<typeof spawnSync>); @@ -54,3 +79,39 @@ describe('copyTextToClipboard', () => { ); }); }); + +describe('buildClipboardOSC52', () => { + it('emits a bare OSC 52 sequence outside tmux', () => { + expect(buildClipboardOSC52('hi', false)).toBe(`\u001B]52;c;${base64('hi')}\u0007`); + }); + + it('wraps the sequence in a tmux passthrough with doubled ESC bytes', () => { + expect(buildClipboardOSC52('hi', true)).toBe( + `\u001BPtmux;\u001B\u001B]52;c;${base64('hi')}\u0007\u001B\\`, + ); + }); +}); + +describe('OSC 52 fallback in copyTextToClipboard', () => { + it('resolves via OSC 52 when native clipboards fail on a terminal', async () => { + stubStdoutTTY(true); + clipboardMock.setText = undefined as unknown as ReturnType<typeof vi.fn>; + spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType<typeof spawnSync>); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await expect(copyTextToClipboard('hello world')).resolves.toBe('osc52'); + + const written = writeSpy.mock.calls.map(([chunk]) => String(chunk)).join(''); + expect(written).toContain(`]52;c;${base64('hello world')}`); + }); + + it('does not write escape sequences when stdout is not a terminal', async () => { + stubStdoutTTY(false); + clipboardMock.setText = vi.fn().mockResolvedValue(undefined); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await copyTextToClipboard('hello'); + + expect(writeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index d7430b5ad..9c220b868 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; -import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +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)), '../../../..'); @@ -122,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( @@ -132,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( @@ -179,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 () => ({ @@ -227,4 +485,21 @@ describe('loadPluginMarketplace', () => { /"tier" must be one of/, ); }); + + it('rejects unknown marketplace entry types', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [{ id: 'demo', type: 'integration', source: './demo' }], + }), + 'utf8', + ); + + await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow( + /Legacy aliases "managed" and "guide" are also accepted/, + ); + }); + }); diff --git a/apps/kimi-code/test/utils/usage/debug-timing.test.ts b/apps/kimi-code/test/utils/usage/debug-timing.test.ts index b986f08e6..5cd04ee24 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 2k | 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 1000'); + 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/test/utils/usage/usage-format.test.ts b/apps/kimi-code/test/utils/usage/usage-format.test.ts index 264bb5c55..f22078285 100644 --- a/apps/kimi-code/test/utils/usage/usage-format.test.ts +++ b/apps/kimi-code/test/utils/usage/usage-format.test.ts @@ -5,6 +5,8 @@ import { renderProgressBar, ratioSeverity, safeUsageRatio, + usagePercent, + usagePercentFromRatio, } from '#/utils/usage/usage-format'; describe('formatTokenCount', () => { @@ -14,15 +16,27 @@ describe('formatTokenCount', () => { expect(formatTokenCount(999)).toBe('999'); }); - it('rounds integers over 1k to 1 decimal', () => { - expect(formatTokenCount(1_000)).toBe('1.0k'); - expect(formatTokenCount(1_234)).toBe('1.2k'); - expect(formatTokenCount(9_876)).toBe('9.9k'); + it('switches to k at 1024 and trims a redundant ".0"', () => { + expect(formatTokenCount(1_000)).toBe('1000'); + expect(formatTokenCount(1_024)).toBe('1k'); + expect(formatTokenCount(1_536)).toBe('1.5k'); + expect(formatTokenCount(2_048)).toBe('2k'); }); - it('switches to M above a million', () => { - expect(formatTokenCount(1_000_000)).toBe('1.0M'); - expect(formatTokenCount(2_500_000)).toBe('2.5M'); + it('rounds k values to 1 decimal', () => { + expect(formatTokenCount(50_552)).toBe('49.4k'); + expect(formatTokenCount(262_144)).toBe('256k'); + }); + + it('rounds k values at or above 100k to whole k', () => { + expect(formatTokenCount(102_400)).toBe('100k'); + expect(formatTokenCount(999_999)).toBe('977k'); + }); + + it('switches to M at 1024*1024', () => { + expect(formatTokenCount(1_048_576)).toBe('1M'); + expect(formatTokenCount(1_572_864)).toBe('1.5M'); + expect(formatTokenCount(10_485_760)).toBe('10M'); }); it('clamps negatives and NaN to 0', () => { @@ -32,6 +46,51 @@ describe('formatTokenCount', () => { }); }); +describe('usagePercent', () => { + it('returns 0 for zero usage', () => { + expect(usagePercent(0, 1000)).toBe(0); + }); + + it('ceil-guarantees at least 1% for any non-zero usage', () => { + expect(usagePercent(4, 10_000)).toBe(1); + }); + + it('ceils fractional percentages', () => { + expect(usagePercent(427, 1000)).toBe(43); + expect(usagePercent(992, 1000)).toBe(100); + }); + + it('clamps to 100 when used meets or exceeds max', () => { + expect(usagePercent(1000, 1000)).toBe(100); + expect(usagePercent(1200, 1000)).toBe(100); + }); + + it('returns 0 for a non-positive or non-finite max', () => { + expect(usagePercent(500, 0)).toBe(0); + expect(usagePercent(500, -1)).toBe(0); + expect(usagePercent(500, Number.NaN)).toBe(0); + }); +}); + +describe('usagePercentFromRatio', () => { + it('coerces NaN to 0', () => { + expect(usagePercentFromRatio(Number.NaN)).toBe(0); + }); + + it('returns 0 for zero usage', () => { + expect(usagePercentFromRatio(0)).toBe(0); + }); + + it('ceil-guarantees at least 1% for any non-zero ratio', () => { + expect(usagePercentFromRatio(0.004)).toBe(1); + }); + + it('ceils fractional percentages and clamps above 100', () => { + expect(usagePercentFromRatio(0.427)).toBe(43); + expect(usagePercentFromRatio(1.5)).toBe(100); + }); +}); + describe('renderProgressBar', () => { it('empty bar at ratio 0', () => { expect(renderProgressBar(0, 10)).toBe('░'.repeat(10)); 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-inspect/index.html b/apps/kimi-inspect/index.html new file mode 100644 index 000000000..3a9b3b5e5 --- /dev/null +++ b/apps/kimi-inspect/index.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Kimi Inspect + + +
+ + + diff --git a/apps/kimi-inspect/package.json b/apps/kimi-inspect/package.json new file mode 100644 index 000000000..63acb6f06 --- /dev/null +++ b/apps/kimi-inspect/package.json @@ -0,0 +1,40 @@ +{ + "name": "@moonshot-ai/kimi-inspect", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "imports": { + "#/*": { + "types": [ + "./src/*.ts", + "./src/*.tsx", + "./src/*/index.ts", + "./src/*/index.tsx" + ], + "default": "./src/*" + } + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@moonshot-ai/agent-core-v2": "workspace:^", + "@tanstack/react-query": "^5.74.4", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "tailwindcss": "^4.1.4", + "typescript": "6.0.2", + "vite": "^6.3.3", + "vitest": "4.1.4" + } +} diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx new file mode 100644 index 000000000..57a5c3dcc --- /dev/null +++ b/apps/kimi-inspect/src/App.tsx @@ -0,0 +1,126 @@ +/** + * App shell — selection state, session resume, and the three WS event + * subscriptions that feed the live bus: + * core `events` — process-wide domain events + * session `interactions` — pending approvals / questions + * agent `events` — the active agent's live event stream + * Layout: header / left sidebar (workspaces + sessions) / chat / inspector. + */ + +import { useEffect, useRef, useState } from 'react'; + +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; + +import { ChatView } from './components/ChatView'; +import { Inspector } from './components/Inspector'; +import { ServerSwitcher } from './components/ServerSwitcher'; +import { Sidebar } from './components/Sidebar'; +import { useConnection } from './connection'; +import { LiveBusProvider, type Emit, type LiveEvent } from './live'; +import { Badge, errorMessage } from './ui'; + +export function App() { + const { klient, wsState, baseUrl, disconnect } = useConnection(); + const [sessionId, setSessionId] = useState(null); + const [agentId, setAgentId] = useState('main'); + const [ready, setReady] = useState(false); + const [resumeError, setResumeError] = useState(null); + const emitRef = useRef(null); + const publish = (source: LiveEvent['source'], data: unknown) => + emitRef.current?.({ source, data, at: Date.now() }); + + // Core events — for the lifetime of the connection. + useEffect(() => { + const sub = klient.ws().listen('events', (data) => publish('core', data)); + return () => sub.dispose(); + }, [klient]); + + // Session interactions — re-subscribe when the session changes. + useEffect(() => { + if (sessionId === null || !ready) return; + const session = klient.ws().session(sessionId); + const a = session.listen('interactions', (data) => publish('session', data)); + const b = session.listen('interactions:resolved', (data) => publish('session', data)); + return () => { + a.dispose(); + b.dispose(); + }; + }, [klient, sessionId, ready]); + + // Agent events — re-subscribe when session or agent changes. + useEffect(() => { + if (sessionId === null || !ready) return; + const sub = klient + .ws() + .session(sessionId) + .agent(agentId) + .listen('events', (data) => publish('agent', data)); + return () => sub.dispose(); + }, [klient, sessionId, agentId, ready]); + + // Resume (materialize) the session on the server when it is selected, so + // session / agent scoped Services become reachable. + useEffect(() => { + if (sessionId === null) return; + let cancelled = false; + setReady(false); + setResumeError(null); + klient + .core(ISessionLifecycleService) + .resume(sessionId) + .then(() => { + if (!cancelled) setReady(true); + }) + .catch((error: unknown) => { + if (!cancelled) setResumeError(error); + }); + return () => { + cancelled = true; + }; + }, [klient, sessionId]); + + // Switching servers invalidates every session/agent selection: sessions + // belong to the server they were listed from. The client and its WS + // subscriptions rebuild from the new config on their own. + useEffect(() => { + setSessionId(null); + setAgentId('main'); + }, [baseUrl]); + + return ( + +
+
+ KIMI INSPECT + + + ws: {wsState} + +
+ +
+
+ + {resumeError !== null ? ( +
+ Failed to open session: {errorMessage(resumeError)} +
+ ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/kimi-inspect/src/channel/channel.test.ts b/apps/kimi-inspect/src/channel/channel.test.ts new file mode 100644 index 000000000..fa1b24966 --- /dev/null +++ b/apps/kimi-inspect/src/channel/channel.test.ts @@ -0,0 +1,195 @@ +/** + * Channel layer unit tests — `ProxyChannel` URL/envelope semantics, `makeProxy` + * routing, and `WsChannel`'s ref-counted `listen`. The WS wire protocol itself + * is covered by the kap-server contract and klient's e2e suites. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import type { Event, IChannel } from './channel'; +import { RPCError } from './errors'; +import { makeProxy } from './proxy'; +import { ProxyChannel } from './proxyChannel'; +import { WsChannel } from './wsChannel'; +import type { WsSocket } from './wsSocket'; + +const ok = (data: unknown) => ({ code: 0, msg: 'success', data, request_id: 'r1' }); + +function fakeFetch(envelope: unknown) { + const calls: { url: string; init?: RequestInit }[] = []; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return { json: async () => envelope }; + }) as unknown as typeof fetch; + return { calls, fetchImpl }; +} + +function stubSocket() { + const state = { + listens: 0, + disposed: 0, + handler: undefined as ((data: unknown) => void) | undefined, + }; + const raw = { + call: vi.fn(async () => 'ws-ret'), + listen: ( + _scope: string, + _event: string, + _ids: unknown, + handler: (data: unknown) => void, + _service?: string, + ) => { + state.listens += 1; + state.handler = handler; + return { + dispose: () => { + state.disposed += 1; + }, + }; + }, + }; + return { raw, state, socket: raw as unknown as WsSocket }; +} + +describe('ProxyChannel.call', () => { + it('POSTs the command to the service base URL; no body and no header without args/token', async () => { + const { calls, fetchImpl } = fakeFetch(ok({ id: 's1' })); + const channel = new ProxyChannel({ + baseUrl: 'http://h:1/api/v2/session/s%201/agent/main/agentRPCService', + fetch: fetchImpl, + }); + const result = await channel.call('getModel', []); + expect(result).toEqual({ id: 's1' }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('http://h:1/api/v2/session/s%201/agent/main/agentRPCService/getModel'); + expect(calls[0]!.init?.method).toBe('POST'); + expect(calls[0]!.init?.body).toBeUndefined(); + }); + + it('sends the complete argument array as the JSON body, plus the bearer token', async () => { + const { calls, fetchImpl } = fakeFetch(ok(null)); + const channel = new ProxyChannel({ + baseUrl: 'http://h:2/api/v2/configService', + token: 'tok', + fetch: fetchImpl, + }); + await channel.call('set', ['workspace', { theme: 'dark' }]); + expect(calls[0]!.init?.body).toBe(JSON.stringify(['workspace', { theme: 'dark' }])); + expect(calls[0]!.init?.headers).toEqual({ + 'content-type': 'application/json', + authorization: 'Bearer tok', + }); + }); + + it('unwraps the envelope and throws RPCError on a non-zero code', async () => { + const { fetchImpl } = fakeFetch({ + code: 40401, + msg: 'session not found', + data: null, + request_id: 'r2', + details: { id: 's9' }, + }); + const channel = new ProxyChannel({ baseUrl: 'http://h:3/api/v2/sessionIndex', fetch: fetchImpl }); + const err: unknown = await channel.call('get', ['s9']).catch((error: unknown) => error); + expect(err).toBeInstanceOf(RPCError); + expect((err as RPCError).code).toBe(40401); + expect((err as RPCError).message).toBe('session not found'); + expect((err as RPCError).details).toEqual({ id: 's9' }); + }); +}); + +describe('makeProxy', () => { + interface DemoService { + read(id: string, n: number): Promise; + onDidChangeMetadata: Event<{ title: string }>; + } + + it('routes methods to call and onXxx members to listen', async () => { + const seen = { calls: [] as [string, unknown[]][], listens: [] as string[] }; + const channel: IChannel = { + call: async (command: string, args?: unknown[]): Promise => { + seen.calls.push([command, args ?? []]); + return 'ret' as T; + }, + listen: (event: string): Event => { + seen.listens.push(event); + return () => ({ dispose: () => {} }); + }, + }; + const svc = makeProxy(channel); + await expect(svc.read('a', 1)).resolves.toBe('ret'); + expect(seen.calls).toEqual([['read', ['a', 1]]]); + const d = svc.onDidChangeMetadata(() => {}); + d.dispose(); + expect(seen.listens).toEqual(['onDidChangeMetadata']); + }); +}); + +describe('WsChannel', () => { + it('forwards calls over the socket with scope + service + ids', async () => { + const { raw, socket } = stubSocket(); + const channel = new WsChannel({ + socket, + scope: 'agent', + service: 'agentRPCService', + sessionId: 's1', + agentId: 'main', + }); + const result = await channel.call('getModel', [{}]); + expect(result).toBe('ws-ret'); + expect(raw.call).toHaveBeenCalledWith('agent', 'agentRPCService', 'getModel', [{}], { + sessionId: 's1', + agentId: 'main', + }); + }); + + it('multiplexes local listeners onto one remote subscription', () => { + const { state, socket } = stubSocket(); + const channel = new WsChannel({ + socket, + scope: 'session', + service: 'sessionMetadata', + sessionId: 's1', + }); + const onDidChange = channel.listen('onDidChangeMetadata'); + const seen: unknown[] = []; + const sub1 = onDidChange((e) => seen.push(['l1', e])); + const sub2 = onDidChange((e) => seen.push(['l2', e])); + expect(state.listens).toBe(1); + state.handler!({ title: 't' }); + expect(seen).toEqual([ + ['l1', { title: 't' }], + ['l2', { title: 't' }], + ]); + sub1.dispose(); + expect(state.disposed).toBe(0); + sub2.dispose(); + expect(state.disposed).toBe(1); + }); +}); + +describe('ProxyChannel.listen', () => { + it('throws without a WS binding', () => { + const channel = new ProxyChannel({ + baseUrl: 'http://h:4/api/v2/configService', + fetch: fakeFetch(ok(null)).fetchImpl, + }); + expect(() => channel.listen('onDidChangeConfiguration')).toThrow(/events are not supported/); + }); + + it('delegates to one lazily-created WsChannel when a WS binding is provided', () => { + const { state, socket } = stubSocket(); + const factory = vi.fn(() => new WsChannel({ socket, scope: 'core', service: 'configService' })); + const channel = new ProxyChannel( + { baseUrl: 'http://h:5/api/v2/configService', fetch: fakeFetch(ok(null)).fetchImpl }, + factory, + ); + const sub = channel.listen('onDidChangeConfiguration')(() => {}); + expect(factory).toHaveBeenCalledTimes(1); + expect(state.listens).toBe(1); + channel.listen('onDidSectionChange'); + expect(factory).toHaveBeenCalledTimes(1); + sub.dispose(); + expect(state.disposed).toBe(1); + }); +}); diff --git a/apps/kimi-inspect/src/channel/channel.ts b/apps/kimi-inspect/src/channel/channel.ts new file mode 100644 index 000000000..fe8724250 --- /dev/null +++ b/apps/kimi-inspect/src/channel/channel.ts @@ -0,0 +1,42 @@ +/** + * Transport-agnostic channel contract for the `/api/v2` client — the + * old-klient / VS Code `ProxyChannel` model: the channel is bound to one + * Service (the URL carries the scope + the Service's decorator id) and + * `command` is the method name, invoked by reflection on the server. + * `listen` is for the Service's `onXxx` emitter events over the persistent + * `/api/v2/ws` transport. + */ + +import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; + +export interface IDisposable { + dispose(): void; +} + +export interface Event { + (listener: (event: T) => unknown, thisArg?: unknown, disposables?: IDisposable[]): IDisposable; +} + +/** The client-facing channel contract. Calls always carry the complete argument array. */ +export interface IChannel { + call(command: string, args?: unknown[]): Promise; + listen(event: string, arg?: unknown): Event; +} + +/** A wire Service reference: a DI decorator (stringifies to the wire channel + * name) or the raw channel name as a string. */ +export type ServiceRef = ServiceIdentifier | string; + +/** + * Remote view of a Service contract: every method becomes an async wire call; + * `onXxx` event members (`Event` — callables returning `IDisposable`) stay + * subscribable events; plain non-function members become zero-arg property + * reads (the `/api/v2` dispatcher returns non-function members as-is). + */ +export type ServiceProxy = { + [K in keyof T]: T[K] extends (...args: infer A) => infer R + ? R extends IDisposable + ? T[K] + : (...args: A) => Promise> + : () => Promise>; +}; diff --git a/apps/kimi-inspect/src/channel/channels.ts b/apps/kimi-inspect/src/channel/channels.ts new file mode 100644 index 000000000..1810f6c83 --- /dev/null +++ b/apps/kimi-inspect/src/channel/channels.ts @@ -0,0 +1,112 @@ +/** + * Protocol loading — the server's `{rpcBasePath}/channels` endpoint is its + * self-description of every wire-callable Service (name, scope, domain, + * methods + properties). On dev servers that's the whitelist-free + * `/api/v1/debug`; older/production servers fall back to the `/api/v2` + * whitelist set (`probeRpcBasePath` decides). Paired with `serviceByName`, + * each descriptor materializes 1:1 into a typed proxy of the channel layer: + * same channel name, same scope route, methods invoked by reflection. + */ + +import { createDecorator } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; + +import { RPCError } from './errors'; +import type { InspectClient } from './client'; +import type { ServiceProxy } from './channel'; + +/** Wire scope kinds reported by the channels endpoint (`app` ≡ the core route). */ +export type ChannelScope = 'app' | 'session' | 'agent'; + +/** Mirror of `ChannelDescriptor` in kap-server (`GET /api/v2/channels`). */ +export interface ChannelDescriptor { + readonly name: string; + readonly scope: ChannelScope; + readonly domain: string; + readonly methods: readonly { + readonly name: string; + readonly kind: 'method' | 'property'; + readonly arity: number; + readonly params: string; + }[]; +} + +/** Fetch the dynamic channel list (unwrapped from the project envelope), + * from whichever RPC surface the connection probed (`rpcBasePath`). */ +export async function fetchChannelDescriptors( + client: InspectClient, +): Promise { + const headers: Record = {}; + if (client.token !== undefined && client.token !== '') { + headers['authorization'] = `Bearer ${client.token}`; + } + const res = await fetch(`${client.baseUrl}${client.rpcBasePath}/channels`, { headers }); + const envelope = (await res.json()) as { + code: number; + msg: string; + data: readonly ChannelDescriptor[]; + }; + if (envelope.code !== 0) throw new RPCError(envelope.code, envelope.msg); + return envelope.data; +} + +/** The dev server's whitelist-free debug surface (`--debug-endpoints`). */ +export const DEBUG_RPC_BASE = '/api/v1/debug' as const; +/** The stable whitelist RPC surface — fallback when debug is not mounted. */ +export const V2_RPC_BASE = '/api/v2' as const; + +export type RpcBasePath = typeof DEBUG_RPC_BASE | typeof V2_RPC_BASE; + +/** + * Probe which RPC surface a server offers: dev servers started with + * `--debug-endpoints` answer `/api/v1/debug/channels`; older/production + * servers only the whitelisted `/api/v2`. Always resolves (fallback `/api/v2`). + */ +export async function probeRpcBasePath(options: { + readonly baseUrl: string; + readonly token?: string; +}): Promise { + try { + const headers: Record = {}; + if (options.token !== undefined && options.token !== '') { + headers['authorization'] = `Bearer ${options.token}`; + } + const res = await fetch( + `${options.baseUrl.replace(/\/$/, '')}${DEBUG_RPC_BASE}/channels`, + { headers }, + ); + if (res.ok) { + const envelope = (await res.json()) as { code?: number }; + if (envelope.code === 0) return DEBUG_RPC_BASE; + } + } catch { + // fall through to the v2 fallback + } + return V2_RPC_BASE; +} + +export interface ServiceTarget { + readonly scope: ChannelScope; + readonly sessionId?: string; + readonly agentId?: string; +} + +/** + * Resolve a Service proxy by wire channel name. The DI decorator registry keys + * identifiers by name, so re-creating the decorator resolves to the same token + * the server channel registry created — the name is the wire channel, which is + * all the proxy uses. Returns `undefined` when the target scope needs a + * session/agent id that isn't available. + */ +export function serviceByName( + client: InspectClient, + name: string, + target: ServiceTarget, +): ServiceProxy | undefined { + const id = createDecorator(name); + if (target.scope === 'app') return client.core(id); + if (target.sessionId === undefined) return undefined; + const base = client.session(target.sessionId); + if (target.scope === 'session') return base.service(id); + if (target.agentId === undefined) return undefined; + return base.agent(target.agentId).service(id); +} diff --git a/apps/kimi-inspect/src/channel/client.ts b/apps/kimi-inspect/src/channel/client.ts new file mode 100644 index 000000000..2f1149dd2 --- /dev/null +++ b/apps/kimi-inspect/src/channel/client.ts @@ -0,0 +1,149 @@ +/** + * Inspect client — the app's `/api/v2` entry point, in the old-klient VS Code + * `ProxyChannel` model: a three-level scope entry (`core` / `session` / + * `agent`) whose every Service handle is a `makeProxy`-materialized typed + * proxy over a service-bound channel, plus the one shared `/api/v2/ws` socket + * for scope event streams and connection state. + * + * const client = createInspectClient({ url: 'http://127.0.0.1:58627' }); + * await client.core(ISessionIndex).list({}); + * await client.session('s1').service(ISessionMetadata).read(); + * await client.session('s1').agent('main').service(IAgentRPCService).getModel(); + * + * The `agent-core-v2` service token is the whole key: its type parameter `T` + * types the returned proxy, and its decorator id (`String(id)`) is the channel + * name in the URL. Calls ride HTTP (`ProxyChannel`); the proxy's `onXxx` + * emitter events and the scope streams (`events` / `interactions` / + * `interactions:resolved`) ride the one shared `WsSocket`. + */ + +import type { ServiceProxy, ServiceRef } from './channel'; +import { makeProxy } from './proxy'; +import { ProxyChannel } from './proxyChannel'; +import { WsChannel } from './wsChannel'; +import { + WsSocket, + type WsScopeIds, + type WsScopeKind, + type WsSocketState, + type WsSubscription, +} from './wsSocket'; + +export interface InspectAgentHandle { + service(id: ServiceRef): ServiceProxy; +} + +export interface InspectSessionHandle extends InspectAgentHandle { + agent(agentId: string): InspectAgentHandle; +} + +export interface InspectWsAgent { + listen(stream: string, handler: (data: unknown) => void): WsSubscription; +} + +export interface InspectWsSession extends InspectWsAgent { + agent(agentId: string): InspectWsAgent; +} + +/** The one owned WebSocket: connection state plus per-scope stream subscriptions. */ +export interface InspectWs { + readonly state: WsSocketState; + onDidChangeState(listener: (state: WsSocketState) => void): WsSubscription; + listen(stream: string, handler: (data: unknown) => void): WsSubscription; + session(sessionId: string): InspectWsSession; + close(): void; +} + +export interface InspectClient { + /** Absolute server base URL, e.g. `http://127.0.0.1:58627`. */ + readonly baseUrl: string; + /** Bearer token in use, when any. */ + readonly token?: string; + /** RPC base path for calls and `/channels`: `/api/v1/debug` on dev servers + * (whitelist-free), `/api/v2` otherwise. Resolved by the connection probe. */ + readonly rpcBasePath: string; + core(id: ServiceRef): ServiceProxy; + session(sessionId: string): InspectSessionHandle; + ws(): InspectWs; +} + +export interface InspectClientOptions { + /** Base URL of the server, e.g. `http://127.0.0.1:58627`. */ + readonly url: string; + /** Optional bearer token. */ + readonly token?: string; + /** RPC base path for service calls + `/channels` introspection. Default + * `/api/v2`; the connection layer probes and passes `/api/v1/debug` when + * the server mounts the dev debug surface (`--debug-endpoints`). */ + readonly rpcBasePath?: string; +} + +export function createInspectClient(options: InspectClientOptions): InspectClient { + const url = options.url.replace(/\/$/, ''); + const rpcBasePath = options.rpcBasePath ?? '/api/v2'; + const socket = new WsSocket(options); + + /** Materialize a typed proxy for one Service on one scope binding. */ + function proxy( + scopePath: string, + scope: WsScopeKind, + ids: WsScopeIds, + id: ServiceRef, + ): ServiceProxy { + const service = String(id); + return makeProxy( + new ProxyChannel( + { baseUrl: `${url}${rpcBasePath}${scopePath}/${service}`, token: options.token }, + () => new WsChannel({ socket, scope, service, ...ids }), + ), + ); + } + + function wsListen(ids: WsScopeIds): InspectWsAgent { + return { + listen: (stream, handler) => + socket.listen( + ids.agentId !== undefined ? 'agent' : ids.sessionId !== undefined ? 'session' : 'core', + stream, + ids, + handler, + ), + }; + } + + const ws: InspectWs = { + get state() { + return socket.currentState; + }, + onDidChangeState: (listener) => socket.onDidChangeState(listener), + ...wsListen({}), + session: (sessionId) => ({ + ...wsListen({ sessionId }), + agent: (agentId) => wsListen({ sessionId, agentId }), + }), + close: () => { + socket.close(); + }, + }; + + return { + baseUrl: url, + token: options.token, + rpcBasePath, + core: (id) => proxy('', 'core', {}, id), + session: (sessionId) => { + const scopePath = `/session/${encodeURIComponent(sessionId)}`; + return { + service: (id) => proxy(scopePath, 'session', { sessionId }, id), + agent: (agentId) => ({ + service: (subId) => + proxy(`${scopePath}/agent/${encodeURIComponent(agentId)}`, 'agent', { + sessionId, + agentId, + }, subId), + }), + }; + }, + ws: () => ws, + }; +} diff --git a/apps/kimi-inspect/src/channel/errors.ts b/apps/kimi-inspect/src/channel/errors.ts new file mode 100644 index 000000000..b655802de --- /dev/null +++ b/apps/kimi-inspect/src/channel/errors.ts @@ -0,0 +1,15 @@ +/** + * Client-side RPC error surfaced when the `/api/v2` envelope carries a non-zero + * `code`. Mirrors the server envelope (`{ code, msg, data, request_id }`) — the + * numeric `code` is the stable branch key across the wire, not `instanceof`. + */ +export class RPCError extends Error { + constructor( + readonly code: number, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = 'RPCError'; + } +} diff --git a/apps/kimi-inspect/src/channel/index.ts b/apps/kimi-inspect/src/channel/index.ts new file mode 100644 index 000000000..bcb918639 --- /dev/null +++ b/apps/kimi-inspect/src/channel/index.ts @@ -0,0 +1,8 @@ +export * from './channel'; +export * from './channels'; +export * from './client'; +export * from './errors'; +export * from './proxy'; +export * from './proxyChannel'; +export * from './wsChannel'; +export * from './wsSocket'; diff --git a/apps/kimi-inspect/src/channel/proxy.ts b/apps/kimi-inspect/src/channel/proxy.ts new file mode 100644 index 000000000..5a0eb7518 --- /dev/null +++ b/apps/kimi-inspect/src/channel/proxy.ts @@ -0,0 +1,21 @@ +/** + * Typed proxy turning an `IChannel` (bound to one Service) into a value + * satisfying that Service's interface `T` — VS Code's `ProxyChannel.toService`. + * + * Members named `onUpperCase` become channel events; every other property access + * becomes a function forwarding its complete argument array to `channel.call` + * (the dispatcher also answers property reads this way). The shared interface + * `T` is the whole contract, with no per-method allowlist or renaming. + */ + +import type { IChannel, ServiceProxy } from './channel'; + +export function makeProxy(channel: IChannel): ServiceProxy { + return new Proxy({} as ServiceProxy, { + get(_target, prop) { + if (typeof prop !== 'string') return undefined; + if (/^on[A-Z]/.test(prop)) return channel.listen(prop); + return (...args: unknown[]) => channel.call(prop, args); + }, + }); +} diff --git a/apps/kimi-inspect/src/channel/proxyChannel.ts b/apps/kimi-inspect/src/channel/proxyChannel.ts new file mode 100644 index 000000000..e88143f8e --- /dev/null +++ b/apps/kimi-inspect/src/channel/proxyChannel.ts @@ -0,0 +1,81 @@ +/** + * `ProxyChannel` — an `IChannel` bound to one Service, routing `call`s to + * kap-server's `/api/v2` HTTP surface. Every call `POST`s the method name to + * the Service base URL with the complete argument array as the JSON body, + * then unwraps the project envelope: a non-zero `code` throws `RPCError`, + * otherwise `data` is returned. Non-function members answer as property reads + * through the same route (the dispatcher returns them as-is). + * + * `listen` cannot be served by HTTP; when the client supplies a WS binding + * (`events` factory) it is delegated to a lazily-created `WsChannel` bound to + * the same scope + Service, so the Service's `onXxx` emitter events work 1:1. + * Without a factory `listen` throws, matching the old HTTP-only channel. + */ + +import type { Event, IChannel } from './channel'; +import { RPCError } from './errors'; +import type { WsChannel } from './wsChannel'; + +interface Envelope { + readonly code: number; + readonly msg: string; + readonly data: T; + readonly request_id: string; + readonly details?: unknown; +} + +export interface ProxyChannelOptions { + /** Service base URL, e.g. `http://127.0.0.1:58627/api/v2[/session/:sid[/agent/:aid]]/:service`. */ + readonly baseUrl: string; + /** Optional bearer token. */ + readonly token?: string; + /** `fetch` implementation; defaults to the global `fetch`. */ + readonly fetch?: typeof fetch; +} + +export class ProxyChannel implements IChannel { + private readonly baseUrl: string; + private readonly token?: string; + private readonly fetchImpl: typeof fetch; + private readonly eventsFactory?: () => WsChannel; + private eventsChannel: WsChannel | undefined; + + constructor(opts: ProxyChannelOptions, events?: () => WsChannel) { + this.baseUrl = opts.baseUrl.replace(/\/$/, ''); + this.token = opts.token; + // Bind the global fetch: browsers throw "Illegal invocation" when the + // native function is invoked with a non-Window receiver. + this.fetchImpl = opts.fetch ?? fetch.bind(globalThis); + this.eventsFactory = events; + } + + async call(command: string, args: unknown[] = []): Promise { + const headers: Record = {}; + let body: string | undefined; + if (args.length > 0) { + headers['content-type'] = 'application/json'; + body = JSON.stringify(args); + } + if (this.token !== undefined) { + headers['authorization'] = `Bearer ${this.token}`; + } + const res = await this.fetchImpl(`${this.baseUrl}/${command}`, { + method: 'POST', + headers, + body, + }); + const envelope = (await res.json()) as Envelope; + if (envelope.code !== 0) { + throw new RPCError(envelope.code, envelope.msg, envelope.details); + } + return envelope.data; + } + + listen(event: string): Event { + if (this.eventsFactory === undefined) { + throw new Error('events are not supported on this channel (no WS binding)'); + } + this.eventsChannel ??= this.eventsFactory(); + return this.eventsChannel.listen(event); + } +} diff --git a/apps/kimi-inspect/src/channel/wsChannel.ts b/apps/kimi-inspect/src/channel/wsChannel.ts new file mode 100644 index 000000000..7166a1ee8 --- /dev/null +++ b/apps/kimi-inspect/src/channel/wsChannel.ts @@ -0,0 +1,79 @@ +/** + * `WsChannel` — an `IChannel` bound to one Service that forwards `call`s and + * `listen`s over the shared `/api/v2/ws` socket instead of HTTP. Same VS Code + * shape as `ProxyChannel` (the URL equivalent is the `{scope, service, ids}` + * triple the socket puts on each frame). `listen` multiplexes local listeners + * onto one remote subscription: the first local listener opens it, the last + * `dispose()` tears it down, and it survives reconnects until then. + */ + +import type { Event, IChannel } from './channel'; +import type { WsScopeIds, WsScopeKind, WsSocket } from './wsSocket'; + +export interface WsChannelOptions { + readonly socket: WsSocket; + readonly scope: WsScopeKind; + /** Service channel name (the decorator id, `String(id)`). */ + readonly service: string; + readonly sessionId?: string; + readonly agentId?: string; +} + +interface SharedEvent { + readonly listeners: Set<{ listener: (data: unknown) => unknown; thisArg: unknown }>; + remote?: { dispose(): void }; +} + +export class WsChannel implements IChannel { + private readonly socket: WsSocket; + private readonly scope: WsScopeKind; + private readonly service: string; + private readonly ids: WsScopeIds; + private readonly events = new Map(); + + constructor(opts: WsChannelOptions) { + this.socket = opts.socket; + this.scope = opts.scope; + this.service = opts.service; + this.ids = { sessionId: opts.sessionId, agentId: opts.agentId }; + } + + call(command: string, args: unknown[] = []): Promise { + return this.socket.call(this.scope, this.service, command, args, this.ids); + } + + listen(event: string): Event { + let shared = this.events.get(event); + if (shared === undefined) { + shared = { listeners: new Set() }; + this.events.set(event, shared); + } + return (listener, thisArg, disposables) => { + const entry = { listener: listener as (data: unknown) => unknown, thisArg }; + shared.listeners.add(entry); + shared.remote ??= this.socket.listen( + this.scope, + event, + this.ids, + (data) => { + for (const current of shared.listeners) current.listener.call(current.thisArg, data); + }, + this.service, + ); + let disposed = false; + const subscription = { + dispose: (): void => { + if (disposed) return; + disposed = true; + shared.listeners.delete(entry); + if (shared.listeners.size === 0) { + shared.remote?.dispose(); + shared.remote = undefined; + } + }, + }; + disposables?.push(subscription); + return subscription; + }; + } +} diff --git a/apps/kimi-inspect/src/channel/wsSocket.ts b/apps/kimi-inspect/src/channel/wsSocket.ts new file mode 100644 index 000000000..98eca0165 --- /dev/null +++ b/apps/kimi-inspect/src/channel/wsSocket.ts @@ -0,0 +1,456 @@ +/** + * `/api/v2/ws` socket — the persistent WebSocket transport behind the inspect + * client's `WsChannel` (Service emitter `listen`s) and scope event streams. + * + * Speaks the kap-server v2 JSON protocol: one socket multiplexes RPC `call`s + * and event `listen`s, correlated by client-chosen ids. Adds the client-side + * safety features a long-lived devtool connection needs: `hello` handshake, + * `ping`→`pong` heartbeat answers, per-call timeouts, and opt-out automatic + * reconnect (active `listen`s are re-subscribed after a reconnect; in-flight + * calls reject on close — the server cannot resume them). + * + * The bearer token is presented at the upgrade through the + * `kimi-code.bearer.` subprotocol (the only credential channel a browser + * WebSocket has) and again in the `hello` frame for the present-only handshake + * check. Works against the DOM WebSocket (browsers, Node ≥ 21); any compatible + * implementation can be injected for tests. + */ + +import { RPCError } from './errors'; + +/** Wire scope kinds, mirroring kap-server's `ScopeKind`. */ +export type WsScopeKind = 'core' | 'session' | 'agent'; + +/** Scope coordinates carried on `call` / `listen` frames. */ +export interface WsScopeIds { + readonly sessionId?: string; + readonly agentId?: string; +} + +export type WsSocketState = 'connecting' | 'open' | 'closed'; + +export interface WsSubscription { + dispose(): void; +} + +/** Minimal DOM-compatible WebSocket surface this module codes against. */ +export interface WsLike { + readonly readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: never) => void): void; +} + +export interface WsLikeCtor { + new (url: string, protocols?: string | string[]): WsLike; + readonly OPEN: number; +} + +export interface WsSocketOptions { + /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v2/ws` URL. */ + readonly url: string; + /** Optional bearer token. */ + readonly token?: string; + /** WebSocket implementation; defaults to the global `WebSocket`. */ + readonly WebSocketImpl?: WsLikeCtor; + /** Reconnect after an unexpected close. Default `true`. */ + readonly autoReconnect?: boolean; + /** Base delay (ms) for the reconnect backoff. Default `500`. */ + readonly reconnectDelayMs?: number; + /** Per-call deadline (ms). Default `30000`. */ + readonly callTimeoutMs?: number; +} + +interface PendingCall { + readonly resolve: (data: unknown) => void; + readonly reject: (err: Error) => void; + readonly timer: ReturnType | undefined; +} + +interface ActiveListen { + readonly scope: WsScopeKind; + readonly service?: string; + readonly event: string; + readonly ids: WsScopeIds; + readonly handler: (data: unknown) => void; + readonly onError?: (error: Error) => void; + acknowledged: boolean; +} + +export interface WsListenError { + readonly scope: WsScopeKind; + readonly service?: string; + readonly event: string; + readonly error: Error; +} + +interface ServerFrame { + readonly type: string; + readonly id?: string; + readonly data?: unknown; + readonly code?: number; + readonly msg?: string; + readonly eventId?: string; +} + +const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; +const DEFAULT_CALL_TIMEOUT_MS = 30_000; + +export class WsSocket { + private readonly wsUrl: string; + private readonly token?: string; + private readonly WsCtor: WsLikeCtor; + private readonly autoReconnect: boolean; + private readonly reconnectDelayMs: number; + private readonly callTimeoutMs: number; + + private ws: WsLike | undefined; + private state: WsSocketState = 'connecting'; + private manualClose = false; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | undefined; + private readyWaiters: { resolve: () => void; reject: (err: Error) => void }[] = []; + + private readonly pending = new Map(); + private readonly listens = new Map(); + private readonly eventControllers = new Map(); + private readonly stateListeners = new Set<(state: WsSocketState) => void>(); + private readonly listenErrorListeners = new Set<(event: WsListenError) => void>(); + + private seq = 0; + private readonly idPrefix = `k${Date.now().toString(36)}`; + + constructor(opts: WsSocketOptions) { + this.wsUrl = toWsUrl(opts.url); + this.token = opts.token; + const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined); + if (ctor === undefined) { + throw new Error('no WebSocket implementation available; pass WebSocketImpl'); + } + this.WsCtor = ctor; + this.autoReconnect = opts.autoReconnect ?? true; + this.reconnectDelayMs = opts.reconnectDelayMs ?? 500; + this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS; + this.connect(); + } + + get currentState(): WsSocketState { + return this.state; + } + + onDidChangeState(listener: (state: WsSocketState) => void): WsSubscription { + this.stateListeners.add(listener); + return { dispose: () => this.stateListeners.delete(listener) }; + } + + onDidListenError(listener: (event: WsListenError) => void): WsSubscription { + this.listenErrorListeners.add(listener); + return { dispose: () => this.listenErrorListeners.delete(listener) }; + } + + /** RPC call over the socket; rejects on `error` frame, timeout, or close. */ + async call( + scope: WsScopeKind, + service: string, + method: string, + arg?: unknown, + ids?: WsScopeIds, + ): Promise { + await this.whenReady(); + // The socket may have dropped between `whenReady` resolving and this + // continuation running; never register a call we cannot send. + if (this.state !== 'open') { + throw new Error('ws closed'); + } + const id = this.nextId(); + const promise = new Promise((resolve, reject) => { + const timer = + this.callTimeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new RPCError(50001, `call timed out after ${this.callTimeoutMs}ms`)); + }, this.callTimeoutMs) + : undefined; + this.pending.set(id, { + resolve: resolve as (data: unknown) => void, + reject, + timer, + }); + }); + this.send({ type: 'call', id, scope, service, method, arg, ...ids }); + return promise; + } + + /** + * Subscribe to a scope event stream. The subscription survives reconnects + * (re-sent after each reconnect) until `dispose()`d. + */ + listen( + scope: WsScopeKind, + event: string, + ids: WsScopeIds, + handler: (data: unknown) => void, + service?: string, + onError?: (error: Error) => void, + ): WsSubscription { + const id = this.nextId(); + this.listens.set(id, { scope, service, event, ids, handler, onError, acknowledged: false }); + if (this.state === 'open') { + this.send({ type: 'listen', id, scope, service, event, ...ids }); + } + return { + dispose: () => { + if (!this.listens.delete(id)) return; + if (this.state === 'open') { + this.send({ type: 'unlisten', id }); + } + }, + }; + } + + /** Tear the socket down permanently; rejects in-flight calls. */ + close(): void { + this.manualClose = true; + if (this.reconnectTimer !== undefined) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + this.setState('closed'); + this.ws?.close(); + this.ws = undefined; + this.failAll(new Error('ws closed')); + this.rejectReadyWaiters(new Error('ws closed')); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private nextId(): string { + this.seq += 1; + return `${this.idPrefix}_${this.seq}`; + } + + private connect(): void { + this.setState('connecting'); + const protocols = + this.token !== undefined && this.token.length > 0 + ? [`${WS_BEARER_PROTOCOL_PREFIX}${this.token}`] + : undefined; + let ws: WsLike; + try { + ws = new this.WsCtor(this.wsUrl, protocols); + } catch (error) { + this.scheduleReconnect(error); + return; + } + this.ws = ws; + ws.addEventListener('open', () => { + this.onOpen(); + }); + ws.addEventListener('message', (event: { data: unknown }) => { + this.onMessage(event.data); + }); + ws.addEventListener('close', () => { + this.onClose(); + }); + ws.addEventListener('error', () => { + // The 'close' event always follows 'error'; reconnect logic lives there. + }); + } + + private onOpen(): void { + this.reconnectAttempt = 0; + this.setState('open'); + this.send({ type: 'hello', token: this.token }); + for (const [id, sub] of this.listens) { + this.send({ + type: 'listen', + id, + scope: sub.scope, + service: sub.service, + event: sub.event, + ...sub.ids, + }); + } + const waiters = this.readyWaiters; + this.readyWaiters = []; + for (const w of waiters) w.resolve(); + } + + private onMessage(raw: unknown): void { + let frame: ServerFrame; + try { + frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame; + } catch { + return; + } + switch (frame.type) { + case 'ready': + case 'server_hello': + return; + case 'ping': + this.send({ type: 'pong' }); + return; + case 'result': { + const p = this.take(frame.id); + p?.resolve(frame.data); + return; + } + case 'error': { + const p = this.take(frame.id); + if (p !== undefined) { + p.reject(new RPCError(frame.code ?? 50001, frame.msg ?? 'error')); + } else { + const sub = this.listens.get(frame.id ?? ''); + if (sub !== undefined) { + this.listens.delete(frame.id ?? ''); + const error = new RPCError(frame.code ?? 50001, frame.msg ?? 'error'); + sub.onError?.(error); + queueMicrotask(() => { + for (const listener of this.listenErrorListeners) { + listener({ scope: sub.scope, service: sub.service, event: sub.event, error }); + } + }); + } + } + return; + } + case 'listen_result': { + const sub = this.listens.get(frame.id ?? ''); + if (sub !== undefined) sub.acknowledged = true; + return; + } + case 'event': { + const sub = this.listens.get(frame.id ?? ''); + if (sub === undefined) return; + if (frame.eventId === undefined) { + sub.handler(frame.data); + return; + } + const controller = new AbortController(); + const eventKey = `${frame.id}:${frame.eventId}`; + this.eventControllers.set(eventKey, controller); + const waits: Promise[] = []; + const data = { + ...(frame.data as object), + signal: controller.signal, + waitUntil: (promise: Promise) => waits.push(promise), + }; + try { + sub.handler(data); + } catch { + // Listener failures are fail-open for the server-side event. + } + void Promise.allSettled(waits).finally(() => { + if (!this.eventControllers.delete(eventKey)) return; + this.send({ type: 'event_result', id: frame.id, eventId: frame.eventId }); + }); + return; + } + case 'event_cancel': { + const key = `${frame.id}:${frame.eventId}`; + const controller = this.eventControllers.get(key); + if (controller !== undefined) { + this.eventControllers.delete(key); + controller.abort(); + } + return; + } + } + } + + private onClose(): void { + this.ws = undefined; + for (const controller of this.eventControllers.values()) controller.abort(); + this.eventControllers.clear(); + this.failAll(new Error('ws closed')); + if (this.manualClose || !this.autoReconnect) { + this.setState('closed'); + this.rejectReadyWaiters(new Error('ws closed')); + return; + } + // Transient drop: queued calls keep waiting for the reconnect. + this.scheduleReconnect(undefined); + } + + private scheduleReconnect(_cause: unknown): void { + if (this.manualClose || !this.autoReconnect) { + this.setState('closed'); + return; + } + this.reconnectAttempt += 1; + const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000); + this.setState('connecting'); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + this.connect(); + }, delay); + this.reconnectTimer.unref?.(); + } + + private whenReady(): Promise { + if (this.state === 'open') return Promise.resolve(); + if (this.state === 'closed' && this.manualClose) { + return Promise.reject(new Error('ws closed')); + } + return new Promise((resolve, reject) => { + this.readyWaiters.push({ resolve, reject }); + }); + } + + private rejectReadyWaiters(err: Error): void { + const waiters = this.readyWaiters; + this.readyWaiters = []; + for (const w of waiters) w.reject(err); + } + + private take(id: string | undefined): PendingCall | undefined { + const p = this.pending.get(id ?? ''); + if (p !== undefined) { + this.pending.delete(id ?? ''); + if (p.timer !== undefined) clearTimeout(p.timer); + } + return p; + } + + private failAll(err: Error): void { + for (const p of this.pending.values()) { + if (p.timer !== undefined) clearTimeout(p.timer); + p.reject(err); + } + this.pending.clear(); + } + + private send(frame: Record): void { + const ws = this.ws; + if (ws === undefined || ws.readyState !== this.WsCtor.OPEN) return; + try { + ws.send(JSON.stringify(frame)); + } catch { + // best-effort; the close handler handles teardown + } + } + + private setState(next: WsSocketState): void { + if (this.state === next) return; + this.state = next; + for (const listener of this.stateListeners) listener(next); + } +} + +/** Derive the `/api/v2/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */ +function toWsUrl(base: string): string { + const url = new URL(base); + if (url.protocol === 'http:') url.protocol = 'ws:'; + else if (url.protocol === 'https:') url.protocol = 'wss:'; + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new Error(`unsupported URL scheme for WS transport: ${base}`); + } + if (!url.pathname.endsWith('/api/v2/ws')) { + url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v2/ws`; + } + url.search = ''; + url.hash = ''; + return url.toString(); +} diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx new file mode 100644 index 000000000..ac6169b8a --- /dev/null +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -0,0 +1,333 @@ +/** + * Main view — the conversation of the active session + agent. + * + * History comes from `IAgentContextMemoryService.get()` (the authoritative + * context); live turns stream in through the agent `events` subscription + * (`assistant.delta` / `thinking.delta` / `tool.*`) as ephemeral entries. On + * `turn.ended` the history is refetched and the ephemeral layer is dropped, so + * the view always converges to the authoritative context. Prompts go out via + * `IAgentRPCService.prompt`; `cancel` aborts the running turn. + */ + +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; +import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; + +import { useConnection } from '../connection'; +import { eventType, payloadField, useLiveEvent } from '../live'; +import { ActionButton, Badge, ErrorLine } from '../ui'; + +interface ChatEntry { + readonly id: string; + readonly kind: 'user' | 'assistant' | 'thinking' | 'tool' | 'error'; + text: string; + name?: string; + args?: string; + output?: string; + isError?: boolean; +} + +interface HistoryMessage { + readonly role?: string; + readonly content?: readonly { type?: string; text?: string; think?: string }[]; + readonly toolCalls?: readonly { id?: string; name?: string; arguments?: string | null }[]; + readonly isError?: boolean; +} + +function mapHistory(messages: readonly HistoryMessage[]): ChatEntry[] { + const entries: ChatEntry[] = []; + messages.forEach((m, i) => { + const parts = m.content ?? []; + const text = parts + .filter((p) => p.type === 'text') + .map((p) => p.text ?? '') + .join('\n'); + const think = parts + .filter((p) => p.type === 'think') + .map((p) => p.think ?? p.text ?? '') + .join('\n'); + if (m.role === 'user') { + entries.push({ id: `h${i}`, kind: 'user', text: text || '[non-text content]' }); + } else if (m.role === 'assistant') { + if (think !== '') entries.push({ id: `h${i}t`, kind: 'thinking', text: think }); + if (text !== '') entries.push({ id: `h${i}a`, kind: 'assistant', text }); + for (const call of m.toolCalls ?? []) { + entries.push({ + id: `h${i}c${call.id ?? ''}`, + kind: 'tool', + text: '', + name: call.name ?? 'tool', + args: call.arguments ?? undefined, + }); + } + } else if (m.role === 'tool') { + entries.push({ + id: `h${i}r`, + kind: 'tool', + text: '', + name: 'result', + output: text, + isError: m.isError, + }); + } + // system / developer messages (the system prompt) are not shown. + }); + return entries; +} + +export function ChatView({ + sessionId, + agentId, + ready, +}: { + sessionId: string | null; + agentId: string; + ready: boolean; +}) { + const { klient } = useConnection(); + const queryClient = useQueryClient(); + const [stream, setStream] = useState([]); + const [input, setInput] = useState(''); + const [running, setRunning] = useState(false); + const [sendError, setSendError] = useState(null); + const bottomRef = useRef(null); + + const enabled = sessionId !== null && ready; + const history = useQuery({ + queryKey: ['history', sessionId, agentId], + queryFn: () => + klient + .session(sessionId as string) + .agent(agentId) + .service(IAgentContextMemoryService) + .get(), + enabled, + }); + + const refetchHistory = useRef | undefined>(undefined); + const scheduleRefetch = () => { + if (refetchHistory.current !== undefined) clearTimeout(refetchHistory.current); + refetchHistory.current = setTimeout(() => { + void queryClient.invalidateQueries({ queryKey: ['history', sessionId, agentId] }); + setStream([]); + setRunning(false); + }, 500); + }; + + useLiveEvent((event) => { + if (event.source !== 'agent') return; + const type = eventType(event); + const data = event.data as Record; + switch (type) { + case 'turn.started': + setRunning(true); + return; + case 'assistant.delta': + case 'thinking.delta': { + const turnId = payloadField(data, 'turnId', '?'); + const kind = type === 'assistant.delta' ? 'assistant' : 'thinking'; + const id = `stream:${turnId}:${kind}`; + const delta = payloadField(data, 'delta', ''); + setStream((prev) => { + const found = prev.find((e) => e.id === id); + if (found === undefined) return [...prev, { id, kind, text: delta }]; + return prev.map((e) => (e.id === id ? { ...e, text: e.text + delta } : e)); + }); + return; + } + case 'tool.call.started': { + const callId = payloadField(data, 'toolCallId', String(Math.random())); + setStream((prev) => [ + ...prev, + { + id: `stream:tool:${callId}`, + kind: 'tool', + text: '', + name: payloadField(data, 'name', 'tool'), + args: typeof data['args'] === 'string' ? data['args'] : JSON.stringify(data['args'] ?? ''), + }, + ]); + return; + } + case 'tool.result': { + const callId = payloadField(data, 'toolCallId', ''); + const output = typeof data['output'] === 'string' ? data['output'] : JSON.stringify(data['output']); + const id = `stream:tool:${callId}`; + setStream((prev) => { + const found = prev.find((e) => e.id === id); + if (found === undefined) { + return [...prev, { id, kind: 'tool', text: '', name: 'result', output, isError: Boolean(data['isError']) }]; + } + return prev.map((e) => (e.id === id ? { ...e, output, isError: Boolean(data['isError']) } : e)); + }); + return; + } + case 'turn.ended': + case 'prompt.completed': + case 'prompt.aborted': + case 'compaction.completed': + scheduleRefetch(); + return; + case 'error': { + const message = + typeof data['message'] === 'string' + ? data['message'] + : JSON.stringify(data).slice(0, 300); + setStream((prev) => [ + ...prev, + { id: `stream:err:${Date.now()}`, kind: 'error', text: message }, + ]); + setRunning(false); + return; + } + } + }); + + const entries = useMemo( + () => [...mapHistory((history.data ?? []) as readonly HistoryMessage[]), ...stream], + [history.data, stream], + ); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ block: 'end' }); + }, [entries.length, stream]); + + const send = async () => { + if (sessionId === null || input.trim() === '' || running) return; + const text = input.trim(); + setInput(''); + setSendError(null); + setStream((prev) => [...prev, { id: `optimistic:${Date.now()}`, kind: 'user', text }]); + try { + await klient + .session(sessionId) + .agent(agentId) + .service(IAgentRPCService) + .prompt({ input: [{ type: 'text', text }] }); + } catch (error) { + setSendError(error); + } + }; + + const cancel = async () => { + if (sessionId === null) return; + try { + await klient.session(sessionId).agent(agentId).service(IAgentRPCService).cancel({}); + } catch (error) { + setSendError(error); + } + }; + + if (sessionId === null) { + return ( +
+ Select a session on the left to open its conversation. +
+ ); + } + if (!ready) { + return ( +
+ Loading session… +
+ ); + } + + return ( +
+
+ {sessionId} + agent: {agentId} + {running ? turn running : idle} +
+ +
+ {history.isError ? : null} + {entries.length === 0 && !history.isLoading ? ( +
Empty context — send a prompt below.
+ ) : null} + {entries.map((entry) => ( + + ))} +
+
+ +
+ {sendError !== null ?
: null} +
+ + + + +
+
+ + + diff --git a/apps/kimi-web/src/components/chat/SlashMenu.vue b/apps/kimi-web/src/components/chat/SlashMenu.vue new file mode 100644 index 000000000..85f23168d --- /dev/null +++ b/apps/kimi-web/src/components/chat/SlashMenu.vue @@ -0,0 +1,115 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/StatusGlyph.vue b/apps/kimi-web/src/components/chat/StatusGlyph.vue new file mode 100644 index 000000000..5a22524d3 --- /dev/null +++ b/apps/kimi-web/src/components/chat/StatusGlyph.vue @@ -0,0 +1,34 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/StatusPanel.vue b/apps/kimi-web/src/components/chat/StatusPanel.vue new file mode 100644 index 000000000..6d51e6148 --- /dev/null +++ b/apps/kimi-web/src/components/chat/StatusPanel.vue @@ -0,0 +1,175 @@ + + + + + + + + diff --git a/apps/kimi-web/src/components/chat/TasksPane.vue b/apps/kimi-web/src/components/chat/TasksPane.vue new file mode 100644 index 000000000..4ca02b237 --- /dev/null +++ b/apps/kimi-web/src/components/chat/TasksPane.vue @@ -0,0 +1,333 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/ThinkingBlock.vue b/apps/kimi-web/src/components/chat/ThinkingBlock.vue new file mode 100644 index 000000000..9fbb37db4 --- /dev/null +++ b/apps/kimi-web/src/components/chat/ThinkingBlock.vue @@ -0,0 +1,152 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/ThinkingPanel.vue b/apps/kimi-web/src/components/chat/ThinkingPanel.vue new file mode 100644 index 000000000..d917b82eb --- /dev/null +++ b/apps/kimi-web/src/components/chat/ThinkingPanel.vue @@ -0,0 +1,73 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/TodoCard.vue b/apps/kimi-web/src/components/chat/TodoCard.vue new file mode 100644 index 000000000..48190f0c0 --- /dev/null +++ b/apps/kimi-web/src/components/chat/TodoCard.vue @@ -0,0 +1,78 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/ToolCall.vue b/apps/kimi-web/src/components/chat/ToolCall.vue new file mode 100644 index 000000000..5e9d3d785 --- /dev/null +++ b/apps/kimi-web/src/components/chat/ToolCall.vue @@ -0,0 +1,40 @@ + + + + diff --git a/apps/kimi-web/src/components/chat/ToolDiffPanel.vue b/apps/kimi-web/src/components/chat/ToolDiffPanel.vue new file mode 100644 index 000000000..9eeac7e09 --- /dev/null +++ b/apps/kimi-web/src/components/chat/ToolDiffPanel.vue @@ -0,0 +1,66 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/ToolGroup.vue b/apps/kimi-web/src/components/chat/ToolGroup.vue new file mode 100644 index 000000000..8a30af9be --- /dev/null +++ b/apps/kimi-web/src/components/chat/ToolGroup.vue @@ -0,0 +1,160 @@ + + + + + + diff --git a/apps/kimi-web/src/components/chat/ToolRow.vue b/apps/kimi-web/src/components/chat/ToolRow.vue new file mode 100644 index 000000000..65d8a3bd5 --- /dev/null +++ b/apps/kimi-web/src/components/chat/ToolRow.vue @@ -0,0 +1,223 @@ + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/AgentTool.vue b/apps/kimi-web/src/components/chat/tool-calls/AgentTool.vue new file mode 100644 index 000000000..2c364b213 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/AgentTool.vue @@ -0,0 +1,145 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue b/apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue new file mode 100644 index 000000000..ad4272b45 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue @@ -0,0 +1,268 @@ + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/EditTool.vue b/apps/kimi-web/src/components/chat/tool-calls/EditTool.vue new file mode 100644 index 000000000..0033da992 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/EditTool.vue @@ -0,0 +1,90 @@ + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/GenericTool.vue b/apps/kimi-web/src/components/chat/tool-calls/GenericTool.vue new file mode 100644 index 000000000..c22a6042a --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/GenericTool.vue @@ -0,0 +1,93 @@ + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/MediaTool.vue b/apps/kimi-web/src/components/chat/tool-calls/MediaTool.vue new file mode 100644 index 000000000..36ab629af --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/MediaTool.vue @@ -0,0 +1,98 @@ + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue b/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue new file mode 100644 index 000000000..a970942cd --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue @@ -0,0 +1,484 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/ToolOutputBlock.vue b/apps/kimi-web/src/components/chat/tool-calls/ToolOutputBlock.vue new file mode 100644 index 000000000..3f7059556 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/ToolOutputBlock.vue @@ -0,0 +1,42 @@ + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/askUserToolParse.ts b/apps/kimi-web/src/components/chat/tool-calls/askUserToolParse.ts new file mode 100644 index 000000000..64c0e300d --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/askUserToolParse.ts @@ -0,0 +1,166 @@ +// Pure parsers for the AskUserQuestion tool card. Kept separate from the SFC so +// the answer-resolution logic is unit-testable without a DOM. +// +// Wire shape: +// tool.arg : JSON { questions: [{ question, header, options[{label,description}], multi_select }] } +// Input questions carry NO id — order === broker order. +// tool.output[0]: on a successful answer, JSON { answers: Record, note? } +// value = the chosen option's label (single), labels joined +// with ', ' (multi), free-text (Other), or labels+text +// (multi+Other). skipped → omitted. Dismissed → { answers: {}, note }. +// LEGACY (transcripts from before the label form): keys are +// `q_` and values are synthesized `opt__` ids — +// still decoded so old sessions keep rendering. +// : on a background launch, plain text (`task_id: …\nstatus: …`); +// on an error (e.g. unsupported interactive questions), plain +// text. Those are NOT the answer payload and must be shown raw. + +export interface AskOption { + label: string; + description: string; +} + +export interface AskQuestion { + question: string; + header: string; + options: AskOption[]; + multiSelect: boolean; +} + +export interface AskOutput { + /** True only when the output parsed as the answer payload (`{ answers: {...} }`). + * False for background / error plain-text output, which the card must show raw. */ + recognized: boolean; + answers: Record; + note: string; +} + +export interface Resolved { + /** Option indices picked for this question. */ + selected: Set; + /** Free-text "Other" segment, when the answer carried one. */ + otherText: string; + /** The flattened value was the literal `true` — answered, but no concrete + option to echo back onto the list. */ + indeterminate: boolean; +} + +export function parseAskInput(arg: string): AskQuestion[] { + if (!arg) return []; + try { + const obj = JSON.parse(arg) as Record; + const raw = obj['questions']; + if (!Array.isArray(raw)) return []; + const out: AskQuestion[] = []; + for (const q of raw) { + if (!q || typeof q !== 'object') continue; + const qr = q as Record; + const opts: AskOption[] = Array.isArray(qr['options']) + ? (qr['options'] as unknown[]).map(o => { + const or = (o && typeof o === 'object' ? o : {}) as Record; + return { + label: typeof or['label'] === 'string' ? or['label'] : '', + description: typeof or['description'] === 'string' ? or['description'] : '', + }; + }) + : []; + out.push({ + question: typeof qr['question'] === 'string' ? qr['question'] : '', + header: typeof qr['header'] === 'string' ? qr['header'] : '', + options: opts, + multiSelect: qr['multi_select'] === true, + }); + } + return out; + } catch { + return []; + } +} + +const EMPTY: AskOutput = { recognized: false, answers: {}, note: '' }; + +export function parseAskOutput(output: string[] | undefined): AskOutput { + const line = output?.[0]; + if (!line) return EMPTY; + let obj: unknown; + try { + obj = JSON.parse(line); + } catch { + // Plain-text output (background `task_id/status`, error message) — show raw. + return EMPTY; + } + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return EMPTY; + const raw = (obj as Record)['answers']; + // The answer payload is the only shape we render specially; anything else + // (a JSON object without an `answers` record) falls back to raw output. + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return EMPTY; + const answers: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === 'string') answers[k] = v; + else if (v === true) answers[k] = true; + } + return { + recognized: true, + answers, + note: typeof (obj as Record)['note'] === 'string' + ? ((obj as Record)['note'] as string) + : '', + }; +} + +/** Look up one question's flattened answer: current transcripts key by the + * question text; legacy transcripts key by `q_`. */ +export function answerFor( + answers: Record, + questionText: string, + index: number, +): string | true | undefined { + return answers[questionText] ?? answers[`q_${index}`]; +} + +const OPT_ID = /^opt_\d+_(\d+)$/; + +/** Decode one question's flattened answer into picked option indices plus any + * free-text "Other" segment. + * + * Current form: the value is option label text — matched exactly against + * `options` (whole-string first, so a single-select label containing a comma + * still resolves; then per comma-segment for multi-select). Segments are + * trimmed before matching because joiners vary by client (', ' from the + * server translator and TUI, ',' in older transcripts). Legacy form: the + * value is `opt__` ids whose trailing index is decoded directly. + * Segments that are neither a label nor an id are the Other free text + * (rejoined with ', ' in case the text itself contained a comma). */ +export function resolveAnswer( + value: string | true | undefined, + options: readonly AskOption[] = [], +): Resolved { + if (value === undefined) return { selected: new Set(), otherText: '', indeterminate: false }; + if (value === true) return { selected: new Set(), otherText: '', indeterminate: true }; + + const indexByLabel = new Map(); + // First occurrence wins on (out-of-contract) duplicate labels. + options.forEach((o, i) => { + if (o.label.length > 0 && !indexByLabel.has(o.label)) indexByLabel.set(o.label, i); + }); + + const whole = indexByLabel.get(value); + if (whole !== undefined) { + return { selected: new Set([whole]), otherText: '', indeterminate: false }; + } + + const selected = new Set(); + const others: string[] = []; + for (const rawSeg of value.split(',')) { + const seg = rawSeg.trim(); + const byLabel = indexByLabel.get(seg); + if (byLabel !== undefined) { + selected.add(byLabel); + continue; + } + const m = OPT_ID.exec(seg); + if (m) selected.add(Number(m[1])); + else if (seg.length > 0) others.push(seg); + } + return { selected, otherText: others.join(', '), indeterminate: false }; +} diff --git a/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts b/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts new file mode 100644 index 000000000..96e8f4e78 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts @@ -0,0 +1,27 @@ +// apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts +import type { Component } from 'vue'; +import type { ToolCall } from '../../../types'; +import { normalizeToolName } from '../../../lib/toolMeta'; +import AgentTool from './AgentTool.vue'; +import AskUserTool from './AskUserTool.vue'; +import EditTool from './EditTool.vue'; +import GenericTool from './GenericTool.vue'; +import MediaTool from './MediaTool.vue'; +import SwarmTool from './SwarmTool.vue'; + +type ToolRenderer = Component; + +/** Pick the renderer for a tool call. */ +export function resolveToolRenderer(tool: ToolCall): ToolRenderer { + if (tool.media && tool.status === 'ok') return MediaTool; + const name = normalizeToolName(tool.name); + if (name === 'edit' || name === 'write' || name === 'multi_edit') return EditTool; + // NOTE: normalizeToolName() folds `agent`/`subagent` into the canonical + // `task` kind (see lib/toolMeta.ts NAME_ALIASES), so the match must be on + // `task` — `agent` here would be dead code and route subagent calls to + // GenericTool, dropping the inline "Open" button for the detail panel. + if (name === 'task') return AgentTool; + if (name === 'agentswarm') return SwarmTool; + if (name === 'askuserquestion') return AskUserTool; + return GenericTool; +} diff --git a/apps/kimi-web/src/components/chatTurnRendering.ts b/apps/kimi-web/src/components/chatTurnRendering.ts new file mode 100644 index 000000000..be57896bd --- /dev/null +++ b/apps/kimi-web/src/components/chatTurnRendering.ts @@ -0,0 +1,125 @@ +// apps/kimi-web/src/components/chatTurnRendering.ts +// Pure turn-rendering helpers: pure functions of their arguments (no Vue +// reactivity, no component state). Shared by ChatPane.vue's template and its +// stateful copy/edit helpers. +import type { ChatTurn, TurnBlock } from '../types'; + +// Shared 1024-based token formatter (lib/formatTokens); re-exported so the +// existing ChatPane import keeps working. +export { formatTokens } from '../lib/formatTokens'; + +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + const m = Math.floor(ms / 60_000); + const s = ((ms % 60_000) / 1000).toFixed(1); + return `${m}m${s}s`; +} + +// Ordered render blocks for an assistant turn. messagesToTurns supplies `blocks` +// (thinking + text + tool cards in call order); fall back to deriving them from +// the aggregate fields for any turn built without blocks (e.g. unit tests). +export function turnBlocks(turn: ChatTurn): TurnBlock[] { + if (turn.blocks) return turn.blocks; + const blocks: TurnBlock[] = []; + if (turn.thinking) blocks.push({ kind: 'thinking', thinking: turn.thinking }); + if (turn.text) blocks.push({ kind: 'text', text: turn.text }); + for (const tool of turn.tools ?? []) blocks.push({ kind: 'tool', tool }); + return blocks; +} + +export type ToolStackPosition = 'single' | 'first' | 'middle' | 'last'; + +export type ToolStackItem = { + tool: Extract['tool']; + sourceIndex: number; +}; + +export type AssistantRenderBlock = + | { kind: 'thinking'; thinking: string; sourceIndex: number } + | { kind: 'text'; text: string; sourceIndex: number } + | { kind: 'tool'; tool: ToolStackItem['tool']; sourceIndex: number } + | { kind: 'tool-stack'; tools: ToolStackItem[] }; + +export function rendersToolCard(block: Extract): boolean { + return !(block.tool.status === 'ok' && block.tool.media); +} + +export function toolStackPosition(index: number, count: number): ToolStackPosition { + if (count <= 1) return 'single'; + if (index === 0) return 'first'; + if (index === count - 1) return 'last'; + return 'middle'; +} + +export function assistantRenderBlocks(turn: ChatTurn): AssistantRenderBlock[] { + const blocks = turnBlocks(turn); + const rendered: AssistantRenderBlock[] = []; + let toolRun: ToolStackItem[] = []; + + const flushToolRun = () => { + if (toolRun.length === 1) { + const [item] = toolRun; + if (item) rendered.push({ kind: 'tool', tool: item.tool, sourceIndex: item.sourceIndex }); + } else if (toolRun.length > 1) { + rendered.push({ kind: 'tool-stack', tools: toolRun }); + } + toolRun = []; + }; + + blocks.forEach((block, sourceIndex) => { + if (block.kind === 'tool') { + if (rendersToolCard(block)) { + toolRun.push({ tool: block.tool, sourceIndex }); + return; + } + flushToolRun(); + rendered.push({ kind: 'tool', tool: block.tool, sourceIndex }); + return; + } + + flushToolRun(); + if (block.kind === 'thinking') { + rendered.push({ kind: 'thinking', thinking: block.thinking, sourceIndex }); + } else if (block.kind === 'text') { + rendered.push({ kind: 'text', text: block.text, sourceIndex }); + } + }); + + flushToolRun(); + return rendered; +} + +export function turnFinalText(turn: ChatTurn): string { + return turnBlocks(turn) + .flatMap((blk) => (blk.kind === 'text' && blk.text ? [blk.text] : [])) + .join('\n\n'); +} + +/** Convert a single turn to Markdown. */ +export function turnToMarkdown(turn: ChatTurn): string { + const parts: string[] = []; + for (const blk of turnBlocks(turn)) { + if (blk.kind === 'thinking' && blk.thinking) { + parts.push(`> **Thinking**\n> ${blk.thinking.split('\n').join('\n> ')}`); + } else if (blk.kind === 'text' && blk.text) { + parts.push(blk.text); + } else if (blk.kind === 'tool' && blk.tool.output && blk.tool.output.length > 0) { + const output = blk.tool.output.join('\n'); + parts.push(`\`\`\`\n[${blk.tool.name}]\n${output}\n\`\`\``); + } + } + return parts.join('\n\n'); +} + +export function toolStackKey(item: ToolStackItem): string { + return item.tool.id || `tool-${item.sourceIndex}`; +} + +export function renderBlockKey(block: AssistantRenderBlock, index: number): string { + if (block.kind === 'tool-stack') { + return `tool-stack-${block.tools[0]?.sourceIndex ?? index}`; + } + if (block.kind === 'tool') return toolStackKey({ tool: block.tool, sourceIndex: block.sourceIndex }); + return `${block.kind}-${block.sourceIndex}`; +} diff --git a/apps/kimi-web/src/components/dialogs/AddWorkspaceDialog.vue b/apps/kimi-web/src/components/dialogs/AddWorkspaceDialog.vue new file mode 100644 index 000000000..26aeafec2 --- /dev/null +++ b/apps/kimi-web/src/components/dialogs/AddWorkspaceDialog.vue @@ -0,0 +1,641 @@ + + + + + + + + + + + + + + diff --git a/apps/kimi-web/src/components/dialogs/BottomSheet.vue b/apps/kimi-web/src/components/dialogs/BottomSheet.vue new file mode 100644 index 000000000..4435ffd8c --- /dev/null +++ b/apps/kimi-web/src/components/dialogs/BottomSheet.vue @@ -0,0 +1,168 @@ + + + + + + + + + + diff --git a/apps/kimi-web/src/components/dialogs/ConfirmDialog.vue b/apps/kimi-web/src/components/dialogs/ConfirmDialog.vue new file mode 100644 index 000000000..508b0db68 --- /dev/null +++ b/apps/kimi-web/src/components/dialogs/ConfirmDialog.vue @@ -0,0 +1,108 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/dialogs/ConfirmDialogHost.vue b/apps/kimi-web/src/components/dialogs/ConfirmDialogHost.vue new file mode 100644 index 000000000..ddb2e4ac6 --- /dev/null +++ b/apps/kimi-web/src/components/dialogs/ConfirmDialogHost.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/apps/kimi-web/src/components/dialogs/LoginDialog.vue b/apps/kimi-web/src/components/dialogs/LoginDialog.vue new file mode 100644 index 000000000..279b5f364 --- /dev/null +++ b/apps/kimi-web/src/components/dialogs/LoginDialog.vue @@ -0,0 +1,501 @@ + + + + + + + + diff --git a/apps/kimi-web/src/components/dialogs/SearchSessionsDialog.vue b/apps/kimi-web/src/components/dialogs/SearchSessionsDialog.vue new file mode 100644 index 000000000..8647f842a --- /dev/null +++ b/apps/kimi-web/src/components/dialogs/SearchSessionsDialog.vue @@ -0,0 +1,301 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue new file mode 100644 index 000000000..814ea8c39 --- /dev/null +++ b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue @@ -0,0 +1,687 @@ + + + + + + + + + + + + diff --git a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue new file mode 100644 index 000000000..1de3de426 --- /dev/null +++ b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue @@ -0,0 +1,522 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/mobile/MobileTopBar.vue b/apps/kimi-web/src/components/mobile/MobileTopBar.vue new file mode 100644 index 000000000..df34d8fde --- /dev/null +++ b/apps/kimi-web/src/components/mobile/MobileTopBar.vue @@ -0,0 +1,175 @@ + + + + + + + + + + + diff --git a/apps/kimi-web/src/components/settings/LanguageSwitcher.vue b/apps/kimi-web/src/components/settings/LanguageSwitcher.vue new file mode 100644 index 000000000..974228ceb --- /dev/null +++ b/apps/kimi-web/src/components/settings/LanguageSwitcher.vue @@ -0,0 +1,19 @@ + + + + diff --git a/apps/kimi-web/src/components/settings/ModelPicker.vue b/apps/kimi-web/src/components/settings/ModelPicker.vue new file mode 100644 index 000000000..16541ef64 --- /dev/null +++ b/apps/kimi-web/src/components/settings/ModelPicker.vue @@ -0,0 +1,350 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/settings/Onboarding.vue b/apps/kimi-web/src/components/settings/Onboarding.vue new file mode 100644 index 000000000..e5447e905 --- /dev/null +++ b/apps/kimi-web/src/components/settings/Onboarding.vue @@ -0,0 +1,139 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/settings/ProviderManager.vue b/apps/kimi-web/src/components/settings/ProviderManager.vue new file mode 100644 index 000000000..e9316f6bd --- /dev/null +++ b/apps/kimi-web/src/components/settings/ProviderManager.vue @@ -0,0 +1,364 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/settings/SettingsDialog.vue b/apps/kimi-web/src/components/settings/SettingsDialog.vue new file mode 100644 index 000000000..181e4197e --- /dev/null +++ b/apps/kimi-web/src/components/settings/SettingsDialog.vue @@ -0,0 +1,842 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/AuthStateIcon.vue b/apps/kimi-web/src/components/ui/AuthStateIcon.vue new file mode 100644 index 000000000..5eda5c2a9 --- /dev/null +++ b/apps/kimi-web/src/components/ui/AuthStateIcon.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/apps/kimi-web/src/components/ui/Avatar.vue b/apps/kimi-web/src/components/ui/Avatar.vue new file mode 100644 index 000000000..bb3f9e7d8 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Avatar.vue @@ -0,0 +1,29 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Badge.vue b/apps/kimi-web/src/components/ui/Badge.vue new file mode 100644 index 000000000..b4a59ca9b --- /dev/null +++ b/apps/kimi-web/src/components/ui/Badge.vue @@ -0,0 +1,45 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Banner.vue b/apps/kimi-web/src/components/ui/Banner.vue new file mode 100644 index 000000000..d0206c7d7 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Banner.vue @@ -0,0 +1,44 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Button.vue b/apps/kimi-web/src/components/ui/Button.vue new file mode 100644 index 000000000..56f7d8fed --- /dev/null +++ b/apps/kimi-web/src/components/ui/Button.vue @@ -0,0 +1,124 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Card.vue b/apps/kimi-web/src/components/ui/Card.vue new file mode 100644 index 000000000..80958e731 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Card.vue @@ -0,0 +1,50 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Checkbox.vue b/apps/kimi-web/src/components/ui/Checkbox.vue new file mode 100644 index 000000000..899b02fee --- /dev/null +++ b/apps/kimi-web/src/components/ui/Checkbox.vue @@ -0,0 +1,52 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/CommandBar.vue b/apps/kimi-web/src/components/ui/CommandBar.vue new file mode 100644 index 000000000..427381c73 --- /dev/null +++ b/apps/kimi-web/src/components/ui/CommandBar.vue @@ -0,0 +1,59 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/ContextRing.vue b/apps/kimi-web/src/components/ui/ContextRing.vue new file mode 100644 index 000000000..36ed8b096 --- /dev/null +++ b/apps/kimi-web/src/components/ui/ContextRing.vue @@ -0,0 +1,43 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Dialog.vue b/apps/kimi-web/src/components/ui/Dialog.vue new file mode 100644 index 000000000..a11153bce --- /dev/null +++ b/apps/kimi-web/src/components/ui/Dialog.vue @@ -0,0 +1,219 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Divider.vue b/apps/kimi-web/src/components/ui/Divider.vue new file mode 100644 index 000000000..6a94348e1 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Divider.vue @@ -0,0 +1,15 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/EmptyState.vue b/apps/kimi-web/src/components/ui/EmptyState.vue new file mode 100644 index 000000000..fb8cf7fc3 --- /dev/null +++ b/apps/kimi-web/src/components/ui/EmptyState.vue @@ -0,0 +1,31 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Field.vue b/apps/kimi-web/src/components/ui/Field.vue new file mode 100644 index 000000000..ca007bfd4 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Field.vue @@ -0,0 +1,30 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Icon.vue b/apps/kimi-web/src/components/ui/Icon.vue new file mode 100644 index 000000000..aaaa00218 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Icon.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/apps/kimi-web/src/components/ui/IconButton.vue b/apps/kimi-web/src/components/ui/IconButton.vue new file mode 100644 index 000000000..09e9bb479 --- /dev/null +++ b/apps/kimi-web/src/components/ui/IconButton.vue @@ -0,0 +1,67 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Input.vue b/apps/kimi-web/src/components/ui/Input.vue new file mode 100644 index 000000000..70f2d657a --- /dev/null +++ b/apps/kimi-web/src/components/ui/Input.vue @@ -0,0 +1,82 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Kbd.vue b/apps/kimi-web/src/components/ui/Kbd.vue new file mode 100644 index 000000000..6b20984fa --- /dev/null +++ b/apps/kimi-web/src/components/ui/Kbd.vue @@ -0,0 +1,40 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Link.vue b/apps/kimi-web/src/components/ui/Link.vue new file mode 100644 index 000000000..e87935718 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Link.vue @@ -0,0 +1,37 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Menu.vue b/apps/kimi-web/src/components/ui/Menu.vue new file mode 100644 index 000000000..3689154eb --- /dev/null +++ b/apps/kimi-web/src/components/ui/Menu.vue @@ -0,0 +1,30 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/MenuItem.vue b/apps/kimi-web/src/components/ui/MenuItem.vue new file mode 100644 index 000000000..cc9cf0d69 --- /dev/null +++ b/apps/kimi-web/src/components/ui/MenuItem.vue @@ -0,0 +1,58 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/MoonSpinner.vue b/apps/kimi-web/src/components/ui/MoonSpinner.vue new file mode 100644 index 000000000..cc7b04ce1 --- /dev/null +++ b/apps/kimi-web/src/components/ui/MoonSpinner.vue @@ -0,0 +1,84 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/PanelHeader.vue b/apps/kimi-web/src/components/ui/PanelHeader.vue new file mode 100644 index 000000000..930ba5179 --- /dev/null +++ b/apps/kimi-web/src/components/ui/PanelHeader.vue @@ -0,0 +1,88 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Pill.vue b/apps/kimi-web/src/components/ui/Pill.vue new file mode 100644 index 000000000..62b6ab5be --- /dev/null +++ b/apps/kimi-web/src/components/ui/Pill.vue @@ -0,0 +1,58 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/SegmentedControl.vue b/apps/kimi-web/src/components/ui/SegmentedControl.vue new file mode 100644 index 000000000..dbb3f66f0 --- /dev/null +++ b/apps/kimi-web/src/components/ui/SegmentedControl.vue @@ -0,0 +1,57 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Select.vue b/apps/kimi-web/src/components/ui/Select.vue new file mode 100644 index 000000000..aaa7f1a2b --- /dev/null +++ b/apps/kimi-web/src/components/ui/Select.vue @@ -0,0 +1,76 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Sheet.vue b/apps/kimi-web/src/components/ui/Sheet.vue new file mode 100644 index 000000000..f12f186db --- /dev/null +++ b/apps/kimi-web/src/components/ui/Sheet.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Skeleton.vue b/apps/kimi-web/src/components/ui/Skeleton.vue new file mode 100644 index 000000000..dd5deb016 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Skeleton.vue @@ -0,0 +1,33 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Spinner.vue b/apps/kimi-web/src/components/ui/Spinner.vue new file mode 100644 index 000000000..49b09fb94 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Spinner.vue @@ -0,0 +1,47 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/StatusDot.vue b/apps/kimi-web/src/components/ui/StatusDot.vue new file mode 100644 index 000000000..53e2dccd7 --- /dev/null +++ b/apps/kimi-web/src/components/ui/StatusDot.vue @@ -0,0 +1,61 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Switch.vue b/apps/kimi-web/src/components/ui/Switch.vue new file mode 100644 index 000000000..2033c6203 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Switch.vue @@ -0,0 +1,56 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Tabs.vue b/apps/kimi-web/src/components/ui/Tabs.vue new file mode 100644 index 000000000..770768add --- /dev/null +++ b/apps/kimi-web/src/components/ui/Tabs.vue @@ -0,0 +1,48 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/Textarea.vue b/apps/kimi-web/src/components/ui/Textarea.vue new file mode 100644 index 000000000..559950865 --- /dev/null +++ b/apps/kimi-web/src/components/ui/Textarea.vue @@ -0,0 +1,65 @@ + + + + + + + diff --git a/apps/kimi-web/test/agent-event-projector.test.ts b/apps/kimi-web/test/agent-event-projector.test.ts new file mode 100644 index 000000000..763e2fe2b --- /dev/null +++ b/apps/kimi-web/test/agent-event-projector.test.ts @@ -0,0 +1,582 @@ +/** + * Web daemon projector contract for transcript isolation, task progress, and + * client-visible error projection. + */ + +import { describe, expect, it } from 'vitest'; +import { classifyFrame, createAgentProjector, subagentProgressText } from '../src/api/daemon/agentEventProjector'; + +describe('subagentProgressText', () => { + it('drops turn.step.started as noise', () => { + expect(subagentProgressText('turn.step.started', {})).toBeNull(); + }); + + it('summarizes a read tool call with its path', () => { + const text = subagentProgressText('tool.use', { name: 'read', args: { path: 'src/foo.ts' } }); + expect(text).toContain('src/foo.ts'); + expect(text).not.toContain('"path"'); + }); + + it('summarizes a bash tool call with its command', () => { + const text = subagentProgressText('tool.call.started', { name: 'bash', args: { command: 'pnpm test' } }); + expect(text).toContain('pnpm test'); + expect(text).not.toContain('"command"'); + }); + + it('drops tool.result lines as noise', () => { + expect(subagentProgressText('tool.result', { name: 'read' })).toBeNull(); + expect(subagentProgressText('tool.result', { name: 'Read_0' })).toBeNull(); + }); + + it('returns tool.progress update text', () => { + expect(subagentProgressText('tool.progress', { update: { text: 'working…' } })).toBe('working…'); + }); + + it('caps a long tool.progress text', () => { + const long = 'x'.repeat(3000); + const text = subagentProgressText('tool.progress', { update: { text: long } }); + expect(text).not.toBeNull(); + expect(text!.length).toBeLessThan(long.length); + expect(text!.endsWith('…')).toBe(true); + }); + + it('returns null for unknown event types', () => { + expect(subagentProgressText('turn.delta', {})).toBeNull(); + }); +}); + +describe('subagent streaming text', () => { + it('forwards a subagent assistant.delta as a text-kind taskProgress', () => { + const projector = createAgentProjector(); + const events = projector.project('assistant.delta', { agentId: 'sub-1', delta: 'Hello' }, 's1'); + expect(events).toContainEqual({ + type: 'taskProgress', + sessionId: 's1', + taskId: 'sub-1', + outputChunk: 'Hello', + stream: 'stdout', + kind: 'text', + }); + }); + + it('drops an empty subagent assistant.delta', () => { + const projector = createAgentProjector(); + const events = projector.project('assistant.delta', { agentId: 'sub-1', delta: '' }, 's1'); + expect(events).toEqual([]); + }); +}); + +describe('agent error projection', () => { + it('drops a subagent error instead of surfacing it as a session warning', () => { + const projector = createAgentProjector(); + + expect( + projector.project( + 'error', + { agentId: 'sub-1', code: 'provider.rate_limit', message: 'Rate limited' }, + 's1', + ), + ).toEqual([]); + }); + + it('keeps a main-agent error visible to the session', () => { + const projector = createAgentProjector(); + + expect( + projector.project( + 'error', + { + agentId: 'main', + code: 'provider.rate_limit', + message: 'Rate limited', + name: 'RateLimitError', + details: { statusCode: 429, requestId: 'req_1' }, + retryable: true, + }, + 's1', + ), + ).toEqual([ + { + type: 'unknown', + raw: { + _agentError: true, + code: 'provider.rate_limit', + message: 'Rate limited', + name: 'RateLimitError', + details: { statusCode: 429, requestId: 'req_1' }, + retryable: true, + }, + }, + ]); + }); +}); + +describe('cron.fired', () => { + it('synthesizes a user message so the cron notice renders live', () => { + const projector = createAgentProjector(); + const events = projector.project( + 'cron.fired', + { + origin: { + kind: 'cron_job', + jobId: 'a3f9c2', + cron: '*/5 * * * *', + recurring: true, + coalescedCount: 2, + stale: false, + }, + prompt: 'Check the deploy status', + }, + 's1', + ); + const created = events.find((e) => e.type === 'messageCreated'); + expect(created).toBeDefined(); + expect(created).toMatchObject({ + type: 'messageCreated', + message: { + role: 'user', + content: [{ type: 'text', text: 'Check the deploy status' }], + metadata: { origin: { kind: 'cron_job', jobId: 'a3f9c2' } }, + }, + }); + }); + + it('ignores cron.fired events missing a prompt or a cron_job origin', () => { + const projector = createAgentProjector(); + expect(projector.project('cron.fired', { origin: { kind: 'cron_job' } }, 's1')).toEqual([]); + expect(projector.project('cron.fired', { prompt: 'hi' }, 's1')).toEqual([]); + }); +}); + +describe('cron.fired prompt id isolation', () => { + it('omits promptId so the synthesized notice does not clobber the abort cache', () => { + const projector = createAgentProjector(); + projector.project( + 'prompt.submitted', + { promptId: 'pr_user', userMessageId: 'u1', content: [{ type: 'text', text: 'hi' }] }, + 's1', + ); + const events = projector.project( + 'cron.fired', + { + origin: { + kind: 'cron_job', + jobId: 'j', + cron: '* * * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + prompt: 'Check the deploy status', + }, + 's1', + ); + const created = events.find((e) => e.type === 'messageCreated'); + expect(created).toBeDefined(); + expect((created as { message: { promptId?: string } }).message.promptId).toBeUndefined(); + }); +}); + +describe('classifyFrame cron.fired', () => { + it('routes both raw and event.-prefixed cron.fired to the agent projector', () => { + const payload = { origin: { kind: 'cron_job' }, prompt: 'x' }; + expect(classifyFrame('cron.fired', payload)).toEqual({ route: 'agent', agentType: 'cron.fired' }); + expect(classifyFrame('event.cron.fired', payload)).toEqual({ route: 'agent', agentType: 'cron.fired' }); + }); +}); + +// Session busy has a single source: the daemon's event.session.work_changed +// (mapped by toAppEvent). The raw turn stream must NOT project a second +// sessionWorkChanged per transition — when it did, every turn end fired +// turn-end consumers (completion notification, sound) twice. +describe('session status single-sourcing', () => { + it('turn.started projects no sessionWorkChanged', () => { + const projector = createAgentProjector(); + const events = projector.project('turn.started', { turnId: 1 }, 's1'); + expect(events.some((e) => e.type === 'sessionWorkChanged')).toBe(false); + }); + + it('turn.ended finalizes the message and usage but projects no sessionWorkChanged', () => { + const projector = createAgentProjector(); + projector.project('turn.started', { turnId: 1 }, 's1'); + projector.project('turn.step.started', { turnId: 1, step: 1 }, 's1'); + const events = projector.project( + 'turn.ended', + { turnId: 1, reason: 'completed', durationMs: 123 }, + 's1', + ); + expect(events.some((e) => e.type === 'sessionWorkChanged')).toBe(false); + expect(events).toContainEqual( + expect.objectContaining({ type: 'messageUpdated', status: 'completed', durationMs: 123 }), + ); + expect(events).toContainEqual(expect.objectContaining({ type: 'sessionUsageUpdated' })); + }); + + it('seedInFlight returns only the seeded message — status comes from the snapshot', () => { + const projector = createAgentProjector(); + const events = projector.seedInFlight('s1', { + turnId: 1, + assistantText: 'partial', + thinkingText: '', + runningTools: [], + }); + expect(events.some((e) => e.type === 'sessionWorkChanged')).toBe(false); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'messageCreated', + message: expect.objectContaining({ role: 'assistant' }), + }), + ); + }); +}); + +describe('main-turn liveness projection', () => { + it('turn.started marks the main conversation active', () => { + const projector = createAgentProjector(); + const events = projector.project('turn.started', { agentId: 'main', turnId: 1 }, 's1'); + expect(events).toContainEqual({ type: 'turnActiveChanged', sessionId: 's1', active: true }); + }); + + it('turn.ended clears it and carries the reason', () => { + const projector = createAgentProjector(); + projector.project('turn.started', { agentId: 'main', turnId: 1 }, 's1'); + const events = projector.project('turn.ended', { agentId: 'main', turnId: 1, reason: 'cancelled' }, 's1'); + expect(events).toContainEqual({ + type: 'turnActiveChanged', + sessionId: 's1', + active: false, + reason: 'cancelled', + }); + }); + + it('subagent turn boundaries never touch main-conversation liveness', () => { + const projector = createAgentProjector(); + const started = projector.project('turn.started', { agentId: 'agent-2', turnId: 1 }, 's1'); + const ended = projector.project('turn.ended', { agentId: 'agent-2', turnId: 1, reason: 'completed' }, 's1'); + expect([...started, ...ended].some((e) => e.type === 'turnActiveChanged')).toBe(false); + }); +}); + +describe('prompt-level lifecycle projection', () => { + it('prompt.completed carries promptId and reason for the sending-flag cleanup', () => { + const projector = createAgentProjector(); + const events = projector.project( + 'prompt.completed', + { agentId: 'main', promptId: 'msg_1', reason: 'blocked', finishedAt: '2026-01-01T00:00:00Z' }, + 's1', + ); + expect(events).toContainEqual({ + type: 'promptCompleted', + sessionId: 's1', + promptId: 'msg_1', + reason: 'blocked', + }); + }); + + it('prompt.aborted projects a promptAborted keyed by promptId', () => { + const projector = createAgentProjector(); + const events = projector.project( + 'prompt.aborted', + { agentId: 'main', promptId: 'msg_2', abortedAt: '2026-01-01T00:00:00Z' }, + 's1', + ); + expect(events).toContainEqual({ type: 'promptAborted', sessionId: 's1', promptId: 'msg_2' }); + }); + + it('subagent-scoped prompt.aborted stays out of the main prompt channel', () => { + const projector = createAgentProjector(); + const events = projector.project('prompt.aborted', { agentId: 'agent-2', promptId: 'msg_3' }, 's1'); + expect(events.some((e) => e.type === 'promptAborted')).toBe(false); + }); + + it('classifyFrame routes prompt.aborted to the agent projector', () => { + expect(classifyFrame('prompt.aborted', { promptId: 'msg_1' })).toEqual({ + route: 'agent', + agentType: 'prompt.aborted', + }); + }); +}); + +describe('step-boundary delta alignment', () => { + it('resets stream offsets at step boundaries — a post-step delta ahead of local state signals a gap', () => { + const projector = createAgentProjector(); + projector.project('turn.started', { turnId: 1 }, 's1'); + projector.project('turn.step.started', { turnId: 1, step: 1 }, 's1'); + projector.project('assistant.delta', { turnId: 1, delta: 'step-one text' }, 's1', { offset: 0 }); + projector.project('turn.step.completed', { turnId: 1, step: 1 }, 's1'); + projector.project('turn.step.started', { turnId: 1, step: 2 }, 's1'); + + const events = projector.project('assistant.delta', { turnId: 1, delta: 'tail' }, 's1', { offset: 12 }); + expect(events).toContainEqual( + expect.objectContaining({ type: 'historyCompacted', reason: 'delta_gap' }), + ); + }); + + it('appends step-2 deltas to the fresh step message at step-relative offsets', () => { + const projector = createAgentProjector(); + projector.project('turn.started', { turnId: 1 }, 's1'); + projector.project('turn.step.started', { turnId: 1, step: 1 }, 's1'); + projector.project('assistant.delta', { turnId: 1, delta: 'step one' }, 's1', { offset: 0 }); + projector.project('turn.step.completed', { turnId: 1, step: 1 }, 's1'); + + const step2 = projector.project('turn.step.started', { turnId: 1, step: 2 }, 's1'); + const created = step2.find((e) => e.type === 'messageCreated'); + const msgId = (created as { message: { id: string } } | undefined)?.message.id; + expect(msgId).toBeDefined(); + + // Offset restarts at 0 for the new step and appends to ITS message. + const events = projector.project('assistant.delta', { turnId: 1, delta: 'step two' }, 's1', { offset: 0 }); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'assistantDelta', + messageId: msgId, + delta: { text: 'step two' }, + }), + ); + }); + + it('seeds only the current step and aligns live deltas against the seeded length', () => { + const projector = createAgentProjector(); + const seeded = projector.seedInFlight('s1', { + turnId: 7, + promptId: 'pr_1', + thinkingText: 'step two thinking', + assistantText: 'step two partial', + runningTools: [{ toolCallId: 'tc_1', name: 'bash', args: { command: 'ls' } }], + }); + const created = seeded.find((e) => e.type === 'messageCreated'); + const message = (created as { message: { id: string; content: unknown[] } } | undefined)?.message; + expect(message).toBeDefined(); + + expect(message!.content).toEqual([ + { type: 'thinking', thinking: 'step two thinking' }, + { type: 'text', text: 'step two partial' }, + { type: 'toolUse', toolCallId: 'tc_1', toolName: 'bash', input: { command: 'ls' } }, + ]); + + const dup = projector.project('assistant.delta', { turnId: 7, delta: 'two part' }, 's1', { offset: 5 }); + expect(dup).toEqual([]); + + const cont = projector.project( + 'assistant.delta', + { turnId: 7, delta: ' continues' }, + 's1', + { offset: 'step two partial'.length }, + ); + expect(cont).toContainEqual( + expect.objectContaining({ + type: 'assistantDelta', + messageId: message!.id, + contentIndex: 3, + delta: { text: ' continues' }, + }), + ); + }); +}); + +describe('turn.step.retrying bubble reuse', () => { + it('refills the abandoned bubble instead of stacking a duplicate one', () => { + const projector = createAgentProjector(); + const sid = 's1'; + projector.project('turn.started', { type: 'turn.started', turnId: 1, origin: { kind: 'user' }, agentId: 'main', sessionId: sid }, sid); + projector.project('turn.step.started', { type: 'turn.step.started', turnId: 1, step: 1, agentId: 'main', sessionId: sid }, sid); + projector.project('assistant.delta', { type: 'assistant.delta', turnId: 1, delta: 'AB', agentId: 'main', sessionId: sid }, sid, { offset: 0 }); + projector.project('tool.call.started', { type: 'tool.call.started', turnId: 1, toolCallId: 'tc1', name: 'Bash', agentId: 'main', sessionId: sid }, sid); + + const retryEvents = projector.project('turn.step.retrying', { type: 'turn.step.retrying', turnId: 1, step: 1, failedAttempt: 1, nextAttempt: 2, maxAttempts: 10, delayMs: 100, agentId: 'main', sessionId: sid }, sid); + expect(retryEvents).toContainEqual(expect.objectContaining({ type: 'messageUpdated' })); + + const restarted = projector.project('turn.step.started', { type: 'turn.step.started', turnId: 1, step: 1, agentId: 'main', sessionId: sid }, sid); + // No new messageCreated for the retried step — the cleared bubble is reused. + expect(restarted.filter((e) => e.type === 'messageCreated')).toEqual([]); + + const deltas = projector.project('assistant.delta', { type: 'assistant.delta', turnId: 1, delta: 'ABC', agentId: 'main', sessionId: sid }, sid, { offset: 0 }); + const toolEvents = projector.project('tool.call.started', { type: 'tool.call.started', turnId: 1, toolCallId: 'tc1', name: 'Bash', agentId: 'main', sessionId: sid }, sid); + + // The same bubble receives the retried stream: exactly one assistant + // message id across the whole attempt→retry sequence. + const messageIds = new Set( + [...deltas, ...toolEvents] + .map((e) => (e as { messageId?: string }).messageId) + .filter((id): id is string => typeof id === 'string'), + ); + expect(messageIds.size).toBe(1); + }); + + it('drops the reuse target when the turn ends before the retried step starts', () => { + const projector = createAgentProjector(); + const sid = 's1'; + projector.project('turn.started', { type: 'turn.started', turnId: 1, origin: { kind: 'user' }, agentId: 'main', sessionId: sid }, sid); + projector.project('turn.step.started', { type: 'turn.step.started', turnId: 1, step: 1, agentId: 'main', sessionId: sid }, sid); + projector.project('assistant.delta', { type: 'assistant.delta', turnId: 1, delta: 'AB', agentId: 'main', sessionId: sid }, sid, { offset: 0 }); + projector.project('turn.step.retrying', { type: 'turn.step.retrying', turnId: 1, step: 1, failedAttempt: 1, nextAttempt: 2, maxAttempts: 10, delayMs: 100, agentId: 'main', sessionId: sid }, sid); + + // The user aborts before the retried step.started ever arrives. + projector.project('turn.ended', { type: 'turn.ended', turnId: 1, reason: 'interrupted', agentId: 'main', sessionId: sid }, sid); + + // The next prompt must open a fresh bubble — not refill the emptied one, + // which would render the new response under the previous prompt. + projector.project('turn.started', { type: 'turn.started', turnId: 2, origin: { kind: 'user' }, agentId: 'main', sessionId: sid }, sid); + const started = projector.project('turn.step.started', { type: 'turn.step.started', turnId: 2, step: 1, agentId: 'main', sessionId: sid }, sid); + expect(started.filter((e) => e.type === 'messageCreated')).toHaveLength(1); + }); + + it('drops the reuse target when the step is interrupted before the retry restarts', () => { + const projector = createAgentProjector(); + const sid = 's1'; + projector.project('turn.started', { type: 'turn.started', turnId: 1, origin: { kind: 'user' }, agentId: 'main', sessionId: sid }, sid); + projector.project('turn.step.started', { type: 'turn.step.started', turnId: 1, step: 1, agentId: 'main', sessionId: sid }, sid); + projector.project('assistant.delta', { type: 'assistant.delta', turnId: 1, delta: 'AB', agentId: 'main', sessionId: sid }, sid, { offset: 0 }); + projector.project('turn.step.retrying', { type: 'turn.step.retrying', turnId: 1, step: 1, failedAttempt: 1, nextAttempt: 2, maxAttempts: 10, delayMs: 100, agentId: 'main', sessionId: sid }, sid); + + projector.project('turn.step.interrupted', { type: 'turn.step.interrupted', turnId: 1, step: 1, agentId: 'main', sessionId: sid }, sid); + + // The next step.started creates a new bubble instead of reusing the + // emptied one left by the interrupted retry attempt. + const started = projector.project('turn.step.started', { type: 'turn.step.started', turnId: 1, step: 2, agentId: 'main', sessionId: sid }, sid); + expect(started.filter((e) => e.type === 'messageCreated')).toHaveLength(1); + }); +}); + +describe('background subagent task registration', () => { + it('folds task.started (kind agent) into the spawned row instead of adding a second row', () => { + const projector = createAgentProjector(); + projector.project( + 'subagent.spawned', + { subagentId: 'agent-1', description: 'Explore repo', runInBackground: true }, + 's1', + ); + + const events = projector.project( + 'task.started', + { + info: { + taskId: 'task-9', + kind: 'agent', + detached: true, + agentId: 'agent-1', + description: 'Explore repo', + startedAt: 1767225600000, + }, + }, + 's1', + ); + + // A single patch of the WS-owned row — never a second (bash) task row. + expect(events).toEqual([ + { + type: 'taskCreated', + sessionId: 's1', + task: expect.objectContaining({ + id: 'agent-1', + kind: 'subagent', + description: 'Explore repo', + runInBackground: true, + backgroundTaskId: 'task-9', + }), + }, + ]); + }); + + it('keys a late registration by agent id so later progress frames stay on one row', () => { + const projector = createAgentProjector(); + const events = projector.project( + 'task.started', + { + info: { + taskId: 'task-9', + kind: 'agent', + detached: true, + agentId: 'agent-1', + description: 'Explore repo', + startedAt: 1767225600000, + }, + }, + 's1', + ); + + expect(events).toEqual([ + { + type: 'taskCreated', + sessionId: 's1', + task: expect.objectContaining({ + id: 'agent-1', + kind: 'subagent', + description: 'Explore repo', + runInBackground: true, + backgroundTaskId: 'task-9', + }), + }, + ]); + + // A later agent-scoped progress frame must not synthesize a second row. + const progress = projector.project( + 'assistant.delta', + { agentId: 'agent-1', delta: 'Hi' }, + 's1', + ); + expect(progress).toContainEqual( + expect.objectContaining({ type: 'taskProgress', taskId: 'agent-1' }), + ); + const created = progress.filter((e) => e.type === 'taskCreated'); + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({ + task: { id: 'agent-1', backgroundTaskId: 'task-9' }, + }); + }); + + it('falls back to the task id when the registration carries no agent id', () => { + const projector = createAgentProjector(); + const events = projector.project( + 'task.started', + { + info: { + taskId: 'task-9', + kind: 'agent', + detached: true, + description: 'Explore repo', + startedAt: 1767225600000, + }, + }, + 's1', + ); + + expect(events).toEqual([ + { + type: 'taskCreated', + sessionId: 's1', + task: expect.objectContaining({ + id: 'task-9', + kind: 'subagent', + description: 'Explore repo', + runInBackground: true, + }), + }, + ]); + }); + + it('keeps projecting process tasks as bash rows', () => { + const projector = createAgentProjector(); + const events = projector.project( + 'task.started', + { + info: { + taskId: 'task-1', + kind: 'process', + description: 'npm test', + command: 'npm test', + startedAt: 1767225600000, + }, + }, + 's1', + ); + + expect(events).toEqual([ + { + type: 'taskCreated', + sessionId: 's1', + task: expect.objectContaining({ id: 'task-1', kind: 'bash', command: 'npm test' }), + }, + ]); + }); +}); diff --git a/apps/kimi-web/test/ask-user-tool-parse.test.ts b/apps/kimi-web/test/ask-user-tool-parse.test.ts new file mode 100644 index 000000000..08727dc14 --- /dev/null +++ b/apps/kimi-web/test/ask-user-tool-parse.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; +import { + answerFor, + parseAskInput, + parseAskOutput, + resolveAnswer, +} from '../src/components/chat/tool-calls/askUserToolParse'; + +const ARG = JSON.stringify({ + questions: [ + { + question: 'Which auth provider?', + header: 'Auth', + multi_select: false, + options: [ + { label: 'Clerk', description: 'Native Vercel Marketplace' }, + { label: 'Auth0', description: 'Enterprise SSO' }, + ], + }, + { + question: 'Where to deploy?', + header: 'Deploy', + multi_select: true, + options: [ + { label: 'Vercel', description: 'Zero-config' }, + { label: 'Fly.io', description: 'Edge' }, + { label: 'AWS', description: 'Full control' }, + ], + }, + ], +}); + +describe('parseAskInput', () => { + it('reads questions, options, header and multi_select', () => { + const qs = parseAskInput(ARG); + expect(qs).toHaveLength(2); + expect(qs[0]).toMatchObject({ header: 'Auth', multiSelect: false }); + expect(qs[0].options.map(o => o.label)).toEqual(['Clerk', 'Auth0']); + expect(qs[1]).toMatchObject({ header: 'Deploy', multiSelect: true }); + expect(qs[1].options).toHaveLength(3); + }); + + it('defaults missing optional fields and tolerates malformed input', () => { + expect(parseAskInput('')).toEqual([]); + expect(parseAskInput('not json')).toEqual([]); + expect(parseAskInput('{}')).toEqual([]); + expect(parseAskInput(JSON.stringify({ questions: 'nope' }))).toEqual([]); + // partial option entries degrade to empty strings, not a throw + const qs = parseAskInput(JSON.stringify({ questions: [{ options: [{ label: 'A' }, null] }] })); + expect(qs[0].options).toEqual([ + { label: 'A', description: '' }, + { label: '', description: '' }, + ]); + }); +}); + +describe('parseAskOutput', () => { + it('recognizes an answer payload and reads answers (question-text keys, label values)', () => { + const out = parseAskOutput([ + JSON.stringify({ answers: { 'Which auth provider?': 'Auth0' }, note: '' }), + ]); + expect(out.recognized).toBe(true); + expect(out.answers).toEqual({ 'Which auth provider?': 'Auth0' }); + }); + + it('recognizes a legacy answer payload (q_ keys, opt ids)', () => { + const out = parseAskOutput([JSON.stringify({ answers: { q_0: 'opt_0_1' }, note: '' })]); + expect(out.recognized).toBe(true); + expect(out.answers).toEqual({ q_0: 'opt_0_1' }); + }); + + it('keeps string and true values, drops others', () => { + const out = parseAskOutput([JSON.stringify({ answers: { a: 'x', b: true, c: 3, d: null } })]); + expect(out.recognized).toBe(true); + expect(out.answers).toEqual({ a: 'x', b: true }); + }); + + it('recognizes the dismissed payload (empty answers + note)', () => { + const out = parseAskOutput([ + JSON.stringify({ answers: {}, note: 'User dismissed the question without answering.' }), + ]); + expect(out.recognized).toBe(true); + expect(Object.keys(out.answers)).toHaveLength(0); + expect(out.note).toContain('dismissed'); + }); + + it('does not recognize plain-text background output', () => { + const out = parseAskOutput(['task_id: abc\ndescription: run it\nstatus: running']); + expect(out.recognized).toBe(false); + }); + + it('does not recognize plain-text error output', () => { + expect(parseAskOutput(['Interactive questions are not supported in this session.']).recognized).toBe(false); + }); + + it('does not recognize JSON that is not the answer payload', () => { + expect(parseAskOutput([JSON.stringify({ foo: 'bar' })]).recognized).toBe(false); + expect(parseAskOutput([JSON.stringify({ answers: 'nope' })]).recognized).toBe(false); + expect(parseAskOutput([JSON.stringify(['x'])]).recognized).toBe(false); + }); + + it('tolerates missing output', () => { + expect(parseAskOutput(undefined)).toEqual({ recognized: false, answers: {}, note: '' }); + expect(parseAskOutput([])).toEqual({ recognized: false, answers: {}, note: '' }); + }); +}); + +describe('resolveAnswer', () => { + const single = [ + { label: 'Clerk', description: '' }, + { label: 'Auth0', description: '' }, + ]; + const multi = [ + { label: 'Vercel', description: '' }, + { label: 'Fly.io', description: '' }, + { label: 'AWS', description: '' }, + ]; + + it('matches a single-select label to its index', () => { + const r = resolveAnswer('Auth0', single); + expect([...r.selected]).toEqual([1]); + expect(r.otherText).toBe(''); + expect(r.indeterminate).toBe(false); + }); + + it('matches comma-joined multi-select labels into several indices', () => { + const r = resolveAnswer('Vercel,AWS', multi); + expect(r.selected).toEqual(new Set([0, 2])); + }); + + it("matches comma-space-joined multi-select labels (server / TUI ', ' form)", () => { + const r = resolveAnswer('Vercel, AWS', multi); + expect(r.selected).toEqual(new Set([0, 2])); + expect(r.otherText).toBe(''); + }); + + it('splits a multi+Other value into labels plus the free-text segment', () => { + const r = resolveAnswer('Vercel, AWS, Custom thing', multi); + expect(r.selected).toEqual(new Set([0, 2])); + expect(r.otherText).toBe('Custom thing'); + }); + + it('resolves a whole-value label containing a comma (single-select)', () => { + const withComma = [ + { label: 'Fast, but risky', description: '' }, + { label: 'Slow and safe', description: '' }, + ]; + const r = resolveAnswer('Fast, but risky', withComma); + expect([...r.selected]).toEqual([0]); + expect(r.otherText).toBe(''); + }); + + it('treats a free-text value as an Other answer', () => { + const r = resolveAnswer('Use OIDC instead of static keys', single); + expect(r.selected.size).toBe(0); + expect(r.otherText).toBe('Use OIDC instead of static keys'); + }); + + it('decodes a legacy single-select option id to its index', () => { + const r = resolveAnswer('opt_0_1', single); + expect([...r.selected]).toEqual([1]); + expect(r.otherText).toBe(''); + expect(r.indeterminate).toBe(false); + }); + + it('decodes legacy comma-joined multi-select ids into several indices', () => { + const r = resolveAnswer('opt_1_0,opt_1_2', multi); + expect(r.selected).toEqual(new Set([0, 2])); + }); + + it('splits a legacy multi+Other value into options plus the free-text segment', () => { + const r = resolveAnswer('opt_0_0,opt_0_2,Custom thing', multi); + expect(r.selected).toEqual(new Set([0, 2])); + expect(r.otherText).toBe('Custom thing'); + }); + + it('joins non-matching segments back so Other text containing a comma survives', () => { + const r = resolveAnswer('Auth0,alpha,beta', single); + expect([...r.selected]).toEqual([1]); + expect(r.otherText).toBe('alpha, beta'); + }); + + it('decodes legacy ids without any options context', () => { + const r = resolveAnswer('opt_0_1'); + expect([...r.selected]).toEqual([1]); + }); + + it('marks the literal true as indeterminate', () => { + const r = resolveAnswer(true, single); + expect(r.indeterminate).toBe(true); + expect(r.selected.size).toBe(0); + }); + + it('returns an empty result for skipped / unanswered questions', () => { + const r = resolveAnswer(undefined, single); + expect(r.selected.size).toBe(0); + expect(r.otherText).toBe(''); + expect(r.indeterminate).toBe(false); + }); +}); + +describe('answerFor', () => { + it('prefers the question-text key (current form)', () => { + const answers = { 'Which auth provider?': 'Auth0' } as const; + expect(answerFor(answers, 'Which auth provider?', 0)).toBe('Auth0'); + }); + + it('falls back to the legacy q_ key', () => { + const answers = { q_1: 'opt_1_2' } as const; + expect(answerFor(answers, 'Where to deploy?', 1)).toBe('opt_1_2'); + }); + + it('returns undefined when neither key is present (skipped question)', () => { + expect(answerFor({}, 'Which auth provider?', 0)).toBeUndefined(); + }); + + it('passes through the literal true (indeterminate answer)', () => { + expect(answerFor({ 'Which auth provider?': true }, 'Which auth provider?', 0)).toBe(true); + }); +}); diff --git a/apps/kimi-web/test/attachment-upload.test.ts b/apps/kimi-web/test/attachment-upload.test.ts new file mode 100644 index 000000000..6c5ed9b75 --- /dev/null +++ b/apps/kimi-web/test/attachment-upload.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import { useAttachmentUpload, type Attachment } from '../src/composables/useAttachmentUpload'; + +// The composable registers its paste listener and cleanup via onMounted / +// onUnmounted. Outside a component (unit test) there is no active instance, so +// Vue would warn; stub the two hooks since these tests don't exercise the +// lifecycle itself. +vi.mock('vue', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, onMounted: vi.fn(), onUnmounted: vi.fn() }; +}); + +type UploadImage = ( + file: Blob, + name?: string, +) => Promise<{ fileId: string; name: string; mediaType: string } | null>; + +function setup(uploadImage?: UploadImage, sessionId: string | null = 'test-session') { + return useAttachmentUpload({ uploadImage: () => uploadImage, sessionId: () => sessionId ?? undefined }); +} + +function imageFile(name: string): File { + return { name, type: 'image/png' } as unknown as File; +} + +function inputEvent(files: File[]): Event { + return { target: { files, value: 'x' } } as unknown as Event; +} + +describe('useAttachmentUpload', () => { + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + + beforeEach(() => { + createObjectURL = vi.fn().mockReturnValue('blob:mock-url'); + revokeObjectURL = vi.fn(); + (globalThis.URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL; + (globalThis.URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeObjectURL; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('adds an uploading attachment via the file input', () => { + const uploadImage = vi.fn().mockResolvedValue({ fileId: 'f1', name: 'a.png', mediaType: 'image/png' }); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([imageFile('a.png')])); + + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0]).toMatchObject({ name: 'a.png', kind: 'image', uploading: true }); + expect(createObjectURL).toHaveBeenCalledOnce(); + }); + + it('accepts a non-media file as a file attachment without a thumbnail object URL', () => { + const uploadImage = vi.fn().mockResolvedValue({ fileId: 'f1', name: 'a.pdf', mediaType: 'application/pdf' }); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([{ name: 'a.pdf', type: 'application/pdf' } as unknown as File])); + + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0]).toMatchObject({ + name: 'a.pdf', + kind: 'file', + mediaType: 'application/pdf', + uploading: true, + }); + // No thumbnail for generic files — the chip renders an icon instead. + expect(att.attachments.value[0].previewUrl).toBeUndefined(); + expect(createObjectURL).not.toHaveBeenCalled(); + }); + + it('accepts a file with an empty MIME type as a file attachment', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([{ name: 'Makefile', type: '' } as unknown as File])); + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0].kind).toBe('file'); + // The wire schema requires a non-empty media_type — '' must be normalized. + expect(att.attachments.value[0].mediaType).toBe('application/octet-stream'); + }); + + it('is a no-op when uploadImage is not provided', () => { + const att = setup(undefined); + att.handleFileInputChange(inputEvent([imageFile('a.png')])); + expect(att.attachments.value).toHaveLength(0); + }); + + it('removeAttachment on a file chip has no object URL to revoke', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([{ name: 'a.pdf', type: 'application/pdf' } as unknown as File])); + const localId = att.attachments.value[0].localId; + + att.removeAttachment(localId); + expect(att.attachments.value).toHaveLength(0); + expect(revokeObjectURL).not.toHaveBeenCalled(); + }); + + it('loadAttachments refills a file attachment without fetching a thumbnail', () => { + const att = setup(undefined); + att.loadAttachments([ + { fileId: 'f_pdf', kind: 'file', url: 'https://example.test/api/v1/files/f_pdf', name: 'a.pdf' }, + ]); + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0]).toMatchObject({ + fileId: 'f_pdf', + kind: 'file', + name: 'a.pdf', + uploading: false, + }); + expect(att.attachments.value[0].previewUrl).toBeUndefined(); + }); + + it('removeAttachment drops the entry and revokes its object URL', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([imageFile('a.png')])); + const localId = att.attachments.value[0].localId; + + att.removeAttachment(localId); + expect(att.attachments.value).toHaveLength(0); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('removeAttachment also closes the preview when it shows the removed entry', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([imageFile('a.png')])); + const added = att.attachments.value[0]; + att.openAttachmentPreview(added); + expect(att.previewAttachment.value).not.toBeNull(); + + att.removeAttachment(added.localId); + expect(att.previewAttachment.value).toBeNull(); + }); + + it('openAttachmentPreview / closeAttachmentPreview toggle the preview', () => { + const att = setup(undefined); + const item: Attachment = { localId: 'x', name: 'a.png', kind: 'image', previewUrl: 'blob:x', uploading: false }; + att.openAttachmentPreview(item); + expect(att.previewAttachment.value?.localId).toBe('x'); + att.closeAttachmentPreview(); + expect(att.previewAttachment.value).toBeNull(); + }); + + it('clearAfterSubmit revokes every object URL and empties the list', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([imageFile('a.png'), imageFile('b.png')])); + expect(att.attachments.value).toHaveLength(2); + + att.clearAfterSubmit(); + expect(att.attachments.value).toHaveLength(0); + expect(revokeObjectURL).toHaveBeenCalledTimes(2); + }); + + it('loadAttachments refills an already-uploaded attachment without re-uploading', () => { + const att = setup(undefined); + att.loadAttachments([ + { fileId: 'f_existing', kind: 'image', url: 'data:image/png;base64,AAAA', name: 'a.png' }, + ]); + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0]).toMatchObject({ + fileId: 'f_existing', + kind: 'image', + name: 'a.png', + uploading: false, + previewUrl: 'data:image/png;base64,AAAA', + }); + }); + + it('loadAttachments replaces any unsent draft attachments instead of appending', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([imageFile('draft.png')])); + expect(att.attachments.value).toHaveLength(1); + + att.loadAttachments([ + { fileId: 'f_existing', kind: 'image', url: 'data:image/png;base64,AAAA', name: 'refill.png' }, + ]); + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0].name).toBe('refill.png'); + }); + + it('loadAttachments with an empty list clears the attachment strip', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([imageFile('draft.png')])); + expect(att.attachments.value).toHaveLength(1); + + att.loadAttachments([]); + expect(att.attachments.value).toHaveLength(0); + }); + + it('loadAttachments re-uploads a fileId-less data URL so it becomes resendable', async () => { + const uploadImage = vi.fn().mockResolvedValue({ fileId: 'f_new', name: 'a.png', mediaType: 'image/png' }); + const att = setup(uploadImage); + const blob = new Blob(['x'], { type: 'image/png' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })); + + att.loadAttachments([{ kind: 'image', url: 'data:image/png;base64,AAAA', name: 'a.png' }]); + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0].uploading).toBe(true); + + // Flush the fetch → blob → upload promise chain so the re-upload resolves. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(att.attachments.value[0].uploading).toBe(false); + expect(att.attachments.value[0].fileId).toBe('f_new'); + expect(uploadImage).toHaveBeenCalledOnce(); + }); + + it('loadAttachments skips a fileId-less data URL when re-upload is unavailable', () => { + const att = setup(undefined); + att.loadAttachments([{ kind: 'image', url: 'data:image/png;base64,AAAA', name: 'a.png' }]); + expect(att.attachments.value).toHaveLength(0); + }); + + it('loadAttachments re-uploads a fileId-less http URL so it becomes resendable', async () => { + const uploadImage = vi.fn().mockResolvedValue({ fileId: 'f_http', name: 'x.png', mediaType: 'image/png' }); + const att = setup(uploadImage); + const blob = new Blob(['x'], { type: 'image/png' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })); + + att.loadAttachments([{ kind: 'image', url: 'https://example.test/x.png', name: 'x.png' }]); + expect(att.attachments.value).toHaveLength(1); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(att.attachments.value[0].fileId).toBe('f_http'); + }); + + it('loadAttachments drops a fileId-less URL whose fetch fails', async () => { + const uploadImage = vi.fn().mockResolvedValue({ fileId: 'f_x', name: 'x.png', mediaType: 'image/png' }); + const att = setup(uploadImage); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401 })); + + att.loadAttachments([{ kind: 'image', url: 'https://example.test/protected.png', name: 'protected.png' }]); + expect(att.attachments.value).toHaveLength(1); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(att.attachments.value).toHaveLength(0); + }); + + it('isolates attachments between sessions', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const sessionId = ref('sess-a'); + const att = useAttachmentUpload({ uploadImage: () => uploadImage, sessionId: () => sessionId.value }); + + att.handleFileInputChange(inputEvent([imageFile('a.png')])); + expect(att.attachments.value).toHaveLength(1); + + // Switch to session B — A's attachment must not show up here. + sessionId.value = 'sess-b'; + expect(att.attachments.value).toHaveLength(0); + att.handleFileInputChange(inputEvent([imageFile('b.png')])); + expect(att.attachments.value).toHaveLength(1); + + // Switch back to A — its attachment is still there. + sessionId.value = 'sess-a'; + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0].name).toBe('a.png'); + + // B's attachment is gone from A's view. + expect(att.attachments.value.map((a) => a.name)).not.toContain('b.png'); + }); + + it('adds dropped files once and stops the drop from bubbling to document handlers', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + const file = { name: 'd.txt', type: 'text/plain' } as unknown as File; + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + + att.handleDrop({ + dataTransfer: { files: [file] }, + preventDefault, + stopPropagation, + } as unknown as DragEvent); + + // The document-level drop listener must not see the same drop again — + // otherwise the file would be attached twice. + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0]).toMatchObject({ name: 'd.txt', kind: 'file' }); + }); + + it('ignores a dragover that carries no files (e.g. text drag)', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + const preventDefault = vi.fn(); + + att.handleDragOver({ + dataTransfer: { items: [{ kind: 'string' }] }, + preventDefault, + stopPropagation: vi.fn(), + } as unknown as DragEvent); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(att.isDragOver.value).toBe(false); + }); + + it('skips a file attachment with no fileId and an empty URL instead of fetching it', async () => { + // The non-clickable chip rebuilt from an inline-base64 notice has neither — + // fetch('') would resolve to the current page and upload the web app HTML. + const uploadImage = vi.fn().mockResolvedValue(null); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const att = setup(uploadImage); + + att.loadAttachments([{ kind: 'file', url: '', name: 'image.avif' }]); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(att.attachments.value).toHaveLength(0); + // No fetch with the empty URL (a same-document fetch would upload the page). + expect(fetchSpy.mock.calls.every((call) => call[0] !== '')).toBe(true); + fetchSpy.mockRestore(); + }); +}); diff --git a/apps/kimi-web/test/chat-turn-rendering.test.ts b/apps/kimi-web/test/chat-turn-rendering.test.ts new file mode 100644 index 000000000..6538aa842 --- /dev/null +++ b/apps/kimi-web/test/chat-turn-rendering.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import type { ChatTurn, ToolCall, TurnBlock } from '../src/types'; +import { + assistantRenderBlocks, + formatDuration, + formatTokens, + rendersToolCard, + renderBlockKey, + toolStackPosition, + turnBlocks, + turnFinalText, + turnToMarkdown, +} from '../src/components/chatTurnRendering'; + +function tool(id: string, over: Partial = {}): ToolCall { + return { id, name: 'read', arg: `· ${id}.ts`, status: 'ok', ...over }; +} + +function toolBlock(id: string, over: Partial = {}): Extract { + return { kind: 'tool', tool: tool(id, over) }; +} + +function assistantTurn(blocks: TurnBlock[], over: Partial = {}): ChatTurn { + return { id: 't1', role: 'assistant', no: 1, text: '', blocks, ...over }; +} + +describe('formatTokens', () => { + it('keeps counts under 1024 verbatim and uses 1024-based k / M units', () => { + expect(formatTokens(0)).toBe('0'); + expect(formatTokens(999)).toBe('999'); + expect(formatTokens(1000)).toBe('1000'); + expect(formatTokens(1500)).toBe('1.5k'); + expect(formatTokens(1_000_000)).toBe('977k'); + expect(formatTokens(2_500_000)).toBe('2.4M'); + }); +}); + +describe('formatDuration', () => { + it('switches units at the 1s and 1m boundaries', () => { + expect(formatDuration(999)).toBe('999ms'); + expect(formatDuration(1000)).toBe('1.0s'); + expect(formatDuration(59_999)).toBe('60.0s'); + expect(formatDuration(60_000)).toBe('1m0.0s'); + expect(formatDuration(90_500)).toBe('1m30.5s'); + }); +}); + +describe('turnBlocks', () => { + it('returns the ordered blocks as-is when present', () => { + const blocks: TurnBlock[] = [{ kind: 'text', text: 'hi' }]; + expect(turnBlocks(assistantTurn(blocks))).toBe(blocks); + }); + + it('falls back to thinking -> text -> tools order when blocks are absent', () => { + const turn: ChatTurn = { + id: 't1', + role: 'assistant', + no: 1, + text: 'answer', + thinking: 'plan', + tools: [tool('a')], + }; + expect(turnBlocks(turn)).toEqual([ + { kind: 'thinking', thinking: 'plan' }, + { kind: 'text', text: 'answer' }, + { kind: 'tool', tool: tool('a') }, + ]); + }); +}); + +describe('rendersToolCard', () => { + it('hides the card only for a successful tool that carries inline media', () => { + expect(rendersToolCard(toolBlock('a'))).toBe(true); + expect(rendersToolCard(toolBlock('r', { status: 'running' }))).toBe(true); + expect( + rendersToolCard(toolBlock('m', { status: 'ok', media: { kind: 'image', url: 'x' } })), + ).toBe(false); + // media but errored -> still rendered as a card + expect( + rendersToolCard(toolBlock('e', { status: 'error', media: { kind: 'image', url: 'x' } })), + ).toBe(true); + }); +}); + +describe('toolStackPosition', () => { + it('marks a lone tool single and otherwise reports first/middle/last', () => { + expect(toolStackPosition(0, 1)).toBe('single'); + expect(toolStackPosition(0, 0)).toBe('single'); + expect(toolStackPosition(0, 3)).toBe('first'); + expect(toolStackPosition(1, 3)).toBe('middle'); + expect(toolStackPosition(2, 3)).toBe('last'); + }); +}); + +describe('assistantRenderBlocks', () => { + it('groups consecutive renderable tools into one tool-stack', () => { + const rendered = assistantRenderBlocks(assistantTurn([toolBlock('a'), toolBlock('b')])); + expect(rendered).toHaveLength(1); + expect(rendered[0]).toMatchObject({ kind: 'tool-stack' }); + if (rendered[0]?.kind === 'tool-stack') { + expect(rendered[0].tools.map((t) => t.tool.id)).toEqual(['a', 'b']); + expect(rendered[0].tools.map((t) => t.sourceIndex)).toEqual([0, 1]); + } + }); + + it('renders a lone tool as a standalone tool, not a stack', () => { + const rendered = assistantRenderBlocks(assistantTurn([toolBlock('a')])); + expect(rendered).toEqual([{ kind: 'tool', tool: tool('a'), sourceIndex: 0 }]); + }); + + it('breaks the stack when a non-tool block interrupts the run', () => { + const rendered = assistantRenderBlocks( + assistantTurn([toolBlock('a'), { kind: 'text', text: 'x' }, toolBlock('b')]), + ); + expect(rendered.map((b) => b.kind)).toEqual(['tool', 'text', 'tool']); + }); + + it('breaks the stack when a media tool (no card) interrupts the run', () => { + const rendered = assistantRenderBlocks( + assistantTurn([ + toolBlock('a'), + toolBlock('b'), + toolBlock('c', { status: 'ok', media: { kind: 'image', url: 'x' } }), + ]), + ); + expect(rendered.map((b) => b.kind)).toEqual(['tool-stack', 'tool']); + if (rendered[0]?.kind === 'tool-stack') { + expect(rendered[0].tools.map((t) => t.tool.id)).toEqual(['a', 'b']); + } + }); + + it('preserves thinking/text order with their source indexes', () => { + const rendered = assistantRenderBlocks( + assistantTurn([ + { kind: 'thinking', thinking: 'plan' }, + { kind: 'text', text: 'answer' }, + ]), + ); + expect(rendered).toEqual([ + { kind: 'thinking', thinking: 'plan', sourceIndex: 0 }, + { kind: 'text', text: 'answer', sourceIndex: 1 }, + ]); + }); +}); + +describe('turnFinalText', () => { + it('joins only the text blocks, dropping thinking and tools', () => { + const turn = assistantTurn([ + { kind: 'thinking', thinking: 'plan' }, + { kind: 'text', text: 'first' }, + toolBlock('a'), + { kind: 'text', text: 'second' }, + ]); + expect(turnFinalText(turn)).toBe('first\n\nsecond'); + }); +}); + +describe('turnToMarkdown', () => { + it('renders thinking as a quote, text verbatim, and tool output as a fenced block', () => { + const turn = assistantTurn([ + { kind: 'thinking', thinking: 'line1\nline2' }, + { kind: 'text', text: 'hello' }, + toolBlock('a', { name: 'bash', output: ['out1', 'out2'] }), + ]); + expect(turnToMarkdown(turn)).toBe( + ['> **Thinking**\n> line1\n> line2', 'hello', '```\n[bash]\nout1\nout2\n```'].join('\n\n'), + ); + }); +}); + +describe('renderBlockKey', () => { + it('derives stable keys per block kind', () => { + expect(renderBlockKey({ kind: 'text', text: 'x', sourceIndex: 2 }, 0)).toBe('text-2'); + expect(renderBlockKey({ kind: 'tool', tool: tool('a'), sourceIndex: 3 }, 0)).toBe('a'); + expect( + renderBlockKey({ kind: 'tool-stack', tools: [{ tool: tool('a'), sourceIndex: 5 }] }, 0), + ).toBe('tool-stack-5'); + }); +}); diff --git a/apps/kimi-web/test/clipboard.test.ts b/apps/kimi-web/test/clipboard.test.ts new file mode 100644 index 000000000..19a9397e7 --- /dev/null +++ b/apps/kimi-web/test/clipboard.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { copyTextToClipboard } from '../src/lib/clipboard'; + +// The web test suite runs in the default node environment (no jsdom), so we +// mock the tiny `navigator` / `document` surface that the helper touches. + +interface FakeDocument { + execCommand: ReturnType; + createElement: ReturnType; + body: { appendChild: ReturnType; removeChild: ReturnType }; + textarea: { value: string; setAttribute: ReturnType; focus: ReturnType; select: ReturnType }; +} + +function installDocument(execResult: boolean | Error): FakeDocument { + const textarea = { + value: '', + style: {} as Record, + setAttribute: vi.fn(), + focus: vi.fn(), + select: vi.fn(), + }; + const doc: FakeDocument = { + execCommand: vi.fn().mockImplementation(() => { + if (execResult instanceof Error) throw execResult; + return execResult; + }), + createElement: vi.fn().mockReturnValue(textarea), + body: { appendChild: vi.fn(), removeChild: vi.fn() }, + textarea: textarea as FakeDocument['textarea'], + }; + vi.stubGlobal('document', doc); + return doc; +} + +function installNavigator(clipboard: unknown): void { + vi.stubGlobal('navigator', clipboard === undefined ? {} : { clipboard }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('copyTextToClipboard', () => { + it('uses navigator.clipboard when available', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + installNavigator({ writeText }); + + await expect(copyTextToClipboard('hello')).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith('hello'); + }); + + it('falls back to execCommand when navigator.clipboard is undefined', async () => { + // Simulates an insecure (plain HTTP) context. + installNavigator(undefined); + const doc = installDocument(true); + + await expect(copyTextToClipboard('abc')).resolves.toBe(true); + expect(doc.execCommand).toHaveBeenCalledWith('copy'); + expect(doc.textarea.value).toBe('abc'); + expect(doc.body.appendChild).toHaveBeenCalledTimes(1); + expect(doc.body.removeChild).toHaveBeenCalledTimes(1); + }); + + it('falls back to execCommand when writeText rejects', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')); + installNavigator({ writeText }); + const doc = installDocument(true); + + await expect(copyTextToClipboard('retry')).resolves.toBe(true); + expect(writeText).toHaveBeenCalled(); + expect(doc.execCommand).toHaveBeenCalledWith('copy'); + }); + + it('resolves false when both paths fail', async () => { + installNavigator(undefined); + installDocument(false); + + await expect(copyTextToClipboard('nope')).resolves.toBe(false); + }); +}); diff --git a/apps/kimi-web/test/composer-draft.test.ts b/apps/kimi-web/test/composer-draft.test.ts new file mode 100644 index 000000000..90b2ce22a --- /dev/null +++ b/apps/kimi-web/test/composer-draft.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { nextTick, ref } from 'vue'; +import { useComposerDraft } from '../src/composables/useComposerDraft'; +import { draftStorageKey } from '../src/lib/storage'; + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear: () => { + map.clear(); + }, + getItem: (key: string) => map.get(key) ?? null, + key: (index: number) => Array.from(map.keys())[index] ?? null, + removeItem: (key: string) => { + map.delete(key); + }, + setItem: (key: string, value: string) => { + map.set(key, value); + }, + }; +} + +function setup(initialSid: string | undefined) { + const sid = ref(initialSid); + const draft = useComposerDraft({ sessionId: () => sid.value }); + return { + draft, + text: draft.text, + setSid: (next: string | undefined) => { + sid.value = next; + }, + }; +} + +describe('useComposerDraft', () => { + let original: Storage | undefined; + + beforeEach(() => { + original = (globalThis as { localStorage?: Storage }).localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: memoryStorage(), + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + if (original === undefined) { + delete (globalThis as { localStorage?: Storage }).localStorage; + } else { + Object.defineProperty(globalThis, 'localStorage', { + value: original, + configurable: true, + writable: true, + }); + } + }); + + it('loads the stored draft for the session on init', () => { + globalThis.localStorage.setItem(draftStorageKey('s1'), 'saved draft'); + const { text } = setup('s1'); + expect(text.value).toBe('saved draft'); + }); + + it('starts empty when the session has no stored draft', () => { + const { text } = setup('s1'); + expect(text.value).toBe(''); + }); + + it('persists the draft when the text changes', async () => { + const { text } = setup('s1'); + text.value = 'hello'; + await nextTick(); + expect(globalThis.localStorage.getItem(draftStorageKey('s1'))).toBe('hello'); + }); + + it('clears the stored draft when the text is emptied', async () => { + globalThis.localStorage.setItem(draftStorageKey('s1'), 'x'); + const { text } = setup('s1'); + text.value = ''; + await nextTick(); + expect(globalThis.localStorage.getItem(draftStorageKey('s1'))).toBeNull(); + }); + + it('saves the old draft and loads the new one on session switch', async () => { + const { text, setSid } = setup('s1'); + text.value = 'draft-s1'; + await nextTick(); + globalThis.localStorage.setItem(draftStorageKey('s2'), 'draft-s2'); + + setSid('s2'); + await nextTick(); + + expect(globalThis.localStorage.getItem(draftStorageKey('s1'))).toBe('draft-s1'); + expect(text.value).toBe('draft-s2'); + }); + + it('loadForEdit replaces the text', () => { + const { draft } = setup('s1'); + draft.loadForEdit('edit me'); + expect(draft.text.value).toBe('edit me'); + }); + + it('autosize fits the textarea height to its content', () => { + const { draft } = setup('s1'); + const style: Record = {}; + const el = { scrollHeight: 120, style }; + draft.textareaRef.value = el as unknown as HTMLTextAreaElement; + + draft.autosize(); + expect(style.height).toBe('120px'); + }); + + it('autosize shrinks the textarea when content is removed', () => { + const { draft } = setup('s1'); + const style: Record = {}; + const el = { scrollHeight: 120, style }; + draft.textareaRef.value = el as unknown as HTMLTextAreaElement; + + draft.autosize(); + el.scrollHeight = 40; + draft.autosize(); + expect(style.height).toBe('40px'); + }); + + it('autosize is a no-op before the textarea mounts', () => { + const { draft } = setup('s1'); + expect(() => { + draft.autosize(); + }).not.toThrow(); + }); + + it('clearDraft removes the persisted draft synchronously', async () => { + // Regression: when the first message of an empty session is submitted, the + // optimistic user turn unmounts the composer before the post-flush text + // watcher can clear the draft. clearDraft must therefore clear it + // synchronously so a remount does not reload the stale text. + globalThis.localStorage.setItem(draftStorageKey('s1'), 'stale draft'); + const { draft } = setup('s1'); + draft.clearDraft(); + // No nextTick — the write is synchronous. + expect(globalThis.localStorage.getItem(draftStorageKey('s1'))).toBeNull(); + + // Simulate the remount after the optimistic turn: a fresh composable + // instance for the same session should start empty, not restore the draft. + const { text } = setup('s1'); + expect(text.value).toBe(''); + await nextTick(); + expect(globalThis.localStorage.getItem(draftStorageKey('s1'))).toBeNull(); + }); +}); diff --git a/apps/kimi-web/test/confirm-dialog.test.ts b/apps/kimi-web/test/confirm-dialog.test.ts new file mode 100644 index 000000000..b059b2244 --- /dev/null +++ b/apps/kimi-web/test/confirm-dialog.test.ts @@ -0,0 +1,141 @@ +// apps/kimi-web/test/confirm-dialog.test.ts +// Logic tests for the useConfirmDialog singleton: boolean confirm/cancel, +// supersede, and the async `action` flow (busy state, close-on-settle, +// rejection propagation). +import { beforeEach, describe, expect, it } from 'vitest'; +import { useConfirmDialog } from '../src/composables/useConfirmDialog'; + +function deferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (err?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (err?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('useConfirmDialog', () => { + const { current, busy, confirm, settle, runAction } = useConfirmDialog(); + + beforeEach(() => { + // Drop any pending request so tests don't leak into each other. + settle(false); + }); + + it('resolves true on confirm without an action', async () => { + const p = confirm({ title: 'Archive?' }); + expect(current.value?.title).toBe('Archive?'); + await runAction(); + expect(current.value).toBeNull(); + await expect(p).resolves.toBe(true); + }); + + it('resolves false on cancel', async () => { + const p = confirm({ title: 'Archive?' }); + settle(false); + expect(current.value).toBeNull(); + await expect(p).resolves.toBe(false); + }); + + it('cancels a pending request when a new confirm supersedes it', async () => { + const first = confirm({ title: 'First' }); + const second = confirm({ title: 'Second' }); + await expect(first).resolves.toBe(false); + expect(current.value?.title).toBe('Second'); + settle(true); + await expect(second).resolves.toBe(true); + }); + + it('keeps the dialog open (busy) while the action runs, then closes', async () => { + const action = deferred(); + let ran = false; + const p = confirm({ + title: 'Archive?', + action: async () => { + ran = true; + await action.promise; + }, + }); + + void runAction(); + expect(ran).toBe(true); + expect(busy.value).toBe(true); + // Still open while the action is in flight. + expect(current.value).not.toBeNull(); + // Cancel is inert while busy. + settle(false); + expect(current.value).not.toBeNull(); + + action.resolve(); + await p; + expect(busy.value).toBe(false); + expect(current.value).toBeNull(); + await expect(p).resolves.toBe(true); + }); + + it('closes and rejects the confirm() promise when the action fails', async () => { + const failure = new Error('boom'); + const p = confirm({ + title: 'Archive?', + action: () => Promise.reject(failure), + }); + // Attach the rejection expectation before running so no unhandled + // rejection can escape between ticks. + const assertion = expect(p).rejects.toBe(failure); + await runAction(); + expect(busy.value).toBe(false); + expect(current.value).toBeNull(); + await assertion; + }); + + it('ignores a duplicate confirm while an action is running', async () => { + const action = deferred(); + let runs = 0; + const p = confirm({ + title: 'Archive?', + action: async () => { + runs += 1; + await action.promise; + }, + }); + void runAction(); + await runAction(); // no-op: busy + expect(runs).toBe(1); + action.resolve(); + await p; + }); + + it('runAction without a pending request is a no-op', async () => { + expect(current.value).toBeNull(); + await runAction(); + expect(busy.value).toBe(false); + }); + + it('resolves a new confirm false instead of superseding while an action runs', async () => { + const action = deferred(); + const first = confirm({ + title: 'First', + action: () => action.promise, + }); + void runAction(); + expect(busy.value).toBe(true); + + // The second confirm can't replace the busy dialog (it would open inert + // under the global busy state) — it resolves unconfirmed immediately and + // leaves the in-flight request untouched. + const second = confirm({ title: 'Second' }); + await expect(second).resolves.toBe(false); + expect(current.value?.title).toBe('First'); + expect(busy.value).toBe(true); + + action.resolve(); + await expect(first).resolves.toBe(true); + expect(busy.value).toBe(false); + expect(current.value).toBeNull(); + }); +}); diff --git a/apps/kimi-web/test/daemon-client.test.ts b/apps/kimi-web/test/daemon-client.test.ts new file mode 100644 index 000000000..dbe9c7918 --- /dev/null +++ b/apps/kimi-web/test/daemon-client.test.ts @@ -0,0 +1,336 @@ +// apps/kimi-web/test/daemon-client.test.ts +// DaemonKimiWebApi public REST adapter: session export binary/error contracts, +// getSessionGoal wire → app mapping, and raw stream-coordinate delivery. +// Wiring: real client/projector; fetch or WebSocket is stubbed at the network boundary. +// Run: pnpm --filter @moonshot-ai/kimi-web exec vitest run test/daemon-client.test.ts + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DaemonKimiWebApi } from '../src/api/daemon/client'; +import { DaemonApiError, DaemonNetworkError } from '../src/api/errors'; +import { clearTrace, traceToJsonl } from '../src/debug/trace'; +import type { AppEvent, KimiEventConnection, KimiEventMeta } from '../src/api/types'; + +class FakeWebSocket { + static readonly OPEN = 1; + static instances: FakeWebSocket[] = []; + + readonly OPEN = FakeWebSocket.OPEN; + readyState = FakeWebSocket.OPEN; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((event?: CloseEvent) => void) | null = null; + + constructor(_url: string, _protocols?: string | string[]) { + FakeWebSocket.instances.push(this); + } + + send(_data: string): void {} + + close(): void { + this.readyState = 3; + this.onclose?.(); + } + + emit(frame: unknown): void { + this.onmessage?.({ data: JSON.stringify(frame) } as MessageEvent); + } +} + +function envelope(data: unknown): Response { + return new Response(JSON.stringify({ code: 0, msg: '', data }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +const WIRE_GOAL = { + goalId: 'goal_1', + objective: 'fix all lint warnings', + status: 'active', + turnsUsed: 1, + 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, + }, +}; + +function createApi(): DaemonKimiWebApi { + return new DaemonKimiWebApi({ + serverHttpUrl: 'http://daemon.test', + clientId: 'web_test', + clientName: 'test', + clientVersion: '0.0.0', + clientUiMode: 'test', + }); +} + +describe('DaemonKimiWebApi.exportSession', () => { + beforeEach(() => { + vi.stubGlobal('location', { search: '?debug=1' }); + vi.stubGlobal('fetch', vi.fn()); + clearTrace(); + }); + + afterEach(() => { + clearTrace(); + vi.unstubAllGlobals(); + }); + + it('posts the Web log to the encoded session export endpoint and returns the ZIP', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(new Uint8Array([80, 75, 3, 4]), { + status: 200, + headers: { + 'content-type': 'application/zip', + 'content-disposition': 'attachment; filename="session-export.zip"', + }, + }), + ); + + const result = await createApi().exportSession('sess/1', '{"event":"safe"}'); + + expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe( + 'http://daemon.test/api/v1/sessions/sess%2F1/export', + ); + expect(vi.mocked(fetch).mock.calls[0]?.[1]).toMatchObject({ + method: 'POST', + body: JSON.stringify({ web_log: '{"event":"safe"}' }), + }); + expect(result.fileName).toBe('session-export.zip'); + expect(result.blob.size).toBe(4); + }); + + it('falls back to a session-id ZIP name for an unsafe response filename', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(new Uint8Array([80, 75]), { + status: 200, + headers: { + 'content-type': 'application/zip', + 'content-disposition': 'attachment; filename="../credentials.zip"', + }, + }), + ); + + const result = await createApi().exportSession('sess_1'); + + expect(result.fileName).toBe('sess_1.zip'); + }); + + it('parses a JSON error envelope returned by the export endpoint', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ code: 41301, msg: 'export too large', request_id: 'req_server' }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + + const caught = await createApi() + .exportSession('sess_1', 'log') + .catch((error: unknown) => error); + + expect(caught).toBeInstanceOf(DaemonApiError); + expect(caught).toMatchObject({ code: 41301, requestId: 'req_server' }); + }); + + it('rejects a successful response whose media type is not a ZIP', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response('not a zip', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ); + + const caught = await createApi().exportSession('sess_1').catch((error: unknown) => error); + + expect(caught).toBeInstanceOf(DaemonNetworkError); + expect(caught).toMatchObject({ phase: 'parse', contentType: 'text/plain' }); + }); + + it('records only Web-log counts in the request trace', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(new Uint8Array([80, 75]), { + status: 200, + headers: { 'content-type': 'application/zip' }, + }), + ); + const secret = 'PROMPT_CONTENT_MUST_NOT_ENTER_TRACE'; + + await createApi().exportSession('sess_1', `${secret}\nsecond line`); + + const trace = traceToJsonl(); + expect(trace).not.toContain(secret); + expect(trace).toContain('web_log_bytes'); + expect(trace).toContain('web_log_entries'); + }); +}); + +describe('DaemonKimiWebApi.getSessionGoal', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('maps a present goal snapshot', async () => { + vi.mocked(fetch).mockResolvedValue(envelope(WIRE_GOAL)); + const goal = await createApi().getSessionGoal('sess_1'); + expect(goal?.objective).toBe('fix all lint warnings'); + expect(goal?.status).toBe('active'); + expect(goal?.turnsUsed).toBe(1); + }); + + it('maps null to null (no active goal)', async () => { + vi.mocked(fetch).mockResolvedValue(envelope(null)); + const goal = await createApi().getSessionGoal('sess_1'); + expect(goal).toBeNull(); + }); + + it('requests the session goal endpoint', async () => { + vi.mocked(fetch).mockResolvedValue(envelope(null)); + await createApi().getSessionGoal('sess_42'); + expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe( + 'http://daemon.test/api/v1/sessions/sess_42/goal', + ); + }); +}); + +describe('DaemonKimiWebApi.connectEvents', () => { + let connection: KimiEventConnection | undefined; + + afterEach(() => { + connection?.close(); + connection = undefined; + vi.unstubAllGlobals(); + }); + + it('delivers raw assistant stream coordinates with the projected delta', () => { + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket as unknown as typeof WebSocket); + const received: Array<{ event: AppEvent; meta: KimiEventMeta }> = []; + connection = createApi().connectEvents({ + onEvent(event, meta) { + received.push({ event, meta }); + }, + onResync() {}, + onError() {}, + onConnectionChange() {}, + }); + const socket = FakeWebSocket.instances[0]!; + + socket.emit({ type: 'server_hello', payload: { protocol_version: 2 } }); + socket.emit({ + type: 'turn.started', + seq: 1, + session_id: 'session-1', + timestamp: '2026-01-01T00:00:00.000Z', + payload: { agentId: 'main', turnId: 7 }, + }); + socket.emit({ + type: 'turn.step.started', + seq: 2, + session_id: 'session-1', + timestamp: '2026-01-01T00:00:00.000Z', + payload: { agentId: 'main', turnId: 7, step: 1 }, + }); + socket.emit({ + type: 'assistant.delta', + seq: 2, + session_id: 'session-1', + timestamp: '2026-01-01T00:00:00.000Z', + volatile: true, + offset: 0, + payload: { agentId: 'main', turnId: 7, delta: 'hello' }, + }); + socket.emit({ + type: 'thinking.delta', + seq: 2, + session_id: 'session-1', + timestamp: '2026-01-01T00:00:00.000Z', + volatile: true, + offset: 0, + payload: { agentId: 'main', turnId: 7, delta: 'thought' }, + }); + + const delta = received.find(({ event }) => event.type === 'assistantDelta'); + expect(delta).toMatchObject({ + event: { + type: 'assistantDelta', + sessionId: 'session-1', + delta: { text: 'hello' }, + }, + meta: { + sessionId: 'session-1', + seq: 2, + stream: { turnId: 7, offset: 0, kind: 'text' }, + }, + }); + + const thinking = received.find( + ({ event }) => event.type === 'assistantDelta' && event.delta.thinking !== undefined, + ); + expect(thinking).toMatchObject({ + event: { + type: 'assistantDelta', + sessionId: 'session-1', + delta: { thinking: 'thought' }, + }, + meta: { + sessionId: 'session-1', + seq: 2, + stream: { turnId: 7, offset: 0, kind: 'thinking' }, + }, + }); + }); + + it('projects list-level work facts from the global session event', () => { + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket as unknown as typeof WebSocket); + const received: AppEvent[] = []; + connection = createApi().connectEvents({ + onEvent(event) { + received.push(event); + }, + onResync() {}, + onError() {}, + onConnectionChange() {}, + }); + const [socket] = FakeWebSocket.instances; + if (socket === undefined) throw new Error('WebSocket was not created'); + + socket.emit({ type: 'server_hello', payload: { protocol_version: 2 } }); + socket.emit({ + type: 'event.session.work_changed', + seq: 1, + session_id: 'session-1', + timestamp: '2026-01-01T00:00:00.000Z', + payload: { + busy: true, + main_turn_active: false, + pending_interaction: 'question', + }, + }); + + expect(received).toContainEqual({ + type: 'sessionWorkChanged', + sessionId: 'session-1', + busy: true, + mainTurnActive: false, + pendingInteraction: 'question', + lastTurnReason: undefined, + }); + }); +}); diff --git a/apps/kimi-web/test/event-batcher.test.ts b/apps/kimi-web/test/event-batcher.test.ts new file mode 100644 index 000000000..0108d9241 --- /dev/null +++ b/apps/kimi-web/test/event-batcher.test.ts @@ -0,0 +1,794 @@ +/** + * Scenario: high-frequency WebSocket render events reach the browser queue. + * Responsibilities: preserve ordering, merge only proven-contiguous assistant + * streams, bound each drain, keep a hidden-tab task fallback, and cancel stale + * callbacks after flush. Wiring: real batcher/coalescer/reducer with only the + * browser scheduler replaced by a manual public scheduler. + * Run: pnpm --filter @moonshot-ai/kimi-web exec vitest run test/event-batcher.test.ts + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { createInitialState, reduceAppEvent, type KimiClientState } from '../src/api/daemon/eventReducer'; +import type { + AppEvent, + AppMessage, + AppSession, + AppSessionSnapshot, + KimiEventConnection, + KimiEventHandlers, + KimiWebApi, +} from '../src/api/types'; +import { + coalesceAppRenderEvents, + createEventBatcher, + isRenderEvent, + splitOversizedAppRenderEvent, + type EventBatcher, + type EventBatcherScheduler, + type PendingAppEvent, +} from '../src/composables/client/eventBatcher'; + +const clientApiMock = vi.hoisted(() => ({})); +const REASONABLE_MAX_STREAM_GROUP_CHARS = 64 * 1024; + +vi.mock('../src/api', () => ({ + getKimiWebApi: () => clientApiMock, +})); + +interface ManualScheduler extends EventBatcherScheduler { + flushFrame(): void; + flushTask(): void; + pendingFrames(): number; + pendingTasks(): number; +} + +interface OwnedQueueItem { + owner: string; + kind: 'render' | 'control'; + id: string; +} + +function manualScheduler(): ManualScheduler { + let nextHandle = 1; + const frames = new Map void>(); + const tasks = new Map void>(); + + function takeOne(callbacks: Map void>): void { + const entry = callbacks.entries().next().value as [number, () => void] | undefined; + if (entry === undefined) return; + callbacks.delete(entry[0]); + entry[1](); + } + + return { + requestFrame(callback) { + const handle = nextHandle++; + frames.set(handle, callback); + return handle; + }, + cancelFrame(handle) { + frames.delete(handle); + }, + requestTask(callback) { + const handle = nextHandle++; + tasks.set(handle, callback); + return handle; + }, + cancelTask(handle) { + tasks.delete(handle); + }, + flushFrame() { + takeOne(frames); + }, + flushTask() { + takeOne(tasks); + }, + pendingFrames: () => frames.size, + pendingTasks: () => tasks.size, + }; +} + +interface DeltaOptions { + sessionId?: string; + messageId?: string; + contentIndex?: number; + turnId?: number; + kind?: 'text' | 'thinking'; + stream?: boolean; + seq?: number; +} + +function pendingDelta(value: string, offset: number, options: DeltaOptions = {}): PendingAppEvent { + const sessionId = options.sessionId ?? 'session-1'; + const kind = options.kind ?? 'text'; + return { + appEvent: { + type: 'assistantDelta', + sessionId, + messageId: options.messageId ?? 'message-1', + contentIndex: options.contentIndex ?? 0, + delta: kind === 'text' ? { text: value } : { thinking: value }, + }, + meta: { + sessionId, + seq: options.seq ?? offset + value.length, + stream: + options.stream === false + ? undefined + : { + turnId: options.turnId ?? 1, + offset, + kind, + }, + }, + }; +} + +function enqueueAppEvent( + enqueue: EventBatcher, + item: PendingAppEvent, +): void { + for (const part of splitOversizedAppRenderEvent(item)) enqueue(part); +} + +function assistantState(content: AppMessage['content'] = [{ type: 'text', text: '' }]): KimiClientState { + const state = createInitialState(); + state.messagesBySession['session-1'] = [ + { + id: 'message-1', + sessionId: 'session-1', + role: 'assistant', + content, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]; + return state; +} + +describe('createEventBatcher (ordered bounded scheduling)', () => { + it('processes queued render items in arrival order when the frame runs', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + (item) => item.startsWith('d'), + { scheduler }, + ); + + enqueue('d1'); + enqueue('d2'); + enqueue('d3'); + + expect(processed).toEqual([]); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + scheduler.flushFrame(); + + expect(processed).toEqual(['d1', 'd2', 'd3']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); + + it('processes a control item synchronously when no render item is pending', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + (item) => item.startsWith('d'), + { scheduler }, + ); + + enqueue('control'); + + expect(processed).toEqual(['control']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); + + it('keeps a control item behind prior render items when one slice is enough', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + (item) => item.startsWith('d'), + { scheduler, maxItemsPerSlice: 10 }, + ); + + enqueue('d1'); + enqueue('d2'); + enqueue('control'); + + expect(processed).toEqual(['d1', 'd2', 'control']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); + + it('continues on a later callback when a control item follows more than one slice', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + (item) => item.startsWith('d'), + { scheduler, maxItemsPerSlice: 2 }, + ); + + enqueue('d1'); + enqueue('d2'); + enqueue('d3'); + enqueue('control'); + enqueue('d4'); + + expect(processed).toEqual(['d1', 'd2']); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + scheduler.flushFrame(); + + expect(processed).toEqual(['d1', 'd2', 'd3', 'control']); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + scheduler.flushTask(); + + expect(processed).toEqual(['d1', 'd2', 'd3', 'control', 'd4']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); + + it('uses the task fallback when animation frames do not run', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + () => true, + { scheduler }, + ); + + enqueue('d1'); + enqueue('d2'); + scheduler.flushTask(); + + expect(processed).toEqual(['d1', 'd2']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); + + it('cancels scheduled callbacks when flush drains the queue authoritatively', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + () => true, + { scheduler }, + ); + + enqueue('d1'); + enqueue('d2'); + enqueue.flush(); + + expect(processed).toEqual(['d1', 'd2']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); + + it('discards a removed owner control item that remains beyond the slice budget', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item.id), + (item) => item.kind === 'render', + { scheduler, maxItemsPerSlice: 2 }, + ); + + enqueue({ owner: 'session-2', kind: 'render', id: 'session-2:d1' }); + enqueue({ owner: 'session-2', kind: 'render', id: 'session-2:d2' }); + enqueue({ owner: 'session-1', kind: 'render', id: 'session-1:d1' }); + enqueue({ owner: 'session-1', kind: 'control', id: 'session-1:idle' }); + expect(processed).toEqual(['session-2:d1', 'session-2:d2']); + + enqueue.discard((item) => item.owner === 'session-1'); + scheduler.flushFrame(); + scheduler.flushTask(); + + expect(processed).toEqual(['session-2:d1', 'session-2:d2']); + }); + + it('preserves the order of items not discarded', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + () => true, + { scheduler }, + ); + + enqueue('session-1:d1'); + enqueue('session-2:d1'); + enqueue('session-1:d2'); + enqueue('session-2:d2'); + enqueue.discard((item) => item.startsWith('session-1:')); + scheduler.flushFrame(); + + expect(processed).toEqual(['session-2:d1', 'session-2:d2']); + }); + + it('cancels scheduled callbacks when discard empties the queue', () => { + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + () => {}, + () => true, + { scheduler }, + ); + + enqueue('session-1:d1'); + enqueue('session-1:d2'); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + enqueue.discard((item) => item.startsWith('session-1:')); + + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); + + it('cancels scheduled work once when dispose is repeated', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const cancelFrame = vi.spyOn(scheduler, 'cancelFrame'); + const cancelTask = vi.spyOn(scheduler, 'cancelTask'); + const enqueue = createEventBatcher( + (item) => processed.push(item), + () => true, + { scheduler }, + ); + + enqueue('d1'); + enqueue('d2'); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + enqueue.dispose(); + enqueue.dispose(); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + expect(cancelFrame).toHaveBeenCalledTimes(1); + expect(cancelTask).toHaveBeenCalledTimes(1); + + scheduler.flushFrame(); + scheduler.flushTask(); + expect(processed).toEqual([]); + }); + + it('ignores items enqueued after dispose without scheduling callbacks', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + () => true, + { scheduler }, + ); + + enqueue.dispose(); + enqueue('d3'); + enqueue.flush(); + + expect(processed).toEqual([]); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); +}); + +describe('coalesceAppRenderEvents (lossless stream grouping)', () => { + it('reduces 10,000 contiguous deltas in capped groups while preserving every character', () => { + const scheduler = manualScheduler(); + let state = assistantState(); + let reducerCalls = 0; + const groupLengths: number[] = []; + const groupOffsets: number[] = []; + const enqueue = createEventBatcher( + ({ appEvent, meta }) => { + reducerCalls += 1; + groupLengths.push( + appEvent.type === 'assistantDelta' ? (appEvent.delta.text?.length ?? 0) : 0, + ); + groupOffsets.push(meta.stream?.offset ?? -1); + state = reduceAppEvent(state, appEvent, meta); + }, + ({ appEvent }) => isRenderEvent(appEvent), + { scheduler, coalesce: coalesceAppRenderEvents }, + ); + + for (let index = 0; index < 10_000; index += 1) { + enqueueAppEvent(enqueue, pendingDelta('abcdefghijklmnop', index * 16)); + } + + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + scheduler.flushFrame(); + + expect(reducerCalls).toBeGreaterThan(1); + expect(reducerCalls).toBeLessThan(100); + expect( + groupLengths.every((length) => length <= REASONABLE_MAX_STREAM_GROUP_CHARS), + ).toBe(true); + let expectedOffset = 0; + for (let index = 0; index < groupOffsets.length; index += 1) { + expect(groupOffsets[index]).toBe(expectedOffset); + expectedOffset += groupLengths[index]!; + } + expect(state.lastSeqBySession['session-1']).toBe(160_000); + expect(state.messagesBySession['session-1']?.[0]?.content).toEqual([ + { type: 'text', text: 'abcdefghijklmnop'.repeat(10_000) }, + ]); + }); + + it('keeps a 10,000-delta hidden-tab backlog in a few capped groups', () => { + const scheduler = manualScheduler(); + const processed: PendingAppEvent[] = []; + const enqueue = createEventBatcher( + (item) => processed.push(item), + ({ appEvent }) => isRenderEvent(appEvent), + { scheduler, coalesce: coalesceAppRenderEvents }, + ); + + for (let index = 0; index < 10_000; index += 1) { + enqueueAppEvent(enqueue, pendingDelta('abcdefghijklmnop', index * 16)); + } + + expect(processed).toEqual([]); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + scheduler.flushTask(); + + expect(processed.length).toBeGreaterThan(1); + expect(processed.length).toBeLessThan(100); + expect( + processed.every( + ({ appEvent }) => + appEvent.type === 'assistantDelta' && + (appEvent.delta.text?.length ?? 0) <= REASONABLE_MAX_STREAM_GROUP_CHARS, + ), + ).toBe(true); + let expectedOffset = 0; + for (const item of processed) { + expect(item.meta.stream?.offset).toBe(expectedOffset); + if (item.appEvent.type === 'assistantDelta') { + expectedOffset += item.appEvent.delta.text?.length ?? 0; + } + } + expect( + processed + .map(({ appEvent }) => + appEvent.type === 'assistantDelta' ? (appEvent.delta.text ?? '') : '', + ) + .join(''), + ).toBe('abcdefghijklmnop'.repeat(10_000)); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); + + it('splits one oversized incoming delta without breaking a surrogate pair', () => { + const value = '\ud83d\ude00'.repeat(50_000) + 'tail'; + + const parts = splitOversizedAppRenderEvent(pendingDelta(value, 7)); + + expect(parts.length).toBeGreaterThan(1); + expect(parts.length).toBeLessThan(100); + let expectedOffset = 7; + for (const part of parts) { + expect(part.meta.stream?.offset).toBe(expectedOffset); + expect(part.meta.seq).toBe(7 + value.length); + if (part.appEvent.type === 'assistantDelta') { + const text = part.appEvent.delta.text ?? ''; + expect(text.length).toBeLessThanOrEqual(REASONABLE_MAX_STREAM_GROUP_CHARS); + expect(/[\uD800-\uDBFF]$/u.test(text)).toBe(false); + expect(/^[\uDC00-\uDFFF]/u.test(text)).toBe(false); + expectedOffset += text.length; + } + } + expect( + parts.every( + ({ appEvent }) => + appEvent.type === 'assistantDelta' && + (appEvent.delta.text?.length ?? 0) <= REASONABLE_MAX_STREAM_GROUP_CHARS, + ), + ).toBe(true); + expect( + parts + .map(({ appEvent }) => + appEvent.type === 'assistantDelta' ? (appEvent.delta.text ?? '') : '', + ) + .join(''), + ).toBe(value); + }); + + it.each([ + ['session differs', pendingDelta('b', 1, { sessionId: 'session-2' })], + ['turn differs', pendingDelta('b', 1, { turnId: 2 })], + ['message differs', pendingDelta('b', 1, { messageId: 'message-2' })], + ['content index differs', pendingDelta('b', 1, { contentIndex: 1 })], + ['delta kind differs', pendingDelta('b', 1, { kind: 'thinking' })], + ['offset is not contiguous', pendingDelta('b', 2)], + ['stream coordinates are missing', pendingDelta('b', 1, { stream: false })], + ])('keeps adjacent deltas separate when %s', (_condition, next) => { + const scheduler = manualScheduler(); + let processed = 0; + const enqueue = createEventBatcher( + () => { + processed += 1; + }, + ({ appEvent }) => isRenderEvent(appEvent), + { scheduler, coalesce: coalesceAppRenderEvents }, + ); + + enqueue(pendingDelta('a', 0)); + enqueue(next); + scheduler.flushFrame(); + + expect(processed).toBe(2); + }); + + it('coalesces contiguous thinking deltas into the existing thinking block', () => { + const scheduler = manualScheduler(); + let state = assistantState([{ type: 'thinking', thinking: 'seed' }]); + const enqueue = createEventBatcher( + ({ appEvent, meta }) => { + state = reduceAppEvent(state, appEvent, meta); + }, + ({ appEvent }) => isRenderEvent(appEvent), + { scheduler, coalesce: coalesceAppRenderEvents }, + ); + + enqueue(pendingDelta(' one', 0, { kind: 'thinking' })); + enqueue(pendingDelta(' two', 4, { kind: 'thinking' })); + scheduler.flushFrame(); + + expect(state.messagesBySession['session-1']?.[0]?.content).toEqual([ + { type: 'thinking', thinking: 'seed one two', signature: undefined }, + ]); + }); + + it('does not reapply a pre-snapshot delta after the snapshot seeds live text', () => { + const scheduler = manualScheduler(); + let state = assistantState(); + const enqueue = createEventBatcher( + ({ appEvent, meta }) => { + state = reduceAppEvent(state, appEvent, meta); + }, + ({ appEvent }) => isRenderEvent(appEvent), + { scheduler, coalesce: coalesceAppRenderEvents }, + ); + + enqueue(pendingDelta('stale', 0)); + enqueue.flush(); + state = assistantState([{ type: 'text', text: 'snapshot' }]); + enqueue(pendingDelta(' live', 8)); + scheduler.flushFrame(); + + expect(state.messagesBySession['session-1']?.[0]?.content).toEqual([ + { type: 'text', text: 'snapshot live' }, + ]); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); +}); + +describe('useKimiWebClient (resync integration)', () => { + it('flushes queued deltas around an authoritative snapshot before live streaming resumes', async () => { + vi.stubGlobal('WebSocket', class {}); + + const sessionId = 'session-1'; + const session: AppSession = { + id: sessionId, + title: 'Session', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + status: 'running', + archived: false, + currentPromptId: 'prompt-1', + cwd: '/workspace', + model: 'model-1', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 1, + }, + messageCount: 1, + lastSeq: 10, + workspaceId: 'workspace-1', + }; + const snapshot = (text: string, asOfSeq: number, epoch: string): AppSessionSnapshot => ({ + asOfSeq, + epoch, + session: { ...session, lastSeq: asOfSeq }, + messages: [ + { + id: 'message-1', + sessionId, + role: 'assistant', + content: [{ type: 'text', text }], + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + hasMoreMessages: false, + inFlightTurn: { + turnId: 1, + assistantText: text, + thinkingText: '', + runningTools: [], + promptId: 'prompt-1', + }, + subagents: [], + pendingApprovals: [], + pendingQuestions: [], + }); + const initialSnapshot = snapshot('seed', 10, 'epoch-1'); + const authoritativeSnapshot = snapshot('snapshot', 20, 'epoch-2'); + + let handlers: KimiEventHandlers | undefined; + let resolveSnapshotRequest!: () => void; + const snapshotRequested = new Promise((resolve) => { + resolveSnapshotRequest = resolve; + }); + let resolveAuthoritativeSnapshot!: (value: AppSessionSnapshot) => void; + const authoritativeSnapshotResponse = new Promise((resolve) => { + resolveAuthoritativeSnapshot = resolve; + }); + let resolveSnapshotApplied!: () => void; + const snapshotApplied = new Promise((resolve) => { + resolveSnapshotApplied = resolve; + }); + let snapshotCalls = 0; + const getSessionSnapshot = vi.fn((_id: string) => { + snapshotCalls += 1; + if (snapshotCalls === 1) return Promise.resolve(initialSnapshot); + resolveSnapshotRequest(); + return authoritativeSnapshotResponse; + }); + let seedCalls = 0; + const connection: KimiEventConnection = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + bindNextPromptId: vi.fn(), + seedSnapshot: vi.fn(() => { + seedCalls += 1; + if (seedCalls === 2) resolveSnapshotApplied(); + }), + abort: vi.fn(), + terminalAttach: vi.fn(), + terminalInput: vi.fn(), + terminalResize: vi.fn(), + terminalDetach: vi.fn(), + terminalClose: vi.fn(), + markSideChannelAgent: vi.fn(), + health: () => ({ connected: true, open: true, stale: false }), + reconnect: vi.fn(), + close: vi.fn(), + }; + const api: Partial = { + getAuth: vi.fn(async () => ({ + ready: true, + defaultModel: 'model-1', + managedProvider: null, + })), + getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })), + getMeta: vi.fn(async () => ({ + serverVersion: '0.0.0', + serverId: 'server-1', + startedAt: '2026-01-01T00:00:00.000Z', + capabilities: {}, + openInApps: [], + dangerousBypassAuth: false, + backend: 'v2', + })), + getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'model-1' })), + listModels: vi.fn(async () => []), + listProviders: vi.fn(async () => []), + listWorkspaces: vi.fn(async () => [ + { + id: 'workspace-1', + root: '/workspace', + name: 'Workspace', + sessionCount: 1, + }, + ]), + getFsHome: vi.fn(async () => ({ home: '/home/test', recentRoots: [] })), + listSessions: vi.fn(async () => ({ items: [session], hasMore: false })), + getSessionSnapshot, + getSessionStatus: vi.fn(async () => ({ + model: 'model-1', + thinkingEffort: 'high', + permission: 'manual', + planMode: false, + swarmMode: false, + contextTokens: 0, + maxContextTokens: 0, + contextUsage: 0, + })), + getSessionGoal: vi.fn(async () => null), + getSessionWarnings: vi.fn(async () => []), + getGitStatus: vi.fn(async () => ({ + branch: '', + ahead: 0, + behind: 0, + entries: {}, + additions: 0, + deletions: 0, + pullRequest: null, + })), + listTasks: vi.fn(async () => []), + listSkills: vi.fn(async () => []), + listSkillsForWorkspace: vi.fn(async () => []), + getFileUrl: (fileId) => `file:${fileId}`, + connectEvents: vi.fn((nextHandlers) => { + handlers = nextHandlers; + return connection; + }), + }; + for (const key of Object.keys(clientApiMock)) delete clientApiMock[key]; + Object.assign(clientApiMock, api); + + try { + const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); + const client = useKimiWebClient(); + await client.load(); + const assistantText = (): string | undefined => + client.turns.value.find((turn) => turn.role === 'assistant')?.text; + + expect(assistantText()).toBe('seed'); + expect(handlers).toBeDefined(); + + const beforeResync = pendingDelta('before', 4, { seq: 11 }); + handlers!.onEvent(beforeResync.appEvent, beforeResync.meta); + handlers!.onResync(sessionId, 11, 'epoch-2'); + + // onResync synchronously drains pre-resync text onto the old state. + expect(assistantText()).toBe('seedbefore'); + await snapshotRequested; + + // A frame can race the REST request. The second flush must consume it on + // the old state before the authoritative snapshot replaces that state. + const duringSnapshot = pendingDelta('old', 10, { seq: 12 }); + handlers!.onEvent(duringSnapshot.appEvent, duringSnapshot.meta); + resolveAuthoritativeSnapshot(authoritativeSnapshot); + await snapshotApplied; + expect(assistantText()).toBe('snapshot'); + + const live = pendingDelta(' live', 8, { seq: 21 }); + handlers!.onEvent(live.appEvent, live.meta); + handlers!.onEvent( + { type: 'sessionMetaUpdated', sessionId, title: 'Session' }, + { sessionId, seq: 22 }, + ); + + expect(assistantText()).toBe('snapshot live'); + } finally { + connection.close(); + vi.unstubAllGlobals(); + } + }); +}); + +describe('isRenderEvent (queue classification)', () => { + it.each(['assistantDelta', 'agentDelta', 'toolOutput', 'taskProgress'])( + 'classifies %s as a render event', + (type) => { + expect(isRenderEvent({ type } as AppEvent)).toBe(true); + }, + ); + + it.each(['messageCreated', 'messageUpdated', 'sessionWorkChanged', 'approvalRequested', 'configChanged'])( + 'classifies %s as a control event', + (type) => { + expect(isRenderEvent({ type } as AppEvent)).toBe(false); + }, + ); +}); diff --git a/apps/kimi-web/test/event-reducer.test.ts b/apps/kimi-web/test/event-reducer.test.ts new file mode 100644 index 000000000..f22c51119 --- /dev/null +++ b/apps/kimi-web/test/event-reducer.test.ts @@ -0,0 +1,635 @@ +import { describe, expect, it } from 'vitest'; +import { createInitialState, reduceAppEvent } from '../src/api/daemon/eventReducer'; +import type { AppMessage, AppSession, AppTask } from '../src/api/types'; +import { i18n } from '../src/i18n'; + +function makeSession(id: string, updatedAt: string): AppSession { + return { + id, + title: id, + createdAt: updatedAt, + updatedAt, + busy: false, + archived: false, + cwd: '/workspace', + model: 'kimi-code', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + }; +} + +function makeMessage(sessionId: string, createdAt: string): AppMessage { + return { + id: `msg_${createdAt}`, + sessionId, + role: 'user', + content: [{ type: 'text', text: 'hi' }], + createdAt, + }; +} + +function makeSubagentTask(id: string, sessionId: string): AppTask { + return { + id, + sessionId, + kind: 'subagent', + description: 'subagent task', + busy: true, + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + +describe('reduceAppEvent turnActiveChanged', () => { + it('sets and clears the per-session main-turn liveness flag', () => { + const state = { + ...createInitialState(), + sessions: [makeSession('s1', '2026-01-01T00:00:00.000Z')], + }; + const started = reduceAppEvent( + state, + { type: 'turnActiveChanged', sessionId: 's1', active: true }, + { sessionId: 's1', seq: 1 }, + ); + expect(started.turnActiveBySession['s1']).toBe(true); + expect(started.sessions[0]?.mainTurnActive).toBe(true); + const ended = reduceAppEvent( + started, + { type: 'turnActiveChanged', sessionId: 's1', active: false, reason: 'completed' }, + { sessionId: 's1', seq: 2 }, + ); + expect(ended.turnActiveBySession['s1']).toBeUndefined(); + expect(ended.sessions[0]?.mainTurnActive).toBe(false); + }); + + it('drops the flag with the rest of a deleted session', () => { + const state = { + ...createInitialState(), + sessions: [makeSession('s1', '2026-01-01T00:00:00.000Z')], + turnActiveBySession: { s1: true }, + }; + const next = reduceAppEvent(state, { type: 'sessionDeleted', sessionId: 's1' }, { sessionId: 's1', seq: 1 }); + expect(next.turnActiveBySession['s1']).toBeUndefined(); + }); +}); + +describe('reduceAppEvent sessionWorkChanged', () => { + it('updates list-level main-turn liveness for an unopened session', () => { + const state = { + ...createInitialState(), + sessions: [makeSession('s1', '2026-01-01T00:00:00.000Z')], + }; + + const next = reduceAppEvent( + state, + { + type: 'sessionWorkChanged', + sessionId: 's1', + busy: true, + mainTurnActive: true, + }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.sessions[0]).toMatchObject({ + busy: true, + mainTurnActive: true, + }); + expect(next.turnActiveBySession['s1']).toBe(true); + }); + + it('updates the listed action-required fallback for an unopened session', () => { + const state = { + ...createInitialState(), + sessions: [makeSession('s1', '2026-01-01T00:00:00.000Z')], + }; + + const next = reduceAppEvent( + state, + { + type: 'sessionWorkChanged', + sessionId: 's1', + busy: true, + pendingInteraction: 'question', + }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.sessions[0]?.pendingInteraction).toBe('question'); + }); + + it('clears streamed main-turn liveness while aggregate work remains busy', () => { + const state = { + ...createInitialState(), + sessions: [ + { + ...makeSession('s1', '2026-01-01T00:00:00.000Z'), + busy: true, + mainTurnActive: true, + }, + ], + turnActiveBySession: { s1: true }, + }; + + const next = reduceAppEvent( + state, + { + type: 'sessionWorkChanged', + sessionId: 's1', + busy: true, + mainTurnActive: false, + pendingInteraction: 'none', + }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.sessions[0]).toMatchObject({ busy: true, mainTurnActive: false }); + expect(next.turnActiveBySession['s1']).toBeUndefined(); + }); + + it('clears stale main-turn liveness when an idle update omits the optional field', () => { + const state = { + ...createInitialState(), + sessions: [ + { + ...makeSession('s1', '2026-01-01T00:00:00.000Z'), + busy: true, + mainTurnActive: true, + }, + ], + turnActiveBySession: { s1: true }, + }; + + const next = reduceAppEvent( + state, + { + type: 'sessionWorkChanged', + sessionId: 's1', + busy: false, + }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.sessions[0]).toMatchObject({ busy: false, mainTurnActive: false }); + expect(next.turnActiveBySession['s1']).toBeUndefined(); + }); + + it('clears a stale turn outcome when the update omits lastTurnReason', () => { + // An omitted last_turn_reason is authoritative ("no current outcome" — + // e.g. a fresh turn cleared the previous one), not "keep the old value". + const state = { + ...createInitialState(), + sessions: [ + { + ...makeSession('s1', '2026-01-01T00:00:00.000Z'), + busy: false, + lastTurnReason: 'cancelled' as const, + }, + ], + }; + + const cleared = reduceAppEvent( + state, + { type: 'sessionWorkChanged', sessionId: 's1', busy: true }, + { sessionId: 's1', seq: 1 }, + ); + expect(cleared.sessions[0]?.lastTurnReason).toBeUndefined(); + + const set = reduceAppEvent( + state, + { type: 'sessionWorkChanged', sessionId: 's1', busy: false, lastTurnReason: 'failed' }, + { sessionId: 's1', seq: 2 }, + ); + expect(set.sessions[0]?.lastTurnReason).toBe('failed'); + }); +}); + +describe('reduceAppEvent messageCreated', () => { + it('bumps the session updatedAt so it floats to the top of the sidebar', () => { + const state = { + ...createInitialState(), + sessions: [makeSession('s-old', '2026-01-01T00:00:00.000Z')], + }; + const next = reduceAppEvent( + state, + { type: 'messageCreated', message: makeMessage('s-old', '2026-06-01T12:00:00.000Z') }, + { sessionId: 's-old', seq: 1 }, + ); + expect(next.sessions[0]?.updatedAt).toBe('2026-06-01T12:00:00.000Z'); + }); + + it('does not move a session backwards when an older message arrives', () => { + const state = { + ...createInitialState(), + sessions: [makeSession('s-new', '2026-06-01T12:00:00.000Z')], + }; + const next = reduceAppEvent( + state, + { type: 'messageCreated', message: makeMessage('s-new', '2026-01-01T00:00:00.000Z') }, + { sessionId: 's-new', seq: 1 }, + ); + expect(next.sessions[0]?.updatedAt).toBe('2026-06-01T12:00:00.000Z'); + }); + + it('leaves other sessions untouched', () => { + const state = { + ...createInitialState(), + sessions: [ + makeSession('s-a', '2026-01-01T00:00:00.000Z'), + makeSession('s-b', '2026-01-01T00:00:00.000Z'), + ], + }; + const next = reduceAppEvent( + state, + { type: 'messageCreated', message: makeMessage('s-a', '2026-06-01T12:00:00.000Z') }, + { sessionId: 's-a', seq: 1 }, + ); + expect(next.sessions.find((s) => s.id === 's-a')?.updatedAt).toBe('2026-06-01T12:00:00.000Z'); + expect(next.sessions.find((s) => s.id === 's-b')?.updatedAt).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('reconciles a resolved video echo into the optimistic user message', () => { + // The optimistic copy still carries the original `video` part (no promptId + // yet — the echo raced the submit response). The daemon echo carries the + // server-resolved `` text tag. They must collapse into + // one bubble, not render as a duplicate. + const optimistic: AppMessage = { + id: 'msg_opt_1', + sessionId: 's-vid', + role: 'user', + content: [ + { type: 'text', text: 'look at this' }, + { type: 'video', source: { kind: 'file', fileId: 'f_abc' } }, + ], + createdAt: '2026-06-01T12:00:00.000Z', + metadata: { 'kimiWeb.optimisticUserMessage': true }, + }; + const echo: AppMessage = { + id: 'msg_real', + sessionId: 's-vid', + role: 'user', + content: [ + { type: 'text', text: 'look at this' }, + { type: 'text', text: '' }, + ], + createdAt: '2026-06-01T12:00:00.000Z', + promptId: 'p1', + }; + const state = { + ...createInitialState(), + sessions: [makeSession('s-vid', '2026-01-01T00:00:00.000Z')], + messagesBySession: { 's-vid': [optimistic] }, + }; + const next = reduceAppEvent( + state, + { type: 'messageCreated', message: echo }, + { sessionId: 's-vid', seq: 1 }, + ); + const msgs = next.messagesBySession['s-vid'] ?? []; + expect(msgs).toHaveLength(1); + // Keeps the optimistic id so the bubble doesn't remount… + expect(msgs[0]?.id).toBe('msg_opt_1'); + // …but takes the daemon's resolved content (the video text tag). + expect(msgs[0]?.content).toEqual(echo.content); + expect(msgs[0]?.promptId).toBe('p1'); + }); +}); + +describe('reduceAppEvent taskProgress', () => { + it('accumulates the full progress output without truncating to a fixed window', () => { + const state = { + ...createInitialState(), + tasksBySession: { 's1': [makeSubagentTask('t1', 's1')] }, + }; + let next = state; + for (let i = 0; i < 60; i++) { + // The real projector emits a taskCreated (without reducer-owned + // outputLines) right before every taskProgress; progress must survive + // that replacement. + next = reduceAppEvent( + next, + { type: 'taskCreated', sessionId: 's1', task: makeSubagentTask('t1', 's1') }, + { sessionId: 's1', seq: i * 2 + 1 }, + ); + next = reduceAppEvent( + next, + { type: 'taskProgress', sessionId: 's1', taskId: 't1', outputChunk: `line ${i}`, stream: 'stdout' }, + { sessionId: 's1', seq: i * 2 + 2 }, + ); + } + const lines = next.tasksBySession['s1']?.[0]?.outputLines; + expect(lines).toHaveLength(60); + expect(lines?.[0]).toBe('line 0'); + expect(lines?.at(-1)).toBe('line 59'); + }); + + it('deduplicates a repeated trailing chunk', () => { + const state = { + ...createInitialState(), + tasksBySession: { 's1': [makeSubagentTask('t1', 's1')] }, + }; + const event = { type: 'taskProgress', sessionId: 's1', taskId: 't1', outputChunk: 'same', stream: 'stdout' } as const; + const once = reduceAppEvent(state, event, { sessionId: 's1', seq: 1 }); + const twice = reduceAppEvent(once, event, { sessionId: 's1', seq: 2 }); + expect(twice.tasksBySession['s1']?.[0]?.outputLines).toEqual(['same']); + }); + + it('caps accumulated output for non-subagent (background) tasks', () => { + const bash: AppTask = { ...makeSubagentTask('b1', 's1'), kind: 'bash' }; + const state = { ...createInitialState(), tasksBySession: { 's1': [bash] } }; + let next = state; + for (let i = 0; i < 60; i++) { + next = reduceAppEvent( + next, + { type: 'taskProgress', sessionId: 's1', taskId: 'b1', outputChunk: `line ${i}`, stream: 'stdout' }, + { sessionId: 's1', seq: i + 1 }, + ); + } + const lines = next.tasksBySession['s1']?.[0]?.outputLines; + expect(lines).toHaveLength(40); + expect(lines?.[0]).toBe('line 20'); + expect(lines?.at(-1)).toBe('line 59'); + }); + + it('concatenates subagent text-kind chunks into a growing text block', () => { + const state = { + ...createInitialState(), + tasksBySession: { 's1': [makeSubagentTask('t1', 's1')] }, + }; + let next = state; + for (const chunk of ['Hello', ', ', 'world', '!']) { + next = reduceAppEvent( + next, + { + type: 'taskProgress', + sessionId: 's1', + taskId: 't1', + outputChunk: chunk, + stream: 'stdout', + kind: 'text', + }, + { sessionId: 's1', seq: 1 }, + ); + } + const task = next.tasksBySession['s1']?.[0]; + expect(task?.text).toBe('Hello, world!'); + // Text chunks must not pollute the line-based progress output. + expect(task?.outputLines ?? []).toHaveLength(0); + }); + + it('preserves accumulated text across a taskCreated replacement', () => { + const state = { + ...createInitialState(), + tasksBySession: { 's1': [{ ...makeSubagentTask('t1', 's1'), text: 'partial' }] }, + }; + const next = reduceAppEvent( + state, + { type: 'taskCreated', sessionId: 's1', task: makeSubagentTask('t1', 's1') }, + { sessionId: 's1', seq: 1 }, + ); + expect(next.tasksBySession['s1']?.[0]?.text).toBe('partial'); + }); + + it('preserves subagent identity metadata across a taskCreated replacement with omitted fields', () => { + const state = { + ...createInitialState(), + tasksBySession: { + 's1': [ + { + ...makeSubagentTask('t1', 's1'), + parentToolCallId: 'call-1', + swarmIndex: 2, + subagentType: 'explore', + runInBackground: true, + outputLines: ['old line'], + text: 'partial', + }, + ], + }, + }; + const next = reduceAppEvent( + state, + { type: 'taskCreated', sessionId: 's1', task: makeSubagentTask('t1', 's1') }, + { sessionId: 's1', seq: 1 }, + ); + expect(next.tasksBySession['s1']?.[0]).toMatchObject({ + parentToolCallId: 'call-1', + swarmIndex: 2, + subagentType: 'explore', + runInBackground: true, + outputLines: ['old line'], + text: 'partial', + }); + }); + + it('keeps the roster-seeded description when a re-projected task carries the placeholder', () => { + // After a page refresh the snapshot roster seeds the real description; a + // later subagent.* lifecycle event re-projects the task with the + // projector's skeleton default ('Sub Agent') — it must not clobber it. + const state = { + ...createInitialState(), + tasksBySession: { + 's1': [{ ...makeSubagentTask('t1', 's1'), description: 'explore the auth flow' }], + }, + }; + const next = reduceAppEvent( + state, + { + type: 'taskCreated', + sessionId: 's1', + task: { ...makeSubagentTask('t1', 's1'), description: 'Sub Agent' }, + }, + { sessionId: 's1', seq: 1 }, + ); + expect(next.tasksBySession['s1']?.[0]?.description).toBe('explore the auth flow'); + }); + + it('takes the incoming description when it is a real one', () => { + const state = { + ...createInitialState(), + tasksBySession: { + 's1': [{ ...makeSubagentTask('t1', 's1'), description: 'Sub Agent' }], + }, + }; + const next = reduceAppEvent( + state, + { + type: 'taskCreated', + sessionId: 's1', + task: { ...makeSubagentTask('t1', 's1'), description: 'write the tests' }, + }, + { sessionId: 's1', seq: 1 }, + ); + expect(next.tasksBySession['s1']?.[0]?.description).toBe('write the tests'); + }); +}); + +describe('reduceAppEvent sessions reference stability', () => { + // The sidebar computeds (sessionsForView / workspaceGroups / mergedWorkspaces) + // depend on `rawState.sessions`. Events that do not change sessions must keep + // the SAME array reference so those computeds are not dirtied; events that do + // change sessions must produce a NEW array. + + it('reuses the sessions reference for an event that does not touch sessions', () => { + const state = { + ...createInitialState(), + sessions: [makeSession('s1', '2026-01-01T00:00:00.000Z')], + messagesBySession: { s1: [makeMessage('s1', '2026-01-01T00:00:00.000Z')] }, + }; + const next = reduceAppEvent( + state, + { + type: 'messageUpdated', + sessionId: 's1', + messageId: 'msg_2026-01-01T00:00:00.000Z', + content: [{ type: 'text', text: 'updated' }], + status: 'completed', + }, + { sessionId: 's1', seq: 2 }, + ); + expect(next.sessions).toBe(state.sessions); + }); + + it('produces a new sessions array for an event that changes sessions', () => { + const state = { + ...createInitialState(), + sessions: [makeSession('s1', '2026-01-01T00:00:00.000Z')], + }; + const next = reduceAppEvent( + state, + { type: 'sessionCreated', session: makeSession('s2', '2026-02-01T00:00:00.000Z') }, + { sessionId: 's2', seq: 3 }, + ); + expect(next.sessions).not.toBe(state.sessions); + expect(next.sessions.map((s) => s.id)).toEqual(['s2', 's1']); + }); +}); + +describe('reduceAppEvent messageCreated cron origin', () => { + it('appends a cron-origin user message instead of reconciling it into an optimistic echo', () => { + const sid = 's-cron'; + const optimistic: AppMessage = { + id: 'opt_1', + sessionId: sid, + role: 'user', + content: [{ type: 'text', text: 'check the BTC price' }], + createdAt: '2026-01-01T00:00:00.000Z', + promptId: 'pr_user', + metadata: { 'kimiWeb.optimisticUserMessage': true }, + }; + const state = { + ...createInitialState(), + sessions: [makeSession(sid, '2026-01-01T00:00:00.000Z')], + messagesBySession: { [sid]: [optimistic] }, + }; + const cronMessage: AppMessage = { + id: 'cron_1', + sessionId: sid, + role: 'user', + content: [{ type: 'text', text: 'check the BTC price' }], + createdAt: '2026-01-01T00:01:00.000Z', + promptId: 'cron_pr_x', + metadata: { + origin: { + kind: 'cron_job', + jobId: 'j', + cron: '* * * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + }, + }; + const next = reduceAppEvent( + state, + { type: 'messageCreated', message: cronMessage }, + { sessionId: sid, seq: 2 }, + ); + const msgs = next.messagesBySession[sid]!; + expect(msgs).toHaveLength(2); + expect(msgs.map((m) => m.id)).toEqual(['opt_1', 'cron_1']); + }); +}); + +describe('reduceAppEvent unknown agent error', () => { + function reduceRaw(raw: unknown): ReturnType { + return reduceAppEvent( + createInitialState(), + { type: 'unknown', raw }, + { sessionId: 's1', seq: 1 }, + ); + } + + it('surfaces a rate-limit failure as a structured notice with full diagnostics', () => { + const next = reduceRaw({ + _agentError: true, + code: 'provider.rate_limit', + message: 'Rate limit reached for requests. Please try again later.', + name: 'RateLimitError', + details: { statusCode: 429, requestId: 'req_1' }, + retryable: true, + }); + const notice = next.warnings[0]; + expect(typeof notice).toBe('object'); + if (typeof notice !== 'object' || notice === null) return; + expect(notice.severity).toBe('error'); + expect(notice.title).toBe(i18n.global.t('warnings.agentError.rateLimit')); + expect(notice.message).toBe('Rate limit reached for requests. Please try again later.'); + const byLabel = new Map(notice.details?.map((d) => [d.label, d.value])); + expect(byLabel.get(i18n.global.t('warnings.details.code'))).toBe('provider.rate_limit'); + expect(byLabel.get(i18n.global.t('warnings.details.status'))).toBe('429'); + expect(byLabel.get(i18n.global.t('warnings.details.requestId'))).toBe('req_1'); + expect(byLabel.get(i18n.global.t('warnings.details.errorName'))).toBe('RateLimitError'); + }); + + it('keeps extra detail fields such as finishReason visible', () => { + const next = reduceRaw({ + _agentError: true, + code: 'provider.filtered', + message: 'Provider filtered the response', + details: { finishReason: 'filtered', rawFinishReason: 'content_filter' }, + }); + const notice = next.warnings[0]; + if (typeof notice !== 'object' || notice === null) throw new Error('expected notice'); + const values = notice.details?.map((d) => d.value) ?? []; + expect(values).toContain('filtered'); + expect(values).toContain('content_filter'); + }); + + it('shows a connection failure without status/requestId rows', () => { + const next = reduceRaw({ + _agentError: true, + code: 'provider.connection_error', + message: 'Connection error.', + }); + const notice = next.warnings[0]; + if (typeof notice !== 'object' || notice === null) throw new Error('expected notice'); + expect(notice.title).toBe(i18n.global.t('warnings.agentError.connection')); + expect(notice.message).toBe('Connection error.'); + expect(notice.details?.map((d) => d.value)).toEqual(['provider.connection_error']); + }); + + it('falls back to the generic title for unmapped or missing codes', () => { + for (const code of ['internal', undefined]) { + const next = reduceRaw({ _agentError: true, code, message: 'boom' }); + const notice = next.warnings[0]; + if (typeof notice !== 'object' || notice === null) throw new Error('expected notice'); + expect(notice.title).toBe(i18n.global.t('warnings.agentError.title')); + expect(notice.message).toBe('boom'); + } + }); + + it('still renders agent warnings as plain strings', () => { + const next = reduceRaw({ _agentWarning: true, message: 'heads up' }); + expect(next.warnings[0]).toBe(`${i18n.global.t('warnings.noteLabel')}: heads up`); + }); +}); diff --git a/apps/kimi-web/test/index-html.test.ts b/apps/kimi-web/test/index-html.test.ts new file mode 100644 index 000000000..860d8683d --- /dev/null +++ b/apps/kimi-web/test/index-html.test.ts @@ -0,0 +1,33 @@ +// apps/kimi-web/test/index-html.test.ts +// CSP regression guard: kap-server serves the built bundle with +// `Content-Security-Policy: default-src 'self'; …` (see securityHeaders.ts), +// which forbids inline scripts and inline event handlers. index.html must +// therefore stay free of both, and the anti-FOUC color-scheme bootstrap must +// load from the external /boot.js. + +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const indexHtml = readFileSync(fileURLToPath(new URL('../index.html', import.meta.url)), 'utf-8'); +const bootJsPath = fileURLToPath(new URL('../public/boot.js', import.meta.url)); + +describe('index.html CSP hygiene', () => { + it('has no '); + expect(existsSync(bootJsPath)).toBe(true); + }); +}); diff --git a/apps/kimi-web/test/input-history.test.ts b/apps/kimi-web/test/input-history.test.ts new file mode 100644 index 000000000..e0e6b77c9 --- /dev/null +++ b/apps/kimi-web/test/input-history.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ref, type Ref } from 'vue'; +import { useInputHistory } from '../src/composables/useInputHistory'; +import { STORAGE_KEYS } from '../src/lib/storage'; + +interface MockTextarea { + value: string; + selectionStart: number; + selectionEnd: number; + setSelectionRange: (start: number, end: number) => void; +} + +function setup(initialText = '', caret = 0, sessionId: string | null = 'test-session') { + const textarea: MockTextarea = { + value: initialText, + selectionStart: caret, + selectionEnd: caret, + setSelectionRange(start: number, end: number) { + this.selectionStart = start; + this.selectionEnd = end; + }, + }; + const text = ref(initialText); + const textareaRef = ref(textarea as unknown as HTMLTextAreaElement) as Ref; + const history = useInputHistory({ text, textareaRef, autosize: () => {}, sessionId: () => sessionId ?? undefined }); + return { text, textarea, history }; +} + +describe('useInputHistory — push', () => { + it('ignores empty or whitespace-only entries', () => { + const { history } = setup(); + history.push(''); + history.push(' '); + expect(history.hasHistory()).toBe(false); + }); + + it('appends distinct entries newest-last', () => { + const { history } = setup(); + history.push('a'); + history.push('b'); + history.push('c'); + expect(history.hasHistory()).toBe(true); + }); + + it('skips a consecutive duplicate', () => { + const { text, history } = setup(); + history.push('a'); + history.push('a'); // duplicate of the newest entry — must be dropped + history.push('b'); + history.recallOlder(); // -> b + expect(text.value).toBe('b'); + history.recallOlder(); // -> a (only one 'a' was kept) + expect(text.value).toBe('a'); + history.recallOlder(); // already oldest — must stay, not land on a second 'a' + expect(text.value).toBe('a'); + }); + + it('drops entries pushed without a session (draft / empty composer)', () => { + const { history } = setup('', 0, null); + history.push('hello'); + expect(history.hasHistory()).toBe(false); + }); +}); + +describe('useInputHistory — recall', () => { + it('walks backward from the most recent entry, then restores the live draft', () => { + const { text, history } = setup('draft'); + history.push('a'); + history.push('b'); + history.push('c'); + + expect(history.isBrowsing()).toBe(false); + history.recallOlder(); // -> c + expect(text.value).toBe('c'); + expect(history.isBrowsing()).toBe(true); + history.recallOlder(); // -> b + expect(text.value).toBe('b'); + history.recallOlder(); // -> a + expect(text.value).toBe('a'); + history.recallOlder(); // already oldest, stay + expect(text.value).toBe('a'); + + history.recallNewer(); // -> b + expect(text.value).toBe('b'); + history.recallNewer(); // -> c + expect(text.value).toBe('c'); + history.recallNewer(); // -> back to the live draft + expect(text.value).toBe('draft'); + expect(history.isBrowsing()).toBe(false); + }); + + it('restores an empty live draft after recalling the single newest entry', () => { + const { text, history } = setup(''); + history.push('only'); + history.recallOlder(); + expect(text.value).toBe('only'); + history.recallNewer(); + expect(text.value).toBe(''); + }); + + it('does nothing when recalling with an empty history', () => { + const { text, history } = setup('draft'); + history.recallOlder(); + history.recallNewer(); + expect(text.value).toBe('draft'); + expect(history.isBrowsing()).toBe(false); + }); + + it('resetBrowsing drops out of history mode without changing text', () => { + const { text, history } = setup('draft'); + history.push('a'); + history.recallOlder(); + expect(history.isBrowsing()).toBe(true); + history.resetBrowsing(); + expect(history.isBrowsing()).toBe(false); + expect(text.value).toBe('a'); // the recalled entry stays as the editable text + }); +}); + +describe('useInputHistory — caretAtTextStart', () => { + it('is true at the very start of the text', () => { + const { textarea, history } = setup('hello\nworld', 0); + textarea.value = 'hello\nworld'; + expect(history.caretAtTextStart()).toBe(true); + }); + + it('is false when the caret is on the first line but not at the start', () => { + const { textarea, history } = setup('hello\nworld', 3); + textarea.value = 'hello\nworld'; + expect(history.caretAtTextStart()).toBe(false); + }); + + it('is false once the caret is past the first newline', () => { + const { textarea, history } = setup('hello\nworld', 8); + textarea.value = 'hello\nworld'; + expect(history.caretAtTextStart()).toBe(false); + }); + + it('is true for an empty composer', () => { + const { history } = setup('', 0); + expect(history.caretAtTextStart()).toBe(true); + }); +}); + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear: () => { + map.clear(); + }, + getItem: (key: string) => map.get(key) ?? null, + key: (index: number) => Array.from(map.keys())[index] ?? null, + removeItem: (key: string) => { + map.delete(key); + }, + setItem: (key: string, value: string) => { + map.set(key, value); + }, + }; +} + +describe('useInputHistory — persistence', () => { + let original: Storage | undefined; + + beforeEach(() => { + original = (globalThis as { localStorage?: Storage }).localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: memoryStorage(), + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + if (original === undefined) { + delete (globalThis as { localStorage?: Storage }).localStorage; + } else { + Object.defineProperty(globalThis, 'localStorage', { + value: original, + configurable: true, + writable: true, + }); + } + }); + + it('writes each pushed entry to localStorage under its session', () => { + const { history } = setup(); + history.push('hello'); + const stored = globalThis.localStorage.getItem(STORAGE_KEYS.inputHistory); + expect(stored).toBe(JSON.stringify({ 'test-session': ['hello'] })); + }); + + it('a freshly mounted composable reads back the persisted history', () => { + const first = setup(); + first.history.push('a'); + first.history.push('b'); + + // Simulates the empty composer unmounting and the docked composer mounting. + const second = setup(); + second.history.recallOlder(); + expect(second.text.value).toBe('b'); + second.history.recallOlder(); + expect(second.text.value).toBe('a'); + }); + + it('keeps histories of different sessions isolated', () => { + const a = setup('', 0, 'sess-a'); + a.history.push('from-a'); + const b = setup('', 0, 'sess-b'); + b.history.push('from-b'); + + // Re-mount each session and confirm each only recalls its own entry. + const a2 = setup('', 0, 'sess-a'); + a2.history.recallOlder(); + expect(a2.text.value).toBe('from-a'); + a2.history.recallOlder(); // no older entry — must stay + expect(a2.text.value).toBe('from-a'); + + const b2 = setup('', 0, 'sess-b'); + b2.history.recallOlder(); + expect(b2.text.value).toBe('from-b'); + }); + + it('trims to the newest 100 entries, dropping the oldest', () => { + const { text, history } = setup(); + for (let i = 0; i < 105; i++) history.push(`m${i}`); + + // Walk all the way back; the oldest kept entry must be m5 (m0..m4 dropped). + for (let i = 0; i < 100; i++) history.recallOlder(); + expect(text.value).toBe('m5'); + history.recallOlder(); // already at the oldest kept entry — must not move + expect(text.value).toBe('m5'); + }); + + it('ignores a malformed stored value and starts empty', () => { + globalThis.localStorage.setItem(STORAGE_KEYS.inputHistory, 'not-json'); + const { history } = setup(); + expect(history.hasHistory()).toBe(false); + }); + + it('migrates a legacy global array into the current session once', () => { + globalThis.localStorage.setItem(STORAGE_KEYS.inputHistory, JSON.stringify(['old1', 'old2'])); + const { text, history } = setup('', 0, 'sess-x'); + history.recallOlder(); // -> old2 + expect(text.value).toBe('old2'); + history.recallOlder(); // -> old1 + expect(text.value).toBe('old1'); + // Persisted in the new map format under the current session. + const stored = JSON.parse(globalThis.localStorage.getItem(STORAGE_KEYS.inputHistory)!); + expect(stored).toEqual({ 'sess-x': ['old1', 'old2'] }); + }); + + it('leaves the legacy array untouched when mounted without a session', () => { + globalThis.localStorage.setItem(STORAGE_KEYS.inputHistory, JSON.stringify(['old1'])); + const { history } = setup('', 0, null); + expect(history.hasHistory()).toBe(false); + // A later docked mount (with a session id) can still migrate it. + const { text, history: docked } = setup('', 0, 'sess-y'); + docked.recallOlder(); + expect(text.value).toBe('old1'); + }); +}); diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts new file mode 100644 index 000000000..36f3f8eda --- /dev/null +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -0,0 +1,865 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + collectFilePathAliases, + findFilePathLinks, + parseFilePathLinkCandidate, +} from '../src/lib/filePathLinks'; +import { parseDiff } from '../src/lib/parseDiff'; +import { buildDiffLines } from '../src/lib/diffLines'; +import { buildEditDiffLines } from '../src/lib/toolDiff'; +import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync'; +import { mergeSnapshotMessages } from '../src/lib/snapshotMessages'; +import { keepLiveSubagents, mergeSnapshotSubagents } from '../src/lib/taskMerge'; +import { normalizeToolName, toolSummary } from '../src/lib/toolMeta'; +import { collapsePrompt, humanizeCron } from '../src/lib/cronHumanize'; +import { + currentValidatedWorkspacePath, + isWorkspacePathInput, + joinWorkspacePathCandidate, + parseWorkspacePathInput, +} from '../src/lib/workspacePathInput'; +import { + commitLevel, + defaultThinkingLevelFor, + effortLabel, + modelThinkingAvailability, + segmentsFor, +} from '../src/lib/modelThinking'; +import type { AppMessage, AppModel, AppTask } from '../src/api/types'; +import { resolveToolRenderer } from '../src/components/chat/tool-calls/toolRegistry'; +import AgentTool from '../src/components/chat/tool-calls/AgentTool.vue'; +import EditTool from '../src/components/chat/tool-calls/EditTool.vue'; +import GenericTool from '../src/components/chat/tool-calls/GenericTool.vue'; +import type { ToolCall } from '../src/types'; +import { + clearTrace, + installClientErrorCapture, + sanitizeForTrace, + sessionExportTraceToJsonl, + traceEntries, + traceKeyEvent, + tracePaused, + traceRestRequest, + traceToJsonl, + traceWsIn, +} from '../src/debug/trace'; + +// The trace tests exercise its exported recording/serialization contract: +// session exports receive only bounded, explicitly selected metadata. + +describe('bounded Web trace', () => { + beforeEach(() => { + tracePaused.value = false; + clearTrace(); + }); + + afterEach(() => { + clearTrace(); + vi.unstubAllGlobals(); + }); + + it('copies only allowlisted key-path metadata into the independent export ring', () => { + const secret = 'PROMPT_TEXT_MUST_NOT_BE_EXPORTED'; + const metadata = { + sessionId: 'sess_1', + contentCount: 2, + mediaCount: 1, + text: secret, + apiKey: secret, + }; + traceKeyEvent('prompt:start', metadata); + + metadata.sessionId = 'changed_after_recording'; + + expect(traceEntries()).toHaveLength(1); + expect(JSON.parse(sessionExportTraceToJsonl())).toEqual({ + ts: expect.any(Number), + event: 'prompt:start', + sessionId: 'sess_1', + contentCount: 2, + mediaCount: 1, + }); + expect(sessionExportTraceToJsonl()).not.toContain(secret); + }); + + it('records export metadata even while the full debug panel is paused', () => { + tracePaused.value = true; + + traceKeyEvent('ws:connection', { status: 'connected' }); + + expect(traceEntries()).toHaveLength(0); + expect(JSON.parse(sessionExportTraceToJsonl())).toMatchObject({ + event: 'ws:connection', + status: 'connected', + }); + }); + + it('caps object keys and reports how many were omitted', () => { + const input = Object.fromEntries(Array.from({ length: 60 }, (_, index) => [`key${index}`, index])); + + const result = sanitizeForTrace(input) as Record; + + expect(result['_truncatedKeys']).toBe(10); + expect(Object.keys(result)).toHaveLength(51); + }); + + it('keeps at most 500 of the newest export entries', () => { + for (let index = 0; index < 501; index++) { + traceKeyEvent('ws:connection', { status: String(index) }); + } + + const exported = sessionExportTraceToJsonl().split('\n').map((line) => JSON.parse(line)); + expect(exported).toHaveLength(500); + expect(exported[0]).toMatchObject({ status: '1' }); + expect(exported.at(-1)).toMatchObject({ status: '500' }); + }); + + it('keeps export JSONL within the 256 KiB UTF-8 budget including newlines', () => { + for (let index = 0; index < 500; index++) { + traceKeyEvent('export:failed', { + sessionId: `sess-${index}-${'😀'.repeat(200)}`, + status: '😀'.repeat(200), + promptId: '😀'.repeat(200), + errorName: '😀'.repeat(200), + requestId: '😀'.repeat(200), + phase: '😀'.repeat(200), + }); + } + + const jsonl = sessionExportTraceToJsonl(); + expect(new TextEncoder().encode(jsonl).byteLength).toBeLessThanOrEqual(256 * 1024); + expect(JSON.parse(jsonl.split('\n').at(-1)!)).toMatchObject({ + sessionId: expect.stringMatching(/^sess-499-/), + }); + }); + + it('never copies prompt, WebSocket, or console content from the full debug trace', () => { + vi.stubGlobal('location', { search: '?debug=1' }); + const promptSecret = 'PROMPT_SECRET_9fdb1a'; + const wsSecret = 'WS_PAYLOAD_SECRET_b84c7e'; + const consoleSecret = 'CONSOLE_SECRET_a2d693'; + + traceRestRequest({ + method: 'POST', + path: '/sessions/sess_1/prompts', + url: 'http://daemon.test/api/v1/sessions/sess_1/prompts', + requestId: 'req_1', + body: { prompt: promptSecret }, + }); + traceWsIn({ + type: 'event', + session_id: 'sess_1', + seq: 4, + payload: { text: wsSecret }, + }); + + const originalLog = console.log; + console.log = vi.fn(); + const dispose = installClientErrorCapture(); + try { + console.log(consoleSecret, { value: consoleSecret }); + } finally { + dispose(); + console.log = originalLog; + } + + traceKeyEvent('prompt:start', { + sessionId: 'sess_1', + contentCount: 1, + mediaCount: 0, + text: promptSecret, + }); + + const fullDebugTrace = traceToJsonl(); + expect(fullDebugTrace).toContain(promptSecret); + expect(fullDebugTrace).toContain(wsSecret); + expect(fullDebugTrace).toContain(consoleSecret); + + const sessionExportTrace = sessionExportTraceToJsonl(); + expect(sessionExportTrace).not.toContain(promptSecret); + expect(sessionExportTrace).not.toContain(wsSecret); + expect(sessionExportTrace).not.toContain(consoleSecret); + expect(JSON.parse(sessionExportTrace)).toEqual({ + ts: expect.any(Number), + event: 'prompt:start', + sessionId: 'sess_1', + contentCount: 1, + mediaCount: 0, + }); + }); +}); + +describe('workspace path input', () => { + it('recognizes the supported absolute path forms', () => { + expect(isWorkspacePathInput('/tmp/project')).toBe(true); + expect(isWorkspacePathInput('~/project')).toBe(true); + expect(isWorkspacePathInput('C:\\project')).toBe(true); + expect(isWorkspacePathInput('C:/project')).toBe(true); + expect(isWorkspacePathInput('\\\\server\\share')).toBe(true); + expect(isWorkspacePathInput('project')).toBe(false); + }); + + it('normalizes separators without changing UNC roots or POSIX backslashes', () => { + expect(parseWorkspacePathInput('/tmp//project/', '').target).toBe('/tmp/project'); + expect(parseWorkspacePathInput('//server//share/project/', '').target).toBe('//server/share/project'); + expect(parseWorkspacePathInput('///tmp//project/', '').target).toBe('/tmp/project'); + expect(parseWorkspacePathInput('/tmp/project\\', '').target).toBe('/tmp/project\\'); + expect(parseWorkspacePathInput('~/project', '/home/alice').target).toBe('/home/alice/project'); + }); + + it('preserves Windows root separators in parent paths', () => { + expect(parseWorkspacePathInput('C:\\Use', '')).toMatchObject({ + parent: 'C:\\', + base: 'Use', + separator: '\\', + }); + expect(parseWorkspacePathInput('C:/Use', '')).toMatchObject({ + parent: 'C:/', + base: 'Use', + separator: '/', + }); + expect(parseWorkspacePathInput('\\\\server\\share\\pro', '')).toMatchObject({ + parent: '\\\\server\\share', + base: 'pro', + separator: '\\', + }); + expect(parseWorkspacePathInput('//server/share/pro', '')).toMatchObject({ + parent: '//server/share', + base: 'pro', + separator: '/', + }); + }); + + it('treats backslashes as literal characters in POSIX paths', () => { + expect(parseWorkspacePathInput('/tmp/foo\\bar', '')).toMatchObject({ + parent: '/tmp', + base: 'foo\\bar', + separator: '/', + }); + }); + + it('builds completion paths from the lexical parent', () => { + const parsed = parseWorkspacePathInput('/tmp/link/proje', ''); + expect(joinWorkspacePathCandidate(parsed.parent, 'project', parsed.separator)).toBe('/tmp/link/project'); + }); + + it('only returns a validated path while it still matches the current input', () => { + expect(currentValidatedWorkspacePath('/var', '', '/tmp')).toBeNull(); + expect(currentValidatedWorkspacePath('/tmp/', '', '/tmp')).toBe('/tmp'); + }); +}); + +describe('parseDiff', () => { + it('parses multiple files and keeps hunk line numbers', () => { + const diff = [ + 'diff --git a/src/a.ts b/src/a.ts', + 'index 1111111..2222222 100644', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1,2 +1,3 @@', + ' const a = 1;', + '-const b = 2;', + '+const b = 3;', + '+const c = 4;', + 'diff --git a/src/comment.sql b/src/comment.sql', + '@@ -5,1 +5,1 @@', + '--- old comment', + '+++ new comment', + ].join('\n'); + + expect(parseDiff(diff)).toEqual([ + { type: 'hunk', text: '@@ -1,2 +1,3 @@' }, + { type: 'context', text: 'const a = 1;', oldNo: 1, newNo: 1 }, + { type: 'del', text: 'const b = 2;', oldNo: 2 }, + { type: 'add', text: 'const b = 3;', newNo: 2 }, + { type: 'add', text: 'const c = 4;', newNo: 3 }, + { type: 'hunk', text: '@@ -5,1 +5,1 @@' }, + { type: 'del', text: '-- old comment', oldNo: 5 }, + { type: 'add', text: '++ new comment', newNo: 5 }, + ]); + }); +}); + +describe('buildDiffLines', () => { + it('lines up context, deletions and additions with old/new line numbers', () => { + const before = 'a\nb\nc'; + const after = 'a\nB\nc\nd'; + expect(buildDiffLines(before, after)).toEqual([ + { type: 'context', text: 'a', oldNo: 1, newNo: 1 }, + { type: 'del', text: 'b', oldNo: 2 }, + { type: 'add', text: 'B', newNo: 2 }, + { type: 'context', text: 'c', oldNo: 3, newNo: 3 }, + { type: 'add', text: 'd', newNo: 4 }, + ]); + }); + + it('treats an empty before as an all-addition write', () => { + expect(buildDiffLines('', 'x\ny')).toEqual([ + { type: 'add', text: 'x', newNo: 1 }, + { type: 'add', text: 'y', newNo: 2 }, + ]); + }); + + it('returns all context for identical texts and empty for two empties', () => { + expect(buildDiffLines('a\nb', 'a\nb')).toEqual([ + { type: 'context', text: 'a', oldNo: 1, newNo: 1 }, + { type: 'context', text: 'b', oldNo: 2, newNo: 2 }, + ]); + expect(buildDiffLines('', '')).toEqual([]); + }); + + it('returns null when the LCS matrix would be too large', () => { + const big = Array.from({ length: 2000 }, (_, i) => `line${i}`).join('\n'); + expect(buildDiffLines(big, `${big}\nextra`)).toBeNull(); + }); + + it('returns null when one side is huge even though the matrix is small', () => { + const huge = Array.from({ length: 6000 }, (_, i) => `line${i}`).join('\n'); + expect(buildDiffLines('one line', huge)).toBeNull(); + }); +}); + +describe('buildEditDiffLines', () => { + it('builds a diff for a single Edit', () => { + const arg = JSON.stringify({ path: 'a.ts', old_string: 'a\nb', new_string: 'a\nB' }); + expect(buildEditDiffLines({ name: 'Edit', arg })).toEqual([ + { type: 'context', text: 'a', oldNo: 1, newNo: 1 }, + { type: 'del', text: 'b', oldNo: 2 }, + { type: 'add', text: 'B', newNo: 2 }, + ]); + }); + + it('falls back to output for replace_all edits', () => { + const arg = JSON.stringify({ path: 'a.ts', old_string: 'a', new_string: 'b', replace_all: true }); + expect(buildEditDiffLines({ name: 'Edit', arg })).toBeNull(); + }); + + it('falls back to output for every Write (new file or overwrite)', () => { + expect(buildEditDiffLines({ name: 'Write', arg: JSON.stringify({ path: 'a.ts', content: 'x' }) })).toBeNull(); + expect( + buildEditDiffLines({ name: 'Write', arg: JSON.stringify({ path: 'a.ts', content: 'x', mode: 'append' }) }), + ).toBeNull(); + }); + + it('returns null for non-edit/write tools', () => { + expect(buildEditDiffLines({ name: 'Bash', arg: JSON.stringify({ command: 'ls' }) })).toBeNull(); + }); +}); + +describe('filePathLinks', () => { + it('rejects URLs and bare unknown filenames', () => { + expect(parseFilePathLinkCandidate('https://example.com/a.ts')).toBeNull(); + expect(parseFilePathLinkCandidate('e2e-success.png')).toBeNull(); + }); + + it('finds path links with line numbers and resolves aliases', () => { + const aliases = collectFilePathAliases(''); + expect(aliases.get('demo.png')).toBe('/assets/demo.png'); + + expect( + findFilePathLinks('Open src/a.ts#L12 and demo.png.', { aliases }), + ).toMatchObject([ + { path: 'src/a.ts', line: 12, text: 'src/a.ts#L12' }, + { path: '/assets/demo.png', text: 'demo.png' }, + ]); + }); +}); + +describe('toolMeta', () => { + it('normalizes common tool aliases', () => { + expect(normalizeToolName('WebFetch')).toBe('web_fetch'); + expect(normalizeToolName('MultiEdit')).toBe('multi_edit'); + expect(normalizeToolName('TodoWrite')).toBe('todo'); + expect(normalizeToolName('rg')).toBe('grep'); + }); + + it('summarizes tool arguments for card headers', () => { + expect( + toolSummary('Read', JSON.stringify({ path: 'src/a.ts', offset: 10, limit: 5 })), + ).toBe('src/a.ts:10-15'); + expect(toolSummary('Read', '{}')).toBe(''); + expect(toolSummary('Bash', JSON.stringify({ command: 'pnpm test' }))).toBe('pnpm test'); + expect( + toolSummary('WebFetch', JSON.stringify({ url: 'https://example.com/path/to' })), + ).toBe('example.com/path'); + }); +}); + +describe('resolveToolRenderer', () => { + // Minimal ToolCall factory — resolveToolRenderer only reads `name`, `status` + // and `media`, so the rest is filled with placeholders. + const tool = (name: string, status: ToolCall['status'] = 'running'): ToolCall => ({ + id: 't1', + name, + arg: '', + status, + }); + + // Regression: normalizeToolName() folds `agent`/`subagent` into the canonical + // `task` kind, so the renderer must match on `task`. If it matched on the raw + // `agent` string these calls would fall through to GenericTool and lose the + // inline "Open" button for the subagent detail panel. + it('routes Agent / subagent calls to the Agent renderer', () => { + expect(resolveToolRenderer(tool('agent'))).toBe(AgentTool); + expect(resolveToolRenderer(tool('Agent'))).toBe(AgentTool); + expect(resolveToolRenderer(tool('subagent'))).toBe(AgentTool); + expect(resolveToolRenderer(tool('task'))).toBe(AgentTool); + }); + + it('routes edit-like calls to the Edit renderer', () => { + expect(resolveToolRenderer(tool('edit'))).toBe(EditTool); + expect(resolveToolRenderer(tool('write'))).toBe(EditTool); + expect(resolveToolRenderer(tool('multi_edit'))).toBe(EditTool); + }); + + it('falls back to the Generic renderer for unknown tools', () => { + expect(resolveToolRenderer(tool('bash'))).toBe(GenericTool); + expect(resolveToolRenderer(tool('read'))).toBe(GenericTool); + }); +}); + +describe('createCoalescedAsyncRunner', () => { + it('reuses the in-flight promise for the same key', async () => { + let runs = 0; + let resolveRun!: () => void; + const runner = createCoalescedAsyncRunner(async (_key: string) => { + runs += 1; + await new Promise((resolve) => { + resolveRun = resolve; + }); + return runs; + }); + + const first = runner.run('session-a'); + const second = runner.run('session-a'); + + expect(runs).toBe(1); + resolveRun(); + await expect(Promise.all([first, second])).resolves.toEqual([1, 1]); + expect(runs).toBe(1); + }); + + it('queues at most one rerun requested while a run is in flight', async () => { + let runs = 0; + const resolvers: Array<() => void> = []; + const runner = createCoalescedAsyncRunner(async (_key: string) => { + runs += 1; + await new Promise((resolve) => { + resolvers.push(resolve); + }); + return runs; + }); + + const first = runner.run('session-a'); + runner.request('session-a'); + runner.request('session-a'); + expect(runs).toBe(1); + + resolvers[0]!(); + await first; + await Promise.resolve(); + + expect(runs).toBe(2); + resolvers[1]!(); + await Promise.resolve(); + expect(runs).toBe(2); + }); +}); + +describe('modelThinking', () => { + const effortModel = (over: Partial = {}): AppModel => ({ + id: 'k', + provider: 'p', + model: 'k', + maxContextSize: 1, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + ...over, + }); + const booleanModel = (capabilities: string[] = ['thinking']): AppModel => ({ + id: 'b', + provider: 'p', + model: 'b', + maxContextSize: 1, + capabilities, + }); + const unsupportedModel = (): AppModel => ({ + id: 'u', + provider: 'p', + model: 'u', + maxContextSize: 1, + capabilities: [], + }); + + describe('modelThinkingAvailability', () => { + it('toggle when model has thinking capability', () => { + expect(modelThinkingAvailability(booleanModel())).toBe('toggle'); + }); + it('always-on when model has always_thinking', () => { + expect(modelThinkingAvailability(booleanModel(['always_thinking']))).toBe('always-on'); + }); + it('unsupported when model lacks thinking capability', () => { + expect(modelThinkingAvailability(unsupportedModel())).toBe('unsupported'); + }); + it('toggle when adaptiveThinking is set', () => { + expect(modelThinkingAvailability({ ...unsupportedModel(), adaptiveThinking: true })).toBe('toggle'); + }); + }); + + describe('defaultThinkingLevelFor', () => { + it('effort model returns defaultEffort', () => { + expect(defaultThinkingLevelFor(effortModel())).toBe('high'); + }); + it('effort model without defaultEffort returns middle effort', () => { + expect(defaultThinkingLevelFor(effortModel({ defaultEffort: undefined }))).toBe('high'); + }); + it('boolean model returns on', () => { + expect(defaultThinkingLevelFor(booleanModel())).toBe('on'); + }); + it('unsupported model returns off', () => { + expect(defaultThinkingLevelFor(unsupportedModel())).toBe('off'); + }); + }); + + describe('segmentsFor', () => { + it('effort toggle → off + efforts (off left)', () => { + expect(segmentsFor(effortModel())).toEqual(['off', 'low', 'high', 'max']); + }); + it('effort always-on → efforts only (no off)', () => { + expect(segmentsFor(effortModel({ capabilities: ['thinking', 'always_thinking'] }))).toEqual([ + 'low', + 'high', + 'max', + ]); + }); + it('boolean toggle → on/off (on left)', () => { + expect(segmentsFor(booleanModel())).toEqual(['on', 'off']); + }); + it('boolean always-on → on', () => { + expect(segmentsFor(booleanModel(['always_thinking']))).toEqual(['on']); + }); + it('unsupported → off', () => { + expect(segmentsFor(unsupportedModel())).toEqual(['off']); + }); + }); + + describe('commitLevel', () => { + it('on normalizes to the model default', () => { + expect(commitLevel(effortModel(), 'on')).toBe('high'); + expect(commitLevel(booleanModel(), 'on')).toBe('on'); + }); + it('off stays off', () => { + expect(commitLevel(effortModel(), 'off')).toBe('off'); + }); + it('concrete effort passes through', () => { + expect(commitLevel(effortModel(), 'max')).toBe('max'); + }); + }); + + describe('effortLabel', () => { + it('capitalizes the first letter', () => { + expect(effortLabel('max')).toBe('Max'); + expect(effortLabel('off')).toBe('Off'); + expect(effortLabel('xhigh')).toBe('Xhigh'); + }); + }); +}); + +describe('humanizeCron', () => { + const dict: Record = { + 'conversation.cron.everyMinute': 'Every minute', + 'conversation.cron.everyNMinutes': 'Every {n} minutes', + 'conversation.cron.everyHour': 'Every hour', + 'conversation.cron.everyNHours': 'Every {n} hours', + 'conversation.cron.dailyAt': 'Daily at {time}', + 'conversation.cron.weekdaysAt': 'Weekdays at {time}', + }; + const t = (key: string, params?: Record): string => { + let s = dict[key] ?? key; + if (params) for (const [k, v] of Object.entries(params)) s = s.replace(`{${k}}`, String(v)); + return s; + }; + + it('labels the common cadences', () => { + expect(humanizeCron('* * * * *', t)).toBe('Every minute'); + expect(humanizeCron('*/5 * * * *', t)).toBe('Every 5 minutes'); + expect(humanizeCron('*/1 * * * *', t)).toBe('Every minute'); + expect(humanizeCron('0 * * * *', t)).toBe('Every hour'); + expect(humanizeCron('0 */2 * * *', t)).toBe('Every 2 hours'); + }); + + it('labels fixed daily and weekday times', () => { + expect(humanizeCron('5 9 * * *', t)).toBe('Daily at 9:05'); + expect(humanizeCron('0 9 * * 1-5', t)).toBe('Weekdays at 9:00'); + }); + + it('falls back to the raw expression for unrecognized shapes', () => { + expect(humanizeCron('0 9 1 * *', t)).toBe('0 9 1 * *'); + expect(humanizeCron('bad', t)).toBe('bad'); + }); +}); + +describe('collapsePrompt', () => { + it('keeps a short single-line prompt intact with no expand toggle', () => { + expect(collapsePrompt('Check the deploy status')).toEqual({ + text: 'Check the deploy status', + hasMore: false, + }); + }); + + it('truncates a long one-line prompt with an ellipsis and reports hasMore', () => { + const long = 'a'.repeat(150); + const result = collapsePrompt(long, 120); + expect(result.hasMore).toBe(true); + expect(result.text.length).toBeLessThan(long.length); + expect(result.text.endsWith('…')).toBe(true); + }); + + it('shows only the first line for a multi-line prompt', () => { + expect(collapsePrompt('first line\nsecond line\nthird line')).toEqual({ + text: 'first line', + hasMore: true, + }); + }); +}); + +describe('mergeSnapshotMessages', () => { + function msg(id: string, createdAt: string): AppMessage { + return { id, sessionId: 's1', role: 'assistant', content: [], createdAt }; + } + + it('keeps loaded messages older than the snapshot window', () => { + const loaded = [ + msg('old-1', '2026-01-01T00:00:00.000Z'), + msg('old-2', '2026-01-02T00:00:00.000Z'), + msg('recent-live', '2026-01-03T00:00:00.000Z'), + ]; + const snapshot = [ + msg('m0', '2026-01-03T00:00:00.000Z'), + msg('m1', '2026-01-04T00:00:00.000Z'), + ]; + expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual([ + 'old-1', + 'old-2', + 'm0', + 'm1', + ]); + }); + + it('returns the snapshot when there is no older loaded prefix', () => { + const loaded = [msg('recent-live', '2026-01-03T00:00:00.000Z')]; + const snapshot = [ + msg('m0', '2026-01-03T00:00:00.000Z'), + msg('m1', '2026-01-04T00:00:00.000Z'), + ]; + expect(mergeSnapshotMessages(loaded, snapshot)).toBe(snapshot); + }); + + it('returns the snapshot when either side is empty', () => { + const snapshot = [msg('m0', '2026-01-03T00:00:00.000Z')]; + expect(mergeSnapshotMessages([], snapshot)).toBe(snapshot); + expect(mergeSnapshotMessages(snapshot, [])).toEqual([]); + }); + + function optimisticUser(id: string, createdAt: string, text: string, promptId: string): AppMessage { + return { + id, + sessionId: 's1', + role: 'user', + content: [{ type: 'text', text }], + createdAt, + promptId, + metadata: { 'kimiWeb.optimisticUserMessage': true }, + }; + } + + function realUser(id: string, createdAt: string, text: string): AppMessage { + return { + id, + sessionId: 's1', + role: 'user', + content: [{ type: 'text', text }], + createdAt, + }; + } + + it('drops an optimistic user message when its promptId is the snapshot message id', () => { + const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'msg_9')]; + const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'hello')]; + expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_9']); + }); + + it('keeps an optimistic user message when a different snapshot message repeats its content', () => { + const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'msg_8')]; + const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'hello')]; + expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_opt_1', 'msg_9']); + }); +}); + +describe('mergeSnapshotSubagents', () => { + function subagent(id: string, overrides: Partial = {}): AppTask { + return { + id, + sessionId: 's1', + kind: 'subagent', + description: `task ${id}`, + busy: true, + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; + } + + it('seeds an empty store from the roster', () => { + const roster = [ + subagent('a1', { subagentPhase: 'working', swarmIndex: 0, parentToolCallId: 'call-1' }), + subagent('a2', { subagentPhase: 'queued', swarmIndex: 1, parentToolCallId: 'call-1' }), + ]; + expect(mergeSnapshotSubagents(roster, [])).toEqual(roster); + }); + + it('keeps reducer-owned accumulated output from an already-live task', () => { + const live = subagent('a1', { + subagentPhase: 'queued', + outputLines: ['line 1'], + text: 'partial answer', + }); + const roster = [subagent('a1', { subagentPhase: 'working' })]; + const [merged] = mergeSnapshotSubagents(roster, [live]); + // Roster is authoritative for identity/status/phase… + expect(merged?.subagentPhase).toBe('working'); + // …but the accumulated output survives the seed. + expect(merged?.outputLines).toEqual(['line 1']); + expect(merged?.text).toBe('partial answer'); + }); + + it('keeps tasks the roster does not know about', () => { + const background: AppTask = { + id: 'bash-1', + sessionId: 's1', + kind: 'bash', + description: 'npm test', + busy: true, + createdAt: '2026-01-01T00:00:00.000Z', + }; + const roster = [subagent('a1')]; + const merged = mergeSnapshotSubagents(roster, [background, subagent('a1')]); + expect(merged.map((t) => t.id)).toEqual(['a1', 'bash-1']); + }); + + it('returns the existing list untouched when the roster is empty', () => { + const existing = [subagent('a1')]; + expect(mergeSnapshotSubagents([], existing)).toBe(existing); + }); +}); + + +describe('keepLiveSubagents', () => { + function subagent(id: string, overrides: Partial = {}): AppTask { + return { + id, + sessionId: 's1', + kind: 'subagent', + description: `task ${id}`, + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; + } + + it('returns the REST list untouched when no live-only subagent exists', () => { + const rest = [subagent('a1')]; + expect(keepLiveSubagents(rest, [subagent('a1')])).toBe(rest); + }); + + it('keeps WS-only swarm subagents that REST omits', () => { + const rest: AppTask[] = []; + const merged = keepLiveSubagents(rest, [subagent('a1')]); + expect(merged.map((t) => t.id)).toEqual(['a1']); + }); + + it('folds a REST background-subagent row into the WS row keyed by agent id', () => { + // The same background subagent: WS keys it by agent id, REST by task id. + const live = subagent('agent-1', { + runInBackground: true, + backgroundTaskId: 'task-9', + outputLines: ['step 1'], + text: 'partial', + }); + const rest = [subagent('task-9', { runInBackground: true })]; + const merged = keepLiveSubagents(rest, [live]); + expect(merged).toHaveLength(1); + expect(merged[0]?.id).toBe('agent-1'); + expect(merged[0]?.outputLines).toEqual(['step 1']); + expect(merged[0]?.text).toBe('partial'); + expect(merged[0]?.backgroundTaskId).toBe('task-9'); + }); + + it('lets REST complete a live row whose finish event was missed', () => { + const live = subagent('agent-1', { + runInBackground: true, + backgroundTaskId: 'task-9', + subagentPhase: 'working', + }); + const rest = [ + subagent('task-9', { + runInBackground: true, + status: 'completed', + completedAt: '2026-01-01T00:01:00.000Z', + outputPreview: 'done', + }), + ]; + const [merged] = keepLiveSubagents(rest, [live]); + expect(merged?.status).toBe('completed'); + // The detail panel prefers subagentPhase over status — it must follow too. + expect(merged?.subagentPhase).toBe('completed'); + expect(merged?.completedAt).toBe('2026-01-01T00:01:00.000Z'); + expect(merged?.outputPreview).toBe('done'); + }); + + it('maps a REST-cancelled row to the failed phase (the enum has no cancelled)', () => { + const live = subagent('agent-1', { + runInBackground: true, + backgroundTaskId: 'task-9', + subagentPhase: 'working', + }); + const rest = [subagent('task-9', { runInBackground: true, status: 'cancelled' })]; + const [merged] = keepLiveSubagents(rest, [live]); + expect(merged?.status).toBe('cancelled'); + expect(merged?.subagentPhase).toBe('failed'); + }); + + it('never lets a lagging poll flip a finished row back to running', () => { + const live = subagent('agent-1', { + runInBackground: true, + backgroundTaskId: 'task-9', + status: 'completed', + completedAt: '2026-01-01T00:01:00.000Z', + }); + const rest = [subagent('task-9', { runInBackground: true, status: 'running' })]; + const [merged] = keepLiveSubagents(rest, [live]); + expect(merged?.status).toBe('completed'); + }); + + it('keeps newer REST output flowing into an already-folded row', () => { + // The live row carries a preview folded in by an earlier poll; the fresh + // REST row has the final persisted output and must win. + const live = subagent('agent-1', { + runInBackground: true, + backgroundTaskId: 'task-9', + outputPreview: 'stale tail', + outputBytes: 100, + }); + const rest = [ + subagent('task-9', { + runInBackground: true, + status: 'completed', + outputPreview: 'final result', + outputBytes: 200, + }), + ]; + const [merged] = keepLiveSubagents(rest, [live]); + expect(merged?.outputPreview).toBe('final result'); + expect(merged?.outputBytes).toBe(200); + }); +}); diff --git a/apps/kimi-web/test/mention-menu.test.ts b/apps/kimi-web/test/mention-menu.test.ts new file mode 100644 index 000000000..13006bca4 --- /dev/null +++ b/apps/kimi-web/test/mention-menu.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick, ref, type Ref } from 'vue'; +import { useMentionMenu } from '../src/composables/useMentionMenu'; +import type { FileItem } from '../src/types'; + +interface MockTextarea { + value: string; + selectionStart: number; + setSelectionRange: (start: number, end: number) => void; + focus: () => void; +} + +function setup(initialText = '', searchFiles?: (q: string) => Promise) { + const textarea: MockTextarea = { + value: initialText, + // Caret defaults to the end of the text. + selectionStart: initialText.length, + setSelectionRange(start: number) { + this.selectionStart = start; + }, + focus: () => {}, + }; + const text = ref(initialText); + const textareaRef = ref(textarea as unknown as HTMLTextAreaElement) as Ref; + const mention = useMentionMenu({ + text, + textareaRef, + autosize: () => {}, + searchFiles: () => searchFiles, + }); + return { text, textarea, mention }; +} + +describe('useMentionMenu — update', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('stays closed when there is no @token', async () => { + const searchFiles = vi.fn().mockResolvedValue([]); + const { mention } = setup('hello', searchFiles); + mention.update(); + await vi.advanceTimersByTimeAsync(200); + expect(mention.open.value).toBe(false); + expect(searchFiles).not.toHaveBeenCalled(); + }); + + it('stays closed when searchFiles is not provided', async () => { + const { mention } = setup('@a'); + mention.update(); + await vi.advanceTimersByTimeAsync(200); + expect(mention.open.value).toBe(false); + }); + + it('opens with search results after the debounce', async () => { + const searchFiles = vi.fn().mockResolvedValue([{ path: 'src/a.ts', name: 'a.ts' }]); + const { mention } = setup('@a', searchFiles); + mention.update(); + expect(mention.open.value).toBe(false); // debounced, not yet + await vi.advanceTimersByTimeAsync(200); + expect(searchFiles).toHaveBeenCalledWith('a'); + expect(mention.open.value).toBe(true); + expect(mention.items.value).toEqual([{ path: 'src/a.ts', name: 'a.ts' }]); + expect(mention.loading.value).toBe(false); + expect(mention.active.value).toBe(0); + }); + + it('clears items and stops loading when the search throws', async () => { + const searchFiles = vi.fn().mockRejectedValue(new Error('boom')); + const { mention } = setup('@a', searchFiles); + mention.update(); + await vi.advanceTimersByTimeAsync(200); + expect(mention.items.value).toEqual([]); + expect(mention.loading.value).toBe(false); + }); +}); + +describe('useMentionMenu — select', () => { + it('replaces the @token with the chosen path', async () => { + const { text, textarea, mention } = setup('hello @a'); + textarea.value = 'hello @a'; + mention.select({ path: 'src/a.ts', name: 'a.ts' }); + expect(text.value).toBe('hello src/a.ts'); + expect(mention.open.value).toBe(false); + await nextTick(); + }); + + it('is a no-op when there is no @token', () => { + const { text, mention } = setup('hello'); + mention.select({ path: 'src/a.ts', name: 'a.ts' }); + expect(text.value).toBe('hello'); + }); +}); diff --git a/apps/kimi-web/test/notification-logic.test.ts b/apps/kimi-web/test/notification-logic.test.ts new file mode 100644 index 000000000..5b13de8fc --- /dev/null +++ b/apps/kimi-web/test/notification-logic.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { i18n } from '../src/i18n'; +import { STORAGE_KEYS, safeGetString } from '../src/lib/storage'; +import { + approvalNotificationCopy, + completionNotificationCopy, + questionNotificationCopy, + shouldNotifyCompletion, + useNotification, +} from '../src/composables/client/useNotification'; + +function createMemoryStorage(): Storage { + const data = new Map(); + return { + get length() { + return data.size; + }, + clear() { + data.clear(); + }, + getItem(key: string) { + return data.get(key) ?? null; + }, + key(index: number) { + return Array.from(data.keys()).at(index) ?? null; + }, + removeItem(key: string) { + data.delete(key); + }, + setItem(key: string, value: string) { + data.set(key, value); + }, + }; +} + +function installStorage(storage: Storage): void { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: storage, + }); +} + +// Singleton — module-level refs + setters. The OS Notification API is absent in +// the test env, so the *enable* path is a no-op; the disable path and the +// load-from-storage defaults are what we exercise here. +const { + notifyOnComplete, + notifyOnQuestion, + notifyOnApproval, + setNotifyOnComplete, + setNotifyOnQuestion, + setNotifyOnApproval, +} = useNotification(); +const importedCompleteDefault = notifyOnComplete.value; +const importedQuestionDefault = notifyOnQuestion.value; +const importedApprovalDefault = notifyOnApproval.value; + +describe('useNotification preferences', () => { + beforeEach(() => { + installStorage(createMemoryStorage()); + }); + + afterEach(() => { + installStorage(createMemoryStorage()); + }); + + it('completion notifications default to on', () => { + expect(importedCompleteDefault).toBe(true); + }); + + it('question notifications default to off so question text stays behind an explicit opt-in', () => { + expect(importedQuestionDefault).toBe(false); + }); + + it('approval notifications default to off', () => { + expect(importedApprovalDefault).toBe(false); + }); + + it('disabling question notifications persists "0" and updates the ref', () => { + void setNotifyOnQuestion(false); + expect(notifyOnQuestion.value).toBe(false); + expect(safeGetString(STORAGE_KEYS.notifyOnQuestion)).toBe('0'); + }); + + it('disabling completion notifications persists "0" and updates the ref', () => { + void setNotifyOnComplete(false); + expect(notifyOnComplete.value).toBe(false); + expect(safeGetString(STORAGE_KEYS.notifyOnComplete)).toBe('0'); + }); + + it('disabling approval notifications persists "0" and updates the ref', () => { + void setNotifyOnApproval(false); + expect(notifyOnApproval.value).toBe(false); + expect(safeGetString(STORAGE_KEYS.notifyOnApproval)).toBe('0'); + }); +}); + +describe('notification copy', () => { + beforeEach(() => { + i18n.global.locale.value = 'en'; + }); + + it('uses an event title and session-title body for completion notifications', () => { + expect(completionNotificationCopy('Refactor auth flow')).toEqual({ + title: 'Kimi Code · Turn finished', + body: 'Refactor auth flow', + }); + }); + + it('falls back to a result hint when there is no session title', () => { + expect(completionNotificationCopy(' ')).toEqual({ + title: 'Kimi Code · Turn finished', + body: 'View result', + }); + }); + + it('prefers the question preview in question notifications', () => { + expect(questionNotificationCopy('Storage migration', 'Which database?')).toEqual({ + title: 'Kimi Code · Needs answer', + body: 'Which database?', + }); + }); + + it('falls back to the session title before the generic question line', () => { + expect(questionNotificationCopy('Storage migration', ' ')).toEqual({ + title: 'Kimi Code · Needs answer', + body: 'Storage migration', + }); + }); + + it('uses tool name in approval notifications', () => { + expect(approvalNotificationCopy('Refactor auth flow', 'bash')).toEqual({ + title: 'Kimi Code · Approval required', + body: 'bash', + }); + }); + + it('falls back to session title and then generic approval line', () => { + expect(approvalNotificationCopy('Refactor auth flow', ' ')).toEqual({ + title: 'Kimi Code · Approval required', + body: 'Refactor auth flow', + }); + expect(approvalNotificationCopy(' ', ' ')).toEqual({ + title: 'Kimi Code · Approval required', + body: 'A tool needs your approval', + }); + }); + + it('localizes approval notification copy', () => { + i18n.global.locale.value = 'zh'; + expect(approvalNotificationCopy('', '')).toEqual({ + title: 'Kimi Code · 等待审批', + body: '有工具等待你审批', + }); + }); + + it('localizes the notification copy', () => { + i18n.global.locale.value = 'zh'; + + expect(completionNotificationCopy('')).toEqual({ + title: 'Kimi Code · 回合完成', + body: '点击查看结果', + }); + expect(questionNotificationCopy('', '')).toEqual({ + title: 'Kimi Code · 待回答', + body: '有提问等待你回答', + }); + }); +}); + +describe('shouldNotifyCompletion', () => { + it('returns true only for idle + no pending approval + no pending question', () => { + expect(shouldNotifyCompletion('idle', false, false)).toBe(true); + }); + + it('returns false for aborted', () => { + expect(shouldNotifyCompletion('aborted', false, false)).toBe(false); + }); + + it('returns false when pending approval exists', () => { + expect(shouldNotifyCompletion('idle', true, false)).toBe(false); + }); + + it('returns false when pending question exists', () => { + expect(shouldNotifyCompletion('idle', false, true)).toBe(false); + }); +}); + +// Same-tag notifications replace silently (renotify is unreliable), so the tag +// must be unique per turn/request for follow-up alerts in a session to pop. +describe('notification tags', () => { + class FakeNotification { + static permission = 'granted'; + static fired: Array<{ title: string; tag?: string }> = []; + onclick: (() => void) | null = null; + constructor(title: string, options?: { body?: string; tag?: string; icon?: string }) { + FakeNotification.fired.push({ title, tag: options?.tag }); + } + close(): void {} + } + + const { maybeNotifyCompletion, maybeNotifyQuestion, maybeNotifyApproval } = useNotification(); + const base = { isUserWatching: false, sessionTitle: 'T', onClick: () => {} }; + + beforeEach(() => { + FakeNotification.fired = []; + (globalThis as Record).Notification = FakeNotification; + notifyOnComplete.value = true; + notifyOnQuestion.value = true; + notifyOnApproval.value = true; + }); + + afterEach(() => { + delete (globalThis as Record).Notification; + notifyOnComplete.value = true; + notifyOnQuestion.value = false; + notifyOnApproval.value = false; + }); + + it('completion tags carry the prompt id so each turn in a session alerts', () => { + maybeNotifyCompletion('s1', { ...base, promptId: 'p1' }); + maybeNotifyCompletion('s1', { ...base, promptId: 'p2' }); + expect(FakeNotification.fired.map((f) => f.tag)).toEqual([ + 'kimi-complete-s1-p1', + 'kimi-complete-s1-p2', + ]); + }); + + it('a replayed idle event for the same turn keeps the same tag', () => { + maybeNotifyCompletion('s1', { ...base, promptId: 'p1' }); + maybeNotifyCompletion('s1', { ...base, promptId: 'p1' }); + expect(FakeNotification.fired).toHaveLength(2); + expect(FakeNotification.fired[0]?.tag).toBe(FakeNotification.fired[1]?.tag); + }); + + it('question and approval tags are per-request', () => { + maybeNotifyQuestion({ ...base, questionPreview: 'q', questionId: 'q1' }); + maybeNotifyApproval({ ...base, toolName: 'bash', approvalId: 'a1' }); + expect(FakeNotification.fired.map((f) => f.tag)).toEqual([ + 'kimi-question-q1', + 'kimi-approval-a1', + ]); + }); + + it('suppresses the notification while the user is watching the session', () => { + maybeNotifyCompletion('s1', { ...base, isUserWatching: true, promptId: 'p1' }); + expect(FakeNotification.fired).toHaveLength(0); + }); +}); diff --git a/apps/kimi-web/test/open-file-attachment.test.ts b/apps/kimi-web/test/open-file-attachment.test.ts new file mode 100644 index 000000000..515b8a64b --- /dev/null +++ b/apps/kimi-web/test/open-file-attachment.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { openFileAttachment } from '../src/lib/openFileAttachment'; + +// The module under test reads the daemon API client; stub the file-byte fetch. +const mocks = vi.hoisted(() => ({ getFileBlob: vi.fn() })); +vi.mock('../src/api', () => ({ + getKimiWebApi: () => ({ getFileBlob: mocks.getFileBlob }), +})); + +interface WinHandle { + location: { href: string }; + close: ReturnType; + opener: unknown; +} + +describe('openFileAttachment', () => { + let win: WinHandle; + let windowOpen: ReturnType; + let createObjectURL: ReturnType; + + beforeEach(() => { + win = { location: { href: '' }, close: vi.fn(), opener: {} }; + windowOpen = vi.fn().mockReturnValue(win); + createObjectURL = vi.fn().mockReturnValue('blob:mock-url'); + (globalThis as { window?: unknown }).window = { open: windowOpen }; + (globalThis.URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL; + (globalThis.URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = vi.fn(); + mocks.getFileBlob.mockResolvedValue(new Blob(['

x

'], { type: 'text/html' })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete (globalThis as { window?: unknown }).window; + }); + + /** The MIME of the blob handed to createObjectURL most recently. */ + function previewedBlobType(): string { + const blob = createObjectURL.mock.calls.at(-1)?.[0] as Blob; + return blob.type; + } + + it('refuses to preview text/html — an active document would run same-origin', async () => { + const result = await openFileAttachment('f_1', 'page.html', 'text/html'); + expect(result).toBe('unsupported'); + expect(windowOpen).not.toHaveBeenCalled(); + }); + + it('refuses to preview image/svg+xml — SVG carries script when navigated', async () => { + const result = await openFileAttachment('f_1', 'vector.svg', 'image/svg+xml'); + expect(result).toBe('unsupported'); + expect(windowOpen).not.toHaveBeenCalled(); + }); + + it('refuses a .html extension even with an empty recorded MIME', async () => { + const result = await openFileAttachment('f_1', 'page.html', ''); + expect(result).toBe('unsupported'); + }); + + it('renders script source inert: text/* is pinned to text/plain, never executed', async () => { + const result = await openFileAttachment('f_1', 'a.js', 'text/javascript'); + expect(result).toBe('previewed'); + expect(previewedBlobType()).toBe('text/plain;charset=utf-8'); + }); + + it('refuses non-text xml types outright', async () => { + expect(await openFileAttachment('f_1', 'a.xml', 'application/xml')).toBe('unsupported'); + expect(await openFileAttachment('f_1', 'a.xhtml', 'application/xhtml+xml')).toBe('unsupported'); + }); + + it('refuses an extensionless file with no usable MIME', async () => { + expect(await openFileAttachment('f_1', 'Makefile', '')).toBe('unsupported'); + }); + + it('previews pdf / safe images / media with their whitelisted MIME', async () => { + expect(await openFileAttachment('f_1', 'a.pdf', 'application/pdf')).toBe('previewed'); + expect(previewedBlobType()).toBe('application/pdf'); + expect(await openFileAttachment('f_1', 'a.png', 'image/png')).toBe('previewed'); + expect(previewedBlobType()).toBe('image/png'); + expect(await openFileAttachment('f_1', 'a.mp4', 'video/mp4')).toBe('previewed'); + expect(previewedBlobType()).toBe('video/mp4'); + }); + + it('previews text but pins the blob to text/plain so it renders inert', async () => { + // A .html file uploaded with a plain-text label must NOT regain its active + // type — the blob is re-wrapped, never trusting the recorded content-type. + const result = await openFileAttachment('f_1', 'evil.html', 'text/plain'); + expect(result).toBe('previewed'); + expect(previewedBlobType()).toBe('text/plain;charset=utf-8'); + }); + + it('previews text-ish extensions with an empty MIME as text/plain', async () => { + const result = await openFileAttachment('f_1', 'notes.md', ''); + expect(result).toBe('previewed'); + expect(previewedBlobType()).toBe('text/plain;charset=utf-8'); + }); + + it('severs window.opener on the preview tab', async () => { + await openFileAttachment('f_1', 'a.pdf', 'application/pdf'); + expect(win.opener).toBeNull(); + expect(win.location.href).toBe('blob:mock-url'); + }); + + it('closes the tab and reports failure when the byte fetch fails', async () => { + mocks.getFileBlob.mockRejectedValue(new Error('401')); + const result = await openFileAttachment('f_1', 'a.pdf', 'application/pdf'); + expect(result).toBe('failed'); + expect(win.close).toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-web/test/server-auth.test.ts b/apps/kimi-web/test/server-auth.test.ts new file mode 100644 index 000000000..853366c83 --- /dev/null +++ b/apps/kimi-web/test/server-auth.test.ts @@ -0,0 +1,365 @@ +// apps/kimi-web/test/server-auth.test.ts +// Credential store for the server bearer token: expiring localStorage +// persistence, one-time storage migration, fragment-token intake, and the +// markAuthRequired clearing path. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const STORAGE_KEY = 'kimi-web.server-credential'; +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW = Date.parse('2026-07-12T00:00:00Z'); + +interface StoredCredential { + version: 1; + credential: string; + expiresAt: number; +} + +function createMemoryStorage(): Storage { + const data = new Map(); + return { + get length() { + return data.size; + }, + clear() { + data.clear(); + }, + getItem(key: string) { + return data.get(key) ?? null; + }, + key(index: number) { + return Array.from(data.keys()).at(index) ?? null; + }, + removeItem(key: string) { + data.delete(key); + }, + setItem(key: string, value: string) { + data.set(key, value); + }, + }; +} + +let localStore: Storage; +let sessionStore: Storage; + +function writeStoredCredential( + credential: string, + expiresAt = Date.now() + 7 * DAY_MS, +): void { + localStore.setItem(STORAGE_KEY, JSON.stringify({ + version: 1, + credential, + expiresAt, + } satisfies StoredCredential)); +} + +function readStoredCredential(): StoredCredential | undefined { + const raw = localStore.getItem(STORAGE_KEY); + return raw === null ? undefined : JSON.parse(raw) as StoredCredential; +} + +/** Fresh module instance per test — the store keeps module-level state. */ +async function loadAuth() { + vi.resetModules(); + return import('../src/api/daemon/serverAuth'); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + localStore = createMemoryStorage(); + sessionStore = createMemoryStorage(); + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: localStore }); + Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: sessionStore }); +}); + +afterEach(() => { + delete (globalThis as { window?: unknown }).window; + vi.useRealTimers(); +}); + +describe('credential persistence', () => { + it('round-trips through localStorage across module reloads', async () => { + const auth = await loadAuth(); + auth.setCredential('tok-1'); + expect(auth.getCredential()).toBe('tok-1'); + expect(readStoredCredential()).toEqual({ + version: 1, + credential: 'tok-1', + expiresAt: NOW + 7 * DAY_MS, + }); + + // Simulate a full page reload: fresh module state, same browser storage. + const reloaded = await loadAuth(); + expect(reloaded.initServerAuth()).toBe(true); + expect(reloaded.getCredential()).toBe('tok-1'); + }); + + it('expires 7 days after write without extending on reads', async () => { + const auth = await loadAuth(); + auth.setCredential('tok-1'); + + vi.setSystemTime(NOW + 6 * DAY_MS); + const beforeExpiry = await loadAuth(); + expect(beforeExpiry.initServerAuth()).toBe(true); + expect(readStoredCredential()?.expiresAt).toBe(NOW + 7 * DAY_MS); + + vi.setSystemTime(NOW + 7 * DAY_MS); + const expired = await loadAuth(); + expect(expired.initServerAuth()).toBe(false); + expect(expired.getCredential()).toBeUndefined(); + expect(localStore.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('stops using an in-memory credential when its 7 days expire', async () => { + const auth = await loadAuth(); + auth.setCredential('tok-1'); + + vi.setSystemTime(NOW + 7 * DAY_MS); + expect(auth.getCredential()).toBeUndefined(); + expect(localStore.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('clearCredential drops the persisted copy', async () => { + const auth = await loadAuth(); + auth.setCredential('tok-1'); + auth.clearCredential(); + expect(auth.getCredential()).toBeUndefined(); + expect(localStore.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('adopts a legacy sessionStorage credential into localStorage', async () => { + sessionStore.setItem(STORAGE_KEY, 'legacy-tok'); + const auth = await loadAuth(); + expect(auth.initServerAuth()).toBe(true); + expect(auth.getCredential()).toBe('legacy-tok'); + expect(readStoredCredential()).toEqual({ + version: 1, + credential: 'legacy-tok', + expiresAt: NOW + 7 * DAY_MS, + }); + expect(sessionStore.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('adds an expiry to a credential stored by the earlier localStorage format', async () => { + localStore.setItem(STORAGE_KEY, 'legacy-local-tok'); + const auth = await loadAuth(); + + expect(auth.initServerAuth()).toBe(true); + expect(auth.getCredential()).toBe('legacy-local-tok'); + expect(readStoredCredential()).toEqual({ + version: 1, + credential: 'legacy-local-tok', + expiresAt: NOW + 7 * DAY_MS, + }); + }); + + it('keeps using a legacy session credential when localStorage migration fails', async () => { + sessionStore.setItem(STORAGE_KEY, 'legacy-tok'); + localStore.setItem = () => { + throw new Error('quota exceeded'); + }; + const auth = await loadAuth(); + + expect(auth.initServerAuth()).toBe(true); + expect(auth.getCredential()).toBe('legacy-tok'); + expect(sessionStore.getItem(STORAGE_KEY)).toBeNull(); + + const reloaded = await loadAuth(); + expect(reloaded.initServerAuth()).toBe(false); + }); + + it('does not grant a fresh window on reload when legacy local migration fails', async () => { + localStore.setItem(STORAGE_KEY, 'legacy-local-tok'); + localStore.setItem = () => { + throw new Error('quota exceeded'); + }; + const auth = await loadAuth(); + + expect(auth.initServerAuth()).toBe(true); + expect(auth.getCredential()).toBe('legacy-local-tok'); + expect(localStore.getItem(STORAGE_KEY)).toBeNull(); + + const reloaded = await loadAuth(); + expect(reloaded.initServerAuth()).toBe(false); + }); + + it('does not revive an expired credential from legacy sessionStorage', async () => { + writeStoredCredential('expired-tok', NOW); + sessionStore.setItem(STORAGE_KEY, 'expired-tok'); + const auth = await loadAuth(); + + expect(auth.initServerAuth()).toBe(false); + expect(auth.getCredential()).toBeUndefined(); + expect(localStore.getItem(STORAGE_KEY)).toBeNull(); + expect(sessionStore.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('keeps an expired record when legacy session cleanup fails', async () => { + writeStoredCredential('expired-tok', NOW); + sessionStore.setItem(STORAGE_KEY, 'expired-tok'); + sessionStore.removeItem = () => { + throw new Error('denied'); + }; + const auth = await loadAuth(); + + expect(auth.initServerAuth()).toBe(false); + expect(localStore.getItem(STORAGE_KEY)).not.toBeNull(); + + const reloaded = await loadAuth(); + expect(reloaded.initServerAuth()).toBe(false); + }); + + it('setCredential removes any legacy sessionStorage copy', async () => { + sessionStore.setItem(STORAGE_KEY, 'legacy-tok'); + const auth = await loadAuth(); + auth.setCredential('tok-2'); + expect(sessionStore.getItem(STORAGE_KEY)).toBeNull(); + expect(readStoredCredential()?.credential).toBe('tok-2'); + }); + + it('removes the legacy sessionStorage copy even when localStorage is blocked', async () => { + const blockedLocal = createMemoryStorage(); + blockedLocal.setItem = () => { + throw new Error('denied'); + }; + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: blockedLocal }); + sessionStore.setItem(STORAGE_KEY, 'legacy-tok'); + + const auth = await loadAuth(); + auth.setCredential('tok-new'); + + // The credential lives in memory only, but the stale session copy must + // not survive to be re-migrated on the next reload. + expect(auth.getCredential()).toBe('tok-new'); + expect(sessionStore.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('keeps working in memory when storage throws', async () => { + const throwing = createMemoryStorage(); + throwing.setItem = () => { + throw new Error('denied'); + }; + throwing.getItem = () => { + throw new Error('denied'); + }; + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: throwing }); + Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: throwing }); + + const auth = await loadAuth(); + expect(auth.initServerAuth()).toBe(false); + auth.setCredential('tok-mem'); + expect(auth.getCredential()).toBe('tok-mem'); + expect(() => { + auth.clearCredential(); + }).not.toThrow(); + }); +}); + +describe('fragment token intake', () => { + function installWindow(hash: string) { + const replaceState = vi.fn(); + const win = { + location: { + hash, + href: `http://localhost:58627/some/path?x=1${hash}`, + }, + history: { state: null, replaceState }, + }; + Object.defineProperty(globalThis, 'window', { configurable: true, value: win }); + return { replaceState }; + } + + it('prefers the fragment token, stores it, and scrubs the URL', async () => { + writeStoredCredential('stored-tok'); + const { replaceState } = installWindow('#token=frag-tok'); + const auth = await loadAuth(); + + expect(auth.initServerAuth()).toBe(true); + expect(auth.getCredential()).toBe('frag-tok'); + expect(readStoredCredential()?.credential).toBe('frag-tok'); + // Fragment scrubbed: path + query kept, token gone. + expect(replaceState).toHaveBeenCalledWith(null, '', '/some/path?x=1'); + }); + + it('ignores an empty fragment and falls back to storage', async () => { + writeStoredCredential('stored-tok'); + installWindow(''); + const auth = await loadAuth(); + + expect(auth.initServerAuth()).toBe(true); + expect(auth.getCredential()).toBe('stored-tok'); + }); +}); + +describe('markAuthRequired', () => { + it('clears the credential and notifies listeners', async () => { + const auth = await loadAuth(); + auth.setCredential('tok-1'); + const listener = vi.fn(); + const off = auth.onAuthRequired(listener); + + auth.markAuthRequired(); + expect(auth.getCredential()).toBeUndefined(); + expect(localStore.getItem(STORAGE_KEY)).toBeNull(); + expect(listener).toHaveBeenCalledTimes(1); + + off(); + auth.markAuthRequired(); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + +describe('cross-tab credential clearing', () => { + it('keeps a newer same-token record when this tab’s in-memory copy expires', async () => { + const auth = await loadAuth(); + auth.setCredential('tok-1'); + writeStoredCredential('tok-1', NOW + 8 * DAY_MS); + const refreshedStored = localStore.getItem(STORAGE_KEY); + + vi.setSystemTime(NOW + 7 * DAY_MS); + expect(auth.getCredential()).toBeUndefined(); + expect(localStore.getItem(STORAGE_KEY)).toBe(refreshedStored); + }); + + it('keeps a newer token another tab persisted when this tab is stale', async () => { + const auth = await loadAuth(); + auth.setCredential('stale-tok'); + // Another tab stores a fresh token (e.g. after rotation + `kimi web`). + writeStoredCredential('fresh-tok'); + const freshStored = localStore.getItem(STORAGE_KEY); + + auth.markAuthRequired(); + + // This tab forgets its rejected credential and prompts… + expect(auth.getCredential()).toBeUndefined(); + // …but the fresh shared token survives for reloads and other tabs. + expect(localStore.getItem(STORAGE_KEY)).toBe(freshStored); + }); + + it('clears the persisted copy when it still matches the rejected credential', async () => { + const auth = await loadAuth(); + auth.setCredential('tok-1'); + auth.markAuthRequired(); + expect(localStore.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('clears the same rejected token even if another tab refreshed its expiry', async () => { + const auth = await loadAuth(); + auth.setCredential('tok-1'); + writeStoredCredential('tok-1', NOW + 8 * DAY_MS); + + auth.markAuthRequired(); + + expect(auth.getCredential()).toBeUndefined(); + expect(localStore.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('does not clear another tab’s token when this tab had no credential', async () => { + writeStoredCredential('fresh-tok'); + const freshStored = localStore.getItem(STORAGE_KEY); + const auth = await loadAuth(); + auth.markAuthRequired(); + expect(localStore.getItem(STORAGE_KEY)).toBe(freshStored); + }); +}); diff --git a/apps/kimi-web/test/side-chat.test.ts b/apps/kimi-web/test/side-chat.test.ts new file mode 100644 index 000000000..fe45257ed --- /dev/null +++ b/apps/kimi-web/test/side-chat.test.ts @@ -0,0 +1,142 @@ +// apps/kimi-web/test/side-chat.test.ts +import { describe, expect, it, vi } from 'vitest'; +import { createInitialState } from '../src/api/daemon/eventReducer'; +import { useSideChat } from '../src/composables/client/useSideChat'; +import type { ExtendedState } from '../src/composables/useKimiWebClient'; + +const apiMock = vi.hoisted(() => ({ + startBtw: vi.fn(), + submitPrompt: vi.fn(), +})); + +vi.mock('../src/api', () => ({ + getKimiWebApi: () => apiMock, +})); + +function createState(): ExtendedState { + return { + ...createInitialState(), + sessions: [ + { + id: 'sess_1', + title: 'Session', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + busy: false as const, + archived: false, + currentPromptId: null, + cwd: '/workspace', + model: 'kimi-code', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + }, + ], + activeSessionId: 'sess_1', + permission: 'auto', + thinking: 'high', + planModeBySession: { sess_1: true }, + swarmModeBySession: {}, + sideChatMessagesByAgent: {}, + sideChatSendingByAgent: {}, + sideChatUserMessageIdsBySession: {}, + } as unknown as ExtendedState; +} + +describe('useSideChat — sendSideChatPromptOn', () => { + it('carries model, thinking, permission and plan/swarm modes on the prompt', async () => { + apiMock.startBtw.mockReset(); + apiMock.submitPrompt.mockReset(); + apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' }); + apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_btw', userMessageId: 'msg_opt_btw' }); + + const state = createState(); + const pushOperationFailure = vi.fn(); + const sideChat = useSideChat(state, { + pushOperationFailure, + nextOptimisticMsgId: () => 'msg_opt_btw', + connectEventsIfNeeded: vi.fn(), + getEventConn: () => null, + thinkingLevelForModelId: () => undefined, + }); + + await sideChat.openSideChatOn('sess_1', 'what changed?'); + + expect(apiMock.startBtw).toHaveBeenCalledWith('sess_1'); + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ + agentId: 'agent_btw_1', + model: 'kimi-code', + thinking: 'high', + permissionMode: 'auto', + planMode: true, + swarmMode: false, + }), + ); + expect(pushOperationFailure).not.toHaveBeenCalled(); + }); + + it('falls back to the active level when the parent model has left the catalog', async () => { + // thinkingLevelForModelId returns undefined for a model the catalog no + // longer lists — the submit then keeps the active-session level (same + // fallback as the normal prompt paths). + apiMock.startBtw.mockReset(); + apiMock.submitPrompt.mockReset(); + apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' }); + apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_btw', userMessageId: 'msg_opt_btw' }); + + const state = createState(); + state.thinking = 'max'; + const sideChat = useSideChat(state, { + pushOperationFailure: vi.fn(), + nextOptimisticMsgId: () => 'msg_opt_btw', + connectEventsIfNeeded: vi.fn(), + getEventConn: () => null, + thinkingLevelForModelId: () => undefined, + }); + + await sideChat.openSideChatOn('sess_1', 'what changed?'); + + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ thinking: 'max' }), + ); + }); + + it('resolves thinking from the parent model, not the level of the session the user switched to', async () => { + // startBtw spans an await during which the user can switch sessions; the + // BTW prompt must still carry the PARENT model's level ('low'), never the + // active view's ('max'). + apiMock.startBtw.mockReset(); + apiMock.submitPrompt.mockReset(); + apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' }); + apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_btw', userMessageId: 'msg_opt_btw' }); + + const state = createState(); + state.thinking = 'max'; // the user is now viewing a max-only session elsewhere + const sideChat = useSideChat(state, { + pushOperationFailure: vi.fn(), + nextOptimisticMsgId: () => 'msg_opt_btw', + connectEventsIfNeeded: vi.fn(), + getEventConn: () => null, + thinkingLevelForModelId: (id) => (id === 'kimi-code' ? 'low' : undefined), + }); + + await sideChat.openSideChatOn('sess_1', 'what changed?'); + + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ model: 'kimi-code', thinking: 'low' }), + ); + }); +}); diff --git a/apps/kimi-web/test/slash-menu.test.ts b/apps/kimi-web/test/slash-menu.test.ts new file mode 100644 index 000000000..35694194a --- /dev/null +++ b/apps/kimi-web/test/slash-menu.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { nextTick, ref, type Ref } from 'vue'; +import type { AppSkill } from '../src/api/types'; +import { useSlashMenu } from '../src/composables/useSlashMenu'; + +// Public slash-menu contract: matching built-ins and dispatching selected +// commands without coupling tests to component internals. + +interface MockTextarea { + value: string; + selectionStart: number; + setSelectionRange: (start: number, end: number) => void; + focus: () => void; +} + +function setup(initialText = '', skills: AppSkill[] = []) { + const textarea: MockTextarea = { + value: initialText, + selectionStart: 0, + setSelectionRange(start: number) { + this.selectionStart = start; + }, + focus: () => {}, + }; + const text = ref(initialText); + const textareaRef = ref(textarea as unknown as HTMLTextAreaElement) as Ref; + const emitted: string[] = []; + const pushed: string[] = []; + const slash = useSlashMenu({ + text, + textareaRef, + autosize: () => {}, + skills: () => skills, + emitCommand: (cmd) => emitted.push(cmd), + historyPush: (entry) => pushed.push(entry), + }); + return { text, textarea, emitted, pushed, slash }; +} + +describe('useSlashMenu — update', () => { + it('stays closed for empty text', () => { + const { slash } = setup(''); + slash.update(); + expect(slash.open.value).toBe(false); + }); + + it('opens and lists commands for a lone slash', () => { + const { slash } = setup('/'); + slash.update(); + expect(slash.open.value).toBe(true); + expect(slash.items.value.length).toBeGreaterThan(0); + expect(slash.active.value).toBe(0); + }); + + it('filters to matching commands', () => { + const { slash } = setup('/com'); + slash.update(); + expect(slash.open.value).toBe(true); + expect(slash.items.value.map((i) => i.name)).toContain('/compact'); + }); + + it('offers the session export command for an export prefix', () => { + const { slash } = setup('/exp'); + slash.update(); + expect(slash.items.value.map((item) => item.name)).toContain('/export'); + }); + + it('closes when nothing matches', () => { + const { slash } = setup('/zzzznotacommand'); + slash.update(); + expect(slash.open.value).toBe(false); + }); + + it('closes once the token contains a space', () => { + const { slash } = setup('/goal some task'); + slash.update(); + expect(slash.open.value).toBe(false); + }); + + it('closes for text that does not start with a slash', () => { + const { slash } = setup('hello'); + slash.update(); + expect(slash.open.value).toBe(false); + }); + + it('includes session skills as /skill:', () => { + const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff', source: 'project' } as AppSkill]); + slash.update(); + const names = slash.items.value.map((i) => i.name); + expect(names).toContain('/skill:deploy'); + }); + + it('keeps builtin-sourced skills unprefixed', () => { + const { slash } = setup('/', [{ name: 'update-config', description: 'edit config', source: 'builtin' } as AppSkill]); + slash.update(); + const names = slash.items.value.map((i) => i.name); + expect(names).toContain('/update-config'); + expect(names).not.toContain('/skill:update-config'); + }); + + it('matches a prefixed skill when filtering by its bare name', () => { + const { slash } = setup('/depl', [{ name: 'deploy', description: 'deploy stuff', source: 'project' } as AppSkill]); + slash.update(); + expect(slash.items.value.map((i) => i.name)).toContain('/skill:deploy'); + }); +}); + +describe('useSlashMenu — select', () => { + it('non-acceptsInput: clears text, pushes history, emits the command', () => { + const { text, emitted, pushed, slash } = setup('/new'); + slash.select({ name: '/new', desc: '' }); + expect(text.value).toBe(''); + expect(pushed).toEqual(['/new']); + expect(emitted).toEqual(['/new']); + expect(slash.open.value).toBe(false); + }); + + it('acceptsInput: keeps the command in the box and does not emit yet', async () => { + const { text, emitted, pushed, slash } = setup('/goal'); + slash.select({ name: '/goal', desc: '', acceptsInput: true }); + expect(text.value).toBe('/goal '); + expect(emitted).toEqual([]); + expect(pushed).toEqual([]); + expect(slash.open.value).toBe(false); + await nextTick(); + }); +}); diff --git a/apps/kimi-web/test/sound-notification.test.ts b/apps/kimi-web/test/sound-notification.test.ts new file mode 100644 index 000000000..79eea7d27 --- /dev/null +++ b/apps/kimi-web/test/sound-notification.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { STORAGE_KEYS, safeGetString } from '../src/lib/storage'; +import { useSoundNotification } from '../src/composables/client/useSoundNotification'; + +function createMemoryStorage(): Storage { + const data = new Map(); + return { + get length() { + return data.size; + }, + clear() { + data.clear(); + }, + getItem(key: string) { + return data.get(key) ?? null; + }, + key(index: number) { + return Array.from(data.keys()).at(index) ?? null; + }, + removeItem(key: string) { + data.delete(key); + }, + setItem(key: string, value: string) { + data.set(key, value); + }, + }; +} + +function installStorage(storage: Storage): void { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: storage, + }); +} + +// Singleton — module-level ref + setter. Audio unlock/listeners are no-ops here +// because the test env has no `window`. +const { soundOnComplete, setSoundOnComplete, maybePlayQuestionSound } = useSoundNotification(); +// Captured at import (before beforeEach resets the ref), so this reflects the +// load-from-storage default when nothing has been stored yet. +const importedDefault = soundOnComplete.value; + +describe('useSoundNotification', () => { + beforeEach(() => { + installStorage(createMemoryStorage()); + setSoundOnComplete(true); // reset the shared singleton to a known state + }); + + afterEach(() => { + installStorage(createMemoryStorage()); + }); + + it('persists "0" and updates the ref when disabled', () => { + setSoundOnComplete(false); + expect(soundOnComplete.value).toBe(false); + expect(safeGetString(STORAGE_KEYS.soundOnComplete)).toBe('0'); + }); + + it('persists "1" and updates the ref when re-enabled', () => { + setSoundOnComplete(false); + setSoundOnComplete(true); + expect(soundOnComplete.value).toBe(true); + expect(safeGetString(STORAGE_KEYS.soundOnComplete)).toBe('1'); + }); + + it('defaults to off when nothing is stored', () => { + expect(importedDefault).toBe(false); + }); + + it('maybePlayQuestionSound is a no-op without throwing when audio is unavailable', () => { + expect(() => { + maybePlayQuestionSound(); + }).not.toThrow(); + }); +}); diff --git a/apps/kimi-web/test/storage-logic.test.ts b/apps/kimi-web/test/storage-logic.test.ts new file mode 100644 index 000000000..6dc7dab75 --- /dev/null +++ b/apps/kimi-web/test/storage-logic.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + loadCollapsedWorkspaces, + loadUnread, + loadWorkspaceOrder, + saveCollapsedWorkspaces, + saveUnread, + saveWorkspaceOrder, + STORAGE_KEYS, + draftStorageKey, + safeGetJson, + safeGetString, + safeRemove, + safeSetJson, + safeSetString, +} from '../src/lib/storage'; + +function createMemoryStorage(): Storage { + const data = new Map(); + return { + get length() { + return data.size; + }, + clear() { + data.clear(); + }, + getItem(key: string) { + return data.get(key) ?? null; + }, + key(index: number) { + return Array.from(data.keys()).at(index) ?? null; + }, + removeItem(key: string) { + data.delete(key); + }, + setItem(key: string, value: string) { + data.set(key, String(value)); + }, + }; +} + +function installStorage(storage: Storage): void { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: storage, + }); +} + +let backing: Storage; + +beforeEach(() => { + backing = createMemoryStorage(); + installStorage(backing); +}); + +afterEach(() => { + installStorage(createMemoryStorage()); +}); + +describe('safeGetString / safeSetString', () => { + it('round-trips a value', () => { + safeSetString('k', 'hello'); + expect(safeGetString('k')).toBe('hello'); + }); + + it('returns null for a missing key', () => { + expect(safeGetString('missing')).toBeNull(); + }); + + it('overwrites an existing value', () => { + safeSetString('k', 'a'); + safeSetString('k', 'b'); + expect(safeGetString('k')).toBe('b'); + }); +}); + +describe('safeRemove', () => { + it('removes an existing key', () => { + safeSetString('k', 'v'); + safeRemove('k'); + expect(safeGetString('k')).toBeNull(); + }); + + it('is a no-op for a missing key', () => { + expect(() => safeRemove('missing')).not.toThrow(); + }); +}); + +describe('safeGetJson / safeSetJson', () => { + it('round-trips a JSON value', () => { + safeSetJson('k', { a: 1, b: [2, 3] }); + expect(safeGetJson('k')).toEqual({ a: 1, b: [2, 3] }); + }); + + it('returns null for a missing key', () => { + expect(safeGetJson('missing')).toBeNull(); + }); + + it('returns null when the stored value is not valid JSON', () => { + safeSetString('k', '{not json'); + expect(safeGetJson('k')).toBeNull(); + }); +}); + +describe('error swallowing', () => { + it('safeGetString returns null when storage throws', () => { + const throwing = createMemoryStorage(); + throwing.getItem = () => { + throw new Error('denied'); + }; + installStorage(throwing); + expect(safeGetString('k')).toBeNull(); + }); + + it('safeSetString does not throw when storage throws', () => { + const throwing = createMemoryStorage(); + throwing.setItem = () => { + throw new Error('quota'); + }; + installStorage(throwing); + expect(() => safeSetString('k', 'v')).not.toThrow(); + }); +}); + +describe('draftStorageKey', () => { + it('uses the session id when present', () => { + expect(draftStorageKey('abc')).toBe('kimi-web.draft.abc'); + }); + + it('falls back to __new__ when sid is empty/undefined', () => { + expect(draftStorageKey(undefined)).toBe('kimi-web.draft.__new__'); + expect(draftStorageKey('')).toBe('kimi-web.draft.__new__'); + }); +}); + +describe('STORAGE_KEYS', () => { + it('keeps the legacy key strings unchanged', () => { + expect(STORAGE_KEYS.theme).toBe('kimi-web.theme'); + expect(STORAGE_KEYS.activeWorkspace).toBe('kimi-active-workspace'); + expect(STORAGE_KEYS.notifyOnComplete).toBe('kimi-web.notify-on-complete'); + expect(STORAGE_KEYS.notifyOnQuestion).toBe('kimi-web.notify-on-question'); + expect(STORAGE_KEYS.soundOnComplete).toBe('kimi-web.sound-on-complete'); + expect(STORAGE_KEYS.locale).toBe('kimi-locale'); + }); +}); + +describe('loadUnread / saveUnread', () => { + it('returns an empty map when the key is missing', () => { + expect(loadUnread()).toEqual({}); + }); + + it('keeps only true entries', () => { + safeSetString(STORAGE_KEYS.unread, JSON.stringify({ B: true, C: false, D: 'yes' })); + expect(loadUnread()).toEqual({ B: true }); + }); + + it('drops false entries, clearing the unread dot', () => { + saveUnread({ B: true, C: true }); + saveUnread({ B: false }); + expect(loadUnread()).toEqual({ C: true }); + }); + + it('merges with the latest stored value so a clear from another tab is not overwritten', () => { + // This tab marks B unread. + saveUnread({ B: true }); + expect(loadUnread()).toEqual({ B: true }); + + // Another tab clears B and marks C (simulated by writing the key directly). + safeSetString(STORAGE_KEYS.unread, JSON.stringify({ C: true })); + + // This tab marks D unread, passing only the change (not a full, stale map). + saveUnread({ D: true }); + + // B must NOT come back — it was cleared by the other tab. + expect(loadUnread()).toEqual({ C: true, D: true }); + }); +}); + +describe('loadCollapsedWorkspaces / saveCollapsedWorkspaces', () => { + it('returns an empty array when the key is missing', () => { + expect(loadCollapsedWorkspaces()).toEqual([]); + }); + + it('round-trips the collapsed ids', () => { + saveCollapsedWorkspaces(['ws-1', 'ws-2']); + expect(loadCollapsedWorkspaces()).toEqual(['ws-1', 'ws-2']); + }); + + it('accepts any iterable of ids', () => { + saveCollapsedWorkspaces(new Set(['ws-1', 'ws-3'])); + expect(loadCollapsedWorkspaces()).toEqual(['ws-1', 'ws-3']); + }); + + it('drops non-string entries and returns [] for malformed values', () => { + safeSetString(STORAGE_KEYS.collapsedWorkspaces, JSON.stringify(['ws-1', 2, null, 'ws-2'])); + expect(loadCollapsedWorkspaces()).toEqual(['ws-1', 'ws-2']); + + safeSetString(STORAGE_KEYS.collapsedWorkspaces, JSON.stringify({ ws: true })); + expect(loadCollapsedWorkspaces()).toEqual([]); + }); +}); + +describe('loadWorkspaceOrder / saveWorkspaceOrder', () => { + it('returns an empty array when the key is missing', () => { + expect(loadWorkspaceOrder()).toEqual([]); + }); + + it('round-trips the ordered ids', () => { + saveWorkspaceOrder(['ws-2', 'ws-1']); + expect(loadWorkspaceOrder()).toEqual(['ws-2', 'ws-1']); + }); + + it('accepts any iterable of ids', () => { + saveWorkspaceOrder(new Set(['ws-3', 'ws-1'])); + expect(loadWorkspaceOrder()).toEqual(['ws-3', 'ws-1']); + }); + + it('drops non-string entries and returns [] for malformed values', () => { + safeSetString(STORAGE_KEYS.workspaceOrder, JSON.stringify(['ws-1', 2, null])); + expect(loadWorkspaceOrder()).toEqual(['ws-1']); + + safeSetString(STORAGE_KEYS.workspaceOrder, JSON.stringify({ ws: true })); + expect(loadWorkspaceOrder()).toEqual([]); + }); +}); diff --git a/apps/kimi-web/test/swarm-card-rows.test.ts b/apps/kimi-web/test/swarm-card-rows.test.ts new file mode 100644 index 000000000..957e8e56e --- /dev/null +++ b/apps/kimi-web/test/swarm-card-rows.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { AppSubagentPhase } from '../src/api/types'; +import type { SwarmMember } from '../src/composables/swarmGroups'; +import type { SwarmResult } from '../src/lib/parseSwarmResult'; +import { buildSwarmCardRows, swarmMemberActivity } from '../src/lib/swarmCardRows'; + +function member( + id: string, + name: string, + opts: { + phase?: AppSubagentPhase; + text?: string; + outputLines?: string[]; + summary?: string; + suspendedReason?: string; + } = {}, +): SwarmMember { + return { + id, + name, + phase: opts.phase ?? 'working', + text: opts.text, + outputLines: opts.outputLines, + summary: opts.summary, + suspendedReason: opts.suspendedReason, + swarmIndex: 0, + }; +} + +function result(subagents: SwarmResult['subagents']): SwarmResult { + return { + summary: `${subagents.length}`, + completed: subagents.filter((s) => s.outcome === 'completed').length, + failed: subagents.filter((s) => s.outcome === 'failed').length, + aborted: subagents.filter((s) => s.outcome === 'aborted').length, + total: subagents.length, + subagents, + }; +} + +describe('swarmMemberActivity', () => { + it('prefers streamed subagent text over outputLines and summary', () => { + const m = member('a', '子任务', { + text: 'line 1\nline 2', + outputLines: ['tool call output'], + summary: 'final summary', + }); + expect(swarmMemberActivity(m)).toBe('line 2'); + }); + + it('falls back to the last outputLines entry when no text is streaming', () => { + const m = member('a', '子任务', { outputLines: ['one', 'two'], summary: 'summary' }); + expect(swarmMemberActivity(m)).toBe('two'); + }); + + it('falls back to summary', () => { + expect(swarmMemberActivity(member('a', '子任务', { summary: 'sum' }))).toBe('sum'); + }); +}); + +describe('buildSwarmCardRows', () => { + it('builds rows from live members when no parsed result exists', () => { + const rows = buildSwarmCardRows( + [member('a', '子任务 A', { text: 'streaming' })], + null, + ); + expect(rows).toEqual([{ id: 'a', name: '子任务 A', activity: 'streaming', phase: 'working', body: 'streaming' }]); + }); + + it('builds rows from result subagents when no members are present', () => { + const rows = buildSwarmCardRows( + [], + result([ + { outcome: 'completed', item: 'A', body: 'A body' }, + { outcome: 'failed', item: 'B', body: 'B body' }, + ]), + ); + expect(rows.map((r) => r.name)).toEqual(['A', 'B']); + expect(rows.map((r) => r.phase)).toEqual(['completed', 'failed']); + }); + + it('appends result-only aborted not_started rows on top of live members', () => { + const rows = buildSwarmCardRows( + [ + member('a1', '子任务 A', { phase: 'completed' }), + member('a2', '子任务 B', { phase: 'working' }), + ], + result([ + { outcome: 'completed', item: 'A', agentId: 'a1', body: 'A body' }, + { outcome: 'completed', item: 'B', agentId: 'a2', body: 'B body' }, + { outcome: 'aborted', item: 'C', state: 'not_started', body: 'C never started' }, + ]), + ); + expect(rows.map((r) => r.id)).toEqual(['a1', 'a2', 'C']); + expect(rows[2]?.phase).toBe('failed'); + expect(rows[2]?.body).toBe('C never started'); + }); + + it('does not duplicate a result row that a live member already covers', () => { + const rows = buildSwarmCardRows( + [member('a1', '子任务 A', { phase: 'failed' })], + result([{ outcome: 'aborted', item: 'A', agentId: 'a1', body: 'A body' }]), + ); + expect(rows.map((r) => r.id)).toEqual(['a1']); + expect(rows[0]?.phase).toBe('failed'); + }); + + it('matches by item substring when agent ids disagree', () => { + const rows = buildSwarmCardRows( + [member('a1', 'find unused exports in src', { phase: 'completed' })], + result([{ outcome: 'aborted', item: 'unused exports', state: 'not_started', body: 'x' }]), + ); + expect(rows.map((r) => r.id)).toEqual(['a1']); + }); +}); diff --git a/apps/kimi-web/test/swarm-groups.test.ts b/apps/kimi-web/test/swarm-groups.test.ts new file mode 100644 index 000000000..69703f9de --- /dev/null +++ b/apps/kimi-web/test/swarm-groups.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import type { AppTask } from '../src/api/types'; +import { + buildSwarmGroups, + countSwarmMembers, + swarmMembersByToolCall, +} from '../src/composables/swarmGroups'; + +function subagentTask( + id: string, + parentToolCallId: string | undefined, + opts: { + swarmIndex?: number; + status?: AppTask['status']; + subagentPhase?: AppTask['subagentPhase']; + text?: string; + outputLines?: string[]; + } = {}, +): AppTask { + return { + id, + sessionId: 'session-1', + kind: 'subagent', + description: `subagent ${id}`, + status: opts.status ?? 'running', + createdAt: '2026-01-01T00:00:00.000Z', + parentToolCallId, + swarmIndex: opts.swarmIndex, + text: opts.text, + outputLines: opts.outputLines, + subagentPhase: opts.subagentPhase ?? 'working', + }; +} + +function bashTask(id: string): AppTask { + return { + id, + sessionId: 'session-1', + kind: 'bash', + description: `bash ${id}`, + busy: true, + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + +describe('buildSwarmGroups', () => { + it('emits a group only when two or more members share a swarmIndex', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1', { swarmIndex: 1 }), + subagentTask('b', 'swarm-1', { swarmIndex: 2 }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0]?.id).toBe('swarm-1'); + expect(groups[0]?.members.map((m) => m.id)).toEqual(['a', 'b']); + }); + + it('filters single-member groups (used for the badge counter)', () => { + const groups = buildSwarmGroups([subagentTask('a', 'swarm-1', { swarmIndex: 1 })]); + expect(groups).toHaveLength(0); + }); + + it('ignores subagents without a swarmIndex', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1'), + subagentTask('b', 'swarm-1'), + ]); + expect(groups).toHaveLength(0); + }); +}); + +describe('countSwarmMembers', () => { + it('counts completed + failed as done across groups', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1', { swarmIndex: 1, subagentPhase: 'completed', status: 'completed' }), + subagentTask('b', 'swarm-1', { swarmIndex: 2, subagentPhase: 'failed', status: 'failed' }), + subagentTask('c', 'swarm-2', { swarmIndex: 1, subagentPhase: 'working' }), + subagentTask('d', 'swarm-2', { swarmIndex: 2, subagentPhase: 'queued' }), + ]); + expect(countSwarmMembers(groups)).toEqual({ done: 2, total: 4 }); + }); +}); + +describe('swarmMembersByToolCall', () => { + it('keeps single-member swarms so a resume-only AgentSwarm gets live progress', () => { + const map = swarmMembersByToolCall([subagentTask('a', 'swarm-1', { swarmIndex: 1 })]); + expect(map.get('swarm-1')?.map((m) => m.id)).toEqual(['a']); + }); + + it('groups every subagent with the same parentToolCallId, ignoring swarmIndex', () => { + const map = swarmMembersByToolCall([ + subagentTask('b', 'swarm-1'), + subagentTask('a', 'swarm-1'), + subagentTask('c', 'swarm-2'), + ]); + expect(map.get('swarm-1')?.map((m) => m.id)).toEqual(['a', 'b']); + expect(map.get('swarm-2')?.map((m) => m.id)).toEqual(['c']); + }); + + it('ignores non-subagent tasks and subagents without a parentToolCallId', () => { + const map = swarmMembersByToolCall([ + bashTask('b-1'), + subagentTask('orphan', undefined), + subagentTask('a', 'swarm-1'), + ]); + expect([...map.keys()]).toEqual(['swarm-1']); + }); + + it('carries task.text so live rows can show still-composing subagent output', () => { + const map = swarmMembersByToolCall([ + subagentTask('a', 'swarm-1', { text: 'Hello, world!' }), + subagentTask('b', 'swarm-1', { outputLines: ['tool line'] }), + ]); + const rows = map.get('swarm-1') ?? []; + expect(rows[0]).toMatchObject({ id: 'a', text: 'Hello, world!' }); + expect(rows[1]).toMatchObject({ id: 'b', outputLines: ['tool line'] }); + }); +}); + +describe('buildSwarmGroups preserves streamed text', () => { + it('carries task.text into each group member', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1', { swarmIndex: 1, text: 'first line' }), + subagentTask('b', 'swarm-1', { swarmIndex: 2, text: 'second line' }), + ]); + expect(groups[0]?.members.map((m) => m.text)).toEqual(['first line', 'second line']); + }); +}); diff --git a/apps/kimi-web/test/swarm-result.test.ts b/apps/kimi-web/test/swarm-result.test.ts new file mode 100644 index 000000000..deb6e0f64 --- /dev/null +++ b/apps/kimi-web/test/swarm-result.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { parseSwarmResult } from '../src/lib/parseSwarmResult'; + +describe('parseSwarmResult', () => { + it('returns null when the payload is not an agent_swarm_result', () => { + expect(parseSwarmResult('all done')).toBeNull(); + expect(parseSwarmResult(undefined)).toBeNull(); + expect(parseSwarmResult([])).toBeNull(); + }); + + it('parses the summary counts and each subagent outcome', () => { + const output = [ + '', + 'completed: 2, failed: 1', + 'first body', + 'second body', + 'boom', + '', + ]; + const result = parseSwarmResult(output); + expect(result).not.toBeNull(); + expect(result?.summary).toBe('completed: 2, failed: 1'); + expect(result?.completed).toBe(2); + expect(result?.failed).toBe(1); + expect(result?.aborted).toBe(0); + expect(result?.total).toBe(3); + expect(result?.subagents).toEqual([ + { outcome: 'completed', item: 'alpha', agentId: 'a1', body: 'first body' }, + { outcome: 'completed', item: 'beta', agentId: 'a2', body: 'second body' }, + { outcome: 'failed', item: 'gamma', body: 'boom' }, + ]); + }); + + it('unescapes the item attribute and captures the resume hint', () => { + const text = [ + '', + 'completed: 0, failed: 1, aborted: 0', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'err', + '', + ].join('\n'); + const result = parseSwarmResult(text); + expect(result?.resumeHint).toContain('resume_agent_ids'); + expect(result?.subagents[0]?.item).toBe('a & b'); + expect(result?.subagents[0]?.mode).toBe('resume'); + expect(result?.subagents[0]?.state).toBe('started'); + }); + + it('does not count a literal "" tag inside a body as a top-level row', () => { + const snippet = 'inner body'; + const body = 'example result below: ' + snippet; + const text = `completed: 1${body}`; + const result = parseSwarmResult(text); + expect(result?.subagents).toHaveLength(1); + expect(result?.subagents[0]?.item).toBe('outer'); + expect(result?.subagents[0]?.body).toContain(snippet); + }); + + it('keeps sibling top-level rows when one body contains a nested subagent snippet', () => { + const text = [ + 'completed: 2', + 'A snippet: inner done', + 'just B', + '', + ].join(''); + const result = parseSwarmResult(text); + expect(result?.subagents.map((s) => s.item)).toEqual(['a', 'b']); + expect(result?.subagents[0]?.body).toContain(' ({ + listTasks: vi.fn(), + getTask: vi.fn(), +})); + +vi.mock('../src/api', () => ({ + getKimiWebApi: () => apiMock, +})); + +function createState(tasks: AppTask[]): ExtendedState { + return { + ...createInitialState(), + activeSessionId: 'sess_1', + tasksBySession: { sess_1: tasks }, + } as unknown as ExtendedState; +} + +function subagent(id: string, overrides: Partial = {}): AppTask { + return { + id, + sessionId: 'sess_1', + kind: 'subagent', + description: `task ${id}`, + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +/** The same background subagent as seen on the two channels: WS keys it by + agent id, REST by background-task id (`backgroundTaskId` links them). + The live row is already completed so the poller's 1s output polling does + not start racing the one-off backfill under test. */ +function liveRow(): AppTask { + return subagent('agent-1', { + runInBackground: true, + backgroundTaskId: 'task-9', + status: 'completed', + completedAt: '2026-01-01T00:01:00.000Z', + }); +} +function restRow(overrides: Partial = {}): AppTask { + return subagent('task-9', { + runInBackground: true, + status: 'completed', + completedAt: '2026-01-01T00:01:00.000Z', + ...overrides, + }); +} + +describe('useTaskPoller terminal-output backfill', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('attaches output fetched under the REST id to the folded agent-id row', async () => { + const state = createState([liveRow()]); + apiMock.listTasks.mockResolvedValue([restRow()]); + apiMock.getTask.mockResolvedValue( + restRow({ outputPreview: 'final result', outputBytes: 2048 }), + ); + + const poller = useTaskPoller(state, computed(() => [])); + await poller.loadTasksForSession('sess_1'); + + expect(apiMock.getTask).toHaveBeenCalledWith( + 'sess_1', + 'task-9', + expect.objectContaining({ withOutput: true }), + ); + const rows = state.tasksBySession['sess_1'] ?? []; + expect(rows.map((t) => t.id)).toEqual(['agent-1']); + expect(rows[0]?.status).toBe('completed'); + expect(rows[0]?.outputPreview).toBe('final result'); + expect(rows[0]?.outputBytes).toBe(2048); + }); + + it('fetches terminal output only once for a task', async () => { + const state = createState([liveRow()]); + apiMock.listTasks.mockResolvedValue([restRow()]); + apiMock.getTask.mockResolvedValue( + restRow({ outputPreview: 'final result', outputBytes: 2048 }), + ); + + const poller = useTaskPoller(state, computed(() => [])); + await poller.loadTasksForSession('sess_1'); + await poller.loadTasksForSession('sess_1'); + + expect(apiMock.getTask).toHaveBeenCalledTimes(1); + }); + + it('retries the backfill on a later load after a transient getTask failure', async () => { + const state = createState([liveRow()]); + apiMock.listTasks.mockResolvedValue([restRow()]); + apiMock.getTask + .mockRejectedValueOnce(new Error('network blip')) + .mockResolvedValue(restRow({ outputPreview: 'final result', outputBytes: 2048 })); + + const poller = useTaskPoller(state, computed(() => [])); + await poller.loadTasksForSession('sess_1'); + expect(state.tasksBySession['sess_1']?.[0]?.outputPreview).toBeUndefined(); + + await poller.loadTasksForSession('sess_1'); + expect(apiMock.getTask).toHaveBeenCalledTimes(2); + expect(state.tasksBySession['sess_1']?.[0]?.outputPreview).toBe('final result'); + }); +}); diff --git a/apps/kimi-web/test/turn-logic.test.ts b/apps/kimi-web/test/turn-logic.test.ts new file mode 100644 index 000000000..83d244538 --- /dev/null +++ b/apps/kimi-web/test/turn-logic.test.ts @@ -0,0 +1,817 @@ +import { describe, expect, it } from 'vitest'; +import type { AppMessage, AppMessageContent } from '../src/api/types'; +import { latestTodos } from '../src/composables/latestTodos'; +import { messagesToTurns } from '../src/composables/messagesToTurns'; + +function message( + id: string, + role: AppMessage['role'], + content: AppMessageContent[], + extra: Partial = {}, +): AppMessage { + return { + id, + sessionId: 'session-1', + role, + content, + createdAt: '2026-01-01T00:00:00.000Z', + ...extra, + }; +} + +describe('messagesToTurns', () => { + it('merges an assistant turn and folds tool results into it', () => { + const turns = messagesToTurns( + [ + message('u1', 'user', [{ type: 'text', text: 'hello' }]), + message('a1', 'assistant', [ + { type: 'thinking', thinking: 'plan' }, + { type: 'toolUse', toolCallId: 'tool-1', toolName: 'read', input: { path: 'src/a.ts' } }, + ]), + message('t1', 'tool', [{ type: 'toolResult', toolCallId: 'tool-1', output: 'alpha\nbeta' }]), + message('a2', 'assistant', [{ type: 'text', text: 'done' }]), + ], + [], + undefined, + false, + ); + + expect(turns).toHaveLength(2); + expect(turns[1]).toMatchObject({ + role: 'assistant', + thinking: 'plan', + text: 'done', + }); + expect(turns[1]?.tools).toMatchObject([ + { id: 'tool-1', status: 'ok', output: ['alpha', 'beta'] }, + ]); + }); + + it('surfaces a ReadMediaFile snapshot result as media', () => { + // After a reload the daemon snapshot delivers a ReadMediaFile result as + // raw content parts (the same shape the live tool.result stream carries), + // so a resumed session must render the image card, not a generic tool card. + const turns = messagesToTurns( + [ + message('a1', 'assistant', [ + { type: 'toolUse', toolCallId: 'tool-9', toolName: 'ReadMediaFile', input: { path: 'shot.png' } }, + ]), + message('t1', 'tool', [ + { + type: 'toolResult', + toolCallId: 'tool-9', + output: [ + { type: 'text', text: '' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,QUJD' } }, + { type: 'text', text: '' }, + ], + }, + ]), + ], + [], + undefined, + false, + ); + + expect(turns[0]?.tools).toMatchObject([ + { + id: 'tool-9', + status: 'ok', + media: { + kind: 'image', + url: 'data:image/png;base64,QUJD', + path: '/tmp/shot.png', + mimeType: 'image/png', + }, + }, + ]); + }); + + it('splits assistant turns when prompt ids differ', () => { + const turns = messagesToTurns( + [ + message('a1', 'assistant', [{ type: 'text', text: 'one' }], { promptId: 'p1' }), + message('a2', 'assistant', [{ type: 'text', text: 'two' }], { promptId: 'p2' }), + ], + [], + undefined, + false, + ); + + expect(turns.map((turn) => turn.text)).toEqual(['one', 'two']); + }); + + it('renders compaction summaries as divider turns', () => { + const turns = messagesToTurns( + [ + message('s1', 'assistant', [{ type: 'text', text: 'summary' }], { + metadata: { origin: { kind: 'compaction_summary' } }, + }), + ], + [], + undefined, + false, + ); + + expect(turns).toMatchObject([{ role: 'compaction', text: 'summary' }]); + }); + + it('renders a live multi-member swarm inline as a tool card', () => { + const turns = messagesToTurns( + [ + message('u1', 'user', [{ type: 'text', text: 'run a swarm' }]), + message('a1', 'assistant', [ + { type: 'toolUse', toolCallId: 'swarm-1', toolName: 'AgentSwarm', input: {} }, + ]), + ], + [], + undefined, + true, + ); + + const assistant = turns.at(-1); + expect(assistant?.tools).toContainEqual( + expect.objectContaining({ id: 'swarm-1', name: 'AgentSwarm', status: 'running' }), + ); + expect(assistant?.blocks ?? []).not.toContainEqual( + expect.objectContaining({ kind: 'agentGroup' }), + ); + }); + + it('renders a completed multi-member swarm inline as a tool card', () => { + const turns = messagesToTurns( + [ + message('u1', 'user', [{ type: 'text', text: 'run a swarm' }]), + message('a1', 'assistant', [ + { type: 'toolUse', toolCallId: 'swarm-2', toolName: 'AgentSwarm', input: {} }, + ]), + message('t1', 'tool', [{ type: 'toolResult', toolCallId: 'swarm-2', output: 'all done' }]), + ], + [], + undefined, + false, + ); + + const assistant = turns.at(-1); + expect(assistant?.tools).toContainEqual( + expect.objectContaining({ id: 'swarm-2', name: 'AgentSwarm', status: 'ok' }), + ); + expect(assistant?.blocks ?? []).not.toContainEqual( + expect.objectContaining({ kind: 'agentGroup' }), + ); + }); + + it('renders a single subagent spawn as a tool card, not an agent block', () => { + const turns = messagesToTurns( + [ + message('u1', 'user', [{ type: 'text', text: 'go explore' }]), + message('a1', 'assistant', [ + { + type: 'toolUse', + toolCallId: 'agent-call-1', + toolName: 'Agent', + input: { description: 'explore the repo', prompt: 'list the top-level dirs' }, + }, + ]), + message('t1', 'tool', [{ type: 'toolResult', toolCallId: 'agent-call-1', output: 'done' }]), + ], + [], + undefined, + false, + ); + + const assistant = turns.at(-1); + // The spawning `Agent` call renders as a normal tool card (args + result)… + expect(assistant?.tools).toContainEqual( + expect.objectContaining({ id: 'agent-call-1', name: 'Agent', status: 'ok' }), + ); + // …and never as an inline agent/agentGroup block (live progress moves to + // the right-side panel). + expect(assistant?.blocks ?? []).not.toContainEqual(expect.objectContaining({ kind: 'agent' })); + expect(assistant?.blocks ?? []).not.toContainEqual( + expect.objectContaining({ kind: 'agentGroup' }), + ); + }); + + it('renders a `