diff --git a/ui-opentui/src/logic/blockCopy.ts b/ui-opentui/src/logic/blockCopy.ts new file mode 100644 index 000000000000..16f0b2661f22 --- /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/window.ts b/ui-opentui/src/logic/window.ts index bf752178bf9b..e16f6a048246 100644 --- a/ui-opentui/src/logic/window.ts +++ b/ui-opentui/src/logic/window.ts @@ -227,10 +227,11 @@ function lineCount(text: string): number { } /** Estimated rendered lines of one part: text → its line count (view strips - * leading/trailing blanks — mirror that); tool/reasoning → 1 collapsed header - * line (the default render for settled, never-mounted history). */ -function partLines(part: Part): number { - if (part.type === 'text') return lineCount(part.text.replace(/^\n+|\n+$/g, '')) + * leading/trailing blanks — mirror that) + the settled block's `⧉ copy` chip + * line when `chips`; tool/reasoning → 1 collapsed header line (the default + * render for settled, never-mounted history). */ +function partLines(part: Part, chips: boolean): number { + if (part.type === 'text') return lineCount(part.text.replace(/^\n+|\n+$/g, '')) + (chips ? 1 : 0) return 1 // collapsed tool/reasoning header line } @@ -240,20 +241,23 @@ function partLines(part: Part): number { * — it is a placeholder until the row is actually mounted/measured, and a * wrong value may only be corrected per `correctionIsLegal` (or left until * remount). `spacing` is the row's turnSpacing margins; `gap` the inter-part - * blank line (0 in /compact). + * blank line (0 in /compact); `chips` mirrors the view's per-block `⧉ copy` + * line (settled non-system rows outside /compact — messageLine.tsx CopyChip). */ export function estimateMessageHeight( message: Pick & { readonly role?: Message['role'] }, spacing: { readonly top: number; readonly bottom: number }, - gap: number + gap: number, + chips = false ): number { const parts = message.parts let content: number if (parts && parts.length > 0) { content = gap * (parts.length - 1) - for (const part of parts) content += partLines(part) + for (const part of parts) content += partLines(part, chips) } else { content = lineCount(message.text) + if (chips && message.role !== undefined && message.role !== 'system' && message.text.trim()) content += 1 } return Math.min(ESTIMATE_MAX_LINES, Math.max(1, content)) + spacing.top + spacing.bottom } diff --git a/ui-opentui/src/test/blockCopy.test.tsx b/ui-opentui/src/test/blockCopy.test.tsx new file mode 100644 index 000000000000..3a294246fa78 --- /dev/null +++ b/ui-opentui/src/test/blockCopy.test.tsx @@ -0,0 +1,159 @@ +/** + * Per-block copy affordance (design pass piece 2; chrome-v3 geometry). 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 `⧉ copy` run sits on its own line at the BOTTOM-LEFT of + * settled assistant text blocks and user prompts (never system rows, + * never a still-streaming block) — off the scrollbar's right-edge column; + * 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 `⧉ copy` chip on the line BELOW the frame row containing `anchor`. */ +async function clickChipNear(probe: RenderProbe, anchor: string): Promise { + const frame = await probe.waitForFrame(f => f.includes(anchor) && f.includes('⧉ copy')) + const rows = frame.split('\n') + const anchorY = rows.findIndex(line => line.includes(anchor)) + expect(anchorY).toBeGreaterThanOrEqual(0) + const y = rows.findIndex((line, i) => i > anchorY && line.includes('⧉ copy')) + expect(y).toBeGreaterThanOrEqual(0) + const x = (rows[y] ?? '').indexOf('⧉') + // bottom-LEFT chrome: the chip hugs the content gutter (shell padding + + // glyph gutter ≈ col 3), never the scrollbar's right-edge column. + expect(x).toBeLessThan(10) + 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('⧉ copy')) + expect(frame).toContain('⧉ copy') + const rows = frame.split('\n') + const y = rows.findIndex(line => line.includes('⧉ copy')) + const x = (rows[y] ?? '').indexOf('⧉') + // bottom-left chrome: never in the scrollbar's right-edge column. + expect(x).toBeLessThan(10) + 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/test/displayModes.test.tsx b/ui-opentui/src/test/displayModes.test.tsx index 9a76193d4472..fad27b980686 100644 --- a/ui-opentui/src/test/displayModes.test.tsx +++ b/ui-opentui/src/test/displayModes.test.tsx @@ -116,23 +116,23 @@ describe('/compact — transcript spacing (frame line-count)', () => { const b = rows.findIndex(r => r.includes('beta-line')) expect(a).toBeGreaterThanOrEqual(0) // user turns are set off by MORE space than the part gap (design pass: - // turn boundary > part gap): top 2 + bottom 1 around each prompt. - expect(b - a).toBe(4) + // turn boundary > part gap): top 2 + bottom 1 around each prompt, plus + // the prompt's own `⧉ copy` footer line (chrome v3 chip placement). + expect(b - a).toBe(5) store.setCompact(true) await probe.settle() const dense = probe.frame().split('\n') const a2 = dense.findIndex(r => r.includes('alpha-line')) const b2 = dense.findIndex(r => r.includes('beta-line')) - expect(b2 - a2).toBe(1) // adjacent rows — densified + expect(b2 - a2).toBe(1) // adjacent rows — densified (copy-chip chrome shed too) store.setCompact(false) await probe.settle() const again = probe.frame().split('\n') const a3 = again.findIndex(r => r.includes('alpha-line')) const b3 = again.findIndex(r => r.includes('beta-line')) - - expect(b3 - a3).toBe(4) + expect(b3 - a3).toBe(5) } finally { probe.destroy() } diff --git a/ui-opentui/src/test/transcriptWindow.test.tsx b/ui-opentui/src/test/transcriptWindow.test.tsx index 439014d8d275..423dbef485c4 100644 --- a/ui-opentui/src/test/transcriptWindow.test.tsx +++ b/ui-opentui/src/test/transcriptWindow.test.tsx @@ -116,9 +116,8 @@ describe('transcript windowing (HERMES_TUI_WINDOWING) — S1 machinery', () => { // The window actually sheds renderables: ~viewport±margin + bottom-30 // stay mounted out of 120 rows; the rest are 1-box spacers. The legacy - // tree keeps every row's text renderables alive. (Ratio ~0.60 — threshold - // 0.65 leaves margin without snapshotting the exact per-row count.) - expect(on.count()).toBeLessThan(off.count() * 0.65) + // tree keeps every row's text renderables alive. + expect(on.count()).toBeLessThan(off.count() * 0.6) } finally { on.probe.destroy() off.probe.destroy() diff --git a/ui-opentui/src/test/window.test.ts b/ui-opentui/src/test/window.test.ts index d6076ec45a0c..7ad0e071c2f8 100644 --- a/ui-opentui/src/test/window.test.ts +++ b/ui-opentui/src/test/window.test.ts @@ -305,6 +305,23 @@ describe('estimateMessageHeight — line-count estimate for never-mounted rows', const text = Array.from({ length: 10_000 }, (_, i) => `l${i}`).join('\n') expect(estimateMessageHeight({ text }, { top: 0, bottom: 0 }, 0)).toBeLessThanOrEqual(500) }) + + test('chips: settled non-system rows count the ⧉ copy line; system rows do not', () => { + const spacing0 = { top: 0, bottom: 0 } + expect(estimateMessageHeight({ role: 'user', text: 'hi' }, spacing0, 1, true)).toBe(2) + expect(estimateMessageHeight({ role: 'system', text: 'note' }, spacing0, 1, true)).toBe(1) + expect(estimateMessageHeight({ role: 'user', text: 'hi' }, spacing0, 1, false)).toBe(1) + // parts: one chip line per text block, none for tool headers + const message: Pick = { + text: '', + parts: [ + { type: 'text', id: 'p1', text: 'one\ntwo' }, + { type: 'tool', id: 't1', name: 'terminal', state: 'complete' } + ] + } + // (2 text + 1 chip) + 1 tool + 1 gap + expect(estimateMessageHeight(message, spacing0, 1, true)).toBe(5) + }) }) describe('edgeMeasureBatch — the S2 idle measure picker', () => { diff --git a/ui-opentui/src/view/messageLine.tsx b/ui-opentui/src/view/messageLine.tsx index 818ce98a599b..af9c964ac015 100644 --- a/ui-opentui/src/view/messageLine.tsx +++ b/ui-opentui/src/view/messageLine.tsx @@ -14,11 +14,25 @@ * 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, relocated in the chrome-v3 pass): every settled + * assistant text block and every user prompt carries a quiet `⧉ copy` run on + * its own line at the block's BOTTOM-LEFT — muted chrome (selectable=false) + * that disappears into the frame until wanted. Bottom-left because the old + * top-right chip sat in the scrollbar's column (the user's complaint); the + * left gutter belongs to the role glyph and is only 1 cell of click target, + * while a trailing `⧉ copy` line never overlaps the scrollbar, keeps a 6-cell + * click target, and reads as a quiet footer. 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. /compact hides the chip line entirely — density mode + * sheds chrome rows first (`/copy` still covers whole-response copy there). + * * 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' @@ -77,6 +91,25 @@ export function lastTextId(parts: readonly Part[] | undefined): string | undefin return undefined } +/** + * The quiet per-block copy chip — a muted `⧉ copy` run on its own line at the + * block's BOTTOM-LEFT (never the right edge: that column belongs to the + * scrollbar). `alignSelf: flex-start` shrinks the click target to the run + * itself (6 cells) instead of the whole row. 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())}> + + ⧉ copy + + + ) +} + export function MessageLine(props: { message: Message; latest?: boolean }) { const theme = useTheme() const display = useDisplay() @@ -130,9 +163,17 @@ 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 ⧉ copy run sits bottom-left under the block (user + // prompts + settled assistant rows; system notes are chrome, + // nothing to copy) — never the right edge: scrollbar column. + + + {m().text} + + + m().text} /> + + } > @@ -163,13 +204,20 @@ 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 run sits bottom-left under the settled block + (off the scrollbar's right-edge column). */} {t => ( - + + + + t().text} /> + + )} diff --git a/ui-opentui/src/view/transcript.tsx b/ui-opentui/src/view/transcript.tsx index 7e89a223d1aa..17e778e5260b 100644 --- a/ui-opentui/src/view/transcript.tsx +++ b/ui-opentui/src/view/transcript.tsx @@ -243,7 +243,12 @@ export function Transcript(props: { store: SessionStore }) { const cached = estimates.get(key) if (cached !== undefined) return cached } - const estimate = estimateMessageHeight(message, turnSpacing(message.role, compact), compact ? 0 : 1) + const estimate = estimateMessageHeight( + message, + turnSpacing(message.role, compact), + compact ? 0 : 1, + !compact && !streaming + ) if (!streaming) estimates.set(key, estimate) return estimate }