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:
brooklyn! 2026-05-19 12:17:35 -05:00 committed by GitHub
parent 5a3317693c
commit b0af1d0931
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 57 additions and 42 deletions

View file

@ -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(

View file

@ -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)
})
})

View file

@ -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
}),

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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) {