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)
+
+
+
);
}
diff --git a/src/constants/decision.ts b/src/constants/decision.ts
new file mode 100644
index 00000000..986c0304
--- /dev/null
+++ b/src/constants/decision.ts
@@ -0,0 +1,4 @@
+export const APPROVE = 'approve';
+export const REJECT = 'reject';
+
+export type Decision = typeof APPROVE | typeof REJECT;
diff --git a/src/constants/index.ts b/src/constants/index.ts
index aa1e736e..18f2b8c0 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -1,4 +1,5 @@
export * as COMMAND from './command';
+export * as DECISION from './decision';
export * as KEY from './key';
export * as MODE from './mode';
export * as PACKAGE from './package';
diff --git a/src/utils/config.test.ts b/src/utils/config.test.ts
index 3f6184bc..ef08f75b 100644
--- a/src/utils/config.test.ts
+++ b/src/utils/config.test.ts
@@ -36,10 +36,12 @@ describe('config', () => {
beforeEach(() => {
testHome = mkdtempSync(join(tmpdir(), 'code-ollama-config-'));
vi.resetModules();
- vi.doMock('node:os', async () => {
- const actual = await vi.importActual('node:os');
- return { ...actual, homedir: () => testHome };
- });
+
+ vi.doMock('node:os', async () => ({
+ ...(await vi.importActual('node:os')),
+ homedir: () => testHome,
+ }));
+
delete process.env.OLLAMA_HOST;
delete process.env.OLLAMA_MODEL;
});
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 0e56be4c..384c57f2 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -2,4 +2,5 @@ export * as agents from './agents';
export * as config from './config';
export * as ollama from './ollama';
export * as screen from './screen';
+export * as test from './test';
export * as tools from './tools';