From a82f2fd679e48538938da30bbf9b79bf0169096c Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 12 Jul 2026 02:07:13 -0400 Subject: [PATCH 1/2] feat(chat): shorten clipboard image attachment labels Display clipboard-pasted temporary images as `[Image 1]`, `[Image 2]`, while preserving actual filenames for drag-and-drop and pasted-path attachments. Keep labels consistent after submission. Changes: - Add a shared attachment-label helper that identifies clipboard images by their location under `TEMP_IMAGES_DIRECTORY`. - Number only clipboard images in attachment order; mixed input should render like `[Image 1] [diagram.png] [Image 2]`. - Use the helper in both `ChatInput` and submitted user-message rendering. - Keep attachment paths, submission payloads, cleanup behavior, and session storage unchanged. - Preserve filename labels for all non-clipboard attachments. --- src/components/Chat/ChatInput.test.tsx | 18 ++++++++------- src/components/Chat/ChatInput.tsx | 9 ++++---- src/components/Chat/attachments.test.ts | 14 ++++++++++++ src/components/Chat/attachments.ts | 27 +++++++++++++++++++++-- src/components/Messages/Message.tsx | 7 +++--- src/components/Messages/Messages.test.tsx | 25 +++++++++++++++++++++ 6 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/components/Chat/ChatInput.test.tsx b/src/components/Chat/ChatInput.test.tsx index 8b6751b2..63a9f725 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'), ); }); @@ -667,7 +669,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 +702,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 +742,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 +775,7 @@ describe('ChatInput', () => { await time.tick(); expect(clipboard.removeClipboardImage).toHaveBeenCalledWith( - join(testDirectory, 'image-1.png'), + join(testDirectory, 'clipboard', 'image-1.png'), ); }); @@ -1392,7 +1394,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..05a3f73c 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, @@ -47,7 +47,6 @@ function toAttachment(path: string, index: number, isTemp = false): Attachment { return { id: `${path}-${String(index)}`, isTemp, - label: getAttachmentLabel(path), path, }; } @@ -467,8 +466,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, From 8f509d8b0ebb256bf2055642393f92fce5cccec2 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 12 Jul 2026 02:17:48 -0400 Subject: [PATCH 2/2] fix(chat): fix drag-and-drop adjacency bug Images dropped directly beside or within existing text now become attachment badges, while surrounding text and cursor position are preserved. The fix is to detect the newly inserted drag-and-drop substring by comparing the previous and next input values, then extract the image from that inserted segment. That would support dropping an image directly beside existing text without requiring a space. --- src/components/Chat/ChatInput.test.tsx | 41 ++++++++++++++++++++++++ src/components/Chat/ChatInput.tsx | 43 ++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/components/Chat/ChatInput.test.tsx b/src/components/Chat/ChatInput.test.tsx index 63a9f725..cfb810ac 100644 --- a/src/components/Chat/ChatInput.test.tsx +++ b/src/components/Chat/ChatInput.test.tsx @@ -662,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(); diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index 05a3f73c..020aea6f 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -43,6 +43,38 @@ 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)}`, @@ -217,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) { @@ -227,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;