From 60f56ae54bcabbb7174d94eb6cf09ca838fc4246 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 8 May 2026 20:01:42 -0400 Subject: [PATCH 1/7] feat(Chat): interrupt agent execution with Ctrl+C or Esc key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Esc or Ctrl+C while the agent is streaming or executing a tool cancels the in-flight operation, commits any partial assistant content already accumulated, shows a UI-only interrupt notice, injects a user-role `turn_aborted` message into history, and returns to the idle input state. Two things happen on interrupt: 1. **UI notice** (display-only, not in history): > `❗ Execution interrupted.` > Rendered via a transient `wasInterrupted` boolean state; disappears on next submit. 2. **History injection** (model sees it, as a `user` role message): ``` The user interrupted the previous turn on purpose. Any running commands may still be running in the background. If any tools were aborted, they may have partially executed. ``` Appended to `messages` state so the model knows not to assume success. In `handleToolApproval` `DECISION.REJECT` case: replace the current `system`-role `"User declined to execute tool {name}"` message with a `user`-role `` message (same text as the interrupt). --- src/components/Chat/Chat.test.tsx | 94 +++++++++++++++++++++++++++++- src/components/Chat/Chat.tsx | 84 ++++++++++++++++++++++---- src/components/Chat/Input.test.tsx | 30 ++++++++++ src/components/Chat/Input.tsx | 19 ++++-- src/constants/ui.ts | 2 +- src/utils/ollama.ts | 32 ++++++++-- 6 files changed, 239 insertions(+), 22 deletions(-) diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index 926a94a2..258e3db9 100644 --- a/src/components/Chat/Chat.test.tsx +++ b/src/components/Chat/Chat.test.tsx @@ -78,15 +78,27 @@ vi.mock('../../utils', async () => ({ }, })); +const interruptState = vi.hoisted(() => ({ + handler: undefined as (() => void) | undefined, + clear() { + this.handler = undefined; + }, +})); + vi.mock('./Input', () => ({ Input: (props: { onSubmit?: (value: string) => void; + onInterrupt?: () => void; isDisabled?: boolean; }) => { if (props.onSubmit) { mockState.handler = props.onSubmit; } + if (props.onInterrupt) { + interruptState.handler = props.onInterrupt; + } + if (props.isDisabled) { return null; } @@ -134,12 +146,17 @@ async function waitForStream() { await time.tick(10); } +function fireInterrupt() { + interruptState.handler?.(); +} + function resetChatMocks() { vi.restoreAllMocks(); vi.clearAllMocks(); mockState.clear(); planApprovalState.clear(); toolApprovalState.clear(); + interruptState.clear(); tools.TOOLS.splice(0, tools.TOOLS.length); vi.mocked(ollama.streamChat).mockImplementation(async function* () { await Promise.resolve(); @@ -312,6 +329,7 @@ describe('Chat', () => { expect.any(Array), 'llama3', expect.any(Array), + expect.any(AbortSignal), ); }); @@ -1021,8 +1039,8 @@ describe('Chat with tool calls', () => { await waitForStream(); rerender(chat); - // Should show rejection message - expect(lastFrame()).toContain('declined'); + // Should show turn_aborted message (user-role rejection) + expect(lastFrame()).toContain('turn_aborted'); }); it('handles tool approval acceptance', async () => { @@ -1235,3 +1253,75 @@ describe('Chat with error', () => { expect(lastFrame()).toContain('Error: Plan generation crashed'); }); }); + +describe('Chat interrupt', () => { + beforeEach(() => { + resetChatMocks(); + }); + + it('shows interrupt notice and turn_aborted message when interrupted during streaming', async () => { + vi.mocked(ollama.streamChat).mockImplementation(async function* () { + yield { type: 'content', content: 'Partial' }; + await new Promise(() => undefined); + }); + + const chat = ( + + ); + const { lastFrame, rerender } = render(chat); + submitInput('hello'); + rerender(chat); + await time.tick(); + + fireInterrupt(); + rerender(chat); + await time.tick(); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('❗ Execution interrupted'); + expect(frame).toContain('turn_aborted'); + expect(frame).toContain('>'); + }); + + it('clears interrupt notice on next submit', async () => { + vi.mocked(ollama.streamChat).mockImplementation(async function* () { + yield { type: 'content', content: 'Partial' }; + await new Promise(() => undefined); + }); + + const chat = ( + + ); + const { lastFrame, rerender } = render(chat); + submitInput('hello'); + rerender(chat); + await time.tick(); + + fireInterrupt(); + rerender(chat); + await time.tick(); + expect(lastFrame()).toContain('❗ Execution interrupted'); + + vi.mocked(ollama.streamChat).mockImplementation(async function* () { + await Promise.resolve(); + yield { type: 'content', content: 'New response' }; + }); + submitInput('continue'); + rerender(chat); + await waitForStream(); + + expect(lastFrame()).not.toContain('❗ Execution interrupted'); + }); +}); diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 38c279a0..0ef64407 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -1,5 +1,5 @@ -import { Box } from 'ink'; -import { useCallback, useEffect, useState } from 'react'; +import { Box, Text } from 'ink'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { DECISION, MODE, PROMPT, ROLE } from '../../constants'; import { agents, ollama, tools } from '../../utils'; @@ -39,6 +39,8 @@ export function Chat({ planContent: string; messages: ollama.Message[]; } | null>(null); + const [wasInterrupted, setWasInterrupted] = useState(false); + const abortControllerRef = useRef(null); useEffect(() => { setMessages([]); @@ -46,6 +48,7 @@ export function Chat({ setIsLoading(false); setPendingToolCall(null); setPendingPlan(null); + setWasInterrupted(false); }, [sessionId]); const buildToolResultMessage = useCallback( @@ -84,11 +87,31 @@ export function Chat({ [], ); + const TURN_ABORTED_MESSAGE = [ + '', + 'The user interrupted the previous turn on purpose. Any running commands may still be running in the background. If any tools were aborted, they may have partially executed.', + '', + ].join('\n'); + + const handleInterrupt = useCallback(() => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + setIsLoading(false); + setStreamingMessage(null); + setWasInterrupted(true); + setMessages((prev) => [ + ...prev, + { role: ROLE.USER, content: TURN_ABORTED_MESSAGE }, + ]); + }, [TURN_ABORTED_MESSAGE]); + const processStream = useCallback( async ( currentMessages: ollama.Message[], executionMode: MODE.Name = mode, ) => { + const controller = new AbortController(); + abortControllerRef.current = controller; const assistantMessage: ollama.Message = { role: ROLE.ASSISTANT, content: '', @@ -129,7 +152,12 @@ export function Chat({ agents.withSystemMessage(currentMessages), model, tools.TOOLS, + controller.signal, )) { + // v8 ignore next 3 + if (controller.signal.aborted) { + return; + } if (chunk.type === 'content') { assistantMessage.content += chunk.content; setStreamingMessage({ ...assistantMessage }); @@ -182,9 +210,14 @@ export function Chat({ commitAssistantMessage(); } catch (error) { // v8 ignore next - assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`; - commitAssistantMessage(); + if (!controller.signal.aborted) { + assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`; + commitAssistantMessage(); + } } finally { + if (abortControllerRef.current === controller) { + abortControllerRef.current = null; + } setIsLoading(false); } }, @@ -194,10 +227,14 @@ export function Chat({ // Process stream with only read-only tools (for plan mode research phase) const processStreamReadOnly = useCallback( async (currentMessages: ollama.Message[]) => { + const controller = new AbortController(); + abortControllerRef.current = controller; + const assistantMessage: ollama.Message = { role: ROLE.ASSISTANT, content: '', }; + let committedMessages = currentMessages; let assistantCommitted = false; @@ -239,7 +276,12 @@ export function Chat({ agents.withSystemMessage(currentMessages), model, readOnlyTools, + controller.signal, )) { + // v8 ignore next 3 + if (controller.signal.aborted) { + return; + } if (chunk.type === 'content') { assistantMessage.content += chunk.content; setStreamingMessage({ ...assistantMessage }); @@ -310,7 +352,12 @@ export function Chat({ agents.withSystemMessage(planMessages), model, [], // No tools during plan generation output + controller.signal, )) { + // v8 ignore next 3 + if (controller.signal.aborted) { + return; + } if (chunk.type === 'content') { planAssistantMessage.content += chunk.content; setStreamingMessage({ ...planAssistantMessage }); @@ -346,9 +393,14 @@ export function Chat({ setIsLoading(false); } catch (error) { // v8 ignore next - assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`; - commitAssistantMessage(); + if (!controller.signal.aborted) { + assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`; + commitAssistantMessage(); + } } finally { + if (abortControllerRef.current === controller) { + abortControllerRef.current = null; + } setIsLoading(false); } }, @@ -431,8 +483,8 @@ export function Chat({ case DECISION.REJECT: { const rejectionMessage: ollama.Message = { - role: ROLE.SYSTEM, - content: `User declined to execute tool ${toolCall.function.name}`, + role: ROLE.USER, + content: TURN_ABORTED_MESSAGE, }; const newMessages = [...messages, rejectionMessage]; @@ -451,7 +503,9 @@ export function Chat({ const handleSubmit = useCallback( async (value: string) => { + setWasInterrupted(false); const userContent = value.trim(); + if (!userContent) { return; } @@ -504,9 +558,19 @@ export function Chat({ /> )} + {wasInterrupted && !isLoading && ( + + ❗ Execution interrupted. + + )} + {!pendingPlan && !pendingToolCall && ( - // eslint-disable-next-line @typescript-eslint/no-misused-promises - + )} ); diff --git a/src/components/Chat/Input.test.tsx b/src/components/Chat/Input.test.tsx index 22a9e4de..4cf2ebcf 100644 --- a/src/components/Chat/Input.test.tsx +++ b/src/components/Chat/Input.test.tsx @@ -480,6 +480,36 @@ describe('Input', () => { expect(onSubmit).not.toHaveBeenCalled(); }); + it('calls onInterrupt on Ctrl+C when disabled', async () => { + const onInterrupt = vi.fn(); + const { stdin } = render( + , + ); + stdin.write(KEY.CTRL_C); + await time.tick(); + expect(onInterrupt).toHaveBeenCalledOnce(); + }); + + it('calls onInterrupt on Esc when disabled', async () => { + const onInterrupt = vi.fn(); + const { stdin } = render( + , + ); + stdin.write(KEY.ESCAPE); + await time.tick(20); + expect(onInterrupt).toHaveBeenCalledOnce(); + }); + + it('does not call onInterrupt when not disabled', async () => { + const onInterrupt = vi.fn(); + const { stdin } = render( + , + ); + stdin.write(KEY.ESCAPE); + await time.tick(); + expect(onInterrupt).not.toHaveBeenCalled(); + }); + it('ignores file suggestion interactions when disabled', async () => { const onSubmit = vi.fn(); const { lastFrame, stdin } = render( diff --git a/src/components/Chat/Input.tsx b/src/components/Chat/Input.tsx index b456b7bf..fd10c9ad 100644 --- a/src/components/Chat/Input.tsx +++ b/src/components/Chat/Input.tsx @@ -9,6 +9,7 @@ import { FileSuggestions } from './FileSuggestions'; interface Props { isDisabled?: boolean; + onInterrupt?: () => void; onSubmit: (value: string) => void; } @@ -17,7 +18,7 @@ function hasFileSuggestionQuery(input: string): boolean { return /(^|\s)@\S+$/.test(input); } -export function Input({ isDisabled = false, onSubmit }: Props) { +export function Input({ isDisabled = false, onInterrupt, onSubmit }: Props) { const { exit } = useApp(); const [input, setInput] = useState(''); const [inputKey, setInputKey] = useState(0); @@ -88,13 +89,23 @@ export function Input({ isDisabled = false, onSubmit }: Props) { ); useInput((_input, key) => { - if (key.ctrl && _input === 'c') { + const isCtrlC = key.ctrl && _input === 'c'; + + if (isDisabled) { + if (key.escape || isCtrlC) { + onInterrupt?.(); + } + return; + } + + if (isCtrlC) { if (input) { setInput(''); remountTextInput(); - } else { - exit(); + return; } + + exit(); } }); diff --git a/src/constants/ui.ts b/src/constants/ui.ts index 51e3df6c..6a7bff4e 100644 --- a/src/constants/ui.ts +++ b/src/constants/ui.ts @@ -1,2 +1,2 @@ -export const HEADER_PREFIX = '🦙'; +export const HEADER_PREFIX = '🦙 '; export const PROMPT_PREFIX = '> '; diff --git a/src/utils/ollama.ts b/src/utils/ollama.ts index 71a3f9df..fc40e6fd 100644 --- a/src/utils/ollama.ts +++ b/src/utils/ollama.ts @@ -31,21 +31,43 @@ export async function* streamChat( messages: Message[], model: string = DEFAULT_MODEL, tools?: Tool[], + signal?: AbortSignal, ): AsyncGenerator { const response = await client.chat({ model, messages, stream: true, tools, + // v8 ignore next + ...(signal ? { signal } : {}), }); - for await (const chunk of response) { - if (chunk.message.content) { - yield { type: 'content', content: chunk.message.content }; + try { + for await (const chunk of response) { + // v8 ignore next 3 + if (signal?.aborted) { + return; + } + + if (chunk.message.content) { + yield { type: 'content', content: chunk.message.content }; + } + + if (chunk.message.tool_calls) { + yield { type: 'tool_calls', tool_calls: chunk.message.tool_calls }; + } } - if (chunk.message.tool_calls) { - yield { type: 'tool_calls', tool_calls: chunk.message.tool_calls }; + } catch (error) { + // v8 ignore start + if ( + error instanceof Error && + (error.name === 'AbortError' || signal?.aborted) + ) { + return; } + + throw error; + // v8 ignore stop } } From a007bd5853fbd81572b4d35171a22a9ef4aa42a2 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 8 May 2026 20:10:42 -0400 Subject: [PATCH 2/7] fix(Chat): don't show turn aborted message --- src/components/Chat/Chat.test.tsx | 10 +++++++--- src/components/Chat/Chat.tsx | 9 ++------- src/components/Chat/constants.ts | 6 ++++++ src/components/Messages.test.tsx | 10 ++++++++++ src/components/Messages.tsx | 15 +++++++++------ 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index 258e3db9..52967af2 100644 --- a/src/components/Chat/Chat.test.tsx +++ b/src/components/Chat/Chat.test.tsx @@ -1039,8 +1039,12 @@ describe('Chat with tool calls', () => { await waitForStream(); rerender(chat); - // Should show turn_aborted message (user-role rejection) - expect(lastFrame()).toContain('turn_aborted'); + expect(lastFrame()).not.toContain('Tool requires approval'); + const calls = vi.mocked(ollama.streamChat).mock.calls; + const lastCallMessages = calls[calls.length - 1][0]; + expect( + lastCallMessages.some((m) => m.content.includes('')), + ).toBe(true); }); it('handles tool approval acceptance', async () => { @@ -1285,7 +1289,7 @@ describe('Chat interrupt', () => { const frame = lastFrame() ?? ''; expect(frame).toContain('❗ Execution interrupted'); - expect(frame).toContain('turn_aborted'); + expect(frame).not.toContain('turn_aborted'); expect(frame).toContain('>'); }); diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 0ef64407..5539b305 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -10,6 +10,7 @@ import { ACTION_NOT_PERFORMED, PLAN_CHECKLIST_REMINDER, PLAN_EXECUTION_REMINDER, + TURN_ABORTED_MESSAGE, } from './constants'; import { Input } from './Input'; import { hasExecutablePlan } from './plan'; @@ -87,12 +88,6 @@ export function Chat({ [], ); - const TURN_ABORTED_MESSAGE = [ - '', - 'The user interrupted the previous turn on purpose. Any running commands may still be running in the background. If any tools were aborted, they may have partially executed.', - '', - ].join('\n'); - const handleInterrupt = useCallback(() => { abortControllerRef.current?.abort(); abortControllerRef.current = null; @@ -103,7 +98,7 @@ export function Chat({ ...prev, { role: ROLE.USER, content: TURN_ABORTED_MESSAGE }, ]); - }, [TURN_ABORTED_MESSAGE]); + }, []); const processStream = useCallback( async ( diff --git a/src/components/Chat/constants.ts b/src/components/Chat/constants.ts index b0acc115..8777828b 100644 --- a/src/components/Chat/constants.ts +++ b/src/components/Chat/constants.ts @@ -1,5 +1,11 @@ export const ACTION_NOT_PERFORMED = 'The requested action was NOT performed'; +export const TURN_ABORTED_MESSAGE = [ + '', + 'The user interrupted the previous turn on purpose. Any running commands may still be running in the background. If any tools were aborted, they may have partially executed.', + '', +].join('\n'); + export const PLAN_CHECKLIST_REMINDER = 'Then display the execution plan as an unchecked Markdown checklist only'; diff --git a/src/components/Messages.test.tsx b/src/components/Messages.test.tsx index 6c849b79..dc54e11f 100644 --- a/src/components/Messages.test.tsx +++ b/src/components/Messages.test.tsx @@ -2,6 +2,7 @@ import { Text } from 'ink'; import { render } from 'ink-testing-library'; import { ROLE, UI } from '../constants'; +import { TURN_ABORTED_MESSAGE } from './Chat/constants'; import { Messages } from './Messages'; vi.mock('@inkjs/ui', () => ({ @@ -77,6 +78,15 @@ describe('Messages', () => { expect(lastFrame()).toContain('test'); }); + it('does not render turn_aborted messages', () => { + const abortedMessage = { role: ROLE.USER, content: TURN_ABORTED_MESSAGE }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('hello'); + expect(lastFrame()).not.toContain('turn_aborted'); + }); + it('renders a streaming message after committed messages', () => { const { lastFrame } = render( - {messages.map((message, index) => ( - - ))} + {messages + .filter((message) => message.content !== TURN_ABORTED_MESSAGE) + .map((message, index) => ( + + ))} {streamingMessage && } From e26067b5228d1d70d724407532633af0ee762ac2 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 8 May 2026 20:14:25 -0400 Subject: [PATCH 3/7] refactor(components): move Messages to its own directory --- src/components/{ => Messages}/Messages.test.tsx | 6 +++--- src/components/{ => Messages}/Messages.tsx | 6 +++--- src/components/Messages/index.ts | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) rename src/components/{ => Messages}/Messages.test.tsx (95%) rename src/components/{ => Messages}/Messages.tsx (91%) create mode 100644 src/components/Messages/index.ts diff --git a/src/components/Messages.test.tsx b/src/components/Messages/Messages.test.tsx similarity index 95% rename from src/components/Messages.test.tsx rename to src/components/Messages/Messages.test.tsx index dc54e11f..aec3dab3 100644 --- a/src/components/Messages.test.tsx +++ b/src/components/Messages/Messages.test.tsx @@ -1,8 +1,8 @@ import { Text } from 'ink'; import { render } from 'ink-testing-library'; -import { ROLE, UI } from '../constants'; -import { TURN_ABORTED_MESSAGE } from './Chat/constants'; +import { ROLE, UI } from '../../constants'; +import { TURN_ABORTED_MESSAGE } from '../Chat/constants'; import { Messages } from './Messages'; vi.mock('@inkjs/ui', () => ({ @@ -71,7 +71,7 @@ describe('Messages', () => { const unknownMessage = { role: 'unknown', content: 'test', - } as unknown as import('../utils/ollama').Message; + } as unknown as import('../../utils/ollama').Message; const { lastFrame } = render( , ); diff --git a/src/components/Messages.tsx b/src/components/Messages/Messages.tsx similarity index 91% rename from src/components/Messages.tsx rename to src/components/Messages/Messages.tsx index e8c240e0..a2af80af 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages/Messages.tsx @@ -2,9 +2,9 @@ import { Spinner } from '@inkjs/ui'; import { Box, Text } from 'ink'; import { memo } from 'react'; -import { ROLE, UI } from '../constants'; -import type { ollama } from '../utils'; -import { TURN_ABORTED_MESSAGE } from './Chat/constants'; +import { ROLE, UI } from '../../constants'; +import type { ollama } from '../../utils'; +import { TURN_ABORTED_MESSAGE } from '../Chat/constants'; interface Props { messages: ollama.Message[]; diff --git a/src/components/Messages/index.ts b/src/components/Messages/index.ts new file mode 100644 index 00000000..67ba5822 --- /dev/null +++ b/src/components/Messages/index.ts @@ -0,0 +1 @@ +export { Messages } from './Messages'; From 119c369431e46f33202856d9bb1170d1916dd350 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 8 May 2026 20:16:35 -0400 Subject: [PATCH 4/7] refactor(components): move constant from Chat to Messages --- src/components/Chat/Chat.tsx | 2 +- src/components/Chat/constants.ts | 6 ------ src/components/Messages/Messages.test.tsx | 2 +- src/components/Messages/Messages.tsx | 2 +- src/components/Messages/constants.ts | 5 +++++ 5 files changed, 8 insertions(+), 9 deletions(-) create mode 100644 src/components/Messages/constants.ts diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 5539b305..298680bd 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -4,13 +4,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { DECISION, MODE, PROMPT, ROLE } from '../../constants'; import { agents, ollama, tools } from '../../utils'; import { Messages } from '../Messages'; +import { TURN_ABORTED_MESSAGE } from '../Messages/constants'; import { PlanApproval } from '../PlanApproval'; import { ToolApproval } from '../ToolApproval'; import { ACTION_NOT_PERFORMED, PLAN_CHECKLIST_REMINDER, PLAN_EXECUTION_REMINDER, - TURN_ABORTED_MESSAGE, } from './constants'; import { Input } from './Input'; import { hasExecutablePlan } from './plan'; diff --git a/src/components/Chat/constants.ts b/src/components/Chat/constants.ts index 8777828b..b0acc115 100644 --- a/src/components/Chat/constants.ts +++ b/src/components/Chat/constants.ts @@ -1,11 +1,5 @@ export const ACTION_NOT_PERFORMED = 'The requested action was NOT performed'; -export const TURN_ABORTED_MESSAGE = [ - '', - 'The user interrupted the previous turn on purpose. Any running commands may still be running in the background. If any tools were aborted, they may have partially executed.', - '', -].join('\n'); - export const PLAN_CHECKLIST_REMINDER = 'Then display the execution plan as an unchecked Markdown checklist only'; diff --git a/src/components/Messages/Messages.test.tsx b/src/components/Messages/Messages.test.tsx index aec3dab3..870374a4 100644 --- a/src/components/Messages/Messages.test.tsx +++ b/src/components/Messages/Messages.test.tsx @@ -2,7 +2,7 @@ import { Text } from 'ink'; import { render } from 'ink-testing-library'; import { ROLE, UI } from '../../constants'; -import { TURN_ABORTED_MESSAGE } from '../Chat/constants'; +import { TURN_ABORTED_MESSAGE } from './constants'; import { Messages } from './Messages'; vi.mock('@inkjs/ui', () => ({ diff --git a/src/components/Messages/Messages.tsx b/src/components/Messages/Messages.tsx index a2af80af..32c95460 100644 --- a/src/components/Messages/Messages.tsx +++ b/src/components/Messages/Messages.tsx @@ -4,7 +4,7 @@ import { memo } from 'react'; import { ROLE, UI } from '../../constants'; import type { ollama } from '../../utils'; -import { TURN_ABORTED_MESSAGE } from '../Chat/constants'; +import { TURN_ABORTED_MESSAGE } from './constants'; interface Props { messages: ollama.Message[]; diff --git a/src/components/Messages/constants.ts b/src/components/Messages/constants.ts new file mode 100644 index 00000000..99e27bd7 --- /dev/null +++ b/src/components/Messages/constants.ts @@ -0,0 +1,5 @@ +export const TURN_ABORTED_MESSAGE = [ + '', + 'The user interrupted the previous turn on purpose. Any running commands may still be running in the background. If any tools were aborted, they may have partially executed.', + '', +].join('\n'); From 07f9fe924b1cd12df2f7da9ee3bcf81a1eeebf22 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 8 May 2026 20:22:16 -0400 Subject: [PATCH 5/7] refactor(components): tidy Messages --- src/components/Messages/Messages.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/Messages/Messages.tsx b/src/components/Messages/Messages.tsx index 32c95460..8818e2b2 100644 --- a/src/components/Messages/Messages.tsx +++ b/src/components/Messages/Messages.tsx @@ -25,18 +25,18 @@ function getMessageColor(role: string): string | undefined { } } -interface MessageRowProps { +interface MessageProps { message: ollama.Message; } -const MessageRow = memo(function MessageRow({ message }: MessageRowProps) { +const Message = memo(function Message({ message }: MessageProps) { return ( - {message.role === ROLE.USER ? UI.PROMPT_PREFIX : ''} + {message.role === ROLE.USER && UI.PROMPT_PREFIX} {message.content} @@ -47,15 +47,12 @@ export function Messages({ messages, isLoading, streamingMessage }: Props) { return ( {messages - .filter((message) => message.content !== TURN_ABORTED_MESSAGE) + .filter(({ content }) => content !== TURN_ABORTED_MESSAGE) .map((message, index) => ( - + ))} - {streamingMessage && } + {streamingMessage && } {isLoading && !streamingMessage?.content && ( From 0aea77cf66870ea68a4752ebd8c591db93984fb1 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 8 May 2026 20:40:50 -0400 Subject: [PATCH 6/7] fix(Chat): don't ask for tool call after rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rejecting a tool call now displays a rejection message and returns directly to idle — no re-stream, no repeated approval prompt. --- src/components/Chat/Chat.test.tsx | 8 +++----- src/components/Chat/Chat.tsx | 33 +++++++++++++++++-------------- src/components/Chat/constants.ts | 5 +++++ 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index 52967af2..123e4ecf 100644 --- a/src/components/Chat/Chat.test.tsx +++ b/src/components/Chat/Chat.test.tsx @@ -1040,11 +1040,9 @@ describe('Chat with tool calls', () => { rerender(chat); expect(lastFrame()).not.toContain('Tool requires approval'); - const calls = vi.mocked(ollama.streamChat).mock.calls; - const lastCallMessages = calls[calls.length - 1][0]; - expect( - lastCallMessages.some((m) => m.content.includes('')), - ).toBe(true); + expect(lastFrame()).toContain('❗ Tool call rejected.'); + expect(lastFrame()).toContain('>'); + expect(vi.mocked(ollama.streamChat)).toHaveBeenCalledOnce(); }); it('handles tool approval acceptance', async () => { diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 298680bd..b99e91e6 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -9,6 +9,7 @@ import { PlanApproval } from '../PlanApproval'; import { ToolApproval } from '../ToolApproval'; import { ACTION_NOT_PERFORMED, + INTERRUPT_REASON, PLAN_CHECKLIST_REMINDER, PLAN_EXECUTION_REMINDER, } from './constants'; @@ -33,14 +34,18 @@ export function Chat({ const [messages, setMessages] = useState([]); const [streamingMessage, setStreamingMessage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [pendingToolCall, setPendingToolCall] = useState(null); const [pendingPlan, setPendingPlan] = useState<{ planContent: string; messages: ollama.Message[]; } | null>(null); - const [wasInterrupted, setWasInterrupted] = useState(false); + + const [interruptReason, setInterruptReason] = + useState(null); const abortControllerRef = useRef(null); useEffect(() => { @@ -49,7 +54,7 @@ export function Chat({ setIsLoading(false); setPendingToolCall(null); setPendingPlan(null); - setWasInterrupted(false); + setInterruptReason(null); }, [sessionId]); const buildToolResultMessage = useCallback( @@ -93,7 +98,7 @@ export function Chat({ abortControllerRef.current = null; setIsLoading(false); setStreamingMessage(null); - setWasInterrupted(true); + setInterruptReason(INTERRUPT_REASON.INTERRUPTED); setMessages((prev) => [ ...prev, { role: ROLE.USER, content: TURN_ABORTED_MESSAGE }, @@ -477,18 +482,12 @@ export function Chat({ } case DECISION.REJECT: { - const rejectionMessage: ollama.Message = { - role: ROLE.USER, - content: TURN_ABORTED_MESSAGE, - }; - - const newMessages = [...messages, rejectionMessage]; setMessages((previousMessages) => [ ...previousMessages, - rejectionMessage, + { role: ROLE.USER, content: TURN_ABORTED_MESSAGE }, ]); - - await processStream(newMessages); + setIsLoading(false); + setInterruptReason(INTERRUPT_REASON.REJECTED); break; } } @@ -498,7 +497,7 @@ export function Chat({ const handleSubmit = useCallback( async (value: string) => { - setWasInterrupted(false); + setInterruptReason(null); const userContent = value.trim(); if (!userContent) { @@ -553,9 +552,13 @@ export function Chat({ /> )} - {wasInterrupted && !isLoading && ( + {interruptReason && !isLoading && ( - ❗ Execution interrupted. + + {interruptReason === INTERRUPT_REASON.REJECTED + ? '❗ Tool call rejected.' + : '❗ Execution interrupted.'} + )} diff --git a/src/components/Chat/constants.ts b/src/components/Chat/constants.ts index b0acc115..98604fd1 100644 --- a/src/components/Chat/constants.ts +++ b/src/components/Chat/constants.ts @@ -5,3 +5,8 @@ export const PLAN_CHECKLIST_REMINDER = export const PLAN_EXECUTION_REMINDER = 'Do not claim success and do not call write_file or run_shell until the user approves execution'; + +export enum INTERRUPT_REASON { + INTERRUPTED = 'interrupted', + REJECTED = 'rejected', +} From a1d90eb018db0e8dab860067d752dfa4be667d95 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 8 May 2026 20:52:39 -0400 Subject: [PATCH 7/7] build(vite): remove `external` since SSR mode auto-externalises everything --- vite.config.mts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/vite.config.mts b/vite.config.mts index 47505985..db27cc74 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -10,15 +10,6 @@ export default defineConfig({ output: { entryFileNames: 'cli.js', }, - external: [ - 'cac', - 'node:child_process', - 'node:fs', - 'node:os', - 'node:path', - 'node:util', - 'ollama', - ], }, },