Skip to content
Merged
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/delete-session-from-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Delete sessions from the session picker: press Ctrl+X on a session, then y to confirm.
91 changes: 88 additions & 3 deletions apps/kimi-code/src/tui/components/dialogs/session-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { formatSessionLabel } from '#/migration/index';
import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { printableChar } from '#/tui/utils/printable-key';
import { SearchableList } from '#/tui/utils/searchable-list';

export interface SessionRow {
Expand Down Expand Up @@ -81,7 +82,7 @@ function sessionSearchText(session: SessionRow): string {
export class SessionPickerComponent extends Container implements Focusable {
private sessions: SessionRow[];
private currentSessionId: string;
private onSelect: (session: SessionRow) => void;
private onSelect: (session: SessionRow) => void | Promise<void>;
private onCancel: () => void;
private onToggleScope?: (selectedSessionId: string) => void;
private maxVisibleSessions: number;
Expand All @@ -92,6 +93,8 @@ export class SessionPickerComponent extends Container implements Focusable {
private hasMore: boolean;
private loadingMore: boolean;
private list: SearchableList<SessionRow>;
private deleteState?: { session: SessionRow; phase: 'confirm' | 'deleting' };
private selectInFlight = false;

focused = false;

Expand All @@ -102,7 +105,7 @@ export class SessionPickerComponent extends Container implements Focusable {
scope?: 'cwd' | 'all';
initialSelectedSessionId?: string;
pageSize?: number;
onSelect: (session: SessionRow) => void;
onSelect: (session: SessionRow) => void | Promise<void>;
onCancel: () => void;
onCtrlC?: () => void;
onCtrlD?: () => void;
Expand All @@ -116,6 +119,8 @@ export class SessionPickerComponent extends Container implements Focusable {
onLoadMore?: () => void;
/** Fired when a search query becomes active while pages remain unfetched. */
onSearchDrain?: () => void;
/** Fired after the user confirms deletion with `y`; the picker clears its delete state once the request settles. */
onDeleteRequest?: (session: SessionRow) => Promise<void>;
}) {
super();
this.sessions = opts.sessions;
Expand Down Expand Up @@ -143,12 +148,14 @@ export class SessionPickerComponent extends Container implements Focusable {
this.visibleCount = Math.min(this.sessions.length, initialLoadedPages * this.pageSize);
this.onCtrlC = opts.onCtrlC;
this.onCtrlD = opts.onCtrlD;
this.onDeleteRequest = opts.onDeleteRequest;
}

private readonly onCtrlC?: () => void;
private readonly onCtrlD?: () => void;
private readonly onLoadMore?: () => void;
private readonly onSearchDrain?: () => void;
private readonly onDeleteRequest?: (session: SessionRow) => Promise<void>;

/** Appends a freshly fetched page, keeping the cursor and active query. */
appendSessions(rows: SessionRow[]): void {
Expand Down Expand Up @@ -210,6 +217,13 @@ export class SessionPickerComponent extends Container implements Focusable {
}

handleInput(data: string): void {
if (this.deleteState !== undefined) {
this.handleDeleteInput(data);
return;
}
// A selection runs resume/switch asynchronously; input during that window
// (e.g. Ctrl+X delete) would race the session swap.
if (this.selectInFlight) return;
if (matchesKey(data, Key.ctrl('c'))) {
this.onCtrlC?.();
return;
Expand All @@ -222,6 +236,14 @@ export class SessionPickerComponent extends Container implements Focusable {
this.onToggleScope?.(this.list.selected()?.id ?? this.currentSessionId);
return;
}
if (matchesKey(data, Key.ctrl('x'))) {
const selected = this.list.selected();
if (selected !== undefined && this.onDeleteRequest !== undefined) {
this.deleteState = { session: selected, phase: 'confirm' };
Comment on lines +239 to +242

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize deletion with session selection

When Enter has already started the asynchronous onSelect flow, the picker remains interactive, so pressing Ctrl+X and confirming can run deletion concurrently with resumeSession/switchToSession. For the current row, the selection flow can call hideSessionPicker() while deletion is still replacing the session; for another row, deletion can close the newly resumed Session while switchToSession() is initializing it, leaving the TUI attached to a closed/deleted session. Fresh evidence beyond the earlier input-lock finding is that handleInput permits this delete path after an in-flight selection; lock the picker when either lifecycle action starts or serialize both operations.

Useful? React with 👍 / 👎.

this.invalidate();
}
return;
}
if (matchesKey(data, Key.escape)) {
if (this.list.clearQuery()) {
this.visibleCount = Math.min(this.filteredSessions().length, this.pageSize);
Expand All @@ -232,7 +254,16 @@ export class SessionPickerComponent extends Container implements Focusable {
}
if (matchesKey(data, Key.enter)) {
const session = this.list.selected();
if (session) this.onSelect(session);
if (session) {
const selection = this.onSelect(session);
if (selection !== undefined) {
this.selectInFlight = true;
const clear = (): void => {
this.selectInFlight = false;
};
void selection.then(clear, clear);
}
}
return;
}

Expand All @@ -242,6 +273,54 @@ export class SessionPickerComponent extends Container implements Focusable {
}
}

private handleDeleteInput(data: string): void {
const state = this.deleteState;
if (state === undefined || state.phase === 'deleting') return;
const k = printableChar(data);
if (matchesKey(data, Key.escape) || k === 'n' || k === 'N') {
this.deleteState = undefined;
this.invalidate();
return;
}
if (k === 'y' || k === 'Y') {
this.deleteState = { session: state.session, phase: 'deleting' };
this.invalidate();
const sessionId = state.session.id;
const clear = (): void => {
if (this.deleteState?.session.id !== sessionId) return;
this.deleteState = undefined;
this.invalidate();
};
// then(clear, clear): rejections settle too — the host has already surfaced the failure.
void this.onDeleteRequest?.(state.session).then(clear, clear);
}
}

private renderDeleteStateLine(width: number): string {
const state = this.deleteState;
if (state === undefined) return '';
const rawTitle = (state.session.title ?? state.session.id).trim() || state.session.id;
const label = singleLine(
formatSessionLabel({ title: rawTitle, metadata: state.session.metadata }),
);
const prefix = state.phase === 'confirm' ? 'Delete session "' : 'Deleting session "';
const suffix = state.phase === 'confirm' ? '"? [y/N]' : '"…';
const labelBudget = Math.max(0, width - visibleWidth(prefix) - visibleWidth(suffix));
const shown = truncateToWidth(label, labelBudget, ELLIPSIS);
// The suffix carries the confirm/cancel keys: it survives by truncating
// the head (prefix + label) instead of the composed line.
const head = truncateToWidth(
prefix + shown,
Math.max(0, width - visibleWidth(suffix)),
ELLIPSIS,
);
const styled =
state.phase === 'confirm'
? currentTheme.boldFg('warning', head + suffix)
: currentTheme.fg('textMuted', head + suffix);
Comment on lines +317 to +320

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Separate destructive and confirmation colors

Whenever deletion confirmation is shown, this styles the entire prompt with warning. The normative TUI dialog spec distinguishes destructive action text, which uses the error token, from the [y/N] confirmation keys, which use bold warning; coloring head + suffix together removes that danger-state distinction. Style the destructive head with error and reserve bold warning for the confirmation suffix.

AGENTS.md reference: apps/kimi-code/AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

return truncateToWidth(styled, width, ELLIPSIS);
Comment on lines +317 to +321

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the confirmation keys when truncating

When a generated session title is long or the terminal is narrow, truncating the entire confirmation string removes the trailing ? [y/N], leaving the armed dialog with no visible indication of how to confirm. Reserve space for the fixed prefix and suffix, truncate only label, and then append the confirmation suffix so the destructive-action controls remain discoverable.

AGENTS.md reference: apps/kimi-code/AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

}

override render(width: number): string[] {
return this.renderLines(width).map((line) => truncateToWidth(line, width, ELLIPSIS));
}
Expand Down Expand Up @@ -294,6 +373,7 @@ export class SessionPickerComponent extends Container implements Focusable {
...(view.query.length > 0 ? ['Backspace clear'] : []),
'↑↓ navigate',
scopeHint,
...(this.onDeleteRequest !== undefined ? ['Ctrl+X delete'] : []),
'Enter select',
'Esc cancel',
].filter((item): item is string => item !== undefined);
Expand Down Expand Up @@ -361,6 +441,11 @@ export class SessionPickerComponent extends Container implements Focusable {
lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS)));
}

if (this.deleteState !== undefined) {
lines.push('');
lines.push(this.renderDeleteStateLine(width));
}

lines.push(currentTheme.fg('primary', '─'.repeat(width)));
return lines;
}
Expand Down
97 changes: 75 additions & 22 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3928,23 +3928,7 @@ export class KimiTUI {
}): Promise<void> {
this.sessionPickerOptions = options;
await this.fetchSessions('cwd');
this.mountSessionPicker({
applyStartupModes: options.applyStartupModes,
onCancel: () => {
this.hideSessionPicker();
if (options.closeOnCancel) void this.stop();
},
onCtrlC: options.forwardEditorExit
? () => {
this.state.editor.onCtrlC?.();
}
: undefined,
onCtrlD: options.forwardEditorExit
? () => {
this.state.editor.onCtrlD?.();
}
: undefined,
});
this.remountSessionPicker();
}

private async toggleSessionPickerScope(selectedSessionId: string): Promise<void> {
Expand All @@ -3953,8 +3937,12 @@ export class KimiTUI {
await this.fetchSessions(nextScope);
if (requestToken !== this.sessionPickerScopeRequestToken) return;
if (this.state.activeDialog !== 'session-picker') return;
this.remountSessionPicker(selectedSessionId);
}

private remountSessionPicker(initialSelectedSessionId?: string): void {
this.mountSessionPicker({
initialSelectedSessionId: selectedSessionId,
initialSelectedSessionId,
applyStartupModes: this.sessionPickerOptions.applyStartupModes,
onCancel: () => {
this.hideSessionPicker();
Expand All @@ -3981,6 +3969,68 @@ export class KimiTUI {
this.restoreEditor();
}

private async deleteSessionFromPicker(session: SessionRow): Promise<void> {
// Invalidate any pending scope-toggle remount: it would replace the picker
// that is about to lock itself for the delete.
this.sessionPickerScopeRequestToken += 1;
try {
await this.waitForLazyCreation();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate scope refreshes before starting deletion

If a Ctrl+A scope fetch is already running when the user presses Ctrl+X and confirms, this path leaves its request token valid until after the non-current deletion, and the current-session path does not invalidate it until the picker closes. The scope request can therefore finish and call remountSessionPicker, replacing the locked picker with a fresh interactive instance while close/delete/replacement creation is still running; selecting another session from that instance races createNewSession() and can close or replace the newly selected session. Invalidate the outstanding scope request before the first await, or preserve the lifecycle lock across remounts.

Useful? React with 👍 / 👎.

if (session.id === this.state.appState.sessionId && this.session !== undefined) {
await this.deleteCurrentSessionFromPicker(session);
return;
}
await this.harness.deleteSession(session.id);
// fetchSessions swallows refetch errors, so drop the row locally first —
// a failed refetch must not resurrect it in the remounted list.
this.state.sessions = this.state.sessions.filter((row) => row.id !== session.id);
const requestToken = ++this.sessionPickerScopeRequestToken;
await this.fetchSessions(this.state.sessionsScope);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle a failed post-delete list refresh

When deletion succeeds but this refresh fails, fetchSessions() catches the error internally and retains the pre-delete state.sessions, so execution continues to remount the picker and announce success while still showing the deleted row. Selecting or deleting that stale row then produces further errors. Make refresh failure observable here or remove the deleted row locally before remounting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the paging cursor when refresh fails

When a user deletes a row before all backend pages have been loaded and this refresh fails, fetchSessions() clears sessionsNextCursor before its try and retains the locally cached rows. The remounted picker consequently sets hasMore to false, making every not-yet-loaded session unreachable until the dialog is reopened and a later refresh succeeds. Fresh evidence beyond the earlier stale-row report is that removing the deleted row locally does not preserve the pagination state; preserve the prior cursor on failure or make the refresh failure observable instead of remounting.

AGENTS.md reference: apps/kimi-code/AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

if (requestToken !== this.sessionPickerScopeRequestToken) return;
if (this.state.activeDialog !== 'session-picker') return;
this.remountSessionPicker();
this.showStatus('Session deleted.');
} catch (error) {
this.showError(`Failed to delete session ${session.id}: ${formatErrorMessage(error)}`);
}
}

private async deleteCurrentSessionFromPicker(session: SessionRow): Promise<void> {
// The picker stays mounted (locking input) until the replacement session
// is ready — restoring the editor mid-flight would let a prompt race the swap.
try {
// Tear down before deleting so no events from the dying session reach the UI.
await this.closeSession('deleting session');
await this.harness.deleteSession(session.id);
} catch (error) {
// The engine aborts a failed delete and keeps the session: reattach,
// falling back to a fresh session if it is gone. showError runs after
// the switch because switchToSession clears the transcript.
const message = `Failed to delete session ${session.id}: ${formatErrorMessage(error)}`;
try {
const resumed = await this.harness.resumeSession({
id: session.id,
replayTurnLimit: REPLAY_FETCH_TURN_LIMIT,
});
await this.switchToSession(resumed, `Resumed session (${resumed.id}).`);
} catch {
// Reattach failed and the session is already unloaded: detach before
// the fallback create so a failed create leaves no ghost UI behind.
this.setAppState({ sessionId: '' });
this.clearTranscriptAndRedraw();
await this.createNewSession();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the picker open when fallback creation fails

If closing or deleting the current session fails, and resuming it also fails, createNewSession() can catch a creation failure and return normally; this branch then hides the picker even though closeSession() already unloaded the session. Because the failure branch retains the old session ID and transcript, the next prompt lazily creates a different session without clearing that transcript, mixing the old history into the new session's UI. Fresh evidence beyond the earlier recovery comments is the new fallback's swallowed failure followed by the unconditional picker close; make creation report whether recovery succeeded and only restore input after a usable session exists.

Useful? React with 👍 / 👎.

Comment on lines +4018 to +4020

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unload a partially attached recovery session

When deletion or close fails, resumeSession() can succeed but switchToSession() can still reject after setSession(resumed) has attached the resumed object, for example when syncRuntimeState() fails. This catch then clears only the displayed session ID; if createNewSession() also fails and swallows that error, the picker closes with this.session still pointing to the partially initialized session and without its event subscription, so the next prompt is dispatched into an inconsistent session. Fresh evidence beyond the earlier recovery finding is that this catch handles post-attachment switchToSession() failures as well as resume failures; unload the partially attached session before attempting the fallback creation.

Useful? React with 👍 / 👎.

}
this.showError(message);
this.hideSessionPicker();
return;
}
// The session is gone whether or not replacement creation succeeds: detach
// first so a failed create leaves no ghost (stale id + transcript) behind.
this.setAppState({ sessionId: '' });
this.clearTranscriptAndRedraw();
await this.createNewSession();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify replacement creation before closing the picker

When deleting the current session succeeds but createSessionFromCurrentState() or post-create setup fails, createNewSession() catches the error and returns normally, so this code still hides the picker even though no usable replacement was established. The deleted session ID and transcript remain displayed; if the next prompt succeeds through lazy creation, that path does not clear the old transcript, mixing the deleted session's history with the new session. Make replacement creation report success here or explicitly reset the detached-session UI on failure.

Useful? React with 👍 / 👎.

this.hideSessionPicker();
}

openUndoSelector(): void {
void slashCommands.handleUndoCommand(this, '');
}
Expand Down Expand Up @@ -4011,19 +4061,19 @@ export class KimiTUI {
onSearchDrain: () => {
void this.drainSessionsForSearch();
},
onSelect: (session: SessionRow) => {
void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch(
onSelect: (session: SessionRow) =>
this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch(
(error) => {
this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`);
},
);
},
),
onCancel: options.onCancel,
onCtrlC: options.onCtrlC,
onCtrlD: options.onCtrlD,
onToggleScope: (selectedSessionId: string) => {
void this.toggleSessionPickerScope(selectedSessionId);
},
onDeleteRequest: (session: SessionRow) => this.deleteSessionFromPicker(session),
});
this.sessionPickerComponent = picker;
this.mountEditorReplacement(picker);
Expand All @@ -4033,6 +4083,9 @@ export class KimiTUI {
session: SessionRow,
applyStartupModes: boolean,
): Promise<void> {
// Invalidate any pending scope-toggle remount: it would replace the picker
// and drop the selection lock.
this.sessionPickerScopeRequestToken += 1;
if (resolve(session.work_dir) !== resolve(this.state.appState.workDir)) {
await this.showResumeOtherWorkDirHint(session);
if (applyStartupModes) await this.stop(0);
Expand Down
Loading
Loading