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:
parent
f423aebb80
commit
c3d2d87a74
94
ui-tui-opentui-v2/src/boundary/clipboard.ts
Normal file
94
ui-tui-opentui-v2/src/boundary/clipboard.ts
Normal 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
|
||||||
|
}
|
||||||
@ -25,6 +25,12 @@ export interface RendererOptions {
|
|||||||
* default is an immediate `renderer.destroy()` (quit).
|
* default is an immediate `renderer.destroy()` (quit).
|
||||||
*/
|
*/
|
||||||
readonly onCtrlC?: () => void
|
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)
|
const isBlocked = options.isBlocked ?? (() => false)
|
||||||
renderer.keyInput.on('keypress', (key: KeyEvent) => {
|
renderer.keyInput.on('keypress', (key: KeyEvent) => {
|
||||||
if (!(key.ctrl && key.name === 'c') || renderer.isDestroyed) return
|
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 (isBlocked()) return // a blocking prompt owns Ctrl+C (→ deny/cancel)
|
||||||
if (options.onCtrlC) options.onCtrlC()
|
if (options.onCtrlC) options.onCtrlC()
|
||||||
else renderer.destroy()
|
else renderer.destroy()
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
import { render } from '@opentui/solid'
|
import { render } from '@opentui/solid'
|
||||||
import { Deferred, Duration, Effect } from 'effect'
|
import { Deferred, Duration, Effect } from 'effect'
|
||||||
|
|
||||||
|
import { readClipboardImage, writeClipboard } from '../boundary/clipboard.ts'
|
||||||
import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/GatewayService.ts'
|
import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/GatewayService.ts'
|
||||||
import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
|
import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
|
||||||
import { getLog } from '../boundary/log.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.
|
// A blocking prompt owns Ctrl+C (→ cancel); otherwise the state machine above runs.
|
||||||
const { renderer, shutdown } = yield* acquireRenderer({
|
const { renderer, shutdown } = yield* acquireRenderer({
|
||||||
mouse: input.mouse,
|
mouse: input.mouse,
|
||||||
isBlocked: () => store.state.prompt !== undefined,
|
isBlocked: () => store.state.prompt !== undefined,
|
||||||
onCtrlC
|
onCtrlC,
|
||||||
|
onCopySelection
|
||||||
})
|
})
|
||||||
doQuit = () => {
|
doQuit = () => {
|
||||||
if (!renderer.isDestroyed) renderer.destroy()
|
if (!renderer.isDestroyed) renderer.destroy()
|
||||||
@ -313,6 +354,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||||||
onResume={onResume}
|
onResume={onResume}
|
||||||
sessionId={() => gateway.sessionId()}
|
sessionId={() => gateway.sessionId()}
|
||||||
history={history}
|
history={history}
|
||||||
|
onImagePaste={onImagePaste}
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export interface AppProps {
|
|||||||
readonly onResume?: (sessionId: string) => void
|
readonly onResume?: (sessionId: string) => void
|
||||||
readonly sessionId?: () => string | undefined
|
readonly sessionId?: () => string | undefined
|
||||||
readonly history?: PromptHistory
|
readonly history?: PromptHistory
|
||||||
|
readonly onImagePaste?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOOP = () => {}
|
const NOOP = () => {}
|
||||||
@ -89,6 +90,7 @@ export function App(props: AppProps) {
|
|||||||
completionFrom={() => props.store.state.completionFrom}
|
completionFrom={() => props.store.state.completionFrom}
|
||||||
onDismiss={() => props.store.clearCompletions()}
|
onDismiss={() => props.store.clearCompletions()}
|
||||||
history={props.history}
|
history={props.history}
|
||||||
|
onImagePaste={props.onImagePaste}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
* the prompt focused via a reactive effect; here a keystroke net is enough since
|
* the prompt focused via a reactive effect; here a keystroke net is enough since
|
||||||
* the composer remounts+refocuses whenever an overlay closes).
|
* 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 { useKeyboard } from '@opentui/solid'
|
||||||
import { For, onMount, Show } from 'solid-js'
|
import { For, onMount, Show } from 'solid-js'
|
||||||
|
|
||||||
@ -80,6 +80,7 @@ export function Composer(props: {
|
|||||||
completionFrom?: (() => number) | undefined
|
completionFrom?: (() => number) | undefined
|
||||||
onDismiss?: (() => void) | undefined
|
onDismiss?: (() => void) | undefined
|
||||||
history?: PromptHistory | undefined
|
history?: PromptHistory | undefined
|
||||||
|
onImagePaste?: (() => void) | undefined
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
let ta: TextareaRenderable | undefined
|
let ta: TextareaRenderable | undefined
|
||||||
@ -182,7 +183,7 @@ export function Composer(props: {
|
|||||||
not a background tint. */}
|
not a background tint. */}
|
||||||
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
|
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
|
||||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
<box style={{ flexShrink: 0, width: GUTTER }}>
|
||||||
<text>
|
<text selectable={false}>
|
||||||
<span style={{ fg: theme().color.prompt }}>{theme().brand.prompt}</span>
|
<span style={{ fg: theme().color.prompt }}>{theme().brand.prompt}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@ -196,6 +197,14 @@ export function Composer(props: {
|
|||||||
keyBindings={[{ action: 'submit', name: 'return' }]}
|
keyBindings={[{ action: 'submit', name: 'return' }]}
|
||||||
onMouseDown={() => ta?.focus()}
|
onMouseDown={() => ta?.focus()}
|
||||||
onSubmit={submit}
|
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 ?? '')}
|
onContentChange={() => props.onType?.(ta?.plainText ?? '')}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export function Header(props: { store: SessionStore }) {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
return (
|
return (
|
||||||
<box style={{ flexShrink: 0 }}>
|
<box style={{ flexShrink: 0 }}>
|
||||||
<text>
|
<text selectable={false}>
|
||||||
<b>{theme().brand.name}</b>
|
<b>{theme().brand.name}</b>
|
||||||
<span style={{ fg: theme().color.muted }}> · opentui · </span>
|
<span style={{ fg: theme().color.muted }}> · opentui · </span>
|
||||||
<Show when={props.store.state.ready} fallback={<span style={{ fg: theme().color.muted }}>connecting…</span>}>
|
<Show when={props.store.state.ready} fallback={<span style={{ fg: theme().color.muted }}>connecting…</span>}>
|
||||||
|
|||||||
@ -28,7 +28,8 @@ export function MessageLine(props: { message: Message }) {
|
|||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: m().role === 'user' ? 1 : 0 }}>
|
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: m().role === 'user' ? 1 : 0 }}>
|
||||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
<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>
|
<span style={{ fg: glyphFg() }}>{glyph()}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@ -104,7 +104,7 @@ export function StatusBar(props: { store: SessionStore }) {
|
|||||||
>
|
>
|
||||||
{/* left: turn/connection dot + model + effort + context bar */}
|
{/* left: turn/connection dot + model + effort + context bar */}
|
||||||
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
|
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
|
||||||
<text>
|
<text selectable={false}>
|
||||||
<span style={{ fg: dotColor() }}>{dot()}</span>
|
<span style={{ fg: dotColor() }}>{dot()}</span>
|
||||||
<Show when={model()}>
|
<Show when={model()}>
|
||||||
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
|
<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 */}
|
{/* right: cwd (branch), pre-truncated so the row never wraps */}
|
||||||
<Show when={rightText()}>
|
<Show when={rightText()}>
|
||||||
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
|
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
|
||||||
<text>
|
<text selectable={false}>
|
||||||
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
|
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function StatusLine(props: { store: SessionStore }) {
|
|||||||
<Show when={line()}>
|
<Show when={line()}>
|
||||||
{text => (
|
{text => (
|
||||||
<box style={{ flexShrink: 0 }}>
|
<box style={{ flexShrink: 0 }}>
|
||||||
<text>
|
<text selectable={false}>
|
||||||
<span style={{ fg: isHint() ? theme().color.warn : theme().color.muted }}>{text()}</span>
|
<span style={{ fg: isHint() ? theme().color.warn : theme().color.muted }}>{text()}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||||||
{/* header — clickable to toggle when there's expandable output */}
|
{/* header — clickable to toggle when there's expandable output */}
|
||||||
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && setExpanded(e => !e)}>
|
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && setExpanded(e => !e)}>
|
||||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
<box style={{ flexShrink: 0, width: GUTTER }}>
|
||||||
<text>
|
<text selectable={false}>
|
||||||
<span style={{ fg: headColor() }}>{headGlyph()}</span>
|
<span style={{ fg: headColor() }}>{headGlyph()}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user