Skip to content

Commit 2ac662d

Browse files
committed
strip ansi chars from paste & use opentui copy function first
1 parent 3d530b2 commit 2ac662d

File tree

6 files changed

+321
-8
lines changed

6 files changed

+321
-8
lines changed

cli/src/hooks/use-chat-keyboard.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ function dispatchAction(
226226
// Next, read clipboard text to check if it's a file path
227227
// This handles the case where a file is dragged/dropped - we want to use
228228
// the file path, not any stale image data that might be in the clipboard
229-
const text = readClipboardText()
229+
const rawText = readClipboardText()
230+
const text = rawText ? Bun.stripANSI(rawText) : null
230231
if (text) {
231232
// Check if the text is a path to an image file
232233
const imagePath = getImageFilePathFromText(text, cwd)

cli/src/hooks/use-clipboard.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { useEffect, useRef, useState } from 'react'
44
import { CURSOR_CHAR } from '../components/multiline-input'
55
import {
66
copyTextToClipboard,
7+
registerClipboardRenderer,
78
subscribeClipboardMessages,
9+
unregisterClipboardRenderer,
810
} from '../utils/clipboard'
911

1012
function formatDefaultClipboardMessage(text: string): string | null {
@@ -30,6 +32,18 @@ export const useClipboard = () => {
3032
return subscribeClipboardMessages(setStatusMessage)
3133
}, [])
3234

35+
// Register the renderer globally so all copyTextToClipboard callers
36+
// can use the renderer's OSC 52 method when available.
37+
useEffect(() => {
38+
if (renderer) {
39+
registerClipboardRenderer(renderer as unknown as Record<string, unknown>)
40+
return () => {
41+
unregisterClipboardRenderer()
42+
}
43+
}
44+
return undefined
45+
}, [renderer])
46+
3347
useEffect(() => {
3448
const handleSelection = (selectionEvent: any) => {
3549
const selectionObj = selectionEvent ?? (renderer as any)?.getSelection?.()

cli/src/utils/__tests__/clipboard.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
showClipboardMessage,
99
subscribeClipboardMessages,
1010
clearClipboardMessage,
11+
registerClipboardRenderer,
12+
unregisterClipboardRenderer,
1113
} from '../clipboard'
1214
import { logger } from '../logger'
1315

@@ -399,6 +401,139 @@ describe('clipboard', () => {
399401
})
400402
})
401403

404+
describe('registerClipboardRenderer and renderer-based copy', () => {
405+
let originalPlatform: PropertyDescriptor | undefined
406+
let originalEnv: Record<string, string | undefined>
407+
let loggerErrorSpy: ReturnType<typeof spyOn>
408+
409+
beforeEach(() => {
410+
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
411+
originalEnv = {
412+
SSH_CLIENT: process.env.SSH_CLIENT,
413+
SSH_TTY: process.env.SSH_TTY,
414+
SSH_CONNECTION: process.env.SSH_CONNECTION,
415+
TERM: process.env.TERM,
416+
TMUX: process.env.TMUX,
417+
STY: process.env.STY,
418+
}
419+
loggerErrorSpy = spyOn(logger, 'error').mockImplementation(() => {})
420+
421+
// Use freebsd + dumb terminal to disable platform tools and OSC52,
422+
// isolating the renderer path.
423+
Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true })
424+
delete process.env.SSH_CLIENT
425+
delete process.env.SSH_TTY
426+
delete process.env.SSH_CONNECTION
427+
process.env.TERM = 'dumb'
428+
delete process.env.TMUX
429+
delete process.env.STY
430+
431+
clearClipboardMessage()
432+
unregisterClipboardRenderer()
433+
})
434+
435+
afterEach(() => {
436+
unregisterClipboardRenderer()
437+
if (originalPlatform) {
438+
Object.defineProperty(process, 'platform', originalPlatform)
439+
}
440+
for (const [key, value] of Object.entries(originalEnv)) {
441+
if (value !== undefined) process.env[key] = value
442+
else delete process.env[key]
443+
}
444+
loggerErrorSpy.mockRestore()
445+
clearClipboardMessage()
446+
})
447+
448+
test('renderer with copyToClipboardOSC52 returning true succeeds', async () => {
449+
const calls: string[] = []
450+
registerClipboardRenderer({
451+
copyToClipboardOSC52: (text: string) => {
452+
calls.push(text)
453+
return true
454+
},
455+
})
456+
457+
await copyTextToClipboard('test text', { suppressGlobalMessage: true })
458+
459+
expect(calls).toEqual(['test text'])
460+
})
461+
462+
test('renderer with copyToClipboardOSC52 returning false falls through and fails', async () => {
463+
registerClipboardRenderer({ copyToClipboardOSC52: () => false })
464+
465+
await expect(
466+
copyTextToClipboard('test text', { suppressGlobalMessage: true })
467+
).rejects.toThrow('No clipboard method available')
468+
})
469+
470+
test('renderer without copyToClipboardOSC52 falls through and fails', async () => {
471+
registerClipboardRenderer({ someOtherMethod: () => true })
472+
473+
await expect(
474+
copyTextToClipboard('test text', { suppressGlobalMessage: true })
475+
).rejects.toThrow('No clipboard method available')
476+
})
477+
478+
test('renderer whose copyToClipboardOSC52 throws falls through gracefully', async () => {
479+
registerClipboardRenderer({
480+
copyToClipboardOSC52: () => { throw new Error('renderer error') },
481+
})
482+
483+
await expect(
484+
copyTextToClipboard('test text', { suppressGlobalMessage: true })
485+
).rejects.toThrow('No clipboard method available')
486+
})
487+
488+
test('unregisterClipboardRenderer removes renderer so it is no longer used', async () => {
489+
const calls: string[] = []
490+
registerClipboardRenderer({
491+
copyToClipboardOSC52: (text: string) => {
492+
calls.push(text)
493+
return true
494+
},
495+
})
496+
unregisterClipboardRenderer()
497+
498+
await expect(
499+
copyTextToClipboard('test text', { suppressGlobalMessage: true })
500+
).rejects.toThrow('No clipboard method available')
501+
502+
expect(calls).toEqual([])
503+
})
504+
505+
test('renderer is tried in remote sessions (SSH) before manual OSC52', async () => {
506+
// Set up as remote session
507+
process.env.SSH_CLIENT = '192.168.1.100 54321 22'
508+
process.env.TERM = 'xterm-256color'
509+
510+
const calls: string[] = []
511+
registerClipboardRenderer({
512+
copyToClipboardOSC52: () => {
513+
calls.push('renderer')
514+
return true
515+
},
516+
})
517+
518+
await copyTextToClipboard('test text', { suppressGlobalMessage: true })
519+
520+
expect(calls).toEqual(['renderer'])
521+
})
522+
523+
test('shows success message when renderer copy succeeds', async () => {
524+
registerClipboardRenderer({ copyToClipboardOSC52: () => true })
525+
526+
const messages: (string | null)[] = []
527+
const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg))
528+
529+
await copyTextToClipboard('Hello world')
530+
531+
expect(messages).toContain('Copied: "Hello world"')
532+
533+
unsubscribe()
534+
})
535+
})
536+
402537
describe('copyTextToClipboard - SSH session detection behavior', () => {
403538
// These tests verify the copy behavior changes based on SSH environment variables.
404539
// In remote sessions (SSH), OSC52 is tried first; in local sessions, platform tools are tried first.

cli/src/utils/__tests__/strings.test.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, expect, test } from 'bun:test'
22

3-
import { truncateToLines, MAX_COLLAPSED_LINES } from '../strings'
3+
import {
4+
truncateToLines,
5+
MAX_COLLAPSED_LINES,
6+
createTextPasteHandler,
7+
createPasteHandler,
8+
LONG_TEXT_THRESHOLD,
9+
} from '../strings'
10+
11+
import type { InputValue } from '../../types/store'
412

513
describe('MAX_COLLAPSED_LINES', () => {
614
test('is set to 3', () => {
@@ -63,3 +71,122 @@ describe('truncateToLines', () => {
6371
expect(truncateToLines(text, 3)).toBe('line 1\nline 2\nline 3...')
6472
})
6573
})
74+
75+
describe('createTextPasteHandler - ANSI stripping', () => {
76+
test('strips ANSI escape sequences from pasted text', () => {
77+
let result: InputValue | null = null
78+
const handler = createTextPasteHandler('', 0, (value) => { result = value })
79+
80+
handler('\x1b[31mred text\x1b[0m')
81+
82+
expect(result).not.toBeNull()
83+
expect(result!.text).toBe('red text')
84+
expect(result!.cursorPosition).toBe(8)
85+
})
86+
87+
test('passes through plain text unchanged', () => {
88+
let result: InputValue | null = null
89+
const handler = createTextPasteHandler('', 0, (value) => { result = value })
90+
91+
handler('plain text')
92+
93+
expect(result).not.toBeNull()
94+
expect(result!.text).toBe('plain text')
95+
})
96+
97+
test('strips complex ANSI sequences (bold, 256-color)', () => {
98+
let result: InputValue | null = null
99+
const handler = createTextPasteHandler('', 0, (value) => { result = value })
100+
101+
handler('\x1b[1m\x1b[38;5;196mbold colored\x1b[0m')
102+
103+
expect(result).not.toBeNull()
104+
expect(result!.text).toBe('bold colored')
105+
})
106+
107+
test('does not insert when text is only ANSI codes (empty after stripping)', () => {
108+
let result: InputValue | null = null
109+
const handler = createTextPasteHandler('', 0, (value) => { result = value })
110+
111+
handler('\x1b[31m\x1b[0m')
112+
113+
expect(result).toBeNull()
114+
})
115+
116+
test('inserts stripped text at cursor position in existing text', () => {
117+
let result: InputValue | null = null
118+
const handler = createTextPasteHandler('hello world', 5, (value) => { result = value })
119+
120+
handler('\x1b[32m pasted\x1b[0m')
121+
122+
expect(result).not.toBeNull()
123+
expect(result!.text).toBe('hello pasted world')
124+
expect(result!.cursorPosition).toBe(12)
125+
})
126+
})
127+
128+
describe('createPasteHandler - ANSI stripping', () => {
129+
test('strips ANSI from eventText for regular text paste', () => {
130+
let result: InputValue | null = null
131+
const handler = createPasteHandler({
132+
text: '',
133+
cursorPosition: 0,
134+
onChange: (value) => { result = value },
135+
})
136+
137+
handler('\x1b[31mhello\x1b[0m')
138+
139+
expect(result).not.toBeNull()
140+
expect(result!.text).toBe('hello')
141+
expect(result!.cursorPosition).toBe(5)
142+
})
143+
144+
test('strips ANSI from eventText before checking long text threshold', () => {
145+
let longTextResult: string | null = null
146+
const handler = createPasteHandler({
147+
text: '',
148+
cursorPosition: 0,
149+
onChange: () => {},
150+
onPasteLongText: (text) => { longTextResult = text },
151+
})
152+
153+
// Create text that is over threshold BEFORE stripping but under AFTER
154+
const ansiOverhead = '\x1b[31m'.repeat(400) + '\x1b[0m'.repeat(400)
155+
const shortContent = 'a'.repeat(100)
156+
handler(ansiOverhead + shortContent)
157+
158+
// Should NOT be treated as long text since stripped content is short
159+
expect(longTextResult).toBeNull()
160+
})
161+
162+
test('strips ANSI but preserves plain text content', () => {
163+
let result: InputValue | null = null
164+
const handler = createPasteHandler({
165+
text: 'existing ',
166+
cursorPosition: 9,
167+
onChange: (value) => { result = value },
168+
})
169+
170+
handler('\x1b[1m\x1b[34mblue bold text\x1b[0m')
171+
172+
expect(result).not.toBeNull()
173+
expect(result!.text).toBe('existing blue bold text')
174+
expect(result!.cursorPosition).toBe(23)
175+
})
176+
177+
test('long text handler receives stripped text', () => {
178+
let longTextResult: string | null = null
179+
const handler = createPasteHandler({
180+
text: '',
181+
cursorPosition: 0,
182+
onChange: () => {},
183+
onPasteLongText: (text) => { longTextResult = text },
184+
})
185+
186+
const longContent = 'x'.repeat(LONG_TEXT_THRESHOLD + 1)
187+
handler(`\x1b[31m${longContent}\x1b[0m`)
188+
189+
expect(longTextResult).not.toBeNull()
190+
expect(longTextResult!).toBe(longContent)
191+
})
192+
})

cli/src/utils/clipboard.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ import { createRequire } from 'module'
44
import { getCliEnv } from './env'
55
import { logger } from './logger'
66

7+
// Global renderer reference for clipboard operations.
8+
// Registered once by the useClipboard hook so all callers of
9+
// copyTextToClipboard automatically benefit from renderer-based
10+
// OSC 52 without threading the renderer through every call site.
11+
let registeredRenderer: Record<string, unknown> | null = null
12+
13+
export function registerClipboardRenderer(renderer: Record<string, unknown>): void {
14+
registeredRenderer = renderer
15+
}
16+
17+
export function unregisterClipboardRenderer(): void {
18+
registeredRenderer = null
19+
}
20+
721
const require = createRequire(import.meta.url)
822

923
type ClipboardListener = (message: string | null) => void
@@ -85,11 +99,13 @@ export async function copyTextToClipboard(
8599
try {
86100
let copied: boolean
87101
if (isRemoteSession()) {
88-
// Remote/SSH: prefer OSC 52 (copies to client terminal's clipboard)
89-
copied = tryCopyViaOsc52(text) || tryCopyViaPlatformTool(text)
102+
// Remote/SSH: prefer renderer OSC 52 (through render pipeline),
103+
// then our manual OSC 52, then platform tools
104+
copied = tryCopyViaRenderer(text) || tryCopyViaOsc52(text) || tryCopyViaPlatformTool(text)
90105
} else {
91-
// Local: prefer platform tools (reliable with tmux), OSC 52 as fallback
92-
copied = tryCopyViaPlatformTool(text) || tryCopyViaOsc52(text)
106+
// Local: prefer platform tools (reliable with tmux),
107+
// then renderer OSC 52, then our manual OSC 52 as fallback
108+
copied = tryCopyViaPlatformTool(text) || tryCopyViaRenderer(text) || tryCopyViaOsc52(text)
93109
}
94110

95111
if (!copied) {
@@ -161,6 +177,17 @@ function tryCopyViaPlatformTool(text: string): boolean {
161177
}
162178
}
163179

180+
function tryCopyViaRenderer(text: string): boolean {
181+
if (!registeredRenderer) return false
182+
const copyFn = registeredRenderer.copyToClipboardOSC52
183+
if (typeof copyFn !== 'function') return false
184+
try {
185+
return Boolean(copyFn.call(registeredRenderer, text))
186+
} catch {
187+
return false
188+
}
189+
}
190+
164191
// 32KB is safe for all environments (tmux is the strictest)
165192
const OSC52_MAX_PAYLOAD = 32_000
166193

0 commit comments

Comments
 (0)