Compare commits

...

18 commits

Author SHA1 Message Date
qer
04041eb998
fix(web): hide injected system asides in user message bubbles (#1535)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* fix(web): hide injected system asides in user message bubbles

* fix(web): preserve literal <system> tags in user prompts

* chore: fold duplicate web changeset into caption-hiding entry
2026-07-10 17:02:08 +08:00
Haozhe
9f66ec416c
feat: harden LLM API fault tolerance against 429 and overload (#1530)
* feat(retry): harden LLM API fault tolerance against 429/overload

- retry more transient errors: 408/409/429/5xx/529, an embedded upstream
  status_code=429 in OpenAI Responses stream errors, and unclassified
  provider errors as a last-resort fallback
- honor server Retry-After (parsed into APIStatusError.retryAfterMs by the
  OpenAI and Anthropic providers); chatWithRetry prefers it over its backoff
- align app-level backoff with claude-code (500ms base, 32s cap, factor 2,
  up to 25% jitter) so high-attempt configs ride out multi-minute overload
- emit a turn.step.retrying meta line in -p --output-format stream-json
2026-07-10 15:47:12 +08:00
qer
0ad568436a
docs(changelog): sync 0.23.4 from apps/kimi-code/CHANGELOG.md (#1528)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
2026-07-10 00:48:47 +08:00
qer
5a90d9d7f1
chore(agents): place Polish before Bug Fixes in changelog sections (#1527) 2026-07-10 00:08:25 +08:00
qer
7ce3c2dc31
fix(ci): pin npm to 11.x in release workflow (#1526) 2026-07-10 00:08:20 +08:00
github-actions[bot]
9f9324cdab
ci: release packages (#1513)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-09 23:45:47 +08:00
qer
ec8dc3456c
fix(web): stop sending prompts into a busy turn on the web UI (#1522)
* fix(web): prevent duplicate first prompts and keep goal drives from looking idle

- Guard startSessionAndSendPrompt with a per-workspace reentry lock so a
  double-click / repeated Enter during draft-session creation cannot fire
  two concurrent first prompts into the same new session.
- Track goal.active in the agent event projector so turn.ended between
  goal-driven continuation turns keeps the session 'running' instead of
  projecting a false 'idle' that drains the local queue into a still-busy
  core (turn.agent_busy).
- Show a 'starting conversation…' loading state on the empty-session
  landing while the first prompt is being created and submitted.
- Persist the resolved model in startSessionAndActivateSkill so the first
  skill turn on a fresh session does not fail with 'Model not set'.

* chore: add changeset for web first-prompt fixes

* fix(web): close remaining first-prompt and goal-settle gaps

- Pass the starting guard through the dock composer: draft-session
  creation selects the new session before submit, which swaps the empty
  composer for the dock; disabling both composers closes the last path
  to a concurrent first POST. Also take the workspace lock in
  startSessionAndActivateSkill / startSessionAndOpenSideChat.
- Emit the owed idle when a goal settles (blocked/paused/completed) in
  the inter-turn gap after a turn.ended was projected as 'running', so
  sending state, in-flight flags and queued prompts flush instead of
  the session staying 'running' forever.

* style(web): fix eqeqeq lint error in first-prompt guard

* fix(web): clear owed idle when a new goal turn starts

The idle debt from a 'running' projection survived turn.started, so an
UpdateGoal('complete'|'blocked') landing mid-turn in the NEXT goal turn
synthesized an early idle. onSessionIdle could then drain queued prompts
into a core that was mid-turn again, re-opening the turn.agent_busy race
for multi-turn goals. Clear the debt on turn.started: from that point the
turn's own turn.ended carries the idle with goalActive already false.

* fix(web): make first-prompt starting state workspace-id-agnostic

isStartingFirstPrompt now reads from the lock set directly (size > 0)
instead of the current activeWorkspaceId. createDraftSession can swap
activeWorkspaceId to a registered id mid-flight; a workspace-keyed read
would then return false while the first prompt is still in the create/
select/submit window, re-enabling the composer and reopening the
duplicate first-submit race.

* revert(web): drop goal-aware idle projection from agentEventProjector

The goalActive / idleOwed shadow state machine grew through multiple
review rounds and still leaves edge cases (snapshot-seeded turns, mid-
turn goal updates). Roll it back to the simple 'turn.ended projects idle'
behavior. Goal-driven sessions can once again race a queued prompt into a
busy core; this is accepted as a known limitation to be resolved properly
in a follow-up that has the core emit an authoritative idle signal.

* chore: align changeset with actual fix scope

* test(web): update profile-patch expectation for model field
2026-07-09 23:37:10 +08:00
Kai
046b6c4175
fix(agent-core): scope [image] config limits to the owning core (#1521)
* fix(agent-core): scope [image] config limits to the owning core

* fix(agent-core): thread harness [image] max_edge_px to TUI paste and ACP ingestion

* chore(changeset): simplify entry to user-facing wording
2026-07-09 20:55:52 +08:00
qer
a3548035a8
feat(tui): add Kimi WebBridge install entry to /plugins panel (#1494)
* feat(tui): add Kimi WebBridge install entry to /plugins panel

Surface a hardcoded Kimi WebBridge entry at the top of the Official tab in the /plugins panel. Selecting it opens the WebBridge install page in the user's browser instead of going through the plugin install flow, since WebBridge is a browser extension plus local daemon rather than an installable plugin package.

* fix(tui): restrict WebBridge open-url shortcut to the pinned row

Match the hardcoded pinned WebBridge entry by object reference instead of by id. A curated or custom marketplace entry on the Third-party tab can legitimately reuse the kimi-webbridge id; routing by id hijacked Enter on those rows and opened the WebBridge page instead of installing. The Official tab still dedupes a same-id official catalog entry so the pinned row is not duplicated.

* fix(tui): label WebBridge plugins row as "open in browser"

The previous "webpage" status did not make it clear that selecting this row opens an external page rather than installing in-app. "open in browser" states the action directly and contrasts with the install label on regular plugin rows.

* test(tui): navigate past pinned WebBridge row in marketplace install tests

Two message-flow tests pressed Enter on the Official tab assuming index 0 was the Kimi Datasource entry. The hardcoded Kimi WebBridge row now leads that tab, so move down one row before installing.
2026-07-09 19:51:53 +08:00
liruifengv
170ae44205
style(web): polish the session sidebar (#1519)
* feat(web): use sidebar fold/unfold icons for sidebar toggle

* feat(web): move settings entry to a sidebar footer row

* feat(web): fully collapse sidebar with animated width transition

* feat(web): redesign sidebar colors, spacing and macos desktop chrome

* feat(desktop): center traffic lights on the 48px header row

* fix(web): restore webkit thin scrollbars and unify sidebar icon sizes

* feat(web): add Kbd keycap component and justify sidebar search shortcut

* style(web): rework sidebar palette and pin a resident sidebar toggle

* fix(desktop): sync window appearance with web UI theme so dimmed traffic lights stay visible

* feat(web): adopt Kimi design icons in the sidebar via a local icon collection

* style(web): mute workspace group title color in the sidebar

* style(web): refine sidebar typography, unify shortcut keycaps, float workspace row actions

* style(web): cap sidebar draggable width at 480px

* style(web): derive sidebar row height from type and padding, float the kebab

* chore: add changeset for sidebar UI polish

* fix(nix): update pnpmDeps hash

* style(web): put the sidebar collapse button inside the header on non-mac

* fix(nix): update pnpmDeps hash
2026-07-09 19:39:20 +08:00
Kai
1bf2c9afee
feat: keep image-heavy sessions within provider request-size limits (#1508)
* feat(kosong): classify HTTP 413 request-body-too-large as a dedicated error type

* feat(agent-core): lower default image downscale cap to 2000px and make it configurable

* feat(agent-core): strip media to text markers and retry when the compaction request is too large

* feat(agent-core): cap model-initiated image reads with a configurable byte budget

* feat(agent-core): resend with degraded media when the provider rejects the request body as too large

* test(agent-core): add explicit timeouts to encode-heavy image budget tests

* feat: add WebP decoding support with wasm integration

- Introduced a new WebP decoding module using @jsquash/webp's wasm decoder.
- Implemented functions to decode WebP images and check for animated WebP formats.
- Updated image compression tests to include scenarios for WebP handling, including encoding and decoding.
- Enhanced error handling for API request size limits to accommodate various error messages.
- Updated pnpm lockfile to include new dependencies for WebP encoding and decoding.

* chore(changeset): consolidate this PR's entries into one

* fix(nix): update pnpmDeps hash for merged lockfile

* feat(agent-core): refuse HEIC/HEIF reads with platform-matched conversion guidance
2026-07-09 18:05:14 +08:00
liruifengv
b91099ed7a
feat: display Extra Usage fuel pack balance in /usage and /status (#1501)
* feat(oauth): parse boosterWallet extra usage from /usages

* feat(oauth): expose extraUsage on AuthManagedUsageResult

* feat(kimi-code): render Extra Usage section in /usage panel

* fix(kimi-code): address Task 3 review feedback for extra usage section

* feat(kimi-code): render Extra Usage section in /status panel

* feat(kimi-code): wire extraUsage into /usage and /status commands

* chore(extra-usage): address final review findings for fuel pack feature

- Update changeset to cover both kimi-code and kimi-code-sdk packages

- Add parser clamp tests and toolkit null-case test

- Replace 'as never' casts in usage-panel tests

- Wrap long import line in status-panel

* chore: temporarily log /usages raw response for debugging

* fix(oauth): accept BOOSTER balance type and drop debug log

* fix(oauth): drop reset hint from Extra Usage and revert periodEnd parsing

* fix(oauth): treat missing amountLeft as zero extra usage and drop debug log

* revert: keep missing amountLeft defaulting to 0 (fully used)

* feat(extra-usage): show monthly cap usage bar and balance in /usage and /status

* fix(extra-usage): show balance and unlimited marker when no monthly cap

* fix(extra-usage): show monthly used with unlimited marker and balance

* fix(extra-usage): label Used and use English Unlimited

* feat(extra-usage): render balance, monthly used and monthly limit as labeled rows

* fix(extra-usage): move Balance row to the bottom

* fix(extra-usage): format currency values with two decimals for column alignment

* fix(extra-usage): right-align currency values so numbers line up

* fix(extra-usage): align currency symbol and decimal point in usage rows
2026-07-09 17:44:59 +08:00
Kai
fe9479d89a
fix: rewrite repeated tool call reminders to redirect instead of prohibit (#1518)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
The r1/r2/r3 reminders injected into repeated tool results led with
prohibition verdicts and, in r2, echoed the repeated tool name and full
arguments back into the context, reinforcing the very pattern they were
meant to break. Rewrite them to state the situation factually and hand
the model a concrete next action: an expectation-setting sentence for
the next call (r1), a forced decision menu of falsify / ask-user /
conclude (r2), and a final hand-off summary without further tool calls
(r3). Detection, thresholds (3/5/8/12), force-stop, and telemetry are
unchanged.
2026-07-09 17:06:19 +08:00
Luyu Cheng
173bdfdab1
fix: resume sessions with missing workdir (#1517) 2026-07-09 16:46:57 +08:00
Luyu Cheng
9fb19154ac
fix: keep prompt goals running until terminal (#1516)
* fix: keep prompt goals running until terminal

* fix: reject invalid prompt goal commands

* fix: ignore stale prompt goal status checks
2026-07-09 15:54:50 +08:00
Luyu Cheng
ad30a1c632
style(web): polish web UI typography and controls (#1502)
* style(web): polish sidebar and tool typography

- Use UI font and medium weight for sidebar and composer controls

- Add reusable shortcut and tool output blocks

- Cap long tool output at 50 lines with a scrollbar

- Update sidebar show-more copy and muted styling

* style(web): refine workspace picker sizing

* style(web): align composer mode menus

* style(web): tune list and question typography

* style(web): reuse shortcut keys in approvals

* style(web): size workspace picker from content

* feat(web): localize chat status labels

* style(web): refine composer toolbar controls

* style(web): use complete Inter variable font

* style(web): tune sidebar workspace typography

* style(web): polish composer and workspace picker

* style(web): refine markdown and thinking typography

* chore: add web UI polish changeset

* fix(web): pin Inter package for Nix build

* style(web): polish goal tool calls

* style: polish goal mode display

* fix: layer latest message pill below menus

* style: align goal strip content
2026-07-09 14:55:58 +08:00
liruifengv
735922c291
feat(kimi-web): add status-aware browser notifications (#1479)
* feat(kimi-web): add approval notification storage key and i18n copy

* feat(kimi-web): add approval notification helpers and tests

* feat(kimi-web): wire approval notifications and guard completion alerts

* fix(kimi-web): extract shouldNotifyCompletion helper and add tests

* feat(kimi-web): add approval notification settings toggle

* chore(kimi-web): add changeset and tidy notification module comment

- Align approval notification tag with spec (kimi-approval-${approvalId})

- Update module header to describe all three notification kinds

* fix(kimi-web): make notifications fire reliably

- Key completion notification tags by turn (sid + promptId) and question
  tags by request id, so a stale notification left in the notification
  center no longer swallows every follow-up alert in the same session
- Suppress notifications only while the window is actually focused, not
  merely visible (document.hasFocus() on top of visibilityState)
- Play the attention sound when a tool needs approval, matching the
  completion and question sounds

* chore(kimi-web): simplify changeset
2026-07-09 12:30:15 +08:00
liruifengv
b89fc1a4fb
docs(changelog): sync 0.23.3 and shorten OAuth error entry (#1509)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
2026-07-08 23:33:29 +08:00
155 changed files with 5802 additions and 1000 deletions

View file

@ -148,8 +148,8 @@ The docs changelog uses five section types:
| English section | Chinese section | Meaning |
|---|---|---|
| `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before |
| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken |
| `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities |
| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken |
| `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames |
| `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts |
@ -176,7 +176,7 @@ Keyword hints:
Within each version, section order is:
```text
Features → Bug Fixes → Polish → Refactors → Other
Features → Polish → Bug Fixes → Refactors → Other
```
Omit empty sections. Within each section, order entries by reader value, not upstream order:

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Hide the internal image-compression note so it no longer renders as user message text.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Retry provider 429, overload, and other transient errors more reliably, honoring the server Retry-After delay, and surface retries in `-p --output-format stream-json`.

View file

@ -37,7 +37,7 @@ jobs:
registry-url: "https://registry.npmjs.org"
- name: Upgrade npm for Trusted Publishing
run: npm install -g npm@latest
run: npm install -g npm@11
- name: Install dependencies
run: pnpm install --frozen-lockfile

View file

@ -1,10 +1,34 @@
# @moonshot-ai/kimi-code
## 0.23.4
### Patch Changes
- [#1501](https://github.com/MoonshotAI/kimi-code/pull/1501) [`b91099e`](https://github.com/MoonshotAI/kimi-code/commit/b91099ed7a2590d1afa4d6e3675671da52b7661c) Thanks [@liruifengv](https://github.com/liruifengv)! - Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands.
- [#1517](https://github.com/MoonshotAI/kimi-code/pull/1517) [`173bdfd`](https://github.com/MoonshotAI/kimi-code/commit/173bdfdab1f484ed79927aeaac7dc8116d3fd346) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix resuming sessions whose original working directory no longer exists.
- [#1516](https://github.com/MoonshotAI/kimi-code/pull/1516) [`9fb1915`](https://github.com/MoonshotAI/kimi-code/commit/9fb19154accf6b6f7abfbf7a9820ccda517bc87e) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts.
- [#1508](https://github.com/MoonshotAI/kimi-code/pull/1508) [`1bf2c9a`](https://github.com/MoonshotAI/kimi-code/commit/1bf2c9afee4643fbf6755f0b92fd60aa14240501) Thanks [@RealKai42](https://github.com/RealKai42)! - Keep image-heavy sessions within provider request-size limits: model-read images now honor a 256 KB per-image budget and a 2000px downscale cap (configurable via `[image]` in config.toml or `KIMI_IMAGE_*` env vars), oversized WebP is compressed as well, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and a request-too-large rejection (HTTP 413) now recovers automatically — the request and /compact both retry with older media replaced by text markers instead of failing the session.
- [#1519](https://github.com/MoonshotAI/kimi-code/pull/1519) [`170ae44`](https://github.com/MoonshotAI/kimi-code/commit/170ae4420526b6592d696cd597d1693dbd1a660b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Polish the session sidebar layout, colors, icons, and typography.
- [#1521](https://github.com/MoonshotAI/kimi-code/pull/1521) [`046b6c4`](https://github.com/MoonshotAI/kimi-code/commit/046b6c417581792933732c7ffe154e120c96171d) Thanks [@RealKai42](https://github.com/RealKai42)! - The `[image]` limits in config.toml now also apply to pasted images (CLI paste and ACP prompts), and each core now uses its own settings, so reloading one client's config no longer changes another client's image compression.
- [#1494](https://github.com/MoonshotAI/kimi-code/pull/1494) [`a354803`](https://github.com/MoonshotAI/kimi-code/commit/a3548035a8b6d25df9a11daab37a21daee1ef73f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add a Kimi WebBridge entry to the Official tab of the /plugins panel that opens the WebBridge install page in your browser.
- [#1479](https://github.com/MoonshotAI/kimi-code/pull/1479) [`735922c`](https://github.com/MoonshotAI/kimi-code/commit/735922c291ec3d32d60da6af053f75e1c6179f92) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Add notifications when a tool needs approval, and improve notification reliability.
- [#1522](https://github.com/MoonshotAI/kimi-code/pull/1522) [`ec8dc34`](https://github.com/MoonshotAI/kimi-code/commit/ec8dc3456c1696a5eba6c37b6e26ef99837c35e2) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent.
- [#1502](https://github.com/MoonshotAI/kimi-code/pull/1502) [`ad30a1c`](https://github.com/MoonshotAI/kimi-code/commit/ad30a1c6328327729221f9f5fc700b621dfef779) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Polish the chat UI with Inter typography, localized labels, and tighter sidebar, composer, and menu styling.
## 0.23.3
### Patch Changes
- [#1506](https://github.com/MoonshotAI/kimi-code/pull/1506) [`e83511a`](https://github.com/MoonshotAI/kimi-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account; the CLI now shows the provider's actual error.
- [#1506](https://github.com/MoonshotAI/kimi-code/pull/1506) [`e83511a`](https://github.com/MoonshotAI/kimi-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account.
## 0.23.2

View file

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

View file

@ -46,13 +46,18 @@ const GOAL_PREFIX = /^\/goal(\s|$)/;
* Parses a headless prompt into a goal-create request, or `undefined` when the
* prompt is not a `/goal` create command (so the caller runs it as a normal
* prompt). Non-create goal subcommands are not supported headless and fall
* through to normal prompt handling.
* through to normal prompt handling. Malformed create commands throw instead of
* falling through, so validation errors are reported before anything is sent to
* the model.
*/
export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined {
const trimmed = prompt.trim();
if (!GOAL_PREFIX.test(trimmed)) return undefined;
const args = trimmed.replace(/^\/goal/, '').trim();
const parsed = parseGoalCommand(args);
if (parsed.kind === 'error') {
throw new Error(parsed.message);
}
if (parsed.kind !== 'create') return undefined;
return { objective: parsed.objective, replace: parsed.replace };
}

View file

@ -234,7 +234,7 @@ async function runHeadlessGoal(
try {
// The objective is sent as the normal prompt; goal continuation keeps the
// turn alive until a terminal state is reached.
await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr);
await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr, true);
} finally {
unsubscribeGoalEvents();
const snapshot = completedSnapshot ?? (await session.getGoal()).goal;
@ -432,9 +432,11 @@ function runPromptTurn(
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
waitForGoalTerminal = false,
): Promise<void> {
let activeTurnId: number | undefined;
let activeAgentId: string | undefined;
let latestStartedTurnId: number | undefined;
const outputWriter =
outputFormat === 'stream-json'
? new PromptJsonWriter(stdout)
@ -469,6 +471,18 @@ function runPromptTurn(
}
activeTurnId = event.turnId;
activeAgentId = event.agentId;
latestStartedTurnId = event.turnId;
return;
}
if (
waitForGoalTerminal &&
event.type === 'goal.updated' &&
event.agentId === PROMPT_MAIN_AGENT_ID &&
activeTurnId === undefined &&
event.snapshot !== null &&
event.snapshot.status !== 'active'
) {
void finishCompletedTurn();
return;
}
if (
@ -487,6 +501,7 @@ function runPromptTurn(
return;
case 'turn.step.retrying':
outputWriter.discardAssistant();
outputWriter.writeRetrying(event);
return;
case 'assistant.delta':
outputWriter.writeAssistantDelta(event.delta);
@ -515,19 +530,29 @@ function runPromptTurn(
return;
case 'turn.ended':
if (event.reason === 'completed') {
void (async () => {
// Flush the buffered assistant message before draining background
// tasks: in stream-json mode the final message is only emitted by
// finish(), so a long background wait would otherwise withhold the
// main turn's result until the drain settles.
outputWriter.flushAssistant();
try {
await session.waitForBackgroundTasksOnPrint();
} catch (error) {
log.warn('waitForBackgroundTasksOnPrint failed', { error });
}
finish();
})();
outputWriter.flushAssistant();
if (waitForGoalTerminal) {
const completedTurnId = event.turnId;
activeTurnId = undefined;
activeAgentId = undefined;
void (async () => {
try {
const { goal } = await session.getGoal();
if (
activeTurnId !== undefined ||
latestStartedTurnId !== completedTurnId
) {
return;
}
if (goal?.status === 'active') return;
await finishCompletedTurn();
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
})();
return;
}
void finishCompletedTurn();
return;
}
finish(new Error(formatTurnEndedFailure(event)));
@ -560,6 +585,20 @@ function runPromptTurn(
session.prompt(prompt).catch((error: unknown) => {
finish(error instanceof Error ? error : new Error(String(error)));
});
async function finishCompletedTurn(): Promise<void> {
// Flush the buffered assistant message before draining background tasks:
// in stream-json mode the final message is only emitted by finish(), so a
// long background wait would otherwise withhold the main turn's result
// until the drain settles.
outputWriter.flushAssistant();
try {
await session.waitForBackgroundTasksOnPrint();
} catch (error) {
log.warn('waitForBackgroundTasksOnPrint failed', { error });
}
finish();
}
});
}
@ -574,6 +613,7 @@ interface PromptTurnWriter {
argumentsPart: string | undefined,
): void;
writeToolResult(toolCallId: string, output: unknown): void;
writeRetrying(event: Extract<Event, { type: 'turn.step.retrying' }>): void;
flushAssistant(): void;
discardAssistant(): void;
finish(): void;
@ -610,7 +650,14 @@ class PromptTranscriptWriter implements PromptTurnWriter {
writeToolResult(): void {}
flushAssistant(): void {}
// Text `-p` keeps retries silent: only the failed attempt's partial assistant
// text is discarded (handled by the caller). No human-readable retry line is
// emitted, matching the prior behavior.
writeRetrying(): void {}
flushAssistant(): void {
this.assistantWriter.finish();
}
discardAssistant(): void {}
@ -649,6 +696,18 @@ interface PromptJsonResumeMetaMessage {
content: string;
}
interface PromptJsonRetryMetaMessage {
role: 'meta';
type: 'turn.step.retrying';
failed_attempt: number;
next_attempt: number;
max_attempts: number;
delay_ms: number;
error_name: string;
error_message: string;
status_code?: number;
}
function writeResumeHint(
sessionId: string,
outputFormat: PromptOutputFormat,
@ -747,6 +806,24 @@ class PromptJsonWriter implements PromptTurnWriter {
this.toolCalls.length = 0;
}
writeRetrying(event: Extract<Event, { type: 'turn.step.retrying' }>): void {
// Emit a machine-readable meta line so stream-json consumers can observe
// provider retries. The failed attempt's partial assistant text was already
// discarded by the caller, so no half-formed assistant message leaks.
const message: PromptJsonRetryMetaMessage = {
role: 'meta',
type: 'turn.step.retrying',
failed_attempt: event.failedAttempt,
next_attempt: event.nextAttempt,
max_attempts: event.maxAttempts,
delay_ms: event.delayMs,
error_name: event.errorName,
error_message: event.errorMessage,
status_code: event.statusCode,
};
this.writeJsonLine(message);
}
finish(): void {
this.flushAssistant();
}
@ -766,7 +843,9 @@ class PromptJsonWriter implements PromptTurnWriter {
return toolCall;
}
private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void {
private writeJsonLine(
message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage,
): void {
this.stdout.write(`${JSON.stringify(message)}\n`);
}
}

View file

@ -213,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUs
if (res.kind === 'error') {
return { error: res.message };
}
return { usage: { summary: res.summary, limits: res.limits } };
return { usage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage } };
}

View file

@ -22,6 +22,7 @@ import { UsagePanelComponent } from '../components/messages/usage-panel';
import { formatErrorMessage } from '../utils/event-payload';
import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label';
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
import { openUrl } from '#/utils/open-url';
import type { SlashCommandHost } from './dispatch';
interface ShowPluginsPickerOptions {
@ -411,6 +412,12 @@ async function handlePluginsPanelSelection(
isOfficialPluginSource(selection.source),
);
return;
case 'open-url':
host.restoreEditor();
openUrl(selection.url);
host.showStatus(`Opening the ${selection.label} page in your browser…`, 'success');
host.showStatus(`If it did not open, visit ${selection.url}`);
return;
}
}

View file

@ -28,6 +28,27 @@ const INSTALL_TRUST_EXIT = 'exit';
const INSTALL_TRUST_TRUST = 'trust';
const ELLIPSIS = '…';
// Hardcoded Web Bridge promotion: a built-in entry that always leads the
// Official tab, even when the marketplace catalog is unavailable. Selecting it
// opens the install page in the browser rather than installing from a source,
// because Web Bridge is a browser extension + daemon, not a plugin package.
const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge';
const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = {
id: 'kimi-webbridge',
displayName: 'Kimi WebBridge',
source: WEB_BRIDGE_URL,
tier: 'official',
homepage: WEB_BRIDGE_URL,
description: 'Control your real browser from Kimi Code — navigate, click, type, and screenshot',
};
// Only the hardcoded pinned row should open the WebBridge install page. Match
// by reference (not id) so a catalog entry on another tab that happens to
// reuse the same id still installs normally instead of being hijacked.
function isPinnedWebBridgeEntry(entry: PluginMarketplaceEntry): boolean {
return entry === WEB_BRIDGE_ENTRY;
}
interface PluginsOverviewItem {
readonly value: string;
readonly kind: 'plugin' | 'action';
@ -304,7 +325,8 @@ export type PluginsPanelSelection =
| { readonly kind: 'details'; readonly id: string }
| { readonly kind: 'reload' }
| { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry }
| { readonly kind: 'install-source'; readonly source: string };
| { readonly kind: 'install-source'; readonly source: string }
| { readonly kind: 'open-url'; readonly url: string; readonly label: string };
export interface PluginsPanelOptions {
readonly installed: readonly PluginSummary[];
@ -402,7 +424,19 @@ export class PluginsPanelComponent extends Container implements Focusable {
}
private get officialEntries(): readonly PluginMarketplaceEntry[] {
return this.marketplaceEntries.filter((entry) => entry.tier === 'official');
// The hardcoded Web Bridge entry always leads the Official tab, even when
// the catalog is loading or unreachable. Dedupe by id so a catalog that
// also lists it does not render a second row.
return [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries];
}
private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] {
// Dedupe by id (not reference): if the official catalog also lists
// kimi-webbridge, the pinned row already represents it, so suppress the
// catalog copy to avoid a duplicate row on the Official tab.
return this.marketplaceEntries.filter(
(entry) => entry.tier === 'official' && entry.id !== WEB_BRIDGE_ENTRY.id,
);
}
private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] {
@ -516,6 +550,10 @@ export class PluginsPanelComponent extends Container implements Focusable {
if (matchesKey(data, Key.enter)) {
const entry = entries[this.selectedIndex];
if (entry === undefined) return;
if (isPinnedWebBridgeEntry(entry)) {
this.opts.onSelect({ kind: 'open-url', url: WEB_BRIDGE_URL, label: entry.displayName });
return;
}
this.opts.onSelect({ kind: 'install', entry });
}
}
@ -622,6 +660,7 @@ export class PluginsPanelComponent extends Container implements Focusable {
lines: string[],
width: number,
entries: readonly PluginMarketplaceEntry[],
indexOffset = 0,
): void {
const colors = currentTheme.palette;
if (this.market.status === 'loading' || this.market.status === 'idle') {
@ -637,7 +676,7 @@ export class PluginsPanelComponent extends Container implements Focusable {
lines.push(chalk.hex(colors.textMuted)(' No plugins found.'));
} else {
for (let i = 0; i < entries.length; i++) {
lines.push(...this.renderMarketplaceRow(entries[i]!, i, width));
lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width));
}
}
const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length;
@ -649,7 +688,11 @@ export class PluginsPanelComponent extends Container implements Focusable {
}
private renderOfficial(lines: string[], width: number): void {
this.renderMarketplaceTab(lines, width, this.officialEntries);
// Web Bridge is pinned above the catalog and stays visible while the
// catalog loads or errors, since it's built into the TUI rather than
// fetched. Catalog rows shift down by one index to match.
lines.push(...this.renderMarketplaceRow(WEB_BRIDGE_ENTRY, 0, width));
this.renderMarketplaceTab(lines, width, this.officialCatalogEntries, 1);
}
private renderThirdParty(lines: string[], width: number): void {
@ -662,7 +705,9 @@ export class PluginsPanelComponent extends Container implements Focusable {
const pointer = selected ? SELECT_POINTER : ' ';
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
const status = marketplaceEntryStatus(entry, this.installedVersions);
const status = isPinnedWebBridgeEntry(entry)
? 'open in browser'
: marketplaceEntryStatus(entry, this.installedVersions);
const line =
prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status);
const descWidth = Math.max(1, width - 4);

View file

@ -22,7 +22,11 @@ import {
safeUsageRatio,
} from '#/utils/usage/usage-format';
import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel';
import {
buildExtraUsageSection,
buildManagedUsageReportLines,
type ManagedUsageReport,
} from './usage-panel';
interface FieldRow {
readonly label: string;
@ -145,5 +149,16 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] {
lines.push(...managedSection);
}
const extraSection = buildExtraUsageSection(
options.managedUsage?.extraUsage,
accent,
value,
muted,
);
if (extraSection.length > 0) {
lines.push('');
lines.push(...extraSection);
}
return lines;
}

View file

@ -30,9 +30,19 @@ export interface ManagedUsageRow {
readonly resetHint?: string;
}
export interface BoosterWalletInfo {
readonly balanceCents: number;
readonly totalCents: number;
readonly monthlyChargeLimitEnabled: boolean;
readonly monthlyChargeLimitCents: number;
readonly monthlyUsedCents: number;
readonly currency: string;
}
export interface ManagedUsageReport {
readonly summary: ManagedUsageRow | null;
readonly limits: readonly ManagedUsageRow[];
readonly extraUsage?: BoosterWalletInfo | null;
}
export interface UsageReportOptions {
@ -121,8 +131,7 @@ function buildManagedUsageSection(
r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0;
const labelWidth = Math.max(10, ...rows.map((r) => r.label.length));
const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length));
const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' =>
sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
const out: string[] = [accent('Plan usage')];
for (const row of rows) {
const ratioUsed = usedRatio(row);
@ -136,6 +145,91 @@ function buildManagedUsageSection(
return out;
}
function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' {
return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
}
function currencySymbol(currency: string): string {
switch (currency.toUpperCase()) {
case 'CNY':
return '¥';
case 'USD':
return '$';
default:
return '';
}
}
interface CurrencyParts {
readonly symbol: string;
readonly number: string;
}
function formatCurrencyParts(cents: number, currency: string): CurrencyParts {
const symbol = currencySymbol(currency);
const main = cents / 100;
const formatted = main.toFixed(2);
return symbol.length > 0
? { symbol, number: formatted }
: { symbol: '', number: `${formatted} ${currency}` };
}
export function buildExtraUsageSection(
extraUsage: BoosterWalletInfo | undefined | null,
accent: Colorize,
value: Colorize,
muted: Colorize,
): string[] {
if (extraUsage === undefined || extraUsage === null) return [];
const hasMonthlyLimit =
extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0;
const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency);
const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency);
const rows: Array<{ label: string; symbol: string; number: string }> = [];
let barLine: string | null = null;
if (hasMonthlyLimit) {
const ratio = Math.max(
0,
Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1),
);
const bar = renderProgressBar(ratio, 20);
barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`;
const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency);
rows.push({ label: 'Used this month', ...used });
rows.push({ label: 'Monthly limit', ...limit });
rows.push({ label: 'Balance', ...balance });
} else {
rows.push({ label: 'Used this month', ...used });
rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' });
rows.push({ label: 'Balance', ...balance });
}
// `Used this month` is the longest label; size the column to the widest label
// so the currency symbol starts in the same column on every row.
const labelWidth = Math.max(...rows.map((r) => r.label.length));
// Right-align the numeric part of currency rows against each other so the
// decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as
// `Unlimited` carry no currency symbol, so they must not widen the numeric
// column — otherwise money values get padded with stray spaces.
const numberWidth = Math.max(
0,
...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)),
);
const row = (label: string, symbol: string, number: string): string => {
const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number;
return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`;
};
const lines: string[] = [accent('Extra Usage')];
if (barLine !== null) lines.push(barLine);
for (const r of rows) lines.push(row(r.label, r.symbol, r.number));
return lines;
}
export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] {
const accent = (text: string) => currentTheme.boldFg('primary', text);
const value = (text: string) => currentTheme.fg('text', text);
@ -157,8 +251,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] {
const value = (text: string) => currentTheme.fg('text', text);
const muted = (text: string) => currentTheme.fg('textDim', text);
const errorStyle = (text: string) => currentTheme.fg('error', text);
const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' =>
sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
const lines: string[] = [
accent('Session usage'),
@ -197,6 +289,17 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] {
lines.push(...managedSection);
}
const extraSection = buildExtraUsageSection(
options.managedUsage?.extraUsage,
accent,
value,
muted,
);
if (extraSection.length > 0) {
lines.push('');
lines.push(...extraSection);
}
return lines;
}

View file

@ -1,4 +1,4 @@
import type { Session } from '@moonshot-ai/kimi-code-sdk';
import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk';
import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image';
@ -23,6 +23,12 @@ export interface EditorKeyboardHost {
state: TUIState;
session: Session | undefined;
cancelInFlight: (() => void) | undefined;
/**
* The host's harness (KimiTUI always has one). Its `imageLimits` drives
* paste-time image compression; hosts without one fall back to the
* env/built-in default.
*/
harness?: KimiHarness | undefined;
handleUserInput(text: string): void;
readonly btwPanelController: BtwPanelController;
@ -407,7 +413,11 @@ export class EditorKeyboardController {
// session's media-originals dir when known, else the temp-dir fallback)
// and recorded on the attachment, so submit-time expansion can announce
// the compression and point the model at the full-fidelity copy.
// The edge cap comes from the host harness's [image] config (resolved per
// paste so a config reload applies immediately); hosts without a harness
// use the env/built-in default.
const compressed = await compressImageForModel(media.bytes, meta.mime, {
maxEdge: this.host.harness?.imageLimits?.maxEdgePx(),
telemetry: {
client: {
track: (event, properties) =>

View file

@ -46,6 +46,12 @@ describe('parseHeadlessGoalCreate', () => {
expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined();
expect(parseHeadlessGoalCreate('/goal pause')).toBeUndefined();
});
it('rejects malformed goal create prompts instead of falling through', () => {
expect(() => parseHeadlessGoalCreate(`/goal ${'x'.repeat(4001)}`)).toThrow(
'Goal objective is too long',
);
});
});
describe('goal summary', () => {
@ -97,6 +103,7 @@ const mocks = vi.hoisted(() => {
handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
}),
waitForBackgroundTasksOnPrint: vi.fn(async () => {}),
};
return {
session,
@ -164,6 +171,8 @@ describe('runPrompt headless goal mode', () => {
mocks.experimentalFeatures = [{ id: 'micro_compaction', enabled: true }];
mocks.sessions = [];
mocks.session.createGoal.mockClear();
mocks.session.prompt.mockClear();
mocks.session.waitForBackgroundTasksOnPrint.mockClear();
mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never);
mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'complete' }) } as never);
});
@ -243,6 +252,113 @@ describe('runPrompt headless goal mode', () => {
expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X');
});
it('keeps listening across continuation turns until the goal is terminal', async () => {
const active = snapshot({ status: 'active', turnsUsed: 1, tokensUsed: 80 });
const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 });
mocks.session.getGoal.mockResolvedValueOnce({ goal: active } as never);
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: '1' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
await Promise.resolve();
for (const handler of mocks.eventHandlers) {
handler(
mocks.mainEvent({
type: 'turn.started',
turnId: 2,
origin: { kind: 'system_trigger', name: 'goal_continuation' },
}),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: '2' }));
handler(
mocks.mainEvent({
type: 'goal.updated',
snapshot: completed,
change: { kind: 'completion', status: 'complete' },
}),
);
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts(), 'test', {
stdout,
stderr,
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
});
expect(stdout.text()).toBe('• 1\n\n• 2\n\n');
expect(stderr.text()).toContain('Goal [complete]');
expect(stderr.text()).toContain('turns: 2');
});
it('ignores stale goal checks once a continuation turn has started', async () => {
const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 });
let resolveFirstGoal: ((value: { goal: null }) => void) | undefined;
const firstGoal = new Promise<{ goal: null }>((resolve) => {
resolveFirstGoal = resolve;
});
mocks.session.getGoal
.mockImplementationOnce(() => firstGoal as never)
.mockResolvedValue({ goal: null } as never);
mocks.session.prompt.mockImplementationOnce(async () => {
const emit = (event: Record<string, unknown>) => {
for (const handler of [...mocks.eventHandlers]) {
handler(mocks.mainEvent(event));
}
};
emit({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } });
emit({ type: 'assistant.delta', turnId: 1, delta: '1' });
emit({ type: 'turn.ended', turnId: 1, reason: 'completed' });
emit({
type: 'turn.started',
turnId: 2,
origin: { kind: 'system_trigger', name: 'goal_continuation' },
});
emit({ type: 'assistant.delta', turnId: 2, delta: '2' });
emit({
type: 'goal.updated',
snapshot: completed,
change: { kind: 'completion', status: 'complete' },
});
resolveFirstGoal?.({ goal: null });
await Promise.resolve();
emit({ type: 'assistant.delta', turnId: 2, delta: ' tail' });
emit({ type: 'turn.ended', turnId: 2, reason: 'completed' });
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts(), 'test', {
stdout,
stderr,
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
});
expect(stdout.text()).toBe('• 1\n\n• 2 tail\n\n');
expect(stderr.text()).toContain('Goal [complete]');
});
it('does not send an invalid goal create prompt as a normal prompt', async () => {
const stdout = writer();
const stderr = writer();
await expect(
runPrompt(opts({ prompt: `/goal ${'x'.repeat(4001)}` }), 'test', {
stdout,
stderr,
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
}),
).rejects.toThrow('Goal objective is too long');
expect(mocks.session.createGoal).not.toHaveBeenCalled();
expect(mocks.session.prompt).not.toHaveBeenCalled();
});
it('validates the resumed session model before creating a headless goal', async () => {
mocks.sessions = [{ id: 'ses_goal', workDir: process.cwd() }];
mocks.session.getStatus.mockResolvedValueOnce({ permission: 'auto', model: '' } as never);

View file

@ -666,6 +666,59 @@ describe('runPrompt', () => {
);
});
it('emits a stream-json meta line on retry and discards the failed attempt output', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'partial attempt' }));
handler(
mocks.mainEvent({
type: 'turn.step.retrying',
turnId: 10,
step: 1,
stepId: 'step-uuid',
failedAttempt: 1,
nextAttempt: 2,
maxAttempts: 3,
delayMs: 300,
errorName: 'APIProviderRateLimitError',
errorMessage: 'llmproxy/openai/responses/resp_abc.json status_code=429',
statusCode: 429,
}),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'final answer' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr });
const retryMeta = JSON.stringify({
role: 'meta',
type: 'turn.step.retrying',
failed_attempt: 1,
next_attempt: 2,
max_attempts: 3,
delay_ms: 300,
error_name: 'APIProviderRateLimitError',
error_message: 'llmproxy/openai/responses/resp_abc.json status_code=429',
status_code: 429,
});
expect(stdout.text()).toBe(
[
retryMeta,
'{"role":"assistant","content":"final answer"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
// The failed attempt's partial text must not leak as an assistant line.
expect(stdout.text()).not.toContain('partial attempt');
expect(stderr.text()).toBe('');
});
it('flushes stream-json assistant output before waiting for background tasks', async () => {
let releaseWait: () => void = () => {};
const waitGate = new Promise<void>((resolve) => {

View file

@ -289,6 +289,87 @@ describe('plugins selector dialogs', () => {
expect(out).toContain('0 installed · 1 available');
});
it('renders the hardcoded Web Bridge entry on the Official tab while loading', () => {
const { panel } = makePanel({ initialTab: 'official' });
// The catalog is still loading, but the built-in Web Bridge entry is shown
// immediately because it is baked into the TUI, not fetched.
const out = strip(renderRaw(panel));
expect(out).toContain('Kimi WebBridge open in browser');
expect(out).toContain('Loading marketplace');
});
it('keeps the Web Bridge entry visible when the Official catalog errors', () => {
const { panel } = makePanel({ initialTab: 'official' });
panel.setMarketplaceError('fetch failed');
const out = strip(renderRaw(panel));
expect(out).toContain('Kimi WebBridge open in browser');
expect(out).toContain('Marketplace unavailable: fetch failed');
});
it('opens the Web Bridge webpage on Enter instead of installing', () => {
const { panel, onSelect } = makePanel({ initialTab: 'official' });
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
// Web Bridge is pinned at index 0, so Enter selects it directly.
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
kind: 'open-url',
url: 'https://www.kimi.com/features/webbridge',
label: 'Kimi WebBridge',
});
});
it('installs a catalog official entry after navigating past Web Bridge', () => {
const { panel, onSelect } = makePanel({ initialTab: 'official' });
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
panel.handleInput('\u001B[B'); // ↓ → kimi-datasource
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
kind: 'install',
entry: expect.objectContaining({ id: 'kimi-datasource' }),
});
});
it('does not duplicate Web Bridge when the catalog also lists it', () => {
const entries = [
{
id: 'kimi-webbridge',
tier: 'official' as const,
displayName: 'Kimi WebBridge',
source: 'https://x/w.zip',
},
...officialEntries,
];
const { panel } = makePanel({ initialTab: 'official' });
panel.setMarketplace(entries, '/tmp/marketplace.json');
const out = strip(renderRaw(panel));
// The label should appear exactly once — the hardcoded row wins, the
// catalog copy is filtered out.
expect(out.split('Kimi WebBridge').length - 1).toBe(1);
});
it('installs a Third-party entry whose id matches the pinned WebBridge', () => {
// A curated/custom marketplace entry can legitimately reuse the
// kimi-webbridge id; on the Third-party tab it must install normally, not
// open the WebBridge page (that shortcut is reserved for the pinned row).
const entries = [
{
id: 'kimi-webbridge',
tier: 'curated' as const,
displayName: 'Kimi WebBridge',
source: 'https://x/w.zip',
},
];
const { panel, onSelect } = makePanel({ initialTab: 'third-party' });
panel.setMarketplace(entries, '/tmp/marketplace.json');
const out = strip(renderRaw(panel));
expect(out).toContain('Kimi WebBridge install');
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
kind: 'install',
entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'https://x/w.zip' }),
});
});
it('installs the selected Third-party entry on Enter', () => {
const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' });
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');

View file

@ -68,6 +68,44 @@ describe('status panel report lines', () => {
expect(output).not.toContain('Runtime');
});
it('formats extra usage section in status report', () => {
const lines = buildStatusReportLines({
version: '1.2.3',
model: 'k2',
workDir: '/tmp/project',
sessionId: 'ses-1',
sessionTitle: null,
thinkingEffort: 'off',
permissionMode: 'manual',
planMode: false,
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
availableModels: {},
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 15000,
totalCents: 20000,
monthlyChargeLimitEnabled: true,
monthlyChargeLimitCents: 20000,
monthlyUsedCents: 5000,
currency: 'USD',
},
},
}).map(strip);
const output = lines.join('\n');
expect(output).toContain('Extra Usage');
expect(output).toContain('Balance');
expect(output).toContain('150.00');
expect(output).toContain('Used this month');
expect(output).toContain('50.00');
expect(output).toContain('Monthly limit');
expect(output).toContain('200.00');
});
it('falls back to app state and shows status load errors as warnings', () => {
const lines = buildStatusReportLines({
version: '1.2.3',

View file

@ -24,7 +24,7 @@ describe('UsagePanelComponent', () => {
output: 250,
},
},
} as never,
},
contextUsage: 0.25,
contextTokens: 2500,
maxContextTokens: 10000,
@ -48,6 +48,142 @@ describe('UsagePanelComponent', () => {
expect(lines.join('\n')).toContain('resets tomorrow');
});
it('formats extra usage with a monthly limit', () => {
const lines = buildUsageReportLines({
sessionUsage: { byModel: {} },
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 10000,
totalCents: 20000,
monthlyChargeLimitEnabled: true,
monthlyChargeLimitCents: 20000,
monthlyUsedCents: 5000,
currency: 'USD',
},
},
}).map(strip);
const output = lines.join('\n');
expect(lines).toContain('Extra Usage');
expect(output).toContain('Balance');
expect(output).toContain('100.00');
expect(output).toContain('Used this month');
expect(output).toContain('50.00');
expect(output).toContain('Monthly limit');
expect(output).toContain('200.00');
// bar row contains block glyphs but no percentage text
expect(output).toContain('░');
});
it('formats extra usage without a monthly limit and omits the progress bar', () => {
const lines = buildUsageReportLines({
sessionUsage: { byModel: {} },
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 18208,
totalCents: 40000,
monthlyChargeLimitEnabled: false,
monthlyChargeLimitCents: 0,
monthlyUsedCents: 21792,
currency: 'CNY',
},
},
}).map(strip);
const output = lines.join('\n');
expect(lines).toContain('Extra Usage');
expect(output).toContain('Balance');
expect(output).toContain('¥182.08');
expect(output).toContain('Used this month');
expect(output).toContain('¥217.92');
expect(output).toContain('Monthly limit');
expect(output).toContain('Unlimited');
expect(output).not.toContain('░');
expect(output).not.toContain('█');
});
it('omits the extra usage section when extraUsage is omitted or null', () => {
for (const extraUsage of [undefined, null]) {
const lines = buildUsageReportLines({
sessionUsage: { byModel: {} },
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
managedUsage: { summary: null, limits: [], extraUsage },
}).map(strip);
expect(lines).not.toContain('Extra Usage');
}
});
it('formats extra usage with CNY currency', () => {
const lines = buildUsageReportLines({
sessionUsage: { byModel: {} },
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 10000,
totalCents: 20000,
monthlyChargeLimitEnabled: true,
monthlyChargeLimitCents: 20000,
monthlyUsedCents: 5000,
currency: 'CNY',
},
},
}).map(strip);
const output = lines.join('\n');
expect(output).toContain('Balance');
expect(output).toContain('100.00');
expect(output).toContain('Used this month');
expect(output).toContain('50.00');
expect(output).toContain('Monthly limit');
expect(output).toContain('200.00');
});
it('aligns the currency symbol and decimal point across extra usage rows', () => {
const lines = buildUsageReportLines({
sessionUsage: { byModel: {} },
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 15901,
totalCents: 300000,
monthlyChargeLimitEnabled: true,
monthlyChargeLimitCents: 300000,
monthlyUsedCents: 24099,
currency: 'CNY',
},
},
}).map(strip);
const extraRows = lines.filter((line) => line.includes('¥'));
expect(extraRows).toHaveLength(3);
// The currency symbol stays in one column...
expect(new Set(extraRows.map((line) => line.indexOf('¥'))).size).toBe(1);
// ...and the right-aligned numeric parts end in the same column, so the
// decimal points line up across rows.
expect(new Set(extraRows.map((line) => line.length)).size).toBe(1);
});
it('wraps preformatted usage lines in a bordered panel', () => {
const component = new UsagePanelComponent(() => ['Session usage'], 'primary');
const output = component.render(80).map(strip);

View file

@ -25,6 +25,7 @@ import {
} from '#/tui/controllers/editor-keyboard';
import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store';
import { parseImageMeta } from '#/utils/image/image-mime';
import { ImageLimits, type KimiHarness } from '@moonshot-ai/kimi-code-sdk';
// vitest hoists vi.mock/vi.hoisted above the imports above, so the mock still
// applies to the editor-keyboard module that pulls in readClipboardMedia.
@ -41,7 +42,7 @@ interface PasteHarness {
pasteImage(): Promise<void>;
}
function createPasteHarness(options: { sessionDir?: string } = {}): PasteHarness {
function createPasteHarness(options: { sessionDir?: string; imageLimits?: ImageLimits } = {}): PasteHarness {
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
};
@ -65,6 +66,11 @@ function createPasteHarness(options: { sessionDir?: string } = {}): PasteHarness
openUndoSelector: vi.fn(),
cancelRunningShellCommand: vi.fn(),
} as unknown as EditorKeyboardHost;
if (options.imageLimits !== undefined) {
(host as unknown as { harness: KimiHarness }).harness = {
imageLimits: options.imageLimits,
} as unknown as KimiHarness;
}
const controller = new EditorKeyboardController(host, store);
controller.install();
@ -141,14 +147,33 @@ describe('clipboard image paste compression', () => {
if (att?.kind !== 'image') throw new Error('expected image attachment');
// Stored metadata reflects the compressed size.
expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(3000);
expect(att.placeholder).toContain('3000×1500');
expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000);
expect(att.placeholder).toContain('2000×1000');
// The stored bytes decode to the compressed dimensions — the thumbnail and
// the submitted image both read from these bytes, so they cannot diverge.
const dims = parseImageMeta(att.bytes);
expect(dims).not.toBeNull();
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(3000);
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000);
});
it('honors the harness [image] max_edge_px when pasting', async () => {
const big = await solidPng(3600, 1800);
readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' });
const { store, pasteImage } = createPasteHarness({
imageLimits: new ImageLimits(process.env, { maxEdgePx: 800 }),
});
await pasteImage();
const att = store.get(1);
if (att?.kind !== 'image') throw new Error('expected image attachment');
// The harness [image] config — not the built-in 2000px — drives ingestion.
expect(Math.max(att.width, att.height)).toBe(800);
expect(att.placeholder).toContain('800×400');
const dims = parseImageMeta(att.bytes);
expect(dims).not.toBeNull();
expect(Math.max(dims!.width, dims!.height)).toBe(800);
});
it('records and persists the pre-compression original for an oversized paste', async () => {

View file

@ -3781,6 +3781,9 @@ command = "vim"
await vi.waitFor(() => {
expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource');
});
// The pinned Kimi WebBridge row leads the Official tab, so move down to
// the Kimi Datasource entry before installing.
panel.handleInput('\u001B[B');
panel.handleInput('\r');
await vi.waitFor(() => {
@ -3987,6 +3990,9 @@ command = "vim"
await vi.waitFor(() => {
expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource');
});
// The pinned Kimi WebBridge row leads the Official tab, so move down to
// the Kimi Datasource entry before installing.
panel.handleInput('\u001B[B');
panel.handleInput('\r');
await vi.waitFor(() => {

View file

@ -1,7 +1,7 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { app, BrowserWindow, Menu, shell } from 'electron';
import { app, BrowserWindow, Menu, nativeTheme, shell } from 'electron';
import type { MenuItemConstructorOptions } from 'electron';
import { ensureServer, kimiHome, serverLogPath } from './ensure-server';
@ -152,8 +152,13 @@ function createWindow(): void {
title: 'Kimi Code Desktop',
// macOS: hide the native title bar and float the traffic lights over the
// content; the web UI reserves a draggable strip at the top to clear them.
// 'hidden' (not 'hiddenInset') so trafficLightPosition can pin the lights
// to the vertical center of the web UI's 48px header row (y 18 + 12px
// button height / 2 = 24 = the header's midline — same line as the
// sidebar-expand button and the conversation title).
// 'default' on other platforms (they keep their native title bar).
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
trafficLightPosition: { x: 16, y: 18 },
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
@ -165,6 +170,63 @@ function createWindow(): void {
win.webContents.on('page-title-updated', (event) => {
event.preventDefault();
});
// macOS traffic lights.
//
// 1) Visibility across transitions: with titleBarStyle 'hidden' + a custom
// trafficLightPosition, the buttons can vanish (or lose their custom
// position) after a full-screen round-trip or on re-focus. Re-assert both
// on those transitions (observed on Electron 33; belt-and-braces).
//
// 2) Blur is NOT such a case: unfocused traffic lights are merely DIMMED by
// AppKit, and the dimmed color follows the WINDOW appearance, not the
// page (electron#27295) — with the OS in dark mode but the web UI on a
// light theme, the light-gray dimmed dots become invisible against the
// light sidebar. That is fixed by the theme sync below, which keeps the
// window appearance aligned with the web UI's <html data-color-scheme>.
if (process.platform === 'darwin') {
const showTrafficLights = (): void => {
if (win.isDestroyed()) return;
win.setWindowButtonPosition({ x: 16, y: 18 });
win.setWindowButtonVisibility(true);
};
win.on('enter-full-screen', showTrafficLights);
win.on('leave-full-screen', showTrafficLights);
win.on('focus', showTrafficLights);
// Theme sync: no preload/IPC channel exists, so inject a tiny observer
// that reports <html data-color-scheme> ('light' | 'dark' | 'system')
// through a tagged console message, and mirror it into
// nativeTheme.themeSource (same three states). The startup/error screens
// (data: URLs) have no such attribute and harmlessly report 'system'.
const THEME_TAG = '__kimi_desktop_theme__:';
win.webContents.on('console-message', (_event, _level, message) => {
if (!message.startsWith(THEME_TAG)) return;
const scheme = message.slice(THEME_TAG.length);
if (scheme === 'light' || scheme === 'dark' || scheme === 'system') {
nativeTheme.themeSource = scheme;
}
});
win.webContents.on('did-finish-load', () => {
win.webContents
.executeJavaScript(
`(() => {
const report = () => {
const v = document.documentElement.dataset.colorScheme;
console.info(${JSON.stringify(THEME_TAG)} + (v === 'light' || v === 'dark' ? v : 'system'));
};
new MutationObserver(report).observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-color-scheme'],
});
report();
})();`,
)
.catch(() => {
// Navigation can tear the page down mid-injection; theme sync is
// cosmetic, so ignore.
});
});
}
win.on('close', () => {
saveBounds(win);
});

View file

@ -13,7 +13,8 @@
"check:style": "node scripts/check-style.mjs"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@chenglou/pretext": "0.0.8",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
@ -27,6 +28,7 @@
},
"devDependencies": {
"@iconify-json/ri": "^1.2.10",
"@iconify-json/tabler": "^1.2.35",
"@vitejs/plugin-vue": "^5.2.4",
"typescript": "6.0.2",
"unplugin-icons": "^23.0.0",

View file

@ -43,6 +43,8 @@ import { stripSkillPrefix } from './lib/slashCommands';
import Button from './components/ui/Button.vue';
import IconButton from './components/ui/IconButton.vue';
import Icon from './components/ui/Icon.vue';
import InternalBuildBanner from './components/InternalBuildBanner.vue';
import { isMacosDesktop } from './lib/desktopFlag';
// Hydrate the server-transport credential (fragment token or sessionStorage)
// BEFORE the client connects, so the first REST/WS calls already carry it.
@ -213,6 +215,7 @@ const {
sidebarMax,
sessionColWidth,
sidebarCollapsed,
sidebarDragging,
sideWidth,
loadSidebarCollapsed,
toggleSidebarCollapse,
@ -645,13 +648,18 @@ function openPr(url: string): void {
<div
v-else
class="app"
:class="{ mobile: isMobile, 'sidebar-collapsed': sidebarCollapsed && !isMobile }"
:style="{ '--side-w': sideWidth + 'px', '--preview-w': previewPanelWidth + 'px' }"
:class="{
mobile: isMobile,
'sidebar-collapsed': sidebarCollapsed && !isMobile,
'macos-desktop': isMacosDesktop,
}"
:style="{ '--preview-w': previewPanelWidth + 'px' }"
>
<!-- Desktop navigation: workspace rail + resizable session column. -->
<template v-if="!isMobile">
<Sidebar
v-show="!sidebarCollapsed"
:collapsed="sidebarCollapsed"
:dragging="sidebarDragging"
:col-width="sideWidth"
:active-workspace="client.visibleWorkspace.value"
:active-workspace-id="client.activeWorkspaceId.value"
@ -681,21 +689,14 @@ function openPr(url: string): void {
/>
<ResizeHandle
v-show="!sidebarCollapsed"
class="side-handle"
:storage-key="SIDEBAR_WIDTH_KEY"
:default-width="SIDEBAR_DEFAULT"
:min="SIDEBAR_MIN"
:max="sidebarMax"
@update:width="sessionColWidth = $event"
@update:dragging="sidebarDragging = $event"
/>
<div v-if="sidebarCollapsed" class="sidebar-rail">
<IconButton
size="sm"
:label="t('sidebar.expandSidebar')"
@click="toggleSidebarCollapse"
>
<Icon name="panel-expand" size="sm" />
</IconButton>
</div>
</template>
<!-- Mobile navigation: slim top bar (switcher + settings sheets). -->
@ -738,6 +739,7 @@ function openPr(url: string): void {
:search-files="client.searchFiles"
:upload-image="client.uploadImage"
:sending="client.isSending.value"
:starting="client.isStartingFirstPrompt.value"
:fast-moon="client.fastMoon.value"
:file-reload-key="client.activeSessionId.value"
:session-loading="client.sessionLoading.value"
@ -792,8 +794,29 @@ function openPr(url: string): void {
@edit-message="handleEditMessage"
/>
<!-- Sidebar toggle floating only when the in-header control can't serve:
on macOS desktop it's RESIDENT (always rendered beside the traffic
lights, the sidebar slides underneath and only the glyph swaps, so it
never moves or flashes); on Windows/web the collapse button lives
inside the sidebar header, so this floating button only appears while
COLLAPSED (to re-expand the sidebar). It must come AFTER
ConversationPane in the DOM: Electron computes the window-drag region
in tree order (drag rects union, no-drag rects subtract), so a no-drag
element placed before the ChatHeader drag region would have its hole
painted back over making the button an inert drag area. -->
<IconButton
v-if="!isMobile && (isMacosDesktop || sidebarCollapsed)"
class="sidebar-toggle-btn"
size="sm"
:label="sidebarCollapsed ? t('sidebar.expandSidebar') : t('sidebar.collapseSidebar')"
@click="toggleSidebarCollapse"
>
<Icon :name="sidebarCollapsed ? 'panel-expand' : 'panel-collapse'" />
</IconButton>
<ResizeHandle
v-if="sidePanelVisible && !isMobile"
class="preview-handle"
:storage-key="PREVIEW_WIDTH_KEY"
:default-width="previewDefaultWidth"
:min="PREVIEW_MIN"
@ -875,6 +898,11 @@ function openPr(url: string): void {
/>
</aside>
<!-- Internal-build tag pinned to the app's bottom-right corner, above
whatever pane happens to be there. Purely informational: pointer
events pass through so it never blocks clicks. -->
<InternalBuildBanner class="internal-build-fab" />
<!-- Model Picker overlay -->
<ModelPicker
v-if="showModelPicker"
@ -898,6 +926,7 @@ function openPr(url: string): void {
:account-model="client.defaultModel.value"
:notify="client.notifyOnComplete.value"
:notify-question="client.notifyOnQuestion.value"
:notify-approval="client.notifyOnApproval.value"
:notify-permission="client.notifyPermission.value"
:sound="client.soundOnComplete.value"
:conversation-toc="client.conversationToc.value"
@ -910,6 +939,7 @@ function openPr(url: string): void {
@set-ui-font-size="client.setUiFontSize($event)"
@set-notify="client.setNotifyOnComplete($event)"
@set-notify-question="client.setNotifyOnQuestion($event)"
@set-notify-approval="client.setNotifyOnApproval($event)"
@set-sound="client.setSoundOnComplete($event)"
@set-conversation-toc="client.setConversationToc($event)"
@update-config="handleUpdateConfig($event)"
@ -1099,18 +1129,20 @@ function openPr(url: string): void {
color: var(--dim);
}
.app {
--side-w: 248px;
--preview-w: 460px;
flex: 1;
min-height: 0;
position: relative;
display: grid;
/* sidebar (rail + resizable session column) | 0-width handle | conversation.
The 4px ResizeHandle overflows its zero-width track via negative margins so
the whole strip is grabbable without consuming layout space. */
/* The right-panel track is PERMANENT (auto = follows the aside's width, 0
when closed) opening animates the aside's width, so the conversation
column is squeezed over smoothly instead of snapping to a new template. */
grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
/* sidebar | 0-width handle | conversation | 0-width handle | right panel.
The 4px ResizeHandles overflow their zero-width tracks via negative margins
so the whole strip is grabbable without consuming layout space. */
/* Both side tracks are PERMANENT (auto = follows the aside's width, 0 when
closed/collapsed) opening or collapsing animates the aside's width, so
the conversation column is squeezed over smoothly instead of snapping to a
new template. Every column is pinned explicitly (grid-column 15) so a
display:none handle can't shift auto-placement. */
grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;
background: var(--bg);
color: var(--color-text);
overflow: hidden;
@ -1124,20 +1156,50 @@ function openPr(url: string): void {
min-width: 0;
}
/* Collapsed sidebar rail: keeps a slim, dedicated grid track so the expand
button never overlaps the conversation header or squeezes the main pane. */
.sidebar-rail {
grid-column: 1;
display: flex;
justify-content: center;
padding-top: 8px;
background: var(--panel);
border-right: 1px solid var(--line);
/* Pin every desktop grid child to its track so auto-placement can never
reshuffle columns when a handle is display:none (v-show/v-if). */
.app > .side { grid-column: 1; }
.side-handle { grid-column: 2; }
.app:not(.mobile) > .con { grid-column: 3; }
.preview-handle { grid-column: 4; }
/* Sidebar toggle floating button pinned to the top-left corner. On macOS
desktop it is resident (rendered in both states beside the traffic lights);
on Windows/web it only appears while the sidebar is collapsed (the collapse
button lives inside the sidebar header). While collapsed the conversation
header pads left so its content clears the button (global block below). */
.sidebar-toggle-btn {
position: absolute;
/* Vertically centered in the 48px conversation header. */
top: 11px;
left: 16px;
z-index: var(--z-sticky);
/* Fade in on appearance (Windows/web: only rendered while collapsed, so
this plays as the sidebar finishes sliding away). macOS disables it. */
animation: sidebar-toggle-btn-in 0.18s var(--ease-out) 0.12s backwards;
/* Floats over the macOS-desktop window-drag header; keep it clickable. */
-webkit-app-region: no-drag;
}
/* The collapsed rail occupies track 1; keep the main pane pinned to the
conversation track even though the sidebar/handle are display:none. */
.app.sidebar-collapsed > .con {
grid-column: 3;
/* macOS desktop (hidden title bar): resident beside the floating traffic
lights (green light's right edge 68px; 72 keeps a gap that matches the
lights' own 8px rhythm); no entrance animation since it never appears. */
.app.macos-desktop .sidebar-toggle-btn {
left: 72px;
animation: none;
}
@keyframes sidebar-toggle-btn-in {
from { opacity: 0; }
}
/* Internal-build tag pinned to the app's bottom-right corner (desktop app
only the component renders nothing elsewhere). Informational: never
intercepts pointer input. */
.internal-build-fab {
position: absolute;
right: var(--space-3);
bottom: var(--space-3);
z-index: var(--z-sticky);
pointer-events: none;
}
/* Mobile single-column shell: slim top bar (auto) over the full-width
@ -1206,4 +1268,19 @@ function openPr(url: string): void {
one continuous line across the layout. */
--panel-head-h: 48px;
}
/* Sidebar collapsed (desktop): the conversation header pads left so its
content clears the floating sidebar toggle (.sidebar-toggle-btn) and the
macOS traffic lights on desktop builds. Animated in step with the sidebar
width transition. Cross-component rule (ChatHeader renders the header), so
it lives in this global block. */
.app:not(.mobile) .chat-header {
transition: padding-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.app.sidebar-collapsed .chat-header {
padding-left: 52px;
}
.app.sidebar-collapsed.macos-desktop .chat-header {
padding-left: 108px;
}
</style>

View file

@ -5,8 +5,8 @@ import { isDesktop } from '../lib/desktopFlag';
const { t } = useI18n();
// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders an inline
// tag meant to sit next to the "Kimi Code" brand in the sidebar header.
// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders a small
// tag pinned to the app's bottom-right corner (positioned by App.vue).
const show = isDesktop;
</script>

View file

@ -254,7 +254,7 @@ defineExpose({ closeMenu });
:label="t('sidebar.options')"
@click.stop="toggleMenu($event)"
>
<Icon name="dots-horizontal" size="sm" />
<Icon name="dots-horizontal" />
</IconButton>
</span>
</div>
@ -287,22 +287,24 @@ defineExpose({ closeMenu });
.se {
/* --sb-* vars come from .side in Sidebar.vue: the title starts at
--sb-pad-x + --sb-gutter + --sb-gap, exactly under the workspace name.
The row is an inset pill: a 6px horizontal margin + 10px padding lands the
leading icon at --sb-pad-x (16px), aligned with the workspace header. */
The row is an inset pill: the .sessions container's --sb-inset padding +
the row's own padding land the leading slot at --sb-pad-x, aligned with
the workspace header. */
display: block;
margin: 0;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
padding: 8px var(--space-2);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
color: var(--color-text);
cursor: pointer;
position: relative;
}
.se:hover { background: var(--color-surface-sunken); color: var(--color-text); }
.se:hover { background: var(--sb-hover, var(--color-surface-sunken)); color: var(--color-text); }
/* Selected: neutral fill (NOT accent-tinted selection reads as "where I
am", the accent stays reserved for actions and status). */
.se.on {
background: var(--color-accent-soft);
color: var(--color-accent-hover);
box-shadow: inset 0 0 0 1px var(--color-accent-bd);
background: var(--color-selected);
color: var(--color-text);
}
.row {
@ -310,9 +312,9 @@ defineExpose({ closeMenu });
align-items: center;
gap: var(--sb-gap, 6px);
min-width: 0;
/* Floor the row at the hover-kebab height (IconButton sm = 26px) so swapping
the timestamp for the kebab on hover doesn't grow the row. */
min-height: 26px;
/* Row height is font-driven: title line-height (13×1.2516px) + 2×5px
.se padding 26px. The hover kebab is absolutely positioned (see .act)
so it never contributes to row height and can't cause hover jitter. */
}
.left {
@ -341,8 +343,9 @@ defineExpose({ closeMenu });
.t {
color: inherit;
font-size: var(--ui-font-size);
font-weight: var(--weight-regular);
font-size: var(--ui-font-size-sm);
font-weight: 450;
line-height: var(--leading-tight);
flex: 1;
min-width: 0;
overflow: hidden;
@ -353,29 +356,39 @@ defineExpose({ closeMenu });
.ts {
color: var(--color-text-faint);
font-size: var(--text-xs);
font-family: var(--font-mono);
font-family: var(--font-ui);
font-weight: 475;
line-height: var(--leading-tight);
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Trailing action slot: time and kebab share one grid cell (grid-area:1/1).
Both stay in the layout and swap via `visibility` (never display:none), so
the slot width = max(time width, IconButton sm 26px) is identical in hover
and rest the badges and title don't reflow, eliminating hover jitter.
`.act .kebab` out-specificities IconButton's own display so the hidden
default wins. */
/* Trailing action slot: the relative time (in flow) sets the slot size; the
kebab is absolutely positioned over it and swapped via `visibility`, so it
contributes neither height (the row stays font-driven) nor width changes
(min-width reserves the kebab's footprint, the title doesn't reflow). */
.act {
display: inline-grid;
position: relative;
flex: none;
display: inline-flex;
align-items: center;
justify-items: center;
justify-content: flex-end;
/* Reserve the kebab's width so the trailing slot (and thus the title) never
shifts between the time and the kebab, even for short times like "2m". */
min-width: 26px;
}
.act .kebab {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
visibility: hidden;
}
.act .ts,
.act .kebab { grid-area: 1 / 1; }
.act .kebab { visibility: hidden; }
.se:hover .act .kebab,
.act:has(.kebab.open) .kebab { visibility: visible; }
.se:hover .act .ts,
.act:has(.kebab.open) .ts { visibility: hidden; }
.kebab.open { color: var(--color-text); background: var(--color-surface-sunken); }
.kebab.open { color: var(--color-text); background: var(--sb-hover, var(--color-surface-sunken)); }
/* Fixed + anchored to the button via inline style (see positionMenu); the menu
is teleported to <body> so the collapsing list's `overflow: hidden` can't clip it. */
@ -409,15 +422,10 @@ defineExpose({ closeMenu });
.sessions .se {
margin: 0;
border-radius: var(--radius-md);
/* Trim the row padding by the inset margin so the title still starts at the
same x as the workspace name (whose header has no inset). */
padding: var(--space-1) calc(var(--sb-pad-x, 12px) - var(--space-2));
}
.sessions .se:hover { background: var(--panel2); }
.sessions .se.on {
background: var(--color-accent-soft);
box-shadow: inset 0 0 0 1px var(--color-accent-bd);
border-radius: var(--radius-sm);
/* Trim the row padding by the container inset so the title still starts at
the same x as the workspace name (whose header has no inset). */
padding: 8px calc(var(--sb-pad-x, 20px) - var(--sb-inset, 12px));
}
.sessions .se .rename-input { border-radius: var(--radius-sm); font-family: var(--sans); }
.sessions .se .kebab { border-radius: var(--radius-sm); }

View file

@ -9,18 +9,16 @@ import { serverEndpointLabel } from '../api/config';
import { copyTextToClipboard } from '../lib/clipboard';
import {
loadCollapsedWorkspaces,
loadShowWorkspacePaths,
saveCollapsedWorkspaces,
saveShowWorkspacePaths,
} from '../lib/storage';
import { moveInOrder, type DropPosition, type WorkspaceSortMode } from '../lib/workspaceOrder';
import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types';
import SearchSessionsDialog from './dialogs/SearchSessionsDialog.vue';
import WorkspaceGroup from './WorkspaceGroup.vue';
import InternalBuildBanner from './InternalBuildBanner.vue';
import { isMacosDesktop } from '../lib/desktopFlag';
import IconButton from './ui/IconButton.vue';
import Icon from './ui/Icon.vue';
import Kbd from './ui/Kbd.vue';
import Menu from './ui/Menu.vue';
import MenuItem from './ui/MenuItem.vue';
import { useConfirmDialog } from '../composables/useConfirmDialog';
@ -49,6 +47,12 @@ const props = withDefaults(
unreadBySession?: Record<string, boolean>;
/** Width (px) of the session column, driven by the App resize handle. */
colWidth?: number;
/** True when the sidebar is collapsed: the container animates to width 0
* (content keeps `colWidth` and is clipped), then hides itself. */
collapsed?: boolean;
/** True while the resize handle is dragged disables the width transition
* so the sidebar follows the pointer 1:1. */
dragging?: boolean;
}>(),
{
activeWorkspace: null,
@ -57,6 +61,8 @@ const props = withDefaults(
pendingBySession: () => ({}),
unreadBySession: () => ({}),
colWidth: 220,
collapsed: false,
dragging: false,
},
);
@ -83,7 +89,7 @@ const emit = defineEmits<{
// Session search dialog (Spotlight-style; filters title + last prompt)
// ---------------------------------------------------------------------------
const showSearch = ref(false);
const sessionSearchShortcut = isAppleShortcutPlatform() ? '⌘K' : 'Ctrl K';
const sessionSearchKeys = isAppleShortcutPlatform() ? ['⌘', 'K'] : ['Ctrl', 'K'];
function openSearch(): void {
// Sessions are loaded per-workspace (first page only); lazily drain the rest
@ -110,7 +116,7 @@ function isAppleShortcutPlatform(): boolean {
return userAgentData?.platform === 'macOS' || userAgentData?.platform === 'iOS';
}
// Scroll-linked header seam: the .btn-wrap bottom border/shadow only appears
// Scroll-linked header seam: the .search-wrap bottom border/shadow only appears
// once the session list has actually scrolled, so an unscrolled list shows no
// abrupt boundary.
const sessionsScrolled = ref(false);
@ -185,18 +191,6 @@ function onLoadMore(id: string): void {
emit('loadMoreSessions', id);
}
// ---------------------------------------------------------------------------
// Workspace path display (toggle in the Workspaces section header)
// ---------------------------------------------------------------------------
// Off by default so the list stays compact; turning it on reveals every
// workspace's root path as a stable subtitle (no hover-induced layout shift).
const showWorkspacePaths = ref<boolean>(loadShowWorkspacePaths());
function toggleShowWorkspacePaths(): void {
showWorkspacePaths.value = !showWorkspacePaths.value;
saveShowWorkspacePaths(showWorkspacePaths.value);
}
// ---------------------------------------------------------------------------
// Workspace drag-to-reorder
// ---------------------------------------------------------------------------
@ -486,11 +480,6 @@ function chooseSortMode(mode: WorkspaceSortMode): void {
closeSectionMenu();
}
function toggleShowWorkspacePathsFromMenu(): void {
toggleShowWorkspacePaths();
closeSectionMenu();
}
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onGhMenuDocClick, true);
document.removeEventListener('mousedown', onWsMenuDocClick);
@ -559,51 +548,49 @@ onBeforeUnmount(() => {
</script>
<template>
<aside class="side" :class="{ 'macos-desktop': isMacosDesktop }">
<aside
class="side"
:class="{ 'macos-desktop': isMacosDesktop, collapsed, 'no-anim': dragging }"
:style="{ width: collapsed ? '0px' : colWidth + 'px' }"
>
<!-- Session column -->
<div class="col" :style="{ width: colWidth + 'px' }">
<!-- Header: logo + settings (no hard border flows into workspace list) -->
<!-- Header: brand + collapse. The collapse button lives INSIDE the header
on non-mac platforms (right-aligned); on macOS desktop the brand is
hidden (traffic lights own that corner) and the header is just a
window-drag strip there the toggle is App.vue's resident floating
button beside the traffic lights. -->
<div class="ch">
<div class="ch-brand">
<svg ref="logoRef" class="ch-logo" :class="{ 'is-dev': isDev }" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @click="onLogoClick" @pointerdown="onLogoPointerDown" @pointerup="onLogoPointerUp" @pointercancel="onLogoPointerUp">
<defs>
<mask id="kimiEyes" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="22" fill="#fff" />
<g class="ch-eyes" fill="#000">
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
</g>
</mask>
</defs>
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#kimiEyes)" />
</svg>
<span class="ch-name">Kimi Code<span v-if="isDev" class="ch-endpoint"> · {{ endpoint }}</span></span>
<InternalBuildBanner />
<template v-if="!isMacosDesktop">
<svg ref="logoRef" class="ch-logo" :class="{ 'is-dev': isDev }" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @click="onLogoClick" @pointerdown="onLogoPointerDown" @pointerup="onLogoPointerUp" @pointercancel="onLogoPointerUp">
<defs>
<mask id="kimiEyes" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="22" fill="#fff" />
<g class="ch-eyes" fill="#000">
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
</g>
</mask>
</defs>
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#kimiEyes)" />
</svg>
<span class="ch-name">Kimi Code<span v-if="isDev" class="ch-endpoint"> · {{ endpoint }}</span></span>
</template>
</div>
<IconButton
v-if="!isMacosDesktop"
class="ch-collapse"
size="sm"
:label="t('sidebar.collapseSidebar')"
@click.stop="emit('collapse')"
>
<Icon name="panel-collapse" />
</IconButton>
<IconButton
size="sm"
:label="t('settings.title')"
@click.stop="emit('openSettings')"
>
<Icon name="settings" />
</IconButton>
</div>
<!-- Session search opens the Spotlight-style search dialog -->
<button class="search" type="button" @click="openSearch">
<Icon class="search-icon" name="search" />
<span class="search-input">{{ t('sidebar.searchShortcut', { shortcut: sessionSearchShortcut }) }}</span>
</button>
<!-- New chat + new workspace buttons -->
<div class="btn-wrap" :class="{ 'btn-wrap--scrolled': sessionsScrolled }">
<div class="btn-wrap">
<button class="btn-new-chat" type="button" @click.stop="emit('create')">
<Icon name="chat-new" />
<span>{{ t('sidebar.newChat') }}</span>
@ -618,6 +605,16 @@ onBeforeUnmount(() => {
</IconButton>
</div>
<!-- Session search opens the Spotlight-style search dialog. Last fixed
row above the list, so it carries the scroll-linked seam. -->
<div class="search-wrap" :class="{ 'search-wrap--scrolled': sessionsScrolled }">
<button class="search" type="button" @click="openSearch">
<Icon class="search-icon" name="search" />
<span class="search-input">{{ t('sidebar.search') }}</span>
<Kbd :keys="sessionSearchKeys" />
</button>
</div>
<!-- Session list grouped by workspace -->
<div class="sessions" @scroll="onSessionsScroll">
<!-- Empty state only when no workspace is registered at all; empty
@ -675,7 +672,6 @@ onBeforeUnmount(() => {
:dragging="draggingWsId === g.workspace.id"
:is-collapsed="isCollapsed"
:is-expanded="isExpanded"
:show-path="showWorkspacePaths"
@group-click="handleGhClick"
@group-contextmenu="openGhMenu"
@toggle-ws-menu="toggleWsMenu"
@ -695,6 +691,14 @@ onBeforeUnmount(() => {
</div>
</template>
</div>
<!-- Footer: settings entry pinned under the session list -->
<div class="side-footer">
<button class="btn-settings" type="button" @click.stop="emit('openSettings')">
<Icon name="settings" />
<span>{{ t('settings.title') }}</span>
</button>
</div>
</div>
<!-- Workspace right-click menu (position:fixed) -->
@ -745,13 +749,6 @@ onBeforeUnmount(() => {
</span>
{{ t('sidebar.sortRecent') }}
</MenuItem>
<MenuItem separator />
<MenuItem @click="toggleShowWorkspacePathsFromMenu()">
<span class="section-menu-check">
<Icon v-if="showWorkspacePaths" name="check" size="sm" />
</span>
{{ t('sidebar.showWorkspacePaths') }}
</MenuItem>
</Menu>
<!-- Session search dialog (Cmd/Ctrl+K) -->
<SearchSessionsDialog
@ -772,24 +769,48 @@ onBeforeUnmount(() => {
<style scoped>
.side {
border-right: 1px solid var(--line);
background: var(--panel);
/* Sidebar sits on its own surface (--color-sidebar-bg, one step off --bg);
the 1px hairline on .col still separates it from the conversation pane. */
background: var(--color-sidebar-bg);
display: flex;
flex-direction: row;
/* Anchor content to the right edge: while the container width animates to 0
the fixed-width column slides out to the left and is clipped, instead of
reflowing. Mirrors the right-side preview panel (App.vue .global-preview). */
justify-content: flex-end;
overflow: hidden;
min-width: 0;
height: 100%;
/* Alignment contract, inherited by SessionRow and the de-terminalization
rules in style.css: text in the workspace header, the path line and session
rows all starts at --sb-pad-x + --sb-gutter + --sb-gap from the sidebar edge. */
--sb-pad-x: var(--space-4); /* row horizontal padding */
--sb-gutter: 20px; /* leading icon slot (14px folder icon + 6px margin) */
transition:
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
visibility 0.28s;
/* Alignment contract, inherited by SessionRow and WorkspaceGroup:
- row boxes (hover/selected pills) sit --sb-inset from the sidebar edges;
- text/icons start at --sb-pad-x = --sb-inset + 8px row padding;
- row titles start at --sb-pad-x + --sb-gutter + --sb-gap. */
--sb-inset: var(--space-3); /* row box inset from the sidebar edge */
--sb-pad-x: var(--space-5); /* content start x (inset + row padding) */
--sb-gutter: 16px; /* leading icon slot (matches the 16px folder icon, so the session title aligns under the workspace name) */
--sb-gap: var(--space-2); /* gap between the icon slot and the text */
/* Sidebar stays one step above compact UI chrome, but still follows the
user-controlled font-size preference. */
--ui-font-size: var(--sidebar-ui-font-size);
/* Row hover wash global --color-hover (lighter than the selected fill;
both translucent, so they sit on any surface). */
--sb-hover: var(--color-hover);
}
/* While dragging the resize handle, follow the pointer 1:1 (same pattern as
.global-preview.no-anim in App.vue). */
.side.no-anim {
transition: none;
}
/* Fully collapsed: width 0 (animated), then drop out of hit-testing / tab
order once the transition ends (visibility interpolates to hidden at the
end when collapsing, and back to visible immediately when expanding). */
.side.collapsed {
visibility: hidden;
}
/* Session column. Width is set inline from the App resize handle. */
/* Session column. Width is set inline from the App resize handle; it stays
fixed while the collapsing container clips it. Carries the sidebar's right
hairline so the border is clipped away together with the content. */
.col {
flex: none;
min-width: 0;
@ -797,35 +818,38 @@ onBeforeUnmount(() => {
flex-direction: column;
min-height: 0;
width: 100%;
box-sizing: border-box;
border-right: 1px solid var(--line);
container-type: inline-size;
container-name: sidebar-col;
}
/* Header: logo + settings (no border — flows into the workspace list). */
/* Header: brand strip (no border flows into the workspace list). On non-mac
platforms the brand sits on the left and the collapse button on the right
(justify-content: space-between); on macOS desktop the brand is hidden and
the header is a window-drag strip (see below). min-height keeps the 26px
control row (50px total with padding) so the list below starts at a stable
y. */
.ch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: var(--space-3) var(--space-3) var(--space-2);
padding: var(--space-3);
min-height: calc(26px + 2 * var(--space-3));
width: 100%;
box-sizing: border-box;
}
/* macOS desktop: the window uses a hidden title bar, so the traffic lights float
over the top-left of the sidebar. Push the header content right to clear them,
and turn the whole header into the window-drag region matching the chat
header. The action buttons and the logo opt out with no-drag so they stay
clickable: this is the same no-drag-inside-drag pattern ChatHeader.vue relies
on (the previous "drag only the brand area" approach still captured the
sibling buttons, because Electron treats a flex-grown drag item's hit area as
covering the whole flex line). */
/* macOS desktop: the window uses a hidden title bar, so the traffic lights
float over the top-left of the sidebar and the resident toggle sits beside
them. The header renders no content here (brand hidden) it is purely a
window-drag strip. */
.side.macos-desktop .ch {
padding-left: 80px;
-webkit-app-region: drag;
}
.side.macos-desktop .ch button,
.side.macos-desktop .ch-logo {
-webkit-app-region: no-drag;
.side.macos-desktop .ch-brand {
display: none;
}
.ch-logo {
height: 22px;
@ -880,22 +904,14 @@ onBeforeUnmount(() => {
.ch-name { display: none; }
}
/* Action buttons */
.btn-wrap {
/* Action buttons first row of the actions group (New chat + search): rows
inside the group stack flush (0 gap, same rhythm as the session list rows);
the group's bottom gap lives on .search-wrap. */
.btn-wrap {
display: flex;
align-items: center;
gap: 8px;
padding: 0 var(--space-2) var(--space-2);
position: relative;
z-index: 1;
background: var(--panel);
border-bottom: 1px solid transparent;
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out);
}
.btn-wrap--scrolled {
border-bottom-color: var(--line);
box-shadow: var(--shadow-sm);
padding: 0 var(--sb-inset);
}
.btn-new-chat {
display: flex;
@ -903,45 +919,61 @@ onBeforeUnmount(() => {
gap: 12px;
flex: 1;
min-width: 0;
min-height: 26px;
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--ui-font-size);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
cursor: pointer;
text-align: left;
}
.btn-new-chat:hover { background: var(--color-surface-sunken); }
.btn-new-chat:hover { background: var(--sb-hover); }
.btn-new-chat:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.btn-new-chat svg { flex: none; width: 16px; height: 16px; }
.btn-new-chat svg { flex: none; }
.btn-new-chat span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Session search */
/* Session search the wrapper is the last fixed row above the list and
carries the scroll-linked seam: its bottom border/shadow only appear once
the session list has actually scrolled, so an unscrolled list shows no
abrupt boundary. */
.search-wrap {
padding: 0 var(--sb-inset);
position: relative;
z-index: 1;
background: var(--color-sidebar-bg);
border-bottom: 1px solid transparent;
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out);
}
.search-wrap--scrolled {
border-bottom-color: var(--line);
box-shadow: var(--shadow-sm);
}
.search {
display: flex;
align-items: center;
gap: 12px;
min-height: 26px;
margin: 0 var(--space-2) var(--space-2);
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
width: 100%;
margin: 0;
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.search:hover { background: var(--color-surface-sunken); }
.search:hover { background: var(--sb-hover); }
.search:focus-visible {
background: var(--color-surface-sunken);
background: var(--sb-hover);
color: var(--color-text);
outline: 2px solid var(--color-accent-bd);
outline-offset: -2px;
@ -953,29 +985,68 @@ onBeforeUnmount(() => {
flex: 1;
min-width: 0;
color: var(--color-text);
font-family: var(--mono);
font-size: var(--ui-font-size);
font-family: var(--font-ui);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Sessions */
/* Sessions owns the vertical padding around the list (the 12px gap to the
search row above and the bottom breathing room). Scrolled content passes
through the top padding and clips at the .search-wrap seam. Scrollbar: the
4px ::-webkit-scrollbar below; standard scrollbar-width would kill it on
Chromium (see the global scrollbar block in style.css). */
.sessions {
flex: 1;
overflow-y: auto;
padding: 0 var(--space-2) var(--space-2);
padding: var(--space-3) var(--sb-inset);
min-height: 0;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
}
.sessions::-webkit-scrollbar { width: 4px; }
.sessions::-webkit-scrollbar-track { background: transparent; }
.sessions::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: var(--radius-xs);
/* Neutral, text-derived translucency adapts to both schemes and sits
quietly on the sidebar surface (no accent tint on hover). */
background: color-mix(in srgb, var(--color-text) 12%, transparent);
border-radius: var(--radius-full);
}
.sessions::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--color-text) 25%, transparent); }
/* Footer settings entry pinned under the session list. Same list-style
control family as search / New chat (full-width, left-aligned, hover
sunken not a Button). */
.side-footer {
flex: none;
padding: var(--space-2) var(--sb-inset);
border-top: 1px solid var(--line);
}
.btn-settings {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 0;
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
cursor: pointer;
text-align: left;
}
.btn-settings:hover { background: var(--sb-hover); }
.btn-settings:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.btn-settings svg { flex: none; }
.btn-settings span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sessions::-webkit-scrollbar-thumb:hover { background: var(--color-accent-bd); }
/* Section label heads the workspace list below the action buttons. Aligns
with the rows' leading inset (--sb-pad-x) so it reads as the list's title. */
@ -985,9 +1056,9 @@ onBeforeUnmount(() => {
justify-content: space-between;
gap: 8px;
padding: 0 var(--space-3) var(--space-1) var(--space-2);
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: .08em;
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-regular);
text-transform: uppercase;
color: var(--faint);
user-select: none;

View file

@ -27,8 +27,6 @@ const props = defineProps<{
wsMenuOpenId: string | null;
/** True while this group is the active drag source (drag-to-reorder). */
dragging: boolean;
/** When true, render the workspace root path as a stable subtitle line. */
showPath: boolean;
isCollapsed: (id: string) => boolean;
/** When true, render all loaded sessions; otherwise only the first page
* (`group.initialCount`). Drives the in-group show-more / show-less toggle. */
@ -109,7 +107,7 @@ function onHeaderDragStart(event: DragEvent): void {
<div class="group" :class="{ dragging }">
<div
class="gh"
:class="{ on: group.workspace.id === activeWorkspaceId, collapsed: isCollapsed(group.workspace.id), 'show-path': showPath }"
:class="{ on: group.workspace.id === activeWorkspaceId, collapsed: isCollapsed(group.workspace.id) }"
draggable="true"
@click.stop="emit('groupClick', group.workspace.id, $event)"
@contextmenu="emit('groupContextmenu', group.workspace, $event)"
@ -118,14 +116,13 @@ function onHeaderDragStart(event: DragEvent): void {
>
<div class="gh-top">
<!-- Folder icon -->
<Icon v-if="isCollapsed(group.workspace.id)" class="gh-folder" name="folder-closed" size="sm" />
<Icon v-else class="gh-folder" name="folder" size="sm" />
<Icon v-if="isCollapsed(group.workspace.id)" class="gh-folder" name="folder-closed" />
<Icon v-else class="gh-folder" name="folder" />
<!-- Workspace name -->
<span
v-if="renamingId !== group.workspace.id"
class="gh-name"
>{{ group.workspace.name }}</span>
<!-- Workspace name hover reveals the full root path -->
<Tooltip v-if="renamingId !== group.workspace.id" :text="group.workspace.root">
<span class="gh-name">{{ group.workspace.name }}</span>
</Tooltip>
<input
v-else
:ref="setRenameInputRef"
@ -138,31 +135,36 @@ function onHeaderDragStart(event: DragEvent): void {
@click.stop
/>
<IconButton
class="gh-more"
<!-- Hover actions float over the row's right edge (no reserved
layout space, the name gets the full row width when idle). Hidden
while renaming so the floating buttons can't cover the input. -->
<div
v-if="renamingId !== group.workspace.id"
class="gh-actions"
:class="{ open: wsMenuOpenId === group.workspace.id }"
size="sm"
:label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="wsMenuOpenId === group.workspace.id"
@click.stop="emit('toggleWsMenu', group.workspace, $event)"
>
<Icon name="dots-horizontal" size="sm" />
</IconButton>
<IconButton
class="gh-more"
:class="{ open: wsMenuOpenId === group.workspace.id }"
size="sm"
:label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="wsMenuOpenId === group.workspace.id"
@click.stop="emit('toggleWsMenu', group.workspace, $event)"
>
<Icon name="dots-horizontal" />
</IconButton>
<IconButton
class="gh-add"
size="sm"
:label="t('workspace.newInGroup')"
@click.stop="emit('createInWorkspace', group.workspace.id)"
>
<Icon name="chat-new" />
</IconButton>
<IconButton
class="gh-add"
size="sm"
:label="t('workspace.newInGroup')"
@click.stop="emit('createInWorkspace', group.workspace.id)"
>
<Icon name="chat-new" />
</IconButton>
</div>
</div>
<Tooltip :text="group.workspace.root">
<div class="gh-path">{{ group.workspace.shortPath || group.workspace.root }}</div>
</Tooltip>
</div>
<div
class="group-sessions"
@ -212,8 +214,8 @@ function onHeaderDragStart(event: DragEvent): void {
<style scoped>
/* Workspace group. The --sb-* custom properties are inherited from .side in
Sidebar.vue, so they don't need to be redeclared here. */
.group { padding-bottom: var(--space-2); }
Sidebar.vue, so they don't need to be redeclared here. Groups stack flush
no bottom gap. */
.group.dragging { opacity: 0.45; }
/* Session list: collapses/expands via a height transition. `interpolate-size:
@ -230,15 +232,14 @@ function onHeaderDragStart(event: DragEvent): void {
}
/* Workspace header an inset rounded row that mirrors the session-row inset
(6px margin + 10px padding), so the folder icon lands at --sb-pad-x and the
name lines up with the session titles below. Hover washes the whole header
(name row + path) in the sunken surface. */
(container --sb-inset + row padding), so the folder icon lands at --sb-pad-x
and the name lines up with the session titles below. Hover washes the whole
header in the row hover fill. */
.gh {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: var(--space-1) var(--space-2);
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--text-xs);
@ -249,24 +250,28 @@ function onHeaderDragStart(event: DragEvent): void {
cursor: grab;
}
.gh:active { cursor: grabbing; }
.gh:hover { background: var(--color-surface-sunken); }
.gh:hover { background: var(--sb-hover, var(--color-surface-sunken)); }
.gh-top {
position: relative;
display: flex;
align-items: center;
gap: var(--sb-gap);
/* Header height is font-driven: name line-height (13×1.2516px) + 2×5px
.gh padding 26px. The floating .gh-actions never contribute to height. */
}
.gh-folder {
flex: none;
color: var(--color-text-muted);
/* 14px icon + margin fills the --sb-gutter icon slot */
margin-right: calc(var(--sb-gutter) - 14px);
}
/* Group title quiet by design: regular weight (no bold), muted color (one
step lighter than the session titles), so group heads read as grouping
labels rather than list content. */
.gh-name {
font-size: var(--ui-font-size-lg);
font-weight: var(--weight-medium);
color: var(--color-text);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
color: var(--color-text-muted);
flex: 1;
min-width: 0;
overflow: hidden;
@ -274,77 +279,86 @@ function onHeaderDragStart(event: DragEvent): void {
white-space: nowrap;
cursor: pointer;
}
.gh-path {
color: var(--color-text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: calc(var(--sb-gutter) + var(--sb-gap));
font-size: var(--ui-font-size-xs);
max-height: 0;
opacity: 0;
transition: max-height var(--duration-base) var(--ease-out),
opacity var(--duration-base) var(--ease-out);
}
/* Path subtitle revealed only when the section-header "show paths" toggle is
on (`.show-path`) or the group is collapsed. Driven by explicit toggle state
rather than hover/focus, so the header height never shifts under the pointer
(a11y: stable layout) and the path stays reachable for touch/keyboard users. */
.gh.show-path .gh-path,
.gh.collapsed .gh-path {
max-height: 1.4em;
opacity: 1;
}
/* More + add buttons — hidden until hover (or while the more menu is open /
focused). `.gh .gh-more` / `.gh .gh-add` out-specificity IconButton's display
so the hidden default wins. */
.gh .gh-more,
.gh .gh-add {
/* More + add buttons float over the row's right edge instead of reserving
layout space, so the name can use the full row width when idle (no
truncation caused by invisible buttons). Revealed on hover / keyboard focus
/ while the more menu is open; the backing stacks the row hover wash on the
sidebar surface so the overlapped title tail doesn't bleed through. */
.gh-actions {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: var(--space-1);
padding-left: var(--space-1);
border-radius: var(--radius-sm);
isolation: isolate;
/* Opaque sidebar surface hides the overlapped name tail. The ::after
hover wash sits above this (still behind the buttons) so the layer reads
seamless with the row. */
background: var(--color-sidebar-bg);
opacity: 0;
pointer-events: none;
}
.gh:hover .gh-more,
.gh:hover .gh-add,
.gh:focus-within .gh-more,
.gh:focus-within .gh-add,
.gh-more.open,
.gh-more:focus-visible,
.gh-add:focus-visible {
/* Row hover wash only while the row is actually hovered. Painted above the
element background (z-index 0) but below the buttons (z-index 1). */
.gh-actions::after {
content: '';
position: absolute;
inset: 0;
z-index: 0;
border-radius: var(--radius-sm);
background: transparent;
}
.gh:hover .gh-actions::after {
background: var(--sb-hover, var(--color-surface-sunken));
}
.gh-actions > * {
position: relative;
z-index: 1;
}
.gh:hover .gh-actions,
.gh:focus-within .gh-actions,
.gh-actions.open {
opacity: 1;
pointer-events: auto;
}
.gh-more.open { color: var(--color-text); background: var(--color-line); }
.group-empty {
padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) + var(--sb-gutter) + var(--sb-gap));
/* Left padding lands the text at the same x as session titles / the
show-more label: (pad-x inset) row padding + gutter + gap. */
padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) - var(--sb-inset) + var(--sb-gutter) + var(--sb-gap));
font-size: var(--text-xs);
color: var(--color-text-faint);
font-family: var(--font-mono);
font-family: var(--font-ui);
}
/* Show-more / show-less a session-row-shaped compact list control (§07). The
empty lead slot mirrors a session row's status gutter, so the label text lands
at the exact same x as the session titles (--sb-pad-x + --sb-gutter + --sb-gap
from the sidebar edge). Hover washes the row in the sunken surface, matching
New chat / session rows; no text recolor. */
from the sidebar edge). Hover washes the row in the shared row hover fill,
matching New chat / session rows; no text recolor. */
.show-more {
display: flex;
align-items: center;
gap: var(--sb-gap);
width: 100%;
min-height: 26px;
margin: 0;
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
line-height: var(--leading-tight);
text-align: left;
cursor: pointer;
}
.show-more:hover { background: var(--color-surface-sunken); }
.show-more:hover { background: var(--sb-hover, var(--color-surface-sunken)); }
.show-more:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.show-more-lead { width: var(--sb-gutter); flex: none; }
.show-more-label {

View file

@ -10,6 +10,7 @@ import Badge from '../ui/Badge.vue';
import Button from '../ui/Button.vue';
import IconButton from '../ui/IconButton.vue';
import Icon from '../ui/Icon.vue';
import Kbd from '../ui/Kbd.vue';
import Tooltip from '../ui/Tooltip.vue';
const props = defineProps<{
@ -314,20 +315,20 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
:loading="pendingAction === `option:${opt.label}`"
:disabled="busy"
@click="approveOption(opt.label)"
>{{ opt.label }}<span class="k">[{{ i + 1 }}]</span></Button>
>{{ opt.label }}<Kbd class="k" :keys="[String(i + 1)]" /></Button>
</Tooltip>
</template>
<Button v-else class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approvePlan'" :disabled="busy" @click="approvePlan">{{ t('approval.approvePlan') }}<span class="k">[1]</span></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="revisePlan">{{ t('approval.revise') }}<span v-if="planReview.options.length === 0" class="k">[2]</span></Button>
<Button class="kbtn" size="sm" variant="danger-soft" :loading="pendingAction === 'rejectAndExit'" :disabled="busy" @click="rejectAndExitPlan">{{ t('approval.rejectAndExit') }}<span v-if="planReview.options.length === 0" class="k">[3]</span></Button>
<Button v-else class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approvePlan'" :disabled="busy" @click="approvePlan">{{ t('approval.approvePlan') }}<Kbd class="k" :keys="['1']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="revisePlan">{{ t('approval.revise') }}<Kbd v-if="planReview.options.length === 0" class="k" :keys="['2']" /></Button>
<Button class="kbtn" size="sm" variant="danger-soft" :loading="pendingAction === 'rejectAndExit'" :disabled="busy" @click="rejectAndExitPlan">{{ t('approval.rejectAndExit') }}<Kbd v-if="planReview.options.length === 0" class="k" :keys="['3']" /></Button>
</div>
<!-- default actions row -->
<div v-else class="abtn">
<Button class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approve'" :disabled="busy" @click="approve">{{ t('approval.approve') }}<span class="k">[1]</span></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'approveSession'" :disabled="busy" @click="approveSession">{{ t('approval.approveSession') }}<span class="k">[2]</span></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'reject'" :disabled="busy" @click="reject">{{ t('approval.reject') }}<span class="k">[3]</span></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="openFeedback">{{ t('approval.feedback') }}<span class="k">[4]</span></Button>
<Button class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approve'" :disabled="busy" @click="approve">{{ t('approval.approve') }}<Kbd class="k" :keys="['1']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'approveSession'" :disabled="busy" @click="approveSession">{{ t('approval.approveSession') }}<Kbd class="k" :keys="['2']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'reject'" :disabled="busy" @click="reject">{{ t('approval.reject') }}<Kbd class="k" :keys="['3']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="openFeedback">{{ t('approval.feedback') }}<Kbd class="k" :keys="['4']" /></Button>
</div>
</template>
</Card>
@ -554,7 +555,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
width: 100%;
}
.plan-actions { flex-wrap: wrap; }
.k { margin-left: var(--space-2); font: var(--text-xs) var(--font-mono); opacity: .7; }
.k { opacity: .75; }
/* =========================================================================
MOBILE (640px): the card spans the full chat column, inner previews scroll

View file

@ -20,6 +20,10 @@ import Pill from '../ui/Pill.vue';
const props = defineProps<{
sessionId?: string;
running?: boolean;
/** True while the empty-composer first prompt is being created + submitted.
* Covers the gap where draft-session creation already selected the new
* session (empty state dock) before the first prompt is submitted. */
starting?: boolean;
queued?: QueuedPromptView[];
searchFiles?: (q: string) => Promise<FileItem[]>;
uploadImage?: (file: Blob, name?: string) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
@ -251,10 +255,12 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
:plan-mode="planMode"
:swarm-mode="swarmMode"
:goal-mode="goalMode"
:goal="goal"
:activation-badges="activationBadges"
:models="models"
:starred-ids="starredIds"
:skills="skills"
:starting="starting"
@submit="emit('submit', $event)"
@steer="emit('steer', $event)"
@command="emit('command', $event)"

View file

@ -51,6 +51,25 @@ const behind = computed(() => props.behind ?? 0);
const adds = computed(() => props.gitDiffStats?.totalAdditions ?? 0);
const dels = computed(() => props.gitDiffStats?.totalDeletions ?? 0);
const hasLineStats = computed(() => adds.value > 0 || dels.value > 0);
const PR_STATE_LABEL_KEYS: Record<string, string> = {
open: 'header.prStatusOpen',
closed: 'header.prStatusClosed',
merged: 'header.prStatusMerged',
draft: 'header.prStatusDraft',
};
function normalizedPrState(state: string): string {
return state.trim().toLowerCase().replaceAll('_', '-');
}
function prStateClass(state: string): string {
const stateClass = normalizedPrState(state);
return PR_STATE_LABEL_KEYS[stateClass] ? `pr-${stateClass}` : 'pr-unknown';
}
function prStateLabel(state: string): string {
return t(PR_STATE_LABEL_KEYS[normalizedPrState(state)] ?? 'header.prStatusUnknown');
}
// ---------------------------------------------------------------------------
// More-menu (kebab dropdown)
@ -295,11 +314,11 @@ async function startArchive(): Promise<void> {
v-if="pr"
type="button"
class="ch-pill ch-pr"
:class="`pr-${pr.state}`"
:class="prStateClass(pr.state)"
@click="pr && emit('openPr', pr.url)"
>
<Icon name="git-pull-request" size="sm" />
<span>PR #{{ pr.number }} · {{ pr.state }}</span>
<span>PR #{{ pr.number }} · {{ prStateLabel(pr.state) }}</span>
</button>
</header>
@ -420,6 +439,7 @@ async function startArchive(): Promise<void> {
.ch-pr.pr-merged { color: var(--color-done); border-color: var(--color-done-bd); background: var(--color-done-soft); }
.ch-pr.pr-closed { color: var(--color-danger); border-color: var(--color-danger-bd); background: var(--color-danger-soft); }
.ch-pr.pr-draft { color: var(--color-text-muted); border-color: var(--color-line-strong); background: var(--color-surface-sunken); }
.ch-pr.pr-unknown { color: var(--color-text-muted); border-color: var(--color-line-strong); background: var(--color-surface-sunken); }
.ch-pr:hover { border-color: var(--color-line-strong); }
/* Fixed more-menu, anchored to the kebab trigger. Surface / items come from

View file

@ -1,5 +1,6 @@
<!-- apps/kimi-web/src/components/chat/Composer.vue -->
<script setup lang="ts">
import { measureNaturalWidth, prepareWithSegments } from '@chenglou/pretext';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import SlashMenu from './SlashMenu.vue';
@ -7,7 +8,7 @@ import MentionMenu from './MentionMenu.vue';
import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands';
import type { FileItem } from './MentionMenu.vue';
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
import type { AppModel, AppSkill, ThinkingLevel } from '../../api/types';
import type { AppGoal, AppModel, AppSkill, ThinkingLevel } from '../../api/types';
import {
coerceThinkingForModel,
commitLevel,
@ -22,6 +23,7 @@ import { useMentionMenu } from '../../composables/useMentionMenu';
import { useComposerDraft } from '../../composables/useComposerDraft';
import { useAttachmentUpload } from '../../composables/useAttachmentUpload';
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';
@ -33,6 +35,9 @@ import Tooltip from '../ui/Tooltip.vue';
const props = withDefaults(defineProps<{
running?: boolean;
/** True while the empty-composer first prompt is being created + submitted.
* Disables the textarea and swaps the send button for a spinner. */
starting?: boolean;
/** Active session id — scopes the persisted unsent draft (per session). */
sessionId?: string;
queued?: QueuedPromptView[];
@ -45,6 +50,7 @@ const props = withDefaults(defineProps<{
planMode?: boolean;
swarmMode?: boolean;
goalMode?: boolean;
goal?: AppGoal | null;
activationBadges?: ActivationBadges;
/** Available models for the quick-switch dropdown. */
models?: AppModel[];
@ -56,6 +62,7 @@ const props = withDefaults(defineProps<{
hideContext?: boolean;
}>(), {
running: false,
starting: false,
queued: () => [],
searchFiles: undefined,
uploadImage: undefined,
@ -65,11 +72,13 @@ const props = withDefaults(defineProps<{
});
const placeholder = computed(() =>
props.running
? t('composer.placeholderRunning')
: props.goalMode
? t('status.goalPlaceholder')
: t('composer.placeholder')
props.starting
? t('composer.starting')
: props.running
? t('composer.placeholderRunning')
: props.goalMode
? t('status.goalPlaceholder')
: t('composer.placeholder')
);
const emit = defineEmits<{
@ -94,7 +103,7 @@ const emit = defineEmits<{
selectModel: [modelId: string];
}>();
const { t } = useI18n();
const { t, locale } = useI18n();
// ---------------------------------------------------------------------------
// Textarea + per-session draft persistence see useComposerDraft.
@ -646,12 +655,20 @@ function setThinkingSegment(draft: string): void {
if (thinkingReadonly.value) return;
emit('setThinking', commitLevel(currentModel.value, draft));
}
function thinkingSegmentLabel(segment: string): string {
if (segment === 'on') return t('status.thinkingOn');
if (segment === 'off') return t('status.thinkingOff');
return effortLabel(segment);
}
// Plan toggle
const planOn = computed(() => props.planMode === true);
const swarmOn = computed(() => props.swarmMode === true);
const goalActive = computed(() => props.activationBadges?.goal !== null);
const goalStatus = computed(() => props.goal?.status ?? props.activationBadges?.goal?.status ?? null);
const goalActive = computed(() => goalStatus.value !== null && goalStatus.value !== 'complete');
const goalArmed = computed(() => goalActive.value || props.goalMode === true);
const goalCanPause = computed(() => goalStatus.value === 'active');
const goalCanResume = computed(() => goalStatus.value === 'paused' || goalStatus.value === 'blocked');
// Modes selector (plan / goal / swarm) the popover that replaces the bare
// "plan" pill. Plan/Swarm are real client toggles; goal reflects agent-driven
@ -696,6 +713,87 @@ const PERM_MODES: { mode: PermissionMode; color: string; labelKey: string; descK
{ mode: 'yolo', color: 'var(--color-warning)', labelKey: 'status.permissionYolo', descKey: 'status.permissionYoloDesc' },
{ mode: 'auto', color: 'var(--color-danger)', labelKey: 'status.permissionAuto', descKey: 'status.permissionAutoDesc' },
];
const MODE_DESC_KEYS = ['status.planDesc', 'status.swarmDesc', 'status.goalDesc'] as const;
const menuMeasureRef = ref<HTMLElement | null>(null);
const permissionDescriptionWidth = ref('');
const modeDescriptionWidth = ref('');
function menuDescStyle(width: string): Record<string, string> {
const style: Record<string, string> = {};
if (width) style['--composer-menu-desc-width'] = width;
return style;
}
const permissionMenuStyle = computed<Record<string, string>>(() => menuDescStyle(permissionDescriptionWidth.value));
const modeMenuMeasureStyle = computed<Record<string, string>>(() => menuDescStyle(modeDescriptionWidth.value));
const modesMenuInlineStyle = computed<Record<string, string>>(() => ({
...modesMenuStyle.value,
...modeMenuMeasureStyle.value,
}));
let menuMeasureFrame: number | null = null;
function cssPx(value: string): number {
const n = Number.parseFloat(value);
return Number.isFinite(n) ? n : 0;
}
function canvasFont(style: CSSStyleDeclaration): string {
return `${style.fontStyle || 'normal'} ${style.fontWeight || '400'} ${style.fontSize} ${style.fontFamily}`;
}
function letterSpacingPx(style: CSSStyleDeclaration): number {
return style.letterSpacing === 'normal' ? 0 : cssPx(style.letterSpacing);
}
function measureTextWidth(text: string, style: CSSStyleDeclaration): number {
if (!text) return 0;
const prepared = prepareWithSegments(text, canvasFont(style), {
letterSpacing: letterSpacingPx(style),
});
return measureNaturalWidth(prepared);
}
function measureMenuDescriptions(): void {
const probe = menuMeasureRef.value?.querySelector<HTMLElement>('.pd-desc');
if (!probe) return;
const style = getComputedStyle(probe);
const permissionWidth = Math.max(
0,
...PERM_MODES.map((opt) => measureTextWidth(t(opt.descKey), style)),
);
const modeWidth = Math.max(
0,
...MODE_DESC_KEYS.map((key) => measureTextWidth(t(key), style)),
);
permissionDescriptionWidth.value = permissionWidth > 0 ? `${Math.ceil(permissionWidth)}px` : '';
modeDescriptionWidth.value = modeWidth > 0 ? `${Math.ceil(modeWidth)}px` : '';
}
function scheduleMenuDescriptionMeasure(): void {
if (typeof window === 'undefined') return;
if (menuMeasureFrame !== null) {
window.cancelAnimationFrame(menuMeasureFrame);
}
void nextTick(() => {
menuMeasureFrame = window.requestAnimationFrame(() => {
menuMeasureFrame = null;
measureMenuDescriptions();
});
});
}
watch(locale, scheduleMenuDescriptionMeasure, { immediate: true });
onMounted(() => {
scheduleMenuDescriptionMeasure();
void document.fonts?.ready.then(scheduleMenuDescriptionMeasure);
});
onUnmounted(() => {
if (menuMeasureFrame !== null) {
window.cancelAnimationFrame(menuMeasureFrame);
menuMeasureFrame = null;
}
});
function choosePermission(mode: PermissionMode): void {
emit('setPermission', mode);
@ -821,6 +919,7 @@ function selectModel(modelId: string): void {
v-model="text"
class="ph"
:placeholder="placeholder"
:disabled="starting"
rows="1"
@keydown="handleKeydown"
@compositionstart="handleCompositionStart"
@ -853,6 +952,10 @@ function selectModel(modelId: string): void {
<!-- Bottom toolbar split into individual controls -->
<div ref="toolbarRef" class="toolbar">
<div ref="menuMeasureRef" class="menu-measure" aria-hidden="true">
<span class="pd-desc" />
</div>
<!-- Left: attach + permission + plan -->
<div class="toolbar-left">
<IconButton
@ -877,7 +980,13 @@ function selectModel(modelId: string): void {
>{{ permLabel }}</span>
<!-- Permission dropdown anchored to the toolbar left side -->
<div v-if="permDropdownOpen && status" class="perm-dropdown" role="menu" @click.stop>
<div
v-if="permDropdownOpen && status"
class="perm-dropdown"
:style="permissionMenuStyle"
role="menu"
@click.stop
>
<button
v-for="opt in PERM_MODES"
:key="opt.mode"
@ -908,15 +1017,23 @@ function selectModel(modelId: string): void {
<span v-if="goalArmed" class="mode-tag">{{ t('status.goalLabel') }}</span>
</button>
<div v-if="modesOpen" ref="modesMenuRef" class="modes-menu" :style="modesMenuStyle">
<div v-if="modesOpen" ref="modesMenuRef" class="modes-menu" :style="modesMenuInlineStyle" role="menu">
<!-- Plan functional client toggle -->
<button type="button" class="mode-row" :class="{ on: planOn }" @click="emit('togglePlan')">
<span class="mode-row-name">{{ t('status.planLabel') }}</span>
<button type="button" class="mode-row" :class="{ on: planOn }" role="menuitem" @click="emit('togglePlan')">
<span class="mode-row-icon"><Icon name="file-edit" size="sm" /></span>
<span class="mode-row-info">
<span class="mode-row-name">{{ t('status.planLabel') }}</span>
<span class="mode-row-desc">{{ t('status.planDesc') }}</span>
</span>
<span class="mode-switch" :class="{ on: planOn }"><span class="mode-knob" /></span>
</button>
<!-- Swarm functional client toggle -->
<button type="button" class="mode-row" :class="{ on: swarmOn }" @click="emit('toggleSwarm')">
<span class="mode-row-name">{{ t('status.swarmLabel') }}</span>
<button type="button" class="mode-row" :class="{ on: swarmOn }" role="menuitem" @click="emit('toggleSwarm')">
<span class="mode-row-icon"><Icon name="sparkles" size="sm" /></span>
<span class="mode-row-info">
<span class="mode-row-name">{{ t('status.swarmLabel') }}</span>
<span class="mode-row-desc">{{ t('status.swarmDesc') }}</span>
</span>
<span class="mode-switch" :class="{ on: swarmOn }"><span class="mode-knob" /></span>
</button>
<!-- Goal lifecycle controls when active; switch is on when active or armed. -->
@ -924,22 +1041,46 @@ function selectModel(modelId: string): void {
<button
type="button"
class="mode-row-main"
@click="goalActive ? emit('controlGoal', 'cancel') : emit('toggleGoal')"
role="menuitem"
@click="goalActive ? emit('focusGoal') : emit('toggleGoal')"
>
<span class="mode-row-name">{{ t('status.goalLabel') }}</span>
<span class="mode-row-icon"><Icon name="target" size="sm" /></span>
<span class="mode-row-info">
<span class="mode-row-name">{{ t('status.goalLabel') }}</span>
<span class="mode-row-desc">{{ t('status.goalDesc') }}</span>
</span>
<span v-if="!goalActive" class="mode-switch" :class="{ on: props.goalMode }"><span class="mode-knob" /></span>
</button>
<div v-if="goalActive" class="mode-row-actions">
<button
type="button"
<Button
v-if="goalCanPause"
size="sm"
variant="secondary"
class="mode-row-action"
@click="emit('controlGoal', 'pause')"
>{{ t('status.goalPause') }}</button>
<button
type="button"
>
<Icon name="pause" size="sm" />
<span>{{ t('status.goalPause') }}</span>
</Button>
<Button
v-if="goalCanResume"
size="sm"
variant="primary"
class="mode-row-action"
@click="emit('controlGoal', 'resume')"
>{{ t('status.goalResume') }}</button>
>
<Icon name="play" size="sm" />
<span>{{ t('status.goalResume') }}</span>
</Button>
<Button
size="sm"
variant="danger-soft"
class="mode-row-action"
@click="emit('controlGoal', 'cancel')"
>
<Icon name="close" size="sm" />
<span>{{ t('status.goalCancel') }}</span>
</Button>
</div>
</div>
</div>
@ -997,10 +1138,13 @@ function selectModel(modelId: string): void {
</Tooltip>
<button
class="send"
:class="{ 'is-starting': starting }"
:aria-label="sendLabel"
:disabled="starting"
@click="handleSubmit()"
>
<Icon name="send" size="sm" />
<Spinner v-if="starting" size="sm" />
<Icon v-else name="send" size="sm" />
</button>
</div>
@ -1063,7 +1207,7 @@ function selectModel(modelId: string): void {
:class="{ 'is-active': seg === activeThinkingSegment }"
:disabled="thinkingReadonly"
@click="setThinkingSegment(seg)"
>{{ effortLabel(seg) }}</button>
>{{ thinkingSegmentLabel(seg) }}</button>
</div>
</div>
@ -1092,9 +1236,11 @@ function selectModel(modelId: string): void {
/* Main composer card */
.composer-card {
--composer-send-size: 32px;
--composer-send-inset: var(--space-2);
position: relative;
border: 1px solid var(--line);
border-radius: 16px;
border-radius: calc((var(--composer-send-size) / 2) + var(--composer-send-inset));
background: var(--bg);
box-shadow: var(--shadow-md);
transition: border-color 0.15s, box-shadow 0.15s;
@ -1358,8 +1504,8 @@ function selectModel(modelId: string): void {
(handled upstream). Interrupt is a separate Stop button so the two are never
confused. */
.send {
width: 32px;
height: 32px;
width: var(--composer-send-size);
height: var(--composer-send-size);
border-radius: 50%;
background: var(--color-accent);
color: var(--color-text-on-accent); /* white on accent — readable in light and dark */
@ -1384,16 +1530,37 @@ function selectModel(modelId: string): void {
transform: scale(0.92);
}
.send:disabled {
cursor: not-allowed;
opacity: 0.88;
}
.send:disabled:active {
transform: none;
}
/* Spinner-on-accent: recolor the ring so the arc reads on the accent fill.
Spinner.vue styles are scoped, so pierce them with :deep(). */
.send.is-starting :deep(.ui-spinner) {
color: var(--color-text-on-accent);
}
.send.is-starting :deep(.ui-spinner__track) {
stroke: rgba(255, 255, 255, 0.32);
}
.send svg {
flex: none;
width: var(--p-ic-lg);
height: var(--p-ic-lg);
}
/* Stop button sibling of Send, shown only while running. Red at rest so the
destructive action is easy to spot; fills solid danger on hover. Kept softer
than the accent Send so Send stays the primary action. */
.stop {
width: 32px;
height: 32px;
width: var(--composer-send-size);
height: var(--composer-send-size);
border-radius: 50%;
background: var(--color-danger-soft);
color: var(--color-danger);
@ -1418,6 +1585,8 @@ function selectModel(modelId: string): void {
}
.stop svg {
flex: none;
width: var(--p-ic-lg);
height: var(--p-ic-lg);
}
/* Bottom toolbar */
@ -1425,10 +1594,19 @@ function selectModel(modelId: string): void {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px 8px;
padding: 6px var(--composer-send-inset) var(--composer-send-inset);
position: relative;
}
.menu-measure {
position: absolute;
width: max-content;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.toolbar-left,
.toolbar-right {
display: flex;
@ -1450,7 +1628,8 @@ function selectModel(modelId: string): void {
cursor: pointer;
user-select: none;
transition: background 0.1s, color 0.15s;
font-family: var(--sans);
font-family: var(--font-ui);
font-weight: var(--weight-medium);
}
.perm-pill:hover {
background: var(--color-surface-sunken);
@ -1486,7 +1665,10 @@ function selectModel(modelId: string): void {
.ctx-num {
font-size: var(--ui-font-size);
color: var(--muted);
font-family: var(--mono);
font-family: var(--font-ui);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
letter-spacing: 0;
line-height: 16px;
}
@ -1498,8 +1680,10 @@ function selectModel(modelId: string): void {
padding: 2px 7px;
border-radius: 6px;
font-size: var(--ui-font-size);
line-height: 16px;
line-height: var(--leading-normal);
color: var(--dim);
font-family: var(--font-ui);
font-weight: var(--weight-medium);
cursor: pointer;
user-select: none;
transition: background 0.1s;
@ -1551,15 +1735,16 @@ function selectModel(modelId: string): void {
display: flex;
flex-direction: column;
gap: 1px;
font-family: var(--font-ui);
}
.md-section {
padding: 4px 7px 2px;
font-size: var(--ui-font-size);
font-size: var(--text-xs);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
letter-spacing: 0;
font-weight: var(--weight-semibold);
}
.md-row {
@ -1570,7 +1755,7 @@ function selectModel(modelId: string): void {
background: none;
border: none;
cursor: pointer;
font-family: var(--mono);
font-family: var(--font-ui);
font-size: var(--ui-font-size);
color: var(--color-text);
padding: 5px 7px;
@ -1637,7 +1822,7 @@ function selectModel(modelId: string): void {
border-radius: var(--radius-sm);
}
.md-thinking .md-name {
font-family: var(--mono);
font-family: var(--font-ui);
font-size: var(--ui-font-size);
color: var(--color-text);
flex: none;
@ -1660,7 +1845,7 @@ function selectModel(modelId: string): void {
border: none;
background: none;
cursor: pointer;
font-family: var(--mono);
font-family: var(--font-ui);
font-size: var(--ui-font-size-xs);
line-height: 1;
color: var(--color-text-muted);
@ -1702,7 +1887,8 @@ function selectModel(modelId: string): void {
left: 10px;
z-index: var(--z-dropdown);
min-width: 220px;
max-width: 280px;
width: max-content;
max-width: calc(100vw - var(--space-8));
background: var(--color-surface-raised);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
@ -1714,9 +1900,11 @@ function selectModel(modelId: string): void {
}
.pd-row {
display: flex;
align-items: flex-start;
gap: 7px;
display: grid;
grid-template-columns: 14px var(--composer-menu-desc-width, max-content);
column-gap: 7px;
row-gap: 2px;
align-items: start;
width: 100%;
background: none;
border: none;
@ -1729,34 +1917,41 @@ function selectModel(modelId: string): void {
.pd-row.is-current { background: var(--color-accent-soft); }
.pd-check {
grid-column: 1;
grid-row: 1;
width: 14px;
flex: none;
min-height: 1lh;
color: var(--color-accent);
font-weight: 500;
font-size: var(--ui-font-size);
font-weight: var(--weight-medium);
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
line-height: var(--leading-normal);
}
.pd-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
display: contents;
}
.pd-name {
font-family: var(--sans);
grid-column: 2;
grid-row: 1;
font-family: var(--font-ui);
font-size: var(--ui-font-size);
font-weight: 500;
font-weight: var(--weight-medium);
line-height: var(--leading-normal);
}
.pd-desc {
font-family: var(--sans);
font-size: var(--ui-font-size);
grid-column: 2;
grid-row: 2;
width: var(--composer-menu-desc-width, auto);
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--muted);
line-height: 1.4;
line-height: var(--leading-normal);
}
/* Toggle pills (Thinking / Plan) */
@ -1773,7 +1968,8 @@ function selectModel(modelId: string): void {
background: none;
border-radius: 6px;
font-size: var(--ui-font-size);
font-family: var(--sans);
font-family: var(--font-ui);
font-weight: var(--weight-medium);
color: var(--color-text);
cursor: pointer;
user-select: none;
@ -1785,7 +1981,7 @@ function selectModel(modelId: string): void {
.mode-label { flex: none; }
.mode-tag {
flex: none;
font-family: var(--mono);
font-family: var(--font-ui);
font-size: calc(var(--ui-font-size) - 3px);
color: var(--color-accent-hover);
background: var(--bg);
@ -1800,39 +1996,82 @@ function selectModel(modelId: string): void {
position: fixed;
z-index: var(--z-dropdown);
min-width: 220px;
width: max-content;
max-width: calc(100vw - var(--space-8));
background: var(--color-surface-raised);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 4px;
padding: 5px;
display: flex;
flex-direction: column;
gap: 1px;
}
.mode-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
display: grid;
grid-template-columns: 14px var(--composer-menu-desc-width, max-content);
column-gap: 7px;
row-gap: 2px;
align-items: start;
width: 100%;
padding: 7px 10px;
padding: 6px 7px;
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
font-family: var(--sans);
font-family: var(--font-ui);
text-align: left;
}
.mode-row:hover:not(:disabled) { background: var(--panel2); }
.mode-row:hover:not(:disabled) { background: var(--color-surface-sunken); }
.mode-row:disabled { cursor: not-allowed; opacity: 0.45; }
.mode-row-name { font-size: var(--ui-font-size-sm); color: var(--color-text); }
.mode-row-info {
display: contents;
}
.mode-row-icon {
grid-column: 1;
grid-row: 1;
width: 14px;
min-height: 1lh;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: var(--ui-font-size);
line-height: var(--leading-normal);
}
.mode-row-name {
grid-column: 2;
grid-row: 1;
font-size: var(--ui-font-size);
font-weight: var(--weight-medium);
color: var(--color-text);
line-height: var(--leading-normal);
}
.mode-row-desc {
grid-column: 2;
grid-row: 2;
width: var(--composer-menu-desc-width, auto);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--muted);
line-height: var(--leading-normal);
}
.mode-row-not-supported {
margin-left: auto;
font-size: var(--ui-font-size-xs);
color: var(--muted);
}
.mode-row.on .mode-row-name { color: var(--color-accent-hover); font-weight: 500; }
.mode-row.on {
background: var(--color-accent-soft);
}
.mode-row.on .mode-row-name { color: var(--color-accent-hover); }
.mode-row.on .mode-row-icon { color: var(--color-accent-hover); }
.mode-row-meta { font-family: var(--mono); font-size: calc(var(--ui-font-size) - 3px); color: var(--muted); }
.mode-row:disabled .mode-row-meta { color: var(--faint); }
.mode-switch {
flex: none;
grid-column: 2;
grid-row: 1;
justify-self: end;
width: 34px;
height: 19px;
border-radius: 999px;
@ -1856,45 +2095,49 @@ function selectModel(modelId: string): void {
.mode-switch.on .mode-knob { transform: translateX(15px); }
.mode-row-goal {
flex-wrap: wrap;
--mode-row-icon-col: 14px;
--mode-row-col-gap: 7px;
--mode-row-pad-x: 7px;
display: flex;
flex-direction: column;
align-items: stretch;
cursor: default;
padding: 0;
gap: 0;
}
.mode-row-goal:hover { background: transparent; }
.mode-row-goal.on {
background: var(--color-accent-soft);
}
.mode-row-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
display: grid;
grid-template-columns: var(--mode-row-icon-col) var(--composer-menu-desc-width, max-content);
column-gap: var(--mode-row-col-gap);
row-gap: 2px;
align-items: start;
width: 100%;
padding: 7px 10px;
padding: 6px var(--mode-row-pad-x);
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
font-family: var(--sans);
font-family: var(--font-ui);
text-align: left;
}
.mode-row-main:hover { background: var(--panel2); }
.mode-row-goal.on .mode-row-main .mode-row-name { color: var(--color-accent-hover); font-weight: 500; }
.mode-row-main:hover { background: var(--color-surface-sunken); }
.mode-row-goal.on .mode-row-main .mode-row-name { color: var(--color-accent-hover); }
.mode-row-actions {
display: flex;
gap: 6px;
flex: 1 1 100%;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-2);
justify-content: flex-start;
padding: 0 var(--mode-row-pad-x) var(--mode-row-pad-x)
calc(var(--mode-row-pad-x) + var(--mode-row-icon-col) + var(--mode-row-col-gap));
}
.mode-row-action {
padding: 3px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: var(--panel);
color: var(--color-text);
font-size: calc(var(--ui-font-size) - 3px);
cursor: pointer;
flex: none;
}
.mode-row-action:hover:not(:disabled) { background: var(--panel2); }
.mode-row-action:disabled { opacity: 0.5; cursor: default; }
.mode-row-action :deep(.ui-button__content) { gap: var(--space-1); }
.mode-row-input {
flex: 1;
min-width: 0;
@ -1949,7 +2192,7 @@ function selectModel(modelId: string): void {
var(--dock-inline-left, max(12px, env(safe-area-inset-left)));
}
.composer-card {
border-radius: var(--radius-xl);
--composer-send-size: 36px;
max-width: 100%;
}
.input-row {
@ -1958,9 +2201,9 @@ function selectModel(modelId: string): void {
}
/* Send → 36px round (hide the SVG arrow, show only the ::after glyph) */
.send {
width: 36px;
height: 36px;
min-width: 36px;
width: var(--composer-send-size);
height: var(--composer-send-size);
min-width: var(--composer-send-size);
padding: 0;
border-radius: 50%;
font-size: 0;
@ -1979,9 +2222,9 @@ function selectModel(modelId: string): void {
}
/* Stop → 36px round "■" glyph to match the mobile Send sizing. */
.stop {
width: 36px;
height: 36px;
min-width: 36px;
width: var(--composer-send-size);
height: var(--composer-send-size);
min-width: var(--composer-send-size);
padding: 0;
border-radius: 50%;
font-size: 0;
@ -1994,7 +2237,7 @@ function selectModel(modelId: string): void {
.stop::after {
content: "■";
/* Fixed icon glyph size — not part of the UI font scale. */
font-size: 14px;
font-size: 17px;
line-height: 1;
}
@ -2065,7 +2308,7 @@ function selectModel(modelId: string): void {
font-size: var(--ui-font-size);
}
.pd-desc {
font-size: var(--ui-font-size);
font-size: var(--text-xs);
}
}

View file

@ -1,5 +1,6 @@
<!-- apps/kimi-web/src/components/chat/ConversationPane.vue -->
<script setup lang="ts">
import { measureNaturalWidth, prepareWithSegments } from '@chenglou/pretext';
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, TodoView, ToolMedia, UIQuestion, WorkspaceView } from '../../types';
@ -11,10 +12,13 @@ import Composer from './Composer.vue';
import ChatDock from './ChatDock.vue';
import ConversationToc, { type ConversationTocItem } from './ConversationToc.vue';
import Icon from '../ui/Icon.vue';
import Spinner from '../ui/Spinner.vue';
import Tooltip from '../ui/Tooltip.vue';
import { getVisibleWorkspaces } from '../../lib/workspacePicker';
import { safeRemove, STORAGE_KEYS } from '../../lib/storage';
const { t, locale } = useI18n();
const props = defineProps<{
turns: ChatTurn[];
sessionId?: string;
@ -45,6 +49,9 @@ const props = defineProps<{
/** Cache-buster that remounts the chat pane when the active session changes. */
fileReloadKey?: string | number;
sending?: boolean;
/** True while the empty-composer first prompt is being created + submitted.
* Drives the empty-session "starting conversation…" loading state. */
starting?: boolean;
fastMoon?: boolean;
/** Mobile shell: compact chrome. */
mobile?: boolean;
@ -134,6 +141,13 @@ const emit = defineEmits<{
// Empty-composer workspace picker.
const wsPickOpen = ref(false);
const wsPickExpanded = ref(false);
const contentWrapRef = ref<HTMLElement | null>(null);
const wsPickMeasureRef = ref<HTMLElement | null>(null);
const wsPickMenuWidth = ref<string>('');
const wsPickStyle = computed<Record<string, string> | undefined>(() =>
wsPickMenuWidth.value ? { '--ws-pick-menu-width': wsPickMenuWidth.value } : undefined,
);
let wsPickMeasureFrame: number | null = null;
const activeWorkspaceLabel = computed(() => {
const w = props.workspaces?.find((ws) => ws.id === props.activeWorkspaceId);
@ -161,7 +175,81 @@ function pickWorkspace(id: string): void {
if (id !== props.activeWorkspaceId) emit('selectWorkspace', id);
}
const { t } = useI18n();
function cssPx(value: string): number {
const n = Number.parseFloat(value);
return Number.isFinite(n) ? n : 0;
}
function canvasFont(style: CSSStyleDeclaration): string {
return `${style.fontStyle || 'normal'} ${style.fontWeight || '400'} ${style.fontSize} ${style.fontFamily}`;
}
function letterSpacingPx(style: CSSStyleDeclaration): number {
return style.letterSpacing === 'normal' ? 0 : cssPx(style.letterSpacing);
}
function measureText(text: string, style: CSSStyleDeclaration): number {
if (!text) return 0;
const prepared = prepareWithSegments(text, canvasFont(style), { letterSpacing: letterSpacingPx(style) });
return measureNaturalWidth(prepared);
}
function workspacePickerMaxWidth(): number {
const containerWidth = contentWrapRef.value?.getBoundingClientRect().width ?? window.innerWidth;
return Math.max(0, containerWidth * 0.75);
}
function updateWorkspacePickerWidth(): void {
const probe = wsPickMeasureRef.value;
if (!probe) return;
const itemPath = probe.querySelector<HTMLElement>('.ws-pick-item-path');
if (!itemPath) return;
const itemPathStyle = getComputedStyle(itemPath);
const measuredPathWidth = (props.workspaces ?? []).reduce(
(max, workspace) => Math.max(max, measureText(workspace.shortPath, itemPathStyle)),
0,
);
wsPickMenuWidth.value = measuredPathWidth
? `${Math.ceil(Math.min(measuredPathWidth, workspacePickerMaxWidth()))}px`
: '';
}
function scheduleWorkspacePickerMeasure(): void {
if (typeof window === 'undefined') return;
void nextTick(() => {
if (wsPickMeasureFrame !== null) window.cancelAnimationFrame(wsPickMeasureFrame);
wsPickMeasureFrame = window.requestAnimationFrame(() => {
wsPickMeasureFrame = null;
updateWorkspacePickerWidth();
});
});
}
watch(
() => [
props.activeWorkspaceId,
props.workspaceName,
props.workspaces?.map((w) => `${w.id}\u0000${w.name}\u0000${w.shortPath}`).join('\u0001') ?? '',
hiddenWorkspaceCount.value,
locale.value,
],
scheduleWorkspacePickerMeasure,
{ immediate: true },
);
onMounted(() => {
scheduleWorkspacePickerMeasure();
window.addEventListener('resize', scheduleWorkspacePickerMeasure);
void document.fonts?.ready.then(scheduleWorkspacePickerMeasure);
});
onUnmounted(() => {
if (wsPickMeasureFrame !== null) window.cancelAnimationFrame(wsPickMeasureFrame);
window.removeEventListener('resize', scheduleWorkspacePickerMeasure);
});
// The align toggle was removed with its UI (6e50cb7) reading layout is
// always centered now. Drop the old persisted preference so users who once
@ -1045,15 +1133,39 @@ defineExpose({ loadComposerForEdit, focusComposer });
class="panes chat-scroll"
@scroll.passive="onPanesScroll"
>
<div class="content-wrap" :class="[mobile ? 'align-mobile' : 'align-center']">
<div ref="contentWrapRef" class="content-wrap" :class="[mobile ? 'align-mobile' : 'align-center']">
<template v-if="turns.length === 0 && !sessionLoading">
<!-- Empty session: Composer rendered in the centre of the pane -->
<div class="empty-spacer" />
<div class="empty-hint">
<span class="empty-hint-title">{{ t('composer.emptyConversationTitle') }}</span>
<span class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
<!-- Workspace picker: choose where this new conversation starts. -->
<div v-if="hasWorkspaces" class="ws-pick">
<span class="empty-hint-title" :class="{ 'is-starting': starting }">
<Spinner v-if="starting" size="sm" />
<span>{{ starting ? t('conversation.starting') : t('composer.emptyConversationTitle') }}</span>
</span>
<span v-if="!starting" class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
<!-- Workspace picker: choose where this new conversation starts.
Hidden while starting a workspace is already committed. -->
<div v-if="hasWorkspaces && !starting" class="ws-pick" :style="wsPickStyle">
<div ref="wsPickMeasureRef" class="ws-pick-measure" aria-hidden="true">
<button type="button" class="ws-pick-btn" tabindex="-1">
<Icon name="folder" size="sm" />
<span class="ws-pick-name">{{ activeWorkspaceLabel }}</span>
<Icon class="ws-pick-chev" name="chevron-down" size="sm" />
</button>
<div class="ws-pick-menu">
<button type="button" class="ws-pick-item" tabindex="-1">
<span class="ws-pick-item-name" />
<span class="ws-pick-item-path" />
</button>
<button type="button" class="ws-pick-item ws-pick-more" tabindex="-1">
<span>{{ t('conversation.moreWorkspaces', { count: hiddenWorkspaceCount }) }}</span>
</button>
<button type="button" class="ws-pick-action" tabindex="-1">
<Icon name="plus" size="sm" />
<span>{{ t('conversation.addWorkspace') }}</span>
</button>
</div>
</div>
<Tooltip :text="t('conversation.switchWorkspace')">
<button type="button" class="ws-pick-btn" @click.stop="wsPickOpen = !wsPickOpen">
<Icon name="folder" size="sm" />
@ -1094,7 +1206,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
</div>
</div>
<button
v-else
v-else-if="!starting"
type="button"
class="empty-add-workspace"
@click="emit('addWorkspace')"
@ -1116,10 +1228,12 @@ defineExpose({ loadComposerForEdit, focusComposer });
:plan-mode="planMode"
:swarm-mode="swarmMode"
:goal-mode="goalMode"
:goal="goal"
:activation-badges="activationBadges"
:models="models"
:starred-ids="starredIds"
:skills="skills"
:starting="starting"
hide-context
@submit="handleComposerSubmit"
@steer="emit('steer', $event)"
@ -1181,6 +1295,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
:style="chatDockStyle"
:session-id="sessionId"
:running="running"
:starting="starting"
:queued="queued"
:search-files="searchFiles"
:upload-image="uploadImage"
@ -1337,11 +1452,19 @@ defineExpose({ loadComposerForEdit, focusComposer });
text-align: center;
padding: 0 16px 16px;
color: var(--color-text);
font-family: var(--sans);
font-family: var(--font-ui);
}
.empty-hint-title {
font-size: calc(var(--ui-font-size) + 16px);
font-weight: 500;
font-optical-sizing: auto;
font-weight: 600;
}
.empty-hint-title.is-starting {
display: inline-flex;
align-items: center;
gap: 9px;
color: var(--dim);
font-weight: 400;
}
.empty-hint-text {
display: inline-block;
@ -1382,13 +1505,31 @@ defineExpose({ loadComposerForEdit, focusComposer });
/* Empty-composer workspace picker */
.ws-pick {
position: relative;
font-family: var(--mono);
font-family: var(--font-ui);
}
.ws-pick-measure {
position: absolute;
visibility: hidden;
pointer-events: none;
width: max-content;
height: 0;
overflow: hidden;
}
.ws-pick-measure .ws-pick-menu {
position: static;
transform: none;
width: max-content;
min-width: 0;
max-width: none;
max-height: none;
overflow: visible;
}
.ws-pick-btn {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 320px;
width: max-content;
max-width: min(100%, calc(100vw - var(--space-8)));
padding: 5px 10px;
background: var(--panel);
border: 1px solid var(--line);
@ -1399,7 +1540,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
cursor: pointer;
}
.ws-pick-btn:hover { border-color: var(--color-accent-bd); color: var(--color-text); }
.ws-pick-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws-pick-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws-pick-chev { flex: none; color: var(--muted); transition: transform 0.15s; }
.ws-pick-chev.open { transform: rotate(180deg); }
.ws-pick-backdrop {
@ -1413,8 +1554,9 @@ defineExpose({ loadComposerForEdit, focusComposer });
transform: translateX(-50%);
top: calc(100% + 6px);
z-index: var(--z-dropdown);
min-width: 220px;
max-width: min(86vw, 340px);
width: var(--ws-pick-menu-width, max-content);
min-width: var(--ws-pick-menu-width, max-content);
max-width: calc(100vw - var(--space-8));
max-height: 50vh;
overflow-y: auto;
background: var(--color-surface-raised);
@ -1435,16 +1577,23 @@ defineExpose({ loadComposerForEdit, focusComposer });
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
font-family: var(--mono);
font-family: var(--font-ui);
}
.ws-pick-item:hover { background: var(--panel2); }
.ws-pick-item.on { background: var(--color-accent-soft); }
.ws-pick-item-name { font-size: var(--ui-font-size-sm); color: var(--color-text); }
.ws-pick-item.on .ws-pick-item-name { color: var(--color-accent-hover); font-weight: 500; }
.ws-pick-item-path { font-size: var(--text-base); color: var(--muted); }
.ws-pick-item-name {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--color-text);
}
.ws-pick-item.on .ws-pick-item-name { color: var(--color-accent-hover); }
.ws-pick-item-path { font-size: var(--text-xs); font-weight: 475; color: var(--muted); }
.ws-pick-item.ws-pick-more {
flex-direction: row;
justify-content: center;
align-items: center;
justify-content: flex-start;
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--dim);
}
.ws-pick-item.ws-pick-more:hover { color: var(--color-text); }
@ -1464,8 +1613,9 @@ defineExpose({ loadComposerForEdit, focusComposer });
border-radius: 6px;
padding: 7px 10px;
cursor: pointer;
font-family: var(--mono);
font-size: var(--ui-font-size-sm);
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--dim);
}
.ws-pick-action:hover { background: var(--panel2); color: var(--color-text); }
@ -1500,7 +1650,9 @@ defineExpose({ loadComposerForEdit, focusComposer });
font-size: var(--ui-font-size-sm);
cursor: pointer;
box-shadow: var(--shadow-sm);
z-index: var(--z-sticky);
/* Positioned after the message flow, so base z-index is enough to float above
content while staying below composer dropdowns. */
z-index: var(--z-base);
}
.newmsg-pill:hover { background: var(--panel2); }
.pill-chevron {

View file

@ -27,6 +27,15 @@ const tokenPct = computed(() => {
return Math.max(0, Math.min(100, Math.round((props.goal.tokensUsed / budget) * 100)));
});
function goalStatusLabel(status: AppGoal['status']): string {
switch (status) {
case 'active': return t('status.goalStatusActive');
case 'paused': return t('status.goalStatusPaused');
case 'blocked': return t('status.goalStatusBlocked');
case 'complete': return t('status.goalStatusComplete');
}
}
function formatMs(ms: number): string {
const sec = Math.max(0, Math.round(ms / 1000));
const min = Math.floor(sec / 60);
@ -42,13 +51,13 @@ function formatMs(ms: number): string {
<Card class="goal-strip" :class="{ expanded }">
<template #head>
<button class="goal-row" type="button" @click="expanded = !expanded">
<span class="goal-kicker">Goal</span>
<span class="goal-kicker">{{ t('status.goalLabel') }}</span>
<span class="goal-objective" :class="{ 'expanded-hidden': expanded }">{{ goal.objective }}</span>
<Badge
:variant="goal.status === 'active' ? 'success' : goal.status === 'blocked' ? 'danger' : goal.status === 'paused' ? 'warning' : 'neutral'"
size="sm"
class="goal-status"
>{{ goal.status }}</Badge>
>{{ goalStatusLabel(goal.status) }}</Badge>
<span class="goal-progress" aria-hidden="true">
<span class="goal-progress-fill" :style="{ width: `${tokenPct}%` }"></span>
</span>
@ -62,36 +71,47 @@ function formatMs(ms: number): string {
<span>Done when</span>
<p>{{ goal.completionCriterion }}</p>
</div>
<div class="goal-stats">
<Badge variant="neutral" size="sm">{{ goal.turnsUsed }} turns</Badge>
<Badge variant="neutral" size="sm">{{ goal.tokensUsed.toLocaleString() }} tokens</Badge>
<Badge variant="neutral" size="sm">{{ formatMs(goal.wallClockMs) }}</Badge>
<Badge v-if="goal.budget.tokenBudget !== null" variant="neutral" size="sm">{{ tokenPct }}% token budget</Badge>
</div>
</template>
<template v-if="expanded" #foot>
<div class="goal-actions">
<Button
v-if="goal.status !== 'paused'"
size="sm"
variant="secondary"
class="goal-action"
@click.stop="emit('controlGoal', 'pause')"
>{{ t('status.goalPause') }}</Button>
<Button
v-if="goal.status === 'paused'"
size="sm"
variant="primary"
class="goal-action"
@click.stop="emit('controlGoal', 'resume')"
>{{ t('status.goalResume') }}</Button>
<Button
size="sm"
variant="danger-soft"
class="goal-action"
@click.stop="emit('controlGoal', 'cancel')"
>{{ t('status.goalCancel') }}</Button>
<div class="goal-footer">
<div class="goal-meta">
<span>{{ goal.turnsUsed }} turns</span>
<span>{{ goal.tokensUsed.toLocaleString() }} tokens</span>
<span>{{ formatMs(goal.wallClockMs) }}</span>
<span v-if="goal.budget.tokenBudget !== null">{{ tokenPct }}% token budget</span>
</div>
<div class="goal-actions">
<Button
v-if="goal.status === 'active'"
size="sm"
variant="secondary"
class="goal-action"
@click.stop="emit('controlGoal', 'pause')"
>
<Icon name="pause" size="md" />
<span>{{ t('status.goalPause') }}</span>
</Button>
<Button
v-if="goal.status === 'paused' || goal.status === 'blocked'"
size="sm"
variant="primary"
class="goal-action"
@click.stop="emit('controlGoal', 'resume')"
>
<Icon name="play" size="md" />
<span>{{ t('status.goalResume') }}</span>
</Button>
<Button
size="sm"
variant="danger-soft"
class="goal-action"
@click.stop="emit('controlGoal', 'cancel')"
>
<Icon name="close" size="md" />
<span>{{ t('status.goalCancel') }}</span>
</Button>
</div>
</div>
</template>
</Card>
@ -99,8 +119,21 @@ function formatMs(ms: number): string {
<style scoped>
.goal-strip {
--composer-send-size: 32px;
--composer-send-inset: var(--space-2);
margin: var(--space-2) var(--space-4) 0;
}
.goal-strip.ui-card {
border-radius: calc((var(--composer-send-size) / 2) + var(--composer-send-inset));
}
.goal-strip :deep(.ui-card__foot) {
padding: var(--composer-send-inset);
}
.goal-strip :deep(.ui-card__head),
.goal-strip :deep(.ui-card__body),
.goal-strip :deep(.ui-card__foot) {
padding-left: calc((var(--composer-send-inset) + var(--composer-send-size)) / 2);
}
/* When collapsed the body/foot slots are not rendered; collapse the (always-
rendered) Card body and drop the head border so the strip is a single row. */
.goal-strip:not(.expanded) :deep(.ui-card__body) { display: none; }
@ -116,13 +149,14 @@ function formatMs(ms: number): string {
background: transparent;
color: var(--color-text);
font: var(--text-base)/var(--leading-normal) var(--font-ui);
text-align: left;
cursor: pointer;
}
.goal-kicker {
flex: none;
color: var(--color-success);
font: var(--weight-bold) var(--text-xs) var(--font-mono);
text-transform: uppercase;
font: var(--text-base)/var(--leading-normal) var(--font-ui);
font-weight: var(--weight-semibold);
}
.goal-objective {
min-width: 0;
@ -132,6 +166,7 @@ function formatMs(ms: number): string {
white-space: nowrap;
color: var(--color-text);
font-size: var(--text-base);
text-align: left;
}
.goal-objective.expanded-hidden {
visibility: hidden;
@ -183,25 +218,43 @@ function formatMs(ms: number): string {
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
text-transform: none;
}
.goal-stats {
.goal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
min-width: 0;
}
.goal-meta {
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
font: 12px/var(--leading-normal) var(--font-ui);
font-weight: 450;
font-variant-numeric: tabular-nums;
}
.goal-actions {
display: flex;
gap: var(--space-2);
width: 100%;
justify-content: flex-end;
flex: none;
}
.goal-action {
flex: 1;
flex: none;
min-width: 0;
height: var(--composer-send-size);
border-radius: calc(var(--composer-send-size) / 2);
padding-inline: var(--space-4);
}
.goal-action :deep(.ui-button__content) {
gap: var(--space-1);
}
@media (max-width: 640px) {
.goal-strip {
--composer-send-size: 36px;
margin: var(--space-2) var(--space-3) 0;
}
.goal-progress {

View file

@ -474,12 +474,12 @@ function copyDiff(code: string, idx: number) {
/* Base prose — assistant message text. */
.md {
font: 500 15px/1.6 var(--font-ui);
font: 400 15px/1.6 var(--font-ui);
color: var(--color-text);
word-break: break-word;
}
.md :deep(.markdown-renderer) {
font: 500 15px/1.6 var(--font-ui);
font: 400 15px/1.6 var(--font-ui);
color: var(--color-text);
}
.md :deep(.markstream-vue),
@ -523,8 +523,9 @@ function copyDiff(code: string, idx: number) {
font-size: var(--content-font-size);
}
/* Emphasis — bold steps up from the body (medium/500) to semibold (700). */
/* Emphasis — keep the weight strong, but soften the ink slightly. */
.md :deep(strong) {
color: color-mix(in srgb, var(--color-text) 86%, var(--color-text-muted));
font-weight: var(--weight-semibold);
}
@ -534,7 +535,8 @@ function copyDiff(code: string, idx: number) {
.md :deep(h3),
.md :deep(h4) {
color: var(--color-text);
font-weight: var(--weight-medium);
font-optical-sizing: auto;
font-weight: 600;
margin: 0.85em 0 0.35em;
line-height: var(--leading-tight);
}
@ -573,9 +575,15 @@ function copyDiff(code: string, idx: number) {
font: .9em var(--font-mono);
background: var(--color-surface-sunken);
color: var(--color-fg);
padding: 0 5px;
padding: 0 4px;
border-radius: var(--radius-sm);
}
.md :deep(strong code),
.md :deep(strong .inline-code),
.md :deep(b code),
.md :deep(b .inline-code) {
font-weight: var(--weight-semibold);
}
/* ---------------------------------------------------------------------------
Code blocks sunken surface, 1px line border, radius md, soft shadow, plus
@ -602,6 +610,9 @@ function copyDiff(code: string, idx: number) {
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
}
.md :deep(.code-block-header .code-header-main) {
font-family: var(--font-ui);
}
/* Copy button mirrors the §03 IconButton: muted glyph, sunken hover, soft
radius, and the shared focus ring. markstream renders its own button, so we
restyle it in place instead of swapping in the IconButton primitive. */

View file

@ -452,7 +452,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
}
.qstep {
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
font: var(--text-xs) var(--font-ui);
margin-left: var(--space-1);
}
/* Minimize toggle — pinned to the right of the header row. */
@ -482,6 +482,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
font-family: var(--font-ui);
}
.qstep-dot {
display: inline-flex;
@ -493,7 +494,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
border: 1px solid var(--color-line);
background: var(--color-surface);
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
font: var(--text-xs) var(--font-ui);
cursor: pointer;
padding: 0;
transition: background var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
@ -545,7 +546,8 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
.qopt-key {
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
font: var(--text-xs) var(--font-ui);
font-weight: var(--weight-medium);
width: 12px;
flex: none;
text-align: center;
@ -560,8 +562,16 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
flex-direction: column;
gap: 2px;
}
.qopt-label { color: var(--color-text); }
.qopt-desc { color: var(--color-text-muted); font: var(--text-xs)/var(--leading-normal) var(--font-ui); }
.qopt-label {
color: var(--color-text);
font-size: var(--text-base);
font-weight: var(--weight-medium);
}
.qopt-desc {
color: var(--color-text-muted);
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
font-weight: var(--weight-medium);
}
.chk, .rad { font: var(--text-base) var(--font-mono); }
@ -603,7 +613,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
.qstep-dot {
width: 28px;
height: 28px;
font: var(--text-xs) var(--font-mono);
font: var(--text-xs) var(--font-ui);
}
/* Options taller, finger-friendly rows. Label + description already stack

View file

@ -118,14 +118,16 @@ watch(
.prev {
color: var(--color-text-faint);
font: var(--text-base)/var(--leading-relaxed) var(--font-mono);
font: var(--text-base)/var(--leading-relaxed) var(--font-ui);
font-weight: 425;
white-space: pre-wrap;
word-break: break-word;
display: block;
}
.tc {
font: var(--text-base)/var(--leading-relaxed) var(--font-mono);
font: var(--text-base)/var(--leading-relaxed) var(--font-ui);
font-weight: 425;
color: var(--color-text-muted);
white-space: pre-wrap;
word-break: break-word;

View file

@ -64,7 +64,8 @@ watch(
overflow-y: auto;
margin: 0;
padding: 12px 14px;
font: var(--text-base)/var(--leading-relaxed) var(--font-mono);
font: var(--text-base)/var(--leading-relaxed) var(--font-ui);
font-weight: 425;
color: var(--color-text-muted);
white-space: pre-wrap;
word-break: break-word;

View file

@ -55,10 +55,12 @@ function onHeadClick(): void {
>
<div class="bh" ref="bhEl" @click="onHeadClick">
<span v-if="icon" class="gl" v-html="icon" aria-hidden="true" />
<span class="a">{{ name }}</span>
<Tooltip :text="arg">
<span v-if="arg" class="p">{{ arg }}</span>
</Tooltip>
<span class="bh-text">
<span class="a">{{ name }}</span>
<Tooltip :text="arg">
<span v-if="arg" class="p">{{ arg }}</span>
</Tooltip>
</span>
<span class="rt">
<span class="status" :class="status" role="status" :aria-label="status">
<Icon v-if="status === 'ok'" name="check" size="sm" />
@ -133,6 +135,13 @@ function onHeadClick(): void {
color: var(--color-text-faint);
flex: none;
}
.bh-text {
display: flex;
align-items: baseline;
gap: inherit;
flex: 1;
min-width: 0;
}
.a {
color: var(--color-text);
font-weight: var(--weight-medium);
@ -140,6 +149,7 @@ function onHeadClick(): void {
}
.p {
color: var(--color-text-muted);
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -160,6 +170,7 @@ function onHeadClick(): void {
}
:slotted(.chip) {
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
flex: none;
}
@ -196,7 +207,7 @@ function onHeadClick(): void {
.bb-pad {
min-height: 0;
overflow: hidden;
padding: 0 11px 11px;
padding: var(--space-2) var(--space-3) var(--space-3);
background: var(--color-surface-sunken);
border-top: 1px solid var(--color-line);
color: var(--color-text);

View file

@ -6,6 +6,7 @@ import { diffStats } from '../../../lib/diffLines';
import { buildEditDiffLines } from '../../../lib/toolDiff';
import { toolGlyph, toolLabel, toolSummary } from '../../../lib/toolMeta';
import ToolRow from '../ToolRow.vue';
import ToolOutputBlock from './ToolOutputBlock.vue';
const props = withDefaults(
defineProps<{
@ -69,10 +70,7 @@ function toggle(): void {
<span v-if="chip" class="chip">{{ chip }}</span>
</template>
<div v-if="summaryFull" class="bb-summary">{{ summaryFull }}</div>
<div class="bb-code">
<div v-if="!hasOutput" class="bb-empty">Waiting for output</div>
<div v-for="(line, i) in tool.output ?? []" :key="i">{{ line }}</div>
</div>
<ToolOutputBlock :lines="tool.output" empty-text="Waiting for output" />
</ToolRow>
</template>
@ -89,14 +87,4 @@ function toggle(): void {
margin-bottom: 6px;
word-break: break-all;
}
.bb-empty {
color: var(--color-text-muted);
font-style: italic;
}
.bb-code {
margin-top: 10px;
padding: 11px 13px;
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
}
</style>

View file

@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue';
import type { FilePreviewRequest, ToolCall, ToolMedia } from '../../../types';
import { toolChip, toolGlyph, toolLabel, toolSummary } from '../../../lib/toolMeta';
import ToolRow from '../ToolRow.vue';
import ToolOutputBlock from './ToolOutputBlock.vue';
const props = withDefaults(
defineProps<{
@ -72,10 +73,7 @@ watch(
<span v-if="chip" class="chip">{{ chip }}</span>
</template>
<div v-if="summaryFull" class="bb-summary">{{ summaryFull }}</div>
<div class="bb-code">
<div v-if="!hasOutput" class="bb-empty">Waiting for output</div>
<div v-for="(line, i) in tool.output ?? []" :key="i">{{ line }}</div>
</div>
<ToolOutputBlock :lines="tool.output" empty-text="Waiting for output" />
</ToolRow>
</template>
@ -92,14 +90,4 @@ watch(
font-size: var(--text-xs);
flex: none;
}
.bb-empty {
color: var(--color-text-muted);
font-style: italic;
}
.bb-code {
margin-top: 10px;
padding: 11px 13px;
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
}
</style>

View file

@ -0,0 +1,42 @@
<!-- Shared line-oriented tool output block. Keeps long outputs to a readable
viewport while preserving the tool card's normal typography. -->
<script setup lang="ts">
import { computed } from 'vue';
const OUTPUT_SCROLL_LINE_COUNT = 50;
const props = defineProps<{
lines?: string[];
emptyText?: string;
}>();
const outputLines = computed(() => props.lines ?? []);
const isScrollable = computed(() => outputLines.value.length > OUTPUT_SCROLL_LINE_COUNT);
const outputStyle = { '--tool-output-visible-lines': String(OUTPUT_SCROLL_LINE_COUNT) };
</script>
<template>
<div class="bb-code tool-output-block" :class="{ scroll: isScrollable }" :style="outputStyle">
<div v-if="outputLines.length === 0 && emptyText" class="bb-empty">{{ emptyText }}</div>
<div v-for="(line, i) in outputLines" :key="i">{{ line }}</div>
</div>
</template>
<style scoped>
.tool-output-block {
margin-top: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
}
.tool-output-block.scroll {
max-height: calc(var(--tool-output-visible-lines) * 1lh);
overflow-y: auto;
scrollbar-gutter: stable;
}
.bb-empty {
color: var(--color-text-muted);
font-style: italic;
}
</style>

View file

@ -394,7 +394,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
}
.mgh-name {
font-size: var(--ui-font-size-lg);
font-weight: 500;
font-weight: 550;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
@ -402,6 +402,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
}
.mgh-path {
font-size: var(--text-base);
font-weight: 425;
color: var(--color-text-faint);
overflow: hidden;
text-overflow: ellipsis;
@ -430,12 +431,14 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
.srow .m { flex: 1; min-width: 0; }
.srow .m .t {
font-size: var(--text-base);
font-weight: 450;
line-height: var(--leading-tight);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.srow.cur .m .t { font-weight: 500; color: var(--color-accent-hover); }
.srow.cur .m .t { color: var(--color-accent-hover); }
/* Running indicator pulse dot in the indent gutter left of the title,
mirroring the desktop SessionRow (.t.run::before). */
@ -471,6 +474,8 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
}
.srow .m .s {
font-size: var(--text-base);
font-weight: 475;
font-variant-numeric: tabular-nums;
color: var(--color-text-faint);
margin-top: 1px;
overflow: hidden;

View file

@ -32,6 +32,8 @@ const props = defineProps<{
notify: boolean;
/** Browser-notification-on-question (needs answer) preference. */
notifyQuestion: boolean;
/** Browser-notification-on-approval preference. */
notifyApproval: boolean;
/** OS permission state ('default' | 'granted' | 'denied') for the hint. */
notifyPermission?: string;
/** Play-a-sound-on-completion preference. */
@ -54,6 +56,7 @@ const emit = defineEmits<{
setUiFontSize: [size: number];
setNotify: [on: boolean];
setNotifyQuestion: [on: boolean];
setNotifyApproval: [on: boolean];
setSound: [on: boolean];
setConversationToc: [on: boolean];
login: [];
@ -417,6 +420,18 @@ function archiveTime(iso: string): string {
@update:model-value="emit('setNotifyQuestion', $event)"
/>
</div>
<div class="row">
<span class="rlabel">
{{ t('settings.notifyOnApproval') }}
<span v-if="notifyPermission === 'denied'" class="hint">{{ t('settings.notifyDenied') }}</span>
</span>
<Switch
:model-value="notifyApproval"
:disabled="notifyPermission === 'denied'"
:label="t('settings.notifyOnApproval')"
@update:model-value="emit('setNotifyApproval', $event)"
/>
</div>
<div class="row">
<span class="rlabel">{{ t('settings.soundOnComplete') }}</span>
<Switch

View file

@ -49,7 +49,11 @@ defineExpose({ el });
transition: background var(--duration-base) var(--ease-out),
color var(--duration-base) var(--ease-out);
}
.ui-icon-button:hover:not(:disabled) { background: var(--color-surface-sunken); color: var(--color-text); }
/* Translucent text-mix instead of the sunken surface: stays visible on ANY
backdrop the sunken token equals the page bg in dark mode, which made
hover feedback vanish for icon buttons sitting directly on --color-bg
(chat header, flat sidebar). */
.ui-icon-button:hover:not(:disabled) { background: color-mix(in srgb, var(--color-text) 8%, transparent); color: var(--color-text); }
.ui-icon-button:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.ui-icon-button:disabled { opacity: 0.5; cursor: not-allowed; }

View file

@ -0,0 +1,40 @@
<!-- apps/kimi-web/src/components/ui/Kbd.vue -->
<!-- Design-system §03 Kbd: keyboard shortcut rendered as keycaps one <kbd>
block per key (e.g. ['⌘', 'K'] renders two caps). Keycap look: sunken
surface + 2px bottom border, 18px tall to match Badge sm. -->
<script setup lang="ts">
defineProps<{
keys: string[];
}>();
</script>
<template>
<span class="ui-kbd">
<kbd v-for="key in keys" :key="key" class="ui-kbd__key">{{ key }}</kbd>
</span>
</template>
<style scoped>
.ui-kbd {
display: inline-flex;
align-items: center;
gap: 3px;
flex: none;
}
.ui-kbd__key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border: 1px solid var(--color-line);
border-bottom-width: 2px;
border-radius: var(--radius-xs);
background: var(--color-surface-sunken);
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: 11px;
line-height: 1;
}
</style>

View file

@ -38,9 +38,9 @@ defineEmits<{ click: [event: MouseEvent] }>();
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-size: var(--text-base);
text-align: left;
cursor: pointer;
transition: background var(--duration-base), color var(--duration-base);

View file

@ -1,20 +1,29 @@
// apps/kimi-web/src/composables/client/useNotification.ts
// Browser notifications for when the agent needs attention: a turn finished or
// a question is waiting for an answer. Each kind has its own on/off preference
// (persisted) plus the shared OS permission + Notification API. Pure UI action
// module — it never reads rawState or calls the API. The rawState-dependent
// bits (is the session active & visible, its title, the click-to-select action)
// are passed in by the caller via the ctx objects.
// Browser notifications for when the agent needs attention: a turn finished, a
// question waiting for an answer, or a tool needing approval. Each kind has its
// own on/off preference (persisted) plus the shared OS permission + Notification
// API. Pure UI action module — it never reads rawState or calls the API. The
// rawState-dependent bits (is the user watching the session, its title, the
// click-to-select action) are passed in by the caller via the ctx objects.
//
// Why two preferences: completion notifications default on (existing behavior),
// but question notifications surface question text and default OFF, so an
// existing user who only opted into completion alerts doesn't start receiving
// question content on their desktop without explicitly opting in.
// Why three preferences: completion notifications default on (existing
// behavior), but question and approval notifications surface request text/tool
// names and default OFF, so an existing user who only opted into completion
// alerts doesn't start receiving sensitive content on their desktop without
// explicitly opting in.
import { ref, type Ref } from 'vue';
import { i18n } from '../../i18n';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
export function shouldNotifyCompletion(
status: 'idle' | 'aborted',
hasPendingApproval: boolean,
hasPendingQuestion: boolean,
): boolean {
return status === 'idle' && !hasPendingApproval && !hasPendingQuestion;
}
function loadNotify(key: string, defaultOn: boolean): boolean {
const v = safeGetString(key);
return v === null ? defaultOn : v === '1';
@ -22,6 +31,7 @@ function loadNotify(key: string, defaultOn: boolean): boolean {
const notifyOnComplete = ref(loadNotify(STORAGE_KEYS.notifyOnComplete, true));
const notifyOnQuestion = ref(loadNotify(STORAGE_KEYS.notifyOnQuestion, false));
const notifyOnApproval = ref(loadNotify(STORAGE_KEYS.notifyOnApproval, false));
const notifyPermission = ref<string>(
typeof Notification !== 'undefined' ? Notification.permission : 'denied',
);
@ -61,20 +71,42 @@ function setNotifyOnQuestion(on: boolean): Promise<void> {
return setNotifyPref(notifyOnQuestion, STORAGE_KEYS.notifyOnQuestion, on);
}
export interface NotifyCompletionCtx {
/** True when the target session is the active one and the page is visible
in which case we suppress the notification. */
isActiveAndVisible: boolean;
/** Enable/disable approval notifications. Off by default. */
function setNotifyOnApproval(on: boolean): Promise<void> {
return setNotifyPref(notifyOnApproval, STORAGE_KEYS.notifyOnApproval, on);
}
export interface NotifyBaseCtx {
/** True when the user is actually watching the target session: it is the
active session, the page is visible, and the window has focus in which
case we suppress the notification. */
isUserWatching: boolean;
/** Session title used as the completion notification body and a question-body fallback. */
sessionTitle: string;
/** Called when the user clicks the notification (e.g. select the session). */
onClick: () => void;
}
export interface NotifyQuestionCtx extends NotifyCompletionCtx {
export interface NotifyCompletionCtx extends NotifyBaseCtx {
/** Prompt id of the finished turn; keys the dedup tag so every turn fires its
own notification while a replayed idle event for the same turn stays
collapsed. Falls back to a per-call unique tag when absent. */
promptId?: string;
}
export interface NotifyQuestionCtx extends NotifyBaseCtx {
/** Short preview of the question, used as the notification body. Falls back
to the session title, then to a generic line when empty. */
questionPreview: string;
/** Unique question request id; used to deduplicate notifications per request. */
questionId: string;
}
export interface NotifyApprovalCtx extends NotifyBaseCtx {
/** Tool call name needing approval, used as the notification body. */
toolName: string;
/** Unique approval request id; used to deduplicate notifications per request. */
approvalId: string;
}
export interface NotificationCopy {
@ -111,12 +143,29 @@ export function questionNotificationCopy(
};
}
export function approvalNotificationCopy(
sessionTitle: string,
toolName: string,
): NotificationCopy {
return {
title: i18n.global.t('settings.notifyApprovalTitle'),
body: firstText(
toolName,
sessionTitle,
i18n.global.t('settings.notifyApprovalFallback'),
),
};
}
/** Shared permission gate + fire. `enabled` is the caller's per-kind preference;
`copy` and `tag` let each kind carry its own text and a per-kind dedup tag
so a completion and a question don't collapse into one notification. */
`copy` and `tag` let each kind carry its own text and a per-turn/per-request
dedup tag: repeats of the same turn or request collapse into one
notification, while distinct ones each fire (same-tag notifications replace
silently renotify is unreliable across platforms so the tag must change
whenever a new alert should pop). */
function maybeNotify(
enabled: boolean,
ctx: NotifyCompletionCtx,
ctx: NotifyBaseCtx,
copy: NotificationCopy,
tag: string,
): void {
@ -135,8 +184,8 @@ function maybeNotify(
fire(ctx, copy, tag);
}
function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): void {
if (ctx.isActiveAndVisible) return;
function fire(ctx: NotifyBaseCtx, copy: NotificationCopy, tag: string): void {
if (ctx.isUserWatching) return;
try {
const n = new Notification(copy.title, { body: copy.body, tag, icon: NOTIFICATION_ICON });
n.onclick = () => {
@ -154,24 +203,38 @@ function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): vo
}
/** Fire a completion notification for a finished session, but only when the
caller says the user isn't already looking at it. */
caller says the user isn't already looking at it. The tag carries the turn's
prompt id: same-tag notifications replace silently, so without it a stale
notification left in the notification center would swallow every later
turn's alert for that session. */
function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void {
maybeNotify(
notifyOnComplete.value,
ctx,
completionNotificationCopy(ctx.sessionTitle),
`kimi-complete-${sid}`,
`kimi-complete-${sid}-${ctx.promptId ?? Date.now()}`,
);
}
/** Fire a notification when a session asks a question, but only when the user
explicitly opted into question notifications and isn't already looking. */
function maybeNotifyQuestion(sid: string, ctx: NotifyQuestionCtx): void {
function maybeNotifyQuestion(ctx: NotifyQuestionCtx): void {
maybeNotify(
notifyOnQuestion.value,
ctx,
questionNotificationCopy(ctx.sessionTitle, ctx.questionPreview),
`kimi-question-${sid}`,
`kimi-question-${ctx.questionId}`,
);
}
/** Fire a notification when a tool needs approval, but only when the user
explicitly opted into approval notifications and isn't already looking. */
function maybeNotifyApproval(ctx: NotifyApprovalCtx): void {
maybeNotify(
notifyOnApproval.value,
ctx,
approvalNotificationCopy(ctx.sessionTitle, ctx.toolName),
`kimi-approval-${ctx.approvalId}`,
);
}
@ -179,10 +242,13 @@ export function useNotification() {
return {
notifyOnComplete,
notifyOnQuestion,
notifyOnApproval,
notifyPermission,
setNotifyOnComplete,
setNotifyOnQuestion,
setNotifyOnApproval,
maybeNotifyCompletion,
maybeNotifyQuestion,
maybeNotifyApproval,
};
}

View file

@ -1,7 +1,9 @@
// apps/kimi-web/src/composables/client/useSoundNotification.ts
// Browser "turn completed" sound: a persisted on/off preference plus a short
// chime synthesized with the WebAudio API (no audio asset, no permission
// prompt). Pure UI action module — it never reads rawState or calls the API.
// Browser attention sound: a persisted on/off preference plus a short chime
// synthesized with the WebAudio API (no audio asset, no permission prompt).
// One chime covers every "the agent needs you" moment — a finished turn, a
// question waiting for an answer, a tool needing approval. Pure UI action
// module — it never reads rawState or calls the API.
//
// Why the eager "unlock": the sound is most useful when the tab is in the
// background (so you hear it while doing something else). But an AudioContext
@ -161,11 +163,19 @@ function maybePlayQuestionSound(): void {
playChime();
}
/** Play the attention sound when a tool needs approval, whenever the
preference is on. Same chime as completion: it means "the agent needs you". */
function maybePlayApprovalSound(): void {
if (!soundOnComplete.value) return;
playChime();
}
export function useSoundNotification() {
return {
soundOnComplete,
setSoundOnComplete,
maybePlayCompletionSound,
maybePlayQuestionSound,
maybePlayApprovalSound,
};
}

View file

@ -82,6 +82,17 @@ const pendingQuestionActions = reactive<Record<string, 'answer' | 'dismiss'>>({}
const pendingApprovalActions = reactive<Record<string, true>>({});
/** Task ids with an in-flight cancel, keyed by taskId. */
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
* 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.
*/
const startingFirstPromptWorkspaces = reactive(new Set<string>());
type SyncSessionResult = 'ok' | 'not-found' | 'failed';
@ -815,12 +826,22 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
text: string,
attachments?: PromptAttachment[],
): 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
// 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.
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
startingFirstPromptWorkspaces.add(workspaceId);
try {
const sid = await createDraftSession(workspaceId);
if (!sid) return;
await submitPromptInternal(sid, text, attachments);
} catch (err) {
pushOperationFailure('startSessionAndSendPrompt', err);
} finally {
startingFirstPromptWorkspaces.delete(workspaceId);
}
}
@ -837,6 +858,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
skillName: string,
args?: string,
): Promise<void> {
// Same reentry window as startSessionAndSendPrompt (see the guard there):
// draft-session creation selects the new session before the activation,
// so concurrent first actions must be dropped here.
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
startingFirstPromptWorkspaces.add(workspaceId);
try {
const sid = await createDraftSession(workspaceId);
if (!sid) return;
@ -862,6 +888,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
: rawState.defaultModel) ?? undefined;
await persistSessionProfile(
{
model,
planMode,
swarmMode,
permissionMode: rawState.permission,
@ -872,6 +899,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
await modelProvider.activateSkill(skillName, args, sid);
} catch (err) {
pushOperationFailure('startSessionAndActivateSkill', err);
} finally {
startingFirstPromptWorkspaces.delete(workspaceId);
}
}
@ -888,12 +917,17 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
workspaceId: string,
prompt?: string,
): Promise<void> {
// Same reentry window as startSessionAndSendPrompt (see the guard there).
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
startingFirstPromptWorkspaces.add(workspaceId);
try {
const sid = await createDraftSession(workspaceId);
if (!sid) return;
await sideChat.openSideChatOn(sid, prompt);
} catch (err) {
pushOperationFailure('startSessionAndOpenSideChat', err);
} finally {
startingFirstPromptWorkspaces.delete(workspaceId);
}
}
@ -2186,6 +2220,14 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
searchFiles,
loadOlderMessages,
refreshSessionSidecars,
/** True while any empty-composer first prompt is being created + submitted
* (the window covered by startingFirstPromptWorkspaces). Drives the
* empty-session "starting conversation…" loading state. Intentionally
* keyed by the lock set itself rather than the current activeWorkspaceId:
* createDraftSession can swap activeWorkspaceId to a registered id
* mid-flight, and a workspace-keyed read would prematurely re-enable the
* composer and reopen the duplicate first-submit race. */
isStartingFirstPrompt: () => startingFirstPromptWorkspaces.size > 0,
};
}

View file

@ -25,6 +25,24 @@ const USER_MEDIA_PATH_TAG_RE = /^<(image|video|audio)\s+path="([^"]+)">(?:<\/\1>
const SYSTEM_MIME_RE = /Mime type:\s*([^.\s]+)/i;
const SYSTEM_SIZE_RE = /Size:\s*(\d+)\s*bytes/i;
const SYSTEM_DIMENSIONS_RE = /Original dimensions:\s*(\d+)x(\d+)\s*pixels/i;
// agent-core inlines a single model-facing `<system>` caption next to a
// compressed image upload (buildImageCompressionCaption), which rides along as
// a text part of the persisted user message. That one caption is harness
// metadata, not something the user typed, and its raw markup must never reach
// the bubble (or the edit/preview text derived from `turn.text`). The TUI and
// agent-core strip ONLY that caption — anchored on its fixed opening
// `<system>Image compressed to fit model limits:` (see
// extractImageCompressionCaptions in agent-core) — and reroute it through the
// hidden system-reminder injection. Mirror that narrow targeting here: a
// literal `<system>…</system>` the user pasted themselves (e.g. an XML / prompt
// example) is their own text, not harness metadata, so it survives untouched.
const CAPTION_OPENING = '<system>Image compressed to fit model limits:';
const CAPTION_PATTERN = /<system>Image compressed to fit model limits:[\s\S]*?<\/system>/g;
function stripImageCompressionCaptions(text: string): string {
if (!text.includes(CAPTION_OPENING)) return text;
return text.replace(CAPTION_PATTERN, '');
}
function unescapeAttr(value: string): string {
// &amp; last so a doubly-escaped value isn't decoded twice.
@ -698,7 +716,9 @@ export function messagesToTurns(
continue;
}
}
textParts.push(c.text);
const stripped = stripImageCompressionCaptions(c.text);
if (stripped !== c.text && stripped.trim().length === 0) continue;
textParts.push(stripped);
}
}
const media = resolveMediaUrl(c);

View file

@ -30,7 +30,7 @@ import {
} from '../lib/storage';
import { createEventBatcher, isRenderEvent } from './client/eventBatcher';
import { useAppearance } from './client/useAppearance';
import { useNotification } from './client/useNotification';
import { useNotification, shouldNotifyCompletion } from './client/useNotification';
import { useSoundNotification } from './client/useSoundNotification';
import { useTaskPoller } from './client/useTaskPoller';
import { useModelProviderState } from './client/useModelProviderState';
@ -860,6 +860,11 @@ function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number
if (appEvent.type === 'questionRequested') {
onQuestionRequested(appEvent.sessionId, appEvent.question);
}
// The agent needs approval for a tool call — surface it so the user comes back.
if (appEvent.type === 'approvalRequested') {
onApprovalRequested(appEvent.sessionId, appEvent.approval);
}
}
const enqueueEvent = createEventBatcher<PendingEvent>(
@ -1375,7 +1380,7 @@ function formatTime(iso: string, _status: string): string {
const diffD = diffMs / 86400000;
if (diffD < 7) return `${Math.round(diffD)}d`;
if (diffD < 30) return `${Math.round(diffD / 7)}w`;
if (diffD < 365) return `${Math.round(diffD / 30)}m`;
if (diffD < 365) return `${Math.round(diffD / 30)}mo`;
return `${Math.round(diffD / 365)}y`;
} catch {
return iso;
@ -1680,6 +1685,11 @@ const isSending = computed<boolean>(() => {
return rawState.sendingBySession[sid] ?? false;
});
// True while the empty-composer first prompt for the active workspace is being
// created + submitted (before the session id exists). Drives the empty-session
// "starting conversation…" loading state in ConversationPane / Composer.
const isStartingFirstPrompt = computed<boolean>(() => workspaceState.isStartingFirstPrompt());
const sideChat = useSideChat(rawState, {
pushOperationFailure,
nextOptimisticMsgId,
@ -2315,10 +2325,27 @@ const workspaceState = useWorkspaceState(rawState, {
fileDiffLoading,
});
/** True when the user is actually watching this session: it is the active
session, the page is visible, and the window has focus. Focus matters on
top of visibility: a window that lost focus to another app often stays
(partially) visible on screen, but the user is working elsewhere and would
miss the moment without a notification. */
function isUserWatching(sid: string): boolean {
return (
sid === rawState.activeSessionId &&
typeof document !== 'undefined' &&
document.visibilityState === 'visible' &&
document.hasFocus()
);
}
function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
// The turn finished — this session no longer has a prompt in flight.
inFlightPromptSessions.delete(sid);
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
// Capture before the cleanup below drops it — it keys the completion
// notification's dedup tag so each finished turn alerts once.
const finishedPromptId = rawState.promptIdBySession[sid];
// 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) {
@ -2343,16 +2370,20 @@ function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
}
// Browser notification when the user isn't watching this session.
notification.maybeNotifyCompletion(sid, {
isActiveAndVisible:
sid === rawState.activeSessionId &&
typeof document !== 'undefined' &&
document.visibilityState === 'visible',
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
onClick: () => {
void workspaceState.selectSession(sid);
},
});
// Only real completions notify; aborted turns and turns that ended up
// blocked on approval/question do not fire the generic "Turn finished" alert.
const hasPendingApproval = (rawState.approvalsBySession[sid] ?? []).length > 0;
const hasPendingQuestion = (rawState.questionsBySession[sid] ?? []).length > 0;
if (shouldNotifyCompletion(status, hasPendingApproval, hasPendingQuestion)) {
notification.maybeNotifyCompletion(sid, {
isUserWatching: isUserWatching(sid),
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
promptId: finishedPromptId,
onClick: () => {
void workspaceState.selectSession(sid);
},
});
}
// Completion sound — only for real completions (aborted/cancelled turns stay
// silent). Plays regardless of visibility so it also reaches a backgrounded tab.
@ -2391,13 +2422,11 @@ function onQuestionRequested(sid: string, question: AppQuestionRequest): void {
header && questionText ? `${header}: ${questionText}` : questionText || header;
// Browser notification when the user isn't watching this session.
notification.maybeNotifyQuestion(sid, {
isActiveAndVisible:
sid === rawState.activeSessionId &&
typeof document !== 'undefined' &&
document.visibilityState === 'visible',
notification.maybeNotifyQuestion({
isUserWatching: isUserWatching(sid),
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
questionPreview: preview,
questionId: question.questionId,
onClick: () => {
void workspaceState.selectSession(sid);
},
@ -2408,6 +2437,23 @@ function onQuestionRequested(sid: string, question: AppQuestionRequest): void {
sound.maybePlayQuestionSound();
}
function onApprovalRequested(sid: string, approval: AppApprovalRequest): void {
// Browser notification when the user isn't watching this session.
notification.maybeNotifyApproval({
isUserWatching: isUserWatching(sid),
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
toolName: approval.toolName,
approvalId: approval.approvalId,
onClick: () => {
void workspaceState.selectSession(sid);
},
});
// Attention sound — plays regardless of visibility so it also reaches a
// backgrounded tab (same as the completion sound).
sound.maybePlayApprovalSound();
}
// ---------------------------------------------------------------------------
// Composable return
// ---------------------------------------------------------------------------
@ -2479,6 +2525,7 @@ export function useKimiWebClient() {
questions,
activity,
isSending,
isStartingFirstPrompt,
fastMoon: appearance.fastMoon,
// Model + Provider reactive state
@ -2501,9 +2548,11 @@ export function useKimiWebClient() {
setAccent: appearance.setAccent,
notifyOnComplete: notification.notifyOnComplete,
notifyOnQuestion: notification.notifyOnQuestion,
notifyOnApproval: notification.notifyOnApproval,
notifyPermission: notification.notifyPermission,
setNotifyOnComplete: notification.setNotifyOnComplete,
setNotifyOnQuestion: notification.setNotifyOnQuestion,
setNotifyOnApproval: notification.setNotifyOnApproval,
soundOnComplete: sound.soundOnComplete,
setSoundOnComplete: sound.setSoundOnComplete,
onboarded,

View file

@ -11,7 +11,9 @@ const SIDEBAR_WIDTH_KEY = STORAGE_KEYS.sidebarWidth;
const SIDEBAR_COLLAPSED_KEY = STORAGE_KEYS.sidebarCollapsed;
const SIDEBAR_DEFAULT = 270;
const SIDEBAR_MIN = 170;
const SIDEBAR_COLLAPSED_WIDTH = 36;
// Hard cap on how wide the sidebar can be dragged, regardless of viewport.
// Below this, the conversation-reserve rule still wins (narrow windows).
const SIDEBAR_MAX = 480;
// Minimum width kept for the conversation pane. The sidebar is capped so the
// conversation keeps at least this much room, which also guarantees the sidebar
// resize handle and collapse button stay inside the viewport even when a width
@ -28,19 +30,25 @@ export function useSidebarLayout(options: UseSidebarLayoutOptions = {}) {
const { viewportWidth } = useViewportWidth();
const sessionColWidth = ref(SIDEBAR_DEFAULT);
const sidebarCollapsed = ref(false);
// True while the sidebar ResizeHandle is being dragged — the sidebar disables
// its width transition so it follows the pointer 1:1 (mirrors panelDragging
// in useDetailPanel).
const sidebarDragging = ref(false);
// Largest sidebar width that still leaves the conversation pane usable. When
// the right-side panel is open, also reserves its minimum width so the
// conversation column can never be squeezed to nothing.
// Largest sidebar width that still leaves the conversation pane usable, then
// clamped to SIDEBAR_MAX so it can never be dragged absurdly wide on large
// displays. When the right-side panel is open, also reserves its minimum
// width so the conversation column can never be squeezed to nothing.
const sidebarMax = computed(() => {
const reserve = CONVERSATION_MIN + (toValue(options.previewOpen) ? PREVIEW_MIN : 0);
return panelMaxWidth(viewportWidth.value, SIDEBAR_MIN, reserve);
return Math.min(SIDEBAR_MAX, panelMaxWidth(viewportWidth.value, SIDEBAR_MIN, reserve));
});
// Expanded width of the sidebar. Collapsing does NOT change this value: the
// sidebar keeps its content at this fixed width and animates its container
// width to 0 (clip, not reflow), mirroring the right-side preview panel.
const sideWidth = computed(() =>
sidebarCollapsed.value
? SIDEBAR_COLLAPSED_WIDTH
: clampPanelWidth(sessionColWidth.value, SIDEBAR_MIN, sidebarMax.value),
clampPanelWidth(sessionColWidth.value, SIDEBAR_MIN, sidebarMax.value),
);
function loadSidebarCollapsed(): void {
@ -71,6 +79,7 @@ export function useSidebarLayout(options: UseSidebarLayoutOptions = {}) {
sidebarMax,
sessionColWidth,
sidebarCollapsed,
sidebarDragging,
sideWidth,
loadSidebarCollapsed,
toggleSidebarCollapse,

View file

@ -3,6 +3,7 @@ export default {
send: 'Send ↵',
queueLabel: 'Queue',
placeholderRunning: 'Press Enter to queue · Ctrl+S to inject into the running turn',
starting: 'Sending…',
queueAutoDrain: 'sends automatically when the current turn ends',
queueNext: 'Up next',
queueDragTitle: 'Drag to reorder',

View file

@ -3,6 +3,7 @@ export default {
toc: 'Conversation outline',
newMessages: 'Latest messages',
loading: 'Loading…',
starting: 'Starting conversation…',
emptyWorkspaceHint: 'Send in {name}',
switchWorkspace: 'Switch workspace',
addWorkspace: 'New workspace',

View file

@ -10,6 +10,11 @@ export default {
gitTooltip: 'Open Files > Changed',
detached: 'detached',
openPr: 'Open pull request',
prStatusOpen: 'open',
prStatusClosed: 'closed',
prStatusMerged: 'merged',
prStatusDraft: 'draft',
prStatusUnknown: 'unknown',
options: 'Options',
copySessionId: 'Copy Session ID',
renameSession: 'Rename',

View file

@ -12,12 +12,15 @@ export default {
notifications: 'Notifications',
notifyOnComplete: 'Notify when a turn completes',
notifyOnQuestion: 'Notify when a question needs an answer',
soundOnComplete: 'Play a sound when a turn completes or needs an answer',
notifyOnApproval: 'Notify when a tool needs approval',
soundOnComplete: 'Play a sound when a turn completes, needs an answer, or needs approval',
notifyDenied: 'Blocked in browser settings',
notifyTitle: 'Kimi Code · Turn finished',
notifyQuestionTitle: 'Kimi Code · Needs answer',
notifyApprovalTitle: 'Kimi Code · Approval required',
notifyFallback: 'View result',
notifyQuestionFallback: 'A question is waiting for your answer',
notifyApprovalFallback: 'A tool needs your approval',
account: 'Account',
uiFontSize: 'Font size',
agentDefaults: 'Agent defaults',

View file

@ -7,7 +7,6 @@ export default {
sortRecent: 'Last edited',
collapseAll: 'Collapse all workspaces',
expandAll: 'Expand all workspaces',
showWorkspacePaths: 'Show workspace paths',
newSession: 'New Session',
newChat: 'New Chat',
newWorkspace: 'New Workspace',
@ -31,14 +30,14 @@ export default {
language: 'Language',
daemon: 'Daemon',
noSessions: 'No conversations yet',
showMore: 'Load more ({count})',
showMore: 'Load {count} more conversations',
showLess: 'Show less',
showAll: 'Show all ({count})',
showAll: 'Show {count} more conversations',
loadingMore: 'Loading…',
collapseSidebar: 'Collapse sidebar',
expandSidebar: 'Expand sidebar',
searchPlaceholder: 'Search sessions',
searchShortcut: 'Search sessions ({shortcut})',
search: 'Search',
searchHint: '↑↓ navigate · ↵ open · Esc close',
searchNoResults: 'No matching sessions',
} as const;

View file

@ -12,23 +12,32 @@ export default {
permissionYoloDesc: 'Auto-approve tool actions, but agent may still ask questions',
// Plan mode pill
planLabel: 'Plan',
planDesc: 'Have the agent make a plan before changing files',
planOn: 'on',
planOff: 'off',
planTooltip: 'Toggle plan mode (research before editing)',
// Mode selector (Plan / Goal / Swarm)
modesLabel: 'Mode',
goalLabel: 'Goal',
goalDesc: 'Track one objective until it is complete',
swarmLabel: 'Swarm',
swarmDesc: 'Run parallel agents for broader exploration',
modeOff: 'Off',
goalPlaceholder: 'What should the agent achieve?',
goalStart: 'Start',
goalPause: 'Pause',
goalResume: 'Resume',
goalCancel: 'Cancel',
goalStatusActive: 'Active',
goalStatusPaused: 'Paused',
goalStatusBlocked: 'Blocked',
goalStatusComplete: 'Complete',
modeNotSupported: 'Not supported',
// Thinking selector
thinkingLabel: 'thinking',
thinkingTooltip: 'Toggle thinking mode',
thinkingOn: 'On',
thinkingOff: 'Off',
starredModels: 'Starred',
moreModels: 'More models…',
// Status panel

View file

@ -13,6 +13,10 @@ export default {
task: 'Task',
swarm: 'Swarm',
ask_user: 'Question',
goal_create: 'Start Goal',
goal_get: 'Read Goal',
goal_budget: 'Set Goal Budget',
goal_update: 'Update Goal',
},
swarm: {
progress: '{done} / {total}',
@ -32,6 +36,17 @@ export default {
created: 'created',
todos: '{count} items',
},
goal: {
objectiveWithCriterion: '{objective} · {criterion}',
status: 'Status: {status}',
budget: '{value} {unit}',
turns: '{value} turns',
tokens: '{value} tokens',
milliseconds: '{value} ms',
seconds: '{value} sec',
minutes: '{value} min',
hours: '{value} hr',
},
group: {
title: '{count} tool call | {count} tool calls',
running: 'running',

View file

@ -3,6 +3,7 @@ export default {
send: '发送 ↵',
queueLabel: '队列',
placeholderRunning: '输入会加入队列 · Ctrl+S 立即插入运行中的回合',
starting: '正在发送…',
queueAutoDrain: '当前回合结束后自动逐条发送',
queueNext: '下一条',
queueDragTitle: '拖拽排序',
@ -22,7 +23,7 @@ export default {
emptyConversationTitle: 'Kimi Code',
emptyConversation: '还没有消息 —— 在下方输入开始对话',
quickStartPlaceholder: '输入消息开始新对话…',
thinkingSuffix: ' · thinking',
thinkingSuffixEffort: ' · thinking: {level}',
thinkingSuffix: ' · 思考',
thinkingSuffixEffort: ' · 思考: {level}',
} as const;

View file

@ -3,6 +3,7 @@ export default {
toc: '对话目录',
newMessages: '最新消息',
loading: '加载中…',
starting: '正在创建对话…',
emptyWorkspaceHint: '在 {name} 中发送',
switchWorkspace: '切换工作区',
addWorkspace: '添加工作区',

View file

@ -10,6 +10,11 @@ export default {
gitTooltip: '打开「文件 > 改动」',
detached: '游离',
openPr: '打开 Pull Request',
prStatusOpen: '已打开',
prStatusClosed: '已关闭',
prStatusMerged: '已合并',
prStatusDraft: '草稿',
prStatusUnknown: '未知',
options: '选项',
copySessionId: '复制 Session ID',
renameSession: '重命名',

View file

@ -12,12 +12,15 @@ export default {
notifications: '通知',
notifyOnComplete: '会话完成时通知',
notifyOnQuestion: '待回答时通知',
soundOnComplete: '会话完成或待回答时播放提示音',
notifyOnApproval: '待审批时通知',
soundOnComplete: '会话完成、待回答或待审批时播放提示音',
notifyDenied: '已在浏览器设置中被阻止',
notifyTitle: 'Kimi Code · 回合完成',
notifyQuestionTitle: 'Kimi Code · 待回答',
notifyApprovalTitle: 'Kimi Code · 等待审批',
notifyFallback: '点击查看结果',
notifyQuestionFallback: '有提问等待你回答',
notifyApprovalFallback: '有工具等待你审批',
account: '账户',
uiFontSize: '字体大小',
agentDefaults: 'Agent 默认值',

View file

@ -7,7 +7,6 @@ export default {
sortRecent: '按最后编辑时间',
collapseAll: '折叠全部工作区',
expandAll: '展开全部工作区',
showWorkspacePaths: '显示工作区路径',
newSession: '新建会话',
newChat: '新建对话',
newWorkspace: '新建工作区',
@ -31,14 +30,14 @@ export default {
language: '语言',
daemon: '后台',
noSessions: '暂无对话',
showMore: '加载更多 ({count})',
showMore: '加载更多 {count} 个对话',
showLess: '收起',
showAll: '展开 ({count})',
showAll: '展开剩余 {count} 个对话',
loadingMore: '加载中…',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
searchPlaceholder: '搜索会话',
searchShortcut: '搜索会话 ({shortcut})',
search: '搜索',
searchHint: '↑↓ 选择 · ↵ 打开 · Esc 关闭',
searchNoResults: '没有匹配的会话',
};

View file

@ -8,27 +8,36 @@ export default {
permissionAuto: '完全自主',
permissionYolo: '自动通过',
permissionManualDesc: '每个工具操作都需要你手动确认',
permissionAutoDesc: '完全自主运行,agent 自己做决定,不再询问',
permissionAutoDesc: '完全自主运行,智能体自己做决定,不再询问',
permissionYoloDesc: '自动批准工具操作,但遇到关键问题仍会询问',
// 计划模式
planLabel: '计划',
planDesc: '先让智能体梳理计划,再修改文件',
planOn: '开',
planOff: '关',
planTooltip: '切换计划模式(先调研再修改)',
// 模式选择器(计划 / 目标 / Swarm
modesLabel: '模式',
goalLabel: '目标',
goalDesc: '持续跟踪一个目标,直到任务完成',
swarmLabel: 'Swarm',
swarmDesc: '并行运行多个智能体,适合大范围探索',
modeOff: '未启用',
goalPlaceholder: '让 Agent 完成什么目标?',
goalPlaceholder: '让智能体完成什么目标?',
goalStart: '开始',
goalPause: '暂停',
goalResume: '继续',
goalCancel: '取消',
goalStatusActive: '进行中',
goalStatusPaused: '已暂停',
goalStatusBlocked: '已阻塞',
goalStatusComplete: '已完成',
modeNotSupported: '暂不支持',
// 思考强度选择
thinkingLabel: '思考',
thinkingTooltip: '切换思考模式',
thinkingOn: '开',
thinkingOff: '关',
starredModels: '收藏',
moreModels: '更多模型…',
// 状态面板

View file

@ -13,6 +13,10 @@ export default {
task: '任务',
swarm: 'Swarm',
ask_user: '提问',
goal_create: '启动目标',
goal_get: '读取目标',
goal_budget: '设置目标预算',
goal_update: '更新目标',
},
swarm: {
progress: '{done} / {total}',
@ -32,6 +36,17 @@ export default {
created: '已创建',
todos: '{count} 项',
},
goal: {
objectiveWithCriterion: '{objective} · {criterion}',
status: '状态:{status}',
budget: '{value} {unit}',
turns: '{value} 轮',
tokens: '{value} token',
milliseconds: '{value} 毫秒',
seconds: '{value} 秒',
minutes: '{value} 分钟',
hours: '{value} 小时',
},
group: {
title: '{count} 个工具调用',
running: '运行中',

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.1 12.9001V15.0909C11.1 15.593 11.5029 16 12 16C12.4971 16 12.9 15.593 12.9 15.0909V12.9001H15.0909C15.593 12.9001 16 12.4972 16 12.0001C16 11.5031 15.593 11.1001 15.0909 11.1001H12.9V8.90909C12.9 8.40701 12.4971 8 12 8C11.5029 8 11.1 8.40701 11.1 8.90909V11.1001H8.90909C8.40701 11.1001 8 11.5031 8 12.0001C8 12.4972 8.40701 12.9001 8.90909 12.9001H11.1Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9996 2.1001C6.53199 2.1001 2.09961 6.53248 2.09961 12.0001C2.09961 13.9226 2.64847 15.7192 3.59804 17.2391L2.517 19.8207C2.10313 20.8091 2.82908 21.9001 3.90059 21.9001H11.9996C17.4672 21.9001 21.8996 17.4677 21.8996 12.0001C21.8996 6.53248 17.4672 2.1001 11.9996 2.1001ZM3.89961 12.0001C3.89961 7.52659 7.5261 3.9001 11.9996 3.9001C16.4731 3.9001 20.0996 7.52659 20.0996 12.0001C20.0996 16.4736 16.4724 20.1001 11.9989 20.1001H4.35146L5.63494 17.0351L5.35165 16.6291C4.43632 15.3172 3.89961 13.7227 3.89961 12.0001Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4626_2033)">
<path d="M18.3623 9.99976L18.209 8.48999C18.2031 8.43161 18.2004 8.37289 18.2002 8.31421C18.1988 8.31196 18.1956 8.30842 18.1904 8.30347C18.1718 8.28559 18.1302 8.26245 18.0713 8.26245H11C10.261 8.26245 9.59753 7.81016 9.32617 7.1228L8.9082 6.06421C8.88101 5.9953 8.85737 5.92501 8.83887 5.85327C8.83778 5.85099 8.833 5.84268 8.81836 5.83179C8.79454 5.81475 8.7549 5.79939 8.70605 5.80054H3.92871C3.86986 5.80054 3.82825 5.82368 3.80957 5.84155C3.80816 5.8429 3.80675 5.84428 3.80566 5.84546L4.47559 14.0955L3.62109 17.5154L5.12109 11.5154C5.34367 10.6251 6.1438 9.99977 7.06152 9.99976H18.3623ZM7.06152 11.7996C6.96976 11.7996 6.88944 11.8629 6.86719 11.9519L5.36719 17.9519C5.33598 18.078 5.43158 18.1999 5.56152 18.2H19.4385C19.5302 18.1999 19.6106 18.1376 19.6328 18.0486L21.1328 12.0486C21.1644 11.9224 21.0686 11.7996 20.9385 11.7996H7.06152ZM20.9385 9.99976C22.2396 9.99977 23.1945 11.2228 22.8789 12.4851L21.3789 18.4851C21.1563 19.3754 20.3562 19.9997 19.4385 19.9998H4.92871C4.41722 19.9998 3.92613 19.8059 3.56445 19.4597C3.20281 19.1135 3.00004 18.6436 3 18.1541L2 5.84644C2.00006 5.35711 2.20311 4.88786 2.56445 4.54175C2.92613 4.19554 3.41722 4.00073 3.92871 4.00073H8.66406C9.10133 3.99051 9.5296 4.1225 9.87793 4.37573C10.2285 4.63118 10.4767 4.99457 10.582 5.40405L11 6.46167H18.0713C18.5828 6.46167 19.0739 6.65648 19.4355 7.00269C19.7971 7.34888 20 7.81883 20 8.30835L20.1719 9.99976H20.9385Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_4626_2033">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.2373 3.7002C10.4169 3.7002 11.5297 4.24779 12.249 5.18262L12.4424 5.43359H18C20.0987 5.43359 21.7998 7.13472 21.7998 9.2334V16.5C21.7998 18.5987 20.0987 20.2998 18 20.2998H6C3.90132 20.2998 2.2002 18.5987 2.2002 16.5V7.5C2.2002 5.40132 3.90132 3.7002 6 3.7002H9.2373ZM6 5.5C4.89543 5.5 4 6.39543 4 7.5V16.5C4 17.6046 4.89543 18.5 6 18.5H18C19.0357 18.5 19.887 17.7128 19.9893 16.7041L20 16.5V9.2334C20 8.19775 19.2128 7.34641 18.2041 7.24414L18 7.2334H12.0479L11.9326 7.22656C11.666 7.19561 11.4205 7.05812 11.2549 6.84277L10.8223 6.28027C10.4437 5.78834 9.85808 5.5 9.2373 5.5H6ZM16 9.59961C16.4971 9.59961 16.9004 10.0029 16.9004 10.5C16.9004 10.9971 16.4971 11.4004 16 11.4004H8C7.50294 11.4004 7.09961 10.9971 7.09961 10.5C7.09961 10.0029 7.50294 9.59961 8 9.59961H16Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 911 B

View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12C6 12.8283 5.32834 13.5 4.5 13.5C3.67166 13.5 3 12.8283 3 12C3 11.1717 3.67166 10.5 4.5 10.5C5.32834 10.5 6 11.1717 6 12Z" fill="currentColor"/>
<path d="M13.5 12C13.5 12.8283 12.8283 13.5 12 13.5C11.1717 13.5 10.5 12.8283 10.5 12C10.5 11.1717 11.1717 10.5 12 10.5C12.8283 10.5 13.5 11.1717 13.5 12Z" fill="currentColor"/>
<path d="M19.5002 13.5C20.3287 13.5 21 12.8287 21 12.0002C21 11.1718 20.3287 10.5 19.5002 10.5C18.6718 10.5 18 11.1718 18 12.0002C18 12.8287 18.6718 13.5 19.5002 13.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 3C16.1944 3 20 6.80558 20 11.5C20 13.523 19.2933 15.381 18.1132 16.8404L21.1364 19.8636C21.4879 20.2151 21.4879 20.7849 21.1364 21.1364C20.7849 21.4879 20.2151 21.4879 19.8636 21.1364L16.8404 18.1132C15.381 19.2933 13.523 20 11.5 20C6.80558 20 3 16.1944 3 11.5C3 6.80558 6.80558 3 11.5 3ZM11.5 18.2C15.2003 18.2 18.2 15.2003 18.2 11.5C18.2 7.79969 15.2003 4.8 11.5 4.8C7.79969 4.8 4.8 7.79969 4.8 11.5C4.8 15.2003 7.79969 18.2 11.5 18.2Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 579 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0404 12C16.0404 9.76874 14.2313 7.9596 12.0001 7.9596C9.76883 7.9596 7.95972 9.76874 7.95972 12C7.95972 14.2313 9.76883 16.0404 12.0001 16.0404C14.2313 16.0404 16.0404 14.2313 16.0404 12ZM14.2222 12C14.2222 13.2271 13.2271 14.2222 12 14.2222C10.7729 14.2222 9.77783 13.2271 9.77783 12C9.77783 10.7729 10.7729 9.77778 12 9.77778C13.2271 9.77778 14.2222 10.7729 14.2222 12Z" fill="currentColor"/>
<path d="M9.91145 21.8009C9.29001 21.6797 8.76914 21.2612 8.50632 20.6922L8.07372 19.7556C7.88838 19.3544 7.43553 19.1048 6.95371 19.1549L5.89572 19.2647C5.2733 19.3293 4.64823 19.114 4.22298 18.6611C3.74343 18.1504 3.32454 17.6037 2.97033 17.0181C2.61571 16.4318 2.32839 15.8106 2.10407 15.1566C1.89769 14.5549 2.02148 13.8954 2.4089 13.3902L3.0376 12.5704C3.30043 12.2277 3.30042 11.7722 3.03758 11.4295L2.40413 10.6035C2.01474 10.0958 1.891 9.43198 2.10208 8.82826C2.55037 7.54612 3.27017 6.35997 4.22 5.34259C4.64518 4.8872 5.27275 4.67067 5.89701 4.73544L6.95383 4.84514C7.43561 4.89515 7.88844 4.6456 8.07377 4.24441L8.50266 3.31593C8.76494 2.74818 9.28448 2.33019 9.90423 2.20761C11.2916 1.9332 12.7148 1.93127 14.0885 2.19913C14.7099 2.32029 15.2308 2.73881 15.4937 3.3078L15.9263 4.24441C16.1116 4.6456 16.5644 4.89514 17.0462 4.84514L18.1043 4.73532C18.7267 4.67072 19.3518 4.88603 19.777 5.33886C20.2566 5.84953 20.6755 6.3963 21.0297 6.98193C21.3843 7.56823 21.6716 8.18942 21.8959 8.84339C22.1023 9.44509 21.9785 10.1046 21.5911 10.6098L20.9624 11.4295C20.6996 11.7722 20.6996 12.2278 20.9624 12.5705L21.5959 13.3964C21.9853 13.9042 22.109 14.568 21.8979 15.1717C21.4497 16.4538 20.7299 17.6399 19.7801 18.6573C19.3549 19.1128 18.7273 19.3294 18.103 19.2646L17.0462 19.1549C16.5645 19.1049 16.1116 19.3544 15.9263 19.7556L15.4974 20.6841C15.2351 21.2518 14.7156 21.6698 14.0958 21.7924C12.7083 22.0668 11.2852 22.0687 9.91145 21.8009ZM13.7432 20.0088C13.7844 20.0006 13.8259 19.9673 13.847 19.9216L14.2758 18.9931C14.7915 17.8768 15.9886 17.2171 17.2341 17.3464L18.2909 17.4561C18.3649 17.4638 18.4272 17.4423 18.4512 17.4166C19.2296 16.5828 19.8171 15.6146 20.1817 14.5716C20.1845 14.5636 20.1796 14.5373 20.1532 14.5029L19.5198 13.677C18.7564 12.6815 18.7564 11.3185 19.5198 10.323L20.1485 9.5033C20.1746 9.46927 20.1795 9.4429 20.1762 9.43327C19.9932 8.89965 19.7603 8.39623 19.4741 7.92293C19.1873 7.4489 18.846 7.00333 18.4517 6.58351C18.4272 6.55739 18.3656 6.53616 18.2921 6.54378L17.234 6.65361C15.9886 6.78287 14.7915 6.12317 14.2758 5.00689L13.8432 4.07027C13.822 4.02448 13.7811 3.9916 13.7406 3.98371C12.5983 3.76097 11.4132 3.76258 10.2571 3.99124C10.2158 3.99941 10.1744 4.03271 10.1533 4.07842L9.72441 5.00689C9.20875 6.12317 8.01164 6.7829 6.76619 6.6536L5.70942 6.54391C5.63535 6.53623 5.573 6.55774 5.54905 6.5834C4.77067 7.41713 4.18312 8.38534 3.81845 9.42835C3.81564 9.43637 3.82054 9.46265 3.84693 9.49706L4.48038 10.323C5.24381 11.3185 5.24383 12.6815 4.48041 13.6769L3.85171 14.4967C3.82561 14.5307 3.82066 14.5571 3.82396 14.5667C4.00701 15.1004 4.23986 15.6038 4.52613 16.0771C4.81284 16.5511 5.15421 16.9967 5.54845 17.4165C5.57298 17.4426 5.63461 17.4638 5.70811 17.4562L6.76608 17.3464C8.01157 17.2171 9.20871 17.8768 9.72438 18.9932L10.157 19.9297C10.1781 19.9755 10.2191 20.0084 10.2595 20.0163C11.4018 20.239 12.587 20.2374 13.7432 20.0088Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,13 +1,22 @@
// apps/kimi-web/src/lib/icons.ts
// Single source of truth for apps/kimi-web icons (design-system §02).
//
// Icons come from Remix Icon (https://remixicon.com/, Apache-2.0) and are
// bundled by unplugin-icons at build time — only the icons listed below end up
// in the production bundle. Each icon is imported twice: once as a Vue
// component (for <Icon name=... />) and once as a `?raw` SVG string (for
// iconSvg() in v-html contexts such as lib/toolMeta.ts).
// Icons come from three collections, all bundled by unplugin-icons at build
// time — only the icons listed below end up in the production bundle:
// - `~icons/kimi/*` — Kimi Design System icons (24×24 outlined,
// fill="currentColor"), local SVGs under src/icons/kimi/ registered as a
// custom collection in vite.config.ts. Preferred when a Kimi icon exists
// for the intent.
// - `~icons/tabler/*` — Tabler Icons (https://tabler.io/icons, MIT),
// 24×24 stroke-based (stroke="currentColor"); used for the sidebar
// panel toggle, which neither pack above covers well.
// - `~icons/ri/*` — Remix Icon (https://remixicon.com/, Apache-2.0) for
// the remaining intents.
// Each icon is imported twice: once as a Vue component (for <Icon name=... />)
// and once as a `?raw` SVG string (for iconSvg() in v-html contexts such as
// lib/toolMeta.ts).
//
// Remix icons are fill-based (fill="currentColor") on a 24x24 source grid; the
// All collections share the 24x24 source grid and follow currentColor; the
// rendered size comes from the size token prop. Colour follows text.
//
// Two consumers share this registry:
@ -16,7 +25,19 @@
import type { Component } from 'vue';
// Components (Vue) ---------------------------------------------------------
// Components (Kimi collection) ----------------------------------------------
import KimiAddConversation from '~icons/kimi/add-conversation';
import KimiFolder from '~icons/kimi/folder';
import KimiFolderOpen from '~icons/kimi/folder-open';
import KimiMore from '~icons/kimi/more';
import KimiSearch from '~icons/kimi/search';
import KimiSetting from '~icons/kimi/setting';
// Components (Tabler) ---------------------------------------------------------
import TablerSidebarLeftCollapse from '~icons/tabler/layout-sidebar-left-collapse';
import TablerSidebarLeftExpand from '~icons/tabler/layout-sidebar-left-expand';
// Components (Remix) ---------------------------------------------------------
import RiAddLine from '~icons/ri/add-line';
import RiAlertLine from '~icons/ri/alert-line';
import RiArrowDownLine from '~icons/ri/arrow-down-line';
@ -29,27 +50,23 @@ import RiBracesLine from '~icons/ri/braces-line';
import RiCalendarCloseLine from '~icons/ri/calendar-close-line';
import RiCalendarScheduleLine from '~icons/ri/calendar-schedule-line';
import RiCalendarTodoLine from '~icons/ri/calendar-todo-line';
import RiChatNewLine from '~icons/ri/chat-new-line';
import RiCheckLine from '~icons/ri/check-line';
import RiCloseLine from '~icons/ri/close-line';
import RiCodeLine from '~icons/ri/code-line';
import RiCollapseDiagonalLine from '~icons/ri/collapse-diagonal-line';
import RiContractLeftLine from '~icons/ri/contract-left-line';
import RiDownloadLine from '~icons/ri/download-line';
import RiDraggable from '~icons/ri/draggable';
import RiEqualizerLine from '~icons/ri/equalizer-line';
import RiExpandDiagonalLine from '~icons/ri/expand-diagonal-line';
import RiExpandRightLine from '~icons/ri/expand-right-line';
import RiExternalLinkLine from '~icons/ri/external-link-line';
import RiFileAddLine from '~icons/ri/file-add-line';
import RiFileCopyLine from '~icons/ri/file-copy-line';
import RiFileEditLine from '~icons/ri/file-edit-line';
import RiFileLine from '~icons/ri/file-line';
import RiFileTextLine from '~icons/ri/file-text-line';
import RiFlashlightLine from '~icons/ri/flashlight-line';
import RiFolderAddLine from '~icons/ri/folder-add-line';
import RiFolderFill from '~icons/ri/folder-fill';
import RiFolderLine from '~icons/ri/folder-line';
import RiFolderOpenLine from '~icons/ri/folder-open-line';
import RiGitPullRequestLine from '~icons/ri/git-pull-request-line';
import RiGlobalLine from '~icons/ri/global-line';
import RiImageLine from '~icons/ri/image-line';
@ -60,24 +77,35 @@ import RiListUnordered from '~icons/ri/list-unordered';
import RiLoginBoxLine from '~icons/ri/login-box-line';
import RiMailLine from '~icons/ri/mail-line';
import RiMessageLine from '~icons/ri/message-line';
import RiMoreLine from '~icons/ri/more-line';
import RiPauseFill from '~icons/ri/pause-fill';
import RiPencilLine from '~icons/ri/pencil-line';
import RiPlayFill from '~icons/ri/play-fill';
import RiQuestionLine from '~icons/ri/question-line';
import RiSearchLine from '~icons/ri/search-line';
import RiSettings3Line from '~icons/ri/settings-3-line';
import RiSortDesc from '~icons/ri/sort-desc';
import RiSparklingLine from '~icons/ri/sparkling-line';
import RiStarFill from '~icons/ri/star-fill';
import RiStarLine from '~icons/ri/star-line';
import RiStopFill from '~icons/ri/stop-fill';
import RiSubtractLine from '~icons/ri/subtract-line';
import RiTargetLine from '~icons/ri/target-line';
import RiTerminalBoxLine from '~icons/ri/terminal-box-line';
import RiTimeLine from '~icons/ri/time-line';
import RiToolsLine from '~icons/ri/tools-line';
import RiUserLine from '~icons/ri/user-line';
// Raw SVG strings ----------------------------------------------------------
// Raw SVG strings (Kimi collection) -----------------------------------------
import RawKimiAddConversation from '~icons/kimi/add-conversation?raw';
import RawKimiFolder from '~icons/kimi/folder?raw';
import RawKimiFolderOpen from '~icons/kimi/folder-open?raw';
import RawKimiMore from '~icons/kimi/more?raw';
import RawKimiSearch from '~icons/kimi/search?raw';
import RawKimiSetting from '~icons/kimi/setting?raw';
// Raw SVG strings (Tabler) ----------------------------------------------------
import RawTablerSidebarLeftCollapse from '~icons/tabler/layout-sidebar-left-collapse?raw';
import RawTablerSidebarLeftExpand from '~icons/tabler/layout-sidebar-left-expand?raw';
// Raw SVG strings (Remix) ----------------------------------------------------
import RawAddLine from '~icons/ri/add-line?raw';
import RawAlertLine from '~icons/ri/alert-line?raw';
import RawArrowDownLine from '~icons/ri/arrow-down-line?raw';
@ -90,27 +118,23 @@ import RawBracesLine from '~icons/ri/braces-line?raw';
import RawCalendarCloseLine from '~icons/ri/calendar-close-line?raw';
import RawCalendarScheduleLine from '~icons/ri/calendar-schedule-line?raw';
import RawCalendarTodoLine from '~icons/ri/calendar-todo-line?raw';
import RawChatNewLine from '~icons/ri/chat-new-line?raw';
import RawCheckLine from '~icons/ri/check-line?raw';
import RawCloseLine from '~icons/ri/close-line?raw';
import RawCodeLine from '~icons/ri/code-line?raw';
import RawCollapseDiagonalLine from '~icons/ri/collapse-diagonal-line?raw';
import RawContractLeftLine from '~icons/ri/contract-left-line?raw';
import RawDownloadLine from '~icons/ri/download-line?raw';
import RawDraggable from '~icons/ri/draggable?raw';
import RawEqualizerLine from '~icons/ri/equalizer-line?raw';
import RawExpandDiagonalLine from '~icons/ri/expand-diagonal-line?raw';
import RawExpandRightLine from '~icons/ri/expand-right-line?raw';
import RawExternalLinkLine from '~icons/ri/external-link-line?raw';
import RawFileAddLine from '~icons/ri/file-add-line?raw';
import RawFileCopyLine from '~icons/ri/file-copy-line?raw';
import RawFileEditLine from '~icons/ri/file-edit-line?raw';
import RawFileLine from '~icons/ri/file-line?raw';
import RawFileTextLine from '~icons/ri/file-text-line?raw';
import RawFlashlightLine from '~icons/ri/flashlight-line?raw';
import RawFolderAddLine from '~icons/ri/folder-add-line?raw';
import RawFolderFill from '~icons/ri/folder-fill?raw';
import RawFolderLine from '~icons/ri/folder-line?raw';
import RawFolderOpenLine from '~icons/ri/folder-open-line?raw';
import RawGitPullRequestLine from '~icons/ri/git-pull-request-line?raw';
import RawGlobalLine from '~icons/ri/global-line?raw';
import RawImageLine from '~icons/ri/image-line?raw';
@ -121,18 +145,17 @@ import RawListUnordered from '~icons/ri/list-unordered?raw';
import RawLoginBoxLine from '~icons/ri/login-box-line?raw';
import RawMailLine from '~icons/ri/mail-line?raw';
import RawMessageLine from '~icons/ri/message-line?raw';
import RawMoreLine from '~icons/ri/more-line?raw';
import RawPauseFill from '~icons/ri/pause-fill?raw';
import RawPencilLine from '~icons/ri/pencil-line?raw';
import RawPlayFill from '~icons/ri/play-fill?raw';
import RawQuestionLine from '~icons/ri/question-line?raw';
import RawSearchLine from '~icons/ri/search-line?raw';
import RawSettings3Line from '~icons/ri/settings-3-line?raw';
import RawSortDesc from '~icons/ri/sort-desc?raw';
import RawSparklingLine from '~icons/ri/sparkling-line?raw';
import RawStarFill from '~icons/ri/star-fill?raw';
import RawStarLine from '~icons/ri/star-line?raw';
import RawStopFill from '~icons/ri/stop-fill?raw';
import RawSubtractLine from '~icons/ri/subtract-line?raw';
import RawTargetLine from '~icons/ri/target-line?raw';
import RawTerminalBoxLine from '~icons/ri/terminal-box-line?raw';
import RawTimeLine from '~icons/ri/time-line?raw';
import RawToolsLine from '~icons/ri/tools-line?raw';
@ -177,6 +200,7 @@ export type IconName =
| 'folder-solid'
| 'file'
| 'file-text'
| 'file-edit'
| 'file-plus'
| 'file-off'
| 'image-off'
@ -197,6 +221,8 @@ export type IconName =
| 'alert-triangle'
| 'clock'
| 'sparkles'
| 'target'
| 'pause'
| 'play'
| 'stop'
| 'star'
@ -220,13 +246,13 @@ function entry(component: Component, svg: string): IconEntry {
export const ICONS: Record<IconName, IconEntry> = {
plus: entry(RiAddLine, RawAddLine),
'chat-new': entry(RiChatNewLine, RawChatNewLine),
'chat-new': entry(KimiAddConversation, RawKimiAddConversation),
'calendar-close': entry(RiCalendarCloseLine, RawCalendarCloseLine),
'calendar-schedule': entry(RiCalendarScheduleLine, RawCalendarScheduleLine),
'calendar-todo': entry(RiCalendarTodoLine, RawCalendarTodoLine),
close: entry(RiCloseLine, RawCloseLine),
check: entry(RiCheckLine, RawCheckLine),
search: entry(RiSearchLine, RawSearchLine),
search: entry(KimiSearch, RawKimiSearch),
copy: entry(RiFileCopyLine, RawFileCopyLine),
link: entry(RiLinksLine, RawLinksLine),
'external-link': entry(RiExternalLinkLine, RawExternalLinkLine),
@ -234,7 +260,7 @@ export const ICONS: Record<IconName, IconEntry> = {
undo: entry(RiArrowGoBackLine, RawArrowGoBackLine),
send: entry(RiArrowUpLine, RawArrowUpLine),
image: entry(RiImageLine, RawImageLine),
settings: entry(RiSettings3Line, RawSettings3Line),
settings: entry(KimiSetting, RawKimiSetting),
sliders: entry(RiEqualizerLine, RawEqualizerLine),
'log-in': entry(RiLoginBoxLine, RawLoginBoxLine),
'chevron-down': entry(RiArrowDownSLine, RawArrowDownSLine),
@ -243,19 +269,20 @@ export const ICONS: Record<IconName, IconEntry> = {
'arrow-down': entry(RiArrowDownLine, RawArrowDownLine),
'arrow-right': entry(RiArrowRightLine, RawArrowRightLine),
minus: entry(RiSubtractLine, RawSubtractLine),
'panel-collapse': entry(RiContractLeftLine, RawContractLeftLine),
'panel-expand': entry(RiExpandRightLine, RawExpandRightLine),
'panel-collapse': entry(TablerSidebarLeftCollapse, RawTablerSidebarLeftCollapse),
'panel-expand': entry(TablerSidebarLeftExpand, RawTablerSidebarLeftExpand),
expand: entry(RiExpandDiagonalLine, RawExpandDiagonalLine),
collapse: entry(RiCollapseDiagonalLine, RawCollapseDiagonalLine),
list: entry(RiListUnordered, RawListUnordered),
sort: entry(RiSortDesc, RawSortDesc),
grip: entry(RiDraggable, RawDraggable),
folder: entry(RiFolderOpenLine, RawFolderOpenLine),
'folder-closed': entry(RiFolderLine, RawFolderLine),
folder: entry(KimiFolderOpen, RawKimiFolderOpen),
'folder-closed': entry(KimiFolder, RawKimiFolder),
'folder-plus': entry(RiFolderAddLine, RawFolderAddLine),
'folder-solid': entry(RiFolderFill, RawFolderFill),
file: entry(RiFileLine, RawFileLine),
'file-text': entry(RiFileTextLine, RawFileTextLine),
'file-edit': entry(RiFileEditLine, RawFileEditLine),
'file-plus': entry(RiFileAddLine, RawFileAddLine),
'file-off': entry(RiFileLine, RawFileLine),
'image-off': entry(RiImageLine, RawImageLine),
@ -276,11 +303,13 @@ export const ICONS: Record<IconName, IconEntry> = {
'alert-triangle': entry(RiAlertLine, RawAlertLine),
clock: entry(RiTimeLine, RawTimeLine),
sparkles: entry(RiSparklingLine, RawSparklingLine),
target: entry(RiTargetLine, RawTargetLine),
pause: entry(RiPauseFill, RawPauseFill),
play: entry(RiPlayFill, RawPlayFill),
stop: entry(RiStopFill, RawStopFill),
star: entry(RiStarFill, RawStarFill),
'star-outline': entry(RiStarLine, RawStarLine),
'dots-horizontal': entry(RiMoreLine, RawMoreLine),
'dots-horizontal': entry(KimiMore, RawKimiMore),
};
export function getIcon(name: IconName): IconEntry {
@ -353,6 +382,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
'folder-solid',
'file',
'file-text',
'file-edit',
'file-plus',
'file-off',
'image-off',
@ -365,6 +395,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
'check-list',
'bolt',
'git-pull-request',
'target',
'calendar-schedule',
'calendar-todo',
'calendar-close',
@ -379,6 +410,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
'alert-triangle',
'clock',
'sparkles',
'pause',
'play',
'stop',
'star',

View file

@ -22,7 +22,6 @@ export const STORAGE_KEYS = {
colorScheme: 'kimi-web.color-scheme',
hiddenWorkspaces: 'kimi-web.hidden-workspaces',
collapsedWorkspaces: 'kimi-web.collapsed-workspaces',
showWorkspacePaths: 'kimi-web.show-workspace-paths',
workspaceOrder: 'kimi-web.workspace-order',
workspaceNameOverrides: 'kimi-web.workspace-name-overrides',
workspaceSort: 'kimi-web.workspace-sort',
@ -32,6 +31,7 @@ export const STORAGE_KEYS = {
conversationToc: 'kimi-web.beta-toc',
notifyOnComplete: 'kimi-web.notify-on-complete',
notifyOnQuestion: 'kimi-web.notify-on-question',
notifyOnApproval: 'kimi-web.notify-on-approval',
soundOnComplete: 'kimi-web.sound-on-complete',
inputHistory: 'kimi-web.input-history',
// cross-file
@ -148,20 +148,6 @@ export function saveCollapsedWorkspaces(ids: Iterable<string>): void {
safeSetJson(STORAGE_KEYS.collapsedWorkspaces, Array.from(ids));
}
/**
* Whether the sidebar shows each workspace's root path under its name. Off by
* default to keep the list compact; toggled from the Workspaces section header.
* Stored as a JSON boolean; any missing/unset value reads as false. UI-only
* state with no server-side source of truth.
*/
export function loadShowWorkspacePaths(): boolean {
return safeGetJson<boolean>(STORAGE_KEYS.showWorkspacePaths) === true;
}
export function saveShowWorkspacePaths(show: boolean): void {
safeSetJson(STORAGE_KEYS.showWorkspacePaths, show);
}
/**
* Display order of workspace ids in the sidebar. Persisted as a JSON array so
* the user can drag workspaces into a custom order that survives a page

View file

@ -25,6 +25,10 @@ const TOOL_LABEL_KEYS: Record<string, string> = {
task: 'tools.label.task',
agentswarm: 'tools.label.swarm',
askuserquestion: 'tools.label.ask_user',
creategoal: 'tools.label.goal_create',
getgoal: 'tools.label.goal_get',
setgoalbudget: 'tools.label.goal_budget',
updategoal: 'tools.label.goal_update',
};
// ---------------------------------------------------------------------------
@ -60,6 +64,10 @@ const NAME_ALIASES: Record<string, string> = {
subagent: 'task',
websearch: 'search',
web_search: 'search',
create_goal: 'creategoal',
get_goal: 'getgoal',
set_goal_budget: 'setgoalbudget',
update_goal: 'updategoal',
};
export function normalizeToolName(name: string): string {
@ -93,6 +101,10 @@ const TOOL_GLYPH: Record<string, IconName> = {
task: 'sparkles',
agentswarm: 'git-pull-request',
askuserquestion: 'help-circle',
creategoal: 'target',
getgoal: 'target',
setgoalbudget: 'target',
updategoal: 'target',
// Cron scheduling tools share a calendar motif: schedule / list / cancel.
croncreate: 'calendar-schedule',
cronlist: 'calendar-todo',
@ -182,6 +194,41 @@ function filePath(d: Record<string, unknown>): string | undefined {
return str(d.path) ?? str(d.file_path) ?? str(d.filePath) ?? str(d.filename);
}
const GOAL_STATUS_KEYS: Record<string, string> = {
active: 'status.goalStatusActive',
blocked: 'status.goalStatusBlocked',
complete: 'status.goalStatusComplete',
};
function goalStatusLabel(value: unknown): string | undefined {
const status = str(value);
if (!status) return undefined;
const key = GOAL_STATUS_KEYS[status];
return key ? t(key) : status;
}
function goalBudgetSummary(d: Record<string, unknown>): string | undefined {
const value = num(d.value);
const unit = str(d.unit);
if (value === undefined || !unit) return undefined;
switch (unit) {
case 'turns':
return t('tools.goal.turns', { value });
case 'tokens':
return t('tools.goal.tokens', { value });
case 'milliseconds':
return t('tools.goal.milliseconds', { value });
case 'seconds':
return t('tools.goal.seconds', { value });
case 'minutes':
return t('tools.goal.minutes', { value });
case 'hours':
return t('tools.goal.hours', { value });
default:
return t('tools.goal.budget', { value, unit });
}
}
const BASH_MAX = 64;
/**
@ -255,6 +302,27 @@ export function toolSummary(name: string, arg: string, full = false): string {
if (items) return c(t('tools.chip.todos', { count: items.length }));
return fallback();
}
case 'creategoal': {
if (full) return fallback();
const objective = str(d.objective);
const criterion = str(d.completionCriterion);
if (objective && criterion) return c(t('tools.goal.objectiveWithCriterion', { objective, criterion }));
return objective ? c(objective) : fallback();
}
case 'getgoal': {
if (full) return fallback();
return '';
}
case 'setgoalbudget': {
if (full) return fallback();
const summary = goalBudgetSummary(d);
return summary ? c(summary) : fallback();
}
case 'updategoal': {
if (full) return fallback();
const status = goalStatusLabel(d.status);
return status ? c(t('tools.goal.status', { status })) : fallback();
}
default:
return fallback();
}

View file

@ -2,7 +2,8 @@ import { createApp } from 'vue';
import App from './App.vue';
import i18n from './i18n';
import { installClientErrorCapture } from './debug/trace';
import '@fontsource-variable/inter/wght.css';
import '@fontsource-variable/inter/opsz.css';
import '@fontsource-variable/inter/opsz-italic.css';
import '@fontsource-variable/jetbrains-mono/wght.css';
import './style.css';

View file

@ -204,11 +204,9 @@ summary {
--ui-font-size-lg: calc(var(--ui-font-size) + 1px);
--ui-font-size-xl: calc(var(--ui-font-size) + 2px);
--content-font-size: calc(var(--base-ui-font-size) + 1px);
--sidebar-ui-font-size: calc(var(--base-ui-font-size) + 1px);
--mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
/* Body/UI font follows the design-system canonical token (system UI font
first; Inter intentionally NOT in the body chain it stays reserved for
--font-display headings). Mirrors --font-ui so the two can never drift. */
/* Body/UI font follows the design-system canonical token. Mirrors --font-ui
so the legacy alias can never drift from the current text face. */
--sans: var(--font-ui);
color-scheme: light dark;
}
@ -388,6 +386,15 @@ html[data-color-scheme="dark"][data-accent="mono"] {
--color-text-on-accent: #ffffff;
--color-line: #e7eaee;
--color-line-strong: #d4d9e0;
/* Neutral selected fill (sidebar rows, list pickers) deliberately NOT
accent-tinted, so selection reads as "where I am", not as an action. */
--color-selected: #00000014;
/* Row hover wash lighter than the selected fill (hover < selected); both
are translucent black so they sit naturally on any light surface. */
--color-hover: #0000000d;
/* Sidebar surface one step off --color-bg (warm off-white / near-black)
so the session column reads as its own plane. */
--color-sidebar-bg: #fbfaf9;
--color-accent: var(--accent-primary);
--color-accent-hover: #0f6fe0;
--color-accent-soft: #e8f3ff;
@ -454,14 +461,15 @@ html[data-color-scheme="dark"][data-accent="mono"] {
--duration-slow: 260ms;
/* -- type families ------------------------------------------------------- */
/* UI/body use the platform's native UI font first (design-system §02);
Inter is intentionally NOT in this chain it is reserved for
--font-display (optional headings / brand wordmarks) below. */
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans SC", "Noto Sans SC",
Roboto, Ubuntu, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-display: "Inter Variable", "Inter", var(--font-ui);
/* UI/body use self-hosted Inter first. CJK and platform system UI families
stay late in the fallback chain so Latin glyphs resolve to Inter while
Chinese text can still fall through to native CJK fonts. */
--font-ui: "Inter Variable", "Inter", "Helvetica Neue", Arial,
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans SC",
"Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Ubuntu, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-display: var(--font-ui);
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace,
"SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
@ -511,6 +519,9 @@ html[data-color-scheme="dark"] {
--color-text-faint: #6b7280;
--color-line: #2d333b;
--color-line-strong: #3d444d;
--color-selected: #ffffff14;
--color-hover: #ffffff0d;
--color-sidebar-bg: #181817;
--color-accent: #58a6ff;
--color-accent-hover: #79b8ff;
--color-accent-soft: rgba(88, 166, 255, 0.14);
@ -550,6 +561,9 @@ html[data-color-scheme="dark"] {
--color-text-faint: #6b7280;
--color-line: #2d333b;
--color-line-strong: #3d444d;
--color-selected: #ffffff14;
--color-hover: #ffffff0d;
--color-sidebar-bg: #181817;
--color-accent: #58a6ff;
--color-accent-hover: #79b8ff;
--color-accent-soft: rgba(88, 166, 255, 0.14);
@ -614,9 +628,16 @@ body {
A single mid-gray works on either background, so no per-theme tokens needed.
Components may still override locally (e.g. the sidebar's even-thinner rule).
--------------------------------------------------------------------------- */
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
/* Firefox-only fallback: the standard scrollbar properties DISABLE the whole
::-webkit-scrollbar customisation in Chromium 121+ (any non-auto value wins
over the pseudo-element styles), silently replacing the 6px/4px custom bars
with the ~11px native thin gutter. Scope them to engines that don't know
the webkit pseudo-element instead. */
@supports not selector(::-webkit-scrollbar) {
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
}
}
*::-webkit-scrollbar {
width: 6px;
@ -643,6 +664,7 @@ body {
font-size: var(--ui-font-size);
font-weight: 400;
line-height: 1.6;
font-optical-sizing: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: auto;

View file

@ -176,6 +176,7 @@ onUnmounted(() => {
<div class="color-card"><div class="color-chip" style="background:#ffffff"></div><div class="color-meta"><div class="cn">bg</div><div class="cv">#ffffff / #0d1117</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#fafbfc"></div><div class="color-meta"><div class="cn">surface</div><div class="cv">#fafbfc / #161b22</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#f3f5f8"></div><div class="color-meta"><div class="cn">surface-sunken</div><div class="cv">#f3f5f8 / #0d1117</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#eceff3"></div><div class="color-meta"><div class="cn">selected</div><div class="cv">#eceff3 / #2d333b</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#14171c"></div><div class="color-meta"><div class="cn">fg</div><div class="cv">#14171c / #e8eaed</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#6b7280"></div><div class="color-meta"><div class="cn">fg-muted</div><div class="cv">#6b7280 / #9aa0a8</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#e7eaee"></div><div class="color-meta"><div class="cn">line</div><div class="cv">#e7eaee / #2d333b</div></div></div>
@ -191,6 +192,9 @@ onUnmounted(() => {
<tr><td class="tk">--color-text</td><td class="val"><span class="swatch" style="background:#14171c"></span>#14171c</td><td class="val"><span class="swatch" style="background:#e8eaed"></span>#e8eaed</td><td>Body text / headings</td></tr>
<tr><td class="tk">--color-text-muted</td><td class="val"><span class="swatch" style="background:#6b7280"></span>#6b7280</td><td class="val"><span class="swatch" style="background:#9aa0a8"></span>#9aa0a8</td><td>Secondary text / placeholder</td></tr>
<tr><td class="tk">--color-line</td><td class="val"><span class="swatch" style="background:#e7eaee"></span>#e7eaee</td><td class="val"><span class="swatch" style="background:#2d333b"></span>#2d333b</td><td>Divider / card border</td></tr>
<tr><td class="tk">--color-selected</td><td class="val"><span class="swatch" style="background:#00000014"></span>#00000014</td><td class="val"><span class="swatch" style="background:#ffffff14"></span>#ffffff14</td><td>Neutral selected fill (sidebar rows, list pickers) translucent, never accent-tinted</td></tr>
<tr><td class="tk">--color-hover</td><td class="val"><span class="swatch" style="background:#0000000d"></span>#0000000d</td><td class="val"><span class="swatch" style="background:#ffffff0d"></span>#ffffff0d</td><td>Row hover wash lighter than the selected fill (hover &lt; selected); translucent, sits on any surface</td></tr>
<tr><td class="tk">--color-sidebar-bg</td><td class="val"><span class="swatch" style="background:#fbfaf9"></span>#fbfaf9</td><td class="val"><span class="swatch" style="background:#181817"></span>#181817</td><td>Sidebar surface one step off <code>--color-bg</code> so the session column reads as its own plane</td></tr>
<tr><td class="tk">--color-accent</td><td class="val"><span class="swatch" style="background:#1783ff"></span>#1783ff</td><td class="val"><span class="swatch" style="background:#58a6ff"></span>#58a6ff</td><td>Primary action / link / focus</td></tr>
<tr><td class="tk">--color-success</td><td class="val"><span class="swatch" style="background:#0e7a38"></span>#0e7a38</td><td class="val"><span class="swatch" style="background:#3fb950"></span>#3fb950</td><td>Success / pass</td></tr>
<tr><td class="tk">--color-warning</td><td class="val"><span class="swatch" style="background:#a9610a"></span>#a9610a</td><td class="val"><span class="swatch" style="background:#d29922"></span>#d29922</td><td>Warning / pending</td></tr>
@ -227,18 +231,19 @@ onUnmounted(() => {
<p>All disabled controls use <code>opacity:.5</code> + <code>cursor:not-allowed</code> uniformly; do not separately grey out or recolor.</p>
<h3 class="sub">Font families</h3>
<p>Kimi Web uses two font families: <b>--font-ui</b> (UI and body, system fonts first) and <b>--font-mono</b> (code and monospace). Components always reference the variables; do not hard-code font names.</p>
<p>Kimi Web uses two font families: <b>--font-ui</b> (UI and body, Inter first) and <b>--font-mono</b> (code and monospace). Components always reference the variables; do not hard-code font names.</p>
<h4 class="mini">--font-ui · UI &amp; body (system fonts first)</h4>
<p>Body and UI use each platform's native UI font close to the system feel, comfortable for long text and CJK. Fallback chain:</p>
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">--font-ui</span></div><pre>--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI",
<h4 class="mini">--font-ui · UI &amp; body (Inter first)</h4>
<p>Body and UI use self-hosted Inter as the primary face. CJK and platform system UI fonts sit late in the fallback chain so Latin glyphs resolve to Inter while Chinese text can fall through to native CJK fonts:</p>
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">--font-ui</span></div><pre>--font-ui: "Inter Variable", "Inter", "Helvetica Neue", Arial,
"PingFang SC", "Microsoft YaHei", "Noto Sans SC",
"Helvetica Neue", Arial, sans-serif,
-apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Ubuntu, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";</pre></div>
<ul class="clean">
<li>System UI fonts first: SF Pro on macOS / iOS, Segoe UI on Windows.</li>
<li>CJK next: PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans SC (Linux).</li>
<li>Helvetica Neue / Arial / sans-serif as generic fallbacks; emoji fonts at the end.</li>
<li>Inter first: self-hosted Latin UI and body text, loaded through the optical-size normal and italic variable faces.</li>
<li>Western fallbacks next: Helvetica Neue / Arial for environments where Inter cannot load.</li>
<li>CJK and system UI fallbacks late: PingFang SC / Microsoft YaHei / Noto Sans SC, then platform UI fonts and emoji fonts.</li>
</ul>
<h4 class="mini">--font-mono · Code &amp; monospace</h4>
@ -251,8 +256,8 @@ onUnmounted(() => {
<thead><tr><th>Font</th><th>Source</th><th>Bundled</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">JetBrains Mono</td><td class="val">@fontsource-variable/jetbrains-mono</td><td class="val"> self-hosted</td><td>monospace / code (--font-mono)</td></tr>
<tr><td class="tk">Inter</td><td class="val">@fontsource-variable/inter</td><td class="val"> self-hosted</td><td>optional: page titles / brand wordmark (--font-display)</td></tr>
<tr><td class="tk">System UI / CJK fonts</td><td class="val">operating system</td><td class="val"></td><td>body / UI (--font-ui), not bundled</td></tr>
<tr><td class="tk">Inter</td><td class="val">@fontsource-variable/inter/opsz.css + opsz-italic.css</td><td class="val"> self-hosted</td><td>UI / body / display (--font-ui, --font-display), wght 100-900, opsz 14-32, normal + italic</td></tr>
<tr><td class="tk">System UI / CJK fonts</td><td class="val">operating system</td><td class="val"></td><td>late fallback for UI / body, not bundled</td></tr>
</tbody>
</table>
<div class="callout good"><span class="ico"></span><div>
@ -262,12 +267,13 @@ onUnmounted(() => {
<h4 class="mini">Usage rules</h4>
<ul class="clean check">
<li>Components always use <code>var(--font-ui)</code> / <code>var(--font-mono)</code>; do not hard-code font names like <code>'Inter'</code> / <code>'JetBrains Mono'</code>.</li>
<li>Body / UI use <code>--font-ui</code> (system fonts first); code / monospace use <code>--font-mono</code> (JetBrains Mono).</li>
<li>Inter is used only for headings / brand scenarios that need a unified look (optional <code>--font-display</code>); it is no longer the body default.</li>
<li>Body / UI use <code>--font-ui</code> (Inter first); code / monospace use <code>--font-mono</code> (JetBrains Mono).</li>
<li>Inter is loaded from the complete optical-size variable faces, including normal and italic styles; <code>font-optical-sizing: auto</code> is enabled globally.</li>
<li>CJK and platform system UI fonts stay late in the <code>--font-ui</code> fallback chain, after Inter and Western fallbacks.</li>
</ul>
<h3 class="sub">Type scale &amp; weight</h3>
<p>The user font-size preference writes <code>--base-ui-font-size</code>. Compact UI chrome follows it through <code>--ui-font-size</code>, while chat reading surfaces and the sidebar derive one readable step above it through <code>--content-font-size</code> and <code>--sidebar-ui-font-size</code>.</p>
<p>The user font-size preference writes <code>--base-ui-font-size</code>. Compact UI chrome and the sidebar follow it through <code>--ui-font-size</code>, while chat reading surfaces derive one readable step above it through <code>--content-font-size</code>.</p>
<p>The fixed product type tokens still define component defaults: <b>UI controls / buttons / forms</b> use <code>--text-base</code> (14px); <b>reading body including chat Markdown, message bubbles, etc.</b> stays one step larger than compact chrome for readability; the <b>sidebar session list</b> follows that same readable step while keeping list density.
Drop stray <code>font-weight: 650 / 750</code>; converge on two weights, 400 / 500 (regular / emphasis).</p>
<div class="panel panel-pad" style="margin:16px 0">
@ -281,11 +287,10 @@ onUnmounted(() => {
<table class="dt">
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">--font-ui</td><td class="val">-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC"</td><td>UI &amp; body (system fonts first)</td></tr>
<tr><td class="tk">--font-ui</td><td class="val">"Inter Variable", "Inter", "Helvetica Neue", Arial</td><td>UI &amp; body (Inter first)</td></tr>
<tr><td class="tk">--font-mono</td><td class="val">JetBrains Mono</td><td>code, tool names, line numbers, diffs</td></tr>
<tr><td class="tk">--base-ui-font-size</td><td class="val">14px user preference</td><td>root setting that drives UI, reading body, and sidebar font sizes</td></tr>
<tr><td class="tk">--content-font-size</td><td class="val">calc(base + 1px)</td><td>chat Markdown, message bubbles, composer</td></tr>
<tr><td class="tk">--sidebar-ui-font-size</td><td class="val">calc(base + 1px)</td><td>sidebar brand, search, workspace and session rows</td></tr>
<tr><td class="tk">--leading-tight/normal/relaxed</td><td class="val">1.25 / 1.5 / 1.7</td><td>headings / UI / long text</td></tr>
<tr><td class="tk">--weight-regular/medium</td><td class="val">400 / 500</td><td>body / emphasis</td></tr>
</tbody>
@ -565,6 +570,18 @@ onUnmounted(() => {
</div>
</div>
<!-- ===== Kbd ===== -->
<h3 class="sub">Kbd · keyboard shortcut</h3>
<p><b>Kbd</b> renders a shortcut as keycaps one block per key, never inline text like <code>(K)</code>. Caps are 18px tall (Badge sm rhythm): sunken surface, 1px border with a 2px bottom edge, 11px UI font, muted text. Typical placement: pushed to the row's trailing edge, opposite the label (e.g. the sidebar search row).</p>
<div class="stage-wrap">
<div class="stage-bar"><span class="st">Kbd · keycaps</span></div>
<div class="stage p">
<span class="p-kbd"><kbd></kbd><kbd>K</kbd></span>
<span class="p-kbd"><kbd>Ctrl</kbd><kbd>K</kbd></span>
<span class="p-kbd"><kbd></kbd><kbd></kbd><kbd>P</kbd></span>
</div>
</div>
<!-- ===== Card / Surface ===== -->
<h3 class="sub">Card / Surface</h3>
<p>All cards across the site share <b>one shell</b>: flat, <code>1px</code> border, <code>--radius-md</code> radius, <b>no shadow</b>. The structure is split into three parts <code>head / body / foot</code>. Cards differ <b>only in the head</b> in two tiers by visual weight, while the shell stays consistent:</p>
@ -1336,13 +1353,13 @@ onUnmounted(() => {
</p>
<h3 class="sub">Layout grid</h3>
<p>On desktop it is a single-row 5-track grid: the sidebar and the right panel each occupy a permanent track, with the conversation column in the middle; two 0-width tracks are for the ResizeHandles.</p>
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">App.vue · .app</span></div><pre>grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
/* sidebar ↑ ↑handle ↑conversation ↑handle ↑right panel (auto) */</pre></div>
<p>On desktop it is a single-row 5-track grid: the sidebar and the right panel each occupy a permanent <code>auto</code> track, with the conversation column in the middle; two 0-width tracks are for the ResizeHandles.</p>
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">App.vue · .app</span></div><pre>grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;
/* sidebar ↑ ↑handle ↑conversation ↑handle ↑right panel (auto) */</pre></div>
<table class="dt">
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">--side-w</td><td class="val">248px (adjustable)</td><td>left conversation column width, changed by dragging the ResizeHandle; should approach §02's <code>--p-sidebar-w</code> (264px)</td></tr>
<tr><td class="tk">sidebar width</td><td class="val">270px default (adjustable)</td><td>expanded sidebar width, changed by dragging the ResizeHandle; should approach §02's <code>--p-sidebar-w</code> (264px)</td></tr>
<tr><td class="tk">--preview-w</td><td class="val">460px</td><td>width of the right preview panel when open</td></tr>
<tr><td class="tk">--panel-head-h</td><td class="val">48px</td><td>unified height for all right panel heads + the conversation column head, so the hairline runs as one line</td></tr>
<tr><td class="tk">--p-bp-sm</td><td class="val">640px</td><td>640 switches to a mobile single column (top bar + conversation), no sidebar / handle / right panel</td></tr>
@ -1350,17 +1367,18 @@ onUnmounted(() => {
</table>
<ul class="clean">
<li>The right panel track exists permanently, with its width transitioning between <code>0 var(--preview-w)</code> (when open it squeezes the conversation column, rather than switching templates).</li>
<li>When the sidebar is collapsed, track 1 becomes a thin "rail" holding only an expand IconButton, avoiding crushing the conversation column head.</li>
<li>The sidebar collapses SYMMETRICALLY to the right panel: its container width animates to 0 while the content keeps its fixed width anchored to the right edge (clipped, sliding out left no reflow, hairline stays on the clipped content). No rail remains. The collapse control differs by platform: on <b>macOS desktop</b> the toggle is a single resident floating IconButton pinned beside the traffic lights (rendered in both states, only the glyph swaps the sidebar slides underneath it, never moves or flashes); on <b>Windows / web</b> the collapse button lives inside the sidebar header (right-aligned), and a floating expand button appears at the top-left only while collapsed. The conversation header pads left in step with the transition while collapsed.</li>
<li>All grid children must have <code>min-height:0; min-width:0</code>, so only the inner scroll containers scroll and the page itself does not scroll.</li>
</ul>
<h3 class="sub">Sidebar alignment system (<code>--sb-*</code>)</h3>
<p>All sidebar rows (group head, session row, New chat button) share 3 custom properties, so the "session title" aligns precisely under the "workspace name".</p>
<p>All sidebar rows (group head, session row, New chat button) share 4 custom properties, so the "session title" aligns precisely under the "workspace name".</p>
<table class="dt">
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">--sb-pad-x</td><td class="val">16px</td><td>row horizontal padding</td></tr>
<tr><td class="tk">--sb-gutter</td><td class="val">20px</td><td>leading icon slot width (14px icon + 6px whitespace)</td></tr>
<tr><td class="tk">--sb-inset</td><td class="val">12px</td><td>row box (hover/selected pill) inset from the sidebar edges matches the brand header's 12px padding</td></tr>
<tr><td class="tk">--sb-pad-x</td><td class="val">20px</td><td>content start x (= --sb-inset + 8px row padding)</td></tr>
<tr><td class="tk">--sb-gutter</td><td class="val">16px</td><td>leading icon slot width matches the workspace folder icon so the session title aligns under the workspace name</td></tr>
<tr><td class="tk">--sb-gap</td><td class="val">6px</td><td>gap between the icon slot and the text</td></tr>
</tbody>
</table>
@ -1369,15 +1387,16 @@ onUnmounted(() => {
</div></div>
<h3 class="sub">Sidebar structure</h3>
<p>The sidebar from top to bottom: brand header search New chat grouped list (workspace head + session rows). Controls reuse the §03 primitives as much as possible.</p>
<p>The sidebar from top to bottom: brand header New chat search grouped list (workspace head + session rows) settings footer. Controls reuse the §03 primitives as much as possible. The sidebar sits on <code>--color-sidebar-bg</code> (one step off <code>--color-bg</code>: warm off-white in light, near-black in dark the session column reads as its own plane; the hairline still separates it from the conversation pane). Vertical rhythm: the brand header keeps 12px padding (on macOS desktop the left padding grows to 80px to clear the traffic lights); rows inside the actions group (New chat + search) stack flush (0 gap, same rhythm as the list rows); adjacent groups are separated by 12px. Row hover uses <code>--sb-hover</code> (= the global <code>--color-hover</code> wash); the selected row uses <code>--color-selected</code> neutral, never the accent.</p>
<table class="dt">
<thead><tr><th>Block</th><th>Use</th><th>Note</th></tr></thead>
<tbody>
<tr><td>Brand header</td><td>logo + name + IconButton</td><td>collapse / settings use IconButton sm; the logo is animated (a blinking eye)</td></tr>
<tr><td>Search</td><td>bare search row (custom)</td><td>no border, hover/focus shows a sunken background; icon + input + clear IconButton. <b>Do not</b> use Input (the 38px bordered version is too heavy)</td></tr>
<tr><td>New chat</td><td>full-width left-aligned button (custom)</td><td>same rhythm as the session rows in the list (left-aligned, hover sunken). <b>Do not</b> use Button (centered, breaks the rhythm)</td></tr>
<tr><td>Brand header</td><td>logo + name + collapse IconButton (right-aligned)</td><td>on Windows / web the brand is left and the collapse IconButton sm is right-aligned inside the header; the logo is animated (a blinking eye). On macOS desktop the header is a bare drag strip (brand hidden, traffic lights + resident floating toggle over it)</td></tr>
<tr><td>New chat</td><td>full-width left-aligned button (custom)</td><td>same rhythm as the session rows in the list (left-aligned, hover = <code>--sb-hover</code>). <b>Do not</b> use Button (centered, breaks the rhythm)</td></tr>
<tr><td>Search</td><td>bare search row (custom)</td><td>no border, hover/focus shows a sunken background; icon + label, with the <code>Kbd</code> keycaps (K / Ctrl K) pushed to the trailing edge label and shortcut are justified apart. <b>Do not</b> use Input (the 38px bordered version is too heavy). Last fixed row above the list its wrapper carries the scroll-linked seam</td></tr>
<tr><td>Section label</td><td><code>.p-section-label</code></td><td>uppercase muted small titles like "Workspaces"</td></tr>
<tr><td>Workspace head / session row</td><td>see next two sections</td><td>share <code>--sb-*</code> alignment</td></tr>
<tr><td>Settings footer</td><td>full-width left-aligned button (custom)</td><td>pinned row under the session list, separated by a 1px <code>--line</code> top border; icon + label, same list-style family as New chat</td></tr>
</tbody>
</table>
<div class="callout warn"><span class="ico">!</span><div>
@ -1389,7 +1408,7 @@ onUnmounted(() => {
<table class="dt">
<thead><tr><th>Part</th><th>Rule</th></tr></thead>
<tbody>
<tr><td>Container</td><td><code>margin: 1px 6px; padding: 7px 10px; radius-md</code>; hover = <code>surface-sunken</code>; active = <code>accent-soft</code> + <code>inset 0 0 0 1px accent-bd</code></td></tr>
<tr><td>Container</td><td><code>padding: 8px 8px</code> inside the list's <code>--sb-inset</code> gutter, <code>radius-sm</code>; <b>no fixed/min height</b> row height is font-driven (title <code>line-height: --leading-tight</code>, 16px) 32px total, the sidebar-wide row rhythm. The hover kebab is absolutely positioned so it never forces the row taller (no hover jitter). hover = <code>--sb-hover</code> (the global <code>--color-hover</code> wash); active = <code>--color-selected</code> neutral, no accent tint, no border, no weight change</td></tr>
<tr><td>Status slot (lead)</td><td>fixed <code>--sb-gutter</code> width; running = <code>Spinner</code> sm, otherwise unread = 7px accent dot</td></tr>
<tr><td>Title</td><td>flex:1 with truncation; double-click enters inline rename (compact input, not Input)</td></tr>
<tr><td>Time</td><td>mono xs, <code>fg-faint</code>; yields to the kebab on hover</td></tr>
@ -1400,11 +1419,11 @@ onUnmounted(() => {
</table>
<h3 class="sub">Workspace group</h3>
<p>The group head and session rows share <code>--sb-*</code>: folder icon (open/closed) name path subtitle, with the kebab and "+" revealed on hover.</p>
<p>The group head and session rows share <code>--sb-*</code>: folder icon (open/closed) name, with the kebab and "+" revealed on hover.</p>
<ul class="clean">
<li>The folder icon sits in the <code>--sb-gutter</code> slot, switching icons between open and closed states.</li>
<li>A small <code>fg-muted</code> path line sits below the name.</li>
<li>The kebab (menu) and "+" (new chat in this workspace) both use <code>IconButton</code> sm, shown on hover or keyboard focus (when not hovered they stay in the tab order via <code>opacity:0</code>, keeping them keyboard-reachable).</li>
<li>The folder icon leads the row (switching icons between open and closed states) with the plain <code>--sb-gap</code> before the name it does not pad out the <code>--sb-gutter</code> slot.</li>
<li>The name is quiet by design regular weight, muted color (<code>--color-text-muted</code>, one step lighter than session titles), so group heads read as grouping labels. No path subtitle; hovering the name shows the full root path in a <code>Tooltip</code>.</li>
<li>The kebab (menu) and "+" (new chat in this workspace) both use <code>IconButton</code> sm inside a floating actions layer anchored to the row's right edge — no reserved layout space, so the name uses the full row width when idle. Shown on hover, keyboard focus, or while the menu is open; the layer backs itself with the sidebar surface (container background) plus the row hover wash (an <code>::after</code> shown only while the row is hovered), so its color exactly equals the row's current background and the overlapped name tail doesn't bleed through (hidden via <code>opacity:0</code>, staying in the tab order).</li>
<li>The group is collapsible; when collapsed its session list is hidden.</li>
</ul>
@ -1413,7 +1432,7 @@ onUnmounted(() => {
<table class="dt">
<thead><tr><th>Part</th><th>Rule</th></tr></thead>
<tbody>
<tr><td class="tk">Container</td><td>session-row pill: <code>display:flex; gap:--sb-gap; min-height:26px</code>, same padding as a session row, <code>radius-md</code>; hover = <code>surface-sunken</code> (no text recolor); <code>:focus-visible</code> uses <code>--p-focus-ring</code></td></tr>
<tr><td class="tk">Container</td><td>session-row pill: <code>display:flex; gap:--sb-gap; padding:8px </code>, <b>no fixed/min height</b> (font-driven, 32px like a session row), same padding as a session row, <code>radius-sm</code>; hover = <code>--sb-hover</code> (no text recolor); <code>:focus-visible</code> uses <code>--p-focus-ring</code></td></tr>
<tr><td class="tk">Lead slot</td><td>empty, <code>--sb-gutter</code> wide, so the label's start x aligns with the session titles (<code>--sb-pad-x + --sb-gutter + --sb-gap</code>)</td></tr>
<tr><td class="tk">Label</td><td><code>font-ui</code>, <code>text-xs</code>, <code>--color-text</code>; flex:1, truncated</td></tr>
<tr><td class="tk">Behavior</td><td>"Load more" fetches the next page and auto-expands; once more than the first page is loaded, "Show less" appears and collapses back to the first page (view-layer trim data is kept, no refetch); "Show all" re-expands</td></tr>
@ -1442,7 +1461,7 @@ onUnmounted(() => {
</ul>
<div class="callout info"><span class="ico">i</span><div>
<b>One-sentence principle:</b> the sidebar / shell is a "list + grid" skeleton that reuses the §02 tokens and §03 primitives (Button / IconButton / Badge / Menu / Spinner / PanelHeader); compact list controls that don't fit a primitive (search, New chat, inline rename, show-more) keep their custom form, governed by this section.
<b>One-sentence principle:</b> the sidebar / shell is a "list + grid" skeleton that reuses the §02 tokens and §03 primitives (Button / IconButton / Badge / Kbd / Menu / Spinner / PanelHeader); compact list controls that don't fit a primitive (search, New chat, inline rename, show-more) keep their custom form, governed by this section.
</div></div>
</section>
@ -1600,7 +1619,7 @@ onUnmounted(() => {
.brand-name { font-weight: 700; font-size: 15px; letter-spacing: -.01em; }
.brand-sub { font-size: 12px; color: var(--d-fg-faint); margin-bottom: 26px; padding-left: 36px; }
.nav-group { margin: 22px 0 8px; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--d-fg-faint); }
.p-section-label { font-size: 13px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--d-fg-faint); }
.p-section-label { font-size: 12px; font-weight: 400; text-transform: uppercase; color: var(--d-fg-faint); }
.nav a {
display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-radius: 7px;
font-size: 13.5px; font-weight: 500; color: var(--d-fg-soft); margin: 1px 0;
@ -1932,6 +1951,16 @@ onUnmounted(() => {
.p-badge.solid { background: var(--p-text); color: var(--p-bg); border-color: var(--p-text); }
.p-badge .p-ic { width: 12px; height: 12px; }
/* Kbd — shortcut keycaps (one <kbd> block per key) */
.p-kbd { display: inline-flex; align-items: center; gap: 3px; }
.p-kbd kbd {
display: inline-flex; align-items: center; justify-content: center;
min-width: 18px; height: 18px; padding: 0 5px;
border: 1px solid var(--p-line); border-bottom-width: 2px; border-radius: var(--p-r-xs);
background: var(--p-surface-sunken); color: var(--p-text-muted);
font-family: var(--p-font-sans); font-size: 11px; line-height: 1;
}
/* model / mode pill (composer toolbar) */
.p-pill {
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px;

View file

@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { i18n } from '../src/i18n';
import { STORAGE_KEYS, safeGetString } from '../src/lib/storage';
import {
approvalNotificationCopy,
completionNotificationCopy,
questionNotificationCopy,
shouldNotifyCompletion,
useNotification,
} from '../src/composables/client/useNotification';
@ -41,11 +43,17 @@ function installStorage(storage: Storage): void {
// Singleton — module-level refs + setters. The OS Notification API is absent in
// the test env, so the *enable* path is a no-op; the disable path and the
// load-from-storage defaults are what we exercise here.
const { notifyOnComplete, notifyOnQuestion, setNotifyOnComplete, setNotifyOnQuestion } = useNotification();
// Captured at import (before beforeEach touches the refs), so these reflect the
// load-from-storage defaults when nothing has been stored yet.
const {
notifyOnComplete,
notifyOnQuestion,
notifyOnApproval,
setNotifyOnComplete,
setNotifyOnQuestion,
setNotifyOnApproval,
} = useNotification();
const importedCompleteDefault = notifyOnComplete.value;
const importedQuestionDefault = notifyOnQuestion.value;
const importedApprovalDefault = notifyOnApproval.value;
describe('useNotification preferences', () => {
beforeEach(() => {
@ -64,6 +72,10 @@ describe('useNotification preferences', () => {
expect(importedQuestionDefault).toBe(false);
});
it('approval notifications default to off', () => {
expect(importedApprovalDefault).toBe(false);
});
it('disabling question notifications persists "0" and updates the ref', () => {
void setNotifyOnQuestion(false);
expect(notifyOnQuestion.value).toBe(false);
@ -75,6 +87,12 @@ describe('useNotification preferences', () => {
expect(notifyOnComplete.value).toBe(false);
expect(safeGetString(STORAGE_KEYS.notifyOnComplete)).toBe('0');
});
it('disabling approval notifications persists "0" and updates the ref', () => {
void setNotifyOnApproval(false);
expect(notifyOnApproval.value).toBe(false);
expect(safeGetString(STORAGE_KEYS.notifyOnApproval)).toBe('0');
});
});
describe('notification copy', () => {
@ -110,6 +128,32 @@ describe('notification copy', () => {
});
});
it('uses tool name in approval notifications', () => {
expect(approvalNotificationCopy('Refactor auth flow', 'bash')).toEqual({
title: 'Kimi Code · Approval required',
body: 'bash',
});
});
it('falls back to session title and then generic approval line', () => {
expect(approvalNotificationCopy('Refactor auth flow', ' ')).toEqual({
title: 'Kimi Code · Approval required',
body: 'Refactor auth flow',
});
expect(approvalNotificationCopy(' ', ' ')).toEqual({
title: 'Kimi Code · Approval required',
body: 'A tool needs your approval',
});
});
it('localizes approval notification copy', () => {
i18n.global.locale.value = 'zh';
expect(approvalNotificationCopy('', '')).toEqual({
title: 'Kimi Code · 等待审批',
body: '有工具等待你审批',
});
});
it('localizes the notification copy', () => {
i18n.global.locale.value = 'zh';
@ -123,3 +167,83 @@ describe('notification copy', () => {
});
});
});
describe('shouldNotifyCompletion', () => {
it('returns true only for idle + no pending approval + no pending question', () => {
expect(shouldNotifyCompletion('idle', false, false)).toBe(true);
});
it('returns false for aborted', () => {
expect(shouldNotifyCompletion('aborted', false, false)).toBe(false);
});
it('returns false when pending approval exists', () => {
expect(shouldNotifyCompletion('idle', true, false)).toBe(false);
});
it('returns false when pending question exists', () => {
expect(shouldNotifyCompletion('idle', false, true)).toBe(false);
});
});
// Same-tag notifications replace silently (renotify is unreliable), so the tag
// must be unique per turn/request for follow-up alerts in a session to pop.
describe('notification tags', () => {
class FakeNotification {
static permission = 'granted';
static fired: Array<{ title: string; tag?: string }> = [];
onclick: (() => void) | null = null;
constructor(title: string, options?: { body?: string; tag?: string; icon?: string }) {
FakeNotification.fired.push({ title, tag: options?.tag });
}
close(): void {}
}
const { maybeNotifyCompletion, maybeNotifyQuestion, maybeNotifyApproval } = useNotification();
const base = { isUserWatching: false, sessionTitle: 'T', onClick: () => {} };
beforeEach(() => {
FakeNotification.fired = [];
(globalThis as Record<string, unknown>).Notification = FakeNotification;
notifyOnComplete.value = true;
notifyOnQuestion.value = true;
notifyOnApproval.value = true;
});
afterEach(() => {
delete (globalThis as Record<string, unknown>).Notification;
notifyOnComplete.value = true;
notifyOnQuestion.value = false;
notifyOnApproval.value = false;
});
it('completion tags carry the prompt id so each turn in a session alerts', () => {
maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
maybeNotifyCompletion('s1', { ...base, promptId: 'p2' });
expect(FakeNotification.fired.map((f) => f.tag)).toEqual([
'kimi-complete-s1-p1',
'kimi-complete-s1-p2',
]);
});
it('a replayed idle event for the same turn keeps the same tag', () => {
maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
expect(FakeNotification.fired).toHaveLength(2);
expect(FakeNotification.fired[0]?.tag).toBe(FakeNotification.fired[1]?.tag);
});
it('question and approval tags are per-request', () => {
maybeNotifyQuestion({ ...base, questionPreview: 'q', questionId: 'q1' });
maybeNotifyApproval({ ...base, toolName: 'bash', approvalId: 'a1' });
expect(FakeNotification.fired.map((f) => f.tag)).toEqual([
'kimi-question-q1',
'kimi-approval-a1',
]);
});
it('suppresses the notification while the user is watching the session', () => {
maybeNotifyCompletion('s1', { ...base, isUserWatching: true, promptId: 'p1' });
expect(FakeNotification.fired).toHaveLength(0);
});
});

View file

@ -206,6 +206,94 @@ describe('messagesToTurns', () => {
expect(turns[0]).toMatchObject({ role: 'user', text: tag });
expect(turns[0]?.images).toBeUndefined();
});
it('strips the hidden image-compression caption from a user bubble', () => {
// The server persists this `<system>` note as its own text part next to a
// compressed upload (buildImageCompressionCaption). It is model-facing
// harness metadata and must never render as user-typed text.
const caption =
'<system>Image compressed to fit model limits: original 3024x1834 image/png (934 KB) -> ' +
'sent 2000x1213 image/png (518 KB). Fine detail may be lost. The uncompressed original ' +
'is saved at "/Users/me/.kimi-code/files/f_0000000000000000000000000"; if you need fine ' +
'detail, call ReadMediaFile on that path with the region parameter to view a crop at full ' +
'fidelity.</system>';
const turns = messagesToTurns(
[
message('u1', 'user', [
{ type: 'text', text: 'look at this' },
{ type: 'text', text: caption },
]),
],
[],
undefined,
false,
);
expect(turns).toHaveLength(1);
expect(turns[0]).toMatchObject({ role: 'user', text: 'look at this' });
expect(turns[0]?.text).not.toContain('<system>');
});
it('drops a caption-only text part and strips captions merged into prose', () => {
const caption =
'<system>Image compressed to fit model limits: original 100x100 image/png (1 KB) -> ' +
'sent 100x100 image/png (1 KB). Fine detail may be lost.</system>';
// Image-only upload: the caption is the sole text part, so nothing
// user-typed remains and the bubble text is empty (the image still renders).
const captionOnly = messagesToTurns(
[message('u1', 'user', [{ type: 'text', text: caption }])],
[],
undefined,
false,
);
expect(captionOnly[0]).toMatchObject({ role: 'user', text: '' });
// TUI-paste style: a caption merged into the surrounding text segment is
// stripped without eating the prose around it.
const merged = messagesToTurns(
[message('u2', 'user', [{ type: 'text', text: `before ${caption} after` }])],
[],
undefined,
false,
);
expect(merged[0]?.text).not.toContain('<system>');
expect(merged[0]?.text).toContain('before');
expect(merged[0]?.text).toContain('after');
});
it('preserves a literal `<system>` block the user typed themselves', () => {
// Only the image-compression caption is harness metadata. A `<system>` tag
// the user pasted on purpose (e.g. an XML / prompt example) is their own
// text, so it must reach the bubble and the edit/resend payload verbatim.
const turns = messagesToTurns(
[
message('u1', 'user', [
{ type: 'text', text: 'hi <system>some example markup</system> there' },
]),
],
[],
undefined,
false,
);
expect(turns[0]?.text).toBe('hi <system>some example markup</system> there');
});
it('leaves ordinary user text and stray angle brackets untouched', () => {
const turns = messagesToTurns(
[
message('u1', 'user', [
{ type: 'text', text: 'a < b and c > d, no system tag here' },
]),
],
[],
undefined,
false,
);
expect(turns[0]).toMatchObject({ role: 'user', text: 'a < b and c > d, no system tag here' });
});
});
describe('latestTodos', () => {

View file

@ -638,7 +638,7 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => {
// Activation must NOT have started while /profile is still pending.
await new Promise((r) => setTimeout(r, 0));
expect(persistSessionProfile).toHaveBeenCalledWith(
{ planMode: true, swarmMode: true, permissionMode: 'auto', thinking: 'high' },
{ model: undefined, planMode: true, swarmMode: true, permissionMode: 'auto', thinking: 'high' },
'sess_new',
);
expect(activateSkill).not.toHaveBeenCalled();

View file

@ -1,7 +1,9 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Icons from 'unplugin-icons/vite';
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const webPort = Number(process.env.WEB_PORT) || 5175;
// Where the dev proxy forwards server traffic. Defaults to the local server
@ -12,7 +14,19 @@ const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url),
};
export default defineConfig({
plugins: [vue(), Icons({ compiler: 'vue3' })],
plugins: [
vue(),
Icons({
compiler: 'vue3',
// Local Kimi Design System icons (24×24 outlined, fill="currentColor"),
// copied from the design-system icon pack into src/icons/kimi/ and
// imported as `~icons/kimi/<file-name>` (plus `?raw`), same as the ri
// collection. Registered in src/lib/icons.ts only.
customCollections: {
kimi: FileSystemIconLoader(fileURLToPath(new URL('./src/icons/kimi', import.meta.url))),
},
}),
],
// Expose the dev proxy's upstream server target to the client so the UI can
// show which server it is connected to (the browser otherwise only sees its
// own same-origin URL). Unused by the same-origin production build.

View file

@ -87,11 +87,12 @@ Fields in the config file fall into two categories: **top-level scalars** that d
| `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) |
| `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) |
| `background` | `table` | — | Background task runtime parameters → [`background`](#background) |
| `image` | `table` | — | Image compression parameters → [`image`](#image) |
| `services` | `table` | — | Built-in external service configuration → [`services`](#services) |
| `permission` | `table` | — | Initial permission rules → [`permission`](#permission) |
| `hooks` | `array<table>` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) |
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `services`, and `permission`.
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `image`, `services`, and `permission`.
## `providers`
@ -202,6 +203,17 @@ You can also switch models temporarily without touching the config file — by s
In print mode (`kimi -p "<prompt>"`), Kimi Code runs a single non-interactive turn and exits as soon as the main agent finishes. If you launch background tasks (for example, concurrent subagents via `Agent(run_in_background=true)`) and need them to run to completion, set `keep_alive_on_exit = true`: the process then waits for every background task to reach a terminal state before exiting, bounded by `print_wait_ceiling_s`. Without it, the single turn ending tears background tasks down with the process.
## `image`
`image` controls how images are compressed before being sent to the model, across every ingestion point (pasted images, `ReadMediaFile` reads, images in MCP tool results, and so on).
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `max_edge_px` | `integer` | `2000` | Longest-edge ceiling in pixels. Larger images are scaled down proportionally to fit; raising it preserves more detail at the cost of larger request bodies |
| `read_byte_budget` | `integer` | `262144` (256 KB) | Per-image byte budget for images the model reads for itself (`ReadMediaFile` default reads). It bounds the accumulated request-body size when the model keeps screenshotting and reading images; fine detail stays reachable through the `region` parameter, which reads a crop back at full fidelity (`region` and `full_resolution` are not subject to this budget) |
`max_edge_px` can be overridden by the `KIMI_IMAGE_MAX_EDGE_PX` environment variable and `read_byte_budget` by `KIMI_IMAGE_READ_BYTE_BUDGET`; both take higher priority than `config.toml`.
<!--
## `experimental`

View file

@ -122,6 +122,8 @@ Switches that control the behavior of subsystems such as telemetry, background t
| --- | --- | --- |
| `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `KIMI_IMAGE_MAX_EDGE_PX` | Longest-edge ceiling (px) for image compression; takes higher priority than `[image] max_edge_px` in `config.toml` (default `2000`) | Positive integer; invalid values are ignored |
| `KIMI_IMAGE_READ_BYTE_BUDGET` | Per-image byte budget for model-initiated image reads (`ReadMediaFile` default reads); takes higher priority than `[image] read_byte_budget` in `config.toml` (default `262144`, i.e. 256 KB) | Positive integer; invalid values are ignored |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast |
| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` |

View file

@ -6,6 +6,32 @@ outline: 2
This page documents the changes in each Kimi Code CLI release.
## 0.23.4 (2026-07-10)
### Features
- web: Add notifications when a tool needs approval, and improve notification reliability.
### Polish
- web: Polish the chat UI with Inter typography, localized labels, and tighter composer and menu styling.
- web: Polish the session sidebar layout, colors, icons, and typography.
- Display the Extra Usage (fuel pack) balance in the `/usage` and `/status` commands.
- Add a Kimi WebBridge entry to the Official tab of the `/plugins` panel that opens the WebBridge install page in your browser.
### Bug Fixes
- Keep image-heavy sessions within provider request-size limits: oversized images (model-read and pasted, including WebP) are downscaled and compressed, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and an HTTP 413 request-too-large now recovers automatically — the request and `/compact` retry with older media replaced by text markers. The limits are configurable via `[image]` in `config.toml` (or `KIMI_IMAGE_*` env vars), and each core keeps its own settings so reloading one client's config no longer changes another client's compression.
- Fix resuming sessions whose original working directory no longer exists.
- Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts.
- web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent.
## 0.23.3 (2026-07-08)
### Bug Fixes
- Fix a misleading "OAuth login expired" message shown when a model is not available for the current account.
## 0.23.2 (2026-07-08)
### Features

View file

@ -87,11 +87,12 @@ timeout = 5
| `thinking` | `table` | — | Thinking 模式默认参数 → [`thinking`](#thinking) |
| `loop_control` | `table` | — | Agent 循环控制参数 → [`loop_control`](#loop_control) |
| `background` | `table` | — | 后台任务运行参数 → [`background`](#background) |
| `image` | `table` | — | 图片压缩参数 → [`image`](#image) |
| `services` | `table` | — | 内置外部服务配置 → [`services`](#services) |
| `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) |
| `hooks` | `array<table>` | — | 生命周期 hook详见 [Hooks](../customization/hooks.md) |
以下各节对 `providers``models``thinking``loop_control``background``services`、`permission` 等嵌套表逐一展开。
以下各节对 `providers``models``thinking``loop_control``background``image`、`services`、`permission` 等嵌套表逐一展开。
## `providers`
@ -202,6 +203,17 @@ display_name = "Kimi for Coding (custom)"
在 print 模式(`kimi -p "<prompt>"`Kimi Code 只跑一个非交互的单轮 turn主 agent 一结束就退出。如果你启动了后台任务(例如通过 `Agent(run_in_background=true)` 并发子代理)并希望它们跑完,请设置 `keep_alive_on_exit = true`:进程会在退出前等待所有后台任务进入终态,最长不超过 `print_wait_ceiling_s`。否则,单轮 turn 结束时后台任务会随进程一起被清理。
## `image`
`image` 控制图片发送给模型前的压缩行为,对所有图片入口生效(粘贴图片、`ReadMediaFile` 读图、MCP 工具结果里的图片等)。
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `max_edge_px` | `integer` | `2000` | 图片最长边上限(像素)。超过时按比例缩小到该值以内;调大可保留更多细节,代价是更大的请求体积 |
| `read_byte_budget` | `integer` | `262144`256 KB | 模型自行读取的图片(`ReadMediaFile` 默认读取)的单图字节预算。会话中模型反复截图、读图时,累计请求体大小由它控制;细节可通过 `region` 参数按原图坐标全保真回读(`region``full_resolution` 不受此预算限制) |
`max_edge_px` 可被环境变量 `KIMI_IMAGE_MAX_EDGE_PX` 覆盖,`read_byte_budget` 可被 `KIMI_IMAGE_READ_BYTE_BUDGET` 覆盖,优先级均高于配置文件。
<!--
## `experimental`

View file

@ -122,6 +122,8 @@ kimi
| --- | --- | --- |
| `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1``true``yes``y`(不区分大小写) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
| `KIMI_IMAGE_MAX_EDGE_PX` | 图片压缩的最长边上限(像素),优先级高于 `config.toml``[image] max_edge_px`(默认 `2000` | 正整数;非法值被忽略 |
| `KIMI_IMAGE_READ_BYTE_BUDGET` | 模型自行读图(`ReadMediaFile` 默认读取)的单图字节预算,优先级高于 `config.toml``[image] read_byte_budget`(默认 `262144`,即 256 KB | 正整数;非法值被忽略 |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://``file://` URL 和本地路径 |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 |
| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1``true``yes``on` |

View file

@ -6,6 +6,32 @@ outline: 2
本页记录 Kimi Code CLI 每个版本的变更内容。
## 0.23.42026-07-10
### 新功能
- web: 新增工具需要审批时的通知,并提升通知的可靠性。
### 优化
- web: 优化聊天界面,采用 Inter 字体、本地化标签与更紧凑的输入框和菜单样式。
- web: 优化会话侧边栏的布局、配色、图标与字体。
- `/usage``/status` 命令现显示 Extra Usage加油包余额。
- `/plugins` 面板的 Official 标签页新增 Kimi WebBridge 入口,可在浏览器中打开 WebBridge 安装页。
### 修复
- 控制图片较多会话的请求体积:超大体量的模型读取与粘贴图片(含 WebP会自动压缩、缩小HEIC/HEIF 图片会给出对应平台的转换命令而非污染会话HTTP 413 请求过大现可自动恢复——请求和 `/compact` 会用文本标记替换旧媒体后重试。相关限制可通过 `config.toml``[image]`(或 `KIMI_IMAGE_*` 环境变量)配置,且每个 core 独立保存设置,重新加载某客户端的配置不再影响其他客户端的图片压缩。
- 修复原工作目录已不存在的会话无法恢复的问题。
- 修复 prompt 模式目标未运行至完成的问题,并在发送 prompt 前校验并提示无效的目标命令。
- web: 修复新对话发送首条消息时偶发的 “another turn is active” 错误,并在发送过程中显示启动状态。
## 0.23.32026-07-08
### 修复
- 修复当前账户无法使用某模型时错误显示“OAuth 登录已过期”的问题。
## 0.23.22026-07-08
### 新功能

View file

@ -152,7 +152,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-RPjCWL7NqDSKgpHGL16zPlUOfjWN2rkaDY/4GFAD8VA=";
hash = "sha256-iBk+TV+rIhmd7bYnVFbW3kTGltojJl3pL2hhmsGO+Fk=";
};
nativeBuildInputs = [

View file

@ -93,6 +93,12 @@ export async function compressPromptImageParts(
readonly originalsDir?: string | undefined;
/** Report an `image_compress` event per prompt image (source `acp_prompt`). */
readonly telemetry?: TelemetryClient | undefined;
/**
* Longest-edge ceiling (px) from the harness's [image] config, resolved
* per prompt so a config reload applies immediately. Absent the
* env/built-in default cap applies.
*/
readonly maxImageEdgePx?: number | undefined;
} = {},
): Promise<PromptPart[]> {
const out: PromptPart[] = [];
@ -101,6 +107,7 @@ export async function compressPromptImageParts(
const parsed = parseImageDataUrl(part.imageUrl.url);
if (parsed !== null) {
const result = await compressBase64ForModel(parsed.base64, parsed.mimeType, {
maxEdge: options.maxImageEdgePx,
telemetry:
options.telemetry === undefined
? undefined

View file

@ -741,6 +741,7 @@ export class AcpSession {
parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks), {
originalsDir:
sessionDir === undefined ? undefined : sessionMediaOriginalsDir(sessionDir),
maxImageEdgePx: this.harness?.imageLimits?.maxEdgePx(),
telemetry:
track === undefined
? undefined

View file

@ -367,6 +367,39 @@ describe('compressPromptImageParts', () => {
await rm(originalsDir, { recursive: true, force: true });
});
it('downsamples to the caller-provided max edge instead of the built-in cap', async () => {
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
const parts = acpBlocksToPromptParts([imageBlock(await pngBase64(3600, 1800), 'image/png')]);
const compressed = await compressPromptImageParts(parts, {
originalsDir,
maxImageEdgePx: 800,
});
const part = compressed[1];
if (part?.type !== 'image_url') throw new Error('expected an image_url part');
const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(part.imageUrl.url);
expect(match).not.toBeNull();
const decoded = await Jimp.fromBuffer(Buffer.from(match![2]!, 'base64'));
expect(decoded.width).toBe(800);
expect(decoded.height).toBe(400);
await rm(originalsDir, { recursive: true, force: true });
});
it('uses the built-in 2000px cap when no max edge is provided', async () => {
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
const parts = acpBlocksToPromptParts([imageBlock(await pngBase64(3600, 1800), 'image/png')]);
const compressed = await compressPromptImageParts(parts, { originalsDir });
const part = compressed[1];
if (part?.type !== 'image_url') throw new Error('expected an image_url part');
const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(part.imageUrl.url);
expect(match).not.toBeNull();
const decoded = await Jimp.fromBuffer(Buffer.from(match![2]!, 'base64'));
expect(decoded.width).toBe(2000);
expect(decoded.height).toBe(1000);
await rm(originalsDir, { recursive: true, force: true });
});
it('emits image_compress telemetry tagged acp_prompt', async () => {
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
const events: { event: string; props: Record<string, unknown> }[] = [];

View file

@ -59,6 +59,7 @@
},
"dependencies": {
"@antfu/utils": "^9.3.0",
"@jsquash/webp": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@moonshot-ai/kaos": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",

View file

@ -0,0 +1,36 @@
/**
* Regenerate `src/tools/support/webp-dec-wasm.ts` from the installed
* `@jsquash/webp` package.
*
* The WebP decoder wasm is committed as a base64 string module because the
* published CLI bundles every dependency into a single file with no runtime
* node_modules a file-path lookup for the .wasm would break there, while a
* string constant survives every packaging (vitest on sources, tsdown
* bundling, nix builds) unchanged. Run this after bumping @jsquash/webp:
*
* node scripts/generate-webp-dec-wasm.mjs
*/
import { createRequire } from 'node:module';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const packageRoot = resolve(import.meta.dirname, '..');
const require = createRequire(resolve(packageRoot, 'package.json'));
const wasmPath = require.resolve('@jsquash/webp/codec/dec/webp_dec.wasm');
const version = require('@jsquash/webp/package.json').version;
const wasm = readFileSync(wasmPath);
const target = resolve(packageRoot, 'src/tools/support/webp-dec-wasm.ts');
writeFileSync(
target,
`// GENERATED FILE — do not edit by hand.
// WebP decoder wasm from @jsquash/webp@${version} (codec/dec/webp_dec.wasm),
// base64-encoded so the bundled CLI needs no on-disk wasm asset.
// Regenerate with: node scripts/generate-webp-dec-wasm.mjs
export const WEBP_DECODER_WASM_BASE64 =
'${wasm.toString('base64')}';
`,
);
console.log(`Wrote ${target} (${wasm.length} bytes of wasm)`);

View file

@ -8,10 +8,12 @@ import {
APIEmptyResponseError,
inputTotal,
isRetryableGenerateError,
type ContentPart,
type GenerateResult,
type Message,
type TokenUsage,
APIContextOverflowError,
APIRequestTooLargeError,
APIStatusError,
createUserMessage,
} from '@moonshot-ai/kosong';
@ -430,6 +432,7 @@ export class FullCompaction {
// prefix-race check and `compactedCount`.
let historyForModel: readonly ContextMessage[] = stripDynamicToolContext(originalHistory);
let droppedCount = 0;
let mediaStripAttempted = false;
let overflowShrinkCount = 0;
let emptyOrTruncatedShrinkCount = 0;
while (true) {
@ -465,6 +468,24 @@ export class FullCompaction {
summary = extractCompactionSummary(response);
break;
} catch (error) {
// A request-body-size rejection (HTTP 413) is first retried with
// media parts replaced by text markers: accumulated base64 payloads
// are the usual culprit, and a text summary does not need them —
// the conversation already narrates what was seen, and the
// ReadMediaFile `<image path="...">` text wrapper survives. Only
// the summarizer input copy is rewritten; the real history keeps
// its media. A 413 after the strip (or with no media to strip)
// falls through to the overflow shrink below — dropping oldest
// messages shrinks the body too.
if (error instanceof APIRequestTooLargeError && !mediaStripAttempted) {
mediaStripAttempted = true;
const stripped = replaceMediaPartsWithMarkers(historyForModel);
if (stripped !== historyForModel) {
historyForModel = stripped;
retryCount = 0;
continue;
}
}
const isContextOverflow = this.shouldRecoverFromContextOverflow(
error,
estimatedCompactionRequestTokens,
@ -472,7 +493,9 @@ export class FullCompaction {
if (isContextOverflow) {
this.observeContextOverflow(estimatedCompactionRequestTokens);
}
if (isContextOverflow && historyForModel.length > 1) {
const shouldShrinkAfterOverflow =
isContextOverflow || error instanceof APIRequestTooLargeError;
if (shouldShrinkAfterOverflow && historyForModel.length > 1) {
overflowShrinkCount += 1;
if (overflowShrinkCount > MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS) {
throw error;
@ -639,6 +662,40 @@ export class FullCompaction {
const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3;
const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const;
const MEDIA_PART_MARKERS = {
image_url: '[image]',
audio_url: '[audio]',
video_url: '[video]',
} as const;
function isMediaPart(part: ContentPart): part is ContentPart & { type: keyof typeof MEDIA_PART_MARKERS } {
return part.type in MEDIA_PART_MARKERS;
}
/**
* Replace media parts (image/audio/video) with text markers in the summarizer
* input, for the 413 strip-and-retry above. Messages without media are
* returned by reference (keeping the per-message token-estimate cache warm),
* and when nothing changed the input array itself is returned so the caller
* can tell there was no media to strip.
*/
function replaceMediaPartsWithMarkers(
messages: readonly ContextMessage[],
): readonly ContextMessage[] {
let changed = false;
const out = messages.map((message) => {
if (!message.content.some(isMediaPart)) return message;
changed = true;
return {
...message,
content: message.content.map((part): ContentPart =>
isMediaPart(part) ? { type: 'text', text: MEDIA_PART_MARKERS[part.type] } : part,
),
};
});
return changed ? out : messages;
}
function shrinkCompactionHistoryAfterOverflow<T extends Message>(
messages: readonly T[],
attempt: number,

View file

@ -48,7 +48,7 @@ export class ConfigState {
});
if (changed.cwd) {
this._cwd = changed.cwd;
void this.agent.kaos.chdir(changed.cwd);
this.agent.setKaos(this.agent.kaos.withCwd(changed.cwd));
}
if (changed.modelAlias) {
this._modelAlias = changed.modelAlias;

View file

@ -18,6 +18,8 @@ import {
type CompactionResult,
} from '../compaction';
import {
degradeOlderMediaParts,
MEDIA_DEGRADE_KEEP_RECENT,
project,
type ProjectionAnomaly,
type ProjectOptions,
@ -488,6 +490,17 @@ export class ContextMemory {
});
}
// Fallback projection for the post-413 media-degraded resend: the normal
// wire projection with all but the most recent media parts replaced by text
// markers, so a request body bloated by accumulated base64 media fits the
// provider's size limit. Purely read-side — the history keeps its media —
// and only used when the provider has already rejected the normal
// projection as too large; see the request-too-large fallback in
// `turn-step`.
get mediaDegradedMessages(): Message[] {
return degradeOlderMediaParts(this.messages, MEDIA_DEGRADE_KEEP_RECENT);
}
useProjectedHistoryFrom(source: ContextMemory): void {
this.clear();
this.pushHistory(...trimTrailingOpenToolExchange(source.project(source.history)));

View file

@ -465,3 +465,58 @@ export function trimTrailingOpenToolExchange(history: readonly Message[]): Messa
const closed = assistant.toolCalls.every((toolCall) => trailingToolCallIds.has(toolCall.id));
return closed ? [...history] : history.slice(0, lastNonToolIndex);
}
/**
* How many of the most recent media parts survive the media-degraded
* projection. The tail images are what the model is actively working from
* (the screenshot it just took); everything older is replaced by a marker.
*/
export const MEDIA_DEGRADE_KEEP_RECENT = 2;
const MEDIA_DEGRADED_PLACEHOLDERS = {
image_url:
'[image omitted: dropped to fit the provider request size limit; re-read the file to view it]',
audio_url:
'[audio omitted: dropped to fit the provider request size limit; re-read the file to hear it]',
video_url:
'[video omitted: dropped to fit the provider request size limit; re-read the file to view it]',
} as const;
function isDegradableMediaPart(
part: ContentPart,
): part is ContentPart & { type: keyof typeof MEDIA_DEGRADED_PLACEHOLDERS } {
return part.type in MEDIA_DEGRADED_PLACEHOLDERS;
}
/**
* Replace all but the `keepRecent` most recent media parts with deterministic
* text markers. This is the media-degraded projection used to resend a request
* the provider rejected as too large (HTTP 413 on accumulated base64 media):
* a purely read-side transform the underlying history is left untouched
* that trades old pixels for bytes while the surrounding text (including
* ReadMediaFile's `<image path="...">` wrapper) survives, so the model can
* re-read any file it still needs. Untouched messages are returned by
* reference, and when nothing needs degrading the input array itself is
* returned.
*/
export function degradeOlderMediaParts(
messages: readonly Message[],
keepRecent: number,
): Message[] {
const mediaCount = messages.reduce(
(count, message) => count + message.content.filter(isDegradableMediaPart).length,
0,
);
let toDegrade = Math.max(0, mediaCount - keepRecent);
if (toDegrade === 0) return messages as Message[];
return messages.map((message) => {
if (toDegrade === 0 || !message.content.some(isDegradableMediaPart)) return message;
const content = message.content.map((part): ContentPart => {
if (toDegrade === 0 || !isDegradableMediaPart(part)) return part;
toDegrade -= 1;
return { type: 'text', text: MEDIA_DEGRADED_PLACEHOLDERS[part.type] };
});
return { ...message, content };
});
}

View file

@ -14,6 +14,7 @@ import type { PluginCommandOrigin } from './context';
import type { McpConnectionManager } from '../mcp';
import { FlagResolver, type ExperimentalFlagResolver } from '../flags';
import { ImageLimits } from '../tools/support/image-limits';
import {
prepareSystemPromptContext,
type PreparedSystemPromptContext,
@ -96,6 +97,8 @@ export interface AgentOptions {
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
readonly pluginCommands?: readonly PluginCommandDef[];
readonly experimentalFlags?: ExperimentalFlagResolver;
/** Owner-scoped [image] limits; a standalone Agent gets env/built-in defaults. */
readonly imageLimits?: ImageLimits;
readonly replay?: ReplayBuilderOptions;
readonly additionalDirs?: readonly string[];
readonly systemPromptContextProvider?: (() => Promise<PreparedSystemPromptContext>) | undefined;
@ -124,6 +127,7 @@ export class Agent {
readonly log: Logger;
readonly telemetry: TelemetryClient;
readonly experimentalFlags: ExperimentalFlagResolver;
readonly imageLimits: ImageLimits;
readonly llmRequestLogger: LlmRequestLogger;
readonly llmRequestRecorder: LlmRequestRecorder;
@ -178,6 +182,7 @@ export class Agent {
this.log = options.log ?? log;
this.telemetry = options.telemetry ?? noopTelemetryClient;
this.experimentalFlags = options.experimentalFlags ?? new FlagResolver();
this.imageLimits = options.imageLimits ?? new ImageLimits();
this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []);
this.systemPromptContextProvider = options.systemPromptContextProvider;

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