diff --git a/ui-tui/src/__tests__/statusBarTicker.test.ts b/ui-tui/src/__tests__/statusBarTicker.test.ts index 6dff476ba0..4f3369bfa3 100644 --- a/ui-tui/src/__tests__/statusBarTicker.test.ts +++ b/ui-tui/src/__tests__/statusBarTicker.test.ts @@ -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])) - }) -}) diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index f407976db3..ee60286297 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -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) + }) }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 874eca50a2..648cc1b69a 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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( diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index e5724c99ba..c961f4c273 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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 ( diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index ec60726ed3..475ad237dc 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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 ( <> ( + {row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && ( + + ─── + + )} + {row.msg.kind === 'intro' ? ( diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index e9439d42dd..9a74b92957 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -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) }