Skip to content

Commit 916dabe

Browse files
committed
feat(cli): add image support UI with banners, /image command, clipboard paste, and visual cards
- Add /image slash command to attach images - Add image input mode similar to bash/referral mode - Add Ctrl+V clipboard paste for images (macOS/Linux/Windows) - Add PendingImagesBanner with visual image cards and X to remove - Update keyboard actions to detect Ctrl+V for image paste - Remove pasted status messages (now shown in banner)
1 parent 93dc9c0 commit 916dabe

File tree

17 files changed

+662
-35
lines changed

17 files changed

+662
-35
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/chat.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { useShallow } from 'zustand/react/shallow'
1212

1313
import { routeUserPrompt } from './commands/router'
1414
import { AnnouncementBanner } from './components/announcement-banner'
15+
import { hasClipboardImage, readClipboardImage } from './utils/clipboard-image'
16+
import { showClipboardMessage } from './utils/clipboard'
1517
import { ChatInputBar } from './components/chat-input-bar'
1618
import { MessageWithAgents } from './components/message-with-agents'
1719
import { StatusBar } from './components/status-bar'
@@ -421,6 +423,7 @@ export const Chat = ({
421423
const inputMode = useChatStore((state) => state.inputMode)
422424
const setInputMode = useChatStore((state) => state.setInputMode)
423425
const askUserState = useChatStore((state) => state.askUserState)
426+
const pendingImages = useChatStore((state) => state.pendingImages)
424427

425428
const {
426429
slashContext,
@@ -940,6 +943,26 @@ export const Chat = ({
940943
onClearQueue: clearQueue,
941944
onExitAppWarning: () => handleCtrlC(),
942945
onExitApp: () => handleCtrlC(),
946+
onPasteImage: () => {
947+
// Check if clipboard has an image
948+
if (!hasClipboardImage()) {
949+
// No image in clipboard, let normal paste happen
950+
return
951+
}
952+
953+
// Read image from clipboard
954+
const result = readClipboardImage()
955+
if (!result.success || !result.imagePath || !result.filename) {
956+
showClipboardMessage(result.error || 'Failed to paste image', { durationMs: 3000 })
957+
return
958+
}
959+
960+
// Add to pending images
961+
useChatStore.getState().addPendingImage({
962+
path: result.imagePath,
963+
filename: result.filename,
964+
})
965+
},
943966
}), [
944967
setInputMode,
945968
handleCloseFeedback,

cli/src/commands/command-registry.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { handleImageCommand } from './image'
12
import { handleInitializationFlowLocally } from './init'
23
import { handleReferralCode } from './referral'
34
import { normalizeReferralCode } from './router-utils'
@@ -212,6 +213,27 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
212213
clearInput(params)
213214
},
214215
},
216+
{
217+
name: 'image',
218+
aliases: ['img', 'attach'],
219+
handler: (params, args) => {
220+
const trimmedArgs = args.trim()
221+
222+
// If user provided a path directly, process it immediately
223+
if (trimmedArgs) {
224+
const result = handleImageCommand(trimmedArgs)
225+
params.setMessages((prev) => result.postUserMessage(prev))
226+
params.saveToHistory(params.inputValue.trim())
227+
clearInput(params)
228+
return
229+
}
230+
231+
// Otherwise enter image mode
232+
useChatStore.getState().setInputMode('image')
233+
params.saveToHistory(params.inputValue.trim())
234+
clearInput(params)
235+
},
236+
},
215237
]
216238

217239
export function findCommand(cmd: string): CommandDefinition | undefined {

cli/src/commands/image.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { existsSync } from 'fs'
2+
import path from 'path'
3+
4+
import { getProjectRoot } from '../project-files'
5+
import { getSystemMessage } from '../utils/message-history'
6+
import {
7+
SUPPORTED_IMAGE_EXTENSIONS,
8+
isImageFile,
9+
} from '../utils/image-handler'
10+
11+
import type { PostUserMessageFn } from '../types/contracts/send-message'
12+
13+
/**
14+
* Handle the /image command to attach an image file.
15+
* Usage: /image <path> [message]
16+
* Example: /image ./screenshot.png please analyze this
17+
*/
18+
export function handleImageCommand(args: string): {
19+
postUserMessage: PostUserMessageFn
20+
transformedPrompt?: string
21+
} {
22+
const trimmedArgs = args.trim()
23+
24+
if (!trimmedArgs) {
25+
// No path provided - show usage help
26+
const postUserMessage: PostUserMessageFn = (prev) => [
27+
...prev,
28+
getSystemMessage(
29+
`📸 **Image Command Usage**\n\n` +
30+
` /image <path> [message]\n\n` +
31+
`**Examples:**\n` +
32+
` /image ./screenshot.png\n` +
33+
` /image ~/Desktop/error.png please help debug this\n` +
34+
` /image assets/diagram.jpg explain this architecture\n\n` +
35+
`**Supported formats:** ${Array.from(SUPPORTED_IMAGE_EXTENSIONS).join(', ')}\n\n` +
36+
`**Tip:** You can also include images directly in your message:\n` +
37+
` "Please analyze ./image.png and tell me what you see"`,
38+
),
39+
]
40+
return { postUserMessage }
41+
}
42+
43+
// Parse the path and optional message
44+
// The path is the first argument (up to first space or the whole string)
45+
const parts = trimmedArgs.match(/^(\S+)(?:\s+(.*))?$/)
46+
if (!parts) {
47+
const postUserMessage: PostUserMessageFn = (prev) => [
48+
...prev,
49+
getSystemMessage('❌ Invalid image command format. Use: /image <path> [message]'),
50+
]
51+
return { postUserMessage }
52+
}
53+
54+
const [, imagePath, message] = parts
55+
const projectRoot = getProjectRoot()
56+
57+
// Resolve the path relative to project root
58+
let resolvedPath = imagePath
59+
if (!path.isAbsolute(imagePath) && !imagePath.startsWith('~')) {
60+
resolvedPath = path.resolve(projectRoot, imagePath)
61+
} else if (imagePath.startsWith('~')) {
62+
resolvedPath = path.resolve(
63+
process.env.HOME || process.env.USERPROFILE || '',
64+
imagePath.slice(1),
65+
)
66+
}
67+
68+
// Check if file exists
69+
if (!existsSync(resolvedPath)) {
70+
const postUserMessage: PostUserMessageFn = (prev) => [
71+
...prev,
72+
getSystemMessage(`❌ Image file not found: ${imagePath}`),
73+
]
74+
return { postUserMessage }
75+
}
76+
77+
// Check if it's a supported image format
78+
if (!isImageFile(imagePath)) {
79+
const ext = path.extname(imagePath).toLowerCase()
80+
const postUserMessage: PostUserMessageFn = (prev) => [
81+
...prev,
82+
getSystemMessage(
83+
`❌ Unsupported image format: ${ext}\n` +
84+
`Supported formats: ${Array.from(SUPPORTED_IMAGE_EXTENSIONS).join(', ')}`,
85+
),
86+
]
87+
return { postUserMessage }
88+
}
89+
90+
// Transform the command into a prompt with the image path
91+
// The image-handler will auto-detect paths like ./image.png or @image.png
92+
const transformedPrompt = message
93+
? `${message} ${imagePath}`
94+
: `Please analyze this image: ${imagePath}`
95+
96+
const postUserMessage: PostUserMessageFn = (prev) => prev
97+
98+
return {
99+
postUserMessage,
100+
transformedPrompt,
101+
}
102+
}

cli/src/commands/router.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { runTerminalCommand } from '@codebuff/sdk'
22

3+
import { existsSync } from 'fs'
4+
import path from 'path'
5+
36
import {
47
findCommand,
58
type RouterParams,
@@ -13,7 +16,9 @@ import {
1316
extractReferralCode,
1417
normalizeReferralCode,
1518
} from './router-utils'
19+
import { getProjectRoot } from '../project-files'
1620
import { useChatStore } from '../state/chat-store'
21+
import { isImageFile, resolveFilePath } from '../utils/image-handler'
1722
import { getSystemMessage, getUserMessage } from '../utils/message-history'
1823

1924
import type { ContentBlock } from '../types/chat'
@@ -96,6 +101,57 @@ export async function routeUserPrompt(
96101
return
97102
}
98103

104+
// Handle image mode input
105+
if (inputMode === 'image') {
106+
const imagePath = trimmed
107+
const projectRoot = getProjectRoot()
108+
const resolvedPath = resolveFilePath(imagePath, projectRoot)
109+
110+
// Validate the image path
111+
if (!existsSync(resolvedPath)) {
112+
setMessages((prev) => [
113+
...prev,
114+
getUserMessage(trimmed),
115+
getSystemMessage(`❌ Image file not found: ${imagePath}`),
116+
])
117+
saveToHistory(trimmed)
118+
setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false })
119+
setInputMode('default')
120+
return
121+
}
122+
123+
if (!isImageFile(resolvedPath)) {
124+
const ext = path.extname(imagePath).toLowerCase()
125+
setMessages((prev) => [
126+
...prev,
127+
getUserMessage(trimmed),
128+
getSystemMessage(
129+
`❌ Unsupported image format: ${ext}\nSupported: .jpg, .jpeg, .png, .webp, .gif, .bmp, .tiff`,
130+
),
131+
])
132+
saveToHistory(trimmed)
133+
setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false })
134+
setInputMode('default')
135+
return
136+
}
137+
138+
// Add to pending images
139+
const filename = path.basename(resolvedPath)
140+
useChatStore.getState().addPendingImage({
141+
path: imagePath,
142+
filename,
143+
})
144+
145+
setMessages((prev) => [
146+
...prev,
147+
getSystemMessage(`📎 Image attached: ${filename}`),
148+
])
149+
saveToHistory(trimmed)
150+
setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false })
151+
setInputMode('default')
152+
return
153+
}
154+
99155
// Handle referral mode input
100156
if (inputMode === 'referral') {
101157
// Validate the referral code (3-50 alphanumeric chars with optional dashes)

cli/src/components/chat-input-bar.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AgentModeToggle } from './agent-mode-toggle'
44
import { FeedbackContainer } from './feedback-container'
55
import { MultipleChoiceForm } from './ask-user'
66
import { MultilineInput, type MultilineInputHandle } from './multiline-input'
7+
import { PendingImagesBanner } from './pending-images-banner'
78
import { ReferralBanner } from './referral-banner'
89
import { SuggestionMenu, type SuggestionItem } from './suggestion-menu'
910
import { UsageBanner } from './usage-banner'
@@ -23,10 +24,17 @@ type Theme = ReturnType<typeof useTheme>
2324
const InputModeBanner = ({
2425
inputMode,
2526
usageBannerShowTime,
27+
hasPendingImages,
2628
}: {
2729
inputMode: InputMode
2830
usageBannerShowTime: number
31+
hasPendingImages: boolean
2932
}) => {
33+
// Show pending images banner if there are images (regardless of mode)
34+
if (hasPendingImages) {
35+
return <PendingImagesBanner />
36+
}
37+
3038
switch (inputMode) {
3139
case 'usage':
3240
return <UsageBanner showTime={usageBannerShowTime} />
@@ -385,6 +393,7 @@ export const ChatInputBar = ({
385393
<InputModeBanner
386394
inputMode={inputMode}
387395
usageBannerShowTime={usageBannerShowTime}
396+
hasPendingImages={useChatStore.getState().pendingImages.length > 0}
388397
/>
389398
</>
390399
)

cli/src/components/message-block.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { pluralize } from '@codebuff/common/util/string'
22
import { TextAttributes } from '@opentui/core'
33
import React, { memo, useCallback, useMemo, useState, type ReactNode } from 'react'
4+
import { spawn } from 'child_process'
5+
import path from 'path'
46

57
import { AgentBranchItem } from './agent-branch-item'
68
import { Button } from './button'
79
import { MessageFooter } from './message-footer'
10+
import { TerminalLink } from './terminal-link'
811
import { ValidationErrorPopover } from './validation-error-popover'
912
import { useTheme } from '../hooks/use-theme'
1013
import { useWhyDidYouUpdateById } from '../hooks/use-why-did-you-update'
@@ -111,6 +114,27 @@ const MessageAttachments = ({
111114

112115
import { BORDER_CHARS } from '../utils/ui-constants'
113116

117+
// Helper to open a file with the system default application
118+
const openFile = (filePath: string) => {
119+
const platform = process.platform
120+
let command: string
121+
let args: string[]
122+
123+
if (platform === 'darwin') {
124+
command = 'open'
125+
args = [filePath]
126+
} else if (platform === 'win32') {
127+
command = 'cmd'
128+
args = ['/c', 'start', '', filePath]
129+
} else {
130+
// Linux and others
131+
command = 'xdg-open'
132+
args = [filePath]
133+
}
134+
135+
spawn(command, args, { detached: true, stdio: 'ignore' }).unref()
136+
}
137+
114138
export const MessageBlock: React.FC<MessageBlockProps> = ({
115139
messageId,
116140
blocks,

cli/src/components/status-bar.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { formatElapsedTime } from '../utils/format-elapsed-time'
88
import type { StreamStatus } from '../hooks/use-message-queue'
99
import type { AuthStatus, StatusIndicatorState } from '../utils/status-indicator-state'
1010

11+
1112
const SHIMMER_INTERVAL_MS = 160
1213

1314
interface StatusBarProps {
@@ -170,7 +171,7 @@ export const StatusBar = ({
170171
const statusIndicatorContent = renderStatusIndicator()
171172
const elapsedTimeContent = renderElapsedTime()
172173

173-
// Only show gray background when there's status indicator or timer content
174+
// Only show gray background when there's status indicator or timer
174175
const hasContent = statusIndicatorContent || elapsedTimeContent
175176

176177
return (

cli/src/data/slash-commands.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,10 @@ export const SLASH_COMMANDS: SlashCommand[] = [
7373
description: 'Redeem a referral code for bonus credits',
7474
aliases: ['redeem'],
7575
},
76+
{
77+
id: 'image',
78+
label: 'image',
79+
description: 'Attach an image file to your message',
80+
aliases: ['img', 'attach'],
81+
},
7682
]

0 commit comments

Comments
 (0)