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.
159 lines
5.0 KiB
TypeScript
159 lines
5.0 KiB
TypeScript
import { TERMUX_TUI_MODE } from '../config/env.js'
|
||
import type { Msg } from '../types.js'
|
||
|
||
import { transcriptBodyWidth } from './inputMetrics.js'
|
||
|
||
const hashText = (text: string) => {
|
||
let h = 5381
|
||
|
||
for (let i = 0; i < text.length; i++) {
|
||
h = ((h << 5) + h) ^ text.charCodeAt(i)
|
||
}
|
||
|
||
return (h >>> 0).toString(36)
|
||
}
|
||
|
||
export const messageHeightKey = (msg: Msg) => {
|
||
const todoSig = msg.todos?.map(t => `${t.status}:${t.content}`).join('\u0001') ?? ''
|
||
|
||
const panelSig =
|
||
msg.panelData?.sections
|
||
.map(s => `${s.title ?? ''}:${s.text?.length ?? 0}:${s.items?.length ?? 0}:${s.rows?.length ?? 0}`)
|
||
.join('\u0001') ?? ''
|
||
|
||
const introSig = msg.kind === 'intro' ? (msg.info?.version ?? '') : ''
|
||
|
||
return [
|
||
msg.role,
|
||
msg.kind ?? '',
|
||
hashText([msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig].join('\0'))
|
||
].join(':')
|
||
}
|
||
|
||
// Hard cap on rows the estimator will count. Each row above this is
|
||
// invisible to the estimator (gets clipped to MAX_ESTIMATE_LINES), but
|
||
// post-mount Yoga measurement converges to the real height on first
|
||
// render. Without this, a long assistant turn (10k+ chars) costs O(text)
|
||
// per offset rebuild × every uncached item — cold-mounting a 1000-row
|
||
// transcript becomes a multi-million-char wrap walk that blocks the UI.
|
||
//
|
||
// 800 covers any realistic assistant message (the prior history-clip
|
||
// ceiling was 16 lines, then full text — this is the sane middle).
|
||
const MAX_ESTIMATE_LINES = 800
|
||
|
||
export const wrappedLines = (text: string, width: number, maxLines: number = MAX_ESTIMATE_LINES) => {
|
||
const w = Math.max(1, width)
|
||
// Worst case: every cell is its own row at width=1, plus a small
|
||
// slack for the trailing partial line. Walking past this byte budget
|
||
// cannot increase n any further once n is already past maxLines, so
|
||
// bail. Saves O(text) walks on multi-megabyte single-line messages.
|
||
const budget = Math.min(text.length, maxLines * w + maxLines)
|
||
let n = 0
|
||
let start = 0
|
||
|
||
for (let i = 0; i <= budget; i++) {
|
||
if (i === text.length || i === budget || text.charCodeAt(i) === 10) {
|
||
const rows = Math.max(1, Math.ceil((i - start) / w))
|
||
n += rows >= maxLines - n ? maxLines - n : rows
|
||
start = i + 1
|
||
|
||
if (n >= maxLines) {
|
||
return maxLines
|
||
}
|
||
}
|
||
}
|
||
|
||
return n
|
||
}
|
||
|
||
export const estimatedMsgHeight = (
|
||
msg: Msg,
|
||
cols: number,
|
||
{
|
||
compact,
|
||
details,
|
||
leadGap = false,
|
||
thinkingVisible = details,
|
||
toolsVisible = details,
|
||
userPrompt = '',
|
||
withSeparator = false
|
||
}: {
|
||
compact: boolean
|
||
details: boolean
|
||
leadGap?: boolean
|
||
thinkingVisible?: boolean
|
||
toolsVisible?: boolean
|
||
userPrompt?: string
|
||
withSeparator?: boolean
|
||
}
|
||
) => {
|
||
if (msg.kind === 'intro') {
|
||
return msg.info?.version ? 9 : 5
|
||
}
|
||
|
||
if (msg.kind === 'panel') {
|
||
return Math.max(3, (msg.panelData?.sections.length ?? 1) * 2 + 1)
|
||
}
|
||
|
||
if (msg.kind === 'trail' && msg.todos?.length) {
|
||
if (msg.todoCollapsedByDefault) {
|
||
return 2
|
||
}
|
||
|
||
return Math.max(2, msg.todos.length + 2)
|
||
}
|
||
|
||
const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt, TERMUX_TUI_MODE)
|
||
const text = msg.text
|
||
let h = wrappedLines(text || ' ', bodyWidth)
|
||
|
||
if (!compact && msg.role === 'assistant') {
|
||
// Paragraph gaps add up to 6 extra rows of breathing room. Slice
|
||
// first so the regex never walks more than the first ~16k chars of
|
||
// a giant assistant message — post-mount Yoga measurement converges
|
||
// to the real height regardless of how the estimate undercounts.
|
||
const scan = text.length > 16_000 ? text.slice(0, 16_000) : text
|
||
h += Math.min(6, (scan.match(/\n\s*\n/g) ?? []).length)
|
||
}
|
||
|
||
if (details) {
|
||
const hasVisibleTools = toolsVisible && Boolean(msg.tools?.length)
|
||
const hasVisibleThinking = thinkingVisible && /\S/.test(msg.thinking ?? '')
|
||
const hasVisibleDetails = hasVisibleTools || hasVisibleThinking
|
||
|
||
if (hasVisibleDetails) {
|
||
h += (hasVisibleTools ? (msg.tools?.length ?? 0) : 0) + (hasVisibleThinking ? wrappedLines(msg.thinking ?? '', bodyWidth) : 0)
|
||
|
||
if (msg.role === 'assistant' && /\S/.test(msg.text)) {
|
||
h += 2
|
||
}
|
||
}
|
||
}
|
||
|
||
if (msg.role === 'user' || msg.kind === 'diff') {
|
||
// Top + bottom blank line.
|
||
h += 2
|
||
} else if (msg.kind === 'slash') {
|
||
h++
|
||
}
|
||
|
||
// Group-boundary blank line owned by BlockSlot: model prose, reasoning/tool
|
||
// trails, and notes/errors each start a new visual group when the block
|
||
// above them is a different kind. The caller resolves the boundary against
|
||
// the previous row (see domain/blockLayout.ts::hasLeadGap) and passes the
|
||
// result here so the estimate matches the rendered marginTop before Yoga
|
||
// remeasures. user / diff / slash never set this — they own their margins.
|
||
if (leadGap) {
|
||
h++
|
||
}
|
||
|
||
// Inter-turn separator above non-first user messages (1 rule row + 1
|
||
// top-margin row). The render-side gate is in appLayout.tsx; we trust
|
||
// the caller to pass `withSeparator` only when it matches that gate.
|
||
if (withSeparator) {
|
||
h += 2
|
||
}
|
||
|
||
return Math.max(1, h)
|
||
}
|