diff --git a/src/components/App/ReadinessCheck.test.tsx b/src/components/App/ReadinessCheck.test.tsx index 5b8f7656..a6fc9ce8 100644 --- a/src/components/App/ReadinessCheck.test.tsx +++ b/src/components/App/ReadinessCheck.test.tsx @@ -3,15 +3,29 @@ import { useRef } from 'react'; import { ReadinessCheck, ReadinessState } from './ReadinessCheck'; +const mockSubmit = vi.hoisted(() => vi.fn()); + vi.mock('@/components/Chat', () => ({ - ChatInput: ({ onSubmit }: { onSubmit: (value: string) => void }) => { + ChatInput: ({ + onSubmit, + }: { + onSubmit: (value: { content: string }) => void; + }) => { const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit; + // Store the callback for tests to access + mockSubmit.mockImplementation((value: { content: string }) => { + onSubmitRef.current(value); + }); return null; }, })); describe('ReadinessCheck', () => { + beforeEach(() => { + mockSubmit.mockClear(); + }); + it('renders checking state', () => { const { lastFrame } = render( { expect(lastFrame()).not.toContain('No models installed'); expect(lastFrame()).not.toContain('Unable to load models'); }); + + it('calls onCommand when ChatInput submits', () => { + const onCommand = vi.fn(); + render( + , + ); + // The mock stores the callback in mockSubmit + mockSubmit({ content: '/model' }); + expect(onCommand).toHaveBeenCalledWith('/model'); + }); }); diff --git a/src/components/App/ReadinessCheck.tsx b/src/components/App/ReadinessCheck.tsx index d13a0ece..ce5e0b81 100644 --- a/src/components/App/ReadinessCheck.tsx +++ b/src/components/App/ReadinessCheck.tsx @@ -115,7 +115,13 @@ export function ReadinessCheck({ {getMessage(setupState, errorMessage)} - + { + onCommand(content); + }} + theme={theme} + /> ); } diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index c582bf6f..79ef95d0 100644 --- a/src/components/Chat/Chat.test.tsx +++ b/src/components/Chat/Chat.test.tsx @@ -7,7 +7,9 @@ import type { Decision } from '@/types'; import { ollama, time, tools } from '@/utils'; const mockState = vi.hoisted(() => ({ - handler: undefined as ((value: string) => void) | undefined, + handler: undefined as + | ((value: { content: string; images?: string[] }) => void) + | undefined, history: [] as string[], testInput: '', shouldReset: false, @@ -97,7 +99,7 @@ vi.mock('@/utils', async () => ({ vi.mock('./ChatInput', () => ({ ChatInput: (props: { history?: string[]; - onSubmit?: (value: string) => void; + onSubmit?: (value: { content: string; images?: string[] }) => void; onInterrupt?: () => void; isDisabled?: boolean; }) => { @@ -140,8 +142,8 @@ async function typeText( await time.tick(); } -function submitInput(value: string) { - mockState.handler?.(value); +function submitInput(value: string, images?: string[]) { + mockState.handler?.({ content: value, images }); mockState.clear(); } @@ -972,6 +974,7 @@ describe('Chat with tool calls', () => { submitInput('make a plan'); rerender(chat); await waitForStream(); + await time.tick(50); rerender(chat); expect(lastFrame()).toContain('Plan Generated'); @@ -987,7 +990,7 @@ describe('Chat with tool calls', () => { choosePlanMode(MODE.AUTO); await time.tick(); - }); + }, 20_000); it('executes an approved plan immediately in auto mode', async () => { const { streamChat } = ollama; @@ -1434,4 +1437,60 @@ describe('Chat interrupt', () => { expect(lastFrame()).not.toContain('Execution interrupted'); }); + + it('submits with empty images array without adding images property', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = render(chat); + submitInput('hello', []); + rerender(chat); + await waitForStream(); + + expect(lastFrame()).toContain('Mocked response'); + }); + + it('submits without images parameter', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = render(chat); + // Call submitInput without the images parameter (undefined) + mockState.handler?.({ content: 'hello' }); + mockState.clear(); + rerender(chat); + await waitForStream(); + + expect(lastFrame()).toContain('Mocked response'); + }); + + it('submits with images array containing items', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = render(chat); + submitInput('hello', ['/tmp/image.png']); + rerender(chat); + await waitForStream(); + + expect(lastFrame()).toContain('Mocked response'); + }); }); diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 0a42b9fc..c6318fa0 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -16,7 +16,7 @@ import type { } from '@/types'; import { agents, ollama, tools } from '@/utils'; -import { ChatInput } from './ChatInput'; +import { ChatInput, type SubmittedInput } from './ChatInput'; import { ACTION_NOT_PERFORMED, InterruptReason, @@ -545,11 +545,11 @@ export function Chat({ ); const handleSubmit = useCallback( - async (value: string) => { + async ({ content, images }: SubmittedInput) => { setInterruptReason(null); - const userContent = value.trim(); + const userContent = content.trim(); - if (!userContent) { + if (!userContent && !images?.length) { return; } @@ -563,6 +563,7 @@ export function Chat({ const userMessage: ollama.Message = { role: ROLE.USER, content: userContent, + ...(images?.length ? { images } : {}), }; const updatedMessages = [...messages, userMessage]; @@ -624,6 +625,7 @@ export function Chat({ onInterrupt={handleInterrupt} // eslint-disable-next-line @typescript-eslint/no-misused-promises onSubmit={handleSubmit} + theme={theme} /> )} diff --git a/src/components/Chat/ChatInput.test.tsx b/src/components/Chat/ChatInput.test.tsx index 9f56ba20..ba78af59 100644 --- a/src/components/Chat/ChatInput.test.tsx +++ b/src/components/Chat/ChatInput.test.tsx @@ -1,9 +1,13 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { Text, useInput } from 'ink'; import { render } from 'ink-testing-library'; import { type ComponentProps, useRef, useState } from 'react'; import { COMMAND, KEY } from '@/constants'; -import { time } from '@/utils'; +import { clipboard, time } from '@/utils'; const { mockExit } = vi.hoisted(() => ({ mockExit: vi.fn(), @@ -13,6 +17,13 @@ const { mockTextInput } = vi.hoisted(() => ({ mockTextInput: vi.fn(), })); +const { mockClipboard } = vi.hoisted(() => ({ + mockClipboard: { + removeClipboardImage: vi.fn(), + saveClipboardImage: vi.fn(), + }, +})); + vi.mock('ink', async () => ({ ...(await vi.importActual('ink')), useApp: vi.fn(() => ({ @@ -20,6 +31,11 @@ vi.mock('ink', async () => ({ })), })); +vi.mock('@/utils', async () => ({ + ...(await vi.importActual('@/utils')), + clipboard: mockClipboard, +})); + vi.mock('../TextInput', () => ({ TextInput: ({ value, @@ -43,6 +59,8 @@ vi.mock('../TextInput', () => ({ isDisabled, cursorPosition, wrapIndent, + onChange, + onSubmit, placeholder, }); const onChangeRef = useRef(onChange); @@ -217,19 +235,34 @@ vi.mock('./FileSuggestions', () => ({ import { ChatInput } from './ChatInput'; describe('ChatInput', () => { + let testDirectory = ''; + function renderInput(props: Partial> = {}) { return render(); } beforeEach(() => { + testDirectory = mkdtempSync(join(tmpdir(), 'code-ollama-chat-input-')); + writeFileSync(join(testDirectory, 'screen.png'), 'png'); mockExit.mockReset(); mockTextInput.mockReset(); + mockClipboard.removeClipboardImage.mockReset(); + mockClipboard.saveClipboardImage.mockReset(); + mockClipboard.saveClipboardImage.mockReturnValue( + join(testDirectory, 'image-1.png'), + ); + }); + + afterEach(() => { + rmSync(testDirectory, { force: true, recursive: true }); }); it('renders input prompt', () => { const { lastFrame } = renderInput(); expect(lastFrame()).toContain('>'); - expect(lastFrame()).toContain('Ask anything... (/ commands, @ files)'); + expect(lastFrame()).toContain( + 'Ask anything... (/ commands, @ files, Ctrl+V images)', + ); }); it('does not show command suggestion on non-slash input', async () => { @@ -296,7 +329,7 @@ describe('ChatInput', () => { await time.tick(); stdin.write(KEY.ENTER); await time.tick(); - expect(onSubmit).toHaveBeenCalledWith('hi'); + expect(onSubmit).toHaveBeenCalledWith({ content: 'hi' }); }); it('inserts the focused file suggestion on Enter with a trailing space', async () => { @@ -317,7 +350,7 @@ describe('ChatInput', () => { await time.tick(); stdin.write(KEY.ENTER); await time.tick(); - expect(onSubmit).toHaveBeenCalledWith('/clear'); + expect(onSubmit).toHaveBeenCalledWith({ content: '/clear' }); }); it('ignores slash command submissions that are not in the command list', async () => { @@ -330,6 +363,166 @@ describe('ChatInput', () => { expect(onSubmit).not.toHaveBeenCalled(); }); + it('stages pasted image paths as attachments and keeps the remaining text', async () => { + const onSubmit = vi.fn(); + const { lastFrame } = renderInput({ onSubmit }); + const inputProps = mockTextInput.mock.calls.at(-1)?.[0] as + | { onChange?: (value: string) => void } + | undefined; + + inputProps?.onChange?.( + `"${join(testDirectory, 'screen.png')}" explain this`, + ); + await time.tick(); + + expect(lastFrame()).toContain('[screen.png]'); + expect(lastFrame()).toContain('explain this'); + + const updatedInputProps = mockTextInput.mock.calls.at(-1)?.[0] as + | { onSubmit?: (value: string) => void } + | undefined; + updatedInputProps?.onSubmit?.('explain this'); + await time.tick(); + + expect(onSubmit).toHaveBeenCalledWith({ + content: 'explain this', + images: [join(testDirectory, 'screen.png')], + }); + }); + + it('stages a clipboard image on Ctrl+V', async () => { + const { lastFrame, stdin } = renderInput(); + + stdin.write('\x16'); + await time.tick(); + + expect(clipboard.saveClipboardImage).toHaveBeenCalledWith('image-1'); + expect(lastFrame()).toContain('[image-1.png]'); + }); + + it('hides the placeholder when an attachment is staged without typed text', async () => { + const { lastFrame, stdin } = renderInput(); + + stdin.write('\x16'); + await time.tick(); + + expect(lastFrame()).toContain('[image-1.png]'); + expect(lastFrame()).not.toContain( + 'Ask anything... (/ commands, @ files, Ctrl+V images)', + ); + }); + + it('shows a clipboard error when image paste fails', async () => { + mockClipboard.saveClipboardImage.mockImplementationOnce(() => { + throw new Error('Clipboard unavailable'); + }); + + const { lastFrame, stdin } = renderInput(); + stdin.write('\x16'); + await time.tick(); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('Clipboard unavailable'); + expect(frame.indexOf('Clipboard unavailable')).toBeLessThan( + frame.indexOf('>'), + ); + }); + + it('shows a clipboard error when image paste throws a non-Error value', async () => { + mockClipboard.saveClipboardImage.mockImplementationOnce(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'String error'; + }); + + const { lastFrame, stdin } = renderInput(); + stdin.write('\x16'); + await time.tick(); + + expect(lastFrame()).toContain('String error'); + }); + + it('removes the last staged attachment on backspace when the input is empty', async () => { + const { lastFrame, stdin } = renderInput(); + + stdin.write('\x16'); + await time.tick(); + expect(lastFrame()).toContain('[image-1.png]'); + + stdin.write(KEY.BACKSPACE); + await time.tick(); + + expect(lastFrame()).not.toContain('[image-1.png]'); + expect(clipboard.removeClipboardImage).toHaveBeenCalledWith( + join(testDirectory, 'image-1.png'), + ); + }); + + it('does not remove attachment on backspace when there are no attachments', async () => { + const { lastFrame, stdin } = renderInput(); + + stdin.write(KEY.BACKSPACE); + await time.tick(); + + // Placeholder should still show + expect(lastFrame()).toContain('Ask anything...'); + expect(clipboard.removeClipboardImage).not.toHaveBeenCalled(); + }); + + it('cleans up staged temp attachments when the session changes', async () => { + const onSubmit = vi.fn(); + const { rerender, stdin } = renderInput({ onSubmit }); + + stdin.write('\x16'); + await time.tick(); + + rerender(); + await time.tick(); + + expect(clipboard.removeClipboardImage).toHaveBeenCalledWith( + join(testDirectory, 'image-1.png'), + ); + }); + + it('does not clean up non-temp attachments when session changes', async () => { + const onSubmit = vi.fn(); + const { lastFrame, rerender } = renderInput({ onSubmit }); + const inputProps = mockTextInput.mock.calls.at(-1)?.[0] as + | { onChange?: (value: string) => void } + | undefined; + + // Stage a file path attachment (non-temp) + inputProps?.onChange?.(`"${join(testDirectory, 'screen.png')}"`); + await time.tick(); + + expect(lastFrame()).toContain('[screen.png]'); + + // Change session - should not clean up non-temp attachment + rerender(); + await time.tick(); + + expect(clipboard.removeClipboardImage).not.toHaveBeenCalled(); + }); + + it('does not clean up non-temp attachments when removed', async () => { + const onSubmit = vi.fn(); + const { lastFrame, stdin } = renderInput({ onSubmit }); + const inputProps = mockTextInput.mock.calls.at(-1)?.[0] as + | { onChange?: (value: string) => void } + | undefined; + + // Stage a file path attachment (non-temp) + inputProps?.onChange?.(`"${join(testDirectory, 'screen.png')}"`); + await time.tick(); + + expect(lastFrame()).toContain('[screen.png]'); + + stdin.write(KEY.BACKSPACE); + await time.tick(); + + expect(lastFrame()).not.toContain('[screen.png]'); + expect(clipboard.removeClipboardImage).not.toHaveBeenCalled(); + }); + it('inserts the focused file suggestion on Tab with a trailing space', async () => { const { lastFrame, stdin } = renderInput(); stdin.write('@'); @@ -433,9 +626,11 @@ describe('ChatInput', () => { expect(lastFrame()).toContain('xy'); stdin.write(KEY.ENTER); await time.tick(10); - expect(onSubmit).toHaveBeenCalledWith('xy'); + expect(onSubmit).toHaveBeenCalledWith({ content: 'xy' }); expect(lastFrame()).not.toContain('xy'); - expect(lastFrame()).toContain('Ask anything... (/ commands, @ files)'); + expect(lastFrame()).toContain( + 'Ask anything... (/ commands, @ files, Ctrl+V images)', + ); }); it('deletes last character on backspace', async () => { @@ -472,7 +667,9 @@ describe('ChatInput', () => { stdin.write(KEY.CTRL_C); await time.tick(); expect(lastFrame()).not.toContain('xy'); - expect(lastFrame()).toContain('Ask anything... (/ commands, @ files)'); + expect(lastFrame()).toContain( + 'Ask anything... (/ commands, @ files, Ctrl+V images)', + ); }); it('calls exit on Ctrl+C when input is empty', async () => { @@ -488,7 +685,9 @@ describe('ChatInput', () => { stdin.write('h'); await time.tick(); expect(lastFrame()).not.toContain('> h'); - expect(lastFrame()).toContain('Ask anything... (/ commands, @ files)'); + expect(lastFrame()).toContain( + 'Ask anything... (/ commands, @ files, Ctrl+V images)', + ); stdin.write(KEY.ENTER); await time.tick(); expect(onSubmit).not.toHaveBeenCalled(); @@ -572,7 +771,9 @@ describe('ChatInput', () => { stdin.write(KEY.DOWN); await time.tick(); expect(lastFrame()).not.toContain('only prompt'); - expect(lastFrame()).toContain('Ask anything... (/ commands, @ files)'); + expect(lastFrame()).toContain( + 'Ask anything... (/ commands, @ files, Ctrl+V images)', + ); }); it('does not navigate history when the input is non-empty and not already navigating', async () => { @@ -635,7 +836,9 @@ describe('ChatInput', () => { await time.tick(); expect(lastFrame()).not.toContain('/clear'); - expect(lastFrame()).toContain('Ask anything... (/ commands, @ files)'); + expect(lastFrame()).toContain( + 'Ask anything... (/ commands, @ files, Ctrl+V images)', + ); }); it('resets prompt history state when the session changes', async () => { @@ -654,7 +857,9 @@ describe('ChatInput', () => { ); await time.tick(); - expect(lastFrame()).toContain('Ask anything... (/ commands, @ files)'); + expect(lastFrame()).toContain( + 'Ask anything... (/ commands, @ files, Ctrl+V images)', + ); stdin.write(KEY.UP); await time.tick(); diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index e67f5ac2..73876c3c 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -2,8 +2,15 @@ import { Box, Text, useApp, useInput } from 'ink'; import { useCallback, useEffect, useRef, useState } from 'react'; import { TextInput } from '@/components/TextInput'; -import { COMMAND, UI } from '@/constants'; - +import { COMMAND, KEY, THEME, UI } from '@/constants'; +import type { ThemeDefinition } from '@/types'; +import { clipboard } from '@/utils'; + +import { + type Attachment, + extractImageAttachments, + getAttachmentLabel, +} from './attachments'; import { CommandMenu } from './CommandMenu'; import { FileSuggestions } from './FileSuggestions'; @@ -11,7 +18,13 @@ interface Props { history: string[]; isDisabled?: boolean; onInterrupt?: () => void; - onSubmit: (value: string) => void; + onSubmit: (value: SubmittedInput) => void; + theme?: ThemeDefinition; +} + +export interface SubmittedInput { + content: string; + images?: string[]; } interface FileSuggestionRef { @@ -24,11 +37,29 @@ function hasFileSuggestionQuery(input: string): boolean { return /(^|.)@\S+/.test(input); } +function toAttachment(path: string, index: number, isTemp = false): Attachment { + return { + id: `${path}-${String(index)}`, + isTemp, + label: getAttachmentLabel(path), + path, + }; +} + +function cleanupAttachments(attachments: Attachment[]) { + for (const attachment of attachments) { + if (attachment.isTemp) { + clipboard.removeClipboardImage(attachment.path); + } + } +} + export function ChatInput({ history: sessionHistory, isDisabled = false, onInterrupt, onSubmit, + theme = THEME.getTheme(), }: Props) { const { exit } = useApp(); const [history, setHistory] = useState(sessionHistory); @@ -37,36 +68,90 @@ export function ChatInput({ const [cursorPosition, setCursorPosition] = useState( undefined, ); + const [attachments, setAttachments] = useState([]); + const [error, setError] = useState(null); const fileSuggestionRef = useRef(null); + const nextClipboardImageRef = useRef(1); + const hasAttachments = attachments.length > 0; useEffect(() => { setHistory(sessionHistory); setHistoryIndex(null); setInput(''); setCursorPosition(undefined); + setError(null); fileSuggestionRef.current = null; + nextClipboardImageRef.current = 1; + setAttachments((previousAttachments) => { + cleanupAttachments(previousAttachments); + return []; + }); }, [sessionHistory]); - const resetInput = useCallback(() => { + const resetInput = useCallback((deleteTempAttachments = false) => { setInput(''); setCursorPosition(undefined); setHistoryIndex(null); + setError(null); + + if (deleteTempAttachments) { + setAttachments((previousAttachments) => { + cleanupAttachments(previousAttachments); + return []; + }); + nextClipboardImageRef.current = 1; + return; + } + + setAttachments([]); }, []); + const removeLastAttachment = useCallback(() => { + setAttachments((previousAttachments) => { + const removedAttachment = previousAttachments.at(-1); + if (removedAttachment?.isTemp) { + clipboard.removeClipboardImage(removedAttachment.path); + } + + return previousAttachments.slice(0, -1); + }); + setError(null); + }, []); + + const stageAttachments = useCallback((paths: string[], isTemp = false) => { + setAttachments((previousAttachments) => [ + ...previousAttachments, + ...paths.map((path, index) => + toAttachment(path, previousAttachments.length + index, isTemp), + ), + ]); + setError(null); + }, []); + + const attachClipboardImage = useCallback(() => { + try { + const path = clipboard.saveClipboardImage( + `image-${String(nextClipboardImageRef.current)}`, + ); + nextClipboardImageRef.current += 1; + stageAttachments([path], true); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } + }, [stageAttachments]); + const handleSelectFileSuggestion = useCallback( (nextInput: FileSuggestionRef) => { setInput(nextInput.value); setCursorPosition(nextInput.cursorPosition); + setError(null); }, [], ); const handleFileSuggestionChange = useCallback( (nextInput: string | null) => { - // Calculate cursor position: end of the file path (before any suffix) if (nextInput) { - // Find where the suffix starts (after the inserted file path) - // Cursor position is right after the inserted file path const mentionMatch = /(^|.)@(\S+)/.exec(input); // v8 ignore start @@ -74,10 +159,11 @@ export function ChatInput({ const prefixLength = mentionMatch.index + mentionMatch[1].length; const queryLength = mentionMatch[2].length; const suffix = input.slice(prefixLength + 1 + queryLength); - - // Cursor is at end of nextInput minus suffix length - const cursorPosition = nextInput.length - suffix.length; - fileSuggestionRef.current = { value: nextInput, cursorPosition }; + const nextCursorPosition = nextInput.length - suffix.length; + fileSuggestionRef.current = { + value: nextInput, + cursorPosition: nextCursorPosition, + }; } else { fileSuggestionRef.current = { value: nextInput, @@ -92,26 +178,50 @@ export function ChatInput({ [input], ); - const handleInputChange = useCallback((nextInput: string) => { - setInput(nextInput); - setHistoryIndex(null); - }, []); + const handleInputChange = useCallback( + (nextInput: string) => { + const didPaste = nextInput.length - input.length > 1; + + if (didPaste) { + const { attachments: nextAttachments, remainingInput } = + extractImageAttachments(nextInput); + + if (nextAttachments.length) { + stageAttachments(nextAttachments); + setInput(remainingInput); + setCursorPosition(remainingInput.length); + setHistoryIndex(null); + return; + } + } + + setInput(nextInput); + setHistoryIndex(null); + setError(null); + }, + [input, stageAttachments], + ); const submitAndReset = useCallback( (input: string) => { const trimmedInput = input.trim(); - if (!trimmedInput) { + const imagePaths = attachments.map(({ path }) => path); + + if (!trimmedInput && !imagePaths.length) { return; } - onSubmit(trimmedInput); - if (!trimmedInput.startsWith('/')) { + onSubmit({ + content: trimmedInput, + ...(imagePaths.length ? { images: imagePaths } : {}), + }); + if (trimmedInput && !trimmedInput.startsWith('/')) { setHistory((previousHistory) => [...previousHistory, trimmedInput]); } - resetInput(); + resetInput(trimmedInput.startsWith('/')); fileSuggestionRef.current = null; }, - [onSubmit, resetInput], + [attachments, onSubmit, resetInput], ); const showCommandMenu = input.startsWith('/'); @@ -119,7 +229,7 @@ export function ChatInput({ const handleHistoryNavigation = useCallback( (direction: 'up' | 'down') => { - if (!history.length || showFileSuggestions) { + if (!history.length || showFileSuggestions || hasAttachments) { return; } @@ -166,16 +276,16 @@ export function ChatInput({ setInput(nextInput); setCursorPosition(nextInput.length); }, - [history, historyIndex, input, showFileSuggestions], + [hasAttachments, history, historyIndex, input, showFileSuggestions], ); const handleSubmitText = useCallback( - (input: string) => { - if (input.startsWith('/')) { + (value: string) => { + if (value.startsWith('/')) { return; } - if (hasFileSuggestionQuery(input)) { + if (hasFileSuggestionQuery(value)) { if (fileSuggestionRef.current) { handleSelectFileSuggestion(fileSuggestionRef.current); } @@ -183,15 +293,15 @@ export function ChatInput({ return; } - submitAndReset(input); + submitAndReset(value); }, [handleSelectFileSuggestion, submitAndReset], ); const handleSubmitCommand = useCallback( - (input: string) => { - if (COMMAND.LIST.find(({ name }) => name === input)) { - submitAndReset(input); + (value: string) => { + if (COMMAND.LIST.find(({ name }) => name === value)) { + submitAndReset(value); } }, [submitAndReset], @@ -207,6 +317,18 @@ export function ChatInput({ return; } + if (key.ctrl && inputKey === 'v') { + attachClipboardImage(); + return; + } + + if ((key.backspace || key.delete || inputKey === KEY.BACKSPACE) && !input) { + if (hasAttachments) { + removeLastAttachment(); + } + return; + } + if (isCtrlC) { if (input) { resetInput(); @@ -226,19 +348,44 @@ export function ChatInput({ } }); + const attachmentPrefix = attachments + .map(({ label }) => `[${label}]`) + .join(' '); + + const wrapIndent = + UI.PROMPT_PREFIX.length + + (attachmentPrefix ? attachmentPrefix.length + 1 : 0); + return ( + {error && ( + + {error} + + )} + {UI.PROMPT_PREFIX} + {hasAttachments && ( + <> + {attachmentPrefix} + + + )} + diff --git a/src/components/Chat/attachments.test.ts b/src/components/Chat/attachments.test.ts new file mode 100644 index 00000000..ac5e177c --- /dev/null +++ b/src/components/Chat/attachments.test.ts @@ -0,0 +1,86 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; + +import { + extractImageAttachments, + getAttachmentLabel, + isReadableImagePath, + resolveAttachmentPath, +} from './attachments'; + +describe('attachments', () => { + let testDirectory = ''; + const originalCwd = process.cwd(); + + beforeEach(() => { + testDirectory = mkdtempSync(join(tmpdir(), 'code-ollama-attachments-')); + mkdirSync(join(testDirectory, 'nested'), { recursive: true }); + writeFileSync(join(testDirectory, 'diagram.png'), 'png'); + writeFileSync(join(testDirectory, 'nested', 'mockup.jpg'), 'jpg'); + process.chdir(testDirectory); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(testDirectory, { force: true, recursive: true }); + }); + + it('returns a basename label for attachments', () => { + expect(getAttachmentLabel('/tmp/path/mockup.png')).toBe('mockup.png'); + }); + + it('detects readable image paths', () => { + expect(isReadableImagePath('./diagram.png')).toBe(true); + expect(isReadableImagePath('./missing.png')).toBe(false); + expect(isReadableImagePath('./diagram.txt')).toBe(false); + }); + + it('resolves relative attachment paths', () => { + expect(resolveAttachmentPath('./diagram.png')).toContain('/diagram.png'); + }); + + it('extracts pasted image paths and leaves prompt text behind', () => { + const result = extractImageAttachments( + './diagram.png compare this with ./nested/mockup.jpg', + ); + + expect(result.remainingInput).toBe('compare this with'); + expect(result.attachments.map((path) => basename(path))).toEqual([ + 'diagram.png', + 'mockup.jpg', + ]); + }); + + it('supports quoted image paths with spaces', () => { + writeFileSync(join(testDirectory, 'wireframe final.png'), 'png'); + + const result = extractImageAttachments( + '"./wireframe final.png" explain this screen', + ); + + expect(result).toMatchObject({ + remainingInput: 'explain this screen', + }); + expect(result.attachments.map((path) => basename(path))).toEqual([ + 'wireframe final.png', + ]); + }); + + it('ignores missing image paths that only look like attachments', () => { + expect(extractImageAttachments('"./missing.png" explain this')).toEqual({ + attachments: [], + remainingInput: '"./missing.png" explain this', + }); + }); + + it('keeps non-image input untouched', () => { + expect( + extractImageAttachments('mention diagram.png in the answer'), + ).toEqual({ + attachments: [], + remainingInput: 'mention diagram.png in the answer', + }); + }); +}); diff --git a/src/components/Chat/attachments.ts b/src/components/Chat/attachments.ts new file mode 100644 index 00000000..d3ca4b68 --- /dev/null +++ b/src/components/Chat/attachments.ts @@ -0,0 +1,126 @@ +import { existsSync, statSync } from 'node:fs'; +import { basename, extname, isAbsolute, resolve } from 'node:path'; + +export interface Attachment { + id: string; + isTemp: boolean; + label: string; + path: string; +} + +export interface ExtractedAttachments { + attachments: string[]; + remainingInput: string; +} + +const IMAGE_EXTENSIONS = new Set([ + '.avif', + '.bmp', + '.gif', + '.heic', + '.heif', + '.jpeg', + '.jpg', + '.png', + '.tif', + '.tiff', + '.webp', +]); + +const PATH_CANDIDATE_PATTERN = + /"([^"\n\r]+\.(?:avif|bmp|gif|heic|heif|jpeg|jpg|png|tif|tiff|webp))"|'([^'\n\r]+\.(?:avif|bmp|gif|heic|heif|jpeg|jpg|png|tif|tiff|webp))'|([^\s"'`]+\.(?:avif|bmp|gif|heic|heif|jpeg|jpg|png|tif|tiff|webp))/gi; + +function normalizeCandidatePath(value: string): string { + return value.replaceAll(String.raw`\ `, ' '); +} + +function isPathLikeCandidate(candidate: string, matchedValue: string): boolean { + return ( + matchedValue.startsWith('"') || + matchedValue.startsWith("'") || + candidate.includes('/') || + candidate.includes('\\') || + candidate.startsWith('.') + ); +} + +export function getAttachmentLabel(path: string): string { + return basename(path); +} + +export function isReadableImagePath(path: string): boolean { + const normalizedPath = normalizeCandidatePath(path); + const extension = extname(normalizedPath).toLowerCase(); + + if (!IMAGE_EXTENSIONS.has(extension)) { + return false; + } + + const resolvedPath = isAbsolute(normalizedPath) + ? normalizedPath + : resolve(normalizedPath); + + if (!existsSync(resolvedPath)) { + return false; + } + + try { + return statSync(resolvedPath).isFile(); + } catch { + // v8 ignore next + return false; + } +} + +export function resolveAttachmentPath(path: string): string { + const normalizedPath = normalizeCandidatePath(path); + return isAbsolute(normalizedPath) ? normalizedPath : resolve(normalizedPath); +} + +export function extractImageAttachments(input: string): ExtractedAttachments { + const attachments: string[] = []; + const segments: string[] = []; + let lastIndex = 0; + + for (const match of input.matchAll(PATH_CANDIDATE_PATTERN)) { + const matchedValue = match[0]; + const candidate = match + .slice(1) + .find((value): value is string => Boolean(value)); + + // v8 ignore start + if (candidate === undefined) { + continue; + } + // v8 ignore stop + + if (!isPathLikeCandidate(candidate, matchedValue)) { + continue; + } + + if (!isReadableImagePath(candidate)) { + continue; + } + + attachments.push(resolveAttachmentPath(candidate)); + segments.push(input.slice(lastIndex, match.index)); + lastIndex = match.index + matchedValue.length; + } + + if (!attachments.length) { + return { + attachments, + remainingInput: input, + }; + } + + segments.push(input.slice(lastIndex)); + + return { + attachments, + remainingInput: segments + .join('') + .replaceAll(/\s{2,}/g, ' ') + .trim(), + }; +} diff --git a/src/components/Messages/Message.tsx b/src/components/Messages/Message.tsx index f3ac9af2..da21a18a 100644 --- a/src/components/Messages/Message.tsx +++ b/src/components/Messages/Message.tsx @@ -55,6 +55,30 @@ export function Message({ message, isStreaming = false, theme }: Props) { ); } + if (isUser) { + const attachments = message.images ?? []; + // v8 ignore start + const attachmentPrefix = attachments + .map((path) => `[${path.split(/[\\/]/).at(-1) ?? path}]`) + .join(' '); + // v8 ignore stop + + return ( + + + {UI.PROMPT_PREFIX} + {attachmentPrefix ? ( + <> + {attachmentPrefix} + {message.content ? ' ' : ''} + + ) : null} + {message.content} + + + ); + } + const segments = parseContent(message.content); const availableWidth = getAssistantContentWidth(stdout.columns); @@ -102,16 +126,8 @@ export function Message({ message, isStreaming = false, theme }: Props) { return ( {segments.map((segment, index) => { - const isFirstSegment = index === 0; - const prefix = isUser && isFirstSegment ? UI.PROMPT_PREFIX : ''; - - // Code blocks: only render for assistant if (segment.type === 'code') { - return isUser ? ( - - {segment.content} - - ) : ( + return ( - {prefix + segment.content} - - ) : ( + return ( {textParts.map((part, partIndex) => ( diff --git a/src/components/Messages/Messages.test.tsx b/src/components/Messages/Messages.test.tsx index 2e89364f..20af3bc8 100644 --- a/src/components/Messages/Messages.test.tsx +++ b/src/components/Messages/Messages.test.tsx @@ -36,6 +36,12 @@ const userMessage: { role: Role; content: string } = { content: 'hello', }; +const userMessageWithImage: Message = { + role: ROLE.USER, + content: 'hello', + images: ['/tmp/design.png'], +}; + const assistantMessage: { role: Role; content: string } = { role: ROLE.ASSISTANT, content: 'world', @@ -85,6 +91,54 @@ describe('Messages', () => { expect(lastFrame()).toContain(`${UI.PROMPT_PREFIX}hello`); }); + it('renders user attachment filenames inline', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('[design.png]'); + expect(lastFrame()).toContain('hello'); + }); + + it('renders user attachment without content and no extra space', () => { + const messageWithImageOnly: Message = { + role: ROLE.USER, + content: '', + images: ['/tmp/design.png'], + }; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('[design.png]'); + // Should not have trailing space after attachment when no content + // The frame should contain "[design.png]" followed by newline, not "[design.png] " + expect(frame).not.toContain('[design.png] '); + }); + + it('handles empty image path by showing original path', () => { + const messageWithEmptyPath: Message = { + role: ROLE.USER, + content: 'test', + images: [''], + }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('[]'); + }); + it('renders assistant message without prompt prefix', () => { const { lastFrame } = render( , diff --git a/src/components/PlanApproval/PlanApproval.tsx b/src/components/PlanApproval/PlanApproval.tsx index ab89738e..a5ec075f 100644 --- a/src/components/PlanApproval/PlanApproval.tsx +++ b/src/components/PlanApproval/PlanApproval.tsx @@ -1,7 +1,7 @@ import { Box, Text } from 'ink'; import { useCallback } from 'react'; -import { MODE, THEME } from '@/constants'; +import { MODE, THEME, UI } from '@/constants'; import type { Mode, ThemeDefinition } from '@/types'; import { SelectPrompt, SelectPromptHint } from '../SelectPrompt'; @@ -35,7 +35,7 @@ export function PlanApproval({ }, [onModeChange]); return ( - + { await time.tick(); }); + it('ignores unhandled ctrl keys', async () => { + const onChange = vi.fn(); + const { stdin } = render( + , + ); + // Ctrl+B is not handled, should be ignored + stdin.write('\x02'); + await time.tick(); + expect(onChange).not.toHaveBeenCalled(); + }); + it('keeps cursor position when typing after moving left', async () => { const onChange = vi.fn(); const { stdin } = render( diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index 2413d1e8..722b3a00 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -175,6 +175,10 @@ export function TextInput({ return; } + if (key.ctrl) { + return; + } + // v8 ignore start if (input) { const newValue = diff --git a/src/components/ToolApproval/ToolApproval.tsx b/src/components/ToolApproval/ToolApproval.tsx index 206f4b18..e9321265 100644 --- a/src/components/ToolApproval/ToolApproval.tsx +++ b/src/components/ToolApproval/ToolApproval.tsx @@ -37,7 +37,7 @@ export function ToolApproval({ const args = JSON.stringify(toolCall.function.arguments, null, 2); return ( - + ({ + mockExecFileSync: vi.fn(), + mockExistsSync: vi.fn(), + mockMkdirSync: vi.fn(), + mockRandomUUID: vi.fn(() => 'test-uuid'), + mockRmSync: vi.fn(), + mockSpawnSync: vi.fn(), + mockTmpdir: '/tmp/code-ollama-tests', + mockWriteFileSync: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFileSync: mockExecFileSync, + spawnSync: mockSpawnSync, +})); + +vi.mock('node:crypto', () => ({ + randomUUID: mockRandomUUID, +})); + +vi.mock('node:os', async () => ({ + ...(await vi.importActual('node:os')), + tmpdir: () => mockTmpdir, +})); + +vi.mock('node:fs', async () => ({ + ...(await vi.importActual('node:fs')), + existsSync: mockExistsSync, + mkdirSync: mockMkdirSync, + rmSync: mockRmSync, + writeFileSync: mockWriteFileSync, +})); + +describe('clipboard', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + mockExecFileSync.mockImplementation(() => Buffer.alloc(0)); + mockExistsSync.mockReturnValue(false); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('writes macOS clipboard images to the temp image directory', async () => { + const { saveClipboardImage, TEMP_IMAGES_DIRECTORY } = + await import('./clipboard'); + + expect(saveClipboardImage('image-1')).toBe( + join(TEMP_IMAGES_DIRECTORY, 'test-uuid.png'), + ); + expect(mockMkdirSync).toHaveBeenCalledWith(TEMP_IMAGES_DIRECTORY, { + recursive: true, + }); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'osascript', + expect.any(Array), + { stdio: 'ignore' }, + ); + }); + + it('writes linux clipboard images from wl-paste output', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + mockSpawnSync.mockReturnValueOnce({ + status: 0, + stdout: Buffer.from('png'), + }); + const { saveClipboardImage, TEMP_IMAGES_DIRECTORY } = + await import('./clipboard'); + + expect(saveClipboardImage('image-2')).toBe( + join(TEMP_IMAGES_DIRECTORY, 'test-uuid.png'), + ); + expect(mockWriteFileSync).toHaveBeenCalledWith( + join(TEMP_IMAGES_DIRECTORY, 'test-uuid.png'), + Buffer.from('png'), + { flag: 'wx', mode: 0o600 }, + ); + }); + + it('throws a fallback error when no linux clipboard tool returns an image', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + mockSpawnSync.mockReturnValue({ status: 1, stdout: Buffer.alloc(0) }); + const { saveClipboardImage } = await import('./clipboard'); + + expect(() => saveClipboardImage('image-3')).toThrow( + 'Clipboard image paste failed. Paste an image path instead.', + ); + }); + + it('falls back to xclip when wl-paste is empty', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + mockSpawnSync + .mockReturnValueOnce({ status: 1, stdout: Buffer.alloc(0) }) + .mockReturnValueOnce({ status: 0, stdout: Buffer.from('png') }); + const { saveClipboardImage, TEMP_IMAGES_DIRECTORY } = + await import('./clipboard'); + + expect(saveClipboardImage('image-4')).toBe( + join(TEMP_IMAGES_DIRECTORY, 'test-uuid.png'), + ); + }); + + it('writes windows clipboard images to png files', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + const { saveClipboardImage, TEMP_IMAGES_DIRECTORY } = + await import('./clipboard'); + + expect(saveClipboardImage('image-5')).toBe( + join(TEMP_IMAGES_DIRECTORY, 'test-uuid.png'), + ); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'powershell', + expect.any(Array), + { stdio: 'ignore' }, + ); + }); + + it('throws a specific error when windows clipboard has no image', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + mockExecFileSync.mockImplementationOnce(() => { + const error = new Error('empty clipboard') as Error & { status: number }; + error.status = 11; + throw error; + }); + const { saveClipboardImage } = await import('./clipboard'); + + expect(() => saveClipboardImage('image-6')).toThrow( + 'Clipboard does not contain an image.', + ); + }); + + it('cleans up partial files when clipboard read throws', async () => { + mockExecFileSync.mockImplementationOnce(() => { + throw new Error('Clipboard failed'); + }); + mockExistsSync.mockReturnValue(true); + const { saveClipboardImage, TEMP_IMAGES_DIRECTORY } = + await import('./clipboard'); + + expect(() => saveClipboardImage('image-7')).toThrow( + 'Clipboard image paste failed. Paste an image path instead.', + ); + expect(mockRmSync).toHaveBeenCalledWith( + join(TEMP_IMAGES_DIRECTORY, 'image-7.png'), + { force: true }, + ); + }); + + it('uses the fallback clipboard error message when the source error is blank', async () => { + mockExecFileSync.mockImplementationOnce(() => { + throw new Error(''); + }); + const { saveClipboardImage } = await import('./clipboard'); + + expect(() => saveClipboardImage('image-8')).toThrow( + 'Clipboard image paste failed. Paste an image path instead.', + ); + }); + + it('maps macOS clipboard-image absence to a friendly message', async () => { + mockExecFileSync.mockImplementationOnce(() => { + throw new Error( + 'Command failed: osascript\nClipboard does not contain an image', + ); + }); + mockExistsSync.mockReturnValue(true); + const { saveClipboardImage, TEMP_IMAGES_DIRECTORY } = + await import('./clipboard'); + + expect(() => saveClipboardImage('image-10')).toThrow( + 'Clipboard does not contain an image.', + ); + expect(mockRmSync).toHaveBeenCalledWith( + join(TEMP_IMAGES_DIRECTORY, 'image-10.png'), + { force: true }, + ); + }); + + it('re-throws non-Error value after cleaning up file', async () => { + mockExecFileSync.mockImplementationOnce(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'String error'; + }); + mockExistsSync.mockReturnValue(true); + const { saveClipboardImage, TEMP_IMAGES_DIRECTORY } = + await import('./clipboard'); + + expect(() => saveClipboardImage('image-11')).toThrow('String error'); + expect(mockRmSync).toHaveBeenCalledWith( + join(TEMP_IMAGES_DIRECTORY, 'image-11.png'), + { force: true }, + ); + }); + + it('throws for unsupported platforms', async () => { + Object.defineProperty(process, 'platform', { + value: 'freebsd', + configurable: true, + }); + const { saveClipboardImage } = await import('./clipboard'); + + expect(() => saveClipboardImage('image-9')).toThrow( + 'Clipboard image paste failed. Paste an image path instead.', + ); + }); + + it('removes temp clipboard images when asked', async () => { + const path = join(mockTmpdir, 'code-ollama', 'images', 'image-1.png'); + mockExistsSync.mockReturnValue(true); + const { removeClipboardImage } = await import('./clipboard'); + + removeClipboardImage(path); + + expect(mockRmSync).toHaveBeenCalledWith(path, { force: true }); + }); + + it('skips clipboard image cleanup when the file is already gone', async () => { + const path = join(mockTmpdir, 'code-ollama', 'images', 'image-2.png'); + mockExistsSync.mockReturnValue(false); + const { removeClipboardImage } = await import('./clipboard'); + + removeClipboardImage(path); + + expect(mockRmSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts new file mode 100644 index 00000000..59c343a0 --- /dev/null +++ b/src/utils/clipboard.ts @@ -0,0 +1,150 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export const TEMP_IMAGES_DIRECTORY = join(tmpdir(), 'code-ollama', 'images'); + +const WINDOWS_CLIPBOARD_EXIT_CODE = 11; + +function ensureTempDirectory(directory: string): string { + mkdirSync(directory, { recursive: true }); + return directory; +} + +function buildTargetPath(directory: string, extension: string) { + const uniqueName = `${randomUUID()}.${extension}`; + return join(ensureTempDirectory(directory), uniqueName); +} + +function readMacClipboardImage(path: string): void { + const script = ` +set outputPath to POSIX file ${JSON.stringify(path)} +try + set clipboardData to the clipboard as «class PNGf» +on error + error "Clipboard does not contain an image" +end try +set fileHandle to open for access outputPath with write permission +try + set eof fileHandle to 0 + write clipboardData to fileHandle +on error errorMessage + close access fileHandle + error errorMessage +end try +close access fileHandle +`; + + execFileSync('osascript', ['-e', script], { stdio: 'ignore' }); +} + +function getClipboardErrorMessage(error: Error): string { + if (error.message.includes('Clipboard does not contain an image')) { + return 'Clipboard does not contain an image.'; + } + + return 'Clipboard image paste failed. Paste an image path instead.'; +} + +function readWindowsClipboardImage(path: string): void { + const script = ` +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$image = [Windows.Forms.Clipboard]::GetImage() +if ($null -eq $image) { exit ${String(WINDOWS_CLIPBOARD_EXIT_CODE)} } +$image.Save($args[0], [System.Drawing.Imaging.ImageFormat]::Png) +`; + + execFileSync('powershell', ['-NoProfile', '-Command', script, path], { + stdio: 'ignore', + }); +} + +function readLinuxClipboardImage(directory: string): string { + const wlPng = spawnSync('wl-paste', ['--no-newline', '--type', 'image/png'], { + encoding: 'buffer', + }); + if (wlPng.status === 0 && wlPng.stdout.length > 0) { + const path = buildTargetPath(directory, 'png'); + writeClipboardImageFile(path, wlPng.stdout); + return path; + } + + const xclipPng = spawnSync( + 'xclip', + ['-selection', 'clipboard', '-t', 'image/png', '-o'], + { encoding: 'buffer' }, + ); + if (xclipPng.status === 0 && xclipPng.stdout.length > 0) { + const path = buildTargetPath(directory, 'png'); + writeClipboardImageFile(path, xclipPng.stdout); + return path; + } + + throw new Error( + 'Clipboard image paste is unavailable. Paste an image path instead.', + ); +} + +function writeClipboardImageFile(path: string, data: Buffer): void { + writeFileSync(path, data, { flag: 'wx', mode: 0o600 }); +} + +export function saveClipboardImage( + baseName: string, + directory = TEMP_IMAGES_DIRECTORY, +): string { + try { + switch (process.platform) { + case 'darwin': { + const path = buildTargetPath(directory, 'png'); + readMacClipboardImage(path); + return path; + } + + case 'win32': { + const path = buildTargetPath(directory, 'png'); + readWindowsClipboardImage(path); + return path; + } + + case 'linux': + return readLinuxClipboardImage(directory); + + default: + throw new Error( + 'Clipboard image paste is not supported on this platform. Paste an image path instead.', + ); + } + } catch (error) { + if ( + error instanceof Error && + 'status' in error && + error.status === WINDOWS_CLIPBOARD_EXIT_CODE + ) { + throw new Error('Clipboard does not contain an image.', { + cause: error, + }); + } + + const path = join(directory, `${baseName}.png`); + if (existsSync(path)) { + rmSync(path, { force: true }); + } + + if (error instanceof Error) { + throw new Error(getClipboardErrorMessage(error), { cause: error }); + } + + // v8 ignore next + throw error; + } +} + +export function removeClipboardImage(path: string): void { + if (existsSync(path)) { + rmSync(path, { force: true }); + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 17844c10..873a15b8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * as agents from './agents'; +export * as clipboard from './clipboard'; export * as config from './config'; export * as ollama from './ollama'; export * as screen from './screen'; diff --git a/src/utils/ollama.test.ts b/src/utils/ollama.test.ts index 17ad3ad2..f241f248 100644 --- a/src/utils/ollama.test.ts +++ b/src/utils/ollama.test.ts @@ -97,6 +97,33 @@ describe('ollama', () => { } expect(results).toEqual([{ type: 'content', content: 'Hello' }]); + expect(mockChat).toHaveBeenCalledWith({ + model: 'codellama', + messages, + stream: true, + tools: undefined, + }); + }); + + it('passes image attachments through to the chat request', async () => { + const messages = [ + { + role: 'user' as const, + content: 'describe this', + images: ['/tmp/a.png'], + }, + ]; + + for await (const chunk of streamChat(messages, 'codellama')) { + void chunk; + } + + expect(mockChat).toHaveBeenCalledWith({ + model: 'codellama', + messages, + stream: true, + tools: undefined, + }); }); it('skips chunks with empty content', async () => { diff --git a/src/utils/ollama.ts b/src/utils/ollama.ts index 628016ae..77719bdc 100644 --- a/src/utils/ollama.ts +++ b/src/utils/ollama.ts @@ -11,6 +11,7 @@ const client = new Ollama({ host }); export interface Message { role: Role; content: string; + images?: string[]; tool_calls?: ToolCall[]; } diff --git a/src/utils/session.test.ts b/src/utils/session.test.ts index e5b9545b..b7fb5bbf 100644 --- a/src/utils/session.test.ts +++ b/src/utils/session.test.ts @@ -76,6 +76,30 @@ describe('session', () => { ]); }); + it('persists image attachments in session messages', async () => { + const { appendMessage, createSession, loadSession } = + await import('./session'); + const session = createSession('gemma4'); + + appendMessage( + session.metadata.id, + { + role: 'user', + content: 'Review this screenshot', + images: ['/tmp/code-ollama/images/image-1.png'], + }, + 'gemma4', + ); + + expect(loadSession(session.metadata.id).messages).toEqual([ + { + role: 'user', + content: 'Review this screenshot', + images: ['/tmp/code-ollama/images/image-1.png'], + }, + ]); + }); + it('lists sessions sorted by most recently updated', async () => { const { createSession, listSessions } = await import('./session'); const first = createSession('gemma4');