mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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
10 changed files with 173 additions and 9 deletions
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).
|
||||
*/
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue