fix(tui): clear selection on right-click copy + group transcript blocks
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.
This commit is contained in:
@@ -8,6 +8,7 @@ import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStor
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js'
|
||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||
import { prevRenderedMsg } from '../domain/blockLayout.js'
|
||||
import {
|
||||
COMPOSER_PROMPT_GAP_WIDTH,
|
||||
composerPromptWidth,
|
||||
@@ -125,6 +126,11 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||
detailsMode={ui.detailsMode}
|
||||
detailsModeCommandOverride={ui.detailsModeCommandOverride}
|
||||
msg={row.msg}
|
||||
prev={prevRenderedMsg(
|
||||
i => transcript.virtualRows[i]?.msg,
|
||||
row.index,
|
||||
{ commandOverride: ui.detailsModeCommandOverride, detailsMode: ui.detailsMode, sections: ui.sections }
|
||||
)}
|
||||
sections={ui.sections}
|
||||
t={ui.theme}
|
||||
/>
|
||||
@@ -141,6 +147,7 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
detailsModeCommandOverride={ui.detailsModeCommandOverride}
|
||||
prevMsg={transcript.historyItems[transcript.historyItems.length - 1]}
|
||||
progress={progress}
|
||||
sections={ui.sections}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { memo, useState } from 'react'
|
||||
|
||||
import { TERMUX_TUI_MODE } from '../config/env.js'
|
||||
import { LONG_MSG } from '../config/limits.js'
|
||||
import { hasLeadGap } from '../domain/blockLayout.js'
|
||||
import { sectionMode } from '../domain/details.js'
|
||||
import { userDisplay } from '../domain/messages.js'
|
||||
import { ROLE } from '../domain/roles.js'
|
||||
@@ -33,6 +34,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
detailsModeCommandOverride = false,
|
||||
isStreaming = false,
|
||||
msg,
|
||||
prev,
|
||||
sections,
|
||||
t,
|
||||
tools = []
|
||||
@@ -49,6 +51,14 @@ export const MessageLine = memo(function MessageLine({
|
||||
const activityMode = sectionMode('activity', detailsMode, sections, detailsModeCommandOverride)
|
||||
const thinking = msg.thinking?.trim() ?? ''
|
||||
|
||||
// One blank line above this block iff it opens a new visual group relative
|
||||
// to the block directly above it (`prev`) — the flex-grouping rule. Applied
|
||||
// intrinsically on each *rendered* element (not via an outer wrapper) so a
|
||||
// block that renders nothing — e.g. a tool trail hidden by /details — emits
|
||||
// no floating gap. Streaming-safe: the gap is derived from the stable
|
||||
// predecessor, never this block's own live content. See domain/blockLayout.
|
||||
const leadGap = hasLeadGap(prev, msg)
|
||||
|
||||
// Collapse toggle for long system messages
|
||||
const systemIsLong = msg.role === 'system' && msg.text.length > SYSTEM_COLLAPSE_CHARS
|
||||
const [systemOpen, setSystemOpen] = useState(false)
|
||||
@@ -66,7 +76,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
|
||||
if (msg.kind === 'trail' && (msg.tools?.length || tools.length || thinking)) {
|
||||
return thinkingMode !== 'hidden' || toolsMode !== 'hidden' || activityMode !== 'hidden' ? (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginTop={leadGap ? 1 : 0}>
|
||||
<ToolTrail
|
||||
commandOverride={detailsModeCommandOverride}
|
||||
detailsMode={detailsMode}
|
||||
@@ -82,6 +92,14 @@ export const MessageLine = memo(function MessageLine({
|
||||
) : null
|
||||
}
|
||||
|
||||
// A trail with no reasoning, tools, or todos to show (e.g. the finalDetails
|
||||
// segment message.complete appends carrying only a token tally) has nothing
|
||||
// to draw — render nothing instead of an empty gutter row. blockRenders()
|
||||
// agrees, so it also stays transparent to grouping and never opens a gap.
|
||||
if (msg.kind === 'trail') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
const maxChars = Math.max(24, cols - 14)
|
||||
const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text
|
||||
@@ -172,7 +190,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
})()
|
||||
|
||||
// Diff segments (emitted by pushInlineDiffSegment between narration
|
||||
// segments) need a blank line on both sides so the patch doesn't butt up
|
||||
// segments) keep a blank line on both sides so the patch doesn't butt up
|
||||
// against the prose around it.
|
||||
const isDiffSegment = msg.kind === 'diff'
|
||||
|
||||
@@ -180,7 +198,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginBottom={msg.role === 'user' || isDiffSegment ? 1 : 0}
|
||||
marginTop={msg.role === 'user' || msg.kind === 'slash' || isDiffSegment ? 1 : 0}
|
||||
marginTop={msg.role === 'user' || msg.kind === 'slash' || isDiffSegment || leadGap ? 1 : 0}
|
||||
>
|
||||
{showDetails && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
@@ -231,6 +249,10 @@ interface MessageLineProps {
|
||||
detailsModeCommandOverride?: boolean
|
||||
isStreaming?: boolean
|
||||
msg: Msg
|
||||
// The block rendered directly above this one. Drives the group-boundary
|
||||
// lead gap (see domain/blockLayout.ts::hasLeadGap). Undefined at the top of
|
||||
// the transcript or when spacing is irrelevant.
|
||||
prev?: Msg
|
||||
sections?: SectionVisibility
|
||||
t: Theme
|
||||
tools?: ActiveTool[]
|
||||
|
||||
@@ -4,8 +4,9 @@ import { memo } from 'react'
|
||||
import type { AppLayoutProgressProps } from '../app/interfaces.js'
|
||||
import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { blockRenders } from '../domain/blockLayout.js'
|
||||
import { appendToolShelfMessage } from '../lib/liveProgress.js'
|
||||
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
|
||||
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
|
||||
|
||||
import { MessageLine } from './messageLine.js'
|
||||
import { TodoPanel } from './todoPanel.js'
|
||||
@@ -13,11 +14,19 @@ import { TodoPanel } from './todoPanel.js'
|
||||
const groupedSegments = (segments: Msg[]): Msg[] =>
|
||||
segments.reduce<Msg[]>((acc, msg) => appendToolShelfMessage(acc, msg), [])
|
||||
|
||||
interface LiveBlock {
|
||||
isStreaming?: boolean
|
||||
key: string
|
||||
msg: Msg
|
||||
tools?: ActiveTool[]
|
||||
}
|
||||
|
||||
export const StreamingAssistant = memo(function StreamingAssistant({
|
||||
cols,
|
||||
compact,
|
||||
detailsMode,
|
||||
detailsModeCommandOverride,
|
||||
prevMsg,
|
||||
progress,
|
||||
sections
|
||||
}: StreamingAssistantProps) {
|
||||
@@ -32,62 +41,60 @@ export const StreamingAssistant = memo(function StreamingAssistant({
|
||||
return null
|
||||
}
|
||||
|
||||
// Flatten the live area into one ordered list so each block's leading gap
|
||||
// can be derived from the block directly above it — including the boundary
|
||||
// back into settled history (prevMsg). Tracking the predecessor rather than
|
||||
// the live text is what keeps the streaming block from jumping when it
|
||||
// flushes into a settled segment.
|
||||
const blocks: LiveBlock[] = groupedSegments(streamSegments).map((msg, i) => ({ key: `seg:${i}`, msg }))
|
||||
|
||||
if (activeTools.length) {
|
||||
blocks.push({ key: 'active-tools', msg: { kind: 'trail', role: 'system', text: '' }, tools: activeTools })
|
||||
}
|
||||
|
||||
if (showStreamingArea) {
|
||||
blocks.push({
|
||||
isStreaming: true,
|
||||
key: 'streaming',
|
||||
msg: { role: 'assistant', text: streaming, ...(streamPendingTools.length && { tools: streamPendingTools }) }
|
||||
})
|
||||
} else if (streamPendingTools.length) {
|
||||
blocks.push({ key: 'pending-tools', msg: { kind: 'trail', role: 'system', text: '', tools: streamPendingTools } })
|
||||
}
|
||||
|
||||
const detailsCtx = { commandOverride: detailsModeCommandOverride, detailsMode, sections }
|
||||
let prev = prevMsg
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupedSegments(streamSegments).map((msg, i) => (
|
||||
<MessageLine
|
||||
cols={cols}
|
||||
compact={compact}
|
||||
detailsMode={detailsMode}
|
||||
detailsModeCommandOverride={detailsModeCommandOverride}
|
||||
key={`seg:${i}`}
|
||||
msg={msg}
|
||||
sections={sections}
|
||||
t={ui.theme}
|
||||
/>
|
||||
))}
|
||||
{blocks.map(block => {
|
||||
const node = (
|
||||
<MessageLine
|
||||
cols={cols}
|
||||
compact={compact}
|
||||
detailsMode={detailsMode}
|
||||
detailsModeCommandOverride={detailsModeCommandOverride}
|
||||
isStreaming={block.isStreaming}
|
||||
key={block.key}
|
||||
msg={block.msg}
|
||||
prev={prev}
|
||||
sections={sections}
|
||||
t={ui.theme}
|
||||
{...(block.tools ? { tools: block.tools } : {})}
|
||||
/>
|
||||
)
|
||||
|
||||
{!!activeTools.length && (
|
||||
<MessageLine
|
||||
cols={cols}
|
||||
compact={compact}
|
||||
detailsMode={detailsMode}
|
||||
detailsModeCommandOverride={detailsModeCommandOverride}
|
||||
msg={{ kind: 'trail', role: 'system', text: '' }}
|
||||
sections={sections}
|
||||
t={ui.theme}
|
||||
tools={activeTools}
|
||||
/>
|
||||
)}
|
||||
// Advance the grouping predecessor only past blocks that actually
|
||||
// paint, so a trail hidden by /details stays transparent here too
|
||||
// (active tools live in the prop, so fold them into the check).
|
||||
const checkMsg = block.tools?.length ? { ...block.msg, tools: block.tools.map(tool => tool.name) } : block.msg
|
||||
|
||||
{showStreamingArea && (
|
||||
<MessageLine
|
||||
cols={cols}
|
||||
compact={compact}
|
||||
detailsMode={detailsMode}
|
||||
detailsModeCommandOverride={detailsModeCommandOverride}
|
||||
isStreaming
|
||||
msg={{
|
||||
role: 'assistant',
|
||||
text: streaming,
|
||||
...(streamPendingTools.length && { tools: streamPendingTools })
|
||||
}}
|
||||
sections={sections}
|
||||
t={ui.theme}
|
||||
/>
|
||||
)}
|
||||
if (blockRenders(checkMsg, detailsCtx)) {
|
||||
prev = block.msg
|
||||
}
|
||||
|
||||
{!showStreamingArea && !!streamPendingTools.length && (
|
||||
<MessageLine
|
||||
cols={cols}
|
||||
compact={compact}
|
||||
detailsMode={detailsMode}
|
||||
detailsModeCommandOverride={detailsModeCommandOverride}
|
||||
msg={{ kind: 'trail', role: 'system', text: '', tools: streamPendingTools }}
|
||||
sections={sections}
|
||||
t={ui.theme}
|
||||
/>
|
||||
)}
|
||||
return node
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -105,6 +112,7 @@ interface StreamingAssistantProps {
|
||||
compact?: boolean
|
||||
detailsMode: DetailsMode
|
||||
detailsModeCommandOverride: boolean
|
||||
prevMsg?: Msg
|
||||
progress: AppLayoutProgressProps
|
||||
sections?: SectionVisibility
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user