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