opentui(v6): per-block copy affordance

This commit is contained in:
alt-glitch 2026-06-10 23:58:55 +05:30
parent 639a9cb9a7
commit 5999cd2848
5 changed files with 273 additions and 9 deletions

View file

@ -0,0 +1,32 @@
/**
* Per-block copy (design pass piece 2) the `` affordance on each assistant
* response text block / user prompt copies that block's SOURCE text (the
* markdown source held in the store part, NOT the concealed rendered text
* the same source the `/copy` command resolves via logic/copy.ts, scoped to
* one block). The write goes through the existing boundary clipboard (OSC 52 +
* native command) and feedback rides the existing hint line via flashNotice.
*
* The writer is injectable so headless tests never spawn xclip/wl-copy or
* touch the developer's real clipboard.
*/
import { writeClipboard } from '../boundary/clipboard.ts'
import { flashNotice } from './notify.ts'
type ClipboardWriter = (text: string) => unknown
const defaultWriter: ClipboardWriter = text => void writeClipboard(text)
let writer: ClipboardWriter = defaultWriter
/** Test seam: swap (or restore, with no argument) the clipboard writer. */
export function setBlockClipboardWriter(fn?: ClipboardWriter): void {
writer = fn ?? defaultWriter
}
/** Copy one block's source text; flashes "Copied" on success. False when empty. */
export function copyBlock(text: string): boolean {
const source = (text ?? '').trim()
if (!source) return false
writer(source)
flashNotice('Copied')
return true
}

View file

@ -0,0 +1,35 @@
/**
* Transient-notice seam (per-block copy feedback, Epic: design pass piece 2).
* Deep view nodes (e.g. the per-block `` copy affordance in messageLine) need
* to flash a short notice ("Copied") on the EXISTING hint line (StatusLine
* the same surface the entry's flashHint uses for /copy and selection-copy),
* but they don't hold the store. The store registers its `setHint` here at
* creation (one live store per app; the latest registration wins, which is
* also what headless tests want), and `flashNotice` mirrors the entry's
* flashHint contract: set, then auto-clear after `ms` unless something newer
* replaced it. No-op when nothing is registered (bare component tests).
*/
type NotifySink = (text: string | undefined) => void
let sink: NotifySink | undefined
let timer: ReturnType<typeof setTimeout> | undefined
let current: string | undefined
/** Register (or clear) the app-wide notice sink — the store's `setHint`. */
export function registerNotifier(fn: NotifySink | undefined): void {
sink = fn
}
/** Flash a transient notice on the hint line; auto-clears after `ms`. */
export function flashNotice(text: string, ms = 1500): void {
sink?.(text)
current = text
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
if (current === text) {
sink?.(undefined)
current = undefined
}
}, ms)
}

View file

