diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 047ad67912f..306324d353d 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -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( diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index ee60286297e..b93df65d72a 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -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) + }) }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 7990b302ae3..7996c7b910b 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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 }), diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 63b6c0a1b2e..a4b6963cb5a 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index f44f1813804..d44e29c1206 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -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. ) : ( - + ) } @@ -215,7 +213,6 @@ interface MessageLineProps { detailsMode?: DetailsMode detailsModeCommandOverride?: boolean isStreaming?: boolean - limitHistoryRender?: boolean msg: Msg sections?: SectionVisibility t: Theme diff --git a/ui-tui/src/config/limits.ts b/ui-tui/src/config/limits.ts index 4be995548a4..9043297d549 100644 --- a/ui-tui/src/config/limits.ts +++ b/ui-tui/src/config/limits.ts @@ -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 diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index ef3a1816975..5b52c236719 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -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, diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index 9a74b929579..0e58b814d12 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -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) {