Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
2233ff1
WIP: image support implementation
brandonkachen Nov 26, 2025
93dc9c0
feat(cli): add image support for sending and displaying images
brandonkachen Nov 26, 2025
916dabe
feat(cli): add image support UI with banners, /image command, clipboa…
brandonkachen Nov 26, 2025
965cf19
fix: resolve duplicate type definitions and add textToCopy prop to
brandonkachen Nov 26, 2025
2684993
fix: consolidate image processing to clear pending images and show at…
brandonkachen Nov 26, 2025
9facb59
fix: clear pending images immediately and show attachments from pendi…
brandonkachen Nov 26, 2025
3482040
refactor: remove "x images attached" text from user message attachments
brandonkachen Nov 26, 2025
e102d64
feat: allow empty messages when images are attached
brandonkachen Nov 26, 2025
d5bdd3c
fix: use default prompt when sending image-only messages
brandonkachen Nov 26, 2025
6ed2997
fix: store resolved path for pending images to ensure processing succ…
brandonkachen Nov 27, 2025
26e1f48
fix: resolve image attachment issues and remove debug logs
brandonkachen Nov 27, 2025
659d220
feat(cli): add colored image thumbnail preview for all terminals
brandonkachen Nov 27, 2025
a0dcffc
fix(cli): fix pending images banner and queue handling
brandonkachen Nov 27, 2025
9210549
fix: wrap first text part in user_message tags for multipart content
brandonkachen Nov 27, 2025
5ef7a51
chore(cli): code cleanup from review feedback
brandonkachen Nov 27, 2025
e5d6550
fix(cli): make e2e tests more robust for CI
brandonkachen Nov 27, 2025
eb100c0
refactor: code cleanup for image support feature
brandonkachen Nov 27, 2025
75a0e92
refactor: replace inline import with proper type import in message-fo…
brandonkachen Nov 27, 2025
9164c7b
refactor: additional code cleanup
brandonkachen Nov 27, 2025
ab5f4b4
fix(cli): /image command now adds images to pending banner
brandonkachen Nov 27, 2025
094bd02
feat(cli): improve image thumbnail preview
brandonkachen Nov 27, 2025
ea7f075
Merge origin/main into image-support
brandonkachen Nov 30, 2025
7b7adcb
fix: automatically compress large images upon attach
brandonkachen Dec 1, 2025
9cb5ac5
Merge remote-tracking branch 'origin/main' into image-support
brandonkachen Dec 1, 2025
a9a6acb
cleanup: consolidate lots of code
brandonkachen Dec 1, 2025
26c20b9
Merge remote-tracking branch 'origin/main' into image-support
brandonkachen Dec 1, 2025
3302e9b
Revert changes to npm-app image handler
brandonkachen Dec 1, 2025
af59959
docs(cli): add Ctrl+V hint to /image command description
brandonkachen Dec 1, 2025
34ce6ad
refactor(cli): remove dead code in /image command, add Ctrl+V hint
brandonkachen Dec 1, 2025
754f3aa
feat(cli): show image errors in pending banner with auto-remove
brandonkachen Dec 1, 2025
0c42bbe
refactor(cli): simplify handleImageCommand and add tests
brandonkachen Dec 1, 2025
c963e25
refactor(cli): extract InputModeBanner as parent component to Pending…
brandonkachen Dec 1, 2025
864b78d
feat(cli): dynamic image display sizing based on actual dimensions
brandonkachen Dec 1, 2025
d84caa6
chore(cli): simplify image thumbnail fallback guard
brandonkachen Dec 1, 2025
ed952e3
chore(cli): tidy timer and image components
brandonkachen Dec 1, 2025
06b1dc6
refactor(cli): remove unused renderAnsiBlockImage functions
brandonkachen Dec 1, 2025
bcbebe3
refactor(cli): simplify ImageThumbnail state management
brandonkachen Dec 1, 2025
0622ffc
fix(sdk): remove duplicate content property that was overwriting imag…
brandonkachen Dec 1, 2025
73151ef
refactor: move image constants to common/src/constants/images.ts
brandonkachen Dec 2, 2025
bdf6d72
refactor(cli): simplify image-handler.ts
brandonkachen Dec 2, 2025
d02b410
fix(cli): use number type for TextAttributes prop
brandonkachen Dec 2, 2025
cc0166e
refactor: consolidate image extension pattern constants
brandonkachen Dec 2, 2025
e321b3b
refactor(cli): simplify truncateFilename function
brandonkachen Dec 2, 2025
96775eb
refactor: move EXTENSION_TO_MIME to common constants
brandonkachen Dec 2, 2025
8c2dd13
refactor(cli): replace dynamic imports with static imports in chat.tsx
brandonkachen Dec 2, 2025
07af04b
refactor: derive SUPPORTED_IMAGE_EXTENSIONS from IMAGE_EXTENSION_TO_MIME
brandonkachen Dec 2, 2025
44c6d72
fix(cli): use atomic setState for pending image updates
brandonkachen Dec 2, 2025
994fa5b
feat(cli): improve image paste UX with instant feedback and status tr…
brandonkachen Dec 2, 2025
64247ca
fix: optimistically show image banner
brandonkachen Dec 2, 2025
193c46a
refactor(cli): extract readClipboardText utility and add processing f…
brandonkachen Dec 2, 2025
64d884c
Merge origin/main into image-support
brandonkachen Dec 2, 2025
7eba824
fix(cli): include fallback prompt in content array for image-only mes…
brandonkachen Dec 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
501 changes: 472 additions & 29 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,17 @@
"@tanstack/react-query": "^5.62.8",
"commander": "^14.0.1",
"immer": "^10.1.3",
"jimp": "^1.6.0",
"open": "^10.1.0",
"pino": "9.4.0",
"posthog-node": "4.17.2",
"string-width": "^7.2.0",
"react": "^19.0.0",
"react-reconciler": "^0.32.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"string-width": "^7.2.0",
"terminal-image": "^4.1.0",
"unified": "^11.0.0",
"yoga-layout": "^3.2.1",
"zod": "^3.24.1",
Expand Down
16 changes: 14 additions & 2 deletions cli/src/__tests__/e2e-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,19 @@ describe.skipIf(!sdkBuilt)('CLI End-to-End Tests', () => {
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
resolve()
}, 800)
}, 2000) // Increased timeout for CI environments

