Skip to content

Commit 88c097e

Browse files
committed
feat: pasted text becomes an attachment like an image
1 parent 8ec6aca commit 88c097e

15 files changed

+375
-23
lines changed

cli/src/chat.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,21 @@ export const Chat = ({
15821582
onChange: setInputValue,
15831583
onPasteImage: chatKeyboardHandlers.onPasteImage,
15841584
onPasteImagePath: chatKeyboardHandlers.onPasteImagePath,
1585+
onPasteLongText: (pastedText) => {
1586+
const id = crypto.randomUUID()
1587+
const preview = pastedText.slice(0, 100).replace(/\n/g, ' ')
1588+
useChatStore.getState().addPendingTextAttachment({
1589+
id,
1590+
content: pastedText,
1591+
preview,
1592+
charCount: pastedText.length,
1593+
})
1594+
// Show temporary status message
1595+
showClipboardMessage(
1596+
`📋 Pasted text (${pastedText.length.toLocaleString()} chars)`,
1597+
{ durationMs: 5000 },
1598+
)
1599+
},
15851600
cwd: getProjectRoot() ?? process.cwd(),
15861601
})}
15871602
/>

cli/src/components/image-card.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,7 @@ export const ImageCard = ({
161161
onMouseOut={() => setIsCloseHovered(false)}
162162
style={{ paddingLeft: 0, paddingRight: 0 }}
163163
>
164-
<text style={{ fg: isCloseHovered ? theme.error : theme.muted }}>
165-
×
166-
</text>
164+
<text style={{ fg: isCloseHovered ? theme.error : theme.muted }}>X</text>
167165
</Button>
168166
) : (
169167
<box style={{ width: CLOSE_BUTTON_WIDTH }} />

cli/src/components/input-mode-banner.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react'
22

33
import { ClaudeConnectBanner } from './claude-connect-banner'
44
import { HelpBanner } from './help-banner'
5-
import { PendingImagesBanner } from './pending-images-banner'
5+
import { PendingAttachmentsBanner } from './pending-attachments-banner'
66
import { ReferralBanner } from './referral-banner'
77
import { UsageBanner } from './usage-banner'
88
import { useChatStore } from '../state/chat-store'
@@ -20,8 +20,8 @@ const BANNER_REGISTRY: Record<
2020
string,
2121
(ctx: { showTime: number }) => React.ReactNode
2222
> = {
23-
default: () => <PendingImagesBanner />,
24-
image: () => <PendingImagesBanner />,
23+
default: () => <PendingAttachmentsBanner />,
24+
image: () => <PendingAttachmentsBanner />,
2525
usage: ({ showTime }) => <UsageBanner showTime={showTime} />,
2626
referral: () => <ReferralBanner />,
2727
help: () => <HelpBanner />,

cli/src/components/message-block.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AgentBranchItem } from './agent-branch-item'
55
import { Button } from './button'
66
import { CopyButton } from './copy-button'
77
import { ImageCard } from './image-card'
8+
import { TextAttachmentCard } from './text-attachment-card'
89
import { ImplementorGroup } from './implementor-row'
910
import { MessageFooter } from './message-footer'
1011
import { ValidationErrorPopover } from './validation-error-popover'
@@ -35,6 +36,7 @@ import type {
3536
HtmlContentBlock,
3637
AgentContentBlock,
3738
ImageAttachment,
39+
TextAttachment,
3840
ImageContentBlock,
3941
ChatMessageMetadata,
4042
} from '../types/chat'
@@ -70,16 +72,19 @@ interface MessageBlockProps {
7072
errors?: Array<{ id: string; message: string }>
7173
}) => void
7274
attachments?: ImageAttachment[]
75+
textAttachments?: TextAttachment[]
7376
metadata?: ChatMessageMetadata
7477
isLastMessage?: boolean
7578
}
7679

