Skip to content

Commit 7ca9bdf

Browse files
authored
fix(tui): skip re-entering plan mode on resume and scope startup flags to startup (#692)
Resuming a session that was already in plan mode with --plan crashed with "Already in plan mode": the resume path called setPlanMode(true) unconditionally while session replay had already restored the active plan state. Check the session status first and only enable plan mode when it is not active yet, in both the resume startup path and the startup session picker. The /sessions picker shared the same onSelect callback, so startup flags were also re-applied on every mid-session switch, overriding the picked session's own persisted modes. Gate the flag application behind an applyStartupModes option that only the startup picker enables, and surface post-switch setup errors instead of leaving them as unhandled rejections.
1 parent d1ba145 commit 7ca9bdf

3 files changed

Lines changed: 166 additions & 43 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Skip re-entering plan mode when resuming a session that is already in plan mode (previously failed with "Already in plan mode"), and stop re-applying `--auto`/`--yolo`/`--plan` startup flags when switching sessions through the `/sessions` picker.

apps/kimi-code/src/tui/kimi-tui.ts

Lines changed: 59 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -588,14 +588,7 @@ export class KimiTUI {
588588
session = await this.harness.createSession(createSessionOptions);
589589
}
590590
if (session !== undefined && shouldReplayHistory) {
591-
if (startup.auto) {
592-
await session.setPermission('auto');
593-
} else if (startup.yolo) {
594-
await session.setPermission('yolo');
595-
}
596-
if (startup.plan) {
597-
await session.setPlanMode(true);
598-
}
591+
await this.applyStartupModesToResumedSession(session);
599592
if (startup.model !== undefined) {
600593
await session.setModel(startup.model);
601594
}
@@ -1097,6 +1090,25 @@ export class KimiTUI {
10971090
});
10981091
}
10991092

