diff --git a/src/components/Chat/ChatInput.test.tsx b/src/components/Chat/ChatInput.test.tsx index 8b6751b2..cfb810ac 100644 --- a/src/components/Chat/ChatInput.test.tsx +++ b/src/components/Chat/ChatInput.test.tsx @@ -21,6 +21,7 @@ const { mockClipboard } = vi.hoisted(() => ({ mockClipboard: { removeClipboardImage: vi.fn(), saveClipboardImage: vi.fn(), + TEMP_IMAGES_DIRECTORY: '', }, })); @@ -360,8 +361,9 @@ describe('ChatInput', () => { mockTextInput.mockReset(); mockClipboard.removeClipboardImage.mockReset(); mockClipboard.saveClipboardImage.mockReset(); + mockClipboard.TEMP_IMAGES_DIRECTORY = join(testDirectory, 'clipboard'); mockClipboard.saveClipboardImage.mockReturnValue( - join(testDirectory, 'image-1.png'), + join(testDirectory, 'clipboard', 'image-1.png'), ); }); @@ -660,6 +662,47 @@ describe('ChatInput', () => { }); }); + it('stages a dropped image adjacent to existing input text', async () => { + const { lastFrame } = renderInput(); + const initialInputProps = mockTextInput.mock.calls.at(-1)?.[0] as + { onChange?: (value: string) => void } | undefined; + initialInputProps?.onChange?.('describe this'); + await time.tick(); + + const updatedInputProps = mockTextInput.mock.calls.at(-1)?.[0] as + { onChange?: (value: string) => void } | undefined; + updatedInputProps?.onChange?.( + `describe this${join(testDirectory, 'screen.png')}`, + ); + await time.tick(); + + expect(lastFrame()).toContain('[screen.png]'); + expect(lastFrame()).toContain('describe this'); + expect(lastFrame()).not.toContain(join(testDirectory, 'screen.png')); + }); + + it('preserves text and cursor position around a dropped image', async () => { + const { lastFrame } = renderInput(); + const initialInputProps = mockTextInput.mock.calls.at(-1)?.[0] as + { onChange?: (value: string) => void } | undefined; + initialInputProps?.onChange?.('beforeafter'); + await time.tick(); + + const updatedInputProps = mockTextInput.mock.calls.at(-1)?.[0] as + { onChange?: (value: string) => void } | undefined; + updatedInputProps?.onChange?.( + `before${join(testDirectory, 'screen.png')}after`, + ); + await time.tick(); + + expect(lastFrame()).toContain('[screen.png]'); + expect(lastFrame()).toContain('beforeafter'); + expect(mockTextInput.mock.calls.at(-1)?.[0]).toMatchObject({ + cursorPosition: 'before'.length, + value: 'beforeafter', + }); + }); + it('stages a clipboard image on Ctrl+V', async () => { const { lastFrame, stdin } = renderInput(); @@ -667,7 +710,7 @@ describe('ChatInput', () => { await time.tick(); expect(clipboard.saveClipboardImage).toHaveBeenCalledWith('image-1'); - expect(lastFrame()).toContain('[image-1.png]'); + expect(lastFrame()).toContain('[Image 1]'); }); it('shows an error when staging a clipboard image via Ctrl+V while a turn is active', async () => { @@ -700,7 +743,7 @@ describe('ChatInput', () => { stdin.write('\x16'); await time.tick(); - expect(lastFrame()).toContain('[image-1.png]'); + expect(lastFrame()).toContain('[Image 1]'); expect(lastFrame()).not.toContain( 'Ask anything... (/ commands, @ files, ! shell, Ctrl+V images)', ); @@ -740,14 +783,14 @@ describe('ChatInput', () => { stdin.write('\x16'); await time.tick(); - expect(lastFrame()).toContain('[image-1.png]'); + expect(lastFrame()).toContain('[Image 1]'); stdin.write(KEY.BACKSPACE); await time.tick(); - expect(lastFrame()).not.toContain('[image-1.png]'); + expect(lastFrame()).not.toContain('[Image 1]'); expect(clipboard.removeClipboardImage).toHaveBeenCalledWith( - join(testDirectory, 'image-1.png'), + join(testDirectory, 'clipboard', 'image-1.png'), ); }); @@ -773,7 +816,7 @@ describe('ChatInput', () => { await time.tick(); expect(clipboard.removeClipboardImage).toHaveBeenCalledWith( - join(testDirectory, 'image-1.png'), + join(testDirectory, 'clipboard', 'image-1.png'), ); }); @@ -1392,7 +1435,7 @@ describe('ChatInput', () => { stdin.write(KEY.CTRL_R); await time.tick(); - expect(lastFrame()).toContain('[image-1.png]'); + expect(lastFrame()).toContain('[Image 1]'); expect(lastFrame()).not.toContain('bck-i-search'); }); diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index e4ca7940..020aea6f 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -9,7 +9,7 @@ import { clipboard } from '@/utils'; import { type Attachment, extractImageAttachments, - getAttachmentLabel, + getAttachmentLabels, } from './attachments'; import { CommandMenu, @@ -43,11 +43,42 @@ function hasFileSuggestionQuery(input: string): boolean { return /(^|.)@\S+/.test(input); } +function getInsertedText(previousInput: string, nextInput: string) { + let prefixLength = 0; + const maximumPrefixLength = Math.min(previousInput.length, nextInput.length); + + while ( + prefixLength < maximumPrefixLength && + previousInput[prefixLength] === nextInput[prefixLength] + ) { + prefixLength += 1; + } + + let suffixLength = 0; + const maximumSuffixLength = previousInput.length - prefixLength; + + while ( + suffixLength < maximumSuffixLength && + previousInput[previousInput.length - suffixLength - 1] === + nextInput[nextInput.length - suffixLength - 1] + ) { + suffixLength += 1; + } + + return { + insertedText: nextInput.slice( + prefixLength, + nextInput.length - suffixLength, + ), + prefix: nextInput.slice(0, prefixLength), + suffix: suffixLength ? nextInput.slice(-suffixLength) : '', + }; +} + function toAttachment(path: string, index: number, isTemp = false): Attachment { return { id: `${path}-${String(index)}`, isTemp, - label: getAttachmentLabel(path), path, }; } @@ -218,8 +249,12 @@ export function ChatInput({ const didPaste = nextInput.length - input.length > 1; if (didPaste) { + const { insertedText, prefix, suffix } = getInsertedText( + input, + nextInput, + ); const { attachments: nextAttachments, remainingInput } = - extractImageAttachments(nextInput); + extractImageAttachments(insertedText); if (nextAttachments.length) { if (isActive) { @@ -228,8 +263,9 @@ export function ChatInput({ } stageAttachments(nextAttachments); - setInput(remainingInput); - setCursorPosition(remainingInput.length); + const inputWithoutAttachments = `${prefix}${remainingInput}${suffix}`; + setInput(inputWithoutAttachments); + setCursorPosition(prefix.length + remainingInput.length); setHistoryIndex(null); resetHistorySearch(); return; @@ -467,8 +503,10 @@ export function ChatInput({ } }); - const attachmentPrefix = attachments - .map(({ label }) => `[${label}]`) + const attachmentPrefix = getAttachmentLabels( + attachments.map(({ path }) => path), + ) + .map((label) => `[${label}]`) .join(' '); const wrapIndent = diff --git a/src/components/Chat/attachments.test.ts b/src/components/Chat/attachments.test.ts index e92e7c4e..660dea88 100644 --- a/src/components/Chat/attachments.test.ts +++ b/src/components/Chat/attachments.test.ts @@ -2,9 +2,12 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; +import { clipboard } from '@/utils'; + import { extractImageAttachments, getAttachmentLabel, + getAttachmentLabels, isReadableImagePath, resolveAttachmentPath, } from './attachments'; @@ -30,6 +33,17 @@ describe('attachments', () => { expect(getAttachmentLabel('/tmp/path/mockup.png')).toBe('mockup.png'); }); + it('numbers clipboard images while preserving file attachment labels', () => { + expect( + getAttachmentLabels([ + join(testDirectory, 'diagram.png'), + join(clipboard.TEMP_IMAGES_DIRECTORY, 'first-uuid.png'), + join(testDirectory, 'nested', 'mockup.jpg'), + join(clipboard.TEMP_IMAGES_DIRECTORY, 'second-uuid.png'), + ]), + ).toEqual(['diagram.png', 'Image 1', 'mockup.jpg', 'Image 2']); + }); + it('detects readable image paths', () => { expect(isReadableImagePath('./diagram.png')).toBe(true); expect(isReadableImagePath('./missing.png')).toBe(false); diff --git a/src/components/Chat/attachments.ts b/src/components/Chat/attachments.ts index 371efd50..cb520f4d 100644 --- a/src/components/Chat/attachments.ts +++ b/src/components/Chat/attachments.ts @@ -1,10 +1,11 @@ import { existsSync, statSync } from 'node:fs'; -import { basename, extname, isAbsolute, resolve } from 'node:path'; +import { basename, extname, isAbsolute, relative, resolve } from 'node:path'; + +import { clipboard } from '@/utils'; export interface Attachment { id: string; isTemp: boolean; - label: string; path: string; } @@ -48,6 +49,28 @@ export function getAttachmentLabel(path: string): string { return basename(path); } +function isClipboardImagePath(path: string): boolean { + const relativePath = relative(clipboard.TEMP_IMAGES_DIRECTORY, path); + return ( + relativePath !== '' && + !relativePath.startsWith('..') && + !isAbsolute(relativePath) + ); +} + +export function getAttachmentLabels(paths: string[]): string[] { + let clipboardImageIndex = 0; + + return paths.map((path) => { + if (!isClipboardImagePath(path)) { + return getAttachmentLabel(path); + } + + clipboardImageIndex += 1; + return `Image ${String(clipboardImageIndex)}`; + }); +} + export function isReadableImagePath(path: string): boolean { const normalizedPath = normalizeCandidatePath(path); const extension = extname(normalizedPath).toLowerCase(); diff --git a/src/components/Messages/Message.tsx b/src/components/Messages/Message.tsx index 06d7c9ac..96ca7871 100644 --- a/src/components/Messages/Message.tsx +++ b/src/components/Messages/Message.tsx @@ -7,6 +7,7 @@ import { ROLE, UI } from '@/constants'; import { useTheme } from '@/contexts'; import type { Message as OllamaMessage } from '@/utils/ollama'; +import { getAttachmentLabels } from '../Chat/attachments'; import { getAssistantContentWidth, getCodeBlockHeight, @@ -112,11 +113,9 @@ export function Message({ if (isUser) { const attachments = message.images ?? []; - // v8 ignore start - const attachmentPrefix = attachments - .map((path) => `[${path.split(/[\\/]/).at(-1) ?? path}]`) + const attachmentPrefix = getAttachmentLabels(attachments) + .map((label) => `[${label}]`) .join(' '); - // v8 ignore stop return ( diff --git a/src/components/Messages/Messages.test.tsx b/src/components/Messages/Messages.test.tsx index a79e63b5..020d337a 100644 --- a/src/components/Messages/Messages.test.tsx +++ b/src/components/Messages/Messages.test.tsx @@ -2,6 +2,7 @@ import { useStdout } from 'ink'; import { ROLE, UI } from '@/constants'; import type { Role } from '@/types'; +import { clipboard } from '@/utils'; import type { Message } from '@/utils/ollama'; import { renderWithTheme } from '@/utils/testing'; @@ -104,6 +105,30 @@ describe('Messages', () => { expect(lastFrame()).toContain('hello'); }); + it('numbers clipboard images while preserving file attachment labels', () => { + const { lastFrame } = renderWithTheme( + , + ); + + expect(lastFrame()).toContain( + '[Image 1] [design.png] [Image 2] compare these', + ); + }); + it('renders user attachment without content and no extra space', () => { const messageWithImageOnly: Message = { role: ROLE.USER,