@@ -12,6 +12,7 @@ import {
1212} from '@pymodel/pi-tui' ;
1313import { CURRENT_MARK , SELECT_POINTER } from '#/tui/constant/symbols' ;
1414import { currentTheme } from '#/tui/theme' ;
15+ import { printableChar } from '#/tui/utils/printable-key' ;
1516import { SearchableList } from '#/tui/utils/searchable-list' ;
1617
1718export interface SessionRow {
@@ -80,7 +81,7 @@ function sessionSearchText(session: SessionRow): string {
8081export 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 }
0 commit comments