Two TUI polish fixes.
(1) Right-click copy now clears the highlight.
The right-click handler copied an active selection via onCopySelectionNoClear
(the copy-on-select variant that keeps the highlight during a drag) and never
cleared it, so after right-click-to-copy the selection stayed lit with no
confirmation and a follow-up right-click re-copied the stale range instead of
pasting. A successful right-click copy now clears the selection and notifies;
if the copy fails (no clipboard path) the highlight survives and we fall back
to the right-click paste handler, exactly as before.
(2) Group transcript blocks so boundaries read clearly.
Model replies, reasoning/tool trails, and system/error notes rendered with no
vertical separation, so distinct block types butted together and were hard to
scan. Group adjacent blocks by kind: one blank line opens only where the visual
group changes (model prose <-> reasoning/tool trails <-> notes), while a run of
same-kind blocks renders flush. The rule lives in domain/blockLayout.ts
(messageGroup + hasLeadGap) and is applied intrinsically in MessageLine via a
`prev` prop, which fixes the things ad-hoc per-block margins kept breaking:
- Streaming stability: the gap is derived from the stable predecessor, never
the live block's own changing text, so the actively-streaming reply computes
the same gap while it streams as the settled segment does once it flushes.
No reflow/jump.
- Transparent empty trails: a trail hidden by /details, or one carrying only a
token tally (the finalDetails segment message.complete appends), renders
nothing and is transparent to grouping (prevRenderedMsg skips it), so there
are no floating gaps, no doubled gap after a prompt, and no padded space
above the final reply. In the default/collapsed modes content-bearing trails
always render, so the grouping is a no-op there.
The virtual-height estimator counts the group-boundary line so scroll math
stays accurate before Yoga remeasures.
ui-tui/src/domain/blockLayout.ts (new), components/messageLine.tsx,
components/streamingAssistant.tsx, components/appLayout.tsx,
lib/virtualHeights.ts, app/useMainApp.ts.
Tests: blockLayout.test.ts (grouping + hidden/empty-trail visibility),
virtualHeights leadGap, app-mouse.test.ts copy behavior. Full ui-tui suite
green apart from 3 pre-existing local/env failures (cursorDrift, ink-resize,
virtualHeights user-prompt-width) unchanged from main.
146 lines
5.4 KiB
TypeScript
146 lines
5.4 KiB
TypeScript
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
|
|
|
|
import { sectionMode } from './details.js'
|
|
|
|
/**
|
|
* Visual group a transcript block belongs to. Blocks in the same group render
|
|
* flush; a single blank line opens at each group boundary. So a run of tool
|
|
* trails (or model paragraphs) reads as one section and the eye only catches a
|
|
* gap where the *kind* of content actually changes — the boundary between
|
|
* reasoning/tool trails, model prose, and notes/errors.
|
|
*
|
|
* user — the human turn (owns its separator + margins in MessageLine)
|
|
* model — assistant prose, the model's voice
|
|
* trail — reasoning + tool-call trails (the agent's working area)
|
|
* note — system notes and errors (a quieter band)
|
|
* diff — inline patch segments (an island, owns its own margins)
|
|
* slash — slash-command echoes (owns its margin)
|
|
* intro — banner / panels (rendered out-of-band, never gapped here)
|
|
*/
|
|
export type BlockGroup = 'diff' | 'intro' | 'model' | 'note' | 'slash' | 'trail' | 'user'
|
|
|
|
export const messageGroup = (msg: Pick<Msg, 'kind' | 'role'>): BlockGroup => {
|
|
switch (msg.kind) {
|
|
case 'intro':
|
|
case 'panel':
|
|
return 'intro'
|
|
case 'slash':
|
|
return 'slash'
|
|
case 'diff':
|
|
return 'diff'
|
|
case 'trail':
|
|
return 'trail'
|
|
}
|
|
|
|
if (msg.role === 'user') {
|
|
return 'user'
|
|
}
|
|
|
|
// Assistant prose is the model's voice; system notes/errors are their own
|
|
// band. (No runtime block uses role 'tool' — tool *results* fold into
|
|
// trails — so a stray 'tool' falls through to the note band harmlessly.)
|
|
return msg.role === 'assistant' ? 'model' : 'note'
|
|
}
|
|
|
|
// Groups whose leading gap is already owned by their own chrome in
|
|
// MessageLine (the turn separator + top margin for user, the top margin for
|
|
// slash, the top+bottom margins for diff) or that are painted out-of-band
|
|
// (intro). The grouping primitive only spaces the model working area —
|
|
// model prose, reasoning/tool trails, and notes/errors.
|
|
const SELF_SPACED: ReadonlySet<BlockGroup> = new Set(['diff', 'intro', 'slash', 'user'])
|
|
|
|
// Groups that already paint a trailing blank line beneath themselves
|
|
// (marginBottom in MessageLine), so the block that follows must not add its
|
|
// own leading gap or the single boundary would become a double gap.
|
|
const PAINTS_TRAILING_GAP: ReadonlySet<BlockGroup> = new Set(['diff', 'user'])
|
|
|
|
/**
|
|
* Whether `cur` renders one blank line above it, given the block rendered
|
|
* directly above it (`prev`). True only where the visual group changes, and
|
|
* only for the model-working-area bands (model / trail / note) — user, slash,
|
|
* diff, and intro keep their existing spacing.
|
|
*
|
|
* Streaming-safe by construction: the result depends on the *predecessor's*
|
|
* group, never on `cur`'s own (live, changing) content. The actively-streaming
|
|
* assistant block therefore computes the same gap while it streams as the
|
|
* settled segment does once it flushes, so the live area never jumps.
|
|
*/
|
|
export const hasLeadGap = (
|
|
prev: Pick<Msg, 'kind' | 'role'> | undefined,
|
|
cur: Pick<Msg, 'kind' | 'role'>
|
|
): boolean => {
|
|
const group = messageGroup(cur)
|
|
|
|
if (SELF_SPACED.has(group)) {
|
|
return false
|
|
}
|
|
|
|
if (!prev) {
|
|
return false
|
|
}
|
|
|
|
const prevGroup = messageGroup(prev)
|
|
|
|
return prevGroup !== group && !PAINTS_TRAILING_GAP.has(prevGroup)
|
|
}
|
|
|
|
export interface DetailsCtx {
|
|
commandOverride?: boolean
|
|
detailsMode: DetailsMode
|
|
sections?: SectionVisibility
|
|
}
|
|
|
|
const trailAllHidden = (ctx: DetailsCtx): boolean =>
|
|
sectionMode('thinking', ctx.detailsMode, ctx.sections, ctx.commandOverride) === 'hidden' &&
|
|
sectionMode('tools', ctx.detailsMode, ctx.sections, ctx.commandOverride) === 'hidden' &&
|
|
sectionMode('activity', ctx.detailsMode, ctx.sections, ctx.commandOverride) === 'hidden'
|
|
|
|
/**
|
|
* Whether a settled transcript block paints anything. A trail renders nothing
|
|
* when it has no reasoning/tools/todos to show (e.g. the finalDetails segment
|
|
* that carries only a token tally) or when every section it does have is hidden
|
|
* (`/details hidden`); every other block draws at least one row. A block that
|
|
* renders nothing is *transparent* to grouping: the block below it draws its
|
|
* boundary against the nearest visible block instead (see prevRenderedMsg), so
|
|
* a hidden or content-less trail never leaves a floating blank line, doubles
|
|
* the gap after a user prompt, or pads the space above the final reply. In the
|
|
* default/collapsed modes content-bearing trails always render, so this is a
|
|
* no-op there.
|
|
*/
|
|
export const blockRenders = (msg: Pick<Msg, 'kind' | 'thinking' | 'todos' | 'tools'>, ctx: DetailsCtx): boolean => {
|
|
if (msg.kind !== 'trail') {
|
|
return true
|
|
}
|
|
|
|
if (msg.todos?.length) {
|
|
return true
|
|
}
|
|
|
|
if (!(msg.tools?.length || msg.thinking?.trim())) {
|
|
return false
|
|
}
|
|
|
|
return !trailAllHidden(ctx)
|
|
}
|
|
|
|
/**
|
|
* The nearest block above `index` that actually renders, resolved through a
|
|
* lazy accessor so it works over either the virtualized history rows or the
|
|
* live block list. This is the grouping predecessor — using it (instead of the
|
|
* literal previous row) keeps hidden trails from interrupting the rhythm.
|
|
*/
|
|
export const prevRenderedMsg = (
|
|
msgAt: (i: number) => Msg | undefined,
|
|
index: number,
|
|
ctx: DetailsCtx
|
|
): Msg | undefined => {
|
|
for (let i = index - 1; i >= 0; i--) {
|
|
const candidate = msgAt(i)
|
|
|
|
if (candidate && blockRenders(candidate, ctx)) {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|