Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-picker-exit-shortcuts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Allow the startup session picker to exit with repeated Ctrl-C or Ctrl-D.
15 changes: 15 additions & 0 deletions apps/kimi-code/src/tui/components/dialogs/session-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand Down
26 changes: 21 additions & 5 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1750,18 +1750,32 @@ export class KimiTUI {

private async bootstrapFromPicker(): Promise<void> {
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?.();
Comment thread
MicroGery marked this conversation as resolved.
},
},
);
}

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({
Expand All @@ -1777,6 +1791,8 @@ export class KimiTUI {
});
},
onCancel,
onCtrlC: shortcuts.onCtrlC,
onCtrlD: shortcuts.onCtrlD,
}),
);
}
Expand Down
21 changes: 21 additions & 0 deletions apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface StartupDriver {
init(): Promise<boolean>;
handleLoginCommand(): Promise<void>;
handleLogoutCommand(): Promise<void>;
stop(exitCode?: number): Promise<void>;
}

interface RuntimeStateDriver extends StartupDriver {
Expand Down Expand Up @@ -113,6 +114,7 @@ function makeSession(overrides: Record<string, unknown> = {}) {
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,
Expand Down Expand Up @@ -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<void> }).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(
Expand Down