diff --git a/src/components/App.test.tsx b/src/components/App.test.tsx index 13d48e1c..a75efc16 100644 --- a/src/components/App.test.tsx +++ b/src/components/App.test.tsx @@ -1,9 +1,10 @@ import { Text } from 'ink'; import { render } from 'ink-testing-library'; -import { tick } from '../utils/test'; +import { test } from '../utils'; -vi.mock('../utils', () => ({ +vi.mock('../utils', async () => ({ + ...(await vi.importActual('../utils')), config: { loadConfig: vi.fn(() => ({ host: 'http://localhost:11434', @@ -97,7 +98,7 @@ describe('App', () => { const { lastFrame, rerender } = render(); capturedCallbacks.onCommand?.('/model'); rerender(); - await tick(); + await test.tick(); expect(lastFrame()).toContain('ModelPicker'); }); @@ -105,10 +106,10 @@ describe('App', () => { const { lastFrame, rerender } = render(); capturedCallbacks.onCommand?.('/model'); rerender(); - await tick(); + await test.tick(); capturedCallbacks.onSelect?.('llama3'); rerender(); - await tick(); + await test.tick(); expect(lastFrame()).toContain('llama3'); expect(lastFrame()).not.toContain('ModelPicker'); }); @@ -117,10 +118,10 @@ describe('App', () => { const { lastFrame, rerender } = render(); capturedCallbacks.onCommand?.('/model'); rerender(); - await tick(); + await test.tick(); capturedCallbacks.onCancel?.(); rerender(); - await tick(); + await test.tick(); expect(lastFrame()).not.toContain('ModelPicker'); expect(lastFrame()).toContain('>'); }); @@ -129,7 +130,7 @@ describe('App', () => { const { lastFrame, rerender } = render(); capturedCallbacks.onCommand?.('/unknown'); rerender(); - await tick(); + await test.tick(); expect(lastFrame()).not.toContain('ModelPicker'); }); @@ -142,19 +143,19 @@ describe('App', () => { // Call the callback passed to Footer - cycles to Auto capturedCallbacks.onToggleMode?.(); rerender(); - await tick(); + await test.tick(); expect(lastFrame()).toContain('Mode: Auto'); // Call again - cycles to Plan capturedCallbacks.onToggleMode?.(); rerender(); - await tick(); + await test.tick(); expect(lastFrame()).toContain('Mode: Plan'); // Call again - cycles back to Safe capturedCallbacks.onToggleMode?.(); rerender(); - await tick(); + await test.tick(); expect(lastFrame()).toContain('Mode: Safe'); }); @@ -165,12 +166,12 @@ describe('App', () => { capturedCallbacks.onModeChange?.('auto'); rerender(); - await tick(); + await test.tick(); expect(lastFrame()).toContain('Mode: Auto'); capturedCallbacks.onModeChange?.('safe'); rerender(); - await tick(); + await test.tick(); expect(lastFrame()).toContain('Mode: Safe'); }); }); diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index ecd4770c..4d6fc5ff 100644 --- a/src/components/Chat/Chat.test.tsx +++ b/src/components/Chat/Chat.test.tsx @@ -1,16 +1,15 @@ import { Text } from 'ink'; import { render } from 'ink-testing-library'; -import { MODE } from '../../constants'; -import { ollama, tools } from '../../utils'; -import { tick } from '../../utils/test'; +import { DECISION, MODE } from '../../constants'; +import { ollama, test, tools } from '../../utils'; const mockState = vi.hoisted(() => ({ - handlers: [] as ((value: string) => void)[], + handler: undefined as ((value: string) => void) | undefined, testInput: '', shouldReset: false, clear() { - this.handlers.length = 0; + this.handler = undefined; this.testInput = ''; this.shouldReset = true; }, @@ -23,8 +22,15 @@ const planApprovalState = vi.hoisted(() => ({ }, })); +const toolApprovalState = vi.hoisted(() => ({ + onChange: undefined as ((value: DECISION.Decision) => void) | undefined, + clear() { + this.onChange = undefined; + }, +})); + vi.mock('@inkjs/ui', async () => { - const actual = await vi.importActual('@inkjs/ui'); + const actual = await vi.importActual('@inkjs/ui'); const { Text } = await import('ink'); return { ...actual, @@ -32,10 +38,19 @@ vi.mock('@inkjs/ui', async () => { options, onChange, }: { - options: { label: string; value: MODE.Name }[]; - onChange?: (value: MODE.Name) => void; + options: { label: string; value: string }[]; + onChange?: (value: string) => void; }) => { - planApprovalState.onChange = onChange; + const isPlanApproval = options.some(({ value }) => + Object.values(MODE.NAME).includes(value as MODE.Name), + ); + + if (isPlanApproval) { + planApprovalState.onChange = onChange; + } else { + toolApprovalState.onChange = onChange; + } + return ( <> {options.map(({ value, label }) => ( @@ -47,25 +62,21 @@ vi.mock('@inkjs/ui', async () => { }; }); -vi.mock('../../utils', async () => { - const actual = - await vi.importActual('../../utils'); - return { - ...actual, - ollama: { - streamChat: vi.fn().mockImplementation(function* () { - yield { type: 'content', content: 'Mocked' }; - yield { type: 'content', content: ' response' }; - }), - }, - tools: { - TOOLS: [], - READ_ONLY_TOOLS: new Set(), - DANGEROUS_TOOLS: new Set(), - executeTool: vi.fn(), - }, - }; -}); +vi.mock('../../utils', async () => ({ + ...(await vi.importActual('../../utils')), + ollama: { + streamChat: vi.fn().mockImplementation(function* () { + yield { type: 'content', content: 'Mocked' }; + yield { type: 'content', content: ' response' }; + }), + }, + tools: { + TOOLS: [], + READ_ONLY_TOOLS: new Set(), + DANGEROUS_TOOLS: new Set(), + executeTool: vi.fn(), + }, +})); vi.mock('./Input', () => ({ Input: (props: { @@ -73,7 +84,7 @@ vi.mock('./Input', () => ({ isDisabled?: boolean; }) => { if (props.onSubmit) { - mockState.handlers.push(props.onSubmit); + mockState.handler = props.onSubmit; } if (props.isDisabled) { @@ -102,13 +113,11 @@ async function typeText( ) { mockState.testInput = text; rerender(tree); - await tick(); + await test.tick(); } function submitInput(value: string) { - for (const handler of mockState.handlers) { - handler(value); - } + mockState.handler?.(value); mockState.clear(); } @@ -116,9 +125,13 @@ function choosePlanMode(mode: MODE.Name) { planApprovalState.onChange?.(mode); } +function chooseToolDecision(decision: DECISION.Decision) { + toolApprovalState.onChange?.(decision); +} + async function waitForStream() { // Allow time for async generator to yield values - await tick(10); + await test.tick(10); } function resetChatMocks() { @@ -126,6 +139,7 @@ function resetChatMocks() { vi.clearAllMocks(); mockState.clear(); planApprovalState.clear(); + toolApprovalState.clear(); tools.TOOLS.splice(0, tools.TOOLS.length); vi.mocked(ollama.streamChat).mockImplementation(async function* () { await Promise.resolve(); @@ -151,7 +165,7 @@ describe('Chat', () => { onModeChange={onModeChange} />, ); - await tick(); + await test.tick(); const frame = lastFrame() ?? ''; expect(frame).not.toContain('coding assistant'); expect(frame).toContain('>'); @@ -167,7 +181,7 @@ describe('Chat', () => { /> ); const { lastFrame, rerender } = render(chat); - await tick(); + await test.tick(); await typeText(rerender, 'hello', chat); submitInput('hello'); rerender(chat); @@ -186,7 +200,7 @@ describe('Chat', () => { /> ); const { lastFrame, rerender } = render(chat); - await tick(); + await test.tick(); await typeText(rerender, 'hello', chat); submitInput('hello'); rerender(chat); @@ -206,13 +220,13 @@ describe('Chat', () => { /> ); const { lastFrame, rerender } = render(chat); - await tick(); + await test.tick(); const beforeFrame = lastFrame() ?? ''; const systemLineCount = beforeFrame.split('\n').length; await typeText(rerender, ' ', chat); submitInput(' '); rerender(chat); - await tick(); + await test.tick(); const afterFrame = lastFrame() ?? ''; const afterLineCount = afterFrame.split('\n').length; // After submitting blank input, line count should not increase @@ -231,7 +245,7 @@ describe('Chat', () => { /> ); const { lastFrame, rerender } = render(chat); - await tick(); + await test.tick(); await typeText(rerender, 'first', chat); submitInput('first'); rerender(chat); @@ -260,7 +274,7 @@ describe('Chat', () => { const { rerender } = render(chat); submitInput('/model'); rerender(chat); - await tick(); + await test.tick(); expect(onCommand).toHaveBeenCalledWith('/model'); }); @@ -715,7 +729,7 @@ describe('Chat with tool calls', () => { expect(lastFrame()).toContain('Plan Generated'); choosePlanMode(MODE.NAME.PLAN); - await tick(); + await test.tick(); rerender(chat); expect(onModeChange).toHaveBeenCalledWith(MODE.NAME.PLAN); @@ -724,7 +738,7 @@ describe('Chat with tool calls', () => { ); choosePlanMode(MODE.NAME.AUTO); - await tick(); + await test.tick(); }); it('executes an approved plan immediately in auto mode', async () => { @@ -867,7 +881,7 @@ describe('Chat with tool calls', () => { onModeChange={vi.fn()} /> ); - const { lastFrame, rerender, stdin } = render(chat); + const { lastFrame, rerender } = render(chat); await typeText(rerender, 'write a file', chat); submitInput('write a file'); @@ -878,11 +892,8 @@ describe('Chat with tool calls', () => { // Verify approval prompt is shown expect(lastFrame()).toContain('Tool requires approval'); - // Reject the tool (move to No with right arrow, then Enter) - stdin.write('\x1B[C'); // Right arrow - await tick(); - stdin.write('\r'); // Enter - await tick(); + chooseToolDecision(DECISION.REJECT); + await waitForStream(); rerender(chat); // Should show rejection message @@ -928,7 +939,7 @@ describe('Chat with tool calls', () => { onModeChange={vi.fn()} /> ); - const { lastFrame, rerender, stdin } = render(chat); + const { lastFrame, rerender } = render(chat); await typeText(rerender, 'write a file', chat); submitInput('write a file'); @@ -939,9 +950,8 @@ describe('Chat with tool calls', () => { // Verify approval prompt is shown expect(lastFrame()).toContain('Tool requires approval'); - // Approve the tool by pressing Enter (yes is default) - stdin.write('\r'); // Enter - await tick(); + chooseToolDecision(DECISION.APPROVE); + await waitForStream(); rerender(chat); // Should have called executeTool @@ -991,7 +1001,7 @@ describe('Chat with tool calls', () => { onModeChange={vi.fn()} /> ); - const { rerender, stdin } = render(chat); + const { rerender } = render(chat); await typeText(rerender, 'write a file', chat); submitInput('write a file'); @@ -999,9 +1009,8 @@ describe('Chat with tool calls', () => { await waitForStream(); rerender(chat); - // Approve the tool by pressing Enter - stdin.write('\r'); - await tick(); + chooseToolDecision(DECISION.APPROVE); + await waitForStream(); rerender(chat); // Should have called executeTool diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 22874dd5..90483dde 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -1,7 +1,7 @@ import { Box } from 'ink'; import { useCallback, useState } from 'react'; -import { MODE, PROMPT, ROLE } from '../../constants'; +import { DECISION, MODE, PROMPT, ROLE } from '../../constants'; import { agents, ollama, tools } from '../../utils'; import { Messages } from '../Messages'; import { PlanApproval } from '../PlanApproval'; @@ -352,7 +352,7 @@ export function Chat({ model, onCommand, mode, onModeChange }: Props) { ); const handleToolApproval = useCallback( - async (approved: boolean) => { + async (decision: DECISION.Decision) => { // v8 ignore next if (!pendingToolCall) { return; @@ -362,35 +362,43 @@ export function Chat({ model, onCommand, mode, onModeChange }: Props) { setPendingToolCall(null); setIsLoading(true); - if (approved) { - const result = await tools.executeTool( - toolCall.function.name, - toolCall.function.arguments, - ); + switch (decision) { + case DECISION.APPROVE: { + const result = await tools.executeTool( + toolCall.function.name, + toolCall.function.arguments, + ); + + const toolResultMessage: ollama.Message = { + role: ROLE.SYSTEM, + content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ''}`, + }; + + const newMessages = [...messages, toolResultMessage]; + setMessages((previousMessages) => [ + ...previousMessages, + toolResultMessage, + ]); + + await processStream(newMessages); + break; + } - const toolResultMessage: ollama.Message = { - role: ROLE.SYSTEM, - content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ''}`, - }; + case DECISION.REJECT: { + const rejectionMessage: ollama.Message = { + role: ROLE.SYSTEM, + content: `User declined to execute tool ${toolCall.function.name}`, + }; - const newMessages = [...messages, toolResultMessage]; - setMessages((previousMessages) => [ - ...previousMessages, - toolResultMessage, - ]); - await processStream(newMessages); - } else { - // Tool was rejected - const rejectionMessage: ollama.Message = { - role: ROLE.SYSTEM, - content: `User declined to execute tool ${toolCall.function.name}`, - }; - const newMessages = [...messages, rejectionMessage]; - setMessages((previousMessages) => [ - ...previousMessages, - rejectionMessage, - ]); - await processStream(newMessages); + const newMessages = [...messages, rejectionMessage]; + setMessages((previousMessages) => [ + ...previousMessages, + rejectionMessage, + ]); + + await processStream(newMessages); + break; + } } }, [pendingToolCall, messages, processStream], @@ -443,8 +451,8 @@ export function Chat({ model, onCommand, mode, onModeChange }: Props) { {!pendingPlan && pendingToolCall && ( void handleToolApproval(true)} - onReject={() => void handleToolApproval(false)} + // eslint-disable-next-line @typescript-eslint/no-misused-promises + onDecision={handleToolApproval} /> )} diff --git a/src/components/Footer.test.tsx b/src/components/Footer.test.tsx index 8b34828d..f7f87543 100644 --- a/src/components/Footer.test.tsx +++ b/src/components/Footer.test.tsx @@ -1,7 +1,7 @@ import { render } from 'ink-testing-library'; import { MODE } from '../constants'; -import { tick } from '../utils/test'; +import { test } from '../utils'; import { Footer } from './Footer'; describe('Footer', () => { @@ -40,7 +40,7 @@ describe('Footer', () => { // Send Shift+Tab escape sequence stdin.write('\x1B[Z'); - await tick(); + await test.tick(); expect(mockToggle).toHaveBeenCalled(); }); @@ -53,7 +53,7 @@ describe('Footer', () => { // Send a regular Tab (without shift) stdin.write('\t'); - await tick(); + await test.tick(); expect(mockToggle).not.toHaveBeenCalled(); }); diff --git a/src/components/ModelPicker.test.tsx b/src/components/ModelPicker.test.tsx index bc4146f0..01e4e741 100644 --- a/src/components/ModelPicker.test.tsx +++ b/src/components/ModelPicker.test.tsx @@ -1,7 +1,7 @@ import { render } from 'ink-testing-library'; import { KEY } from '../constants'; -import { tick } from '../utils/test'; +import { test } from '../utils'; const { mockListModels, mockOnChange } = vi.hoisted(() => ({ mockListModels: vi.fn(), @@ -34,7 +34,8 @@ vi.mock('@inkjs/ui', async () => { }; }); -vi.mock('../utils', () => ({ +vi.mock('../utils', async () => ({ + ...(await vi.importActual('../utils')), ollama: { listModels: mockListModels }, })); @@ -65,7 +66,7 @@ describe('ModelPicker', () => { onCancel={vi.fn()} />, ); - await tick(10); + await test.tick(10); const frame = lastFrame() ?? ''; expect(frame).toContain('gemma4'); expect(frame).toContain('llama3'); @@ -80,7 +81,7 @@ describe('ModelPicker', () => { onCancel={vi.fn()} />, ); - await tick(10); + await test.tick(10); expect(lastFrame()).toContain('llama3'); }); @@ -93,7 +94,7 @@ describe('ModelPicker', () => { onCancel={vi.fn()} />, ); - await tick(10); + await test.tick(10); mockOnChange('llama3'); expect(onSelect).toHaveBeenCalledWith('llama3'); }); @@ -107,9 +108,9 @@ describe('ModelPicker', () => { onCancel={onCancel} />, ); - await tick(10); + await test.tick(10); stdin.write(KEY.ESCAPE); - await tick(50); + await test.tick(50); expect(onCancel).toHaveBeenCalled(); }); @@ -122,7 +123,7 @@ describe('ModelPicker', () => { onCancel={vi.fn()} />, ); - await tick(10); + await test.tick(10); expect(lastFrame()).toContain('Error loading models: No connection'); }); @@ -135,7 +136,7 @@ describe('ModelPicker', () => { onCancel={vi.fn()} />, ); - await tick(10); + await test.tick(10); expect(lastFrame()).toContain('Error loading models: network timeout'); }); @@ -148,9 +149,9 @@ describe('ModelPicker', () => { onCancel={onCancel} />, ); - await tick(10); + await test.tick(10); stdin.write('a'); - await tick(10); + await test.tick(10); expect(onCancel).not.toHaveBeenCalled(); }); }); diff --git a/src/components/PlanApproval.test.tsx b/src/components/PlanApproval.test.tsx index 10a1fbdc..77688dd0 100644 --- a/src/components/PlanApproval.test.tsx +++ b/src/components/PlanApproval.test.tsx @@ -1,7 +1,7 @@ import { render } from 'ink-testing-library'; import { KEY, MODE } from '../constants'; -import { tick } from '../utils/test'; +import { test } from '../utils'; const { mockOnChange } = vi.hoisted(() => ({ mockOnChange: vi.fn<(value: MODE.Name) => void>(), @@ -84,7 +84,7 @@ describe('PlanApproval', () => { ); stdin.write(KEY.ESCAPE); - await tick(50); + await test.tick(50); expect(onModeChange).toHaveBeenCalledWith(MODE.NAME.PLAN); }); @@ -96,7 +96,7 @@ describe('PlanApproval', () => { ); stdin.write(KEY.ENTER); - await tick(50); + await test.tick(50); expect(onModeChange).not.toHaveBeenCalled(); }); diff --git a/src/components/ToolApproval.test.tsx b/src/components/ToolApproval.test.tsx index 23aea78c..8e22942e 100644 --- a/src/components/ToolApproval.test.tsx +++ b/src/components/ToolApproval.test.tsx @@ -1,7 +1,35 @@ import { render } from 'ink-testing-library'; -import { KEY } from '../constants'; -import { tick } from '../utils/test'; +import { DECISION, KEY } from '../constants'; +import { test } from '../utils'; + +const { mockOnChange } = vi.hoisted(() => ({ + mockOnChange: vi.fn<(value: DECISION.Decision) => void>(), +})); + +vi.mock('@inkjs/ui', async () => { + const { Text } = await import('ink'); + return { + Select: ({ + options, + onChange, + }: { + options: { label: string; value: DECISION.Decision }[]; + defaultValue?: DECISION.Decision; + onChange?: (value: DECISION.Decision) => void; + }) => { + mockOnChange.mockImplementation((value) => onChange?.(value)); + return ( + <> + {options.map(({ value, label }) => ( + {label} + ))} + + ); + }, + }; +}); + import { ToolApproval } from './ToolApproval'; describe('ToolApproval', () => { @@ -18,112 +46,72 @@ describe('ToolApproval', () => { it('renders tool name and arguments', () => { const toolCall = createToolCall('read_file', { path: '/test.txt' }); const { lastFrame } = render( - , + , ); expect(lastFrame()).toContain('read_file'); expect(lastFrame()).toContain('Tool requires approval'); }); - it('calls onApprove when Enter is pressed with yes selected', async () => { - const onApprove = vi.fn(); - const onReject = vi.fn(); - const toolCall = createToolCall(); - - const { stdin } = render( - , + it('calls onDecision with approve when approve is chosen', () => { + const onDecision = vi.fn(); + render( + , ); - stdin.write(KEY.ENTER); - await tick(); + mockOnChange(DECISION.APPROVE); - expect(onApprove).toHaveBeenCalledTimes(1); - expect(onReject).not.toHaveBeenCalled(); + expect(onDecision).toHaveBeenCalledTimes(1); + expect(onDecision).toHaveBeenCalledWith(DECISION.APPROVE); }); - it('calls onReject when switching to no and pressing Enter', async () => { - const onApprove = vi.fn(); - const onReject = vi.fn(); - const toolCall = createToolCall(); - - const { stdin } = render( - , + it('calls onDecision with reject when reject is chosen', () => { + const onDecision = vi.fn(); + render( + , ); - // Move selection to 'no' with right arrow, then press Enter - stdin.write(KEY.RIGHT); - await tick(); - stdin.write(KEY.ENTER); - await tick(); + mockOnChange(DECISION.REJECT); - expect(onReject).toHaveBeenCalledTimes(1); - expect(onApprove).not.toHaveBeenCalled(); + expect(onDecision).toHaveBeenCalledTimes(1); + expect(onDecision).toHaveBeenCalledWith(DECISION.REJECT); }); - it('toggles selection with right arrow key', async () => { - const toolCall = createToolCall(); - - const { lastFrame, stdin } = render( - , + it('formats JSON arguments nicely', () => { + const toolCall = createToolCall('write_file', { + path: '/test.txt', + content: 'hello', + }); + const { lastFrame } = render( + , ); - // Toggle to no with right arrow - stdin.write(KEY.RIGHT); - await tick(); - - expect(lastFrame()).toContain('Yes'); + expect(lastFrame()).toContain('path'); + expect(lastFrame()).toContain('content'); }); - it('toggles selection with left arrow key', async () => { - const toolCall = createToolCall(); - - const { lastFrame, stdin } = render( - , + it('calls onDecision with reject when Escape is pressed', async () => { + const onDecision = vi.fn(); + const { stdin } = render( + , ); - // Move right first, then back with left - stdin.write(KEY.RIGHT); - await tick(); - stdin.write(KEY.LEFT); - await tick(); + stdin.write(KEY.ESCAPE); + await test.tick(50); - expect(lastFrame()).toContain('Yes'); + expect(onDecision).toHaveBeenCalledTimes(1); + expect(onDecision).toHaveBeenCalledWith(DECISION.REJECT); }); - it('formats JSON arguments nicely', () => { - const toolCall = createToolCall('write_file', { - path: '/test.txt', - content: 'hello', - }); - const { lastFrame } = render( - , + it('ignores non-escape keys', async () => { + const onDecision = vi.fn(); + const { stdin } = render( + , ); - expect(lastFrame()).toContain('path'); - expect(lastFrame()).toContain('content'); + stdin.write(KEY.ENTER); + await test.tick(50); + + expect(onDecision).not.toHaveBeenCalled(); }); }); diff --git a/src/components/ToolApproval.tsx b/src/components/ToolApproval.tsx index 783480fd..fbeb769f 100644 --- a/src/components/ToolApproval.tsx +++ b/src/components/ToolApproval.tsx @@ -1,58 +1,54 @@ +import { Select } from '@inkjs/ui'; import { Box, Text, useInput } from 'ink'; -import { useState } from 'react'; +import { useCallback } from 'react'; +import { DECISION } from '../constants'; import type { ToolCall } from '../utils/ollama'; interface Props { toolCall: ToolCall; - onApprove: () => void; - onReject: () => void; + onDecision: (decision: DECISION.Decision) => void; } -export function ToolApproval({ toolCall, onApprove, onReject }: Props) { - const [selected, setSelected] = useState<'yes' | 'no'>('yes'); +const options: { label: string; value: DECISION.Decision }[] = [ + { label: 'Approve tool call', value: DECISION.APPROVE }, + { label: 'Reject tool call', value: DECISION.REJECT }, +]; +export function ToolApproval({ toolCall, onDecision }: Props) { useInput((_, key) => { - if (key.return) { - if (selected === 'yes') { - onApprove(); - } else { - onReject(); - } - // v8 ignore start - } else if (key.leftArrow || key.rightArrow) { - setSelected((prev) => (prev === 'yes' ? 'no' : 'yes')); + if (key.escape) { + onDecision(DECISION.REJECT); } - // v8 ignore stop }); + const handleChange = useCallback( + (value: string) => { + onDecision(value as DECISION.Decision); + }, + [onDecision], + ); + const args = JSON.stringify(toolCall.function.arguments, null, 2); return ( - - - ⚠️ Tool requires approval: - - - - Tool: {toolCall.function.name} - - - Arguments: {args} - - - + + ⚠️ Tool requires approval: + + - - {selected === 'yes' ? '▶ ' : ' '}✓ Yes (Enter) - + Tool: {toolCall.function.name} - - {selected === 'no' ? '▶ ' : ' '}✗ No (Esc) - + Arguments: {args} + + + Select approval action (↑↓ + Enter to confirm, Esc to reject) + + +