From 4c43935e31170140699a54cba631792872628655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=B8=A1?= Date: Mon, 6 Jul 2026 12:08:08 +0800 Subject: [PATCH 01/98] fix: show Windows session search shortcut (#1393) --- .changeset/fix-session-search-shortcut.md | 5 +++++ apps/kimi-web/src/components/Sidebar.vue | 11 ++++++++++- apps/kimi-web/src/i18n/locales/en/sidebar.ts | 2 +- apps/kimi-web/src/i18n/locales/zh/sidebar.ts | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-session-search-shortcut.md diff --git a/.changeset/fix-session-search-shortcut.md b/.changeset/fix-session-search-shortcut.md new file mode 100644 index 0000000000..75c9649de9 --- /dev/null +++ b/.changeset/fix-session-search-shortcut.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Show the correct session search shortcut on Windows. diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 0a31ae5fc0..06ea24d455 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -83,6 +83,7 @@ const emit = defineEmits<{ // Session search dialog (Spotlight-style; filters title + last prompt) // --------------------------------------------------------------------------- const showSearch = ref(false); +const sessionSearchShortcut = isAppleShortcutPlatform() ? '⌘K' : 'Ctrl K'; function openSearch(): void { // Sessions are loaded per-workspace (first page only); lazily drain the rest @@ -101,6 +102,14 @@ function onSearchKeydown(e: KeyboardEvent): void { onMounted(() => window.addEventListener('keydown', onSearchKeydown)); onBeforeUnmount(() => window.removeEventListener('keydown', onSearchKeydown)); +function isAppleShortcutPlatform(): boolean { + if (typeof navigator === 'undefined') return false; + if (/Mac|iPod|iPhone|iPad/.test(navigator.platform)) return true; + + const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData; + return userAgentData?.platform === 'macOS' || userAgentData?.platform === 'iOS'; +} + // Scroll-linked header seam: the .btn-wrap bottom border/shadow only appears // once the session list has actually scrolled, so an unscrolled list shows no // abrupt boundary. @@ -590,7 +599,7 @@ onBeforeUnmount(() => { diff --git a/apps/kimi-web/src/i18n/locales/en/sidebar.ts b/apps/kimi-web/src/i18n/locales/en/sidebar.ts index bb53bc6c0a..cda064d444 100644 --- a/apps/kimi-web/src/i18n/locales/en/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/en/sidebar.ts @@ -38,7 +38,7 @@ export default { collapseSidebar: 'Collapse sidebar', expandSidebar: 'Expand sidebar', searchPlaceholder: 'Search sessions', - searchShortcut: 'Search sessions (⌘K)', + searchShortcut: 'Search sessions ({shortcut})', searchHint: '↑↓ navigate · ↵ open · Esc close', searchNoResults: 'No matching sessions', } as const; diff --git a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts index 95698ae77c..473f3eecad 100644 --- a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts @@ -38,7 +38,7 @@ export default { collapseSidebar: '收起侧边栏', expandSidebar: '展开侧边栏', searchPlaceholder: '搜索会话', - searchShortcut: '搜索会话 (⌘K)', + searchShortcut: '搜索会话 ({shortcut})', searchHint: '↑↓ 选择 · ↵ 打开 · Esc 关闭', searchNoResults: '没有匹配的会话', }; From ce41f4b58d128ae47b0312eab24a845bbc0d08a3 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 12:39:58 +0800 Subject: [PATCH 02/98] fix(web): keep Sidebar single-root so collapse hides it (#1406) A top-level Teleport in the sidebar template made the component multi-root, so v-show could not apply display:none and the collapsed sidebar stayed mounted at the rail width, squeezing the conversation. Move the teleport inside the aside so the sidebar is single-root again. --- .changeset/fix-sidebar-collapse-single-root.md | 5 +++++ apps/kimi-web/src/components/Sidebar.vue | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-sidebar-collapse-single-root.md diff --git a/.changeset/fix-sidebar-collapse-single-root.md b/.changeset/fix-sidebar-collapse-single-root.md new file mode 100644 index 0000000000..52444cc399 --- /dev/null +++ b/.changeset/fix-sidebar-collapse-single-root.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix the collapsed sidebar not hiding and squeezing the conversation layout. diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 06ea24d455..bbc06a94a6 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -761,10 +761,13 @@ onBeforeUnmount(() => { @select="onSelectSession" @close="showSearch = false" /> + + + + - - - diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 00add89630..2f3776950d 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1694,6 +1694,30 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } } + /** Restore an archived session — calls API, then puts the returned session + * back at the front of the list so it reappears in the sidebar. */ + async function restoreSession(id: string): Promise { + try { + const restored = await getKimiWebApi().restoreSession(id); + upsertSessionFront(restored); + return true; + } catch (err) { + pushOperationFailure('restoreSession', err, { sessionId: id }); + return false; + } + } + + /** List archived sessions (server-side `archived_only` filter). Kept separate + * from the per-workspace active list — callers (e.g. Settings) hold the page + * locally and do their own search/filter/sort. */ + function loadArchivedSessions(input?: { beforeId?: string; pageSize?: number }) { + return getKimiWebApi().listSessions({ + archivedOnly: true, + beforeId: input?.beforeId, + pageSize: input?.pageSize ?? 50, + }); + } + /** Logout from the managed Kimi provider. Re-checks auth and reloads sessions. */ async function logout(): Promise { try { @@ -2007,6 +2031,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta renameWorkspace, deleteWorkspace, archiveSession, + restoreSession, + loadArchivedSessions, logout, compact, forkSession, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 9e149433e6..d136c63473 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -2522,6 +2522,8 @@ export function useKimiWebClient() { reorderWorkspaces, setWorkspaceSortMode, archiveSession: workspaceState.archiveSession, + restoreSession: workspaceState.restoreSession, + loadArchivedSessions: workspaceState.loadArchivedSessions, compact: workspaceState.compact, forkSession: workspaceState.forkSession, undo: workspaceState.undo, diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts index ae500f8024..a452bc7b11 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -6,6 +6,7 @@ export default { agent: 'Agent', account: 'Account', advanced: 'Advanced', + archived: 'Archived', }, appearance: 'Appearance', notifications: 'Notifications', @@ -47,4 +48,20 @@ export default { exportLogBtn: 'Export log', conversationToc: 'Show conversation outline', conversationTocHint: 'Show a clickable outline in the right margin to jump between messages', + archivedTitle: 'Archived sessions', + archivedDesc: 'Browse archived sessions, see their workspace path, name, and archive time, and restore them to the session list.', + archivedSearch: 'Search archived sessions', + archivedAllWorkspaces: 'All workspaces', + archivedSortLabel: 'Sort by', + archivedSortArchived: 'Archive time', + archivedSortCreated: 'Created time', + archivedSortName: 'Name', + archivedRestore: 'Restore', + archivedEmpty: 'No archived sessions yet', + archivedNoMatch: 'No matching archived sessions', + archivedSessionsCount: '{count} sessions', + archivedAt: 'Archived {time}', + archivedLoadMore: 'Load more', + archivedLoading: 'Loading…', + archivedLoadingAll: 'Loading all archived sessions…', }; diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts index 67e4f07f5c..34aa2f2939 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -6,6 +6,7 @@ export default { agent: 'Agent', account: '账户', advanced: '高级', + archived: '已归档', }, appearance: '外观', notifications: '通知', @@ -47,4 +48,20 @@ export default { exportLogBtn: '导出日志', conversationToc: '显示对话目录', conversationTocHint: '在右侧显示可点击跳转的对话目录', + archivedTitle: '已归档会话', + archivedDesc: '查看已归档会话,确认其所属工作区路径、会话名称和归档时间,并可恢复到会话列表。', + archivedSearch: '搜索已归档会话', + archivedAllWorkspaces: '所有工作区', + archivedSortLabel: '排序方式', + archivedSortArchived: '归档时间', + archivedSortCreated: '创建时间', + archivedSortName: '按字母顺序', + archivedRestore: '恢复', + archivedEmpty: '还没有归档的会话', + archivedNoMatch: '没有匹配的已归档会话', + archivedSessionsCount: '{count} 个会话', + archivedAt: '归档于 {time}', + archivedLoadMore: '加载更多', + archivedLoading: '加载中…', + archivedLoadingAll: '正在加载全部归档会话…', }; diff --git a/packages/server/src/lib/sessionArchive.ts b/packages/server/src/lib/sessionArchive.ts new file mode 100644 index 0000000000..c784662377 --- /dev/null +++ b/packages/server/src/lib/sessionArchive.ts @@ -0,0 +1,109 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { basename, isAbsolute, join, relative, resolve } from 'node:path'; + +import { SessionNotFoundError } from '@moonshot-ai/agent-core'; + +/** + * Temporary server-side session restore. + * + * TODO: remove once `@moonshot-ai/agent-core` exposes `ISessionService.restore` + * natively. At that point the `:restore` route should delegate to the service + * instead of rewriting `state.json` here. + * + * Archive is a boolean flag (`archived`) persisted in each session's + * `/state.json`. agent-core's `SessionStore` can set it to `true` + * (`archive`) but has no inverse; while agent-core is being refactored we flip + * it back from the server by: + * 1. reading `/session_index.jsonl` to resolve `sessionId -> sessionDir`; + * 2. validating the resolved dir is inside `/sessions` (defense + * against a tampered index); + * 3. read-modify-write `state.json` with `archived: false`. + * + * This mirrors `SessionStore.archive` and publishes no event (same as archive). + * The query read-model rebuilds from the store on every call, so a restored + * session shows up in subsequent lists with no extra invalidation. + */ + +interface SessionIndexEntry { + readonly sessionId: string; + readonly sessionDir: string; + readonly workDir: string; +} + +export async function restoreArchivedSession(homeDir: string, sessionId: string): Promise { + const sessionDir = await findSessionDir(homeDir, sessionId); + if (sessionDir === undefined) { + throw new SessionNotFoundError(sessionId); + } + + const statePath = join(sessionDir, 'state.json'); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(statePath, 'utf-8')) as unknown; + } catch { + throw new SessionNotFoundError(sessionId); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new SessionNotFoundError(sessionId); + } + + const next: Record = { + ...(parsed as Record), + archived: false, + updatedAt: new Date().toISOString(), + }; + await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); +} + +async function findSessionDir(homeDir: string, sessionId: string): Promise { + const indexPath = join(homeDir, 'session_index.jsonl'); + let raw: string; + try { + raw = await readFile(indexPath, 'utf-8'); + } catch { + return undefined; + } + + const sessionsDir = join(homeDir, 'sessions'); + let found: string | undefined; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const entry = parseIndexLine(trimmed); + if (entry === undefined || entry.sessionId !== sessionId) continue; + const sessionDir = resolve(entry.sessionDir); + if (!isAbsolute(entry.sessionDir)) continue; + if (!isPathInside(sessionsDir, sessionDir)) continue; + if (basename(sessionDir) !== entry.sessionId) continue; + // Last valid line wins, matching `readSessionIndex`'s Map semantics. + found = sessionDir; + } + return found; +} + +function parseIndexLine(line: string): SessionIndexEntry | undefined { + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== 'object' || parsed === null) return undefined; + const entry = parsed as Partial; + if ( + typeof entry.sessionId !== 'string' || + typeof entry.sessionDir !== 'string' || + typeof entry.workDir !== 'string' + ) { + return undefined; + } + return { + sessionId: entry.sessionId, + sessionDir: entry.sessionDir, + workDir: entry.workDir, + }; + } catch { + return undefined; + } +} + +function isPathInside(parent: string, child: string): boolean { + const rel = relative(resolve(parent), resolve(child)); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 2c69bdb807..f8ae7c5b05 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -23,11 +23,12 @@ import { workspaceIdSchema, type Event, } from '@moonshot-ai/protocol'; -import { IPromptService, ISessionService, SessionNotFoundError, SessionUndoUnavailableError, ErrorCodes, KimiError, IWorkspaceRegistry, WorkspaceNotFoundError, IEventService, type IInstantiationService, type SessionClientTelemetry } from '@moonshot-ai/agent-core'; +import { IPromptService, ISessionService, SessionNotFoundError, SessionUndoUnavailableError, ErrorCodes, KimiError, IEnvironmentService, IWorkspaceRegistry, WorkspaceNotFoundError, IEventService, type IInstantiationService, type SessionClientTelemetry } from '@moonshot-ai/agent-core'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; +import { restoreArchivedSession } from '../lib/sessionArchive'; import { defineRoute } from '../middleware/defineRoute'; import { parseActionSuffix } from './action-suffix'; @@ -84,6 +85,7 @@ const sessionsListQueryCoercion = z status: sessionStatusSchema.optional(), include_archive: booleanQueryParam, exclude_empty: booleanQueryParam, + archived_only: booleanQueryParam, workspace_id: workspaceIdSchema.optional(), }) @@ -96,6 +98,14 @@ const sessionsListQueryCoercion = z params: { code: ErrorCode.VALIDATION_FAILED }, }); } + if (value.archived_only === true && value.include_archive === true) { + ctx.addIssue({ + code: 'custom', + message: 'archived_only and include_archive are mutually exclusive', + path: ['archived_only'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } }); const sessionChildrenListQueryCoercion = z @@ -157,6 +167,71 @@ function headerString(headers: Record, key: string): string | u return trimmed.length === 0 ? undefined : trimmed; } +const DEFAULT_SESSION_LIST_PAGE_SIZE = 20; +const MAX_SESSION_LIST_PAGE_SIZE = 100; + +type SessionListRequest = Parameters[0]; +type SessionListPage = Awaited>; +type SessionListItem = SessionListPage['items'][number]; +type SessionListBaseQuery = Omit; +type SessionListCursor = Pick; + +function normalizeSessionListPageSize(cursor: SessionListCursor): number { + const requested = cursor.page_size ?? DEFAULT_SESSION_LIST_PAGE_SIZE; + return Math.min(Math.max(requested, 1), MAX_SESSION_LIST_PAGE_SIZE); +} + +async function listSessionsWithRouteFilter( + fetchPage: (query: SessionListRequest) => Promise, + baseQuery: SessionListBaseQuery, + cursor: SessionListCursor, + predicate: (session: SessionListItem) => boolean, +): Promise { + const targetSize = normalizeSessionListPageSize(cursor); + const forward = cursor.after_id !== undefined && cursor.before_id === undefined; + + const matches: SessionListItem[] = []; + // Forward starts from the after_id pivot (the newest page above it); backward + // starts from before_id (or the newest when there is no cursor). After the first + // page both drain toward older sessions via before_id. In forward mode we stop + // the moment we reach the pivot session itself, so paging stays within the + // after_id bound and never reintroduces the pivot or anything older. + let beforeId = forward ? undefined : cursor.before_id; + let afterId = forward ? cursor.after_id : undefined; + let coreHasMore = true; + + while (matches.length <= targetSize && coreHasMore) { + const page = await fetchPage({ + ...baseQuery, + before_id: beforeId, + after_id: afterId, + page_size: MAX_SESSION_LIST_PAGE_SIZE, + }); + if (page.items.length === 0) break; + + let hitPivot = false; + for (const session of page.items) { + if (forward && session.id === afterId) { + hitPivot = true; + break; + } + if (predicate(session)) matches.push(session); + } + coreHasMore = page.has_more && !hitPivot; + if (!coreHasMore) break; + + const nextBeforeId = page.items[page.items.length - 1]?.id; + if (nextBeforeId === undefined || nextBeforeId === beforeId) break; + beforeId = nextBeforeId; + afterId = undefined; + } + + return { + items: matches.slice(0, targetSize), + has_more: matches.length > targetSize, + }; +} + export function registerSessionsRoutes( app: SessionRouteHost, ix: IInstantiationService, @@ -267,15 +342,12 @@ export function registerSessionsRoutes( async (req, reply) => { try { const raw = req.query; - const baseQuery = { - before_id: raw.before_id, - after_id: raw.after_id, - page_size: raw.page_size, - status: raw.status, - includeArchive: raw.include_archive, + const archivedOnly = raw.archived_only === true; + const status = raw.status; + let baseQuery: SessionListBaseQuery = { + includeArchive: archivedOnly ? true : raw.include_archive, excludeEmpty: raw.exclude_empty, }; - let query; if (raw.workspace_id !== undefined) { const registry = ix.invokeFunction((a) => a.get(IWorkspaceRegistry)); let root: string; @@ -290,11 +362,30 @@ export function registerSessionsRoutes( } throw err; } - query = { ...baseQuery, workDir: root }; - } else { - query = baseQuery; + baseQuery = { ...baseQuery, workDir: root }; + } + + if (archivedOnly) { + const page = await listSessionsWithRouteFilter( + (query) => ix.invokeFunction((a) => a.get(ISessionService).list(query)), + baseQuery, + raw, + (session) => + session.archived === true && (status === undefined || session.status === status), + ); + reply.send(okEnvelope(page, req.id)); + return; } - const page = await ix.invokeFunction((a) => a.get(ISessionService).list(query)); + + const page = await ix.invokeFunction((a) => + a.get(ISessionService).list({ + ...baseQuery, + before_id: raw.before_id, + after_id: raw.after_id, + page_size: raw.page_size, + status, + }), + ); reply.send(okEnvelope(page, req.id)); } catch (err) { sendMappedError(reply, req.id, err); @@ -427,7 +518,7 @@ export function registerSessionsRoutes( const { tail } = req.params; const parsed = parseActionSuffix({ tail, - allowedActions: ['fork', 'compact', 'undo', 'abort', 'btw', 'archive'] as const, + allowedActions: ['fork', 'compact', 'undo', 'abort', 'btw', 'archive', 'restore'] as const, resourceLabel: 'session', }); if (parsed.kind !== 'action') { @@ -485,6 +576,16 @@ export function registerSessionsRoutes( return; } + if (parsed.action === 'restore') { + const homeDir = ix.invokeFunction((a) => a.get(IEnvironmentService)).homeDir; + await restoreArchivedSession(homeDir, parsed.id); + const session = await ix.invokeFunction((a) => + a.get(ISessionService).get(parsed.id), + ); + reply.send(okEnvelope(session, req.id)); + return; + } + const body = undoSessionRequestSchema.parse(req.body); const result = await ix.invokeFunction((a) => a.get(ISessionService).undo(parsed.id, body), diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 46d662d792..103bd22f8b 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -856,3 +856,167 @@ describe('POST /api/v1/sessions/{session_id}:archive — archive', () => { expect(env.code).toBe(40401); }); }); + +describe('GET /api/v1/sessions?archived_only — archived-only list', () => { + it('returns only archived sessions and hides them from the default list', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-archived-only'); + const created = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + + await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}:archive`, + payload: {}, + }); + + const defaultList = envelopeOf<{ items: Array<{ id: string }> }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions' })).json(), + ); + expect(defaultList.data!.items.find((s) => s.id === created.id)).toBeUndefined(); + + const archivedOnly = envelopeOf<{ items: Array<{ id: string; archived?: boolean }>; has_more: boolean }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions?archived_only=true' })).json(), + ); + expect(archivedOnly.code).toBe(0); + const listed = archivedOnly.data!.items.find((s) => s.id === created.id); + expect(listed).toBeDefined(); + expect(listed!.archived).toBe(true); + // No live session should leak into the archived-only view. + expect(archivedOnly.data!.items.every((s) => s.archived === true)).toBe(true); + }); + + it('paginates archived_only without returning empty filtered pages', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-archived-only-pagination'); + + const archivedOlder = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${archivedOlder.id}:archive`, + payload: {}, + }); + + const archivedNewer = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${archivedNewer.id}:archive`, + payload: {}, + }); + + await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + }); + await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + }); + + const first = envelopeOf<{ items: Array<{ id: string; archived?: boolean }>; has_more: boolean }>( + (await appOf(r).inject({ + method: 'GET', + url: '/api/v1/sessions?archived_only=true&page_size=1', + })).json(), + ); + expect(first.code).toBe(0); + expect(first.data!.items).toHaveLength(1); + expect(first.data!.items[0]).toMatchObject({ id: archivedNewer.id, archived: true }); + expect(first.data!.has_more).toBe(true); + + const second = envelopeOf<{ items: Array<{ id: string; archived?: boolean }>; has_more: boolean }>( + (await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions?archived_only=true&page_size=1&before_id=${archivedNewer.id}`, + })).json(), + ); + expect(second.code).toBe(0); + expect(second.data!.items).toHaveLength(1); + expect(second.data!.items[0]).toMatchObject({ id: archivedOlder.id, archived: true }); + expect(second.data!.has_more).toBe(false); + }); + + it('rejects archived_only together with include_archive', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/sessions?archived_only=true&include_archive=true', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); + +describe('POST /api/v1/sessions/{session_id}:restore — restore', () => { + it('restores an archived session so it reappears in the default list', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-restore'); + const created = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + + await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}:archive`, + payload: {}, + }); + + const restoreRes = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}:restore`, + payload: {}, + }); + const restoreEnv = envelopeOf(restoreRes.json()); + expect(restoreEnv.code).toBe(0); + const session = sessionSchema.parse(restoreEnv.data); + expect(session.id).toBe(created.id); + expect(session.archived).toBe(false); + + const defaultList = envelopeOf<{ items: Array<{ id: string; archived?: boolean }> }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions' })).json(), + ); + const relisted = defaultList.data!.items.find((s) => s.id === created.id); + expect(relisted).toBeDefined(); + expect(relisted!.archived).toBe(false); + + const getRes = envelopeOf( + (await appOf(r).inject({ method: 'GET', url: `/api/v1/sessions/${created.id}` })).json(), + ); + expect(getRes.code).toBe(0); + expect(sessionSchema.parse(getRes.data).archived).toBe(false); + }); + + it('returns 40401 for unknown id', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions/sess_missing:restore', + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); +}); From dd9077595db4eb92a4009beb2063efc49d44402c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 6 Jul 2026 20:52:40 +0800 Subject: [PATCH 19/98] chore(agent-core): classify turn_interrupted telemetry cause (#1431) Add an `interrupt_reason` field to the `turn_interrupted` telemetry event so the data can tell a deliberate user cancel (`user_cancelled`) apart from a programmatic abort (`aborted`), max-steps exhaustion (`max_steps`), an error (`error`), or a hook-filtered turn (`filtered`). The user-cancel signal comes from the existing UserCancellationError carried as the abort signal's reason, reused here without changing any loop control or external protocol semantics. --- packages/agent-core/src/agent/turn/index.ts | 53 ++++++++++++++++++-- packages/agent-core/src/loop/events.ts | 6 +++ packages/agent-core/src/loop/run-turn.ts | 10 +++- packages/agent-core/test/agent/turn.test.ts | 54 ++++++++++++++++++++- 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 31575418d8..a253fcb276 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -604,7 +604,17 @@ export class TurnFlow { this.agent.emitEvent(errorEvent); } if (ended.reason !== 'completed') { - this.trackTurnInterrupted(turnId, this.currentStepByTurn.get(turnId) ?? this.currentStep); + // Fallback for turns that end abnormally without a `turn.interrupted` + // loop event reaching `trackLoopTelemetry` (e.g. a user-prompt hook block + // or an abort that bypasses the step loop). `ended.reason` maps onto the + // same interrupt-reason taxonomy the loop-event path uses; for a + // `cancelled` end the signal's reason decides user_cancelled vs aborted. + const interruptReason = telemetryInterruptReason(ended.reason, isUserCancellation(signal.reason)); + this.trackTurnInterrupted( + turnId, + this.currentStepByTurn.get(turnId) ?? this.currentStep, + interruptReason, + ); } this.telemetryModeByTurn.delete(turnId); this.currentStepByTurn.delete(turnId); @@ -938,7 +948,11 @@ export class TurnFlow { if (event.reason === 'error' && event.activeStep !== undefined) { this.stepFailureByTurn.set(turnId, event); } - this.trackTurnInterrupted(turnId, interruptedStep(event)); + this.trackTurnInterrupted( + turnId, + interruptedStep(event), + event.interruptReason ?? telemetryInterruptReason(event.reason, false), + ); return; } this.trackToolLifecycle(event, turnId); @@ -1024,12 +1038,17 @@ export class TurnFlow { return false; } - private trackTurnInterrupted(turnId: number, atStep: number): void { + private trackTurnInterrupted( + turnId: number, + atStep: number, + interruptReason: TelemetryInterruptReason, + ): void { if (this.interruptedTelemetryTurnIds.has(turnId)) return; this.interruptedTelemetryTurnIds.add(turnId); this.agent.telemetry.track('turn_interrupted', { mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(), at_step: atStep, + interrupt_reason: interruptReason, ...this.requestProtocolProps(), }); } @@ -1232,6 +1251,34 @@ function interruptedStep(event: LoopTurnInterruptedEvent): number { return event.activeStep ?? event.attemptedSteps; } +/** + * Telemetry-facing interrupt reason. The loop reports `LoopInterruptReason` + * (`aborted` | `max_steps` | `error`); we split `aborted` into a deliberate + * user cancel vs. any other programmatic abort so telemetry can tell them + * apart. `filtered` is folded in for the fallback path (turn ends flagged + * `filtered` never emit a `turn.interrupted` loop event). + */ +type TelemetryInterruptReason = + | 'user_cancelled' + | 'aborted' + | 'max_steps' + | 'error' + | 'filtered'; + +function telemetryInterruptReason( + reason: LoopTurnInterruptedEvent['reason'] | Exclude, + userCancelled: boolean, +): TelemetryInterruptReason { + if ((reason === 'aborted' || reason === 'cancelled') && userCancelled) { + return 'user_cancelled'; + } + if (reason === 'aborted' || reason === 'cancelled') return 'aborted'; + if (reason === 'failed') return 'error'; + // Remaining values are `max_steps` | `error` | `filtered`, which match the + // telemetry enum. + return reason; +} + interface ApiErrorClassification { readonly errorType: string; readonly statusCode?: number; diff --git a/packages/agent-core/src/loop/events.ts b/packages/agent-core/src/loop/events.ts index 6fbc6c356f..5b19e3633f 100644 --- a/packages/agent-core/src/loop/events.ts +++ b/packages/agent-core/src/loop/events.ts @@ -4,6 +4,7 @@ import type { ToolInputDisplay } from '../tools/display'; import type { ExecutableToolResult, LoopStepStopReason, ToolUpdate } from './types'; export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error'; +export type LoopInterruptCause = LoopInterruptReason | 'user_cancelled'; export interface LoopStepBeginEvent { readonly type: 'step.begin'; @@ -94,6 +95,11 @@ export interface LoopTurnInterruptedEvent { readonly attemptedSteps: number; readonly activeStep?: number | undefined; readonly message?: string | undefined; + /** + * Telemetry-facing interrupt cause. `aborted` is split into a deliberate user + * cancel vs. any other abort; `max_steps`/`error` mirror `reason`. + */ + readonly interruptReason?: LoopInterruptCause | undefined; } export interface LoopTextDeltaEvent { diff --git a/packages/agent-core/src/loop/run-turn.ts b/packages/agent-core/src/loop/run-turn.ts index d6646ca1e4..06a59c1d5d 100644 --- a/packages/agent-core/src/loop/run-turn.ts +++ b/packages/agent-core/src/loop/run-turn.ts @@ -10,6 +10,7 @@ import { addUsage, emptyUsage, type TokenUsage } from '@moonshot-ai/kosong'; import type { Logger } from '#/logging/types'; +import { isUserCancellation } from '../utils/abort'; import { createMaxStepsExceededError, errorMessage, @@ -148,7 +149,12 @@ export async function runTurn(input: RunTurnInput): Promise { } } catch (error) { if (isAbortError(error) || signal.aborted) { - dispatchEvent(makeInterruptedEvent('aborted', steps, activeStep)); + // A deliberate user cancel travels as the signal's reason (and may be the + // thrown error itself). Report it distinctly from a timeout or other + // programmatic abort so telemetry can tell the two apart. + const interruptReason = + isUserCancellation(signal.reason) || isUserCancellation(error) ? 'user_cancelled' : 'aborted'; + dispatchEvent(makeInterruptedEvent('aborted', steps, activeStep, undefined, interruptReason)); return { stopReason: 'aborted', steps, usage }; } const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error'; @@ -164,6 +170,7 @@ function makeInterruptedEvent( attemptedSteps: number, activeStep: number | undefined, message?: string | undefined, + interruptReason: LoopTurnInterruptedEvent['interruptReason'] = reason, ): LoopTurnInterruptedEvent { return { type: 'turn.interrupted', @@ -171,5 +178,6 @@ function makeInterruptedEvent( attemptedSteps, ...(activeStep !== undefined ? { activeStep } : {}), ...(message !== undefined ? { message } : {}), + interruptReason, }; } diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 852b95d42d..2413c700bd 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -73,7 +73,59 @@ describe('Agent turn flow', () => { }); expect(records).toContainEqual({ event: 'turn_interrupted', - properties: { mode: 'agent', at_step: 0 }, + properties: { mode: 'agent', at_step: 0, interrupt_reason: 'error' }, + }); + }); + + it('reports turn_interrupted telemetry as user_cancelled on manual abort', async () => { + const records: TelemetryRecord[] = []; + const ctx = testAgent({ + kaos: createCommandKaos('should-not-run'), + telemetry: recordingTelemetry(records), + }); + ctx.configure({ tools: ['Bash'] }); + + ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall()); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command' }] }); + await ctx.untilApprovalRequest(); + + // User presses stop: the RPC cancel carries no explicit reason, which the + // turn treats as a deliberate user cancellation. + await ctx.rpc.cancel({ turnId: 0 }); + await ctx.untilTurnEnd(); + + const interrupted = records.find((candidate) => candidate.event === 'turn_interrupted'); + expect(interrupted).toEqual({ + event: 'turn_interrupted', + properties: expect.objectContaining({ + mode: 'agent', + interrupt_reason: 'user_cancelled', + }), + }); + }); + + it('reports turn_interrupted telemetry as aborted on programmatic abort', async () => { + const records: TelemetryRecord[] = []; + const ctx = testAgent({ + kaos: createCommandKaos('should-not-run'), + telemetry: recordingTelemetry(records), + }); + ctx.configure({ tools: ['Bash'] }); + + ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall()); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command' }] }); + await ctx.untilApprovalRequest(); + + // A programmatic abort (e.g. a subagent deadline timeout) carries a plain + // AbortError as its reason, not a UserCancellationError, so telemetry must + // not report it as a user cancellation. + ctx.agent.turn.cancel(0, abortError()); + await ctx.untilTurnEnd(); + + const interrupted = records.find((candidate) => candidate.event === 'turn_interrupted'); + expect(interrupted).toEqual({ + event: 'turn_interrupted', + properties: expect.objectContaining({ mode: 'agent', interrupt_reason: 'aborted' }), }); }); From 4aacddc43222d0a44f202360462617788ca75660 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 21:18:45 +0800 Subject: [PATCH 20/98] fix(kimi-web): clarify desktop notification title and icon (#1434) --- .changeset/web-notification-title-icon.md | 5 ++ .../src/composables/client/useNotification.ts | 73 ++++++++++++++++--- apps/kimi-web/src/i18n/locales/en/settings.ts | 6 +- apps/kimi-web/src/i18n/locales/zh/settings.ts | 6 +- apps/kimi-web/test/notification-logic.test.ts | 54 +++++++++++++- 5 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 .changeset/web-notification-title-icon.md diff --git a/.changeset/web-notification-title-icon.md b/.changeset/web-notification-title-icon.md new file mode 100644 index 0000000000..0b817363b8 --- /dev/null +++ b/.changeset/web-notification-title-icon.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show the Kimi icon and clearer titles in web desktop notifications. diff --git a/apps/kimi-web/src/composables/client/useNotification.ts b/apps/kimi-web/src/composables/client/useNotification.ts index 29b6b3034d..64a7558bd6 100644 --- a/apps/kimi-web/src/composables/client/useNotification.ts +++ b/apps/kimi-web/src/composables/client/useNotification.ts @@ -26,6 +26,8 @@ const notifyPermission = ref( typeof Notification !== 'undefined' ? Notification.permission : 'denied', ); +const NOTIFICATION_ICON = '/favicon.ico'; + /** Shared setter: disabling is instant; enabling requests OS permission first and stays off if the user blocks it. */ async function setNotifyPref(pref: Ref, key: string, on: boolean): Promise { @@ -63,7 +65,7 @@ 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; - /** Session title used as the notification title. */ + /** 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; @@ -71,14 +73,53 @@ export interface NotifyCompletionCtx { export interface NotifyQuestionCtx extends NotifyCompletionCtx { /** Short preview of the question, used as the notification body. Falls back - to a generic line when empty. */ + to the session title, then to a generic line when empty. */ questionPreview: string; } +export interface NotificationCopy { + readonly title: string; + readonly body: string; +} + +function firstText(...values: Array): string { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) return trimmed; + } + return ''; +} + +export function completionNotificationCopy(sessionTitle: string): NotificationCopy { + return { + title: i18n.global.t('settings.notifyTitle'), + body: firstText(sessionTitle, i18n.global.t('settings.notifyFallback')), + }; +} + +export function questionNotificationCopy( + sessionTitle: string, + questionPreview: string, +): NotificationCopy { + return { + title: i18n.global.t('settings.notifyQuestionTitle'), + body: firstText( + questionPreview, + sessionTitle, + i18n.global.t('settings.notifyQuestionFallback'), + ), + }; +} + /** Shared permission gate + fire. `enabled` is the caller's per-kind preference; - `body` and `tag` let each kind carry its own text and a per-kind dedup tag + `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. */ -function maybeNotify(enabled: boolean, ctx: NotifyCompletionCtx, body: string, tag: string): void { +function maybeNotify( + enabled: boolean, + ctx: NotifyCompletionCtx, + copy: NotificationCopy, + tag: string, +): void { if (!enabled) return; if (typeof Notification === 'undefined') return; const perm = Notification.permission; @@ -87,18 +128,17 @@ function maybeNotify(enabled: boolean, ctx: NotifyCompletionCtx, body: string, t // Request permission asynchronously; if granted, fire the notification. void Notification.requestPermission().then((p) => { notifyPermission.value = p; - if (p === 'granted') fire(ctx, body, tag); + if (p === 'granted') fire(ctx, copy, tag); }); return; } - fire(ctx, body, tag); + fire(ctx, copy, tag); } -function fire(ctx: NotifyCompletionCtx, body: string, tag: string): void { +function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): void { if (ctx.isActiveAndVisible) return; - const title = ctx.sessionTitle.trim() || 'Kimi Code'; try { - const n = new Notification(title, { body, tag }); + const n = new Notification(copy.title, { body: copy.body, tag, icon: NOTIFICATION_ICON }); n.onclick = () => { try { window.focus(); @@ -116,14 +156,23 @@ function fire(ctx: NotifyCompletionCtx, body: string, tag: string): void { /** Fire a completion notification for a finished session, but only when the caller says the user isn't already looking at it. */ function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void { - maybeNotify(notifyOnComplete.value, ctx, i18n.global.t('settings.notifyBody'), `kimi-complete-${sid}`); + maybeNotify( + notifyOnComplete.value, + ctx, + completionNotificationCopy(ctx.sessionTitle), + `kimi-complete-${sid}`, + ); } /** 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 { - const body = ctx.questionPreview || i18n.global.t('settings.notifyQuestionBody'); - maybeNotify(notifyOnQuestion.value, ctx, body, `kimi-question-${sid}`); + maybeNotify( + notifyOnQuestion.value, + ctx, + questionNotificationCopy(ctx.sessionTitle, ctx.questionPreview), + `kimi-question-${sid}`, + ); } export function useNotification() { diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts index a452bc7b11..ec2c8d7707 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -14,8 +14,10 @@ export default { notifyOnQuestion: 'Notify when a question needs an answer', soundOnComplete: 'Play a sound when a turn completes or needs an answer', notifyDenied: 'Blocked in browser settings', - notifyBody: 'Finished a turn', - notifyQuestionBody: 'A question is waiting for your answer', + notifyTitle: 'Kimi Code · Turn finished', + notifyQuestionTitle: 'Kimi Code · Needs answer', + notifyFallback: 'View result', + notifyQuestionFallback: 'A question is waiting for your answer', 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 34aa2f2939..a06a14bdda 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -14,8 +14,10 @@ export default { notifyOnQuestion: '待回答时通知', soundOnComplete: '会话完成或待回答时播放提示音', notifyDenied: '已在浏览器设置中被阻止', - notifyBody: '已完成一轮', - notifyQuestionBody: '有提问等待你回答', + notifyTitle: 'Kimi Code · 回合完成', + notifyQuestionTitle: 'Kimi Code · 待回答', + notifyFallback: '点击查看结果', + notifyQuestionFallback: '有提问等待你回答', account: '账户', uiFontSize: '字体大小', agentDefaults: 'Agent 默认值', diff --git a/apps/kimi-web/test/notification-logic.test.ts b/apps/kimi-web/test/notification-logic.test.ts index 73a8619f6d..f10a31d7d4 100644 --- a/apps/kimi-web/test/notification-logic.test.ts +++ b/apps/kimi-web/test/notification-logic.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { i18n } from '../src/i18n'; import { STORAGE_KEYS, safeGetString } from '../src/lib/storage'; -import { useNotification } from '../src/composables/client/useNotification'; +import { + completionNotificationCopy, + questionNotificationCopy, + useNotification, +} from '../src/composables/client/useNotification'; function createMemoryStorage(): Storage { const data = new Map(); @@ -71,3 +76,50 @@ describe('useNotification preferences', () => { expect(safeGetString(STORAGE_KEYS.notifyOnComplete)).toBe('0'); }); }); + +describe('notification copy', () => { + beforeEach(() => { + i18n.global.locale.value = 'en'; + }); + + it('uses an event title and session-title body for completion notifications', () => { + expect(completionNotificationCopy('Refactor auth flow')).toEqual({ + title: 'Kimi Code · Turn finished', + body: 'Refactor auth flow', + }); + }); + + it('falls back to a result hint when there is no session title', () => { + expect(completionNotificationCopy(' ')).toEqual({ + title: 'Kimi Code · Turn finished', + body: 'View result', + }); + }); + + it('prefers the question preview in question notifications', () => { + expect(questionNotificationCopy('Storage migration', 'Which database?')).toEqual({ + title: 'Kimi Code · Needs answer', + body: 'Which database?', + }); + }); + + it('falls back to the session title before the generic question line', () => { + expect(questionNotificationCopy('Storage migration', ' ')).toEqual({ + title: 'Kimi Code · Needs answer', + body: 'Storage migration', + }); + }); + + it('localizes the notification copy', () => { + i18n.global.locale.value = 'zh'; + + expect(completionNotificationCopy('')).toEqual({ + title: 'Kimi Code · 回合完成', + body: '点击查看结果', + }); + expect(questionNotificationCopy('', '')).toEqual({ + title: 'Kimi Code · 待回答', + body: '有提问等待你回答', + }); + }); +}); From 578f7d334c7919e5987229c157060b1daae30139 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 21:30:25 +0800 Subject: [PATCH 21/98] fix(web): reconcile session from snapshot on reopen (#1409) * fix(web): reconcile session from snapshot on reopen * fix(web): discard stale snapshot when a newer prompt races reopen * fix(web): harden reopen snapshot against first-open and optimistic-send races * fix(web): keep evicted reopens subscribed when a snapshot races * fix(web): let resync snapshots bypass the reopen staleness guard * fix(web): force-apply the snapshot after an undo * fix(web): gate session reopen on durable seq instead of updatedAt * refactor(web): always rebuild reopened sessions from a snapshot * fix(web): sharpen reopen snapshot discard and skip rebuilds mid-stream * refactor(web): unconditionally apply session snapshots, drop the staleness guard * fix(web): preserve loaded older messages when reopen snapshots apply --- .changeset/fix-web-reopen-reconcile.md | 5 ++ .../composables/client/useWorkspaceState.ts | 7 ++- .../src/composables/useKimiWebClient.ts | 49 +++++++------------ apps/kimi-web/src/lib/snapshotMessages.ts | 27 ++++++++++ apps/kimi-web/test/lib-logic.test.ts | 42 +++++++++++++++- 5 files changed, 94 insertions(+), 36 deletions(-) create mode 100644 .changeset/fix-web-reopen-reconcile.md create mode 100644 apps/kimi-web/src/lib/snapshotMessages.ts diff --git a/.changeset/fix-web-reopen-reconcile.md b/.changeset/fix-web-reopen-reconcile.md new file mode 100644 index 0000000000..fd5eb32821 --- /dev/null +++ b/.changeset/fix-web-reopen-reconcile.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix the end of a reply staying missing after reopening a session. diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 2f3776950d..1cff661331 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -972,10 +972,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const result = await syncSessionFromSnapshot(sessionId); if (result === 'not-found') return; } else { - // Re-open: if the session was evicted from the subscription cap since - // last open, reopenSession rebuilds it from a snapshot (the kept cursor - // may have skipped per-session events); otherwise it re-subscribes from - // the tracked cursor and the daemon replays any missed durable events. + // Re-open: rebuild from a fresh snapshot rather than resuming from the + // tracked cursor — the daemon only replays durable events, so volatile + // streamed deltas lost to a WS hiccup would otherwise stay missing. const result = await reopenSession(sessionId); if (result === 'not-found') return; } diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index d136c63473..2b1e7b6d08 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -13,6 +13,7 @@ import { type WorkspaceSortMode, } from '../lib/workspaceOrder'; import { mergeWorkspaces } from '../lib/mergeWorkspaces'; +import { mergeSnapshotMessages } from '../lib/snapshotMessages'; import { createCoalescedAsyncRunner } from '../lib/snapshotSync'; import { loadUnread, @@ -1137,7 +1138,12 @@ async function syncSessionFromSnapshot(sessionId: string): Promise { - if (sessionsWithStaleCursor.has(sessionId)) { - return syncSessionFromSnapshot(sessionId); - } - subscribeToSessionEvents(sessionId); - return 'ok'; + return syncSessionFromSnapshot(sessionId); } // --------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/lib/snapshotMessages.ts b/apps/kimi-web/src/lib/snapshotMessages.ts new file mode 100644 index 0000000000..29b301ee6a --- /dev/null +++ b/apps/kimi-web/src/lib/snapshotMessages.ts @@ -0,0 +1,27 @@ +// apps/kimi-web/src/lib/snapshotMessages.ts +// Merge an authoritative snapshot tail into already-loaded messages. +// +// The session snapshot returns only the most recent bounded page. After a user +// has loaded older pages, replacing the whole message array with that tail would +// drop the older prefix they already fetched and reset scrollback. Preserve any +// loaded messages older than the snapshot window; the snapshot is authoritative +// for its own window and replaces anything inside it. +import type { AppMessage } from '../api/types'; + +export function mergeSnapshotMessages( + loaded: AppMessage[], + snapshot: AppMessage[], +): AppMessage[] { + if (snapshot.length === 0) return snapshot; + if (loaded.length === 0) return snapshot; + + const earliestSnapshotMs = Date.parse(snapshot[0]!.createdAt); + if (Number.isNaN(earliestSnapshotMs)) return snapshot; + + const older = loaded.filter((message) => { + const createdAtMs = Date.parse(message.createdAt); + return !Number.isNaN(createdAtMs) && createdAtMs < earliestSnapshotMs; + }); + + return older.length > 0 ? [...older, ...snapshot] : snapshot; +} diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index d04269b014..00ca0f0132 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -8,6 +8,7 @@ import { parseDiff } from '../src/lib/parseDiff'; import { buildDiffLines } from '../src/lib/diffLines'; import { buildEditDiffLines } from '../src/lib/toolDiff'; import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync'; +import { mergeSnapshotMessages } from '../src/lib/snapshotMessages'; import { normalizeToolName, toolSummary } from '../src/lib/toolMeta'; import { coerceThinkingForModel, @@ -17,7 +18,7 @@ import { modelThinkingAvailability, segmentsFor, } from '../src/lib/modelThinking'; -import type { AppModel } from '../src/api/types'; +import type { AppMessage, AppModel } from '../src/api/types'; import { resolveToolRenderer } from '../src/components/chat/tool-calls/toolRegistry'; import AgentTool from '../src/components/chat/tool-calls/AgentTool.vue'; import EditTool from '../src/components/chat/tool-calls/EditTool.vue'; @@ -369,3 +370,42 @@ describe('modelThinking', () => { }); }); }); + +describe('mergeSnapshotMessages', () => { + function msg(id: string, createdAt: string): AppMessage { + return { id, sessionId: 's1', role: 'assistant', content: [], createdAt }; + } + + it('keeps loaded messages older than the snapshot window', () => { + const loaded = [ + msg('old-1', '2026-01-01T00:00:00.000Z'), + msg('old-2', '2026-01-02T00:00:00.000Z'), + msg('recent-live', '2026-01-03T00:00:00.000Z'), + ]; + const snapshot = [ + msg('m0', '2026-01-03T00:00:00.000Z'), + msg('m1', '2026-01-04T00:00:00.000Z'), + ]; + expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual([ + 'old-1', + 'old-2', + 'm0', + 'm1', + ]); + }); + + it('returns the snapshot when there is no older loaded prefix', () => { + const loaded = [msg('recent-live', '2026-01-03T00:00:00.000Z')]; + const snapshot = [ + msg('m0', '2026-01-03T00:00:00.000Z'), + msg('m1', '2026-01-04T00:00:00.000Z'), + ]; + expect(mergeSnapshotMessages(loaded, snapshot)).toBe(snapshot); + }); + + it('returns the snapshot when either side is empty', () => { + const snapshot = [msg('m0', '2026-01-03T00:00:00.000Z')]; + expect(mergeSnapshotMessages([], snapshot)).toBe(snapshot); + expect(mergeSnapshotMessages(snapshot, [])).toEqual([]); + }); +}); From 10922fc70fc9b69bb7b5806a1e545854c50ac72a Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 6 Jul 2026 21:54:12 +0800 Subject: [PATCH 22/98] feat(telemetry): add system metrics collection (#1435) * feat(telemetry): add system metrics collection Add periodic CPU and memory telemetry sampling with warmup capture, lifecycle cleanup, and tests. * fix(telemetry): attach prompt session to system metrics --- apps/kimi-code/src/cli/run-prompt.ts | 1 + apps/kimi-code/src/cli/telemetry.ts | 2 + apps/kimi-code/test/cli/export.test.ts | 1 + apps/kimi-code/test/cli/run-prompt.test.ts | 3 + apps/kimi-code/test/cli/run-shell.test.ts | 1 + packages/telemetry/src/bootstrap.ts | 6 + packages/telemetry/src/client.ts | 22 ++++ packages/telemetry/src/systemMetrics.ts | 107 ++++++++++++++++ packages/telemetry/test/telemetry.test.ts | 135 +++++++++++++++++++++ 9 files changed, 278 insertions(+) create mode 100644 packages/telemetry/src/systemMetrics.ts diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 66315ff3e7..a9221d215b 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -179,6 +179,7 @@ export async function runPrompt( version, uiMode: PROMPT_UI_MODE, model: telemetryModel, + sessionId: session.id, }); setCrashPhase('runtime'); diff --git a/apps/kimi-code/src/cli/telemetry.ts b/apps/kimi-code/src/cli/telemetry.ts index 2e7f49b4bc..b228c913ee 100644 --- a/apps/kimi-code/src/cli/telemetry.ts +++ b/apps/kimi-code/src/cli/telemetry.ts @@ -32,6 +32,7 @@ export interface InitializeCliTelemetryOptions { readonly version: string; readonly uiMode: string; readonly model?: string; + readonly sessionId?: string; } export function createCliTelemetryBootstrap(): CliTelemetryBootstrap { @@ -54,6 +55,7 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): version: options.version, uiMode: options.uiMode, model: options.model ?? options.config.defaultModel, + sessionId: options.sessionId, getAccessToken: async () => (await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); diff --git a/apps/kimi-code/test/cli/export.test.ts b/apps/kimi-code/test/cli/export.test.ts index 4518cf3e75..14fdc01900 100644 --- a/apps/kimi-code/test/cli/export.test.ts +++ b/apps/kimi-code/test/cli/export.test.ts @@ -412,6 +412,7 @@ describe('kimi export', () => { version: expect.any(String), uiMode: 'shell', model: 'k2', + sessionId: undefined, getAccessToken: expect.any(Function), }); expect(mocks.initializeTelemetry.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index d75a6b3a50..aafb99af90 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -217,6 +217,9 @@ describe('runPrompt', () => { expect(mocks.session.prompt).toHaveBeenCalledWith('say hello'); expect(stdout.text()).toBe('• hello world\n\n'); expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'ses_prompt' }), + ); expect(mocks.shutdownTelemetry).toHaveBeenCalled(); expect(mocks.harnessClose).toHaveBeenCalled(); }); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index b582c8543b..3fa673ab9f 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -213,6 +213,7 @@ describe('runShell', () => { version: '1.2.3-test', uiMode: 'shell', model: 'k2', + sessionId: undefined, getAccessToken: expect.any(Function), }); expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime'); diff --git a/packages/telemetry/src/bootstrap.ts b/packages/telemetry/src/bootstrap.ts index 041e650d43..f5d79f0635 100644 --- a/packages/telemetry/src/bootstrap.ts +++ b/packages/telemetry/src/bootstrap.ts @@ -1,5 +1,6 @@ import { getDefaultTelemetryClient } from './client'; import { EventSink } from './sink'; +import { SystemMetricsCollector } from './systemMetrics'; import { AsyncTransport } from './transport'; export const TELEMETRY_DISABLE_ENV = 'KIMI_DISABLE_TELEMETRY'; @@ -65,5 +66,10 @@ export function initializeTelemetry(options: TelemetryBootstrapOptions): void { client.attachSink(sink); sink.startPeriodicFlush(); + + const systemMetricsCollector = new SystemMetricsCollector({ client }); + client.setSystemMetricsCollector(systemMetricsCollector); + systemMetricsCollector.start(); + void sink.retryDiskEvents().catch(() => {}); } diff --git a/packages/telemetry/src/client.ts b/packages/telemetry/src/client.ts index d4904e8f2f..3710278a15 100644 --- a/packages/telemetry/src/client.ts +++ b/packages/telemetry/src/client.ts @@ -13,6 +13,10 @@ export interface TelemetryShutdownOptions { readonly timeoutMs?: number; } +export interface SystemMetricsCollectorHandle { + stop(): void; +} + const MAX_QUEUE_SIZE = 1000; interface PendingTelemetryEvent extends TelemetryEvent { @@ -25,6 +29,7 @@ interface PendingTelemetryEvent extends TelemetryEvent { export class TelemetryClient { private queue: PendingTelemetryEvent[] = []; private sink: EventSink | null = null; + private systemMetricsCollector: SystemMetricsCollectorHandle | null = null; private deviceId: string | null = null; private sessionId: string | null = null; private disabled = false; @@ -38,6 +43,13 @@ export class TelemetryClient { return new ScopedTelemetryClient(this, input); } + setSystemMetricsCollector(collector: SystemMetricsCollectorHandle): void { + if (this.systemMetricsCollector !== null && this.systemMetricsCollector !== collector) { + this.systemMetricsCollector.stop(); + } + this.systemMetricsCollector = collector; + } + attachSink(sink: EventSink): void { if (this.sink !== null && this.sink !== sink) { this.sink.stopPeriodicFlush(); @@ -60,6 +72,8 @@ export class TelemetryClient { disable(): void { this.disabled = true; this.queue = []; + this.systemMetricsCollector?.stop(); + this.systemMetricsCollector = null; if (this.sink !== null) { this.sink.stopPeriodicFlush(); this.sink.clearBuffer(); @@ -116,6 +130,8 @@ export class TelemetryClient { } async shutdown(options: TelemetryShutdownOptions = {}): Promise { + this.systemMetricsCollector?.stop(); + this.systemMetricsCollector = null; const sink = this.sink; if (sink === null) return; sink.stopPeriodicFlush(); @@ -139,6 +155,8 @@ export class TelemetryClient { resetForTests(): void { this.sink?.stopPeriodicFlush(); + this.systemMetricsCollector?.stop(); + this.systemMetricsCollector = null; this.queue = []; this.sink = null; this.deviceId = null; @@ -163,6 +181,10 @@ class ScopedTelemetryClient extends TelemetryClient { return new ScopedTelemetryClient(this.parent, mergeContext(this.context, input)); } + override setSystemMetricsCollector(collector: SystemMetricsCollectorHandle): void { + this.parent.setSystemMetricsCollector(collector); + } + override attachSink(sink: EventSink): void { this.parent.attachSink(sink); } diff --git a/packages/telemetry/src/systemMetrics.ts b/packages/telemetry/src/systemMetrics.ts new file mode 100644 index 0000000000..a00fe03d48 --- /dev/null +++ b/packages/telemetry/src/systemMetrics.ts @@ -0,0 +1,107 @@ +import { cpus, freemem, loadavg, totalmem } from 'node:os'; + +import type { TelemetryProperties } from './types'; + +const DEFAULT_INTERVAL_MS = 30_000; +const DEFAULT_WARMUP_SAMPLE_MS = 1_500; +const SYSTEM_METRICS_EVENT = 'system_metrics'; + +export interface SystemMetricsTrackClient { + track(event: string, properties?: TelemetryProperties): void; +} + +export interface SystemMetricsCollectorOptions { + readonly client: SystemMetricsTrackClient; + readonly intervalMs?: number; + readonly warmupSampleMs?: number | null; +} + +export class SystemMetricsCollector { + private readonly client: SystemMetricsTrackClient; + private readonly intervalMs: number; + private readonly warmupSampleMs: number | null; + private intervalTimer: ReturnType | null = null; + private warmupTimer: ReturnType | null = null; + private previousCpuUsage = process.cpuUsage(); + private previousHrtime = process.hrtime.bigint(); + private readonly processStartedAtSeconds = Math.floor(Date.now() / 1000 - process.uptime()); + + constructor(options: SystemMetricsCollectorOptions) { + this.client = options.client; + this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS; + this.warmupSampleMs = + options.warmupSampleMs === undefined ? DEFAULT_WARMUP_SAMPLE_MS : options.warmupSampleMs; + } + + start(): void { + if (this.intervalTimer !== null) return; + + if (this.warmupSampleMs !== null && this.warmupSampleMs > 0) { + this.warmupTimer = setTimeout(() => { + this.warmupTimer = null; + this.sampleSafely(); + }, this.warmupSampleMs); + this.warmupTimer.unref?.(); + } + + this.intervalTimer = setInterval(() => { + this.sampleSafely(); + }, this.intervalMs); + this.intervalTimer.unref?.(); + } + + stop(): void { + if (this.warmupTimer !== null) { + clearTimeout(this.warmupTimer); + this.warmupTimer = null; + } + if (this.intervalTimer !== null) { + clearInterval(this.intervalTimer); + this.intervalTimer = null; + } + } + + private sampleSafely(): void { + try { + this.sample(); + } catch { + this.stop(); + } + } + + private sample(): void { + const now = process.hrtime.bigint(); + const elapsedUs = Number(now - this.previousHrtime) / 1_000; + + const cpu = process.cpuUsage(this.previousCpuUsage); + this.previousCpuUsage = process.cpuUsage(); + this.previousHrtime = now; + + const mem = process.memoryUsage(); + const constrainedMemory = getConstrainedMemoryBytes(); + + this.client.track(SYSTEM_METRICS_EVENT, { + process_started_at: this.processStartedAtSeconds, + process_uptime_ms: Math.round(process.uptime() * 1000), + rss_bytes: mem.rss, + heap_used_bytes: mem.heapUsed, + heap_total_bytes: mem.heapTotal, + external_bytes: mem.external, + array_buffers_bytes: mem.arrayBuffers, + constrained_memory_bytes: constrainedMemory, + cpu_user_us: cpu.user, + cpu_system_us: cpu.system, + cpu_elapsed_us: Math.round(elapsedUs), + load_avg_1m: loadavg()[0], + free_mem_bytes: freemem(), + total_mem_bytes: totalmem(), + cpu_count: cpus().length, + }); + } +} + +function getConstrainedMemoryBytes(): number | undefined { + if (typeof process.constrainedMemory !== 'function') return undefined; + const value = process.constrainedMemory(); + return Number.isFinite(value) ? value : undefined; +} diff --git a/packages/telemetry/test/telemetry.test.ts b/packages/telemetry/test/telemetry.test.ts index a19082b7c3..8e048f6359 100644 --- a/packages/telemetry/test/telemetry.test.ts +++ b/packages/telemetry/test/telemetry.test.ts @@ -12,6 +12,7 @@ import { isTelemetryDisabledByEnv } from '../src/bootstrap'; import { TelemetryClient, resetDefaultTelemetryClientForTests } from '../src/client'; import { installCrashHandlersForClient, setCrashPhase, uninstallCrashHandlers } from '../src/crash'; import { EventSink } from '../src/sink'; +import { SystemMetricsCollector } from '../src/systemMetrics'; import { AsyncTransport, DISK_EVENT_MAX_AGE_MS, @@ -30,6 +31,7 @@ afterEach(() => { uninstallCrashHandlers(); setCrashPhase('startup'); resetDefaultTelemetryClientForTests(); + vi.useRealTimers(); for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -158,6 +160,19 @@ describe('TelemetryClient', () => { expect(transport.sent).toHaveLength(0); }); + it('stops the previous system metrics collector when replacing it', () => { + const client = new TelemetryClient(); + const first = { stop: vi.fn() }; + const second = { stop: vi.fn() }; + + client.setSystemMetricsCollector(first); + client.setSystemMetricsCollector(second); + client.disable(); + + expect(first.stop).toHaveBeenCalledTimes(1); + expect(second.stop).toHaveBeenCalledTimes(1); + }); + it('flushes the previous sink synchronously when replacing sinks', () => { const client = new TelemetryClient(); const first = new RecordingTransport(); @@ -204,6 +219,77 @@ describe('TelemetryClient', () => { }); }); +describe('SystemMetricsCollector', () => { + it('emits a numeric system_metrics sample after the warmup delay', () => { + vi.useFakeTimers(); + const tracked: Array<{ + event: string; + properties: Record; + }> = []; + const client = { + track( + event: string, + properties: Record = {}, + ): void { + tracked.push({ event, properties }); + }, + }; + const collector = new SystemMetricsCollector({ + client, + intervalMs: 30_000, + warmupSampleMs: 1_500, + }); + + collector.start(); + vi.advanceTimersByTime(1_499); + expect(tracked).toHaveLength(0); + + vi.advanceTimersByTime(1); + collector.stop(); + + expect(tracked).toHaveLength(1); + const event = tracked[0]; + if (event === undefined) throw new Error('Expected a system_metrics event'); + expect(event.event).toBe('system_metrics'); + expect(numberProperty(event.properties, 'process_started_at')).toBeGreaterThan(0); + expect(numberProperty(event.properties, 'process_uptime_ms')).toBeGreaterThanOrEqual(0); + expect(numberProperty(event.properties, 'rss_bytes')).toBeGreaterThan(0); + expect(numberProperty(event.properties, 'heap_used_bytes')).toBeGreaterThan(0); + expect(numberProperty(event.properties, 'heap_total_bytes')).toBeGreaterThan(0); + expect(numberProperty(event.properties, 'external_bytes')).toBeGreaterThanOrEqual(0); + expect(numberProperty(event.properties, 'array_buffers_bytes')).toBeGreaterThanOrEqual(0); + expect(numberProperty(event.properties, 'cpu_user_us')).toBeGreaterThanOrEqual(0); + expect(numberProperty(event.properties, 'cpu_system_us')).toBeGreaterThanOrEqual(0); + expect(numberProperty(event.properties, 'cpu_elapsed_us')).toBeGreaterThan(0); + expect(numberProperty(event.properties, 'load_avg_1m')).toBeGreaterThanOrEqual(0); + expect(numberProperty(event.properties, 'free_mem_bytes')).toBeGreaterThanOrEqual(0); + expect(numberProperty(event.properties, 'total_mem_bytes')).toBeGreaterThan(0); + expect(numberProperty(event.properties, 'cpu_count')).toBeGreaterThanOrEqual(1); + }); + + it('does not duplicate interval sampling when started twice', () => { + vi.useFakeTimers(); + const tracked: string[] = []; + const client = { + track(event: string): void { + tracked.push(event); + }, + }; + const collector = new SystemMetricsCollector({ + client, + intervalMs: 30_000, + warmupSampleMs: null, + }); + + collector.start(); + collector.start(); + vi.advanceTimersByTime(30_000); + collector.stop(); + + expect(tracked).toEqual(['system_metrics']); + }); +}); + describe('EventSink', () => { it('enriches context without mutating the original event', () => { const transport = new RecordingTransport(); @@ -692,6 +778,44 @@ describe('telemetry bootstrap', () => { const file = readFileSync(join(telemetryDir, readdirOne(telemetryDir)), 'utf-8'); expect(file).toContain('"event":"sync_flush"'); }); + + it('writes system metrics with the singleton session context', async () => { + vi.useFakeTimers(); + const homeDir = await tempHome(); + initializeTelemetry({ + homeDir, + deviceId: 'dev', + sessionId: 'ses', + appName: 'kimi-code-cli', + version: '1.2.3', + }); + + vi.advanceTimersByTime(1_500); + flushTelemetrySync(); + + const telemetryDir = join(homeDir, 'telemetry'); + const file = readFileSync(join(telemetryDir, readdirOne(telemetryDir)), 'utf-8'); + const events = file + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { + event: string; + session_id: string | null; + properties: Record; + }, + ); + const metrics = events.find((event) => event.event === 'system_metrics'); + if (metrics === undefined) throw new Error('Expected a system_metrics event'); + + expect(metrics.session_id).toBe('ses'); + expect(Number.isFinite(metrics.properties['process_started_at'])).toBe(true); + expect(metrics.properties['process_started_at']).toBeGreaterThan(0); + expect(Number.isFinite(metrics.properties['process_uptime_ms'])).toBe(true); + expect(metrics.properties['process_uptime_ms']).toBeGreaterThanOrEqual(0); + expect(metrics.properties['rss_bytes']).toBeGreaterThan(0); + }); }); describe('crash handler', () => { @@ -826,6 +950,17 @@ function readdirOne(dir: string): string { return entry; } +function numberProperty( + properties: Record, + key: string, +): number { + const value = properties[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Expected property ${key} to be a finite number, got ${String(value)}`); + } + return value; +} + function requestInitFrom( fetchImpl: { readonly mock: { readonly calls: readonly unknown[][] } }, index = 0, From a5fbcb75b4b3ab937536a7a2f621c0374812c753 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 21:54:38 +0800 Subject: [PATCH 23/98] fix(kimi-web): keep composer toolbar usable on narrow windows (#1436) The desktop composer toolbar renders every control on one row and relies on overflow:hidden to fit, so between the mobile breakpoint and a wide window it clipped its own content. Shed secondary ink below 980px (the context readout moves into the ring tooltip, the model name truncates earlier, permission is capped) and keep the context ring visible on phones too, instead of sending it to the settings sheet. --- .changeset/web-composer-toolbar-responsive.md | 5 ++ .../kimi-web/src/components/chat/Composer.vue | 69 ++++++++++++++++--- 2 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 .changeset/web-composer-toolbar-responsive.md diff --git a/.changeset/web-composer-toolbar-responsive.md b/.changeset/web-composer-toolbar-responsive.md new file mode 100644 index 0000000000..8b4009036d --- /dev/null +++ b/.changeset/web-composer-toolbar-responsive.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Keep the composer toolbar from clipping its controls on narrow windows and phones, with the context ring staying visible at every width. diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 2eecea4f45..4f591349a7 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -949,9 +949,20 @@ function selectModel(modelId: string): void { - + - + {{ kFmt(status.ctxUsed) }}/{{ kFmt(status.ctxMax) }} @@ -1454,13 +1465,19 @@ function selectModel(modelId: string): void { color: var(--color-danger); } -/* Context group — circular ring + num */ +/* Context group — circular ring + num. Focusable for keyboard / switch access + to its aria-label and tooltip (see template), so it needs a focus ring. */ .ctx-group { display: flex; align-items: center; gap: 4px; flex-shrink: 0; - padding: 2px 0; + padding: 2px 4px; + border-radius: var(--radius-xs); +} +.ctx-group:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; } .ctx-num { @@ -1886,6 +1903,37 @@ function selectModel(modelId: string): void { font-size: var(--ui-font-size-xs); } +/* ---- Narrow composer toolbar ---------------------------------------------- + Below a wide desktop the chat column can be narrower than the full toolbar + needs — with the sidebar open on a small window, and on phones. The desktop + toolbar shows every control on one row and toolbar-left / toolbar-right are + overflow:hidden, so without shedding ink the row clips its own content. The + context ring stays visible at every width (it is the live context-pressure + signal) but the "12k/256k" readout moves into the ring's tooltip, the model + name truncates earlier, and the permission label is capped so the ring and + the send button are never squeezed out. Mobile (≤640px) additionally hides + perm / modes via the rules below (those live in MobileSettingsSheet there). */ +@media (max-width: 980px) { + /* The ring already conveys context pressure; the "12k/256k" readout lives in + the tooltip and returns at wider widths. */ + .ctx-num { + display: none; + } + /* Model name was budgeted for a wide card (280px); trim it so the ring and + send button are not squeezed out on a narrow column. */ + .model-pill b { + max-width: 130px; + } + /* Permission label is short (manual/yolo/auto); cap it defensively so a + longer label can never push the toolbar past its container. */ + .perm-pill { + max-width: 104px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + /* ---- Mobile composer (prototype): round attach + rounded panel input + round blue send with a soft shadow. The .cin container loses its border and acts as a flex row; the textarea itself becomes the pill input. ---- */ @@ -1947,13 +1995,14 @@ function selectModel(modelId: string): void { line-height: 1; } - /* Mobile toolbar: hide secondary controls; only attach + model stay visible. - Permission / plan / context live in the MobileSettingsSheet. The /compact - chip stays: it is the ONLY context-pressure signal on a phone (it appears - at ≥80% usage) and tapping it triggers compaction directly. */ + /* Mobile toolbar: hide secondary controls; attach / context ring / model / + send stay visible. Permission + plan move into the MobileSettingsSheet. + The context ring stays at every width by design — it is the live + context-pressure signal on a phone (the "12k/256k" readout is hidden here + by the ≤980px rule above and remains in the ring's tooltip). The /compact + chip also stays so compaction is one tap away at ≥80% usage. */ .perm-pill, - .modes, - .ctx-group { + .modes { display: none; } From c5e3e80041a763143934c271d2524ac555d48d2a Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 22:05:05 +0800 Subject: [PATCH 24/98] feat(web): render AgentSwarm as an inline tool card (#1425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): render AgentSwarm as an inline tool card Replace the bottom SwarmCard footer and the messagesToTurns live-skip with one dedicated inline tool card for AgentSwarm. The card shows a phase overview plus a per-subagent accordion: live progress while it runs, parsed aggregated result once it completes (and after a refresh that has already dropped the live tasks). Refresh and resync keep member identity metadata (swarmIndex, parentToolCallId, subagentType, runInBackground) stable across skeleton task replacement in the reducer, and the .content-wrap flex layout is hardened against the overflow compression that previously displaced the footer. * fix(web): handle swarm review feedback - SwarmTool: when AgentSwarm fails before producing a structured agent_swarm_result (e.g. argument validation), render the raw tool output instead of the "waiting for subagents" placeholder so the failure cause is visible. - resolveSwarmMembers: source live members from the AppTask store keyed by parentToolCallId rather than buildSwarmGroups, which filters out single-member groups. A resume-only AgentSwarm now streams its live progress before the final result arrives. The badge counter still relies on buildSwarmGroups's filter. * fix(web): carry streamed subagent text into swarm rows Swarm subagents that stream normal assistant output accumulate it on AppTask.text (text-kind taskProgress), not outputLines. The new live member map was dropping `text`, so a still-composing subagent rendered an empty / stale row until the structured result arrived. - Add `text` to SwarmMember and thread it through buildSwarmGroups and swarmMembersByToolCall. - SwarmTool: prefer member.text for both the row activity preview and the expanded body; fall back to outputLines / summary. - Tests cover text propagation through both helpers. * fix(web): merge swarm result rows and fall back to raw output Address the two latest swarm review comments: - Rows: when a parsed agent_swarm_result coexists with live AppTasks (which the detail panel also depends on), the inline card previously only rendered the live members. Interrupted swarms can carry state="not_started" / outcome="aborted" result entries for items that never spawned a task; those rows were dropped until a refresh cleared the live tasks. Extract the row model into buildSwarmCardRows and merge result-only aborted/not-started rows with the live member rows. - Fallback: when the tool is no longer running but produced no structured result (argument validation, parser miss, or legacy legacy transcript), render the raw tool output instead of "Waiting for subagents…" so the final text / failure cause is visible to the user. * fix(web): parse swarm result subagent bodies defensively Producer writes subagent body unescaped, so a subagent that analyzes or emits an AgentSwarm snippet can include a literal "" inside its body. The non-greedy regex treated that as the row close and truncated the body in the result-only path (post-refresh where the AppTask store is gone). Rewrite the parser to scan opening tags, then resolve each row's body as everything up to the last "" before the next row's opening tag (or document end), preserving embedded close-tag strings. Add tests for a literal "" within a single body and across sibling rows. * fix(web): only count top-level subagent result tags A subagent body that contains a literal `` tag — for example emitting an AgentSwarm/XML snippet — was being pre-collected as another result row, splitting the real body and producing duplicate / bogus subagents after refresh where the AppTask store is gone. Rewrite parseSubagents with a depth-tracking tokenizer: scan every `` / `` token in order, push a real row frame only at the outermost level, and treat openings / closings while nested inside another body as body text. Drop the now-inaccurate "literal without matching open" regression tests; replace with tests that verify a balanced nested snippet stays inside the parent body and does not register as a separate row. --- .changeset/web-swarm-inline-tool-card.md | 5 + apps/kimi-web/src/App.vue | 11 +- apps/kimi-web/src/api/daemon/eventReducer.ts | 11 +- .../kimi-web/src/components/chat/ChatPane.vue | 20 +- .../src/components/chat/ConversationPane.vue | 34 +- .../src/components/chat/SwarmCard.vue | 208 -------- .../components/chat/tool-calls/SwarmTool.vue | 481 ++++++++++++++++++ .../chat/tool-calls/toolRegistry.ts | 2 + .../src/composables/client/useSideChat.ts | 1 - .../src/composables/messagesToTurns.ts | 41 -- apps/kimi-web/src/composables/swarmGroups.ts | 39 ++ .../src/composables/useKimiWebClient.ts | 11 +- apps/kimi-web/src/i18n/locales/en/tools.ts | 12 + apps/kimi-web/src/i18n/locales/zh/tools.ts | 12 + apps/kimi-web/src/lib/parseSwarmResult.ts | 128 +++++ apps/kimi-web/src/lib/swarmCardRows.ts | 100 ++++ apps/kimi-web/src/lib/toolMeta.ts | 2 + apps/kimi-web/test/event-reducer.test.ts | 32 ++ apps/kimi-web/test/swarm-card-rows.test.ts | 115 +++++ apps/kimi-web/test/swarm-groups.test.ts | 127 +++++ apps/kimi-web/test/swarm-result.test.ts | 72 +++ apps/kimi-web/test/turn-logic.test.ts | 60 +-- 22 files changed, 1163 insertions(+), 361 deletions(-) create mode 100644 .changeset/web-swarm-inline-tool-card.md delete mode 100644 apps/kimi-web/src/components/chat/SwarmCard.vue create mode 100644 apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue create mode 100644 apps/kimi-web/src/lib/parseSwarmResult.ts create mode 100644 apps/kimi-web/src/lib/swarmCardRows.ts create mode 100644 apps/kimi-web/test/swarm-card-rows.test.ts create mode 100644 apps/kimi-web/test/swarm-groups.test.ts create mode 100644 apps/kimi-web/test/swarm-result.test.ts diff --git a/.changeset/web-swarm-inline-tool-card.md b/.changeset/web-swarm-inline-tool-card.md new file mode 100644 index 0000000000..cb1b55185c --- /dev/null +++ b/.changeset/web-swarm-inline-tool-card.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +web: Replace the swarm footer with a single inline tool card that shows live subagent progress and the aggregated result, and keep swarm badges stable after refresh. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index f8e28149f3..2e6133f3fc 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -34,6 +34,7 @@ import { useFilePreview, type DetailTarget } from './composables/useFilePreview' import { useDetailPanel } from './composables/useDetailPanel'; import { useIsMobile } from './composables/useIsMobile'; import { openDialogCount } from './composables/dialogStack'; +import type { SwarmMember } from './composables/swarmGroups'; import ServerAuthDialog from './components/ServerAuthDialog.vue'; import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth'; import type { AppConfig, ThinkingLevel } from './api/types'; @@ -55,6 +56,15 @@ const showServerAuth = computed( () => !client.dangerousBypassAuth.value && authRequired.value, ); provide('resolveImage', client.resolveImageUrl); +// Live swarm member roster for the inline AgentSwarm tool card. Sourced from the +// AppTask store so the card shows each subagent's live phase; on refresh the +// tasks are gone and the card falls back to the parsed tool result. Includes +// single-member "swarms" (e.g. AgentSwarm with one resume_agent_ids entry), +// which buildSwarmGroups filters out for the badge counter. +provide( + 'resolveSwarmMembers', + (toolCallId: string): SwarmMember[] => client.swarmMembersByToolCallId.value.get(toolCallId) ?? [], +); const { t } = useI18n(); // KAP/daemon debug panel — opt-in via ?debug=1 or localStorage kimi-web.debug=1. @@ -681,7 +691,6 @@ function openPr(url: string): void { :tasks="client.tasks.value" :todos="client.todos.value" :goal="client.goal.value" - :swarms="client.swarms.value" :activation-badges="client.activationBadges.value" :status="client.status.value" :thinking="client.thinking.value" diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 5bec650985..69b4000382 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -539,12 +539,19 @@ export function reduceAppEvent( next.tasksBySession[sid] = [...list, event.task]; } else { const patched = [...list]; + const previous = list[idx]!; // The projected task does not carry reducer-owned accumulated progress; // preserve it across the replacement so subagent output keeps growing. + // A resync also rebuilds skeleton tasks without their identity metadata, + // so keep the previous value when the projected task omits it. patched[idx] = { ...event.task, - outputLines: list[idx]!.outputLines, - text: list[idx]!.text, + outputLines: previous.outputLines, + text: previous.text, + swarmIndex: event.task.swarmIndex ?? previous.swarmIndex, + parentToolCallId: event.task.parentToolCallId ?? previous.parentToolCallId, + subagentType: event.task.subagentType ?? previous.subagentType, + runInBackground: event.task.runInBackground ?? previous.runInBackground, }; next.tasksBySession[sid] = patched; } diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index 3b93355463..5ea1f1990d 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -541,7 +541,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): @@ -1193,7 +1167,6 @@ defineExpose({ loadComposerForEdit, focusComposer }); @open-btw="emit('command', '/btw')" @create-goal="emit('createGoal', $event)" @focus-goal="focusGoal" - @focus-swarm="focusSwarm" @compact="emit('compact')" @pick-model="emit('pickModel')" @select-model="emit('selectModel', $event)" @@ -1269,6 +1242,7 @@ defineExpose({ loadComposerForEdit, focusComposer }); min-height: 100%; display: flex; flex-direction: column; + flex-shrink: 0; } .content-wrap.align-center { margin-left: auto; margin-right: auto; } .content-wrap.align-left { margin-left: 0; margin-right: auto; } @@ -1288,12 +1262,6 @@ defineExpose({ loadComposerForEdit, focusComposer }); min-width: 0; } } -.swarm-stack { - padding: 0 18px 16px; -} -.content-wrap.align-mobile .swarm-stack { - padding: 0 14px 18px; -} /* Empty-workspace spacers: push the centred Composer to the vertical middle. */ .empty-spacer { flex: 1; } diff --git a/apps/kimi-web/src/components/chat/SwarmCard.vue b/apps/kimi-web/src/components/chat/SwarmCard.vue deleted file mode 100644 index bb9d70c0bd..0000000000 --- a/apps/kimi-web/src/components/chat/SwarmCard.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - - - diff --git a/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue b/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue new file mode 100644 index 0000000000..15e0ce2ad2 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue @@ -0,0 +1,481 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts b/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts index f21e6857db..96e8f4e784 100644 --- a/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts +++ b/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts @@ -7,6 +7,7 @@ import AskUserTool from './AskUserTool.vue'; import EditTool from './EditTool.vue'; import GenericTool from './GenericTool.vue'; import MediaTool from './MediaTool.vue'; +import SwarmTool from './SwarmTool.vue'; type ToolRenderer = Component; @@ -20,6 +21,7 @@ export function resolveToolRenderer(tool: ToolCall): ToolRenderer { // `task` — `agent` here would be dead code and route subagent calls to // GenericTool, dropping the inline "Open" button for the detail panel. if (name === 'task') return AgentTool; + if (name === 'agentswarm') return SwarmTool; if (name === 'askuserquestion') return AskUserTool; return GenericTool; } diff --git a/apps/kimi-web/src/composables/client/useSideChat.ts b/apps/kimi-web/src/composables/client/useSideChat.ts index 547ee52c20..1655666abf 100644 --- a/apps/kimi-web/src/composables/client/useSideChat.ts +++ b/apps/kimi-web/src/composables/client/useSideChat.ts @@ -66,7 +66,6 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { [], (fileId) => getKimiWebApi().getFileUrl(fileId), sideChatRunning.value, - [], ); }); diff --git a/apps/kimi-web/src/composables/messagesToTurns.ts b/apps/kimi-web/src/composables/messagesToTurns.ts index 6f3f11035f..2d51bc33fd 100644 --- a/apps/kimi-web/src/composables/messagesToTurns.ts +++ b/apps/kimi-web/src/composables/messagesToTurns.ts @@ -12,7 +12,6 @@ import type { AppMessage, AppApprovalRequest, AppTask, CompactionMarkerMetadata } from '../api/types'; import { COMPACTION_MARKER_METADATA_KEY } from '../api/types'; import type { AgentMember, ApprovalBlock, ChatTurn, DiffLine, ToolCall, ToolMedia, TurnBlock } from '../types'; -import { phaseForTask } from './swarmGroups'; const READ_MEDIA_TOOL_RE = /^read[_-]?media(?:file)?$/i; const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s; @@ -189,13 +188,6 @@ export function toAgentMember(task: AppTask): AgentMember { }; } -function sortAgentTasks(a: AppTask, b: AppTask): number { - const ai = a.swarmIndex ?? Number.MAX_SAFE_INTEGER; - const bi = b.swarmIndex ?? Number.MAX_SAFE_INTEGER; - if (ai !== bi) return ai - bi; - return a.createdAt.localeCompare(b.createdAt); -} - // --------------------------------------------------------------------------- // Inline buildApprovalBlock (mirrors the one in useKimiWebClient.ts; kept // here to avoid a circular import when tests import this module directly). @@ -412,7 +404,6 @@ export function messagesToTurns( * spinning forever after the turn already finished. */ sessionActive = true, - subagentTasks: AppTask[] = [], /** Preserved `plan_review` displays keyed by toolCallId — used to link the * ExitPlanMode tool card back to the plan file after the approval resolves. */ planReviewByToolCallId: Record = {}, @@ -426,20 +417,6 @@ export function messagesToTurns( approvalByTool.set(a.toolCallId, a); } - const subagentsByTool = new Map(); - for (const task of subagentTasks) { - if (task.kind !== 'subagent') continue; - const keys = [task.parentToolCallId, task.id].filter((key): key is string => typeof key === 'string' && key.length > 0); - for (const key of keys) { - const list = subagentsByTool.get(key) ?? []; - list.push(task); - subagentsByTool.set(key, list); - } - } - for (const [key, list] of subagentsByTool.entries()) { - subagentsByTool.set(key, list.toSorted(sortAgentTasks)); - } - let pendingGroup: Group | null = null; function flushGroup(final = false): void { @@ -497,24 +474,6 @@ export function messagesToTurns( else g.blocks.push({ kind: 'thinking', thinking: c.thinking }); } } else if (c.type === 'toolUse') { - // A multi-member LIVE swarm renders as its OWN SwarmCard footer while - // any member is still active (see buildSwarmGroups / activeSwarms). - // Don't ALSO render it inline, or the swarm shows up twice. Once every - // member has finished, the footer is removed and we fall through to - // render the AgentSwarm call as a normal tool card — the same thing a - // refresh shows, when the live subagent tasks are gone. - const agentTasks = subagentsByTool.get(c.toolCallId); - if (agentTasks && agentTasks.length > 0) { - const swarmMembers = agentTasks.filter((t) => t.swarmIndex !== undefined); - if (swarmMembers.length > 1) { - const live = swarmMembers.some((t) => { - const phase = phaseForTask(t); - return phase !== 'completed' && phase !== 'failed'; - }); - if (live) continue; - } - } - // Single `Agent` subagent spawns and all other tools render as a normal // tool card: the card shows the fixed args (prompt / description) plus // the final result when expanded, while a subagent's live progress diff --git a/apps/kimi-web/src/composables/swarmGroups.ts b/apps/kimi-web/src/composables/swarmGroups.ts index 8eced26873..ce7d55955a 100644 --- a/apps/kimi-web/src/composables/swarmGroups.ts +++ b/apps/kimi-web/src/composables/swarmGroups.ts @@ -7,6 +7,9 @@ export interface SwarmMember { phase: AppSubagentPhase; summary?: string; outputLines?: string[]; + /** Accumulated streaming text (text-kind taskProgress) — preferred over + * outputLines/summary when rendering the live swarm row activity/body. */ + text?: string; suspendedReason?: string; swarmIndex: number; } @@ -53,6 +56,7 @@ export function buildSwarmGroups(tasks: AppTask[]): SwarmGroup[] { phase: phaseForTask(task), summary: task.outputPreview, outputLines: task.outputLines, + text: task.text, suspendedReason: task.suspendedReason, swarmIndex: task.swarmIndex, }); @@ -86,3 +90,38 @@ export function countSwarmMembers(groups: SwarmGroup[]): { done: number; total: } return { done, total }; } + +/** + * Bucket foreground/background subagent tasks by their spawning tool call and + * return one member list per AgentSwarm call. Unlike buildSwarmGroups this keeps + * single-member "swarms" (e.g. AgentSwarm used with one resume_agent_ids entry), + * so the inline card can show a resumed subagent's live progress before the + * structured result arrives. Also includes subagents without a swarmIndex so a + * late-synced task still appears (sorted last). + */ +export function swarmMembersByToolCall(tasks: AppTask[]): Map { + const buckets = new Map(); + for (const task of tasks) { + if (task.kind !== 'subagent' || !task.parentToolCallId) continue; + const list = buckets.get(task.parentToolCallId) ?? []; + list.push({ + id: task.id, + name: task.description, + subagentType: task.subagentType, + phase: phaseForTask(task), + summary: task.outputPreview, + outputLines: task.outputLines, + text: task.text, + suspendedReason: task.suspendedReason, + swarmIndex: task.swarmIndex ?? Number.MAX_SAFE_INTEGER, + }); + buckets.set(task.parentToolCallId, list); + } + for (const [key, members] of buckets) { + buckets.set( + key, + members.toSorted((a, b) => a.swarmIndex - b.swarmIndex || a.id.localeCompare(b.id)), + ); + } + return buckets; +} diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 2b1e7b6d08..f65fd06478 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -65,8 +65,8 @@ import { toAppEvent } from '../api/daemon/mappers'; import { messagesToTurns } from './messagesToTurns'; import { latestTodos } from './latestTodos'; -import { buildSwarmGroups, countSwarmMembers } from './swarmGroups'; -import type { SwarmGroup } from './swarmGroups'; +import { buildSwarmGroups, countSwarmMembers, swarmMembersByToolCall } from './swarmGroups'; +import type { SwarmGroup, SwarmMember } from './swarmGroups'; import type { ActivityState, ActivationBadges, @@ -1656,7 +1656,6 @@ const turns = computed(() => { approvals, (fileId) => getKimiWebApi().getFileUrl(fileId), activity.value !== 'idle', - activeAppTasks.value, rawState.planReviewByToolCallId, ); }); @@ -1668,6 +1667,11 @@ const tasks = computed(() => { }); const swarms = computed(() => buildSwarmGroups(activeAppTasks.value)); +// Foreground/background subagents keyed by their spawning tool call id — used by +// the inline AgentSwarm tool card to stream each subagent's live progress. +const swarmMembersByToolCallId = computed>(() => + swarmMembersByToolCall(activeAppTasks.value), +); const goal = computed(() => { const sid = rawState.activeSessionId; @@ -2386,6 +2390,7 @@ export function useKimiWebClient() { todos, goal, swarms, + swarmMembersByToolCallId, activationBadges, compaction, status, diff --git a/apps/kimi-web/src/i18n/locales/en/tools.ts b/apps/kimi-web/src/i18n/locales/en/tools.ts index fe81c4acd0..aaddeb3348 100644 --- a/apps/kimi-web/src/i18n/locales/en/tools.ts +++ b/apps/kimi-web/src/i18n/locales/en/tools.ts @@ -11,8 +11,20 @@ export default { search: 'Search', todo: 'Todo', task: 'Task', + swarm: 'Swarm', ask_user: 'Question', }, + swarm: { + progress: '{done} / {total}', + runningSub: '{count} in progress', + doneSub: '{completed} completed · {failed} failed', + phaseQueued: 'Queued', + phaseWorking: 'Working', + phaseSuspended: 'Suspended', + phaseCompleted: 'Completed', + phaseFailed: 'Failed', + waiting: 'Waiting for subagents…', + }, chip: { lines: '{count} lines', results: '{count} results', diff --git a/apps/kimi-web/src/i18n/locales/zh/tools.ts b/apps/kimi-web/src/i18n/locales/zh/tools.ts index 1fe96fda98..0cee010588 100644 --- a/apps/kimi-web/src/i18n/locales/zh/tools.ts +++ b/apps/kimi-web/src/i18n/locales/zh/tools.ts @@ -11,8 +11,20 @@ export default { search: '搜索', todo: '待办', task: '任务', + swarm: 'Swarm', ask_user: '提问', }, + swarm: { + progress: '{done} / {total}', + runningSub: '{count} 个进行中', + doneSub: '完成 {completed} · 失败 {failed}', + phaseQueued: '排队', + phaseWorking: '运行中', + phaseSuspended: '暂停', + phaseCompleted: '完成', + phaseFailed: '失败', + waiting: '等待子任务加入…', + }, chip: { lines: '{count} 行', results: '{count} 结果', diff --git a/apps/kimi-web/src/lib/parseSwarmResult.ts b/apps/kimi-web/src/lib/parseSwarmResult.ts new file mode 100644 index 0000000000..33b3ba434e --- /dev/null +++ b/apps/kimi-web/src/lib/parseSwarmResult.ts @@ -0,0 +1,128 @@ +// apps/kimi-web/src/lib/parseSwarmResult.ts +// Parse the `` payload returned by the AgentSwarm tool +// (see packages/agent-core/.../agent-swarm.ts renderSwarmResults). The result +// arrives as a plain string inside the toolResult output; the swarm card turns +// it into a structured aggregate view. Defensive: never throws. + +export interface SwarmResultSubagent { + outcome: string; + item?: string; + agentId?: string; + mode?: string; + state?: string; + body: string; +} + +export interface SwarmResult { + /** Raw summary line, e.g. `completed: 8, failed: 2`. */ + summary: string; + completed: number; + failed: number; + aborted: number; + total: number; + subagents: SwarmResultSubagent[]; + resumeHint?: string; +} + +const SUMMARY_RE = /([\s\S]*?)<\/summary>/; +const RESUME_HINT_RE = /([\s\S]*?)<\/resume_hint>/; +// Marks either a subagent opening tag (captures attributes) or a `` +// closing tag. Body parsing tracks a depth so literal `` / +// `` text inside a row's body (e.g. a subagent emitting an +// AgentSwarm snippet) does not register as a top-level row — producer writes +// body unescaped. +const TOKEN_RE = /]*)>|<\/subagent>/g; +const SUBAGENT_CLOSE = ''; +const COUNT_RE = /(completed|failed|aborted):\s*(\d+)/g; +const ATTR_RE = /([a-z_]+)="([^"]*)"/g; + +function unescapeAttr(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} + +function parseAttrs(raw: string): Record { + const attrs: Record = {}; + ATTR_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ATTR_RE.exec(raw)) !== null) { + attrs[m[1]!] = unescapeAttr(m[2]!); + } + return attrs; +} + +function parseCounts(summary: string): Pick { + const counts = { completed: 0, failed: 0, aborted: 0 }; + COUNT_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = COUNT_RE.exec(summary)) !== null) { + const key = m[1] as 'completed' | 'failed' | 'aborted'; + counts[key] = Number(m[2]); + } + return counts; +} + +type RowFrame = { attrs: string; bodyStart: number }; + +function parseSubagent(attrs: string, body: string): SwarmResultSubagent { + const parsed = parseAttrs(attrs); + return { + outcome: parsed['outcome'] ?? 'completed', + item: parsed['item'], + agentId: parsed['agent_id'], + mode: parsed['mode'], + state: parsed['state'], + body: body.trim(), + }; +} + +function parseSubagents(text: string): SwarmResultSubagent[] { + const subs: SwarmResultSubagent[] = []; + // Each stack frame is either a real top-level row (carries attrs + the body + // start offset) or `null` for a nested literal `` matched inside + // another row's body so nested tags don't register as their own result row. + const stack: (RowFrame | null)[] = []; + TOKEN_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = TOKEN_RE.exec(text)) !== null) { + if (m[0] === SUBAGENT_CLOSE) { + if (stack.length === 0) continue; + const frame = stack.pop()!; + // Pop balances this close with its matching opening. A frame is real only + // when it sits on a then-empty stack, i.e. a top-level row. + if (frame && stack.length === 0) { + subs.push(parseSubagent(frame.attrs, text.slice(frame.bodyStart, m.index))); + } + } else if (stack.length === 0) { + stack.push({ attrs: m[1] ?? '', bodyStart: TOKEN_RE.lastIndex }); + } else { + stack.push(null); + } + } + return subs; +} + +export function parseSwarmResult(output: string[] | string | undefined | null): SwarmResult | null { + if (output === undefined || output === null) return null; + const text = Array.isArray(output) ? output.join('\n') : output; + if (!text.includes('')) return null; + + const summary = SUMMARY_RE.exec(text)?.[1]?.trim() ?? ''; + const { completed, failed, aborted } = parseCounts(summary); + const resumeHint = RESUME_HINT_RE.exec(text)?.[1]?.trim(); + const subagents = parseSubagents(text); + + const totalFromSummary = completed + failed + aborted; + return { + summary, + completed, + failed, + aborted, + total: totalFromSummary > 0 ? totalFromSummary : subagents.length, + subagents, + resumeHint, + }; +} diff --git a/apps/kimi-web/src/lib/swarmCardRows.ts b/apps/kimi-web/src/lib/swarmCardRows.ts new file mode 100644 index 0000000000..f4d9458704 --- /dev/null +++ b/apps/kimi-web/src/lib/swarmCardRows.ts @@ -0,0 +1,100 @@ +// apps/kimi-web/src/lib/swarmCardRows.ts +// Build the accordion row model for the AgentSwarm inline tool card. Pure +// function of live members (AppTask store, real-time phase) and the parsed +// `` payload (terminal result) — kept in plain TS so it can +// be unit-tested without mounting the component. + +import type { AppSubagentPhase } from '../api/types'; +import type { SwarmMember } from '../composables/swarmGroups'; +import type { SwarmResult, SwarmResultSubagent } from './parseSwarmResult'; + +export interface SwarmCardRow { + id: string; + name: string; + activity: string; + phase: AppSubagentPhase; + body: string; +} + +function lastNonEmptyLine(text: string | undefined): string { + if (!text) return ''; + return text.split('\n').map((l) => l.trimEnd()).filter(Boolean).at(-1) ?? ''; +} + +export function swarmMemberActivity(member: SwarmMember): string { + // Prefer streamed subagent text so a still-composing agent shows its latest + // line instead of an empty / last-summary row. + return ( + member.suspendedReason || + lastNonEmptyLine(member.text) || + lastNonEmptyLine(member.outputLines?.join('\n')) || + member.summary || + '' + ); +} + +function swarmMemberBody(member: SwarmMember): string { + if (member.suspendedReason) return member.suspendedReason; + if (member.text) return member.text; + if (member.outputLines && member.outputLines.length > 0) return member.outputLines.join('\n'); + return member.summary ?? ''; +} + +function outcomeToPhase(outcome: string): AppSubagentPhase { + if (outcome === 'completed') return 'completed'; + if (outcome === 'failed' || outcome === 'aborted') return 'failed'; + return 'working'; +} + +function resultRow(sub: SwarmResultSubagent, index: number): SwarmCardRow { + return { + id: sub.agentId ?? sub.item ?? `result-${index}`, + name: sub.item ?? `subagent ${index + 1}`, + activity: sub.body.split('\n')[0] ?? '', + phase: outcomeToPhase(sub.outcome), + body: sub.body, + }; +} + +/** + * Whether a live member already accounts for a result subagent. Members may + * come from the projector (task id / description) while the result references + * agent_id / item; the two ids don't always match, so also treat item ⊆ + * description as a match. + */ +function memberCoversResult(member: SwarmMember, sub: SwarmResultSubagent): boolean { + if (sub.agentId && member.id === sub.agentId) return true; + if (sub.item && member.name.includes(sub.item)) return true; + return false; +} + +/** + * Merge the live members with the agent_swarm_result payload into one row list. + * + * - Members are authoritative while present (real-time phase + streamed text). + * - When a parsed result is also present, append result rows that no member + * covers — e.g. interrupted swarms emit `state="not_started"` / + * `outcome="aborted"` entries for items that never spawned a task, which + * would otherwise be invisible until a refresh dropped the live tasks. + * - When no members are present (post-refresh), fall back to result-only rows. + */ +export function buildSwarmCardRows(members: SwarmMember[], result: SwarmResult | null): SwarmCardRow[] { + const memberRows = members.map((m) => ({ + id: m.id, + name: m.name, + activity: swarmMemberActivity(m), + phase: m.phase, + body: swarmMemberBody(m), + })); + if (!result) return memberRows; + + const resultOnly = result.subagents + .filter( + (sub) => + (sub.outcome === 'aborted' || sub.state === 'not_started') && + !members.some((m) => memberCoversResult(m, sub)), + ) + .map((sub, i) => resultRow(sub, i)); + + return memberRows.length > 0 ? [...memberRows, ...resultOnly] : result.subagents.map((s, i) => resultRow(s, i)); +} diff --git a/apps/kimi-web/src/lib/toolMeta.ts b/apps/kimi-web/src/lib/toolMeta.ts index 38bc2e453a..b504b41086 100644 --- a/apps/kimi-web/src/lib/toolMeta.ts +++ b/apps/kimi-web/src/lib/toolMeta.ts @@ -23,6 +23,7 @@ const TOOL_LABEL_KEYS: Record = { search: 'tools.label.search', todo: 'tools.label.todo', task: 'tools.label.task', + agentswarm: 'tools.label.swarm', askuserquestion: 'tools.label.ask_user', }; @@ -90,6 +91,7 @@ const TOOL_GLYPH: Record = { web_fetch: 'globe', todo: 'check-list', task: 'sparkles', + agentswarm: 'git-pull-request', askuserquestion: 'help-circle', }; diff --git a/apps/kimi-web/test/event-reducer.test.ts b/apps/kimi-web/test/event-reducer.test.ts index 338acf0c8a..1efb430f66 100644 --- a/apps/kimi-web/test/event-reducer.test.ts +++ b/apps/kimi-web/test/event-reducer.test.ts @@ -233,6 +233,38 @@ describe('reduceAppEvent taskProgress', () => { ); expect(next.tasksBySession['s1']?.[0]?.text).toBe('partial'); }); + + it('preserves subagent identity metadata across a taskCreated replacement with omitted fields', () => { + const state = { + ...createInitialState(), + tasksBySession: { + 's1': [ + { + ...makeSubagentTask('t1', 's1'), + parentToolCallId: 'call-1', + swarmIndex: 2, + subagentType: 'explore', + runInBackground: true, + outputLines: ['old line'], + text: 'partial', + }, + ], + }, + }; + const next = reduceAppEvent( + state, + { type: 'taskCreated', sessionId: 's1', task: makeSubagentTask('t1', 's1') }, + { sessionId: 's1', seq: 1 }, + ); + expect(next.tasksBySession['s1']?.[0]).toMatchObject({ + parentToolCallId: 'call-1', + swarmIndex: 2, + subagentType: 'explore', + runInBackground: true, + outputLines: ['old line'], + text: 'partial', + }); + }); }); describe('reduceAppEvent sessions reference stability', () => { diff --git a/apps/kimi-web/test/swarm-card-rows.test.ts b/apps/kimi-web/test/swarm-card-rows.test.ts new file mode 100644 index 0000000000..957e8e56e0 --- /dev/null +++ b/apps/kimi-web/test/swarm-card-rows.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { AppSubagentPhase } from '../src/api/types'; +import type { SwarmMember } from '../src/composables/swarmGroups'; +import type { SwarmResult } from '../src/lib/parseSwarmResult'; +import { buildSwarmCardRows, swarmMemberActivity } from '../src/lib/swarmCardRows'; + +function member( + id: string, + name: string, + opts: { + phase?: AppSubagentPhase; + text?: string; + outputLines?: string[]; + summary?: string; + suspendedReason?: string; + } = {}, +): SwarmMember { + return { + id, + name, + phase: opts.phase ?? 'working', + text: opts.text, + outputLines: opts.outputLines, + summary: opts.summary, + suspendedReason: opts.suspendedReason, + swarmIndex: 0, + }; +} + +function result(subagents: SwarmResult['subagents']): SwarmResult { + return { + summary: `${subagents.length}`, + completed: subagents.filter((s) => s.outcome === 'completed').length, + failed: subagents.filter((s) => s.outcome === 'failed').length, + aborted: subagents.filter((s) => s.outcome === 'aborted').length, + total: subagents.length, + subagents, + }; +} + +describe('swarmMemberActivity', () => { + it('prefers streamed subagent text over outputLines and summary', () => { + const m = member('a', '子任务', { + text: 'line 1\nline 2', + outputLines: ['tool call output'], + summary: 'final summary', + }); + expect(swarmMemberActivity(m)).toBe('line 2'); + }); + + it('falls back to the last outputLines entry when no text is streaming', () => { + const m = member('a', '子任务', { outputLines: ['one', 'two'], summary: 'summary' }); + expect(swarmMemberActivity(m)).toBe('two'); + }); + + it('falls back to summary', () => { + expect(swarmMemberActivity(member('a', '子任务', { summary: 'sum' }))).toBe('sum'); + }); +}); + +describe('buildSwarmCardRows', () => { + it('builds rows from live members when no parsed result exists', () => { + const rows = buildSwarmCardRows( + [member('a', '子任务 A', { text: 'streaming' })], + null, + ); + expect(rows).toEqual([{ id: 'a', name: '子任务 A', activity: 'streaming', phase: 'working', body: 'streaming' }]); + }); + + it('builds rows from result subagents when no members are present', () => { + const rows = buildSwarmCardRows( + [], + result([ + { outcome: 'completed', item: 'A', body: 'A body' }, + { outcome: 'failed', item: 'B', body: 'B body' }, + ]), + ); + expect(rows.map((r) => r.name)).toEqual(['A', 'B']); + expect(rows.map((r) => r.phase)).toEqual(['completed', 'failed']); + }); + + it('appends result-only aborted not_started rows on top of live members', () => { + const rows = buildSwarmCardRows( + [ + member('a1', '子任务 A', { phase: 'completed' }), + member('a2', '子任务 B', { phase: 'working' }), + ], + result([ + { outcome: 'completed', item: 'A', agentId: 'a1', body: 'A body' }, + { outcome: 'completed', item: 'B', agentId: 'a2', body: 'B body' }, + { outcome: 'aborted', item: 'C', state: 'not_started', body: 'C never started' }, + ]), + ); + expect(rows.map((r) => r.id)).toEqual(['a1', 'a2', 'C']); + expect(rows[2]?.phase).toBe('failed'); + expect(rows[2]?.body).toBe('C never started'); + }); + + it('does not duplicate a result row that a live member already covers', () => { + const rows = buildSwarmCardRows( + [member('a1', '子任务 A', { phase: 'failed' })], + result([{ outcome: 'aborted', item: 'A', agentId: 'a1', body: 'A body' }]), + ); + expect(rows.map((r) => r.id)).toEqual(['a1']); + expect(rows[0]?.phase).toBe('failed'); + }); + + it('matches by item substring when agent ids disagree', () => { + const rows = buildSwarmCardRows( + [member('a1', 'find unused exports in src', { phase: 'completed' })], + result([{ outcome: 'aborted', item: 'unused exports', state: 'not_started', body: 'x' }]), + ); + expect(rows.map((r) => r.id)).toEqual(['a1']); + }); +}); diff --git a/apps/kimi-web/test/swarm-groups.test.ts b/apps/kimi-web/test/swarm-groups.test.ts new file mode 100644 index 0000000000..a806de3230 --- /dev/null +++ b/apps/kimi-web/test/swarm-groups.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import type { AppTask } from '../src/api/types'; +import { + buildSwarmGroups, + countSwarmMembers, + swarmMembersByToolCall, +} from '../src/composables/swarmGroups'; + +function subagentTask( + id: string, + parentToolCallId: string | undefined, + opts: { + swarmIndex?: number; + status?: AppTask['status']; + subagentPhase?: AppTask['subagentPhase']; + text?: string; + outputLines?: string[]; + } = {}, +): AppTask { + return { + id, + sessionId: 'session-1', + kind: 'subagent', + description: `subagent ${id}`, + status: opts.status ?? 'running', + createdAt: '2026-01-01T00:00:00.000Z', + parentToolCallId, + swarmIndex: opts.swarmIndex, + text: opts.text, + outputLines: opts.outputLines, + subagentPhase: opts.subagentPhase ?? 'working', + }; +} + +function bashTask(id: string): AppTask { + return { + id, + sessionId: 'session-1', + kind: 'bash', + description: `bash ${id}`, + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + +describe('buildSwarmGroups', () => { + it('emits a group only when two or more members share a swarmIndex', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1', { swarmIndex: 1 }), + subagentTask('b', 'swarm-1', { swarmIndex: 2 }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0]?.id).toBe('swarm-1'); + expect(groups[0]?.members.map((m) => m.id)).toEqual(['a', 'b']); + }); + + it('filters single-member groups (used for the badge counter)', () => { + const groups = buildSwarmGroups([subagentTask('a', 'swarm-1', { swarmIndex: 1 })]); + expect(groups).toHaveLength(0); + }); + + it('ignores subagents without a swarmIndex', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1'), + subagentTask('b', 'swarm-1'), + ]); + expect(groups).toHaveLength(0); + }); +}); + +describe('countSwarmMembers', () => { + it('counts completed + failed as done across groups', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1', { swarmIndex: 1, subagentPhase: 'completed', status: 'completed' }), + subagentTask('b', 'swarm-1', { swarmIndex: 2, subagentPhase: 'failed', status: 'failed' }), + subagentTask('c', 'swarm-2', { swarmIndex: 1, subagentPhase: 'working' }), + subagentTask('d', 'swarm-2', { swarmIndex: 2, subagentPhase: 'queued' }), + ]); + expect(countSwarmMembers(groups)).toEqual({ done: 2, total: 4 }); + }); +}); + +describe('swarmMembersByToolCall', () => { + it('keeps single-member swarms so a resume-only AgentSwarm gets live progress', () => { + const map = swarmMembersByToolCall([subagentTask('a', 'swarm-1', { swarmIndex: 1 })]); + expect(map.get('swarm-1')?.map((m) => m.id)).toEqual(['a']); + }); + + it('groups every subagent with the same parentToolCallId, ignoring swarmIndex', () => { + const map = swarmMembersByToolCall([ + subagentTask('b', 'swarm-1'), + subagentTask('a', 'swarm-1'), + subagentTask('c', 'swarm-2'), + ]); + expect(map.get('swarm-1')?.map((m) => m.id)).toEqual(['a', 'b']); + expect(map.get('swarm-2')?.map((m) => m.id)).toEqual(['c']); + }); + + it('ignores non-subagent tasks and subagents without a parentToolCallId', () => { + const map = swarmMembersByToolCall([ + bashTask('b-1'), + subagentTask('orphan', undefined), + subagentTask('a', 'swarm-1'), + ]); + expect([...map.keys()]).toEqual(['swarm-1']); + }); + + it('carries task.text so live rows can show still-composing subagent output', () => { + const map = swarmMembersByToolCall([ + subagentTask('a', 'swarm-1', { text: 'Hello, world!' }), + subagentTask('b', 'swarm-1', { outputLines: ['tool line'] }), + ]); + const rows = map.get('swarm-1') ?? []; + expect(rows[0]).toMatchObject({ id: 'a', text: 'Hello, world!' }); + expect(rows[1]).toMatchObject({ id: 'b', outputLines: ['tool line'] }); + }); +}); + +describe('buildSwarmGroups preserves streamed text', () => { + it('carries task.text into each group member', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1', { swarmIndex: 1, text: 'first line' }), + subagentTask('b', 'swarm-1', { swarmIndex: 2, text: 'second line' }), + ]); + expect(groups[0]?.members.map((m) => m.text)).toEqual(['first line', 'second line']); + }); +}); diff --git a/apps/kimi-web/test/swarm-result.test.ts b/apps/kimi-web/test/swarm-result.test.ts new file mode 100644 index 0000000000..deb6e0f64b --- /dev/null +++ b/apps/kimi-web/test/swarm-result.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { parseSwarmResult } from '../src/lib/parseSwarmResult'; + +describe('parseSwarmResult', () => { + it('returns null when the payload is not an agent_swarm_result', () => { + expect(parseSwarmResult('all done')).toBeNull(); + expect(parseSwarmResult(undefined)).toBeNull(); + expect(parseSwarmResult([])).toBeNull(); + }); + + it('parses the summary counts and each subagent outcome', () => { + const output = [ + '', + 'completed: 2, failed: 1', + 'first body', + 'second body', + 'boom', + '', + ]; + const result = parseSwarmResult(output); + expect(result).not.toBeNull(); + expect(result?.summary).toBe('completed: 2, failed: 1'); + expect(result?.completed).toBe(2); + expect(result?.failed).toBe(1); + expect(result?.aborted).toBe(0); + expect(result?.total).toBe(3); + expect(result?.subagents).toEqual([ + { outcome: 'completed', item: 'alpha', agentId: 'a1', body: 'first body' }, + { outcome: 'completed', item: 'beta', agentId: 'a2', body: 'second body' }, + { outcome: 'failed', item: 'gamma', body: 'boom' }, + ]); + }); + + it('unescapes the item attribute and captures the resume hint', () => { + const text = [ + '', + 'completed: 0, failed: 1, aborted: 0', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'err', + '', + ].join('\n'); + const result = parseSwarmResult(text); + expect(result?.resumeHint).toContain('resume_agent_ids'); + expect(result?.subagents[0]?.item).toBe('a & b'); + expect(result?.subagents[0]?.mode).toBe('resume'); + expect(result?.subagents[0]?.state).toBe('started'); + }); + + it('does not count a literal "" tag inside a body as a top-level row', () => { + const snippet = 'inner body'; + const body = 'example result below: ' + snippet; + const text = `completed: 1${body}`; + const result = parseSwarmResult(text); + expect(result?.subagents).toHaveLength(1); + expect(result?.subagents[0]?.item).toBe('outer'); + expect(result?.subagents[0]?.body).toContain(snippet); + }); + + it('keeps sibling top-level rows when one body contains a nested subagent snippet', () => { + const text = [ + 'completed: 2', + 'A snippet: inner done', + 'just B', + '', + ].join(''); + const result = parseSwarmResult(text); + expect(result?.subagents.map((s) => s.item)).toEqual(['a', 'b']); + expect(result?.subagents[0]?.body).toContain(' { it('merges an assistant turn and folds tool results into it', () => { const turns = messagesToTurns( @@ -58,7 +34,6 @@ describe('messagesToTurns', () => { [], undefined, false, - [], ); expect(turns).toHaveLength(2); @@ -81,7 +56,6 @@ describe('messagesToTurns', () => { [], undefined, false, - [], ); expect(turns.map((turn) => turn.text)).toEqual(['one', 'two']); @@ -97,13 +71,12 @@ describe('messagesToTurns', () => { [], undefined, false, - [], ); expect(turns).toMatchObject([{ role: 'compaction', text: 'summary' }]); }); - it('suppresses an inline tool card for a live multi-member swarm', () => { + it('renders a live multi-member swarm inline as a tool card', () => { const turns = messagesToTurns( [ message('u1', 'user', [{ type: 'text', text: 'run a swarm' }]), @@ -114,16 +87,11 @@ describe('messagesToTurns', () => { [], undefined, true, - [ - subagentTask('a-1', 'swarm-1', 1, 'working'), - subagentTask('a-2', 'swarm-1', 2, 'queued'), - subagentTask('a-3', 'swarm-1', 3, 'completed'), - ], ); const assistant = turns.at(-1); - expect(assistant?.tools ?? []).not.toContainEqual( - expect.objectContaining({ id: 'swarm-1' }), + expect(assistant?.tools).toContainEqual( + expect.objectContaining({ id: 'swarm-1', name: 'AgentSwarm', status: 'running' }), ); expect(assistant?.blocks ?? []).not.toContainEqual( expect.objectContaining({ kind: 'agentGroup' }), @@ -142,11 +110,6 @@ describe('messagesToTurns', () => { [], undefined, false, - [ - subagentTask('b-1', 'swarm-2', 1, 'completed'), - subagentTask('b-2', 'swarm-2', 2, 'completed'), - subagentTask('b-3', 'swarm-2', 3, 'failed'), - ], ); const assistant = turns.at(-1); @@ -159,17 +122,6 @@ describe('messagesToTurns', () => { }); it('renders a single subagent spawn as a tool card, not an agent block', () => { - const singleSubagent: AppTask = { - id: 'sub-1', - sessionId: 'session-1', - kind: 'subagent', - description: 'explore the repo', - status: 'completed', - createdAt: '2026-01-01T00:00:00.000Z', - parentToolCallId: 'agent-call-1', - subagentPhase: 'completed', - runInBackground: false, - }; const turns = messagesToTurns( [ message('u1', 'user', [{ type: 'text', text: 'go explore' }]), @@ -186,7 +138,6 @@ describe('messagesToTurns', () => { [], undefined, false, - [singleSubagent], ); const assistant = turns.at(-1); @@ -217,7 +168,6 @@ describe('messagesToTurns', () => { [], (id) => `/api/v1/files/${id}`, false, - [], ); expect(turns).toHaveLength(1); @@ -235,7 +185,6 @@ describe('messagesToTurns', () => { [], undefined, false, - [], ); expect(turns[0]).toMatchObject({ role: 'user', text: tag }); @@ -252,7 +201,6 @@ describe('messagesToTurns', () => { [], (id) => `/api/v1/files/${id}`, false, - [], ); expect(turns[0]).toMatchObject({ role: 'user', text: tag }); From 0824e7b6683435bec777fd6ce3f7b481ba7007ad Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 22:12:14 +0800 Subject: [PATCH 25/98] chore: ignore and remove throwaway scratch files (#1439) Agent working notes (HANDOVER/handoff) and one-off UI prototype HTML files were committed by mistake. Remove the already-tracked ones, add .gitignore patterns for these classes of files and a .tmp/ scratch dir, and document the rule plus a pre-commit self-check in AGENTS.md so future mistakes are caught mechanically. No publishable package is affected, so this PR needs no changeset. --- .gitignore | 12 + AGENTS.md | 4 + HANDOVER-kimi-web-table-width.md | 149 ---- .../design/queue-composer-mockup.html | 547 --------------- .../design/sidebar-show-more-demo.html | 649 ------------------ apps/kimi-web/design/undo-exit-demos.html | 398 ----------- composer-toolbar-designs.html | 298 -------- 7 files changed, 16 insertions(+), 2041 deletions(-) delete mode 100644 HANDOVER-kimi-web-table-width.md delete mode 100644 apps/kimi-web/design/queue-composer-mockup.html delete mode 100644 apps/kimi-web/design/sidebar-show-more-demo.html delete mode 100644 apps/kimi-web/design/undo-exit-demos.html delete mode 100644 composer-toolbar-designs.html diff --git a/.gitignore b/.gitignore index 2f3246b134..203492e4e1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,15 @@ docs/superpowers/ reports/ .superpowers/ /plan/ + +# Agent scratch / throwaway files - do not commit +.tmp/ +HANDOVER*.md +HANDOFF*.md +handoff.md +handover.md +*-designs.html +*-design.html +*-mockup.html +*-demo.html +*-demos.html diff --git a/AGENTS.md b/AGENTS.md index a9f77b457d..f8cdf7c107 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,3 +77,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - After finishing a task and before submitting a PR, you must run the `gen-changesets` skill (see `.agents/skills/gen-changesets/SKILL.md`) and generate a changeset under `.changeset/` according to its rules. - When generating a changeset, **never** decide on a `major` bump on your own. When you judge a change to meet the major criteria (breaking changes, incompatible user configuration, renamed or removed commands/arguments, changed behavior semantics, etc.), you must stop and explain it to the user and ask for confirmation. **Only write `major` after the user has explicitly agreed.** Otherwise default to `minor` (and fall back to `patch` if `minor` is unclear). See the "Hard rule: confirm with the user before writing `major`" section in `.agents/skills/gen-changesets/SKILL.md` for details. - Prefer importing via `import ... from '#/...'`, which serves the same purpose as `import ... from '@/...'`. +- Do not commit throwaway scratch or exploratory files. Never stage: + - Agent working notes or handoff/summary documents (e.g. `HANDOVER-*.md`, `HANDOFF-*.md`, `handoff.md`). + - Throwaway UI/UX prototypes or design mockups (e.g. `*-designs.html`, `*-mockup.html`, `*-demo(s).html`) at the repo root or under a `design/` folder. The only tracked `.html` files should be Vite `index.html` entrypoints. + Before committing or opening a PR, run `git status` and `git diff --staged --stat` and remove anything matching these patterns. Put scratch work under `.tmp/` (gitignored) instead of the repo root or the source tree. diff --git a/HANDOVER-kimi-web-table-width.md b/HANDOVER-kimi-web-table-width.md deleted file mode 100644 index c3ac05613a..0000000000 --- a/HANDOVER-kimi-web-table-width.md +++ /dev/null @@ -1,149 +0,0 @@ -# 接手文档:kimi-web 聊天表格宽度 - -> **当前状态(未提交,用户先用着)**:表格在阅读列(≈760px)内**单元格换行**显示—— -> 不横向滚动、不溢出、不裁切,但**没有比正文更宽**。这是目前效果最干净、可以先用的版本。 -> -> **仍未达成的目标**:让宽表格像 Manus 那样**突破到比正文更宽的列**再换行。两次突破尝试 -> (容器单位 `cqw`、固定 px `@container` 断点)都没做出满意效果,已回退/停用,见 §4、§6。 -> -> **只改了 1 个文件**:`apps/kimi-web/src/components/chat/Markdown.vue`(表格样式块)。 -> typecheck / check:style 通过。 - -## 1. 背景与目标 - -`apps/kimi-web` 聊天里 markstream 渲染的 markdown 表格,默认被钉在阅读列宽内 -(`width:100%` + `table-layout:fixed`),宽表格要么被挤成很窄的列、要么表格内横向滚动。 - -用户想要的最终效果(参照 Manus 截图): - -1. 表格**比正文阅读列更宽**(横向铺开),但 -2. 单元格**换行**,所以表格**永不横向滚动**(表格自身和对话面板都不滚),且 -3. 正文 / 代码 / diff / 工具块 / thinking 等其它块仍限在 760px 阅读列内。 - -> 第 1 点(撑得比正文宽)目前**没做到**——见 §6。当前上线的是"列内换行"的降级版。 - -## 2. 当前实现(列内换行版,正在用) - -`Markdown.vue` ` - - -
-
-

消息队列 UX · 高保真原型

-

把「发送」和「中断」解耦,把队列从隐藏面板搬进对话流。下面是运行中 + 排队的核心场景,可交互(试着输入回车、点 ×、点排队项编辑、点“出队一条”)。

-
- -
- -
-
-

相对现在的改动(4 点)

-
A
发送永远是发送 — 运行中也不再变成“中断”,Enter / 按钮都是入队。
-
B
Stop 独立按钮 — 与发送平级、运行时固定显示,急停仍一键可达。
-
C
队列内联进对话流 — 作为半透明待发送气泡,看得见、点得动。
-
D
可编辑 / 可排序 / 可删除 — 点气泡编辑、拖拽排序、× 删除。
-
- -
场景 · 运行中(agent 正在改写中间件,你又追加了 3 条)
- -
-
-
- -
帮我把登录接口改成 JWT,并补上对应的单元测试。
- -

我看了一下 src/auth 的结构,当前是基于 session cookie 的实现,先读取相关文件。

- -
- - read_file - src/auth/session.ts - 0.2s -
-
- - read_file - src/auth/middleware.ts - 运行中… -
-
🌔正在改写认证中间件…
- - -
-
- - - 队列 · 3 - - 当前回合结束后自动逐条发送 -
-
-
-
- - -
-
- -
-
- - yolo - - plan - -
-
- kimi-k2· thinking - - - - - - -
-
-
- - 运行中:Enter 加入队列 · Ctrl+S 立即插入当前回合 -
-
- -
- - - - 提示:在输入框里打点什么按 Enter,会作为新气泡进队列。 -
-
-
- - -
- -
对照 · 空闲态(没有队列、没有运行中的 turn)
-
-
-
这个项目用的是什么测试框架?
-

用的是 vitest,纯逻辑测试,没有 jsdom / 组件测试。测试文件跟被测模块放在一起。

-
-
-
- -
-
- - yolo -
-
- kimi-k2· thinking - - - - -
-
-
-
-
- - -
- - - - diff --git a/apps/kimi-web/design/sidebar-show-more-demo.html b/apps/kimi-web/design/sidebar-show-more-demo.html deleted file mode 100644 index d42cf7c5d0..0000000000 --- a/apps/kimi-web/design/sidebar-show-more-demo.html +++ /dev/null @@ -1,649 +0,0 @@ - - - - - -kimi-web · 侧栏「展开更多」Demo - - - -
-

侧栏「展开更多」Demo

- - - -
- -
-
-
方案 A · 加载更多 + 整组折叠
(点工作区标题折叠整组)
- -
-
-
方案 B · 加载更多 + 组内展开/收起
(收回到第一页)
- -
-
-

怎么看这个 demo

-
    -
  • 左右两个侧栏是同一组数据(kimi-code 工作区共 23 个会话,每页 5 条),可独立点按。
  • -
  • 顶部 按钮样式 切换「当前实现」与「规范合规」,两套样式同时作用在两个方案上,方便对比。
  • -
  • 第二个工作区 docs 用来看「上面的列表展开后会把下面的工作区顶下去」——这就是想收起的动机。
  • -
- -

方案 A:加载更多 + 整组折叠

-
    -
  • 只有 加载更多 一个按钮,拉下一页追加。
  • -
  • 收起靠 点工作区标题(整组会话全部隐藏)。这是 §07 已经定义的折叠。
  • -
  • 改动最小,完全符合现有规范。
  • -
  • 一折就整组全收,连第一页也看不见。
  • -
- -

方案 B:加载更多 + 组内展开/收起

-
    -
  • 加载超过第一页后,多一个 收起 按钮;收回后变成 展开
  • -
  • 收起只回到第一页(前 5 条),数据不丢、不重新请求。
  • -
  • 能收回第一页之外的会话,缓解把下面工作区顶下去的问题。
  • -
  • §07 目前没有定义这个组内控件,需要扩展规范。
  • -
- -

样式差异(切到「当前实现」看)

-
    -
  • 当前:裸 button —— mono 字体、无圆角、hover 文字变 accent、无焦点环,且文字比 session 标题偏右 8px
  • -
  • 设计稿:做成「会话行同款」的紧凑列表控件 —— 行首留空使文字与 session 标题精确对齐font-uiradius-md、26px 行高、hover 只出 sunken 底、:focus-visible 焦点环。
  • -
-

规范参考:design-system.html §07(侧栏)/ §02(token / 字体)/ §08(焦点环)。

-
-
- - - - diff --git a/apps/kimi-web/design/undo-exit-demos.html b/apps/kimi-web/design/undo-exit-demos.html deleted file mode 100644 index de6bb3c117..0000000000 --- a/apps/kimi-web/design/undo-exit-demos.html +++ /dev/null @@ -1,398 +0,0 @@ - - - - - -用户消息 undo 退场动画 · 10 个候选 - - - -
-

用户消息 undo 退场动画 · 10 个候选

-

点击每条气泡预览退场效果;点击「重播」或「全部重置」可再次观看。气泡样式对齐真实 .u-bub(右对齐、accent-soft、非对称圆角)。

-
- -
- - 复杂度:低 = 纯 transform/opacity/filter;中 = 需 clip-path / perspective / 多 filter;高 = 需 @property + mask。 -
- -
- -
-
-

01 柔雾消散

-

原地失焦 + 轻微缩小。最克制、最贴设计系统的「安静」气质。

- opacity · blur · scale -
-
撤销这条消息,看看效果
-
- -
-
-

02 向右滑出

-

向右平移并淡出,方向明确(与「撤回」语义一致)。性能最好。

- translateX · opacity -
-
撤销这条消息,看看效果
-
- -
-
-

03 缩小淡出

-

向右锚点缩小并淡出。比柔雾多一点「被收走」的感觉,但仍然克制。

- scale · opacity(origin right) -
-
撤销这条消息,看看效果
-
- -
-
-

04 横向收束

-

沿右边缘把气泡压扁到 0。像把消息「抽走」,方向感强。

- scaleX · opacity(origin right) -
-
撤销这条消息,看看效果
-
- -
-
-

05 向上折起

-

3D 向上翻折消失,立体、有层次。需父级 perspective。

- rotateX · opacity · perspective -
-
撤销这条消息,看看效果
-
- -
-
-

06 裁切擦除

-

用 clip-path 从右向左裁掉。干净、利落,无明显位移。

- clip-path inset · opacity -
-
撤销这条消息,看看效果
-
- -
-
-

07 浮起消散 低~中

-

向上浮起、阴影放大、淡出。像消息「飘走」,轻盈。

- translateY · box-shadow · opacity -
-
撤销这条消息,看看效果
-
- -
-
-

08 墨迹晕开

-

失焦 + 提亮 + 去饱和,像墨迹在水里晕开。柔和、有质感。

- blur · brightness · grayscale · opacity -
-
撤销这条消息,看看效果
-
- -
-
-

09 粒子点阵消散

-

mask 点阵逐点收缩,气泡「碎成点」再消失。最接近「粒子消散」。

- @property --k · radial-gradient mask -
-
撤销这条消息,看看效果
-
- -
-
-

10 散焦放大

-

轻微放大 + 强失焦,像被一阵风吹散。比柔雾更「爆」一点。

- scale · blur · opacity -
-
撤销这条消息,看看效果
-
- -
- - - - diff --git a/composer-toolbar-designs.html b/composer-toolbar-designs.html deleted file mode 100644 index 9cabf5c224..0000000000 --- a/composer-toolbar-designs.html +++ /dev/null @@ -1,298 +0,0 @@ - - - - - -kimi-web · Composer 左下角按钮设计方案 v2 - - - - - - -
-
-

Composer 左下角 · v2

- -
-

统一目标:减少噪点(去掉彩色胶囊、合并视觉块),并用完全不同的设计语言。右上角可切换深色预览。

- - -
-
A

静谧线性(Quiet Linear)

推荐
-

参考 Linear / Vercel。三个控件收进一条发丝边框的容器,黑白灰 + 精致留白;权限仅用一个 6px 状态点表达,去掉所有彩色胶囊。

-
-
给 Kimi 发送消息…
-
-
-
- - - - - -
-
-
- kimi-k2 · thinking - -
-
-
-
整条读作"一个对象"而非 3~5 个碎片;权限颜色从大字红/橙降级为一个小点,安静但仍有语义。
-
- - -
-
B

浮动胶囊 Dock(Floating Pill)

-

参考 iOS / Arc 浏览器。所有控件放进一枚圆角 999px 的"药丸",半透明 + 背景模糊,像悬浮在输入框底部的工具条。激活项用反色胶囊。

-
-
给 Kimi 发送消息…
-
-
-
- - - -
-
-
- kimi-k2 - -
-
-
-
把"当前启用的模式"直接做成反色胶囊(这里是 Plan),状态一目了然;附件退化为单个 +,节省空间。
-
- - -
-
C

毛玻璃浮层(Frosted Glass)

-

半透明磨砂 + 柔和阴影,工具条轻飘飘地浮在输入区上方,整体更"现代、通透"。权限点带一圈柔光 halo。

-
-
给 Kimi 发送消息…
-
-
-
- - - -
-
-
- kimi-k2 · thinking - -
-
-
-
通透感最强,适合配浅色/渐变背景;代价是 backdrop-filter 在低端机上略耗性能。
-
- - -
-
D

编辑式纯文字(Editorial)

-

参考 Notion / 写作应用。彻底去掉边框、填充、胶囊,只剩一条分隔线 + 精心排版的文字和小图标,hover 才显出交互。最克制、最"轻"。

-
-
给 Kimi 发送消息…
-
-
- - / - - / - -
-
- kimi-k2 · thinking - -
-
-
-
信息密度最低,几乎"隐形";适合强调输入本身、不想被工具条抢戏的场景。
-
- -
- - - - From d86fa38e119c5834fff13a67194efe8d62c117e1 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 22:12:43 +0800 Subject: [PATCH 26/98] fix(kimi-web): disable text hyphenation and code ligatures (#1438) Set hyphens: none (with the -webkit- prefix) on body so chat and markdown text never gain a hyphen glyph at a line break; components still control where lines wrap via word-break/overflow-wrap. Disable the ligatures that coding fonts enable by default (liga/calt/ss01) on native code elements so code renders literally, e.g. != stays as two characters. --- .changeset/web-text-rendering.md | 5 +++++ apps/kimi-web/src/style.css | 15 +++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 .changeset/web-text-rendering.md diff --git a/.changeset/web-text-rendering.md b/.changeset/web-text-rendering.md new file mode 100644 index 0000000000..2830647c89 --- /dev/null +++ b/.changeset/web-text-rendering.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Prevent chat text from hyphenating at line breaks and render code without font ligatures. diff --git a/apps/kimi-web/src/style.css b/apps/kimi-web/src/style.css index 13c1f34850..40c71fd7e6 100644 --- a/apps/kimi-web/src/style.css +++ b/apps/kimi-web/src/style.css @@ -223,6 +223,17 @@ summary { vertical-align: -0.15em; } +/* Render code literally: disable the ligatures coding fonts enable by default + (e.g. `!=` collapsing to `≠`, `=>` to `⇒`). */ +code, +pre, +kbd, +samp, +tt { + font-feature-settings: "liga" 0, "calt" 0, "ss01" 0; + font-variant-ligatures: none; +} + /* color-scheme drives UA-rendered parts (scrollbars, form controls, etc.). An explicit choice must pin it to a single value — otherwise a dark-mode user on a light OS gets light scrollbars floating on the dark UI. */ @@ -635,6 +646,10 @@ body { text-rendering: auto; font-synthesis: none; text-size-adjust: 100%; + /* Never insert a hyphen glyph at line breaks (the page is `lang="en"`); + word-break/overflow-wrap still decide where lines wrap. */ + -webkit-hyphens: none; + hyphens: none; } /* --------------------------------------------------------------------------- From ac5b5e4cbfdb050817c9fce7e08dd3bdd8ea354e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 6 Jul 2026 22:19:02 +0800 Subject: [PATCH 27/98] fix(kimi-web): keep tool components from jumping on expand or collapse (#1433) * fix(kimi-web): keep tool components from jumping on expand or collapse Also show the scroll-to-bottom button whenever scrolled up, and render a fallback icon for tools without a dedicated glyph. * fix(kimi-web): preserve bottom follow during content-only resizes Late-loading media can grow after scrollKey has run; keep chasing the bottom on content growth, and only suppress follow during the pinned expand/collapse window. --- .changeset/fix-web-collapsible-jump.md | 5 ++ apps/kimi-web/scripts/gen-icon-data.mjs | 1 + .../src/components/chat/ConversationPane.vue | 63 ++++++++++++++++++- .../src/components/chat/ToolGroup.vue | 52 ++++++++++----- apps/kimi-web/src/components/chat/ToolRow.vue | 33 +++++++--- apps/kimi-web/src/lib/icon-data.ts | 3 + apps/kimi-web/src/lib/toolMeta.ts | 3 +- 7 files changed, 134 insertions(+), 26 deletions(-) create mode 100644 .changeset/fix-web-collapsible-jump.md diff --git a/.changeset/fix-web-collapsible-jump.md b/.changeset/fix-web-collapsible-jump.md new file mode 100644 index 0000000000..f6c0b8a9a1 --- /dev/null +++ b/.changeset/fix-web-collapsible-jump.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix tool components jumping the conversation when expanded or collapsed. diff --git a/apps/kimi-web/scripts/gen-icon-data.mjs b/apps/kimi-web/scripts/gen-icon-data.mjs index d228a8d03a..ce48d09e21 100644 --- a/apps/kimi-web/scripts/gen-icon-data.mjs +++ b/apps/kimi-web/scripts/gen-icon-data.mjs @@ -68,6 +68,7 @@ const GROUPS = [ code: 'code-line', terminal: 'terminal-box-line', pencil: 'pencil-line', + tool: 'tools-line', glob: 'braces-line', globe: 'global-line', 'check-list': 'list-check', diff --git a/apps/kimi-web/src/components/chat/ConversationPane.vue b/apps/kimi-web/src/components/chat/ConversationPane.vue index 76a7483299..f3f06d05e2 100644 --- a/apps/kimi-web/src/components/chat/ConversationPane.vue +++ b/apps/kimi-web/src/components/chat/ConversationPane.vue @@ -235,6 +235,7 @@ function resolveAgentTaskId(toolCallId: string): string | undefined { return undefined; } provide('resolveAgentTaskId', resolveAgentTaskId); +provide('pinScroll', pinScrollFor); const todoDoneCount = computed(() => (props.todos ?? []).filter((td) => td.status === 'done').length); const hasDockWork = computed(() => bashTasks.value.length > 0 || @@ -442,6 +443,11 @@ function onPanesScroll(): void { if (!el) return; const top = el.scrollTop; + if (isPinned()) { + lastScrollTop = top; + return; + } + if (performance.now() - lastSmoothScroll < 100) { lastScrollTop = top; return; @@ -456,6 +462,7 @@ function onPanesScroll(): void { } if (top < lastScrollTop - 1 && dist > 1) { following.value = false; + showPill.value = true; } else if (dist <= BOTTOM_THRESHOLD && top > lastScrollTop + 1) { following.value = true; showPill.value = false; @@ -581,6 +588,42 @@ function cancelRaf(id: number): void { else clearTimeout(id); } +// --- Scroll anchoring for expand/collapse interactions ---------------------- +// Toggling a tool row/group grows or shrinks its body, which would otherwise move +// the viewport: a collapse near the bottom shrinks scrollHeight and lets the +// browser clamp scrollTop, and the auto-follow may snap to the tail. While the +// transition runs we pin the toggled row's viewport position and suppress the +// auto-follow, so the row stays put and only its body opens downward / collapses +// upward. +let pinUntil = 0; +let pinRaf = 0; +let pinEl: HTMLElement | null = null; +let pinTargetTop = 0; + +function isPinned(): boolean { + return performance.now() < pinUntil; +} + +function pinScrollFor(el: HTMLElement, ms = 260): void { + const panes = panesRef.value; + if (!panes) return; + pinEl = el; + pinTargetTop = el.getBoundingClientRect().top; + pinUntil = performance.now() + ms; + if (pinRaf) return; + const tick = () => { + pinRaf = 0; + if (performance.now() >= pinUntil || !pinEl) { + pinEl = null; + return; + } + const delta = pinEl.getBoundingClientRect().top - pinTargetTop; + if (delta) panes.scrollTop += delta; + pinRaf = raf(tick); + }; + pinRaf = raf(tick); +} + function scheduleStableFollow(maxFrames = 36): void { if (!following.value && !hasUserActionFollowLock()) return; const token = ++stableFollowToken; @@ -802,6 +845,8 @@ let contentObserver: MutationObserver | null = null; let resizeObserver: ResizeObserver | null = null; let observedContent: Element | null = null; let observedDock: HTMLElement | null = null; +let lastObservedScrollHeight = 0; +let lastObservedClientHeight = 0; let scrollRaf = 0; let pillEligible = false; const historyLoadInProgress = ref(false); @@ -817,6 +862,7 @@ function scheduleFollow(allowPill: boolean): void { scrollRaf = 0; const wantPill = pillEligible; pillEligible = false; + if (isPinned()) return; if (following.value || hasUserActionFollowLock()) scrollToBottom(false); else if (wantPill) showPill.value = true; }) as unknown as number; @@ -855,6 +901,8 @@ function rebindScrollObservers(): void { ensureContentObserved(); ensureDockObserved(); } + lastObservedScrollHeight = el?.scrollHeight ?? 0; + lastObservedClientHeight = el?.clientHeight ?? 0; } function onContentMutated(): void { @@ -904,7 +952,19 @@ onMounted(() => { if (typeof ResizeObserver === 'function') { resizeObserver = new ResizeObserver(() => { updatePanesScrollbarWidth(); - scheduleFollow(false); + const el = panesRef.value; + if (!el) return; + const { scrollHeight, clientHeight } = el; + const grew = scrollHeight > lastObservedScrollHeight + 1; + const viewportShrank = clientHeight < lastObservedClientHeight - 1; + lastObservedScrollHeight = scrollHeight; + lastObservedClientHeight = clientHeight; + // Follow the tail on genuine growth (new turns, streaming, or late-loading + // media that gain height after scrollKey has already run) or a shrinking + // viewport (composer dock growing and hiding the last message). While a tool + // row/group is being toggled (the pinned window) suppress follow entirely, + // so the row opens downward / collapses upward without moving the viewport. + if (!isPinned() && (grew || viewportShrank)) scheduleFollow(false); }); } rebindScrollObservers(); @@ -922,6 +982,7 @@ onUnmounted(() => { if (resizeObserver) resizeObserver.disconnect(); if (scrollRaf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(scrollRaf); if (stableFollowRaf) cancelRaf(stableFollowRaf); + if (pinRaf) cancelRaf(pinRaf); if (abortToastTimer !== null) clearTimeout(abortToastTimer); if (copyConversationCopiedTimer !== null) { clearTimeout(copyConversationCopiedTimer); diff --git a/apps/kimi-web/src/components/chat/ToolGroup.vue b/apps/kimi-web/src/components/chat/ToolGroup.vue index a80c7e92dd..8a30af9bea 100644 --- a/apps/kimi-web/src/components/chat/ToolGroup.vue +++ b/apps/kimi-web/src/components/chat/ToolGroup.vue @@ -1,6 +1,6 @@ @@ -131,6 +142,17 @@ function toggle(): void { transform: rotate(90deg); } .tool-group-body { + display: grid; + grid-template-rows: minmax(0, 0fr); + overflow: hidden; + transition: grid-template-rows var(--duration-base) var(--ease-out); +} +.tool-group-body.open { + grid-template-rows: minmax(0, 1fr); +} +.tool-group-body-inner { + min-height: 0; + overflow: hidden; display: flex; flex-direction: column; } diff --git a/apps/kimi-web/src/components/chat/ToolRow.vue b/apps/kimi-web/src/components/chat/ToolRow.vue index 239d1a4cb1..63d49c4c4d 100644 --- a/apps/kimi-web/src/components/chat/ToolRow.vue +++ b/apps/kimi-web/src/components/chat/ToolRow.vue @@ -1,5 +1,6 @@ @@ -642,7 +614,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): - +
@@ -660,7 +632,6 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): @open-agent="emit('openAgent', $event)" /> -
@@ -855,29 +826,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): margin-top: 2px; margin-right: 4px; } -.u-meta .u-time { - display: inline-flex; - align-items: center; - padding: 2px 5px; - background: none; - border: none; - border-radius: var(--radius-sm); - color: var(--muted); - font: inherit; - font-size: var(--text-base); - line-height: 1; - cursor: pointer; - opacity: 0.7; - transition: opacity 0.12s, color 0.12s, background-color 0.12s; - white-space: nowrap; -} -.u-meta .u-time:hover { - opacity: 1; - color: var(--color-accent); - background: var(--hover); -} -.u-meta .u-edit, -.u-meta .u-time { +.u-meta .u-edit { min-height: 22px; box-sizing: border-box; } diff --git a/apps/kimi-web/src/components/chat/CronNotice.vue b/apps/kimi-web/src/components/chat/CronNotice.vue index e4f8b52e5d..4b2084e904 100644 --- a/apps/kimi-web/src/components/chat/CronNotice.vue +++ b/apps/kimi-web/src/components/chat/CronNotice.vue @@ -1,19 +1,23 @@ - + embedded inside an assistant turn's blocks — in both cases it takes the + same text + cron data. --> diff --git a/apps/kimi-web/src/components/chat/MessageTime.vue b/apps/kimi-web/src/components/chat/MessageTime.vue new file mode 100644 index 0000000000..daa6a0345f --- /dev/null +++ b/apps/kimi-web/src/components/chat/MessageTime.vue @@ -0,0 +1,62 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chatTurnRendering.ts b/apps/kimi-web/src/components/chatTurnRendering.ts index 01d13025a1..f928adf2de 100644 --- a/apps/kimi-web/src/components/chatTurnRendering.ts +++ b/apps/kimi-web/src/components/chatTurnRendering.ts @@ -2,7 +2,7 @@ // Pure turn-rendering helpers: pure functions of their arguments (no Vue // reactivity, no component state). Shared by ChatPane.vue's template and its // stateful copy/edit helpers. -import type { ChatTurn, CronTurnData, TurnBlock } from '../types'; +import type { ChatTurn, TurnBlock } from '../types'; export function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; @@ -41,8 +41,7 @@ export type AssistantRenderBlock = | { kind: 'thinking'; thinking: string; sourceIndex: number } | { kind: 'text'; text: string; sourceIndex: number } | { kind: 'tool'; tool: ToolStackItem['tool']; sourceIndex: number } - | { kind: 'tool-stack'; tools: ToolStackItem[] } - | { kind: 'cron'; text: string; cron: CronTurnData; sourceIndex: number }; + | { kind: 'tool-stack'; tools: ToolStackItem[] }; export function rendersToolCard(block: Extract): boolean { return !(block.tool.status === 'ok' && block.tool.media); @@ -86,8 +85,6 @@ export function assistantRenderBlocks(turn: ChatTurn): AssistantRenderBlock[] { rendered.push({ kind: 'thinking', thinking: block.thinking, sourceIndex }); } else if (block.kind === 'text') { rendered.push({ kind: 'text', text: block.text, sourceIndex }); - } else if (block.kind === 'cron') { - rendered.push({ kind: 'cron', text: block.text, cron: block.cron, sourceIndex }); } }); diff --git a/apps/kimi-web/src/composables/messagesToTurns.ts b/apps/kimi-web/src/composables/messagesToTurns.ts index 4293cb0be4..8d2993a22d 100644 --- a/apps/kimi-web/src/composables/messagesToTurns.ts +++ b/apps/kimi-web/src/composables/messagesToTurns.ts @@ -415,10 +415,6 @@ function buildCronTurn(msg: AppMessage, no: number, kind: 'cron_job' | 'cron_mis return { id: msg.id, role: 'cron', no, text, createdAt: msg.createdAt, cron }; } -function buildCronBlock(msg: AppMessage, kind: 'cron_job' | 'cron_missed'): TurnBlock { - const { text, cron } = buildCronData(msg, kind); - return { kind: 'cron', text, cron }; -} /** * Whether a USER-role message should be shown. Mirrors the TUI's @@ -457,12 +453,6 @@ function continuesAssistantGroup(group: Group | null, promptId: string | undefin ); } -/** True while a tool in this group has been called but not yet resolved — i.e. - * the group is mid-tool-call. A cron injection that arrives now is sandwiched - * between the tool use and its result and must not flush the group. */ -function hasRunningTool(group: Group): boolean { - return group.tools.some((t) => t.status === 'running'); -} /** Extract the plan file path from an ExitPlanMode tool result. The approved * output contains `Plan saved to: `; this survives a page reload (unlike @@ -653,19 +643,10 @@ export function messagesToTurns( // User messages flush the pending group and start a new user turn if (msg.role === 'user') { const cronKind = cronOriginKind(msg); - // A cron injection steered into an in-flight turn can land between a - // tool use and its result in the live event stream. It must NOT flush the - // pending assistant group then — flushing would orphan the next - // tool.result, which only folds into a pending group, leaving the tool - // rendered without output. So embed it as a block only while the group - // has an in-flight tool. Otherwise — a cron at a turn boundary, including - // an idle fire on a REST snapshot that carries no prompt ids (where the - // whole transcript shares one group) — flush and render it as its own - // turn so it doesn't merge into the previous answer. - if (cronKind !== undefined && pendingGroup !== null && hasRunningTool(pendingGroup)) { - pendingGroup.blocks.push(buildCronBlock(msg, cronKind)); - continue; - } + // A cron injection always renders as its own standalone turn: agent-core + // buffers steer input while a turn is in flight and only injects it at the + // turn boundary, so the cron message does not land between a tool use and + // its result in practice. flushGroup(); if (cronKind !== undefined) { turns.push(buildCronTurn(msg, no++, cronKind)); diff --git a/apps/kimi-web/src/lib/icons.ts b/apps/kimi-web/src/lib/icons.ts index 3511af5393..e41f0b1e4a 100644 --- a/apps/kimi-web/src/lib/icons.ts +++ b/apps/kimi-web/src/lib/icons.ts @@ -26,6 +26,9 @@ import RiArrowRightLine from '~icons/ri/arrow-right-line'; import RiArrowRightSLine from '~icons/ri/arrow-right-s-line'; import RiArrowUpLine from '~icons/ri/arrow-up-line'; import RiBracesLine from '~icons/ri/braces-line'; +import RiCalendarCloseLine from '~icons/ri/calendar-close-line'; +import RiCalendarScheduleLine from '~icons/ri/calendar-schedule-line'; +import RiCalendarTodoLine from '~icons/ri/calendar-todo-line'; import RiChatNewLine from '~icons/ri/chat-new-line'; import RiCheckLine from '~icons/ri/check-line'; import RiCloseLine from '~icons/ri/close-line'; @@ -84,6 +87,9 @@ import RawArrowRightLine from '~icons/ri/arrow-right-line?raw'; import RawArrowRightSLine from '~icons/ri/arrow-right-s-line?raw'; import RawArrowUpLine from '~icons/ri/arrow-up-line?raw'; import RawBracesLine from '~icons/ri/braces-line?raw'; +import RawCalendarCloseLine from '~icons/ri/calendar-close-line?raw'; +import RawCalendarScheduleLine from '~icons/ri/calendar-schedule-line?raw'; +import RawCalendarTodoLine from '~icons/ri/calendar-todo-line?raw'; import RawChatNewLine from '~icons/ri/chat-new-line?raw'; import RawCheckLine from '~icons/ri/check-line?raw'; import RawCloseLine from '~icons/ri/close-line?raw'; @@ -136,6 +142,9 @@ import RawUserLine from '~icons/ri/user-line?raw'; export type IconName = | 'plus' | 'chat-new' + | 'calendar-close' + | 'calendar-schedule' + | 'calendar-todo' | 'close' | 'check' | 'search' @@ -212,6 +221,9 @@ function entry(component: Component, svg: string): IconEntry { export const ICONS: Record = { plus: entry(RiAddLine, RawAddLine), 'chat-new': entry(RiChatNewLine, RawChatNewLine), + 'calendar-close': entry(RiCalendarCloseLine, RawCalendarCloseLine), + 'calendar-schedule': entry(RiCalendarScheduleLine, RawCalendarScheduleLine), + 'calendar-todo': entry(RiCalendarTodoLine, RawCalendarTodoLine), close: entry(RiCloseLine, RawCloseLine), check: entry(RiCheckLine, RawCheckLine), search: entry(RiSearchLine, RawSearchLine), @@ -353,6 +365,9 @@ export const ICON_GROUPS: ReadonlyArray 'check-list', 'bolt', 'git-pull-request', + 'calendar-schedule', + 'calendar-todo', + 'calendar-close', ], ], ['Communication', ['message', 'mail', 'user']], diff --git a/apps/kimi-web/src/lib/toolMeta.ts b/apps/kimi-web/src/lib/toolMeta.ts index 0c7c057e65..6ecfd4c00b 100644 --- a/apps/kimi-web/src/lib/toolMeta.ts +++ b/apps/kimi-web/src/lib/toolMeta.ts @@ -93,6 +93,10 @@ const TOOL_GLYPH: Record = { task: 'sparkles', agentswarm: 'git-pull-request', askuserquestion: 'help-circle', + // Cron scheduling tools share a calendar motif: schedule / list / cancel. + croncreate: 'calendar-schedule', + cronlist: 'calendar-todo', + crondelete: 'calendar-close', }; export function toolGlyph(name: string): string { diff --git a/apps/kimi-web/src/types.ts b/apps/kimi-web/src/types.ts index e40b580624..d98b3dbcf1 100644 --- a/apps/kimi-web/src/types.ts +++ b/apps/kimi-web/src/types.ts @@ -229,8 +229,7 @@ export interface CronTurnData { export type TurnBlock = | { kind: 'text'; text: string } | { kind: 'thinking'; thinking: string } - | { kind: 'tool'; tool: ToolCall } - | { kind: 'cron'; text: string; cron: CronTurnData }; + | { kind: 'tool'; tool: ToolCall }; export interface ChatTurn { id: string; diff --git a/apps/kimi-web/test/turn-logic.test.ts b/apps/kimi-web/test/turn-logic.test.ts index b28d93b5dd..07b09bae7c 100644 --- a/apps/kimi-web/test/turn-logic.test.ts +++ b/apps/kimi-web/test/turn-logic.test.ts @@ -320,56 +320,6 @@ describe('messagesToTurns cron', () => { expect(turns).toHaveLength(1); }); - it('embeds an in-turn cron injection as a block and keeps folding the following tool result', () => { - const envelope = - '\n' + - '\nCheck BTC\n\n'; - const turns = messagesToTurns( - [ - message('u1', 'user', [{ type: 'text', text: 'hi' }]), - message('a1', 'assistant', [ - { - type: 'toolUse', - toolCallId: 'tc1', - toolName: 'FetchURL', - input: { url: 'https://example.com' }, - }, - ]), - message('c1', 'user', [{ type: 'text', text: envelope }], { - metadata: { - origin: { - kind: 'cron_job', - jobId: 'j', - cron: '* * * * *', - recurring: true, - coalescedCount: 1, - stale: false, - }, - }, - }), - message('t1', 'tool', [ - { type: 'toolResult', toolCallId: 'tc1', output: 'the price is 62k' }, - ]), - ], - [], - ); - - // user turn + one assistant turn (tool + embedded cron block + result). - // No standalone cron turn, and the tool result is preserved. - expect(turns).toHaveLength(2); - const assistant = turns[1]!; - expect(assistant.role).toBe('assistant'); - expect(assistant.tools?.[0]).toMatchObject({ - id: 'tc1', - status: 'ok', - output: ['the price is 62k'], - }); - expect(assistant.blocks?.find((b) => b.kind === 'cron')).toMatchObject({ - kind: 'cron', - text: 'Check BTC', - cron: { jobId: 'j' }, - }); - }); it('flushes an idle cron fire as its own turn even when no prompt ids are present', () => { const envelope = From 0cc9831a2f79d93903259bd3353e746abac01b67 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 8 Jul 2026 14:53:29 +0800 Subject: [PATCH 66/98] fix(web): composer model switch also updates global default model (#1491) * fix(web): composer model switch also updates global default model The composer model switcher still switches the active session's model via POST /sessions/{id}/profile (awaited, so the model pill reflects the result), and additionally fires POST /api/v1/config with { default_model } as a fire-and-forget side effect so new sessions inherit the chosen default. The config request is skipped when the model already matches the current default. * fix(web): route ModelPicker overlay selection through the default-model update The overlay opened from the composer's "More models" row (and /model) is a continuation of the same switch flow, so its selection now also bumps the global default model instead of only switching the active session. * fix(web): only persist the default model after a confirmed session switch setModel now returns whether the switch was accepted (true for the draft path), so the composer flow no longer writes a stale or invalid model alias into the global config when the session-level switch failed and rolled back. --- .../web-composer-default-model-switch.md | 5 ++++ apps/kimi-web/src/App.vue | 24 +++++++++++++++++-- .../client/useModelProviderState.ts | 11 ++++++--- 3 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 .changeset/web-composer-default-model-switch.md diff --git a/.changeset/web-composer-default-model-switch.md b/.changeset/web-composer-default-model-switch.md new file mode 100644 index 0000000000..e35a51784d --- /dev/null +++ b/.changeset/web-composer-default-model-switch.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +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. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 6c82df9863..22f645ef2c 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -338,7 +338,27 @@ function openLogin(): void { async function handleSelectModel(modelId: string): Promise { showModelPicker.value = false; - await client.setModel(modelId); + // Same semantics as the composer dropdown rows: the overlay is just the + // "more models" continuation of the same flow, so it must also bump the + // global default (see handleComposerSelectModel). + await handleComposerSelectModel(modelId); +} + +async function handleComposerSelectModel(modelId: string): Promise { + // Primary action: switch the active session's model via POST /sessions/{id}/profile + // (same as the model picker overlay). Awaited so the model pill reflects the + // result and failures surface. In the onboarding draft this just stores the + // pick for the first session. + const switched = await client.setModel(modelId); + + // Side effect: also bump the daemon-wide default model via POST /config so + // new sessions inherit the choice. Fire-and-forget — it must not block the UI + // or mask the session switch. Only after a confirmed switch (a stale/invalid + // alias must not become the global default), and skip when it already + // matches the default. + if (switched && modelId !== client.defaultModel.value) { + void client.updateConfig({ defaultModel: modelId }); + } } async function handleAddProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise { @@ -759,7 +779,7 @@ function openPr(url: string): void { @archive-session="(id) => client.archiveSession(id)" @compact="client.compact()" @pick-model="openModelPicker()" - @select-model="client.setModel($event)" + @select-model="handleComposerSelectModel($event)" @open-file="openFilePreview($event)" @open-media="openMediaPreview($event)" @open-thinking="openThinkingPanel($event)" diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 6eb852e1ff..fcd25624f2 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -181,8 +181,12 @@ export function useModelProviderState( * can return model '', so the authoritative current model comes from * GET /sessions/{id}/status, which we re-read right after. Optimistically show * the chosen id meanwhile. Never crashes. + * + * Returns whether the switch was accepted (true for the draft path too), so + * callers can gate follow-up persistence (e.g. bumping the global default) on + * a confirmed switch — errors are surfaced here, not thrown. */ - async function setModel(modelId: string): Promise { + async function setModel(modelId: string): Promise { const sid = rawState.activeSessionId; const nextThinking = coerceThinkingForModel(modelById(modelId), rawState.thinking); const prevThinking = rawState.thinking; @@ -191,7 +195,7 @@ export function useModelProviderState( // Remember the pick — startSessionAndSendPrompt applies it at create time. draftModel.value = modelId; applyThinkingLevel(nextThinking); - return; + return true; } // Optimistic: show the chosen model immediately, but remember the previous // one so we can roll back if the switch never reaches the daemon. @@ -217,12 +221,13 @@ export function useModelProviderState( saveThinkingToStorage(prevThinking); } pushOperationFailure('setModel', err, { sessionId: sid }); - return; + return false; } // refreshSessionStatus folds the authoritative current model from /status // back into the session (the profile echo can return ''). Best-effort: a // failure here does not mean the switch failed, so it must not roll back. await refreshSessionStatus(sid); + return true; } /** Toggle whether a model is starred (favorited) in the model picker. */ From b0809ddac833d8d920d95187f7ef64f97bafdbc6 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 8 Jul 2026 15:03:30 +0800 Subject: [PATCH 67/98] feat(web): prefix skill slash commands with skill: to distinguish them from built-in commands (#1492) --- .changeset/web-skill-slash-prefix.md | 5 +++++ apps/kimi-web/src/App.vue | 15 ++++++++------ .../kimi-web/src/components/chat/Composer.vue | 9 ++++++--- apps/kimi-web/src/lib/slashCommands.ts | 20 ++++++++++++++++--- apps/kimi-web/test/slash-menu.test.ts | 20 ++++++++++++++++--- 5 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 .changeset/web-skill-slash-prefix.md diff --git a/.changeset/web-skill-slash-prefix.md b/.changeset/web-skill-slash-prefix.md new file mode 100644 index 0000000000..5ba22488ba --- /dev/null +++ b/.changeset/web-skill-slash-prefix.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +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. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 22f645ef2c..eb9131b48b 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -39,6 +39,7 @@ import ServerAuthDialog from './components/ServerAuthDialog.vue'; import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth'; import type { AppConfig, ThinkingLevel } from './api/types'; import { coerceThinkingForModel, commitLevel, segmentsFor } from './lib/modelThinking'; +import { stripSkillPrefix } from './lib/slashCommands'; import Button from './components/ui/Button.vue'; import IconButton from './components/ui/IconButton.vue'; import Icon from './components/ui/Icon.vue'; @@ -506,13 +507,15 @@ function handleCommand(cmd: string): void { break; default: { // Not a built-in command → treat it as a session skill activation - // (the user picked `/` from the menu, or typed `/ args`). - // The daemon answers an unknown name with skill.not_found, surfaced as a - // warning, so a stray slash is harmless. With no active session, create - // one first (same path as the first prompt) so the activation isn't - // silently dropped on the new-session screen. + // (the user picked `/skill:` from the menu, or typed + // `/ args`). Strip the `skill:` display prefix — the REST API + // takes the bare skill name. The daemon answers an unknown name with + // skill.not_found, surfaced as a warning, so a stray slash is harmless. + // With no active session, create one first (same path as the first + // prompt) so the activation isn't silently dropped on the new-session + // screen. const space = cmd.indexOf(' '); - const name = (space === -1 ? cmd : cmd.slice(0, space)).slice(1); + const name = stripSkillPrefix((space === -1 ? cmd : cmd.slice(0, space)).slice(1)); const args = space === -1 ? undefined : cmd.slice(space + 1).trim() || undefined; if (!name) break; if (!client.activeSessionId.value && client.activeWorkspaceId.value) { diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 4f591349a7..e8069b0ec4 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -4,7 +4,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import SlashMenu from './SlashMenu.vue'; import MentionMenu from './MentionMenu.vue'; -import { buildSlashItems, parseSlash } from '../../lib/slashCommands'; +import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands'; import type { FileItem } from './MentionMenu.vue'; import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types'; import type { AppModel, AppSkill, ThinkingLevel } from '../../api/types'; @@ -304,11 +304,14 @@ function handleSubmit(): void { // If it's a known slash command, keep the optional tail as command input // instead of submitting it as normal chat text. This covers `/goal `, // `/swarm `, `/btw `, slash skills with args, and bare - // commands such as `/model`. + // commands such as `/model`. A hand-typed bare skill name (`/deploy`) also + // resolves to its prefixed menu entry (`/skill:deploy`), mirroring the TUI. if (trimmed) { const parsed = parseSlash(trimmed); const known = parsed - ? buildSlashItems(props.skills).some((item) => item.name === parsed.cmd) + ? buildSlashItems(props.skills).some( + (item) => item.name === parsed.cmd || item.name === `/${SKILL_COMMAND_PREFIX}${parsed.cmd.slice(1)}`, + ) : false; if (parsed && known) { text.value = ''; diff --git a/apps/kimi-web/src/lib/slashCommands.ts b/apps/kimi-web/src/lib/slashCommands.ts index 8eb24ed756..8977f3e246 100644 --- a/apps/kimi-web/src/lib/slashCommands.ts +++ b/apps/kimi-web/src/lib/slashCommands.ts @@ -65,16 +65,30 @@ export function parseSlash(input: string): { cmd: string; arg: string } | null { }; } +/** The prefix marking a slash item as a skill activation (`/skill:`). */ +export const SKILL_COMMAND_PREFIX = 'skill:'; + +/** + * Strip the `skill:` prefix from a slash-command name (with or without the + * leading `/`), returning the bare skill name. Non-prefixed input is returned + * unchanged. + */ +export function stripSkillPrefix(name: string): string { + return name.startsWith(SKILL_COMMAND_PREFIX) ? name.slice(SKILL_COMMAND_PREFIX.length) : name; +} + /** * Build the full slash-item list: built-in commands followed by the session's - * skills (each shown as `/`). Skills carry their raw description and + * skills. Non-builtin skills are shown as `/skill:` so the user can + * tell them apart from built-in commands (mirroring the TUI); builtin-sourced + * skills keep the bare `/`. Skills carry their raw description and * an `isSkill` flag so the caller knows to activate rather than run a command. */ export function buildSlashItems( - skills: ReadonlyArray<{ name: string; description: string }> = [], + skills: ReadonlyArray<{ name: string; description: string; source?: string }> = [], ): SlashCommand[] { const skillItems: SlashCommand[] = skills.map((s) => ({ - name: `/${s.name}`, + name: s.source === 'builtin' ? `/${s.name}` : `/${SKILL_COMMAND_PREFIX}${s.name}`, desc: s.description, isSkill: true, // Keep the selected skill in the composer so arguments can be appended. diff --git a/apps/kimi-web/test/slash-menu.test.ts b/apps/kimi-web/test/slash-menu.test.ts index 9b1d8657b6..c270eafb49 100644 --- a/apps/kimi-web/test/slash-menu.test.ts +++ b/apps/kimi-web/test/slash-menu.test.ts @@ -74,11 +74,25 @@ describe('useSlashMenu — update', () => { expect(slash.open.value).toBe(false); }); - it('includes session skills as /', () => { - const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff' } as AppSkill]); + it('includes session skills as /skill:', () => { + const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff', source: 'project' } as AppSkill]); slash.update(); const names = slash.items.value.map((i) => i.name); - expect(names).toContain('/deploy'); + expect(names).toContain('/skill:deploy'); + }); + + it('keeps builtin-sourced skills unprefixed', () => { + const { slash } = setup('/', [{ name: 'update-config', description: 'edit config', source: 'builtin' } as AppSkill]); + slash.update(); + const names = slash.items.value.map((i) => i.name); + expect(names).toContain('/update-config'); + expect(names).not.toContain('/skill:update-config'); + }); + + it('matches a prefixed skill when filtering by its bare name', () => { + const { slash } = setup('/depl', [{ name: 'deploy', description: 'deploy stuff', source: 'project' } as AppSkill]); + slash.update(); + expect(slash.items.value.map((i) => i.name)).toContain('/skill:deploy'); }); }); From 67b2147d8e8832b1901b669bfec24e4794eafa95 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:37:29 +0800 Subject: [PATCH 68/98] ci: release packages (#1468) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/add-vercel-plugin.md | 5 --- .changeset/count-created-goal-turn.md | 5 --- .changeset/fix-headless-exit-code.md | 5 --- .changeset/fix-hooks-windows-console-popup.md | 5 --- .changeset/fix-web-ws-reconnect-toast.md | 5 --- .changeset/forbid-model-goal-pauses.md | 5 --- .changeset/goal-blocked-audit-prompt.md | 5 --- .changeset/image-compression-improvements.md | 6 ---- .../migrate-web-icons-to-unplugin-icons.md | 5 --- .../select-tools-discard-on-compaction.md | 5 --- .../structured-output-provider-format.md | 5 --- .../web-composer-default-model-switch.md | 5 --- .changeset/web-confirm-dialog-enter-key.md | 5 --- .changeset/web-cron-redesign.md | 5 --- .changeset/web-skill-slash-prefix.md | 5 --- apps/kimi-code/CHANGELOG.md | 32 +++++++++++++++++++ apps/kimi-code/package.json | 2 +- packages/kosong/CHANGELOG.md | 6 ++++ packages/kosong/package.json | 2 +- packages/node-sdk/CHANGELOG.md | 6 ++++ packages/node-sdk/package.json | 2 +- 21 files changed, 47 insertions(+), 79 deletions(-) delete mode 100644 .changeset/add-vercel-plugin.md delete mode 100644 .changeset/count-created-goal-turn.md delete mode 100644 .changeset/fix-headless-exit-code.md delete mode 100644 .changeset/fix-hooks-windows-console-popup.md delete mode 100644 .changeset/fix-web-ws-reconnect-toast.md delete mode 100644 .changeset/forbid-model-goal-pauses.md delete mode 100644 .changeset/goal-blocked-audit-prompt.md delete mode 100644 .changeset/image-compression-improvements.md delete mode 100644 .changeset/migrate-web-icons-to-unplugin-icons.md delete mode 100644 .changeset/select-tools-discard-on-compaction.md delete mode 100644 .changeset/structured-output-provider-format.md delete mode 100644 .changeset/web-composer-default-model-switch.md delete mode 100644 .changeset/web-confirm-dialog-enter-key.md delete mode 100644 .changeset/web-cron-redesign.md delete mode 100644 .changeset/web-skill-slash-prefix.md diff --git a/.changeset/add-vercel-plugin.md b/.changeset/add-vercel-plugin.md deleted file mode 100644 index 8fd2082665..0000000000 --- a/.changeset/add-vercel-plugin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Add the Vercel plugin to the bundled plugin marketplace. Run /plugins and select Vercel Plugin to install it. diff --git a/.changeset/count-created-goal-turn.md b/.changeset/count-created-goal-turn.md deleted file mode 100644 index 8e9ba8ea23..0000000000 --- a/.changeset/count-created-goal-turn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Count the turn that starts an autonomous goal toward its goal turn usage. diff --git a/.changeset/fix-headless-exit-code.md b/.changeset/fix-headless-exit-code.md deleted file mode 100644 index f2de03dd82..0000000000 --- a/.changeset/fix-headless-exit-code.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix `kimi -p` runs exiting with code 0 when a turn fails. diff --git a/.changeset/fix-hooks-windows-console-popup.md b/.changeset/fix-hooks-windows-console-popup.md deleted file mode 100644 index 46f8b18c73..0000000000 --- a/.changeset/fix-hooks-windows-console-popup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix console windows flashing on Windows each time a hook runs. diff --git a/.changeset/fix-web-ws-reconnect-toast.md b/.changeset/fix-web-ws-reconnect-toast.md deleted file mode 100644 index dbf190b924..0000000000 --- a/.changeset/fix-web-ws-reconnect-toast.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background. diff --git a/.changeset/forbid-model-goal-pauses.md b/.changeset/forbid-model-goal-pauses.md deleted file mode 100644 index 3f56a747bb..0000000000 --- a/.changeset/forbid-model-goal-pauses.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Prevent autonomous goals from being paused by model-reported status updates. diff --git a/.changeset/goal-blocked-audit-prompt.md b/.changeset/goal-blocked-audit-prompt.md deleted file mode 100644 index 4f498ddb1d..0000000000 --- a/.changeset/goal-blocked-audit-prompt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Tighten goal-mode guidance for blocked and complete status updates. diff --git a/.changeset/image-compression-improvements.md b/.changeset/image-compression-improvements.md deleted file mode 100644 index 70e7622233..0000000000 --- a/.changeset/image-compression-improvements.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch -"@moonshot-ai/kimi-code-sdk": patch ---- - -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. diff --git a/.changeset/migrate-web-icons-to-unplugin-icons.md b/.changeset/migrate-web-icons-to-unplugin-icons.md deleted file mode 100644 index e15b783bd3..0000000000 --- a/.changeset/migrate-web-icons-to-unplugin-icons.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Compile icons at build time so the bundled web UI only carries the icons it renders. diff --git a/.changeset/select-tools-discard-on-compaction.md b/.changeset/select-tools-discard-on-compaction.md deleted file mode 100644 index 70bda32d5c..0000000000 --- a/.changeset/select-tools-discard-on-compaction.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them. After a compaction the boundary announcement re-lists every loadable tool name and the model re-selects what it still needs; a from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. This keeps the post-compaction context at its minimal users+summary floor and removes the schema-rebuild budget heuristics. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active. diff --git a/.changeset/structured-output-provider-format.md b/.changeset/structured-output-provider-format.md deleted file mode 100644 index 625fb130a1..0000000000 --- a/.changeset/structured-output-provider-format.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kosong": patch ---- - -Add provider-level structured response format support. diff --git a/.changeset/web-composer-default-model-switch.md b/.changeset/web-composer-default-model-switch.md deleted file mode 100644 index e35a51784d..0000000000 --- a/.changeset/web-composer-default-model-switch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -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. diff --git a/.changeset/web-confirm-dialog-enter-key.md b/.changeset/web-confirm-dialog-enter-key.md deleted file mode 100644 index c74cc4e05b..0000000000 --- a/.changeset/web-confirm-dialog-enter-key.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Press Enter to confirm in archive and other confirmation dialogs. diff --git a/.changeset/web-cron-redesign.md b/.changeset/web-cron-redesign.md deleted file mode 100644 index d338b601cf..0000000000 --- a/.changeset/web-cron-redesign.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Redesign the scheduled reminder UI. diff --git a/.changeset/web-skill-slash-prefix.md b/.changeset/web-skill-slash-prefix.md deleted file mode 100644 index 5ba22488ba..0000000000 --- a/.changeset/web-skill-slash-prefix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -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. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 44e5829a89..4cb493e842 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,37 @@ # @moonshot-ai/kimi-code +## 0.23.2 + +### Patch Changes + +- [#1489](https://github.com/MoonshotAI/kimi-code/pull/1489) [`2206d21`](https://github.com/MoonshotAI/kimi-code/commit/2206d21327129aa2331b6b159cfce61110b7f94f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add the Vercel plugin to the bundled plugin marketplace. Run /plugins and select Vercel Plugin to install it. + +- [#1477](https://github.com/MoonshotAI/kimi-code/pull/1477) [`150206a`](https://github.com/MoonshotAI/kimi-code/commit/150206a6f7027879df954e26736b4baa5d336235) Thanks [@chengluyu](https://github.com/chengluyu)! - Count the turn that starts an autonomous goal toward its goal turn usage. + +- [#1483](https://github.com/MoonshotAI/kimi-code/pull/1483) [`f30781b`](https://github.com/MoonshotAI/kimi-code/commit/f30781bb273321f3e3bbb548a9d0724ab6299fc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix `kimi -p` runs exiting with code 0 when a turn fails. + +- [#1466](https://github.com/MoonshotAI/kimi-code/pull/1466) [`063bce2`](https://github.com/MoonshotAI/kimi-code/commit/063bce2a2f52601abaa0d13173ab88371cbbe9ae) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix console windows flashing on Windows each time a hook runs. + +- [#1474](https://github.com/MoonshotAI/kimi-code/pull/1474) [`11c6a37`](https://github.com/MoonshotAI/kimi-code/commit/11c6a37ce030f8e64de5c810da07ec7fab3b0615) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background. + +- [#1476](https://github.com/MoonshotAI/kimi-code/pull/1476) [`d1a964f`](https://github.com/MoonshotAI/kimi-code/commit/d1a964fba9b3dca902ea6f81bacaccc839955c03) Thanks [@chengluyu](https://github.com/chengluyu)! - Prevent autonomous goals from being paused by model-reported status updates. + +- [#1481](https://github.com/MoonshotAI/kimi-code/pull/1481) [`1317000`](https://github.com/MoonshotAI/kimi-code/commit/131700097a732b97b3d17c5e2efa1c5a44b013ef) Thanks [@chengluyu](https://github.com/chengluyu)! - Tighten goal-mode guidance for blocked and complete status updates. + +- [#1460](https://github.com/MoonshotAI/kimi-code/pull/1460) [`474ce28`](https://github.com/MoonshotAI/kimi-code/commit/474ce289dd39aa42d1a77a9a2e15531aee49aa15) Thanks [@RealKai42](https://github.com/RealKai42)! - Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly. + +- [#1467](https://github.com/MoonshotAI/kimi-code/pull/1467) [`ee38545`](https://github.com/MoonshotAI/kimi-code/commit/ee385456d0eda380fec067db92c025462db13f5a) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Compile icons at build time so the bundled web UI only carries the icons it renders. + +- [#1471](https://github.com/MoonshotAI/kimi-code/pull/1471) [`9b76e5b`](https://github.com/MoonshotAI/kimi-code/commit/9b76e5bff631cceaeecb2b0cbc096533c5fdc8cc) Thanks [@starquakee](https://github.com/starquakee)! - Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them. After a compaction the boundary announcement re-lists every loadable tool name and the model re-selects what it still needs; a from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. This keeps the post-compaction context at its minimal users+summary floor and removes the schema-rebuild budget heuristics. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active. + +- [#1491](https://github.com/MoonshotAI/kimi-code/pull/1491) [`0cc9831`](https://github.com/MoonshotAI/kimi-code/commit/0cc9831a2f79d93903259bd3353e746abac01b67) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: The composer model switcher switches the active session's model as before and additionally bumps the global default model, so new sessions inherit the choice. + +- [#1490](https://github.com/MoonshotAI/kimi-code/pull/1490) [`b30a45e`](https://github.com/MoonshotAI/kimi-code/commit/b30a45efecfa5ece4f4f10f2c5403ba097e7690b) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Press Enter to confirm in archive and other confirmation dialogs. + +- [#1480](https://github.com/MoonshotAI/kimi-code/pull/1480) [`2ad0120`](https://github.com/MoonshotAI/kimi-code/commit/2ad0120c2a5c8383892e4da1ee7c6853926ed365) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Redesign the scheduled reminder UI. + +- [#1492](https://github.com/MoonshotAI/kimi-code/pull/1492) [`b0809dd`](https://github.com/MoonshotAI/kimi-code/commit/b0809ddac833d8d920d95187f7ef64f97bafdbc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show session skills in the slash menu as `/skill:` so they are distinguishable from built-in commands; typing the bare skill name still works. + ## 0.23.1 ### Patch Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 3d1e8916b9..9512d71bb4 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.1", + "version": "0.23.2", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", diff --git a/packages/kosong/CHANGELOG.md b/packages/kosong/CHANGELOG.md index 3ea8fce138..9d71fb022f 100644 --- a/packages/kosong/CHANGELOG.md +++ b/packages/kosong/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kosong +## 0.5.3 + +### Patch Changes + +- [#1397](https://github.com/MoonshotAI/kimi-code/pull/1397) [`6c9abe8`](https://github.com/MoonshotAI/kimi-code/commit/6c9abe8cf7765b489ca50a2bb0f9b829fd680a51) Thanks [@kermanx](https://github.com/kermanx)! - Add provider-level structured response format support. + ## 0.5.2 ### Patch Changes diff --git a/packages/kosong/package.json b/packages/kosong/package.json index 16d09e9db5..1c7e19223d 100644 --- a/packages/kosong/package.json +++ b/packages/kosong/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kosong", - "version": "0.5.2", + "version": "0.5.3", "private": true, "description": "The LLM abstraction layer for modern AI agent applications", "license": "MIT", diff --git a/packages/node-sdk/CHANGELOG.md b/packages/node-sdk/CHANGELOG.md index 08bf9779af..0ad65961c3 100644 --- a/packages/node-sdk/CHANGELOG.md +++ b/packages/node-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kimi-code-sdk +## 0.13.1 + +### Patch Changes + +- [#1460](https://github.com/MoonshotAI/kimi-code/pull/1460) [`474ce28`](https://github.com/MoonshotAI/kimi-code/commit/474ce289dd39aa42d1a77a9a2e15531aee49aa15) Thanks [@RealKai42](https://github.com/RealKai42)! - Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly. + ## 0.13.0 ### Minor Changes diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index e9343b2925..ac9877d242 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code-sdk", - "version": "0.13.0", + "version": "0.13.1", "private": true, "description": "TypeScript SDK for the Kimi Code Agent", "license": "MIT", From 2394d013bc8b01a031af95f2927b4ac6647971e0 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 8 Jul 2026 17:02:00 +0800 Subject: [PATCH 69/98] 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 a751feccff..7b98cd3f5a 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 89124bdace..bce292cfc3 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 70/98] 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 0000000000..944dbdd6d2 --- /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 ca1f19c8bb..005da411f8 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 b67aef3189..14a6fb5c95 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 ff9f2ce762..61ab5dd13f 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 5343b221e5..68bd19de18 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 71/98] 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 944dbdd6d2..0000000000 --- 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 4cb493e842..9bde39c4ac 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 9512d71bb4..4d9f8df77a 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 72/98] 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 9bde39c4ac..2a3fc5857e 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 7b98cd3f5a..d0549df1cd 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 bce292cfc3..444699fdc0 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 73/98] 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 0000000000..4ffab64dcb --- /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 eb9131b48b..f41127fb7b 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 44d7cf3ca6..8f3493a6f4 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 db58f6b646..3016c0c6d6 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 2f7e822fcd..9b61944bb2 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 ec2c8d7707..c8aa5a56f5 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 a06a14bdda..d20d6bfa6c 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 a6d0a1f31d..36dab4c614 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 f10a31d7d4..5b13de8fcb 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 74/98] 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 0000000000..5a829b6c17 --- /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 febbb86264..f7d2f2370d 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 5d1b8a6c39..2291a26811 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 920eb24924..987465dbb6 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 729707b8e8..9e97319297 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 0448ff2613..48047bccbb 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 4078c1d144..8da9067d2b 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 66b603babd..5de3154b9e 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 e8069b0ec4..92031302e2 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 da5d73cbd7..4a87b0cc0b 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 a44bb31d93..cc9cf0d694 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 0000000000..67c87a182d --- /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 9b61944bb2..0e2b7adf55 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 bf046bee45..4273de01de 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 3bb705ca36..70adc2e5ec 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 fc4ca8cc47..8b7f022785 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 aaddeb3348..13cc18211f 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 6c1ec1926e..719f7adb7a 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 543cece6c0..687845609e 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 bea56f556a..9e92e43bd6 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 98287e9d57..0256b119b3 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 0cee010588..f9560e3da9 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 e41f0b1e4a..a6e1ab5d2e 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 6ecfd4c00b..a2b0111322 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 d546f7ae62..55475a2348 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 dda829b723..1f9ae2e503 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 7053cfce05..358d777e2a 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 bdb4158d0d..c914a4d56c 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 7396c9c5c9..dab48ecc99 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 75/98] 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 0000000000..0aa7146f11 --- /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 5ab0cfe859..57f223ad44 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 100368203b..3bb3a705f8 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 04780bd261..8d75c4721f 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 76/98] 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 0000000000..187152cf0e --- /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 0eb7c1db1d..d4724b6fc2 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 32b1279500..7a0d169f0d 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 78613d33ab..4384ce3483 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 77/98] 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 31170f8cf1..605408777f 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 cff03b9bb6..087831645b 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 78/98] 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 0000000000..d31c53ee5d --- /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 51ccd3fc17..fd5d397f4b 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 23cef0b27d..195860bc39 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 7d27be8e2c..041860896a 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 ff39cb7e6b..cf2598f829 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 534104a20e..928d9008c2 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 94dca02bf9..0229db7b03 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 98d33ee17a..f390653bc5 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 73e091644d..6ceaae4170 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 79/98] 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 | 128 +++++-- 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, 1838 insertions(+), 214 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 0000000000..076b0ef786 --- /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 0fe14bc09e..73e05b7ef1 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 f7e8935554..303886e360 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 e2638aaad8..a45748f7bf 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 2291a26811..30158be01c 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 987465dbb6..8c9127e2eb 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(() => {