Skip to content

Commit 240a330

Browse files
committed
feat: delete sessions from the session picker
Ctrl+X on a row asks for confirmation and deletes the session. The picker stays mounted and locks input while the deletion runs, so nothing races the swap; deleting the current session closes it and starts a fresh one, and a failed delete reattaches to the session that survived.
1 parent 9fee463 commit 240a330

4 files changed

Lines changed: 924 additions & 25 deletions

File tree

apps/pythinker-code/src/tui/components/dialogs/session-picker.ts

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@pymodel/pi-tui';
1313
import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols';
1414
import { currentTheme } from '#/tui/theme';
15+
import { printableChar } from '#/tui/utils/printable-key';
1516
import { SearchableList } from '#/tui/utils/searchable-list';
1617

1718
export interface SessionRow {
@@ -80,7 +81,7 @@ function sessionSearchText(session: SessionRow): string {
8081
export class SessionPickerComponent extends Container implements Focusable {
8182
private sessions: SessionRow[];
8283
private currentSessionId: string;
83-
private onSelect: (session: SessionRow) => void;
84+
private onSelect: (session: SessionRow) => void | Promise<void>;
8485
private onCancel: () => void;
8586
private onToggleScope?: (selectedSessionId: string) => void;
8687
private maxVisibleSessions: number;
@@ -91,6 +92,8 @@ export class SessionPickerComponent extends Container implements Focusable {
9192
private hasMore: boolean;
9293
private loadingMore: boolean;
9394
private list: SearchableList<SessionRow>;
95+
private deleteState?: { session: SessionRow; phase: 'confirm' | 'deleting' };
96+
private selectInFlight = false;
9497

9598
focused = false;
9699

@@ -101,7 +104,7 @@ export class SessionPickerComponent extends Container implements Focusable {
101104
scope?: 'cwd' | 'all';
102105
initialSelectedSessionId?: string;
103106
pageSize?: number;
104-
onSelect: (session: SessionRow) => void;
107+
onSelect: (session: SessionRow) => void | Promise<void>;
105108
onCancel: () => void;
106109
onCtrlC?: () => void;
107110
onCtrlD?: () => void;
@@ -115,6 +118,8 @@ export class SessionPickerComponent extends Container implements Focusable {
115118
onLoadMore?: () => void;
116119
/** Fired when a search query becomes active while pages remain unfetched. */
117120
onSearchDrain?: () => void;
121+
/** Fired after the user confirms deletion with `y`; the picker clears its delete state once the request settles. */
122+
onDeleteRequest?: (session: SessionRow) => Promise<void>;
118123
}) {
119124
super();
120125
this.sessions = opts.sessions;
@@ -142,12 +147,14 @@ export class SessionPickerComponent extends Container implements Focusable {
142147
this.visibleCount = Math.min(this.sessions.length, initialLoadedPages * this.pageSize);
143148
this.onCtrlC = opts.onCtrlC;
144149
this.onCtrlD = opts.onCtrlD;
150+
this.onDeleteRequest = opts.onDeleteRequest;
145151
}
146152

147153
private readonly onCtrlC?: () => void;
148154
private readonly onCtrlD?: () => void;
149155
private readonly onLoadMore?: () => void;
150156
private readonly onSearchDrain?: () => void;
157+
private readonly onDeleteRequest?: (session: SessionRow) => Promise<void>;
151158

152159
/** Appends a freshly fetched page, keeping the cursor and active query. */
153160
appendSessions(rows: SessionRow[]): void {
@@ -209,6 +216,13 @@ export class SessionPickerComponent extends Container implements Focusable {
209216
}
210217

211218
handleInput(data: string): void {
219+
if (this.deleteState !== undefined) {
220+
this.handleDeleteInput(data);
221+
return;
222+
}
223+
// A selection runs resume/switch asynchronously; input during that window
224+
// (e.g. Ctrl+X delete) would race the session swap.
225+
if (this.selectInFlight) return;
212226
if (matchesKey(data, Key.ctrl('c'))) {
213227
this.onCtrlC?.();
214228
return;
@@ -221,6 +235,14 @@ export class SessionPickerComponent extends Container implements Focusable {
221235
this.onToggleScope?.(this.list.selected()?.id ?? this.currentSessionId);
222236
return;
223237
}
238+
if (matchesKey(data, Key.ctrl('x'))) {
239+
const selected = this.list.selected();
240+
if (selected !== undefined && this.onDeleteRequest !== undefined) {
241+
this.deleteState = { session: selected, phase: 'confirm' };
242+
this.invalidate();
243+
}
244+
return;
245+
}
224246
if (matchesKey(data, Key.escape)) {
225247
if (this.list.clearQuery()) {
226248
this.visibleCount = Math.min(this.filteredSessions().length, this.pageSize);
@@ -231,7 +253,16 @@ export class SessionPickerComponent extends Container implements Focusable {
231253
}
232254
if (matchesKey(data, Key.enter)) {
233255
const session = this.list.selected();
234-
if (session) this.onSelect(session);
256+
if (session) {
257+
const selection = this.onSelect(session);
258+
if (selection !== undefined) {
259+
this.selectInFlight = true;
260+
const clear = (): void => {
261+
this.selectInFlight = false;
262+
};
263+
void selection.then(clear, clear);
264+
}
265+
}
235266
return;
236267
}
237268

@@ -241,6 +272,52 @@ export class SessionPickerComponent extends Container implements Focusable {
241272
}
242273
}
243274

275+
private handleDeleteInput(data: string): void {
276+
const state = this.deleteState;
277+
if (state === undefined || state.phase === 'deleting') return;
278+
const k = printableChar(data);
279+
if (matchesKey(data, Key.escape) || k === 'n' || k === 'N') {
280+
this.deleteState = undefined;
281+
this.invalidate();
282+
return;
283+
}
284+
if (k === 'y' || k === 'Y') {
285+
this.deleteState = { session: state.session, phase: 'deleting' };
286+
this.invalidate();
287+
const sessionId = state.session.id;
288+
const clear = (): void => {
289+
if (this.deleteState?.session.id !== sessionId) return;
290+
this.deleteState = undefined;
291+
this.invalidate();
292+
};
293+
// then(clear, clear): rejections settle too — the host has already surfaced the failure.
294+
void this.onDeleteRequest?.(state.session).then(clear, clear);
295+
}
296+
}
297+
298+
private renderDeleteStateLine(width: number): string {
299+
const state = this.deleteState;
300+
if (state === undefined) return '';
301+
const rawTitle = (state.session.title ?? state.session.id).trim() || state.session.id;
302+
const label = singleLine(rawTitle);
303+
const prefix = state.phase === 'confirm' ? 'Delete session "' : 'Deleting session "';
304+
const suffix = state.phase === 'confirm' ? '"? [y/N]' : '"…';
305+
const labelBudget = Math.max(0, width - visibleWidth(prefix) - visibleWidth(suffix));
306+
const shown = truncateToWidth(label, labelBudget, ELLIPSIS);
307+
// The suffix carries the confirm/cancel keys: it survives by truncating
308+
// the head (prefix + label) instead of the composed line.
309+
const head = truncateToWidth(
310+
prefix + shown,
311+
Math.max(0, width - visibleWidth(suffix)),
312+
ELLIPSIS,
313+
);
314+
const styled =
315+
state.phase === 'confirm'
316+
? currentTheme.boldFg('warning', head + suffix)
317+
: currentTheme.fg('textMuted', head + suffix);
318+
return truncateToWidth(styled, width, ELLIPSIS);
319+
}
320+
244321
override render(width: number): string[] {
245322
return this.renderLines(width).map((line) => truncateToWidth(line, width, ELLIPSIS));
246323
}
@@ -293,6 +370,7 @@ export class SessionPickerComponent extends Container implements Focusable {
293370
...(view.query.length > 0 ? ['Backspace clear'] : []),
294371
'↑↓ navigate',
295372
scopeHint,
373+
...(this.onDeleteRequest !== undefined ? ['Ctrl+X delete'] : []),
296374
'Enter select',
297375
'Esc cancel',
298376
].filter((item): item is string => item !== undefined);
@@ -360,6 +438,11 @@ export class SessionPickerComponent extends Container implements Focusable {
360438
lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS)));
361439
}
362440

441+
if (this.deleteState !== undefined) {
442+
lines.push('');
443+
lines.push(this.renderDeleteStateLine(width));
444+
}
445+
363446
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
364447
return lines;
365448
}

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

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3683,23 +3683,7 @@ export class PythinkerTUI {
36833683
}): Promise<void> {
36843684
this.sessionPickerOptions = options;
36853685
await this.fetchSessions('cwd');
3686-
this.mountSessionPicker({
3687-
applyStartupModes: options.applyStartupModes,
3688-
onCancel: () => {
3689-
this.hideSessionPicker();
3690-
if (options.closeOnCancel) void this.stop();
3691-
},
3692-
onCtrlC: options.forwardEditorExit
3693-
? () => {
3694-
this.state.editor.onCtrlC?.();
3695-
}
3696-
: undefined,
3697-
onCtrlD: options.forwardEditorExit
3698-
? () => {
3699-
this.state.editor.onCtrlD?.();
3700-
}
3701-
: undefined,
3702-
});
3686+
this.remountSessionPicker();
37033687
}
37043688

37053689
private async toggleSessionPickerScope(selectedSessionId: string): Promise<void> {
@@ -3708,8 +3692,12 @@ export class PythinkerTUI {
37083692
await this.fetchSessions(nextScope);
37093693
if (requestToken !== this.sessionPickerScopeRequestToken) return;
37103694
if (this.state.activeDialog !== 'session-picker') return;
3695+
this.remountSessionPicker(selectedSessionId);
3696+
}
3697+
3698+
private remountSessionPicker(initialSelectedSessionId?: string): void {
37113699
this.mountSessionPicker({
3712-
initialSelectedSessionId: selectedSessionId,
3700+
initialSelectedSessionId,
37133701
applyStartupModes: this.sessionPickerOptions.applyStartupModes,
37143702
onCancel: () => {
37153703
this.hideSessionPicker();
@@ -3736,6 +3724,68 @@ export class PythinkerTUI {
37363724
this.restoreEditor();
37373725
}
37383726

3727+
private async deleteSessionFromPicker(session: SessionRow): Promise<void> {
3728+
// Invalidate any pending scope-toggle remount: it would replace the picker
3729+
// that is about to lock itself for the delete.
3730+
this.sessionPickerScopeRequestToken += 1;
3731+
try {
3732+
await this.waitForLazyCreation();
3733+
if (session.id === this.state.appState.sessionId && this.session !== undefined) {
3734+
await this.deleteCurrentSessionFromPicker(session);
3735+
return;
3736+
}
3737+
await this.harness.deleteSession(session.id);
3738+
// fetchSessions swallows refetch errors, so drop the row locally first —
3739+
// a failed refetch must not resurrect it in the remounted list.
3740+
this.state.sessions = this.state.sessions.filter((row) => row.id !== session.id);
3741+
const requestToken = ++this.sessionPickerScopeRequestToken;
3742+
await this.fetchSessions(this.state.sessionsScope);
3743+
if (requestToken !== this.sessionPickerScopeRequestToken) return;
3744+
if (this.state.activeDialog !== 'session-picker') return;
3745+
this.remountSessionPicker();
3746+
this.showStatus('Session deleted.');
3747+
} catch (error) {
3748+
this.showError(`Failed to delete session ${session.id}: ${formatErrorMessage(error)}`);
3749+
}
3750+
}
3751+
3752+
private async deleteCurrentSessionFromPicker(session: SessionRow): Promise<void> {
3753+
// The picker stays mounted (locking input) until the replacement session
3754+
// is ready — restoring the editor mid-flight would let a prompt race the swap.
3755+
try {
3756+
// Tear down before deleting so no events from the dying session reach the UI.
3757+
await this.closeSession('deleting session');
3758+
await this.harness.deleteSession(session.id);
3759+
} catch (error) {
3760+
// The engine aborts a failed delete and keeps the session: reattach,
3761+
// falling back to a fresh session if it is gone. showError runs after
3762+
// the switch because switchToSession clears the transcript.
3763+
const message = `Failed to delete session ${session.id}: ${formatErrorMessage(error)}`;
3764+
try {
3765+
const resumed = await this.harness.resumeSession({
3766+
id: session.id,
3767+
replayTurnLimit: REPLAY_FETCH_TURN_LIMIT,
3768+
});
3769+
await this.switchToSession(resumed, `Resumed session (${resumed.id}).`);
3770+
} catch {
3771+
// Reattach failed and the session is already unloaded: detach before
3772+
// the fallback create so a failed create leaves no ghost UI behind.
3773+
this.setAppState({ sessionId: '' });
3774+
this.clearTranscriptAndRedraw();
3775+
await this.createNewSession();
3776+
}
3777+
this.showError(message);
3778+
this.hideSessionPicker();
3779+
return;
3780+
}
3781+
// The session is gone whether or not replacement creation succeeds: detach
3782+
// first so a failed create leaves no ghost (stale id + transcript) behind.
3783+
this.setAppState({ sessionId: '' });
3784+
this.clearTranscriptAndRedraw();
3785+
await this.createNewSession();
3786+
this.hideSessionPicker();
3787+
}
3788+
37393789
openUndoSelector(): void {
37403790
void slashCommands.handleUndoCommand(this, '');
37413791
}
@@ -3766,19 +3816,19 @@ export class PythinkerTUI {
37663816
onSearchDrain: () => {
37673817
void this.drainSessionsForSearch();
37683818
},
3769-
onSelect: (session: SessionRow) => {
3770-
void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch(
3819+
onSelect: (session: SessionRow) =>
3820+
this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch(
37713821
(error) => {
37723822
this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`);
37733823
},
3774-
);
3775-
},
3824+
),
37763825
onCancel: options.onCancel,
37773826
onCtrlC: options.onCtrlC,
37783827
onCtrlD: options.onCtrlD,
37793828
onToggleScope: (selectedSessionId: string) => {
37803829
void this.toggleSessionPickerScope(selectedSessionId);
37813830
},
3831+
onDeleteRequest: (session: SessionRow) => this.deleteSessionFromPicker(session),
37823832
});
37833833
this.sessionPickerComponent = picker;
37843834
this.mountEditorReplacement(picker);
@@ -3788,6 +3838,9 @@ export class PythinkerTUI {
37883838
session: SessionRow,
37893839
applyStartupModes: boolean,
37903840
): Promise<void> {
3841+
// Invalidate any pending scope-toggle remount: it would replace the picker
3842+
// and drop the selection lock.
3843+
this.sessionPickerScopeRequestToken += 1;
37913844
if (resolve(session.work_dir) !== resolve(this.state.appState.workDir)) {
37923845
await this.showResumeOtherWorkDirHint(session);
37933846
if (applyStartupModes) await this.stop(0);

0 commit comments

Comments
 (0)