fix: bash mode input locking, ESC cancellation, and no timeout (#642)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -787,6 +787,10 @@ export default function App({
|
||||
>(null);
|
||||
const toolAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Bash mode state - track running commands for input locking and ESC cancellation
|
||||
const [bashRunning, setBashRunning] = useState(false);
|
||||
const bashAbortControllerRef = useRef<AbortController | null>(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}
|
||||
|
||||
@@ -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 && (
|
||||
<StreamingOutputDisplay streaming={line.streaming} />
|
||||
<StreamingOutputDisplay
|
||||
streaming={line.streaming}
|
||||
showInterruptHint
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Full output after completion (no collapse for bash mode) */}
|
||||
{line.phase === "finished" && line.output && (
|
||||
<CollapsedOutputDisplay output={line.output} maxLines={Infinity} />
|
||||
)}
|
||||
{line.phase === "finished" &&
|
||||
line.output &&
|
||||
(line.output === INTERRUPTED_BY_USER ? (
|
||||
// Red styling for interrupted commands (LET-7199)
|
||||
<Box flexDirection="row">
|
||||
<Box width={5} flexShrink={0}>
|
||||
<Text>{" ⎿ "}</Text>
|
||||
</Box>
|
||||
<Text color={colors.status.interrupt}>{INTERRUPTED_BY_USER}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<CollapsedOutputDisplay output={line.output} maxLines={Infinity} />
|
||||
))}
|
||||
|
||||
{/* Fallback: show output when phase is undefined (legacy bash commands before streaming) */}
|
||||
{!line.phase && line.output && (
|
||||
|
||||
@@ -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<void>;
|
||||
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]);
|
||||
|
||||
@@ -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 (
|
||||
<Box>
|
||||
<Text dimColor>{` ⎿ Running... (${elapsed}s)`}</Text>
|
||||
<Text
|
||||
dimColor
|
||||
>{` ⎿ Running... (${elapsed}s)${interruptHint}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +64,7 @@ export const StreamingOutputDisplay = memo(
|
||||
{/* Hidden count + elapsed time */}
|
||||
{hiddenCount > 0 && (
|
||||
<Text dimColor>
|
||||
{" "}… +{hiddenCount} more lines ({elapsed}s)
|
||||
{" "}… +{hiddenCount} more lines ({elapsed}s){interruptHint}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -42,10 +42,13 @@ export function spawnWithLauncher(
|
||||
let timedOut = false;
|
||||
let killTimer: ReturnType<typeof setTimeout> | 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;
|
||||
|
||||
Reference in New Issue
Block a user