Skip to content

Commit 4a31600

Browse files
committed
feat: Attach files
1 parent 6ffb450 commit 4a31600

File tree

15 files changed

+440
-31
lines changed

15 files changed

+440
-31
lines changed

cli/src/chat.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { loadLocalAgents } from './utils/local-agent-registry'
6666
import { logger } from './utils/logger'
6767
import {
6868
addClipboardPlaceholder,
69+
addPendingFileFromPath,
6970
addPendingImageFromFile,
7071
validateAndAddImage,
7172
} from './utils/pending-attachments'
@@ -1133,6 +1134,9 @@ export const Chat = ({
11331134
showClipboardMessage('Failed to add image', { durationMs: 3000 })
11341135
})
11351136
},
1137+
onPasteFilePath: (filePath: string, isDirectory: boolean) => {
1138+
addPendingFileFromPath(filePath, isDirectory)
1139+
},
11361140
onPasteText: (text: string) => {
11371141
setInputValue((prev) => {
11381142
const before = prev.text.slice(0, prev.cursorPosition)
@@ -1494,6 +1498,7 @@ export const Chat = ({
14941498
onChange: setInputValue,
14951499
onPasteImage: chatKeyboardHandlers.onPasteImage,
14961500
onPasteImagePath: chatKeyboardHandlers.onPasteImagePath,
1501+
onPasteFilePath: chatKeyboardHandlers.onPasteFilePath,
14971502
onPasteLongText: (pastedText) => {
14981503
const id = crypto.randomUUID()
14991504
const preview = pastedText.slice(0, 100).replace(/\n/g, ' ')

cli/src/commands/router.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { getSystemProcessEnv } from '../utils/env'
3232
import { getSystemMessage, getUserMessage } from '../utils/message-history'
3333
import {
3434
capturePendingAttachments,
35+
hasProcessingFiles,
3536
hasProcessingImages,
3637
validateAndAddImage,
3738
} from '../utils/pending-attachments'
@@ -522,9 +523,9 @@ export async function routeUserPrompt(
522523

523524
// Regular message or unknown slash command - send to agent
524525

525-
// Block sending if images are still processing
526-
if (hasProcessingImages()) {
527-
showClipboardMessage('processing images...', {
526+
// Block sending if attachments are still processing
527+
if (hasProcessingImages() || hasProcessingFiles()) {
528+
showClipboardMessage('processing attachments...', {
528529
durationMs: 2000,
529530
})
530531
return
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { AttachmentCard } from './attachment-card'
2+
import { useTheme } from '../hooks/use-theme'
3+
4+
import type { FileAttachment } from '../types/chat'
5+
import type { PendingFileAttachment } from '../types/store'
6+
7+
const FILE_CARD_WIDTH = 20
8+
const MAX_FILENAME_LENGTH = 16
9+
10+
const FILE_ICON_LINES = [
11+
' ┌───╮',
12+
' │ ≡ │',
13+
' └───╯',
14+
]
15+
16+
const FOLDER_ICON_LINES = [
17+
' ╭──╮ ',
18+
' │ ╰──╮',
19+
' ╰─────╯',
20+
]
21+
22+
const truncateFilename = (filename: string): string => {
23+
if (filename.length <= MAX_FILENAME_LENGTH) return filename
24+
// Find extension — ignore leading dot (dotfiles like .gitignore)
25+
const lastDot = filename.lastIndexOf('.')
26+
const hasExtension = lastDot > 0
27+
const ext = hasExtension ? filename.slice(lastDot) : ''
28+
const baseName = hasExtension ? filename.slice(0, lastDot) : filename
29+
const maxBaseLength = MAX_FILENAME_LENGTH - ext.length - 1 // -1 for ellipsis
30+
if (maxBaseLength <= 0) return filename.slice(0, MAX_FILENAME_LENGTH - 1) + '…'
31+
return baseName.slice(0, maxBaseLength) + '…' + ext
32+
}
33+
34+
interface FileAttachmentCardProps {
35+
attachment: PendingFileAttachment | FileAttachment
36+
onRemove?: () => void
37+
showRemoveButton?: boolean
38+
}
39+
40+
export const FileAttachmentCard = ({
41+
attachment,
42+
onRemove,
43+
showRemoveButton = true,
44+
}: FileAttachmentCardProps) => {
45+
const theme = useTheme()
46+
const iconLines = attachment.isDirectory ? FOLDER_ICON_LINES : FILE_ICON_LINES
47+
const truncatedName = truncateFilename(attachment.filename)
48+
const status = 'status' in attachment ? attachment.status : undefined
49+
50+
return (
51+
<AttachmentCard
52+
width={FILE_CARD_WIDTH}
53+
onRemove={onRemove}
54+
showRemoveButton={showRemoveButton}
55+
>
56+
{/* ASCII art icon area */}
57+
<box
58+
style={{
59+
height: 3,
60+
justifyContent: 'center',
61+
alignItems: 'center',
62+
}}
63+
>
64+
<text style={{ fg: theme.info }}>
65+
{iconLines.join('\n')}
66+
</text>
67+
</box>
68+
69+
{/* Filename and note */}
70+
<box
71+
style={{
72+
paddingLeft: 1,
73+
paddingRight: 1,
74+
flexDirection: 'column',
75+
}}
76+
>
77+
<text
78+
style={{
79+
fg: theme.foreground,
80+
wrapMode: 'none',
81+
}}
82+
>
83+
{truncatedName}
84+
</text>
85+
{(status === 'processing' || attachment.note) && (
86+
<text
87+
style={{
88+
fg: status === 'error' ? theme.error : theme.muted,
89+
wrapMode: 'none',
90+
}}
91+
>
92+
{status === 'processing' ? 'reading…' : attachment.note}
93+
</text>
94+
)}
95+
</box>
96+
</AttachmentCard>
97+
)
98+
}

cli/src/components/message-block.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { memo, useState } from 'react'
44
import { BlocksRenderer } from './blocks/blocks-renderer'
55
import { UserContentWithCopyButton } from './blocks/user-content-copy'
66
import { Button } from './button'
7+
import { FileAttachmentCard } from './file-attachment-card'
78
import { ImageCard } from './image-card'
89
import { MessageFooter } from './message-footer'
910
import { TextAttachmentCard } from './text-attachment-card'
@@ -19,6 +20,7 @@ import type { FeedbackCategory } from '@codebuff/common/constants/feedback'
1920

2021
import type {
2122
ContentBlock,
23+
FileAttachment,
2224
ImageAttachment,
2325
TextAttachment,
2426
ChatMessageMetadata,
@@ -58,18 +60,21 @@ interface MessageBlockProps {
5860
}) => void
5961
attachments?: ImageAttachment[]
6062
textAttachments?: TextAttachment[]
63+
fileAttachments?: FileAttachment[]
6164
metadata?: ChatMessageMetadata
6265
isLastMessage?: boolean
6366
}
6467

6568
const MessageAttachments = memo(({
6669
imageAttachments,
6770
textAttachments,
71+
fileAttachments,
6872
}: {
6973
imageAttachments: ImageAttachment[]
7074
textAttachments: TextAttachment[]
75+
fileAttachments: FileAttachment[]
7176
}) => {
72-
if (imageAttachments.length === 0 && textAttachments.length === 0) {
77+
if (imageAttachments.length === 0 && textAttachments.length === 0 && fileAttachments.length === 0) {
7378
return null
7479
}
7580

@@ -95,6 +100,13 @@ const MessageAttachments = memo(({
95100
showRemoveButton={false}
96101
/>
97102
))}
103+
{fileAttachments.map((attachment) => (
104+
<FileAttachmentCard
105+
key={attachment.path}
106+
attachment={attachment}
107+
showRemoveButton={false}
108+
/>
109+
))}
98110
</box>
99111
)
100112
})
@@ -127,6 +139,7 @@ export const MessageBlock = memo(({
127139
onOpenFeedback,
128140
attachments,
129141
textAttachments,
142+
fileAttachments,
130143
metadata,
131144
isLastMessage,
132145
}: MessageBlockProps) => {
@@ -301,10 +314,12 @@ export const MessageBlock = memo(({
301314
{/* Show attachments for user messages */}
302315
{isUser &&
303316
((attachments && attachments.length > 0) ||
304-
(textAttachments && textAttachments.length > 0)) && (
317+
(textAttachments && textAttachments.length > 0) ||
318+
(fileAttachments && fileAttachments.length > 0)) && (
305319
<MessageAttachments
306320
imageAttachments={attachments ?? []}
307321
textAttachments={textAttachments ?? []}
322+
fileAttachments={fileAttachments ?? []}
308323
/>
309324
)}
310325
</box>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export const MessageWithAgents = memo(
268268
onOpenFeedback={onOpenFeedback}
269269
attachments={message.attachments}
270270
textAttachments={message.textAttachments}
271+
fileAttachments={message.fileAttachments}
271272
metadata={message.metadata}
272273
isLastMessage={isLastMessage}
273274
/>
@@ -303,6 +304,7 @@ export const MessageWithAgents = memo(
303304
onOpenFeedback={onOpenFeedback}
304305
attachments={message.attachments}
305306
textAttachments={message.textAttachments}
307+
fileAttachments={message.fileAttachments}
306308
metadata={message.metadata}
307309
isLastMessage={isLastMessage}
308310
/>

cli/src/components/pending-attachments-banner.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { BottomBanner } from './bottom-banner'
2+
import { FileAttachmentCard } from './file-attachment-card'
23
import { ImageCard } from './image-card'
34
import { TextAttachmentCard } from './text-attachment-card'
45
import { useTheme } from '../hooks/use-theme'
56
import { useChatStore } from '../state/chat-store'
67

7-
import type { PendingImageAttachment, PendingTextAttachment } from '../types/store'
8+
import type {
9+
PendingFileAttachment,
10+
PendingImageAttachment,
11+
PendingTextAttachment,
12+
} from '../types/store'
813

914
/**
1015
* Combined banner for both image and text attachments.
@@ -24,6 +29,9 @@ export const PendingAttachmentsBanner = () => {
2429
const pendingTextAttachments = pendingAttachments.filter(
2530
(a): a is PendingTextAttachment => a.kind === 'text',
2631
)
32+
const pendingFileAttachments = pendingAttachments.filter(
33+
(a): a is PendingFileAttachment => a.kind === 'file',
34+
)
2735

2836
// Separate error messages from actual images
2937
const errorImages: PendingImageAttachment[] = []
@@ -38,10 +46,11 @@ export const PendingAttachmentsBanner = () => {
3846

3947
const hasValidImages = validImages.length > 0
4048
const hasTextAttachments = pendingTextAttachments.length > 0
41-
const hasErrorsOnly = errorImages.length > 0 && !hasValidImages && !hasTextAttachments
49+
const hasFileAttachments = pendingFileAttachments.length > 0
50+
const hasErrorsOnly = errorImages.length > 0 && !hasValidImages && !hasTextAttachments && !hasFileAttachments
4251

4352
// Nothing to show
44-
if (!hasValidImages && !hasTextAttachments && errorImages.length === 0) {
53+
if (!hasValidImages && !hasTextAttachments && !hasFileAttachments && errorImages.length === 0) {
4554
return null
4655
}
4756

@@ -92,6 +101,15 @@ export const PendingAttachmentsBanner = () => {
92101
onRemove={() => removePendingAttachment(attachment.id)}
93102
/>
94103
))}
104+
105+
{/* File/folder attachment cards */}
106+
{pendingFileAttachments.map((attachment) => (
107+
<FileAttachmentCard
108+
key={attachment.id}
109+
attachment={attachment}
110+
onRemove={() => removePendingAttachment(attachment.path)}
111+
/>
112+
))}
95113
</box>
96114
</BottomBanner>
97115
)

cli/src/hooks/helpers/send-message.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { usageQueryKeys } from '../use-usage-query'
2626

2727
import type {
2828
PendingAttachment,
29+
PendingFileAttachment,
2930
PendingImageAttachment,
3031
PendingTextAttachment,
3132
} from '../../types/store'
@@ -144,6 +145,10 @@ export const prepareUserMessage = async (params: {
144145
(a): a is PendingTextAttachment => a.kind === 'text',
145146
)
146147

148+
const pendingFileAttachments = allAttachments.filter(
149+
(a): a is PendingFileAttachment => a.kind === 'file',
150+
)
151+
147152
// Append text attachments to the content
148153
let finalContent = content
149154
if (pendingTextAttachments.length > 0) {
@@ -155,6 +160,23 @@ export const prepareUserMessage = async (params: {
155160
: textAttachmentContent
156161
}
157162

163+
// Append file/folder attachments to the content
164+
if (pendingFileAttachments.length > 0) {
165+
const fileAttachmentContent = pendingFileAttachments
166+
.filter((att) => att.status === 'ready')
167+
.map((att) =>
168+
att.isDirectory
169+
? `[Directory: ${att.path}]\n${att.content}`
170+
: `[File: ${att.path}]\n${att.content}`,
171+
)
172+
.join('\n\n')
173+
if (fileAttachmentContent) {
174+
finalContent = finalContent
175+
? `${finalContent}\n\n${fileAttachmentContent}`
176+
: fileAttachmentContent
177+
}
178+
}
179+
158180
const { attachments: imageAttachments, messageContent } = await processImagesForMessage({
159181
content: finalContent,
160182
pendingImages,
@@ -172,8 +194,18 @@ export const prepareUserMessage = async (params: {
172194
charCount: att.charCount,
173195
}))
174196

197+
// Convert pending file attachments to stored file attachments for display
198+
const fileAttachmentsForMessage = pendingFileAttachments
199+
.filter((att) => att.status === 'ready')
200+
.map((att) => ({
201+
path: att.path,
202+
filename: att.filename,
203+
isDirectory: att.isDirectory,
204+
note: att.note,
205+
}))
206+
175207
// Pass original content (not finalContent) for display, but finalContent goes to agent
176-
const userMessage = getUserMessage(content, imageAttachments, textAttachmentsForMessage)
208+
const userMessage = getUserMessage(content, imageAttachments, textAttachmentsForMessage, fileAttachmentsForMessage)
177209
const userMessageId = userMessage.id
178210
if (imageAttachments.length > 0) {
179211
userMessage.attachments = imageAttachments

0 commit comments

Comments
 (0)