mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): per-block copy affordance
This commit is contained in:
parent
639a9cb9a7
commit
5999cd2848
5 changed files with 273 additions and 9 deletions
32
ui-opentui/src/logic/blockCopy.ts
Normal file
32
ui-opentui/src/logic/blockCopy.ts
Normal 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
|
||||
}
|
||||
35
ui-opentui/src/logic/notify.ts
Normal file
35
ui-opentui/src/logic/notify.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
151
ui-opentui/src/test/blockCopy.test.tsx
Normal file
151
ui-opentui/src/test/blockCopy.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue