diff --git a/ui-opentui/src/logic/blockCopy.ts b/ui-opentui/src/logic/blockCopy.ts deleted file mode 100644 index 16f0b2661f2..00000000000 --- a/ui-opentui/src/logic/blockCopy.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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 e16f6a04824..bf752178bf9 100644 --- a/ui-opentui/src/logic/window.ts +++ b/ui-opentui/src/logic/window.ts @@ -227,11 +227,10 @@ function lineCount(text: string): number { } /** Estimated rendered lines of one part: text → its line count (view strips - * 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) + * 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, '')) return 1 // collapsed tool/reasoning header line } @@ -241,23 +240,20 @@ function partLines(part: Part, chips: boolean): 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); `chips` mirrors the view's per-block `⧉ copy` - * line (settled non-system rows outside /compact — messageLine.tsx CopyChip). + * blank line (0 in /compact). */ export function estimateMessageHeight( message: Pick & { readonly role?: Message['role'] }, spacing: { readonly top: number; readonly bottom: number }, - gap: number, - chips = false + gap: number ): 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, chips) + for (const part of parts) content += partLines(part) } 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 deleted file mode 100644 index 3a294246fa7..00000000000 --- a/ui-opentui/src/test/blockCopy.test.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/** - * 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 fad27b98068..9a76193d447 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, plus - // the prompt's own `⧉ copy` footer line (chrome v3 chip placement). - expect(b - a).toBe(5) + // turn boundary > part gap): top 2 + bottom 1 around each prompt. + expect(b - a).toBe(4) 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 (copy-chip chrome shed too) + expect(b2 - a2).toBe(1) // adjacent rows — densified 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(5) + + expect(b3 - a3).toBe(4) } finally { probe.destroy() } diff --git a/ui-opentui/src/test/transcriptWindow.test.tsx b/ui-opentui/src/test/transcriptWindow.test.tsx index 423dbef485c..439014d8d27 100644 --- a/ui-opentui/src/test/transcriptWindow.test.tsx +++ b/ui-opentui/src/test/transcriptWindow.test.tsx @@ -116,8 +116,9 @@ 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. - expect(on.count()).toBeLessThan(off.count() * 0.6) + // 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) } 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 7ad0e071c2f..d6076ec45a0 100644 --- a/ui-opentui/src/test/window.test.ts +++ b/ui-opentui/src/test/window.test.ts @@ -305,23 +305,6 @@ 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 af9c964ac01..818ce98a599 100644 --- a/ui-opentui/src/view/messageLine.tsx +++ b/ui-opentui/src/view/messageLine.tsx @@ -14,25 +14,11 @@ * 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' @@ -91,25 +77,6 @@ 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() @@ -163,17 +130,9 @@ 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). - // 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} /> - - + + {m().text} + } > @@ -204,20 +163,13 @@ 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; a - quiet ⧉ copy run sits bottom-left under the settled block - (off the scrollbar's right-edge column). */} + Interstitial narration demotes to muted once settled. */} {t => ( - - - - t().text} /> - - + )} diff --git a/ui-opentui/src/view/transcript.tsx b/ui-opentui/src/view/transcript.tsx index 17e778e5260..7e89a223d1a 100644 --- a/ui-opentui/src/view/transcript.tsx +++ b/ui-opentui/src/view/transcript.tsx @@ -243,12 +243,7 @@ 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, - !compact && !streaming - ) + const estimate = estimateMessageHeight(message, turnSpacing(message.role, compact), compact ? 0 : 1) if (!streaming) estimates.set(key, estimate) return estimate }