diff --git a/ui-opentui/src/logic/blockCopy.ts b/ui-opentui/src/logic/blockCopy.ts new file mode 100644 index 00000000000..16f0b2661f2 --- /dev/null +++ b/ui-opentui/src/logic/blockCopy.ts @@ -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 +} diff --git a/ui-opentui/src/logic/notify.ts b/ui-opentui/src/logic/notify.ts new file mode 100644 index 00000000000..c6dabd023cd --- /dev/null +++ b/ui-opentui/src/logic/notify.ts @@ -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 | 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) +} diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index c0179836718..6ea9bb4bcd1 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -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 { diff --git a/ui-opentui/src/test/blockCopy.test.tsx b/ui-opentui/src/test/blockCopy.test.tsx new file mode 100644 index 00000000000..16ecc237f10 --- /dev/null +++ b/ui-opentui/src/test/blockCopy.test.tsx @@ -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 + +afterEach(() => { + setBlockClipboardWriter() // restore the real clipboard writer + registerNotifier(undefined) +}) + +async function mountApp(store: Store, width = 80, height = 30): Promise { + return renderProbe( + () => ( + store.state.theme}> + + + ), + { height, width } + ) +} + +/** Click the `⧉` chip on the frame row that contains `anchor`. */ +async function clickChipNear(probe: RenderProbe, anchor: string): Promise { + 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 = [] + 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 = [] + 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() + } + }) +}) diff --git a/ui-opentui/src/view/messageLine.tsx b/ui-opentui/src/view/messageLine.tsx index 32bedfdf9f1..211af2950e2 100644 --- a/ui-opentui/src/view/messageLine.tsx +++ b/ui-opentui/src/view/messageLine.tsx @@ -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 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 ( + copyBlock(props.source())}> + + + + + ) +} + 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). - - {m().text} - + // A quiet ⧉ chip trails the block (user prompts + settled + // assistant rows; system notes are chrome, nothing to copy). + + + + {m().text} + + + + m().text} /> + + } > @@ -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 => ( - + + + + + + t().text} /> + + )}