7780
const MessageAttachments = ({
78-
attachments,
81+
imageAttachments,
82+
textAttachments,
7983
}: {
80-
attachments: ImageAttachment[]
84+
imageAttachments: ImageAttachment[]
85+
textAttachments: TextAttachment[]
8186
}) => {
82-
if (attachments.length === 0) {
87+
if (imageAttachments.length === 0 && textAttachments.length === 0) {
8388
return null
8489
}
8590

@@ -92,13 +97,20 @@ const MessageAttachments = ({
9297
marginTop: 1,
9398
}}
9499
>
95-
{attachments.map((attachment) => (
100+
{imageAttachments.map((attachment) => (
96101
<ImageCard
97102
key={attachment.path}
98103
image={attachment}
99104
showRemoveButton={false}
100105
/>
101106
))}
107+
{textAttachments.map((attachment) => (
108+
<TextAttachmentCard
109+
key={attachment.id}
110+
attachment={attachment}
111+
showRemoveButton={false}
112+
/>
113+
))}
102114
</box>
103115
)
104116
}
@@ -129,6 +141,7 @@ export const MessageBlock: React.FC<MessageBlockProps> = ({
129141
validationErrors,
130142
onOpenFeedback,
131143
attachments,
144+
textAttachments,
132145
metadata,
133146
isLastMessage,
134147
}) => {
@@ -300,9 +313,12 @@ export const MessageBlock: React.FC<MessageBlockProps> = ({
300313
showCopyButton={isUser}
301314
/>
302315
)}
303-
{/* Show image attachments for user messages */}
304-
{isUser && attachments && attachments.length > 0 && (
305-
<MessageAttachments attachments={attachments} />
316+
{/* Show attachments for user messages */}
317+
{isUser && ((attachments && attachments.length > 0) || (textAttachments && textAttachments.length > 0)) && (
318+
<MessageAttachments
319+
imageAttachments={attachments ?? []}
320+
textAttachments={textAttachments ?? []}
321+
/>
306322
)}
307323

308324
{isAi && (

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ export const MessageWithAgents = memo(
223223
validationErrors={message.validationErrors}
224224
onOpenFeedback={onOpenFeedback}
225225
attachments={message.attachments}
226+
textAttachments={message.textAttachments}
226227
metadata={message.metadata}
227228
isLastMessage={isLastMessage}
228229
/>
@@ -254,6 +255,7 @@ export const MessageWithAgents = memo(
254255
onFeedback={onFeedback}
255256
onCloseFeedback={onCloseFeedback}
256257
attachments={message.attachments}
258+
textAttachments={message.textAttachments}
257259
metadata={message.metadata}
258260
isLastMessage={isLastMessage}
259261
/>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { BottomBanner } from './bottom-banner'
2+
import { ImageCard } from './image-card'
3+
import { TextAttachmentCard } from './text-attachment-card'
4+
import { useTheme } from '../hooks/use-theme'
5+
import { useChatStore } from '../state/chat-store'
6+
7+
/**
8+
* Combined banner for both image and text attachments.
9+
* Displays all attachments in a single horizontal row.
10+
*/
11+
export const PendingAttachmentsBanner = () => {
12+
const theme = useTheme()
13+
const pendingImages = useChatStore((state) => state.pendingImages)
14+
const removePendingImage = useChatStore((state) => state.removePendingImage)
15+
const pendingTextAttachments = useChatStore(
16+
(state) => state.pendingTextAttachments,
17+
)
18+
const removePendingTextAttachment = useChatStore(
19+
(state) => state.removePendingTextAttachment,
20+
)
21+
22+
// Separate error messages from actual images
23+
const errorImages: typeof pendingImages = []
24+
const validImages: typeof pendingImages = []
25+
for (const img of pendingImages) {
26+
if (img.status === 'error') {
27+
errorImages.push(img)
28+
} else {
29+
validImages.push(img)
30+
}
31+
}
32+
33+
const hasValidImages = validImages.length > 0
34+
const hasTextAttachments = pendingTextAttachments.length > 0
35+
const hasErrorsOnly = errorImages.length > 0 && !hasValidImages && !hasTextAttachments
36+
37+
// Nothing to show
38+
if (!hasValidImages && !hasTextAttachments && errorImages.length === 0) {
39+
return null
40+
}
41+
42+
// If we only have errors (no valid attachments), show just the error messages
43+
if (hasErrorsOnly) {
44+
return (
45+
<BottomBanner borderColorKey="error">
46+
{errorImages.map((image, index) => (
47+
<text key={`${image.path}-${index}`} style={{ fg: theme.error }}>
48+
{image.note} ({image.filename})
49+
</text>
50+
))}
51+
</BottomBanner>
52+
)
53+
}
54+
55+
return (
56+
<BottomBanner borderColorKey="imageCardBorder">
57+
{/* Error messages shown above the attachments */}
58+
{errorImages.map((image, index) => (
59+
<text key={`error-${image.path}-${index}`} style={{ fg: theme.error }}>
60+
{image.note} ({image.filename})
61+
</text>
62+
))}
63+
64+
{/* All attachment cards in a horizontal row */}
65+
<box
66+
style={{
67+
flexDirection: 'row',
68+
gap: 1,
69+
flexWrap: 'wrap',
70+
}}
71+
>
72+
{/* Image cards */}
73+
{validImages.map((image, index) => (
74+
<ImageCard
75+
key={`img-${image.path}-${index}`}
76+
image={image}
77+
onRemove={() => removePendingImage(image.path)}
78+
/>
79+
))}
80+
81+
{/* Text attachment cards */}
82+
{pendingTextAttachments.map((attachment) => (
83+
<TextAttachmentCard
84+
key={attachment.id}
85+
attachment={attachment}
86+
onRemove={() => removePendingTextAttachment(attachment.id)}
87+
/>
88+
))}
89+
</box>
90+
</BottomBanner>
91+
)
92+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { BottomBanner } from './bottom-banner'
2+
import { TextAttachmentCard } from './text-attachment-card'
3+
import { useChatStore } from '../state/chat-store'
4+
5+
export const PendingTextBanner = () => {
6+
const pendingTextAttachments = useChatStore(
7+
(state) => state.pendingTextAttachments,
8+
)
9+
const removePendingTextAttachment = useChatStore(
10+
(state) => state.removePendingTextAttachment,
11+
)
12+
13+
if (pendingTextAttachments.length === 0) {
14+
return null
15+
}
16+
17+
return (
18+
<BottomBanner borderColorKey="imageCardBorder">
19+
{/* Text attachment cards in a horizontal row */}
20+
<box
21+
style={{
22+
flexDirection: 'row',
23+
gap: 1,
24+
flexWrap: 'wrap',
25+
}}
26+
>
27+
{pendingTextAttachments.map((attachment) => (
28+
<TextAttachmentCard
29+
key={attachment.id}
30+
attachment={attachment}
31+
onRemove={() => removePendingTextAttachment(attachment.id)}
32+
/>
33+
))}
34+
</box>
35+
</BottomBanner>
36+
)
37+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useState } from 'react'
2+
3+
import { Button } from './button'
4+
import { useTheme } from '../hooks/use-theme'
5+
import { IMAGE_CARD_BORDER_CHARS } from '../utils/ui-constants'
6+
7+
import type { PendingTextAttachment } from '../state/chat-store'
8+
9+
const TEXT_CARD_WIDTH = 24
10+
const MAX_PREVIEW_LINES = 2
11+
12+
interface TextAttachmentCardProps {
13+
attachment: PendingTextAttachment | { preview: string; charCount: number }
14+
onRemove?: () => void
15+
showRemoveButton?: boolean
16+
}
17+
18+
export const TextAttachmentCard = ({
19+
attachment,
20+
onRemove,
21+
showRemoveButton = true,
22+
}: TextAttachmentCardProps) => {
23+
const theme = useTheme()
24+
const [isCloseHovered, setIsCloseHovered] = useState(false)
25+
26+
// Preview is already processed (newlines replaced with spaces), truncate to fit
27+
const displayPreview =
28+
attachment.preview.slice(0, 40) +
29+
(attachment.preview.length > 40 ? '…' : '')
30+
31+
return (
32+
<box style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
33+
{/* Main card with border */}
34+
<box
35+
style={{
36+
flexDirection: 'column',
37+
borderStyle: 'single',
38+
borderColor: theme.imageCardBorder,
39+
width: TEXT_CARD_WIDTH,
40+
padding: 0,
41+
}}
42+
customBorderChars={IMAGE_CARD_BORDER_CHARS}
43+
>
44+
{/* Preview area */}
45+
<box
46+
style={{
47+
paddingLeft: 1,
48+
paddingRight: 1,
49+
height: 3,
50+
justifyContent: 'center',
51+
}}
52+
>
53+
<text
54+
style={{
55+
fg: theme.foreground,
56+
wrapMode: 'none',
57+
}}
58+
>
59+
{displayPreview || '(empty)'}
60+
</text>
61+
</box>
62+
63+
{/* Footer with icon and char count */}
64+
<box
65+
style={{
66+
paddingLeft: 1,
67+
paddingRight: 1,
68+
flexDirection: 'row',
69+
gap: 1,
70+
}}
71+
>
72+
<text style={{ fg: theme.info }}>📄</text>
73+
<text
74+
style={{
75+
fg: theme.muted,
76+
wrapMode: 'none',
77+
}}
78+
>
79+
{attachment.charCount.toLocaleString()} chars
80+
</text>
81+
</box>
82+
</box>
83+
84+
{/* Close button outside the card */}
85+
{showRemoveButton && onRemove && (
86+
<Button
87+
onClick={onRemove}
88+
onMouseOver={() => setIsCloseHovered(true)}
89+
onMouseOut={() => setIsCloseHovered(false)}
90+
style={{ paddingLeft: 0, paddingRight: 0 }}
91+
>
92+
<text style={{ fg: isCloseHovered ? theme.error : theme.muted }}>[×]</text>
93+
</Button>
94+
)}
95+
</box>
96+
)
97+
}

0 commit comments

Comments
 (0)