diff --git a/.changeset/session-picker-exit-shortcuts.md b/.changeset/session-picker-exit-shortcuts.md new file mode 100644 index 00000000..87fdc855 --- /dev/null +++ b/.changeset/session-picker-exit-shortcuts.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow the startup session picker to exit with repeated Ctrl-C or Ctrl-D. diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index a8524cb6..b39ced4a 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -94,6 +94,8 @@ export class SessionPickerComponent extends Container implements Focusable { colors: ColorPalette; onSelect: (sessionId: string) => void; onCancel: () => void; + onCtrlC?: () => void; + onCtrlD?: () => void; maxVisibleSessions?: number; }) { super(); @@ -104,9 +106,22 @@ export class SessionPickerComponent extends Container implements Focusable { this.onSelect = opts.onSelect; this.onCancel = opts.onCancel; this.maxVisibleSessions = opts.maxVisibleSessions ?? 4; + this.onCtrlC = opts.onCtrlC; + this.onCtrlD = opts.onCtrlD; } + private readonly onCtrlC?: () => void; + private readonly onCtrlD?: () => void; + handleInput(data: string): void { + if (matchesKey(data, Key.ctrl('c'))) { + this.onCtrlC?.(); + return; + } + if (matchesKey(data, Key.ctrl('d'))) { + this.onCtrlD?.(); + return; + } if (matchesKey(data, Key.escape)) { this.onCancel(); return; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index cd777a0f..f3ae15d8 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1750,18 +1750,32 @@ export class KimiTUI { private async bootstrapFromPicker(): Promise { await this.fetchSessions(); - this.mountSessionPicker(() => { - this.hideSessionPicker(); - void this.stop(); - }); + this.mountSessionPicker( + () => { + this.hideSessionPicker(); + void this.stop(); + }, + { + onCtrlC: () => { + this.state.editor.onCtrlC?.(); + }, + onCtrlD: () => { + this.state.editor.onCtrlD?.(); + }, + }, + ); } hideSessionPicker(): void { + this.editorKeyboard.clearPendingExit(); this.state.activeDialog = null; this.restoreEditor(); } - private mountSessionPicker(onCancel: () => void): void { + private mountSessionPicker( + onCancel: () => void, + shortcuts: { readonly onCtrlC?: () => void; readonly onCtrlD?: () => void } = {}, + ): void { this.state.activeDialog = 'session-picker'; this.mountEditorReplacement( new SessionPickerComponent({ @@ -1777,6 +1791,8 @@ export class KimiTUI { }); }, onCancel, + onCtrlC: shortcuts.onCtrlC, + onCtrlD: shortcuts.onCtrlD, }), ); } diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 5f584531..13c014a6 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -17,6 +17,27 @@ describe('SessionPickerComponent', () => { vi.restoreAllMocks(); }); + it('forwards Ctrl-C and Ctrl-D to optional host shortcuts', () => { + const onCtrlC = vi.fn(); + const onCtrlD = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [], + loading: false, + currentSessionId: '', + colors: getColorPalette('dark'), + onSelect: vi.fn(), + onCancel: vi.fn(), + onCtrlC, + onCtrlD, + }); + + component.handleInput('\u0003'); + component.handleInput('\u0004'); + + expect(onCtrlC).toHaveBeenCalledOnce(); + expect(onCtrlD).toHaveBeenCalledOnce(); + }); + it('renders millisecond updated_at timestamps as relative times', () => { const now = new Date('2026-05-11T12:00:00.000Z').getTime(); vi.spyOn(Date, 'now').mockReturnValue(now); 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 3b5a0c14..144dae4c 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -30,6 +30,7 @@ interface StartupDriver { init(): Promise; handleLoginCommand(): Promise; handleLogoutCommand(): Promise; + stop(exitCode?: number): Promise; } interface RuntimeStateDriver extends StartupDriver { @@ -113,6 +114,7 @@ function makeSession(overrides: Record = {}) { setPlanMode: vi.fn(async () => {}), getGoal: vi.fn(async () => ({ goal: null })), onEvent: vi.fn(() => () => {}), + getResumeState: vi.fn(() => null), listSkills: vi.fn(async () => []), close: vi.fn(async () => {}), ...overrides, @@ -361,6 +363,34 @@ describe("KimiTUI startup", () => { expect(driver.state.startupState).toBe("picker"); }); + 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"); + await new Promise((resolve) => setImmediate(resolve)); + + driver.state.editor.onCtrlC?.(); + + expect(stop).not.toHaveBeenCalled(); + }); + it("tracks terminal theme reports while auto theme is active", () => { const harness = makeHarness(); const driver = makeDriver(