@@ -182,8 +182,13 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
182182
183183 let assistantBuffer : AssistantBuffer | undefined ;
184184 let inflight = 0 ;
185+ let promptRunning = 0 ;
186+ let turnRevision = 0 ;
187+ let interruptState : { queued : string [ ] ; cancelled : boolean } | undefined ;
185188 let lastDisplayedError : string | undefined ;
186189
190+ const isTurnRunning = ( ) : boolean => inflight > 0 || promptRunning > 0 ;
191+
187192 // Ref of the live model, kept in sync by switchModel so the picker can mark
188193 // it with a ✓. Undefined when opts.modelRef is not a catalog ref.
189194 let currentModelRef : CuaModelRef | undefined = tryResolveModelRef ( opts . modelRef ) ;
@@ -229,7 +234,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
229234 * protection, so it refuses up front rather than failing on apply.
230235 */
231236 const refuseWhileBusy = ( command : string ) : boolean => {
232- if ( inflight === 0 ) return false ;
237+ if ( ! isTurnRunning ( ) && ! interruptState ) return false ;
233238 messages . addError ( `${ command } is unavailable while a turn is running` ) ;
234239 requestRender ( "selector_busy" , false , { command } ) ;
235240 return true ;
@@ -496,10 +501,20 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
496501 } ) ;
497502 } ;
498503
504+ const promptAgent = async ( text : string ) : Promise < void > => {
505+ promptRunning += 1 ;
506+ try {
507+ await opts . harness . prompt ( text ) ;
508+ } finally {
509+ promptRunning -= 1 ;
510+ }
511+ } ;
512+
499513 const runPrompt = async ( text : string ) : Promise < void > => {
500514 debug ?. log ( "run_prompt_start" , { length : text . length } ) ;
501515 try {
502516 const parsed = parseSlashCommand ( text ) ;
517+ if ( parsed && refuseWhileBusy ( `/${ parsed . command } ` ) ) return ;
503518 if ( parsed ?. command === "model" ) {
504519 const argument = parsed . argument . trim ( ) ;
505520 if ( ! argument ) {
@@ -548,7 +563,21 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
548563 await opts . harness . skill ( skill . name , skillRemainder ) ;
549564 return ;
550565 }
551- await opts . harness . prompt ( text ) ;
566+ if ( interruptState ) {
567+ interruptState . queued . push ( text ) ;
568+ messages . addNotice ( interruptState . cancelled ? "queued for after abort" : "queued for the interrupted turn" ) ;
569+ requestRender ( "prompt_queued_during_interrupt" ) ;
570+ return ;
571+ }
572+ if ( isTurnRunning ( ) ) {
573+ const revision = turnRevision ;
574+ await opts . harness . steer ( text ) ;
575+ if ( revision !== turnRevision ) return ;
576+ messages . addNotice ( "queued for the next available turn" ) ;
577+ requestRender ( "prompt_queued_for_steer" ) ;
578+ return ;
579+ }
580+ await promptAgent ( text ) ;
552581 } catch ( err ) {
553582 messages . addError ( ( err as Error ) . message ) ;
554583 debug ?. log ( "run_prompt_error" , { message : ( err as Error ) . message } ) ;
@@ -568,13 +597,74 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
568597 void runPrompt ( trimmed ) ;
569598 } ;
570599
600+ const startQueuedPrompt = ( queued : string [ ] , notice : string ) : void => {
601+ messages . addNotice ( `${ notice } ; sending ${ queued . length } queued message${ queued . length === 1 ? "" : "s" } ` ) ;
602+ requestRender ( "queued_prompt_start" , false , { queued : queued . length } ) ;
603+ void promptAgent ( queued . join ( "\n\n" ) ) . catch ( ( err : unknown ) => {
604+ messages . addError ( ( err as Error ) . message ) ;
605+ debug ?. log ( "queued_prompt_error" , { message : ( err as Error ) . message } ) ;
606+ requestRender ( "queued_prompt_error" ) ;
607+ } ) ;
608+ } ;
609+
610+ const interruptTurn = async ( ) : Promise < void > => {
611+ if ( interruptState ) return ;
612+ const state : { queued : string [ ] ; cancelled : boolean } = { queued : [ ] , cancelled : false } ;
613+ interruptState = state ;
614+ turnRevision += 1 ;
615+ messages . addNotice ( "interrupting…" ) ;
616+ requestRender ( "input_interrupt_start" , false , { key : "escape" } ) ;
617+ try {
618+ const { clearedSteer, clearedFollowUp } = await opts . harness . abort ( ) ;
619+ if ( state . cancelled ) {
620+ const queued = state . queued ;
621+ state . queued = [ ] ;
622+ if ( queued . length > 0 ) {
623+ interruptState = undefined ;
624+ startQueuedPrompt ( queued , "abort complete" ) ;
625+ }
626+ return ;
627+ }
628+ const queued = [
629+ ...clearedSteer . map ( userMessageText ) . filter ( ( text ) : text is string => ! ! text ) ,
630+ ...clearedFollowUp . map ( userMessageText ) . filter ( ( text ) : text is string => ! ! text ) ,
631+ ...state . queued ,
632+ ] ;
633+ state . queued = [ ] ;
634+ if ( queued . length === 0 ) {
635+ messages . addNotice ( "turn aborted" ) ;
636+ requestRender ( "input_abort_stream" , false , { key : "escape" } ) ;
637+ return ;
638+ }
639+
640+ interruptState = undefined ;
641+ startQueuedPrompt ( queued , "turn interrupted" ) ;
642+ } catch ( err ) {
643+ state . queued = [ ] ;
644+ messages . addError ( ( err as Error ) . message ) ;
645+ debug ?. log ( "input_interrupt_error" , { message : ( err as Error ) . message } ) ;
646+ requestRender ( "input_interrupt_error" ) ;
647+ } finally {
648+ if ( interruptState === state ) interruptState = undefined ;
649+ }
650+ } ;
651+
571652 const removeListener = tui . addInputListener ( ( data ) => {
572653 // Input listeners run before the focused component, so an open picker has
573654 // to own every key: otherwise ctrl+c / ctrl+d here would quit the app
574655 // instead of cancelling the picker.
575656 if ( activeSelector ) return undefined ;
576657 if ( matchesKey ( data , "ctrl+c" ) ) {
577- if ( inflight > 0 ) {
658+ if ( interruptState ) {
659+ interruptState . cancelled = true ;
660+ interruptState . queued = [ ] ;
661+ messages . addNotice ( "aborted" ) ;
662+ debug ?. log ( "input_cancel_interrupt_replay" , { key : "ctrl+c" } ) ;
663+ requestRender ( "input_cancel_interrupt_replay" , false , { key : "ctrl+c" } ) ;
664+ return { consume : true } ;
665+ }
666+ if ( isTurnRunning ( ) ) {
667+ turnRevision += 1 ;
578668 void opts . harness . abort ( ) ;
579669 messages . addNotice ( "aborted" ) ;
580670 debug ?. log ( "input_abort_stream" , { key : "ctrl+c" } ) ;
@@ -591,11 +681,9 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
591681 debug ?. log ( "input_exit_request" , { key : "ctrl+d" } ) ;
592682 return { consume : true } ;
593683 }
594- if ( matchesKey ( data , "escape" ) && inflight > 0 ) {
595- void opts . harness . abort ( ) ;
596- messages . addNotice ( "turn aborted" ) ;
597- debug ?. log ( "input_abort_stream" , { key : "escape" } ) ;
598- requestRender ( "input_abort_stream" , false , { key : "escape" } ) ;
684+ if ( matchesKey ( data , "escape" ) && ( isTurnRunning ( ) || interruptState ) ) {
685+ void interruptTurn ( ) ;
686+ debug ?. log ( "input_interrupt_stream" , { key : "escape" } ) ;
599687 return { consume : true } ;
600688 }
601689 return undefined ;
@@ -616,7 +704,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
616704
617705 await waitForExit (
618706 ( ) => exitRequested ,
619- ( ) => inflight > 0 ,
707+ ( ) => isTurnRunning ( ) || ! ! interruptState ,
620708 ) ;
621709
622710 return 0 ;
@@ -644,6 +732,16 @@ function modelLabel(model: Model<any> | undefined): string {
644732 return model . id ;
645733}
646734
735+ function userMessageText ( message : AgentMessage ) : string | undefined {
736+ if ( message . role !== "user" ) return undefined ;
737+ if ( typeof message . content === "string" ) return message . content . trim ( ) || undefined ;
738+ const text = message . content
739+ . filter ( ( content ) => content . type === "text" )
740+ . map ( ( content ) => content . text )
741+ . join ( "" ) ;
742+ return text . trim ( ) || undefined ;
743+ }
744+
647745function lastErrorMessage ( messages : AgentMessage [ ] ) : string | undefined {
648746 for ( let i = messages . length - 1 ; i >= 0 ; i -= 1 ) {
649747 const m = messages [ i ] ;
0 commit comments