From 8d1ad50142ea0b922b7c9de7976f0b3abf34088a Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 22 Jan 2026 14:55:16 -0800 Subject: [PATCH] fix: bash mode input locking, ESC cancellation, and no timeout (#642) Co-authored-by: Letta --- src/cli/App.tsx | 66 +++++++++++++++---- src/cli/components/BashCommandMessage.tsx | 22 +++++-- src/cli/components/InputRich.tsx | 15 ++++- src/cli/components/StreamingOutputDisplay.tsx | 11 +++- src/tools/impl/shellRunner.ts | 15 +++-- 5 files changed, 104 insertions(+), 25 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index f3a0e11..aae49da 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -787,6 +787,10 @@ export default function App({ >(null); const toolAbortControllerRef = useRef(null); + // Bash mode state - track running commands for input locking and ESC cancellation + const [bashRunning, setBashRunning] = useState(false); + const bashAbortControllerRef = useRef(null); + // Eager approval checking: only enabled when resuming a session (LET-7101) // After first successful message, we disable it since any new approvals are from our own turn const [needsEagerApprovalCheck, setNeedsEagerApprovalCheck] = useState( @@ -3898,11 +3902,19 @@ export default function App({ // Handle bash mode command submission // Expands aliases from shell config files, then runs with spawnCommand + // Implements input locking and ESC cancellation (LET-7199) const handleBashSubmit = useCallback( async (command: string) => { + // Input locking - prevent multiple concurrent bash commands + if (bashRunning) return; + const cmdId = uid("bash"); const startTime = Date.now(); + // Set up state for input locking and cancellation + setBashRunning(true); + bashAbortControllerRef.current = new AbortController(); + // Add running bash_command line with streaming state buffersRef.current.byId.set(cmdId, { kind: "bash_command", @@ -3938,7 +3950,8 @@ export default function App({ const result = await spawnCommand(finalCommand, { cwd: process.cwd(), env: getShellEnv(), - timeout: 30000, // 30 second timeout + timeout: 0, // No timeout - user must ESC to interrupt (LET-7199) + signal: bashAbortControllerRef.current.signal, onOutput: (chunk, stream) => { const entry = buffersRef.current.byId.get(cmdId); if (entry && entry.kind === "bash_command") { @@ -3962,11 +3975,16 @@ export default function App({ const success = result.exitCode === 0; // Update line with output, clear streaming state + const displayOutput = + output || + (success + ? "(Command completed with no output)" + : `Exit code: ${result.exitCode}`); buffersRef.current.byId.set(cmdId, { kind: "bash_command", id: cmdId, input: command, - output: output || (success ? "" : `Exit code: ${result.exitCode}`), + output: displayOutput, phase: "finished", success, streaming: undefined, @@ -3975,16 +3993,29 @@ export default function App({ // Cache for next user message bashCommandCacheRef.current.push({ input: command, - output: output || (success ? "" : `Exit code: ${result.exitCode}`), + output: displayOutput, }); } catch (error: unknown) { - // Handle command errors (timeout, abort, etc.) - const errOutput = - error instanceof Error - ? (error as { stderr?: string; stdout?: string }).stderr || - (error as { stdout?: string }).stdout || - error.message - : String(error); + // Check if this was an abort (user pressed ESC) + const err = error as { name?: string; code?: string; message?: string }; + const isAbort = + bashAbortControllerRef.current?.signal.aborted || + err.code === "ABORT_ERR" || + err.name === "AbortError" || + err.message === "The operation was aborted"; + + let errOutput: string; + if (isAbort) { + errOutput = INTERRUPTED_BY_USER; + } else { + // Handle command errors (timeout, other failures) + errOutput = + error instanceof Error + ? (error as { stderr?: string; stdout?: string }).stderr || + (error as { stdout?: string }).stdout || + error.message + : String(error); + } buffersRef.current.byId.set(cmdId, { kind: "bash_command", @@ -3998,13 +4029,24 @@ export default function App({ // Still cache for next user message (even failures are visible to agent) bashCommandCacheRef.current.push({ input: command, output: errOutput }); + } finally { + // Clean up state + setBashRunning(false); + bashAbortControllerRef.current = null; } refreshDerived(); }, - [refreshDerived, refreshDerivedStreaming], + [bashRunning, refreshDerived, refreshDerivedStreaming], ); + // Handle ESC interrupt for bash mode commands (LET-7199) + const handleBashInterrupt = useCallback(() => { + if (bashAbortControllerRef.current) { + bashAbortControllerRef.current.abort(); + } + }, []); + /** * Check and handle any pending approvals before sending a slash command. * Returns true if approvals need user input (caller should return { submitted: false }). @@ -8878,6 +8920,8 @@ Plan file path: ${planFilePath}`; thinkingMessage={thinkingMessage} onSubmit={onSubmit} onBashSubmit={handleBashSubmit} + bashRunning={bashRunning} + onBashInterrupt={handleBashInterrupt} permissionMode={uiPermissionMode} onPermissionModeChange={handlePermissionModeChange} onExit={handleExit} diff --git a/src/cli/components/BashCommandMessage.tsx b/src/cli/components/BashCommandMessage.tsx index f6817df..93092ce 100644 --- a/src/cli/components/BashCommandMessage.tsx +++ b/src/cli/components/BashCommandMessage.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import { memo } from "react"; +import { INTERRUPTED_BY_USER } from "../../constants"; import type { StreamingState } from "../helpers/accumulator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { BlinkDot } from "./BlinkDot.js"; @@ -60,13 +61,26 @@ export const BashCommandMessage = memo( {/* Streaming output during execution */} {line.phase === "running" && line.streaming && ( - + )} {/* Full output after completion (no collapse for bash mode) */} - {line.phase === "finished" && line.output && ( - - )} + {line.phase === "finished" && + line.output && + (line.output === INTERRUPTED_BY_USER ? ( + // Red styling for interrupted commands (LET-7199) + + + {" ⎿ "} + + {INTERRUPTED_BY_USER} + + ) : ( + + ))} {/* Fallback: show output when phase is undefined (legacy bash commands before streaming) */} {!line.phase && line.output && ( diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 591be64..4124a72 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -122,6 +122,8 @@ export function Input({ thinkingMessage, onSubmit, onBashSubmit, + bashRunning = false, + onBashInterrupt, permissionMode: externalMode, onPermissionModeChange, onExit, @@ -149,6 +151,8 @@ export function Input({ thinkingMessage: string; onSubmit: (message?: string) => Promise<{ submitted: boolean }>; onBashSubmit?: (command: string) => Promise; + bashRunning?: boolean; + onBashInterrupt?: () => void; permissionMode?: PermissionMode; onPermissionModeChange?: (mode: PermissionMode) => void; onExit?: () => void; @@ -289,7 +293,13 @@ export function Input({ if (onEscapeCancel) return; if (key.escape) { - // When streaming, use Esc to interrupt + // When bash command running, use Esc to interrupt (LET-7199) + if (bashRunning && onBashInterrupt) { + onBashInterrupt(); + return; + } + + // When agent streaming, use Esc to interrupt if (streaming && onInterrupt && !interruptRequested) { onInterrupt(); // Don't load queued messages into input - let the dequeue effect @@ -609,6 +619,9 @@ export function Input({ if (isBashMode) { if (!previousValue.trim()) return; + // Input locking - don't accept new commands while one is running (LET-7199) + if (bashRunning) return; + // Add to history if not empty and not a duplicate of the last entry if (previousValue.trim() !== history[history.length - 1]) { setHistory([...history, previousValue]); diff --git a/src/cli/components/StreamingOutputDisplay.tsx b/src/cli/components/StreamingOutputDisplay.tsx index 920cb7c..27f408b 100644 --- a/src/cli/components/StreamingOutputDisplay.tsx +++ b/src/cli/components/StreamingOutputDisplay.tsx @@ -4,6 +4,8 @@ import type { StreamingState } from "../helpers/accumulator"; interface StreamingOutputDisplayProps { streaming: StreamingState; + /** Show "(esc to interrupt)" hint - used by bash mode (LET-7199) */ + showInterruptHint?: boolean; } /** @@ -11,7 +13,7 @@ interface StreamingOutputDisplayProps { * Shows a rolling window of the last 5 lines with elapsed time. */ export const StreamingOutputDisplay = memo( - ({ streaming }: StreamingOutputDisplayProps) => { + ({ streaming, showInterruptHint }: StreamingOutputDisplayProps) => { // Force re-render every second for elapsed timer const [, forceUpdate] = useState(0); useEffect(() => { @@ -24,10 +26,13 @@ export const StreamingOutputDisplay = memo( const hiddenCount = Math.max(0, totalLineCount - tailLines.length); const firstLine = tailLines[0]; + const interruptHint = showInterruptHint ? " (esc to interrupt)" : ""; if (!firstLine) { return ( - {` ⎿ Running... (${elapsed}s)`} + {` ⎿ Running... (${elapsed}s)${interruptHint}`} ); } @@ -59,7 +64,7 @@ export const StreamingOutputDisplay = memo( {/* Hidden count + elapsed time */} {hiddenCount > 0 && ( - {" "}… +{hiddenCount} more lines ({elapsed}s) + {" "}… +{hiddenCount} more lines ({elapsed}s){interruptHint} )} diff --git a/src/tools/impl/shellRunner.ts b/src/tools/impl/shellRunner.ts index 40272b8..48138a2 100644 --- a/src/tools/impl/shellRunner.ts +++ b/src/tools/impl/shellRunner.ts @@ -42,10 +42,13 @@ export function spawnWithLauncher( let timedOut = false; let killTimer: ReturnType | null = null; - const timeoutId = setTimeout(() => { - timedOut = true; - childProcess.kill("SIGTERM"); - }, options.timeoutMs); + // Only set timeout if timeoutMs > 0 (0 means no timeout) + const timeoutId = options.timeoutMs + ? setTimeout(() => { + timedOut = true; + childProcess.kill("SIGTERM"); + }, options.timeoutMs) + : null; const abortHandler = () => { childProcess.kill("SIGTERM"); @@ -72,7 +75,7 @@ export function spawnWithLauncher( }); childProcess.on("error", (err: NodeJS.ErrnoException) => { - clearTimeout(timeoutId); + if (timeoutId) clearTimeout(timeoutId); if (killTimer) { clearTimeout(killTimer); killTimer = null; @@ -92,7 +95,7 @@ export function spawnWithLauncher( }); childProcess.on("close", (code) => { - clearTimeout(timeoutId); + if (timeoutId) clearTimeout(timeoutId); if (killTimer) { clearTimeout(killTimer); killTimer = null;