diff --git a/resources/mcp-bridge.js b/resources/mcp-bridge.js index 67186989..583142b4 100644 --- a/resources/mcp-bridge.js +++ b/resources/mcp-bridge.js @@ -350,7 +350,7 @@ const TOOLS = [ { name: 'create_shell', description: - "Spawn a new shell tab in this worktree. If `command` is set, runs it via `zsh -ilc `; otherwise opens an interactive login shell. Returns the new shell's id — keep it so you can read_shell_output / kill_shell later. Use this instead of telling the user to run `npm run dev` by hand.", + "Spawn a new shell tab in this worktree. If `command` is set, runs it via `zsh -ilc `; otherwise opens an interactive login shell. Returns the new shell's id — keep it so you can read_shell_output / kill_shell later. Use this instead of telling the user to run `npm run dev` by hand. By default the tab auto-closes 30s after the command finishes successfully (override with close_delay); a failed command leaves the tab open.", inputSchema: { type: 'object', properties: { @@ -368,6 +368,16 @@ const TOOLS = [ type: 'string', description: 'Optional short label shown on the tab. Defaults to a truncated form of the command.' + }, + background: { + type: 'boolean', + description: + 'When true, the tab is created in the background. Use for commands you want to run without interrupting the user. Defaults to false.' + }, + close_delay: { + type: 'number', + description: + 'Seconds (integer >= 0) to wait after the command finishes successfully before closing the tab. 0 closes immediately on success. Defaults to 30.' } } } @@ -635,13 +645,17 @@ async function handleToolCall(name, args) { return JSON.stringify((r && r.shells) || [], null, 2) } if (name === 'create_shell') { - const r = await callControl('POST', '/shells', { + const body = { command: (args && args.command) || '', cwd: (args && args.cwd) || '', - label: (args && args.label) || '' - }) + label: (args && args.label) || '', + background: !!(args && args.background) + } + if (args && args.close_delay != null) body.closeDelay = args.close_delay + const r = await callControl('POST', '/shells', body) const commandPart = args && args.command ? ' (' + args.command + ')' : '' - return 'Created shell ' + r.id + ' "' + r.label + '"' + commandPart + const bgPart = body.background ? ' [background]' : '' + return 'Created shell ' + r.id + ' "' + r.label + '"' + commandPart + bgPart } if (name === 'read_shell_output') { if (!args || !args.shell_id) throw new Error('shell_id is required') diff --git a/src/main/control-server.ts b/src/main/control-server.ts index 515f32a7..05c13035 100644 --- a/src/main/control-server.ts +++ b/src/main/control-server.ts @@ -65,7 +65,13 @@ export interface ShellQueries { ) => { output: string; matchCount?: number; error?: string } createShell: ( worktreePath: string, - opts: { command?: string; cwd?: string; label?: string } + opts: { + command?: string + cwd?: string + label?: string + background?: boolean + closeDelay?: number + } ) => { id: string; label: string } killShell: (shellId: string) => void } @@ -507,10 +513,20 @@ async function handleRequest( const command = typeof body.command === 'string' ? body.command.trim() : '' const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : '' const label = typeof body.label === 'string' ? body.label.trim() : '' + const background = body.background === true + // closeDelay defaults to 30s when omitted; a provided value must be a + // non-negative finite number (0 = close immediately on success). + const rawDelay = body.closeDelay + const closeDelay = + typeof rawDelay === 'number' && Number.isFinite(rawDelay) && rawDelay >= 0 + ? Math.floor(rawDelay) + : 30 const created = deps.shell.createShell(callerWorktree, { command: command || undefined, cwd: cwd || undefined, - label: label || undefined + label: label || undefined, + background, + closeDelay }) return sendJson(res, 200, created) } diff --git a/src/main/index.ts b/src/main/index.ts index 32718428..94fcc4f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,6 +32,7 @@ import { WorktreeDeletionFSM } from './worktree-deletion-fsm' import { PanesFSM, stripTransientTabFields } from './panes-fsm' import { ActivityDeriver } from './activity-deriver' import { AutoSleepMonitor } from './auto-sleep-monitor' +import { ShellAutoCloseMonitor } from './shell-autoclose-monitor' import { WorktreeWatcher } from './worktree-watcher' import { SnoozeTimer } from './snooze-timer' import { getWeeklyStats } from './weekly-stats' @@ -868,6 +869,11 @@ const activityDeriver = new ActivityDeriver(store) // drives panesFSM.sleepJsonClaudeTab. const autoSleepMonitor = new AutoSleepMonitor(store, panesFSM) +// Auto-closes shell tabs a configurable delay after a successful command +// exit. Self-wires to ptyManager's exit listener in its constructor. +const shellAutoCloseMonitor = new ShellAutoCloseMonitor(store, panesFSM, ptyManager) +void shellAutoCloseMonitor + /** Install agent status hooks at the user-scope settings file for both * supported agents. Called once when consent flips to 'accepted'. The * hook command is env-gated on $HARNESS_TERMINAL_ID, so it no-ops for @@ -2384,6 +2390,15 @@ function registerIpcHandlers(): void { else if (type === 'shell') panesFSM.wakeShellTab(wtPath, tabId) return true }) + // "Keep open" / re-arm for a shell tab's auto-close. delay === null + // disarms (clears closeDelay); a number re-arms it. + transport.onRequest( + 'panes:setShellCloseDelay', + (_ctx, wtPath: string, tabId: string, delay: number | null) => { + panesFSM.setShellCloseDelay(wtPath, tabId, delay) + return true + } + ) // Renderer-driven lastActive bump. The composer fires this while the // user is typing so the auto-sleep monitor can't re-sleep a tab mid- // composition — ActivityDeriver only bumps lastActive on status @@ -3758,17 +3773,24 @@ async function runBoot(): Promise { const finalLines = kept.length > lines ? kept.slice(-lines) : kept return { output: finalLines.join('\n'), matchCount } }, - createShell: (wtPath, { command, cwd, label }) => { + createShell: (wtPath, { command, cwd, label, background, closeDelay }) => { const id = `shell-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const fallback = command ? command.slice(0, 32) : 'Shell' const finalLabel = (label && label.trim()) || fallback - panesFSM.addTab(wtPath, { - id, - type: 'shell', - label: finalLabel, - command, - cwd - }) + panesFSM.addTab( + wtPath, + { + id, + type: 'shell', + label: finalLabel, + command, + cwd, + background: background || undefined, + closeDelay + }, + undefined, + { activate: !background } + ) return { id, label: finalLabel } }, killShell: (shellId) => { diff --git a/src/main/panes-fsm.test.ts b/src/main/panes-fsm.test.ts index 58412613..da310a22 100644 --- a/src/main/panes-fsm.test.ts +++ b/src/main/panes-fsm.test.ts @@ -211,6 +211,63 @@ describe('PanesFSM.restoreFromConfig', () => { }) }) +describe('PanesFSM.addTab background activation', () => { + it('activate:false appends without changing the leaf activeTabId', () => { + const { fsm, store } = buildFSM() + const wtPath = '/wt/bg' + seedLeaf(store, wtPath, { + type: 'leaf', + id: 'p1', + tabs: [{ id: 'agent-1', type: 'agent', label: 'Claude' }], + activeTabId: 'agent-1' + }) + fsm.addTab( + wtPath, + { id: 'sh-1', type: 'shell', label: 'build', background: true }, + undefined, + { activate: false } + ) + const leaf = store.getSnapshot().state.terminals.panes[wtPath] as PaneLeaf + expect(leaf.tabs.map((t) => t.id)).toEqual(['agent-1', 'sh-1']) + expect(leaf.activeTabId).toBe('agent-1') + }) + + it('default (activate) makes the new tab active', () => { + const { fsm, store } = buildFSM() + const wtPath = '/wt/fg' + seedLeaf(store, wtPath, { + type: 'leaf', + id: 'p1', + tabs: [{ id: 'agent-1', type: 'agent', label: 'Claude' }], + activeTabId: 'agent-1' + }) + fsm.addTab(wtPath, { id: 'sh-1', type: 'shell', label: 'build' }) + const leaf = store.getSnapshot().state.terminals.panes[wtPath] as PaneLeaf + expect(leaf.activeTabId).toBe('sh-1') + }) +}) + +describe('PanesFSM.selectTab', () => { + it('clears the background flag when a background tab is selected', () => { + const { fsm, store } = buildFSM() + const wtPath = '/wt/sel' + seedLeaf(store, wtPath, { + type: 'leaf', + id: 'p1', + tabs: [ + { id: 'agent-1', type: 'agent', label: 'Claude' }, + { id: 'sh-1', type: 'shell', label: 'build', background: true } + ], + activeTabId: 'agent-1' + }) + fsm.selectTab(wtPath, 'p1', 'sh-1') + const leaf = store.getSnapshot().state.terminals.panes[wtPath] as PaneLeaf + expect(leaf.activeTabId).toBe('sh-1') + const shellTab = leaf.tabs.find((t) => t.id === 'sh-1') + expect(shellTab?.background).toBeUndefined() + }) +}) + describe('PanesFSM.openFileTab', () => { it('appends a file tab with the file- id and basename label', () => { const { fsm, store } = buildFSM() diff --git a/src/main/panes-fsm.ts b/src/main/panes-fsm.ts index 7ef86c68..1aab0eee 100644 --- a/src/main/panes-fsm.ts +++ b/src/main/panes-fsm.ts @@ -286,7 +286,16 @@ export class PanesFSM { return pane } - addTab(wtPath: string, tab: TerminalTab, paneId?: string): void { + addTab( + wtPath: string, + tab: TerminalTab, + paneId?: string, + opts?: { activate?: boolean } + ): void { + // Background tabs (create_shell background:true) are appended without + // stealing focus — the leaf keeps its current activeTabId. A brand-new + // leaf is the exception below: a lone tab is unavoidably active. + const activate = opts?.activate !== false // Brand-new json-claude tabs default to 'awake' — the user just // clicked to create one, so the renderer's auto-spawn path should // proceed. Slept-by-default only applies to tabs hydrated from @@ -313,7 +322,7 @@ export class PanesFSM { return { ...leaf, tabs: [...leaf.tabs, normalizedTab], - activeTabId: normalizedTab.id + activeTabId: activate ? normalizedTab.id : leaf.activeTabId } }) this.commit(wtPath, updated) @@ -479,9 +488,21 @@ export class PanesFSM { selectTab(wtPath: string, paneId: string, tabId: string): void { const tree = this.getTree(wtPath) if (!tree || !findLeaf(tree, paneId)) return - const updated = mapLeaves(tree, (leaf) => - leaf.id === paneId ? { ...leaf, activeTabId: tabId } : leaf - ) + const updated = mapLeaves(tree, (leaf) => { + if (leaf.id !== paneId) return leaf + // Selecting a background shell promotes it to a normal tab: drop the + // `background` flag so its title stops rendering italic. + const i = leaf.tabs.findIndex((t) => t.id === tabId) + if (i !== -1 && leaf.tabs[i].background) { + const promoted: TerminalTab = { ...leaf.tabs[i], background: undefined } + return { + ...leaf, + activeTabId: tabId, + tabs: [...leaf.tabs.slice(0, i), promoted, ...leaf.tabs.slice(i + 1)] + } + } + return { ...leaf, activeTabId: tabId } + }) this.commit(wtPath, updated) } @@ -497,6 +518,20 @@ export class PanesFSM { this.opts.persist(this.buildPersistPayload()) } + /** Arm (number) or disarm (null) a shell tab's auto-close delay. Drives + * the "Keep open" button — the ShellAutoCloseMonitor re-reads this at + * fire time, so clearing it cancels a pending close. */ + setShellCloseDelay(wtPath: string, tabId: string, closeDelay: number | null): void { + const tree = this.getTree(wtPath) + if (!tree) return + if (!findLeafByTabId(tree, tabId)) return + this.store.dispatch({ + type: 'terminals/tabCloseDelayChanged', + payload: { worktreePath: wtPath, tabId, closeDelay } + }) + this.opts.persist(this.buildPersistPayload()) + } + /** Activate the existing review tab for this worktree, or create one if * none exists. Only one review tab can live per worktree at a time — * every entry point in the renderer funnels through here. */ diff --git a/src/main/pty-manager.ts b/src/main/pty-manager.ts index 1527b87f..a5e10da9 100644 --- a/src/main/pty-manager.ts +++ b/src/main/pty-manager.ts @@ -92,6 +92,15 @@ export class PtyManager { private historyDirty = new Set() private historyFlushTimer: NodeJS.Timeout | null = null private perfMonitor: PerfMonitor | null = null + private exitListeners = new Set<(id: string, exitCode: number) => void>() + + /** Subscribe to PTY exits with their exit code. Used by the shell + * auto-close monitor, which needs the code (the store's terminals/removed + * event carries only the id). Returns an unsubscribe fn. */ + addExitListener(fn: (id: string, exitCode: number) => void): () => void { + this.exitListeners.add(fn) + return () => this.exitListeners.delete(fn) + } /** Wire the authoritative store after it's constructed. PTY status, * shell activity, and cleanup events dispatch through it. */ @@ -225,6 +234,13 @@ export class PtyManager { this.sendSignal?.('terminal:exit', id, exitCode) this.ptys.delete(id) cleanupTerminalLog(id) + for (const fn of this.exitListeners) { + try { + fn(id, exitCode) + } catch { + // a listener throwing must not abort the others' cleanup + } + } }) this.ptys.set(id, instance) diff --git a/src/main/shell-autoclose-monitor.ts b/src/main/shell-autoclose-monitor.ts new file mode 100644 index 00000000..2d24ccce --- /dev/null +++ b/src/main/shell-autoclose-monitor.ts @@ -0,0 +1,81 @@ +import type { Store } from './store' +import type { PanesFSM } from './panes-fsm' +import type { PtyManager } from './pty-manager' +import { findLeafByTabId } from '../shared/state/terminals' +import { log } from './debug' + +/** Watches shell PTY exits and auto-closes the tab a configurable delay + * after a *successful* run (exit code 0), honoring the tab's `closeDelay`. + * + * Rules (see the create_shell MCP tool): + * - Only shell tabs with `closeDelay` set are eligible. + * - A non-zero exit code (failure) never auto-closes — the user keeps the + * error output. + * - At fire time we re-read state: the tab must still exist, still carry a + * `closeDelay` (cleared via "Keep open"), and must NOT be its leaf's + * active tab — a tab the user is looking at stays open. + * + * The exit code isn't in the store's terminals/removed event, so we hook + * PtyManager.addExitListener directly rather than subscribing to the store. */ +export class ShellAutoCloseMonitor { + private store: Store + private panesFSM: PanesFSM + private timers = new Map() + private unsubscribe: (() => void) | null = null + + constructor(store: Store, panesFSM: PanesFSM, ptyManager: PtyManager) { + this.store = store + this.panesFSM = panesFSM + this.unsubscribe = ptyManager.addExitListener((id, exitCode) => + this.onExit(id, exitCode) + ) + } + + stop(): void { + this.unsubscribe?.() + this.unsubscribe = null + for (const t of this.timers.values()) clearTimeout(t) + this.timers.clear() + } + + private onExit(id: string, exitCode: number): void { + if (exitCode !== 0) return + const located = this.locate(id) + if (!located) return + const { tab } = located + if (tab.type !== 'shell' || tab.closeDelay === undefined) return + + const existing = this.timers.get(id) + if (existing) clearTimeout(existing) + const timer = setTimeout(() => { + this.timers.delete(id) + this.fire(id) + }, tab.closeDelay * 1000) + this.timers.set(id, timer) + } + + private fire(id: string): void { + const located = this.locate(id) + if (!located) return + const { wtPath, tab, activeTabId } = located + // "Keep open" cleared closeDelay, or the user is now viewing the tab. + if (tab.closeDelay === undefined) return + if (activeTabId === id) return + log('shell-autoclose', `close tab=${id} wt=${wtPath} delay=${tab.closeDelay}s`) + this.panesFSM.closeTab(wtPath, id) + } + + /** Resolve a shell tab id to its worktree path, tab record, and the + * active-tab id of the leaf that holds it. Null if no longer present. */ + private locate( + id: string + ): { wtPath: string; tab: import('../shared/state/terminals').TerminalTab; activeTabId: string } | null { + const panes = this.store.getSnapshot().state.terminals.panes + for (const [wtPath, tree] of Object.entries(panes)) { + const leaf = findLeafByTabId(tree, id) + const tab = leaf?.tabs.find((t) => t.id === id) + if (leaf && tab) return { wtPath, tab, activeTabId: leaf.activeTabId } + } + return null + } +} diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index c2ccab45..b17bfa37 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -331,6 +331,8 @@ export function buildBackend( panesEnsureInitialized: (wtPath: string) => req('panes:ensureInitialized', wtPath), panesSleepTab: (wtPath: string, tabId: string) => req('panes:sleepTab', wtPath, tabId), panesWakeTab: (wtPath: string, tabId: string) => req('panes:wakeTab', wtPath, tabId), + panesSetShellCloseDelay: (wtPath: string, tabId: string, delay: number | null) => + req('panes:setShellCloseDelay', wtPath, tabId, delay), panesOpenReview: (wtPath: string) => req('panes:openReview', wtPath), panesOpenFile: (wtPath: string, filePath: string, nearTabId?: string) => req('panes:openFile', wtPath, filePath, nearTabId), diff --git a/src/renderer/components/TerminalPanel.tsx b/src/renderer/components/TerminalPanel.tsx index 57fce6c5..d4dd5304 100644 --- a/src/renderer/components/TerminalPanel.tsx +++ b/src/renderer/components/TerminalPanel.tsx @@ -331,7 +331,7 @@ function SortableTab({ tab, isActive, status, shellActivity, showClose, onSelect aria-label="Rename tab" /> ) : ( - {displayLabel} + {displayLabel} )} {showClose && ( diff --git a/src/renderer/components/WorkspaceView.tsx b/src/renderer/components/WorkspaceView.tsx index ce285124..84f13e5a 100644 --- a/src/renderer/components/WorkspaceView.tsx +++ b/src/renderer/components/WorkspaceView.tsx @@ -646,6 +646,13 @@ export function WorkspaceView({ modelOverride={tab.type === 'agent' ? tab.model : undefined} shellCommand={tab.type === 'shell' ? tab.command : undefined} shellCwd={tab.type === 'shell' ? tab.cwd : undefined} + shellBackground={tab.type === 'shell' ? tab.background : undefined} + shellCloseDelay={tab.type === 'shell' ? tab.closeDelay : undefined} + onKeepOpen={ + tab.type === 'shell' + ? (): void => { void backend.panesSetShellCloseDelay(worktreePath, tab.id, null) } + : undefined + } onRestartAgent={ tab.type === 'agent' ? (): void => onRestartAgentTab(worktreePath, tab.id) diff --git a/src/renderer/components/XTerminal.tsx b/src/renderer/components/XTerminal.tsx index 8e5bed5b..0de2f8b1 100644 --- a/src/renderer/components/XTerminal.tsx +++ b/src/renderer/components/XTerminal.tsx @@ -292,13 +292,21 @@ interface XTerminalProps { * replay. The Quake overlay sets this — the marker is useful chrome for * in-pane tabs but noise on the transient drop-down console. */ hideRestoreNotice?: boolean + /** Shell tabs only: this is a background shell (create_shell background:true). + * Spawn the PTY even while the tab is hidden so the command actually runs. */ + shellBackground?: boolean + /** Shell tabs only: auto-close delay in seconds, or undefined when not + * armed. When set, an overlay banner offers to keep the tab open. */ + shellCloseDelay?: number + /** Disarm the shell tab's auto-close (the "Do not close" action). */ + onKeepOpen?: () => void onRestartAgent?: () => void /** When provided AND this is a Claude agent tab, an overlay chip in * the top-left invites the user to switch to the Chat interface. */ onSwitchToChat?: () => void } -export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionName, sessionId, initialPrompt, teleportSessionId, modelOverride, shellCommand, shellCwd, backgroundVar, preamble, hideRestoreNotice, onRestartAgent, onSwitchToChat }: XTerminalProps): JSX.Element { +export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionName, sessionId, initialPrompt, teleportSessionId, modelOverride, shellCommand, shellCwd, backgroundVar, preamble, hideRestoreNotice, shellBackground, shellCloseDelay, onKeepOpen, onRestartAgent, onSwitchToChat }: XTerminalProps): JSX.Element { // Lazy font-cache init — fires once on first XTerminal mount. See // initFontCache() comment for why this is lazy rather than at module // top. @@ -335,6 +343,10 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa ) const commitHoverTimer = useRef(null) const [exited, setExited] = useState(false) + // Local-only: hides the auto-close prompt after "Close if successful" + // without disarming the behavior. "Do not close" instead clears closeDelay + // in state. + const [closePromptDismissed, setClosePromptDismissed] = useState(false) const containerRef = useRef(null) const terminalRef = useRef(null) const fitAddonRef = useRef(null) @@ -594,7 +606,7 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa let cleanupExit: (() => void) | null = null let disposed = false - if (type === 'agent') { + if (type === 'agent' || type === 'shell') { cleanupExit = backend.onTerminalExit((id) => { if (id === terminalId && !disposed) setExited(true) }) @@ -620,8 +632,15 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa // Spawning now would come up at the fallback 120x30 and every burst // of output before the first resize IPC would paint at the wrong // column, producing a visible "flash" when the worktree is opened. + // + // Exception: a background shell (create_shell background:true) is + // meant to run without ever being displayed. Deferring would mean its + // command never executes, so we spawn immediately at the fallback + // grid — there's no flash because nobody is watching, and a later + // resize reflows it if the user does open the tab. const rect = containerRef.current?.getBoundingClientRect() - if (!rect || rect.width < 20 || rect.height < 20) { + const noLayout = !rect || rect.width < 20 || rect.height < 20 + if (noLayout && !shellBackground) { pendingSpawnRef.current = () => { void spawnPty() } return } @@ -1063,6 +1082,24 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa )} + {type === 'shell' && !exited && shellCloseDelay !== undefined && onKeepOpen && !closePromptDismissed && ( +
+ + + + +
+ )} {exited && type === 'agent' && onRestartAgent && (
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 2a7ca434..e30ab4f4 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -284,6 +284,12 @@ height: 2rem; } +/* Tab whose contents haven't been viewed yet (e.g. a background shell + spawned via the MCP create_shell with background:true, until selected). */ +@utility tab-unviewed { + font-style: italic; +} + @layer base { * { margin: 0; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 0ead0aa5..3e503aac 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -423,6 +423,7 @@ export interface ElectronAPI { panesEnsureInitialized(wtPath: string): Promise panesSleepTab(wtPath: string, tabId: string): Promise panesWakeTab(wtPath: string, tabId: string): Promise + panesSetShellCloseDelay(wtPath: string, tabId: string, delay: number | null): Promise panesOpenReview(wtPath: string): Promise panesOpenFile(wtPath: string, filePath: string, nearTabId?: string): Promise panesSetReviewSelection( diff --git a/src/shared/state/terminals.test.ts b/src/shared/state/terminals.test.ts index e6d32779..0f80ec59 100644 --- a/src/shared/state/terminals.test.ts +++ b/src/shared/state/terminals.test.ts @@ -941,4 +941,56 @@ describe('terminalsReducer', () => { const agentTab = leaves[0].tabs[0] expect(agentTab.sessionId).toBe('sess-abc') }) + + it('tabCloseDelayChanged arms a delay on the matching shell tab', () => { + const tree: PaneNode = { + type: 'leaf', + id: 'p1', + tabs: [ + { id: 't1', type: 'shell', label: 'Shell' }, + { id: 't2', type: 'shell', label: 'Shell' } + ], + activeTabId: 't1' + } + const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } } + const next = apply(start, { + type: 'terminals/tabCloseDelayChanged', + payload: { worktreePath: '/wt/a', tabId: 't1', closeDelay: 30 } + }) + const tabs = getLeaves(next.panes['/wt/a'])[0].tabs + expect(tabs[0].closeDelay).toBe(30) + // untouched tab keeps its reference identity + expect(tabs[1]).toBe(getLeaves(start.panes['/wt/a'])[0].tabs[1]) + }) + + it('tabCloseDelayChanged with null disarms (deletes) the delay', () => { + const tree: PaneNode = { + type: 'leaf', + id: 'p1', + tabs: [{ id: 't1', type: 'shell', label: 'Shell', closeDelay: 30 }], + activeTabId: 't1' + } + const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } } + const next = apply(start, { + type: 'terminals/tabCloseDelayChanged', + payload: { worktreePath: '/wt/a', tabId: 't1', closeDelay: null } + }) + const tab = getLeaves(next.panes['/wt/a'])[0].tabs[0] + expect('closeDelay' in tab).toBe(false) + }) + + it('tabCloseDelayChanged is a no-op when the value is unchanged', () => { + const tree: PaneNode = { + type: 'leaf', + id: 'p1', + tabs: [{ id: 't1', type: 'shell', label: 'Shell', closeDelay: 30 }], + activeTabId: 't1' + } + const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } } + const next = apply(start, { + type: 'terminals/tabCloseDelayChanged', + payload: { worktreePath: '/wt/a', tabId: 't1', closeDelay: 30 } + }) + expect(next).toBe(start) + }) }) diff --git a/src/shared/state/terminals.ts b/src/shared/state/terminals.ts index e4c1cbdd..205dc396 100644 --- a/src/shared/state/terminals.ts +++ b/src/shared/state/terminals.ts @@ -64,6 +64,19 @@ export interface TerminalTab { /** For shell tabs: directory to run in. Relative paths resolve against the * worktree root; absolute paths are used as-is. */ cwd?: string + /** For shell tabs spawned via the MCP `create_shell` with `background:true`: + * the tab is created without stealing focus and its title renders italic. + * Cleared the moment the tab is selected (terminals/tabSelected), after + * which it behaves like any other shell tab. Also drives spawn-while-hidden + * in XTerminal so a background command runs even though its tab is never + * displayed. */ + background?: boolean + /** For shell tabs: seconds to wait after a *successful* exit (code 0) + * before the tab auto-closes, unless it is its leaf's active tab at that + * moment. Undefined = never auto-close. Set by the MCP `create_shell` + * (default 30); cleared to undefined when the user clicks "Keep open", + * which the ShellAutoCloseMonitor re-reads at fire time to cancel. */ + closeDelay?: number /** For review tabs: which commits the review is showing. When both * reviewFromCommit and reviewToCommit are undefined, the review shows the * whole branch (uncommitted + all commits ahead of base). When set, they @@ -301,6 +314,10 @@ export type TerminalsEvent = toCommit?: string } } + | { + type: 'terminals/tabCloseDelayChanged' + payload: { worktreePath: string; tabId: string; closeDelay: number | null } + } export const initialTerminals: TerminalsState = { statuses: {}, @@ -682,6 +699,32 @@ export function terminalsReducer( if (!mutated) return state return { ...state, panes: { ...state.panes, [worktreePath]: updated } } } + case 'terminals/tabCloseDelayChanged': { + const { worktreePath, tabId, closeDelay } = event.payload + const tree = state.panes[worktreePath] + if (!tree) return state + let mutated = false + const updated = mapLeaves(tree, (leaf) => { + const i = leaf.tabs.findIndex((t) => t.id === tabId) + if (i === -1) return leaf + const tab = leaf.tabs[i] + const next: TerminalTab = { ...tab } + if (closeDelay === null) { + if (tab.closeDelay === undefined) return leaf + delete next.closeDelay + } else { + if (tab.closeDelay === closeDelay) return leaf + next.closeDelay = closeDelay + } + mutated = true + return { + ...leaf, + tabs: [...leaf.tabs.slice(0, i), next, ...leaf.tabs.slice(i + 1)] + } + }) + if (!mutated) return state + return { ...state, panes: { ...state.panes, [worktreePath]: updated } } + } case 'terminals/sizeChanged': { const { terminalId, cols, rows } = event.payload const existing = state.sessions[terminalId]