import { stripVTControlCharacters } from "node:util"; import type { Component, Focusable, TUI } from "@mariozechner/pi-tui"; import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import type { Theme } from "@mariozechner/pi-coding-agent"; import { PtyTerminalSession } from "./pty-session.js"; import { sessionManager, generateSessionId } from "./session-manager.js"; import type { InteractiveShellConfig } from "./config.js"; import { type InteractiveShellResult, type HandsFreeUpdate, type InteractiveShellOptions, type DialogChoice, type OverlayState, HEADER_LINES, FOOTER_LINES_COMPACT, formatDuration, formatShortcut, } from "./types.js"; import { captureCompletionOutput, captureTransferOutput, maybeBuildHandoffPreview, maybeWriteHandoffSnapshot } from "./handoff-utils.js"; import { createSessionQueryState, getSessionOutput } from "./session-query.js"; export class InteractiveShellOverlay implements Component, Focusable { focused = false; private tui: TUI; private theme: Theme; private done: (result: InteractiveShellResult) => void; private session: PtyTerminalSession; private options: InteractiveShellOptions; private config: InteractiveShellConfig; private state: OverlayState = "running"; private dialogSelection: DialogChoice = "transfer"; private exitCountdown = 0; private countdownInterval: ReturnType | null = null; private lastWidth = 0; private lastHeight = 0; // Hands-free mode private userTookOver = false; private handsFreeInterval: ReturnType | null = null; private handsFreeInitialTimeout: ReturnType | null = null; private startTime: number; private sessionId: string | null = null; private sessionUnregistered = false; // Timeout private timeoutTimer: ReturnType | null = null; // Prevent double done() calls private finished = false; // Budget tracking for hands-free updates private totalCharsSent = 0; private budgetExhausted = false; private currentUpdateInterval: number; private currentQuietThreshold: number; private updateMode: "on-quiet" | "interval"; private quietTimer: ReturnType | null = null; private hasUnsentData = false; // Non-blocking mode: track status for agent queries private completionResult: InteractiveShellResult | undefined; private queryState = createSessionQueryState(); // Completion callbacks for waiters private completeCallbacks: Array<() => void> = []; // Simple render throttle to reduce flicker private renderTimeout: ReturnType | null = null; constructor( tui: TUI, theme: Theme, options: InteractiveShellOptions, config: InteractiveShellConfig, done: (result: InteractiveShellResult) => void, ) { this.tui = tui; this.theme = theme; this.options = options; this.config = config; this.done = done; this.startTime = options.startedAt ?? Date.now(); const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100); const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100); const cols = Math.max(20, overlayWidth - 4); const rows = Math.max(3, overlayHeight - (HEADER_LINES + FOOTER_LINES_COMPACT + 2)); const ptyEvents = { onData: (data: string) => { this.debouncedRender(); if (this.state === "hands-free" && (this.updateMode === "on-quiet" || this.options.autoExitOnQuiet)) { const visible = stripVTControlCharacters(data); if (visible.trim().length > 0) { if (this.updateMode === "on-quiet") { this.hasUnsentData = true; } this.resetQuietTimer(); } } }, onExit: () => { if (this.finished) return; this.stopTimeout(); if (this.state === "hands-free" && this.sessionId) { if (this.hasUnsentData || this.updateMode === "interval") { this.emitHandsFreeUpdate(); this.hasUnsentData = false; } if (this.options.onHandsFreeUpdate) { this.options.onHandsFreeUpdate({ status: "exited", sessionId: this.sessionId, runtime: Date.now() - this.startTime, tail: [], tailTruncated: false, totalCharsSent: this.totalCharsSent, budgetExhausted: this.budgetExhausted, }); } this.finishWithExit(); return; } this.stopHandsFreeUpdates(); this.state = "exited"; this.exitCountdown = this.config.exitAutoCloseDelay; this.startExitCountdown(); this.tui.requestRender(); }, }; if (options.existingSession) { this.session = options.existingSession; this.session.setEventHandlers(ptyEvents); this.session.resize(cols, rows); } else { this.session = new PtyTerminalSession( { command: options.command, cwd: options.cwd, cols, rows, scrollback: this.config.scrollbackLines, ansiReemit: this.config.ansiReemit, }, ptyEvents, ); } // Initialize hands-free mode settings this.updateMode = options.handsFreeUpdateMode ?? config.handsFreeUpdateMode; this.currentUpdateInterval = options.handsFreeUpdateInterval ?? config.handsFreeUpdateInterval; this.currentQuietThreshold = options.handsFreeQuietThreshold ?? config.handsFreeQuietThreshold; if (options.mode === "hands-free" || options.mode === "dispatch") { this.state = "hands-free"; this.sessionId = options.sessionId ?? generateSessionId(options.name); sessionManager.registerActive({ id: this.sessionId, command: options.command, reason: options.reason, write: (data) => this.session.write(data), kill: () => this.killSession(), background: () => this.backgroundSession(), getOutput: (options) => this.getOutputSinceLastCheck(options), getStatus: () => this.getSessionStatus(), getRuntime: () => this.getRuntime(), getResult: () => this.getCompletionResult(), setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs), setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs), onComplete: (callback) => this.registerCompleteCallback(callback), }); this.startHandsFreeUpdates(); } if (options.timeout && options.timeout > 0) { this.timeoutTimer = setTimeout(() => { this.finishWithTimeout(); }, options.timeout); } if (options.existingSession && options.existingSession.exited) { queueMicrotask(() => { if (this.finished) return; this.stopTimeout(); if (this.state === "hands-free" && this.sessionId) { if (this.options.onHandsFreeUpdate) { this.options.onHandsFreeUpdate({ status: "exited", sessionId: this.sessionId, runtime: Date.now() - this.startTime, tail: [], tailTruncated: false, totalCharsSent: this.totalCharsSent, budgetExhausted: this.budgetExhausted, }); } this.finishWithExit(); } else { this.stopHandsFreeUpdates(); this.state = "exited"; this.exitCountdown = this.config.exitAutoCloseDelay; this.startExitCountdown(); this.tui.requestRender(); } }); } } // Public methods for non-blocking mode (agent queries) /** Get rendered terminal output (last N lines, truncated if too large) */ getOutputSinceLastCheck(options: { skipRateLimit?: boolean; lines?: number; maxChars?: number; offset?: number; drain?: boolean; incremental?: boolean } | boolean = false): { output: string; truncated: boolean; totalBytes: number; totalLines?: number; hasMore?: boolean; rateLimited?: boolean; waitSeconds?: number } { return getSessionOutput(this.session, this.config, this.queryState, options, this.completionResult?.completionOutput); } /** Get current session status */ getSessionStatus(): "running" | "user-takeover" | "exited" | "killed" | "backgrounded" { if (this.completionResult) { if (this.completionResult.cancelled) return "killed"; if (this.completionResult.backgrounded) return "backgrounded"; if (this.userTookOver) return "user-takeover"; return "exited"; } if (this.userTookOver) return "user-takeover"; if (this.state === "exited") return "exited"; return "running"; } /** Get runtime in milliseconds */ getRuntime(): number { return Date.now() - this.startTime; } /** Get completion result (if session has ended) */ getCompletionResult(): InteractiveShellResult | undefined { return this.completionResult; } /** Register a callback to be called when session completes */ registerCompleteCallback(callback: () => void): void { // If already completed, call immediately if (this.completionResult) { callback(); return; } this.completeCallbacks.push(callback); } /** Trigger all completion callbacks */ private triggerCompleteCallbacks(): void { for (const callback of this.completeCallbacks) { try { callback(); } catch (error) { console.error("interactive-shell: completion callback error:", error); } } this.completeCallbacks = []; } /** Debounced render - waits for data to settle before rendering */ private debouncedRender(): void { if (this.renderTimeout) { clearTimeout(this.renderTimeout); } // Wait 16ms for more data before rendering this.renderTimeout = setTimeout(() => { this.renderTimeout = null; this.tui.requestRender(); }, 16); } /** Kill the session programmatically */ killSession(): void { if (!this.finished) { this.finishWithKill(); } } private startExitCountdown(): void { this.stopCountdown(); this.countdownInterval = setInterval(() => { this.exitCountdown--; if (this.exitCountdown <= 0) { this.finishWithExit(); } else { this.tui.requestRender(); } }, 1000); } private stopCountdown(): void { if (this.countdownInterval) { clearInterval(this.countdownInterval); this.countdownInterval = null; } } private startHandsFreeUpdates(): void { if (this.options.onHandsFreeUpdate) { // Send initial update after a short delay (let process start) this.handsFreeInitialTimeout = setTimeout(() => { this.handsFreeInitialTimeout = null; if (this.state === "hands-free") { this.emitHandsFreeUpdate(); } }, 2000); this.handsFreeInterval = setInterval(() => { if (this.state === "hands-free") { if (this.updateMode === "on-quiet") { if (this.hasUnsentData) { this.emitHandsFreeUpdate(); this.hasUnsentData = false; if (this.options.autoExitOnQuiet) { this.resetQuietTimer(); } else { this.stopQuietTimer(); } } } else { this.emitHandsFreeUpdate(); } } }, this.currentUpdateInterval); } if (this.options.autoExitOnQuiet) { this.resetQuietTimer(); } } private resetQuietTimer(): void { this.stopQuietTimer(); this.quietTimer = setTimeout(() => { this.quietTimer = null; if (this.state === "hands-free") { // Auto-exit on quiet: kill session when output stops (agent likely finished task) if (this.options.autoExitOnQuiet) { const gracePeriod = this.options.autoExitGracePeriod ?? this.config.autoExitGracePeriod; if (Date.now() - this.startTime < gracePeriod) { if (this.hasUnsentData) { this.emitHandsFreeUpdate(); this.hasUnsentData = false; } this.resetQuietTimer(); return; } // Emit final update with any pending output if (this.hasUnsentData) { this.emitHandsFreeUpdate(); this.hasUnsentData = false; } // Send completion notification and auto-close // Use "killed" status since we're forcibly terminating (matches finishWithKill's cancelled=true) if (this.options.onHandsFreeUpdate && this.sessionId) { this.options.onHandsFreeUpdate({ status: "killed", sessionId: this.sessionId, runtime: Date.now() - this.startTime, tail: [], tailTruncated: false, totalCharsSent: this.totalCharsSent, budgetExhausted: this.budgetExhausted, }); } this.finishWithKill(); return; } // Normal behavior: just emit update if (this.hasUnsentData) { this.emitHandsFreeUpdate(); this.hasUnsentData = false; } } }, this.currentQuietThreshold); } private stopQuietTimer(): void { if (this.quietTimer) { clearTimeout(this.quietTimer); this.quietTimer = null; } } /** Update the hands-free update interval dynamically */ setUpdateInterval(intervalMs: number): void { const clamped = Math.max(5000, Math.min(300000, intervalMs)); if (clamped === this.currentUpdateInterval) return; this.currentUpdateInterval = clamped; if (this.handsFreeInterval) { clearInterval(this.handsFreeInterval); this.handsFreeInterval = setInterval(() => { if (this.state === "hands-free") { if (this.updateMode === "on-quiet") { if (this.hasUnsentData) { this.emitHandsFreeUpdate(); this.hasUnsentData = false; if (this.options.autoExitOnQuiet) { this.resetQuietTimer(); } else { this.stopQuietTimer(); } } } else { this.emitHandsFreeUpdate(); } } }, this.currentUpdateInterval); } } /** Update the quiet threshold dynamically */ setQuietThreshold(thresholdMs: number): void { const clamped = Math.max(1000, Math.min(30000, thresholdMs)); if (clamped === this.currentQuietThreshold) return; this.currentQuietThreshold = clamped; if (this.quietTimer) { this.resetQuietTimer(); } } private stopHandsFreeUpdates(): void { if (this.handsFreeInitialTimeout) { clearTimeout(this.handsFreeInitialTimeout); this.handsFreeInitialTimeout = null; } if (this.handsFreeInterval) { clearInterval(this.handsFreeInterval); this.handsFreeInterval = null; } this.stopQuietTimer(); } private stopTimeout(): void { if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); this.timeoutTimer = null; } } private unregisterActiveSession(releaseId = false): void { if (this.sessionId && !this.sessionUnregistered) { sessionManager.unregisterActive(this.sessionId, releaseId); this.sessionUnregistered = true; } } private emitHandsFreeUpdate(): void { if (!this.options.onHandsFreeUpdate || !this.sessionId) return; const maxChars = this.options.handsFreeUpdateMaxChars ?? this.config.handsFreeUpdateMaxChars; const maxTotalChars = this.options.handsFreeMaxTotalChars ?? this.config.handsFreeMaxTotalChars; let tail: string[] = []; let truncated = false; // Only include content if budget not exhausted if (!this.budgetExhausted) { // Get incremental output since last update let newOutput = this.session.getRawStream({ sinceLast: true, stripAnsi: true }); // Truncate if exceeds per-update limit if (newOutput.length > maxChars) { newOutput = newOutput.slice(-maxChars); truncated = true; } // Check total budget if (this.totalCharsSent + newOutput.length > maxTotalChars) { // Truncate to fit remaining budget const remaining = maxTotalChars - this.totalCharsSent; if (remaining > 0) { newOutput = newOutput.slice(-remaining); truncated = true; } else { newOutput = ""; } this.budgetExhausted = true; } if (newOutput.length > 0) { this.totalCharsSent += newOutput.length; // Split into lines for the tail array tail = newOutput.split("\n"); } } this.options.onHandsFreeUpdate({ status: "running", sessionId: this.sessionId, runtime: Date.now() - this.startTime, tail, tailTruncated: truncated, totalCharsSent: this.totalCharsSent, budgetExhausted: this.budgetExhausted, }); } private triggerUserTakeover(): void { if (this.state !== "hands-free" || !this.sessionId) return; // Flush any pending output before stopping updates // In interval mode, hasUnsentData is not tracked, so always flush if (this.hasUnsentData || this.updateMode === "interval") { this.emitHandsFreeUpdate(); this.hasUnsentData = false; } this.stopHandsFreeUpdates(); this.state = "running"; this.userTookOver = true; if (this.options.onHandsFreeUpdate) { this.options.onHandsFreeUpdate({ status: "user-takeover", sessionId: this.sessionId, runtime: Date.now() - this.startTime, tail: [], tailTruncated: false, userTookOver: true, totalCharsSent: this.totalCharsSent, budgetExhausted: this.budgetExhausted, }); } // In streaming mode (blocking tool call), unregister now since the agent // gets the result via tool return. Otherwise keep registered for queries. if (this.options.streamingMode) { this.unregisterActiveSession(true); } this.tui.requestRender(); } private returnToHandsFree(): void { if (!this.userTookOver || !this.sessionId || this.session.exited) return; this.state = "hands-free"; this.userTookOver = false; // Re-register if streaming mode previously released the session if (this.sessionUnregistered) { sessionManager.registerActive({ id: this.sessionId, command: this.options.command, reason: this.options.reason, write: (data) => this.session.write(data), kill: () => this.killSession(), background: () => this.backgroundSession(), getOutput: (options) => this.getOutputSinceLastCheck(options), getStatus: () => this.getSessionStatus(), getRuntime: () => this.getRuntime(), getResult: () => this.getCompletionResult(), setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs), setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs), onComplete: (callback) => this.registerCompleteCallback(callback), }); this.sessionUnregistered = false; } if (this.options.onHandsFreeUpdate) { this.options.onHandsFreeUpdate({ status: "agent-resumed", sessionId: this.sessionId, runtime: Date.now() - this.startTime, tail: [], tailTruncated: false, totalCharsSent: this.totalCharsSent, budgetExhausted: this.budgetExhausted, }); } this.startHandsFreeUpdates(); this.tui.requestRender(); } private getDialogOptions(): Array<{ key: DialogChoice; label: string }> { const options: Array<{ key: DialogChoice; label: string }> = []; if (this.userTookOver && !this.session.exited) { options.push({ key: "return-to-agent", label: "Return control to agent" }); } options.push( { key: "transfer", label: "Transfer output to agent" }, { key: "background", label: "Run in background" }, { key: "kill", label: "Kill process" }, { key: "cancel", label: "Cancel (return to session)" }, ); return options; } /** Capture output for dispatch completion notifications */ private captureCompletionOutput(): InteractiveShellResult["completionOutput"] { return captureCompletionOutput(this.session, this.config); } /** Capture output for transfer action (Ctrl+T or dialog) */ private captureTransferOutput(): InteractiveShellResult["transferred"] { return captureTransferOutput(this.session, this.config); } private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined { return maybeBuildHandoffPreview(this.session, when, this.config, this.options); } private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoff"] | undefined { return maybeWriteHandoffSnapshot(this.session, when, this.config, { command: this.options.command, cwd: this.options.cwd, }, this.options); } private finishWithExit(): void { if (this.finished) return; this.finished = true; this.stopCountdown(); this.stopTimeout(); this.stopHandsFreeUpdates(); const handoffPreview = this.maybeBuildHandoffPreview("exit"); const handoff = this.maybeWriteHandoffSnapshot("exit"); const completionOutput = this.captureCompletionOutput(); this.session.dispose(); const result: InteractiveShellResult = { exitCode: this.session.exitCode, signal: this.session.signal, backgrounded: false, cancelled: false, sessionId: this.sessionId ?? undefined, userTookOver: this.userTookOver, completionOutput, handoffPreview, handoff, }; this.completionResult = result; this.triggerCompleteCallbacks(); // In streaming mode (blocking tool call), unregister now since the agent // gets the result via tool return. Otherwise keep registered for queries. if (this.options.streamingMode) { this.unregisterActiveSession(true); } this.done(result); } backgroundSession(): void { this.finishWithBackground(); } private finishWithBackground(): void { if (this.finished) return; this.finished = true; this.stopCountdown(); this.stopTimeout(); this.stopHandsFreeUpdates(); const handoffPreview = this.maybeBuildHandoffPreview("detach"); const handoff = this.maybeWriteHandoffSnapshot("detach"); const addOptions = this.sessionId ? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch", startedAt: new Date(this.startTime) } : undefined; const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason, addOptions); const result: InteractiveShellResult = { exitCode: null, backgrounded: true, backgroundId: id, cancelled: false, sessionId: this.sessionId ?? undefined, userTookOver: this.userTookOver, handoffPreview, handoff, }; this.completionResult = result; this.triggerCompleteCallbacks(); // In streaming mode (blocking tool call), unregister now since the agent // gets the result via tool return. releaseId=false because background owns the ID. if (this.options.streamingMode) { this.unregisterActiveSession(false); } this.done(result); } private finishWithKill(): void { if (this.finished) return; this.finished = true; this.stopCountdown(); this.stopTimeout(); this.stopHandsFreeUpdates(); const handoffPreview = this.maybeBuildHandoffPreview("kill"); const handoff = this.maybeWriteHandoffSnapshot("kill"); const completionOutput = this.captureCompletionOutput(); this.session.kill(); this.session.dispose(); const result: InteractiveShellResult = { exitCode: null, backgrounded: false, cancelled: true, sessionId: this.sessionId ?? undefined, userTookOver: this.userTookOver, completionOutput, handoffPreview, handoff, }; this.completionResult = result; this.triggerCompleteCallbacks(); // In streaming mode (blocking tool call), unregister now since the agent // gets the result via tool return. Otherwise keep registered for queries. if (this.options.streamingMode) { this.unregisterActiveSession(true); } this.done(result); } private finishWithTransfer(): void { if (this.finished) return; this.finished = true; this.stopCountdown(); this.stopTimeout(); this.stopHandsFreeUpdates(); // Capture output BEFORE killing the session const transferred = this.captureTransferOutput(); const completionOutput = this.captureCompletionOutput(); const handoffPreview = this.maybeBuildHandoffPreview("transfer"); const handoff = this.maybeWriteHandoffSnapshot("transfer"); this.session.kill(); this.session.dispose(); const result: InteractiveShellResult = { exitCode: this.session.exitCode, signal: this.session.signal, backgrounded: false, cancelled: false, sessionId: this.sessionId ?? undefined, userTookOver: this.userTookOver, transferred, completionOutput, handoffPreview, handoff, }; this.completionResult = result; this.triggerCompleteCallbacks(); // In streaming mode (blocking tool call), unregister now since the agent // gets the result via tool return. Otherwise keep registered for queries. if (this.options.streamingMode) { this.unregisterActiveSession(true); } this.done(result); } private finishWithTimeout(): void { if (this.finished) return; this.finished = true; this.stopCountdown(); this.stopTimeout(); // Send final update with any unsent data, then "exited" notification (for timeout) if (this.state === "hands-free" && this.options.onHandsFreeUpdate && this.sessionId) { // Flush any pending output before sending exited notification if (this.hasUnsentData || this.updateMode === "interval") { this.emitHandsFreeUpdate(); this.hasUnsentData = false; } // Now send exited notification (timedOut is indicated in final tool result) this.options.onHandsFreeUpdate({ status: "exited", sessionId: this.sessionId, runtime: Date.now() - this.startTime, tail: [], tailTruncated: false, totalCharsSent: this.totalCharsSent, budgetExhausted: this.budgetExhausted, }); } this.stopHandsFreeUpdates(); const handoffPreview = this.maybeBuildHandoffPreview("timeout"); const handoff = this.maybeWriteHandoffSnapshot("timeout"); const completionOutput = this.captureCompletionOutput(); this.session.kill(); this.session.dispose(); const result: InteractiveShellResult = { exitCode: null, backgrounded: false, cancelled: false, timedOut: true, sessionId: this.sessionId ?? undefined, userTookOver: this.userTookOver, completionOutput, handoffPreview, handoff, }; this.completionResult = result; this.triggerCompleteCallbacks(); // In streaming mode (blocking tool call), unregister now since the agent // gets the result via tool return. Otherwise keep registered for queries. if (this.options.streamingMode) { this.unregisterActiveSession(true); } this.done(result); } handleInput(data: string): void { if (this.state === "detach-dialog") { this.handleDialogInput(data); return; } if (matchesKey(data, this.config.focusShortcut)) { this.options.onUnfocus?.(); return; } // Ctrl+G: Return to agent monitoring (only active during takeover) if (this.userTookOver && this.state === "running" && matchesKey(data, "ctrl+g")) { this.returnToHandsFree(); return; } // Ctrl+T: Quick transfer - capture output and close (works in all states including "exited") if (matchesKey(data, "ctrl+t")) { // If in hands-free mode, trigger takeover first (notifies agent) if (this.state === "hands-free") { this.triggerUserTakeover(); } this.finishWithTransfer(); return; } // Ctrl+B: Quick background - dismiss overlay, keep process running if (matchesKey(data, "ctrl+b") && !this.session.exited) { if (this.state === "hands-free") { this.triggerUserTakeover(); } this.finishWithBackground(); return; } if (this.state === "exited") { if (data.length > 0) { this.finishWithExit(); } return; } // Ctrl+Q opens detach dialog (works in both hands-free and running) if (matchesKey(data, "ctrl+q")) { // If in hands-free mode, trigger takeover first (notifies agent) if (this.state === "hands-free") { this.triggerUserTakeover(); } this.state = "detach-dialog"; this.dialogSelection = (this.userTookOver && !this.session.exited) ? "return-to-agent" : "transfer"; this.tui.requestRender(); return; } // Scroll does NOT trigger takeover if (matchesKey(data, "shift+up")) { this.session.scrollUp(Math.max(1, this.session.rows - 2)); this.tui.requestRender(); return; } if (matchesKey(data, "shift+down")) { this.session.scrollDown(Math.max(1, this.session.rows - 2)); this.tui.requestRender(); return; } // Any other input in hands-free mode triggers user takeover if (this.state === "hands-free") { this.triggerUserTakeover(); // Fall through to send the input to subprocess } this.session.write(data); } private handleDialogInput(data: string): void { if (matchesKey(data, "escape")) { this.state = "running"; this.tui.requestRender(); return; } if (matchesKey(data, "up") || matchesKey(data, "down")) { const options = this.getDialogOptions(); const keys = options.map(o => o.key); const currentIdx = keys.indexOf(this.dialogSelection); const direction = matchesKey(data, "up") ? -1 : 1; const newIdx = (currentIdx + direction + keys.length) % keys.length; this.dialogSelection = keys[newIdx]!; this.tui.requestRender(); return; } if (matchesKey(data, "enter")) { switch (this.dialogSelection) { case "return-to-agent": this.returnToHandsFree(); break; case "transfer": this.finishWithTransfer(); break; case "kill": this.finishWithKill(); break; case "background": this.finishWithBackground(); break; case "cancel": this.state = "running"; this.tui.requestRender(); break; } } } render(width: number): string[] { width = Math.max(4, width); const th = this.theme; const borderColor = this.focused ? "borderAccent" : "borderMuted"; const borderGlyphs = this.focused ? { topLeft: "╔", topRight: "╗", bottomLeft: "╚", bottomRight: "╝", horizontal: "═", vertical: "║", separatorLeft: "╠", separatorRight: "╣" } : { topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", horizontal: "─", vertical: "│", separatorLeft: "├", separatorRight: "┤" }; const border = (s: string) => th.fg(borderColor, s); const accent = (s: string) => th.fg("accent", s); const dim = (s: string) => th.fg("dim", s); const warning = (s: string) => th.fg("warning", s); const innerWidth = width - 4; const pad = (s: string, w: number) => { const vis = visibleWidth(s); return s + " ".repeat(Math.max(0, w - vis)); }; const row = (content: string) => border(`${borderGlyphs.vertical} `) + pad(truncateToWidth(content, innerWidth, ""), innerWidth) + border(` ${borderGlyphs.vertical}`); const emptyRow = () => row(""); const lines: string[] = []; // Sanitize command: collapse newlines and whitespace to single spaces for display const sanitizedCommand = this.options.command.replace(/\s+/g, " ").trim(); const focusBadgeLabel = this.focused ? " SHELL FOCUSED " : " EDITOR FOCUSED "; const compactFocusBadgeLabel = this.focused ? " SHELL " : " EDITOR "; const makeFocusBadge = (label: string) => th.bg("selectedBg", th.bold(th.fg(this.focused ? "accent" : "muted", label))); const pid = dim(`PID: ${this.session.pid}`); let titleMeta = `${makeFocusBadge(focusBadgeLabel)} ${pid}`; if (visibleWidth(titleMeta) > innerWidth - 4) { titleMeta = `${makeFocusBadge(compactFocusBadgeLabel)} ${pid}`; } if (visibleWidth(titleMeta) > innerWidth - 2) { titleMeta = makeFocusBadge(compactFocusBadgeLabel); } titleMeta = truncateToWidth(titleMeta, innerWidth, ""); const title = truncateToWidth(sanitizedCommand, Math.max(0, innerWidth - visibleWidth(titleMeta) - 1), "..."); lines.push(border(borderGlyphs.topLeft + borderGlyphs.horizontal.repeat(width - 2) + borderGlyphs.topRight)); lines.push( row( accent(title) + " ".repeat(Math.max(0, innerWidth - visibleWidth(title) - visibleWidth(titleMeta))) + titleMeta, ), ); let hint: string; // Sanitize reason: collapse newlines and whitespace to single spaces for display const sanitizedReason = this.options.reason?.replace(/\s+/g, " ").trim(); if (this.state === "hands-free") { const elapsed = formatDuration(Date.now() - this.startTime); hint = `🤖 Hands-free (${elapsed}) • Type anything to take over`; } else if (this.userTookOver) { hint = sanitizedReason ? `You took over • Ctrl+G return to agent • ${sanitizedReason}` : "You took over • Ctrl+G return to agent"; } else { hint = sanitizedReason ? `Ctrl+B background • ${sanitizedReason}` : "Ctrl+B background"; } lines.push(row(dim(truncateToWidth(hint, innerWidth, "...")))); lines.push(border(borderGlyphs.separatorLeft + borderGlyphs.horizontal.repeat(width - 2) + borderGlyphs.separatorRight)); const dialogOptions = this.state === "detach-dialog" ? this.getDialogOptions() : []; const overlayHeight = Math.floor((this.tui.terminal.rows * this.config.overlayHeightPercent) / 100); const footerHeight = this.state === "detach-dialog" ? dialogOptions.length + 2 : FOOTER_LINES_COMPACT; const chrome = HEADER_LINES + footerHeight + 2; const termRows = Math.max(0, overlayHeight - chrome); if (termRows > 0) { if (innerWidth !== this.lastWidth || termRows !== this.lastHeight) { this.session.resize(innerWidth, termRows); this.lastWidth = innerWidth; this.lastHeight = termRows; // After resize, ensure we're at the bottom to prevent flash to top this.session.scrollToBottom(); } const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit }); for (const line of viewportLines) { lines.push(row(truncateToWidth(line, innerWidth, ""))); } } if (this.session.isScrolledUp()) { const hintText = "── ↑ scrolled (Shift+Down) ──"; const padLen = Math.max(0, Math.floor((width - 2 - visibleWidth(hintText)) / 2)); lines.push( border(borderGlyphs.separatorLeft) + dim( " ".repeat(padLen) + hintText + " ".repeat(width - 2 - padLen - visibleWidth(hintText)), ) + border(borderGlyphs.separatorRight), ); } else { lines.push(border(borderGlyphs.separatorLeft + borderGlyphs.horizontal.repeat(width - 2) + borderGlyphs.separatorRight)); } const footerLines: string[] = []; const focusHint = `${formatShortcut(this.config.focusShortcut)} ${this.focused ? "unfocus" : "focus shell"}`; if (this.state === "detach-dialog") { footerLines.push(row(accent("Session actions:"))); for (const opt of dialogOptions) { const sel = this.dialogSelection === opt.key; footerLines.push(row((sel ? accent("▶ ") : " ") + (sel ? accent(opt.label) : opt.label))); } footerLines.push(row(dim("↑↓ select • Enter confirm • Esc cancel"))); } else if (this.state === "exited") { const exitMsg = this.session.exitCode === 0 ? th.fg("success", "✓ Exited successfully") : warning(`✗ Exited with code ${this.session.exitCode}`); footerLines.push(row(exitMsg)); footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close) • ${focusHint}`))); } else if (this.state === "hands-free") { if (this.focused) { footerLines.push(row(dim(`🤖 Agent controlling • Type to take over • Ctrl+T transfer • Ctrl+B background • ${focusHint}`))); } else { footerLines.push(row(dim(`🤖 Agent controlling • ${focusHint}`))); } } else if (!this.focused) { footerLines.push(row(dim(focusHint))); } else if (this.userTookOver) { footerLines.push(row(dim(`Ctrl+G agent • Ctrl+T transfer • Ctrl+B background • Ctrl+Q menu • ${focusHint}`))); } else { footerLines.push(row(dim(`Ctrl+T transfer • Ctrl+B background • Ctrl+Q menu • Shift+Up/Down scroll • ${focusHint}`))); } while (footerLines.length < footerHeight) { footerLines.push(emptyRow()); } lines.push(...footerLines); lines.push(border(borderGlyphs.bottomLeft + borderGlyphs.horizontal.repeat(width - 2) + borderGlyphs.bottomRight)); return lines; } invalidate(): void { this.lastWidth = 0; this.lastHeight = 0; } dispose(): void { this.stopCountdown(); this.stopTimeout(); this.stopHandsFreeUpdates(); if (this.renderTimeout) { clearTimeout(this.renderTimeout); this.renderTimeout = null; } // Safety cleanup in case dispose() is called without going through finishWith* // If session hasn't completed yet, kill it to prevent orphaned processes if (!this.completionResult) { this.session.kill(); this.session.dispose(); this.unregisterActiveSession(true); } else if (this.options.streamingMode) { // Streaming mode already delivered result via tool return, safe to clean up this.unregisterActiveSession(true); } // Non-blocking mode with completion: keep registered so agent can query } }