From 9468868f3d9e06a65276a8cb5037a93594e9151d Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 17 Jun 2026 22:33:30 +0800 Subject: [PATCH 001/356] docs(changelog): sync 0.17.0 from apps/kimi-code/CHANGELOG.md (#859) --- docs/en/release-notes/changelog.md | 15 +++++++++++++++ docs/zh/release-notes/changelog.md | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 55b339188..c7c9c9368 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,21 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.17.0 (2026-06-17) + +### Features + +- Add Kimi Code Web mode, which you can start with `kimi web` or `/web` in the CLI, and continue sessions in a browser chat interface. + +### Bug Fixes + +- Show the underlying connection error when OAuth token refresh fails after internal retries, instead of prompting for login. Token refresh failures are no longer re-retried at the agent loop level. +- Restore the turn counter from persisted loop events on resume so post-resume turns no longer reuse turn ids that already appear in history. + +### Polish + +- Skip debug TPS when the output stream is too short to measure reliably. + ## 0.16.0 (2026-06-16) ### Features diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index d80fc723d..b30eb2230 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,21 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.17.0(2026-06-17) + +### 新功能 + +- 新增 Kimi Code Web 模式,可通过 `kimi web` 或 CLI 内的 `/web` 启动,在浏览器中的聊天界面继续会话。 + +### 修复 + +- 当 OAuth token 刷新在内部重试后失败时,显示底层连接错误,而不是提示登录。token 刷新失败不再在 agent 循环层级被重新重试。 +- 在恢复会话时从持久化的循环事件中还原轮次计数器,避免恢复后的轮次重复使用历史中已存在的 turn id。 + +### 优化 + +- 当输出流太短而无法可靠测量时,跳过 debug TPS。 + ## 0.16.0(2026-06-16) ### 新功能 From 0e2877bee347466ed6cc8afda9f9faf338069012 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Wed, 17 Jun 2026 23:37:23 +0800 Subject: [PATCH 002/356] fix(server): use execPath for daemon/supervisor re-exec in SEA (#860) * fix(server): use execPath for daemon/supervisor re-exec in SEA Detect SEA via node:sea and re-exec process.execPath instead of resolving argv[1] against cwd, which produced a bogus /kimi and crashed the spawn with ENOENT for the native binary (kimi web). Apply the same fix to both resolveDaemonProgram (kimi web daemon spawner) and resolveSupervisorProgram (launchd/systemd/schtasks), and handle the spawn error event so a launch failure is logged instead of crashing the parent with an unhandled error event. * chore: add changeset for native server start fix * fix(server): run background daemon from its log directory Spawn the detached server child with cwd set to the server log directory instead of inheriting the caller's cwd, so the long-lived daemon does not pin the directory it was launched from (notably blocking its deletion on Windows). --- .changeset/fix-daemon-cwd.md | 5 ++ .changeset/fix-native-server-start.md | 5 ++ apps/kimi-code/src/cli/sub/server/daemon.ts | 68 +++++++++++++++-- apps/kimi-code/test/cli/server/server.test.ts | 73 ++++++++++++++++++- packages/server/src/svc/program.ts | 35 +++++++++ packages/server/test/svc/launchd.test.ts | 14 ++++ 6 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-daemon-cwd.md create mode 100644 .changeset/fix-native-server-start.md diff --git a/.changeset/fix-daemon-cwd.md b/.changeset/fix-daemon-cwd.md new file mode 100644 index 000000000..e438cc9c6 --- /dev/null +++ b/.changeset/fix-daemon-cwd.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stop the background local server from locking the directory it was started in. diff --git a/.changeset/fix-native-server-start.md b/.changeset/fix-native-server-start.md new file mode 100644 index 000000000..961ed38cc --- /dev/null +++ b/.changeset/fix-native-server-start.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the local server failing to start in the background on the native binary. diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index 3e72730c8..1c840d418 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -17,7 +17,8 @@ */ import { spawn } from 'node:child_process'; -import { closeSync, mkdirSync, openSync } from 'node:fs'; +import { appendFileSync, closeSync, mkdirSync, openSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { createServer } from 'node:net'; import { dirname, isAbsolute, join, resolve } from 'node:path'; @@ -127,17 +128,51 @@ export async function resolveDaemonPort( return getFreePort(host); } +interface NodeSeaModule { + isSea(): boolean; +} + +const nodeRequire = createRequire(import.meta.url); +let cachedSea: NodeSeaModule | null | undefined; + +function loadSeaModule(): NodeSeaModule | null { + if (cachedSea !== undefined) return cachedSea; + try { + cachedSea = nodeRequire('node:sea') as NodeSeaModule; + } catch { + cachedSea = null; + } + return cachedSea; +} + +/** True when running as a compiled single-executable (SEA / native) binary. */ +function detectSea(): boolean { + const sea = loadSeaModule(); + if (sea === null) return false; + try { + return sea.isSea(); + } catch { + return false; + } +} + /** * Absolute path to the CLI entry that should be re-execed to run the daemon. * Mirrors `resolveSupervisorProgram` in `packages/server/src/svc/program.ts`: - * when the CLI is a compiled single binary, `argv[1]` is literally `server` - * and we must fall back to `process.execPath`. + * when the CLI is a compiled single binary, `argv[1]` is the invoked command + * name (e.g. `kimi`) or the first user argument — never a script path — so we + * must re-exec `process.execPath` itself. */ -function resolveDaemonProgram( +export function resolveDaemonProgram( argv: readonly string[] = process.argv, cwd: string = process.cwd(), execPath: string = process.execPath, + isSea: boolean = detectSea(), ): string { + // In a SEA binary `argv[1]` is not a script path, so resolving it against + // `cwd` would produce a bogus path (e.g. `/kimi`) and crash the spawn + // with ENOENT. Always re-exec the binary itself. + if (isSea) return execPath; const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath); return isAbsolute(candidate) ? candidate : resolve(cwd, candidate); } @@ -149,10 +184,11 @@ interface SpawnDaemonChildOptions { idleGraceMs?: number; } -function spawnDaemonChild(options: SpawnDaemonChildOptions): void { +export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { const program = resolveDaemonProgram(); const logPath = daemonLogPath(); - mkdirSync(dirname(logPath), { recursive: true }); + const logDir = dirname(logPath); + mkdirSync(logDir, { recursive: true }); const args = [ 'server', 'run', @@ -170,7 +206,25 @@ function spawnDaemonChild(options: SpawnDaemonChildOptions): void { } const logFd = openSync(logPath, 'a'); try { - const child = spawn(program, args, { detached: true, stdio: ['ignore', logFd, logFd] }); + const child = spawn(program, args, { + detached: true, + // Run from the server log directory instead of inheriting the caller's + // cwd, so the long-lived daemon does not pin the directory it was + // launched from (notably blocking its deletion on Windows). + cwd: logDir, + stdio: ['ignore', logFd, logFd], + }); + child.once('error', (error) => { + // A spawn failure (e.g. ENOENT) surfaces asynchronously on the child, + // not as a thrown error. Without a listener Node would crash the parent + // with an unhandled 'error' event; record it instead and let the polling + // loop in `ensureDaemon` report the timeout. + try { + appendFileSync(logPath, `[spawner] failed to launch daemon: ${error.message}\n`); + } catch { + // Best-effort; the log directory may already be gone. + } + }); child.unref(); } finally { // `spawn` dups the fd into the child; the parent must not keep it open. diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index e2a8aab12..80304ef37 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -8,8 +8,11 @@ * Foreground startup behavior is exercised end-to-end in `server-e2e/`. */ -import { readFileSync } from 'node:fs'; +import type { ChildProcess } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { createServer, type Server } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import chalk, { Chalk } from 'chalk'; import { Command } from 'commander'; @@ -20,6 +23,11 @@ import { addLifecycleCommands } from '#/cli/sub/server/lifecycle'; import type { KillCommandDeps } from '#/cli/sub/server/kill'; import { darkColors } from '#/tui/theme/colors'; +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: vi.fn() }; +}); + function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -612,6 +620,69 @@ describe('resolveDaemonPort', () => { }); }); +describe('resolveDaemonProgram', () => { + it('uses the absolute script path outside SEA mode', async () => { + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['node', '/opt/kimi/dist/cli.mjs'], '/tmp', '/usr/bin/node', false)).toBe('/opt/kimi/dist/cli.mjs'); + }); + + it('normalizes a relative executable path against cwd outside SEA mode', async () => { + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['node', './kimi'], '/tmp/kimi-bin', '/usr/bin/node', false)).toBe('/tmp/kimi-bin/kimi'); + }); + + it('returns execPath in SEA mode when argv[1] is a bare command name', async () => { + // Reproduces `kimi web` from the shell: argv[1] is the invoked command + // name (`kimi`), not a path. Resolving it against cwd produced `/kimi` + // and crashed the spawn with ENOENT. + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['/Users/x/.kimi-code/bin/kimi', 'kimi', 'web'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); + }); + + it('returns execPath in SEA mode for a spawned `server` child', async () => { + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['/Users/x/.kimi-code/bin/kimi', 'server', 'run'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); + }); +}); + +describe('spawnDaemonChild', () => { + let workDir: string; + let prevHome: string | undefined; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-cwd-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = workDir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(workDir, { recursive: true, force: true }); + }); + + it('spawns the daemon with cwd set to the server log directory', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild, daemonLogPath } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ port: 58627, logLevel: 'info' }); + + expect(spawnMock).toHaveBeenCalledOnce(); + const [program, args, options] = spawnMock.mock.calls[0]!; + expect(program).toBeTruthy(); + expect(args).toEqual(expect.arrayContaining(['server', 'run', '--daemon'])); + expect(options).toMatchObject({ detached: true, cwd: dirname(daemonLogPath()) }); + expect(options?.cwd).not.toBe(process.cwd()); + }); +}); + describe('createIdleShutdownHandler', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/server/src/svc/program.ts b/packages/server/src/svc/program.ts index 12959397c..1d55f83a2 100644 --- a/packages/server/src/svc/program.ts +++ b/packages/server/src/svc/program.ts @@ -1,10 +1,45 @@ +import { createRequire } from 'node:module'; import { isAbsolute, resolve } from 'node:path'; +interface NodeSeaModule { + isSea(): boolean; +} + +const nodeRequire = createRequire(import.meta.url); +let cachedSea: NodeSeaModule | null | undefined; + +function loadSeaModule(): NodeSeaModule | null { + if (cachedSea !== undefined) return cachedSea; + try { + cachedSea = nodeRequire('node:sea') as NodeSeaModule; + } catch { + cachedSea = null; + } + return cachedSea; +} + +/** True when running as a compiled single-executable (SEA / native) binary. */ +function detectSea(): boolean { + const sea = loadSeaModule(); + if (sea === null) return false; + try { + return sea.isSea(); + } catch { + return false; + } +} + export function resolveSupervisorProgram( argv: readonly string[] = process.argv, cwd: string = process.cwd(), execPath: string = process.execPath, + isSea: boolean = detectSea(), ): string { + // In a SEA binary `argv[1]` is the invoked command name (e.g. `kimi`) or the + // first user argument — never a script path — so the re-exec target is always + // the binary itself. Resolving it against `cwd` would produce a bogus path + // (e.g. `/kimi`) and crash the spawn with ENOENT. + if (isSea) return execPath; const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath); return isAbsolute(candidate) ? candidate : resolve(cwd, candidate); } diff --git a/packages/server/test/svc/launchd.test.ts b/packages/server/test/svc/launchd.test.ts index d44291a21..cce6ed29f 100644 --- a/packages/server/test/svc/launchd.test.ts +++ b/packages/server/test/svc/launchd.test.ts @@ -140,6 +140,20 @@ describe('resolveSupervisorProgram', () => { it('normalizes a relative executable path to an absolute path', () => { expect(resolveSupervisorProgram(['node', './kimi'], '/tmp/kimi-bin')).toBe('/tmp/kimi-bin/kimi'); }); + + it('uses the absolute script path outside SEA mode', () => { + expect(resolveSupervisorProgram(['node', '/opt/kimi/dist/cli.mjs'], '/tmp', '/usr/bin/node', false)).toBe('/opt/kimi/dist/cli.mjs'); + }); + + it('returns execPath in SEA mode even when argv[1] is a bare command name', () => { + // Reproduces `kimi web` from the shell: argv[1] is the invoked command + // name, not a path — resolving it against cwd produced `/kimi` (ENOENT). + expect(resolveSupervisorProgram(['/Users/x/.kimi-code/bin/kimi', 'kimi', 'web'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); + }); + + it('returns execPath in SEA mode for a spawned `server` child', () => { + expect(resolveSupervisorProgram(['/Users/x/.kimi-code/bin/kimi', 'server', 'run'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); + }); }); describe('launchd manager — install', () => { From bd0979578bcad5fe3bf989e022b7823824f3f25c Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 17 Jun 2026 23:48:23 +0800 Subject: [PATCH 003/356] feat(web): group default model dropdown by provider in settings (#861) * feat(web): group default model dropdown by provider in settings * fix(web): prevent login dialog closing on backdrop click --- .changeset/disable-login-backdrop-close.md | 5 ++ .../group-settings-model-by-provider.md | 5 ++ apps/kimi-web/src/components/LoginDialog.vue | 2 +- .../src/components/SettingsDialog.vue | 58 ++++++++++++++----- apps/kimi-web/test/settings-dialog.test.ts | 18 ++++++ 5 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 .changeset/disable-login-backdrop-close.md create mode 100644 .changeset/group-settings-model-by-provider.md diff --git a/.changeset/disable-login-backdrop-close.md b/.changeset/disable-login-backdrop-close.md new file mode 100644 index 000000000..599e5f666 --- /dev/null +++ b/.changeset/disable-login-backdrop-close.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Prevent the web login dialog from closing when clicking the backdrop. diff --git a/.changeset/group-settings-model-by-provider.md b/.changeset/group-settings-model-by-provider.md new file mode 100644 index 000000000..569cdc57e --- /dev/null +++ b/.changeset/group-settings-model-by-provider.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Group the default model dropdown in web settings by provider. diff --git a/apps/kimi-web/src/components/LoginDialog.vue b/apps/kimi-web/src/components/LoginDialog.vue index 5d35a27d1..71a50bee2 100644 --- a/apps/kimi-web/src/components/LoginDialog.vue +++ b/apps/kimi-web/src/components/LoginDialog.vue @@ -197,7 +197,7 @@ function formatSeconds(s: number): string { @@ -420,72 +411,96 @@ function formatSeconds(s: number): string { } /* Device-code body */ -.dc-body { - padding: 16px 16px 8px; +.nb { + padding: 18px 16px 14px; display: flex; flex-direction: column; gap: 14px; } -.dc-instruction { +.nb-lead { font-size: var(--ui-font-size); color: var(--text); line-height: 1.6; } -.dc-uri-row { display: flex; } -.dc-uri-btn { - display: inline-flex; + +/* Primary path: open the complete URI (device code embedded) */ +.nb-primary { + display: flex; align-items: center; - gap: 6px; + justify-content: center; + gap: 8px; + width: 100%; + background: var(--blue); + color: var(--bg); + border: 1px solid var(--blue); + border-radius: 5px; font-family: var(--mono); font-size: var(--ui-font-size); - color: var(--blue); - background: var(--soft); - border: 1px solid var(--bd); - border-radius: 3px; - padding: 5px 10px; + font-weight: 600; + padding: 11px 14px; cursor: pointer; text-decoration: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; } -.dc-uri-btn:hover { background: var(--bd); } -.dc-link-icon { flex: none; } +.nb-primary:hover { background: var(--blue2); border-color: var(--blue2); } -.dc-code-wrap { - border: 1px solid var(--line); - border-radius: 4px; - background: var(--panel); - padding: 10px 12px; -} -.dc-code-label { - font-size: max(9px, calc(var(--ui-font-size) - 4px)); +/* "or" divider */ +.nb-or { + display: flex; + align-items: center; + gap: 10px; color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 6px; + font-size: max(9px, calc(var(--ui-font-size) - 2.5px)); + letter-spacing: 0.06em; } -.dc-code-row { +.nb-or::before, +.nb-or::after { + content: ""; + flex: 1; + height: 1px; + background: var(--line); +} + +/* Fallback path: open plain URI, type the code */ +.nb-fallback { + display: flex; + flex-direction: column; + gap: 9px; +} +.nb-fb-text { + font-size: calc(var(--ui-font-size) - 1.5px); + color: var(--dim); + line-height: 1.6; +} +.nb-fb-link { + color: var(--blue); + text-decoration: none; + border-bottom: 1px solid var(--bd); +} +.nb-fb-link:hover { border-bottom-color: var(--blue); } +.nb-code-row { display: flex; align-items: center; gap: 12px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 4px; + padding: 9px 11px; } -.dc-code-value { - font-size: calc(var(--ui-font-size) + 8px); - font-weight: 700; - color: var(--ink); - letter-spacing: 0.12em; +.nb-code { flex: 1; font-family: var(--mono); + font-size: calc(var(--ui-font-size) + 5px); + font-weight: 700; + color: var(--ink); + letter-spacing: 0.14em; } -.dc-copy-btn { +.nb-copy { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: calc(var(--ui-font-size) - 2.5px); - padding: 4px 10px; + padding: 5px 11px; border: 1px solid var(--line); border-radius: 3px; background: none; @@ -494,19 +509,20 @@ function formatSeconds(s: number): string { flex: none; transition: background 0.1s; } -.dc-copy-btn:hover { background: var(--soft); } -.dc-copy-btn.is-copied { color: var(--ok); border-color: var(--ok); } +.nb-copy:hover { background: var(--soft); } +.nb-copy.is-copied { color: var(--ok); border-color: var(--ok); } -.dc-status-row { +/* Status */ +.nb-status { display: flex; align-items: center; gap: 8px; - padding: 8px 0; + padding-top: 11px; border-top: 1px solid var(--line2); } -.dc-spinner { display: flex; align-items: center; } -.dc-status-text { font-size: var(--ui-font-size); color: var(--dim); flex: 1; } -.dc-countdown { +.nb-spinner { display: flex; align-items: center; } +.nb-status-text { font-size: calc(var(--ui-font-size) - 1.5px); color: var(--dim); flex: 1; } +.nb-countdown { font-size: calc(var(--ui-font-size) - 2.5px); color: var(--muted); font-variant-numeric: tabular-nums; @@ -536,16 +552,6 @@ function formatSeconds(s: number): string { } .act-btn.primary:hover { background: var(--blue2); } -/* Footer */ -.footer-hint { - padding: 6px 14px; - font-size: max(9px, calc(var(--ui-font-size) - 3.5px)); - color: var(--faint); - border-top: 1px solid var(--line2); - background: var(--panel); - border-radius: 0 0 4px 4px; -} - @media (max-width: 640px) { .backdrop { align-items: stretch; @@ -563,24 +569,27 @@ function formatSeconds(s: number): string { overflow: hidden; } .center-body, - .dc-body { + .nb { overflow-y: auto; -webkit-overflow-scrolling: touch; } - .dc-code-row, - .dc-status-row, + .nb-code-row, + .nb-status, .actions { flex-wrap: wrap; } - .dc-code-value { + .nb-code { min-width: 0; overflow-wrap: anywhere; letter-spacing: 0.08em; } - .dc-copy-btn { + .nb-copy { min-height: 34px; } - .dc-status-text { + .nb-primary { + min-height: 44px; + } + .nb-status-text { min-width: 0; } .actions { diff --git a/apps/kimi-web/src/i18n/locales/en/commands.ts b/apps/kimi-web/src/i18n/locales/en/commands.ts index 796452963..1027cb50c 100644 --- a/apps/kimi-web/src/i18n/locales/en/commands.ts +++ b/apps/kimi-web/src/i18n/locales/en/commands.ts @@ -5,7 +5,7 @@ export default { clear: { desc: 'Clear and start a new session' }, model: { desc: 'Switch model' }, provider: { desc: 'Manage providers (add / remove / refresh)' }, - login: { desc: 'Sign in to the platform with an API key' }, + login: { desc: 'Sign in to Kimi in the browser' }, permission: { desc: 'Switch approval mode (manual / auto / yolo)' }, plan: { desc: 'Toggle plan mode on/off' }, swarm: { desc: 'Toggle swarm mode; /swarm runs a task in swarm' }, diff --git a/apps/kimi-web/src/i18n/locales/en/login.ts b/apps/kimi-web/src/i18n/locales/en/login.ts index f0520a555..3be4f32fd 100644 --- a/apps/kimi-web/src/i18n/locales/en/login.ts +++ b/apps/kimi-web/src/i18n/locales/en/login.ts @@ -2,22 +2,21 @@ export default { title: 'Sign in to Kimi Code', close: 'Close (Esc)', starting: 'Starting authorization flow…', - instruction: 'Open the link below in your browser and enter the device code to authorize:', - deviceCode: 'Device code', + lead: 'Click the button below to authorize in a new browser tab.', + authorizeInBrowser: 'Authorize in browser', + orDivider: 'or', + fallbackPrefix: 'On another device? Open ', + fallbackSuffix: ' and enter the device code:', copy: 'Copy', copied: 'Copied', waitingAuth: 'Waiting for authorization', - waitingAuthEllipsis: 'Waiting for authorization…', - openBrowser: 'Open browser', - cancel: 'Cancel', - footerHint: 'This dialog will close automatically once authorization completes · Esc to close', + waitingAutoClose: 'Waiting for authorization, signs in automatically…', success: 'Authorized', successHint: 'Loading, will close automatically…', expiredTitle: 'Authorization code expired', expiredHint: 'Please restart the authorization flow', retry: 'Retry', closeBtn: 'Close', - escClose: 'Esc to close', errorTitle: 'The current daemon does not support login yet', errorHint: 'Please upgrade kimi-code and try again', } as const; diff --git a/apps/kimi-web/src/i18n/locales/zh/commands.ts b/apps/kimi-web/src/i18n/locales/zh/commands.ts index d2a6513e9..9f0b6e323 100644 --- a/apps/kimi-web/src/i18n/locales/zh/commands.ts +++ b/apps/kimi-web/src/i18n/locales/zh/commands.ts @@ -5,7 +5,7 @@ export default { clear: { desc: '清空并新建会话' }, model: { desc: '切换模型' }, provider: { desc: '管理提供商 (添加/删除/刷新)' }, - login: { desc: '通过 API Key 登录平台' }, + login: { desc: '在浏览器中登录 Kimi' }, permission: { desc: '切换审批模式 (manual/auto/yolo)' }, plan: { desc: '切换计划模式 开/关' }, swarm: { desc: '切换 swarm 模式;/swarm <任务> 直接在 swarm 下执行' }, diff --git a/apps/kimi-web/src/i18n/locales/zh/login.ts b/apps/kimi-web/src/i18n/locales/zh/login.ts index f03e17f45..041550c62 100644 --- a/apps/kimi-web/src/i18n/locales/zh/login.ts +++ b/apps/kimi-web/src/i18n/locales/zh/login.ts @@ -2,22 +2,21 @@ export default { title: '登录 Kimi Code', close: '关闭 (Esc)', starting: '正在启动授权流程…', - instruction: '在浏览器中打开以下链接,输入设备码完成授权:', - deviceCode: '设备码', + lead: '点击下方按钮,在新标签页中完成授权。', + authorizeInBrowser: '在浏览器中授权', + orDivider: '或者', + fallbackPrefix: '换个设备?在浏览器打开 ', + fallbackSuffix: ' 输入设备码:', copy: '复制', copied: '已复制', waitingAuth: '等待授权', - waitingAuthEllipsis: '等待授权…', - openBrowser: '打开浏览器', - cancel: '取消', - footerHint: '授权完成后对话框将自动关闭 · Esc 关闭', + waitingAutoClose: '等待授权,完成后自动登录…', success: '已授权', successHint: '正在加载,稍后自动关闭…', expiredTitle: '授权码已过期', expiredHint: '请重新开始授权流程', retry: '重试', closeBtn: '关闭', - escClose: 'Esc 关闭', errorTitle: '当前 daemon 暂不支持登录', errorHint: '请升级 kimi-code 后重试', } as const; From a74a6b7f6b1d13d24eae356a2208c012128b180d Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 18 Jun 2026 14:30:29 +0800 Subject: [PATCH 009/356] fix(web): improve slash menu and skill editing experience (#878) * fix(web): keep slash skills editable * fix(web): wrap long slash menu entries * fix(web): rank exact slash matches before substrings --- .changeset/web-slash-menu-wrap.md | 5 ++++ .changeset/web-slash-skill-ux.md | 5 ++++ apps/kimi-web/src/components/SlashMenu.vue | 20 ++++++++++++--- apps/kimi-web/src/lib/slashCommands.ts | 30 +++++++++++++++++----- apps/kimi-web/test/composer.test.ts | 13 ++++++++++ apps/kimi-web/test/slash-skills.test.ts | 24 +++++++++++++++++ 6 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 .changeset/web-slash-menu-wrap.md create mode 100644 .changeset/web-slash-skill-ux.md diff --git a/.changeset/web-slash-menu-wrap.md b/.changeset/web-slash-menu-wrap.md new file mode 100644 index 000000000..dcefc0e3b --- /dev/null +++ b/.changeset/web-slash-menu-wrap.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow long web slash command names and descriptions to wrap without overflowing the slash menu. diff --git a/.changeset/web-slash-skill-ux.md b/.changeset/web-slash-skill-ux.md new file mode 100644 index 000000000..364dbf3be --- /dev/null +++ b/.changeset/web-slash-skill-ux.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix web slash skill selection sending immediately and allow slash search to match skill names by substring. diff --git a/apps/kimi-web/src/components/SlashMenu.vue b/apps/kimi-web/src/components/SlashMenu.vue index 77357c4f0..556dbe7db 100644 --- a/apps/kimi-web/src/components/SlashMenu.vue +++ b/apps/kimi-web/src/components/SlashMenu.vue @@ -51,8 +51,9 @@ const emit = defineEmits<{ } .slash-item { - display: flex; - align-items: baseline; + display: grid; + grid-template-columns: minmax(90px, 32%) minmax(0, 1fr); + align-items: start; gap: 10px; padding: 5px 12px; cursor: pointer; @@ -73,12 +74,23 @@ const emit = defineEmits<{ .slash-name { color: var(--blue); font-weight: 600; - min-width: 90px; - flex-shrink: 0; + min-width: 0; + line-height: 1.45; + overflow-wrap: anywhere; } .slash-desc { color: var(--dim); font-size: calc(var(--ui-font-size) - 2.5px); + min-width: 0; + line-height: 1.45; + overflow-wrap: anywhere; +} + +@media (max-width: 520px) { + .slash-item { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } } diff --git a/apps/kimi-web/src/lib/slashCommands.ts b/apps/kimi-web/src/lib/slashCommands.ts index 609db59c8..b9fa53d52 100644 --- a/apps/kimi-web/src/lib/slashCommands.ts +++ b/apps/kimi-web/src/lib/slashCommands.ts @@ -79,20 +79,38 @@ export function buildSlashItems( name: `/${s.name}`, desc: s.description, isSkill: true, + // Keep the selected skill in the composer so arguments can be appended. + acceptsInput: true, })); return [...SLASH_COMMANDS, ...skillItems]; } /** - * Filter slash items by a query string (case-insensitive substring on the name). - * If query is empty or just "/", returns all items. Defaults to the built-in - * commands; pass a merged list (see buildSlashItems) to include skills. + * Filter slash items by a query string. Matches are ranked so exact and prefix + * matches come before arbitrary substring matches. If query is empty or just + * "/", returns all items. Defaults to the built-in commands; pass a merged list + * (see buildSlashItems) to include skills. */ export function filterCommands( query: string, items: SlashCommand[] = SLASH_COMMANDS, ): SlashCommand[] { - const q = query.toLowerCase().trim(); - if (q === '' || q === '/') return items; - return items.filter((c) => c.name.toLowerCase().includes(q)); + const q = query.toLowerCase().trim().replace(/^\//, ''); + if (q === '') return items; + + return items + .map((item, index) => { + const name = item.name.toLowerCase().replace(/^\//, ''); + let score = 0; + if (name === q) score = 3; + else if (name.startsWith(q)) score = 2; + else if (name.includes(q)) score = 1; + return { item, index, score }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + return a.index - b.index; + }) + .map(({ item }) => item); } diff --git a/apps/kimi-web/test/composer.test.ts b/apps/kimi-web/test/composer.test.ts index 1291c8422..1daae458f 100644 --- a/apps/kimi-web/test/composer.test.ts +++ b/apps/kimi-web/test/composer.test.ts @@ -280,6 +280,19 @@ describe('Composer slash command input', () => { expect((textarea.element as HTMLTextAreaElement).value).toBe('/goal '); expect(wrapper.emitted('command')).toBeUndefined(); }); + + it('keeps selected session skills in the composer so arguments can be added', async () => { + const wrapper = mountComposer({ + skills: [{ name: 'my-skill', description: 'Do a thing', source: 'project' }], + }); + const textarea = wrapper.get('textarea'); + + await textarea.setValue('/my'); + await textarea.trigger('keydown', { key: 'Enter' }); + + expect((textarea.element as HTMLTextAreaElement).value).toBe('/my-skill '); + expect(wrapper.emitted('command')).toBeUndefined(); + }); }); describe('Composer model dropdown', () => { diff --git a/apps/kimi-web/test/slash-skills.test.ts b/apps/kimi-web/test/slash-skills.test.ts index 6b76a20dd..bbb880506 100644 --- a/apps/kimi-web/test/slash-skills.test.ts +++ b/apps/kimi-web/test/slash-skills.test.ts @@ -4,6 +4,7 @@ import { SLASH_COMMANDS, buildSlashItems, filterCommands } from '../src/lib/slas const skills = [ { name: 'brainstorm', description: 'Turn an idea into a design' }, { name: 'deep-research', description: 'Fan-out web research' }, + { name: 'xxx-context', description: 'Manage context' }, ]; describe('slash menu with session skills', () => { @@ -40,4 +41,27 @@ describe('slash menu with session skills', () => { const items = buildSlashItems(skills); expect(filterCommands('/', items).length).toBe(items.length); }); + + it('flags session skills as accepting input so they stay in the composer', () => { + const items = buildSlashItems(skills); + const brainstorm = items.find((i) => i.name === '/brainstorm'); + expect(brainstorm?.acceptsInput).toBe(true); + }); + + it('matches substrings anywhere in the command name, not only as a prefix', () => { + const items = buildSlashItems(skills); + expect(filterCommands('/context', items).map((i) => i.name)).toEqual([ + '/xxx-context', + ]); + expect(filterCommands('/research', items).map((i) => i.name)).toEqual([ + '/deep-research', + ]); + }); + + it('ranks exact and prefix matches ahead of substring matches', () => { + const items = buildSlashItems([{ name: 'log', description: 'Write a log' }]); + const names = filterCommands('/log', items).map((i) => i.name); + expect(names[0]).toBe('/log'); + expect(names).toContain('/login'); + }); }); From 584d9975303909fc8410dd269b2fc97426d5954e Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 18 Jun 2026 14:39:24 +0800 Subject: [PATCH 010/356] feat(server): report host kimi-code CLI version in /meta (#879) - prefer coreProcessOptions.identity.version in start.ts for /meta and the OpenAPI/AsyncAPI docs - fall back to getServerVersion() when the server runs standalone without a host identity - update meta.ts and version.ts docs to describe the new version source - add e2e test verifying server_version reports the host identity version --- packages/server/src/routes/meta.ts | 5 ----- packages/server/src/start.ts | 2 +- packages/server/src/version.ts | 9 --------- packages/server/test/meta.e2e.test.ts | 18 ++++++++++++++++++ 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/server/src/routes/meta.ts b/packages/server/src/routes/meta.ts index b3f6c47ad..4849a76ac 100644 --- a/packages/server/src/routes/meta.ts +++ b/packages/server/src/routes/meta.ts @@ -13,10 +13,6 @@ * **Wire shape**: matches `metaResponseSchema` (REST.md §3.1) exactly. The * envelope wrap is `okEnvelope(data, req.id)` — `req.id` is the bare 26-char * ULID set by Fastify's `genReqId` via `resolveRequestId`. - * - * **Anti-corruption**: no SDK package import, no broker / bridge access. The - * version source is the server's own `package.json` read via - * `getServerVersion()` — no indirection through services or agent-core. */ import { metaResponseSchema } from '@moonshot-ai/protocol'; @@ -45,7 +41,6 @@ interface RouteHost { } export interface MetaRouteOptions { - /** Server `package.json` version. Cached at startup. */ readonly serverVersion: string; /** Per-process ULID. Minted once at boot in `start.ts`. */ readonly serverId: string; diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index dcd11349d..43f3b0ec3 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -87,7 +87,7 @@ export async function startServer(opts: ServerStartOptions): Promise (data) => JSON.stringify(data)); installErrorHandler(app); - const serverVersion = getServerVersion(); + const serverVersion = opts.coreProcessOptions?.identity?.version ?? getServerVersion(); async function registerOpenApi(): Promise { const { default: swagger } = await import('@fastify/swagger'); diff --git a/packages/server/src/version.ts b/packages/server/src/version.ts index 7f4af4ece..6395495be 100644 --- a/packages/server/src/version.ts +++ b/packages/server/src/version.ts @@ -1,12 +1,3 @@ -/** - * Read the server's own `package.json` version at boot. Mirrors - * `packages/agent-core/src/version.ts:4-13` so the server's `/meta` can - * report a real version without taking an SDK / agent-core export dep. - * - * Tries the bundled-at-build `package.json` next to `dist/` first; falls back - * to `0.0.0` if the file is unreachable (e.g. tree-shaken bundle). - */ - import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; diff --git a/packages/server/test/meta.e2e.test.ts b/packages/server/test/meta.e2e.test.ts index 364cc96f7..6a4039be5 100644 --- a/packages/server/test/meta.e2e.test.ts +++ b/packages/server/test/meta.e2e.test.ts @@ -165,6 +165,24 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => { }); }); +describe('GET /api/v1/meta — server_version precedence', () => { + it('reports the host kimi-code identity version when provided', async () => { + server = await startServer({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { + homeDir: bridgeHome, + identity: { userAgentProduct: 'kimi-code-cli', version: '9.9.9-test' }, + }, + }); + const res = await appOf(server).inject({ method: 'GET', url: '/api/v1/meta' }); + const data = (res.json() as { data: { server_version: string } }).data; + expect(data.server_version).toBe('9.9.9-test'); + }); +}); + describe('GET /api/v1/meta — request_id propagation (W4.3 contract)', () => { it('echoes a client-supplied valid ULID verbatim', async () => { const r = await bootDaemon(); From 7bc3d99933b0bbc3f9188a2b02bcc90e81623f72 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 18 Jun 2026 14:46:38 +0800 Subject: [PATCH 011/356] fix(web): keep highlighted slash command visible in long menus (#881) * fix(web): scroll active slash item into view and stabilize item keys * fix(web): use function ref for slash menu items to preserve order * chore(changeset): add changeset for web slash menu scroll polish --- .changeset/web-slash-menu-scroll.md | 5 +++++ apps/kimi-web/src/components/SlashMenu.vue | 13 ++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/web-slash-menu-scroll.md diff --git a/.changeset/web-slash-menu-scroll.md b/.changeset/web-slash-menu-scroll.md new file mode 100644 index 000000000..273630430 --- /dev/null +++ b/.changeset/web-slash-menu-scroll.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep the highlighted web slash command visible while navigating a long slash menu. diff --git a/apps/kimi-web/src/components/SlashMenu.vue b/apps/kimi-web/src/components/SlashMenu.vue index 556dbe7db..9925cff35 100644 --- a/apps/kimi-web/src/components/SlashMenu.vue +++ b/apps/kimi-web/src/components/SlashMenu.vue @@ -1,6 +1,7 @@ @@ -308,6 +318,10 @@ function onLogout(): void { font-weight: 600; color: var(--blue2); } +.srow-val.dim { + font-weight: 400; + color: var(--muted); +} /* Chevron (prototype ›) — fixed icon glyph size, not part of UI font scale. */ .chev { diff --git a/apps/kimi-web/src/components/SettingsDialog.vue b/apps/kimi-web/src/components/SettingsDialog.vue index 6ca9faab1..37ab6fdd4 100644 --- a/apps/kimi-web/src/components/SettingsDialog.vue +++ b/apps/kimi-web/src/components/SettingsDialog.vue @@ -32,6 +32,8 @@ const props = defineProps<{ models?: AppModel[]; /** True while POST /api/v1/config is saving. */ configSaving?: boolean; + /** Server version reported by GET /api/v1/meta. */ + serverVersion?: string; }>(); const emit = defineEmits<{ @@ -278,6 +280,14 @@ function setTab(tab: SettingsTab): void { + +
+

{{ t('settings.build') }}

+
+ {{ t('settings.serverVersion') }} + {{ serverVersion || '-' }} +
+
diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index a2bcd44f7..1d9e22b7f 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -2109,6 +2109,7 @@ const connection = computed(() => rawState.connection); const loading = computed(() => rawState.loading); const sessionLoading = computed(() => rawState.sessionLoading); +const serverVersion = computed(() => rawState.serverVersion); const permission = computed(() => rawState.permission); const thinking = computed(() => rawState.thinking); @@ -4271,6 +4272,7 @@ export function useKimiWebClient() { connection, loading, sessionLoading, + serverVersion, initialized, permission, thinking, diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts index 3d1c7cf1d..e0daba67d 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -37,6 +37,7 @@ export default { configUnavailable: 'The server did not return config yet. These settings are unavailable.', advanced: 'Advanced', build: 'Build', + serverVersion: 'Server version', exportLog: 'Troubleshooting log', logHint: 'Enable with ?debug=1 to capture', exportLogBtn: 'Export log', diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts index 23dc44baa..ef0e8a8df 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -37,6 +37,7 @@ export default { configUnavailable: '当前服务端没有返回 config,设置项暂不可用。', advanced: '高级', build: '构建', + serverVersion: '服务端版本', exportLog: '故障排查日志', logHint: '加 ?debug=1 开启采集', exportLogBtn: '导出日志', diff --git a/apps/kimi-web/test/settings-dialog.test.ts b/apps/kimi-web/test/settings-dialog.test.ts index e2499b344..6819f5900 100644 --- a/apps/kimi-web/test/settings-dialog.test.ts +++ b/apps/kimi-web/test/settings-dialog.test.ts @@ -93,6 +93,7 @@ function mountDialog() { config, models, configSaving: false, + serverVersion: '1.2.3', }, global: { plugins: [i18n], @@ -180,6 +181,14 @@ describe('SettingsDialog config controls', () => { const openaiOptions = groups[1]!.findAll('option'); expect(openaiOptions.some((o) => o.attributes('value') === 'openai/gpt-5')).toBe(true); }); + + it('renders server version on the General tab', () => { + const wrapper = mountDialog(); + + // General is the default active tab. + expect(wrapper.text()).toContain('Server version'); + expect(wrapper.text()).toContain('1.2.3'); + }); }); describe('SettingsDialog dialog focus', () => { @@ -202,6 +211,7 @@ describe('SettingsDialog dialog focus', () => { config, models, configSaving: false, + serverVersion: '1.2.3', }, global: { plugins: [i18n], stubs: { LanguageSwitcher: true } }, attachTo: document.body, From 8ab9e969637ffee18b09a0b265ffa860c5a2e11c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 18 Jun 2026 17:21:50 +0800 Subject: [PATCH 014/356] fix(web): paginate session list on load (#882) * fix(web): list sessions per workspace with pagination * fix(web): use global paginated session list instead of per-workspace --- .changeset/web-sessions-pagination.md | 5 + .../src/composables/useKimiWebClient.ts | 38 +++- .../useKimiWebClient-session-list.test.ts | 176 ++++++++++++++++++ 3 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 .changeset/web-sessions-pagination.md create mode 100644 apps/kimi-web/test/useKimiWebClient-session-list.test.ts diff --git a/.changeset/web-sessions-pagination.md b/.changeset/web-sessions-pagination.md new file mode 100644 index 000000000..95b2d765d --- /dev/null +++ b/.changeset/web-sessions-pagination.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web app only loading the 20 most recent sessions; it now follows pagination so older sessions are reachable. diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 1d9e22b7f..2468607d1 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -2668,18 +2668,37 @@ async function updateConfig(patch: Partial): Promise { // global connecting-splash so a page refresh doesn't flash a half-empty app. const initialized = ref(false); +// Backend max page size for GET /sessions. Bigger pages mean fewer round-trips +// when draining the full session list. +const SESSION_PAGE_SIZE = 100; + +/** Drain every page of sessions, newest first. A single global walk (instead of + * per-workspace) so sessions whose cwd is not a registered workspace root are + * still reachable after a refresh. */ +async function listAllSessionsGlobal(): Promise { + const api = getKimiWebApi(); + const items: AppSession[] = []; + let beforeId: string | undefined; + for (;;) { + const page = await api.listSessions({ pageSize: SESSION_PAGE_SIZE, beforeId }); + items.push(...page.items); + if (!page.hasMore || page.items.length === 0) break; + beforeId = page.items[page.items.length - 1]!.id; + } + return items; +} + async function load(): Promise { rawState.loading = true; try { const api = getKimiWebApi(); - // Parallel: health + meta + sessions + models - const [, , sessionsPage] = await Promise.all([ + // Parallel: health + meta + models + await Promise.all([ api.getHealth().catch(() => null), api.getMeta().then((m) => { rawState.serverVersion = m.serverVersion; rawState.availableOpenInApps = m.openInApps; }).catch(() => null), - api.listSessions({ pageSize: 20 }).catch(() => ({ items: [], hasMore: false })), loadModels(), ]); @@ -2687,14 +2706,17 @@ async function load(): Promise { await checkAuth(); await loadConfig(); - rawState.sessions = sessionsPage.items; + // Drain every session via a single global walk so sessions whose cwd is not + // a registered workspace root are still reachable after a refresh. + const sessions = await listAllSessionsGlobal().catch(() => [] as AppSession[]); + rawState.sessions = sessions; // Load workspaces (real if available, else derived from session cwds). await loadWorkspaces(); // First load: pick the workspace of the most-recent session, unless the // user already has a persisted active workspace that still exists. - const mostRecent = sessionsPage.items[0]; + const mostRecent = sessions[0]; const persisted = rawState.activeWorkspaceId; const persistedStillExists = persisted !== null && mergedWorkspaces.value.some((w) => w.id === persisted); @@ -2703,7 +2725,7 @@ async function load(): Promise { } // URL deep link (/sessions/) takes priority over auto-select. The - // session may live beyond the first listSessions page — fetch it then. + // session may live outside the loaded pages (e.g. archived) — fetch it then. // selectSession syncs the active workspace off the (now present) entry. bindSessionRoute(); const urlSessionId = @@ -2719,8 +2741,8 @@ async function load(): Promise { // Auto-select first session if none selected (also the fallback for a dead // deep link — 'replace' rewrites the URL to the session actually shown). - if (!rawState.activeSessionId && sessionsPage.items.length > 0) { - await selectSession(sessionsPage.items[0]!.id, { urlMode: 'replace' }); + if (!rawState.activeSessionId && sessions.length > 0) { + await selectSession(sessions[0]!.id, { urlMode: 'replace' }); } } catch (err) { pushOperationFailure('load', err); diff --git a/apps/kimi-web/test/useKimiWebClient-session-list.test.ts b/apps/kimi-web/test/useKimiWebClient-session-list.test.ts new file mode 100644 index 000000000..908389b78 --- /dev/null +++ b/apps/kimi-web/test/useKimiWebClient-session-list.test.ts @@ -0,0 +1,176 @@ +// apps/kimi-web/test/useKimiWebClient-session-list.test.ts +// +// load() must drain every session page (not just the first), so a session +// beyond the initial page is still reachable from the sidebar. A single global +// walk is used so sessions whose cwd is not a registered workspace root are +// included too. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + AppSession, + KimiEventHandlers, + KimiWebApi, + Page, +} from '../src/api/types'; + +const t0 = '2026-06-11T00:00:00.000Z'; + +function session(id: string, overrides?: Partial): AppSession { + return { + id, + title: id, + createdAt: t0, + updatedAt: t0, + status: 'idle', + archived: false, + cwd: '/repo', + model: 'kimi-test', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 128_000, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + ...overrides, + }; +} + +interface SetupOpts { + pages: Array>; + listWorkspaces?: () => Promise; + listSessionsError?: Error; +} + +async function setup(opts: SetupOpts) { + vi.resetModules(); + vi.stubGlobal('WebSocket', class WebSocket {}); + window.history.replaceState(null, '', '/'); + + let handlers: KimiEventHandlers | undefined; + const eventConn = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + bindNextPromptId: vi.fn(), + seedSnapshot: vi.fn(), + abort: vi.fn(), + close: vi.fn(), + }; + + let cursor = 0; + const listSessions = vi.fn( + async (_input?: { workspaceId?: string; beforeId?: string; pageSize?: number }) => { + if (opts.listSessionsError) throw opts.listSessionsError; + const page = opts.pages[cursor] ?? { items: [], hasMore: false }; + cursor += 1; + return page; + }, + ); + + const api = { + getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })), + getMeta: vi.fn(async () => ({ daemonVersion: 't', serverId: 's', startedAt: t0, capabilities: {} })), + getAuth: vi.fn(async () => ({ ready: true, defaultModel: 'kimi-test', managedProvider: null })), + listModels: vi.fn(async () => []), + listWorkspaces: vi.fn(opts.listWorkspaces ?? (async () => [])), + getFsHome: vi.fn(async () => ({ home: '/home', recentRoots: [] })), + listSessions, + getSession: vi.fn(async (id: string) => session(id)), + getSessionSnapshot: vi.fn(async (id: string) => ({ + asOfSeq: 0, + epoch: 'ep_test', + session: session(id), + messages: [], + hasMoreMessages: false, + inFlightTurn: null, + pendingApprovals: [], + pendingQuestions: [], + })), + listTasks: vi.fn(async () => []), + getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })), + getSessionStatus: vi.fn(async () => ({ + model: 'kimi-test', + thinkingLevel: 'high', + permission: 'manual', + planMode: false, + swarmMode: false, + contextTokens: 0, + maxContextTokens: 128_000, + contextUsage: 0, + })), + connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => { + handlers = nextHandlers; + return eventConn; + }), + getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`), + } as unknown as KimiWebApi; + + vi.doMock('../src/api', () => ({ getKimiWebApi: () => api })); + const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); + + return { + api, + client: useKimiWebClient(), + getHandlers: () => { + if (!handlers) throw new Error('connectEvents was not called'); + return handlers; + }, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + vi.clearAllMocks(); + localStorage.clear(); + window.history.replaceState(null, '', '/'); +}); + +describe('load() session listing', () => { + it('drains every page using the beforeId cursor', async () => { + const { api, client } = await setup({ + pages: [ + { items: [session('sess_1'), session('sess_2')], hasMore: true }, + { items: [session('sess_3')], hasMore: false }, + ], + }); + + await client.load(); + + const calls = (api.listSessions as ReturnType).mock.calls; + expect(calls).toHaveLength(2); + expect(calls[0]![0]).toMatchObject({ beforeId: undefined }); + expect(calls[1]![0]).toMatchObject({ beforeId: 'sess_2' }); + + const ids = client.sessions.value.map((s) => s.id).sort(); + expect(ids).toEqual(['sess_1', 'sess_2', 'sess_3']); + }); + + it('never passes a workspaceId so unregistered-cwd sessions are included', async () => { + const { api, client } = await setup({ + pages: [{ items: [session('sess_orphan', { cwd: '/tmp/scratch' })], hasMore: false }], + }); + + await client.load(); + + const calls = (api.listSessions as ReturnType).mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0]![0]?.workspaceId).toBeUndefined(); + expect(client.sessions.value.map((s) => s.id)).toEqual(['sess_orphan']); + }); + + it('surfaces an empty list when listSessions rejects', async () => { + const { client } = await setup({ + pages: [], + listSessionsError: new Error('boom'), + }); + + await expect(client.load()).resolves.toBeUndefined(); + expect(client.sessions.value).toEqual([]); + }); +}); From 42d648655a8ffd9d01153c52d9301ea804be9052 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 18 Jun 2026 17:38:02 +0800 Subject: [PATCH 015/356] refactor(telemetry): merge duplicate session-start and goal events (#885) * refactor(telemetry): merge duplicate session-start and goal events * test: align telemetry tests with merged session-start events - run-shell: assert sessionStartedProperties plumbing instead of the removed 'started' event, drop the now-redundant resumed-lifecycle test, and fix the startup_perf call-count assertion - node-sdk: cover process-level and session-level sessionStartedProperties merging on session_started * fix(telemetry): keep session_started canonical fields authoritative Caller-supplied sessionStartedProperties were merged after the canonical fields (client_name, client_version, ui_mode, resumed), so a caller could silently override them via the public SDK options. Reorder so the harness-owned canonical fields always win, while session-level properties still override process-level ones for non-canonical keys. --- apps/kimi-code/src/cli/run-prompt.ts | 10 +- apps/kimi-code/src/cli/run-shell.ts | 9 +- apps/kimi-code/src/tui/commands/goal.ts | 1 - apps/kimi-code/test/cli/run-shell.test.ts | 48 +------ apps/kimi-code/test/tui/commands/goal.test.ts | 1 - .../session-event-handler-goal-queue.test.ts | 1 - packages/acp-adapter/src/server.ts | 47 +------ packages/node-sdk/src/kimi-harness.ts | 21 ++- packages/node-sdk/src/sdk-rpc-client.ts | 1 + packages/node-sdk/src/types.ts | 3 + .../test/create-session-transport.test.ts | 127 ++++++++++++++++++ 11 files changed, 161 insertions(+), 108 deletions(-) diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index f7cef067d..ecac8d3db 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -83,6 +83,7 @@ export async function runPrompt( } track('oauth_refresh', { success: false, reason: outcome.reason }); }, + sessionStartedProperties: { yolo: false, plan: false, afk: true }, }); log.info('kimi-code starting', { version, @@ -115,7 +116,7 @@ export async function runPrompt( for (const warning of (await harness.getConfigDiagnostics()).warnings) { stderr.write(`Warning: ${warning}\n`); } - const { session, resumed, restorePermission, telemetryModel, goalModel } = + const { session, restorePermission, telemetryModel, goalModel } = await resolvePromptSession( harness, opts, @@ -138,13 +139,6 @@ export async function runPrompt( }); setCrashPhase('runtime'); - withTelemetryContext({ sessionId: session.id }).track('started', { - resumed, - yolo: false, - plan: false, - afk: true, - }); - const outputFormat = opts.outputFormat ?? 'text'; // Headless goal mode: `kimi -p "/goal "`. The goal driver keeps // the turn-run alive across continuation turns, so the normal prompt-turn diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 25d6af369..b1a1afcdd 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -72,6 +72,7 @@ export async function runShell( reason: outcome.reason, }); }, + sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false }, }); log.info('kimi-code starting', { version, @@ -116,7 +117,6 @@ export async function runShell( }); setCrashPhase('runtime'); - const resumed = opts.continue || opts.session !== undefined; const trackLifecycleForSession = ( sessionId: string, event: string, @@ -161,13 +161,6 @@ export async function runShell( const initStartedAt = Date.now(); await tui.start(); const initMs = Date.now() - initStartedAt; - trackLifecycle('started', { - resumed, - yolo: opts.yolo, - auto: opts.auto, - plan: opts.plan, - afk: false, - }); const startupSessionId = tui.getCurrentSessionId(); const mcpMs = await tui.getStartupMcpMs(); trackLifecycleForSession(startupSessionId, 'startup_perf', { diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index 3dca323d7..881e1a40b 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -412,7 +412,6 @@ async function startGoal( if (options.beforeSend !== undefined && !(await options.beforeSend())) { return false; } - host.track('goal_create', { replace: parsed.replace }); host.state.transcriptContainer.addChild(new GoalSetMessageComponent()); host.state.ui.requestRender(); if (options.sendInput !== undefined) { diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 158d9edfa..e93fd5db1 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -191,6 +191,7 @@ describe('runShell', () => { userAgentProduct: 'kimi-code-cli', version: '1.2.3-test', }), + sessionStartedProperties: { yolo: true, auto: false, plan: true, afk: false }, }), ); expect(mocks.harnessEnsureConfigFile).toHaveBeenCalledOnce(); @@ -228,15 +229,7 @@ describe('runShell', () => { workDir: process.cwd(), }); expect(mocks.tuiStart).toHaveBeenCalledOnce(); - expect(mocks.harnessTrack).not.toHaveBeenCalledWith('started', expect.anything()); expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' }); - expect(mocks.lifecycleTrack).toHaveBeenCalledWith('started', { - resumed: false, - yolo: true, - auto: false, - plan: true, - afk: false, - }); expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', { duration_ms: expect.any(Number), config_ms: expect.any(Number), @@ -327,39 +320,6 @@ describe('runShell', () => { expect(mocks.harnessTrack).toHaveBeenCalledWith('first_launch'); }); - it('marks resumed lifecycle starts from session flags', async () => { - mocks.loadTuiConfig.mockResolvedValue({ - theme: 'dark', - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' }, - }); - mocks.tuiStart.mockResolvedValue(undefined); - mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); - - await runShell( - { - session: 'ses-1', - continue: false, - yolo: false, - auto: false, - plan: false, - model: undefined, - outputFormat: undefined, - prompt: undefined, - skillsDirs: [], - }, - '1.2.3-test', - ); - - expect(mocks.lifecycleTrack).toHaveBeenCalledWith('started', { - resumed: true, - yolo: false, - auto: false, - plan: false, - afk: false, - }); - }); - it('binds startup_perf to the session captured before MCP metrics resolve', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -389,9 +349,9 @@ describe('runShell', () => { '1.2.3-test', ); - expect(mocks.withTelemetryContext).toHaveBeenNthCalledWith(1, { sessionId: 'ses-startup' }); - expect(mocks.withTelemetryContext).toHaveBeenNthCalledWith(2, { sessionId: 'ses-startup' }); - expect(mocks.lifecycleTrack).toHaveBeenNthCalledWith(2, 'startup_perf', { + expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' }); + expect(mocks.withTelemetryContext).not.toHaveBeenCalledWith({ sessionId: 'ses-later' }); + expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', { duration_ms: expect.any(Number), config_ms: expect.any(Number), init_ms: expect.any(Number), diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index bad8e506b..1560f3d9a 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -251,7 +251,6 @@ describe('handleGoalCommand', () => { expect(session.createGoal).toHaveBeenCalledWith( expect.objectContaining({ objective: 'Ship feature X', replace: false }), ); - expect(host.track).toHaveBeenCalledWith('goal_create', { replace: false }); expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); expect(host.sendNormalUserInput).not.toHaveBeenCalledWith('/goal Ship feature X'); }); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 4794d1ab4..d02578f30 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -185,7 +185,6 @@ describe('SessionEventHandler goal queue promotion', () => { text: 'Ship queued goal', }); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - expect(host.track).toHaveBeenCalledWith('goal_create', { replace: false }); }); it('waits for queued user input to drain before promoting the next queued goal', async () => { diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index f4d343d29..607e7ef6e 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -288,6 +288,7 @@ export class AcpServer implements Agent { workDir: params.cwd, kaos: acpKaos, persistenceKaos, + sessionStartedProperties: { mode: 'new' }, // @ts-expect-error — `mcpServers` is a kernel-side extension // (agent-core `CreateSessionPayload`) the SDK transparently // forwards via spread. See block comment above. @@ -305,11 +306,6 @@ export class AcpServer implements Agent { currentThinkingEnabled, ); this.sessions.set(session.id, acpSession); - // Telemetry breadcrumb so we can observe ACP adoption (number of - // sessions started via this surface, vs. TUI / SDK direct). The - // property set is deliberately minimal: `mode` distinguishes - // `newSession` from `loadSession`; no user content / PII. - this.trackSessionStarted(session.id, 'new'); // Phase 14 (PLAN D11) advertises both the model and mode pickers as // a unified `configOptions: SessionConfigOption[]` surface. The // dedicated Phase 12 `modes:` field is gone — see @@ -363,10 +359,8 @@ export class AcpServer implements Agent { cwd: params.cwd, sessionId: params.sessionId, mcpServers: params.mcpServers, + mode: 'load', }); - // Same telemetry breadcrumb as `newSession`, but `mode: 'load'` - // so we can distinguish session creation from resumption. No PII. - this.trackSessionStarted(session.id, 'load'); // Synchronously replay history — the response must not settle // until every historical `session/update` has been pushed, // otherwise the client would race the load completion against @@ -401,11 +395,8 @@ export class AcpServer implements Agent { cwd: params.cwd, sessionId: params.sessionId, mcpServers: params.mcpServers, + mode: 'resume', }); - // Telemetry breadcrumb — distinguishes resume from new/load so we - // can observe which clients adopt the lighter-weight resume - // surface vs the history-replaying load surface. No PII. - this.trackSessionStarted(session.id, 'resume'); this.scheduleAvailableCommandsUpdate(session.id); return { configOptions }; } @@ -437,6 +428,7 @@ export class AcpServer implements Agent { cwd: string; sessionId: string; mcpServers?: ReadonlyArray; + mode: 'load' | 'resume'; }): Promise<{ session: Session; acpSession: AcpSession; @@ -466,6 +458,7 @@ export class AcpServer implements Agent { id: params.sessionId, kaos: acpKaos, persistenceKaos, + sessionStartedProperties: { mode: params.mode }, // @ts-expect-error — see block comment above; mcpServers is a // kernel-only field that the SDK forwards via spread. mcpServers, @@ -786,8 +779,7 @@ export class AcpServer implements Agent { * throwing) — adapter-level unit tests routinely construct minimal * `KimiHarness` shapes that only stub `auth.status` + `createSession`. * Production callers always supply a real harness with both methods; - * the swallow-and-fallback path exists purely for test ergonomics - * (matches the `track?.bind(...)` pattern at `trackSessionStarted`). + * the swallow-and-fallback path exists purely for test ergonomics. * * Logged at `warn` when a fallback fires so a dev who forgot to set * `default_model = ...` sees a breadcrumb in the agent log. @@ -867,7 +859,7 @@ export class AcpServer implements Agent { * Build a {@link TelemetryTrackFn} wrapper bound to the underlying * harness so the {@link AcpSession} (and its reverse-RPC bridges in * Phase 13) can emit PII-free breadcrumbs through the same - * `harness.track` channel `trackSessionStarted` uses. The wrapper + * `harness.track` channel. The wrapper * shape is required by the broader `Record` properties * type {@link TelemetryTrackFn} uses — the harness's own `track` is * typed against the narrower `TelemetryProperties` (a @@ -928,31 +920,6 @@ export class AcpServer implements Agent { } } - /** - * Emit the single ACP-adapter telemetry event. - * - * Wraps {@link KimiHarness.track} so a partial harness stub - * (common in unit tests — see `auth-gate.test.ts`, `session-new.test.ts`) - * cannot crash the request path. Production callers always supply a - * real harness with `.track`; the swallow-and-log fallback exists - * purely for test ergonomics. - * - * Property set is deliberately minimal: `sessionId` + `mode`. No - * user content, no IDE identity, no client capabilities — keeping - * the breadcrumb PII-free. - */ - private trackSessionStarted(sessionId: string, mode: 'new' | 'load' | 'resume'): void { - const track = this.harness.track?.bind(this.harness); - if (typeof track !== 'function') return; - try { - track('acp_session_started', { sessionId, mode }); - } catch (err) { - log.warn('acp: telemetry track failed', { - sessionId, - error: err instanceof Error ? err.message : String(err), - }); - } - } } /** diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 1dbd1bdf8..82288ae5d 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -37,6 +37,7 @@ export interface KimiHarnessRuntimeOptions { readonly telemetry: TelemetryClient; readonly ensureConfigFile: () => Promise; readonly onClose: () => void | Promise; + readonly sessionStartedProperties?: TelemetryProperties; } export class KimiHarness { @@ -50,6 +51,7 @@ export class KimiHarness { private readonly activeSessions = new Map(); private readonly ensureConfigFileImpl: () => Promise; private readonly closeImpl: () => void | Promise; + private readonly sessionStartedProperties: TelemetryProperties; constructor( private readonly rpc: SDKRpcClientBase, @@ -63,6 +65,7 @@ export class KimiHarness { this.auth = options.auth; this.ensureConfigFileImpl = options.ensureConfigFile; this.closeImpl = options.onClose; + this.sessionStartedProperties = options.sessionStartedProperties ?? {}; } get sessions(): ReadonlyMap { @@ -86,7 +89,7 @@ export class KimiHarness { } async createSession(options: CreateSessionOptions): Promise { - const { planMode, kaos, persistenceKaos, ...coreOptions } = options; + const { planMode, kaos, persistenceKaos, sessionStartedProperties, ...coreOptions } = options; const summary = kaos === undefined && persistenceKaos === undefined ? await this.rpc.createSession(coreOptions) @@ -104,7 +107,7 @@ export class KimiHarness { if (planMode === true) { await session.setPlanMode(true); } - this.trackSessionStarted(summary.id, false); + this.trackSessionStarted(summary.id, false, sessionStartedProperties); this.trackSessionEvent(session.id, 'session_new'); return session; } @@ -112,7 +115,7 @@ export class KimiHarness { async resumeSession(input: ResumeSessionInput): Promise { const id = normalizeSessionId(input.id); const active = this.activeSessions.get(id); - const { kaos, persistenceKaos, ...resumeInput } = input; + const { kaos, persistenceKaos, sessionStartedProperties, ...resumeInput } = input; if (active !== undefined) { if (kaos !== undefined || persistenceKaos !== undefined) { await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos); @@ -134,7 +137,7 @@ export class KimiHarness { }, }); this.activeSessions.set(session.id, session); - this.trackSessionStarted(summary.id, true); + this.trackSessionStarted(summary.id, true, sessionStartedProperties); this.trackSessionEvent(session.id, 'session_resume'); return session; } @@ -246,8 +249,16 @@ export class KimiHarness { withTelemetryContext(this.telemetry, { sessionId: eventSessionId }).track(event); } - private trackSessionStarted(eventSessionId: string, resumed: boolean): void { + private trackSessionStarted( + eventSessionId: string, + resumed: boolean, + sessionScoped?: TelemetryProperties, + ): void { withTelemetryContext(this.telemetry, { sessionId: eventSessionId }).track('session_started', { + ...this.sessionStartedProperties, + ...sessionScoped, + // Canonical fields are owned by the harness and must win over any + // caller-supplied sessionStartedProperties that happen to share a key. client_name: this.identity?.userAgentProduct ?? null, client_version: this.identity?.version ?? null, ui_mode: this.uiMode, diff --git a/packages/node-sdk/src/sdk-rpc-client.ts b/packages/node-sdk/src/sdk-rpc-client.ts index 2ffdb7986..923d0765a 100644 --- a/packages/node-sdk/src/sdk-rpc-client.ts +++ b/packages/node-sdk/src/sdk-rpc-client.ts @@ -139,5 +139,6 @@ export function createKimiHarness(options: KimiHarnessOptions): KimiHarness { telemetry: rpc.telemetry, ensureConfigFile: () => rpc.ensureConfigFile(), onClose: () => rpc.close(), + sessionStartedProperties: options.sessionStartedProperties, }); } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 041d78495..f1c67d4e2 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -88,6 +88,7 @@ export interface KimiHarnessOptions { readonly skillDirs?: readonly string[]; readonly telemetry?: TelemetryClient | undefined; readonly onOAuthRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined; + readonly sessionStartedProperties?: TelemetryProperties; } export interface CreateSessionOptions { @@ -100,6 +101,7 @@ export interface CreateSessionOptions { readonly metadata?: JsonObject | undefined; readonly kaos?: Kaos | undefined; readonly persistenceKaos?: Kaos | undefined; + readonly sessionStartedProperties?: TelemetryProperties; } export interface RenameSessionInput { @@ -111,6 +113,7 @@ export interface ResumeSessionInput { readonly id: string; readonly kaos?: Kaos | undefined; readonly persistenceKaos?: Kaos | undefined; + readonly sessionStartedProperties?: TelemetryProperties; } export interface ForkSessionInput { diff --git a/packages/node-sdk/test/create-session-transport.test.ts b/packages/node-sdk/test/create-session-transport.test.ts index 2dc550dc9..d7c322774 100644 --- a/packages/node-sdk/test/create-session-transport.test.ts +++ b/packages/node-sdk/test/create-session-transport.test.ts @@ -175,6 +175,133 @@ describe('KimiHarness.createSession transport link', () => { } }); + it('merges process-level sessionStartedProperties into session_started', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const records: TelemetryRecord[] = []; + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + telemetry: recordingTelemetry(records), + sessionStartedProperties: { yolo: true, plan: false }, + }); + + try { + const session = await harness.createSession({ + id: 'ses_process_props', + workDir, + }); + + expect(records).toContainEqual({ + event: 'session_started', + sessionId: session.id, + properties: { + client_name: 'kimi-code-cli', + client_version: '0.0.0-test', + ui_mode: 'shell', + resumed: false, + yolo: true, + plan: false, + }, + }); + } finally { + await harness.close(); + } + }); + + it('merges session-level sessionStartedProperties and overrides process-level ones', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const records: TelemetryRecord[] = []; + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + telemetry: recordingTelemetry(records), + sessionStartedProperties: { mode: 'process', source: 'process' }, + }); + + try { + const session = await harness.createSession({ + id: 'ses_scoped_props', + workDir, + sessionStartedProperties: { mode: 'new' }, + }); + + expect(records).toContainEqual({ + event: 'session_started', + sessionId: session.id, + properties: { + client_name: 'kimi-code-cli', + client_version: '0.0.0-test', + ui_mode: 'shell', + resumed: false, + mode: 'new', + source: 'process', + }, + }); + + await session.close(); + await harness.resumeSession({ + id: session.id, + sessionStartedProperties: { mode: 'load' }, + }); + + expect(records).toContainEqual({ + event: 'session_started', + sessionId: session.id, + properties: { + client_name: 'kimi-code-cli', + client_version: '0.0.0-test', + ui_mode: 'shell', + resumed: true, + mode: 'load', + source: 'process', + }, + }); + } finally { + await harness.close(); + } + }); + + it('does not let sessionStartedProperties override canonical session_started fields', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const records: TelemetryRecord[] = []; + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + telemetry: recordingTelemetry(records), + }); + + try { + const session = await harness.createSession({ + id: 'ses_reserved_keys', + workDir, + sessionStartedProperties: { + client_name: 'evil', + client_version: 'evil', + ui_mode: 'evil', + resumed: true, + extra: 'kept', + }, + }); + + expect(records).toContainEqual({ + event: 'session_started', + sessionId: session.id, + properties: { + client_name: 'kimi-code-cli', + client_version: '0.0.0-test', + ui_mode: 'shell', + resumed: false, + extra: 'kept', + }, + }); + } finally { + await harness.close(); + } + }); + it('emits session_fork with the forked session context', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); From cde7ca51ccec14bf159fede1f77e6a5d67413a5f Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:48:23 +0800 Subject: [PATCH 016/356] feat(goal): support guided goal authoring (#839) --- .../tui/components/dialogs/approval-panel.ts | 12 ++ .../dialogs/goal-start-permission-prompt.ts | 12 +- apps/kimi-code/src/tui/kimi-tui.ts | 7 +- .../src/tui/reverse-rpc/approval/adapter.ts | 36 ++++++ apps/kimi-code/src/tui/reverse-rpc/types.ts | 3 + .../components/dialogs/approval-panel.test.ts | 27 +++++ .../tui/reverse-rpc/approval-adapter.test.ts | 91 +++++++++++++++ .../policies/goal-start-review-ask.ts | 49 +++++++++ .../src/agent/permission/policies/index.ts | 5 + packages/agent-core/src/agent/turn/index.ts | 25 ++++- .../agent-core/src/skill/builtin/index.ts | 3 + .../src/skill/builtin/write-goal.md | 94 ++++++++++++++++ .../src/skill/builtin/write-goal.ts | 22 ++++ .../src/tools/builtin/goal/create-goal.ts | 22 +++- .../src/tools/builtin/goal/get-goal.ts | 3 +- .../src/tools/builtin/goal/serialize.ts | 17 +++ .../agent-core/test/agent/permission.test.ts | 1 + .../permission/goal-start-review-ask.test.ts | 104 ++++++++++++++++++ .../test/harness/goal-session.test.ts | 88 +++++++++++++++ packages/agent-core/test/tools/goal.test.ts | 10 ++ packages/protocol/src/display.ts | 9 ++ 21 files changed, 629 insertions(+), 11 deletions(-) create mode 100644 packages/agent-core/src/agent/permission/policies/goal-start-review-ask.ts create mode 100644 packages/agent-core/src/skill/builtin/write-goal.md create mode 100644 packages/agent-core/src/skill/builtin/write-goal.ts create mode 100644 packages/agent-core/src/tools/builtin/goal/serialize.ts create mode 100644 packages/agent-core/test/agent/permission/goal-start-review-ask.test.ts diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts index 1f1a55403..670e612cf 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -379,6 +379,18 @@ export class ApprovalPanelComponent extends Container implements Focusable { } else { lines.push(indent(strong(` ${labelWithNum}`))); } + + // Optional helper text under the label, aligned past the pointer/number. + // Choices without a description render exactly as before. + if ( + option.description !== undefined && + option.description.length > 0 && + !(this.feedbackMode && option.requires_feedback === true && isSelected) + ) { + for (const descLine of wrapTextWithAnsi(option.description, Math.max(20, width - 7))) { + lines.push(indent(` ${dim(descLine)}`)); + } + } } lines.push(''); diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index c7cc39923..e60d85ce0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -11,7 +11,7 @@ export interface GoalStartPermissionPromptOptions { readonly onCancel: () => void; } -const MANUAL_OPTIONS: readonly StartPermissionOption[] = [ +export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -37,7 +37,7 @@ const MANUAL_OPTIONS: readonly StartPermissionOption[] = [ }, ]; -const YOLO_OPTIONS: readonly StartPermissionOption[] = [ +export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -57,6 +57,14 @@ const YOLO_OPTIONS: readonly StartPermissionOption[] = [ }, ]; +export function goalStartOptions(mode: 'manual' | 'yolo'): readonly StartPermissionOption[] { + return mode === 'yolo' ? GOAL_START_YOLO_OPTIONS : GOAL_START_MANUAL_OPTIONS; +} + +const MANUAL_OPTIONS = GOAL_START_MANUAL_OPTIONS; + +const YOLO_OPTIONS = GOAL_START_YOLO_OPTIONS; + const MANUAL_NOTICE_LINES = [ 'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', 'Manual mode is not suitable for unattended goal work.', diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 79c818b70..b0a2094de 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1479,7 +1479,12 @@ export class KimiTUI { request: ApprovalRequest, response: ApprovalResponse, ): void { - if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return; + if ( + request.toolName === 'ExitPlanMode' || + request.display.kind === 'plan_review' || + request.display.kind === 'goal_start' + ) + return; const parts: string[] = []; switch (response.decision) { case 'approved': diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts index a17e60586..8112534a0 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts @@ -1,6 +1,7 @@ import type { ApprovalRequest, ApprovalResponse, ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; import type { ApprovalPanelResponse } from '#/tui/components/dialogs/approval-panel'; +import { goalStartOptions } from '#/tui/components/dialogs/goal-start-permission-prompt'; import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/reverse-rpc/types'; const DEFAULT_APPROVAL_CHOICES: ApprovalPanelChoice[] = [ @@ -176,6 +177,8 @@ function describeApproval(display: ToolInputDisplay, action: string): string { switch (display.kind) { case 'plan_review': return ''; + case 'goal_start': + return 'Start a goal?'; case 'generic': if (typeof display.detail === 'string' && display.detail.length > 0) { return display.detail; @@ -320,6 +323,13 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] { ]; case 'plan_review': return []; + case 'goal_start': { + const lines = [`Start goal: ${display.objective}`]; + if (typeof display.completionCriterion === 'string' && display.completionCriterion.length > 0) { + lines.push(`Done when: ${display.completionCriterion}`); + } + return [{ type: 'brief', text: lines.join('\n') }]; + } case 'generic': return []; case 'todo_list': @@ -335,10 +345,36 @@ function adaptChoices(toolName: string, display: ToolInputDisplay): ApprovalPane if (toolName === 'ExitPlanMode' || display.kind === 'plan_review') { return adaptPlanReviewChoices(display); } + if (display.kind === 'goal_start') { + return adaptGoalStartChoices(display); + } return DEFAULT_APPROVAL_CHOICES.map((choice) => cloneChoice(choice)); } +function adaptGoalStartChoices( + display: Extract, +): ApprovalPanelChoice[] { + // Reuse the exact options the /goal start menu shows. Each mode option starts + // the goal under that permission mode (the policy reads selected_label); "Do + // not start" declines so no goal is created. + return goalStartOptions(display.mode).map((option) => + option.value === 'cancel' + ? { + label: option.label, + response: 'cancelled', + selected_label: 'cancel', + description: option.description, + } + : { + label: option.label, + response: 'approved', + selected_label: option.value, + description: option.description, + }, + ); +} + function adaptPlanReviewChoices(display: ToolInputDisplay): ApprovalPanelChoice[] { const optionChoices = display.kind === 'plan_review' && display.options !== undefined && display.options.length >= 2 diff --git a/apps/kimi-code/src/tui/reverse-rpc/types.ts b/apps/kimi-code/src/tui/reverse-rpc/types.ts index c23c938f0..2a41f0df2 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/types.ts +++ b/apps/kimi-code/src/tui/reverse-rpc/types.ts @@ -103,6 +103,9 @@ export interface ApprovalPanelChoice { response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; selected_label?: string | undefined; requires_feedback?: boolean | undefined; + // Optional helper text shown dim beneath the label. Omitted/empty renders + // exactly as a plain label-only choice. + description?: string | undefined; } // ── Approval / Question view payloads ──────────────────────────────── diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts index 473f3261e..612ec152f 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts @@ -61,6 +61,33 @@ describe('ApprovalPanelComponent', () => { expect(out).not.toContain('y/a/n/f'); }); + it('renders choice descriptions beneath the label when present', () => { + const pending: PendingApproval = { + data: { + id: 'approval_goal', + tool_call_id: 'tool_goal', + tool_name: 'CreateGoal', + action: 'Creating a goal', + description: '', + display: [], + choices: [ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: 'Tools are approved automatically, and questions are skipped.', + }, + { label: 'Do not start', response: 'cancelled', selected_label: 'cancel' }, + ], + }, + }; + const out = strip(new ApprovalPanelComponent(pending, () => {}).render(80).join('\n')); + expect(out).toContain('1. Switch to Auto and start'); + expect(out).toContain('Tools are approved automatically, and questions are skipped.'); + // A choice without a description stays label-only — no stray blank helper line. + expect(out).toContain('2. Do not start'); + }); + it('renders dangerous shell warnings with simple copy and no icon', () => { const pending: PendingApproval = { data: { diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts index 997230fec..cdc8709c3 100644 --- a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts +++ b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts @@ -211,6 +211,97 @@ describe('approval adapter', () => { ]); }); + it('renders the /goal start menu for a CreateGoal approval in manual mode', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-goal', + toolName: 'CreateGoal', + action: 'Creating a goal', + display: { + kind: 'goal_start', + objective: 'Fix the failing auth tests', + completionCriterion: 'npm test -- auth exits 0', + mode: 'manual', + }, + }); + + // Objective + criterion are previewed as a brief block. + expect(adapted.display).toEqual([ + { + type: 'brief', + text: 'Start goal: Fix the failing auth tests\nDone when: npm test -- auth exits 0', + }, + ]); + // Choices mirror the manual-mode /goal start menu; mode options approve and + // carry the mode in selected_label, "Do not start" cancels. Each keeps the + // /goal menu's description. + expect(adapted.choices).toEqual([ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + label: 'Switch to YOLO and start', + response: 'approved', + selected_label: 'yolo', + description: + 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + }, + { + label: 'Start in Manual', + response: 'approved', + selected_label: 'manual', + description: + 'Keep approvals on. Kimi Code will ask before risky actions, so the goal may stop and wait for you.', + }, + { + label: 'Do not start', + response: 'cancelled', + selected_label: 'cancel', + description: 'Return to the input box with your goal command.', + }, + ]); + }); + + it('renders the yolo-mode /goal start menu for a CreateGoal approval', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-goal-yolo', + toolName: 'CreateGoal', + action: 'Creating a goal', + display: { + kind: 'goal_start', + objective: 'Ship the feature', + mode: 'yolo', + }, + }); + + expect(adapted.display).toEqual([{ type: 'brief', text: 'Start goal: Ship the feature' }]); + expect(adapted.choices).toEqual([ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + label: 'Keep YOLO and start', + response: 'approved', + selected_label: 'yolo', + description: + 'Tools and plan changes stay approved automatically. Kimi Code may still ask you questions.', + }, + { + label: 'Do not start', + response: 'cancelled', + selected_label: 'cancel', + description: 'Return to the input box with your goal command.', + }, + ]); + }); + it('maps approved-for-session responses into core approval payloads', () => { expect( adaptPanelResponse({ diff --git a/packages/agent-core/src/agent/permission/policies/goal-start-review-ask.ts b/packages/agent-core/src/agent/permission/policies/goal-start-review-ask.ts new file mode 100644 index 000000000..7356ef598 --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/goal-start-review-ask.ts @@ -0,0 +1,49 @@ +import type { Agent } from '../..'; +import type { + ApprovalResponse, + PermissionMode, + PermissionPolicy, + PermissionPolicyContext, + PermissionPolicyResult, +} from '../types'; + +/** + * Starting a goal turns the agent loose on autonomous, multi-turn work, so a + * model-issued `CreateGoal` is confirmed with the same menu the `/goal` command + * shows: choose the permission mode to run the goal under, or decline. The + * chosen mode is applied before the goal is created so the run proceeds under + * it. `auto` mode auto-approves the goal upstream and never reaches here. + */ +export class GoalStartReviewAskPermissionPolicy implements PermissionPolicy { + readonly name = 'goal-start-review-ask'; + + constructor(private readonly agent: Agent) {} + + evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined { + if (context.toolCall.name !== 'CreateGoal') return; + if (this.agent.permission.mode === 'auto') return; + if (context.execution.display?.kind !== 'goal_start') return; + return { + kind: 'ask', + resolveApproval: (result) => this.resolveGoalStart(result), + }; + } + + private resolveGoalStart(result: ApprovalResponse): undefined { + // Declining ("Do not start") or any non-approval creates no goal; the tool + // call is then blocked with the standard rejection message. + if (result.decision !== 'approved') return undefined; + // The selected option names the permission mode to run the goal under. + const mode = toPermissionMode(result.selectedLabel); + if (mode !== undefined && mode !== this.agent.permission.mode) { + this.agent.permission.setMode(mode); + } + // Approved: let CreateGoal execute and create the goal under the chosen mode. + return undefined; + } +} + +function toPermissionMode(label: string | undefined): PermissionMode | undefined { + if (label === 'auto' || label === 'yolo' || label === 'manual') return label; + return undefined; +} diff --git a/packages/agent-core/src/agent/permission/policies/index.ts b/packages/agent-core/src/agent/permission/policies/index.ts index 291d0f1b3..a0ba9bdfe 100644 --- a/packages/agent-core/src/agent/permission/policies/index.ts +++ b/packages/agent-core/src/agent/permission/policies/index.ts @@ -11,6 +11,7 @@ import { SensitiveFileAccessAskPermissionPolicy, } from './file-access-ask'; import { GitCwdWriteApprovePermissionPolicy } from './git-cwd-write-approve'; +import { GoalStartReviewAskPermissionPolicy } from './goal-start-review-ask'; import { PlanModeGuardDenyPermissionPolicy } from './plan-mode-guard-deny'; import { PlanModeToolApprovePermissionPolicy } from './plan-mode-tool-approve'; import { PreToolCallHookPermissionPolicy } from './pre-tool-call-hook'; @@ -46,6 +47,10 @@ export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy new UserConfiguredAllowPermissionPolicy(agent), // ExitPlanMode with active plan_review + non-empty plan + non-auto → ask (tracks plan_submitted/plan_resolved itself). Runs before session history so a stale session approval can't bypass review of a new plan body. new ExitPlanModeReviewAskPermissionPolicy(agent), + // CreateGoal (non-auto) → ask with the same start menu as /goal: choose the + // permission mode to run the goal under, or decline. Applies the mode, then + // lets the tool create the goal. + new GoalStartReviewAskPermissionPolicy(agent), // EnterPlanMode, Write/Edit on the plan file, or ExitPlanMode with no actionable plan_review → approve. new PlanModeToolApprovePermissionPolicy(agent), // Access touches a sensitive file (.env, SSH key, credentials) → ask. diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 7cd2e7330..7fff37876 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -316,12 +316,14 @@ export class TurnFlow { return await this.driveGoal(firstTurnId, input, origin, signal); } const end = await this.runOneTurn(firstTurnId, input, origin, signal, true); - const resumedFromPausedOrBlocked = - initialGoalStatus === 'paused' || initialGoalStatus === 'blocked'; - const currentGoalStatus = this.agent.goal.getGoal().goal?.status; + // A goal can become active during an ordinary turn: the model creates one + // with CreateGoal, or resumes a paused/blocked goal via UpdateGoal. Either + // way, hand the now-active goal to the driver so it is actually pursued, + // instead of stopping after the turn that merely started it. (The + // already-active case took the early return above.) + const goalBecameActive = this.agent.goal.getGoal().goal?.status === 'active'; if ( - resumedFromPausedOrBlocked && - currentGoalStatus === 'active' && + goalBecameActive && end.event.reason !== 'cancelled' && end.event.reason !== 'failed' ) { @@ -524,7 +526,18 @@ export class TurnFlow { }); } this.agent.emitEvent(ended); - if (standalone && this.currentId === turnId) { + // Release the active turn in the same frame as turn.ended for a standalone + // turn, so the session is observably idle the instant turn.ended fires. + // Exception: if the model turned the goal active during this turn (e.g. + // CreateGoal), the session is NOT idle — turnWorker is about to drive the + // goal. Keep the active turn alive (as the already-active goal path does) so + // those autonomous continuations stay cancelable and exclude concurrent + // turns; turnWorker releases it after the drive. + if ( + standalone && + this.currentId === turnId && + this.agent.goal.getGoal().goal?.status !== 'active' + ) { this.activeTurn = null; } if (this.agent.swarmMode.shouldAutoExit) { diff --git a/packages/agent-core/src/skill/builtin/index.ts b/packages/agent-core/src/skill/builtin/index.ts index e73204c06..a5150815e 100644 --- a/packages/agent-core/src/skill/builtin/index.ts +++ b/packages/agent-core/src/skill/builtin/index.ts @@ -8,12 +8,14 @@ import { SUB_SKILL_REVIEW, } from './sub-skill'; import { UPDATE_CONFIG_SKILL } from './update-config'; +import { WRITE_GOAL_SKILL } from './write-goal'; export function registerBuiltinSkills(registry: SessionSkillRegistry): void { registry.registerBuiltinSkill(MCP_CONFIG_SKILL); registry.registerBuiltinSkill(IMPORT_FROM_CC_CODEX_SKILL); registry.registerBuiltinSkill(UPDATE_CONFIG_SKILL); registry.registerBuiltinSkill(CUSTOM_THEME_SKILL); + registry.registerBuiltinSkill(WRITE_GOAL_SKILL); registry.registerBuiltinSkill(SUB_SKILL_PARENT); registry.registerBuiltinSkill(SUB_SKILL_REVIEW); registry.registerBuiltinSkill(SUB_SKILL_CONSOLIDATE); @@ -27,4 +29,5 @@ export { SUB_SKILL_PARENT, SUB_SKILL_REVIEW, UPDATE_CONFIG_SKILL, + WRITE_GOAL_SKILL, }; diff --git a/packages/agent-core/src/skill/builtin/write-goal.md b/packages/agent-core/src/skill/builtin/write-goal.md new file mode 100644 index 000000000..b258722d7 --- /dev/null +++ b/packages/agent-core/src/skill/builtin/write-goal.md @@ -0,0 +1,94 @@ +--- +name: write-goal +description: Help the user craft a well-specified `/goal` objective for goal mode — turn a rough intention into a completion contract with a clear finish line, proof, boundaries, and stop rule. Use when the user asks for help writing, refining, or improving a goal. +--- + +# Write a good goal (write-goal) + +Help the user turn a rough intention into a `/goal` objective that goal mode can pursue across many turns without supervision. A goal is not a task description — it is a completion contract. It says what must become *true*, how that truth is *proven*, where the work may and may not *reach*, and when to *stop and report* instead of grinding on. + +This skill is about authoring the objective text together with the user. Drafting and starting are separate steps: you settle the wording first, and only once the user has approved it do you start the goal by calling `CreateGoal`. The user still gets a final confirmation before it runs. + +## Ask, don't narrate + +**This is the most important rule in this skill. Every decision you put to the user goes through `AskUserQuestion`. No exceptions except one (below).** + +Goal authoring is a chain of choices — what to scope, which phrasing, whether to add a budget and how large, which permission mode to start under. For every one of them: **stop and call `AskUserQuestion`.** Do not write a paragraph that lists options and asks the user to reply in prose. Do not say "let me know if you'd prefer A or B." Do not bundle three questions into a wall of text. If you catch yourself typing out choices for the user to answer in free text, delete it and call `AskUserQuestion` instead. + +A prose menu is a defect, not a style choice: it is slower for the user, easy to skim past, and usually gets a vague answer that forces another round. The only time you may ask in plain text is when `AskUserQuestion` is genuinely unavailable — auto mode, or a host that does not support it — and only then do you fall back to a short message with clearly labelled options and wait. Plain prose for *open-ended* input ("what would prove this is done?") is fine; the rule is about **choices between options**, which always use the tool. + +## Rules of engagement + +- **Only help when the user has asked for it.** Never volunteer to wrap an ordinary request in a goal, and never start one on your own. A normal "fix this test" is a normal request; treat it as a goal only when the user says they want a goal. If a task looks like it would suit goal mode, you may mention that once — but wait for the user to choose. +- **Write in the user's language.** Draft the objective in whatever language the user is writing to you in. If the project configuration or a saved memory names a preferred language, honor that instead. Keep the surrounding discussion in the same language. +- **Show before you start.** Always present the full drafted goal back to the user and get their agreement before anything runs. The user should read the exact text that will become the objective, not a paraphrase of it. +- **Draft with the user, not for them.** Goal-writing is a conversation. Offer a draft, explain the choices you made, invite changes, and fold the feedback in. Expect more than one round. +- **Respect the user's final call.** If, after you have pointed out what is vague or risky, the user still wants a looser or thinner goal, write the goal they asked for. Note the trade-off once; do not keep relitigating it or quietly "improve" the wording against their wishes. + +## What makes a goal good + +The strongest goals share one shape: they define **proof, not effort**. "Keep improving the code" describes effort and never ends. "Done when `npm test` exits 0 and no file outside `src/auth` changed" describes proof and is checkable. Aim for a contract with these parts: + +1. **End state** — the condition that must become true. Name the finish line concretely: a passing suite, an empty queue, a search that returns zero matches, a deployed artifact. +2. **Proof** — the observable evidence that the end state holds. Prefer things the agent can run and you can inspect afterward: a command's exit code, a test count, a `grep`/`rg` with no hits, a file that now exists, a metric over a threshold. +3. **Boundaries** — what the work may and may not touch. Name the scope (which module, which directory) and the off-limits actions (do not edit the spec, do not change unrelated files, do not make destructive data changes). +4. **The loop** — when the work is iterative, say how to iterate: rerun the check after each change, work through the queue item by item, replay the failing cases until they pass. +5. **The stop rule** — how to end honestly when "done" is not reachable. A "stop and ask before widening scope" clause and an explicit blocked path ("if an external service is down, record it and move on") let the agent report instead of faking a pass or looping forever. This is about *honesty*, not a spending limit — keep it separate from any budget (see below). + +Two habits make almost any goal better: + +- **Make it queue-shaped.** Goals that shrink a list work best: failing tests, open issues, error traces, files to migrate, rows to process. A queue gives the agent a worklist and gives you a countable definition of done. +- **Lean on existing verification.** Tests, CI, type-checks, lint, eval suites, browser audits, and zero-match searches are leverage — they are what let a goal run unattended and still be trusted. If a task has no way to prove completion, help the user add one or reconsider whether goal mode fits. + +Longer runs are not better runs. A tight contract that finishes in a handful of turns beats an open-ended one that burns hours re-running the whole suite after every edit. + +## Budgets are opt-in + +Goal mode can run under a turn or token budget, but **do not set one by default, and never bake a turn cap into the objective text.** A well-specified goal already stops on its own — when the proof passes or a blocker is hit — so an arbitrary cap usually does nothing except risk cutting off work midway. + +When a budget is genuinely useful — typically an open-ended or exploratory goal that could run long unattended — you may suggest one, framed around the number users actually feel: token cost. Let the user choose the value, and sanity-check it against the work. A cap far larger than the task needs (say a thousand turns for a goal that will finish in a few) is not a safety net; it just invites wasted tokens. If the user asks for a value that looks oversized, say so and offer a smaller one, but respect their final call. + +## Workflow + +1. **Understand the intention.** Ask what outcome the user actually wants and what would prove it is done. If a finish line or a check is missing, that gap is the first thing to resolve together. As soon as the open questions reduce to concrete options, put them to the user with **AskUserQuestion** — do not list the options in prose. +2. **Draft the goal.** Write a concrete objective in the user's language, covering as many parts of the contract above as the task warrants. Keep it readable — one or a few sentences for simple work, a short structured block (end state, checks, boundaries, stop rule) for larger work. +3. **Show it and explain.** Present the draft in full and walk through the choices: what you picked as the finish line, what proves it, what you fenced off, when it stops. Point out anything still soft. +4. **Revise together.** Take the user's edits and produce a new draft. When you are weighing alternative phrasings or scopes, offer them as an **AskUserQuestion** choice instead of describing them. Repeat until they are satisfied. If they want it looser than you would recommend, say so once, then write their version. +5. **Start it.** Once the user approves the wording, start the goal by calling `CreateGoal` with the agreed objective (and a `completionCriterion` if you settled on one). Do not just print the text for the user to paste, and do not start before they have approved. Starting still surfaces a final confirmation, so the user keeps the last word on whether it runs. + +## A reusable shape + +For a non-trivial goal, this fill-in-the-blanks structure covers the contract: + +``` + +Done when . +Scope: only ; do not . +Loop: . +If , stop and report instead of forcing a pass. +``` + +Not every goal needs every line, and none of them is a turn cap — the goal stops when the proof passes or a blocker is hit. A small, well-scoped task can be a single clear sentence. Add structure as the work grows or the cost of a wrong autonomous run rises. + +## Weak to strong + +- Weak: `Find all bugs in this codebase.` — no finish line, no proof, no stop. The agent may block at once or run far past what you wanted. + Strong: `Fix every test in test/auth that currently fails, rerun npm test until it exits 0, change no file outside test/ or src/auth, and report anything you cannot fix with its location and why.` +- Weak: `Optimize the project.` — no scope, no measure. + Strong: `Migrate the payment module to the new API, make npm test -- payment exit 0, keep the diff limited to payment-related files, and stop and ask before touching shared infrastructure.` +- Weak: `Make it faster.` + Strong: `Make renderFrame at least 3x faster measured by the bench/render benchmark; if you cannot reach 3x after several attempts, report the best result and why.` + +## Common mistakes + +| Mistake | Better | +| --- | --- | +| Starting or suggesting a goal the user did not ask for | Only draft a goal once the user asks; mention the option at most once otherwise | +| Drafting in English when the user is writing in another language | Match the user's language (or the project / memory preference) | +| Running the goal before the user has seen the exact text | Show the full draft and get agreement first | +| Polishing the goal silently against the user's stated wishes | Note the trade-off once, then write the goal they asked for | +| Burying a discrete choice in prose | Offer the options with AskUserQuestion (plain labelled options if it is unavailable) | +| Specifying effort ("keep improving X") | Specify proof ("done when check X passes") | +| Baking a turn cap into the objective or setting a budget unprompted | Let the goal stop on its proof; suggest a budget only when useful, framed on token cost | +| No blocked path | Add an explicit "stop and report" rule for blockers | +| A goal with no way to verify completion | Anchor it to tests, a search, a metric, or another inspectable check | diff --git a/packages/agent-core/src/skill/builtin/write-goal.ts b/packages/agent-core/src/skill/builtin/write-goal.ts new file mode 100644 index 000000000..3203e7790 --- /dev/null +++ b/packages/agent-core/src/skill/builtin/write-goal.ts @@ -0,0 +1,22 @@ +import { parseSkillText } from '../parser'; +import type { SkillDefinition } from '../types'; +import WRITE_GOAL_BODY from './write-goal.md?raw'; + +const PSEUDO_PATH = 'builtin://write-goal'; + +const parsed = parseSkillText({ + skillMdPath: '/builtin/skills/write-goal.md', + skillDirName: 'write-goal', + source: 'builtin', + text: WRITE_GOAL_BODY, +}); + +export const WRITE_GOAL_SKILL: SkillDefinition = { + ...parsed, + path: PSEUDO_PATH, + dir: PSEUDO_PATH, + metadata: { + ...parsed.metadata, + type: parsed.metadata.type ?? 'inline', + }, +}; diff --git a/packages/agent-core/src/tools/builtin/goal/create-goal.ts b/packages/agent-core/src/tools/builtin/goal/create-goal.ts index 317350af6..440e5b40d 100644 --- a/packages/agent-core/src/tools/builtin/goal/create-goal.ts +++ b/packages/agent-core/src/tools/builtin/goal/create-goal.ts @@ -9,8 +9,10 @@ import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; import type { ToolExecution } from '../../../loop/types'; +import type { ToolInputDisplay } from '../../display'; import { toInputJsonSchema } from '../../support/input-schema'; import DESCRIPTION from './create-goal.md?raw'; +import { goalForModel } from './serialize'; export const CreateGoalToolInputSchema = z .object({ @@ -40,6 +42,7 @@ export class CreateGoalTool implements BuiltinTool { return { description: 'Creating a goal', + display: this.resolveGoalStartDisplay(args), approvalRule: this.name, execute: async () => { const snapshot = await goal.createGoal( @@ -50,8 +53,25 @@ export class CreateGoalTool implements BuiltinTool { }, 'model', ); - return { output: JSON.stringify({ goal: snapshot }, null, 2) }; + return { output: JSON.stringify({ goal: goalForModel(snapshot) }, null, 2) }; }, }; } + + /** + * Starting a goal switches the agent into autonomous, multi-turn work, so its + * approval reuses the same choice the `/goal` command offers: pick the + * permission mode to run under, or decline. `auto` mode auto-approves the goal + * upstream and never reaches this prompt, so the menu only covers manual/yolo. + */ + private resolveGoalStartDisplay(args: CreateGoalToolInput): ToolInputDisplay | undefined { + const mode = this.agent.permission.mode; + if (mode === 'auto') return undefined; + return { + kind: 'goal_start', + objective: args.objective, + completionCriterion: args.completionCriterion, + mode, + }; + } } diff --git a/packages/agent-core/src/tools/builtin/goal/get-goal.ts b/packages/agent-core/src/tools/builtin/goal/get-goal.ts index df6c87feb..3713aa6ef 100644 --- a/packages/agent-core/src/tools/builtin/goal/get-goal.ts +++ b/packages/agent-core/src/tools/builtin/goal/get-goal.ts @@ -11,6 +11,7 @@ import type { BuiltinTool } from '../../../agent/tool'; import type { ToolExecution } from '../../../loop/types'; import { toInputJsonSchema } from '../../support/input-schema'; import DESCRIPTION from './get-goal.md?raw'; +import { goalResultForModel } from './serialize'; export const GetGoalToolInputSchema = z.object({}).strict(); export type GetGoalToolInput = z.infer; @@ -29,7 +30,7 @@ export class GetGoalTool implements BuiltinTool { approvalRule: this.name, execute: async () => { const result = store.getGoal(); - return { output: JSON.stringify(result, null, 2) }; + return { output: JSON.stringify(goalResultForModel(result), null, 2) }; }, }; } diff --git a/packages/agent-core/src/tools/builtin/goal/serialize.ts b/packages/agent-core/src/tools/builtin/goal/serialize.ts new file mode 100644 index 000000000..d125aeaac --- /dev/null +++ b/packages/agent-core/src/tools/builtin/goal/serialize.ts @@ -0,0 +1,17 @@ +import type { GoalSnapshot, GoalToolResult } from '../../../agent/goal'; + +/** + * The goalId is a random UUID with no user-facing meaning, and no goal tool + * takes one (there is only ever one goal at a time). Keep it out of what the + * model sees so it never echoes the id back to the user as if it mattered. + */ +export function goalForModel(goal: GoalSnapshot): Omit { + const { goalId: _goalId, ...rest } = goal; + return rest; +} + +export function goalResultForModel( + result: GoalToolResult, +): { goal: Omit | null } { + return { goal: result.goal === null ? null : goalForModel(result.goal) }; +} diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts index 243ca7572..8fdd3d2b1 100644 --- a/packages/agent-core/test/agent/permission.test.ts +++ b/packages/agent-core/test/agent/permission.test.ts @@ -709,6 +709,7 @@ describe('Permission policy chain', () => { 'user-configured-ask', 'user-configured-allow', 'exit-plan-mode-review-ask', + 'goal-start-review-ask', 'plan-mode-tool-approve', 'sensitive-file-access-ask', 'git-control-path-access-ask', diff --git a/packages/agent-core/test/agent/permission/goal-start-review-ask.test.ts b/packages/agent-core/test/agent/permission/goal-start-review-ask.test.ts new file mode 100644 index 000000000..cd97e277d --- /dev/null +++ b/packages/agent-core/test/agent/permission/goal-start-review-ask.test.ts @@ -0,0 +1,104 @@ +import type { ToolCall } from '@moonshot-ai/kosong'; +import { describe, expect, it } from 'vitest'; + +import type { PermissionPolicyContext } from '../../../src/agent/permission'; +import type { PermissionMode } from '../../../src/agent/permission'; +import { GoalStartReviewAskPermissionPolicy } from '../../../src/agent/permission/policies/goal-start-review-ask'; +import type { ToolInputDisplay } from '../../../src/tools/display'; +import { ToolAccesses } from '../../../src/loop'; + +const signal = new AbortController().signal; + +function fakeAgent(initialMode: PermissionMode) { + const permission = { + mode: initialMode, + setMode(mode: PermissionMode) { + this.mode = mode; + }, + }; + return { agent: { permission } as never, permission }; +} + +function policyContext(toolName: string, display: ToolInputDisplay | undefined): PermissionPolicyContext { + return { + turnId: '0', + stepNumber: 1, + signal, + llm: {}, + args: {}, + toolCall: { + type: 'function', + id: `call_${toolName}`, + name: toolName, + arguments: '{}', + } satisfies ToolCall, + execution: { + accesses: ToolAccesses.none(), + approvalRule: toolName, + display, + execute: async () => ({ output: '' }), + }, + } as unknown as PermissionPolicyContext; +} + +const GOAL_DISPLAY: ToolInputDisplay = { + kind: 'goal_start', + objective: 'Fix the failing auth tests', + mode: 'manual', +}; + +describe('GoalStartReviewAskPermissionPolicy', () => { + it('ignores tools other than CreateGoal', () => { + const { agent } = fakeAgent('manual'); + const policy = new GoalStartReviewAskPermissionPolicy(agent); + expect(policy.evaluate(policyContext('Bash', undefined))).toBeUndefined(); + }); + + it('does not ask in auto mode (the goal is auto-approved upstream)', () => { + const { agent } = fakeAgent('auto'); + const policy = new GoalStartReviewAskPermissionPolicy(agent); + expect(policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY))).toBeUndefined(); + }); + + it('does not ask without a goal_start display', () => { + const { agent } = fakeAgent('manual'); + const policy = new GoalStartReviewAskPermissionPolicy(agent); + expect(policy.evaluate(policyContext('CreateGoal', undefined))).toBeUndefined(); + }); + + it('asks with the start menu for a CreateGoal in manual mode', () => { + const { agent } = fakeAgent('manual'); + const policy = new GoalStartReviewAskPermissionPolicy(agent); + const result = policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY)); + expect(result?.kind).toBe('ask'); + }); + + it('switches to the chosen mode on approval, then lets the goal be created', () => { + const { agent, permission } = fakeAgent('manual'); + const policy = new GoalStartReviewAskPermissionPolicy(agent); + const result = policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY)); + if (result?.kind !== 'ask') throw new Error('expected ask'); + // Returning undefined lets CreateGoal.execute run and create the goal. + expect(result.resolveApproval?.({ decision: 'approved', selectedLabel: 'auto' })).toBeUndefined(); + expect(permission.mode).toBe('auto'); + }); + + it('keeps the current mode when the user starts in manual', () => { + const { agent, permission } = fakeAgent('manual'); + const policy = new GoalStartReviewAskPermissionPolicy(agent); + const result = policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY)); + if (result?.kind !== 'ask') throw new Error('expected ask'); + expect(result.resolveApproval?.({ decision: 'approved', selectedLabel: 'manual' })).toBeUndefined(); + expect(permission.mode).toBe('manual'); + }); + + it('creates no goal and changes no mode when the user declines', () => { + const { agent, permission } = fakeAgent('manual'); + const policy = new GoalStartReviewAskPermissionPolicy(agent); + const result = policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY)); + if (result?.kind !== 'ask') throw new Error('expected ask'); + // A cancel resolves to undefined; the manager then blocks the tool call. + expect(result.resolveApproval?.({ decision: 'cancelled', selectedLabel: 'cancel' })).toBeUndefined(); + expect(permission.mode).toBe('manual'); + }); +}); diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index 84e0edffe..8cfeddc6d 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -239,6 +239,94 @@ describe('goal session end-to-end', () => { expect((await api.getGoal({ agentId: 'main' })).goal).toBeNull(); }); + it('drives a goal the model creates mid-turn with CreateGoal', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent, scripted } = await setupSession(sessionDir, events, [ + 'CreateGoal', + 'GetGoal', + 'UpdateGoal', + ]); + const api = new SessionAPIImpl(session); + + // No goal exists at launch. The model creates one mid-turn via CreateGoal; + // the driver must then pursue it across continuation turns instead of + // stopping after the ordinary turn that merely started it. + scripted.mockNextResponse({ + type: 'function', + id: 'create', + name: 'CreateGoal', + arguments: JSON.stringify({ objective: 'work' }), + }); + scripted.mockNextResponse({ type: 'text', text: 'Goal created and active.' }); + scripted.mockNextResponse({ + type: 'function', + id: 'complete', + name: 'UpdateGoal', + arguments: JSON.stringify({ status: 'complete' }), + }); + scripted.mockNextResponse({ type: 'text', text: 'I completed the goal.' }); + + agent.turn.prompt([{ type: 'text', text: 'Please start a goal to do the work' }]); + await agent.turn.waitForCurrentTurn(); + + // The driver ran a continuation turn after the goal became active, reaching + // the UpdateGoal('complete') the standalone turn never would have. + expect(scripted.calls.length).toBeGreaterThanOrEqual(4); + expect(JSON.stringify(scripted.calls[2]?.history ?? [])).toContain( + 'Continue working toward the active goal', + ); + const turnStarts = events.filter((e) => e['type'] === 'turn.started').length; + expect(turnStarts).toBeGreaterThanOrEqual(2); + expect((await api.getGoal({ agentId: 'main' })).goal).toBeNull(); + }); + + it('keeps the active turn alive (cancelable) while driving a goal created mid-turn', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const scripted = createScriptedGenerate(); + let agentRef: { turn: { readonly hasActiveTurn: boolean } } | undefined; + const activeDuringCall: boolean[] = []; + const generate: NonNullable = (...args) => { + activeDuringCall.push(agentRef?.turn.hasActiveTurn ?? false); + return scripted.generate(...args); + }; + const { agent } = await setupSession( + sessionDir, + events, + ['CreateGoal', 'GetGoal', 'UpdateGoal'], + generate, + ); + agentRef = agent; + + scripted.mockNextResponse({ + type: 'function', + id: 'create', + name: 'CreateGoal', + arguments: JSON.stringify({ objective: 'work' }), + }); + scripted.mockNextResponse({ type: 'text', text: 'Goal created and active.' }); + scripted.mockNextResponse({ + type: 'function', + id: 'complete', + name: 'UpdateGoal', + arguments: JSON.stringify({ status: 'complete' }), + }); + scripted.mockNextResponse({ type: 'text', text: 'I completed the goal.' }); + + agent.turn.prompt([{ type: 'text', text: 'Please start a goal to do the work' }]); + await agent.turn.waitForCurrentTurn(); + + // Calls 0-1 are the standalone first turn (CreateGoal, then text); calls 2-3 + // are the goal driver's continuation turn. The continuation must run under a + // live active turn so a user cancel can abort it and no concurrent turn can + // launch. Before the fix the standalone turn released the active turn the + // instant it created the goal, leaving calls 2-3 with no active turn. + expect(activeDuringCall.length).toBeGreaterThanOrEqual(4); + expect(activeDuringCall[2]).toBe(true); + expect(activeDuringCall[3]).toBe(true); + }); + it('asks the model to explain why it marked a goal blocked', async () => { const sessionDir = await makeTempDir(); const events: Array> = []; diff --git a/packages/agent-core/test/tools/goal.test.ts b/packages/agent-core/test/tools/goal.test.ts index 2568be66c..f47bb709f 100644 --- a/packages/agent-core/test/tools/goal.test.ts +++ b/packages/agent-core/test/tools/goal.test.ts @@ -29,6 +29,7 @@ function fakeAgent(opts: { type?: 'main' | 'sub'; goal?: GoalMode } = {}): Agent emitEvent: () => {}, telemetry: { track: () => {} }, context: { appendSystemReminder: () => {} }, + permission: { mode: 'manual' }, } as unknown as Agent; (agent as { goal: GoalMode }).goal = opts.goal ?? new GoalMode(agent); return agent; @@ -47,6 +48,15 @@ describe('CreateGoalTool', () => { expect(store.getGoal().goal?.objective).toBe('Ship feature X'); }); + it('omits the internal goalId from the model-facing output', async () => { + const store = makeStore(); + const tool = new CreateGoalTool(fakeAgent({ goal: store })); + const result = await executeTool(tool, ctx({ objective: 'Ship feature X' })); + expect(store.getGoal().goal?.goalId).toBeTruthy(); + expect(result.output).not.toContain('goalId'); + expect(result.output).not.toContain(store.getGoal().goal?.goalId ?? 'no-id'); + }); + it('passes completionCriterion and replace', async () => { const store = makeStore(); const tool = new CreateGoalTool(fakeAgent({ goal: store })); diff --git a/packages/protocol/src/display.ts b/packages/protocol/src/display.ts index 223eef0ec..8628fbcf5 100644 --- a/packages/protocol/src/display.ts +++ b/packages/protocol/src/display.ts @@ -75,6 +75,15 @@ export const ToolInputDisplaySchema = z.discriminatedUnion('kind', [ .readonly() .optional(), }), + z.object({ + kind: z.literal('goal_start'), + objective: z.string(), + completionCriterion: z.string().optional(), + // Current permission mode at approval time. The client uses it to pick the + // start menu (manual vs yolo); `auto` never reaches this display because it + // auto-approves the goal without a prompt. + mode: z.enum(['manual', 'yolo']), + }), z.object({ kind: z.literal('generic'), summary: z.string(), From de610deb5f760606b82cc595e59c5176cc66ce82 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 18 Jun 2026 19:14:47 +0800 Subject: [PATCH 017/356] fix(web): drop workspace session count after archiving the last session (#896) The daemon's workspace session_count counted archived session directories, so a workspace still reported a non-zero count after its last session was archived. The web sidebar then showed the workspace as non-empty, making it look like the archive had failed (and a retry would error out). Count only non-archived sessions in the workspace registry, and trust the live local count in the web app once sessions have loaded. --- .changeset/fix-archive-last-session-count.md | 5 + .../src/composables/useKimiWebClient.ts | 8 +- .../test/archive-last-session.test.ts | 167 ++++++++++++++++++ apps/kimi-web/test/session-row.test.ts | 11 ++ .../workspace/workspaceRegistryService.ts | 20 ++- packages/server/test/sessions.e2e.test.ts | 41 ++++- 6 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-archive-last-session-count.md create mode 100644 apps/kimi-web/test/archive-last-session.test.ts diff --git a/.changeset/fix-archive-last-session-count.md b/.changeset/fix-archive-last-session-count.md new file mode 100644 index 000000000..73ad0cebf --- /dev/null +++ b/.changeset/fix-archive-last-session-count.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web workspace session count so it drops to 0 after archiving the last session instead of staying at 1. diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 2468607d1..eed900b3a 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -2353,8 +2353,12 @@ const mergedWorkspaces = computed(() => { const result: AppWorkspace[] = []; for (const root of [...realRoots, ...derivedRoots]) { const w = byRoot.get(root)!; - // Match count by either id or root (derived id === root). - const count = counts.get(w.id) ?? counts.get(w.root) ?? w.sessionCount; + // Match count by either id or root (derived id === root). Once sessions + // have loaded, trust the live local count (0 when no sessions remain) rather + // than the daemon's sessionCount, which historically counted archived + // sessions and would keep a workspace looking non-empty after its last + // session was archived. + const count = counts.get(w.id) ?? counts.get(w.root) ?? (rawState.loading ? w.sessionCount : 0); let branch = w.branch; if (!branch && activeGit && activeRoot === w.root) branch = activeGit.branch; result.push({ ...w, sessionCount: count, branch }); diff --git a/apps/kimi-web/test/archive-last-session.test.ts b/apps/kimi-web/test/archive-last-session.test.ts new file mode 100644 index 000000000..582a01db8 --- /dev/null +++ b/apps/kimi-web/test/archive-last-session.test.ts @@ -0,0 +1,167 @@ +// apps/kimi-web/test/archive-last-session.test.ts +// +// Reproduces / verifies the bug where archiving the only session in a workspace +// does not behave correctly. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AppSession, AppWorkspace, KimiEventHandlers, KimiWebApi } from '../src/api/types'; + +const now = '2026-06-11T00:00:00.000Z'; + +function session(id: string, overrides?: Partial): AppSession { + return { + id, + title: id, + createdAt: now, + updatedAt: now, + status: 'idle', + archived: false, + cwd: '/repo', + model: 'kimi-test', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 128_000, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + ...overrides, + }; +} + +async function setup(opts: { + sessions?: AppSession[]; + workspaces?: AppWorkspace[]; +}) { + vi.resetModules(); + vi.stubGlobal('WebSocket', class WebSocket {}); + window.history.replaceState(null, '', '/'); + + let handlers: KimiEventHandlers | undefined; + const eventConn = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + bindNextPromptId: vi.fn(), + seedSnapshot: vi.fn(), + abort: vi.fn(), + close: vi.fn(), + }; + const listed = opts.sessions ?? []; + const workspaces = opts.workspaces ?? []; + const api = { + getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })), + getMeta: vi.fn(async () => ({ daemonVersion: 't', serverId: 's', startedAt: now, capabilities: {} })), + getAuth: vi.fn(async () => ({ ready: true, defaultModel: 'kimi-test', managedProvider: null })), + listModels: vi.fn(async () => []), + listWorkspaces: vi.fn(async () => workspaces), + getFsHome: vi.fn(async () => ({ home: '/home', recentRoots: [] })), + listSessions: vi.fn(async () => ({ items: listed, hasMore: false })), + getSession: vi.fn(async (id: string) => { + const found = listed.find((s) => s.id === id); + if (!found) throw new Error('SESSION_NOT_FOUND'); + return found; + }), + archiveSession: vi.fn(async () => ({ archived: true })), + getSessionSnapshot: vi.fn(async (id: string) => { + const found = listed.find((s) => s.id === id) ?? session(id); + return { + asOfSeq: 0, + epoch: 'ep_test', + session: found, + messages: [], + hasMoreMessages: false, + inFlightTurn: null, + pendingApprovals: [], + pendingQuestions: [], + }; + }), + listTasks: vi.fn(async () => []), + getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })), + getSessionStatus: vi.fn(async () => ({ + model: 'kimi-test', + thinkingLevel: 'high', + permission: 'manual', + planMode: false, + swarmMode: false, + contextTokens: 0, + maxContextTokens: 128_000, + contextUsage: 0, + })), + connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => { + handlers = nextHandlers; + return eventConn; + }), + getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`), + } as unknown as KimiWebApi; + + vi.doMock('../src/api', () => ({ getKimiWebApi: () => api })); + const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); + + return { + api, + client: useKimiWebClient(), + getHandlers: () => { + if (!handlers) throw new Error('connectEvents was not called'); + return handlers; + }, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + vi.clearAllMocks(); + localStorage.clear(); + window.history.replaceState(null, '', '/'); +}); + +describe('archive last session in workspace', () => { + it('removes the only session and clears active session', async () => { + const { client } = await setup({ + sessions: [session('sess_1', { cwd: '/repo' })], + workspaces: [{ id: 'ws_repo', root: '/repo', name: 'repo', sessionCount: 1 }], + }); + await client.load(); + + expect(client.activeSessionId.value).toBe('sess_1'); + expect(client.sessions.value.map((s) => s.id)).toEqual(['sess_1']); + expect(client.workspacesView.value.map((w) => ({ id: w.id, sessionCount: w.sessionCount }))).toEqual([ + { id: 'ws_repo', sessionCount: 1 }, + ]); + + await client.archiveSession('sess_1'); + + expect(client.sessions.value).toEqual([]); + expect(client.activeSessionId.value).toBe(''); + expect(window.location.pathname).toBe('/'); + expect(client.workspacesView.value.map((w) => ({ id: w.id, sessionCount: w.sessionCount }))).toEqual([ + { id: 'ws_repo', sessionCount: 0 }, + ]); + }); + + it('removes the only session in one workspace when another workspace exists', async () => { + const { client } = await setup({ + sessions: [ + session('sess_a', { cwd: '/repo-a' }), + session('sess_b', { cwd: '/repo-b' }), + ], + workspaces: [ + { id: 'ws_a', root: '/repo-a', name: 'repo-a', sessionCount: 1 }, + { id: 'ws_b', root: '/repo-b', name: 'repo-b', sessionCount: 1 }, + ], + }); + await client.load(); + + expect(client.activeSessionId.value).toBe('sess_a'); + + await client.archiveSession('sess_a'); + + expect(client.sessions.value.map((s) => s.id)).toEqual(['sess_b']); + expect(client.activeSessionId.value).toBe('sess_b'); + }); +}); diff --git a/apps/kimi-web/test/session-row.test.ts b/apps/kimi-web/test/session-row.test.ts index 70a00b70f..5b6dc1760 100644 --- a/apps/kimi-web/test/session-row.test.ts +++ b/apps/kimi-web/test/session-row.test.ts @@ -56,4 +56,15 @@ describe('SessionRow status / busy', () => { expect(w.find('.tag-ask').exists()).toBe(false); expect(w.find('.tag-aborted').exists()).toBe(false); }); + + it('emits archive after confirming via the kebab menu', async () => { + const w = row({ id: 'only', title: 'Only' }); + + await w.find('.kebab').trigger('click'); + await w.find('.menu-item.archive').trigger('click'); + expect(w.find('.archive-confirm').exists()).toBe(true); + + await w.find('.btn-confirm').trigger('click'); + expect(w.emitted('archive')).toEqual([['only']]); + }); }); diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts index 26d26aebe..4d18a0798 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts +++ b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts @@ -171,7 +171,7 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe ): Promise { const [{ is_git_repo, branch }, session_count] = await Promise.all([ detectGit(entry.root), - countSessionDirs(join(this.sessionsDir, workspaceId)), + countActiveSessions(join(this.sessionsDir, workspaceId)), ]); return { id: workspaceId, @@ -343,7 +343,7 @@ export async function detectGit(root: string): Promise { return { is_git_repo: true, branch: ref ? (ref[1] ?? null) : null }; } -async function countSessionDirs(dir: string): Promise { +async function countActiveSessions(dir: string): Promise { let dirents; try { dirents = await fsp.readdir(dir, { withFileTypes: true }); @@ -354,11 +354,25 @@ async function countSessionDirs(dir: string): Promise { } let count = 0; for (const d of dirents) { - if (d.isDirectory()) count += 1; + if (!d.isDirectory()) continue; + if (await isSessionArchived(join(dir, d.name))) continue; + count += 1; } return count; } +async function isSessionArchived(sessionDir: string): Promise { + try { + const raw = await fsp.readFile(join(sessionDir, 'state.json'), 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return typeof parsed === 'object' && parsed !== null && (parsed as { archived?: boolean }).archived === true; + } catch { + // Treat unreadable/missing state.json as non-archived so the directory still + // counts as a session (matches the session store's own loading behavior). + return false; + } +} + export function userHomeDir(): string { return os.homedir(); } diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 44b034706..52231b3d6 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -25,7 +25,7 @@ * bearing piece of Chain 2). */ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -712,6 +712,45 @@ describe('POST /api/v1/sessions/{session_id}:archive — archive', () => { expect(listEnv.data!.items.find((s) => s.id === created.id)).toBeUndefined(); }); + it('updates the workspace session_count to 0 when the last session is archived', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-archive-count'); + mkdirSync(cwd, { recursive: true }); + const ws = envelopeOf<{ id: string; session_count: number; root: string }>( + (await appOf(r).inject({ method: 'POST', url: '/api/v1/workspaces', payload: { root: cwd } })).json(), + ).data!; + expect(ws.session_count).toBe(0); + + const created = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { workspace_id: ws.id, metadata: { cwd: ws.root } }, + })).json(), + ).data!; + + const listBefore = envelopeOf<{ items: Array<{ id: string; session_count: number }> }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/workspaces' })).json(), + ).data!; + const before = listBefore.items.find((w) => w.id === ws.id); + expect(before).toBeDefined(); + expect(before!.session_count).toBe(1); + + const archiveRes = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}:archive`, + payload: {}, + }); + expect(envelopeOf<{ archived: boolean }>(archiveRes.json()).data).toEqual({ archived: true }); + + const listAfter = envelopeOf<{ items: Array<{ id: string; session_count: number }> }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/workspaces' })).json(), + ).data!; + const after = listAfter.items.find((w) => w.id === ws.id); + expect(after).toBeDefined(); + expect(after!.session_count).toBe(0); + }); + it('includes archived sessions when include_archive=true and marks archived flag', async () => { const r = await bootDaemon(); const cwd = join(tmpDir, 'workspace-archive-include'); From 495fe8c674d654cdf87217ca4ada775507f861f6 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 18 Jun 2026 19:23:23 +0800 Subject: [PATCH 018/356] feat(web): add session search (#895) * feat(web): add session search Add a search box to the web sidebar that instantly filters all loaded sessions by title and the last user prompt (case-insensitive). Surface the last user prompt from the server: the daemon already persisted it in session metadata, and it now flows through the session schema into the REST response so the web client can match against it. * fix(web): keep lastPrompt fresh on session.meta.updated Address Codex review: the daemon emits session.meta.updated with patch.lastPrompt whenever a new prompt is submitted, but the web projector only forwarded the title. That left the cached session's lastPrompt stale, so sidebar search by the latest prompt text failed until a full reload. Forward lastPrompt through the projector and reducer, and cover it with a pipeline test. * refactor(web): avoid conditional spreads in meta patch Address Codex review: per the root AGENTS.md, optional object properties should be passed directly rather than via conditional spreads. Use nullish coalescing so a field the event does not carry keeps its prior value. * fix(web): stop Escape from aborting a run while search is focused Address Codex review: ConversationPane registers a document-level keydown that aborts the active prompt on Escape. Without handling it on the search input, pressing Escape to dismiss the search would unexpectedly stop the agent. Stop propagation and clear the query, matching the inline rename inputs. * fix(web): exclude hidden-workspace sessions from search Address Codex review: removing a workspace only records its root in hiddenWorkspaceRoots and leaves the sessions intact; the grouped sidebar skips the hidden root, but sessionsForView (the search source) did not, so a matching title or prompt could resurrect sessions from a removed workspace. Filter sessionsForView by the visible workspace set so the flat list matches what the grouped sidebar renders. --- .changeset/web-session-search.md | 5 + .../src/api/daemon/agentEventProjector.ts | 13 +- apps/kimi-web/src/api/daemon/eventReducer.ts | 13 +- apps/kimi-web/src/api/daemon/mappers.ts | 1 + apps/kimi-web/src/api/daemon/wire.ts | 2 + apps/kimi-web/src/api/types.ts | 4 +- apps/kimi-web/src/components/Sidebar.vue | 137 +++++++++++++++++- .../src/composables/useKimiWebClient.ts | 8 +- apps/kimi-web/src/i18n/locales/en/sidebar.ts | 3 + apps/kimi-web/src/i18n/locales/zh/sidebar.ts | 3 + apps/kimi-web/src/types.ts | 2 + .../test/session-meta-updated.test.ts | 90 ++++++++++++ apps/kimi-web/test/sidebar-search.test.ts | 100 +++++++++++++ .../useKimiWebClient-session-list.test.ts | 22 +++ .../src/services/session/session.ts | 1 + .../test/services/session-service.test.ts | 21 +++ .../protocol/src/__tests__/session.test.ts | 8 + packages/protocol/src/session.ts | 2 + 18 files changed, 419 insertions(+), 16 deletions(-) create mode 100644 .changeset/web-session-search.md create mode 100644 apps/kimi-web/test/session-meta-updated.test.ts create mode 100644 apps/kimi-web/test/sidebar-search.test.ts diff --git a/.changeset/web-session-search.md b/.changeset/web-session-search.md new file mode 100644 index 000000000..cbd99e900 --- /dev/null +++ b/.changeset/web-session-search.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add instant session search to the web sidebar, filtering by title and the last user prompt. diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 496a39355..62b36a52a 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -616,12 +616,17 @@ export function createAgentProjector(): AgentProjector { // ----------------------------------------------------------------------- case 'session.meta.updated': { // The daemon auto-generates a title from the first prompt (and other - // clients can rename a session). It announces both via this event. We + // clients can rename a session); it also reports the latest user prompt + // via patch.lastPrompt. It announces all of these via this event. We // don't have the full AppSession here, so emit a lightweight - // sessionMetaUpdated that patches only the title field. + // sessionMetaUpdated that patches only the changed meta fields. const title: string | undefined = p?.patch?.title ?? p?.title; - if (typeof title === 'string' && title.length > 0) { - out.push({ type: 'sessionMetaUpdated', sessionId, title }); + const lastPrompt: string | undefined = p?.patch?.lastPrompt; + const patch: { title?: string; lastPrompt?: string } = {}; + if (typeof title === 'string' && title.length > 0) patch.title = title; + if (typeof lastPrompt === 'string') patch.lastPrompt = lastPrompt; + if (patch.title !== undefined || patch.lastPrompt !== undefined) { + out.push({ type: 'sessionMetaUpdated', sessionId, ...patch }); } break; } diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 7bb394ca9..6c50be965 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -259,11 +259,16 @@ export function reduceAppEvent( // ------------------------------------------------------------------------- case 'sessionMetaUpdated': { - // Lightweight title patch — the daemon's auto-generated title (or a title - // changed by another client) arrives via session.meta.updated. We patch - // only the title field; the full session object stays as-is. + // Lightweight meta patch — the daemon's auto-generated title (or a title + // changed by another client) and the latest user prompt arrive via + // session.meta.updated. We keep prior values for any field the event does + // not carry; the full session object otherwise stays as-is. Keeping + // lastPrompt fresh lets sidebar search match the most recent prompt + // without a full reload. next.sessions = next.sessions.map((s) => - s.id === event.sessionId ? { ...s, title: event.title } : s, + s.id === event.sessionId + ? { ...s, title: event.title ?? s.title, lastPrompt: event.lastPrompt ?? s.lastPrompt } + : s, ); break; } diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index 0670b8f34..2170bef43 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -99,6 +99,7 @@ export function toAppSession(wire: WireSession): AppSession { status: toAppSessionStatus(wire.status), archived: wire.archived ?? false, currentPromptId: wire.current_prompt_id, + lastPrompt: wire.last_prompt, cwd: wire.metadata.cwd, model: wire.agent_config.model, usage: toAppSessionUsage(wire.usage), diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index d76893b9e..f2f9195e8 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -69,6 +69,8 @@ export interface WireSession { status: WireSessionStatus; archived: boolean; current_prompt_id?: string; + /** Text of the most recent user prompt, for search/preview. */ + last_prompt?: string; // PRESUMED — daemon adds this once it ships the workspace registry; until then // it is absent and the client maps sessions by metadata.cwd === workspace.root. workspace_id?: string; diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index ade373520..ca243fd15 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -67,6 +67,8 @@ export interface AppSession { status: AppSessionStatus; archived: boolean; currentPromptId?: string; + /** Text of the most recent user prompt, for search/preview. */ + lastPrompt?: string; cwd: string; model: string; usage: AppSessionUsage; @@ -392,7 +394,7 @@ export type AppEvent = | { type: 'sessionUpdated'; session: AppSession; changedFields: string[] } | { type: 'sessionDeleted'; sessionId: string } | { type: 'sessionStatusChanged'; sessionId: string; status: AppSessionStatus; previousStatus: AppSessionStatus; currentPromptId?: string } - | { type: 'sessionMetaUpdated'; sessionId: string; title: string } + | { type: 'sessionMetaUpdated'; sessionId: string; title?: string; lastPrompt?: string } | { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string; swarmMode?: boolean; planMode?: boolean } | { type: 'historyCompacted'; sessionId: string; beforeSeq: number; reason: string; summaryMessageId?: string } | { type: 'compactionStarted'; sessionId: string; trigger: 'manual' | 'auto'; instruction?: string } diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 47c120018..db0afc1ce 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -3,14 +3,14 @@ The old workspace rail and workspace tabs have been removed; workspace switching, folding and renaming all live in the group header. --> + + + + From 6b68aa85e2a58cfdaacba5580f66a6a74550ccf6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 13:53:31 +0800 Subject: [PATCH 049/356] feat(cli): add -c as shorthand for --continue (#999) The lowercase -c now maps to --continue, shown in help as the primary short flag. The uppercase -C still works as a hidden alias since commander does not allow two short flags on a single option. --- .changeset/add-lowercase-continue-shorthand.md | 5 +++++ apps/kimi-code/src/cli/commands.ts | 5 +++-- apps/kimi-code/test/cli/options.test.ts | 4 ++++ docs/en/configuration/overrides.md | 2 +- docs/en/guides/getting-started.md | 4 ++-- docs/en/reference/kimi-command.md | 2 +- docs/zh/configuration/overrides.md | 2 +- docs/zh/guides/getting-started.md | 4 ++-- docs/zh/reference/kimi-command.md | 2 +- 9 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 .changeset/add-lowercase-continue-shorthand.md diff --git a/.changeset/add-lowercase-continue-shorthand.md b/.changeset/add-lowercase-continue-shorthand.md new file mode 100644 index 000000000..179c1c3c4 --- /dev/null +++ b/.changeset/add-lowercase-continue-shorthand.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add `-c` as a shorthand for `--continue`. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 3614eb217..2ca5dadd7 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -44,7 +44,8 @@ export function createProgram( .hideHelp() .argParser((val: string | boolean) => (val === true ? '' : (val as string))), ) - .option('-C, --continue', 'Continue the previous session for the working directory.', false) + .option('-c, --continue', 'Continue the previous session for the working directory.', false) + .addOption(new Option('-C').hideHelp().default(false)) .option('-y, --yolo', 'Automatically approve all actions.', false) .option('--auto', 'Start in auto permission mode.', false) .addOption( @@ -123,7 +124,7 @@ export function createProgram( const opts: CLIOptions = { session: sessionValue, - continue: raw['continue'] as boolean, + continue: raw['continue'] === true || raw['C'] === true, yolo: yoloValue, auto: autoValue, plan: raw['plan'] as boolean, diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index ad46d5dde..c82281932 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -155,6 +155,10 @@ describe('CLI options parsing', () => { expect(parse(['-C']).continue).toBe(true); }); + it('-c is an alias for --continue', () => { + expect(parse(['-c']).continue).toBe(true); + }); + it('--continue and --session combined raises a conflict', () => { const opts = parse(['--continue', '--session', 'abc123']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); diff --git a/docs/en/configuration/overrides.md b/docs/en/configuration/overrides.md index f217e1f59..0e7e34ca9 100644 --- a/docs/en/configuration/overrides.md +++ b/docs/en/configuration/overrides.md @@ -54,7 +54,7 @@ Options passed at startup have the highest priority and apply only to the curren | Option | Effect | | --- | --- | | `-S, --session [id]` | Resume a specific session; enters interactive selection when no id is given | -| `-C, --continue` | Resume the last session for the current working directory | +| `-c, --continue` | Resume the last session for the current working directory | | `-y, --yolo` | Auto-approve all tool calls | | `--plan` | Start in Plan mode | | `-m, --model ` | Use a specific model alias for this session | diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index d2ee52a6b..7a1dddda4 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -88,10 +88,10 @@ To run a single instruction without entering the interactive UI, use `-p`: kimi -p "Take a look at this project's directory structure" ``` -To resume the previous session, add `-C`: +To resume the previous session, add `-c`: ```sh -kimi -C +kimi -c ``` On first launch you need to configure an API source. In the interactive UI, enter `/login` to begin the login flow: diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index cc38781b7..54b139a0c 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -16,7 +16,7 @@ All flags are optional — run `kimi` directly to enter an interactive session: | `--version` | `-V` | Print the version number and exit | | `--help` | `-h` | Show help information and exit | | `--session [id]` | `-S` | Resume a session. With an ID, opens that session directly; without an ID, enters an interactive selector | -| `--continue` | `-C` | Continue the most recent session in the current working directory, without specifying an ID manually | +| `--continue` | `-c` | Continue the most recent session in the current working directory, without specifying an ID manually | | `--model ` | `-m` | Specify a model alias for this launch. When omitted, new sessions use `default_model` from the config file | | `--prompt ` | `-p` | Run a single prompt non-interactively and stream the Assistant output to stdout. This mode does not open the TUI | | `--output-format ` | | Set the non-interactive output format; supports `text` and `stream-json`. Can only be used with `--prompt`; defaults to `text` | diff --git a/docs/zh/configuration/overrides.md b/docs/zh/configuration/overrides.md index 8bb7a511f..92eb0b138 100644 --- a/docs/zh/configuration/overrides.md +++ b/docs/zh/configuration/overrides.md @@ -54,7 +54,7 @@ Kimi Code CLI 有三个地方可以影响运行参数:配置文件、命令行 | 选项 | 作用 | | --- | --- | | `-S, --session [id]` | 恢复指定会话;不带 id 时进入交互式选择 | -| `-C, --continue` | 续上当前目录的上一次会话 | +| `-c, --continue` | 续上当前目录的上一次会话 | | `-y, --yolo` | 自动批准所有工具调用 | | `--plan` | 以 Plan 模式启动 | | `-m, --model ` | 指定本次使用的模型别名 | diff --git a/docs/zh/guides/getting-started.md b/docs/zh/guides/getting-started.md index 229bde7db..c2e0e75e2 100644 --- a/docs/zh/guides/getting-started.md +++ b/docs/zh/guides/getting-started.md @@ -88,10 +88,10 @@ kimi kimi -p "帮我看一下这个项目的目录结构" ``` -继续上一次会话加 `-C`: +继续上一次会话加 `-c`: ```sh -kimi -C +kimi -c ``` 首次启动时需要配置 API 来源。在交互界面中输入 `/login` 进入登录流程: diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 5cec0fa35..b410844d8 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -16,7 +16,7 @@ kimi [options] | `--version` | `-V` | 打印版本号并退出 | | `--help` | `-h` | 显示帮助信息并退出 | | `--session [id]` | `-S` | 恢复一个会话。带 ID 时直接打开指定会话;不带 ID 时进入交互式选择器 | -| `--continue` | `-C` | 继续当前工作目录下最近一次的会话,无需手动指定 ID | +| `--continue` | `-c` | 继续当前工作目录下最近一次的会话,无需手动指定 ID | | `--model ` | `-m` | 为本次启动指定模型别名。省略时新会话使用配置文件中的 `default_model` | | `--prompt ` | `-p` | 非交互执行单次 prompt,并把 Assistant 输出流式写到 stdout。该模式不会打开 TUI | | `--output-format ` | | 设置非交互输出格式,支持 `text` 与 `stream-json`。仅可与 `--prompt` 一起使用,默认 `text` | From b84704bff39ae5cb382d2a8dc0911db286e84ead Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 23 Jun 2026 15:07:40 +0800 Subject: [PATCH 050/356] perf(kaos): optimize large file reads (#971) --- .changeset/large-file-reads.md | 5 + .../agent-core/src/tools/builtin/file/read.ts | 167 ++++++++-- packages/agent-core/test/tools/read.test.ts | 107 +++++++ packages/kaos/src/internal.ts | 5 +- packages/kaos/src/local.ts | 301 +++++++++++++++++- packages/kaos/test/local.test.ts | 158 +++++++++ 6 files changed, 699 insertions(+), 44 deletions(-) create mode 100644 .changeset/large-file-reads.md diff --git a/.changeset/large-file-reads.md b/.changeset/large-file-reads.md new file mode 100644 index 000000000..eaf25643b --- /dev/null +++ b/.changeset/large-file-reads.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Read large text files in bounded memory and read tail lines without scanning whole files. diff --git a/packages/agent-core/src/tools/builtin/file/read.ts b/packages/agent-core/src/tools/builtin/file/read.ts index 252eef3af..d6abd5764 100644 --- a/packages/agent-core/src/tools/builtin/file/read.ts +++ b/packages/agent-core/src/tools/builtin/file/read.ts @@ -83,6 +83,25 @@ type TextPreviewKaos = Kaos & { readTextPreview?: (path: string, n: number) => Promise; }; +interface TextFileScan { + totalLines: number; + endsWithNewline: boolean; + hasNul: boolean; + lineEndingFlags: LineEndingFlags; +} + +type RangeReadKaos = TextPreviewKaos & { + scanTextFile?: (path: string) => Promise; + readLineRange?: ( + path: string, + options: { startLine: number; maxLines: number; errors?: 'strict' | 'replace' | 'ignore' }, + ) => AsyncGenerator; + readTailLines?: ( + path: string, + options: { tailCount: number; errors?: 'strict' | 'replace' | 'ignore' }, + ) => AsyncGenerator; +}; + async function readTextHeader(kaos: TextPreviewKaos, path: string, n: number): Promise { if (kaos.readTextPreview !== undefined) { return kaos.readTextPreview(path, n); @@ -141,6 +160,41 @@ function renderedLineBytes(renderedLine: string, isFirst: boolean): number { return (isFirst ? 0 : 1) + Buffer.byteLength(renderedLine, 'utf8'); } +function renderEntries( + entries: readonly ReadLineEntry[], + lineEndingStyle: LineEndingStyle, +): { + renderedLines: string[]; + truncatedLineNumbers: number[]; + maxBytesReached: boolean; +} { + const renderedLines: string[] = []; + const truncatedLineNumbers: number[] = []; + let bytes = 0; + let maxBytesReached = false; + + for (const entry of entries) { + const rendered = renderLine(entry, lineEndingStyle); + const lineBytes = renderedLineBytes(rendered.line, renderedLines.length === 0); + if (renderedLines.length > 0 && bytes + lineBytes > MAX_BYTES) { + maxBytesReached = true; + break; + } + + if (rendered.wasTruncated) { + truncatedLineNumbers.push(entry.lineNo); + } + renderedLines.push(rendered.line); + bytes += lineBytes; + if (bytes >= MAX_BYTES) { + maxBytesReached = true; + break; + } + } + + return { renderedLines, truncatedLineNumbers, maxBytesReached }; +} + function isRegularFileMode(stMode: number): boolean { return (stMode & S_IFMT) === S_IFREG; } @@ -275,6 +329,36 @@ export class ReadTool implements BuiltinTool { effectiveLimit: number, requestedLines: number, ): Promise { + const rangeKaos = this.kaos as RangeReadKaos; + if (rangeKaos.scanTextFile !== undefined && rangeKaos.readLineRange !== undefined) { + const scan = await rangeKaos.scanTextFile(safePath); + if (scan.hasNul) { + return { isError: true, output: notReadableFileOutput(displayPath) }; + } + const selectedEntries: ReadLineEntry[] = []; + let lineNo = lineOffset; + for await (const rawLine of rangeKaos.readLineRange(safePath, { + startLine: lineOffset, + maxLines: effectiveLimit, + errors: 'strict', + })) { + selectedEntries.push({ lineNo, rawContent: stripTrailingLf(rawLine) }); + lineNo += 1; + } + const lineEndingStyle = lineEndingStyleFromFlags(scan.lineEndingFlags); + const rendered = renderEntries(selectedEntries, lineEndingStyle); + return this.finishReadResult({ + renderedLines: rendered.renderedLines, + truncatedLineNumbers: rendered.truncatedLineNumbers, + maxLinesReached: effectiveLimit >= MAX_LINES && lineOffset + MAX_LINES <= scan.totalLines, + maxBytesReached: rendered.maxBytesReached, + lineEndingStyle, + startLine: selectedEntries.length > 0 ? lineOffset : 0, + totalLines: scan.totalLines, + requestedLines, + }); + } + const selectedEntries: ReadLineEntry[] = []; const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; let currentLineNo = 0; @@ -311,37 +395,15 @@ export class ReadTool implements BuiltinTool { } const lineEndingStyle = lineEndingStyleFromFlags(flags); - const renderedLines: string[] = []; - const truncatedLineNumbers: number[] = []; - let bytes = 0; - let maxBytesReached = false; - - for (const entry of selectedEntries) { - const rendered = renderLine(entry, lineEndingStyle); - const lineBytes = renderedLineBytes(rendered.line, renderedLines.length === 0); - if (renderedLines.length > 0 && bytes + lineBytes > MAX_BYTES) { - maxBytesReached = true; - break; - } - - if (rendered.wasTruncated) { - truncatedLineNumbers.push(entry.lineNo); - } - renderedLines.push(rendered.line); - bytes += lineBytes; - if (bytes >= MAX_BYTES) { - maxBytesReached = true; - break; - } - } + const rendered = renderEntries(selectedEntries, lineEndingStyle); return this.finishReadResult({ - renderedLines, - truncatedLineNumbers, + renderedLines: rendered.renderedLines, + truncatedLineNumbers: rendered.truncatedLineNumbers, maxLinesReached, - maxBytesReached, + maxBytesReached: rendered.maxBytesReached, lineEndingStyle, - startLine: renderedLines.length > 0 ? lineOffset : 0, + startLine: selectedEntries.length > 0 ? lineOffset : 0, totalLines: currentLineNo, requestedLines, }); @@ -355,6 +417,33 @@ export class ReadTool implements BuiltinTool { requestedLines: number, ): Promise { const tailCount = Math.abs(lineOffset); + const rangeKaos = this.kaos as RangeReadKaos; + if (rangeKaos.scanTextFile !== undefined && rangeKaos.readTailLines !== undefined) { + const scan = await rangeKaos.scanTextFile(safePath); + if (scan.hasNul) { + return { isError: true, output: notReadableFileOutput(displayPath) }; + } + const rawLines: string[] = []; + for await (const rawLine of rangeKaos.readTailLines(safePath, { + tailCount, + errors: 'strict', + })) { + rawLines.push(rawLine); + } + const startLine = Math.max(1, scan.totalLines - rawLines.length + 1); + const entries = rawLines.map((rawLine, index) => ({ + lineNo: startLine + index, + rawContent: stripTrailingLf(rawLine), + })); + return this.finishTailEntries({ + entries, + lineEndingFlags: scan.lineEndingFlags, + effectiveLimit, + totalLines: scan.totalLines, + requestedLines, + }); + } + const entries: ReadLineEntry[] = []; const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; let currentLineNo = 0; @@ -374,8 +463,24 @@ export class ReadTool implements BuiltinTool { } } - const lineEndingStyle = lineEndingStyleFromFlags(flags); - let renderedCandidates = entries.slice(0, effectiveLimit).map((entry) => { + return this.finishTailEntries({ + entries, + lineEndingFlags: flags, + effectiveLimit, + totalLines: currentLineNo, + requestedLines, + }); + } + + private finishTailEntries(input: { + entries: readonly ReadLineEntry[]; + lineEndingFlags: LineEndingFlags; + effectiveLimit: number; + totalLines: number; + requestedLines: number; + }): ExecutableToolResult { + const lineEndingStyle = lineEndingStyleFromFlags(input.lineEndingFlags); + let renderedCandidates = input.entries.slice(0, input.effectiveLimit).map((entry) => { return { entry, rendered: renderLine(entry, lineEndingStyle) }; }); @@ -416,8 +521,8 @@ export class ReadTool implements BuiltinTool { maxBytesReached, lineEndingStyle, startLine: renderedCandidates[0]?.entry.lineNo ?? 0, - totalLines: currentLineNo, - requestedLines, + totalLines: input.totalLines, + requestedLines: input.requestedLines, }); } diff --git a/packages/agent-core/test/tools/read.test.ts b/packages/agent-core/test/tools/read.test.ts index 54e1fabdd..a3ba41615 100644 --- a/packages/agent-core/test/tools/read.test.ts +++ b/packages/agent-core/test/tools/read.test.ts @@ -636,6 +636,113 @@ describe('ReadTool', () => { expect(readText).not.toHaveBeenCalled(); }); + it('uses range reader when available without consuming readLines', async () => { + const content = Array.from({ length: 20 }, (_, i) => `line ${String(i + 1)}`).join('\n'); + const bytes = Buffer.from(content, 'utf8'); + const readLines = vi.fn(); + const readLineRange = vi.fn(async function* readLineRange( + _path: string, + options: { startLine: number; maxLines: number }, + ): AsyncGenerator { + for (let i = options.startLine; i < options.startLine + options.maxLines; i += 1) { + yield `line ${String(i)}\n`; + } + }); + const scanTextFile = vi.fn(async () => ({ + totalLines: 20, + endsWithNewline: false, + hasNul: false, + lineEndingFlags: { hasCrLf: false, hasLf: true, hasLoneCr: false }, + })); + const tool = new ReadTool( + createFakeKaos({ + stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), + readBytes: vi.fn().mockImplementation(async (_path, n) => { + return n === undefined ? bytes : bytes.subarray(0, n); + }), + readLines, + scanTextFile, + readLineRange, + } as unknown as Partial), + PERMISSIVE_WORKSPACE, + ); + + const result = await executeTool(tool, context({ path: '/tmp/range.txt', line_offset: 5, n_lines: 3 })); + const output = toolContentString(result); + + expect(output).toContain('5\tline 5'); + expect(output).toContain('7\tline 7'); + expect(output).not.toContain('8\tline 8'); + expect(readLineRange).toHaveBeenCalledWith('/tmp/range.txt', { + startLine: 5, + maxLines: 3, + errors: 'strict', + }); + expect(readLines).not.toHaveBeenCalled(); + }); + + it('uses tail reader when available without consuming readLines', async () => { + const content = Array.from({ length: 20 }, (_, i) => `line ${String(i + 1)}`).join('\n'); + const bytes = Buffer.from(content, 'utf8'); + const readLines = vi.fn(); + const readTailLines = vi.fn(async function* readTailLines(): AsyncGenerator { + yield 'line 18\n'; + yield 'line 19\n'; + yield 'line 20'; + }); + const scanTextFile = vi.fn(async () => ({ + totalLines: 20, + endsWithNewline: false, + hasNul: false, + lineEndingFlags: { hasCrLf: false, hasLf: true, hasLoneCr: false }, + })); + const tool = new ReadTool( + createFakeKaos({ + stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), + readBytes: vi.fn().mockImplementation(async (_path, n) => { + return n === undefined ? bytes : bytes.subarray(0, n); + }), + readLines, + scanTextFile, + readTailLines, + } as unknown as Partial), + PERMISSIVE_WORKSPACE, + ); + + const result = await executeTool(tool, context({ path: '/tmp/tail.txt', line_offset: -3 })); + const output = toolContentString(result); + + expect(output).toContain('18\tline 18'); + expect(output).toContain('20\tline 20'); + expect(readTailLines).toHaveBeenCalledWith('/tmp/tail.txt', { tailCount: 3, errors: 'strict' }); + expect(readLines).not.toHaveBeenCalled(); + }); + + it('short-circuits on scan NUL before range read', async () => { + const readLineRange = vi.fn(); + const tool = new ReadTool( + createFakeKaos({ + stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), + readBytes: vi.fn().mockResolvedValue(Buffer.from('text')), + scanTextFile: vi.fn(async () => ({ + totalLines: 1, + endsWithNewline: false, + hasNul: true, + lineEndingFlags: { hasCrLf: false, hasLf: false, hasLoneCr: false }, + })), + readLineRange, + } as unknown as Partial), + PERMISSIVE_WORKSPACE, + ); + + const result = await executeTool(tool, context({ path: '/tmp/nul.txt' })); + const output = toolContentString(result); + + expect(result.isError).toBe(true); + expect(output).toContain('is not readable as UTF-8 text'); + expect(readLineRange).not.toHaveBeenCalled(); + }); + it('caps default reads at MAX_LINES', async () => { const content = Array.from({ length: MAX_LINES + 1 }, (_, i) => `line ${String(i + 1)}`).join( '\n', diff --git a/packages/kaos/src/internal.ts b/packages/kaos/src/internal.ts index 1a4742e18..6c89acf27 100644 --- a/packages/kaos/src/internal.ts +++ b/packages/kaos/src/internal.ts @@ -136,6 +136,7 @@ export function decodeTextWithErrors( data: Buffer, encoding: BufferEncoding, errors: 'strict' | 'replace' | 'ignore' = 'strict', + ignoreBOM: boolean = false, ): string { // Map Node's BufferEncoding names to Web TextDecoder labels where the two // diverge. Only UTF-family encodings participate in the strict/replace/ @@ -163,7 +164,7 @@ export function decodeTextWithErrors( } if (errors === 'strict') { - return new TextDecoder(webLabel, { fatal: true }).decode(data); + return new TextDecoder(webLabel, { fatal: true, ignoreBOM }).decode(data); } // 'ignore' must skip invalid input bytes/code units, not delete every @@ -174,7 +175,7 @@ export function decodeTextWithErrors( } // 'replace' → substitute each invalid sequence with U+FFFD (default). - return new TextDecoder(webLabel, { fatal: false }).decode(data); + return new TextDecoder(webLabel, { fatal: false, ignoreBOM }).decode(data); } /** diff --git a/packages/kaos/src/local.ts b/packages/kaos/src/local.ts index 484f0b090..ba3c7f29d 100644 --- a/packages/kaos/src/local.ts +++ b/packages/kaos/src/local.ts @@ -22,6 +22,22 @@ import type { KaosProcess } from './process'; import type { StatResult } from './types'; const isWindows: boolean = process.platform === 'win32'; +const READ_CHUNK_SIZE = 64 * 1024; + +type TextDecodeErrors = 'strict' | 'replace' | 'ignore'; + +interface LineEndingFlags { + hasCrLf: boolean; + hasLf: boolean; + hasLoneCr: boolean; +} + +interface TextFileScan { + totalLines: number; + endsWithNewline: boolean; + hasNul: boolean; + lineEndingFlags: LineEndingFlags; +} /** * Build the `(dev, ino)` cycle-detection key used by `_globWalk`'s @@ -456,22 +472,173 @@ export class LocalKaos implements Kaos { async *readLines( path: string, - options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, + options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, ): AsyncGenerator { const resolved = this._resolvePath(path); const encoding = options?.encoding ?? 'utf-8'; const errors = options?.errors ?? 'strict'; - const buf = await readFile(resolved); - const content = decodeTextWithErrors(buf, encoding, errors); - const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line === undefined) continue; - if (i < lines.length - 1) { - yield line + '\n'; - } else if (line !== '') { - yield line; + + if (!isUtf8Encoding(encoding)) { + const content = decodeTextWithErrors(await readFile(resolved), encoding, errors); + yield* splitLinesKeepingTerminator(content); + return; + } + + yield* this._readUtf8Lines(resolved, errors); + } + + async scanTextFile(path: string): Promise { + const resolved = this._resolvePath(path); + const fh = await open(resolved, 'r'); + try { + const buf = Buffer.alloc(READ_CHUNK_SIZE); + const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; + const validator = createUtf8Validator(); + let totalLines = 0; + let totalBytes = 0; + let endsWithNewline = false; + let hasNul = false; + let prevWasCr = false; + + while (true) { + const { bytesRead } = await fh.read(buf, 0, buf.length, null); + if (bytesRead === 0) break; + const chunk = buf.subarray(0, bytesRead); + validator.write(chunk); + for (let i = 0; i < chunk.length; i += 1) { + const byte = chunk[i]; + if (byte === undefined) continue; + if (byte === 0) hasNul = true; + if (byte === 0x0a) totalLines += 1; + } + prevWasCr = updateLineEndingFlagsFromBytes(flags, chunk, prevWasCr); + totalBytes += bytesRead; + endsWithNewline = chunk[bytesRead - 1] === 0x0a; } + + if (prevWasCr) flags.hasLoneCr = true; + validator.end(); + if (totalBytes > 0 && !endsWithNewline) totalLines += 1; + return { totalLines, endsWithNewline, hasNul, lineEndingFlags: flags }; + } finally { + await fh.close(); + } + } + + async *readLineRange( + path: string, + options: { startLine: number; maxLines: number; errors?: TextDecodeErrors }, + ): AsyncGenerator { + const resolved = this._resolvePath(path); + const errors = options.errors ?? 'strict'; + yield* this._readUtf8Lines(resolved, errors, { + startLine: options.startLine, + maxLines: options.maxLines, + }); + } + + async *readTailLines( + path: string, + options: { tailCount: number; errors?: TextDecodeErrors }, + ): AsyncGenerator { + if (options.tailCount <= 0) return; + const resolved = this._resolvePath(path); + const errors = options.errors ?? 'strict'; + const fh = await open(resolved, 'r'); + try { + const s = await fh.stat(); + if (s.size === 0) return; + + let pos = s.size; + let foundLf = 0; + let startOffset = 0; + let needLf = options.tailCount; + let sawTailBlock = false; + + while (pos > 0 && foundLf < needLf) { + const readSize = Math.min(READ_CHUNK_SIZE, pos); + pos -= readSize; + const buf = Buffer.alloc(readSize); + await fh.read(buf, 0, readSize, pos); + if (!sawTailBlock) { + sawTailBlock = true; + const endsWithNewline = buf[readSize - 1] === 0x0a; + needLf = endsWithNewline ? options.tailCount + 1 : options.tailCount; + } + for (let i = readSize - 1; i >= 0; i -= 1) { + const byte = buf[i]; + if (byte !== 0x0a) continue; + foundLf += 1; + if (foundLf === needLf) { + startOffset = pos + i + 1; + break; + } + } + } + + if (foundLf < needLf) startOffset = 0; + const data = await readRange(fh, startOffset, s.size - startOffset); + const text = decodeTextWithErrors(data, 'utf-8', errors, startOffset !== 0); + yield* splitLinesKeepingTerminator(text); + } finally { + await fh.close(); + } + } + + private async *_readUtf8Lines( + resolved: string, + errors: TextDecodeErrors, + range?: { startLine?: number; maxLines?: number }, + ): AsyncGenerator { + const startLine = range?.startLine ?? 1; + const maxLines = range?.maxLines ?? Number.POSITIVE_INFINITY; + const fh = await open(resolved, 'r'); + try { + const buf = Buffer.alloc(READ_CHUNK_SIZE); + let pending: Buffer[] = []; + let pendingOffset = 0; + let fileOffset = 0; + let lineNo = 1; + let yielded = 0; + + while (true) { + const { bytesRead } = await fh.read(buf, 0, buf.length, null); + if (bytesRead === 0) break; + const chunk = buf.subarray(0, bytesRead); + let lineStart = 0; + + for (let i = 0; i < chunk.length; i += 1) { + const byte = chunk[i]; + if (byte !== 0x0a) continue; + const piece = chunk.subarray(lineStart, i + 1); + const lineOffset = pending.length === 0 ? fileOffset + lineStart : pendingOffset; + const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]); + if (lineNo >= startLine) { + yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0); + yielded += 1; + if (yielded >= maxLines) return; + } + pending = []; + lineStart = i + 1; + lineNo += 1; + } + + if (lineStart < chunk.length) { + const tail = Buffer.from(chunk.subarray(lineStart)); + if (pending.length === 0) pendingOffset = fileOffset + lineStart; + pending.push(tail); + } + fileOffset += bytesRead; + } + + if (pending.length > 0) { + const line = Buffer.concat(pending); + if (lineNo >= startLine) { + yield decodeTextWithErrors(line, 'utf-8', errors, pendingOffset !== 0); + } + } + } finally { + await fh.close(); } } @@ -597,6 +764,118 @@ export class LocalKaos implements Kaos { } } +function isUtf8Encoding(encoding: BufferEncoding): boolean { + return encoding === 'utf-8' || encoding === 'utf8'; +} + +function* splitLinesKeepingTerminator(text: string): Generator { + if (text.length === 0) return; + let start = 0; + for (let i = 0; i < text.length; i += 1) { + if (text.codePointAt(i) === 0x0a) { + yield text.slice(start, i + 1); + start = i + 1; + } + } + if (start < text.length) { + yield text.slice(start); + } +} + +function updateLineEndingFlagsFromBytes( + flags: LineEndingFlags, + chunk: Buffer, + prevWasCr: boolean, +): boolean { + for (let i = 0; i < chunk.length; i += 1) { + const byte = chunk[i]; + if (byte === undefined) continue; + if (byte === 0x0d) { + if (prevWasCr) flags.hasLoneCr = true; + prevWasCr = true; + } else if (byte === 0x0a) { + if (prevWasCr) { + flags.hasCrLf = true; + } else { + flags.hasLf = true; + } + prevWasCr = false; + } else { + if (prevWasCr) flags.hasLoneCr = true; + prevWasCr = false; + } + } + return prevWasCr; +} + +function createUtf8Validator(): { write(chunk: Buffer): void; end(): void } { + let needed = 0; + let lower = 0x80; + let upper = 0xbf; + + const fail = (): never => { + throw new TypeError('Invalid UTF-8 data'); + }; + + return { + write(chunk: Buffer): void { + for (let i = 0; i < chunk.length; i += 1) { + const byte = chunk[i]; + if (byte === undefined) continue; + if (needed === 0) { + if (byte <= 0x7f) continue; + if (byte >= 0xc2 && byte <= 0xdf) { + needed = 1; + } else if (byte === 0xe0) { + needed = 2; + lower = 0xa0; + } else if (byte >= 0xe1 && byte <= 0xec) { + needed = 2; + } else if (byte === 0xed) { + needed = 2; + upper = 0x9f; + } else if (byte >= 0xee && byte <= 0xef) { + needed = 2; + } else if (byte === 0xf0) { + needed = 3; + lower = 0x90; + } else if (byte >= 0xf1 && byte <= 0xf3) { + needed = 3; + } else if (byte === 0xf4) { + needed = 3; + upper = 0x8f; + } else { + fail(); + } + } else { + if (byte < lower || byte > upper) fail(); + lower = 0x80; + upper = 0xbf; + needed -= 1; + } + } + }, + end(): void { + if (needed !== 0) fail(); + }, + }; +} + +async function readRange( + fh: Awaited>, + start: number, + length: number, +): Promise { + const data = Buffer.alloc(length); + let offset = 0; + while (offset < length) { + const { bytesRead } = await fh.read(data, offset, length - offset, start + offset); + if (bytesRead === 0) break; + offset += bytesRead; + } + return offset === length ? data : data.subarray(0, offset); +} + // Wait for a freshly spawned ChildProcess to either emit 'spawn' (success) or // 'error' (ENOENT / EACCES / etc.). Until this resolves, callers should not // assume the child is running — they may otherwise write to the stdin of a diff --git a/packages/kaos/test/local.test.ts b/packages/kaos/test/local.test.ts index 01f192f88..cb2d23e93 100644 --- a/packages/kaos/test/local.test.ts +++ b/packages/kaos/test/local.test.ts @@ -176,6 +176,164 @@ describe('LocalKaos', () => { }); }); + describe('readLines streaming', () => { + async function collectLines(path: string, options?: Parameters[1]) { + const lines: string[] = []; + for await (const line of kaos.readLines(path, options)) { + lines.push(line); + } + return lines; + } + + it('preserves content exactly across representative line endings', async () => { + const fixtures: Array<[string, string]> = [ + ['multiline', 'line1\nline2\nline3\n'], + ['no trailing newline', 'line1\nline2'], + ['single line', 'only'], + ['single newline', '\n'], + ['empty', ''], + ['crlf', 'a\r\nb\r\n'], + ['lone cr', 'a\rB\n'], + ]; + for (const [name, content] of fixtures) { + const filePath = join(tempDir, `${name}.txt`); + await kaos.writeText(filePath, content); + expect((await collectLines(filePath)).join('')).toBe(content); + } + }); + + it('preserves multibyte characters and long single lines across chunk boundaries', async () => { + const filePath = join(tempDir, 'boundary.txt'); + const content = `${'a'.repeat(65535)}😀\n${'x'.repeat(200000)}`; + await kaos.writeText(filePath, content); + await expect(collectLines(filePath)).resolves.toEqual([ + `${'a'.repeat(65535)}😀\n`, + 'x'.repeat(200000), + ]); + }); + + it('preserves U+FEFF at the start of a non-first line', async () => { + const filePath = join(tempDir, 'bom-line.txt'); + const content = 'a\n\uFEFFb\n'; + await kaos.writeText(filePath, content); + await expect(collectLines(filePath)).resolves.toEqual(['a\n', '\uFEFFb\n']); + }); + + it('keeps utf16le and hex on the decode-then-split path', async () => { + const utf16Path = join(tempDir, 'utf16le.txt'); + await kaos.writeBytes(utf16Path, Buffer.from('a\n\u0A41\n', 'utf16le')); + await expect(collectLines(utf16Path, { encoding: 'utf16le' })).resolves.toEqual([ + 'a\n', + 'ੁ\n', + ]); + + const hexPath = join(tempDir, 'hex.txt'); + await kaos.writeBytes(hexPath, Buffer.from('a\nb')); + await expect(collectLines(hexPath, { encoding: 'hex' })).resolves.toEqual(['610a62']); + }); + + it('throws lazily when strict UTF-8 errors appear after the first line', async () => { + const filePath = join(tempDir, 'invalid-after-first-line.txt'); + await kaos.writeBytes(filePath, Buffer.concat([Buffer.from('ok\n', 'utf-8'), Buffer.from([0xff])])); + const gen = kaos.readLines(filePath); + await expect(gen.next()).resolves.toMatchObject({ value: 'ok\n', done: false }); + await expect(gen.next()).rejects.toThrow(); + }); + }); + + describe('scanTextFile', () => { + it('counts lines and classifies line endings', async () => { + const lf = join(tempDir, 'lf.txt'); + await kaos.writeText(lf, 'a\nb'); + await expect(kaos.scanTextFile(lf)).resolves.toMatchObject({ + totalLines: 2, + endsWithNewline: false, + hasNul: false, + lineEndingFlags: { hasCrLf: false, hasLf: true, hasLoneCr: false }, + }); + + const crlf = join(tempDir, 'crlf.txt'); + await kaos.writeText(crlf, 'a\r\nb\r\n'); + await expect(kaos.scanTextFile(crlf)).resolves.toMatchObject({ + totalLines: 2, + endsWithNewline: true, + lineEndingFlags: { hasCrLf: true, hasLf: false, hasLoneCr: false }, + }); + + const loneCr = join(tempDir, 'lone-cr.txt'); + await kaos.writeText(loneCr, 'a\rB\n'); + await expect(kaos.scanTextFile(loneCr)).resolves.toMatchObject({ + totalLines: 1, + lineEndingFlags: { hasCrLf: false, hasLf: true, hasLoneCr: true }, + }); + }); + + it('detects NUL and invalid UTF-8', async () => { + const nul = join(tempDir, 'nul.txt'); + await kaos.writeBytes(nul, Buffer.from('a\u0000b\n', 'utf-8')); + await expect(kaos.scanTextFile(nul)).resolves.toMatchObject({ hasNul: true }); + + const invalid = join(tempDir, 'invalid.txt'); + await kaos.writeBytes(invalid, Buffer.from([0xff])); + await expect(kaos.scanTextFile(invalid)).rejects.toThrow(); + }); + }); + + describe('readLineRange', () => { + async function collectRange(path: string, startLine: number, maxLines: number) { + const lines: string[] = []; + for await (const line of kaos.readLineRange(path, { startLine, maxLines })) { + lines.push(line); + } + return lines; + } + + it('reads only the requested line window', async () => { + const filePath = join(tempDir, 'range.txt'); + await kaos.writeText(filePath, 'a\nb\nc\nd\n'); + await expect(collectRange(filePath, 2, 2)).resolves.toEqual(['b\n', 'c\n']); + await expect(collectRange(filePath, 5, 2)).resolves.toEqual([]); + }); + + it('preserves U+FEFF at the start of a ranged non-first line', async () => { + const filePath = join(tempDir, 'range-bom.txt'); + await kaos.writeText(filePath, 'a\n\uFEFFb\n'); + await expect(collectRange(filePath, 2, 1)).resolves.toEqual(['\uFEFFb\n']); + }); + }); + + describe('readTailLines', () => { + async function collectTail(path: string, tailCount: number) { + const lines: string[] = []; + for await (const line of kaos.readTailLines(path, { tailCount })) { + lines.push(line); + } + return lines; + } + + it('reads last lines with and without trailing newline', async () => { + const trailing = join(tempDir, 'tail-trailing.txt'); + await kaos.writeText(trailing, 'a\nb\nc\n'); + await expect(collectTail(trailing, 2)).resolves.toEqual(['b\n', 'c\n']); + + const noTrailing = join(tempDir, 'tail-no-trailing.txt'); + await kaos.writeText(noTrailing, 'a\nb\nc'); + await expect(collectTail(noTrailing, 2)).resolves.toEqual(['b\n', 'c']); + }); + + it('returns the whole file when tailCount exceeds line count', async () => { + const filePath = join(tempDir, 'tail-short.txt'); + await kaos.writeText(filePath, 'a\nb\n'); + await expect(collectTail(filePath, 5)).resolves.toEqual(['a\n', 'b\n']); + }); + + it('preserves CRLF and U+FEFF in tail lines', async () => { + const filePath = join(tempDir, 'tail-crlf-bom.txt'); + await kaos.writeText(filePath, 'a\r\n\uFEFFb\r\n'); + await expect(collectTail(filePath, 1)).resolves.toEqual(['\uFEFFb\r\n']); + }); + }); + describe('readText errors parameter (Python compat)', () => { // A file with a valid UTF-8 prefix "中", an invalid standalone byte 0xff, // and a valid UTF-8 suffix "文". Under strict decoding this throws. From e15edfd017506fde396b8b0dcf68008b61b39752 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 15:09:55 +0800 Subject: [PATCH 051/356] fix: always expose the free-text Other option in question prompts (#1003) The question adapter only set allow_other on the wire when the SDK item carried an otherLabel/otherDescription, but the AskUserQuestion tool never provides those fields. Web clients honor allow_other, so the free-text option silently disappeared in the web UI while the TUI (which renders it unconditionally) kept working. Set allow_other unconditionally to match the tool's 'users always have an Other option' contract. --- .changeset/fix-web-question-other-option.md | 5 +++++ packages/agent-core/src/services/question/question.ts | 10 ++-------- .../agent-core/test/services/question-adapter.test.ts | 3 +++ 3 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-web-question-other-option.md diff --git a/.changeset/fix-web-question-other-option.md b/.changeset/fix-web-question-other-option.md new file mode 100644 index 000000000..12bf99b30 --- /dev/null +++ b/.changeset/fix-web-question-other-option.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web question prompt missing the free-text Other option. diff --git a/packages/agent-core/src/services/question/question.ts b/packages/agent-core/src/services/question/question.ts index 772502556..23fbc9185 100644 --- a/packages/agent-core/src/services/question/question.ts +++ b/packages/agent-core/src/services/question/question.ts @@ -142,14 +142,8 @@ function buildItem( if (item.header !== undefined) out.header = item.header; if (item.body !== undefined) out.body = item.body; if (item.multiSelect !== undefined) out.multi_select = item.multiSelect; - // SDK has no `allowOther` field — `otherLabel` / `otherDescription` exist - // and we expose them on the wire alongside an inferred `allow_other: true` - // when either tag is set. (SDK semantics: presence of `otherLabel` enables - // the "Other" affordance; we surface that explicitly on the wire so client - // renderers don't have to infer.) - const hasOtherAffordance = - item.otherLabel !== undefined || item.otherDescription !== undefined; - if (hasOtherAffordance) out.allow_other = true; + // SDK has no allowOther field; always advertise the free-text Other option on the wire. + out.allow_other = true; if (item.otherLabel !== undefined) out.other_label = item.otherLabel; if (item.otherDescription !== undefined) out.other_description = item.otherDescription; return out; diff --git a/packages/agent-core/test/services/question-adapter.test.ts b/packages/agent-core/test/services/question-adapter.test.ts index 1a97c8a0e..e5a6b5d4f 100644 --- a/packages/agent-core/test/services/question-adapter.test.ts +++ b/packages/agent-core/test/services/question-adapter.test.ts @@ -63,6 +63,9 @@ describe('question-adapter · toBrokerRequest (in-process → protocol)', () => expect(protoReq.questions[0]?.header).toBe('Pets'); expect(protoReq.questions[0]?.body).toBe('pick one'); expect(protoReq.questions[0]?.multi_select).toBe(false); + // Other affordance is always on, even when the SDK item has no otherLabel. + expect(protoReq.questions[0]?.allow_other).toBe(true); + expect(protoReq.questions[0]?.other_label).toBeUndefined(); expect(protoReq.questions[1]?.id).toBe('q_1'); expect(protoReq.questions[1]?.options.map((o) => o.id)).toEqual([ From ea1b33b6743b822aa5083dbeb2d5e84a78b0ab3d Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 15:20:00 +0800 Subject: [PATCH 052/356] refactor(web): extract pure turn-rendering helpers from ChatPane (#1001) * refactor(web): extract pure turn-rendering helpers from ChatPane * chore: add changeset for chat pane helper extraction * test(kimi-web): cover chat turn-rendering helpers Pure-logic tests for the helpers extracted from ChatPane, focused on assistantRenderBlocks (tool-stack grouping, interrupt/media break, single tool) plus the formatting/boundary helpers. Doubles as a safety net confirming the extraction preserved behavior. --- .changeset/web-split-chatpane-helpers.md | 5 + apps/kimi-web/src/components/ChatPane.vue | 147 ++------------ .../src/components/chatTurnRendering.ts | 139 ++++++++++++++ .../kimi-web/test/chat-turn-rendering.test.ts | 179 ++++++++++++++++++ 4 files changed, 335 insertions(+), 135 deletions(-) create mode 100644 .changeset/web-split-chatpane-helpers.md create mode 100644 apps/kimi-web/src/components/chatTurnRendering.ts create mode 100644 apps/kimi-web/test/chat-turn-rendering.test.ts diff --git a/.changeset/web-split-chatpane-helpers.md b/.changeset/web-split-chatpane-helpers.md new file mode 100644 index 000000000..4606cde7e --- /dev/null +++ b/.changeset/web-split-chatpane-helpers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Extract pure turn-rendering helpers out of the chat pane into their own module. diff --git a/apps/kimi-web/src/components/ChatPane.vue b/apps/kimi-web/src/components/ChatPane.vue index 898776e3b..bd0cc5c14 100644 --- a/apps/kimi-web/src/components/ChatPane.vue +++ b/apps/kimi-web/src/components/ChatPane.vue @@ -2,7 +2,7 @@ + + + + From fd16ffb80a90fda8a611a27a158b9b7a33e13303 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 16:51:12 +0800 Subject: [PATCH 056/356] fix(tui): fix Tab key completion in the editor (#1012) Stop plain Tab from opening the file completion list when the autocomplete menu is closed; Tab now only accepts the selected item while the menu is open. After Tab-completing a slash command name, reopen the menu to show its subcommands instead of falling back to file completions. --- .changeset/fix-slash-subcommand-after-tab.md | 5 ++ .changeset/fix-tab-file-completion.md | 5 ++ .../tui/components/editor/custom-editor.ts | 52 ++++++++++-- .../components/editor/custom-editor.test.ts | 82 +++++++++++++++++++ 4 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-slash-subcommand-after-tab.md create mode 100644 .changeset/fix-tab-file-completion.md diff --git a/.changeset/fix-slash-subcommand-after-tab.md b/.changeset/fix-slash-subcommand-after-tab.md new file mode 100644 index 000000000..51a6dedcd --- /dev/null +++ b/.changeset/fix-slash-subcommand-after-tab.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show subcommand suggestions after Tab-completing a slash command name. diff --git a/.changeset/fix-tab-file-completion.md b/.changeset/fix-tab-file-completion.md new file mode 100644 index 000000000..5fd1a5183 --- /dev/null +++ b/.changeset/fix-tab-file-completion.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the Tab key unexpectedly opening the file completion list. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 28fba5fea..600817547 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -404,20 +404,54 @@ export class CustomEditor extends Editor { return; } + // Swallow Tab while the autocomplete dropdown is closed so it does not + // trigger pi-tui's built-in file completion. When the dropdown is open, + // fall through so pi-tui can still accept the selected item with Tab. + if (matchesKey(normalized, Key.tab) && !this.isShowingAutocomplete()) { + return; + } + super.handleInput(normalized); - this.reopenPathCompletionAfterInput(); + this.reopenAutocompleteAfterInput(); } - private reopenPathCompletionAfterInput(): void { + private reopenAutocompleteAfterInput(): void { + if (this.isShowingAutocomplete()) return; const { line, col } = this.getCursor(); const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? ''; - if (!textBeforeCursor.endsWith('/')) return; - if (this.isShowingAutocomplete()) return; - const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); - const isAtMention = extractAtPrefix(textBeforeCursor) !== null; - if (!isSlashArgument && !isAtMention) return; - (this as unknown as { requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void }) - .requestAutocomplete?.({ force: true, explicitTab: false }); + const editor = this as unknown as { + requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void; + }; + if (editor.requestAutocomplete === undefined) return; + const trigger = (): void => { + // Use force:false so slash-aware logic runs: commands with argument + // completions return their subcommands, commands without them return + // null. force:true would bypass the slash branch and fall through to + // path completion, wrongly popping up the file list. + editor.requestAutocomplete?.({ force: false, explicitTab: false }); + }; + + // Reopen path / argument completion right after a `/` is typed + // (e.g. `/add-dir /` or an `@dir/` mention). + if (textBeforeCursor.endsWith('/')) { + const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); + const isAtMention = extractAtPrefix(textBeforeCursor) !== null; + if (isSlashArgument || isAtMention) { + trigger(); + } + return; + } + + // After accepting a slash command name via Tab, pi-tui inserts a trailing + // space and closes the menu without triggering argument completion. Reopen + // it so subcommands (e.g. `/goal ` → status/pause/…) show immediately. + if ( + textBeforeCursor.endsWith(' ') && + textBeforeCursor.startsWith('/') && + textBeforeCursor.includes(' ') + ) { + trigger(); + } } } diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 9e991ef3b..e6129ffce 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -142,6 +142,73 @@ describe('CustomEditor slash argument completion refresh', () => { }); }); +describe('CustomEditor slash command name Tab-accept', () => { + it('reopens subcommand completions after Tab-accepting a slash command name', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'goal', + description: 'Manage goals', + getArgumentCompletions: (prefix) => + prefix === '' + ? [ + { value: 'status', label: 'status' }, + { value: 'pause', label: 'pause' }, + ] + : null, + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/go') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/goal '); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('does not fall back to file completions for a command without subcommands', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'compact', + description: 'Compact context', + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/comp') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/compact '); + expect(editor.isShowingAutocomplete()).toBe(false); + }); +}); + describe('CustomEditor @ mention completion refresh', () => { it('reopens the next directory level after tab-accepting an @ directory', async () => { const editor = makeEditor(); @@ -200,6 +267,21 @@ describe('CustomEditor @ mention completion refresh', () => { }); }); +describe('CustomEditor Tab key handling', () => { + it('does not open autocomplete when Tab is pressed with the dropdown closed', async () => { + const editor = makeEditor(); + const provider = providerReturning([{ value: '@src/file.ts', label: 'file.ts' }]); + editor.setAutocompleteProvider(provider); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + expect(provider.getSuggestions).not.toHaveBeenCalled(); + expect(editor.isShowingAutocomplete()).toBe(false); + }); +}); + describe('CustomEditor slash argument hint', () => { // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); From fb780fce9665e2119cee6d0bc7f85895c6970865 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 17:01:17 +0800 Subject: [PATCH 057/356] refactor(web): extract input-history recall into a composable (#1011) * refactor(web): extract input-history recall into a composable Move the shell-style up/down recall of previously sent messages out of Composer into useInputHistory. The composable owns the history list, the browsing cursor, and the textarea caret/selection work needed to apply a recalled entry, taking the text ref, textarea ref, and autosize as deps. The composer keeps the keydown orchestration (which also juggles the slash and mention menus) and calls into the composable for push / recall / caret / browsing state. Composer.vue: 2104 -> 2050 lines. No behavior change. * test(web): cover useInputHistory recall behavior Add unit tests for the extracted input-history composable: push dedup and empty-skip, walking backward/forward through entries, restoring the live draft (empty and non-empty), empty-history no-op, resetBrowsing, and the caretAtFirstLine gate. --- .../web-extract-composer-input-history.md | 5 + apps/kimi-web/src/components/Composer.vue | 92 +++--------- .../src/composables/useInputHistory.ts | 107 ++++++++++++++ apps/kimi-web/test/input-history.test.ts | 136 ++++++++++++++++++ 4 files changed, 267 insertions(+), 73 deletions(-) create mode 100644 .changeset/web-extract-composer-input-history.md create mode 100644 apps/kimi-web/src/composables/useInputHistory.ts create mode 100644 apps/kimi-web/test/input-history.test.ts diff --git a/.changeset/web-extract-composer-input-history.md b/.changeset/web-extract-composer-input-history.md new file mode 100644 index 000000000..24947eea8 --- /dev/null +++ b/.changeset/web-extract-composer-input-history.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Extract the composer's shell-style input-history recall into a reusable composable. diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index 075c206cf..3f5b8cbb2 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -11,6 +11,7 @@ import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPrompt import type { AppModel, AppSkill, ThinkingLevel } from '../api/types'; import { modelThinkingAvailability } from '../lib/modelThinking'; import { draftStorageKey, safeGetString, safeRemove, safeSetString } from '../lib/storage'; +import { useInputHistory } from '../composables/useInputHistory'; // --------------------------------------------------------------------------- // Attachment state @@ -142,66 +143,11 @@ watch( ); // --------------------------------------------------------------------------- -// Sent-message history recall (shell-style ↑/↓). ArrowUp on the first line -// recalls older messages; ArrowDown on the last line walks back toward the live -// draft. Editing the text drops out of history browsing. +// Sent-message history recall (shell-style ↑/↓). See useInputHistory for the +// implementation; the composer keeps the keydown orchestration (which also +// juggles the slash and mention menus). // --------------------------------------------------------------------------- -const inputHistory = ref([]); -// -1 = browsing nothing (live draft). Otherwise an index into inputHistory. -let historyIndex = -1; -let draftBeforeHistory = ''; - -function pushInputHistory(entry: string): void { - const trimmed = entry.trim(); - historyIndex = -1; - if (!trimmed) return; - // Skip consecutive duplicates so repeated sends don't pad the history. - if (inputHistory.value[inputHistory.value.length - 1] === trimmed) return; - inputHistory.value = [...inputHistory.value, trimmed]; -} - -function caretAtFirstLine(): boolean { - const el = textareaRef.value; - if (!el) return false; - const pos = el.selectionStart ?? 0; - // No newline before the caret → it sits on the first visual line. - return el.value.lastIndexOf('\n', pos - 1) === -1; -} - -function applyHistoryText(value: string): void { - text.value = value; - void nextTick(() => { - const el = textareaRef.value; - if (!el) return; - autosize(); - const pos = value.length; - el.setSelectionRange(pos, pos); - }); -} - -function recallOlder(): void { - if (inputHistory.value.length === 0) return; - if (historyIndex === -1) { - draftBeforeHistory = text.value; - historyIndex = inputHistory.value.length - 1; - } else if (historyIndex > 0) { - historyIndex -= 1; - } else { - return; // already at the oldest entry - } - applyHistoryText(inputHistory.value[historyIndex]!); -} - -function recallNewer(): void { - if (historyIndex === -1) return; - if (historyIndex < inputHistory.value.length - 1) { - historyIndex += 1; - applyHistoryText(inputHistory.value[historyIndex]!); - } else { - historyIndex = -1; - applyHistoryText(draftBeforeHistory); - } -} +const history = useInputHistory({ text, textareaRef, autosize }); // --------------------------------------------------------------------------- // Slash-command menu @@ -316,7 +262,7 @@ function selectMentionItem(item: FileItem): void { function handleInput(): void { // Manual typing leaves history-browsing mode — the text is now a fresh draft. - historyIndex = -1; + history.resetBrowsing(); updateSlashMenu(); updateMentionMenu(); } @@ -542,7 +488,7 @@ function handleSubmit(): void { } attachments.value = []; - pushInputHistory(trimmed); + history.push(trimmed); text.value = ''; slashOpen.value = false; mentionOpen.value = false; @@ -570,7 +516,7 @@ function handleSteer(): void { revokeAttachment(att); } attachments.value = []; - pushInputHistory(trimmed); + history.push(trimmed); text.value = ''; slashOpen.value = false; mentionOpen.value = false; @@ -680,26 +626,26 @@ function handleKeydown(e: KeyboardEvent): void { return; } - // History recall (shell-style ↑/↓). + // History recall (shell-style ↑/↓) — see useInputHistory for the machinery. // // ENTERING history: a plain ArrowUp only recalls when the caret is on the // first line, so editing a multi-line draft with the arrows still works. - // ONCE BROWSING (historyIndex !== -1), the arrows walk history directly, - // regardless of where the caret landed — a recalled multi-line entry leaves - // the caret at its end, and the old "must be on the first line" gate then - // trapped it there, so further ArrowUp did nothing ("only one step back"). - // Walking freely while browsing fixes that; typing exits history (handleInput - // resets historyIndex), after which the arrows move the caret normally again. + // ONCE BROWSING, the arrows walk history directly, regardless of where the + // caret landed — a recalled multi-line entry leaves the caret at its end, and + // the old "must be on the first line" gate then trapped it there, so further + // ArrowUp did nothing ("only one step back"). Walking freely while browsing + // fixes that; typing exits history (handleInput resets browsing), after which + // the arrows move the caret normally again. if (!slashOpen.value && !mentionOpen.value && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) { - const browsing = historyIndex !== -1; - if (e.key === 'ArrowUp' && inputHistory.value.length > 0 && (browsing || caretAtFirstLine())) { + const browsing = history.isBrowsing(); + if (e.key === 'ArrowUp' && history.hasHistory() && (browsing || history.caretAtFirstLine())) { e.preventDefault(); - recallOlder(); + history.recallOlder(); return; } if (e.key === 'ArrowDown' && browsing) { e.preventDefault(); - recallNewer(); + history.recallNewer(); return; } } diff --git a/apps/kimi-web/src/composables/useInputHistory.ts b/apps/kimi-web/src/composables/useInputHistory.ts new file mode 100644 index 000000000..134e1e30a --- /dev/null +++ b/apps/kimi-web/src/composables/useInputHistory.ts @@ -0,0 +1,107 @@ +// apps/kimi-web/src/composables/useInputHistory.ts +import { nextTick, ref, type Ref } from 'vue'; + +export interface InputHistoryDeps { + /** The live composer text — recalled entries overwrite it. */ + text: Ref; + /** The textarea element, used to read the caret and move the selection. */ + textareaRef: Ref; + /** Re-fit the textarea after its text changes. */ + autosize: () => void; +} + +/** + * Shell-style ↑/↓ recall of previously sent messages. + * + * `ArrowUp` on the first line steps back through older entries; `ArrowDown` + * walks forward again and ultimately restores the draft the user had before + * they started browsing. Any manual edit drops out of browsing mode (see + * `resetBrowsing`, called from the composer's input handler). + * + * The composer keeps the keydown orchestration (which also juggles the slash + * and mention menus); this composable owns only the history list, the browsing + * cursor, and the textarea caret/selection work needed to apply a recalled + * entry. + */ +export function useInputHistory(deps: InputHistoryDeps) { + const { text, textareaRef, autosize } = deps; + + const inputHistory = ref([]); + // -1 = browsing nothing (live draft). Otherwise an index into inputHistory. + let historyIndex = -1; + let draftBeforeHistory = ''; + + function push(entry: string): void { + const trimmed = entry.trim(); + historyIndex = -1; + if (!trimmed) return; + // Skip consecutive duplicates so repeated sends don't pad the history. + if (inputHistory.value.at(-1) === trimmed) return; + inputHistory.value = [...inputHistory.value, trimmed]; + } + + function caretAtFirstLine(): boolean { + const el = textareaRef.value; + if (!el) return false; + const pos = el.selectionStart ?? 0; + // No newline before the caret → it sits on the first visual line. + return el.value.lastIndexOf('\n', pos - 1) === -1; + } + + function applyHistoryText(value: string): void { + text.value = value; + void nextTick(() => { + const el = textareaRef.value; + if (!el) return; + autosize(); + const pos = value.length; + el.setSelectionRange(pos, pos); + }); + } + + function recallOlder(): void { + if (inputHistory.value.length === 0) return; + if (historyIndex === -1) { + draftBeforeHistory = text.value; + historyIndex = inputHistory.value.length - 1; + } else if (historyIndex > 0) { + historyIndex -= 1; + } else { + return; // already at the oldest entry + } + applyHistoryText(inputHistory.value[historyIndex]!); + } + + function recallNewer(): void { + if (historyIndex === -1) return; + if (historyIndex < inputHistory.value.length - 1) { + historyIndex += 1; + applyHistoryText(inputHistory.value[historyIndex]!); + } else { + historyIndex = -1; + applyHistoryText(draftBeforeHistory); + } + } + + function resetBrowsing(): void { + historyIndex = -1; + } + + function isBrowsing(): boolean { + return historyIndex !== -1; + } + + function hasHistory(): boolean { + return inputHistory.value.length > 0; + } + + return { + push, + caretAtFirstLine, + recallOlder, + recallNewer, + resetBrowsing, + isBrowsing, + hasHistory, + }; +} diff --git a/apps/kimi-web/test/input-history.test.ts b/apps/kimi-web/test/input-history.test.ts new file mode 100644 index 000000000..1fe294945 --- /dev/null +++ b/apps/kimi-web/test/input-history.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { ref, type Ref } from 'vue'; +import { useInputHistory } from '../src/composables/useInputHistory'; + +interface MockTextarea { + value: string; + selectionStart: number; + selectionEnd: number; + setSelectionRange: (start: number, end: number) => void; +} + +function setup(initialText = '', caret = 0) { + const textarea: MockTextarea = { + value: initialText, + selectionStart: caret, + selectionEnd: caret, + setSelectionRange(start: number, end: number) { + this.selectionStart = start; + this.selectionEnd = end; + }, + }; + const text = ref(initialText); + const textareaRef = ref(textarea as unknown as HTMLTextAreaElement) as Ref; + const history = useInputHistory({ text, textareaRef, autosize: () => {} }); + return { text, textarea, history }; +} + +describe('useInputHistory — push', () => { + it('ignores empty or whitespace-only entries', () => { + const { history } = setup(); + history.push(''); + history.push(' '); + expect(history.hasHistory()).toBe(false); + }); + + it('appends distinct entries newest-last', () => { + const { history } = setup(); + history.push('a'); + history.push('b'); + history.push('c'); + expect(history.hasHistory()).toBe(true); + }); + + it('skips a consecutive duplicate', () => { + const { text, history } = setup(); + history.push('a'); + history.push('a'); // duplicate of the newest entry — must be dropped + history.push('b'); + history.recallOlder(); // -> b + expect(text.value).toBe('b'); + history.recallOlder(); // -> a (only one 'a' was kept) + expect(text.value).toBe('a'); + history.recallOlder(); // already oldest — must stay, not land on a second 'a' + expect(text.value).toBe('a'); + }); +}); + +describe('useInputHistory — recall', () => { + it('walks backward from the most recent entry, then restores the live draft', () => { + const { text, history } = setup('draft'); + history.push('a'); + history.push('b'); + history.push('c'); + + expect(history.isBrowsing()).toBe(false); + history.recallOlder(); // -> c + expect(text.value).toBe('c'); + expect(history.isBrowsing()).toBe(true); + history.recallOlder(); // -> b + expect(text.value).toBe('b'); + history.recallOlder(); // -> a + expect(text.value).toBe('a'); + history.recallOlder(); // already oldest, stay + expect(text.value).toBe('a'); + + history.recallNewer(); // -> b + expect(text.value).toBe('b'); + history.recallNewer(); // -> c + expect(text.value).toBe('c'); + history.recallNewer(); // -> back to the live draft + expect(text.value).toBe('draft'); + expect(history.isBrowsing()).toBe(false); + }); + + it('restores an empty live draft after recalling the single newest entry', () => { + const { text, history } = setup(''); + history.push('only'); + history.recallOlder(); + expect(text.value).toBe('only'); + history.recallNewer(); + expect(text.value).toBe(''); + }); + + it('does nothing when recalling with an empty history', () => { + const { text, history } = setup('draft'); + history.recallOlder(); + history.recallNewer(); + expect(text.value).toBe('draft'); + expect(history.isBrowsing()).toBe(false); + }); + + it('resetBrowsing drops out of history mode without changing text', () => { + const { text, history } = setup('draft'); + history.push('a'); + history.recallOlder(); + expect(history.isBrowsing()).toBe(true); + history.resetBrowsing(); + expect(history.isBrowsing()).toBe(false); + expect(text.value).toBe('a'); // the recalled entry stays as the editable text + }); +}); + +describe('useInputHistory — caretAtFirstLine', () => { + it('is true at the very start of the text', () => { + const { textarea, history } = setup('hello\nworld', 0); + textarea.value = 'hello\nworld'; + expect(history.caretAtFirstLine()).toBe(true); + }); + + it('is true when the caret sits before any newline', () => { + const { textarea, history } = setup('hello\nworld', 3); + textarea.value = 'hello\nworld'; + expect(history.caretAtFirstLine()).toBe(true); + }); + + it('is false once the caret is past the first newline', () => { + const { textarea, history } = setup('hello\nworld', 8); + textarea.value = 'hello\nworld'; + expect(history.caretAtFirstLine()).toBe(false); + }); + + it('is true for an empty composer', () => { + const { history } = setup('', 0); + expect(history.caretAtFirstLine()).toBe(true); + }); +}); From 9c553e4bf7d0a2c09030212fe06577343ea76a60 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 17:14:43 +0800 Subject: [PATCH 058/356] feat(tui): add Alt+S to switch model for current session only (#1020) In the /model picker, Enter still switches the model and saves it as the default; Alt+S now switches only for the current session without writing to the config file. --- .changeset/session-only-model-switch.md | 5 ++ apps/kimi-code/src/tui/commands/config.ts | 42 +++++++++++------ .../tui/components/dialogs/model-selector.ts | 17 ++++++- .../dialogs/tabbed-model-selector.ts | 4 ++ .../components/dialogs/model-selector.test.ts | 46 +++++++++++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 45 ++++++++++++++++++ 6 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 .changeset/session-only-model-switch.md diff --git a/.changeset/session-only-model-switch.md b/.changeset/session-only-model-switch.md new file mode 100644 index 000000000..9dbc0295c --- /dev/null +++ b/.changeset/session-only-model-switch.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add an Alt+S shortcut in the model picker to switch the model for the current session only, without saving it as the default. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 9b91d4ba0..42e87b294 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -311,7 +311,11 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = currentThinking: host.state.appState.thinking, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void performModelSwitch(host, alias, thinking); + void performModelSwitch(host, alias, thinking, true); + }, + onSessionOnlySelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performModelSwitch(host, alias, thinking, false); }, onCancel: () => { host.restoreEditor(); @@ -320,7 +324,12 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = ); } -async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise { +async function performModelSwitch( + host: SlashCommandHost, + alias: string, + thinking: boolean, + persist: boolean, +): Promise { if (host.state.appState.streamingPhase !== 'idle') { host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); return; @@ -360,19 +369,26 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, thinkin } let persisted = false; - try { - persisted = await persistModelSelection(host, alias, thinking); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); - return; + if (persist) { + try { + persisted = await persistModelSelection(host, alias, thinking); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); + return; + } } - const status = runtimeChanged - ? `Switched to ${alias} with thinking ${level}.` - : persisted - ? `Saved ${alias} with thinking ${level} as default.` - : `Already using ${alias} with thinking ${level}.`; + let status: string; + if (runtimeChanged) { + status = persist + ? `Switched to ${alias} with thinking ${level}.` + : `Switched to ${alias} with thinking ${level} for this session only.`; + } else if (persist && persisted) { + status = `Saved ${alias} with thinking ${level} as default.`; + } else { + status = `Already using ${alias} with thinking ${level}.`; + } host.showStatus(status, 'success'); } diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index f223ba50b..c319c83a4 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -65,6 +65,9 @@ export interface ModelSelectorOptions { * TabbedModelSelectorComponent so the inner list advertises the tab keys. */ readonly providerSwitchHint?: boolean; readonly onSelect: (selection: ModelSelection) => void; + /** When provided, Alt+S invokes this instead of onSelect — used to apply the + * choice to the current session only, without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } @@ -160,6 +163,16 @@ export class ModelSelectorComponent extends Container implements Focusable { alias: selected.alias, thinking: effectiveThinking(selected.model, this.draftFor(selected)), }); + return; + } + + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const selected = this.selectedChoice(); + if (selected === undefined) return; + this.opts.onSessionOnlySelect({ + alias: selected.alias, + thinking: effectiveThinking(selected.model, this.draftFor(selected)), + }); } } @@ -179,7 +192,9 @@ export class ModelSelectorComponent extends Container implements Focusable { if (this.opts.providerSwitchHint) hintParts.push('Tab toggle provider'); hintParts.push('↑↓ navigate'); if (searchable && view.query.length > 0) hintParts.push('Backspace clear'); - hintParts.push('Enter select', 'Esc cancel'); + hintParts.push('Enter select'); + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 747072a5c..767e5ecdd 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -44,6 +44,9 @@ export interface TabbedModelSelectorOptions { * tab derived from `currentValue`. */ readonly initialTabId?: string; readonly onSelect: (selection: ModelSelection) => void; + /** Forwarded to each inner selector; when set, Alt+S applies the choice to + * the current session only without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } @@ -250,6 +253,7 @@ function makeSelector( searchable: true, providerSwitchHint: true, onSelect: opts.onSelect, + onSessionOnlySelect: opts.onSessionOnlySelect, onCancel: opts.onCancel, }; return new ModelSelectorComponent(inner); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index eec53eca4..4d0ad439b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -254,4 +254,50 @@ describe('ModelSelectorComponent', () => { } } }); + + it('invokes onSessionOnlySelect on Alt+S with the effective thinking state', () => { + const onSelect = vi.fn(); + const onSessionOnlySelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinking: true, + onSelect, + onSessionOnlySelect, + onCancel: vi.fn(), + }); + + // Toggle thinking Off, then Alt+S applies the choice to the session only. + picker.handleInput(RIGHT); + picker.handleInput(`${ESC}s`); + expect(onSessionOnlySelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: false }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ignores Alt+S and hides its hint when onSessionOnlySelect is not provided', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinking: true, + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(`${ESC}s`); + expect(onSelect).not.toHaveBeenCalled(); + expect(text(picker)).not.toContain('Alt+S session-only'); + }); + + it('shows the Alt+S session-only hint when onSessionOnlySelect is provided', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinking: true, + onSelect: vi.fn(), + onSessionOnlySelect: vi.fn(), + onCancel: vi.fn(), + }); + expect(text(picker)).toContain('Alt+S session-only'); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index cab1bfeae..32ff744e8 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -3411,6 +3411,51 @@ command = "vim" expect(driver.state.appState.thinking).toBe(true); }); + it('applies /model selection to the session only on Alt+S without persisting', async () => { + const session = makeSession(); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + displayName: 'Kimi Turbo', + capabilities: ['thinking'], + }, + }, + defaultModel: 'k2', + defaultThinking: false, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0]; + // /model turbo preselects turbo; Alt+S applies it to the current session only. + (picker as TabbedModelSelectorComponent).handleInput(`${ESC}s`); + + await vi.waitFor(() => { + expect(session.setModel).toHaveBeenCalledWith('turbo'); + expect(session.setThinking).toHaveBeenCalledWith('on'); + }); + expect(setConfig).not.toHaveBeenCalled(); + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinking).toBe(true); + }); + it('persists /model selection even when runtime state is unchanged', async () => { const session = makeSession(); const setConfig = vi.fn(async () => ({ providers: {} })); From 6d506380cea8b520012a206a89ac5c723e9b775e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 17:23:31 +0800 Subject: [PATCH 059/356] fix(changeset): downgrade web panel resize changeset from minor to patch (#1021) --- .changeset/web-unbounded-panel-width.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/web-unbounded-panel-width.md b/.changeset/web-unbounded-panel-width.md index 42748324d..8b4e6cf2a 100644 --- a/.changeset/web-unbounded-panel-width.md +++ b/.changeset/web-unbounded-panel-width.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code": patch --- Allow the web sidebar and detail panel to be resized up to the available viewport width, keeping their resize handles reachable on narrow windows. From 83384ee6d46b37c00b7b8f160a7c48aebbd6921e Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 18:07:00 +0800 Subject: [PATCH 060/356] fix(web): persist input history so recall works after the first message (#1015) * fix(web): persist input history so recall works after the first message The composer has two mutually-exclusive instances: the empty-session composer and the docked composer. The first message of a new session is sent by the empty composer, which unmounts as soon as the first turn appears; the docked composer then mounted with an empty in-memory history, so ArrowUp did nothing until a second message was sent. The history was also lost on every page reload. Persist the history to localStorage as a single global list and re-read it on mount. Global (not per-session) because a new session has no id until after the first submit, so per-session keys would not line up across the empty -> docked handoff. Caps the list at 200 entries. Adds persistence-focused unit tests (surviving a remount, the 200-entry cap, and a malformed stored value). * fix(web): record slash commands in input history too Move the history.push call ahead of the slash-command branch so that known commands (with or without args, e.g. /goal or /model) are recorded and can be recalled with ArrowUp, instead of only plain messages. Steer already pushed; only the submit slash path was missing it. * fix(web): record menu-selected slash commands in history Bare slash commands picked from the slash menu (e.g. /model, /login) go through selectSlashCommand and emit directly, never reaching handleSubmit, so they were not recorded even after the typed-slash fix. Push the command name before emitting. acceptsInput commands are still recorded later by handleSubmit together with their argument. --- .changeset/fix-web-persist-input-history.md | 5 ++ apps/kimi-web/src/components/Composer.vue | 10 ++- .../src/composables/useInputHistory.ts | 24 +++++- apps/kimi-web/src/lib/storage.ts | 1 + apps/kimi-web/test/input-history.test.ts | 85 ++++++++++++++++++- 5 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-web-persist-input-history.md diff --git a/.changeset/fix-web-persist-input-history.md b/.changeset/fix-web-persist-input-history.md new file mode 100644 index 000000000..05529c938 --- /dev/null +++ b/.changeset/fix-web-persist-input-history.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. Slash commands are now recorded too — both typed-and-submitted and ones picked from the slash menu — so they can be recalled like plain messages. diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index 3f5b8cbb2..f18e47746 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -185,6 +185,10 @@ function selectSlashCommand(item: SlashCommand): void { return; } text.value = ''; + // Menu-selected bare commands (e.g. /model, /login) reach here directly and + // never go through handleSubmit, so record them for ↑/↓ recall too. acceptsInput + // commands are pushed later by handleSubmit with their argument. + history.push(item.name); emit('command', item.name); } @@ -459,6 +463,11 @@ function handleSubmit(): void { if (!trimmed && readyAttachments.length === 0) return; + // Record for ↑/↓ recall before the slash branch so commands (with or without + // args) are recallable too, not just plain messages. `push` ignores empty / + // whitespace, so an image-only send adds nothing. + history.push(trimmed); + // 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 @@ -488,7 +497,6 @@ function handleSubmit(): void { } attachments.value = []; - history.push(trimmed); text.value = ''; slashOpen.value = false; mentionOpen.value = false; diff --git a/apps/kimi-web/src/composables/useInputHistory.ts b/apps/kimi-web/src/composables/useInputHistory.ts index 134e1e30a..fcb8cc754 100644 --- a/apps/kimi-web/src/composables/useInputHistory.ts +++ b/apps/kimi-web/src/composables/useInputHistory.ts @@ -1,5 +1,15 @@ // apps/kimi-web/src/composables/useInputHistory.ts import { nextTick, ref, type Ref } from 'vue'; +import { STORAGE_KEYS, safeGetJson, safeSetJson } from '../lib/storage'; + +/** Cap the persisted history so storage can't grow without bound. */ +const MAX_HISTORY = 200; + +function loadHistory(): string[] { + const stored = safeGetJson(STORAGE_KEYS.inputHistory); + if (!Array.isArray(stored)) return []; + return stored.filter((s): s is string => typeof s === 'string' && s.length > 0); +} export interface InputHistoryDeps { /** The live composer text — recalled entries overwrite it. */ @@ -18,6 +28,14 @@ export interface InputHistoryDeps { * they started browsing. Any manual edit drops out of browsing mode (see * `resetBrowsing`, called from the composer's input handler). * + * The history is persisted to localStorage (one global list). The composer has + * two mutually-exclusive instances — the empty-session composer and the docked + * composer — and the first message of a new session is sent by the empty + * composer, which unmounts as soon as the first turn appears. Persisting (and + * re-reading on mount) is what lets the docked composer recall that first + * message instead of starting from an empty list. A single global list also + * sidesteps the fact that a new session has no id until after the first submit. + * * The composer keeps the keydown orchestration (which also juggles the slash * and mention menus); this composable owns only the history list, the browsing * cursor, and the textarea caret/selection work needed to apply a recalled @@ -26,7 +44,7 @@ export interface InputHistoryDeps { export function useInputHistory(deps: InputHistoryDeps) { const { text, textareaRef, autosize } = deps; - const inputHistory = ref([]); + const inputHistory = ref(loadHistory()); // -1 = browsing nothing (live draft). Otherwise an index into inputHistory. let historyIndex = -1; let draftBeforeHistory = ''; @@ -37,7 +55,9 @@ export function useInputHistory(deps: InputHistoryDeps) { if (!trimmed) return; // Skip consecutive duplicates so repeated sends don't pad the history. if (inputHistory.value.at(-1) === trimmed) return; - inputHistory.value = [...inputHistory.value, trimmed]; + const next = [...inputHistory.value, trimmed]; + inputHistory.value = next.length > MAX_HISTORY ? next.slice(-MAX_HISTORY) : next; + safeSetJson(STORAGE_KEYS.inputHistory, inputHistory.value); } function caretAtFirstLine(): boolean { diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index a0241936e..d1b5d5ca2 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -24,6 +24,7 @@ export const STORAGE_KEYS = { hiddenWorkspaces: 'kimi-web.hidden-workspaces', betaToc: 'kimi-web.beta-toc', notifyOnComplete: 'kimi-web.notify-on-complete', + inputHistory: 'kimi-web.input-history', // cross-file locale: 'kimi-locale', clientId: 'kimi-web.client-id', diff --git a/apps/kimi-web/test/input-history.test.ts b/apps/kimi-web/test/input-history.test.ts index 1fe294945..32aee0372 100644 --- a/apps/kimi-web/test/input-history.test.ts +++ b/apps/kimi-web/test/input-history.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ref, type Ref } from 'vue'; import { useInputHistory } from '../src/composables/useInputHistory'; +import { STORAGE_KEYS } from '../src/lib/storage'; interface MockTextarea { value: string; @@ -134,3 +135,85 @@ describe('useInputHistory — caretAtFirstLine', () => { expect(history.caretAtFirstLine()).toBe(true); }); }); + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear: () => { + map.clear(); + }, + getItem: (key: string) => map.get(key) ?? null, + key: (index: number) => Array.from(map.keys())[index] ?? null, + removeItem: (key: string) => { + map.delete(key); + }, + setItem: (key: string, value: string) => { + map.set(key, value); + }, + }; +} + +describe('useInputHistory — persistence', () => { + let original: Storage | undefined; + + beforeEach(() => { + original = (globalThis as { localStorage?: Storage }).localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: memoryStorage(), + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + if (original === undefined) { + delete (globalThis as { localStorage?: Storage }).localStorage; + } else { + Object.defineProperty(globalThis, 'localStorage', { + value: original, + configurable: true, + writable: true, + }); + } + }); + + it('writes each pushed entry to localStorage', () => { + const { history } = setup(); + history.push('hello'); + const stored = globalThis.localStorage.getItem(STORAGE_KEYS.inputHistory); + expect(stored).toBe(JSON.stringify(['hello'])); + }); + + it('a freshly mounted composable reads back the persisted history', () => { + const first = setup(); + first.history.push('a'); + first.history.push('b'); + + // Simulates the empty composer unmounting and the docked composer mounting. + const second = setup(); + second.history.recallOlder(); + expect(second.text.value).toBe('b'); + second.history.recallOlder(); + expect(second.text.value).toBe('a'); + }); + + it('trims to the newest 200 entries, dropping the oldest', () => { + const { text, history } = setup(); + for (let i = 0; i < 205; i++) history.push(`m${i}`); + + // Walk all the way back; the oldest kept entry must be m5 (m0..m4 dropped). + for (let i = 0; i < 200; i++) history.recallOlder(); + expect(text.value).toBe('m5'); + history.recallOlder(); // already at the oldest kept entry — must not move + expect(text.value).toBe('m5'); + }); + + it('ignores a malformed stored value and starts empty', () => { + globalThis.localStorage.setItem(STORAGE_KEYS.inputHistory, 'not-json'); + const { history } = setup(); + expect(history.hasHistory()).toBe(false); + }); +}); From 318c964f074123ad228cbddcf7809fa4baaa7fb2 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 18:17:48 +0800 Subject: [PATCH 061/356] refactor(web): extract slash-command menu into a composable (#1026) Move the slash menu's open/items/active state, the filter logic, and item selection out of Composer into useSlashMenu. The composable takes the text ref, textarea ref, autosize, a skills getter, and the emit/history-push callbacks as deps. The composer keeps the keydown orchestration (arrow keys, Enter/Tab, Escape) because it also juggles the mention menu and history recall; it consumes the returned open/items/active refs directly and calls update/select. The destructured refs are aliased back to the original names so the rest of the component is unchanged. Composer.vue: 2058 -> 2035 lines. Adds unit tests for useSlashMenu. No behavior change. --- .changeset/web-extract-slash-menu.md | 5 + apps/kimi-web/src/components/Composer.vue | 61 ++++------ apps/kimi-web/src/composables/useSlashMenu.ts | 72 ++++++++++++ apps/kimi-web/test/slash-menu.test.ts | 104 ++++++++++++++++++ 4 files changed, 200 insertions(+), 42 deletions(-) create mode 100644 .changeset/web-extract-slash-menu.md create mode 100644 apps/kimi-web/src/composables/useSlashMenu.ts create mode 100644 apps/kimi-web/test/slash-menu.test.ts diff --git a/.changeset/web-extract-slash-menu.md b/.changeset/web-extract-slash-menu.md new file mode 100644 index 000000000..f99720c05 --- /dev/null +++ b/.changeset/web-extract-slash-menu.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Extract the composer's slash-command menu logic into a reusable composable. diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index f18e47746..fa3bf080f 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -4,14 +4,14 @@ 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 type { SlashCommand } from '../lib/slashCommands'; -import { buildSlashItems, filterCommands, parseSlash } from '../lib/slashCommands'; +import { buildSlashItems, parseSlash } from '../lib/slashCommands'; import type { FileItem } from './MentionMenu.vue'; import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../types'; import type { AppModel, AppSkill, ThinkingLevel } from '../api/types'; import { modelThinkingAvailability } from '../lib/modelThinking'; import { draftStorageKey, safeGetString, safeRemove, safeSetString } from '../lib/storage'; import { useInputHistory } from '../composables/useInputHistory'; +import { useSlashMenu } from '../composables/useSlashMenu'; // --------------------------------------------------------------------------- // Attachment state @@ -150,47 +150,24 @@ watch( const history = useInputHistory({ text, textareaRef, autosize }); // --------------------------------------------------------------------------- -// Slash-command menu +// Slash-command menu — see useSlashMenu for the implementation. The composer +// keeps the keydown orchestration (arrow keys / Enter / Escape) because it also +// juggles the mention menu and history recall. // --------------------------------------------------------------------------- - -const slashOpen = ref(false); -const slashItems = ref([]); -const slashActive = ref(0); - -function updateSlashMenu(): void { - const val = text.value; - // Only show if the value starts with / and has no space yet (single token) - if (val.startsWith('/') && !val.includes(' ')) { - // Built-in commands + the active session's skills (shown as /). - slashItems.value = filterCommands(val, buildSlashItems(props.skills)); - slashActive.value = 0; - slashOpen.value = slashItems.value.length > 0; - } else { - slashOpen.value = false; - } -} - -function selectSlashCommand(item: SlashCommand): void { - slashOpen.value = false; - if (item.acceptsInput) { - text.value = `${item.name} `; - void nextTick(() => { - const el = textareaRef.value; - if (!el) return; - const pos = text.value.length; - el.setSelectionRange(pos, pos); - el.focus(); - autosize(); - }); - return; - } - text.value = ''; - // Menu-selected bare commands (e.g. /model, /login) reach here directly and - // never go through handleSubmit, so record them for ↑/↓ recall too. acceptsInput - // commands are pushed later by handleSubmit with their argument. - history.push(item.name); - emit('command', item.name); -} +const { + open: slashOpen, + items: slashItems, + active: slashActive, + update: updateSlashMenu, + select: selectSlashCommand, +} = useSlashMenu({ + text, + textareaRef, + autosize, + skills: () => props.skills, + emitCommand: (cmd) => emit('command', cmd), + historyPush: (entry) => history.push(entry), +}); // --------------------------------------------------------------------------- // @-mention menu diff --git a/apps/kimi-web/src/composables/useSlashMenu.ts b/apps/kimi-web/src/composables/useSlashMenu.ts new file mode 100644 index 000000000..1f1bcbcf1 --- /dev/null +++ b/apps/kimi-web/src/composables/useSlashMenu.ts @@ -0,0 +1,72 @@ +// apps/kimi-web/src/composables/useSlashMenu.ts +import { nextTick, ref, type Ref } from 'vue'; +import type { AppSkill } from '../api/types'; +import { buildSlashItems, filterCommands, type SlashCommand } from '../lib/slashCommands'; + +export interface SlashMenuDeps { + /** The live composer text — drives filtering and is rewritten on select. */ + text: Ref; + /** The textarea element, used to focus and place the caret for acceptsInput. */ + textareaRef: Ref; + /** Re-fit the textarea after its text changes. */ + autosize: () => void; + /** Current session skills (getter, so the menu stays reactive). */ + skills: () => AppSkill[]; + /** Emit a chosen slash command up to the parent. */ + emitCommand: (cmd: string) => void; + /** Record a sent command for ↑/↓ recall. */ + historyPush: (entry: string) => void; +} + +/** + * `/` slash-command menu: filtering, keyboard navigation state, and selection. + * + * The composer keeps the keydown orchestration (arrow keys, Enter/Tab, Escape) + * because it also juggles the mention menu and history recall; this composable + * owns the menu's open/items/active state, the filter logic, and what happens + * when an item is chosen. + */ +export function useSlashMenu(deps: SlashMenuDeps) { + const { text, textareaRef, autosize, skills, emitCommand, historyPush } = deps; + + const open = ref(false); + const items = ref([]); + const active = ref(0); + + function update(): void { + const val = text.value; + // Only show if the value starts with `/` and has no space yet (single token). + if (val.startsWith('/') && !val.includes(' ')) { + // Built-in commands + the active session's skills (shown as /). + items.value = filterCommands(val, buildSlashItems(skills())); + active.value = 0; + open.value = items.value.length > 0; + } else { + open.value = false; + } + } + + function select(item: SlashCommand): void { + open.value = false; + if (item.acceptsInput) { + text.value = `${item.name} `; + void nextTick(() => { + const el = textareaRef.value; + if (!el) return; + const pos = text.value.length; + el.setSelectionRange(pos, pos); + el.focus(); + autosize(); + }); + return; + } + text.value = ''; + // Menu-selected bare commands (e.g. /model, /login) reach here directly and + // never go through handleSubmit, so record them for recall too. acceptsInput + // commands are pushed later by handleSubmit together with their argument. + historyPush(item.name); + emitCommand(item.name); + } + + return { open, items, active, update, select }; +} diff --git a/apps/kimi-web/test/slash-menu.test.ts b/apps/kimi-web/test/slash-menu.test.ts new file mode 100644 index 000000000..9b1d8657b --- /dev/null +++ b/apps/kimi-web/test/slash-menu.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { nextTick, ref, type Ref } from 'vue'; +import type { AppSkill } from '../src/api/types'; +import { useSlashMenu } from '../src/composables/useSlashMenu'; + +interface MockTextarea { + value: string; + selectionStart: number; + setSelectionRange: (start: number, end: number) => void; + focus: () => void; +} + +function setup(initialText = '', skills: AppSkill[] = []) { + const textarea: MockTextarea = { + value: initialText, + selectionStart: 0, + setSelectionRange(start: number) { + this.selectionStart = start; + }, + focus: () => {}, + }; + const text = ref(initialText); + const textareaRef = ref(textarea as unknown as HTMLTextAreaElement) as Ref; + const emitted: string[] = []; + const pushed: string[] = []; + const slash = useSlashMenu({ + text, + textareaRef, + autosize: () => {}, + skills: () => skills, + emitCommand: (cmd) => emitted.push(cmd), + historyPush: (entry) => pushed.push(entry), + }); + return { text, textarea, emitted, pushed, slash }; +} + +describe('useSlashMenu — update', () => { + it('stays closed for empty text', () => { + const { slash } = setup(''); + slash.update(); + expect(slash.open.value).toBe(false); + }); + + it('opens and lists commands for a lone slash', () => { + const { slash } = setup('/'); + slash.update(); + expect(slash.open.value).toBe(true); + expect(slash.items.value.length).toBeGreaterThan(0); + expect(slash.active.value).toBe(0); + }); + + it('filters to matching commands', () => { + const { slash } = setup('/mod'); + slash.update(); + expect(slash.open.value).toBe(true); + expect(slash.items.value.map((i) => i.name)).toContain('/model'); + }); + + it('closes when nothing matches', () => { + const { slash } = setup('/zzzznotacommand'); + slash.update(); + expect(slash.open.value).toBe(false); + }); + + it('closes once the token contains a space', () => { + const { slash } = setup('/goal some task'); + slash.update(); + expect(slash.open.value).toBe(false); + }); + + it('closes for text that does not start with a slash', () => { + const { slash } = setup('hello'); + slash.update(); + expect(slash.open.value).toBe(false); + }); + + it('includes session skills as /', () => { + const { slash } = setup('/', [{ name: 'deploy', description: 'deploy stuff' } as AppSkill]); + slash.update(); + const names = slash.items.value.map((i) => i.name); + expect(names).toContain('/deploy'); + }); +}); + +describe('useSlashMenu — select', () => { + it('non-acceptsInput: clears text, pushes history, emits the command', () => { + const { text, emitted, pushed, slash } = setup('/model'); + slash.select({ name: '/model', desc: '' }); + expect(text.value).toBe(''); + expect(pushed).toEqual(['/model']); + expect(emitted).toEqual(['/model']); + expect(slash.open.value).toBe(false); + }); + + it('acceptsInput: keeps the command in the box and does not emit yet', async () => { + const { text, emitted, pushed, slash } = setup('/goal'); + slash.select({ name: '/goal', desc: '', acceptsInput: true }); + expect(text.value).toBe('/goal '); + expect(emitted).toEqual([]); + expect(pushed).toEqual([]); + expect(slash.open.value).toBe(false); + await nextTick(); + }); +}); From 661c1fbe5b026ec32d80696290a18313b24eafef Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 18:38:50 +0800 Subject: [PATCH 062/356] refactor(web): extract @-mention menu into a composable (#1030) Move the @-mention menu's open/items/active/loading state, the @token detection, debounced search, and insertion logic out of Composer into useMentionMenu. The composer keeps the keydown orchestration (it also juggles the slash menu and history recall) and consumes the returned refs directly; the destructured refs are aliased back to the original names so the rest of the component is unchanged. Move the FileItem view type into types.ts (mirroring the FileData move) so the .ts composable can import it without hitting the type-aware lint rule against importing types from .vue files; MentionMenu re-exports it for the existing .vue consumers. Composer.vue: 2035 -> 1987 lines. Adds unit tests for useMentionMenu. No behavior change. --- .changeset/web-extract-mention-menu.md | 5 + apps/kimi-web/src/components/Composer.vue | 82 ++++------------ apps/kimi-web/src/components/MentionMenu.vue | 8 +- .../src/composables/useMentionMenu.ts | 98 +++++++++++++++++++ apps/kimi-web/src/types.ts | 6 ++ apps/kimi-web/test/mention-menu.test.ts | 97 ++++++++++++++++++ 6 files changed, 227 insertions(+), 69 deletions(-) create mode 100644 .changeset/web-extract-mention-menu.md create mode 100644 apps/kimi-web/src/composables/useMentionMenu.ts create mode 100644 apps/kimi-web/test/mention-menu.test.ts diff --git a/.changeset/web-extract-mention-menu.md b/.changeset/web-extract-mention-menu.md new file mode 100644 index 000000000..8217f84f0 --- /dev/null +++ b/.changeset/web-extract-mention-menu.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Extract the composer's @-mention menu logic into a reusable composable. diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index fa3bf080f..02c919898 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -12,6 +12,7 @@ import { modelThinkingAvailability } from '../lib/modelThinking'; import { draftStorageKey, safeGetString, safeRemove, safeSetString } from '../lib/storage'; import { useInputHistory } from '../composables/useInputHistory'; import { useSlashMenu } from '../composables/useSlashMenu'; +import { useMentionMenu } from '../composables/useMentionMenu'; // --------------------------------------------------------------------------- // Attachment state @@ -170,72 +171,23 @@ const { }); // --------------------------------------------------------------------------- -// @-mention menu +// @-mention menu — see useMentionMenu for the implementation. The composer +// keeps the keydown orchestration because it also juggles the slash menu and +// history recall. // --------------------------------------------------------------------------- - -const mentionOpen = ref(false); -const mentionItems = ref([]); -const mentionActive = ref(0); -const mentionLoading = ref(false); - -// Debounce timer for mention search -let mentionTimer: ReturnType | null = null; - -/** Find the @token under the cursor in the current text value. Returns null if none. */ -function getMentionToken(): { token: string; start: number; end: number } | null { - const val = text.value; - const pos = textareaRef.value?.selectionStart ?? val.length; - // Walk backwards from cursor to find the start of a @token - let start = pos - 1; - while (start >= 0 && !/\s/.test(val[start]!)) { - start--; - } - start++; - const tokenPart = val.slice(start, pos); - if (!tokenPart.startsWith('@')) return null; - // The end of the token is where the cursor is (or after the next space) - return { token: tokenPart.slice(1), start, end: pos }; -} - -function updateMentionMenu(): void { - const mt = getMentionToken(); - if (!mt || !props.searchFiles) { - mentionOpen.value = false; - return; - } - const query = mt.token; - if (mentionTimer !== null) clearTimeout(mentionTimer); - mentionTimer = setTimeout(async () => { - mentionLoading.value = true; - mentionOpen.value = true; - mentionActive.value = 0; - try { - const results = await props.searchFiles!(query); - mentionItems.value = results; - } catch { - mentionItems.value = []; - } finally { - mentionLoading.value = false; - } - }, 200); -} - -function selectMentionItem(item: FileItem): void { - const mt = getMentionToken(); - if (!mt) return; - const val = text.value; - // Replace @query token with the file path - text.value = val.slice(0, mt.start) + item.path + val.slice(mt.end); - mentionOpen.value = false; - void nextTick(() => { - const el = textareaRef.value; - if (!el) return; - const newPos = mt.start + item.path.length; - el.setSelectionRange(newPos, newPos); - el.focus(); - autosize(); - }); -} +const { + open: mentionOpen, + items: mentionItems, + active: mentionActive, + loading: mentionLoading, + update: updateMentionMenu, + select: selectMentionItem, +} = useMentionMenu({ + text, + textareaRef, + autosize, + searchFiles: () => props.searchFiles, +}); // --------------------------------------------------------------------------- // Input event handler — updates both menus diff --git a/apps/kimi-web/src/components/MentionMenu.vue b/apps/kimi-web/src/components/MentionMenu.vue index d6373076b..c00943c43 100644 --- a/apps/kimi-web/src/components/MentionMenu.vue +++ b/apps/kimi-web/src/components/MentionMenu.vue @@ -2,11 +2,11 @@ diff --git a/apps/kimi-web/src/components/chat/OpenInMenu.vue b/apps/kimi-web/src/components/chat/OpenInMenu.vue index fdf988348..d210a8616 100644 --- a/apps/kimi-web/src/components/chat/OpenInMenu.vue +++ b/apps/kimi-web/src/components/chat/OpenInMenu.vue @@ -6,6 +6,7 @@ import { computed, nextTick, onUnmounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage'; +import { copyTextToClipboard } from '../../lib/clipboard'; const { t } = useI18n(); @@ -164,11 +165,10 @@ function handleQuickOpen(): void { const copiedPath = ref(false); async function copyPath(): Promise { if (!props.workDir) return; - try { - await navigator.clipboard.writeText(props.workDir); - copiedPath.value = true; - setTimeout(() => { copiedPath.value = false; }, 1200); - } catch { /* ignore */ } + const ok = await copyTextToClipboard(props.workDir); + if (!ok) return; + copiedPath.value = true; + setTimeout(() => { copiedPath.value = false; }, 1200); } diff --git a/apps/kimi-web/src/components/chat/TasksPane.vue b/apps/kimi-web/src/components/chat/TasksPane.vue index cec4883af..8536aa2cf 100644 --- a/apps/kimi-web/src/components/chat/TasksPane.vue +++ b/apps/kimi-web/src/components/chat/TasksPane.vue @@ -5,6 +5,7 @@ import { reactive } from 'vue'; import { useI18n } from 'vue-i18n'; import type { TaskItem } from '../../types'; +import { copyTextToClipboard } from '../../lib/clipboard'; defineProps<{ tasks: TaskItem[] }>(); @@ -47,13 +48,10 @@ function statusClass(state: string): string { } async function copyToClipboard(text: string, taskId: string, set: Set): Promise { - try { - await navigator.clipboard.writeText(text); - set.add(taskId); - setTimeout(() => set.delete(taskId), 1500); - } catch { - // Ignore clipboard failures (e.g. denied permission). - } + const ok = await copyTextToClipboard(text); + if (!ok) return; + set.add(taskId); + setTimeout(() => set.delete(taskId), 1500); } async function copyTaskCommand(task: TaskItem): Promise { diff --git a/apps/kimi-web/src/components/dialogs/LoginDialog.vue b/apps/kimi-web/src/components/dialogs/LoginDialog.vue index 581411f43..f5bc30d0b 100644 --- a/apps/kimi-web/src/components/dialogs/LoginDialog.vue +++ b/apps/kimi-web/src/components/dialogs/LoginDialog.vue @@ -5,6 +5,7 @@ import { onMounted, onUnmounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useDialogFocus } from '../../composables/useDialogFocus'; +import { copyTextToClipboard } from '../../lib/clipboard'; const { t } = useI18n(); @@ -160,13 +161,10 @@ async function retryFlow(): Promise { async function copyCode(): Promise { if (!flow.value) return; - try { - await navigator.clipboard.writeText(flow.value.userCode); - copied.value = true; - setTimeout(() => { copied.value = false; }, 2000); - } catch { - // clipboard unavailable — ignore - } + const ok = await copyTextToClipboard(flow.value.userCode); + if (!ok) return; + copied.value = true; + setTimeout(() => { copied.value = false; }, 2000); } async function close(): Promise { diff --git a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue index cfa5dc2f6..11998f833 100644 --- a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue @@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import type { Session, WorkspaceGroup, WorkspaceView } from '../../types'; +import { copyTextToClipboard } from '../../lib/clipboard'; import BottomSheet from '../dialogs/BottomSheet.vue'; const { t } = useI18n(); @@ -188,7 +189,7 @@ function toggleWsMenu(id: string): void { confirmingWsDeleteId.value = null; } function onCopyWsPath(ws: WorkspaceView): void { - void navigator.clipboard.writeText(ws.root); + void copyTextToClipboard(ws.root); wsMenuFor.value = null; } function onDeleteWorkspace(id: string): void { diff --git a/apps/kimi-web/src/debug/KapDebugView.vue b/apps/kimi-web/src/debug/KapDebugView.vue index f1f92f5ca..4438b2d6e 100644 --- a/apps/kimi-web/src/debug/KapDebugView.vue +++ b/apps/kimi-web/src/debug/KapDebugView.vue @@ -5,6 +5,7 @@ Dev tooling: labels are intentionally not localized. -->