Skip to content

Commit 2233ff1

Browse files
committed
WIP: image support implementation
1 parent e2e4753 commit 2233ff1

File tree

9 files changed

+402
-7
lines changed

9 files changed

+402
-7
lines changed

cli/src/components/image-card.tsx

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import React, { useEffect, useState } from 'react'
2+
import fs from 'fs'
3+
4+
import { Button } from './button'
5+
6+
import { useTheme } from '../hooks/use-theme'
7+
import {
8+
supportsInlineImages,
9+
renderInlineImage,
10+
} from '../utils/terminal-images'
11+
12+
const MAX_FILENAME_LENGTH = 18
13+
14+
const BORDER_CHARS = {
15+
horizontal: '─',
16+
vertical: '│',
17+
top: '─',
18+
bottom: '─',
19+
left: '│',
20+
right: '│',
21+
topLeft: '┌',
22+
topRight: '┐',
23+
bottomLeft: '└',
24+
bottomRight: '┘',
25+
topT: '┬',
26+
bottomT: '┴',
27+
leftT: '├',
28+
rightT: '┤',
29+
cross: '┼',
30+
}
31+
32+
const truncateFilename = (filename: string): string => {
33+
if (filename.length <= MAX_FILENAME_LENGTH) {
34+
return filename
35+
}
36+
const ext = filename.split('.').pop() || ''
37+
const nameWithoutExt = filename.slice(0, filename.length - ext.length - 1)
38+
const truncatedName = nameWithoutExt.slice(
39+
0,
40+
MAX_FILENAME_LENGTH - ext.length - 4,
41+
)
42+
return `${truncatedName}${ext ? '.' + ext : ''}`
43+
}
44+
45+
export interface ImageCardImage {
46+
path: string
47+
filename: string
48+
}
49+
50+
interface ImageCardProps {
51+
image: ImageCardImage
52+
onRemove?: () => void
53+
showRemoveButton?: boolean
54+
}
55+
56+
export const ImageCard = ({
57+
image,
58+
onRemove,
59+
showRemoveButton = true,
60+
}: ImageCardProps) => {
61+
const theme = useTheme()
62+
const [isCloseHovered, setIsCloseHovered] = useState(false)
63+
const [thumbnailSequence, setThumbnailSequence] = useState<string | null>(null)
64+
const canShowThumbnail = supportsInlineImages()
65+
66+
// Load thumbnail if terminal supports inline images
67+
useEffect(() => {
68+
if (!canShowThumbnail) return
69+
70+
try {
71+
const imageData = fs.readFileSync(image.path)
72+
const base64Data = imageData.toString('base64')
73+
const sequence = renderInlineImage(base64Data, {
74+
width: 4, // Small thumbnail width in cells
75+
height: 3, // Small thumbnail height in cells
76+
filename: image.filename,
77+
})
78+
setThumbnailSequence(sequence)
79+
} catch {
80+
// Failed to load image, will show icon fallback
81+
setThumbnailSequence(null)
82+
}
83+
}, [image.path, image.filename, canShowThumbnail])
84+
85+
const truncatedName = truncateFilename(image.filename)
86+
87+
return (
88+
<box
89+
style={{
90+
flexDirection: 'column',
91+
borderStyle: 'single',
92+
borderColor: theme.info,
93+
width: 22,
94+
padding: 0,
95+
}}
96+
customBorderChars={BORDER_CHARS}
97+
>
98+
{/* Thumbnail or icon area with overlaid close button */}
99+
<box
100+
style={{
101+
height: 3,
102+
flexDirection: 'row',
103+
backgroundColor: theme.surface,
104+
}}
105+
>
106+
{/* Thumbnail/icon centered */}
107+
<box
108+
style={{
109+
flexGrow: 1,
110+
justifyContent: 'center',
111+
alignItems: 'center',
112+
}}
113+
>
114+
{thumbnailSequence ? (
115+
<text>{thumbnailSequence}</text>
116+
) : (
117+
<text style={{ fg: theme.info }}>🖼️</text>
118+
)}
119+
</box>
120+
{/* Close button in top-right corner */}
121+
{showRemoveButton && onRemove && (
122+
<Button
123+
onClick={onRemove}
124+
onMouseOver={() => setIsCloseHovered(true)}
125+
onMouseOut={() => setIsCloseHovered(false)}
126+
style={{ paddingLeft: 0, paddingRight: 1 }}
127+
>
128+
<text style={{ fg: isCloseHovered ? theme.error : theme.muted }}>×</text>
129+
</Button>
130+
)}
131+
</box>
132+
133+
{/* Filename only - full width */}
134+
<box
135+
style={{
136+
paddingLeft: 1,
137+
paddingRight: 1,
138+
}}
139+
>
140+
<text
141+
style={{
142+
fg: theme.foreground,
143+
wrapMode: 'none',
144+
}}
145+
>
146+
{truncatedName}
147+
</text>
148+
</box>
149+
</box>
150+
)
151+
}