1093+
// Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed
1094+
// session may already be in plan mode from its persisted records, and
1095+
// re-entering plan mode throws, so only enable it when it is not active yet.
1096+
// setPermission is idempotent and needs no such guard.
1097+
private async applyStartupModesToResumedSession(session: Session): Promise<void> {
1098+
const { startup } = this.options;
1099+
if (startup.auto) {
1100+
await session.setPermission('auto');
1101+
} else if (startup.yolo) {
1102+
await session.setPermission('yolo');
1103+
}
1104+
if (startup.plan) {
1105+
const status = await session.getStatus();
1106+
if (!status.planMode) {
1107+
await session.setPlanMode(true);
1108+
}
1109+
}
1110+
}
1111+
11001112
// Re-apply startup flags that the user explicitly passed on the command line.
11011113
// syncRuntimeState and session-replay hydration can both read stale persisted
11021114
// values, so this guarantees the footer reflects the CLI intent.
@@ -1830,27 +1842,28 @@ export class KimiTUI {
18301842

18311843
async showSessionPicker(): Promise<void> {
18321844
await this.fetchSessions();
1833-
this.mountSessionPicker(() => {
1834-
this.hideSessionPicker();
1845+
this.mountSessionPicker({
1846+
onCancel: () => {
1847+
this.hideSessionPicker();
1848+
},
18351849
});
18361850
}
18371851

18381852
private async bootstrapFromPicker(): Promise<void> {
18391853
await this.fetchSessions();
1840-
this.mountSessionPicker(
1841-
() => {
1854+
this.mountSessionPicker({
1855+
applyStartupModes: true,
1856+
onCancel: () => {
18421857
this.hideSessionPicker();
18431858
void this.stop();
18441859
},
1845-
{
1846-
onCtrlC: () => {
1847-
this.state.editor.onCtrlC?.();
1848-
},
1849-
onCtrlD: () => {
1850-
this.state.editor.onCtrlD?.();
1851-
},
1860+
onCtrlC: () => {
1861+
this.state.editor.onCtrlC?.();
18521862
},
1853-
);
1863+
onCtrlD: () => {
1864+
this.state.editor.onCtrlD?.();
1865+
},
1866+
});
18541867
}
18551868

18561869
hideSessionPicker(): void {
@@ -1859,37 +1872,40 @@ export class KimiTUI {
18591872
this.restoreEditor();
18601873
}
18611874

1862-
private mountSessionPicker(
1863-
onCancel: () => void,
1864-
shortcuts: { readonly onCtrlC?: () => void; readonly onCtrlD?: () => void } = {},
1865-
): void {
1875+
private mountSessionPicker(options: {
1876+
readonly onCancel: () => void;
1877+
readonly onCtrlC?: () => void;
1878+
readonly onCtrlD?: () => void;
1879+
// CLI mode flags (--auto/--yolo/--plan) target the session picked at
1880+
// startup (bare --session); later /sessions switches keep the picked
1881+
// session's own persisted modes.
1882+
readonly applyStartupModes?: boolean;
1883+
}): void {
18661884
this.state.activeDialog = 'session-picker';
18671885
this.mountEditorReplacement(
18681886
new SessionPickerComponent({
18691887
sessions: this.state.sessions,
18701888
loading: this.state.loadingSessions,
18711889
currentSessionId: this.state.appState.sessionId,
18721890
onSelect: (sessionId: string) => {
1873-
void this.resumeSession(sessionId).then(async (switched) => {
1874-
if (!switched) {
1875-
return;
1876-
}
1877-
const session = this.requireSession();
1878-
if (this.options.startup.auto) {
1879-
await session.setPermission('auto');
1880-
} else if (this.options.startup.yolo) {
1881-
await session.setPermission('yolo');
1882-
}
1883-
if (this.options.startup.plan) {
1884-
await session.setPlanMode(true);
1885-
}
1886-
this.applyStartupPermissionAndPlanToAppState();
1887-
this.hideSessionPicker();
1888-
});
1891+
void this.resumeSession(sessionId)
1892+
.then(async (switched) => {
1893+
if (!switched) {
1894+
return;
1895+
}
1896+
if (options.applyStartupModes === true) {
1897+
await this.applyStartupModesToResumedSession(this.requireSession());
1898+
this.applyStartupPermissionAndPlanToAppState();
1899+
}
1900+
this.hideSessionPicker();
1901+
})
1902+
.catch((error) => {
1903+
this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`);
1904+
});
18891905
},
1890-
onCancel,
1891-
onCtrlC: shortcuts.onCtrlC,
1892-
onCtrlD: shortcuts.onCtrlD,
1906+
onCancel: options.onCancel,
1907+
onCtrlC: options.onCtrlC,
1908+
onCtrlD: options.onCtrlD,
18931909
}),
18941910
);
18951911
}

apps/kimi-code/test/tui/kimi-tui-startup.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,33 @@ describe('KimiTUI startup', () => {
363363
expect(driver.state.appState.planMode).toBe(true);
364364
});
365365

366+
it('skips setPlanMode when the resumed session is already in plan mode', async () => {
367+
const session = makeSession({
368+
id: 'ses-latest',
369+
getStatus: vi.fn(async () => ({
370+
model: 'k2',
371+
thinkingLevel: 'off',
372+
permission: 'manual',
373+
planMode: true,
374+
contextTokens: 10,
375+
maxContextTokens: 100,
376+
contextUsage: 0.1,
377+
})),
378+
setPlanMode: vi.fn(async () => {
379+
throw new Error('Already in plan mode');
380+
}),
381+
});
382+
const harness = makeHarness(session, {
383+
listSessions: vi.fn(async () => [{ id: 'ses-latest' }]),
384+
});
385+
const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true }));
386+
387+
await expect(driver.init()).resolves.toBe(true);
388+
389+
expect(session.setPlanMode).not.toHaveBeenCalled();
390+
expect(driver.state.appState.planMode).toBe(true);
391+
});
392+
366393
it('forces footer state to reflect --auto even if getStatus lags behind', async () => {
367394
const session = makeSession({
368395
id: 'ses-latest',
@@ -627,6 +654,81 @@ describe('KimiTUI startup', () => {
627654
expect(driver.state.appState.permissionMode).toBe('auto');
628655
});
629656

657+
it('skips setPlanMode after picking a session already in plan mode', async () => {
658+
const session = makeSession({
659+
id: 'ses-picked',
660+
getStatus: vi.fn(async () => ({
661+
model: 'k2',
662+
thinkingLevel: 'off',
663+
permission: 'manual',
664+
planMode: true,
665+
contextTokens: 10,
666+
maxContextTokens: 100,
667+
contextUsage: 0.1,
668+
})),
669+
setPlanMode: vi.fn(async () => {
670+
throw new Error('Already in plan mode');
671+
}),
672+
});
673+
const harness = makeHarness(session, {
674+
listSessions: vi.fn(async () => [
675+
{
676+
id: 'ses-picked',
677+
title: 'Picked session',
678+
workDir: '/tmp/proj-a',
679+
updatedAt: Date.now(),
680+
},
681+
]),
682+
});
683+
const driver = makeDriver(harness, makeStartupInput({ session: '', plan: true }));
684+
685+
await (driver as unknown as { initMainTui(): Promise<boolean> }).initMainTui();
686+
expect(driver.state.startupState).toBe('picker');
687+
await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker();
688+
689+
const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void };
690+
picker.handleInput('\r');
691+
await new Promise((resolve) => setImmediate(resolve));
692+
693+
expect(session.setPlanMode).not.toHaveBeenCalled();
694+
expect(driver.state.appState.planMode).toBe(true);
695+
});
696+
697+
it('does not apply startup flags when switching sessions via the /sessions picker', async () => {
698+
const initial = makeSession({ id: 'ses-1' });
699+
const picked = makeSession({
700+
id: 'ses-2',
701+
setPermission: vi.fn(async () => {}),
702+
setPlanMode: vi.fn(async () => {
703+
throw new Error('Already in plan mode');
704+
}),
705+
});
706+
const harness = makeHarness(initial, {
707+
resumeSession: vi.fn(async () => picked),
708+
listSessions: vi.fn(async () => [
709+
{
710+
id: 'ses-2',
711+
title: 'Other session',
712+
workDir: '/tmp/proj-a',
713+
updatedAt: Date.now(),
714+
},
715+
]),
716+
});
717+
const driver = makeDriver(harness, makeStartupInput({ auto: true, plan: true }));
718+
await expect(driver.init()).resolves.toBe(false);
719+
720+
await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker();
721+
const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void };
722+
picker.handleInput('\r');
723+
await new Promise((resolve) => setImmediate(resolve));
724+
725+
expect(driver.state.appState.sessionId).toBe('ses-2');
726+
expect(picked.setPermission).not.toHaveBeenCalled();
727+
expect(picked.setPlanMode).not.toHaveBeenCalled();
728+
expect(driver.state.appState.permissionMode).toBe('manual');
729+
expect(driver.state.appState.planMode).toBe(false);
730+
});
731+
630732
it('clears startup picker exit confirmation before resuming a selected session', async () => {
631733
const session = makeSession({ id: 'ses-picked' });
632734
const harness = makeHarness(session, {

0 commit comments

Comments
 (0)