From ad239cb1c08266a442c9ca0382fefed87bcb1fd4 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 14:17:12 +0800 Subject: [PATCH] fix(cli,tui): allow --auto, --yolo, and --plan with resumed sessions (#683) * fix(cli,tui): allow --auto, --yolo, and --plan with resumed sessions * docs(cli): update flag conflict docs for resumed sessions * fix(tui): apply startup permission/plan overrides after picker selection --- ...llow-auto-yolo-plan-with-session-resume.md | 5 + apps/kimi-code/src/cli/options.ts | 9 - apps/kimi-code/src/tui/kimi-tui.ts | 144 ++-- apps/kimi-code/test/cli/options.test.ts | 70 +- .../test/tui/kimi-tui-startup.test.ts | 751 ++++++++++++------ docs/en/guides/sessions.md | 2 +- docs/en/reference/kimi-command.md | 4 +- docs/zh/guides/sessions.md | 2 +- docs/zh/reference/kimi-command.md | 4 +- 9 files changed, 652 insertions(+), 339 deletions(-) create mode 100644 .changeset/allow-auto-yolo-plan-with-session-resume.md diff --git a/.changeset/allow-auto-yolo-plan-with-session-resume.md b/.changeset/allow-auto-yolo-plan-with-session-resume.md new file mode 100644 index 000000000..17ef5eeb0 --- /dev/null +++ b/.changeset/allow-auto-yolo-plan-with-session-resume.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow `--auto`, `--yolo`, and `--plan` to be combined with `--session` or `--continue` by applying the requested mode to the resumed session. diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 5b1c50699..98f4cb196 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -55,14 +55,5 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions { if (opts.yolo && opts.auto) { throw new OptionConflictError('Cannot combine --yolo with --auto.'); } - if (!promptMode && (opts.continue || opts.session !== undefined) && opts.yolo) { - throw new OptionConflictError('Cannot combine --yolo with --continue or --session.'); - } - if (!promptMode && (opts.continue || opts.session !== undefined) && opts.auto) { - throw new OptionConflictError('Cannot combine --auto with --continue or --session.'); - } - if (!promptMode && (opts.continue || opts.session !== undefined) && opts.plan) { - throw new OptionConflictError('Cannot combine --plan with --continue or --session.'); - } return { options: opts, uiMode: promptMode ? 'print' : 'shell' }; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 7a43affc5..759a92667 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -8,7 +8,6 @@ import { getCapabilities, Spacer, } from '@earendil-works/pi-tui'; -import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { ApprovalRequest, @@ -20,14 +19,17 @@ import type { PromptPart, Session, } from '@moonshot-ai/kimi-code-sdk'; +import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; +import { openUrl } from '#/utils/open-url'; import { getInputHistoryFile } from '#/utils/paths'; import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect'; +import { BannerProvider } from './banner/banner-provider'; import { BUILTIN_SLASH_COMMANDS, buildSkillSlashCommands, @@ -37,9 +39,10 @@ import { type KimiSlashCommand, type SkillListSession, } from './commands'; +import * as slashCommands from './commands/dispatch'; +import { BannerComponent } from './components/chrome/banner'; import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; import { GutterContainer } from './components/chrome/gutter-container'; -import { CHROME_GUTTER } from './constant/rendering'; import { MoonLoader, type SpinnerStyle } from './components/chrome/moon-loader'; import { WelcomeComponent } from './components/chrome/welcome'; import { @@ -54,15 +57,6 @@ import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent } from './components/dialogs/session-picker'; -import { AuthFlowController } from './controllers/auth-flow'; -import { BtwPanelController } from './controllers/btw-panel'; -import { EditorKeyboardController } from './controllers/editor-keyboard'; -import { SessionEventHandler } from './controllers/session-event-handler'; -import * as slashCommands from './commands/dispatch'; -import { SessionReplayRenderer } from './controllers/session-replay'; -import { StreamingUIController } from './controllers/streaming-ui'; -import { TasksBrowserController } from './controllers/tasks-browser'; -import { installRainbowDance } from './easter-eggs/dance'; import { FileMentionProvider, type SlashAutocompleteCommand, @@ -92,19 +86,26 @@ import { NO_ACTIVE_SESSION_MESSAGE, PRODUCT_NAME, } from './constant/kimi-tui'; +import { CHROME_GUTTER } from './constant/rendering'; import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal'; -import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; +import { AuthFlowController } from './controllers/auth-flow'; +import { BtwPanelController } from './controllers/btw-panel'; +import { EditorKeyboardController } from './controllers/editor-keyboard'; +import { SessionEventHandler } from './controllers/session-event-handler'; +import { SessionReplayRenderer } from './controllers/session-replay'; +import { StreamingUIController } from './controllers/streaming-ui'; +import { TasksBrowserController } from './controllers/tasks-browser'; +import { installRainbowDance } from './easter-eggs/dance'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; -import { BannerProvider } from './banner/banner-provider'; -import { BannerComponent } from './components/chrome/banner'; import { registerReverseRPCHandlers } from './reverse-rpc/index'; import { QuestionController } from './reverse-rpc/question/controller'; import { createQuestionAskHandler } from './reverse-rpc/question/handler'; import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; +import { createTUIState, type TUIState } from './tui-state'; import { INITIAL_LIVE_PANE, type AppState, @@ -116,15 +117,14 @@ import { type TUIStartupOptions, type TUIStartupState, } from './types'; -import { createTUIState, type TUIState } from './tui-state'; import { isExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; -import { openUrl } from '#/utils/open-url'; import { sessionRowsForPicker } from './utils/session-picker-rows'; +import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; import { installTerminalThemeTracking } from './utils/terminal-theme'; @@ -246,10 +246,7 @@ export class KimiTUI { public onExit?: (exitCode?: number) => Promise; - track( - event: string, - properties?: Parameters[1], - ): void { + track(event: string, properties?: Parameters[1]): void { this.harness.track(event, properties); } @@ -377,8 +374,7 @@ export class KimiTUI { try { const migrationResult = await this.runMigrationScreen(this.migrationPlan); if (this.migrateOnly) { - const failed = - migrationResult.decision === 'now' && migrationResult.migrated === false; + const failed = migrationResult.decision === 'now' && migrationResult.migrated === false; this.disposeTerminalTracking(); this.state.ui.stop(); await this.onExit?.(failed ? 1 : 0); @@ -424,11 +420,7 @@ export class KimiTUI { if (this.state.appState.banner === null || this.state.appState.banner === undefined) { return; } - if ( - this.state.transcriptContainer.children.some( - (child) => child instanceof BannerComponent, - ) - ) { + if (this.state.transcriptContainer.children.some((child) => child instanceof BannerComponent)) { return; } const welcomeIndex = this.state.transcriptContainer.children.findIndex( @@ -489,10 +481,7 @@ export class KimiTUI { this.showStatus(parts.join(' · ') + '.'); } for (const f of result.failed) { - this.showStatus( - `Skipped refreshing ${f.provider}: ${f.reason}`, - 'warning', - ); + this.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning'); } } catch { // Best-effort: startup must not crash on background refresh failures. @@ -511,6 +500,7 @@ export class KimiTUI { } if (shouldReplayHistory) { await this.sessionReplay.hydrateFromReplay(this.requireSession()); + this.applyStartupPermissionAndPlanToAppState(); } const resumeState = this.session?.getResumeState(); if (resumeState?.warning !== undefined) { @@ -568,7 +558,8 @@ export class KimiTUI { if (resolve(target.workDir) !== resolve(workDir)) { this.state.ui.stop(); process.stderr.write( - `${currentTheme.fg('warning', + `${currentTheme.fg( + 'warning', `Session "${startup.sessionFlag}" was created under a different directory.\n` + ` cd "${target.workDir}" && kimi -r ${startup.sessionFlag}`, )}\n\n`, @@ -596,8 +587,18 @@ export class KimiTUI { } else { session = await this.harness.createSession(createSessionOptions); } - if (session !== undefined && startup.model !== undefined && isResumeStartup) { - await session.setModel(startup.model); + if (session !== undefined && shouldReplayHistory) { + if (startup.auto) { + await session.setPermission('auto'); + } else if (startup.yolo) { + await session.setPermission('yolo'); + } + if (startup.plan) { + await session.setPlanMode(true); + } + if (startup.model !== undefined) { + await session.setModel(startup.model); + } } } catch (error) { if (!isOAuthLoginRequiredError(error)) throw error; @@ -610,6 +611,7 @@ export class KimiTUI { } await this.setSession(session); await this.syncRuntimeState(session); + this.applyStartupPermissionAndPlanToAppState(); this.state.startupState = 'ready'; return shouldReplayHistory; } @@ -1079,10 +1081,7 @@ export class KimiTUI { } async syncRuntimeState(session: Session = this.requireSession()): Promise { - const [status, goalResult] = await Promise.all([ - session.getStatus(), - session.getGoal(), - ]); + const [status, goalResult] = await Promise.all([session.getStatus(), session.getGoal()]); this.setAppState({ sessionId: session.id, model: status.model ?? '', @@ -1098,6 +1097,21 @@ export class KimiTUI { }); } + // Re-apply startup flags that the user explicitly passed on the command line. + // syncRuntimeState and session-replay hydration can both read stale persisted + // values, so this guarantees the footer reflects the CLI intent. + private applyStartupPermissionAndPlanToAppState(): void { + const { startup } = this.options; + if (startup.auto) { + this.setAppState({ permissionMode: 'auto' }); + } else if (startup.yolo) { + this.setAppState({ permissionMode: 'yolo' }); + } + if (startup.plan) { + this.setAppState({ planMode: true }); + } + } + // Plan mode is set by createSession — do not re-enter it here. private async activateRuntime(): Promise { const session = this.requireSession(); @@ -1336,10 +1350,7 @@ export class KimiTUI { return new GoalSetMessageComponent(); } if (entry.goalData?.kind === 'lifecycle') { - return buildGoalMarker( - entry.goalData.change, - this.state.toolOutputExpanded, - ); + return buildGoalMarker(entry.goalData.change, this.state.toolOutputExpanded); } return null; case 'assistant': { @@ -1396,7 +1407,10 @@ export class KimiTUI { } } - private appendApprovalTranscriptEntry(request: ApprovalRequest, response: ApprovalResponse): void { + private appendApprovalTranscriptEntry( + request: ApprovalRequest, + response: ApprovalResponse, + ): void { if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return; const parts: string[] = []; switch (response.decision) { @@ -1425,9 +1439,7 @@ export class KimiTUI { private renderWelcome(): void { if ( - this.state.transcriptContainer.children.some( - (child) => child instanceof WelcomeComponent, - ) + this.state.transcriptContainer.children.some((child) => child instanceof WelcomeComponent) ) { return; } @@ -1457,16 +1469,12 @@ export class KimiTUI { } showStatus(message: string, color?: ColorToken): void { - this.state.transcriptContainer.addChild( - new StatusMessageComponent(message, color), - ); + this.state.transcriptContainer.addChild(new StatusMessageComponent(message, color)); this.state.ui.requestRender(); } showNotice(title: string, detail?: string): void { - this.state.transcriptContainer.addChild( - new NoticeMessageComponent(title, detail), - ); + this.state.transcriptContainer.addChild(new NoticeMessageComponent(title, detail)); this.state.ui.requestRender(); } @@ -1641,9 +1649,7 @@ export class KimiTUI { } async applyTheme(themeName: ThemeName, resolved?: ResolvedTheme): Promise { - const palette = await getColorPalette( - themeName === 'auto' ? (resolved ?? 'dark') : themeName, - ); + const palette = await getColorPalette(themeName === 'auto' ? (resolved ?? 'dark') : themeName); currentTheme.setPalette(palette); this.setAppState({ theme: themeName }); this.updateEditorBorderHighlight(); @@ -1689,7 +1695,9 @@ export class KimiTUI { ); } - private shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode: EffectiveActivityPaneMode): boolean { + private shouldPlaceActivitySpinnerInAgentSwarm( + effectiveMode: EffectiveActivityPaneMode, + ): boolean { return ( this.sessionEventHandler.hasActiveAgentSwarmToolCall() && (effectiveMode === 'waiting' || effectiveMode === 'tool') @@ -1781,11 +1789,7 @@ export class KimiTUI { // Persist the skip marker `detectPendingMigration` checks, so "Never ask // again" actually stops the prompt from reappearing every launch. try { - writeFileSync( - join(this.harness.homeDir, '.skip-migration-from-kimi-cli'), - '', - 'utf-8', - ); + writeFileSync(join(this.harness.homeDir, '.skip-migration-from-kimi-cli'), '', 'utf-8'); } catch { // Non-blocking: a failed marker write must never crash startup. } @@ -1852,10 +1856,21 @@ export class KimiTUI { loading: this.state.loadingSessions, currentSessionId: this.state.appState.sessionId, onSelect: (sessionId: string) => { - void this.resumeSession(sessionId).then((switched) => { - if (switched) { - this.hideSessionPicker(); + void this.resumeSession(sessionId).then(async (switched) => { + if (!switched) { + return; } + const session = this.requireSession(); + if (this.options.startup.auto) { + await session.setPermission('auto'); + } else if (this.options.startup.yolo) { + await session.setPermission('yolo'); + } + if (this.options.startup.plan) { + await session.setPlanMode(true); + } + this.applyStartupPermissionAndPlanToAppState(); + this.hideSessionPicker(); }); }, onCancel, @@ -1955,5 +1970,4 @@ export class KimiTUI { this.patchLivePane({ pendingQuestion: null }); this.restoreEditor(); } - } diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 90fb53ecf..e14629e01 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -47,7 +47,11 @@ describe('CLI options parsing', () => { describe('--version', () => { it('prints the version string and exits', () => { let output = ''; - const program = createProgram('1.2.3', () => {}, () => {}); + const program = createProgram( + '1.2.3', + () => {}, + () => {}, + ); program.exitOverride(); program.configureOutput({ writeOut: (s) => { @@ -61,7 +65,11 @@ describe('CLI options parsing', () => { it('supports -V as a short alias', () => { let output = ''; - const program = createProgram('4.5.6', () => {}, () => {}); + const program = createProgram( + '4.5.6', + () => {}, + () => {}, + ); program.exitOverride(); program.configureOutput({ writeOut: (s) => { @@ -103,9 +111,7 @@ describe('CLI options parsing', () => { '--flag', ]); - expect(pluginRunnerCalls).toEqual([ - { entry: '/plugin/tool.mjs', args: ['query', '--flag'] }, - ]); + expect(pluginRunnerCalls).toEqual([{ entry: '/plugin/tool.mjs', args: ['query', '--flag'] }]); }); }); @@ -161,6 +167,50 @@ describe('CLI options parsing', () => { }); }); + describe('--auto / --yolo / --plan with --session / --continue', () => { + it('allows --auto with --continue', () => { + const opts = parse(['--auto', '--continue']); + expect(opts.auto).toBe(true); + expect(opts.continue).toBe(true); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --auto with an explicit session id', () => { + const opts = parse(['--auto', '--session', 'ses_123']); + expect(opts.auto).toBe(true); + expect(opts.session).toBe('ses_123'); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --yolo with --continue', () => { + const opts = parse(['--yolo', '--continue']); + expect(opts.yolo).toBe(true); + expect(opts.continue).toBe(true); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --yolo with an explicit session id', () => { + const opts = parse(['--yolo', '--session', 'ses_123']); + expect(opts.yolo).toBe(true); + expect(opts.session).toBe('ses_123'); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --plan with --continue', () => { + const opts = parse(['--plan', '--continue']); + expect(opts.plan).toBe(true); + expect(opts.continue).toBe(true); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --plan with an explicit session id', () => { + const opts = parse(['--plan', '--session', 'ses_123']); + expect(opts.plan).toBe(true); + expect(opts.session).toBe('ses_123'); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + }); + describe('--model / -m', () => { it('parses -m as a model override', () => { expect(parse(['-m', 'kimi-code/k2']).model).toBe('kimi-code/k2'); @@ -211,7 +261,9 @@ describe('CLI options parsing', () => { it('rejects prompt mode with bare --session picker', () => { const opts = parse(['-p', 'resume here', '--session']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); - expect(() => validateOptions(opts)).toThrow('Cannot use --session without an id in prompt mode.'); + expect(() => validateOptions(opts)).toThrow( + 'Cannot use --session without an id in prompt mode.', + ); }); it('rejects prompt mode with --yolo because prompt mode always uses auto permission', () => { @@ -281,7 +333,11 @@ describe('CLI options parsing', () => { }); it('registers the visible sub-commands', () => { - const program = createProgram('0.0.0', () => {}, () => {}); + const program = createProgram( + '0.0.0', + () => {}, + () => {}, + ); const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) .map((command) => command.name()); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index f207d3914..a46d67466 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1,30 +1,23 @@ -import { describe, expect, it, vi } from "vitest"; +import { log, type GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; +import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { describe, expect, it, vi } from 'vitest'; -import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; -import { log, type GoalSnapshot } from "@moonshot-ai/kimi-code-sdk"; - -import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui"; -import { BannerProvider } from "#/tui/banner/banner-provider"; -import { BannerComponent } from "#/tui/components/chrome/banner"; -import { WelcomeComponent } from "#/tui/components/chrome/welcome"; -import { - handleLoginCommand, - handleLogoutCommand, -} from "#/tui/commands/auth"; -import { - promptPlatformSelection, - promptLogoutProviderSelection, -} from "#/tui/commands/prompts"; +import { BannerProvider } from '#/tui/banner/banner-provider'; +import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; +import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; +import { BannerComponent } from '#/tui/components/chrome/banner'; +import { WelcomeComponent } from '#/tui/components/chrome/welcome'; +import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, OSC11_QUERY, QUERY_TERMINAL_THEME, TERMINAL_THEME_LIGHT, -} from "#/tui/utils/terminal-theme"; +} from '#/tui/utils/terminal-theme'; -vi.mock("#/tui/commands/prompts", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('#/tui/commands/prompts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; }); @@ -53,7 +46,7 @@ interface MigrateExitDriver extends StartupDriver { } const MIGRATION_PLAN: MigrationPlan = { - sourceHome: "/x/.kimi", + sourceHome: '/x/.kimi', hasConfig: false, hasMcp: false, hasUserHistory: false, @@ -65,8 +58,8 @@ const MIGRATION_PLAN: MigrationPlan = { }; function makeStartupInput( - cliOptions: Partial = {}, - tuiConfig: Partial = {}, + cliOptions: Partial = {}, + tuiConfig: Partial = {}, ): KimiTUIStartupInput { return { cliOptions: { @@ -82,26 +75,26 @@ function makeStartupInput( ...cliOptions, }, tuiConfig: { - theme: "dark", + theme: 'dark', editorCommand: null, - notifications: { enabled: true, condition: "unfocused" }, + notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, ...tuiConfig, }, - version: "0.0.0-test", - workDir: "/tmp/proj-a", + version: '0.0.0-test', + workDir: '/tmp/proj-a', }; } function makeSession(overrides: Record = {}) { return { - id: "ses-1", - model: "k2", - summary: { title: "Session title" }, + id: 'ses-1', + model: 'k2', + summary: { title: 'Session title' }, getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "manual", + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -124,9 +117,9 @@ function makeSession(overrides: Record = {}) { function goalSnapshot(overrides: Partial = {}): GoalSnapshot { return { - goalId: "goal-1", - objective: "Ship feature X", - status: "paused", + goalId: 'goal-1', + objective: 'Ship feature X', + status: 'paused', turnsUsed: 2, tokensUsed: 100, wallClockMs: 1000, @@ -146,9 +139,39 @@ function goalSnapshot(overrides: Partial = {}): GoalSnapshot { }; } +function createResumeState(overrides: { permissionMode?: string; planMode?: boolean } = {}) { + return { + id: 'ses-latest', + workDir: '/tmp/proj-a', + sessionDir: '/tmp/proj-a/.kimi/sessions/ses-latest', + createdAt: Date.now(), + updatedAt: Date.now(), + sessionMetadata: {}, + agents: { + main: { + type: 'main', + config: { + cwd: '/tmp/proj-a', + modelCapabilities: { max_context_tokens: 100 }, + thinkingLevel: 'off', + systemPrompt: '', + }, + context: { history: [], tokenCount: 10 }, + replay: [], + permission: { mode: overrides.permissionMode ?? 'manual', rules: [] }, + plan: overrides.planMode ? { id: 'plan-1', content: '', path: '/tmp/plan.md' } : null, + swarmMode: false, + usage: {}, + tools: [], + background: [], + }, + }, + } as never; +} + function loginRequiredError(): Error & { readonly code: string } { return Object.assign(new Error('OAuth provider "managed:kimi-code" requires login.'), { - code: "auth.login_required", + code: 'auth.login_required', }); } @@ -156,7 +179,7 @@ function makeHarness(session = makeSession(), overrides: Record return { getConfig: vi.fn(async () => ({ models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession: vi.fn(async () => session), @@ -178,21 +201,21 @@ function makeHarness(session = makeSession(), overrides: Record function makeDriver(harness: ReturnType, input: KimiTUIStartupInput) { const driver = new KimiTUI(harness as never, input) as unknown as StartupDriver; - vi.spyOn(driver.state.ui, "requestRender").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "setProgress").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); return driver; } -type InputListener = Parameters[0]; -const DARK_OSC11_REPORT = "\u001B]11;rgb:2828/2c2c/3434\u0007"; -const LIGHT_OSC11_REPORT = "\u001B]11;rgb:fafa/fbfb/fcfc\u0007"; +type InputListener = Parameters[0]; +const DARK_OSC11_REPORT = '\u001B]11;rgb:2828/2c2c/3434\u0007'; +const LIGHT_OSC11_REPORT = '\u001B]11;rgb:fafa/fbfb/fcfc\u0007'; function captureInputListeners(driver: StartupDriver) { const listeners: InputListener[] = []; const removeInputListener = vi.fn<() => void>(); - const write = vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + const write = vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); const addInputListener = vi - .spyOn(driver.state.ui, "addInputListener") + .spyOn(driver.state.ui, 'addInputListener') .mockImplementation((listener: InputListener) => { listeners.push(listener); return removeInputListener; @@ -201,13 +224,13 @@ function captureInputListeners(driver: StartupDriver) { return { listeners, removeInputListener, write, addInputListener }; } -describe("KimiTUI startup", () => { - it("creates a fresh session from startup flags and syncs runtime state", async () => { +describe('KimiTUI startup', () => { + it('creates a fresh session from startup flags and syncs runtime state', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "yolo", + model: 'k2', + thinkingLevel: 'off', + permission: 'yolo', planMode: true, contextTokens: 25, maxContextTokens: 200, @@ -220,51 +243,253 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ - workDir: "/tmp/proj-a", - permission: "yolo", + workDir: '/tmp/proj-a', + permission: 'yolo', planMode: true, }); expect(session.setApprovalHandler).toHaveBeenCalledOnce(); expect(session.setQuestionHandler).toHaveBeenCalledOnce(); expect(harness.setTelemetryContext).toHaveBeenCalledWith({ sessionId: null }); - expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: "ses-1" }); - expect(driver.state.startupState).toBe("ready"); + expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: 'ses-1' }); + expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", - permissionMode: "yolo", + sessionId: 'ses-1', + model: 'k2', + permissionMode: 'yolo', planMode: true, contextTokens: 25, maxContextTokens: 200, contextUsage: 0.125, - sessionTitle: "Session title", + sessionTitle: 'Session title', }); }); - it("resumes the latest session for --continue and marks history for replay", async () => { - const session = makeSession({ id: "ses-latest" }); + it('resumes the latest session for --continue and marks history for replay', async () => { + const session = makeSession({ id: 'ses-latest' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }, { id: "ses-old" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }, { id: 'ses-old' }]), }); const driver = makeDriver(harness, makeStartupInput({ continue: true })); await expect(driver.init()).resolves.toBe(true); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-latest" }); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); expect(harness.createSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe("ses-latest"); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe('ses-latest'); }); - it("syncs a persisted goal when resuming a session", async () => { - const goal = goalSnapshot({ status: "blocked", terminalReason: "needs input" }); + it('applies --auto permission when resuming a session via --continue', async () => { + let permission = 'manual'; const session = makeSession({ - id: "ses-latest", + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('applies --yolo permission when resuming a session via --continue', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, yolo: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(driver.state.appState.permissionMode).toBe('yolo'); + }); + + it('applies --plan mode when resuming a session via --continue', async () => { + let planMode = false; + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async (enabled: boolean) => { + planMode = enabled; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).toHaveBeenCalledWith(true); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('forces footer state to reflect --auto even if getStatus lags behind', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async () => {}), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('forces footer state to reflect --plan even if getStatus lags behind', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => {}), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).toHaveBeenCalledWith(true); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('keeps --auto in the footer after session replay hydration', async () => { + const session = makeSession({ + id: 'ses-latest', + getResumeState: vi.fn(() => createResumeState({ permissionMode: 'manual', planMode: false })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + await ( + driver as unknown as { + finishStartup(shouldReplayHistory: boolean): Promise; + } + ).finishStartup(true); + + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('keeps --plan in the footer after session replay hydration', async () => { + const session = makeSession({ + id: 'ses-latest', + getResumeState: vi.fn(() => createResumeState({ permissionMode: 'manual', planMode: false })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + await ( + driver as unknown as { + finishStartup(shouldReplayHistory: boolean): Promise; + } + ).finishStartup(true); + + expect(driver.state.appState.planMode).toBe(true); + }); + + it('applies --auto permission when resuming an explicit session', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-target', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: 'ses-target', auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('syncs a persisted goal when resuming a session', async () => { + const goal = goalSnapshot({ status: 'blocked', terminalReason: 'needs input' }); + const session = makeSession({ + id: 'ses-latest', getGoal: vi.fn(async () => ({ goal })), }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), - getExperimentalFeatures: vi.fn(async () => [{ id: "micro_compaction", enabled: true }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + getExperimentalFeatures: vi.fn(async () => [{ id: 'micro_compaction', enabled: true }]), }); const driver = makeDriver(harness, makeStartupInput({ continue: true })); @@ -274,7 +499,7 @@ describe("KimiTUI startup", () => { expect(driver.state.appState.goal).toEqual(goal); }); - it("syncs goal state regardless of the goal flag", async () => { + it('syncs goal state regardless of the goal flag', async () => { const goal = goalSnapshot(); const session = makeSession({ getGoal: vi.fn(async () => ({ goal })), @@ -288,48 +513,48 @@ describe("KimiTUI startup", () => { expect(driver.state.appState.goal).toEqual(goal); }); - it("clears goal state when closing the current session", async () => { + it('clears goal state when closing the current session', async () => { const goal = goalSnapshot(); const session = makeSession({ getGoal: vi.fn(async () => ({ goal })), }); const harness = makeHarness(session, { - getExperimentalFeatures: vi.fn(async () => [{ id: "micro_compaction", enabled: true }]), + getExperimentalFeatures: vi.fn(async () => [{ id: 'micro_compaction', enabled: true }]), }); const driver = makeDriver(harness, makeStartupInput()) as unknown as RuntimeStateDriver; await expect(driver.init()).resolves.toBe(false); expect(driver.state.appState.goal).toEqual(goal); - await driver.closeSession("test close"); + await driver.closeSession('test close'); expect(driver.state.appState.goal).toBeNull(); }); - it("passes the CLI model override when creating a fresh startup session", async () => { + it('passes the CLI model override when creating a fresh startup session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ model: "kimi-code/k2.5" })); + const driver = makeDriver(harness, makeStartupInput({ model: 'kimi-code/k2.5' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ - workDir: "/tmp/proj-a", - model: "kimi-code/k2.5", + workDir: '/tmp/proj-a', + model: 'kimi-code/k2.5', permission: undefined, planMode: undefined, }); }); - it("applies the CLI model override when resuming a startup session", async () => { - let model = "k2"; + it('applies the CLI model override when resuming a startup session', async () => { + let model = 'k2'; const session = makeSession({ setModel: vi.fn(async (nextModel: string) => { model = nextModel; }), getStatus: vi.fn(async () => ({ model, - thinkingLevel: "off", - permission: "manual", + thinkingLevel: 'off', + permission: 'manual', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -337,51 +562,92 @@ describe("KimiTUI startup", () => { })), }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), }); const driver = makeDriver( harness, - makeStartupInput({ continue: true, model: "kimi-code/k2.5" }), + makeStartupInput({ continue: true, model: 'kimi-code/k2.5' }), ); await expect(driver.init()).resolves.toBe(true); - expect(session.setModel).toHaveBeenCalledWith("kimi-code/k2.5"); - expect(driver.state.appState.model).toBe("kimi-code/k2.5"); + expect(session.setModel).toHaveBeenCalledWith('kimi-code/k2.5'); + expect(driver.state.appState.model).toBe('kimi-code/k2.5'); }); - it("enters picker startup for bare --session without creating a session", async () => { + it('enters picker startup for bare --session without creating a session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ session: "" })); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).not.toHaveBeenCalled(); expect(harness.resumeSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("picker"); + expect(driver.state.startupState).toBe('picker'); }); - it("clears startup picker exit confirmation before resuming a selected session", async () => { - const session = makeSession({ id: "ses-picked" }); + it('applies --auto after picking a session from bare --session', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-picked', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); const harness = makeHarness(session, { listSessions: vi.fn(async () => [ { - id: "ses-picked", - title: "Picked session", - workDir: "/tmp/proj-a", + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', updatedAt: Date.now(), }, ]), }); - const driver = makeDriver(harness, makeStartupInput({ session: "" })); - const stop = vi.spyOn(driver, "stop").mockResolvedValue(undefined); + const driver = makeDriver(harness, makeStartupInput({ session: '', auto: true })); + + await (driver as unknown as { initMainTui(): Promise }).initMainTui(); + expect(driver.state.startupState).toBe('picker'); + await (driver as unknown as { bootstrapFromPicker(): Promise }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('clears startup picker exit confirmation before resuming a selected session', async () => { + const session = makeSession({ id: 'ses-picked' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [ + { + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); + const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined); await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false); await (driver as unknown as { bootstrapFromPicker(): Promise }).bootstrapFromPicker(); const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; - picker.handleInput("\u0003"); - picker.handleInput("\r"); + picker.handleInput('\u0003'); + picker.handleInput('\r'); await new Promise((resolve) => setImmediate(resolve)); driver.state.editor.onCtrlC?.(); @@ -389,11 +655,11 @@ describe("KimiTUI startup", () => { expect(stop).not.toHaveBeenCalled(); }); - it("tracks terminal theme reports while auto theme is active", () => { + it('tracks terminal theme reports while auto theme is active', () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }), + makeStartupInput({}, { theme: 'auto' }), ) as unknown as ThemeTrackingDriver; const { listeners, write, addInputListener } = captureInputListeners(driver); @@ -408,19 +674,19 @@ describe("KimiTUI startup", () => { write.mockClear(); expect(listeners[0]?.(TERMINAL_THEME_LIGHT)).toEqual({ consume: true }); expect(write).toHaveBeenCalledWith(OSC11_QUERY); - expect(driver.state.appState.theme).toBe("auto"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(DARK_OSC11_REPORT)).toEqual({ consume: true }); - expect(driver.state.appState.theme).toBe("auto"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(LIGHT_OSC11_REPORT)).toEqual({ consume: true }); - expect(driver.state.appState.theme).toBe("auto"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).toHaveBeenCalled(); }); - it("does not track terminal theme reports for explicit themes", () => { + it('does not track terminal theme reports for explicit themes', () => { const harness = makeHarness(); const driver = makeDriver(harness, makeStartupInput()) as unknown as ThemeTrackingDriver; const { write, addInputListener } = captureInputListeners(driver); @@ -431,23 +697,23 @@ describe("KimiTUI startup", () => { expect(write).not.toHaveBeenCalled(); }); - it("disables terminal theme reports after leaving auto theme", () => { + it('disables terminal theme reports after leaving auto theme', () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }), + makeStartupInput({}, { theme: 'auto' }), ) as unknown as ThemeTrackingDriver; const { write, removeInputListener } = captureInputListeners(driver); driver.refreshTerminalThemeTracking(); - driver.state.appState.theme = "dark"; + driver.state.appState.theme = 'dark'; driver.refreshTerminalThemeTracking(); expect(removeInputListener).toHaveBeenCalledOnce(); expect(write).toHaveBeenCalledWith(DISABLE_TERMINAL_THEME_REPORTING); }); - it("starts TUI without a session when fresh startup needs OAuth login", async () => { + it('starts TUI without a session when fresh startup needs OAuth login', async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { throw loginRequiredError(); @@ -457,11 +723,11 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - expect(driver.state.startupState).toBe("ready"); - expect((driver as any).startupNotice).toContain("OAuth login expired"); + expect(driver.state.startupState).toBe('ready'); + expect((driver as any).startupNotice).toContain('OAuth login expired'); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", + sessionId: '', + model: '', thinking: false, contextTokens: 0, maxContextTokens: 0, @@ -470,12 +736,12 @@ describe("KimiTUI startup", () => { }); }); - it("preserves fresh startup yolo and plan intent after OAuth login", async () => { + it('preserves fresh startup yolo and plan intent after OAuth login', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "yolo", + model: 'k2', + thinkingLevel: 'off', + permission: 'yolo', planMode: true, contextTokens: 10, maxContextTokens: 100, @@ -488,10 +754,10 @@ describe("KimiTUI startup", () => { .mockResolvedValueOnce(session); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", + defaultModel: 'k2', defaultThinking: false, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession, @@ -501,9 +767,9 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", - permissionMode: "yolo", + sessionId: '', + model: '', + permissionMode: 'yolo', planMode: true, }); @@ -511,31 +777,31 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(1, { - workDir: "/tmp/proj-a", - permission: "yolo", + workDir: '/tmp/proj-a', + permission: 'yolo', planMode: true, }); expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: "/tmp/proj-a", - model: "k2", - thinking: "off", - permission: "yolo", + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: 'yolo', planMode: true, }); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", - permissionMode: "yolo", + sessionId: 'ses-1', + model: 'k2', + permissionMode: 'yolo', planMode: true, }); }); - it("does not force manual permission after OAuth login without --yolo", async () => { + it('does not force manual permission after OAuth login without --yolo', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "auto", + model: 'k2', + thinkingLevel: 'off', + permission: 'auto', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -548,10 +814,10 @@ describe("KimiTUI startup", () => { .mockResolvedValueOnce(session); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", + defaultModel: 'k2', defaultThinking: false, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession, @@ -563,25 +829,25 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: "/tmp/proj-a", - model: "k2", - thinking: "off", + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', permission: undefined, planMode: undefined, }); expect(driver.state.appState).toMatchObject({ - permissionMode: "auto", + permissionMode: 'auto', }); }); - it("syncs configured thinking after OAuth login refreshes an active session", async () => { + it('syncs configured thinking after OAuth login refreshes an active session', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", + defaultModel: 'k2', defaultThinking: true, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), }); @@ -593,25 +859,25 @@ describe("KimiTUI startup", () => { vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await handleLoginCommand(driver as any); - expect(session.setModel).toHaveBeenCalledWith("k2"); - expect(session.setThinking).toHaveBeenCalledWith("on"); + expect(session.setModel).toHaveBeenCalledWith('k2'); + expect(session.setThinking).toHaveBeenCalledWith('on'); expect(driver.state.appState).toMatchObject({ - model: "k2", + model: 'k2', thinking: true, maxContextTokens: 100, }); - expect(harness.track).toHaveBeenCalledWith("login", { - provider: "managed:kimi-code", + expect(harness.track).toHaveBeenCalledWith('login', { + provider: 'managed:kimi-code', already_logged_in: false, }); }); - it("tracks login with already_logged_in when a token already exists", async () => { + it('tracks login with already_logged_in when a token already exists', async () => { const session = makeSession(); const harness = makeHarness(session, { auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -627,22 +893,22 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - "managed:kimi-code", + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), }), ); - expect(harness.track).toHaveBeenCalledWith("login", { - provider: "managed:kimi-code", + expect(harness.track).toHaveBeenCalledWith('login', { + provider: 'managed:kimi-code', already_logged_in: true, }); }); - it("logs login failures with session context", async () => { - const warn = vi.spyOn(log, "warn").mockImplementation(() => {}); + it('logs login failures with session context', async () => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); const session = makeSession(); - const loginError = new Error("Failed to list Kimi Code models (HTTP 402)."); + const loginError = new Error('Failed to list Kimi Code models (HTTP 402).'); const harness = makeHarness(session, { auth: { status: vi.fn(async () => ({ providers: [] })), @@ -662,20 +928,20 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - "managed:kimi-code", + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), }), ); expect(warn).toHaveBeenCalledWith( - "login failed", + 'login failed', expect.objectContaining({ - providerName: "managed:kimi-code", + providerName: 'managed:kimi-code', alreadyLoggedIn: false, - sessionId: "ses-1", + sessionId: 'ses-1', error: expect.objectContaining({ - message: "Failed to list Kimi Code models (HTTP 402).", + message: 'Failed to list Kimi Code models (HTTP 402).', }), }), ); @@ -684,18 +950,18 @@ describe("KimiTUI startup", () => { } }); - it("tracks logout after managed credentials and session state are cleared", async () => { + it('tracks logout after managed credentials and session state are cleared', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, - providers: { "managed:kimi-code": { type: "kimi" } }, + providers: { 'managed:kimi-code': { type: 'kimi' } }, })), auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -707,38 +973,36 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue( - "managed:kimi-code", - ); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); expect(session.close).toHaveBeenCalledOnce(); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", + sessionId: '', + model: '', sessionTitle: null, }); - expect(harness.track).toHaveBeenCalledWith("logout", { provider: "managed:kimi-code" }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:kimi-code' }); }); - it("keeps the active session when logging out a different provider", async () => { + it('keeps the active session when logging out a different provider', async () => { const session = makeSession(); const removeProvider = vi.fn(async () => {}); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, providers: { - "managed:kimi-code": { type: "kimi" }, - openai: { type: "openai", baseUrl: "https://api.openai.com/v1" }, + 'managed:kimi-code': { type: 'kimi' }, + openai: { type: 'openai', baseUrl: 'https://api.openai.com/v1' }, }, })), removeProvider, auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -750,33 +1014,33 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue("openai"); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('openai'); await handleLogoutCommand(driver as any); - expect(removeProvider).toHaveBeenCalledWith("openai"); + expect(removeProvider).toHaveBeenCalledWith('openai'); expect(harness.auth.logout).not.toHaveBeenCalled(); expect(session.close).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", + sessionId: 'ses-1', + model: 'k2', }); - expect(harness.track).toHaveBeenCalledWith("logout", { provider: "openai" }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'openai' }); }); - it("can log out a stale managed entry even after the OAuth token is gone", async () => { + it('can log out a stale managed entry even after the OAuth token is gone', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, - providers: { "managed:kimi-code": { type: "kimi" } }, + providers: { 'managed:kimi-code': { type: 'kimi' } }, })), auth: { // Token gone (e.g. credentials file deleted) but the managed entry // is still sitting in config.providers. status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: false }], + providers: [{ providerName: 'managed:kimi-code', hasToken: false }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -787,17 +1051,15 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue( - "managed:kimi-code", - ); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); }); - it("starts TUI without replaying when --continue needs OAuth login", async () => { + it('starts TUI without replaying when --continue needs OAuth login', async () => { const harness = makeHarness(makeSession(), { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), resumeSession: vi.fn(async () => { throw loginRequiredError(); }), @@ -806,29 +1068,29 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-latest" }); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); expect(harness.createSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe(""); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe(''); }); - it("starts TUI without replaying when an explicit resume needs OAuth login", async () => { + it('starts TUI without replaying when an explicit resume needs OAuth login', async () => { const harness = makeHarness(makeSession(), { - listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), resumeSession: vi.fn(async () => { throw loginRequiredError(); }), }); - const driver = makeDriver(harness, makeStartupInput({ session: "ses-target" })); + const driver = makeDriver(harness, makeStartupInput({ session: 'ses-target' })); await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-target" }); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe(""); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe(''); }); - it("disposes terminal focus/theme tracking on the kimi migrate exit", async () => { + it('disposes terminal focus/theme tracking on the kimi migrate exit', async () => { const harness = makeHarness(); const driver = makeDriver(harness, { ...makeStartupInput(), @@ -836,11 +1098,11 @@ describe("KimiTUI startup", () => { migrateOnly: true, }) as unknown as MigrateExitDriver; // pi-tui start/stop and focus tracking touch the real TTY — stub the I/O. - vi.spyOn(driver.state.ui, "start").mockImplementation(() => {}); - vi.spyOn(driver.state.ui, "stop").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); // The migration screen would await user input; resolve it immediately. - vi.spyOn(driver, "runMigrationScreen").mockResolvedValue({ decision: "later" }); + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); const onExit = vi.fn(async () => {}); driver.onExit = onExit; @@ -853,40 +1115,40 @@ describe("KimiTUI startup", () => { expect(onExit).toHaveBeenCalledWith(0); }); - it("disposes terminal tracking when post-migration startup fails", async () => { + it('disposes terminal tracking when post-migration startup fails', async () => { const harness = makeHarness(); const driver = makeDriver(harness, { ...makeStartupInput(), migrationPlan: MIGRATION_PLAN, migrateOnly: false, }) as unknown as MigrateExitDriver; - vi.spyOn(driver.state.ui, "start").mockImplementation(() => {}); - vi.spyOn(driver.state.ui, "stop").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); // The migration screen resolves "later"; startup then continues into // initMainTui(), which fails (e.g. a session-resume error). - vi.spyOn(driver, "runMigrationScreen").mockResolvedValue({ decision: "later" }); - vi.spyOn(driver, "initMainTui").mockRejectedValue(new Error("resume boom")); + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); + vi.spyOn(driver, 'initMainTui').mockRejectedValue(new Error('resume boom')); - await expect(driver.start()).rejects.toThrow("resume boom"); + await expect(driver.start()).rejects.toThrow('resume boom'); // The focus tracking installed by startEventLoop() must be torn down // before the error propagates — not left active after the process exits. expect(driver.terminalFocusTrackingDispose).toBeUndefined(); }); - it("keeps non-login startup session errors fatal", async () => { + it('keeps non-login startup session errors fatal', async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { - throw new Error("provider config is invalid"); + throw new Error('provider config is invalid'); }), }); const driver = makeDriver(harness, makeStartupInput()); - await expect(driver.init()).rejects.toThrow("provider config is invalid"); + await expect(driver.init()).rejects.toThrow('provider config is invalid'); }); - it("does not mount the footer when resuming a missing session fails", async () => { + it('does not mount the footer when resuming a missing session fails', async () => { // Regression: a stray pre-startEventLoop render used to paint the footer // (cwd/git + "context:" statusline) to the terminal before the fatal // error, leaving it stranded above the error message. The footer must not @@ -896,23 +1158,21 @@ describe("KimiTUI startup", () => { }); const driver = makeDriver( harness, - makeStartupInput({ session: "missing-session" }), + makeStartupInput({ session: 'missing-session' }), ) as unknown as MigrateExitDriver; - await expect(driver.initMainTui()).rejects.toThrow( - 'Session "missing-session" not found.', - ); + await expect(driver.initMainTui()).rejects.toThrow('Session "missing-session" not found.'); expect(uiContainsFooter(driver)).toBe(false); }); - it("mounts the footer once startup reaches the main TUI", async () => { - const session = makeSession({ id: "ses-target" }); + it('mounts the footer once startup reaches the main TUI', async () => { + const session = makeSession({ id: 'ses-target' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), }); const driver = makeDriver( harness, - makeStartupInput({ session: "ses-target" }), + makeStartupInput({ session: 'ses-target' }), ) as unknown as MigrateExitDriver; // Not mounted until init() succeeds. @@ -923,31 +1183,27 @@ describe("KimiTUI startup", () => { expect(uiContainsFooter(driver)).toBe(true); }); - it("renders the banner below the welcome message after it loads", async () => { + it('renders the banner below the welcome message after it loads', async () => { const banner = { - tag: "New", - mainText: "Banner main", + tag: 'New', + mainText: 'Banner main', subText: null, }; - const loadSpy = vi - .spyOn(BannerProvider.prototype, "load") - .mockResolvedValue(banner); - const session = makeSession({ id: "ses-target" }); + const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); + const session = makeSession({ id: 'ses-target' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), }); const driver = makeDriver( harness, - makeStartupInput({ session: "ses-target" }), + makeStartupInput({ session: 'ses-target' }), ) as unknown as MigrateExitDriver; await driver.initMainTui(); await vi.waitFor(() => { expect( - driver.state.transcriptContainer.children.some( - (child) => child instanceof BannerComponent, - ), + driver.state.transcriptContainer.children.some((child) => child instanceof BannerComponent), ).toBe(true); }); @@ -965,29 +1221,24 @@ describe("KimiTUI startup", () => { loadSpy.mockRestore(); }); - it("resumes a startup session when Windows workdir uses backslashes", async () => { - const session = makeSession({ id: "ses-target" }); + it('resumes a startup session when Windows workdir uses backslashes', async () => { + const session = makeSession({ id: 'ses-target' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [ - { id: "ses-target", workDir: "C:/Users/kimi/project" }, - ]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: 'C:/Users/kimi/project' }]), + }); + const driver = makeDriver(harness, { + ...makeStartupInput({ session: 'ses-target' }), + workDir: String.raw`C:\Users\kimi\project`, }); - const driver = makeDriver( - harness, - { - ...makeStartupInput({ session: "ses-target" }), - workDir: String.raw`C:\Users\kimi\project`, - }, - ); await expect(driver.init()).resolves.toBe(true); expect(harness.listSessions).toHaveBeenCalledWith({ - sessionId: "ses-target", + sessionId: 'ses-target', workDir: String.raw`C:\Users\kimi\project`, }); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-target" }); - expect(driver.state.appState.sessionId).toBe("ses-target"); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(driver.state.appState.sessionId).toBe('ses-target'); }); }); diff --git a/docs/en/guides/sessions.md b/docs/en/guides/sessions.md index 6f8a05913..15c56fcca 100644 --- a/docs/en/guides/sessions.md +++ b/docs/en/guides/sessions.md @@ -51,7 +51,7 @@ kimi --session ``` ::: warning -`--continue` and `--session` are mutually exclusive. `--yolo` and `--plan` cannot be combined with them either. +`--continue` and `--session` are mutually exclusive. ::: ## Switching sessions inside the TUI diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index b8be01096..5b96a9496 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -37,12 +37,10 @@ The following combinations are rejected at startup: - `--continue` and `--session` are mutually exclusive — both mean "resume a previous session" - `--yolo` and `--auto` are mutually exclusive — the two permission modes cannot be combined -- `--yolo` and `--auto` cannot be used together with `--continue` or `--session` — resumed sessions inherit the approval settings of the original session -- `--plan` cannot be used with `--continue` or `--session` — Plan mode only takes effect for new sessions - `--prompt` cannot be used with `--yolo`, `--auto`, or `--plan` — non-interactive mode uses `auto` permission by default - `--output-format` can only be used together with `--prompt` -To force YOLO or Plan mode when resuming a session, switch via slash commands inside the interactive session instead. +When resuming a session, you can override its saved permission or plan mode by adding `--auto`, `--yolo`, or `--plan`. For example, `kimi --continue --auto` resumes the latest session and switches it to auto permission mode. ## Common Usage diff --git a/docs/zh/guides/sessions.md b/docs/zh/guides/sessions.md index ad042f610..444fb4489 100644 --- a/docs/zh/guides/sessions.md +++ b/docs/zh/guides/sessions.md @@ -51,7 +51,7 @@ kimi --session ``` ::: warning 注意 -`--continue` 与 `--session` 互斥;`--yolo` 和 `--plan` 也不能与它们同时使用。 +`--continue` 与 `--session` 互斥。 ::: ## 在 TUI 中切换会话 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 36fb20d4c..67d259f30 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -37,12 +37,10 @@ kimi [options] - `--continue` 与 `--session` 互斥——两者都表示"恢复历史会话" - `--yolo` 和 `--auto` 互斥——两种权限模式互斥 -- `--yolo` 与 `--auto` 不能与 `--continue` 或 `--session` 同时使用——恢复会话时沿用原会话的审批设置 -- `--plan` 不能与 `--continue` 或 `--session` 同时使用——Plan 模式只对新会话生效 - `--prompt` 不能与 `--yolo`、`--auto` 或 `--plan` 同时使用——非交互模式固定使用 `auto` 权限 - `--output-format` 只能与 `--prompt` 一起使用 -如需在恢复会话时强制使用 YOLO 或 Plan 模式,请改在交互式会话内通过斜杠命令切换。 +恢复会话时,可以通过 `--auto`、`--yolo` 或 `--plan` 覆盖原会话保存的权限或计划模式。例如,`kimi --continue --auto` 会恢复最近会话并切换到 auto 权限模式。 ## 典型用法