fix(cli,tui): allow --auto, --yolo, and --plan with resumed sessions (#683)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* 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
This commit is contained in:
liruifengv 2026-06-12 14:17:12 +08:00 committed by GitHub
parent dff9fd4e32
commit ad239cb1c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 652 additions and 339 deletions

View file

@ -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.

View file

@ -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' };
}

View file

@ -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<void>;
track(
event: string,
properties?: Parameters<KimiHarness['track']>[1],
): void {
track(event: string, properties?: Parameters<KimiHarness['track']>[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<void> {
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<void> {
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<void> {
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();
}
}

View file

@ -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());

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -51,7 +51,7 @@ kimi --session
```
::: warning 注意
`--continue``--session` 互斥`--yolo``--plan` 也不能与它们同时使用
`--continue``--session` 互斥。
:::
## 在 TUI 中切换会话

View file

@ -37,12 +37,10 @@ kimi <subcommand> [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 权限模式
## 典型用法