feat(tui): segment turns with rule above non-first user msgs; trim ticker dead space (#21846)

Multi-turn transcripts ran together visually because every user message
got the same vertical rhythm regardless of position. Adds a short ─── in
the border colour above every user message after the first, so each turn
reads as its own block. Height estimator gains a `withSeparator` flag so
virtual scrolling pre-allocates the extra two rows (rule + top margin)
and avoids a jump on first measurement.

While in the area: the busy-indicator duration was padded with
`padStart(7)`, leaving five visible spaces between `·` and the digits
(`⠋ ·      2s`) — especially loud under the verb-less `unicode` style.
Drop the padding entirely (`⠋ · 2s`); the model label now shifts a few
columns as the duration grows, which is the right trade-off for the
minimal indicator styles. The verb-padding test stays; the
duration-padding test is removed alongside the function it covered.
This commit is contained in:
brooklyn! 2026-05-08 05:12:09 -07:00 committed by GitHub
parent 7190e20e0b
commit 42f9234da3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 49 additions and 17 deletions

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { DURATION_PAD_LEN, padTickerDuration, padVerb, VERB_PAD_LEN } from '../components/appChrome.js'
import { padVerb, VERB_PAD_LEN } from '../components/appChrome.js'
import { VERBS } from '../content/verbs.js'
describe('FaceTicker verb padding', () => {
@ -16,12 +16,3 @@ describe('FaceTicker verb padding', () => {
}
})
})
describe('FaceTicker duration padding', () => {
it('keeps elapsed segment width stable across second/minute boundaries', () => {
const samples = [9000, 10000, 59000, 60000, 61000, 3599000]
const lens = samples.map(ms => padTickerDuration(ms).length)
expect(new Set(lens)).toEqual(new Set([DURATION_PAD_LEN]))
})
})

View file

@ -31,4 +31,12 @@ describe('virtual height estimates', () => {
estimatedMsgHeight(msg, 80, { compact: false, details: false })
)
})
it('reserves two extra rows for the inter-turn separator on non-first user messages', () => {
const msg: Msg = { role: 'user', text: 'follow-up question' }
const base = estimatedMsgHeight(msg, 80, { compact: false, details: false })
const withSep = estimatedMsgHeight(msg, 80, { compact: false, details: false, withSeparator: true })
expect(withSep).toBe(base + 2)
})
})

View file

@ -264,15 +264,21 @@ export function useMainApp(gw: GatewayClient) {
return cache
}, [heightCacheKey])
// Index of the first user-role message — separator-rendering in
// appLayout.tsx skips this row, so the height estimator must skip it
// too. -1 when no user message exists yet (no row will gate true).
const firstUserIdx = useMemo(() => virtualRows.findIndex(r => r.msg.role === 'user'), [virtualRows])
const estimateRowHeight = useCallback(
(index: number) =>
estimatedMsgHeight(virtualRows[index]!.msg, cols, {
compact: ui.compact,
details: detailsVisible,
limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS,
userPrompt: ui.theme.brand.prompt
userPrompt: ui.theme.brand.prompt,
withSeparator: virtualRows[index]!.msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx
}),
[cols, detailsVisible, ui.compact, ui.theme.brand.prompt, virtualRows]
[cols, detailsVisible, firstUserIdx, ui.compact, ui.theme.brand.prompt, virtualRows]
)
const syncHeightCache = useCallback(

View file

@ -23,9 +23,7 @@ const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
// Keep verb segment width stable so status-bar content to the right doesn't
// jitter when the ticker rotates between short/long verbs.
export const VERB_PAD_LEN = VERBS.reduce((max, v) => Math.max(max, v.length), 0) + 1 // + ellipsis
export const DURATION_PAD_LEN = 7 // e.g. " 9s", "1m 05s", "59m 59s"
export const padVerb = (verb: string) => `${verb}`.padEnd(VERB_PAD_LEN, ' ')
export const padTickerDuration = (ms: number) => fmtDuration(ms).padStart(DURATION_PAD_LEN, ' ')
// Compact alternates for the `emoji` and `ascii` indicator styles.
// Each entry is a fixed-width (display-width) glyph.
@ -114,7 +112,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu
// verb segment is hidden (e.g. `unicode` spinner style). When the verb
// IS shown, its trailing padding already provides the gap, so the extra
// space is harmless.
const durationSegment = startedAt ? ` · ${padTickerDuration(now - startedAt)}` : ''
const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''
return (
<Text color={color}>

View file

@ -76,6 +76,15 @@ const TranscriptPane = memo(function TranscriptPane({
return -1
}, [transcript.historyItems])
// Index of the first user-role message; every later user message gets a
// small dash above it so multi-turn transcripts visually segment by
// turn. -1 when no user message has been sent yet → no separator ever
// renders.
const firstUserIdx = useMemo(
() => transcript.historyItems.findIndex(m => m.role === 'user'),
[transcript.historyItems]
)
return (
<>
<ScrollBox
@ -95,6 +104,12 @@ const TranscriptPane = memo(function TranscriptPane({
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && (
<Box marginTop={1}>
<Text color={ui.theme.color.border}></Text>
</Box>
)}
{row.msg.kind === 'intro' ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />

View file

@ -43,8 +43,15 @@ export const estimatedMsgHeight = (
compact,
details,
limitHistory = false,
userPrompt = ''
}: { compact: boolean; details: boolean; limitHistory?: boolean; userPrompt?: string }
userPrompt = '',
withSeparator = false
}: {
compact: boolean
details: boolean
limitHistory?: boolean
userPrompt?: string
withSeparator?: boolean
}
) => {
if (msg.kind === 'intro') {
return msg.info?.version ? 9 : 5
@ -80,5 +87,12 @@ export const estimatedMsgHeight = (
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)
}