From 8aef1c79f310d56fdaaa3af10ac547922c4395e6 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Tue, 17 Feb 2026 18:52:54 -0800 Subject: [PATCH] Add escape key to cancel agent response When the agent is streaming a response, users can press Escape to stop it. The UI shows "[esc] to cancel" hint while streaming. If cancelled, any partial response is preserved and a red "Response cancelled." message is added. Co-Authored-By: Claude Haiku 4.5 --- components/App.tsx | 47 ++++++++++++++++++++++++++++++++++++++ components/InputBox.tsx | 9 ++++++++ components/MessageArea.tsx | 4 ++++ 3 files changed, 60 insertions(+) diff --git a/components/App.tsx b/components/App.tsx index 08f9048..1e1320e 100644 --- a/components/App.tsx +++ b/components/App.tsx @@ -4,6 +4,7 @@ import type { MCPServerStreamableHttp } from "@openai/agents"; import type { GeneralAgent } from "../agents/general.js"; import type { Logger, LogEvent } from "../classes/logger.js"; import type { Config } from "../classes/config.js"; +import chalk from "chalk"; import { renderMarkdown } from "../utils/markdown.js"; import { MessageArea } from "./MessageArea.js"; import { InputBox } from "./InputBox.js"; @@ -49,6 +50,7 @@ export function App({ const processingRef = useRef(false); const queueRef = useRef([]); const [queueCount, setQueueCount] = useState(0); + const abortRef = useRef(null); const initialProcessed = useRef(false); const [focusArea, setFocusArea] = useState("input"); const [scrollX, setScrollX] = useState(0); @@ -105,6 +107,12 @@ export function App({ setFocusArea("input"); }, []); + const handleCancel = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort(); + } + }, []); + // Subscribe to logger events useEffect(() => { const unsubLog = logger.onLog((event: LogEvent) => { @@ -147,6 +155,9 @@ export function App({ setToolCallCount(0); setStartTime(Date.now()); + const abort = new AbortController(); + abortRef.current = abort; + try { const stream = await agent.chat(input, [mcpServer]); const textStream = stream.toTextStream({ @@ -155,10 +166,43 @@ export function App({ let accumulated = ""; for await (const chunk of textStream) { + if (abort.signal.aborted) break; accumulated += chunk; setStreamingText(accumulated); } + if (abort.signal.aborted) { + const cancelMsg = chalk.red("Response cancelled."); + if (accumulated) { + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: accumulated, + rendered: renderMarkdown(accumulated), + timestamp: logger.getTimestamp(), + }, + { + role: "assistant", + content: cancelMsg, + rendered: cancelMsg, + timestamp: logger.getTimestamp(), + }, + ]); + } else { + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: cancelMsg, + rendered: cancelMsg, + timestamp: logger.getTimestamp(), + }, + ]); + } + return; + } + const finalOutput = await agent.finalizeStream(stream); const content = finalOutput || accumulated; @@ -183,6 +227,7 @@ export function App({ }, ]); } finally { + abortRef.current = null; setIsStreaming(false); setStreamingText(""); setStartTime(null); @@ -269,10 +314,12 @@ export function App({ contextDir={config.context_dir} queueCount={queueCount} focus={focusArea === "input"} + isStreaming={isStreaming} onToggleFocus={handleToggleFocus} onScroll={handleScroll} onScrollVertical={handleScrollVertical} onReturnToInput={handleReturnToInput} + onCancel={handleCancel} /> ); diff --git a/components/InputBox.tsx b/components/InputBox.tsx index 51d1efb..d344b03 100644 --- a/components/InputBox.tsx +++ b/components/InputBox.tsx @@ -9,10 +9,12 @@ interface InputBoxProps { contextDir: string; queueCount: number; focus: boolean; + isStreaming: boolean; onToggleFocus: () => void; onScroll: (direction: "left" | "right") => void; onScrollVertical: (direction: "up" | "down") => void; onReturnToInput: () => void; + onCancel: () => void; } const DELIM = "\0"; @@ -36,10 +38,12 @@ export function InputBox({ contextDir, queueCount, focus, + isStreaming, onToggleFocus, onScroll, onScrollVertical, onReturnToInput, + onCancel, }: InputBoxProps) { const [value, setValue] = useState(""); const historyFile = path.join(contextDir, "chat_history.txt"); @@ -63,6 +67,11 @@ export function InputBox({ ); useInput((input, key) => { + if (key.escape && isStreaming) { + onCancel(); + return; + } + if (key.tab) { onToggleFocus(); return; diff --git a/components/MessageArea.tsx b/components/MessageArea.tsx index cd4d2da..0d5ac3d 100644 --- a/components/MessageArea.tsx +++ b/components/MessageArea.tsx @@ -94,6 +94,10 @@ export function MessageArea({ ` 🕝 ${Math.round((Date.now() - startTime) / 1000)}s`} {toolCallCount > 0 && ` | 🛠️ ${toolCallCount} tool call${toolCallCount > 1 ? "s" : ""}`} + {" | "} + + + [esc] to cancel {streamingText && {renderMarkdown(streamingText)}}