cli/src/components/message-block.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { MessageFooter } from './message-footer'
88
import { ValidationErrorPopover } from './validation-error-popover'
99
import { useTheme } from '../hooks/use-theme'
1010
import { useWhyDidYouUpdateById } from '../hooks/use-why-did-you-update'
11+
import { ImageCard } from './image-card'
1112
import { isTextBlock, isToolBlock } from '../types/chat'
1213
import { shouldRenderAsSimpleText } from '../utils/constants'
1314
import {
@@ -28,6 +29,7 @@ import type {
2829
TextContentBlock,
2930
HtmlContentBlock,
3031
AgentContentBlock,
32+
ImageAttachment,
3133
} from '../types/chat'
3234
import { isAskUserBlock } from '../types/chat'
3335
import type { ThemeColor } from '../types/theme-system'
@@ -61,6 +63,48 @@ interface MessageBlockProps {
6163
footerMessage?: string
6264
errors?: Array<{ id: string; message: string }>
6365
}) => void
66+
attachments?: ImageAttachment[]
67+
}
68+
69+
const MessageAttachments = ({
70+
attachments,
71+
}: {
72+
attachments: ImageAttachment[]
73+
}) => {
74+
const theme = useTheme()
75+
76+
if (attachments.length === 0) {
77+
return null
78+
}
79+
80+
return (
81+
<box
82+
style={{
83+
flexDirection: 'column',
84+
gap: 0,
85+
marginTop: 1,
86+
}}
87+
>
88+
<text style={{ fg: theme.muted }}>
89+
📎 {attachments.length} image{attachments.length > 1 ? 's' : ''} attached
90+
</text>
91+
<box
92+
style={{
93+
flexDirection: 'row',
94+
gap: 1,
95+
flexWrap: 'wrap',
96+
}}
97+
>
98+
{attachments.map((attachment, index) => (
99+
<ImageCard
100+
key={`${attachment.path}-${index}`}
101+
image={attachment}
102+
showRemoveButton={false}
103+
/>
104+
))}
105+
</box>
106+
</box>
107+
)
64108
}
65109

66110
import { BORDER_CHARS } from '../utils/ui-constants'
@@ -90,6 +134,7 @@ export const MessageBlock: React.FC<MessageBlockProps> = ({
90134
onCloseFeedback,
91135
validationErrors,
92136
onOpenFeedback,
137+
attachments,
93138
}) => {
94139
const [showValidationPopover, setShowValidationPopover] = useState(false)
95140

@@ -208,6 +253,11 @@ export const MessageBlock: React.FC<MessageBlockProps> = ({
208253
palette={markdownOptions.palette}
209254
/>
210255
)}
256+
{/* Show image attachments for user messages */}
257+
{isUser && attachments && attachments.length > 0 && (
258+
<MessageAttachments attachments={attachments} />
259+
)}
260+
211261
{isAi && (
212262
<MessageFooter
213263
messageId={messageId}

cli/src/components/message-footer.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,21 @@ export const MessageFooter: React.FC<MessageFooterProps> = ({
112112
const footerItems: { key: string; node: React.ReactNode }[] = []
113113

114114
// Add copy button first if there's content to copy
115-
const hasContent =
116-
(blocks && blocks.length > 0) || (content && content.trim().length > 0)
117-
if (hasContent) {
115+
// Build text from content and text blocks
116+
const textToCopy = [
117+
content,
118+
...(blocks || [])
119+
.filter((b): b is import('../types/chat').TextContentBlock => b.type === 'text')
120+
.map((b) => b.content),
121+
]
122+
.filter(Boolean)
123+
.join('\n\n')
124+
.trim()
125+
126+
if (textToCopy.length > 0) {
118127
footerItems.push({
119128
key: 'copy',
120-
node: <CopyIconButton blocks={blocks} content={content} />,
129+
node: <CopyIconButton textToCopy={textToCopy} />,
121130
})
122131
}
123132

cli/src/components/message-with-agents.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export const MessageWithAgents = memo(
241241
onBuildMax={onBuildMax}
242242
onFeedback={onFeedback}
243243
onCloseFeedback={onCloseFeedback}
244+
attachments={message.attachments}
244245
/>
245246
</box>
246247
)}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { ImageCard } from './image-card'
2+
import { useTerminalLayout } from '../hooks/use-terminal-layout'
3+
import { useTheme } from '../hooks/use-theme'
4+
import { useChatStore } from '../state/chat-store'
5+
import { BORDER_CHARS } from '../utils/ui-constants'
6+
7+
export const PendingImagesBanner = () => {
8+
const theme = useTheme()
9+
const { width } = useTerminalLayout()
10+
const pendingImages = useChatStore((state) => state.pendingImages)
11+
const removePendingImage = useChatStore((state) => state.removePendingImage)
12+
13+
if (pendingImages.length === 0) {
14+
return null
15+
}
16+
17+
return (
18+
<box
19+
style={{
20+
flexDirection: 'column',
21+
marginLeft: width.is('sm') ? 0 : 1,
22+
marginRight: width.is('sm') ? 0 : 1,
23+
borderStyle: 'single',
24+
borderColor: theme.info,
25+
paddingLeft: 1,
26+
paddingRight: 1,
27+
paddingTop: 0,
28+
paddingBottom: 0,
29+
}}
30+
border={['bottom', 'left', 'right']}
31+
customBorderChars={BORDER_CHARS}
32+
>
33+
{/* Header */}
34+
<text style={{ fg: theme.info }}>
35+
📎 {pendingImages.length} image{pendingImages.length > 1 ? 's' : ''}{' '}
36+
attached
37+
</text>
38+
39+
{/* Image cards in a horizontal row */}
40+
<box
41+
style={{
42+
flexDirection: 'row',
43+
gap: 1,
44+
flexWrap: 'wrap',
45+
}}
46+
>
47+
{pendingImages.map((image, index) => (
48+
<ImageCard
49+
key={`${image.path}-${index}`}
50+
image={image}
51+
onRemove={() => removePendingImage(image.path)}
52+
/>
53+
))}
54+
</box>
55+
</box>
56+
)
57+
}

0 commit comments

Comments
 (0)