Skip to content

Commit 6edd6e8

Browse files
committed
feat(desktop): add terminal handoff and key batching
1 parent 2381ef7 commit 6edd6e8

22 files changed

Lines changed: 930 additions & 27 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,8 @@ function main(): void {
484484
newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))),
485485
closeFocusedBrowserTab: (win) => closeFocusedBrowserTab(win),
486486
reopenClosedBrowserTab: (win) => reopenClosedBrowserTab(win),
487+
closeFocusedTerminal: () => terminal.closeFocusedTerminal(),
488+
reopenClosedTerminal: () => terminal.reopenClosedTerminal(),
487489
toggleSidebar: () => getMainWindow()?.webContents.send('desktop:command', 'toggle-sidebar'),
488490
signOut: signOutFromMenu,
489491
checkForUpdates: () =>

apps/desktop/src/main/ipc.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,20 @@ export function registerIpcHandlers(deps: IpcDeps): void {
583583
return deps.terminal.executeTool(toolCallId, call.operation, args)
584584
},
585585
},
586+
'terminal:handoff-done': {
587+
kind: 'send',
588+
gate: 'app-origin',
589+
requires: 'terminal',
590+
handler: (terminalId) => {
591+
if (typeof terminalId === 'string') deps.terminal.finishHandoff(terminalId)
592+
},
593+
},
594+
'terminal:focused': {
595+
kind: 'send',
596+
gate: 'app-origin',
597+
requires: 'terminal',
598+
handler: (focused) => deps.terminal.setPanelFocused(focused === true),
599+
},
586600
'terminal:scrollback': {
587601
kind: 'invoke',
588602
gate: 'app-origin',

apps/desktop/src/main/menu.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ function makeDeps(): MenuDeps {
2020
openSettings: vi.fn(),
2121
newWindow: vi.fn(),
2222
newChat: vi.fn(),
23+
closeFocusedTerminal: vi.fn(() => false),
24+
reopenClosedTerminal: vi.fn(() => false),
2325
closeFocusedBrowserTab: vi.fn(() => false),
2426
reopenClosedBrowserTab: vi.fn(() => false),
2527
toggleSidebar: vi.fn(),

apps/desktop/src/main/menu.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ export interface MenuDeps {
1616
newChat: () => void
1717
closeFocusedBrowserTab: (win: BrowserWindow | null) => boolean
1818
reopenClosedBrowserTab: (win: BrowserWindow | null) => boolean
19+
/**
20+
* Terminal counterparts. Menu accelerators are global, so Cmd-W and
21+
* Cmd-Shift-T reach here whatever the user is looking at; each panel gets
22+
* asked whether the keystroke was meant for it before the window acts.
23+
*/
24+
closeFocusedTerminal: () => boolean
25+
reopenClosedTerminal: () => boolean
1926
toggleSidebar: () => void
2027
signOut: () => void
2128
checkForUpdates: () => void
@@ -101,6 +108,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
101108
label: 'Reopen Closed Tab',
102109
accelerator: 'CmdOrCtrl+Shift+T',
103110
click: (_item, focusedWindow) => {
111+
if (deps.reopenClosedTerminal()) return
104112
const win =
105113
focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow()
106114
deps.reopenClosedBrowserTab(win)
@@ -110,6 +118,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
110118
label: 'Close Window',
111119
accelerator: 'CmdOrCtrl+W',
112120
click: (_item, focusedWindow) => {
121+
if (deps.closeFocusedTerminal()) return
113122
const win =
114123
focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow()
115124
if (deps.closeFocusedBrowserTab(win)) return

apps/desktop/src/main/terminal/index.ts

Lines changed: 184 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@ import { createLogger } from '@sim/logger'
1515
import {
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
*/
6669
const 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+
6892
function 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+
84118
const EMPTY_TABS: TerminalTabsState = { tabs: [], activeTerminalId: null }
85119

86120
class 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

Comments
 (0)