@ -25,6 +25,7 @@ import type { DetailsMode } from './details.ts'
import { diffStats, type DiffStats } from './diff.ts'
import type { SessionTabId } from './sessionPicker.ts'
import { envOutputUnlimited } from './env.ts'
import { registerNotifier } from './notify.ts'
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
@ -612,6 +613,10 @@ export function createSessionStore() {
function setHint(text: string | undefined): void {
setState('hint', text)
}
// Per-block copy feedback (design pass piece 2): deep view nodes flash
// "Copied" on this store's hint line via the notify seam — the same surface
// the entry's flashHint uses. One live store per app; latest wins.
registerNotifier(setHint)
/** /compact — set the compact-transcript display flag (Epic 3). */
function setCompact(on: boolean): void {

View file

@ -0,0 +1,151 @@
/**
* Per-block copy affordance (design pass piece 2). Layers:
* 1. pure: copyBlock writes the SOURCE through the injectable writer and
* flashes "Copied" via the notify seam (the store's hint line).
* 2. frames: a quiet `` chip trails settled assistant text blocks and user
* prompts (never system rows, never a still-streaming block); clicking it
* through the real mouse path copies that block's source and the hint
* line shows "Copied".
*/
import { afterEach, describe, expect, test } from 'vitest'
import { copyBlock, setBlockClipboardWriter } from '../logic/blockCopy.ts'
import { registerNotifier } from '../logic/notify.ts'
import { createSessionStore } from '../logic/store.ts'
import { App } from '../view/App.tsx'
import { ThemeProvider } from '../view/theme.tsx'
import { renderProbe, type RenderProbe } from './lib/render.ts'
type Store = ReturnType<typeof createSessionStore>
afterEach(() => {
setBlockClipboardWriter() // restore the real clipboard writer
registerNotifier(undefined)
})
async function mountApp(store: Store, width = 80, height = 30): Promise<RenderProbe> {
return renderProbe(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ height, width }
)
}
/** Click the `⧉` chip on the frame row that contains `anchor`. */
async function clickChipNear(probe: RenderProbe, anchor: string): Promise<void> {
const frame = await probe.waitForFrame(f => f.includes(anchor) && f.includes('⧉'))
const rows = frame.split('\n')
const y = rows.findIndex(line => line.includes(anchor) && line.includes('⧉'))
expect(y).toBeGreaterThanOrEqual(0)
const x = (rows[y] ?? '').indexOf('⧉')
await probe.click(x, y)
}
describe('copyBlock — pure copy + feedback', () => {
test('writes the trimmed source through the writer and flashes Copied', () => {
const writes: string[] = []
const notices: Array<string | undefined> = []
setBlockClipboardWriter(text => writes.push(text))
registerNotifier(text => notices.push(text))
expect(copyBlock(' # Title\n\nthe *source* text ')).toBe(true)
expect(writes).toEqual(['# Title\n\nthe *source* text'])
expect(notices[0]).toBe('Copied')
})
test('an empty block copies nothing and flashes nothing', () => {
const writes: string[] = []
const notices: Array<string | undefined> = []
setBlockClipboardWriter(text => writes.push(text))
registerNotifier(text => notices.push(text))
expect(copyBlock(' ')).toBe(false)
expect(writes).toEqual([])
expect(notices).toEqual([])
})
})
describe('store — the notify seam rides the hint line', () => {
test('createSessionStore registers its setHint; flashNotice lands in state.hint', () => {
const store = createSessionStore()
setBlockClipboardWriter(() => {})
copyBlock('anything')
expect(store.state.hint).toBe('Copied')
})
})
describe('⧉ chip frames — quiet chrome, source-true copy', () => {
test('clicking the chip on a user prompt copies the prompt source + shows Copied', async () => {
const writes: string[] = []
setBlockClipboardWriter(text => writes.push(text))
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.pushUser('please *fix* the build')
const probe = await mountApp(store)
try {
await clickChipNear(probe, 'please')
expect(writes).toEqual(['please *fix* the build'])
expect(store.state.hint).toBe('Copied')
const frame = await probe.waitForFrame(f => f.includes('Copied'))
expect(frame).toContain('Copied')
} finally {
probe.destroy()
}
})
test('a settled assistant text block carries a chip; clicking copies the MARKDOWN SOURCE', async () => {
const writes: string[] = []
setBlockClipboardWriter(text => writes.push(text))
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({ payload: { text: 'the **bold** answer' }, type: 'message.delta' })
store.apply({ type: 'message.complete' })
const probe = await mountApp(store)
try {
const frame = await probe.waitForFrame(f => f.includes('⧉'))
expect(frame).toContain('⧉')
const rows = frame.split('\n')
const y = rows.findIndex(line => line.includes('⧉'))
const x = (rows[y] ?? '').indexOf('⧉')
await probe.click(x, y)
// SOURCE, not the concealed rendered text: the ** markers survive.
expect(writes).toEqual(['the **bold** answer'])
} finally {
probe.destroy()
}
})
test('no chip while the turn is still streaming; it appears on settle', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({ payload: { text: 'streaming words' }, type: 'message.delta' })
const probe = await mountApp(store)
try {
// (markdown BODY text doesn't paint in headless char frames — assert on
// the chip itself, which is a plain-text renderable)
await probe.settle()
expect(probe.frame()).not.toContain('⧉')
store.apply({ type: 'message.complete' })
const settled = await probe.waitForFrame(f => f.includes('⧉'))
expect(settled).toContain('⧉')
} finally {
probe.destroy()
}
})
test('system rows get no chip (chrome, nothing to copy)', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.pushSystem('gateway notice line')
const probe = await mountApp(store)
try {
const frame = await probe.waitForFrame(f => f.includes('gateway notice line'))
expect(frame).not.toContain('⧉')
} finally {
probe.destroy()
}
})
})

