@@ -15,12 +15,15 @@ import { createLogger } from '@sim/logger'
1515import {
1616 DEFAULT_RUN_WAIT_MS ,
1717 isTerminalControlKey ,
18+ MAX_INPUT_KEYS ,
1819 MAX_RUN_WAIT_MS ,
1920 MAX_TERMINALS ,
2021 MAX_TOOL_OUTPUT_CHARS ,
2122 type TerminalCommandEvent ,
23+ type TerminalControlKey ,
2224 type TerminalCwdResult ,
2325 type TerminalErrorCode ,
26+ type TerminalHandoffResult ,
2427 type TerminalOperation ,
2528 type TerminalPanesResult ,
2629 type TerminalStartOptions ,
@@ -65,6 +68,27 @@ const INPUT_SCREEN_LINES = 60
6568 */
6669const CWD_POLL_MS = 1_000
6770
71+ /** Pause between keys sent to a tmux pane, matching the pty keystroke gap. */
72+ const TMUX_KEY_GAP_MS = 150
73+
74+ /** How often a handoff checks whether the user or the command has finished. */
75+ const HANDOFF_POLL_MS = 500
76+
77+ /**
78+ * Ceiling on a handoff. A person can take as long as they like — they may
79+ * have walked away mid-install — so this only exists so a forgotten handoff
80+ * cannot hold a turn open forever.
81+ */
82+ const HANDOFF_MAX_MS = 12 * 60 * 60 * 1000
83+
84+ /**
85+ * Grace given to a command that is still running after the user hands back.
86+ * Answering a prompt usually finishes the job within seconds; anything longer
87+ * is an ordinary long-running command, and comes back as `running` for the
88+ * agent to poll rather than holding the turn.
89+ */
90+ const HANDOFF_SETTLE_MS = 5_000
91+
6892function delay ( ms : number ) : Promise < void > {
6993 return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) )
7094}
@@ -81,6 +105,16 @@ function elideOutput(value: string): { text: string; truncated: boolean } {
81105 return elide ( value , MAX_TOOL_OUTPUT_CHARS )
82106}
83107
108+ /**
109+ * The keys an input call wants pressed, in order. A lone `key` is the same
110+ * thing with one element, so both arrive here as one list.
111+ */
112+ function requestedKeys ( args : TerminalToolArgs ) : TerminalControlKey [ ] {
113+ const batch = Array . isArray ( args . keys ) ? args . keys : [ ]
114+ const keys = batch . length > 0 ? batch : isTerminalControlKey ( args . key ) ? [ args . key ] : [ ]
115+ return keys . filter ( isTerminalControlKey ) . slice ( 0 , MAX_INPUT_KEYS )
116+ }
117+
84118const EMPTY_TABS : TerminalTabsState = { tabs : [ ] , activeTerminalId : null }
85119
86120class TerminalError extends Error {
@@ -119,6 +153,12 @@ export class TerminalService {
119153 private sink : TerminalSink | null = null
120154 private lastEmittedTabs : string | null = null
121155 private cwdTimer : NodeJS . Timeout | null = null
156+ /** True while the user's keyboard focus is in the terminal panel. */
157+ private panelFocused = false
158+ /** Directories of recently closed terminals, newest first, for reopening. */
159+ private readonly recentlyClosedCwds : string [ ] = [ ]
160+ /** Terminals handed to the user; the value is whether they have handed back. */
161+ private readonly handoffs = new Map < string , boolean > ( )
122162
123163 constructor ( private readonly options : TerminalServiceOptions = { } ) { }
124164
@@ -215,22 +255,95 @@ export class TerminalService {
215255 return this . getTabs ( )
216256 }
217257
258+ /**
259+ * Closes a terminal, or resets it when it is the only one left.
260+ *
261+ * Emptying the panel is not an option the close button should have: the
262+ * resource IS a terminal, so a panel with no shell in it is a dead end the
263+ * user has to close and reopen to escape. Replacing the last shell with a
264+ * fresh one in the same directory gives the button a sensible meaning at
265+ * every count — the same shape as closing a browser's last tab, which
266+ * leaves you a tab rather than an empty window.
267+ *
268+ * A shell that exits on its own is different and still closes the panel:
269+ * that is the user saying they are done, not asking for a clean one.
270+ */
218271 closeTerminal ( terminalId : string ) : TerminalTabsState {
219272 const session = this . sessions . get ( terminalId )
220273 if ( ! session ) {
221274 throw new TerminalError ( 'NO_SUCH_TERMINAL' , unknownTerminal ( terminalId ) )
222275 }
276+ const closedCwd = session . currentCwd
277+ const cols = session . cols
278+ const rows = session . rows
223279 const order = [ ...this . sessions . keys ( ) ]
224280 const index = order . indexOf ( terminalId )
225281 session . dispose ( )
226282 this . sessions . delete ( terminalId )
283+
284+ if ( this . sessions . size === 0 ) {
285+ this . spawn ( this . resolveCwd ( closedCwd ) , cols , rows )
286+ return this . getTabs ( )
287+ }
288+
289+ this . rememberClosed ( closedCwd )
227290 if ( this . activeId === terminalId ) {
228291 this . activeId = order [ index + 1 ] ?? order [ index - 1 ] ?? null
229292 }
230293 this . emitTabs ( )
231294 return this . getTabs ( )
232295 }
233296
297+ /**
298+ * Reopens the most recently closed terminal, in the directory it was in.
299+ *
300+ * A shell cannot be restored the way a browser tab can — its processes are
301+ * gone and its scrollback with them — so this reopens where it was working,
302+ * which is the part that is expensive for the user to retype.
303+ */
304+ reopenClosedTerminal ( ) : boolean {
305+ if ( ! this . panelFocused ) return false
306+ const cwd = this . recentlyClosedCwds . shift ( )
307+ if ( cwd === undefined || this . sessions . size >= MAX_TERMINALS ) return false
308+ this . openTerminal ( cwd ?? undefined )
309+ return true
310+ }
311+
312+ /** Closes the active terminal, but only while the panel owns interaction focus. */
313+ closeFocusedTerminal ( ) : boolean {
314+ if ( ! this . panelFocused || ! this . activeId ) return false
315+ this . closeTerminal ( this . activeId )
316+ return true
317+ }
318+
319+ /**
320+ * Whether the terminal panel owns keyboard focus. Menu accelerators are
321+ * global, so Cmd-W has to know whether the user is looking at a terminal or
322+ * at something else in the window before deciding what to close.
323+ */
324+ setPanelFocused ( focused : boolean ) : void {
325+ this . panelFocused = focused
326+ }
327+
328+ private rememberClosed ( cwd : string | null ) : void {
329+ this . recentlyClosedCwds . unshift ( cwd ?? '' )
330+ if ( this . recentlyClosedCwds . length > MAX_TERMINALS ) {
331+ this . recentlyClosedCwds . length = MAX_TERMINALS
332+ }
333+ }
334+
335+ /** A directory that still exists, else the usual starting point. */
336+ private resolveCwd ( candidate : string | null ) : string {
337+ if ( candidate ) {
338+ try {
339+ if ( statSync ( candidate ) . isDirectory ( ) ) return candidate
340+ } catch {
341+ // Deleted while the shell was open; fall through.
342+ }
343+ }
344+ return this . startingCwd ( )
345+ }
346+
234347 write ( terminalId : string , data : string ) : void {
235348 this . sessions . get ( terminalId ) ?. write ( data )
236349 }
@@ -296,6 +409,8 @@ export class TerminalService {
296409 home : homedir ( ) ,
297410 terminalId : session . terminalId ,
298411 } satisfies TerminalCwdResult
412+ case 'handoff' :
413+ return this . handoff ( session , args )
299414 case 'panes' : {
300415 if ( ! tmux ) {
301416 throw new TerminalError (
@@ -359,6 +474,60 @@ export class TerminalService {
359474 }
360475 }
361476
477+ /**
478+ * Gives the terminal to the user and waits for them.
479+ *
480+ * A command sitting on a prompt it cannot answer — a password, a decision
481+ * that is not the agent's to make — otherwise leaves the tool call spinning
482+ * with nothing on screen to explain why. This surfaces a chip in the chat
483+ * saying what is needed, and resolves when the command that was blocking
484+ * finishes, so the agent resumes knowing the outcome rather than guessing
485+ * whether the user got to it.
486+ */
487+ private async handoff ( session : TerminalSession , args : TerminalToolArgs ) : Promise < unknown > {
488+ const reason = typeof args . reason === 'string' ? args . reason . trim ( ) : ''
489+ const terminalId = session . terminalId
490+ this . handoffs . set ( terminalId , false )
491+
492+ const settled = ( handedBack : boolean ) : TerminalHandoffResult => {
493+ const view = session . readScrollback ( INPUT_SCREEN_LINES )
494+ return {
495+ terminalId,
496+ reason,
497+ handedBack,
498+ running : session . foreground ,
499+ output : view . output ,
500+ cwd : session . currentCwd ,
501+ }
502+ }
503+
504+ try {
505+ const deadline = Date . now ( ) + HANDOFF_MAX_MS
506+ let handedBackAt : number | null = null
507+ while ( Date . now ( ) < deadline ) {
508+ await delay ( HANDOFF_POLL_MS )
509+ if ( ! session . alive ) {
510+ throw new TerminalError ( 'SESSION_CLOSED' , 'That terminal was closed during the handoff.' )
511+ }
512+ // The command finishing is the real end of the handoff, whether or not
513+ // the user pressed anything: it means the prompt got answered.
514+ if ( ! session . isBusy ) return settled ( this . handoffs . get ( terminalId ) === true )
515+ if ( this . handoffs . get ( terminalId ) === true ) {
516+ handedBackAt ??= Date . now ( )
517+ if ( Date . now ( ) - handedBackAt >= HANDOFF_SETTLE_MS ) return settled ( true )
518+ }
519+ }
520+ return settled ( this . handoffs . get ( terminalId ) === true )
521+ } finally {
522+ this . handoffs . delete ( terminalId )
523+ }
524+ }
525+
526+ /** The user pressing the hand-back button on a waiting handoff. */
527+ finishHandoff ( terminalId : string ) : void {
528+ if ( this . handoffs . has ( terminalId ) ) this . handoffs . set ( terminalId , true )
529+ }
530+
362531 /** The pane a call names, or the session's active one. */
363532 private async resolvePane (
364533 session : string ,
@@ -388,22 +557,28 @@ export class TerminalService {
388557 args : TerminalToolArgs
389558 ) : Promise < unknown > {
390559 const target = await this . resolvePane ( session , args , terminal )
391- if ( typeof args . key === 'string' && isTerminalControlKey ( args . key ) ) {
392- await sendKey ( target , TMUX_KEY_NAMES [ args . key ] ?? args . key , terminal . env )
560+ const keys = requestedKeys ( args )
561+ if ( keys . length > 0 ) {
562+ for ( let index = 0 ; index < keys . length ; index += 1 ) {
563+ // Paced like the pty path: a pane redraws between presses, so a batch
564+ // lands where the same keys pressed by hand would.
565+ if ( index > 0 ) await delay ( TMUX_KEY_GAP_MS )
566+ await sendKey ( target , TMUX_KEY_NAMES [ keys [ index ] ] ?? keys [ index ] , terminal . env )
567+ }
393568 } else if ( typeof args . text === 'string' ) {
394569 await sendText ( target , args . text , terminal . env )
395570 // Enter is a separate send-keys for the same reason it is a separate pty
396571 // write: a program reading one chunk treats text plus a carriage return
397572 // as text, and the message sits unsubmitted.
398573 if ( / [ \r \n ] $ / . test ( args . text ) ) await sendKey ( target , 'Enter' , terminal . env )
399574 } else {
400- throw new TerminalError ( 'INVALID_REQUEST' , 'input needs either `text` or `key `.' )
575+ throw new TerminalError ( 'INVALID_REQUEST' , 'input needs `text`, `key`, or `keys `.' )
401576 }
402577
403578 await delay ( INPUT_ECHO_MS )
404579 const captured = await capturePane ( target , INPUT_SCREEN_LINES , terminal . env )
405580 return {
406- sent : args . key ?? args . text ,
581+ sent : keys . length > 0 ? keys . join ( ', ' ) : args . text ,
407582 terminalId : terminal . terminalId ,
408583 pane : target ,
409584 output : captured . stdout ,
@@ -425,17 +600,18 @@ export class TerminalService {
425600 // lets the model assume its message went through and start waiting on
426601 // a reply to text still sitting unsubmitted in a composer; the screen
427602 // is the evidence of what the program actually did with the input.
428- if ( isTerminalControlKey ( args . key ) ) {
429- session . sendKey ( args . key )
603+ const keys = requestedKeys ( args )
604+ if ( keys . length > 0 ) {
605+ await session . pressKeys ( keys )
430606 await delay ( INPUT_ECHO_MS )
431- return { sent : args . key , ...session . readScrollback ( INPUT_SCREEN_LINES ) }
607+ return { sent : keys . join ( ', ' ) , ...session . readScrollback ( INPUT_SCREEN_LINES ) }
432608 }
433609 if ( typeof args . text === 'string' ) {
434610 await session . type ( args . text )
435611 await delay ( INPUT_ECHO_MS )
436612 return { sent : args . text , ...session . readScrollback ( INPUT_SCREEN_LINES ) }
437613 }
438- throw new TerminalError ( 'INVALID_REQUEST' , 'input needs either `text` or `key `.' )
614+ throw new TerminalError ( 'INVALID_REQUEST' , 'input needs `text`, `key`, or `keys `.' )
439615 }
440616
441617 /**
0 commit comments