From 2394d013bc8b01a031af95f2927b4ac6647971e0 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 8 Jul 2026 17:02:00 +0800 Subject: [PATCH 01/19] docs(changelog): sync 0.23.2 from apps/kimi-code/CHANGELOG.md (#1496) --- docs/en/release-notes/changelog.md | 28 ++++++++++++++++++++++++++++ docs/zh/release-notes/changelog.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index a751feccf..7b98cd3f5 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,34 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.23.2 (2026-07-08) + +### Features + +- Add the Vercel plugin to the bundled plugin marketplace. Run `/plugins` and select Vercel Plugin to install it. + +### Bug Fixes + +- Fix `kimi -p` runs exiting with code 0 when a turn fails. +- Prevent autonomous goals from being paused by model-reported status updates. +- Count the turn that starts an autonomous goal toward its turn budget. +- Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly. +- web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background. +- Fix console windows flashing on Windows each time a hook runs. + +### Polish + +- web: Redesign the scheduled reminder UI. +- web: Show session skills in the slash menu as `/skill:` so they are distinguishable from built-in commands; typing the bare skill name still works. +- web: The composer model switcher switches the active session's model as before and additionally bumps the global default model, so new sessions inherit the choice. +- web: Press Enter to confirm in archive and other confirmation dialogs. +- Tighten goal-mode guidance for blocked and complete status updates. +- Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them, and the model re-selects the tools it still needs afterward. A from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active. + +### Refactors + +- web: Compile icons at build time so the bundled web UI only carries the icons it renders. + ## 0.23.1 (2026-07-07) ### Bug Fixes diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 89124bdac..bce292cfc 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,34 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.23.2(2026-07-08) + +### 新功能 + +- 内置插件市场新增 Vercel 插件,运行 `/plugins` 并选择 Vercel Plugin 即可安装。 + +### 修复 + +- 修复 `kimi -p` 在轮次失败时仍以退出码 0 退出的问题。 +- 修复自主目标会被模型上报的状态更新暂停的问题。 +- 修复启动自主目标的轮次未计入其轮次预算的问题。 +- 将图片降采样上限从 2000px 提高到 3000px,并修复 EXIF 旋转(竖拍)照片在压缩说明与媒体读取备注中宽高互换的问题,使区域回读坐标正确对应。 +- web: 修复从后台返回后,WebSocket 重连完成但连接错误提示仍残留的问题。 +- 修复 Windows 上每次运行 hook 时控制台窗口闪烁的问题。 + +### 优化 + +- web: 重新设计定时提醒界面。 +- web: 在斜杠菜单中以 `/skill:` 显示会话技能,便于与内置命令区分;直接输入技能名称仍然可用。 +- web: 输入框的模型切换器在切换当前会话模型的同时,也会更新全局默认模型,使新会话继承该选择。 +- web: 归档等确认对话框支持按 Enter 确认。 +- 优化目标模式对阻塞与完成状态更新的指引。 +- 渐进式工具加载(`select_tools`,实验功能):压缩后丢弃已加载的工具 schema,由模型重新选择仍需要的工具,使压缩后上下文保持精简;凭记忆调用未再加载的工具会被拒绝,并提示先选择。仅在启用 `tool-select` 实验标志且模型支持 `select_tools` 时生效。 + +### 重构 + +- web: 在构建时编译图标,使打包后的 web UI 仅包含实际渲染的图标。 + ## 0.23.1(2026-07-07) ### 修复 From e83511a7118652a67676bbcfd41148907ad7b8de Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 8 Jul 2026 21:16:44 +0800 Subject: [PATCH 02/19] fix: surface provider auth error for unavailable models (#1506) * fix: surface provider auth error for unavailable models When an OAuth-managed model returns 401 after a forced token refresh, the token is valid but the provider rejected it for that model (the account lacks access). Emit provider.auth_error carrying the provider's message instead of auth.login_required with a misleading "OAuth login expired. Send /login" prompt. * fix(agent-core): preserve provider auth errors through compaction Treat provider.auth_error like auth.login_required in the compaction path so an auth rejection during compaction surfaces the provider's message instead of being wrapped as a generic compaction failure. --- .changeset/fix-model-auth-error-message.md | 5 +++++ packages/agent-core/src/agent/compaction/full.ts | 7 ++++++- packages/agent-core/src/session/provider-manager.ts | 5 +++-- .../agent-core/test/agent/compaction/full.test.ts | 4 ++-- packages/agent-core/test/agent/turn.test.ts | 11 ++++++----- 5 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-model-auth-error-message.md diff --git a/.changeset/fix-model-auth-error-message.md b/.changeset/fix-model-auth-error-message.md new file mode 100644 index 000000000..944dbdd6d --- /dev/null +++ b/.changeset/fix-model-auth-error-message.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +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. diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index ca1f19c8b..005da411f 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -595,7 +595,12 @@ export class FullCompaction { thinking_effort: this.agent.config.thinkingEffort, error_type: error instanceof Error ? error.name : 'Unknown', }); - if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error; + if ( + isKimiError(error) && + (error.code === ErrorCodes.AUTH_LOGIN_REQUIRED || + error.code === ErrorCodes.PROVIDER_AUTH_ERROR) + ) + throw error; throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error }); } } diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index b67aef318..14a6fb5c9 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -203,9 +203,10 @@ export class ProviderManager implements ModelProvider { } catch (error) { if (!(error instanceof APIStatusError) || error.statusCode !== 401) throw error; if (refreshed) { + const reason = error.message.replaceAll('\r', ''); throw new KimiError( - ErrorCodes.AUTH_LOGIN_REQUIRED, - 'OAuth provider credentials were rejected. Send /login to login.', + ErrorCodes.PROVIDER_AUTH_ERROR, + reason.length > 0 ? reason : 'OAuth provider credentials were rejected.', { cause: error, details: { statusCode: error.statusCode, requestId: error.requestId }, diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index ff9f2ce76..61ab5dd13 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -362,7 +362,7 @@ describe('FullCompaction', () => { expect(messageText(compactionCall?.history[5])).toBe('lookup result'); }); - it('force-refreshes OAuth credentials on compaction 401 and falls back to login_required when replay 401', async () => { + it('force-refreshes OAuth credentials on compaction 401 and treats replay 401 as provider auth error', async () => { const tokenCalls: Array = []; const authKeys: string[] = []; const oauthOptions = oauthTestAgentOptions(async (options) => { @@ -398,7 +398,7 @@ describe('FullCompaction', () => { expect.objectContaining({ event: 'error', args: expect.objectContaining({ - code: 'auth.login_required', + code: 'provider.auth_error', details: expect.objectContaining({ statusCode: 401, requestId: 'req-compact-401', diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 5343b221e..68bd19de1 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -1409,7 +1409,7 @@ describe('Agent turn flow', () => { ); }); - it('falls back to login_required when force-refresh and replay both 401', async () => { + it('treats 401 after force-refresh as provider auth error', async () => { const tokenCalls: Array = []; const authKeys: string[] = []; const oauthOptions = oauthAgentOptions( @@ -1447,7 +1447,7 @@ describe('Agent turn flow', () => { args: expect.objectContaining({ reason: 'failed', error: expect.objectContaining({ - code: 'auth.login_required', + code: 'provider.auth_error', details: expect.objectContaining({ statusCode: 401, requestId: 'req-401', @@ -1644,7 +1644,7 @@ describe('Agent turn flow', () => { expect(payloads[1]).toMatchObject({ turnStep: '0.1', attempt: '2/3' }); }); - it('force-refreshes OAuth credentials on video upload 401 and falls back to login_required when replay 401', async () => { + it('force-refreshes OAuth credentials on video upload 401 and surfaces the provider auth error when replay 401', async () => { const tokenCalls: Array = []; const authKeys: string[] = []; const oauthOptions = oauthAgentOptions( @@ -1689,8 +1689,9 @@ describe('Agent turn flow', () => { expect(result.isError).toBe(true); expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']); expect(tokenCalls).toEqual([undefined, true]); - expect(result.output).toContain('OAuth provider credentials were rejected'); - expect(result.output).toContain('Send /login to login'); + expect(result.output).toContain('Unauthorized'); + expect(result.output).not.toContain('OAuth provider credentials were rejected'); + expect(result.output).not.toContain('Send /login to login'); }); it('cancels an active turn', async () => { From 93c0b7bb7836fa990cd9cd35f6518ed55841d2fe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:18:00 +0800 Subject: [PATCH 03/19] ci: release packages (#1507) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/fix-model-auth-error-message.md | 5 ----- apps/kimi-code/CHANGELOG.md | 6 ++++++ apps/kimi-code/package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/fix-model-auth-error-message.md diff --git a/.changeset/fix-model-auth-error-message.md b/.changeset/fix-model-auth-error-message.md deleted file mode 100644 index 944dbdd6d..000000000 --- a/.changeset/fix-model-auth-error-message.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -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. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 4cb493e84..9bde39c4a 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kimi-code +## 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. + ## 0.23.2 ### Patch Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 9512d71bb..4d9f8df77 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.23.2", + "version": "0.23.3", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", From b89fc1a4fbe8c0c3933659cb86b325c82731cf8f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 23:33:29 +0800 Subject: [PATCH 04/19] docs(changelog): sync 0.23.3 and shorten OAuth error entry (#1509) --- apps/kimi-code/CHANGELOG.md | 2 +- docs/en/release-notes/changelog.md | 6 ++++++ docs/zh/release-notes/changelog.md | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 9bde39c4a..2a3fc5857 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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 diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 7b98cd3f5..d0549df1c 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,12 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 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 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index bce292cfc..444699fdc 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,12 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.23.3(2026-07-08) + +### 修复 + +- 修复当前账户无法使用某模型时错误显示“OAuth 登录已过期”的问题。 + ## 0.23.2(2026-07-08) ### 新功能 From 735922c291ec3d32d60da6af053f75e1c6179f92 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 12:30:15 +0800 Subject: [PATCH 05/19] 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 --- .changeset/web-approval-notifications.md | 5 + apps/kimi-web/src/App.vue | 2 + .../components/settings/SettingsDialog.vue | 15 ++ .../src/composables/client/useNotification.ts | 114 +++++++++++---- .../client/useSoundNotification.ts | 16 ++- .../src/composables/useKimiWebClient.ts | 75 +++++++--- apps/kimi-web/src/i18n/locales/en/settings.ts | 5 +- apps/kimi-web/src/i18n/locales/zh/settings.ts | 5 +- apps/kimi-web/src/lib/storage.ts | 1 + apps/kimi-web/test/notification-logic.test.ts | 130 +++++++++++++++++- 10 files changed, 320 insertions(+), 48 deletions(-) create mode 100644 .changeset/web-approval-notifications.md diff --git a/.changeset/web-approval-notifications.md b/.changeset/web-approval-notifications.md new file mode 100644 index 000000000..4ffab64dc --- /dev/null +++ b/.changeset/web-approval-notifications.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Add notifications when a tool needs approval, and improve notification reliability. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index eb9131b48..f41127fb7 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -898,6 +898,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 +911,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)" diff --git a/apps/kimi-web/src/components/settings/SettingsDialog.vue b/apps/kimi-web/src/components/settings/SettingsDialog.vue index 44d7cf3ca..8f3493a6f 100644 --- a/apps/kimi-web/src/components/settings/SettingsDialog.vue +++ b/apps/kimi-web/src/components/settings/SettingsDialog.vue @@ -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)" /> +
+ + {{ t('settings.notifyOnApproval') }} + {{ t('settings.notifyDenied') }} + + +
{{ t('settings.soundOnComplete') }} ( typeof Notification !== 'undefined' ? Notification.permission : 'denied', ); @@ -61,20 +71,42 @@ function setNotifyOnQuestion(on: boolean): Promise { 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 { + 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, }; } diff --git a/apps/kimi-web/src/composables/client/useSoundNotification.ts b/apps/kimi-web/src/composables/client/useSoundNotification.ts index db58f6b64..3016c0c6d 100644 --- a/apps/kimi-web/src/composables/client/useSoundNotification.ts +++ b/apps/kimi-web/src/composables/client/useSoundNotification.ts @@ -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, }; } diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 2f7e822fc..9b61944bb 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -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( @@ -2315,10 +2320,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 +2365,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 +2417,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 +2432,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 // --------------------------------------------------------------------------- @@ -2501,9 +2542,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, diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts index ec2c8d770..c8aa5a56f 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -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', diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts index a06a14bdd..d20d6bfa6 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -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 默认值', diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index a6d0a1f31..36dab4c61 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -32,6 +32,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 diff --git a/apps/kimi-web/test/notification-logic.test.ts b/apps/kimi-web/test/notification-logic.test.ts index f10a31d7d..5b13de8fc 100644 --- a/apps/kimi-web/test/notification-logic.test.ts +++ b/apps/kimi-web/test/notification-logic.test.ts @@ -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).Notification = FakeNotification; + notifyOnComplete.value = true; + notifyOnQuestion.value = true; + notifyOnApproval.value = true; + }); + + afterEach(() => { + delete (globalThis as Record).Notification; + notifyOnComplete.value = true; + notifyOnQuestion.value = false; + notifyOnApproval.value = false; + }); + + it('completion tags carry the prompt id so each turn in a session alerts', () => { + maybeNotifyCompletion('s1', { ...base, promptId: 'p1' }); + maybeNotifyCompletion('s1', { ...base, promptId: 'p2' }); + expect(FakeNotification.fired.map((f) => f.tag)).toEqual([ + 'kimi-complete-s1-p1', + 'kimi-complete-s1-p2', + ]); + }); + + it('a replayed idle event for the same turn keeps the same tag', () => { + maybeNotifyCompletion('s1', { ...base, promptId: 'p1' }); + maybeNotifyCompletion('s1', { ...base, promptId: 'p1' }); + expect(FakeNotification.fired).toHaveLength(2); + expect(FakeNotification.fired[0]?.tag).toBe(FakeNotification.fired[1]?.tag); + }); + + it('question and approval tags are per-request', () => { + maybeNotifyQuestion({ ...base, questionPreview: 'q', questionId: 'q1' }); + maybeNotifyApproval({ ...base, toolName: 'bash', approvalId: 'a1' }); + expect(FakeNotification.fired.map((f) => f.tag)).toEqual([ + 'kimi-question-q1', + 'kimi-approval-a1', + ]); + }); + + it('suppresses the notification while the user is watching the session', () => { + maybeNotifyCompletion('s1', { ...base, isUserWatching: true, promptId: 'p1' }); + expect(FakeNotification.fired).toHaveLength(0); + }); +}); From ad30a1c6328327729221f9f5fc700b621dfef779 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:55:58 +0800 Subject: [PATCH 06/19] 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 --- .changeset/web-ui-polish.md | 5 + apps/kimi-web/package.json | 3 +- apps/kimi-web/src/components/SessionRow.vue | 12 +- apps/kimi-web/src/components/Sidebar.vue | 19 +- .../src/components/WorkspaceGroup.vue | 9 +- .../src/components/chat/ApprovalCard.vue | 19 +- .../kimi-web/src/components/chat/ChatDock.vue | 1 + .../src/components/chat/ChatHeader.vue | 24 +- .../kimi-web/src/components/chat/Composer.vue | 400 ++++++++++++++---- .../src/components/chat/ConversationPane.vue | 171 +++++++- .../src/components/chat/GoalStrip.vue | 125 ++++-- .../kimi-web/src/components/chat/Markdown.vue | 21 +- .../src/components/chat/QuestionCard.vue | 22 +- .../src/components/chat/ThinkingBlock.vue | 6 +- .../src/components/chat/ThinkingPanel.vue | 3 +- apps/kimi-web/src/components/chat/ToolRow.vue | 21 +- .../components/chat/tool-calls/EditTool.vue | 16 +- .../chat/tool-calls/GenericTool.vue | 16 +- .../chat/tool-calls/ToolOutputBlock.vue | 42 ++ .../components/mobile/MobileSwitcherSheet.vue | 9 +- apps/kimi-web/src/components/ui/MenuItem.vue | 4 +- .../src/components/ui/ShortcutKey.vue | 24 ++ .../src/composables/useKimiWebClient.ts | 2 +- apps/kimi-web/src/i18n/locales/en/header.ts | 5 + apps/kimi-web/src/i18n/locales/en/sidebar.ts | 4 +- apps/kimi-web/src/i18n/locales/en/status.ts | 9 + apps/kimi-web/src/i18n/locales/en/tools.ts | 15 + apps/kimi-web/src/i18n/locales/zh/composer.ts | 4 +- apps/kimi-web/src/i18n/locales/zh/header.ts | 5 + apps/kimi-web/src/i18n/locales/zh/sidebar.ts | 4 +- apps/kimi-web/src/i18n/locales/zh/status.ts | 13 +- apps/kimi-web/src/i18n/locales/zh/tools.ts | 15 + apps/kimi-web/src/lib/icons.ts | 15 + apps/kimi-web/src/lib/toolMeta.ts | 68 +++ apps/kimi-web/src/main.ts | 3 +- apps/kimi-web/src/style.css | 23 +- apps/kimi-web/src/views/DesignSystemView.vue | 28 +- flake.nix | 2 +- pnpm-lock.yaml | 10 +- 39 files changed, 941 insertions(+), 256 deletions(-) create mode 100644 .changeset/web-ui-polish.md create mode 100644 apps/kimi-web/src/components/chat/tool-calls/ToolOutputBlock.vue create mode 100644 apps/kimi-web/src/components/ui/ShortcutKey.vue diff --git a/.changeset/web-ui-polish.md b/.changeset/web-ui-polish.md new file mode 100644 index 000000000..5a829b6c1 --- /dev/null +++ b/.changeset/web-ui-polish.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Polish the chat UI with Inter typography, localized labels, and tighter sidebar, composer, and menu styling. diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index febbb8626..f7d2f2370 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -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", diff --git a/apps/kimi-web/src/components/SessionRow.vue b/apps/kimi-web/src/components/SessionRow.vue index 5d1b8a6c3..2291a2681 100644 --- a/apps/kimi-web/src/components/SessionRow.vue +++ b/apps/kimi-web/src/components/SessionRow.vue @@ -341,8 +341,9 @@ defineExpose({ closeMenu }); .t { color: inherit; - font-size: var(--ui-font-size); - font-weight: var(--weight-regular); + font-size: var(--text-base); + font-weight: 450; + line-height: var(--leading-tight); flex: 1; min-width: 0; overflow: hidden; @@ -353,7 +354,10 @@ 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; + font-variant-numeric: tabular-nums; + text-align: right; } /* Trailing action slot: time and kebab share one grid cell (grid-area:1/1). @@ -366,7 +370,7 @@ defineExpose({ closeMenu }); display: inline-grid; flex: none; align-items: center; - justify-items: center; + justify-items: end; } .act .ts, .act .kebab { grid-area: 1 / 1; } diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 920eb2492..987465dbb 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -23,6 +23,7 @@ import IconButton from './ui/IconButton.vue'; import Icon from './ui/Icon.vue'; import Menu from './ui/Menu.vue'; import MenuItem from './ui/MenuItem.vue'; +import ShortcutKey from './ui/ShortcutKey.vue'; import { useConfirmDialog } from '../composables/useConfirmDialog'; const { t } = useI18n(); @@ -599,7 +600,10 @@ onBeforeUnmount(() => { @@ -911,6 +915,7 @@ onBeforeUnmount(() => { color: var(--color-text); font-family: var(--font-ui); font-size: var(--ui-font-size); + font-weight: var(--weight-medium); cursor: pointer; text-align: left; } @@ -950,15 +955,25 @@ onBeforeUnmount(() => { flex: none; } .search-input { + display: inline-flex; + align-items: center; + gap: var(--space-1); flex: 1; min-width: 0; color: var(--color-text); - font-family: var(--mono); + font-family: var(--font-ui); font-size: var(--ui-font-size); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.search-input > span { + overflow: hidden; + text-overflow: ellipsis; +} +.search-label { + font-weight: var(--weight-medium); +} /* Sessions */ .sessions { diff --git a/apps/kimi-web/src/components/WorkspaceGroup.vue b/apps/kimi-web/src/components/WorkspaceGroup.vue index 729707b8e..9e9731929 100644 --- a/apps/kimi-web/src/components/WorkspaceGroup.vue +++ b/apps/kimi-web/src/components/WorkspaceGroup.vue @@ -265,7 +265,7 @@ function onHeaderDragStart(event: DragEvent): void { .gh-name { font-size: var(--ui-font-size-lg); - font-weight: var(--weight-medium); + font-weight: 550; color: var(--color-text); flex: 1; min-width: 0; @@ -276,6 +276,7 @@ function onHeaderDragStart(event: DragEvent): void { } .gh-path { color: var(--color-text-faint); + font-weight: 425; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -317,10 +318,10 @@ function onHeaderDragStart(event: DragEvent): void { .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)); + padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) - var(--space-2) + 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 @@ -338,7 +339,7 @@ function onHeaderDragStart(event: DragEvent): void { border: none; border-radius: var(--radius-md); background: transparent; - color: var(--color-text); + color: var(--color-text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: left; diff --git a/apps/kimi-web/src/components/chat/ApprovalCard.vue b/apps/kimi-web/src/components/chat/ApprovalCard.vue index 0448ff261..48047bccb 100644 --- a/apps/kimi-web/src/components/chat/ApprovalCard.vue +++ b/apps/kimi-web/src/components/chat/ApprovalCard.vue @@ -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 ShortcutKey from '../ui/ShortcutKey.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 }}[{{ i + 1 }}] + >{{ opt.label }}[{{ i + 1 }}] - - - + + +
- - - - + + + +
@@ -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 diff --git a/apps/kimi-web/src/components/chat/ChatDock.vue b/apps/kimi-web/src/components/chat/ChatDock.vue index 4078c1d14..8da9067d2 100644 --- a/apps/kimi-web/src/components/chat/ChatDock.vue +++ b/apps/kimi-web/src/components/chat/ChatDock.vue @@ -251,6 +251,7 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus }); :plan-mode="planMode" :swarm-mode="swarmMode" :goal-mode="goalMode" + :goal="goal" :activation-badges="activationBadges" :models="models" :starred-ids="starredIds" diff --git a/apps/kimi-web/src/components/chat/ChatHeader.vue b/apps/kimi-web/src/components/chat/ChatHeader.vue index 66b603bab..5de3154b9 100644 --- a/apps/kimi-web/src/components/chat/ChatHeader.vue +++ b/apps/kimi-web/src/components/chat/ChatHeader.vue @@ -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 = { + 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 { v-if="pr" type="button" class="ch-pill ch-pr" - :class="`pr-${pr.state}`" + :class="prStateClass(pr.state)" @click="pr && emit('openPr', pr.url)" > - PR #{{ pr.number }} · {{ pr.state }} + PR #{{ pr.number }} · {{ prStateLabel(pr.state) }} @@ -420,6 +439,7 @@ async function startArchive(): Promise { .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 diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index e8069b0ec..92031302e 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -1,5 +1,6 @@ + + + + diff --git a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue index da5d73cbd..4a87b0cc0 100644 --- a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue @@ -394,7 +394,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise { } .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 { } .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 { .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 { } .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; diff --git a/apps/kimi-web/src/components/ui/MenuItem.vue b/apps/kimi-web/src/components/ui/MenuItem.vue index a44bb31d9..cc9cf0d69 100644 --- a/apps/kimi-web/src/components/ui/MenuItem.vue +++ b/apps/kimi-web/src/components/ui/MenuItem.vue @@ -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); diff --git a/apps/kimi-web/src/components/ui/ShortcutKey.vue b/apps/kimi-web/src/components/ui/ShortcutKey.vue new file mode 100644 index 000000000..67c87a182 --- /dev/null +++ b/apps/kimi-web/src/components/ui/ShortcutKey.vue @@ -0,0 +1,24 @@ + + + + diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 9b61944bb..0e2b7adf5 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -1380,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; diff --git a/apps/kimi-web/src/i18n/locales/en/header.ts b/apps/kimi-web/src/i18n/locales/en/header.ts index bf046bee4..4273de01d 100644 --- a/apps/kimi-web/src/i18n/locales/en/header.ts +++ b/apps/kimi-web/src/i18n/locales/en/header.ts @@ -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', diff --git a/apps/kimi-web/src/i18n/locales/en/sidebar.ts b/apps/kimi-web/src/i18n/locales/en/sidebar.ts index 3bb705ca3..70adc2e5e 100644 --- a/apps/kimi-web/src/i18n/locales/en/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/en/sidebar.ts @@ -31,9 +31,9 @@ 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', diff --git a/apps/kimi-web/src/i18n/locales/en/status.ts b/apps/kimi-web/src/i18n/locales/en/status.ts index fc4ca8cc4..8b7f02278 100644 --- a/apps/kimi-web/src/i18n/locales/en/status.ts +++ b/apps/kimi-web/src/i18n/locales/en/status.ts @@ -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 diff --git a/apps/kimi-web/src/i18n/locales/en/tools.ts b/apps/kimi-web/src/i18n/locales/en/tools.ts index aaddeb334..13cc18211 100644 --- a/apps/kimi-web/src/i18n/locales/en/tools.ts +++ b/apps/kimi-web/src/i18n/locales/en/tools.ts @@ -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', diff --git a/apps/kimi-web/src/i18n/locales/zh/composer.ts b/apps/kimi-web/src/i18n/locales/zh/composer.ts index 6c1ec1926..719f7adb7 100644 --- a/apps/kimi-web/src/i18n/locales/zh/composer.ts +++ b/apps/kimi-web/src/i18n/locales/zh/composer.ts @@ -22,7 +22,7 @@ export default { emptyConversationTitle: 'Kimi Code', emptyConversation: '还没有消息 —— 在下方输入开始对话', quickStartPlaceholder: '输入消息开始新对话…', - thinkingSuffix: ' · thinking', - thinkingSuffixEffort: ' · thinking: {level}', + thinkingSuffix: ' · 思考', + thinkingSuffixEffort: ' · 思考: {level}', } as const; diff --git a/apps/kimi-web/src/i18n/locales/zh/header.ts b/apps/kimi-web/src/i18n/locales/zh/header.ts index 543cece6c..687845609 100644 --- a/apps/kimi-web/src/i18n/locales/zh/header.ts +++ b/apps/kimi-web/src/i18n/locales/zh/header.ts @@ -10,6 +10,11 @@ export default { gitTooltip: '打开「文件 > 改动」', detached: '游离', openPr: '打开 Pull Request', + prStatusOpen: '已打开', + prStatusClosed: '已关闭', + prStatusMerged: '已合并', + prStatusDraft: '草稿', + prStatusUnknown: '未知', options: '选项', copySessionId: '复制 Session ID', renameSession: '重命名', diff --git a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts index bea56f556..9e92e43bd 100644 --- a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts @@ -31,9 +31,9 @@ export default { language: '语言', daemon: '后台', noSessions: '暂无对话', - showMore: '加载更多 ({count})', + showMore: '加载更多 {count} 个对话', showLess: '收起', - showAll: '展开 ({count})', + showAll: '展开剩余 {count} 个对话', loadingMore: '加载中…', collapseSidebar: '收起侧边栏', expandSidebar: '展开侧边栏', diff --git a/apps/kimi-web/src/i18n/locales/zh/status.ts b/apps/kimi-web/src/i18n/locales/zh/status.ts index 98287e9d5..0256b119b 100644 --- a/apps/kimi-web/src/i18n/locales/zh/status.ts +++ b/apps/kimi-web/src/i18n/locales/zh/status.ts @@ -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: '更多模型…', // 状态面板 diff --git a/apps/kimi-web/src/i18n/locales/zh/tools.ts b/apps/kimi-web/src/i18n/locales/zh/tools.ts index 0cee01058..f9560e3da 100644 --- a/apps/kimi-web/src/i18n/locales/zh/tools.ts +++ b/apps/kimi-web/src/i18n/locales/zh/tools.ts @@ -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: '运行中', diff --git a/apps/kimi-web/src/lib/icons.ts b/apps/kimi-web/src/lib/icons.ts index e41f0b1e4..a6e1ab5d2 100644 --- a/apps/kimi-web/src/lib/icons.ts +++ b/apps/kimi-web/src/lib/icons.ts @@ -43,6 +43,7 @@ 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'; @@ -61,6 +62,7 @@ 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'; @@ -72,6 +74,7 @@ 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'; @@ -104,6 +107,7 @@ 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'; @@ -122,6 +126,7 @@ 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'; @@ -133,6 +138,7 @@ 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 +183,7 @@ export type IconName = | 'folder-solid' | 'file' | 'file-text' + | 'file-edit' | 'file-plus' | 'file-off' | 'image-off' @@ -197,6 +204,8 @@ export type IconName = | 'alert-triangle' | 'clock' | 'sparkles' + | 'target' + | 'pause' | 'play' | 'stop' | 'star' @@ -256,6 +265,7 @@ export const ICONS: Record = { '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,6 +286,8 @@ export const ICONS: Record = { '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), @@ -353,6 +365,7 @@ export const ICON_GROUPS: ReadonlyArray 'folder-solid', 'file', 'file-text', + 'file-edit', 'file-plus', 'file-off', 'image-off', @@ -365,6 +378,7 @@ export const ICON_GROUPS: ReadonlyArray 'check-list', 'bolt', 'git-pull-request', + 'target', 'calendar-schedule', 'calendar-todo', 'calendar-close', @@ -379,6 +393,7 @@ export const ICON_GROUPS: ReadonlyArray 'alert-triangle', 'clock', 'sparkles', + 'pause', 'play', 'stop', 'star', diff --git a/apps/kimi-web/src/lib/toolMeta.ts b/apps/kimi-web/src/lib/toolMeta.ts index 6ecfd4c00..a2b011132 100644 --- a/apps/kimi-web/src/lib/toolMeta.ts +++ b/apps/kimi-web/src/lib/toolMeta.ts @@ -25,6 +25,10 @@ const TOOL_LABEL_KEYS: Record = { 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 = { 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 = { 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 | undefined { return str(d.path) ?? str(d.file_path) ?? str(d.filePath) ?? str(d.filename); } +const GOAL_STATUS_KEYS: Record = { + 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 | 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(); } diff --git a/apps/kimi-web/src/main.ts b/apps/kimi-web/src/main.ts index d546f7ae6..55475a234 100644 --- a/apps/kimi-web/src/main.ts +++ b/apps/kimi-web/src/main.ts @@ -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'; diff --git a/apps/kimi-web/src/style.css b/apps/kimi-web/src/style.css index dda829b72..1f9ae2e50 100644 --- a/apps/kimi-web/src/style.css +++ b/apps/kimi-web/src/style.css @@ -206,9 +206,8 @@ summary { --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; } @@ -454,14 +453,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; @@ -643,6 +643,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; diff --git a/apps/kimi-web/src/views/DesignSystemView.vue b/apps/kimi-web/src/views/DesignSystemView.vue index 7053cfce0..358d777e2 100644 --- a/apps/kimi-web/src/views/DesignSystemView.vue +++ b/apps/kimi-web/src/views/DesignSystemView.vue @@ -227,18 +227,19 @@ onUnmounted(() => {

All disabled controls use opacity:.5 + cursor:not-allowed uniformly; do not separately grey out or recolor.

Font families

-

Kimi Web uses two font families: --font-ui (UI and body, system fonts first) and --font-mono (code and monospace). Components always reference the variables; do not hard-code font names.

+

Kimi Web uses two font families: --font-ui (UI and body, Inter first) and --font-mono (code and monospace). Components always reference the variables; do not hard-code font names.

-

--font-ui · UI & body (system fonts first)

-

Body and UI use each platform's native UI font — close to the system feel, comfortable for long text and CJK. Fallback chain:

-
--font-ui
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI",
+            

--font-ui · UI & body (Inter first)

+

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:

+
--font-ui
--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";
    -
  • System UI fonts first: SF Pro on macOS / iOS, Segoe UI on Windows.
  • -
  • CJK next: PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans SC (Linux).
  • -
  • Helvetica Neue / Arial / sans-serif as generic fallbacks; emoji fonts at the end.
  • +
  • Inter first: self-hosted Latin UI and body text, loaded through the optical-size normal and italic variable faces.
  • +
  • Western fallbacks next: Helvetica Neue / Arial for environments where Inter cannot load.
  • +
  • CJK and system UI fallbacks late: PingFang SC / Microsoft YaHei / Noto Sans SC, then platform UI fonts and emoji fonts.

--font-mono · Code & monospace

@@ -251,8 +252,8 @@ onUnmounted(() => { FontSourceBundledUsage JetBrains Mono@fontsource-variable/jetbrains-mono✓ self-hostedmonospace / code (--font-mono) - Inter@fontsource-variable/inter✓ self-hostedoptional: page titles / brand wordmark (--font-display) - System UI / CJK fontsoperating system—body / UI (--font-ui), not bundled + Inter@fontsource-variable/inter/opsz.css + opsz-italic.css✓ self-hostedUI / body / display (--font-ui, --font-display), wght 100-900, opsz 14-32, normal + italic + System UI / CJK fontsoperating system—late fallback for UI / body, not bundled
@@ -262,8 +263,9 @@ onUnmounted(() => {

Usage rules

  • Components always use var(--font-ui) / var(--font-mono); do not hard-code font names like 'Inter' / 'JetBrains Mono'.
  • -
  • Body / UI use --font-ui (system fonts first); code / monospace use --font-mono (JetBrains Mono).
  • -
  • Inter is used only for headings / brand scenarios that need a unified look (optional --font-display); it is no longer the body default.
  • +
  • Body / UI use --font-ui (Inter first); code / monospace use --font-mono (JetBrains Mono).
  • +
  • Inter is loaded from the complete optical-size variable faces, including normal and italic styles; font-optical-sizing: auto is enabled globally.
  • +
  • CJK and platform system UI fonts stay late in the --font-ui fallback chain, after Inter and Western fallbacks.

Type scale & weight

@@ -281,7 +283,7 @@ onUnmounted(() => { - + diff --git a/flake.nix b/flake.nix index bdb4158d0..c914a4d56 100644 --- a/flake.nix +++ b/flake.nix @@ -152,7 +152,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-RPjCWL7NqDSKgpHGL16zPlUOfjWN2rkaDY/4GFAD8VA="; + hash = "sha256-hUn5Srn3HnEEzU5DLxgjIzFjI0ukM3iSP4QagftEXdE="; }; nativeBuildInputs = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7396c9c5c..dab48ecc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,8 +166,11 @@ importers: apps/kimi-web: dependencies: + '@chenglou/pretext': + specifier: 0.0.8 + version: 0.0.8 '@fontsource-variable/inter': - specifier: ^5.2.8 + specifier: 5.2.8 version: 5.2.8 '@fontsource-variable/jetbrains-mono': specifier: ^5.2.8 @@ -995,6 +998,9 @@ packages: '@chenglou/pretext@0.0.5': resolution: {integrity: sha512-A8GZN10REdFGsyuiUgLV8jjPDDFMg5GmgxGWV0I3igxBOnzj+jgz2VMmVD7g+SFyoctfeqHFxbNatKSzVRWtRg==} + '@chenglou/pretext@0.0.8': + resolution: {integrity: sha512-yqm2GMxnPI7VHcHwe84P8ZF0JK/2d2DMKPqMN+s95jQhwDMYYXKVFVJUMEaVWckQStdsjdLav/0Vu+d9YbtGxA==} + '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} @@ -8077,6 +8083,8 @@ snapshots: '@chenglou/pretext@0.0.5': {} + '@chenglou/pretext@0.0.8': {} + '@chevrotain/types@11.1.2': {} '@colors/colors@1.5.0': From 9fb19154accf6b6f7abfbf7a9820ccda517bc87e Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:54:50 +0800 Subject: [PATCH 07/19] 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 --- .changeset/fix-prompt-goal-mode.md | 5 + apps/kimi-code/src/cli/goal-prompt.ts | 7 +- apps/kimi-code/src/cli/run-prompt.ts | 70 +++++++++--- apps/kimi-code/test/cli/goal-prompt.test.ts | 116 ++++++++++++++++++++ 4 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-prompt-goal-mode.md diff --git a/.changeset/fix-prompt-goal-mode.md b/.changeset/fix-prompt-goal-mode.md new file mode 100644 index 000000000..0aa7146f1 --- /dev/null +++ b/.changeset/fix-prompt-goal-mode.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts. diff --git a/apps/kimi-code/src/cli/goal-prompt.ts b/apps/kimi-code/src/cli/goal-prompt.ts index 5ab0cfe85..57f223ad4 100644 --- a/apps/kimi-code/src/cli/goal-prompt.ts +++ b/apps/kimi-code/src/cli/goal-prompt.ts @@ -46,13 +46,18 @@ const GOAL_PREFIX = /^\/goal(\s|$)/; * Parses a headless prompt into a goal-create request, or `undefined` when the * prompt is not a `/goal` create command (so the caller runs it as a normal * prompt). Non-create goal subcommands are not supported headless and fall - * through to normal prompt handling. + * through to normal prompt handling. Malformed create commands throw instead of + * falling through, so validation errors are reported before anything is sent to + * the model. */ export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined { const trimmed = prompt.trim(); if (!GOAL_PREFIX.test(trimmed)) return undefined; const args = trimmed.replace(/^\/goal/, '').trim(); const parsed = parseGoalCommand(args); + if (parsed.kind === 'error') { + throw new Error(parsed.message); + } if (parsed.kind !== 'create') return undefined; return { objective: parsed.objective, replace: parsed.replace }; } diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 100368203..3bb3a705f 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -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 { 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 ( @@ -515,19 +529,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 +584,20 @@ function runPromptTurn( session.prompt(prompt).catch((error: unknown) => { finish(error instanceof Error ? error : new Error(String(error))); }); + + async function finishCompletedTurn(): Promise { + // Flush the buffered assistant message before 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(); + } }); } @@ -610,7 +648,9 @@ class PromptTranscriptWriter implements PromptTurnWriter { writeToolResult(): void {} - flushAssistant(): void {} + flushAssistant(): void { + this.assistantWriter.finish(); + } discardAssistant(): void {} diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index 04780bd26..8d75c4721 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -46,6 +46,12 @@ describe('parseHeadlessGoalCreate', () => { expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined(); expect(parseHeadlessGoalCreate('/goal pause')).toBeUndefined(); }); + + it('rejects malformed goal create prompts instead of falling through', () => { + expect(() => parseHeadlessGoalCreate(`/goal ${'x'.repeat(4001)}`)).toThrow( + 'Goal objective is too long', + ); + }); }); describe('goal summary', () => { @@ -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) => { + 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); From 173bdfdab1f484ed79927aeaac7dc8116d3fd346 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:46:57 +0800 Subject: [PATCH 08/19] fix: resume sessions with missing workdir (#1517) --- .changeset/fix-missing-workdir-resume.md | 5 +++++ packages/agent-core/src/agent/config/index.ts | 2 +- .../test/agent/config-state.test.ts | 19 +++++++++++++++++++ .../test/tools/fixtures/fake-kaos.ts | 6 +++--- 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-missing-workdir-resume.md diff --git a/.changeset/fix-missing-workdir-resume.md b/.changeset/fix-missing-workdir-resume.md new file mode 100644 index 000000000..187152cf0 --- /dev/null +++ b/.changeset/fix-missing-workdir-resume.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix resuming sessions whose original working directory no longer exists. diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 0eb7c1db1..d4724b6fc 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -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; diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 32b127950..7a0d169f0 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -4,8 +4,27 @@ import { emptyUsage } from '@moonshot-ai/kosong'; import { ProviderManager } from '../../src/session/provider-manager'; import type { KimiConfig } from '../../src/config'; import { testAgent } from './harness'; +import { createFakeKaos } from '../tools/fixtures/fake-kaos'; describe('ConfigState model capabilities', () => { + it('updates the agent cwd without requiring the directory to exist', () => { + const chdir = vi.fn(async () => { + throw Object.assign(new Error('missing workspace'), { code: 'ENOENT' }); + }); + const ctx = testAgent({ + kaos: createFakeKaos({ + getcwd: () => '/workspace', + chdir, + }), + }); + + ctx.agent.config.update({ cwd: '/tmp/missing-workdir' }); + + expect(ctx.agent.config.cwd).toBe('/tmp/missing-workdir'); + expect(ctx.agent.kaos.getcwd()).toBe('/tmp/missing-workdir'); + expect(chdir).not.toHaveBeenCalled(); + }); + it('computes provider and model capabilities from ProviderManager metadata', () => { const ctx = testAgent({ providerManager: new ProviderManager({ diff --git a/packages/agent-core/test/tools/fixtures/fake-kaos.ts b/packages/agent-core/test/tools/fixtures/fake-kaos.ts index 78613d33a..4384ce348 100644 --- a/packages/agent-core/test/tools/fixtures/fake-kaos.ts +++ b/packages/agent-core/test/tools/fixtures/fake-kaos.ts @@ -32,9 +32,9 @@ export function createFakeKaos( overrides?: Partial, envLayers: readonly Record[] = [], ): Kaos { - // Hold cwd in a closure so `chdir` (which `config.update({cwd})` now - // routes through) can mutate it and later `getcwd()` calls see the - // update — mirroring real-kaos semantics without needing a backing fs. + // Hold cwd in a closure so tests that call `chdir` directly can mutate it + // and later `getcwd()` calls see the update — mirroring real-kaos semantics + // without needing a backing fs. let cwd = overrides?.getcwd?.() ?? '/workspace'; const base: Kaos = { name: 'fake', From fe9479d89a22760a5fbe4ef659c0be556c5c3cfd Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 Jul 2026 17:06:19 +0800 Subject: [PATCH 09/19] fix: rewrite repeated tool call reminders to redirect instead of prohibit (#1518) 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. --- .../agent-core/src/agent/turn/tool-dedup.ts | 34 ++++++++----------- .../test/agent/turn/tool-dedup.test.ts | 34 +++++++++---------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/packages/agent-core/src/agent/turn/tool-dedup.ts b/packages/agent-core/src/agent/turn/tool-dedup.ts index 31170f8cf..605408777 100644 --- a/packages/agent-core/src/agent/turn/tool-dedup.ts +++ b/packages/agent-core/src/agent/turn/tool-dedup.ts @@ -7,32 +7,28 @@ import { canonicalTelemetryArgs } from './canonical-args'; const REMINDER_TEXT_1 = '\n\n\n' + - 'You are repeating the exact same tool call with identical parameters.' + - ' Please carefully analyze the previous result. If the task is not yet complete,' + - ' try a different method or parameters instead of repeating the same call.' + + 'The same tool call has been repeated several times in a row. ' + + 'Before making your next call, write one sentence stating what new information you expect it to produce. ' + + 'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.' + '\n'; -function makeReminderText2(toolName: string, repeatCount: number, args: unknown): string { - const argsStr = canonicalTelemetryArgs(args); +function makeReminderText2(repeatCount: number): string { return ( '\n\n\n' + - 'You have repeatedly called the same tool with identical parameters many times.\n' + - 'Repeated tool call detected:\n' + - `- tool: ${toolName}\n` + - `- repeated_times: ${String(repeatCount)}\n` + - `- arguments: ${argsStr}\n` + - 'The previous repeated calls did not make progress. Do not call this exact same tool with the exact same arguments again.\n' + - 'Carefully inspect the latest tool result and choose a different next action, different parameters, or finish the task if enough evidence has been gathered.' + + `The same tool call has now been issued ${String(repeatCount)} times in a row. ` + + 'Choose exactly one of the following and state your choice before acting:\n' + + '(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' + + '(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' + + '(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.' + '\n' ); } const REMINDER_TEXT_3 = '\n\n\n' + - 'You are stuck in a dead end and have repeatedly made the same function call without progress.\n' + - 'Stop all function calls immediately. Do not call any tool in your next response.\n' + - 'In analysis, review the current execution state and identify why progress is blocked.\n' + - 'Then return a text-only summary to the user that reports the current problem, what has already been tried, and what information or decision is needed next.' + + 'Write your final response now, without any further tool calls. ' + + 'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' + + 'Text only.' + '\n'; const REPEAT_REMINDER_1_START = 3; @@ -105,8 +101,8 @@ const DEDUP_PLACEHOLDER_RESULT: ExecutableToolResult = { output: '' }; * - Cross-step dedup: when the exact same call is repeated consecutively * across steps, the result returned to the model is suffixed with a system * reminder once the streak hits 3. The reminder escalates as the streak - * grows: r1 (gentle nudge) from streak 3, r2 (concrete repeat report) from - * streak 5, r3 (dead-end stop instruction) from streak 8. From streak 12 + * grows: r1 (expectation-setting nudge) from streak 3, r2 (forced decision + * menu) from streak 5, r3 (final hand-off instruction) from streak 8. From streak 12 * onward the turn is force-stopped via `{ stopTurn: true }` so the loop * cannot keep spinning on the same call. Force-stop does not flip a * successful tool result into an error — the underlying tool's `isError` @@ -239,7 +235,7 @@ export class ToolCallDeduplicator { finalResult = appendReminder(result, REMINDER_TEXT_3); action = 'r3'; } else if (streak >= REPEAT_REMINDER_2_START) { - finalResult = appendReminder(result, makeReminderText2(toolName, streak, args)); + finalResult = appendReminder(result, makeReminderText2(streak)); action = 'r2'; } else if (streak >= REPEAT_REMINDER_1_START) { finalResult = appendReminder(result, REMINDER_TEXT_1); diff --git a/packages/agent-core/test/agent/turn/tool-dedup.test.ts b/packages/agent-core/test/agent/turn/tool-dedup.test.ts index cff03b9bb..087831645 100644 --- a/packages/agent-core/test/agent/turn/tool-dedup.test.ts +++ b/packages/agent-core/test/agent/turn/tool-dedup.test.ts @@ -116,8 +116,8 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); } expect(last!.output as string).toContain(''); - expect(last!.output as string).toContain('repeating the exact same tool call'); - expect(last!.output as string).not.toContain('repeated_times'); + expect(last!.output as string).toContain('what new information you expect'); + expect(last!.output as string).not.toContain('Choose exactly one'); }); it('keeps injecting reminder1 at 4 consecutive', async () => { @@ -129,7 +129,7 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); } expect(last!.output as string).toContain(''); - expect(last!.output as string).toContain('repeating the exact same tool call'); + expect(last!.output as string).toContain('what new information you expect'); }); it('injects reminder2 at exactly 5 consecutive', async () => { @@ -141,9 +141,9 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); } expect(last!.output as string).toContain(''); - expect(last!.output as string).toContain('repeated_times: 5'); - expect(last!.output as string).toContain('tool: Read'); - expect(last!.output as string).toContain('arguments:'); + expect(last!.output as string).toContain('issued 5 times in a row'); + expect(last!.output as string).toContain('Choose exactly one of the following'); + expect(last!.output as string).toContain('Falsification check'); }); it.each([6, 7])('keeps injecting reminder2 at %i consecutive', async (streak) => { @@ -155,8 +155,8 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); } expect(last!.output as string).toContain(''); - expect(last!.output as string).toContain(`repeated_times: ${String(streak)}`); - expect(last!.output as string).toContain('tool: Read'); + expect(last!.output as string).toContain(`issued ${String(streak)} times in a row`); + expect(last!.output as string).toContain('Choose exactly one of the following'); }); it('injects the dead-end reminder at exactly 8 consecutive', async () => { @@ -168,7 +168,7 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); } expect(last!.output as string).toContain(''); - expect(last!.output as string).toContain('stuck in a dead end'); + expect(last!.output as string).toContain('without any further tool calls'); }); it('resets streak when a different call is interleaved', async () => { @@ -214,9 +214,9 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); expect(original.output as string).toContain(''); - expect(original.output as string).toContain('repeating the exact same tool call'); + expect(original.output as string).toContain('what new information you expect'); expect(finalDup.output as string).toContain(''); - expect(finalDup.output as string).toContain('repeating the exact same tool call'); + expect(finalDup.output as string).toContain('what new information you expect'); }); it('same-step spam alone does not trigger reminder', async () => { @@ -273,7 +273,7 @@ describe('ToolCallDeduplicator', () => { const arr = final.output as Array<{ type: string; text: string }>; expect(arr).toHaveLength(1); expect(arr[0]!.type).toBe('text'); - expect(arr[0]!.text).toBe('hello' + makeReminderText2('X', 5, { a: 1 })); + expect(arr[0]!.text).toBe('hello' + makeReminderText2(5)); }); it('pushes a new text part when trailing part is non-text', async () => { @@ -408,8 +408,8 @@ describe('ToolCallDeduplicator', () => { const dedup = new ToolCallDeduplicator(); const last = await runStreak(dedup, 8); expect(last.output as string).toContain(''); - expect(last.output as string).toContain('stuck in a dead end'); - expect(last.output as string).toContain('Stop all function calls immediately'); + expect(last.output as string).toContain('Write your final response now'); + expect(last.output as string).toContain('without any further tool calls'); // 8 is the reminder threshold, not yet force-stop. expect(last.isError).toBeUndefined(); expect(stopTurnOf(last)).toBeUndefined(); @@ -420,7 +420,7 @@ describe('ToolCallDeduplicator', () => { async (streak) => { const dedup = new ToolCallDeduplicator(); const last = await runStreak(dedup, streak); - expect(last.output as string).toContain('stuck in a dead end'); + expect(last.output as string).toContain('Write your final response now'); expect(last.isError).toBeUndefined(); expect(stopTurnOf(last)).toBeUndefined(); }, @@ -429,7 +429,7 @@ describe('ToolCallDeduplicator', () => { it('force-stops the turn at exactly 12 consecutive without marking the tool failed', async () => { const dedup = new ToolCallDeduplicator(); const last = await runStreak(dedup, 12); - expect(last.output as string).toContain('stuck in a dead end'); + expect(last.output as string).toContain('Write your final response now'); // The underlying tool succeeded — force-stop must not flip it to error. expect(last.isError).toBeUndefined(); expect(stopTurnOf(last)).toBe(true); @@ -459,7 +459,7 @@ describe('ToolCallDeduplicator', () => { // The underlying tool was an error — that must survive force-stop. expect(last!.isError).toBe(true); expect(stopTurnOf(last!)).toBe(true); - expect(last!.output as string).toContain('stuck in a dead end'); + expect(last!.output as string).toContain('Write your final response now'); }); }); From b91099ed7a2590d1afa4d6e3675671da52b7661c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 17:44:59 +0800 Subject: [PATCH 10/19] 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 --- .changeset/expose-extrausage-toolkit.md | 6 + apps/kimi-code/src/tui/commands/info.ts | 2 +- .../tui/components/messages/status-panel.ts | 17 ++- .../tui/components/messages/usage-panel.ts | 111 +++++++++++++- .../components/messages/status-panel.test.ts | 38 +++++ .../components/messages/usage-panel.test.ts | 138 +++++++++++++++++- packages/oauth/src/managed-usage.ts | 69 ++++++++- packages/oauth/src/toolkit.ts | 2 + packages/oauth/test/managed-usage.test.ts | 72 ++++++++- packages/oauth/test/toolkit.test.ts | 73 +++++++++ 10 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 .changeset/expose-extrausage-toolkit.md diff --git a/.changeset/expose-extrausage-toolkit.md b/.changeset/expose-extrausage-toolkit.md new file mode 100644 index 000000000..d31c53ee5 --- /dev/null +++ b/.changeset/expose-extrausage-toolkit.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands. diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 51ccd3fc1..fd5d397f4 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -213,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 23cef0b27..195860bc3 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -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; } diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 7d27be8e2..041860896 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -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', diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index ff39cb7e6..cf2598f82 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -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); diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 534104a20..928d9008c 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -45,14 +45,78 @@ export interface UsageRow { readonly resetHint?: string | undefined; } +export interface BoosterWalletInfo { + /** Remaining balance in whole cents (from balance.amountLeft). */ + readonly balanceCents: number; + /** Total balance in whole cents (from balance.amount). */ + readonly totalCents: number; + /** Whether the user enabled a monthly spending cap. */ + readonly monthlyChargeLimitEnabled: boolean; + /** Monthly spending cap in whole cents; 0 means unlimited. */ + readonly monthlyChargeLimitCents: number; + /** Monthly spend so far in whole cents. */ + readonly monthlyUsedCents: number; + /** ISO currency code, e.g. USD / CNY. */ + readonly currency: string; +} + export interface ParsedManagedUsage { readonly summary: UsageRow | null; readonly limits: UsageRow[]; + readonly extraUsage: BoosterWalletInfo | null; +} + +const FIXED_POINT_CENTS = 1_000_000; + +function fixedPointToCents(value: number): number { + const cents = value / FIXED_POINT_CENTS; + if (cents > 0 && cents < 1) return 1; + return Math.round(cents); +} + +function parseMoney(raw: unknown): { cents: number; currency: string } | null { + if (!isRecord(raw)) return null; + const cents = toInt(raw['priceInCents']); + if (cents === null) return null; + const currency = typeof raw['currency'] === 'string' ? raw['currency'] : ''; + return { cents, currency }; +} + +function parseBoosterWallet(raw: unknown): BoosterWalletInfo | null { + if (!isRecord(raw)) return null; + const balance = raw['balance']; + if (!isRecord(balance)) return null; + if (balance['type'] !== 'BOOSTER') return null; + const amountRaw = toInt(balance['amount']); + if (amountRaw === null || amountRaw <= 0) return null; + const totalCents = fixedPointToCents(amountRaw); + const amountLeftRaw = toInt(balance['amountLeft']); + const balanceCents = amountLeftRaw !== null ? fixedPointToCents(amountLeftRaw) : 0; + + const monthlyLimit = parseMoney(raw['monthlyChargeLimit']); + const monthlyUsed = parseMoney(raw['monthlyUsed']); + const monthlyChargeLimitEnabled = raw['monthlyChargeLimitEnabled'] === true; + + const currency = + monthlyLimit && monthlyLimit.currency.length > 0 + ? monthlyLimit.currency + : monthlyUsed && monthlyUsed.currency.length > 0 + ? monthlyUsed.currency + : 'USD'; + + return { + balanceCents, + totalCents, + monthlyChargeLimitEnabled, + monthlyChargeLimitCents: monthlyLimit?.cents ?? 0, + monthlyUsedCents: monthlyUsed?.cents ?? 0, + currency, + }; } export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { if (typeof payload !== 'object' || payload === null) { - return { summary: null, limits: [] }; + return { summary: null, limits: [], extraUsage: null }; } const rec = payload as Record; const summary = toUsageRow(rec['usage'], 'Weekly limit'); @@ -71,7 +135,8 @@ export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { if (row !== null) limits.push(row); } } - return { summary, limits }; + const extraUsage = parseBoosterWallet(rec['boosterWallet']); + return { summary, limits, extraUsage }; } function toUsageRow(raw: unknown, defaultLabel: string): UsageRow | null { diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index 94dca02bf..0229db7b0 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -97,6 +97,7 @@ export type AuthManagedUsageResult = readonly kind: 'ok'; readonly summary: ParsedManagedUsage['summary']; readonly limits: ParsedManagedUsage['limits']; + readonly extraUsage: ParsedManagedUsage['extraUsage']; } | FetchManagedUsageError; @@ -291,6 +292,7 @@ export class KimiOAuthToolkit { kind: 'ok', summary: result.parsed.summary, limits: result.parsed.limits, + extraUsage: result.parsed.extraUsage, }; } catch (error) { return { diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 98d33ee17..f390653bc 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -25,8 +25,8 @@ describe('isManagedKimiCode', () => { describe('parseManagedUsagePayload', () => { it('returns empty when payload is not an object', () => { - expect(parseManagedUsagePayload(null)).toEqual({ summary: null, limits: [] }); - expect(parseManagedUsagePayload('nope')).toEqual({ summary: null, limits: [] }); + expect(parseManagedUsagePayload(null)).toEqual({ summary: null, limits: [], extraUsage: null }); + expect(parseManagedUsagePayload('nope')).toEqual({ summary: null, limits: [], extraUsage: null }); }); it('extracts a summary from the `usage` object', () => { @@ -74,6 +74,73 @@ describe('parseManagedUsagePayload', () => { const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, resetAt: future } }); expect(parsed.summary?.resetHint).toMatch(/resets in/); }); + + it('extracts extra usage from boosterWallet.balance', () => { + const parsed = parseManagedUsagePayload({ + usage: { used: 40, limit: 1000, name: 'Weekly limit' }, + boosterWallet: { + id: 'wallet_1', + balance: { + type: 'BOOSTER', + amount: '20000000000', + amountLeft: '10000000000', + unit: 'UNIT_CURRENCY', + }, + monthlyChargeLimitEnabled: true, + monthlyChargeLimit: { currency: 'USD', priceInCents: '20000' }, + monthlyUsed: { currency: 'USD', priceInCents: '5000' }, + }, + }); + expect(parsed.extraUsage).toEqual({ + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }); + }); + + it('treats missing amountLeft as zero balance', () => { + const parsed = parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'BOOSTER', amount: '20000000000' } }, + }); + expect(parsed.extraUsage).toMatchObject({ totalCents: 20000, balanceCents: 0 }); + }); + + it('defaults monthly limit fields when absent', () => { + const parsed = parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { + balance: { type: 'BOOSTER', amount: '20000000000', amountLeft: '20000000000' }, + }, + }); + expect(parsed.extraUsage).toEqual({ + balanceCents: 20000, + totalCents: 20000, + monthlyChargeLimitEnabled: false, + monthlyChargeLimitCents: 0, + monthlyUsedCents: 0, + currency: 'USD', + }); + }); + + it('returns null extra usage when boosterWallet is missing or invalid', () => { + expect(parseManagedUsagePayload({ usage: { used: 1, limit: 10 } }).extraUsage).toBeNull(); + expect( + parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'OTHER', amount: '100', amountLeft: '50' } }, + }).extraUsage, + ).toBeNull(); + expect( + parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'BOOSTER', amount: '0', amountLeft: '0' } }, + }).extraUsage, + ).toBeNull(); + }); }); describe('fetchManagedUsage', () => { @@ -92,6 +159,7 @@ describe('fetchManagedUsage', () => { parsed: { summary: { label: 'Weekly limit', used: 1, limit: 10 }, limits: [], + extraUsage: null, }, }); diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 73e091644..6ceaae417 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -567,6 +567,79 @@ describe('KimiOAuthToolkit', () => { expect((await storage.load(storageName))?.accessToken).toBe('fresh-access'); }); + it('propagates extraUsage from the managed usage response', async () => { + const storage = new MemoryTokenStorage(); + storage.tokens.set('kimi-code', token('access-1')); + const fetchImpl = vi.fn(async (_input: unknown, _init?: RequestInit) => + new Response( + JSON.stringify({ + usage: { used: 10, limit: 100, name: 'Weekly limit' }, + limits: [], + boosterWallet: { + balance: { + type: 'BOOSTER', + amount: '20000000000', + amountLeft: '10000000000', + }, + monthlyChargeLimitEnabled: true, + monthlyChargeLimit: { currency: 'USD', priceInCents: '20000' }, + monthlyUsed: { currency: 'USD', priceInCents: '5000' }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) as unknown as typeof fetch; + vi.stubGlobal('fetch', fetchImpl); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + await expect(toolkit.getManagedUsage()).resolves.toMatchObject({ + kind: 'ok', + summary: { label: 'Weekly limit', used: 10, limit: 100 }, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }); + }); + + it('returns null extraUsage when the payload has no boosterWallet', async () => { + const storage = new MemoryTokenStorage(); + storage.tokens.set('kimi-code', token('access-1')); + const fetchImpl = vi.fn(async (_input: unknown, _init?: RequestInit) => + new Response( + JSON.stringify({ + usage: { used: 10, limit: 100, name: 'Weekly limit' }, + limits: [], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) as unknown as typeof fetch; + vi.stubGlobal('fetch', fetchImpl); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + await expect(toolkit.getManagedUsage()).resolves.toMatchObject({ + kind: 'ok', + summary: { label: 'Weekly limit', used: 10, limit: 100 }, + limits: [], + extraUsage: null, + }); + }); + it('removes managed config on logout when an adapter supports cleanup', async () => { const storage = new MemoryTokenStorage(); storage.tokens.set('kimi-code', token('access-1')); From 1bf2c9afee4643fbf6755f0b92fd60aa14240501 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 Jul 2026 18:05:14 +0800 Subject: [PATCH 11/19] 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 --- .changeset/image-request-size-limits.md | 6 + .../editor-keyboard-image-paste.test.ts | 6 +- docs/en/configuration/config-files.md | 14 +- docs/en/configuration/env-vars.md | 2 + docs/zh/configuration/config-files.md | 14 +- docs/zh/configuration/env-vars.md | 2 + flake.nix | 2 +- packages/agent-core/package.json | 1 + .../scripts/generate-webp-dec-wasm.mjs | 36 ++ .../agent-core/src/agent/compaction/full.ts | 59 ++- .../agent-core/src/agent/context/index.ts | 13 + .../agent-core/src/agent/context/projector.ts | 55 +++ .../agent-core/src/agent/records/types.ts | 5 +- packages/agent-core/src/agent/turn/index.ts | 1 + packages/agent-core/src/config/schema.ts | 22 ++ packages/agent-core/src/config/toml.ts | 12 + packages/agent-core/src/loop/llm.ts | 6 +- packages/agent-core/src/loop/run-turn.ts | 22 +- packages/agent-core/src/loop/turn-step.ts | 130 +++++-- packages/agent-core/src/rpc/core-impl.ts | 5 + .../src/tools/builtin/file/read-media.ts | 61 +++- .../src/tools/support/image-compress.ts | 248 ++++++++++--- .../src/tools/support/webp-dec-wasm.ts | 7 + .../src/tools/support/webp-decode.ts | 86 +++++ .../compaction/compaction-scenarios.test.ts | 27 ++ .../test/agent/compaction/full.test.ts | 137 +++++++ .../test/agent/context/projector.test.ts | 66 +++- packages/agent-core/test/agent/turn.test.ts | 72 ++++ .../agent-core/test/config/configs.test.ts | 22 ++ .../loop/tool-exchange-fallback.e2e.test.ts | 176 ++++++++- .../test/tools/image-compress.test.ts | 343 ++++++++++++++++-- .../agent-core/test/tools/read-media.test.ts | 189 +++++++++- packages/kosong/src/errors.ts | 47 +++ packages/kosong/src/index.ts | 2 + packages/kosong/test/errors.test.ts | 64 ++++ pnpm-lock.yaml | 94 +---- 36 files changed, 1839 insertions(+), 215 deletions(-) create mode 100644 .changeset/image-request-size-limits.md create mode 100644 packages/agent-core/scripts/generate-webp-dec-wasm.mjs create mode 100644 packages/agent-core/src/tools/support/webp-dec-wasm.ts create mode 100644 packages/agent-core/src/tools/support/webp-decode.ts diff --git a/.changeset/image-request-size-limits.md b/.changeset/image-request-size-limits.md new file mode 100644 index 000000000..076b0ef78 --- /dev/null +++ b/.changeset/image-request-size-limits.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +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. + diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts index 0fe14bc09..73e05b7ef 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -141,14 +141,14 @@ 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('records and persists the pre-compression original for an oversized paste', async () => { diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index f7e893555..303886e36 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -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
TokenValueUsage
--font-ui-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC"…UI & body (system fonts first)
--font-ui"Inter Variable", "Inter", "Helvetica Neue", Arial…UI & body (Inter first)
--font-monoJetBrains Mono…code, tool names, line numbers, diffs
--base-ui-font-size14px user preferenceroot setting that drives UI, reading body, and sidebar font sizes
--content-font-sizecalc(base + 1px)chat Markdown, message bubbles, composer
` | — | 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 ""`), 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`. + @@ -792,8 +793,29 @@ function openPr(url: string): void { @edit-message="handleEditMessage" /> + + + + + + + + .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 @@ -1208,4 +1267,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; +} diff --git a/apps/kimi-web/src/components/InternalBuildBanner.vue b/apps/kimi-web/src/components/InternalBuildBanner.vue index e2638aaad..a45748f7b 100644 --- a/apps/kimi-web/src/components/InternalBuildBanner.vue +++ b/apps/kimi-web/src/components/InternalBuildBanner.vue @@ -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; diff --git a/apps/kimi-web/src/components/SessionRow.vue b/apps/kimi-web/src/components/SessionRow.vue index 2291a2681..30158be01 100644 --- a/apps/kimi-web/src/components/SessionRow.vue +++ b/apps/kimi-web/src/components/SessionRow.vue @@ -254,7 +254,7 @@ defineExpose({ closeMenu }); :label="t('sidebar.options')" @click.stop="toggleMenu($event)" > - + @@ -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.25≈16px) + 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,7 +343,7 @@ defineExpose({ closeMenu }); .t { color: inherit; - font-size: var(--text-base); + font-size: var(--ui-font-size-sm); font-weight: 450; line-height: var(--leading-tight); flex: 1; @@ -356,30 +358,37 @@ defineExpose({ closeMenu }); font-size: var(--text-xs); 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: end; + 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 so the collapsing list's `overflow: hidden` can't clip it. */ @@ -413,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); } diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 987465dbb..8c9127e2e 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -9,21 +9,18 @@ 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 ShortcutKey from './ui/ShortcutKey.vue'; import { useConfirmDialog } from '../composables/useConfirmDialog'; const { t } = useI18n(); @@ -50,6 +47,12 @@ const props = withDefaults( unreadBySession?: Record; /** 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, @@ -58,6 +61,8 @@ const props = withDefaults( pendingBySession: () => ({}), unreadBySession: () => ({}), colWidth: 220, + collapsed: false, + dragging: false, }, ); @@ -84,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 @@ -111,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); @@ -186,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(loadShowWorkspacePaths()); - -function toggleShowWorkspacePaths(): void { - showWorkspacePaths.value = !showWorkspacePaths.value; - saveShowWorkspacePaths(showWorkspacePaths.value); -} - // --------------------------------------------------------------------------- // Workspace drag-to-reorder // --------------------------------------------------------------------------- @@ -487,11 +480,6 @@ function chooseSortMode(mode: WorkspaceSortMode): void { closeSectionMenu(); } -function toggleShowWorkspacePathsFromMenu(): void { - toggleShowWorkspacePaths(); - closeSectionMenu(); -} - onBeforeUnmount(() => { document.removeEventListener('mousedown', onGhMenuDocClick, true); document.removeEventListener('mousedown', onWsMenuDocClick); @@ -560,54 +548,49 @@ onBeforeUnmount(() => {