opentui(v2): clipboard copy/paste, image paste, glyph-free selection (items 1, 4)

Item 1 — copy/paste:
- boundary/clipboard.ts (ported/trimmed from opencode): writeClipboard = OSC 52
  (SSH/tmux-safe) + a native command (pbcopy/wl-copy/xclip/xsel/clip);
  readClipboardImage = clipboard PNG via wl-paste/xclip/pngpaste/powershell.
- Ctrl+C copies a live MOUSE SELECTION (renderer.getSelection) before the
  interrupt/quit machine runs (opencode's selection-key precedence), with a
  "Copied to clipboard" hint; falls through to interrupt/quit when there's no
  selection.
- text paste inserts natively (textarea handlePaste); the composer's onPaste only
  intercepts an EMPTY bracketed paste (image-only clipboard) → readClipboardImage
  → image.attach_bytes (the next prompt.submit picks it up).

Item 4 — mouse selection now ignores decorative glyphs: selectable={false} on the
message/tool gutter glyphs and all chrome (header, status bar, status line,
composer prompt glyph), so a drag copies the message text, not ❯/⚕/▶/.

Live-smoked (this env has no clipboard tools/DISPLAY, so native copy + image read
can't be confirmed here, but): drag-select + Ctrl+C → "Copied to clipboard" (not
quit); no-selection Ctrl+C still arms quit; bracketed text paste lands in the
composer. 71 pass.
This commit is contained in:
alt-glitch 2026-06-08 18:20:59 +00:00
parent f423aebb80
commit c3d2d87a74
10 changed files with 173 additions and 9 deletions

View file

@ -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<Buffer> {
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<void> {
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
}

View file

@ -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()

View file

@ -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}
/>
</ThemeProvider>
),

View file

@ -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}
/>
}
>

View file

@ -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. */}
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text>
<text selectable={false}>
<span style={{ fg: theme().color.prompt }}>{theme().brand.prompt}</span>
</text>
</box>
@ -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 ?? '')}
/>
</box>

View file

@ -13,7 +13,7 @@ export function Header(props: { store: SessionStore }) {
const theme = useTheme()
return (
<box style={{ flexShrink: 0 }}>
<text>
<text selectable={false}>
<b>{theme().brand.name}</b>
<span style={{ fg: theme().color.muted }}> · opentui · </span>
<Show when={props.store.state.ready} fallback={<span style={{ fg: theme().color.muted }}>connecting</span>}>

View file

@ -28,7 +28,8 @@ export function MessageLine(props: { message: Message }) {
return (
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: m().role === 'user' ? 1 : 0 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text>
{/* the role glyph is decorative — exclude it from mouse selection (item 4) */}
<text selectable={false}>
<span style={{ fg: glyphFg() }}>{glyph()}</span>
</text>
</box>

View file

@ -104,7 +104,7 @@ export function StatusBar(props: { store: SessionStore }) {
>
{/* left: turn/connection dot + model + effort + context bar */}
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text>
<text selectable={false}>
<span style={{ fg: dotColor() }}>{dot()}</span>
<Show when={model()}>
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
@ -124,7 +124,7 @@ export function StatusBar(props: { store: SessionStore }) {
{/* right: cwd (branch), pre-truncated so the row never wraps */}
<Show when={rightText()}>
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
</text>
</box>

View file

@ -21,7 +21,7 @@ export function StatusLine(props: { store: SessionStore }) {
<Show when={line()}>
{text => (
<box style={{ flexShrink: 0 }}>
<text>
<text selectable={false}>
<span style={{ fg: isHint() ? theme().color.warn : theme().color.muted }}>{text()}</span>
</text>
</box>

View file

@ -45,7 +45,7 @@ export function ToolPart(props: { part: ToolPartState }) {
{/* header — clickable to toggle when there's expandable output */}
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && setExpanded(e => !e)}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text>
<text selectable={false}>
<span style={{ fg: headColor() }}>{headGlyph()}</span>
</text>
</box>