// Check both stdout and stderr - CLI may output to either
proc.stdout?.once('data', () => {
started = true
clearTimeout(timeout)
resolve()
})
proc.stderr?.once('data', () => {
started = true
clearTimeout(timeout)
resolve()
})
})

proc.kill('SIGTERM')
Expand All @@ -139,13 +145,19 @@ describe.skipIf(!sdkBuilt)('CLI End-to-End Tests', () => {
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
resolve()
}, 800)
}, 2000) // Increased timeout for CI environments

// Check both stdout and stderr - CLI may output to either
proc.stdout?.once('data', () => {
started = true
clearTimeout(timeout)
resolve()
})
proc.stderr?.once('data', () => {
started = true
clearTimeout(timeout)
resolve()
})
})

proc.kill('SIGTERM')
Expand Down
74 changes: 47 additions & 27 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
import { useShallow } from 'zustand/react/shallow'

import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
import { addClipboardPlaceholder, addPendingImageFromFile } from './utils/add-pending-image'
import { getProjectRoot } from './project-files'
import { AnnouncementBanner } from './components/announcement-banner'
import { hasClipboardImage, readClipboardImage, readClipboardText } from './utils/clipboard-image'
import { showClipboardMessage } from './utils/clipboard'
import { ChatInputBar } from './components/chat-input-bar'
import { MessageWithAgents } from './components/message-with-agents'
import { PendingBashMessage } from './components/pending-bash-message'
Expand All @@ -36,7 +40,7 @@ import {
type ChatKeyboardState,
createDefaultChatKeyboardState,
} from './utils/keyboard-actions'
import { useMessageQueue } from './hooks/use-message-queue'
import { useMessageQueue, type QueuedMessage } from './hooks/use-message-queue'
import { useQueueControls } from './hooks/use-queue-controls'
import { useQueueUi } from './hooks/use-queue-ui'
import { useChatScrollbox } from './hooks/use-scroll-management'
Expand Down Expand Up @@ -429,6 +433,7 @@ export const Chat = ({
const inputMode = useChatStore((state) => state.inputMode)
const setInputMode = useChatStore((state) => state.setInputMode)
const askUserState = useChatStore((state) => state.askUserState)
const pendingImages = useChatStore((state) => state.pendingImages)

const {
slashContext,
Expand Down Expand Up @@ -542,31 +547,12 @@ export const Chat = ({
clearQueue,
isQueuePausedRef,
} = useMessageQueue(
(content: string) => {
// Route queued messages through the router to handle bash commands, slash commands, etc.
return routeUserPrompt({
abortControllerRef,
(message: QueuedMessage) =>
sendMessageRef.current?.({
content: message.content,
agentMode,
inputRef,
inputValue: content,
isChainInProgressRef,
isStreaming,
logoutMutation,
streamMessageIdRef,
addToQueue,
clearMessages,
saveToHistory: () => {}, // Already saved when queued
scrollToLatest,
sendMessage,
setCanProcessQueue,
setInputFocused,
setInputValue: () => {}, // Input already cleared when queued
setIsAuthenticated,
setMessages,
setUser,
stopStreaming,
})
},
images: message.images,
}) ?? Promise.resolve(),
isChainInProgressRef,
activeAgentStreamsRef,
)
Expand Down Expand Up @@ -1032,8 +1018,42 @@ export const Chat = ({
onExitApp: () => handleCtrlC(),
onBashHistoryUp: navigateUp,
onBashHistoryDown: navigateDown,
onDismissBashOverlay: () => {},
onCancelBashCommand: () => {},
onPasteImage: () => {
const placeholderPath = addClipboardPlaceholder()

setTimeout(() => {
if (!hasClipboardImage()) {
useChatStore.getState().removePendingImage(placeholderPath)
const text = readClipboardText()
if (text) {
setInputValue((prev) => {
const before = prev.text.slice(0, prev.cursorPosition)
const after = prev.text.slice(prev.cursorPosition)
return {
text: before + text + after,
cursorPosition: before.length + text.length,
lastEditDueToNav: false,
}
})
}
return
}

const result = readClipboardImage()
if (!result.success || !result.imagePath) {
useChatStore.getState().removePendingImage(placeholderPath)
showClipboardMessage(result.error || 'Failed to paste image', {
durationMs: 3000,
})
return
}

const cwd = getProjectRoot() ?? process.cwd()
void addPendingImageFromFile(result.imagePath, cwd, placeholderPath)
}, 0)

return true
},
}),
[
setInputMode,
Expand Down
95 changes: 95 additions & 0 deletions cli/src/commands/__tests__/image.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, test, expect } from 'bun:test'

/**
* Tests for the handleImageCommand argument parsing behavior.
*
* These tests verify the parsing logic independently of the actual
* validateAndAddImage implementation by testing the parsing function directly.
*/

// Extract the parsing logic that handleImageCommand uses
// New simplified implementation: split on whitespace
function parseImageCommandArgs(args: string): {
imagePath: string | null
message: string
} {
const [imagePath, ...rest] = args.trim().split(/\s+/)

if (!imagePath) {
return { imagePath: null, message: '' }
}

return { imagePath, message: rest.join(' ') }
}

describe('handleImageCommand parsing', () => {
describe('argument parsing', () => {
test('parses image path only', () => {
const result = parseImageCommandArgs('./screenshot.png')
expect(result.imagePath).toBe('./screenshot.png')
expect(result.message).toBe('')
})

test('parses image path with message', () => {
const result = parseImageCommandArgs('./screenshot.png please analyze this')
expect(result.imagePath).toBe('./screenshot.png')
expect(result.message).toBe('please analyze this')
})

test('parses image path with multi-word message', () => {
const result = parseImageCommandArgs('./image.jpg what is in this picture?')
expect(result.imagePath).toBe('./image.jpg')
expect(result.message).toBe('what is in this picture?')
})

test('handles absolute paths with message', () => {
const result = parseImageCommandArgs('/path/to/file.png describe the UI')
expect(result.imagePath).toBe('/path/to/file.png')
expect(result.message).toBe('describe the UI')
})

test('trims whitespace from input', () => {
const result = parseImageCommandArgs(' ./image.png ')
expect(result.imagePath).toBe('./image.png')
expect(result.message).toBe('')
})

test('handles multiple spaces between path and message', () => {
const result = parseImageCommandArgs('./image.png hello world')
expect(result.imagePath).toBe('./image.png')
// The regex only captures content after the first whitespace group
expect(result.message).toBe('hello world')
})
})

describe('invalid input handling', () => {
test('returns null imagePath for empty input', () => {
const result = parseImageCommandArgs('')
expect(result.imagePath).toBeNull()
expect(result.message).toBe('')
})

test('returns null imagePath for whitespace-only input', () => {
const result = parseImageCommandArgs(' ')
expect(result.imagePath).toBeNull()
expect(result.message).toBe('')
})
})

describe('edge cases', () => {
test('handles filenames with extensions', () => {
const result = parseImageCommandArgs('image.jpeg')
expect(result.imagePath).toBe('image.jpeg')
})

test('handles relative paths', () => {
const result = parseImageCommandArgs('../screenshots/test.png')
expect(result.imagePath).toBe('../screenshots/test.png')
})

test('handles tilde paths', () => {
const result = parseImageCommandArgs('~/Downloads/image.png')
expect(result.imagePath).toBe('~/Downloads/image.png')
})
})
})
29 changes: 26 additions & 3 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { handleImageCommand } from './image'
import { handleInitializationFlowLocally } from './init'
import { handleReferralCode } from './referral'
import { normalizeReferralCode } from './router-utils'
import { handleUsageCommand } from './usage'
import { useChatStore } from '../state/chat-store'
import { useLoginStore } from '../state/login-store'
import { getSystemMessage, getUserMessage } from '../utils/message-history'
import { capturePendingImages } from '../utils/add-pending-image'

import type { MultilineInputHandle } from '../components/multiline-input'
import type { InputValue } from '../state/chat-store'
import type { InputValue, PendingImage } from '../state/chat-store'
import type { ChatMessage } from '../types/chat'
import type { SendMessageFn } from '../types/contracts/send-message'
import type { User } from '../utils/auth'
Expand All @@ -23,7 +25,7 @@ export type RouterParams = {
isStreaming: boolean
logoutMutation: UseMutationResult<boolean, Error, void, unknown>
streamMessageIdRef: React.MutableRefObject<string | null>
addToQueue: (message: string) => void
addToQueue: (message: string, images?: PendingImage[]) => void
clearMessages: () => void
saveToHistory: (message: string) => void
scrollToLatest: () => void
Expand Down Expand Up @@ -186,7 +188,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
params.streamMessageIdRef.current ||
params.isChainInProgressRef.current
) {
params.addToQueue(trimmed)
const pendingImages = capturePendingImages()
params.addToQueue(trimmed, pendingImages)
params.setInputFocused(true)
params.inputRef.current?.focus()
return
Expand All @@ -212,6 +215,26 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
clearInput(params)
},
},
{
name: 'image',
aliases: ['img', 'attach'],
handler: async (params, args) => {
const trimmedArgs = args.trim()

// If user provided a path directly, process it immediately
if (trimmedArgs) {
await handleImageCommand(trimmedArgs)
params.saveToHistory(params.inputValue.trim())
clearInput(params)
return
}

// Otherwise enter image mode
useChatStore.getState().setInputMode('image')
params.saveToHistory(params.inputValue.trim())
clearInput(params)
},
},
]

export function findCommand(cmd: string): CommandDefinition | undefined {
Expand Down
20 changes: 20 additions & 0 deletions cli/src/commands/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getProjectRoot } from '../project-files'
import { validateAndAddImage } from '../utils/add-pending-image'

/**
* Handle the /image command to attach an image file.
* Usage: /image <path> [message]
* Example: /image ./screenshot.png please analyze this
*
* Returns the optional message as transformedPrompt (empty string if none).
* Errors are shown in the pending images banner with auto-remove.
*/
export async function handleImageCommand(args: string): Promise<string> {
const [imagePath, ...rest] = args.trim().split(/\s+/)

if (imagePath) {
await validateAndAddImage(imagePath, getProjectRoot())
}

return rest.join(' ')
}
Loading
Loading