mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
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:
parent
7190e20e0b
commit
42f9234da3
6 changed files with 49 additions and 17 deletions
|
|
@ -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]))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue