Merge remote-tracking branch 'upstream/main' into spine-v2

# Conflicts:
#	apps/kimi-code/src/cli/run-shell.ts
#	apps/kimi-code/src/tui/components/chrome/footer.ts
#	apps/kimi-code/test/cli/run-shell.test.ts
#	apps/kimi-web/src/components/chat/ChatDock.vue
#	apps/kimi-web/src/components/chat/Composer.vue
#	apps/kimi-web/src/components/chat/ConversationPane.vue
#	packages/agent-core-v2/scripts/check-domain-layers.mjs
#	packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts
#	packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts
#	packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
#	packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts
#	packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts
#	packages/agent-core-v2/src/agent/profile/profileService.ts
#	packages/agent-core-v2/src/agent/replayBuilder/replayTimelineModel.ts
#	packages/agent-core-v2/src/agent/usage/usageOps.ts
#	packages/agent-core-v2/src/index.ts
#	packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts
#	packages/agent-core-v2/test/agent/contextSize/contextSize.test.ts
#	packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts
#	packages/agent-core-v2/test/agent/loop/loop.test.ts
#	packages/agent-core-v2/test/agent/plan/plan.test.ts
#	packages/agent-core-v2/test/app/config/config.test.ts
#	packages/agent-core-v2/test/session/todo/sessionTodo.test.ts
#	packages/agent-core-v2/test/tool/tool.test.ts
#	packages/node-sdk/src/sdk-rpc-client.ts
This commit is contained in:
7Sageer 2026-07-17 13:29:39 +08:00
commit 518e4d566d
1083 changed files with 74669 additions and 14951 deletions

View file

@ -7,7 +7,7 @@ Base classes and serialization are **centralized** in `_base/errors`; error **co
## Where things live
- `src/_base/errors/errors.ts`: base classes — `Error2`, `ExpectedError`, `ErrorNoTelemetry`, `BugIndicatingError`, `NotImplementedError`, plus `isError2` and `unwrapErrorCause`.
- `src/_base/errors/codes.ts`: the `ErrorDomain` contract, the `ErrorCode` type (aliased to the protocol's `KimiErrorCode`), the registry (`registerErrorDomain` / `errorInfo` / `isErrorCode`), and `CoreErrors` (`internal`, `not_implemented`).
- `src/_base/errors/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/<domain>/errors.ts`: the domain's `XxxErrors` descriptor (codes + retryable list + per-code info overrides), self-registered on import.
@ -17,7 +17,7 @@ Base classes and serialization are **centralized** in `_base/errors`; error **co
- **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 `<domain>/errors.ts` as an `XxxErrors` descriptor (`satisfies ErrorDomain` + `registerErrorDomain`), then wire it into the facade. Never add domain codes to `_base/errors`.
- **One `code` per failure mode.** Codes read `domain.reason`. The valid code strings are fixed by the protocol (`KimiErrorCode` in `packages/protocol/src/events.ts`): **add new codes to the protocol first**. Renaming/removing a code is a major.
- **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.
@ -35,6 +35,6 @@ Base classes and serialization are **centralized** in `_base/errors`; error **co
## Red lines (this topic)
- Throw a coded error with a `code`, not a bare string (except unreachable guards / `BugIndicatingError` / `NotImplementedError`).
- Codes live in the owning domain's `errors.ts` and self-register; new codes land in the protocol first.
- 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`.

View file

@ -38,20 +38,20 @@ Pick surface → Read the v1 route (if any) → Reuse / add the protocol schema
Apply the decision above. For a v1-matched endpoint, the **spec** is the protocol schema plus the existing mirror routes:
- `packages/protocol/src/rest/<resource>.ts` — the wire schema you must match.
- `packages/kap-server/src/protocol/rest-<resource>.ts` — the wire schema you must match.
- `packages/kap-server/src/routes/<resource>.ts` — the file you are writing (create it if missing); sibling route files show the conventions.
The protocol schema is the source of truth. Do not re-derive the wire shape from memory or from the v2 domain model.
### 2. Reuse (or add) the protocol schema
The wire schema lives in **`@moonshot-ai/protocol`** under `packages/protocol/src/rest/<resource>.ts` (e.g. `promptSubmissionSchema`, `promptListResponseSchema`, `configResponseSchema`). Every `/api/v1` route in `packages/kap-server` imports from it — that single import is what guarantees the server speaks the same shape released clients expect.
The wire schema lives in **`packages/kap-server/src/protocol`** under `rest-<resource>.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 in protocol** → add it to `packages/protocol/src/rest/<resource>.ts` first, with a `rest-<resource>.test.ts`, then consume it from the route. The protocol package is the source of truth; server-v2 never owns a v1 wire schema locally.
- **Schema exists but only v1 uses it**move/keep it in protocol and import it into server-v2; do not fork a copy.
- **Schema missing** → add it to `packages/kap-server/src/protocol/rest-<resource>.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)
@ -59,7 +59,7 @@ For a `/api/v1` endpoint, the request and response schemas **must be the establi
- ✅ **Adding** an optional field is allowed (`field: z.string().optional()`). Old clients ignore it; new clients may send it.
- ❌ **Renaming** a field, **changing** its type, **tightening** its validation, or **changing its meaning** is a wire break — do not do it in a mirror route. If the v2 domain genuinely needs a different shape, that shape belongs on `/api/v2`, not on the `/api/v1` mirror.
- ❌ Re-declaring the schema inline in server-v2 (even if it "looks identical") is forbidden — it drifts. One schema, one home: `packages/protocol`.
- ❌ 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.
@ -94,8 +94,8 @@ packages/agent-core-v2/src/<domain>Legacy/
Skeleton (matches `prompt/`):
```ts
// prompt.ts — contract shaped by @moonshot-ai/protocol
import type { PromptSubmitResult, PromptSubmission } from '@moonshot-ai/protocol';
// 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 {
@ -128,7 +128,7 @@ Conventions:
- **Header comment** must say it is an `L7 edge adapter` and name both the v1 contract it implements and the native v2 Service it leaves untouched (see `prompt.ts`).
- **Scope** = the lifetime of the *legacy* state it holds (the `prompt` queue is per-agent → `LifecycleScope.Agent`). Apply [orient.md](orient.md) / [design.md](design.md) normally — a LegacyService is not exempt from scope rules.
- **Delegate, do not duplicate** business logic. The LegacyService translates the v1 contract into native-Service calls and translates results back; the real work stays in the native Service.
- **Contract types come from `@moonshot-ai/protocol`**, so the interface cannot drift from the wire shape.
- **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
@ -139,9 +139,9 @@ const route = defineRoute(
{
method: 'POST',
path: '/sessions/{session_id}/prompts',
body: promptSubmissionSchema, // ← from @moonshot-ai/protocol
body: promptSubmissionSchema, // ← from kap-server/src/protocol
params: sessionIdParamSchema,
success: { data: promptSubmitResultSchema }, // ← from @moonshot-ai/protocol
success: { data: promptSubmitResultSchema }, // ← from kap-server/src/protocol
errors: {
[ErrorCode.SESSION_NOT_FOUND]: {},
[ErrorCode.SESSION_BUSY]: {},
@ -169,7 +169,7 @@ app.post(route.path, route.options, route.handler);
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 `<domain>Legacy/errors.ts` (e.g. `prompt.not_found`, `session.busy`).
- **Wire code** — register the matching number in `packages/protocol/src/error-codes.ts` and reference it in the route's `errors` map and `sendMappedError`.
- **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) {
@ -202,10 +202,10 @@ Where the route mirrors v1, the test is the regression guard for the schema-fide
### 7. Verify
- `pnpm -C packages/kap-server test` — server routes green.
- `pnpm -C packages/protocol test` — schema tests green (incl. any new `rest-*.test.ts`).
- `pnpm -C packages/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/server-e2e ...` when a v1 parity scenario exists.
- `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`
@ -218,9 +218,9 @@ This is the reference alignment (commits `feat(server-v2): port v1 /sessions/:si
- `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to `IAgentRPCService` (a wire facade over the v2 turn driver) in `actionMap`. The native `IAgentPromptService` is untouched.
- `/api/v1` gets an `AgentPromptLegacyService` (`prompt/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IAgentPromptService`. The `/api/v1` routes consume the LegacyService.
**The schema.** Both surfaces import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from `@moonshot-ai/protocol`. The `/api/v1` and `/api/v2` routes are therefore compatible with released clients by construction; the LegacyService projects v2 turn results back into those protocol shapes.
**The 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/protocol` (`error-codes.ts`), then mapped in the route's `sendMappedError` — including the idempotent `prompt.already_completed``40903 { data: { aborted: false } }`.
**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.
@ -230,21 +230,21 @@ Before submitting a server-align change:
- [ ] Surface chosen deliberately: `/api/v1` mirror for a v1-matched endpoint, `/api/v2` for a new native capability (both if needed).
- [ ] For a `/api/v1` mirror, the route matches the established v1 contract (protocol schema + sibling routes) path-for-path, verb-for-verb, action-for-action.
- [ ] Request and response schemas come from `@moonshot-ai/protocol` (`packages/protocol/src/rest/<resource>.ts`); no inline re-declaration in server-v2.
- [ ] 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 `<domain>Legacy` / `I<Domain>LegacyService` edge adapter when the semantics diverge.
- [ ] LegacyService registered with the correct `LifecycleScope` and a header comment naming it an L7 edge adapter + the native Service it preserves.
- [ ] Domain error codes registered in `agent-core-v2`; wire codes registered in `packages/protocol`; route maps them in `sendMappedError`, matching v1's status codes and idempotent envelopes.
- [ ] 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; schema tests in `packages/protocol` added/updated.
- [ ] 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: `packages/protocol`. Never re-declare a v1 wire schema inline in server-v2.
- 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 `<domain>Legacy` edge adapter instead. The native Service serves the v2 architecture; the LegacyService serves the wire contract.
- A LegacyService is still a v2 Service: it follows scope, domain-direction, and DI rules. "Edge adapter" describes its role, not an exemption.
- The protocol schema (`packages/protocol/src/rest/<resource>.ts`) plus the existing mirror routes are the spec for a `/api/v1` route — match them; do not re-derive the wire shape from the v2 domain model or from memory.
- Register every new error code in **both** `agent-core-v2` and `packages/protocol`; an unmapped code is a wire break.
- 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.

View file

@ -24,7 +24,6 @@ All other workspace packages are private internal packages, are not published to
- `@moonshot-ai/kosong`
- `@moonshot-ai/migration-legacy`
- `@moonshot-ai/protocol`
- `@moonshot-ai/server-e2e`
- `@moonshot-ai/vis`
- `@moonshot-ai/vis-server`
- `@moonshot-ai/vis-web`

View file

@ -7,7 +7,6 @@
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [
"@moonshot-ai/server-e2e",
"@moonshot-ai/vis",
"@moonshot-ai/vis-server",
"@moonshot-ai/vis-web"

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
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.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix repeated request rejections after an interrupted model response by recording tool calls that never ran and closing them with an interrupted result.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix the built-in URL fetch tool's network safeguards: crafted domains and redirect chains can no longer reach loopback or internal network services.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Remove per-workspace git repo badges and branch labels; branch, PR, and diff status remain shown for the active session.

View file

@ -28,6 +28,48 @@ jobs:
- name: Smoke test CLI bundle
run: pnpm -C apps/kimi-code run smoke
vscode-vsix-package:
name: VSIX package audit (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: all
- os: macos-latest
target: darwin-arm64
- os: windows-latest
target: win32-x64
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
- name: Build and audit target VSIX
run: pnpm --filter kimi-code run package:platform -- --target "${{ matrix.target }}"
- name: Run installed VSIX Extension Host smoke (Linux)
if: runner.os == 'Linux'
run: xvfb-run -a pnpm --filter kimi-code run test:extension-host -- --version 1.100.0
- name: Run installed VSIX Extension Host smoke on stable (Linux)
if: runner.os == 'Linux'
run: xvfb-run -a pnpm --filter kimi-code run test:extension-host -- --version stable
- name: Run installed VSIX Extension Host smoke
if: runner.os != 'Linux'
run: pnpm --filter kimi-code run test:extension-host -- --version 1.100.0
- uses: actions/upload-artifact@v4
with:
name: vscode-vsix-${{ matrix.target }}
path: apps/vscode/artifacts/vsix/*.vsix
if-no-files-found: error
test:
runs-on: ubuntu-latest
strategy:
@ -125,6 +167,8 @@ 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

3
.gitignore vendored
View file

@ -20,6 +20,9 @@ plugins/cdn/
.kimi-code/local.toml
.kimi-sandbox/
.vscode/
!apps/vscode/.vscode/
!apps/vscode/.vscode/*.json
apps/vscode/artifacts/
Dockerfile
docker-compose.yml

View file

@ -24,7 +24,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
- `packages/oauth`: Kimi OAuth and managed auth utilities.
- `packages/telemetry`: shared client-side telemetry infrastructure.
- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`.
- `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`.
- `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

View file

@ -1,5 +1,151 @@
# @moonshot-ai/kimi-code
## 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

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/kimi-code",
"version": "0.24.1",
"version": "0.26.0",
"description": "The Starting Point for Next-Gen Agents",
"license": "MIT",
"author": "Moonshot AI",

View file

@ -2,7 +2,7 @@ import { execSync, spawnSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { log } from '@moonshot-ai/kimi-code-sdk';
import { flushDiagnosticLogsSync, log } from '@moonshot-ai/kimi-code-sdk';
import {
setCrashPhase,
setTelemetryContext,
@ -176,6 +176,14 @@ export async function runShell(
// 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);

View file

@ -401,6 +401,9 @@ async function runServerInProcess(
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,

View file

@ -10,7 +10,8 @@
* native `DomainEvent` stream (payloads are already v1-protocol-shaped),
* - drives a turn through `IAgentPromptService.enqueue()` and awaits
* `Turn.result` for authoritative completion,
* - drains background tasks (config-driven) before exiting.
* - 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.
*/
@ -34,13 +35,16 @@ import {
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';
@ -81,12 +85,9 @@ import {
const PROMPT_UI_MODE = 'print';
const DEFAULT_PRINT_WAIT_CEILING_S = 3600;
const TASK_CONFIG_SECTION = 'task';
const LEGACY_BACKGROUND_CONFIG_SECTION = 'background';
interface TaskPrintWaitConfig {
readonly printWaitCeilingS?: number;
}
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,
@ -158,9 +159,11 @@ export async function runV2Print(
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup);
try {
const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
restorePermission = resolved.restorePermission;
// 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(
@ -168,12 +171,16 @@ export async function runV2Print(
deviceId,
appName: CLI_USER_AGENT_PRODUCT,
uiMode: PROMPT_UI_MODE,
model: resolved.telemetryModel,
model: opts.model ?? defaultModel,
getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null,
}),
);
}
telemetryService.setContext({ sessionId: resolved.session.id });
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');
}
@ -336,8 +343,13 @@ async function runNativeTurn(
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({
@ -361,16 +373,41 @@ async function runNativeTurn(
}
const result = await turn.result;
// Turn settled, but `-p` is not done until any background work the turn
// spawned has drained (config-bounded). Flush the buffered assistant
// message first so a long drain does not withhold the final message.
// 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 drainBackgroundTasks(app, session);
} catch {
// Draining is best-effort; a wedged background task must not fail the
// (already completed) turn. Swallow and proceed to finish.
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;
@ -466,12 +503,182 @@ function dispatchNativeEvent(
}
}
async function drainBackgroundTasks(app: Scope, session: ISessionScopeHandle): Promise<void> {
const config = app.accessor.get(IConfigService);
const section =
config.get<TaskPrintWaitConfig>(TASK_CONFIG_SECTION) ??
config.get<TaskPrintWaitConfig>(LEGACY_BACKGROUND_CONFIG_SECTION);
const ceilingS = section?.printWaitCeilingS;
export type PrintTurnEnding = Extract<DomainEvent, { type: 'turn.ended' }>;
/**
* 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<PrintTurnEnding | null>;
}
/**
* 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<PrintTurnEnding | null> =>
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<void>;
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<void> {
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<void> {
const ceilingMs =
typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0
? ceilingS * 1000

View file

@ -18,6 +18,7 @@ import { dirname } from 'node:path';
import {
bootstrap,
ensureMainAgent,
IAgentActivityView,
IAgentPermissionModeService,
IAgentProfileService,
IBootstrapService,
@ -26,7 +27,6 @@ import {
IModelResolver,
IPluginService,
IProviderService,
ISessionActivity,
ISessionContext,
ISessionExportService,
ISessionIndex,
@ -71,7 +71,6 @@ import type {
FlagExplanation,
PermissionMode,
ResumedSessionState,
SessionEvent,
TelemetryClient,
TelemetryContextPatch,
TelemetryProperties,
@ -311,11 +310,15 @@ export class CoreHarness {
// TODO(v2-gap): G-5 — v2 cannot replay plugin session-start reminders;
// `input.forcePluginSessionStartReminder` is accepted and ignored.
const active = this.activeSessions.get(id);
if (active !== undefined && active.handle.accessor.get(ISessionActivity).status() !== 'idle') {
throw new CoreError(
CoreErrorCodes.TURN_AGENT_BUSY,
`Session "${id}" is busy; wait for the current turn to finish before reloading.`,
);
if (active !== undefined) {
const main = await ensureMainAgent(active.handle);
const activity = main.accessor.get(IAgentActivityView).state();
if (activity.turn !== undefined || activity.background.length > 0) {
throw new CoreError(
CoreErrorCodes.TURN_AGENT_BUSY,
`Session "${id}" is busy; wait for the current turn to finish before reloading.`,
);
}
}
await this.deps.app.accessor.get(IPluginService).reloadPlugins();
if (active !== undefined) {

View file

@ -19,20 +19,24 @@ import {
IAgentPermissionModeService,
IAgentPermissionRulesService,
IAgentPlanService,
AGENT_WIRE_RECORD_KEY,
IAgentProfileService,
IAgentScopeContext,
IAgentSwarmService,
IAgentTaskService,
IAgentToolRegistryService,
IAgentUsageService,
IAgentWireRecordService,
IAppendLogStore,
ISessionMetadata,
ISessionTodoService,
IWireService,
MAIN_AGENT_ID,
type AgentMeta,
type IAgentScopeHandle,
type ISessionScopeHandle,
type SessionMeta,
type ToolInfo,
type WireRecord,
} from '@moonshot-ai/agent-core-v2';
import {
@ -107,7 +111,17 @@ export async function buildResumedAgents(
async function buildReplayFromWireRecords(
accessor: IAgentScopeHandle['accessor'],
): Promise<AgentReplayRecord[]> {
const records = accessor.get(IAgentWireRecordService).getRecords();
// Flush first so the read sees every appended record, then fold the
// persisted journal one-shot — the same read pattern
// `MessageLegacyService.readTranscript` uses.
await accessor.get(IWireService).flush();
const scope = accessor.get(IAgentScopeContext).scope();
const records: WireRecord[] = [];
for await (const record of accessor
.get(IAppendLogStore)
.read<WireRecord>(scope, AGENT_WIRE_RECORD_KEY)) {
records.push(record);
}
const { entries } = reduceTranscript(records);
const rehydrated = await rehydrateTranscript(entries, accessor.get(IAgentBlobService));
return rehydrated.map(projectTranscriptEntry);
@ -190,7 +204,7 @@ function projectSessionMetadata(meta: SessionMeta): ResumedSessionMetadata {
function projectAgentMeta(id: string, meta: AgentMeta): ResumedAgentMeta {
const type = meta.type === 'main' || meta.type === 'sub' ? meta.type : id === MAIN_AGENT_ID ? 'main' : 'sub';
return {
homedir: meta.homedir,
homedir: meta.homedir ?? '',
type,
parentAgentId: meta.labels?.['parentAgentId'] ?? meta.parentAgentId ?? null,
swarmItem: meta.labels?.['swarmItem'] ?? meta.swarmItem,

View file

@ -13,12 +13,12 @@
* be assignable to each other, so a transcript can never be fed to the model
* by accident (and vice versa).
*
* Form: the reducer set is declared through v2's public `defineDerivedModel`
* (the same shape a wire-attached derived model would use), but the TUI folds
* it manually over `IAgentWireRecordService.getRecords()` resume's
* `wire.replay` runs inside v2 before the facade sees the session, so a
* facade-side `wire.attach` would always miss the restore fold. Replay is
* read exactly once per resume, so a one-shot reduce loses nothing.
* Form: the reducer set is a plain local table (the same shape a wire model
* would declare), folded one-shot over the persisted `wire.jsonl` records
* (`IAppendLogStore.read`) resume's `wire.restore` runs inside v2 before
* the facade sees the session, so a facade-side live fold would always miss
* the restore. Replay is read exactly once per resume, so a one-shot reduce
* loses nothing.
*
* Record entry mapping (op types not exported by the v2 barrel are keyed by
* their literal wire names; payloads are declared locally):
@ -50,7 +50,6 @@ import {
contextApplyCompaction,
contextClear,
contextUndo,
defineDerivedModel,
isRealUserInput,
planModeCancel,
planModeEnter,
@ -485,10 +484,9 @@ function snapshotFromGoalStateLike(goal: GoalStateLike): GoalSnapshot {
// -- public model + one-shot reduce -----------------------------------------
export const TranscriptModel = defineDerivedModel<TranscriptModelState>(
'kimi.tui.transcript',
() => ({ entries: [], working: INITIAL_WORKING }),
{
type TranscriptReducer = (state: TranscriptModelState, payload: unknown) => TranscriptModelState;
const transcriptReducers: Record<string, TranscriptReducer> = {
[contextAppendMessage.type]: (state, payload: unknown) =>
applyAppendMessage(state, toMutableMessage((payload as { message: ContextMessageLike }).message)),
[contextAppendLoopEvent.type]: (state, payload: unknown) =>
@ -525,8 +523,13 @@ export const TranscriptModel = defineDerivedModel<TranscriptModelState>(
entries: [...state.entries, { type: 'config_updated', config: payload as ConfigUpdateWirePayload }],
working: state.working,
}),
},
);
};
export const TranscriptModel = {
name: 'kimi.tui.transcript',
initial: (): TranscriptModelState => ({ entries: [], working: INITIAL_WORKING }),
reducers: transcriptReducers,
};
function stripSummaryPrefix(text: string): string {
return text.startsWith(COMPACTION_SUMMARY_PREFIX)

View file

@ -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<MigrationPlan | null> {
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;
}
}

View file

@ -36,6 +36,21 @@ 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,
@ -46,6 +61,14 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig {
};
}
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<void> {
const session = host.session;
if (session === undefined) {
@ -252,7 +275,7 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string):
host.showError('No model selected. Run /model to select one first.');
return;
}
const effective = effectiveModelAlias(model);
const effective = effectiveModelForHost(host, model);
const segments = segmentsFor(effective);
const arg = args.trim().toLowerCase();
if (arg.length === 0) {
@ -260,10 +283,19 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string):
return;
}
if (!segments.includes(arg)) {
host.showError(
`Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`,
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',
);
return;
}
await performModelSwitch(host, alias, arg, true);
}
@ -280,6 +312,7 @@ function showEffortPicker(
new EffortSelectorComponent({
efforts: segments,
currentValue,
warning: hasConversationHistory(host) ? EFFORT_SWITCH_CACHE_WARNING : undefined,
onSelect: (effort) => {
host.restoreEditor();
void performModelSwitch(host, alias, effort, true);
@ -376,7 +409,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',
@ -386,10 +425,11 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
}
host.mountEditorReplacement(
new TabbedModelSelectorComponent({
models: host.state.appState.availableModels,
models,
currentValue: host.state.appState.model,
selectedValue,
currentThinkingEffort: host.state.appState.thinkingEffort,
warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();
void performModelSwitch(host, alias, thinking, true);

View file

@ -3,7 +3,7 @@
*
* Layout:
* Line 1: [yolo] [plan] <model> <cwd> <git-badge> <shortcut hints>
* Line 2: context: XX.X% (raw X / projection Y / all Z)
* Line 2: context: N% (raw X / projection Y / all Z)
*/
import type { Component } from '@moonshot-ai/pi-tui';
@ -23,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;
@ -154,33 +158,33 @@ 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: the folded projection size against the window, with
* the raw stored-history size alongside for fold visibility. 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,
rawTokens?: number,
): string {
const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`;
if (maxTokens && maxTokens > 0 && tokens !== undefined) {
if (maxTokens !== undefined && maxTokens > 0 && tokens !== undefined) {
const pct = String(usagePercent(tokens, maxTokens));
// raw = whole stored history, projection = folded view the model sees,
// all = the context-window ceiling the percentage is measured against.
const raw = rawTokens ?? tokens;
return (
`context: ${pct} (raw ${formatTokenCount(raw)} / ` +
`projection ${formatTokenCount(tokens)} / all ${formatTokenCount(maxTokens)})`
);
// raw reads 0 until the first measurement — show the compact form then.
if (rawTokens !== undefined && rawTokens > 0) {
return (
`context: ${pct}% (raw ${formatTokenCount(rawTokens)} / ` +
`projection ${formatTokenCount(tokens)} / all ${formatTokenCount(maxTokens)})`
);
}
return `context: ${pct}% (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`;
}
return `context: ${pct}`;
return `context: ${String(usagePercentFromRatio(usage))}%`;
}
export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string {

View file

@ -3,6 +3,7 @@ import {
Key,
matchesKey,
truncateToWidth,
wrapTextWithAnsi,
type Focusable,
} from '@moonshot-ai/pi-tui';
@ -22,6 +23,10 @@ export interface EffortSelectorOptions {
/** 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;
}
/**
@ -76,8 +81,13 @@ export class EffortSelectorComponent extends Container implements Focusable {
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);

View file

@ -5,6 +5,7 @@ import {
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type Focusable,
} from '@moonshot-ai/pi-tui';
@ -73,6 +74,10 @@ 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. */
@ -286,8 +291,13 @@ export class ModelSelectorComponent extends Container implements Focusable {
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));

View file

@ -43,6 +43,10 @@ export interface TabbedModelSelectorOptions {
/** 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. */
@ -100,24 +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.
// 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 out: string[] = [
inner[0] ?? '',
inner[1] ?? '',
inner[2] ?? '',
inner[3] ?? '',
stripLine,
'',
];
for (let i = 4; i < inner.length; i++) out.push(inner[i]!);
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));
}
@ -182,6 +182,7 @@ function makeSelector(
currentThinkingEffort: opts.currentThinkingEffort,
searchable: true,
providerSwitchHint: true,
warning: opts.warning,
onSelect: opts.onSelect,
onSessionOnlySelect: opts.onSessionOnlySelect,
onCancel: opts.onCancel,

View file

@ -397,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;
}
}

View file

@ -20,6 +20,7 @@ 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';
@ -371,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`;
}

View file

@ -20,6 +20,7 @@ import {
ratioSeverity,
renderProgressBar,
safeUsageRatio,
usagePercent,
} from '#/utils/usage/usage-format';
import {
@ -133,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 {

View file

@ -28,6 +28,7 @@ import type { TokenUsage } from '#/core/index';
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';
@ -138,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 {
@ -154,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 {
@ -2318,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(

View file

@ -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';
@ -266,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'));

View file

@ -667,7 +667,7 @@ export class SessionReplayRenderer {
switch (result.decision) {
case 'rejected':
content =
result.selected_label === 'Revise' ? 'Plan sent back for revision' : 'Plan review rejected';
result.selectedLabel === 'Revise' ? 'Plan sent back for revision' : 'Plan review rejected';
break;
case 'cancelled':
content = 'Plan review cancelled';

View file

@ -1,5 +1,7 @@
import type { GoalSnapshot } from '#/core/index';
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`;
}

View file

@ -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)));
}
/**

View file

@ -61,6 +61,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,
@ -72,6 +73,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
return {
...actual,
resolveKimiHome: mocks.resolveKimiHome,
flushDiagnosticLogsSync: mocks.flushDiagnosticLogsSync,
};
});
@ -567,6 +569,101 @@ describe('runShell', () => {
expect(mocks.kimiTuiConstructor).not.toHaveBeenCalled();
});
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',

View file

@ -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 });
});
});

View file

@ -11,6 +11,7 @@ import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
IAgentActivityView,
IAgentBlobService,
IAgentContextMemoryService,
IAgentContextSizeService,
@ -19,11 +20,12 @@ import {
IAgentPermissionRulesService,
IAgentPlanService,
IAgentProfileService,
IAgentScopeContext,
IAgentSwarmService,
IAgentTaskService,
IAgentToolRegistryService,
IAgentUsageService,
IAgentWireRecordService,
IAppendLogStore,
IBootstrapService,
IConfigService,
IEventBus,
@ -32,7 +34,6 @@ import {
IModelResolver,
IPluginService,
IProviderService,
ISessionActivity,
ISessionApprovalService,
ISessionContext,
ISessionCronService,
@ -44,6 +45,7 @@ import {
ISessionQuestionService,
ISessionTodoService,
ISessionWorkspaceContext,
IWireService,
IWorkspaceRegistry,
} from '@moonshot-ai/agent-core-v2';
import { CoreErrorCodes, isCoreError } from '../../src/core/errors';
@ -173,7 +175,18 @@ function makeFixture(options?: {
[IAgentUsageService, { status: () => usage }],
[IAgentToolRegistryService, { list: () => [] }],
[IAgentTaskService, { list: () => [] }],
[IAgentWireRecordService, { getRecords: () => [] }],
[
IAgentActivityView,
{
state: () =>
options?.activityStatus === 'running'
? { lifecycle: 'ready', turn: { turnId: 1 }, background: [] }
: { lifecycle: 'ready', background: [] },
},
],
[IWireService, { flush: () => Promise.resolve() }],
[IAgentScopeContext, { scope: () => 'agent-main' }],
[IAppendLogStore, { read: async function* () {} }],
[IAgentBlobService, { loadParts: async (parts: readonly unknown[]) => parts }],
] as ReadonlyArray<readonly [unknown, unknown]>,
};
@ -223,7 +236,6 @@ function makeFixture(options?: {
ISessionWorkspaceContext,
{ workDir, additionalDirs: ['/extra'], addAdditionalDir: record(`${sid}.addAdditionalDir`) },
],
[ISessionActivity, { status: () => options?.activityStatus ?? 'idle' }],
[ISessionTodoService, { getTodos: () => [] }],
]),
};

View file

@ -11,13 +11,15 @@ import {
IAgentPermissionRulesService,
IAgentPlanService,
IAgentProfileService,
IAgentScopeContext,
IAgentSwarmService,
IAgentTaskService,
IAgentToolRegistryService,
IAgentUsageService,
IAgentWireRecordService,
IAppendLogStore,
ISessionMetadata,
ISessionTodoService,
IWireService,
} from '@moonshot-ai/agent-core-v2';
import { buildResumedAgents, buildResumedSessionState } from '../../src/core/replay';
@ -141,7 +143,16 @@ function makeFixture(metaOverrides?: Record<string, unknown>) {
[IAgentSwarmService, { isActive: true }],
[IAgentUsageService, { status: () => usage }],
[IAgentToolRegistryService, { list: () => toolInfos }],
[IAgentWireRecordService, { getRecords: () => wireRecords }],
[IWireService, { flush: () => Promise.resolve() }],
[IAgentScopeContext, { scope: () => 'agent-main' }],
[
IAppendLogStore,
{
read: async function* () {
for (const record of wireRecords) yield record;
},
},
],
[IAgentBlobService, { loadParts: async (parts: readonly unknown[]) => parts }],
[
IAgentTaskService,

View file

@ -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);

View file

@ -102,4 +102,50 @@ describe('EffortSelectorComponent', () => {
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, ''));
});
});

View file

@ -338,6 +338,55 @@ describe('ModelSelectorComponent', () => {
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({
@ -421,6 +470,45 @@ describe('ModelSelectorComponent', () => {
// 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', () => {

View file

@ -131,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);
});
});

View file

@ -587,6 +587,34 @@ 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', () => {

View file

@ -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', () => {

View file

@ -60,8 +60,8 @@ describe('status panel report lines', () => {
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');

View file

@ -40,9 +40,9 @@ 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');

View file

@ -45,32 +45,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('renders raw / projection / all token counts when the window is known', () => {
@ -83,7 +101,7 @@ describe('FooterComponent — context NaN resilience', () => {
}),
);
const out = strip(fc.render(200).join(''));
expect(out).toMatch(/context: 25\.0% \(raw 400\.0k \/ projection 250\.0k \/ all 1\.0M\)/);
expect(out).toMatch(/context: 25% \(raw 391k \/ projection 244k \/ all 977k\)/);
});
it('tokens provided but max=0 → falls back to percent-only, no division-by-zero artefact', () => {
@ -92,7 +110,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\//);
});
@ -105,7 +123,7 @@ 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', () => {
@ -123,7 +141,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', () => {

View file

@ -20,6 +20,7 @@ import type { 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 {
@ -4583,7 +4584,7 @@ command = "vim"
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%');
});
});
@ -5881,16 +5882,20 @@ describe('/model status displayName override', () => {
});
describe('/effort support_efforts override', () => {
it('rejects efforts hidden by support_efforts override', async () => {
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: 'managed:kimi-code',
model: 'kimi-k2',
provider: 'compatible',
model: 'compatible-model',
protocol: 'anthropic',
maxContextSize: 100,
displayName: 'Kimi K2',
displayName: 'Compatible Model',
capabilities: ['thinking'],
supportEfforts: ['low', 'high', 'max'],
overrides: { supportEfforts: ['low', 'high'] },
@ -5904,8 +5909,125 @@ describe('/effort support_efforts override', () => {
await driver.handleUserInput('/effort max');
await vi.waitFor(() => {
expect(renderTranscript(driver)).toContain('Unsupported thinking effort "max" for k2. Available: off, low, high');
expect(session.setThinking).toHaveBeenCalledWith('max');
});
expect(renderTranscript(driver)).not.toContain('Switched to Kimi K2 with thinking 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();
});
});

View file

@ -539,7 +539,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 () => {
@ -585,7 +585,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.',
});
});

View file

@ -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');
});

View file

@ -39,7 +39,7 @@ describe('formatStepDebugTiming', () => {
},
});
expect(result).toBe(
'[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2.0k | cache read 1.2k (60%) / write 100',
'[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2k | cache read 1.2k (60%) / write 100',
);
});
@ -54,7 +54,7 @@ describe('formatStepDebugTiming', () => {
output: 200,
},
});
expect(result).toContain('tokens in 1.0k');
expect(result).toContain('tokens in 1000');
expect(result).toContain('cache read 0 (0%)');
expect(result).not.toContain('/ write 0');
});

View file

@ -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));

View file

@ -3,25 +3,16 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="64x64" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)" />
<!-- Apply persisted display prefs BEFORE the bundle loads: without this,
users can get a color-scheme/font flash before useKimiWebClient mirrors
the data attributes. Mirrors applyColorSchemeToDocument. -->
<script>
(function () {
try {
var v = localStorage.getItem('kimi-web.color-scheme');
if (v === 'light' || v === 'dark' || v === 'system') {
document.documentElement.dataset.colorScheme = v;
}
} catch (e) {
/* ignore */
}
})();
</script>
the data attributes. Mirrors applyColorSchemeToDocument. Loaded from the
external /boot.js (a classic script, so still render-blocking) because
the server's Content-Security-Policy forbids inline scripts. -->
<script src="/boot.js"></script>
<title>Kimi Code Web</title>
</head>
<body>

View file

@ -0,0 +1,10 @@
(function () {
try {
var v = localStorage.getItem('kimi-web.color-scheme');
if (v === 'light' || v === 'dark' || v === 'system') {
document.documentElement.dataset.colorScheme = v;
}
} catch {
/* ignore */
}
})();

View file

@ -27,6 +27,9 @@ import GlobalLoading from './components/GlobalLoading.vue';
import DebugPanel from './debug/DebugPanel.vue';
import { isTraceEnabled } from './debug/trace';
import { useKimiWebClient } from './composables/useKimiWebClient';
import { useConfirmDialog } from './composables/useConfirmDialog';
import type { PromptAttachment } from './composables/useKimiWebClient';
import type { TurnAttachment } from './types';
import { useAuthGate } from './composables/useAuthGate';
import { usePageTitle } from './composables/usePageTitle';
import { useSidebarLayout } from './composables/useSidebarLayout';
@ -72,6 +75,7 @@ provide(
(toolCallId: string): SwarmMember[] => client.swarmMembersByToolCallId.value.get(toolCallId) ?? [],
);
const { t } = useI18n();
const { confirm } = useConfirmDialog();
// KAP/daemon debug panel opt-in via ?debug=1 or localStorage kimi-web.debug=1.
const debugEnabled = isTraceEnabled();
@ -143,6 +147,28 @@ function openOnboarding(): void {
showOnboarding.value = true;
}
// iOS Safari does not shrink `dvh` for the on-screen keyboard. Instead it pans
// the visual viewport (offsetTop > 0) to reveal the focused field, which a
// 100dvh in-flow shell cannot follow: the dock ends up behind the keyboard, or
// the page shows a blank band past the shell's bottom edge. Pin the shell to
// the VISUAL viewport instead: position:fixed + top/height mirrored from
// visualViewport (height shrinks with the keyboard, offsetTop tracks the pan).
// No-ops on desktop, where offsetTop is 0 and height equals innerHeight.
let appHeightRaf = 0;
function setAppHeight(): void {
const vv = window.visualViewport;
const root = document.documentElement.style;
root.setProperty('--app-height', `${vv?.height ?? window.innerHeight}px`);
root.setProperty('--app-top', `${vv?.offsetTop ?? 0}px`);
}
function syncAppHeight(): void {
if (appHeightRaf) return;
appHeightRaf = requestAnimationFrame(() => {
appHeightRaf = 0;
setAppHeight();
});
}
onMounted(() => {
// Register the 401 listener before the first requests go out, so a token
// rejection during the initial load() can never be missed.
@ -154,6 +180,10 @@ onMounted(() => {
});
void client.load();
loadSidebarCollapsed();
setAppHeight();
window.visualViewport?.addEventListener('resize', syncAppHeight);
window.visualViewport?.addEventListener('scroll', syncAppHeight);
window.addEventListener('resize', syncAppHeight);
// Capture-phase so Escape closes the side detail layer BEFORE the
// conversation pane's bubble-phase handler interrupts a running prompt.
document.addEventListener('keydown', onGlobalKeydown, true);
@ -161,6 +191,15 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('keydown', onGlobalKeydown, true);
window.visualViewport?.removeEventListener('resize', syncAppHeight);
window.visualViewport?.removeEventListener('scroll', syncAppHeight);
window.removeEventListener('resize', syncAppHeight);
if (appHeightRaf) {
cancelAnimationFrame(appHeightRaf);
appHeightRaf = 0;
}
document.documentElement.style.removeProperty('--app-height');
document.documentElement.style.removeProperty('--app-top');
if (offAuthRequired !== null) {
offAuthRequired();
offAuthRequired = null;
@ -283,7 +322,7 @@ const showSettings = ref(false);
type SubmitPayload = {
text: string;
attachments: { fileId: string; kind: 'image' | 'video' }[];
attachments: PromptAttachment[];
};
const pendingWorkspaceSubmit = ref<SubmitPayload | null>(null);
// Inline error shown inside the add-workspace picker after the daemon rejects
@ -321,8 +360,10 @@ async function openModelPicker(): Promise<void> {
modelsUnavailable.value = false;
showModelPicker.value = true;
try {
await client.refreshOAuthProviderModels();
await client.loadModels();
// Full refresh first (every refreshable provider, not just OAuth), so the
// list always reflects the live catalog the WS model-catalog event that
// used to keep the cache warm is no longer forwarded by the daemon.
await client.refreshAllProviders();
} catch {
modelsUnavailable.value = true;
} finally {
@ -376,14 +417,43 @@ async function handleAddProvider(input: { type: string; apiKey?: string; baseUrl
await client.addProvider(input);
}
async function handleDeleteProvider(id: string): Promise<void> {
await client.deleteProvider(id);
}
async function handleRefreshProvider(id: string): Promise<void> {
await client.refreshProvider(id);
}
// Destructive session/workspace/provider actions confirm through the shared
// modal here (the menu components only emit the intent). Each passes its work
// as the dialog `action`, so the dialog stays open with a loading state until
// the operation settles. All three client calls toast their own errors and
// never reject.
async function confirmArchiveSession(id: string): Promise<void> {
await confirm({
title: t('sidebar.archive'),
message: t('sidebar.archiveConfirm'),
variant: 'danger',
action: () => client.archiveSession(id),
});
}
async function confirmDeleteWorkspace(id: string): Promise<void> {
const name = client.workspacesView.value.find((w) => w.id === id)?.name ?? id;
await confirm({
title: t('sidebar.removeWorkspace'),
message: t('workspace.removeWorkspaceConfirm', { name }),
variant: 'danger',
action: () => client.deleteWorkspace(id),
});
}
async function confirmDeleteProvider(id: string): Promise<void> {
await confirm({
title: t('providers.delete'),
message: t('providers.confirmDelete'),
variant: 'danger',
action: () => client.deleteProvider(id),
});
}
async function handleUpdateConfig(patch: Partial<AppConfig>): Promise<void> {
configSaving.value = true;
try {
@ -420,11 +490,11 @@ async function handleLoginSuccess(): Promise<void> {
// then drop that message's text back into the composer for editing.
async function handleEditMessage(payload: {
text: string;
images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[];
attachments?: TurnAttachment[];
}): Promise<void> {
await client.undo(1);
await nextTick();
conversationPaneRef.value?.loadComposerForEdit(payload.text, payload.images);
conversationPaneRef.value?.loadComposerForEdit(payload.text, payload.attachments);
}
// Handler for slash commands emitted by Composer (via ConversationPane)
@ -671,11 +741,11 @@ function openPr(url: string): void {
@select-workspace="client.openWorkspace($event)"
@add-workspace="showAddWorkspace = true"
@rename="(id, title) => client.renameSession(id, title)"
@archive="(id) => client.archiveSession(id)"
@archive="confirmArchiveSession($event)"
@fork="(id) => client.forkSession(id)"
@export="(id) => client.exportSession(id)"
@rename-workspace="(id, name) => client.renameWorkspace(id, name)"
@delete-workspace="(id) => client.deleteWorkspace(id)"
@delete-workspace="confirmDeleteWorkspace($event)"
@reorder-workspaces="client.reorderWorkspaces($event)"
@set-workspace-sort-mode="client.setWorkspaceSortMode($event)"
@load-more-sessions="(id) => void client.loadMoreSessions(id)"
@ -732,10 +802,11 @@ function openPr(url: string): void {
:pending-question-actions="client.pendingQuestionActions"
:pending-approval-actions="client.pendingApprovalActions"
:running="running"
:turn-active="client.turnActive.value"
:queued="client.queued.value"
:search-files="client.searchFiles"
:upload-image="client.uploadImage"
:sending="client.isSending.value"
:working="client.working.value"
:starting="client.isStartingFirstPrompt.value"
:fast-moon="client.fastMoon.value"
:file-reload-key="client.activeSessionId.value"
@ -778,7 +849,7 @@ function openPr(url: string): void {
@refresh-git-status="client.activeSessionId.value && client.loadGitStatus(client.activeSessionId.value)"
@rename-session="(id, title) => client.renameSession(id, title)"
@fork-session="(id) => client.forkSession(id)"
@archive-session="(id) => client.archiveSession(id)"
@archive-session="confirmArchiveSession($event)"
@export-session="(id) => client.exportSession(id)"
@compact="client.compact()"
@pick-model="openModelPicker()"
@ -957,7 +1028,7 @@ function openPr(url: string): void {
:unavailable="providersUnavailable"
@add="handleAddProvider($event)"
@refresh="handleRefreshProvider($event)"
@delete="handleDeleteProvider($event)"
@delete="confirmDeleteProvider($event)"
@open-login="() => { showProviders = false; openLogin(); }"
@close="showProviders = false"
/>
@ -1022,8 +1093,8 @@ function openPr(url: string): void {
@create-in-workspace="handleCreateSessionInWorkspace($event)"
@add-workspace="showAddWorkspace = true"
@rename="(id, title) => client.renameSession(id, title)"
@archive="(id) => client.archiveSession(id)"
@delete-workspace="(id) => client.deleteWorkspace(id)"
@archive="confirmArchiveSession($event)"
@delete-workspace="confirmDeleteWorkspace($event)"
@load-more="(id) => void client.loadMoreSessions(id)"
/>
@ -1071,8 +1142,17 @@ function openPr(url: string): void {
.gload-fade-leave-to { opacity: 0; }
.app-shell {
/* Pinned to the visual viewport (see setAppHeight): --app-top tracks iOS's
keyboard pan and --app-height shrinks with the keyboard, so the shell
always covers exactly the visible area. Fixed positioning keeps it out of
the document flow that iOS pans. */
position: fixed;
top: var(--app-top, 0px);
left: 0;
right: 0;
height: 100vh;
height: 100dvh;
height: var(--app-height, 100dvh);
display: flex;
flex-direction: column;
overflow: hidden;
@ -1248,10 +1328,10 @@ function openPr(url: string): void {
.auth-page {
align-items: flex-start;
padding:
max(48px, env(safe-area-inset-top))
max(20px, env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom))
max(20px, env(safe-area-inset-left));
max(48px, var(--safe-top))
max(20px, var(--safe-right))
max(24px, var(--safe-bottom))
max(20px, var(--safe-left));
}
.auth-page-copy h1 {
font-size: 26px;

View file

@ -57,6 +57,7 @@ const MAIN_AGENT_TRANSCRIPT_FRAMES = new Set<string>([
'tool.result',
'agent.status.updated',
'prompt.completed',
'prompt.aborted',
'error',
]);
@ -127,6 +128,10 @@ interface SessionState {
// Subagent lifecycle deltas after spawned only carry subagentId. Keep the
// spawned metadata here so later updates can replace the full AppTask.
subagentMeta: Map<string, AppTask>;
// Bubble cleared by turn.step.retrying, to be reused by the retried
// step.started (same turn) instead of stacking a new bubble.
retryReuseMsgId: string | undefined;
}
function createSessionState(): SessionState {
@ -148,6 +153,7 @@ function createSessionState(): SessionState {
model: '',
messages: [],
subagentMeta: new Map(),
retryReuseMsgId: undefined,
};
}
@ -699,12 +705,11 @@ export function createAgentProjector(): AgentProjector {
// -----------------------------------------------------------------------
case 'turn.started': {
// Bind turnId → promptId. Generate a synthetic one if none was pre-bound.
// Session status is intentionally NOT projected here — the daemon's
// `event.session.status_changed` is the single source of status
// transitions (it carries the authoritative previousStatus /
// currentPromptId and dedupes per real transition); projecting a
// second running/idle event per turn from the raw stream made every
// turn-end consumer (notifications, sounds) fire twice.
// Session busy is intentionally NOT projected here — the daemon's
// `event.session.work_changed` is the single source of the busy fact
// (it re-reads the authoritative drain registry and dedupes per real
// transition); projecting a second busy flip per turn from the raw
// stream made every turn-end consumer fire twice.
const turnId: number = p?.turnId;
const existingPromptId = s.currentPromptId ?? ulid('pr_');
s.currentPromptId = existingPromptId;
@ -714,6 +719,9 @@ export function createAgentProjector(): AgentProjector {
// Fresh turn → fresh step stream offsets.
s.turnTextLen = 0;
s.turnThinkLen = 0;
// Main-conversation liveness (the moon) keys off the main agent's turn
// boundary directly — only main-agent frames reach this switch arm.
out.push({ type: 'turnActiveChanged', sessionId, active: true });
break;
}
@ -736,6 +744,17 @@ export function createAgentProjector(): AgentProjector {
s.turnTextLen = 0;
s.turnThinkLen = 0;
// A retry continuation: refill the bubble turn.step.retrying cleared,
// instead of creating a second bubble with the same step's content.
if (s.retryReuseMsgId !== undefined) {
const reuseId = s.retryReuseMsgId;
s.retryReuseMsgId = undefined;
if (getMsgById(s, reuseId) !== undefined) {
s.currentAssistantMsgId = reuseId;
break;
}
}
// Create a new pending assistant message
const msg = startAssistantMessage(s, sessionId, promptId);
s.currentAssistantMsgId = msg.id;
@ -957,6 +976,16 @@ export function createAgentProjector(): AgentProjector {
const reason: string = p?.reason ?? 'completed';
const durationMs = numberField(p ?? {}, 'durationMs');
// Main-conversation liveness: the prompt this turn served is done.
// This — not the session-busy status — is what ends the working moon.
// It MUST be emitted first in this arm: the onMainTurnEnd side effect
// gates on `seq > lastSeqBySession`, and sibling events in this arm
// advance that cursor — emitted after them, this event would compare
// equal and the prompt-finish cleanup (moon, queue drain) would never
// fire (observed: moon stuck when a turn ends with background tasks
// still running, where no work_changed(busy:false) fallback exists).
out.push({ type: 'turnActiveChanged', sessionId, active: false, reason: p?.reason });
if (msgId) {
finishAssistantMessage(s, msgId);
const msg = getMsgById(s, msgId);
@ -976,30 +1005,86 @@ export function createAgentProjector(): AgentProjector {
const usageSnapshot = buildUsageSnapshot(s);
out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot });
// No sessionStatusChanged here — see turn.started. The daemon's
// `event.session.status_changed` flips the session to idle/aborted.
// No busy projection here — see turn.started. The daemon's
// `event.session.work_changed` flips the session busy fact.
// Clear per-turn state. Reset the stream offsets too so a stale length
// from this turn can't wedge the next turn's delta alignment into a
// silent skip if its turn.started is missed across a reconnect.
// silent skip if its turn.started is missed across a reconnect. The
// retry reuse target is per-turn as well: if the turn died between
// turn.step.retrying and the retried step.started, the next prompt
// must open a fresh bubble, not refill this turn's emptied one.
s.currentAssistantMsgId = undefined;
s.currentPromptId = undefined;
s.turnTextLen = 0;
s.turnThinkLen = 0;
s.retryReuseMsgId = undefined;
break;
}
// -----------------------------------------------------------------------
case 'prompt.completed': {
// No-op at AppEvent level — turn.ended already handles the transition to idle
// No state change at AppEvent level — turn.ended / the session
// status_changed ahead of this event already finished the prompt. The
// event rides along so the web layer can spot the one case that has no
// turn-level signal: a prompt blocked before any turn started (reason
// 'blocked'), which would otherwise pin the in-flight state forever.
const promptId: string | undefined = p?.promptId;
if (typeof promptId === 'string' && promptId.length > 0) {
out.push({ type: 'promptCompleted', sessionId, promptId, reason: p?.reason ?? 'completed' });
}
break;
}
// -----------------------------------------------------------------------
case 'turn.step.retrying':
case 'prompt.aborted': {
// Fires both for an active-turn abort (a turn.ended + status_changed
// precede it — the prompt is already finished) and for a QUEUED prompt
// that never started a turn (no turn events, no status flip). The web
// layer keys on promptId to clear the in-flight state in the latter case.
const promptId: string | undefined = p?.promptId;
if (typeof promptId === 'string' && promptId.length > 0) {
out.push({ type: 'promptAborted', sessionId, promptId });
}
break;
}
// -----------------------------------------------------------------------
case 'turn.step.retrying': {
// The step's stream restarts from offset 0. Reuse the abandoned
// bubble instead of stacking a new one: strip its streamed parts and
// keep the id in retryReuseMsgId so the retried step.started refills
// it in place. Otherwise the failed attempt's partial bubble stays
// rendered next to the retry's full stream — the "text/tool shown
// twice" duplication (far more visible since the retry budget grew).
const msgId = s.currentAssistantMsgId;
if (msgId !== undefined) {
const msg = getMsgById(s, msgId);
if (msg !== undefined) {
msg.content = msg.content.filter(
(c) => c.type !== 'text' && c.type !== 'thinking' && c.type !== 'toolUse',
);
out.push({
type: 'messageUpdated',
sessionId,
messageId: msgId,
content: msg.content.map((c) => ({ ...c })),
status: 'pending',
});
s.retryReuseMsgId = msgId;
}
}
s.turnTextLen = 0;
s.turnThinkLen = 0;
s.toolStartTimes.clear();
break;
}
case 'turn.step.interrupted': {
// Discard current assistant message; next step.started will create a new one
// Discard current assistant message; next step.started will create a
// new one. Drop any pending retry reuse target for the same reason.
s.currentAssistantMsgId = undefined;
s.retryReuseMsgId = undefined;
break;
}
@ -1088,10 +1173,20 @@ export function createAgentProjector(): AgentProjector {
// -----------------------------------------------------------------------
case 'error': {
// Fold into an unknown event so the reducer pushes a warning string
// Fold into an unknown event so the reducer surfaces it as a structured
// error notice (semantic title + code/status/requestId details). The
// wire payload already carries name/details/retryable — pass them
// through untouched; the reducer decides what to display.
out.push({
type: 'unknown',
raw: { _agentError: true, code: p?.code, message: p?.message },
raw: {
_agentError: true,
code: p?.code,
message: p?.message,
name: p?.name,
details: p?.details,
retryable: p?.retryable,
},
});
break;
}
@ -1124,6 +1219,48 @@ export function createAgentProjector(): AgentProjector {
: typeof info.command === 'string'
? info.command
: i18n.global.t('tasks.defaultDescription');
// A background subagent registers into the background-task store under
// a fresh task id that differs from its agent id. Record the task id on
// the existing WS-owned row (keyed by agent id) instead of adding a
// second row — REST `/tasks` returns the same agent keyed by task id,
// and keepLiveSubagents folds that copy into this row.
if (info.kind === 'agent') {
const agentId =
typeof info.agentId === 'string' && info.agentId.length > 0
? info.agentId
: undefined;
if (agentId !== undefined) {
// Key by agent id even when the spawn event never reached this
// client (subscribed late): later agent-scoped progress frames are
// routed by agent id, and seeding subagentMeta here keeps them on
// this one row instead of synthesizing a second one.
const task = patchSubagent(s, sessionId, agentId, {
description,
backgroundTaskId: taskId,
runInBackground: true,
});
if (task) out.push({ type: 'taskCreated', sessionId, task });
} else {
// No agent id — nothing to link; key the row by the background
// task id so the REST poll dedupes it.
out.push({
type: 'taskCreated',
sessionId,
task: {
id: taskId,
sessionId,
kind: 'subagent',
description,
status: 'running',
createdAt: startedAt ?? new Date().toISOString(),
startedAt,
subagentPhase: 'queued',
runInBackground: true,
},
});
}
break;
}
const command = typeof info.command === 'string' ? info.command : undefined;
out.push({
type: 'taskCreated',
@ -1318,6 +1455,7 @@ const KNOWN_AGENT_CORE_TYPES = new Set([
'agent.status.updated',
'prompt.submitted',
'prompt.completed',
'prompt.aborted',
'session.meta.updated',
'compaction.started',
'compaction.completed',

View file

@ -17,7 +17,6 @@ import type {
AppSessionCursor,
AppSessionRuntimeStatus,
AppSessionSnapshot,
AppSessionStatus,
AppTask,
AppTaskStatus,
AppTerminal,
@ -52,7 +51,6 @@ import {
toWireApprovalResponse,
toWirePromptSubmission,
toWireQuestionResponse,
toWireSessionStatus,
toAppWorkspace,
wireEventSeq,
wireEventSessionId,
@ -347,7 +345,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
async listSessions(
input?: PageRequest & {
status?: AppSessionStatus;
busy?: boolean;
workspaceId?: string;
includeArchive?: boolean;
archivedOnly?: boolean;
@ -358,7 +356,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
before_id: input?.beforeId,
after_id: input?.afterId,
page_size: input?.pageSize,
status: input?.status ? toWireSessionStatus(input.status) : undefined,
busy: input?.busy,
include_archive: input?.includeArchive,
archived_only: input?.archivedOnly,
exclude_empty: input?.excludeEmpty,
@ -566,7 +564,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
};
traceKeyEvent('session:snapshot:accepted', {
sessionId,
status: snapshot.session.status,
busy: snapshot.session.busy,
seq: snapshot.asOfSeq,
messageCount: snapshot.messages.length,
durationMs: Date.now() - startedAt,
@ -1178,8 +1176,6 @@ export class DaemonKimiWebApi implements KimiWebApi {
name: e.name,
path: e.path,
isDir: e.is_dir,
isGitRepo: e.is_git_repo,
branch: e.branch,
})),
};
} catch {
@ -1435,6 +1431,18 @@ export class DaemonKimiWebApi implements KimiWebApi {
const { type, seq, session_id: sessionId, payload, offset } = frame;
const appEvents = projector.project(type, payload, sessionId, { offset });
for (const appEvent of appEvents) {
const turnId = (payload as { turnId?: unknown } | null)?.turnId;
const stream =
appEvent.type === 'assistantDelta' &&
typeof turnId === 'number' &&
typeof offset === 'number' &&
(type === 'assistant.delta' || type === 'thinking.delta')
? {
turnId,
offset,
kind: type === 'assistant.delta' ? ('text' as const) : ('thinking' as const),
}
: undefined;
// historyCompacted from the projector is either a compaction signal
// (reason auto_compact — no reload, the divider marker handles it) or
// a delta-gap recovery (reason delta_gap — a real resync, routed to
@ -1442,7 +1450,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) {
handlers.onResync(sessionId, seq);
}
handlers.onEvent(appEvent, { sessionId, seq });
handlers.onEvent(appEvent, { sessionId, seq, stream });
}
},

View file

@ -15,6 +15,8 @@ import type {
AppGoal,
AppMessage,
AppMessageContent,
AppNotice,
AppNoticeDetail,
AppWarning,
AppQuestionRequest,
AppSession,
@ -66,6 +68,11 @@ export interface KimiClientState {
* live event won the race even when the goal entry stayed absent. */
goalVersionBySession: Record<string, number>;
lastSeqBySession: Record<string, number>;
/** MAIN-agent turn in flight, per session set from the main agent's
* turn.started/turn.ended boundary events and seeded from the snapshot's
* (main-only) inFlightTurn. Half of the working moon; subagent turns never
* reach the events that set this. */
turnActiveBySession: Record<string, boolean>;
compactionBySession: Record<string, CompactionStatus>;
config?: AppConfig | null;
warnings: AppWarning[];
@ -83,6 +90,7 @@ export function createInitialState(): KimiClientState {
goalBySession: {},
goalVersionBySession: {},
lastSeqBySession: {},
turnActiveBySession: {},
compactionBySession: {},
warnings: [],
};
@ -110,6 +118,7 @@ function cloneState(s: KimiClientState): KimiClientState {
goalBySession: { ...s.goalBySession },
goalVersionBySession: { ...s.goalVersionBySession },
lastSeqBySession: { ...s.lastSeqBySession },
turnActiveBySession: { ...s.turnActiveBySession },
compactionBySession: { ...s.compactionBySession },
warnings: [...s.warnings],
};
@ -229,6 +238,64 @@ function appendToolOutputToMessages(messages: AppMessage[], toolCallId: string,
// Reducer
// ---------------------------------------------------------------------------
/** Agent error code semantic title key under `warnings.agentError`. Codes
* come from the protocol error domain (agent-core-v2 `ProtocolErrors`);
* anything unmapped falls back to the generic `title`. */
const AGENT_ERROR_TITLE_KEYS: Readonly<Record<string, string>> = {
'provider.connection_error': 'connection',
'provider.auth_error': 'auth',
'provider.rate_limit': 'rateLimit',
'provider.overloaded': 'overloaded',
'provider.filtered': 'filtered',
'provider.api_error': 'api',
'context.overflow': 'contextOverflow',
};
interface AgentErrorRaw {
code?: string;
message?: string;
name?: string;
details?: Record<string, unknown>;
}
/**
* Build the structured error notice for a failed agent turn (typically a
* model-provider failure). The wire payload already carries the coded error
* surface it in full so a rate-limit / auth / endpoint failure is diagnosable
* from the toast: semantic title, the provider's raw message as the body, and
* a diagnostics list (error code, HTTP status, request id, SDK error name,
* plus any extra detail fields such as finishReason).
*/
function buildAgentErrorNotice(raw: AgentErrorRaw): AppNotice {
const t = i18n.global.t;
const details: AppNoticeDetail[] = [];
const push = (label: string, value: unknown): void => {
if (typeof value === 'number' || typeof value === 'boolean') {
details.push({ label, value: String(value) });
} else if (typeof value === 'string' && value.length > 0) {
details.push({ label, value });
}
};
push(t('warnings.details.code'), raw.code);
const rawDetails = raw.details ?? {};
push(t('warnings.details.status'), rawDetails['statusCode']);
push(t('warnings.details.requestId'), rawDetails['requestId']);
push(t('warnings.details.errorName'), raw.name);
// Keep any remaining detail fields (finishReason, rawFinishReason, …) so no
// diagnostics the daemon sent are hidden.
for (const [key, value] of Object.entries(rawDetails)) {
if (key === 'statusCode' || key === 'requestId') continue;
push(key, value);
}
const titleKey = (raw.code !== undefined ? AGENT_ERROR_TITLE_KEYS[raw.code] : undefined) ?? 'title';
return {
severity: 'error',
title: t(`warnings.agentError.${titleKey}`),
message: raw.message,
details: details.length > 0 ? details : undefined,
};
}
/**
* Apply a single AppEvent to the state, returning a new state object.
* The event carries `_wireSeq` and `_wireSessionId` as hidden extras when
@ -282,6 +349,7 @@ export function reduceAppEvent(
delete next.approvalsBySession[id];
delete next.questionsBySession[id];
delete next.lastSeqBySession[id];
delete next.turnActiveBySession[id];
if (next.activeSessionId === id) {
next.activeSessionId = undefined;
}
@ -289,15 +357,25 @@ export function reduceAppEvent(
}
// -------------------------------------------------------------------------
case 'sessionStatusChanged': {
case 'sessionWorkChanged': {
next.sessions = next.sessions.map((s) => {
if (s.id !== event.sessionId) return s;
return {
...s,
status: event.status,
currentPromptId: event.currentPromptId,
busy: event.busy,
mainTurnActive: event.mainTurnActive ?? (event.busy ? s.mainTurnActive : false),
pendingInteraction: event.pendingInteraction ?? s.pendingInteraction,
// Authoritative, not nullish-merge: an omitted last_turn_reason is
// how the server says "no current outcome" (a fresh turn cleared
// the previous one), so the stale value must not survive.
lastTurnReason: event.lastTurnReason,
};
});
if (event.mainTurnActive === true) {
next.turnActiveBySession[event.sessionId] = true;
} else if (event.mainTurnActive === false || !event.busy) {
delete next.turnActiveBySession[event.sessionId];
}
break;
}
@ -582,6 +660,7 @@ export function reduceAppEvent(
parentToolCallId: event.task.parentToolCallId ?? previous.parentToolCallId,
subagentType: event.task.subagentType ?? previous.subagentType,
runInBackground: event.task.runInBackground ?? previous.runInBackground,
backgroundTaskId: event.task.backgroundTaskId ?? previous.backgroundTaskId,
};
next.tasksBySession[sid] = patched;
}
@ -664,6 +743,29 @@ export function reduceAppEvent(
case 'agentTurnEnded':
break;
// -------------------------------------------------------------------------
// Prompt-level lifecycle events drive the web layer's in-flight cleanup
// (see useKimiWebClient.processEvent), not reducer state. Advance seq
// silently.
case 'promptCompleted':
case 'promptAborted':
break;
// -------------------------------------------------------------------------
case 'turnActiveChanged': {
next.sessions = next.sessions.map((session) =>
session.id === event.sessionId
? { ...session, mainTurnActive: event.active }
: session,
);
if (event.active) {
next.turnActiveBySession[event.sessionId] = true;
} else {
delete next.turnActiveBySession[event.sessionId];
}
break;
}
case 'unknown': {
// Distinguish no-op known events (sentinel _noop) from agent errors/warnings
// and truly unknown events.
@ -673,18 +775,20 @@ export function reduceAppEvent(
_agentWarning?: boolean;
code?: string;
message?: string;
name?: string;
details?: Record<string, unknown>;
type?: string;
} | null;
if (raw && raw._noop === true) {
// No-op streaming/tool event — seq already advanced, nothing else to do
} else if (raw && (raw._agentError || raw._agentWarning)) {
// Surface the agent's real error/warning message (e.g. a 403 from the
// model provider) instead of a useless "Unhandled event".
const label = raw._agentError
? i18n.global.t('warnings.errorLabel')
: i18n.global.t('warnings.noteLabel');
const msg = raw.message ?? raw.code ?? 'agent error';
next.warnings = [...next.warnings, `${label}: ${msg}`];
} else if (raw && raw._agentError) {
// Surface the agent's real error (e.g. a 429 from the model provider)
// as a structured notice: semantic title + raw provider message +
// diagnostics (code / HTTP status / request id) for troubleshooting.
next.warnings = [...next.warnings, buildAgentErrorNotice(raw)];
} else if (raw && raw._agentWarning) {
const msg = raw.message ?? raw.code ?? 'agent warning';
next.warnings = [...next.warnings, `${i18n.global.t('warnings.noteLabel')}: ${msg}`];
} else {
// Truly unknown — push a warning
const wireType = raw?.type ?? '(unknown)';

View file

@ -15,7 +15,6 @@ import type {
AppMessageRole,
AppQuestionRequest,
AppSession,
AppSessionStatus,
AppSessionUsage,
AppTask,
AppTaskStatus,
@ -46,7 +45,6 @@ import type {
WireQuestionRequest,
WireQuestionResponse,
WireSession,
WireSessionStatus,
WireSessionUsage,
WireWorkspace,
WireEvent,
@ -88,33 +86,16 @@ export function isPlaceholderSessionUsage(usage: AppSessionUsage): boolean {
);
}
export function toAppSessionStatus(wire: WireSessionStatus): AppSessionStatus {
switch (wire) {
case 'idle': return 'idle';
case 'running': return 'running';
case 'awaiting_approval': return 'awaitingApproval';
case 'awaiting_question': return 'awaitingQuestion';
case 'aborted': return 'aborted';
}
}
export function toWireSessionStatus(status: AppSessionStatus): WireSessionStatus {
switch (status) {
case 'idle': return 'idle';
case 'running': return 'running';
case 'awaitingApproval': return 'awaiting_approval';
case 'awaitingQuestion': return 'awaiting_question';
case 'aborted': return 'aborted';
}
}
export function toAppSession(wire: WireSession): AppSession {
return {
id: wire.id,
title: wire.title,
createdAt: wire.created_at,
updatedAt: wire.updated_at,
status: toAppSessionStatus(wire.status),
busy: wire.busy,
mainTurnActive: wire.main_turn_active,
pendingInteraction: wire.pending_interaction,
lastTurnReason: wire.last_turn_reason,
archived: wire.archived ?? false,
currentPromptId: wire.current_prompt_id,
lastPrompt: wire.last_prompt,
@ -136,8 +117,6 @@ export function toAppWorkspace(wire: WireWorkspace): AppWorkspace {
id: wire.id,
root: wire.root,
name: wire.name,
isGitRepo: wire.is_git_repo,
branch: wire.branch ?? undefined,
lastOpenedAt: wire.last_opened_at,
sessionCount: wire.session_count,
};
@ -530,13 +509,31 @@ export function toAppEvent(wire: WireEvent): AppEvent {
root: w.payload.root,
};
case 'event.session.work_changed':
return {
type: 'sessionWorkChanged',
sessionId: w.session_id,
busy: w.payload.busy,
mainTurnActive: w.payload.main_turn_active,
pendingInteraction: w.payload.pending_interaction,
lastTurnReason: w.payload.last_turn_reason,
};
// Deprecated: old journals may still carry status_changed; fold it onto
// the busy flag (awaiting/running were live work, aborted was not).
case 'event.session.status_changed':
return {
type: 'sessionStatusChanged',
type: 'sessionWorkChanged',
sessionId: w.session_id,
status: toAppSessionStatus(w.payload.status),
previousStatus: toAppSessionStatus(w.payload.previous_status),
currentPromptId: w.payload.current_prompt_id,
busy: w.payload.status !== 'idle' && w.payload.status !== 'aborted',
mainTurnActive: w.payload.status !== 'idle' && w.payload.status !== 'aborted',
pendingInteraction:
w.payload.status === 'awaiting_approval'
? 'approval'
: w.payload.status === 'awaiting_question'
? 'question'
: 'none',
lastTurnReason: w.payload.status === 'aborted' ? 'cancelled' : undefined,
};
case 'event.session.usage_updated':

View file

@ -66,7 +66,10 @@ export interface WireSession {
title: string;
created_at: string;
updated_at: string;
status: WireSessionStatus;
busy: boolean;
main_turn_active?: boolean;
pending_interaction?: 'none' | 'approval' | 'question';
last_turn_reason?: 'completed' | 'cancelled' | 'failed';
archived: boolean;
current_prompt_id?: string;
/** Text of the most recent user prompt, for search/preview. */
@ -156,8 +159,6 @@ export interface WireWorkspace {
id: string;
root: string;
name: string;
is_git_repo: boolean;
branch: string | null;
last_opened_at?: string;
session_count: number;
}
@ -166,8 +167,6 @@ export interface WireFsBrowseEntry {
name: string;
path: string;
is_dir: boolean;
is_git_repo: boolean;
branch?: string;
}
export interface WireFsBrowseResult {
@ -678,6 +677,13 @@ interface WireEventBase<T extends string, P> {
type WireEventSessionCreated = WireEventBase<'event.session.created', { session: WireSession }>;
type WireEventSessionUpdated = WireEventBase<'event.session.updated', { session: WireSession; changed_fields: string[] }>;
type WireEventSessionDeleted = WireEventBase<'event.session.deleted', { session_id: string }>;
type WireEventSessionWorkChanged = WireEventBase<'event.session.work_changed', {
busy: boolean;
main_turn_active?: boolean;
pending_interaction?: 'none' | 'approval' | 'question';
last_turn_reason?: 'completed' | 'cancelled' | 'failed';
}>;
/** @deprecated Old journals may still carry this; mapped onto busy for replay. */
type WireEventSessionStatusChanged = WireEventBase<'event.session.status_changed', {
status: WireSessionStatus;
previous_status: WireSessionStatus;
@ -829,6 +835,7 @@ export type WireEvent =
| WireEventSessionCreated
| WireEventSessionUpdated
| WireEventSessionDeleted
| WireEventSessionWorkChanged
| WireEventSessionStatusChanged
| WireEventSessionUsageUpdated
| WireEventSessionHistoryCompacted

View file

@ -41,13 +41,6 @@ export type AppWarning = string | AppNotice;
// Session
// ---------------------------------------------------------------------------
export type AppSessionStatus =
| 'idle'
| 'running'
| 'awaitingApproval'
| 'awaitingQuestion'
| 'aborted';
export interface AppSessionUsage {
inputTokens: number;
outputTokens: number;
@ -67,7 +60,19 @@ export interface AppSession {
title: string;
createdAt: string;
updatedAt: string;
status: AppSessionStatus;
/** Any agent in the session holds an active turn or background lease.
* Awaiting states ride the approval/question channels; turn outcomes ride
* turn.ended. */
busy: boolean;
/** Whether the main agent has an active turn. Unlike busy, this excludes
* background tasks and sub-agent work. */
mainTurnActive?: boolean;
/** List-level fallback for the action-required badge. */
pendingInteraction?: 'none' | 'approval' | 'question';
/** Outcome of the main agent's most recent turn (when the server reports
* one). Presentation rule for the "aborted" tag:
* `!busy && (cancelled | failed)`. */
lastTurnReason?: 'completed' | 'cancelled' | 'failed';
archived: boolean;
currentPromptId?: string;
/** Text of the most recent user prompt, for search/preview. */
@ -120,10 +125,6 @@ export interface AppWorkspace {
root: string;
/** Display name — defaults to basename(root), may be renamed on the daemon. */
name: string;
/** Whether root is inside a git repository. */
isGitRepo: boolean;
/** Current branch, when known. */
branch?: string;
/** ISO timestamp of when this workspace was last opened. */
lastOpenedAt?: string;
/** Number of sessions belonging to this workspace. */
@ -135,8 +136,6 @@ export interface FsBrowseEntry {
name: string;
path: string;
isDir: boolean;
isGitRepo: boolean;
branch?: string;
}
export interface FsBrowseResult {
@ -338,6 +337,12 @@ export interface AppTask {
* the dock: the dock lists background subagents, while foreground subagents
* render inline in the message flow as the `Agent` tool card. */
runInBackground?: boolean;
/** The id this same subagent has in the server's background-task store
* (REST `/tasks`), learned from the `task.started` registration event. The
* WS event stream keys the agent by agent id while REST keys it by task id;
* this links the two so the REST copy can be folded into this row and so
* cancel can target the id REST actually knows. */
backgroundTaskId?: string;
}
// ---------------------------------------------------------------------------
@ -417,7 +422,14 @@ export type AppEvent =
| { type: 'workspaceDeleted'; workspaceId: string; root: string }
| { type: 'sessionUpdated'; session: AppSession; changedFields: string[] }
| { type: 'sessionDeleted'; sessionId: string }
| { type: 'sessionStatusChanged'; sessionId: string; status: AppSessionStatus; previousStatus: AppSessionStatus; currentPromptId?: string }
| {
type: 'sessionWorkChanged';
sessionId: string;
busy: boolean;
mainTurnActive?: boolean;
pendingInteraction?: 'none' | 'approval' | 'question';
lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}
| { type: 'sessionMetaUpdated'; sessionId: string; title?: string; lastPrompt?: string }
| { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string; swarmMode?: boolean; planMode?: boolean }
| { type: 'historyCompacted'; sessionId: string; beforeSeq: number; reason: string; summaryMessageId?: string }
@ -454,6 +466,20 @@ export type AppEvent =
kind?: 'line' | 'text';
}
| { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number }
// Prompt-level lifecycle (distinct from turn-level): a prompt that never
// produced a turn — blocked by a pre-submit hook, or aborted while queued —
// gets no turn.ended and no session status flip, so these are the web layer's
// only signal to clear the per-session in-flight state. A normal turn's
// prompt.completed is a no-op for state (the status_changed ahead of it
// already finished the prompt).
| { type: 'promptCompleted'; sessionId: string; promptId: string; reason: string }
| { type: 'promptAborted'; sessionId: string; promptId: string }
// The MAIN agent's turn boundary — the single source of truth for "the main
// conversation has a turn in flight" (half of the working moon, and the
// streaming reveal). Deliberately NOT derived from session status: a
// background subagent or BTW side chat keeps the session busy but must not
// light up the main conversation's moon. `reason` rides on deactivation.
| { type: 'turnActiveChanged'; sessionId: string; active: boolean; reason?: string }
| { type: 'goalUpdated'; sessionId: string; goal: AppGoal | null }
| { type: 'configChanged'; changedFields: string[]; config: AppConfig }
| {
@ -512,7 +538,7 @@ export interface AppSessionSnapshot {
}
export interface KimiEventHandlers {
onEvent(event: AppEvent, meta: { sessionId: string; seq: number }): void;
onEvent(event: AppEvent, meta: KimiEventMeta): void;
onResync(sessionId: string, currentSeq: number, epoch?: string): void;
onError(code: number, msg: string, fatal: boolean): void;
onConnectionChange(connected: boolean): void;
@ -520,6 +546,18 @@ export interface KimiEventHandlers {
onTerminalExit?(sessionId: string, terminalId: string, exitCode: number | null): void;
}
/** Raw stream coordinates are present only for kap-server assistant/thinking
deltas. They let the render queue merge chunks without guessing continuity. */
export interface KimiEventMeta {
sessionId: string;
seq: number;
stream?: {
turnId: number;
offset: number;
kind: 'text' | 'thinking';
};
}
export interface KimiEventConnection {
subscribe(sessionId: string, cursor?: AppSessionCursor): void;
unsubscribe(sessionId: string): void;
@ -667,7 +705,7 @@ export interface AppSessionWarning {
export interface KimiWebApi {
getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>;
getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record<string, boolean>; openInApps: string[]; dangerousBypassAuth: boolean; backend: 'v1' | 'v2' }>;
listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; archivedOnly?: boolean; excludeEmpty?: boolean }): Promise<Page<AppSession>>;
listSessions(input?: PageRequest & { busy?: boolean; workspaceId?: string; includeArchive?: boolean; archivedOnly?: boolean; excludeEmpty?: boolean }): Promise<Page<AppSession>>;
createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise<AppSession>;
/** Fetch one session by id (deep links beyond the first listSessions page). */
getSession(sessionId: string): Promise<AppSession>;

View file

@ -69,7 +69,9 @@ watch(dragging, (d) => emit('update:dragging', d));
touch-action: none;
/* sits over the 1px column border so the whole 4px strip is grabbable */
margin: 0 -2px;
z-index: var(--z-sticky);
/* above pane-level sticky chrome (chat dock, headers at --z-sticky): its 2px
overhang into the neighbour pane must stay visible and grabbable */
z-index: var(--z-dropdown);
}
.rh-bar {
position: absolute;

View file

@ -8,7 +8,6 @@ import type { Session } from '../types';
import { copyTextToClipboard } from '../lib/clipboard';
import Spinner from './ui/Spinner.vue';
import Badge from './ui/Badge.vue';
import { useConfirmDialog } from '../composables/useConfirmDialog';
import IconButton from './ui/IconButton.vue';
import Menu from './ui/Menu.vue';
import MenuItem from './ui/MenuItem.vue';
@ -16,7 +15,6 @@ import Icon from './ui/Icon.vue';
import Tooltip from './ui/Tooltip.vue';
const { t } = useI18n();
const { confirm } = useConfirmDialog();
const props = withDefaults(
defineProps<{
@ -168,18 +166,11 @@ function exportRow(): void {
emit('export', props.session.id);
}
// Archive confirm modal, consistent with remove-workspace.
async function startArchive(): Promise<void> {
// Archive the modal confirm and the async work live in App.vue
// (confirmArchiveSession); the row only emits the intent.
function startArchive(): void {
closeMenu();
if (
await confirm({
title: t('sidebar.archive'),
message: t('sidebar.archiveConfirm'),
variant: 'danger',
})
) {
emit('archive', props.session.id);
}
emit('archive', props.session.id);
}
// Expose closeMenu so the parent can close on outside-click.
@ -214,12 +205,11 @@ defineExpose({ closeMenu });
<!-- Pending tags coloured per kind, shown even when the row isn't
active. "Answer" = an askUserQuestion is waiting; "Approve" = a
permission request is waiting. The session's lifecycle status drives
the same tags as a fallback for background sessions whose pending
lists aren't loaded yet (status known, counts not). -->
permission request is waiting. The list-level interaction fact is
the fallback for sessions whose detailed pending lists aren't loaded. -->
<Tooltip :text="t('workspace.awaitingAnswerTitle')">
<Badge
v-if="!renaming && (questionCount > 0 || session.status === 'awaitingQuestion')"
v-if="!renaming && (questionCount > 0 || session.pendingInteraction === 'question')"
variant="info"
size="sm"
>
@ -228,17 +218,20 @@ defineExpose({ closeMenu });
</Tooltip>
<Tooltip :text="t('workspace.awaitingPermissionTitle')">
<Badge
v-if="!renaming && (approvalCount > 0 || session.status === 'awaitingApproval')"
v-if="!renaming && (approvalCount > 0 || session.pendingInteraction === 'approval')"
variant="warning"
size="sm"
>
{{ t('workspace.awaitingPermission') }}
</Badge>
</Tooltip>
<!-- Aborted: a distinct, low-key error tag (not collapsed into idle). -->
<!-- Aborted: a distinct, low-key error tag the session is quiet and
its last main turn was cancelled or failed. Hidden while input is
pending (the awaiting pills own the row then, exactly like the
retired awaiting_* lifecycle status superseded `aborted`). -->
<Tooltip :text="t('workspace.abortedTitle')">
<Badge
v-if="!renaming && session.status === 'aborted'"
v-if="!renaming && !session.busy && session.pendingInteraction !== 'question' && session.pendingInteraction !== 'approval' && questionCount === 0 && approvalCount === 0 && (session.lastTurnReason === 'cancelled' || session.lastTurnReason === 'failed')"
variant="danger"
size="sm"
>

View file

@ -30,10 +30,8 @@ import Kbd from './ui/Kbd.vue';
import Menu from './ui/Menu.vue';
import MenuItem from './ui/MenuItem.vue';
import Pill from './ui/Pill.vue';
import { useConfirmDialog } from '../composables/useConfirmDialog';
const { t } = useI18n();
const { confirm } = useConfirmDialog();
// Dev-only affordance: when the page is served by the Vite dev server, the
// logo turns yellow and a backend pill next to the brand shows the engine
@ -371,19 +369,12 @@ function startRenameFromMenu(): void {
closeGhMenu();
}
async function deleteFromMenu(): Promise<void> {
function deleteFromMenu(): void {
const ws = ghMenuTarget.value;
if (!ws) return;
closeGhMenu();
if (
await confirm({
title: t('sidebar.removeWorkspace'),
message: t('workspace.removeWorkspaceConfirm', { name: ws.name }),
variant: 'danger',
})
) {
emit('deleteWorkspace', ws.id);
}
// The modal confirm + async delete live in App.vue (confirmDeleteWorkspace).
emit('deleteWorkspace', ws.id);
}
// ---------------------------------------------------------------------------
@ -449,17 +440,10 @@ function startRenameWs(ws: WorkspaceView): void {
closeWsMenu();
}
async function deleteWs(ws: WorkspaceView): Promise<void> {
function deleteWs(ws: WorkspaceView): void {
closeWsMenu();
if (
await confirm({
title: t('sidebar.removeWorkspace'),
message: t('workspace.removeWorkspaceConfirm', { name: ws.name }),
variant: 'danger',
})
) {
emit('deleteWorkspace', ws.id);
}
// The modal confirm + async delete live in App.vue (confirmDeleteWorkspace).
emit('deleteWorkspace', ws.id);
}
// ---------------------------------------------------------------------------
@ -571,6 +555,7 @@ async function chooseBackend(name: BackendName): Promise<void> {
}
const next = await switchDevBackend(name);
if (next === null) {
console.warn('[kimi-web] dev backend switch failed:', name);
closeBackendMenu();
return;
}

View file

@ -295,7 +295,9 @@ onUnmounted(() => {
.toasts {
left: 12px;
right: 12px;
bottom: calc(76px + env(safe-area-inset-bottom));
/* Sit just above the chat dock; --dock-h already includes the dock's own
safe-area padding, so no extra safe-bottom term is needed. */
bottom: calc(var(--dock-h, 76px) + 8px);
width: auto;
max-height: 50vh;
}

View file

@ -198,7 +198,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
:label="minimized ? t('question.expand') : t('question.minimize')"
@click="minimized = !minimized"
>
<Icon v-if="minimized" name="chevron-down" size="md" />
<Icon v-if="minimized" name="chevron-up" size="md" />
<Icon v-else name="minus" size="md" />
</IconButton>
</div>

View file

@ -0,0 +1,204 @@
<!-- apps/kimi-web/src/components/chat/AttachmentChip.vue -->
<!-- One attachment rendered as a pill chip the SAME component for the
composer's pending-attachment strip and for sent messages in the chat
bubble. Context differences are props, not restyled variants:
- composer: uploading spinner, error tint, remove button
- bubble: plain chip, click opens preview / downloads
Tile rule: images show a real thumbnail, videos a play glyph, files a
neutral file icon with the extension badge next to the name. -->
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import AuthMedia from './AuthMedia.vue';
import Icon from '../ui/Icon.vue';
import Spinner from '../ui/Spinner.vue';
import Tooltip from '../ui/Tooltip.vue';
import type { IconName } from '../../lib/icons';
const props = withDefaults(
defineProps<{
kind: 'image' | 'video' | 'file';
/** Undefined only for pasted media without a name — a generic label shows. */
name?: string;
/** Thumbnail source for images (object URL or the authed file URL). */
url?: string;
/** When present, AuthMedia fetches image bytes with auth. */
fileId?: string;
mediaType?: string;
size?: number;
/** Composer: upload in flight — spinner replaces the ext badge. */
uploading?: boolean;
/** Composer: upload failed — chip tinted, info icon replaces the badge. */
error?: boolean;
/** Composer: show a remove button. */
removable?: boolean;
/** Accessible label for the remove button. */
removeLabel?: string;
}>(),
{ uploading: false, error: false, removable: false },
);
const emit = defineEmits<{
/** Primary action (preview media / download file) — the parent decides. */
activate: [];
remove: [];
}>();
const { t } = useI18n();
const ext = computed(() => {
const fromName = props.name?.match(/\.([A-Za-z0-9]{1,8})$/)?.[1];
const e = fromName ?? props.mediaType?.split('/')[1]?.split('+')[0];
return e ? e.toUpperCase() : undefined;
});
const fileIcon = computed<IconName>(() => {
const e = ext.value ?? '';
if (/^(txt|md|doc|docx|rtf|log)$/i.test(e)) return 'file-text';
return 'file';
});
const displayName = computed(() => {
if (props.name) return props.name;
if (props.kind === 'image') return t('composer.attachmentImage');
if (props.kind === 'video') return t('composer.attachmentVideo');
return t('composer.attachmentFile');
});
function formatSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const title = computed(() => {
const parts = [displayName.value];
if (props.size !== undefined) parts.push(formatSize(props.size));
return parts.join(' · ');
});
</script>
<template>
<span
class="att-chip"
:class="{ 'is-error': error, uploading }"
:title="title"
:data-kind="kind"
>
<button type="button" class="att-activate" :aria-label="title" @click="emit('activate')">
<span class="att-tile">
<AuthMedia
v-if="kind === 'image' && url"
:url="url"
kind="image"
:alt="name"
:file-id="fileId"
media-class="att-thumb"
/>
<Icon v-else-if="kind === 'video'" name="play" size="sm" />
<Icon v-else-if="kind === 'image'" name="image" size="sm" />
<Icon v-else :name="fileIcon" size="sm" />
</span>
<span class="att-name">{{ displayName }}</span>
<Spinner v-if="uploading" size="sm" :label="t('composer.uploading')" />
<span v-else-if="error" class="att-err"><Icon name="info" size="sm" /></span>
</button>
<Tooltip v-if="removable" :text="removeLabel ?? t('composer.remove')">
<button type="button" class="att-rm" :aria-label="removeLabel ?? t('composer.remove')" @click="emit('remove')">
<Icon name="close" size="sm" />
</button>
</Tooltip>
</span>
</template>
<style scoped>
.att-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 4px 9px 4px 5px;
background: var(--color-bg);
border: 1px solid var(--color-line);
border-radius: 999px;
font-size: var(--ui-font-size-sm);
transition: border-color var(--duration-fast) ease;
}
.att-chip:hover {
border-color: var(--color-line-strong);
}
.att-activate {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.att-activate:focus-visible {
outline: none;
box-shadow: var(--p-focus-ring);
border-radius: 999px;
}
.att-tile {
width: 20px;
height: 20px;
border-radius: 50%;
flex: none;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: var(--color-text-muted);
background: var(--color-surface-sunken);
}
.att-tile :deep(.att-thumb) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.att-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
font-weight: var(--weight-medium);
}
.att-chip.is-error {
border-color: var(--color-danger-bd);
}
.att-chip.is-error .att-err {
flex: none;
display: flex;
align-items: center;
color: var(--color-danger);
}
.att-rm {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--color-text-faint);
cursor: pointer;
}
.att-rm:hover {
background: var(--color-hover);
color: var(--color-text);
}
.att-rm:focus-visible {
outline: none;
box-shadow: var(--p-focus-ring);
}
</style>

View file

@ -3,11 +3,12 @@
<!-- pending question/approval cards, and the composer. Only rendered inside a -->
<!-- chat-pane group so it never leaks into files/tasks/preview/btw panes. -->
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ActivationBadges, ApprovalBlock, ConversationStatus, PermissionMode, QueuedPromptView, TaskItem, TodoTreeNode, TodoView, UIQuestion } from '../../types';
import type { AppGoal, AppModel, AppSkill, QuestionResponse, ThinkingLevel } from '../../api/types';
import type { FileItem } from './MentionMenu.vue';
import type { PromptAttachment } from '../../composables/useKimiWebClient';
import Composer from './Composer.vue';
import GoalStrip from './GoalStrip.vue';
import QuestionCard from './QuestionCard.vue';
@ -59,8 +60,8 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
submit: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
steer: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
submit: [payload: { text: string; attachments: PromptAttachment[] }];
steer: [payload: { text: string; attachments: PromptAttachment[] }];
command: [cmd: string];
interrupt: [];
setPermission: [mode: PermissionMode];
@ -107,11 +108,12 @@ const treeStats = computed(() => {
const composerRef = ref<{
loadForEdit: (value: string) => boolean;
loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]) => void;
loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]) => void;
focus: () => void;
} | null>(null);
const workPanelRef = ref<HTMLElement | null>(null);
const workbarRef = ref<HTMLElement | null>(null);
const dockRef = ref<HTMLElement | null>(null);
function loadForEdit(value: string): boolean {
// The nested Composer is only rendered in ChatDock's v-else when a pending
@ -122,7 +124,7 @@ function loadForEdit(value: string): boolean {
return true;
}
function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]): void {
function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]): void {
composerRef.value?.loadAttachmentsForEdit(atts);
}
@ -149,17 +151,36 @@ watch(
{ immediate: true },
);
let dockResizeObserver: ResizeObserver | null = null;
function publishDockHeight(): void {
// Border-box height of the dock, exposed so fixed overlays (e.g. toasts) can
// anchor just above the composer. offsetHeight includes the dock's own
// safe-area padding, so consumers don't need to add safe-bottom again.
const height = dockRef.value?.offsetHeight ?? 0;
document.documentElement.style.setProperty('--dock-h', `${height}px`);
}
onMounted(() => {
if (typeof ResizeObserver !== 'function' || !dockRef.value) return;
dockResizeObserver = new ResizeObserver(publishDockHeight);
dockResizeObserver.observe(dockRef.value);
publishDockHeight();
});
onUnmounted(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('mousedown', onDocumentMouseDown, true);
}
dockResizeObserver?.disconnect();
dockResizeObserver = null;
});
defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
</script>
<template>
<div class="chat-dock" :class="[mobile ? 'align-mobile' : 'align-center']" @click.stop>
<div ref="dockRef" class="chat-dock" :class="[mobile ? 'align-mobile' : 'align-center']" @click.stop>
<Transition name="dock-panel">
<div
ref="workPanelRef"
@ -386,12 +407,10 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
@media (max-width: 640px) {
.chat-dock {
--dock-inline-left: max(12px, env(safe-area-inset-left));
--dock-inline-right: max(12px, env(safe-area-inset-right));
}
.chat-dock.align-mobile {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
/* Inline (landscape) safe-area lives here only; the inner composer /
workbar read --dock-inline-* so the inset is applied exactly once. */
--dock-inline-left: max(12px, var(--safe-left));
--dock-inline-right: max(12px, var(--safe-right));
}
.dock-work-panel {
left: 10px;

View file

@ -12,10 +12,8 @@ import MenuItem from '../ui/MenuItem.vue';
import IconButton from '../ui/IconButton.vue';
import Icon from '../ui/Icon.vue';
import Tooltip from '../ui/Tooltip.vue';
import { useConfirmDialog } from '../../composables/useConfirmDialog';
const { t } = useI18n();
const { confirm } = useConfirmDialog();
const props = defineProps<{
sessionId?: string;
@ -210,21 +208,13 @@ function exportSession(): void {
}
// ---------------------------------------------------------------------------
// Archive modal confirm (the header has no session row to swap, so use the
// shared ConfirmDialog instead of the inline strip used in SessionRow).
// Archive the modal confirm and the async work live in App.vue
// (confirmArchiveSession); the header only emits the intent.
// ---------------------------------------------------------------------------
async function startArchive(): Promise<void> {
function startArchive(): void {
if (!props.sessionId) return;
closeMenu();
if (
await confirm({
title: t('header.archiveSession'),
message: t('sidebar.archiveConfirm'),
variant: 'danger',
})
) {
emit('archiveSession', props.sessionId);
}
emit('archiveSession', props.sessionId);
}
</script>

View file

@ -2,7 +2,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ChatTurn, ApprovalBlock, FilePreviewRequest, ToolMedia, QueuedPromptView } from '../../types';
import type { ChatTurn, ApprovalBlock, FilePreviewRequest, ToolMedia, QueuedPromptView, TurnAttachment } from '../../types';
import ToolCall from './ToolCall.vue';
import ToolGroup from './ToolGroup.vue';
import Markdown from './Markdown.vue';
@ -11,12 +11,14 @@ import ActivityNotice from './ActivityNotice.vue';
import CronNotice from './CronNotice.vue';
import MessageTime from './MessageTime.vue';
import AuthMedia from './AuthMedia.vue';
import AttachmentChip from './AttachmentChip.vue';
import MoonSpinner from '../ui/MoonSpinner.vue';
import Spinner from '../ui/Spinner.vue';
import Icon from '../ui/Icon.vue';
import Tooltip from '../ui/Tooltip.vue';
import { useConfirmDialog } from '../../composables/useConfirmDialog';
import { copyTextToClipboard } from '../../lib/clipboard';
import { openFileAttachment } from '../../lib/openFileAttachment';
import {
assistantRenderBlocks,
formatDuration,
@ -43,6 +45,10 @@ onUnmounted(() => {
clearTimeout(undoFallbackTimer);
undoFallbackTimer = null;
}
if (unsupportedOpenTimer !== null) {
clearTimeout(unsupportedOpenTimer);
unsupportedOpenTimer = null;
}
});
const props = withDefaults(
@ -50,17 +56,18 @@ const props = withDefaults(
turns: ChatTurn[];
approvals?: { approvalId: string; block: ApprovalBlock; agentName?: string }[];
/**
* True while the active session is busy (activity !== idle). Used to mark the
* last assistant turn as actively streaming so its Markdown animates the
* smooth typewriter/fade reveal; all other turns render statically.
* True while the MAIN agent has a turn in flight (not merely "session
* busy" background subagents and BTW side chats don't set this). Marks
* the last assistant turn as actively streaming so its Markdown animates
* the smooth typewriter/fade reveal; all other turns render statically.
*/
running?: boolean;
turnActive?: boolean;
/**
* True immediately after the user hits send and before the assistant reply
* starts streaming. Renders a moon-spinner placeholder at the end of the
* transcript so the user knows the request is in flight.
* The main conversation has an unfinished prompt (submitted, or a main
* turn in flight). Renders the moon-spinner placeholder at the end of the
* transcript and gates "edit & resend" on the last user message.
*/
sending?: boolean;
working?: boolean;
/** Switches the CSS-only working moon to the faster visual cadence. */
fastMoon?: boolean;
/**
@ -110,8 +117,8 @@ const props = withDefaults(
}>(),
{
approvals: () => [],
running: false,
sending: false,
turnActive: false,
working: false,
fastMoon: false,
compaction: null,
hasMoreMessages: false,
@ -170,21 +177,20 @@ watch(
);
// The id of the turn that is actively streaming: the last assistant turn while
// the session is running. Its Markdown renders with `streaming` (final=false);
// every other turn renders statically.
// the main turn is in flight. Its Markdown renders with `streaming`
// (final=false); every other turn renders statically.
const streamingTurnId = computed<string | null>(() => {
if (!props.running || props.turns.length === 0) return null;
if (!props.turnActive || props.turns.length === 0) return null;
const last = props.turns.at(-1)!;
return last.role === 'assistant' ? last.id : null;
});
// Trailing "working" moon. `sending` is an optimistic flag set on submit and
// kept until the session goes idle, so during a normal turn the moon shows the
// whole time. After a page refresh that in-memory flag is gone, so fall back to
// `running` (restored from the session's live status) otherwise a refresh mid
// stream froze the transcript with no "still working" indicator. Either flag
// shows the same moon footer.
const showWorking = computed(() => props.sending || props.running);
// Trailing "working" moon: shown while the main conversation has an unfinished
// prompt. `working` is the union of the optimistic submit window and the main
// turn's liveness (restored from the snapshot's inFlightTurn after a refresh);
// background agents and BTW side chats never show here the moon belongs to
// the main conversation only.
const showWorking = computed(() => props.working);
const emit = defineEmits<{
openFile: [target: FilePreviewRequest];
@ -200,7 +206,7 @@ const emit = defineEmits<{
/** Show an Edit/Write tool call's diff in the right-side panel. */
openToolDiff: [id: string];
/** Edit + resend the last user message (parent undoes, then refills composer). */
editMessage: [payload: { text: string; images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[] }];
editMessage: [payload: { text: string; attachments?: TurnAttachment[] }];
/** Fetch the next older page of messages (triggered by top sentinel visibility or click). */
loadOlderMessages: [];
/** Remove a queued message by index. */
@ -217,7 +223,7 @@ const emit = defineEmits<{
const dragFrom = ref<number | null>(null);
const dragOver = ref<{ index: number; position: 'before' | 'after' } | null>(null);
function hasImages(item: QueuedPromptView): boolean {
function hasAttachments(item: QueuedPromptView): boolean {
return (item.attachments?.length ?? 0) > 0;
}
@ -277,13 +283,12 @@ const lastUserTurnId = computed<string | null>(() => {
});
/** Whether to offer "edit & resend" on this turn: the latest user message, only
while the session is idle (not mid-reply) and it isn't a slash activation. */
while the conversation has nothing unfinished and it isn't a slash activation. */
function canEditTurn(turn: ChatTurn): boolean {
return (
turn.role === 'user' &&
turn.id === lastUserTurnId.value &&
!props.running &&
!props.sending &&
!props.working &&
!turn.skillActivation &&
!turn.pluginCommand
);
@ -333,7 +338,7 @@ async function onUndo(turn: ChatTurn): Promise<void> {
function confirmEditMessage(turn: ChatTurn): void {
if (undoingTurnId.value !== null) return;
undoingTurnId.value = turn.id;
emit('editMessage', { text: turn.text, images: turn.images });
emit('editMessage', { text: turn.text, attachments: turn.attachments });
// Fallback: if the server rewind never removes the turn (e.g. it failed),
// release the guard so the user can retry.
undoFallbackTimer = setTimeout(() => {
@ -468,12 +473,37 @@ function copyUserMessage(turn: ChatTurn): void {
}).catch(() => {/* ignore */});
}
function userImageMedia(img: { url: string; alt?: string; fileId?: string }): ToolMedia {
// User-uploaded images carry no path/mime metadata; the preview panel falls
function userAttachmentMedia(att: TurnAttachment): ToolMedia {
// User-uploaded media carries no path/mime metadata; the preview panel falls
// back to a generic label and sniffs the mime from the URL when needed. When
// a fileId is present the preview fetches the bytes with auth (a bare
// getFileUrl src 401s under daemon auth).
return { kind: 'image', url: img.url, path: img.alt, fileId: img.fileId };
return { kind: att.kind === 'video' ? 'video' : 'image', url: att.url, path: att.name, fileId: att.fileId };
}
// Transient "can't open this type" hint after clicking a file chip of a
// non-previewable type. Mirrors the copiedTurn timer pattern; cleared on unmount.
const unsupportedOpenName = ref<string | null>(null);
let unsupportedOpenTimer: ReturnType<typeof setTimeout> | null = null;
function onAttachmentClick(att: TurnAttachment): void {
if (att.kind === 'image' || att.kind === 'video') {
emit('openMedia', userAttachmentMedia(att));
return;
}
// Generic files open in a new tab, but only whitelisted inert types
// anything else gets the unsupported hint instead of an active-document
// preview (see openFileAttachment).
if (att.fileId === undefined) return;
void openFileAttachment(att.fileId, att.name, att.mediaType).then((result) => {
if (result !== 'unsupported') return;
unsupportedOpenName.value = att.name ?? att.fileId ?? '';
if (unsupportedOpenTimer !== null) clearTimeout(unsupportedOpenTimer);
unsupportedOpenTimer = setTimeout(() => {
unsupportedOpenTimer = null;
unsupportedOpenName.value = null;
}, 2400);
});
}
function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): boolean {
@ -523,32 +553,19 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<template v-if="turn.role === 'user'">
<div class="u-turn">
<div class="u-bub turn-anchor" :class="{ undoing: undoingTurnId === turn.id }" :data-turn-id="turn.id">
<!-- Image / video attachments -->
<div v-if="turn.images && turn.images.length > 0" class="u-imgs">
<template v-for="(img, ii) in turn.images" :key="ii">
<AuthMedia
v-if="img.kind === 'video'"
:url="img.url"
kind="video"
:file-id="img.fileId"
media-class="u-img"
/>
<button
v-else
type="button"
class="u-img-btn"
:aria-label="t('filePreview.enlargeImage')"
@click="emit('openMedia', userImageMedia(img))"
>
<AuthMedia
:url="img.url"
kind="image"
:alt="img.alt"
:file-id="img.fileId"
media-class="u-img"
/>
</button>
</template>
<!-- Unified attachment chips: files, images and videos -->
<div v-if="turn.attachments && turn.attachments.length > 0" class="u-atts">
<AttachmentChip
v-for="(att, ai) in turn.attachments"
:key="ai"
:kind="att.kind"
:name="att.name"
:url="att.url"
:file-id="att.fileId"
:media-type="att.mediaType"
:size="att.size"
@activate="onAttachmentClick(att)"
/>
</div>
<!-- Skill activation card (replaces raw XML) -->
<div v-if="turn.skillActivation" class="skill-act">
@ -656,9 +673,9 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<!-- Compaction in progress body-sized moon activity notice -->
<ActivityNotice v-if="compaction" :label="t('conversation.compacting')" />
<!-- Working placeholder moon spinner while the turn is in flight (covers
a page refresh mid-stream, where `sending` was lost but the session is
still running). -->
<!-- Working placeholder moon spinner while the conversation has an
unfinished prompt (covers a page refresh mid-stream, where the
optimistic submit flag was lost but the main turn is still in flight). -->
<div v-if="showWorking" class="sending-placeholder">
<MoonSpinner :fast="fastMoon" />
</div>
@ -703,21 +720,26 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
>
<span v-if="item.text" class="u-text q-text">{{ item.text }}</span>
<span v-else class="q-text q-text-placeholder">
<Icon name="image" size="sm" />
{{ t('composer.queuedImageOnly', { n: item.attachments?.length ?? 0 }) }}
<Icon name="file" size="sm" />
{{ t('composer.queuedAttachments', { n: item.attachments?.length ?? 0 }) }}
</span>
</button>
<div v-if="hasImages(item)" class="q-imgs">
<AuthMedia
v-for="(att, ai) in item.attachments"
:key="ai"
:url="att.url"
:kind="att.kind"
:file-id="att.fileId"
media-class="q-img"
:controls="false"
muted
/>
<div v-if="hasAttachments(item)" class="q-imgs">
<template v-for="(att, ai) in item.attachments" :key="ai">
<span v-if="att.kind === 'file'" class="q-file">
<Icon name="file" size="sm" />
{{ att.name ?? att.fileId }}
</span>
<AuthMedia
v-else
:url="att.url"
:kind="att.kind"
:file-id="att.fileId"
media-class="q-img"
:controls="false"
muted
/>
</template>
</div>
<span v-if="qi === 0" class="q-tag q-tag-next">{{ t('composer.queueNext') }}</span>
<span v-else class="q-tag q-tag-idx">#{{ qi + 1 }}</span>
@ -734,6 +756,10 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
</div>
</div>
<!-- Transient hint after clicking a file chip whose type can't be opened. -->
<div v-if="unsupportedOpenName !== null" class="open-unsupported" role="status">
{{ t('composer.attachmentOpenUnsupported', { name: unsupportedOpenName }) }}
</div>
</template>
<style scoped>
@ -773,8 +799,29 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
padding: 16px 14px 20px;
flex: 1;
min-height: 0;
position: relative;
}
.chat .chat-empty { align-self: stretch; }
/* Bottom-center pill for the "can't open this file type" hint. */
.open-unsupported {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
max-width: min(90%, 480px);
padding: 6px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--color-line);
background: var(--color-surface-raised);
color: var(--color-text-muted);
font-size: var(--ui-font-size-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
z-index: 2;
}
.chat > .u-turn,
.chat > .a-msg,
.chat > .compact-divider,
@ -1059,39 +1106,14 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
}
}
.u-imgs {
/* Unified attachment chips (files / images / videos) above the bubble text
the chip itself is AttachmentChip; this is only the row layout. */
.u-atts {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.u-img {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
object-fit: cover;
}
/* Clickable image thumbnail reset button chrome so it looks like the plain
image it replaced, while still opening the preview on click. */
.u-img-btn {
display: block;
flex: none;
align-self: flex-start;
max-width: 100%;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
}
.u-img-btn .u-img {
display: block;
}
.u-img-btn:focus-visible {
outline: none;
box-shadow: var(--p-focus-ring);
}
/* NOTE: Chat/bubble styles live in src/style.css (global). Scoped `.u-bub`
rules here did NOT win the cascade, so they were moved to the global sheet. */
@ -1133,7 +1155,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
.chat {
box-sizing: border-box;
width: 100%;
padding: 14px max(12px, env(safe-area-inset-right)) 18px max(12px, env(safe-area-inset-left));
padding: 14px max(12px, var(--safe-right)) 18px max(12px, var(--safe-left));
}
.u-bub {
max-width: min(88%, calc(100vw - 52px));
@ -1347,6 +1369,21 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
border-radius: var(--radius-sm);
border: 1px solid var(--color-line);
}
.q-file {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-line);
color: var(--color-text-muted);
font-size: calc(var(--ui-font-size) - 3px);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.q-tag {
flex: none;
padding: 1px 6px;

View file

@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
import SlashMenu from './SlashMenu.vue';
import MentionMenu from './MentionMenu.vue';
import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands';
import { formatTokens } from '../../lib/formatTokens';
import type { FileItem } from './MentionMenu.vue';
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
import type { AppGoal, AppModel, AppSkill, ThinkingLevel } from '../../api/types';
@ -21,13 +22,16 @@ import { useInputHistory } from '../../composables/useInputHistory';
import { useSlashMenu } from '../../composables/useSlashMenu';
import { useMentionMenu } from '../../composables/useMentionMenu';
import { useComposerDraft } from '../../composables/useComposerDraft';
import { useAttachmentUpload } from '../../composables/useAttachmentUpload';
import { useAttachmentUpload, type Attachment } from '../../composables/useAttachmentUpload';
import { openFileAttachment } from '../../lib/openFileAttachment';
import type { PromptAttachment } from '../../composables/useKimiWebClient';
import Spinner from '../ui/Spinner.vue';
import Button from '../ui/Button.vue';
import IconButton from '../ui/IconButton.vue';
import Icon from '../ui/Icon.vue';
import ContextRing from '../ui/ContextRing.vue';
import Tooltip from '../ui/Tooltip.vue';
import AttachmentChip from './AttachmentChip.vue';
// ---------------------------------------------------------------------------
// Props & emits
@ -82,10 +86,10 @@ const placeholder = computed(() =>
);
const emit = defineEmits<{
submit: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
submit: [payload: { text: string; attachments: PromptAttachment[] }];
/** Steer the composer text (+ any queued prompts, merged by the parent)
into the RUNNING turn TUI ctrl+s. */
steer: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
steer: [payload: { text: string; attachments: PromptAttachment[] }];
command: [cmd: string];
interrupt: [];
setPermission: [mode: PermissionMode];
@ -287,11 +291,28 @@ function focus(): void {
// or if focus is triggered during an animation/transition.
textareaRef.value?.focus({ preventScroll: true });
}
function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]): void {
function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]): void {
loadAttachments(atts);
}
defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
// Build the wire-bound attachment payload: images/videos only need the fileId,
// while file parts also carry name/mediaType/size for the daemon's file shape.
function toPromptAttachment(a: Attachment): PromptAttachment {
return { fileId: a.fileId!, kind: a.kind, name: a.name, mediaType: a.mediaType, size: a.size };
}
// Chip primary action: media opens the lightbox preview; a generic file opens
// in a new tab (browser-renderable types) or downloads, once its upload has
// completed and produced a daemon file id.
function onAttachmentActivate(att: Attachment): void {
if (att.kind === 'file') {
if (att.fileId !== undefined) void openFileAttachment(att.fileId, att.name, att.mediaType);
return;
}
openAttachmentPreview(att);
}
function handleSubmit(): void {
const trimmed = text.value.trim();
@ -334,7 +355,7 @@ function handleSubmit(): void {
const payload = {
text: trimmed,
attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })),
attachments: readyAttachments.map((a) => toPromptAttachment(a)),
};
// Revoke object URLs and drop the submitted attachments.
@ -364,7 +385,7 @@ function handleSteer(): void {
const payload = {
text: trimmed,
attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })),
attachments: readyAttachments.map((a) => toPromptAttachment(a)),
};
clearAfterSubmit();
history.push(trimmed);
@ -587,14 +608,14 @@ onUnmounted(() => {
document.removeEventListener('click', onDocClick, true);
});
// Context formatting
const kFmt = (n: number) => `${Math.round(n / 1000)}k`;
// Clamped to 0100: ctxUsed can momentarily exceed ctxMax (estimates), and
// ctxMax can be 0 before the first status fetch both broke the ring.
// ctxMax can be 0 before the first status fetch both broke the ring. ceil
// (not round) so a session under 0.5% usage still shows a sliver of arc
// Math.round floored it to an empty, "no data"-looking ring.
const pct = computed(() => {
const max = props.status?.ctxMax ?? 0;
if (max <= 0) return 0;
return Math.min(100, Math.max(0, Math.round(((props.status?.ctxUsed ?? 0) / max) * 100)));
return Math.min(100, Math.max(0, Math.ceil(((props.status?.ctxUsed ?? 0) / max) * 100)));
});
const rawPct = computed(() => {
@ -607,10 +628,10 @@ const rawPct = computed(() => {
const hasRaw = computed(() => (props.status?.ctxRaw ?? 0) > (props.status?.ctxUsed ?? 0));
const ctxTooltip = computed(() => {
const used = (props.status?.ctxUsed ?? 0).toLocaleString();
const max = (props.status?.ctxMax ?? 0).toLocaleString();
const used = formatTokens(props.status?.ctxUsed ?? 0);
const max = formatTokens(props.status?.ctxMax ?? 0);
if (hasRaw.value) {
const raw = (props.status?.ctxRaw ?? 0).toLocaleString();
const raw = formatTokens(props.status?.ctxRaw ?? 0);
return t('status.ctxTooltipRaw', { used, raw, max, pct: pct.value });
}
return t('status.ctxTooltip', { used, max, pct: pct.value });
@ -841,34 +862,22 @@ function selectModel(modelId: string): void {
>
<!-- Attachment chips (above the input row) -->
<div v-if="attachments.length > 0" class="att-strip">
<div v-for="att in attachments" :key="att.localId" class="att-chip" :class="{ 'att-error': att.error }">
<!-- Thumbnail (video shows its first frame; an icon overlays it) -->
<Tooltip :text="t('composer.previewAttachment', { name: att.name })">
<button type="button" class="att-preview" @click="openAttachmentPreview(att)">
<video v-if="att.kind === 'video'" class="att-thumb" :src="att.previewUrl" muted playsinline preload="metadata" />
<img v-else class="att-thumb" :src="att.previewUrl" :alt="att.name" />
<span v-if="att.kind === 'video'" class="att-video-badge" aria-hidden="true">
<Icon name="play" size="sm" />
</span>
</button>
</Tooltip>
<!-- Name + status -->
<span class="att-name">{{ att.name }}</span>
<!-- Spinner while uploading -->
<Spinner v-if="att.uploading" size="sm" :label="t('composer.uploading')" />
<!-- Error indicator -->
<Tooltip v-else-if="att.error" :text="t('composer.uploadFailed')">
<span class="att-err-icon">
<Icon name="info" size="sm" />
</span>
</Tooltip>
<!-- Remove button -->
<Tooltip :text="t('composer.removeNamed', { name: att.name })">
<button class="att-rm" @click="removeAttachment(att.localId)">
<Icon name="close" size="sm" />
</button>
</Tooltip>
</div>
<AttachmentChip
v-for="att in attachments"
:key="att.localId"
:kind="att.kind"
:name="att.name"
:url="att.previewUrl"
:file-id="att.fileId"
:media-type="att.mediaType"
:size="att.size"
:uploading="att.uploading"
:error="att.error"
removable
:remove-label="t('composer.removeNamed', { name: att.name })"
@activate="onAttachmentActivate(att)"
@remove="removeAttachment(att.localId)"
/>
</div>
<div v-if="previewAttachment" class="att-lightbox" @click.self="closeAttachmentPreview">
@ -937,12 +946,11 @@ function selectModel(modelId: string): void {
</div>
</div>
<!-- Hidden file input -->
<!-- Hidden file input (no accept filter any file type can be attached) -->
<input
v-if="hasUpload"
ref="fileInputRef"
type="file"
accept="image/*,video/*"
multiple
class="file-input-hidden"
@change="handleFileInputChange"
@ -959,10 +967,10 @@ function selectModel(modelId: string): void {
<IconButton
v-if="hasUpload"
size="md"
:label="t('composer.attachImage')"
:label="t('composer.attachFile')"
@click="openFilePicker"
>
<Icon name="image" />
<Icon name="attachment" />
</IconButton>
<!-- Permission pill click to open dropdown -->
@ -1091,12 +1099,10 @@ function selectModel(modelId: string): void {
<!-- Compact chip when context is high -->
<button v-if="showCompact" class="compact-chip" @click.stop="emit('compact')">/compact</button>
<!-- Context meter circular ring + token count. The ring is
aria-hidden, so the trigger exposes the full usage (used/max/pct)
via aria-label; focusable so keyboard and switch-control users
reach the same tooltip hover users see. The visible "12k/256k"
count is hidden under 980px by CSS, but SR users still get this
label. -->
<!-- Context meter circular ring only; the full usage (used/max/pct)
lives in the tooltip. The ring is aria-hidden, so the trigger
exposes those numbers via aria-label; focusable so keyboard and
switch-control users reach the same tooltip hover users see. -->
<Tooltip :text="ctxTooltip">
<span
v-if="status && !hideContext"
@ -1106,7 +1112,7 @@ function selectModel(modelId: string): void {
:aria-label="ctxTooltip"
>
<ContextRing :pct="pct" :raw-pct="hasRaw ? rawPct : undefined" />
<span class="ctx-num">{{ kFmt(status.ctxUsed) }}/<template v-if="hasRaw">{{ kFmt(status.ctxRaw) }}/</template>{{ kFmt(status.ctxMax) }}</span>
<span class="ctx-num">{{ formatTokens(status.ctxUsed) }}/<template v-if="hasRaw">{{ formatTokens(status.ctxRaw) }}/</template>{{ formatTokens(status.ctxMax) }}</span>
</span>
</Tooltip>
@ -1218,6 +1224,16 @@ function selectModel(modelId: string): void {
</div>
</div>
</div>
<!-- Full-window drop target affordance: shown while files are dragged anywhere
over the app (document-level listeners in useAttachmentUpload). Pure CSS
show/hide a Vue <Transition> can strand an invisible node when the drag
ends before the enter transition starts. -->
<div class="drop-overlay" :class="{ show: isDragOver }" aria-hidden="true">
<div class="drop-card">
<Icon name="file-plus" size="lg" />
<span>{{ t('composer.dropToAttach') }}</span>
</div>
</div>
</div>
</template>
@ -1232,6 +1248,41 @@ function selectModel(modelId: string): void {
background: var(--color-accent-soft);
}
/* Full-window drop overlay: pointer-events none the document-level handlers
in useAttachmentUpload receive the drop, the overlay is purely visual. */
.drop-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-bg) 72%, transparent);
pointer-events: none;
opacity: 0;
visibility: hidden;
transition:
opacity var(--duration-base) ease,
visibility var(--duration-base);
}
.drop-overlay.show {
opacity: 1;
visibility: visible;
}
.drop-card {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-6);
border-radius: var(--radius-lg);
border: 1.5px dashed var(--color-accent);
background: var(--color-bg);
color: var(--color-accent);
font-size: var(--ui-font-size-lg);
font-weight: var(--weight-medium);
box-shadow: var(--shadow-md);
}
/* Main composer card */
.composer-card {
--composer-send-size: 32px;
@ -1251,7 +1302,8 @@ function selectModel(modelId: string): void {
/* Attachment strip */
/* Attachment strip the chip itself is the shared AttachmentChip; this is
only the row layout above the input. */
.att-strip {
display: flex;
flex-wrap: wrap;
@ -1259,100 +1311,6 @@ function selectModel(modelId: string): void {
padding: 4px 0 6px;
}
.att-chip {
position: relative;
display: flex;
align-items: center;
gap: 5px;
background: var(--panel2);
border: 1px solid var(--color-accent-bd);
border-radius: 4px;
padding: 3px 6px 3px 4px;
font-family: var(--mono);
font-size: calc(var(--ui-font-size) - 3px);
color: var(--color-text);
max-width: 220px;
}
.att-preview {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-xs);
background: transparent;
padding: 0;
cursor: zoom-in;
flex: none;
}
.att-preview:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Play glyph over a video thumbnail so it reads as a video, not a still. */
.att-video-badge {
position: absolute;
left: 4px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: var(--color-text-on-accent);
pointer-events: none;
}
.att-chip.att-error {
border-color: var(--color-danger);
color: var(--color-danger);
}
.att-thumb {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: var(--radius-xs);
flex-shrink: 0;
background: var(--line2);
}
.att-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.att-err-icon {
display: flex;
align-items: center;
color: var(--color-danger);
flex-shrink: 0;
}
.att-rm {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 1px;
cursor: pointer;
color: var(--muted);
flex-shrink: 0;
}
.att-rm:hover {
color: var(--color-danger);
}
.att-lightbox {
position: fixed;
inset: 0;
@ -1646,8 +1604,8 @@ function selectModel(modelId: string): void {
color: var(--color-danger);
}
/* Context group — circular ring + num. Focusable for keyboard / switch access
to its aria-label and tooltip (see template), so it needs a focus ring. */
/* Context group — circular ring. Focusable for keyboard / switch access to its
aria-label and tooltip (see template), so it needs a focus ring. */
.ctx-group {
display: flex;
align-items: center;
@ -2154,10 +2112,10 @@ function selectModel(modelId: string): void {
toolbar shows every control on one row and toolbar-left / toolbar-right are
overflow:hidden, so without shedding ink the row clips its own content. The
context ring stays visible at every width (it is the live context-pressure
signal) but the "12k/256k" readout moves into the ring's tooltip, the model
name truncates earlier, and the permission label is capped so the ring and
the send button are never squeezed out. Mobile (640px) additionally hides
perm / modes via the rules below (those live in MobileSettingsSheet there). */
signal; the exact numbers live in its tooltip), the model name truncates
earlier, and the permission label is capped so the ring and the send button
are never squeezed out. Mobile (640px) additionally hides perm / modes via
the rules below (those live in MobileSettingsSheet there). */
@media (max-width: 980px) {
/* The ring already conveys context pressure; the "12k/256k" readout lives in
the tooltip and returns at wider widths. */
@ -2186,9 +2144,9 @@ function selectModel(modelId: string): void {
.composer {
padding:
9px
var(--dock-inline-right, max(12px, env(safe-area-inset-right)))
max(24px, env(safe-area-inset-bottom))
var(--dock-inline-left, max(12px, env(safe-area-inset-left)));
var(--dock-inline-right, max(12px, var(--safe-right)))
max(24px, var(--safe-bottom))
var(--dock-inline-left, max(12px, var(--safe-left)));
}
.composer-card {
--composer-send-size: 36px;
@ -2243,9 +2201,9 @@ function selectModel(modelId: string): void {
/* Mobile toolbar: hide secondary controls; attach / context ring / model /
send stay visible. Permission + plan move into the MobileSettingsSheet.
The context ring stays at every width by design it is the live
context-pressure signal on a phone (the "12k/256k" readout is hidden here
by the 980px rule above and remains in the ring's tooltip). The /compact
chip also stays so compaction is one tap away at 80% usage. */
context-pressure signal on a phone (the exact numbers live in the ring's
tooltip). The /compact chip also stays so compaction is one tap away at
80% usage. */
.perm-pill,
.modes {
display: none;

View file

@ -2,9 +2,10 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch, type ComponentPublicInstance } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ActivationBadges, ApprovalBlock, ChatTurn, ConversationStatus, FilePreviewRequest, PermissionMode, QueuedPromptView, TaskItem, TodoTreeNode, TodoView, ToolMedia, UIQuestion, WorkspaceView } from '../../types';
import type { ActivationBadges, ApprovalBlock, ChatTurn, ConversationStatus, FilePreviewRequest, PermissionMode, QueuedPromptView, TaskItem, TodoTreeNode, TodoView, ToolMedia, TurnAttachment, UIQuestion, WorkspaceView } from '../../types';
import type { AppGoal, AppModel, AppSkill, QuestionResponse, ThinkingLevel } from '../../api/types';
import type { FileItem } from './MentionMenu.vue';
import type { PromptAttachment } from '../../composables/useKimiWebClient';
import ChatPane from './ChatPane.vue';
import ChatHeader from './ChatHeader.vue';
import Composer from './Composer.vue';
@ -42,7 +43,11 @@ const props = defineProps<{
pendingQuestionActions?: Record<string, 'answer' | 'dismiss'>;
/** Approval ids with an in-flight respond (drives the card loading state). */
pendingApprovalActions?: Record<string, true>;
/** Session busy (any agent, incl. background work) — Stop/Escape affordances. */
running?: boolean;
/** MAIN agent turn in flight the conversation's streaming state (streaming
* reveal, turn-end scroll settle). Background-only work does NOT set this. */
turnActive?: boolean;
queued?: QueuedPromptView[];
searchFiles?: (q: string) => Promise<FileItem[]>;
uploadImage?: (file: Blob, name?: string) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
@ -50,7 +55,9 @@ const props = defineProps<{
changes?: { path: string; status: string }[];
/** Cache-buster that remounts the chat pane when the active session changes. */
fileReloadKey?: string | number;
sending?: boolean;
/** The main conversation has an unfinished prompt (submitted or a main turn
* in flight) the working moon. */
working?: boolean;
/** True while the empty-composer first prompt is being created + submitted.
* Drives the empty-session "starting conversation…" loading state. */
starting?: boolean;
@ -94,8 +101,8 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
submit: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
steer: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
submit: [payload: { text: string; attachments: PromptAttachment[] }];
steer: [payload: { text: string; attachments: PromptAttachment[] }];
approval: [approvalId: string, response: { decision: 'approved' | 'rejected' | 'cancelled'; scope?: 'session'; feedback?: string }];
cancelTask: [taskId: string];
answer: [questionId: string, response: QuestionResponse];
@ -125,7 +132,7 @@ const emit = defineEmits<{
openChanges: [];
refreshGitStatus: [];
/** Edit + resend the last user message (App undoes, then refills composer). */
editMessage: [payload: { text: string; images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[] }];
editMessage: [payload: { text: string; attachments?: TurnAttachment[] }];
/** Empty-composer workspace picker: start a new conversation elsewhere. */
selectWorkspace: [workspaceId: string];
/** Empty-composer workspace picker: create a new workspace. */
@ -192,7 +199,7 @@ let copyConversationCopiedTimer: ReturnType<typeof setTimeout> | null = null;
so the caller can avoid dropping the prompt. */
function loadComposerForEdit(
value: string,
attachments?: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[],
attachments?: TurnAttachment[],
): boolean {
const composer = dockedComposerRef.value ?? emptyComposerRef.value;
if (!composer) return false;
@ -425,7 +432,7 @@ const chatDockStyle = computed(() => ({
}));
type ComposerHandle = {
loadForEdit: (value: string) => boolean | void;
loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]) => void;
loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]) => void;
focus: () => void;
};
type RefArg = Element | (ComponentPublicInstance & Partial<ComposerHandle>) | null;
@ -898,7 +905,9 @@ watch(
);
watch(
() => props.running,
// Settle the scroll-follow when the conversation's turn finishes (not when
// background-only work ends the transcript didn't move then).
() => props.turnActive,
async (now, was) => {
if (now || !was) return;
if (!following.value && !hasUserActionFollowLock()) return;
@ -918,7 +927,7 @@ function followAfterUserAction(): void {
});
}
function handleComposerSubmit(payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }): void {
function handleComposerSubmit(payload: { text: string; attachments: PromptAttachment[] }): void {
followAfterUserAction();
emit('submit', payload);
}
@ -930,7 +939,7 @@ function handleComposerSubmit(payload: { text: string; attachments: { fileId: st
// smooth-scrolls once the truncated turns actually land.
function handleEditMessage(payload: {
text: string;
images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[];
attachments?: TurnAttachment[];
}): void {
following.value = true;
showPill.value = false;
@ -1170,12 +1179,20 @@ function handleInterrupt(): void {
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape' && (props.running || props.sending)) {
if (event.key === 'Escape' && (props.running || props.working)) {
event.preventDefault();
handleInterrupt();
}
}
// When the on-screen keyboard opens, browsers without interactive-widget support
// fire a visualViewport resize instead of shrinking the layout viewport. Re-follow
// the tail so the latest turn stays visible above the keyboard. No-op while the
// user has manually scrolled away (following === false).
function onVisualViewportResize(): void {
if (following.value) scheduleFollow();
}
onMounted(() => {
nextTick(() => {
if (typeof MutationObserver === 'function') {
@ -1207,6 +1224,7 @@ onMounted(() => {
document.addEventListener('visibilitychange', onVisibilityChange);
document.addEventListener('keydown', onKeyDown);
}
window.visualViewport?.addEventListener('resize', onVisualViewportResize);
});
});
@ -1226,6 +1244,7 @@ onUnmounted(() => {
document.removeEventListener('visibilitychange', onVisibilityChange);
document.removeEventListener('keydown', onKeyDown);
}
window.visualViewport?.removeEventListener('resize', onVisualViewportResize);
});
function focusComposer(): void {
@ -1398,8 +1417,8 @@ defineExpose({ loadComposerForEdit, focusComposer });
:key="fileReloadKey ?? 'no-session'"
:turns="turns"
:approvals="approvals"
:running="running"
:sending="sending"
:turn-active="turnActive"
:working="working"
:fast-moon="fastMoon"
:session-loading="sessionLoading"
:compaction="compaction"

View file

@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { AppGoal } from '../../api/types';
import { useConfirmDialog } from '../../composables/useConfirmDialog';
import { formatTokens } from '../../lib/formatTokens';
import Card from '../ui/Card.vue';
import Badge from '../ui/Badge.vue';
import Button from '../ui/Button.vue';
@ -95,7 +96,7 @@ async function onCancel(): Promise<void> {
>
<div class="goal-meta">
<span>{{ goal.turnsUsed }} turns</span>
<span>{{ goal.tokensUsed.toLocaleString() }} tokens</span>
<span>{{ formatTokens(goal.tokensUsed) }} tokens</span>
<span>{{ formatMs(goal.wallClockMs) }}</span>
<span v-if="goal.budget.tokenBudget !== null">{{ tokenPct }}% token budget</span>
</div>

View file

@ -338,6 +338,15 @@ const codeBlockProps = {
loading: false,
};
function copyCodeBlockFallback(code: string): void {
// markstream emits `copy` even when it skipped the write because the
// Clipboard API is unavailable. Reuse our plain-HTTP fallback in that case,
// while avoiding a duplicate write after markstream succeeds on HTTPS.
const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
if (clipboard && typeof clipboard.writeText === 'function') return;
void copyTextToClipboard(code);
}
// Root cause for the "large session turns into code skeletons" failure:
// markstream mounts every code block in the loaded transcript, then shiki has
// to tokenize all of them. `loading: false` removes the visible skeleton gate,
@ -437,6 +446,7 @@ function copyDiff(code: string, idx: number) {
:smooth-streaming="streaming"
:batch-rendering="allowBatchRender"
:defer-nodes-until-visible="false"
@copy="copyCodeBlockFallback"
/>
<!-- ```diff fence → local renderer (preserves +/- markers + colours) -->

View file

@ -283,7 +283,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
:label="minimized ? t('question.expand') : t('question.minimize')"
@click="minimized = !minimized"
>
<Icon v-if="minimized" name="chevron-down" size="md" />
<Icon v-if="minimized" name="chevron-up" size="md" />
<Icon v-else name="minus" size="md" />
</IconButton>
</div>

View file

@ -115,8 +115,8 @@ function autosize(): void {
v-else
:turns="turns"
:approvals="[]"
:running="running"
:sending="sending"
:turn-active="running"
:working="sending || running"
/>
<div v-if="showLoading" class="sc-loading" aria-hidden="true">
<MoonSpinner />

View file

@ -6,6 +6,7 @@ import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ConversationStatus, PermissionMode } from '../../types';
import type { ThinkingLevel } from '../../api/types';
import { formatTokens } from '../../lib/formatTokens';
import Dialog from '../ui/Dialog.vue';
const { t } = useI18n();
@ -28,15 +29,18 @@ const emit = defineEmits<{
// button, which we forward to the parent.
const open = ref(true);
const pct = computed(() =>
props.status.ctxMax > 0 ? Math.round((props.status.ctxUsed / props.status.ctxMax) * 100) : 0,
);
// ceil (not round) so sub-0.5% usage still renders a visible bar sliver;
// clamped to 0100 ctxUsed can momentarily exceed ctxMax (estimates).
const pct = computed(() => {
if (props.status.ctxMax <= 0) return 0;
return Math.min(100, Math.max(0, Math.ceil((props.status.ctxUsed / props.status.ctxMax) * 100)));
});
const contextValue = computed(() =>
props.status.ctxMax > 0
? t('status.statusContextValue', {
used: props.status.ctxUsed.toLocaleString(),
max: props.status.ctxMax.toLocaleString(),
used: formatTokens(props.status.ctxUsed),
max: formatTokens(props.status.ctxMax),
pct: pct.value,
})
: t('status.statusNone'),

View file

@ -1,6 +1,7 @@
<!-- apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue -->
<!-- A single AgentSwarm tool call, rendered as one inline "operation card".
Defaults to collapsed; when opened the body shows a phase overview and a
Expanded by default while the swarm runs, collapsed once settled; when
opened the body shows a phase overview and a phase overview and a
per-member accordion each subagent is a collapsible row (state dot +
name + one-line activity + phase) that expands on its own to reveal the
full output. While the swarm runs the rows come from the AppTask store
@ -120,8 +121,10 @@ const segments = computed<Segment[]>(() =>
),
);
// Collapsed by default §04 tool rows expand on demand.
const open = ref(false);
// Running swarms start expanded so live progress is visible without a click;
// settled cards (history, finished runs) stay collapsed §04 tool rows
// expand on demand. The default applies only at mount; manual toggles stick.
const open = ref(status.value === 'running' || inProgress.value > 0);
function toggle(): void {
open.value = !open.value;
}

View file

@ -4,11 +4,9 @@
// stateful copy/edit helpers.
import type { ChatTurn, TurnBlock } from '../types';
export function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
// 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`;

View file

@ -22,7 +22,6 @@ import Dialog from '../ui/Dialog.vue';
import Button from '../ui/Button.vue';
import IconButton from '../ui/IconButton.vue';
import Spinner from '../ui/Spinner.vue';
import Badge from '../ui/Badge.vue';
import Icon from '../ui/Icon.vue';
import Tooltip from '../ui/Tooltip.vue';
@ -62,7 +61,7 @@ const entries = ref<FsBrowseEntry[]>([]);
// dialog never resizes while searching.
const filter = ref('');
const searching = ref(false);
interface SearchHit { path: string; name: string; rel: string; isGitRepo?: boolean; branch?: string }
interface SearchHit { path: string; name: string; rel: string }
const searchResults = ref<SearchHit[]>([]);
const isSearching = computed(() => filter.value.trim().length > 0);
let searchToken = 0;
@ -137,7 +136,7 @@ async function runSearch(query: string): Promise<void> {
if (!e.isDir) continue;
const rel = e.path.startsWith(root) ? e.path.slice(root.length).replace(/^\/+/, '') : e.path;
if (fuzzyMatch(q, rel || e.name)) {
hits.push({ path: e.path, name: e.name, rel: rel || e.name, isGitRepo: e.isGitRepo, branch: e.branch });
hits.push({ path: e.path, name: e.name, rel: rel || e.name });
if (hits.length >= SEARCH_MAX_RESULTS) break;
}
if (node.depth + 1 < SEARCH_MAX_DEPTH) queue.push({ path: e.path, depth: node.depth + 1 });
@ -419,9 +418,6 @@ onUnmounted(() => {
>
<Icon class="dir-icon" name="folder-closed" size="sm" />
<span class="folder-name">{{ c.name }}</span>
<Badge v-if="c.isGitRepo" variant="info" size="sm">
{{ t('workspace.gitTag') }}<span v-if="c.branch" class="git-branch"> {{ c.branch }}</span>
</Badge>
</button>
<div v-if="pathCandidates.length === 0" class="fl-empty fl-error">
{{ t('workspace.noPathMatch', { parent: pathParent }) }}
@ -442,9 +438,6 @@ onUnmounted(() => {
>
<Icon class="dir-icon" name="folder-closed" size="sm" />
<span class="folder-name search-rel">{{ hit.rel }}</span>
<Badge v-if="hit.isGitRepo" variant="info" size="sm">
{{ t('workspace.gitTag') }}<span v-if="hit.branch" class="git-branch"> {{ hit.branch }}</span>
</Badge>
</button>
<div v-if="!searching && searchResults.length === 0" class="fl-empty">{{ t('workspace.noFilterMatch', { q: filter.trim() }) }}</div>
<div v-else-if="searching && searchResults.length === 0" class="fl-loading">{{ t('workspace.searching') }}</div>
@ -460,9 +453,6 @@ onUnmounted(() => {
>
<Icon class="dir-icon" name="folder-closed" size="sm" />
<span class="folder-name">{{ entry.name }}</span>
<Badge v-if="entry.isGitRepo" variant="info" size="sm">
{{ t('workspace.gitTag') }}<span v-if="entry.branch" class="git-branch"> {{ entry.branch }}</span>
</Badge>
</button>
<div v-if="entries.length === 0" class="fl-empty">{{ t('workspace.noSubfolders') }}</div>
</template>
@ -603,7 +593,6 @@ onUnmounted(() => {
white-space: nowrap;
color: var(--color-text);
}
.git-branch { color: var(--color-text-muted); }
/* Degraded mode (daemon can't browse): compact hint under the input box. */
.degraded-hint {

View file

@ -145,7 +145,7 @@ onUnmounted(() => {
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: max(10px, env(safe-area-inset-bottom));
padding-bottom: max(16px, var(--safe-bottom));
}
/* Slide-up + fade transition for the whole sheet (scrim fades, panel slides). */

View file

@ -3,18 +3,11 @@
Dialog (height auto, right-aligned footer). The single confirmation surface
for user actions driven app-wide by useConfirmDialog(). -->
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from '../ui/Dialog.vue';
import Button from '../ui/Button.vue';
const confirmButtonRef = ref<InstanceType<typeof Button> | null>(null);
function confirmButtonElement(): HTMLElement | null {
const el = confirmButtonRef.value?.$el;
return el instanceof HTMLElement ? el : null;
}
const props = withDefaults(defineProps<{
open: boolean;
title: string;
@ -37,6 +30,10 @@ const emit = defineEmits<{
const { t } = useI18n();
function onCancel(): void {
// While the confirm action runs (loading), every cancel path Cancel
// button, header close, Esc, overlay click is inert so the dialog can't
// be dismissed out from under the in-flight work.
if (props.loading) return;
emit('update:open', false);
emit('cancel');
}
@ -70,11 +67,17 @@ onBeforeUnmount(() => {
</script>
<template>
<!-- initial-focus uses a selector, not the Button component's $el: Button
has a template-root comment, so in dev builds it renders as a fragment
whose $el is a text node (unfocusable) focus would fall back to the
header close button and Enter would cancel instead of confirm. -->
<Dialog
:open="open"
:title="title"
height="auto"
:initial-focus="confirmButtonElement"
initial-focus=".confirm-dialog__confirm"
:close-on-esc="!loading"
:close-on-overlay="!loading"
@update:open="emit('update:open', $event)"
@close="onCancel"
>
@ -84,7 +87,7 @@ onBeforeUnmount(() => {
{{ cancelLabel ?? t('common.cancel') }}
</Button>
<Button
ref="confirmButtonRef"
class="confirm-dialog__confirm"
:variant="variant"
:loading="loading"
@click="emit('confirm')"

View file

@ -5,7 +5,13 @@
import { useConfirmDialog } from '../../composables/useConfirmDialog';
import ConfirmDialog from './ConfirmDialog.vue';
const { current, settle } = useConfirmDialog();
const { current, busy, settle, runAction } = useConfirmDialog();
// runAction never rejects (a failing action rejects the confirm() promise
// instead), so the floating promise is safe to drop here.
function onConfirm(): void {
void runAction();
}
</script>
<template>
@ -16,7 +22,8 @@ const { current, settle } = useConfirmDialog();
:confirm-label="current?.confirmLabel"
:cancel-label="current?.cancelLabel"
:variant="current?.variant"
@confirm="settle(true)"
:loading="busy"
@confirm="onConfirm"
@cancel="settle(false)"
/>
</template>

View file

@ -71,6 +71,9 @@ const props = defineProps<{
type Step = 'starting' | 'device-code' | 'success' | 'expired' | 'error';
const step = ref<Step>('starting');
// True when the error step came from repeated poll failures (daemon gone)
// rather than startOAuthLogin failing (unsupported endpoint) picks the copy.
const pollError = ref(false);
interface FlowData {
flowId: string;
@ -87,6 +90,11 @@ const copied = ref(false);
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let countdownTimer: ReturnType<typeof setInterval> | null = null;
// Consecutive failed polls (onPollOAuthLogin returned null). A single null can
// be a transient blip; several in a row means the daemon is gone and polling
// forever would strand the user on "waiting for authorization".
let consecutivePollFailures = 0;
const MAX_CONSECUTIVE_POLL_FAILURES = 3;
// -------------------------------------------------------------------------
// Lifecycle
@ -107,6 +115,8 @@ onUnmounted(() => {
async function startFlow(): Promise<void> {
stopTimers();
flow.value = null;
pollError.value = false;
consecutivePollFailures = 0;
step.value = 'starting';
const result = await props.onStartOAuthLogin();
@ -158,18 +168,32 @@ function scheduleNextPoll(intervalSec: number): void {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
const result = await props.onPollOAuthLogin();
if (result?.status === 'authenticated') {
if (result === null) {
// Poll failed (or no active flow). Keep polling through transient
// blips, but give up with an explicit error after several in a row.
consecutivePollFailures += 1;
if (consecutivePollFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
stopTimers();
pollError.value = true;
step.value = 'error';
return;
}
scheduleNextPoll(intervalSec);
return;
}
consecutivePollFailures = 0;
if (result.status === 'authenticated') {
stopTimers();
step.value = 'success';
setTimeout(() => {
emit('success');
emit('close');
}, 1200);
} else if (result?.status === 'expired' || result?.status === 'cancelled') {
} else if (result.status === 'expired' || result.status === 'cancelled') {
stopTimers();
step.value = 'expired';
} else {
// pending or null keep polling
// pending keep polling
scheduleNextPoll(intervalSec);
}
}, intervalSec * 1000);
@ -289,12 +313,16 @@ function formatSeconds(s: number): string {
</div>
</template>
<!-- Error (endpoint missing or network failure) -->
<!-- Error (endpoint missing, network failure, or repeated poll failures) -->
<template v-else-if="step === 'error'">
<div class="center-body">
<AuthStateIcon kind="error" />
<span class="center-text warn-text">{{ t('login.errorTitle') }}</span>
<span class="center-hint">{{ t('login.errorHint') }}</span>
<span class="center-text warn-text">
{{ pollError ? t('login.pollErrorTitle') : t('login.errorTitle') }}
</span>
<span class="center-hint">
{{ pollError ? t('login.pollErrorHint') : t('login.errorHint') }}
</span>
</div>
<div class="actions">
<Button variant="primary" @click="retryFlow">{{ t('login.retry') }}</Button>

View file

@ -21,6 +21,7 @@ import {
} from '../../lib/modelThinking';
import BottomSheet from '../dialogs/BottomSheet.vue';
import LanguageSwitcher from '../settings/LanguageSwitcher.vue';
import { formatTokens } from '../../lib/formatTokens';
import Button from '../ui/Button.vue';
import Input from '../ui/Input.vue';
import SegmentedControl from '../ui/SegmentedControl.vue';
@ -106,15 +107,15 @@ const permSub = computed<string>(() => {
return `${p} · ${desc}`;
});
const kFmt = (n: number): string => `${Math.round(n / 1000)}k`;
const ctxPct = computed<number>(() =>
// ceil (not round) so sub-0.5% usage still renders a visible bar sliver.
props.status.ctxMax > 0
? Math.min(100, Math.max(0, Math.round((props.status.ctxUsed / props.status.ctxMax) * 100)))
? Math.min(100, Math.max(0, Math.ceil((props.status.ctxUsed / props.status.ctxMax) * 100)))
: 0,
);
// Same "12k/256k" format as the desktop toolbar ring.
// Shared 1024-based formatter, same as the desktop tooltip / status panel.
const ctxValue = computed<string>(() =>
props.status.ctxMax > 0 ? `${kFmt(props.status.ctxUsed)}/${kFmt(props.status.ctxMax)}` : t('status.statusNone'),
props.status.ctxMax > 0 ? `${formatTokens(props.status.ctxUsed)}/${formatTokens(props.status.ctxMax)}` : t('status.statusNone'),
);
function setThinkingSegment(value: string): void {
@ -585,11 +586,11 @@ watch(
align-items: flex-start;
gap: 10px;
min-width: 0;
padding: 14px max(14px, env(safe-area-inset-right)) 14px max(14px, env(safe-area-inset-left));
padding: 14px max(14px, var(--safe-right)) 14px max(14px, var(--safe-left));
}
.group-title {
padding-left: max(14px, env(safe-area-inset-left));
padding-right: max(14px, env(safe-area-inset-right));
padding-left: max(14px, var(--safe-left));
padding-right: max(14px, var(--safe-right));
}
.srow-main {
flex: 1 1 auto;

View file

@ -1,7 +1,7 @@
<!-- apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue -->
<!-- Mobile switcher bottom sheet, mirroring the desktop sidebar: a "+ New
chat" row, then collapsible workspace groups (folder icon + name +
branch/path sub-line + per-group "+") with their session rows beneath.
path sub-line + per-group "+") with their session rows beneath.
Tapping a session selects it AND closes the sheet; tapping a group header
folds it, same as the desktop sidebar. -->
<script setup lang="ts">
@ -9,7 +9,6 @@ import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Session, WorkspaceGroup, WorkspaceView } from '../../types';
import { copyTextToClipboard } from '../../lib/clipboard';
import { useConfirmDialog } from '../../composables/useConfirmDialog';
import BottomSheet from '../dialogs/BottomSheet.vue';
import IconButton from '../ui/IconButton.vue';
import Icon from '../ui/Icon.vue';
@ -18,7 +17,6 @@ import MenuItem from '../ui/MenuItem.vue';
import Tooltip from '../ui/Tooltip.vue';
const { t } = useI18n();
const { confirm } = useConfirmDialog();
const props = withDefaults(
defineProps<{
@ -45,7 +43,7 @@ const emit = defineEmits<{
addWorkspace: [];
rename: [id: string, title: string];
archive: [id: string];
/** NOTE: needs `@delete-workspace="client.deleteWorkspace($event)"` wiring in App.vue. */
/** NOTE: App.vue wires this to confirmDeleteWorkspace (modal confirm + async delete). */
deleteWorkspace: [workspaceId: string];
loadMore: [workspaceId: string];
}>();
@ -152,23 +150,16 @@ function onRename(s: Session): void {
const title = next?.trim();
if (title) emit('rename', s.id, title);
}
async function onArchive(id: string): Promise<void> {
function onArchive(id: string): void {
menuFor.value = null;
if (
await confirm({
title: t('sidebar.archive'),
message: t('sidebar.archiveConfirm'),
variant: 'danger',
})
) {
emit('archive', id);
}
// The modal confirm + async archive live in App.vue (confirmArchiveSession).
emit('archive', id);
}
// ---------------------------------------------------------------------------
// Per-workspace "" menu: copy path + delete workspace. Copy path is handled
// locally, like the desktop sidebar; delete is confirmed via modal then
// emitted to the parent.
// locally, like the desktop sidebar; delete is emitted to the parent (App.vue
// owns the modal confirm + async delete).
// ---------------------------------------------------------------------------
const wsMenuFor = ref<string | null>(null);
@ -180,17 +171,9 @@ function onCopyWsPath(ws: WorkspaceView): void {
void copyTextToClipboard(ws.root);
wsMenuFor.value = null;
}
async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
function onDeleteWorkspace(ws: WorkspaceView): void {
wsMenuFor.value = null;
if (
await confirm({
title: t('sidebar.removeWorkspace'),
message: t('workspace.removeWorkspaceConfirm', { name: ws.name }),
variant: 'danger',
})
) {
emit('deleteWorkspace', ws.id);
}
emit('deleteWorkspace', ws.id);
}
</script>
@ -228,7 +211,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
<div class="mgh-main">
<span class="mgh-name">{{ g.workspace.name }}</span>
<Tooltip :text="g.workspace.root">
<span class="mgh-path">{{ g.workspace.branch || g.workspace.shortPath }}</span>
<span class="mgh-path">{{ g.workspace.shortPath }}</span>
</Tooltip>
</div>
@ -274,7 +257,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
@click="onSelectSession(s.id)"
>
<div class="m">
<div class="t" :class="{ run: s.busy, aborted: s.status === 'aborted' }">{{ s.title }}</div>
<div class="t" :class="{ run: s.busy, aborted: !s.busy && (attentionBySession[s.id] ?? 0) === 0 && (s.lastTurnReason === 'cancelled' || s.lastTurnReason === 'failed') }">{{ s.title }}</div>
<div class="s">{{ s.time }}</div>
</div>
<span v-if="(attentionBySession[s.id] ?? 0) > 0" class="att">{{ attentionBySession[s.id] }}</span>

View file

@ -90,9 +90,11 @@ const statusText = computed<string>(() =>
display: flex;
align-items: center;
gap: 10px;
height: 50px;
/* Grow the bar by the top inset so the 50px content row stays below the
status bar / notch in standalone PWA mode and landscape. */
height: calc(50px + var(--safe-top));
flex: none;
padding: 0 12px;
padding: var(--safe-top) max(12px, var(--safe-right)) 0 max(12px, var(--safe-left));
border-bottom: 1px solid var(--color-line);
background: var(--color-bg);
font-family: var(--font-ui);

View file

@ -5,6 +5,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { AppModel } from '../../api/types';
import { useDialogFocus } from '../../composables/useDialogFocus';
import { formatTokens } from '../../lib/formatTokens';
import Dialog from '../ui/Dialog.vue';
import Button from '../ui/Button.vue';
import IconButton from '../ui/IconButton.vue';
@ -197,7 +198,7 @@ function selectTab(tabId: string): void {
</span>
</span>
<span class="model-provider">{{ m.provider }}</span>
<span class="model-ctx">{{ t('model.contextSuffix', { size: Math.round(m.maxContextSize / 1000) }) }}</span>
<span class="model-ctx">{{ t('model.contextSuffix', { size: formatTokens(m.maxContextSize) }) }}</span>
<IconButton
size="sm"
:label="isStarred(m.id) ? t('model.unstarTitle') : t('model.starTitle')"

View file

@ -14,10 +14,8 @@ import Input from '../ui/Input.vue';
import Select from '../ui/Select.vue';
import Icon from '../ui/Icon.vue';
import Tooltip from '../ui/Tooltip.vue';
import { useConfirmDialog } from '../../composables/useConfirmDialog';
const { t } = useI18n();
const { confirm } = useConfirmDialog();
const dialogRef = ref<HTMLElement | null>(null);
// Move focus into the dialog on open; restore it to the opener on close.
@ -43,17 +41,10 @@ const emit = defineEmits<{
// Delete confirmation
// -------------------------------------------------------------------------
// Delete confirmation modal, consistent with remove-workspace.
async function onDeleteProvider(id: string): Promise<void> {
if (
await confirm({
title: t('providers.delete'),
message: t('providers.confirmDelete'),
variant: 'danger',
})
) {
emit('delete', id);
}
// Delete the modal confirm and the async delete live in App.vue
// (confirmDeleteProvider); the manager only emits the intent.
function onDeleteProvider(id: string): void {
emit('delete', id);
}
// -------------------------------------------------------------------------

View file

@ -1,13 +1,13 @@
// apps/kimi-web/src/composables/client/eventBatcher.ts
// Coalesce high-frequency streaming events onto the next animation frame.
// Coalesce high-frequency streaming events and apply them in bounded slices.
//
// Pure logic (no Vue, no DOM) so it is unit-testable in isolation. See
// useKimiWebClient.ts for where it is wired into the WS event pipeline.
// Pure logic (no Vue) so the queue, ordering, and scheduler fallback can be
// tested directly. See useKimiWebClient.ts for the WS pipeline wiring.
import type { AppEvent } from '../../api/types';
import type { AppEvent, KimiEventMeta } from '../../api/types';
// Events that merely append a chunk to something already streaming. They can
// arrive dozens to hundreds of times per second, so they are worth coalescing.
// arrive dozens to hundreds of times per second, so they are worth batching.
const RENDER_EVENT_TYPES: ReadonlySet<AppEvent['type']> = new Set<AppEvent['type']>([
'assistantDelta',
'agentDelta',
@ -15,66 +15,316 @@ const RENDER_EVENT_TYPES: ReadonlySet<AppEvent['type']> = new Set<AppEvent['type
'taskProgress',
]);
/** True for high-frequency render-only events that are safe to delay to the
next animation frame. Everything else (lifecycle / control-flow) must apply
immediately so turn-end cleanup etc. is not delayed by a throttled rAF. */
/** True for high-frequency render events. Lifecycle / control events remain
ordering barriers and are never merged with render events. */
export function isRenderEvent(appEvent: AppEvent): boolean {
return RENDER_EVENT_TYPES.has(appEvent.type);
}
function defaultScheduleFrame(cb: () => void): number {
return typeof requestAnimationFrame === 'function'
? requestAnimationFrame(cb)
: (setTimeout(cb, 16) as unknown as number);
export interface EventBatcherScheduler {
/** Request the next visual frame. Return null when frames are unavailable. */
requestFrame(callback: () => void): number | null;
cancelFrame(handle: number): void;
/** Request a task that still runs when animation frames are suspended. */
requestTask(callback: () => void): number;
cancelTask(handle: number): void;
}
const FALLBACK_TASK_DELAY_MS = 50;
const DEFAULT_MAX_ITEMS_PER_SLICE = 100;
/** Keep each append passed to the reducer small enough that concatenation and
* Markdown invalidation remain bounded. Offsets use JS string lengths, so the
* limit is measured in UTF-16 code units too. */
const MAX_COALESCED_STREAM_CHARS = 32 * 1024;
const defaultScheduler: EventBatcherScheduler = {
requestFrame(callback) {
return typeof requestAnimationFrame === 'function'
? requestAnimationFrame(callback)
: null;
},
cancelFrame(handle) {
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle);
},
requestTask(callback) {
return setTimeout(callback, FALLBACK_TASK_DELAY_MS) as unknown as number;
},
cancelTask(handle) {
clearTimeout(handle);
},
};
export interface EventBatcherOptions<T> {
/** Merge the new item into the last pending item, or return undefined. */
coalesce?: (previous: T, next: T) => T | undefined;
/** Maximum queued groups processed by one scheduled or synchronous slice. */
maxItemsPerSlice?: number;
scheduler?: EventBatcherScheduler;
}
/**
* Coalesce batchable items onto a single scheduled callback, while applying
* non-batchable items immediately.
*
* A non-batchable item first drains any pending batchable items (in arrival
* order) so overall ordering is preserved a lifecycle event never overtakes
* the deltas that arrived before it.
*
* The returned handle is itself callable (enqueue) and also exposes `flush()`
* to synchronously drain pending batchable items. Callers that replace state
* authoritatively (e.g. applying a server snapshot) must `flush()` first so
* stale queued deltas are not applied on top of the new state.
* Queue batchable items until the next frame (or task fallback), while keeping
* control events in arrival order. A control event triggers one bounded slice:
* short/coalesced queues still settle immediately, while a large queue resumes
* on later frames instead of becoming one long main-thread task.
*/
export interface EventBatcher<T> {
(item: T): void;
/** Synchronously drain any pending batchable items in arrival order. */
/** Synchronously drain every pending item. Reserved for authoritative state replacement. */
flush(): void;
/** Drop queued items that no longer have a valid owner. */
discard(predicate: (item: T) => boolean): void;
/** Cancel scheduled work and permanently discard this batcher's queue. */
dispose(): void;
}
export function createEventBatcher<T>(
process: (item: T) => void,
isBatchable: (item: T) => boolean,
schedule: (cb: () => void) => number = defaultScheduleFrame,
options: EventBatcherOptions<T> = {},
): EventBatcher<T> {
let pending: T[] = [];
let handle: number | null = null;
const scheduler = options.scheduler ?? defaultScheduler;
const maxItemsPerSlice = Math.max(
1,
Math.floor(options.maxItemsPerSlice ?? DEFAULT_MAX_ITEMS_PER_SLICE),
);
const pending: T[] = [];
let head = 0;
let frameHandle: number | null = null;
let taskHandle: number | null = null;
let scheduleVersion = 0;
let disposed = false;
const drain = (): void => {
handle = null;
if (pending.length === 0) return;
const batch = pending;
pending = [];
for (const item of batch) process(item);
const countPending = (): number => pending.length - head;
const cancelScheduled = (): void => {
scheduleVersion += 1;
if (frameHandle !== null) {
scheduler.cancelFrame(frameHandle);
frameHandle = null;
}
if (taskHandle !== null) {
scheduler.cancelTask(taskHandle);
taskHandle = null;
}
};
const compactQueue = (): void => {
if (head === pending.length) {
pending.length = 0;
head = 0;
} else if (head >= 1024) {
pending.splice(0, head);
head = 0;
}
};
let drainSlice: () => void;
const scheduleDrain = (): void => {
if (
disposed ||
frameHandle !== null ||
taskHandle !== null ||
countPending() === 0
) {
return;
}
const version = ++scheduleVersion;
const run = (): void => {
if (version !== scheduleVersion) return;
drainSlice();
};
frameHandle = scheduler.requestFrame(run);
taskHandle = scheduler.requestTask(run);
};
drainSlice = (): void => {
cancelScheduled();
let processed = 0;
while (!disposed && processed < maxItemsPerSlice && head < pending.length) {
const item = pending[head++]!;
process(item);
processed += 1;
}
compactQueue();
scheduleDrain();
};
const enqueue = ((item: T) => {
if (disposed) return;
if (isBatchable(item)) {
pending.push(item);
if (handle === null) handle = schedule(drain);
const previous = pending.length > head ? pending.at(-1) : undefined;
const merged = previous === undefined ? undefined : options.coalesce?.(previous, item);
if (merged === undefined) pending.push(item);
else pending[pending.length - 1] = merged;
scheduleDrain();
return;
}
// Immediate item: flush pending batchables first to preserve order.
drain();
process(item);
if (countPending() === 0) {
process(item);
return;
}
// Keep the control event behind everything that arrived before it. Process
// one bounded slice now so a short/coalesced stream still completes without
// waiting for another frame; schedule the remainder when the budget is hit.
pending.push(item);
drainSlice();
}) as EventBatcher<T>;
enqueue.flush = drain;
enqueue.flush = (): void => {
if (disposed) return;
cancelScheduled();
while (!disposed && head < pending.length) process(pending[head++]!);
compactQueue();
};
enqueue.discard = (predicate): void => {
if (disposed || countPending() === 0) return;
let write = head;
for (let read = head; read < pending.length; read += 1) {
const item = pending[read]!;
if (!predicate(item)) pending[write++] = item;
}
pending.length = write;
compactQueue();
if (countPending() === 0) cancelScheduled();
else scheduleDrain();
};
enqueue.dispose = (): void => {
if (disposed) return;
disposed = true;
cancelScheduled();
pending.length = 0;
head = 0;
};
return enqueue;
}
export interface PendingAppEvent {
appEvent: AppEvent;
meta: KimiEventMeta;
}
interface AssistantChunk {
kind: 'text' | 'thinking';
value: string;
}
function assistantChunk(event: AppEvent): AssistantChunk | undefined {
if (event.type !== 'assistantDelta') return undefined;
if (event.delta.text !== undefined && event.delta.thinking === undefined) {
return { kind: 'text', value: event.delta.text };
}
if (event.delta.thinking !== undefined && event.delta.text === undefined) {
return { kind: 'thinking', value: event.delta.thinking };
}
return undefined;
}
/**
* A single server frame can already contain a large coalesced delta. Split it
* before enqueueing so the per-group cap also holds for that case. Every part
* keeps the wire seq and advances only the raw stream offset.
*/
export function splitOversizedAppRenderEvent(
item: PendingAppEvent,
): readonly PendingAppEvent[] {
if (item.appEvent.type !== 'assistantDelta') return [item];
const appEvent = item.appEvent;
const stream = item.meta.stream;
const chunk = assistantChunk(appEvent);
if (
stream === undefined ||
chunk === undefined ||
stream.kind !== chunk.kind ||
chunk.value.length <= MAX_COALESCED_STREAM_CHARS
) {
return [item];
}
const parts: PendingAppEvent[] = [];
let start = 0;
while (start < chunk.value.length) {
let end = Math.min(start + MAX_COALESCED_STREAM_CHARS, chunk.value.length);
// Do not expose an unpaired surrogate in an intermediate render. Moving
// the boundary back by one still preserves offset continuity because raw
// offsets are counted in UTF-16 code units.
if (
end < chunk.value.length &&
end > start &&
/[\uD800-\uDBFF]/u.test(chunk.value[end - 1]!) &&
/[\uDC00-\uDFFF]/u.test(chunk.value[end]!)
) {
end -= 1;
}
const value = chunk.value.slice(start, end);
parts.push({
appEvent: {
...appEvent,
delta: chunk.kind === 'text' ? { text: value } : { thinking: value },
},
meta: {
...item.meta,
stream: { ...stream, offset: stream.offset + start },
},
});
start = end;
}
return parts;
}
/**
* Merge adjacent main-assistant text/thinking deltas only when their complete
* stream identity and offsets prove that concatenation is lossless.
*
* Protocol/stub events without raw stream metadata deliberately stay separate.
* Control events and other render-event types are never merged.
*/
export function coalesceAppRenderEvents(
previous: PendingAppEvent,
next: PendingAppEvent,
): PendingAppEvent | undefined {
if (previous.appEvent.type !== 'assistantDelta' || next.appEvent.type !== 'assistantDelta') {
return undefined;
}
const previousStream = previous.meta.stream;
const nextStream = next.meta.stream;
const previousChunk = assistantChunk(previous.appEvent);
const nextChunk = assistantChunk(next.appEvent);
if (
previousStream === undefined ||
nextStream === undefined ||
previousChunk === undefined ||
nextChunk === undefined ||
previous.meta.sessionId !== next.meta.sessionId ||
previous.appEvent.sessionId !== next.appEvent.sessionId ||
previous.appEvent.messageId !== next.appEvent.messageId ||
previous.appEvent.contentIndex !== next.appEvent.contentIndex ||
previousStream.turnId !== nextStream.turnId ||
previousStream.kind !== nextStream.kind ||
previousChunk.kind !== nextChunk.kind ||
previousStream.kind !== previousChunk.kind ||
nextStream.kind !== nextChunk.kind ||
nextStream.offset !== previousStream.offset + previousChunk.value.length ||
previousChunk.value.length + nextChunk.value.length > MAX_COALESCED_STREAM_CHARS
) {
return undefined;
}
const value = previousChunk.value + nextChunk.value;
return {
appEvent: {
...previous.appEvent,
delta: previousChunk.kind === 'text' ? { text: value } : { thinking: value },
},
// Advance the durable watermark with the newest frame while preserving the
// first offset of the merged chunk for the next continuity check.
meta: {
...next.meta,
stream: { ...previousStream },
},
};
}

View file

@ -69,7 +69,6 @@ export interface UseModelProviderStateDeps {
refreshSessionStatus: (sessionId: string) => Promise<void>;
persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise<void>;
activity: ComputedRef<ActivityState>;
inFlightPromptSessions: Set<string>;
saveThinkingToStorage: (v: ThinkingLevel) => void;
/** Replace one session in place (matched by id). Owned by the facade so the
* model module never assigns rawState.sessions directly. */
@ -90,7 +89,6 @@ export function useModelProviderState(
refreshSessionStatus,
persistSessionProfile,
activity,
inFlightPromptSessions,
saveThinkingToStorage,
updateSession,
updateSessionMessages,
@ -194,19 +192,6 @@ export function useModelProviderState(
}
}
async function refreshOAuthProviderModels(): Promise<void> {
try {
const result = await getKimiWebApi().refreshOAuthProviderModels();
for (const failure of result.failed) {
pushOperationFailure('refreshOAuthProviderModels', new Error(failure.reason), {
message: failure.provider,
});
}
} catch {
// Older daemons may not expose this endpoint; model listing still works.
}
}
/** Load providers */
async function loadProviders(): Promise<void> {
try {
@ -306,15 +291,14 @@ export function useModelProviderState(
async function activateSkill(skillName: string, args?: string, sessionId?: string): Promise<void> {
const sid = sessionId ?? rawState.activeSessionId;
if (!sid) return;
const guarded = activity.value === 'idle' && !inFlightPromptSessions.has(sid);
const guarded = activity.value === 'idle' && !rawState.inFlightBySession[sid];
const tempId = `msg_skill_opt_${Date.now().toString(36)}`;
const localTurnToken = guarded ? beginLocalTurn(sid) : undefined;
if (guarded) {
// Share the local-turn-start lifecycle with prompt submits: a racing
// terminal snapshot must not clear this skill's turn either.
inFlightPromptSessions.add(sid);
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: true };
rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: true };
const optimisticMsg: AppMessage = {
id: tempId,
sessionId: sid,
@ -338,8 +322,7 @@ export function useModelProviderState(
await getKimiWebApi().activateSkill(sid, skillName, args);
} catch (err) {
if (guarded) {
inFlightPromptSessions.delete(sid);
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: false };
updateSessionMessages(sid, (msgs) => msgs.filter((m) => m.id !== tempId));
}
pushOperationFailure('activateSkill', err, { sessionId: sid });
@ -426,7 +409,10 @@ export function useModelProviderState(
try {
const api = getKimiWebApi();
return await api.pollOAuthLogin();
} catch {
} catch (err) {
// The dialog counts consecutive nulls and gives up after a few; keep the
// cause in the log so a dead daemon is diagnosable.
console.warn('[kimi-web] pollOAuthLogin failed', err);
return null;
}
}
@ -461,7 +447,6 @@ export function useModelProviderState(
loadSkillsForSession,
loadSkillsForWorkspace,
loadModels,
refreshOAuthProviderModels,
loadProviders,
setModel,
toggleStarModel,

View file

@ -78,10 +78,11 @@ export function useTaskPoller(
bytes: withOutput.outputBytes,
});
}
// Only a definitive response marks the task as fetched — a transient
// failure must leave it eligible for a later backfill.
fetchedTerminalTaskOutputIds.add(task.id);
} catch {
// Task may have finished between listTasks and getTask; ignore.
} finally {
fetchedTerminalTaskOutputIds.add(task.id);
}
}),
);
@ -92,7 +93,15 @@ export function useTaskPoller(
rawState.tasksBySession = {
...rawState.tasksBySession,
[sessionId]: existing.map((t) => {
const polled = outputByTaskId.get(t.id);
// Output was fetched by REST task id; a background subagent row folded
// into its WS agent-id row (keepLiveSubagents) is matched via
// backgroundTaskId, otherwise its final output would be dropped here
// and never refetched (the REST id is already marked as fetched).
const polled =
outputByTaskId.get(t.id) ??
(t.backgroundTaskId !== undefined
? outputByTaskId.get(t.backgroundTaskId)
: undefined);
if (!polled) return t;
return { ...t, outputPreview: polled.preview, outputBytes: polled.bytes };
}),
@ -145,12 +154,13 @@ export function useTaskPoller(
bytes: withOutput.outputBytes,
});
}
} catch {
// Task may have finished between listTasks and getTask; ignore.
} finally {
// Mark as fetched only on a definitive response; a transient failure
// stays eligible for the next poll.
if (isTerminal) {
fetchedTerminalTaskOutputIds.add(task.id);
}
} catch {
// Task may have finished between listTasks and getTask; ignore.
}
}),
);

View file

@ -19,7 +19,6 @@ import type {
AppInFlightTurn,
AppMessage,
AppSession,
AppSessionStatus,
AppWorkspace,
ApprovalDecision,
ApprovalResponse,
@ -83,7 +82,7 @@ function isTaskAlreadyFinishedError(err: unknown): boolean {
* action kind. Drives the card's loading state and guards against a duplicate
* submit while the first request is still in flight (the server would reject
* the second resolve with 40902). Module-level singleton matches
* `inFlightPromptSessions` in the facade.
* `inFlightBySession` on rawState.
*/
const pendingQuestionActions = reactive<Record<string, 'answer' | 'dismiss'>>({});
/** Approval ids with an in-flight respond, keyed by approvalId. */
@ -94,8 +93,8 @@ const pendingTaskCancellations = reactive<Record<string, true>>({});
* Workspace ids whose empty-session first prompt is currently being created +
* submitted. The empty-composer path (`startSessionAndSendPrompt`) awaits
* `createDraftSession` (addWorkspace + createSession + selectSession) before
* the session id exists, so the per-session `inFlightPromptSessions` guard
* cannot cover that window a second Enter / send-button click during it
* the session id exists, so the per-session prompt-in-flight guard cannot
* cover that window a second Enter / send-button click during it
* would otherwise fire a second concurrent POST and trip the daemon's
* `turn.agent_busy` race. Module-level singleton matches the other
* `pending*Actions` guards above.
@ -111,7 +110,7 @@ const startingFirstPromptWorkspaces = reactive(new Set<string>());
* - pending: set while the start request (POST /prompts or skill
* activation) has not been acknowledged by the daemon a snapshot
* requested in that window cannot reflect the turn server-side either.
* Module-level singleton matches `inFlightPromptSessions` in the facade.
* Module-level singleton matches `inFlightBySession` on rawState.
*/
const promptGenerationBySession = new Map<string, number>();
const pendingLocalTurnStarts = new Map<string, Set<number>>();
@ -200,7 +199,6 @@ export interface UseWorkspaceStateDeps {
opts?: { title?: string; message?: string; sessionId?: string },
) => void;
activity: ComputedRef<ActivityState>;
inFlightPromptSessions: Set<string>;
sessionsKnownEmpty: Set<string>;
// rawState.sessions mutation funnel, owned by the facade. This module never
// assigns rawState.sessions directly — it goes through these.
@ -258,7 +256,6 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
modelProvider,
pushOperationFailure,
activity,
inFlightPromptSessions,
sessionsKnownEmpty,
setSessions,
updateSession,
@ -490,22 +487,34 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
/** Drain every page of sessions, newest first. A single global walk (instead of
* per-workspace) so sessions whose cwd is not a registered workspace root are
* still reachable after a refresh. */
async function listAllSessionsGlobal(): Promise<AppSession[]> {
* still reachable after a refresh. A later-page failure returns the pages
* already fetched plus the error; only a first-page failure rejects. */
async function listAllSessionsGlobal(): Promise<{
sessions: AppSession[];
error?: unknown;
}> {
const api = getKimiWebApi();
const items: AppSession[] = [];
let beforeId: string | undefined;
let continuationError: unknown;
for (;;) {
const page = await api.listSessions({
pageSize: SESSION_PAGE_SIZE,
beforeId,
excludeEmpty: true,
});
let page: { items: AppSession[]; hasMore: boolean };
try {
page = await api.listSessions({
pageSize: SESSION_PAGE_SIZE,
beforeId,
excludeEmpty: true,
});
} catch (error) {
if (items.length === 0) throw error;
continuationError = error;
break;
}
items.push(...page.items);
if (!page.hasMore || page.items.length === 0) break;
beforeId = page.items[page.items.length - 1]!.id;
}
return items;
return { sessions: items, error: continuationError };
}
/**
@ -528,6 +537,20 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
);
}
/** Keep fresh rows authoritative while retaining cached rows a partial list
* request never reached. */
function mergePartialSessionsWithCached(sessions: AppSession[]): AppSession[] {
const merged = [...sessions];
const loadedIds = new Set(merged.map((session) => session.id));
for (const session of rawState.sessions) {
if (loadedIds.has(session.id)) continue;
merged.push(session);
loadedIds.add(session.id);
}
merged.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return merged;
}
/** Load the initial page of sessions for one workspace, then keep fetching
* older pages while the oldest loaded session is still within
* SESSIONS_RECENT_WINDOW_MS. Every page (including continuations) uses the
@ -536,7 +559,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
* keeping only up to the first session that falls outside the window. */
async function loadInitialSessionsForWorkspace(
workspaceId: string,
): Promise<{ workspaceId: string; page: { items: AppSession[]; hasMore: boolean } }> {
): Promise<{
workspaceId: string;
page: { items: AppSession[]; hasMore: boolean };
error?: unknown;
}> {
const api = getKimiWebApi();
const items: AppSession[] = [];
const now = Date.now();
@ -544,6 +571,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
let beforeId: string | undefined;
let hasMore = false;
let isFirstPage = true;
let continuationError: unknown;
for (;;) {
let page: { items: AppSession[]; hasMore: boolean };
try {
@ -555,9 +583,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
});
} catch (error) {
// A failed continuation page must not discard sessions already loaded
// from earlier pages; only a page-1 failure propagates (the caller then
// falls back to an empty page for that workspace).
// from earlier pages; only a page-1 failure rejects the workspace load.
if (isFirstPage) throw error;
continuationError = error;
hasMore = true;
break;
}
hasMore = page.hasMore;
@ -584,39 +613,97 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
if (!page.hasMore || oldestBeyondWindow) break;
beforeId = oldest.id;
}
return { workspaceId, page: { items, hasMore } };
return { workspaceId, page: { items, hasMore }, error: continuationError };
}
/** Fetch the first page of sessions for every known workspace concurrently.
* Returns the merged, recency-sorted list and seeds per-workspace hasMore. */
async function loadInitialSessionsByWorkspace(): Promise<AppSession[]> {
* Returns the merged, recency-sorted list and seeds per-workspace hasMore.
* When every workspace request fails, returns undefined so the caller keeps
* the previously loaded sessions instead of committing a false empty list. */
async function loadInitialSessionsByWorkspace(): Promise<AppSession[] | undefined> {
const workspaces = rawState.workspaces;
if (workspaces.length === 0) {
// /workspaces may be unavailable or empty on older / partially-failing
// daemons while /sessions still works. Fall back to the legacy global
// walk so history still shows and mergedWorkspaces can derive workspaces
// from session cwds, instead of rendering a blank sidebar.
const fallback = await listAllSessionsGlobal().catch(() => [] as AppSession[]);
const fallback = await listAllSessionsGlobal();
const sessions =
fallback.error === undefined
? fallback.sessions
: mergePartialSessionsWithCached(fallback.sessions);
rawState.sessionsHasMoreByWorkspace = {};
rawState.sessionsCursorByWorkspace = {};
rawState.sessionsInitialCountByWorkspace = {};
rawState.sessionsFullyLoaded = true;
return fallback;
rawState.sessionsFullyLoaded = fallback.error === undefined;
if (fallback.error !== undefined) pushOperationFailure('load', fallback.error);
return sessions;
}
const pages = await Promise.all(
workspaces.map((w) =>
loadInitialSessionsForWorkspace(w.id).catch(() => ({
workspaceId: w.id,
page: { items: [] as AppSession[], hasMore: false },
})),
),
const results = await Promise.allSettled(
workspaces.map((w) => loadInitialSessionsForWorkspace(w.id)),
);
const loaded: AppSession[] = [];
const loadedIds = new Set<string>();
const successfulPages = new Map<string, { items: AppSession[]; hasMore: boolean }>();
const failedWorkspaceIds = new Set<string>();
let firstError: unknown;
for (let index = 0; index < results.length; index++) {
const result = results[index]!;
if (result.status === 'fulfilled') {
successfulPages.set(result.value.workspaceId, result.value.page);
if (result.value.error !== undefined) {
if (failedWorkspaceIds.size === 0) firstError = result.value.error;
failedWorkspaceIds.add(result.value.workspaceId);
}
for (const session of result.value.page.items) {
if (loadedIds.has(session.id)) continue;
loaded.push(session);
loadedIds.add(session.id);
}
continue;
}
if (failedWorkspaceIds.size === 0) firstError = result.reason;
failedWorkspaceIds.add(workspaces[index]!.id);
}
// One failed workspace must not erase another workspace's successful page,
// nor the failed workspace's last usable rows. If every request failed,
// leave both sessions and pagination state untouched for a natural retry.
if (successfulPages.size === 0) {
pushOperationFailure('load', firstError);
return undefined;
}
const failedWorkspaceRoots = new Set(
workspaces
.filter((workspace) => failedWorkspaceIds.has(workspace.id))
.map((workspace) => workspace.root),
);
const registeredWorkspaceIds = new Set(workspaces.map((workspace) => workspace.id));
for (const session of rawState.sessions) {
const belongsToFailedWorkspace =
session.workspaceId !== undefined && registeredWorkspaceIds.has(session.workspaceId)
? failedWorkspaceIds.has(session.workspaceId)
: failedWorkspaceRoots.has(session.cwd) ||
failedWorkspaceIds.has(workspaceIdForSession(session));
if (!belongsToFailedWorkspace || loadedIds.has(session.id)) continue;
loaded.push(session);
loadedIds.add(session.id);
}
const hasMore: Record<string, boolean> = {};
const cursors: Record<string, string | undefined> = {};
const counts: Record<string, number> = {};
for (const { workspaceId, page } of pages) {
loaded.push(...page.items);
for (const { id: workspaceId } of workspaces) {
const page = successfulPages.get(workspaceId);
if (page === undefined) {
const previousHasMore = rawState.sessionsHasMoreByWorkspace[workspaceId];
const previousCursor = rawState.sessionsCursorByWorkspace[workspaceId];
const previousCount = rawState.sessionsInitialCountByWorkspace[workspaceId];
if (previousHasMore !== undefined) hasMore[workspaceId] = previousHasMore;
if (previousCursor !== undefined) cursors[workspaceId] = previousCursor;
if (previousCount !== undefined) counts[workspaceId] = previousCount;
continue;
}
// Trust the server's hasMore — the per-workspace session_count is only a
// (possibly stale) label total, not an authority on whether more pages exist.
hasMore[workspaceId] = page.hasMore;
@ -640,6 +727,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// Keep rawState.sessions newest-first for readers that pick sessions[0]
// (e.g. auto-selecting the most recent session on first load).
loaded.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
if (failedWorkspaceIds.size > 0) pushOperationFailure('load', firstError);
return loaded;
}
@ -693,10 +781,18 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
* first search; a no-op once the full list is loaded. */
async function loadAllSessions(): Promise<void> {
if (rawState.sessionsFullyLoaded) return;
const sessions = await listAllSessionsGlobal().catch(() => null);
if (sessions === null) return;
const result = await listAllSessionsGlobal().catch((err) => {
console.warn('[kimi-web] loadAllSessions failed; search covers only loaded sessions', err);
return null;
});
if (result === null) return;
const sessions =
result.error === undefined
? result.sessions
: mergePartialSessionsWithCached(result.sessions);
setSessionsPreservingLiveUsage(sessions);
rawState.sessionsFullyLoaded = true;
rawState.sessionsFullyLoaded = result.error === undefined;
if (result.error !== undefined) return;
const cleared: Record<string, boolean> = {};
for (const w of rawState.workspaces) cleared[w.id] = false;
rawState.sessionsHasMoreByWorkspace = cleared;
@ -755,8 +851,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// the old full global walk: the sidebar now truncates by loading, not by
// hiding already-fetched rows.
await loadWorkspaces();
const sessions = await loadInitialSessionsByWorkspace();
setSessionsPreservingLiveUsage(sessions);
const loadedSessions = await loadInitialSessionsByWorkspace();
const sessions = loadedSessions ?? rawState.sessions;
if (loadedSessions !== undefined) setSessionsPreservingLiveUsage(loadedSessions);
// First load: pick the workspace of the most-recent session, unless the
// user already has a persisted active workspace that still exists.
@ -1024,7 +1121,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
): Promise<void> {
// Guard the whole "create draft session + submit first prompt" flow: the
// session id doesn't exist until `createDraftSession` resolves, so the
// per-session `inFlightPromptSessions` guard can't cover this window. A
// per-session in-flight guard can't cover this window. A
// second Enter / send-button click in that window would otherwise fire a
// concurrent first POST for the same new session and trip the daemon's
// `turn.agent_busy` race.
@ -1140,7 +1237,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
upsertWorkspacePreserveOrder(ws);
openWorkspaceDraft(ws.id);
return true;
} catch {
} catch (err) {
// The caller shows an inline error in the picker; keep the cause in the log.
console.warn('[kimi-web] addWorkspaceByPath failed for', trimmed, err);
return false;
}
}
@ -1309,13 +1408,12 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
Returns true when the daemon accepted the prompt. */
async function submitPromptInternal(sid: string, text: string, attachments?: PromptAttachment[]): Promise<boolean> {
// Mark this session as having a prompt in flight BEFORE any await, so a racing
// sendPrompt sees it and enqueues. Cleared when activity returns to idle.
// beginLocalTurn also bumps the snapshot generation and marks the submit
// pending, so a racing terminal snapshot can't clear this prompt (see
// handleSessionSnapshot).
// sendPrompt sees it and enqueues. Cleared when the main turn ends (or the
// prompt dies without one). beginLocalTurn also bumps the snapshot generation
// and marks the submit pending, so a racing terminal snapshot can't clear
// this prompt (see handleSessionSnapshot).
const localTurnToken = beginLocalTurn(sid);
inFlightPromptSessions.add(sid);
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: true };
rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: true };
const tempId = nextOptimisticMsgId();
try {
const api = getKimiWebApi();
@ -1323,11 +1421,18 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
if (text) content.push({ type: 'text', text });
for (const att of attachments ?? []) {
if (att.kind === 'video') content.push({ type: 'video', source: { kind: 'file', fileId: att.fileId } });
else content.push({ type: 'image', source: { kind: 'file', fileId: att.fileId } });
else if (att.kind === 'file') {
content.push({
type: 'file',
fileId: att.fileId,
name: att.name ?? '',
mediaType: att.mediaType || 'application/octet-stream',
size: att.size ?? 0,
});
} else content.push({ type: 'image', source: { kind: 'file', fileId: att.fileId } });
}
if (content.length === 0) {
inFlightPromptSessions.delete(sid);
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: false };
return false;
}
@ -1365,8 +1470,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
await api.updateSession(sid, { goalObjective: text.trim() });
} catch (err) {
pushOperationFailure('createGoal', err, { sessionId: sid });
inFlightPromptSessions.delete(sid);
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: false };
updateSessionMessages(sid, (msgs) =>
msgs.some((m) => m.id === tempId) ? msgs.filter((m) => m.id !== tempId) : msgs,
);
@ -1425,8 +1529,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// queued forever (turn.ended will never arrive), and roll back the
// optimistic user message so the transcript doesn't show a delivered-
// looking message the daemon never received.
inFlightPromptSessions.delete(sid);
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: false };
updateSessionMessages(sid, (msgs) =>
msgs.some((m) => m.id === tempId) ? msgs.filter((m) => m.id !== tempId) : msgs,
);
@ -1444,10 +1547,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
if (!sid) return;
// If the session is not idle OR a prompt is already in flight (submitted but
// the WS turn.started hasn't flipped activity to 'running' yet), enqueue
// instead of submitting directly. Gating on inFlightPromptSessions closes the
// window where two rapid prompts would both submit and race.
if (activity.value !== 'idle' || inFlightPromptSessions.has(sid)) {
// the WS turn.started hasn't arrived yet), enqueue instead of submitting
// directly. The in-flight flag closes the window where two rapid prompts
// would both submit and race.
if (activity.value !== 'idle' || rawState.inFlightBySession[sid]) {
enqueue(text, attachments);
return;
}
@ -1485,7 +1588,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
const merged = parts.join('\n\n');
// Idle and nothing in flight — there is no turn to steer into; normal send.
if (activity.value === 'idle' && !inFlightPromptSessions.has(sid)) {
if (activity.value === 'idle' && !rawState.inFlightBySession[sid]) {
await submitPromptInternal(sid, merged, mergedAttachments);
return;
}
@ -1495,7 +1598,15 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
if (merged) content.push({ type: 'text', text: merged });
for (const att of mergedAttachments) {
if (att.kind === 'video') content.push({ type: 'video', source: { kind: 'file', fileId: att.fileId } });
else content.push({ type: 'image', source: { kind: 'file', fileId: att.fileId } });
else if (att.kind === 'file') {
content.push({
type: 'file',
fileId: att.fileId,
name: att.name ?? '',
mediaType: att.mediaType || 'application/octet-stream',
size: att.size ?? 0,
});
} else content.push({ type: 'image', source: { kind: 'file', fileId: att.fileId } });
}
const tempId = nextOptimisticMsgId();
const optimisticMsg: AppMessage = {
@ -1589,12 +1700,12 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
}
/**
* Shared prompt-finish cleanup, used by BOTH the WS idle/aborted event path
* (facade `onSessionIdle`) and the authoritative-snapshot path
* Shared prompt-finish cleanup, used by BOTH the main-turn-ended path
* (facade `onMainTurnEnd`) and the authoritative-snapshot path
* (handleSessionSnapshot below). Returns whether this call actually flipped
* an in-flight prompt to finished.
*
* Clears the local in-flight/sending/prompt-id state and drains exactly ONE
* Clears the local in-flight/prompt-id state and drains exactly ONE
* queued message the resubmitted prompt re-arms the in-flight flag, and
* its own finish drains the following one. Repeat calls (e.g. a late
* duplicate idle event) therefore cannot drain more than one message per
@ -1602,8 +1713,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
* unread) on top; the snapshot path deliberately adds none.
*/
function finishPromptLocal(sid: string): boolean {
const wasInFlight = inFlightPromptSessions.delete(sid);
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
const wasInFlight = rawState.inFlightBySession[sid] === true;
rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: false };
// Drop any cached prompt_id so a later skill activation (which has no
// prompt_id) doesn't accidentally reuse this stale id for :abort.
if (rawState.promptIdBySession[sid] !== undefined) {
@ -1649,10 +1760,13 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
*/
function handleSessionSnapshot(
sid: string,
snapshot: { inFlightTurn: AppInFlightTurn | null; status: AppSessionStatus },
snapshot: { inFlightTurn: AppInFlightTurn | null; busy: boolean },
): void {
if (snapshot.inFlightTurn !== null) return;
if (snapshot.status !== 'idle' && snapshot.status !== 'aborted') return;
// inFlightTurn tracks only the main agent, while busy aggregates all
// agents and background work. Keep the local prompt alive only when both
// facts still support a running main turn. Either terminal fact may also
// reconcile the other tracker when a snapshot catches it stale.
if (snapshot.inFlightTurn !== null && snapshot.busy) return;
finishPromptLocal(sid);
}
@ -1812,7 +1926,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
pendingTaskCancellations[taskId] = true;
try {
const api = getKimiWebApi();
await api.cancelTask(sid, taskId);
// A background subagent row is keyed by agent id, but REST `/tasks` only
// knows its background-task id.
const restTaskId = (rawState.tasksBySession[sid] ?? []).find((t) => t.id === taskId)
?.backgroundTaskId;
await api.cancelTask(sid, restTaskId ?? taskId);
// Update task status locally
const list = rawState.tasksBySession[sid] ?? [];
rawState.tasksBySession = {
@ -2079,8 +2197,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// Best-effort registry cleanup; ignore failures (the hide already took effect).
try {
await getKimiWebApi().deleteWorkspace(id);
} catch {
} catch (err) {
// registry delete is optional — the sidebar hide is what the user sees.
console.warn('[kimi-web] deleteWorkspace registry cleanup failed for', id, err);
}
rawState.workspaces = rawState.workspaces.filter((w) => w.id !== id && w.root !== root);
if (removingActiveWorkspace || activeSessionInRemovedWorkspace) {
@ -2374,7 +2493,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
size: result.size,
lineCount: result.lineCount,
};
} catch {
} catch (err) {
console.warn('[kimi-web] readFileContent failed for', path, err);
return null;
}
}

View file

@ -11,7 +11,7 @@
import type { AppMessage, AppApprovalRequest, AppTask, CompactionMarkerMetadata } from '../api/types';
import { COMPACTION_MARKER_METADATA_KEY } from '../api/types';
import type { AgentMember, ApprovalBlock, ChatTurn, CronTurnData, DiffLine, ToolCall, ToolMedia, TurnBlock } from '../types';
import type { AgentMember, ApprovalBlock, ChatTurn, CronTurnData, DiffLine, ToolCall, ToolMedia, TurnAttachment, TurnBlock } from '../types';
const READ_MEDIA_TOOL_RE = /^read[_-]?media(?:file)?$/i;
const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s;
@ -66,8 +66,17 @@ function mediaPathTag(text: string): { kind: 'image' | 'video' | 'audio'; path:
* recover the fileId from the cache filename to build a playable URL. Returns
* undefined when the basename isn't shaped like a file-store id (`f_…`) e.g.
* TUI cache names (`<uuid>-<label>`) or legacy `/tmp/foo.mp4` paths so the
* caller leaves the raw tag as text instead of fabricating a broken /files url. */
const FILE_STORE_ID_RE = /^f_[A-Za-z0-9]{10,}$/;
* caller leaves the raw tag as text instead of fabricating a broken /files url.
*
* File-store ids come in two shapes: v1 `f_`<26-char ULID> (no hyphens) and
* v2 `f_`<randomUUID> (32 hex chars + 4 hyphens). */
const FILE_STORE_ID_RE =
/^f_(?:[0-9A-Za-z]{26}|[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})$/;
/** Same two id shapes, anchored at the start of a `<fileId>-<name>` basename.
Splitting on the first '-' instead would truncate v2 UUID ids at their
first inner hyphen. */
const FILE_STORE_ID_AT_START_RE =
/^f_(?:[0-9A-Za-z]{26}|[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(?=-)/;
function fileIdFromCachePath(p: string): string | undefined {
const base = p.split(/[\\/]/).at(-1) ?? '';
const dot = base.lastIndexOf('.');
@ -75,6 +84,32 @@ function fileIdFromCachePath(p: string): string | undefined {
return FILE_STORE_ID_RE.test(id) ? id : undefined;
}
/** A generic file attachment comes back from the server as a text notice (see
* resolvePromptMediaFiles in the kap-server prompts route):
* Attached file "<name>" (<mime>, <n> bytes): <dir>/<fileId>-<name> open it with the Read tool
* Recover the chip from the notice instead of dumping it absolute server
* path and all into the bubble. The fileId is matched by shape at the start
* of the basename (ULID or UUID, see FILE_STORE_ID_AT_START_RE). Inline-base64
* attachments are content-hash named (no fileId): they still become a chip so
* the notice stays hidden, just without bytes to open. */
const ATTACHED_FILE_NOTICE_RE =
/^Attached file "(.+)" \(([^,]+), (\d+) bytes\): (.+) — open it with the Read tool$/;
function attachedFileNotice(
text: string,
): { name: string; mediaType: string; size: number; fileId?: string } | null {
const m = ATTACHED_FILE_NOTICE_RE.exec(text.trim());
if (!m) return null;
const base = (m[4] ?? '').split(/[\\/]/).at(-1) ?? '';
const id = FILE_STORE_ID_AT_START_RE.exec(base)?.[0];
return {
name: m[1]!,
mediaType: m[2]!,
size: Number(m[3]),
fileId: id !== undefined && FILE_STORE_ID_RE.test(id) ? id : undefined,
};
}
function bytesFromBase64(b64: string): number {
if (b64.length === 0) return 0;
const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
@ -765,7 +800,7 @@ export function messagesToTurns(
origin?.kind === 'plugin_command' && origin?.trigger === 'user-slash';
const textParts: string[] = [];
const images: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[] = [];
const attachments: TurnAttachment[] = [];
for (const c of msg.content) {
if (c.type === 'text') {
if (isSkillActivation) {
@ -786,24 +821,60 @@ export function messagesToTurns(
if (tag && (tag.kind === 'video' || tag.kind === 'image') && getFileUrl) {
const fileId = fileIdFromCachePath(tag.path);
if (fileId) {
images.push({ url: getFileUrl(fileId), kind: tag.kind, alt: fileId, fileId });
attachments.push({ url: getFileUrl(fileId), kind: tag.kind, fileId });
continue;
}
}
// A generic file upload comes back as an "Attached file …" notice;
// recover the chip the same way (see attachedFileNotice).
const attached = attachedFileNotice(c.text);
if (attached) {
attachments.push({
kind: 'file',
// No recoverable fileId (inline-base64 upload) → no URL: the
// chip renders name/size but stays non-clickable.
url: attached.fileId && getFileUrl ? getFileUrl(attached.fileId) : '',
fileId: attached.fileId,
name: attached.name,
mediaType: attached.mediaType,
size: attached.size,
});
continue;
}
const stripped = stripImageCompressionCaptions(c.text);
if (stripped !== c.text && stripped.trim().length === 0) continue;
textParts.push(stripped);
}
}
const media = resolveMediaUrl(c);
if (media) images.push({ url: media.url, kind: media.kind, alt: c.type === 'file' ? c.name : undefined, fileId: media.fileId });
if (media) {
attachments.push({
url: media.url,
kind: media.kind,
name: c.type === 'file' ? c.name : undefined,
fileId: media.fileId,
});
continue;
}
// Non-media files (pdf/zip/yaml/…) carry no playable URL, but the chip
// still renders them with name/size and a download action.
if (c.type === 'file' && getFileUrl) {
attachments.push({
kind: 'file',
url: getFileUrl(c.fileId),
fileId: c.fileId,
name: c.name,
mediaType: c.mediaType || undefined,
size: c.size,
});
}
}
turns.push({
id: msg.id,
role: 'user',
no: no++,
text: textParts.join('\n'),
images: images.length > 0 ? images : undefined,
attachments: attachments.length > 0 ? attachments : undefined,
skillActivation: isSkillActivation
? { name: origin.skillName!, args: origin.skillArgs }
: undefined,

View file

@ -1,6 +1,8 @@
// apps/kimi-web/src/composables/useAttachmentUpload.ts
// Image/video attachment handling for the composer: file picker, paste, drag &
// drop, the upload machinery, the chip strip, and the preview lightbox.
// Attachment handling for the composer: file picker, paste, drag & drop, the
// upload machinery, the chip strip, and the preview lightbox. Images and
// videos get media chips with thumbnails; any other file type attaches as a
// generic file chip (an icon + name, no thumbnail) and is sent as a file part.
//
// Pending attachments are scoped per session (keyed by session id) so switching
// sessions can't leak one session's unsent attachments into another session's
@ -17,10 +19,14 @@ export interface Attachment {
localId: string;
/** File name */
name: string;
/** image or video — drives the chip preview and the content-block type. */
kind: 'image' | 'video';
/** Object URL for the thumbnail preview */
previewUrl: string;
/** image, video, or any other file — drives the chip preview and the content-block type. */
kind: 'image' | 'video' | 'file';
/** Object URL for the thumbnail preview (unset for file attachments — those render an icon chip). */
previewUrl?: string;
/** Local MIME of the picked file — echoed into the wire file part. */
mediaType?: string;
/** Local byte size of the picked file — echoed into the wire file part. */
size?: number;
/** True while uploading */
uploading: boolean;
/** Resolved daemon file id (set after upload completes) */
@ -61,13 +67,15 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
}
function revokeAttachment(att: Attachment): void {
if (att.previewUrl === undefined) return;
try { URL.revokeObjectURL(att.previewUrl); } catch { /* ignore */ }
}
function mediaKind(mime: string): 'image' | 'video' | null {
function attachmentKind(mime: string): 'image' | 'video' | 'file' {
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
return null;
// Everything else — including an empty/unknown MIME — attaches as a file.
return 'file';
}
async function addFiles(files: File[]): Promise<void> {
@ -76,15 +84,24 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
// Capture the session at upload time; async completion must update the same
// session even if the user has since switched away.
const sid = sessionId() ?? '';
const media = files
.map((file) => ({ file, kind: mediaKind(file.type) }))
.filter((m): m is { file: File; kind: 'image' | 'video' } => m.kind !== null);
if (media.length === 0) return;
if (files.length === 0) return;
for (const { file, kind } of media) {
for (const file of files) {
const kind = attachmentKind(file.type);
const localId = nextLocalId();
const previewUrl = URL.createObjectURL(file);
const att: Attachment = { localId, name: file.name, kind, previewUrl, uploading: true };
// Only media gets a thumbnail object URL; files render an icon chip.
const previewUrl = kind === 'file' ? undefined : URL.createObjectURL(file);
const att: Attachment = {
localId,
name: file.name,
kind,
previewUrl,
// Extensionless/unknown files report an empty MIME — normalize now so
// the wire file part's required non-empty media_type never sees ''.
mediaType: file.type || 'application/octet-stream',
size: file.size,
uploading: true,
};
setForSession(sid, [...(attachmentsBySession.value[sid] ?? []), att]);
// Upload in background; update the attachment when done.
@ -94,7 +111,15 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
sid,
current.map((a) =>
a.localId === localId
? { ...a, uploading: false, fileId: result?.fileId, error: result === null }
? {
...a,
uploading: false,
fileId: result?.fileId,
// Adopt the server-recorded MIME when available — the
// server's file meta is what the prompt route reads.
mediaType: result?.mediaType ?? a.mediaType,
error: result === null,
}
: a,
),
);
@ -144,7 +169,7 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
const cd = e.clipboardData;
if (!cd) return;
// Collect image files from both .items and .files to cover all browsers/OS.
// Collect attached files from both .items and .files to cover all browsers/OS.
const files: File[] = [];
const seenKeys = new Set<string>();
@ -159,7 +184,7 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
// From DataTransferItemList.
for (const item of Array.from(cd.items)) {
if (item.kind === 'file' && mediaKind(item.type)) {
if (item.kind === 'file') {
const blob = item.getAsFile();
if (blob) addBlob(blob, blob.name || `paste-${Date.now()}.${item.type.split('/')[1] ?? 'png'}`);
}
@ -167,23 +192,27 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
// From FileList (some browsers/OS put screenshots here directly).
for (const file of Array.from(cd.files)) {
if (mediaKind(file.type)) {
addBlob(file, file.name);
}
addBlob(file, file.name);
}
if (files.length === 0) return; // No media — let normal text paste proceed unmodified.
if (files.length === 0) return; // No files — let normal text paste proceed unmodified.
e.preventDefault();
void addFiles(files);
}
// Drag-drop handlers.
// Drag-drop handlers. WindowDragDepth tracks nested dragenter/dragleave pairs
// for the document-level listeners below (declared here so the composer
// handlers can reset it on their own drop).
let windowDragDepth = 0;
function handleDragOver(e: DragEvent): void {
if (!uploadImage()) return;
const hasFiles = Array.from(e.dataTransfer?.items ?? []).some((item) => item.kind === 'file');
if (!hasFiles) return;
// Stop the document-level handler from double-counting this as a new enter.
e.preventDefault();
e.stopPropagation();
isDragOver.value = true;
}
@ -192,6 +221,45 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
}
function handleDrop(e: DragEvent): void {
windowDragDepth = 0;
isDragOver.value = false;
if (!uploadImage()) return;
// Stop the document-level drop handler from adding the same files twice.
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer?.files ?? []);
void addFiles(files);
}
// Window-level drag & drop. Without a document-wide handler, dropping a file
// anywhere outside the small composer box makes the browser navigate away to
// the file. Nested dragenter/dragleave pairs fire while moving across child
// elements, so the overlay is driven by a counter, not by single events.
function windowDragHasFiles(e: DragEvent): boolean {
return Array.from(e.dataTransfer?.items ?? []).some((item) => item.kind === 'file');
}
function handleWindowDragEnter(e: DragEvent): void {
if (!uploadImage() || !windowDragHasFiles(e)) return;
e.preventDefault();
windowDragDepth += 1;
isDragOver.value = true;
}
function handleWindowDragOver(e: DragEvent): void {
if (!uploadImage() || !windowDragHasFiles(e)) return;
// Keep the browser from navigating away when the drop lands outside the composer.
e.preventDefault();
}
function handleWindowDragLeave(e: DragEvent): void {
if (!uploadImage() || !windowDragHasFiles(e)) return;
windowDragDepth = Math.max(0, windowDragDepth - 1);
if (windowDragDepth === 0) isDragOver.value = false;
}
function handleWindowDrop(e: DragEvent): void {
windowDragDepth = 0;
isDragOver.value = false;
if (!uploadImage()) return;
e.preventDefault();
@ -231,7 +299,7 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
* fetch an authenticated blob URL so the thumbnail doesn't 401. Replaces any
* unsent draft attachments (mirroring loadForEdit(text), which overwrites) so
* a later submit sends exactly the edited message's files, not a mix. */
function loadAttachments(atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]): void {
function loadAttachments(atts: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]): void {
const sid = sessionId() ?? '';
for (const existing of attachmentsBySession.value[sid] ?? []) revokeAttachment(existing);
setForSession(sid, []);
@ -243,16 +311,17 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
if (att.fileId) {
// Ready as-is; fetch an authenticated thumbnail for protected URLs.
// File attachments have no thumbnail — nothing to fetch or revoke.
const entry: Attachment = {
localId,
name,
kind: att.kind,
previewUrl: att.url,
previewUrl: att.kind === 'file' ? undefined : att.url,
uploading: false,
fileId: att.fileId,
};
setForSession(sid, [...(attachmentsBySession.value[sid] ?? []), entry]);
if (!isData && !isBlob) {
if (att.kind !== 'file' && !isData && !isBlob) {
void getKimiWebApi().getFileBlob(att.fileId).then((blob) => {
const blobUrl = URL.createObjectURL(blob);
const current = attachmentsBySession.value[sid] ?? [];
@ -271,6 +340,10 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
// actually resendable — otherwise handleSubmit silently drops it. If the
// URL can't be fetched (CORS / non-2xx) or upload is unavailable, skip
// the chip rather than show a misleading ready attachment.
// No URL at all (the non-clickable chip rebuilt from an inline-base64
// notice): skip too — fetch('') would resolve to the current page and
// upload the web app's HTML as the attachment.
if (!att.url) continue;
const upload = uploadImage();
if (!upload) continue;
const entry: Attachment = {
@ -310,11 +383,19 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
onMounted(() => {
document.addEventListener('paste', handleDocumentPaste);
document.addEventListener('dragenter', handleWindowDragEnter);
document.addEventListener('dragover', handleWindowDragOver);
document.addEventListener('dragleave', handleWindowDragLeave);
document.addEventListener('drop', handleWindowDrop);
});
// Revoke all object URLs (every session) and remove the global listener on unmount.
onUnmounted(() => {
document.removeEventListener('paste', handleDocumentPaste);
document.removeEventListener('dragenter', handleWindowDragEnter);
document.removeEventListener('dragover', handleWindowDragOver);
document.removeEventListener('dragleave', handleWindowDragLeave);
document.removeEventListener('drop', handleWindowDrop);
for (const atts of Object.values(attachmentsBySession.value)) {
for (const att of atts) revokeAttachment(att);
}

View file

@ -13,34 +13,71 @@ export type ConfirmOptions = {
confirmLabel?: string;
cancelLabel?: string;
variant?: ConfirmVariant;
/** Async work started when the user confirms. While it runs, the dialog
* stays open with the confirm button in a loading state (cancel / Esc /
* overlay-click are suppressed), then closes and resolves true. A
* rejection closes the dialog and rethrows to the confirm() caller. */
action?: () => unknown;
};
type ConfirmRequest = ConfirmOptions & {
resolve: (ok: boolean) => void;
reject: (err: unknown) => void;
};
const current = ref<ConfirmRequest | null>(null);
/** True while a confirmed request's `action` is still running. */
const busy = ref(false);
function settle(ok: boolean): void {
const req = current.value;
if (!req) return;
if (!req || busy.value) return;
current.value = null;
req.resolve(ok);
}
/** Invoked by the host when the user confirms. Runs the request's `action`
* (keeping the dialog open with a loading state until it settles), or just
* resolves true when the request has none. Never rejects. */
async function runAction(): Promise<void> {
const req = current.value;
if (!req || busy.value) return;
if (!req.action) {
settle(true);
return;
}
busy.value = true;
try {
await req.action();
if (current.value === req) current.value = null;
req.resolve(true);
} catch (error) {
if (current.value === req) current.value = null;
req.reject(error);
} finally {
busy.value = false;
}
}
function confirm(options: ConfirmOptions): Promise<boolean> {
// A confirmed action is still in flight: a second dialog can't supersede the
// busy one (it would inherit the global busy state and open inert), so the
// new request resolves unconfirmed instead.
if (busy.value) return Promise.resolve(false);
// If a confirm is already open, treat it as cancelled before showing the new
// one so its caller isn't left hanging.
if (current.value) settle(false);
return new Promise<boolean>((resolve) => {
current.value = { ...options, resolve };
return new Promise<boolean>((resolve, reject) => {
current.value = { ...options, resolve, reject };
});
}
export function useConfirmDialog(): {
current: typeof current;
busy: typeof busy;
confirm: typeof confirm;
settle: typeof settle;
runAction: typeof runAction;
} {
return { current, confirm, settle };
return { current, busy, confirm, settle, runAction };
}

View file

@ -134,14 +134,10 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions)
if (result) {
previewFile.value = { ...result, path: result.path || normalized.path };
} else {
previewFile.value = {
path: normalized.path,
content: '',
encoding: 'utf-8',
mime: 'text/plain',
isBinary: false,
size: 0,
};
// readFileContent swallows daemon failures into null — show the error
// state instead of a misleading 0-byte "empty file" (the cause is
// already console.warn'd in readFileContent).
previewError.value = t('filePreview.errors.loadFailed');
}
} catch (err) {
if (requestSeq !== previewRequestSeq) return;

View file

@ -29,7 +29,13 @@ import {
saveWorkspaceSort,
STORAGE_KEYS,
} from '../lib/storage';
import { createEventBatcher, isRenderEvent } from './client/eventBatcher';
import {
coalesceAppRenderEvents,
createEventBatcher,
isRenderEvent,
splitOversizedAppRenderEvent,
type PendingAppEvent,
} from './client/eventBatcher';
import { useAppearance } from './client/useAppearance';
import { useNotification, shouldNotifyCompletion } from './client/useNotification';
import { useSoundNotification } from './client/useSoundNotification';
@ -64,6 +70,7 @@ import type {
AppWorkspace,
ApprovalDecision,
KimiEventConnection,
KimiEventMeta,
ThinkingLevel,
} from '../api/types';
import { createInitialState, reduceAppEvent, type CompactionStatus, type KimiClientState } from '../api/daemon/eventReducer';
@ -277,8 +284,17 @@ interface GitStatusEntry {
}
/** An uploaded attachment to send with a prompt. `kind` drives the content-block
type (image vs video) so a still and a clip resolve to the right wire shape. */
export type PromptAttachment = { fileId: string; kind: 'image' | 'video' };
type: images/videos become media parts; any other kind becomes a file part
the server materializes and hands to the model as a path reference.
name/mediaType/size feed the wire file shape (the server's file-store meta
stays authoritative, so a chip reloaded from history may omit them). */
export type PromptAttachment = {
fileId: string;
kind: 'image' | 'video' | 'file';
name?: string;
mediaType?: string;
size?: number;
};
/** A prompt waiting for the session to go idle. Keeps the uploaded
fileIds so attachments survive queueing (not just the text). */
@ -326,8 +342,13 @@ export interface ExtendedState extends KimiClientState {
// AUTHORITATIVE id for :abort — the event projector synthesizes a `pr_…` id
// when turn.started races ahead of binding, which the daemon rejects.
promptIdBySession: Record<string, string>;
// True while a prompt is in flight but the assistant reply hasn't started yet.
sendingBySession: Record<string, boolean>;
// A prompt this client submitted (or skill-activated) has not reached its
// terminal state yet — the OPTIMISTIC half of the working moon, covering the
// window before the turn.started round-trips (and the queue-drain re-arm).
// Set at every local turn entry point; cleared by finishPromptLocal, the
// entry points' own error paths, the authoritative-quiet fallback, or session
// forget. `turnActiveBySession` owns everything from turn.started on.
inFlightBySession: Record<string, boolean>;
// True when a BACKGROUND session finished a turn the user hasn't opened since
// (drives the unread blue dot in the sidebar). Set on idle for a non-active
// session, cleared when the session is selected.
@ -392,7 +413,7 @@ const rawState: ExtendedState = reactive({
queuedBySession: {},
gitStatusBySession: {},
promptIdBySession: {},
sendingBySession: {},
inFlightBySession: {},
unreadBySession: loadUnread(),
authReady: false,
defaultModel: null,
@ -575,13 +596,11 @@ function forgetSession(sessionId: string): void {
// per-session maps we are about to delete.
eventConn?.unsubscribe(sessionId);
dropWsSubscription(sessionId);
// Drain the streaming-event batcher too. unsubscribe() stops future server
// frames, but events already queued for the next animation frame would
// otherwise survive and be reduced AFTER the maps below are cleared —
// recreating entries like messagesBySession[id] and lastSeqBySession[id].
// That would make hasLoadedMessages() treat the stale empty cache as
// authoritative and skip the next snapshot fetch for this id.
enqueueEvent.flush();
// Drop this session's queued render AND control events. Flushing them here is
// unsafe: a delayed idle event can drain a queued prompt into the session
// after the archive request succeeded. Other sessions keep their own ordered
// backlog and scheduled continuation.
enqueueEvent.discard(({ meta }) => meta.sessionId === sessionId);
removeSession(sessionId);
removeSessionMessages(sessionId);
delete rawState.approvalsBySession[sessionId];
@ -600,13 +619,13 @@ function forgetSession(sessionId: string): void {
sessionsKnownEmpty.delete(sessionId);
// In-flight / queued prompt state: drop these too so a queued follow-up
// can't be submitted to a session that was just archived when its turn later
// goes idle (onSessionIdle drains queuedBySession[sid] without re-checking
// ends (onMainTurnEnd drains queuedBySession[sid] without re-checking
// that the session still exists).
inFlightPromptSessions.delete(sessionId);
forgetLocalTurnState(sessionId);
delete rawState.queuedBySession[sessionId];
delete rawState.promptIdBySession[sessionId];
delete rawState.sendingBySession[sessionId];
delete rawState.inFlightBySession[sessionId];
delete rawState.turnActiveBySession[sessionId];
// Drop per-session mode toggles and re-persist so a deleted session's entry
// doesn't linger in localStorage.
delete rawState.planModeBySession[sessionId];
@ -699,10 +718,12 @@ async function refreshSessionGoal(sessionId: string): Promise<void> {
* session and immediately persisting its draft modes, so a concurrent session
* switch can't write the patch to the wrong session.
*
* Returns the update promise (errors swallowed the UI already updated
* optimistically). Most callers fire-and-forget via `void persistSessionProfile(...)`;
* call sites that must order strictly after the profile (e.g. a skill
* activation that can't carry its own modes) await it. */
* Returns the update promise. Failures are surfaced via pushOperationFailure
* (the UI already updated optimistically, so the user must be told when the
* daemon did not apply the change); the promise itself never rejects. Most
* callers fire-and-forget via `void persistSessionProfile(...)`; call sites
* that must order strictly after the profile (e.g. a skill activation that
* can't carry its own modes) await it. */
function persistSessionProfile(patch: {
model?: string;
permissionMode?: string;
@ -717,8 +738,10 @@ function persistSessionProfile(patch: {
// Promise.resolve wrap: tolerate a sync/undefined return (e.g. test mocks).
return Promise.resolve(getKimiWebApi().updateSession(sid, patch))
.then(() => refreshSessionStatus(sid))
.catch(() => {
/* ignore — local state already reflects the change */
.catch((err) => {
// Local state already reflects the change; tell the user (and the log)
// that the daemon did not persist it.
pushOperationFailure('persistSessionProfile', err, { sessionId: sid });
});
}
@ -785,13 +808,6 @@ function nextOptimisticMsgId(): string {
return `msg_opt_${Date.now().toString(36)}_${optimisticMsgSeq}`;
}
// Per-session "a prompt is in flight" flag. Flipped SYNCHRONOUSLY the moment we
// decide to submit (before any await), and cleared when the session returns to
// idle. This gates concurrent prompts: `activity` only turns 'running' after the
// WS turn.started round-trips, so a fast second sendPrompt would otherwise race
// past the queue check and clobber promptIdBySession (breaking abort).
const inFlightPromptSessions = new Set<string>();
// Helper: mutate rawState by applying a reducer on a snapshot then re-assigning fields
function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq: number): void {
const snapshot: KimiClientState = {
@ -805,6 +821,7 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
goalBySession: rawState.goalBySession,
goalVersionBySession: rawState.goalVersionBySession,
lastSeqBySession: rawState.lastSeqBySession,
turnActiveBySession: rawState.turnActiveBySession,
compactionBySession: rawState.compactionBySession,
config: rawState.config,
warnings: rawState.warnings,
@ -821,6 +838,7 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
rawState.goalBySession = next.goalBySession;
rawState.goalVersionBySession = next.goalVersionBySession;
rawState.lastSeqBySession = next.lastSeqBySession;
rawState.turnActiveBySession = next.turnActiveBySession;
rawState.compactionBySession = next.compactionBySession;
rawState.config = next.config ?? null;
rawState.warnings = next.warnings;
@ -856,22 +874,19 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
// synchronously triggers a full Vue re-render per event, which saturates the
// main thread and makes the stream look janky (see messagesToTurns / Markdown).
//
// We coalesce those render-only events onto the next animation frame so Vue
// commits a single render per frame. Lifecycle / control-flow events
// (sessionStatusChanged, messageCreated, approval*, question*, ...) are applied
// immediately: they are infrequent, and some (e.g. sessionStatusChanged idle)
// drive turn-end cleanup that must not be delayed by a throttled rAF in a
// background tab. Ordering is preserved by draining any pending render events
// before applying an immediate event.
// Adjacent, offset-contiguous assistant/thinking deltas are merged before they
// reach the reducer. The remaining ordered groups are processed with a fixed
// per-frame budget and a task fallback, so a hidden tab cannot turn the entire
// backlog into one unbounded rAF drain. Lifecycle / control-flow events remain
// strict ordering barriers and are never dropped or merged.
type PendingEvent = { appEvent: AppEvent; meta: { sessionId: string; seq: number } };
function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number }): void {
function processEvent(appEvent: AppEvent, meta: KimiEventMeta): void {
// Capture BEFORE applyEvent advances lastSeqBySession: turn-end side
// effects below only run when this event actually moves the durable cursor
// forward. A late duplicate idle (e.g. replayed after a snapshot already
// advanced past it) must not drain a second queued message.
const prevSeq = rawState.lastSeqBySession[meta.sessionId] ?? 0;
const wasMainTurnActive = rawState.turnActiveBySession[meta.sessionId] ?? false;
// meta carries wire-level seq/sessionId so the reducer can advance
// lastSeqBySession[sessionId] = seq. Compaction completion appends a
// persistent divider marker in the reducer (TUI parity: the scrollback
@ -916,20 +931,52 @@ function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number
appearance.recordMoonDelta((appEvent.delta.text?.length ?? 0) + (appEvent.delta.thinking?.length ?? 0));
}
// Turn-end cleanup for the session the event belongs to — including
// sessions running in the background (see onSessionIdle).
// Turn-end: both 'idle' and 'aborted' mean the prompt is no longer in
// flight, so both must flush in-flight/queued state. (Awaiting-* is still
// in flight — it's waiting on the user — and must NOT flush.)
// Gated on the durable cursor advancing: a late duplicate of an idle we
// already consumed (directly or via a snapshot past it) must not run the
// side effects again — above all, it must not drain another queued message.
// Prompt-end cleanup. The MAIN agent's turn boundary is the authoritative
// "the prompt is done" signal: it drives the in-flight/moon cleanup, the
// queued-message drain, and the completion side effects. The session may
// stay busy afterwards (background subagents / BTW) — that must NOT hold
// any of these. The session's idle/aborted status is only a fallback quiet
// signal (a turn.ended can be lost on abrupt agent disposal): it clears the
// boolean liveness flags, but drain/notify stay single-owned by the
// turn-boundary path. Both are gated on the durable cursor advancing so a
// late duplicate cannot fire twice.
if (
appEvent.type === 'sessionStatusChanged' &&
(appEvent.status === 'idle' || appEvent.status === 'aborted') &&
appEvent.type === 'turnActiveChanged' &&
!appEvent.active &&
meta.seq > prevSeq
) {
onSessionIdle(appEvent.sessionId, appEvent.status);
const reason = appEvent.reason;
onMainTurnEnd(
appEvent.sessionId,
reason === 'cancelled' || reason === 'failed' || reason === 'blocked' ? 'aborted' : 'idle',
);
}
if (
appEvent.type === 'sessionWorkChanged' &&
((appEvent.mainTurnActive === false && wasMainTurnActive) ||
(appEvent.mainTurnActive === undefined && !appEvent.busy)) &&
meta.seq > prevSeq
) {
clearWorkingFlags(appEvent.sessionId);
}
// A prompt that never produced a turn gets no turn.ended and no session
// status flip: a QUEUED prompt aborted before launch (prompt.aborted), or a
// prompt blocked by a pre-submit hook (prompt.completed with reason
// 'blocked'). Without this the local in-flight flag — and the working moon —
// would stick forever. Keyed on the promptId captured at submit: a normal
// turn's prompt.completed/aborted arrives AFTER its status_changed (which
// already cleared the id), so it no-ops; another client's prompt never
// matches. Only fires when the event moves the durable cursor forward, same
// as the status path above.
if (
(appEvent.type === 'promptAborted' ||
(appEvent.type === 'promptCompleted' && appEvent.reason === 'blocked')) &&
meta.seq > prevSeq &&
rawState.promptIdBySession[appEvent.sessionId] === appEvent.promptId
) {
workspaceState.finishPromptLocal(appEvent.sessionId);
}
// The agent asked a question and is waiting for an answer — surface it so
@ -946,9 +993,10 @@ function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number
}
}
const enqueueEvent = createEventBatcher<PendingEvent>(
const enqueueEvent = createEventBatcher<PendingAppEvent>(
({ appEvent, meta }) => processEvent(appEvent, meta),
({ appEvent }) => isRenderEvent(appEvent),
{ coalesce: coalesceAppRenderEvents },
);
// ---------------------------------------------------------------------------
@ -979,10 +1027,11 @@ function connectEventsIfNeeded(): void {
return;
}
// Coalesce high-frequency render events onto the next animation frame;
// everything else is applied immediately. See createEventBatcher /
// processEvent above.
enqueueEvent({ appEvent, meta });
// Merge safe streaming chunks, then process the ordered queue in bounded
// slices. See createEventBatcher / processEvent above.
for (const pendingEvent of splitOversizedAppRenderEvent({ appEvent, meta })) {
enqueueEvent(pendingEvent);
}
},
onResync(sessionId: string, currentSeq: number, epoch?: string) {
@ -1231,6 +1280,21 @@ function pushOperationFailure(
err: unknown,
opts?: { title?: string; message?: string; sessionId?: string },
): void {
// Always-on logging: a surfaced failure must be diagnosable from the console
// and from the exported web log (session export), not just from the toast.
console.error(`[kimi-web] operation failed: ${operation}`, err);
const api = isDaemonApiError(err);
const network = isDaemonNetworkError(err);
traceKeyEvent('operation:failed', {
sessionId: opts?.sessionId,
status: 'failed',
operation,
errorName: err instanceof Error ? err.name : typeof err,
errorCode: api ? err.code : undefined,
requestId: api || network ? err.requestId : undefined,
phase: network ? err.phase : undefined,
httpStatus: network ? err.status : undefined,
});
pushWarning(operationFailureNotice(operation, err, opts));
}
@ -1387,12 +1451,27 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
sessionsRetryingStaleSnapshot.delete(sessionId);
// Resync replaces the missed event stream, so a terminal snapshot must
// also clear the local sending flag that normally ends on a WS idle event.
// also clear the local in-flight flag that normally ends with the turn.
workspaceState.handleSessionSnapshot(
sessionId,
{ inFlightTurn: snap.inFlightTurn, status: snap.session.status },
{ inFlightTurn: snap.inFlightTurn, busy: snap.session.busy },
);
// The snapshot's inFlightTurn is main-agent-only — seed the moon's
// liveness flag from it (the projector was reset by the resync, so no
// turn.ended may ever arrive for a turn that was live before it). Gated
// on the snapshot's busy fact: the live tracker can hold a stale turn
// whose turn.ended was lost (abrupt agent disposal) — the server-side
// busy read is the reconciler, so a dead turn never relights the moon.
{
const next = { ...rawState.turnActiveBySession };
const mainTurnActive =
snap.session.mainTurnActive ?? (snap.inFlightTurn !== null && snap.session.busy);
if (mainTurnActive) next[sessionId] = true;
else delete next[sessionId];
rawState.turnActiveBySession = next;
}
connectEventsIfNeeded();
if (eventConn) {
// Seed BEFORE subscribing: the in-flight assistant message must exist
@ -1508,34 +1587,25 @@ async function reopenSession(sessionId: string): Promise<SyncSessionResult> {
// View-model mappers
// ---------------------------------------------------------------------------
/** Whether the session should show the "working" spinner. Only a `running`
session qualifies `awaiting*` is waiting on the user (not working) and
`aborted` is finished, so neither spins. Additionally, a session whose only
running task is its BTW side-channel agent should not look busy. When tasks
have not been loaded yet e.g. right after a page refresh we trust the
daemon-reported `running` status rather than hiding the spinner. */
function isSessionEffectivelyRunning(session: AppSession | undefined): boolean {
if (!session) return false;
if (session.status !== 'running') return false;
const sessionId = session.id;
const hiddenBtwAgentId = sideChat.sideChatTargetBySession.value[sessionId]?.agentId;
const tasks = rawState.tasksBySession[sessionId] ?? [];
const runningTasks = tasks.filter((t) => t.status === 'running');
if (runningTasks.length === 0) {
// No task list yet (fresh refresh) — trust the daemon-reported session status,
// unless the only active work is a BTW side-chat agent. In that window the
// side chat is sending and its task hasn't been loaded, so suppress the main
// session spinner so the main composer stays usable.
if (hiddenBtwAgentId && rawState.sideChatSendingByAgent[hiddenBtwAgentId]) {
return false;
}
return true;
}
return runningTasks.some((t) => t.id !== hiddenBtwAgentId);
/** Whether the session should show a "working" indicator (sidebar spinner,
row badge gating). ONE unified condition, shared with the working moon and
the Stop button: the main conversation has unfinished work a prompt
submitted but not yet terminated (`inFlightBySession`) or a main turn in
flight (`turnActiveBySession`). Background tasks and subagent turns do NOT
light it; an approval/question pause does NOT dim it (the turn is still
open). */
function isMainTurnActive(sessionId: string, listed?: boolean): boolean {
return (
(rawState.inFlightBySession[sessionId] ?? false) ||
(rawState.turnActiveBySession[sessionId] ?? false) ||
(listed ??
rawState.sessions.find((session) => session.id === sessionId)?.mainTurnActive ??
false)
);
}
/** Format createdAt/updatedAt into a short display string */
function formatTime(iso: string, _status: string): string {
function formatTime(iso: string): string {
try {
const d = new Date(iso);
const now = Date.now();
@ -1573,7 +1643,10 @@ function stopSessionTimeClock(): void {
}
if (import.meta.hot) {
import.meta.hot.dispose(stopSessionTimeClock);
import.meta.hot.dispose(() => {
stopSessionTimeClock();
enqueueEvent.dispose();
});
}
/** Build DiffLine[] from old_text/new_text strings */
@ -1829,9 +1902,10 @@ const sessions = computed<Session[]>(() => {
.map((s) => ({
id: s.id,
title: s.title,
time: formatTime(s.updatedAt, s.status),
status: s.status,
busy: isSessionEffectivelyRunning(s),
time: formatTime(s.updatedAt),
busy: isMainTurnActive(s.id, s.mainTurnActive),
pendingInteraction: s.pendingInteraction,
lastTurnReason: s.lastTurnReason,
}));
});
@ -1846,10 +1920,10 @@ const skills = computed<AppSkill[]>(() => {
return wid ? (modelProvider.skillsByWorkspace.value[wid] ?? []) : [];
});
const isSending = computed<boolean>(() => {
const inFlight = computed<boolean>(() => {
const sid = rawState.activeSessionId;
if (!sid) return false;
return rawState.sendingBySession[sid] ?? false;
return rawState.inFlightBySession[sid] ?? false;
});
// True while the empty-composer first prompt for the active workspace is being
@ -1883,11 +1957,29 @@ const turns = computed<ChatTurn[]>(() => {
messages,
approvals,
(fileId) => getKimiWebApi().getFileUrl(fileId),
activity.value !== 'idle',
turnActive.value,
rawState.planReviewByToolCallId,
);
});
/** The MAIN agent of the active session has a turn in flight the working
* moon's authoritative half (the optimistic `inFlight` window covers the gap
* before the turn.started round-trips). Background agents and BTW side chats
* do NOT set this; the session-busy status lives on `activity`. */
const turnActive = computed<boolean>(() => {
const sid = rawState.activeSessionId;
if (!sid) return false;
return (
(rawState.turnActiveBySession[sid] ?? false) ||
(rawState.sessions.find((session) => session.id === sid)?.mainTurnActive ?? false)
);
});
/** The working moon: the main conversation has an unfinished prompt either
* submitted-but-not-terminated (`inFlight`) or a main turn in flight
* (`turnActive`). */
const working = computed<boolean>(() => inFlight.value || turnActive.value);
const tasks = computed<TaskItem[]>(() => {
// Touch the clock so a running task's elapsed time recomputes each tick.
void taskPoller.taskClock.value;
@ -2007,6 +2099,7 @@ const queued = computed<QueuedPromptView[]>(() => {
fileId: a.fileId,
kind: a.kind,
url: api.getFileUrl(a.fileId),
name: a.name,
})),
}));
});
@ -2041,6 +2134,13 @@ const pendingApprovals = computed<
/**
* Activity state for the active session.
* Priority: awaiting-approval > awaiting-question > running > idle
*
* `running` is main-conversation liveness the same condition as the working
* moon (the optimistic submit window or an in-flight main turn). The wire
* `busy` fact deliberately includes background tasks, but everything driven
* by `activity` (Stop button, composer/page-title spinners, send-vs-queue
* gating) follows the main conversation only: a session left with only
* background tasks is idle here, exactly like the retired turn-scoped status.
*/
const activity = computed<ActivityState>(() => {
const sid = rawState.activeSessionId;
@ -2052,8 +2152,7 @@ const activity = computed<ActivityState>(() => {
const questionList = rawState.questionsBySession[sid] ?? [];
if (questionList.length > 0) return 'awaiting-question';
const activeSession = rawState.sessions.find((s) => s.id === sid);
if (isSessionEffectivelyRunning(activeSession)) {
if (inFlight.value || turnActive.value) {
return 'running';
}
@ -2065,7 +2164,6 @@ const modelProvider = useModelProviderState(rawState, {
refreshSessionStatus,
persistSessionProfile,
activity,
inFlightPromptSessions,
saveThinkingToStorage,
updateSession,
updateSessionMessages,
@ -2198,8 +2296,6 @@ const mergedWorkspaces = computed<AppWorkspace[]>(() =>
workspaces: rawState.workspaces,
sessions: rawState.sessions,
hiddenWorkspaceRoots: rawState.hiddenWorkspaceRoots,
activeRoot: rawState.sessions.find((s) => s.id === rawState.activeSessionId)?.cwd,
activeBranch: gitInfo.value?.branch ?? null,
sessionsHasMoreByWorkspace: rawState.sessionsHasMoreByWorkspace,
}),
);
@ -2258,7 +2354,6 @@ const workspacesView = computed<WorkspaceView[]>(() => {
name: w.name,
root: w.root,
shortPath: shortenHome(w.root, rawState.fsHome),
branch: w.branch,
sessionCount: w.sessionCount,
}));
if (workspaceSortMode.value === 'recent') {
@ -2329,9 +2424,10 @@ const sessionsForView = computed<Session[]>(() => {
return {
id: s.id,
title: s.title,
time: formatTime(s.updatedAt, s.status),
status: s.status,
busy: isSessionEffectivelyRunning(s),
time: formatTime(s.updatedAt),
busy: isMainTurnActive(s.id, s.mainTurnActive),
pendingInteraction: s.pendingInteraction,
lastTurnReason: s.lastTurnReason,
lastPrompt: s.lastPrompt,
workspaceId,
workspaceName: nameByWorkspaceId.get(workspaceId),
@ -2351,9 +2447,10 @@ const workspaceGroups = computed<WorkspaceGroup[]>(() => {
const view: Session = {
id: s.id,
title: s.title,
time: formatTime(s.updatedAt, s.status),
status: s.status,
busy: isSessionEffectivelyRunning(s),
time: formatTime(s.updatedAt),
busy: isMainTurnActive(s.id, s.mainTurnActive),
pendingInteraction: s.pendingInteraction,
lastTurnReason: s.lastTurnReason,
updatedAt: s.updatedAt,
};
const list = byId.get(wid) ?? [];
@ -2395,8 +2492,8 @@ function setWorkspaceSortMode(mode: WorkspaceSortMode): void {
/**
* Per-session pending-attention count = pending approvals + pending questions.
* For the active session this is live (driven by WS events). Other sessions
* light up once the daemon ships Session.pending_attention; until then their
* counts are derived from whatever approvals/questions we've already seen.
* are derived from whatever approvals/questions we've already seen; the row's
* list-level pendingInteraction fact supplies the pre-status badge fallback.
*/
const attentionBySession = computed<Record<string, number>>(() => {
const out: Record<string, number> = {};
@ -2459,11 +2556,13 @@ const availableOpenInApps = computed<string[]>(() => rawState.availableOpenInApp
// ---------------------------------------------------------------------------
// Per-session turn-end cleanup + queue auto-flush.
// Driven by the daemon's sessionStatusChanged → idle event (wired in
// Driven by the main agent's turn.ended boundary (wired in
// connectEventsIfNeeded), NOT by the active-session `activity` computed: a
// watcher on `activity` only ever saw the ACTIVE session, so a session that
// finished in the background kept its in-flight flag forever — every later
// prompt to it was silently enqueued and never flushed.
// prompt to it was silently enqueued and never flushed. The session-busy
// status stream is deliberately NOT the trigger: background agents keep it
// non-idle past the main turn's end, which would hold the moon and the queue.
// ---------------------------------------------------------------------------
const workspaceState = useWorkspaceState(rawState, {
@ -2472,7 +2571,6 @@ const workspaceState = useWorkspaceState(rawState, {
modelProvider,
pushOperationFailure,
activity,
inFlightPromptSessions,
sessionsKnownEmpty,
setSessions,
updateSession,
@ -2524,11 +2622,33 @@ function isUserWatching(sid: string): boolean {
);
}
function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
/**
* Authoritative-quiet escape hatch. The session's idle/aborted status means no
* main turn can still be in flight (an awaiting interaction would report
* awaiting_*, not idle), so both working-moon flags are cleared even when the
* turn.ended that owned them never arrived (e.g. abrupt agent disposal). This
* is the ONLY writer of `turnActiveBySession` outside the reducer /
* snapshot seed, and the ONLY clearer of `inFlightBySession` outside
* finishPromptLocal / the entry points' error paths. Drain and completion
* side effects are NOT run here they stay single-owned by the turn.ended
* path (onMainTurnEnd).
*/
function clearWorkingFlags(sid: string): void {
if (rawState.turnActiveBySession[sid]) {
const next = { ...rawState.turnActiveBySession };
delete next[sid];
rawState.turnActiveBySession = next;
}
if (rawState.inFlightBySession[sid]) {
rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: false };
}
}
function onMainTurnEnd(sid: string, status: 'idle' | 'aborted'): void {
// Capture before finishPromptLocal drops it — it keys the completion
// notification's dedup tag so each finished turn alerts once.
const finishedPromptId = rawState.promptIdBySession[sid];
// Shared finish cleanup: clears in-flight/sending/prompt-id and drains one
// Shared finish cleanup: clears in-flight/prompt-id and drains one
// queued message. The notification/sound/unread side effects below stay
// WS-event-only — the snapshot path (handleSessionSnapshot) must not cry
// wolf when opening a historical session.
@ -2687,7 +2807,9 @@ export function useKimiWebClient() {
warnings,
questions,
activity,
isSending,
turnActive,
inFlight,
working,
isStartingFirstPrompt,
fastMoon: appearance.fastMoon,
@ -2805,7 +2927,6 @@ export function useKimiWebClient() {
resolveImageUrl: workspaceState.resolveImageUrl,
// Model + Provider actions
refreshOAuthProviderModels: modelProvider.refreshOAuthProviderModels,
loadModels: modelProvider.loadModels,
loadProviders: modelProvider.loadProviders,
skills,

View file

@ -55,6 +55,7 @@ const EXPORT_TRACE_EVENTS = [
'session:snapshot:start',
'session:snapshot:accepted',
'session:snapshot:failed',
'operation:failed',
'window:error',
'window:unhandled-rejection',
'ws:connection',
@ -68,6 +69,9 @@ type ExportTraceEvent = (typeof EXPORT_TRACE_EVENTS)[number];
export interface ExportTraceMetadata {
sessionId?: string;
status?: string;
busy?: boolean;
/** Client operation name (e.g. 'archiveSession') for operation:failed. */
operation?: string;
seq?: number;
durationMs?: number;
messageCount?: number;
@ -226,6 +230,7 @@ function pushExportTrace(event: string, info?: ExportTraceMetadata): void {
event: event as ExportTraceEvent,
sessionId: exportString(info?.sessionId),
status: exportString(info?.status),
operation: exportString(info?.operation),
seq: exportNumber(info?.seq),
durationMs: exportNumber(info?.durationMs),
messageCount: exportNumber(info?.messageCount),

View file

@ -8,13 +8,18 @@ export default {
queueNext: 'Up next',
queueDragTitle: 'Drag to reorder',
editQueued: 'Edit (load back into the input)',
queuedImageOnly: 'image ×{n}',
queuedAttachments: 'attachment ×{n}',
queuedHasImage: 'Contains {n} image(s) — remove only, not editable',
attachmentImage: 'Image',
attachmentVideo: 'Video',
attachmentFile: 'File',
attachmentOpenUnsupported: 'Cant open {name} — this file type isnt supported',
dropToAttach: 'Drop files to attach',
remove: 'Remove',
removeNamed: 'Remove {name}',
uploading: 'Uploading',
uploadFailed: 'Upload failed',
attachImage: 'Attach image',
attachFile: 'Attach file',
previewAttachment: 'Preview {name}',
interrupt: 'Interrupt',
interruptTitle: 'Interrupt current operation',

View file

@ -19,4 +19,6 @@ export default {
closeBtn: 'Close',
errorTitle: 'The current daemon does not support login yet',
errorHint: 'Please upgrade kimi-code and try again',
pollErrorTitle: 'Lost connection to the daemon',
pollErrorHint: 'Authorization polling failed repeatedly. Check the kimi-code process and try again.',
} as const;

Some files were not shown because too many files have changed in this diff Show more