mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-31 06:51:29 +00:00
Merge pull request #28829 from NousResearch/bb/tui-no-history-truncation
fix(tui): render full assistant text in scrollback (no history truncation)
This commit is contained in:
parent
5a3317693c
commit
b0af1d0931
8 changed files with 57 additions and 42 deletions
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
boundedHistoryRenderText,
|
||||
boundedLiveRenderText,
|
||||
buildToolTrailLine,
|
||||
edgePreview,
|
||||
|
|
@ -160,15 +159,6 @@ describe('boundedLiveRenderText', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('boundedHistoryRenderText', () => {
|
||||
it('uses a non-live omission label for completed history', () => {
|
||||
const out = boundedHistoryRenderText('abcdefghij', { maxChars: 4, maxLines: 10 })
|
||||
|
||||
expect(out).toContain('[showing tail; omitted')
|
||||
expect(out).not.toContain('live tail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edgePreview', () => {
|
||||
it('keeps both ends for long text', () => {
|
||||
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(
|
||||
|
|
|
|||
|
|
@ -39,4 +39,19 @@ describe('virtual height estimates', () => {
|
|||
|
||||
expect(withSep).toBe(base + 2)
|
||||
})
|
||||
|
||||
it('caps wrapped-line counting so giant assistant turns do not block offset rebuilds', () => {
|
||||
// wrappedLines is invoked once per uncached message during
|
||||
// useVirtualHistory's offset rebuild. Unbounded counting on a long
|
||||
// assistant response (10k+ chars × every row × every rebuild) blocks
|
||||
// the UI on cold mount. Cap is ~800 rows; post-mount Yoga
|
||||
// measurement converges to the true height regardless.
|
||||
const giant = 'x'.repeat(1_000_000)
|
||||
const t0 = performance.now()
|
||||
const rows = wrappedLines(giant, 80)
|
||||
const elapsed = performance.now() - t0
|
||||
|
||||
expect(rows).toBeLessThanOrEqual(800)
|
||||
expect(elapsed).toBeLessThan(50)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { STARTUP_RESUME_ID } from '../config/env.js'
|
||||
import { FULL_RENDER_TAIL_ITEMS, MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
||||
import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
||||
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
|
||||
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
||||
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
||||
|
|
@ -274,7 +274,6 @@ export function useMainApp(gw: GatewayClient) {
|
|||
estimatedMsgHeight(virtualRows[index]!.msg, cols, {
|
||||
compact: ui.compact,
|
||||
details: detailsVisible,
|
||||
limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS,
|
||||
userPrompt: ui.theme.brand.prompt,
|
||||
withSeparator: virtualRows[index]!.msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type { AppLayoutProps } from '../app/interfaces.js'
|
|||
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { INLINE_MODE, SHOW_FPS } from '../config/env.js'
|
||||
import { FULL_RENDER_TAIL_ITEMS } from '../config/limits.js'
|
||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||
import {
|
||||
COMPOSER_PROMPT_GAP_WIDTH,
|
||||
|
|
@ -125,7 +124,6 @@ const TranscriptPane = memo(function TranscriptPane({
|
|||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
detailsModeCommandOverride={ui.detailsModeCommandOverride}
|
||||
limitHistoryRender={row.index < transcript.historyItems.length - FULL_RENDER_TAIL_ITEMS}
|
||||
msg={row.msg}
|
||||
sections={ui.sections}
|
||||
t={ui.theme}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { userDisplay } from '../domain/messages.js'
|
|||
import { ROLE } from '../domain/roles.js'
|
||||
import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js'
|
||||
import {
|
||||
boundedHistoryRenderText,
|
||||
boundedLiveRenderText,
|
||||
compactPreview,
|
||||
hasAnsi,
|
||||
|
|
@ -32,7 +31,6 @@ export const MessageLine = memo(function MessageLine({
|
|||
detailsMode = 'collapsed',
|
||||
detailsModeCommandOverride = false,
|
||||
isStreaming = false,
|
||||
limitHistoryRender = false,
|
||||
msg,
|
||||
sections,
|
||||
t,
|
||||
|
|
@ -149,7 +147,7 @@ export const MessageLine = memo(function MessageLine({
|
|||
// streamingMarkdown.tsx for the cost model.
|
||||
<StreamingMd cols={bodyWidth} compact={compact} t={t} text={boundedLiveRenderText(msg.text)} />
|
||||
) : (
|
||||
<Md cols={bodyWidth} compact={compact} t={t} text={limitHistoryRender ? boundedHistoryRenderText(msg.text) : msg.text} />
|
||||
<Md cols={bodyWidth} compact={compact} t={t} text={msg.text} />
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -215,7 +213,6 @@ interface MessageLineProps {
|
|||
detailsMode?: DetailsMode
|
||||
detailsModeCommandOverride?: boolean
|
||||
isStreaming?: boolean
|
||||
limitHistoryRender?: boolean
|
||||
msg: Msg
|
||||
sections?: SectionVisibility
|
||||
t: Theme
|
||||
|
|
|
|||
|
|
@ -3,15 +3,6 @@ export const LARGE_PASTE = { chars: 8000, lines: 80 }
|
|||
export const LIVE_RENDER_MAX_CHARS = 16_000
|
||||
export const LIVE_RENDER_MAX_LINES = 240
|
||||
|
||||
// History-render bounds for messages outside FULL_RENDER_TAIL. Each rendered
|
||||
// line ≈ 1 Yoga/Text node + inline spans, so this is the dominant lever on
|
||||
// cold-mount cost during PageUp catch-up. 16 lines × 25 mounted ≈ 400 nodes
|
||||
// — comfortably inside the 16ms per-frame budget. User pages back to
|
||||
// recognize, not to read; full re-render once it falls inside the tail.
|
||||
export const HISTORY_RENDER_MAX_CHARS = 800
|
||||
export const HISTORY_RENDER_MAX_LINES = 16
|
||||
export const FULL_RENDER_TAIL_ITEMS = 8
|
||||
|
||||
export const LONG_MSG = 300
|
||||
export const MAX_HISTORY = 800
|
||||
export const THINKING_COT_MAX = 160
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import {
|
||||
HISTORY_RENDER_MAX_CHARS,
|
||||
HISTORY_RENDER_MAX_LINES,
|
||||
LIVE_RENDER_MAX_CHARS,
|
||||
LIVE_RENDER_MAX_LINES,
|
||||
THINKING_COT_MAX
|
||||
|
|
@ -129,11 +127,6 @@ export const boundedLiveRenderText = (
|
|||
{ maxChars = LIVE_RENDER_MAX_CHARS, maxLines = LIVE_RENDER_MAX_LINES } = {}
|
||||
) => boundedRenderText(text, 'showing live tail', { maxChars, maxLines })
|
||||
|
||||
export const boundedHistoryRenderText = (
|
||||
text: string,
|
||||
{ maxChars = HISTORY_RENDER_MAX_CHARS, maxLines = HISTORY_RENDER_MAX_LINES } = {}
|
||||
) => boundedRenderText(text, 'showing tail', { maxChars, maxLines })
|
||||
|
||||
const boundedRenderText = (
|
||||
text: string,
|
||||
labelPrefix: string,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Msg } from '../types.js'
|
||||
|
||||
import { transcriptBodyWidth } from './inputMetrics.js'
|
||||
import { boundedHistoryRenderText } from './text.js'
|
||||
|
||||
const hashText = (text: string) => {
|
||||
let h = 5381
|
||||
|
|
@ -30,10 +29,40 @@ export const messageHeightKey = (msg: Msg) => {
|
|||
].join(':')
|
||||
}
|
||||
|
||||
export const wrappedLines = (text: string, width: number) => {
|
||||
const w = Math.max(1, width)
|
||||
// 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
|
||||
|
||||
return text.split('\n').reduce((n, line) => n + Math.max(1, Math.ceil(line.length / w)), 0)
|
||||
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 = (
|
||||
|
|
@ -42,13 +71,11 @@ export const estimatedMsgHeight = (
|
|||
{
|
||||
compact,
|
||||
details,
|
||||
limitHistory = false,
|
||||
userPrompt = '',
|
||||
withSeparator = false
|
||||
}: {
|
||||
compact: boolean
|
||||
details: boolean
|
||||
limitHistory?: boolean
|
||||
userPrompt?: string
|
||||
withSeparator?: boolean
|
||||
}
|
||||
|
|
@ -70,11 +97,16 @@ export const estimatedMsgHeight = (
|
|||
}
|
||||
|
||||
const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt)
|
||||
const text = msg.role === 'assistant' && limitHistory ? boundedHistoryRenderText(msg.text) : msg.text
|
||||
const text = msg.text
|
||||
let h = wrappedLines(text || ' ', bodyWidth)
|
||||
|
||||
if (!compact && msg.role === 'assistant') {
|
||||
h += Math.min(6, (text.match(/\n\s*\n/g) ?? []).length)
|
||||
// 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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue