opentui(v6): defer per-block copy button (carved to its own PR)
The clickable per-block ⧉ copy chip under each message block is split out of the engine PR (#42922) into its own issue + PR, to keep the engine PR focused. Removes: - logic/blockCopy.ts + its unit test (copyBlock + injectable writer) - the CopyChip component and its two render sites in view/messageLine.tsx (flat message text + text parts); the wrapper boxes unwrap back to bare <text>/<Markdown> - the `chips` height accounting in logic/window.ts (estimateMessageHeight + partLines added a phantom +1 line per block) and the arg passed from view/transcript.tsx; updated window.test.ts / displayModes.test.tsx / transcriptWindow.test.tsx expectations accordingly Unaffected (intentionally kept — core, parity-critical): mouse-selection copy / Ctrl+C / copy-on-select (OSC52) in boundary/renderer.ts, and the /copy [n] command in logic/copy.ts. Verified: npm run check green (type-check + lint + 813 tests), acceptance greps clean (no CopyChip/copyBlock/blockCopy left; selection-copy + /copy intact), and a live tmux smoke confirms no ⧉ copy renders under messages.
This commit is contained in:
parent
a348fc1ccc
commit
3723bf5fe6
@ -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
|
||||
}
|
||||
@ -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<Message, 'text' | 'parts'> & { 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
|
||||
}
|
||||
|
||||
@ -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<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 `⧉ copy` chip on the line BELOW the frame row containing `anchor`. */
|
||||
async function clickChipNear(probe: RenderProbe, anchor: string): Promise<void> {
|
||||
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<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('⧉ 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<Message, 'text' | 'parts'> = {
|
||||
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', () => {
|
||||
|
||||
@ -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 <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'
|
||||
@ -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 (
|
||||
<box style={{ flexShrink: 0, alignSelf: 'flex-start' }} onMouseDown={() => copyBlock(props.source())}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>⧉ copy</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
|
||||
<text selectionBg={theme().color.selectionBg}>
|
||||
<span style={{ fg: bodyFg() }}>{m().text}</span>
|
||||
</text>
|
||||
<Show when={m().role !== 'system' && m().text.trim() && !display().compact}>
|
||||
<CopyChip source={() => m().text} />
|
||||
</Show>
|
||||
</box>
|
||||
<text selectionBg={theme().color.selectionBg}>
|
||||
<span style={{ fg: bodyFg() }}>{m().text}</span>
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text selectable={false}>
|
||||
@ -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 => (
|
||||
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
|
||||
<Markdown
|
||||
text={t().text.replace(/^\n+|\n+$/g, '')}
|
||||
streaming={m().streaming ?? false}
|
||||
fg={textFg(t().id)}
|
||||
/>
|
||||
<Show when={!m().streaming && !display().compact}>
|
||||
<CopyChip source={() => t().text} />
|
||||
</Show>
|
||||
</box>
|
||||
<Markdown
|
||||
text={t().text.replace(/^\n+|\n+$/g, '')}
|
||||
streaming={m().streaming ?? false}
|
||||
fg={textFg(t().id)}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user