diff --git a/ui-tui-opentui-v2/src/boundary/clipboard.ts b/ui-tui-opentui-v2/src/boundary/clipboard.ts new file mode 100644 index 00000000000..fa044c97af4 --- /dev/null +++ b/ui-tui-opentui-v2/src/boundary/clipboard.ts @@ -0,0 +1,94 @@ +/** + * Clipboard (item 1) — copy via OSC 52 (works over SSH/tmux) + a native platform + * command, and read a clipboard IMAGE for paste-to-attach. Ported/trimmed from + * opencode `clipboard.ts`. A boundary concern (spawns processes / writes stdout); + * everything is best-effort and never throws into the view. + */ +import { spawn } from 'node:child_process' +import { platform } from 'node:os' + +/** Run a command, optionally piping `input` to stdin; resolve its stdout bytes. */ +function run(cmd: string, args: string[] = [], input?: string): Promise { + return new Promise((resolve, reject) => { + let child + try { + child = spawn(cmd, args, { stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'] }) + } catch (cause) { + reject(cause instanceof Error ? cause : new Error(String(cause))) + return + } + const out: Buffer[] = [] + child.on('error', reject) + child.stdout?.on('data', (c: Buffer) => out.push(c)) + child.on('close', code => (code === 0 ? resolve(Buffer.concat(out)) : reject(new Error(`${cmd} exit ${code}`)))) + if (input !== undefined) child.stdin?.end(input) + }) +} + +/** OSC 52 copy — the terminal puts `text` on the system clipboard (SSH/tmux-safe). */ +function writeOsc52(text: string): void { + if (!process.stdout.isTTY) return + const seq = `\x1b]52;c;${Buffer.from(text).toString('base64')}\x07` + // tmux/screen need the sequence wrapped in their passthrough escape. + process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${seq}\x1b\\` : seq) +} + +/** Native copy commands to try, in order, for the current platform. */ +function copyCandidates(): Array<[string, string[]]> { + const os = platform() + if (os === 'darwin') return [['pbcopy', []]] + if (os === 'win32') return [['clip', []]] + // linux: prefer Wayland, then X11 tools + const list: Array<[string, string[]]> = [] + if (process.env.WAYLAND_DISPLAY) list.push(['wl-copy', []]) + list.push(['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]) + return list +} + +/** Copy `text` to the clipboard: OSC 52 (always) + the first native command that works. */ +export async function writeClipboard(text: string): Promise { + writeOsc52(text) + for (const [cmd, args] of copyCandidates()) { + try { + await run(cmd, args, text) + return + } catch { + // try the next candidate + } + } +} + +/** Read a clipboard IMAGE as base64 PNG (for paste-to-attach); undefined if none. */ +export async function readClipboardImage(): Promise<{ data: string; mime: string } | undefined> { + const os = platform() + const tries: Array<[string, string[]]> = [] + if (os === 'linux') { + if (process.env.WAYLAND_DISPLAY) tries.push(['wl-paste', ['-t', 'image/png']]) + tries.push(['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]) + } else if (os === 'darwin') { + tries.push(['pngpaste', ['-']]) // brew install pngpaste + } else if (os === 'win32') { + tries.push([ + 'powershell.exe', + [ + '-NonInteractive', + '-NoProfile', + '-Command', + 'Add-Type -AssemblyName System.Windows.Forms; $img=[System.Windows.Forms.Clipboard]::GetImage(); if($img){$ms=New-Object System.IO.MemoryStream; $img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); [Console]::Out.Write([System.Convert]::ToBase64String($ms.ToArray()))}' + ] + ]) + } + for (const [cmd, args] of tries) { + try { + const buf = await run(cmd, args) + if (buf.length) { + // powershell already returns base64 text; the others return raw PNG bytes. + const data = os === 'win32' ? buf.toString('utf8').trim() : buf.toString('base64') + if (data) return { data, mime: 'image/png' } + } + } catch { + // try the next candidate + } + } + return undefined +} diff --git a/ui-tui-opentui-v2/src/boundary/renderer.ts b/ui-tui-opentui-v2/src/boundary/renderer.ts index c187e8be200..3f50ed1b4d0 100644 --- a/ui-tui-opentui-v2/src/boundary/renderer.ts +++ b/ui-tui-opentui-v2/src/boundary/renderer.ts @@ -25,6 +25,12 @@ export interface RendererOptions { * default is an immediate `renderer.destroy()` (quit). */ readonly onCtrlC?: () => void + /** + * Copy a mouse selection (item 1). When there's a live selection, Ctrl+C copies + * it (this callback) instead of interrupting/quitting — opencode's selection + * key precedence (`app.tsx:388`). + */ + readonly onCopySelection?: (text: string) => void } /** @@ -64,6 +70,16 @@ export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options: const isBlocked = options.isBlocked ?? (() => false) renderer.keyInput.on('keypress', (key: KeyEvent) => { if (!(key.ctrl && key.name === 'c') || renderer.isDestroyed) return + // Copy a live mouse selection first (item 1) — takes precedence over the + // interrupt/quit machine and over a blocking prompt's cancel. + if (options.onCopySelection) { + const text = renderer.getSelection()?.getSelectedText() ?? '' + if (text) { + options.onCopySelection(text) + renderer.clearSelection() + return + } + } if (isBlocked()) return // a blocking prompt owns Ctrl+C (→ deny/cancel) if (options.onCtrlC) options.onCtrlC() else renderer.destroy() diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index 3f01edd79f9..bc1de60b462 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -22,6 +22,7 @@ import { render } from '@opentui/solid' import { Deferred, Duration, Effect } from 'effect' +import { readClipboardImage, writeClipboard } from '../boundary/clipboard.ts' import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/GatewayService.ts' import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts' import { getLog } from '../boundary/log.ts' @@ -199,11 +200,51 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { } } + // Transient hint that auto-clears (used by copy/image-paste feedback). + const flashHint = (message: string, ms = 1500) => { + store.setHint(message) + setTimeout(() => { + if (store.state.hint === message) store.setHint(undefined) + }, ms) + } + + // Copy a mouse selection to the clipboard (item 1) — OSC 52 + native command. + const onCopySelection = (text: string) => { + void writeClipboard(text) + flashHint('Copied to clipboard') + } + + // Paste an IMAGE (item 1): read the clipboard image and attach it to the + // session (image.attach_bytes); the next prompt.submit picks it up. + const onImagePaste = () => { + void (async () => { + const img = await readClipboardImage() + if (!img) { + flashHint('No image in clipboard', 2000) + return + } + const sid = gateway.sessionId() + if (!sid) { + flashHint('No session for image', 2000) + return + } + try { + await Effect.runPromise( + gateway.request('image.attach_bytes', { content_base64: img.data, filename: 'pasted.png', session_id: sid }) + ) + flashHint('🖼 image attached — type a message and send', 3000) + } catch { + flashHint('Image attach failed', 2000) + } + })() + } + // A blocking prompt owns Ctrl+C (→ cancel); otherwise the state machine above runs. const { renderer, shutdown } = yield* acquireRenderer({ mouse: input.mouse, isBlocked: () => store.state.prompt !== undefined, - onCtrlC + onCtrlC, + onCopySelection }) doQuit = () => { if (!renderer.isDestroyed) renderer.destroy() @@ -313,6 +354,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { onResume={onResume} sessionId={() => gateway.sessionId()} history={history} + onImagePaste={onImagePaste} /> ), diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index 4ea0b418eeb..1bbb306e90b 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -37,6 +37,7 @@ export interface AppProps { readonly onResume?: (sessionId: string) => void readonly sessionId?: () => string | undefined readonly history?: PromptHistory + readonly onImagePaste?: () => void } const NOOP = () => {} @@ -89,6 +90,7 @@ export function App(props: AppProps) { completionFrom={() => props.store.state.completionFrom} onDismiss={() => props.store.clearCompletions()} history={props.history} + onImagePaste={props.onImagePaste} /> } > diff --git a/ui-tui-opentui-v2/src/view/composer.tsx b/ui-tui-opentui-v2/src/view/composer.tsx index 057e47836de..4d1c5da7d3f 100644 --- a/ui-tui-opentui-v2/src/view/composer.tsx +++ b/ui-tui-opentui-v2/src/view/composer.tsx @@ -19,7 +19,7 @@ * the prompt focused via a reactive effect; here a keystroke net is enough since * the composer remounts+refocuses whenever an overlay closes). */ -import { type TextareaRenderable } from '@opentui/core' +import { type PasteEvent, type TextareaRenderable } from '@opentui/core' import { useKeyboard } from '@opentui/solid' import { For, onMount, Show } from 'solid-js' @@ -80,6 +80,7 @@ export function Composer(props: { completionFrom?: (() => number) | undefined onDismiss?: (() => void) | undefined history?: PromptHistory | undefined + onImagePaste?: (() => void) | undefined }) { const theme = useTheme() let ta: TextareaRenderable | undefined @@ -182,7 +183,7 @@ export function Composer(props: { not a background tint. */} - + {theme().brand.prompt} @@ -196,6 +197,14 @@ export function Composer(props: { keyBindings={[{ action: 'submit', name: 'return' }]} onMouseDown={() => ta?.focus()} onSubmit={submit} + onPaste={(e: PasteEvent) => { + // An empty bracketed paste = an image-only clipboard (item 1) — read + + // attach it. Text pastes fall through to the textarea's native insert. + if (new TextDecoder().decode(e.bytes).trim() === '') { + e.preventDefault() + props.onImagePaste?.() + } + }} onContentChange={() => props.onType?.(ta?.plainText ?? '')} /> diff --git a/ui-tui-opentui-v2/src/view/header.tsx b/ui-tui-opentui-v2/src/view/header.tsx index 90dbda59472..cef02bc50dd 100644 --- a/ui-tui-opentui-v2/src/view/header.tsx +++ b/ui-tui-opentui-v2/src/view/header.tsx @@ -13,7 +13,7 @@ export function Header(props: { store: SessionStore }) { const theme = useTheme() return ( - + {theme().brand.name} · opentui · connecting…}> diff --git a/ui-tui-opentui-v2/src/view/messageLine.tsx b/ui-tui-opentui-v2/src/view/messageLine.tsx index b241d996863..620659dd651 100644 --- a/ui-tui-opentui-v2/src/view/messageLine.tsx +++ b/ui-tui-opentui-v2/src/view/messageLine.tsx @@ -28,7 +28,8 @@ export function MessageLine(props: { message: Message }) { return ( - + {/* the role glyph is decorative — exclude it from mouse selection (item 4) */} + {glyph()} diff --git a/ui-tui-opentui-v2/src/view/statusBar.tsx b/ui-tui-opentui-v2/src/view/statusBar.tsx index fbdd9c00e5b..2f39353485c 100644 --- a/ui-tui-opentui-v2/src/view/statusBar.tsx +++ b/ui-tui-opentui-v2/src/view/statusBar.tsx @@ -104,7 +104,7 @@ export function StatusBar(props: { store: SessionStore }) { > {/* left: turn/connection dot + model + effort + context bar */} - + {dot()} {` ${model()}`} @@ -124,7 +124,7 @@ export function StatusBar(props: { store: SessionStore }) { {/* right: cwd (branch), pre-truncated so the row never wraps */} - + {rightText()} diff --git a/ui-tui-opentui-v2/src/view/statusLine.tsx b/ui-tui-opentui-v2/src/view/statusLine.tsx index 629f6de997b..15c9a2d4e42 100644 --- a/ui-tui-opentui-v2/src/view/statusLine.tsx +++ b/ui-tui-opentui-v2/src/view/statusLine.tsx @@ -21,7 +21,7 @@ export function StatusLine(props: { store: SessionStore }) { {text => ( - + {text()} diff --git a/ui-tui-opentui-v2/src/view/toolPart.tsx b/ui-tui-opentui-v2/src/view/toolPart.tsx index 4f340e15384..3dadc289026 100644 --- a/ui-tui-opentui-v2/src/view/toolPart.tsx +++ b/ui-tui-opentui-v2/src/view/toolPart.tsx @@ -45,7 +45,7 @@ export function ToolPart(props: { part: ToolPartState }) { {/* header — clickable to toggle when there's expandable output */} collapsible() && setExpanded(e => !e)}> - + {headGlyph()}