Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions ui-tui/src/__tests__/text.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'

import {
boundedHistoryRenderText,
boundedLiveRenderText,
buildToolTrailLine,
edgePreview,
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions ui-tui/src/__tests__/virtualHeights.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 1 addition & 2 deletions ui-tui/src/app/useMainApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}),
Expand Down
2 changes: 0 additions & 2 deletions ui-tui/src/components/appLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
5 changes: 1 addition & 4 deletions ui-tui/src/components/messageLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,7 +31,6 @@ export const MessageLine = memo(function MessageLine({
detailsMode = 'collapsed',
detailsModeCommandOverride = false,
isStreaming = false,
limitHistoryRender = false,
msg,
sections,
t,
Expand Down Expand Up @@ -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} />
)
}

Expand Down Expand Up @@ -215,7 +213,6 @@ interface MessageLineProps {
detailsMode?: DetailsMode
detailsModeCommandOverride?: boolean
isStreaming?: boolean
limitHistoryRender?: boolean
msg: Msg
sections?: SectionVisibility
t: Theme
Expand Down
9 changes: 0 additions & 9 deletions ui-tui/src/config/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 0 additions & 7 deletions ui-tui/src/lib/text.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import {
HISTORY_RENDER_MAX_CHARS,
HISTORY_RENDER_MAX_LINES,
LIVE_RENDER_MAX_CHARS,
LIVE_RENDER_MAX_LINES,
THINKING_COT_MAX
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 39 additions & 7 deletions ui-tui/src/lib/virtualHeights.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -30,10 +29,40 @@ export const messageHeightKey = (msg: Msg) => {
].join(':')
}

export const wrappedLines = (text: string, width: number) => {
// 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

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 text.split('\n').reduce((n, line) => n + Math.max(1, Math.ceil(line.length / w)), 0)
return n
}

export const estimatedMsgHeight = (
Expand All @@ -42,13 +71,11 @@ export const estimatedMsgHeight = (
{
compact,
details,
limitHistory = false,
userPrompt = '',
withSeparator = false
}: {
compact: boolean
details: boolean
limitHistory?: boolean
userPrompt?: string
withSeparator?: boolean
}
Expand All @@ -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)
Comment thread
OutThisLife marked this conversation as resolved.

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) {
Expand Down
Loading