From 634a6d64c9855965aae3ed46af205cb541c3cb9e Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 6 May 2026 20:38:30 -0400 Subject: [PATCH 1/7] refactor(components): implement shared component SelectPrompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It uses `@inkjs/ui`’s `Option`/`SelectProps` types, accepts `children` for the content above the select, and owns the shared `Esc` handling for select-backed prompts. Used in: - ModelPicker - PlanApproval - ToolApproval --- src/components/ModelPicker.tsx | 31 ++++----- src/components/PlanApproval.tsx | 46 ++++++------ src/components/SelectPrompt.test.tsx | 100 +++++++++++++++++++++++++++ src/components/SelectPrompt.tsx | 37 ++++++++++ src/components/ToolApproval.tsx | 24 +++---- src/components/index.ts | 1 + 6 files changed, 189 insertions(+), 50 deletions(-) create mode 100644 src/components/SelectPrompt.test.tsx create mode 100644 src/components/SelectPrompt.tsx diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index 0643abf0..88f21e5d 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -1,8 +1,9 @@ -import { Select, Spinner } from '@inkjs/ui'; -import { Box, Text, useInput } from 'ink'; +import { Spinner } from '@inkjs/ui'; +import { Text, useInput } from 'ink'; import { useEffect, useState } from 'react'; import { ollama } from '../utils'; +import { SelectPrompt } from './SelectPrompt'; interface Props { currentModel: string; @@ -16,6 +17,12 @@ export function ModelPicker({ currentModel, onSelect, onCancel }: Props) { ); const [error, setError] = useState(null); + useInput((_, key) => { + if (key.escape) { + onCancel(); + } + }); + useEffect(() => { async function load() { try { @@ -29,12 +36,6 @@ export function ModelPicker({ currentModel, onSelect, onCancel }: Props) { void load(); }, []); - useInput((_, key) => { - if (key.escape) { - onCancel(); - } - }); - if (error) { return Error loading models: {error}; } @@ -44,16 +45,14 @@ export function ModelPicker({ currentModel, onSelect, onCancel }: Props) { } return ( - + Select a model (↑↓ + Enter to confirm, Esc to cancel) - - - + ); } diff --git a/src/components/SelectPrompt.test.tsx b/src/components/SelectPrompt.test.tsx new file mode 100644 index 00000000..2db306dd --- /dev/null +++ b/src/components/SelectPrompt.test.tsx @@ -0,0 +1,100 @@ +import { render } from 'ink-testing-library'; + +import { KEY } from '../constants'; +import { test } from '../utils'; + +const { mockOnChange } = vi.hoisted(() => ({ + mockOnChange: vi.fn<(value: string) => void>(), +})); + +vi.mock('@inkjs/ui', async () => { + const { Text } = await import('ink'); + return { + Select: ({ + options, + onChange, + defaultValue, + }: { + options: { label: string; value: string }[]; + defaultValue?: string; + onChange?: (value: string) => void; + }) => { + mockOnChange.mockImplementation((value) => onChange?.(value)); + return ( + <> + {defaultValue ? {`default:${defaultValue}`} : null} + {options.map(({ value, label }) => ( + {label} + ))} + + ); + }, + }; +}); + +import { Text } from 'ink'; + +import { SelectPrompt } from './SelectPrompt'; + +describe('SelectPrompt', () => { + const options = [ + { label: 'First option', value: 'first' }, + { label: 'Second option', value: 'second' }, + ]; + + it('renders children above the select', () => { + const { lastFrame } = render( + + Prompt text + , + ); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('Prompt text'); + expect(frame).toContain('First option'); + expect(frame).toContain('Second option'); + }); + + it('calls onEscape when Escape is pressed', async () => { + const onEscape = vi.fn(); + const { stdin } = render( + , + ); + + stdin.write(KEY.ESCAPE); + await test.tick(50); + + expect(onEscape).toHaveBeenCalledTimes(1); + }); + + it('ignores Escape when onEscape is not provided', async () => { + const { stdin } = render( + , + ); + + stdin.write(KEY.ESCAPE); + await test.tick(50); + }); + + it('passes defaultValue through to Select', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('default:second'); + }); + + it('forwards selection changes', () => { + const onChange = vi.fn(); + render(); + + mockOnChange('second'); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('second'); + }); +}); diff --git a/src/components/SelectPrompt.tsx b/src/components/SelectPrompt.tsx new file mode 100644 index 00000000..97ec152d --- /dev/null +++ b/src/components/SelectPrompt.tsx @@ -0,0 +1,37 @@ +import type { Option, SelectProps } from '@inkjs/ui'; +import { Select } from '@inkjs/ui'; +import { Box, useInput } from 'ink'; +import type { ReactNode } from 'react'; + +export interface SelectPromptProps { + children?: ReactNode; + options: Option[]; + onChange: NonNullable; + onEscape?: () => void; + defaultValue?: SelectProps['defaultValue']; +} + +export function SelectPrompt({ + children, + options, + onChange, + onEscape, + defaultValue, +}: SelectPromptProps) { + useInput((_, key) => { + if (key.escape) { + onEscape?.(); + } + }); + + return ( + + {children} + - + ); } diff --git a/src/components/index.ts b/src/components/index.ts index eb4286e9..87af3742 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,4 +6,5 @@ export { Header } from './Header'; export { Messages } from './Messages'; export { ModelPicker } from './ModelPicker'; export { PlanApproval } from './PlanApproval'; +export { SelectPrompt } from './SelectPrompt'; export { ToolApproval } from './ToolApproval'; From 53109d13d4cd80c03998418e012c9e54a0a167ee Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 6 May 2026 20:45:13 -0400 Subject: [PATCH 2/7] refactor(components): use SelectPrompt's onEscape in ModelPicker --- src/components/ModelPicker.tsx | 9 ++------- src/components/SelectPrompt.tsx | 1 + 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index 88f21e5d..998c584f 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -1,5 +1,5 @@ import { Spinner } from '@inkjs/ui'; -import { Text, useInput } from 'ink'; +import { Text } from 'ink'; import { useEffect, useState } from 'react'; import { ollama } from '../utils'; @@ -17,12 +17,6 @@ export function ModelPicker({ currentModel, onSelect, onCancel }: Props) { ); const [error, setError] = useState(null); - useInput((_, key) => { - if (key.escape) { - onCancel(); - } - }); - useEffect(() => { async function load() { try { @@ -49,6 +43,7 @@ export function ModelPicker({ currentModel, onSelect, onCancel }: Props) { options={options} defaultValue={currentModel} onChange={onSelect} + onEscape={onCancel} > Select a model (↑↓ + Enter to confirm, Esc to cancel) diff --git a/src/components/SelectPrompt.tsx b/src/components/SelectPrompt.tsx index 97ec152d..e57eee5f 100644 --- a/src/components/SelectPrompt.tsx +++ b/src/components/SelectPrompt.tsx @@ -27,6 +27,7 @@ export function SelectPrompt({ return ( {children} + +