View file

@ -14,11 +14,19 @@
* interstitial narration text demotes to muted and only the FINAL text block
* keeps the full-bright answer color (see `lastTextId`).
*
* Per-block copy (piece 2): every settled assistant text block and every user
* prompt carries a quiet `` chip at its top-right muted chrome
* (selectable=false) that disappears into the frame until wanted. Click
* copies that block's SOURCE text (the markdown source in the store, same as
* `/copy` not the concealed rendered text) via logic/blockCopy and flashes
* "Copied" on the existing hint line.
*
* Stable `id` per part as the <For> key so a new tool part below a streaming text
* part doesn't remount it.
*/
import { For, Match, Show, Switch } from 'solid-js'
import { copyBlock } from '../logic/blockCopy.ts'
import { collapseHiddenParts, hiddenRunLabel } from '../logic/details.ts'
import type { Message, Part } from '../logic/store.ts'
import type { ThemeColors } from '../logic/theme.ts'
@ -76,6 +84,22 @@ export function lastTextId(parts: readonly Part[] | undefined): string | undefin
return undefined
}
/**
* The quiet per-block copy chip muted `` chrome at a block's top-right.
* Click copies the block's SOURCE (markdown source / prompt text) and flashes
* "Copied". selectable=false: it must never ride along in a drag-selection.
*/
function CopyChip(props: { source: () => string }) {
const theme = useTheme()
return (
<box style={{ flexShrink: 0, marginLeft: 1 }} onMouseDown={() => copyBlock(props.source())}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}></span>
</text>
</box>
)
}
export function MessageLine(props: { message: Message; latest?: boolean }) {
const theme = useTheme()
const display = useDisplay()
@ -123,9 +147,18 @@ export function MessageLine(props: { message: Message; latest?: boolean }) {
// themed selection: a solid muted/accent bar that preserves the
// text fg (no selectionFg → the original color shows through, so a
// highlight over content reads as a clean bar, not SGR-inverse).
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: bodyFg() }}>{m().text}</span>
</text>
// A quiet ⧉ chip trails the block (user prompts + settled
// assistant rows; system notes are chrome, nothing to copy).
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
<box style={{ flexGrow: 1, minWidth: 0 }}>
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: bodyFg() }}>{m().text}</span>
</text>
</box>
<Show when={m().role !== 'system' && m().text.trim()}>
<CopyChip source={() => m().text} />
</Show>
</box>
}
>
<text selectable={false}>
@ -156,13 +189,21 @@ export function MessageLine(props: { message: Message; latest?: boolean }) {
per-delta remount no scrollbar flicker, #2); it renders GFM
tables natively (#3). Leading/trailing blanks stripped so the
column `gap` is the sole inter-part spacing (item 5).
Interstitial narration demotes to muted once settled. */}
Interstitial narration demotes to muted once settled; a
quiet copy chip sits at the settled block's top-right. */}
{t => (
<Markdown
text={t().text.replace(/^\n+|\n+$/g, '')}
streaming={m().streaming ?? false}
fg={textFg(t().id)}
/>
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<Markdown
text={t().text.replace(/^\n+|\n+$/g, '')}
streaming={m().streaming ?? false}
fg={textFg(t().id)}
/>
</box>
<Show when={!m().streaming}>
<CopyChip source={() => t().text} />
</Show>
</box>
)}
</Match>
</Switch>