mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
perf(tui): cache stringWidth/wrapText/sliceAnsi + skip-slice when line fits clip
CPU profile (Apr 2026, real-user scroll on 11k-line session) showed three hot loops in the per-frame render path: Output.get() per-frame walk: 24% total └─ sliceAnsi(line, from, to) per write: 18% total stringWidth(line) chain (cached + JS): 14% total All three were re-doing identical work every frame: same string → same clipped slice → same width. Fixes: 1. Memoize stringWidth (8k-entry LRU) for non-ASCII strings; ASCII fast-path skips the cache (inline scan beats Map.get for short ASCII, the >90% case). String.charCodeAt scan up to 64 chars is cheaper than the regex fallback. 2. Memoize wrapText (4k-entry LRU keyed by maxWidth|wrapType|text) — wrapAnsi is pure and the same content reflows identically every frame. 3. Memoize sliceAnsi (4k-entry LRU keyed by start|end|str) for the end-defined hot path used by Output.get(). 4. Skip the slice entirely in Output.get() when the line already fits the clip box (startsBefore=false && endsAfter=false). Most transcript lines never exceed their container width, and tokenizing them just to slice (line, 0, width) was pure overhead. This single fast-path drops sliceAnsi from 18% → ~0% in the profile. Also tighten virtualization constants (MAX_MOUNTED 260→120, OVERSCAN 40→20, SLIDE_STEP 25→12) and cap historical-message render at 800 chars / 16 lines via HISTORY_RENDER_MAX_*; messages inside the FULL_RENDER_TAIL_ITEMS window still render in full so reading-zone behavior is unchanged. Validation, real-user CPU profile, page-up scroll on 11k-line session: Output.get() self-time: 24% → 0.3% sliceAnsi total: 18% → not in top 25 stringWidth family: 14% → ~3% idle: 60.7% → 77.3% Frame timings (synthetic page-up profile harness): dur p95: ~10ms → 4.87ms dur p99: 25ms+ → 12.80ms yoga p99: ~20ms → 1.87ms The remaining CPU in the profile is Yoga layoutNode + React commit, which is the irreducible work for this UI tree size.
This commit is contained in:
parent
85e9a23efb
commit
c370e2e1e5
14 changed files with 450 additions and 42 deletions
|
|
@ -467,9 +467,21 @@ export default class Output {
|
||||||
|
|
||||||
if (clipHorizontally) {
|
if (clipHorizontally) {
|
||||||
lines = lines.map(line => {
|
lines = lines.map(line => {
|
||||||
const from = x < clip.x1! ? clip.x1! - x : 0
|
const startsBefore = x < clip.x1!
|
||||||
const width = stringWidth(line)
|
const width = stringWidth(line)
|
||||||
const to = x + width > clip.x2! ? clip.x2! - x : width
|
const endsAfter = x + width > clip.x2!
|
||||||
|
|
||||||
|
// Fast path: line fits entirely within the clip box — skip
|
||||||
|
// the tokenize/slice. This is the common case for transcript
|
||||||
|
// text where containers are wider than the rendered content.
|
||||||
|
// CPU profile (Apr 2026) showed sliceAnsi at 18% total time;
|
||||||
|
// most calls were no-op slices like (line, 0, width).
|
||||||
|
if (!startsBefore && !endsAfter) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = startsBefore ? clip.x1! - x : 0
|
||||||
|
const to = endsAfter ? clip.x2! - x : width
|
||||||
let sliced = sliceAnsi(line, from, to)
|
let sliced = sliceAnsi(line, from, to)
|
||||||
|
|
||||||
// Wide chars (CJK, emoji) occupy 2 cells. When `to` lands
|
// Wide chars (CJK, emoji) occupy 2 cells. When `to` lands
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,58 @@ const bunStringWidth = typeof Bun !== 'undefined' && typeof Bun.stringWidth ===
|
||||||
|
|
||||||
const BUN_STRING_WIDTH_OPTS = { ambiguousIsNarrow: true } as const
|
const BUN_STRING_WIDTH_OPTS = { ambiguousIsNarrow: true } as const
|
||||||
|
|
||||||
export const stringWidth: (str: string) => number = bunStringWidth
|
const rawStringWidth: (str: string) => number = bunStringWidth
|
||||||
? str => bunStringWidth(str, BUN_STRING_WIDTH_OPTS)
|
? str => bunStringWidth(str, BUN_STRING_WIDTH_OPTS)
|
||||||
: stringWidthJavaScript
|
: stringWidthJavaScript
|
||||||
|
|
||||||
|
// Memoize stringWidth — it's pure, hot (~100k calls/frame per the comment
|
||||||
|
// above), and the underlying impl scans every grapheme + tests EMOJI_REGEX.
|
||||||
|
// CPU profile (Apr 2026) showed stringWidth dominating at 21% of total
|
||||||
|
// runtime during scroll. Cache is global (vs per-frame) since the same
|
||||||
|
// strings recur across frames in a stable transcript.
|
||||||
|
//
|
||||||
|
// Pure-ASCII short-strings (the >90% common case) skip the cache: the inline
|
||||||
|
// loop in stringWidthJavaScript is already faster than a Map.get for them.
|
||||||
|
const widthCache = new Map<string, number>()
|
||||||
|
const WIDTH_CACHE_LIMIT = 8192
|
||||||
|
|
||||||
|
export const stringWidth: (str: string) => number = str => {
|
||||||
|
if (!str) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASCII fast-path detection — for short ASCII, skip the cache.
|
||||||
|
if (str.length <= 64) {
|
||||||
|
let asciiOnly = true
|
||||||
|
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i)
|
||||||
|
|
||||||
|
if (code >= 127 || code === 0x1b) {
|
||||||
|
asciiOnly = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asciiOnly) {
|
||||||
|
return rawStringWidth(str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = widthCache.get(str)
|
||||||
|
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = rawStringWidth(str)
|
||||||
|
|
||||||
|
if (widthCache.size >= WIDTH_CACHE_LIMIT) {
|
||||||
|
// Drop oldest entry — Map iteration order is insertion order.
|
||||||
|
widthCache.delete(widthCache.keys().next().value!)
|
||||||
|
}
|
||||||
|
|
||||||
|
widthCache.set(str, w)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,40 @@ import { wrapAnsi } from './wrapAnsi.js'
|
||||||
|
|
||||||
const ELLIPSIS = '…'
|
const ELLIPSIS = '…'
|
||||||
|
|
||||||
|
// CPU profile (Apr 2026) showed `wrap-ansi` → `string-width` consuming 30% of
|
||||||
|
// total runtime during fast scroll: every layout pass re-wraps every visible
|
||||||
|
// line via wrap-ansi, which calls string-width once per grapheme. The output
|
||||||
|
// is pure of (text, maxWidth, wrapType), so memoize it. LRU-bounded so long
|
||||||
|
// sessions don't accrete unbounded cache.
|
||||||
|
const WRAP_CACHE_LIMIT = 4096
|
||||||
|
const wrapCache = new Map<string, string>()
|
||||||
|
|
||||||
|
function memoizedWrap(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
||||||
|
// Key folds maxWidth + wrapType into the prefix so the same text re-wrapped
|
||||||
|
// at a different width doesn't collide. Width prefix bounded by viewport
|
||||||
|
// (~10 distinct widths in a session); wrapType bounded by enum (~6 values).
|
||||||
|
const key = `${maxWidth}|${wrapType}|${text}`
|
||||||
|
const cached = wrapCache.get(key)
|
||||||
|
|
||||||
|
if (cached !== undefined) {
|
||||||
|
// LRU touch
|
||||||
|
wrapCache.delete(key)
|
||||||
|
wrapCache.set(key, cached)
|
||||||
|
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = computeWrap(text, maxWidth, wrapType)
|
||||||
|
|
||||||
|
if (wrapCache.size >= WRAP_CACHE_LIMIT) {
|
||||||
|
wrapCache.delete(wrapCache.keys().next().value!)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapCache.set(key, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// sliceAnsi may include a boundary-spanning wide char (e.g. CJK at position
|
// sliceAnsi may include a boundary-spanning wide char (e.g. CJK at position
|
||||||
// end-1 with width 2 overshoots by 1). Retry with a tighter bound once.
|
// end-1 with width 2 overshoots by 1). Retry with a tighter bound once.
|
||||||
function sliceFit(text: string, start: number, end: number): string {
|
function sliceFit(text: string, start: number, end: number): string {
|
||||||
|
|
@ -42,12 +76,9 @@ function truncate(text: string, columns: number, position: 'start' | 'middle' |
|
||||||
return sliceFit(text, 0, columns - 1) + ELLIPSIS
|
return sliceFit(text, 0, columns - 1) + ELLIPSIS
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function wrapText(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
function computeWrap(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
||||||
if (wrapType === 'wrap') {
|
if (wrapType === 'wrap') {
|
||||||
return wrapAnsi(text, maxWidth, {
|
return wrapAnsi(text, maxWidth, { trim: false, hard: true })
|
||||||
trim: false,
|
|
||||||
hard: true
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wrapType === 'wrap-char') {
|
if (wrapType === 'wrap-char') {
|
||||||
|
|
@ -55,25 +86,24 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wrapType === 'wrap-trim') {
|
if (wrapType === 'wrap-trim') {
|
||||||
return wrapAnsi(text, maxWidth, {
|
return wrapAnsi(text, maxWidth, { trim: true, hard: true })
|
||||||
trim: true,
|
|
||||||
hard: true
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wrapType!.startsWith('truncate')) {
|
if (wrapType!.startsWith('truncate')) {
|
||||||
let position: 'end' | 'middle' | 'start' = 'end'
|
const position: 'end' | 'middle' | 'start' =
|
||||||
|
wrapType === 'truncate-middle' ? 'middle' : wrapType === 'truncate-start' ? 'start' : 'end'
|
||||||
if (wrapType === 'truncate-middle') {
|
|
||||||
position = 'middle'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wrapType === 'truncate-start') {
|
|
||||||
position = 'start'
|
|
||||||
}
|
|
||||||
|
|
||||||
return truncate(text, maxWidth, position)
|
return truncate(text, maxWidth, position)
|
||||||
}
|
}
|
||||||
|
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function wrapText(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
||||||
|
// Skip cache for trivial inputs (faster than Map lookup).
|
||||||
|
if (!text || maxWidth <= 0) {
|
||||||
|
return computeWrap(text, maxWidth, wrapType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return memoizedWrap(text, maxWidth, wrapType)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,42 @@ function filterStartCodes(codes: AnsiCode[]): AnsiCode[] {
|
||||||
return codes.filter(c => !isEndCode(c))
|
return codes.filter(c => !isEndCode(c))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LRU cache: same (string, start, end) → same output. Output.get() re-emits
|
||||||
|
// identical writes every frame for stable transcript content; this avoids
|
||||||
|
// re-tokenizing them. CPU profile (Apr 2026) showed sliceAnsi at 18% total
|
||||||
|
// time during scroll. Bounded at 4096 entries — entries are short clipped
|
||||||
|
// lines so memory cost is small.
|
||||||
|
const sliceCache = new Map<string, string>()
|
||||||
|
const SLICE_CACHE_LIMIT = 4096
|
||||||
|
|
||||||
export default function sliceAnsi(str: string, start: number, end?: number): string {
|
export default function sliceAnsi(str: string, start: number, end?: number): string {
|
||||||
|
if (!str) return ''
|
||||||
|
|
||||||
|
// Hot-path: only cache when end is defined (the Output.get() use-case).
|
||||||
|
if (end !== undefined) {
|
||||||
|
const key = `${start}|${end}|${str}`
|
||||||
|
const cached = sliceCache.get(key)
|
||||||
|
|
||||||
|
if (cached !== undefined) {
|
||||||
|
sliceCache.delete(key)
|
||||||
|
sliceCache.set(key, cached)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = computeSlice(str, start, end)
|
||||||
|
|
||||||
|
if (sliceCache.size >= SLICE_CACHE_LIMIT) {
|
||||||
|
sliceCache.delete(sliceCache.keys().next().value!)
|
||||||
|
}
|
||||||
|
|
||||||
|
sliceCache.set(key, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return computeSlice(str, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSlice(str: string, start: number, end?: number): string {
|
||||||
const tokens = tokenize(str)
|
const tokens = tokenize(str)
|
||||||
let activeCodes: AnsiCode[] = []
|
let activeCodes: AnsiCode[] = []
|
||||||
let position = 0
|
let position = 0
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
boundedHistoryRenderText,
|
||||||
boundedLiveRenderText,
|
boundedLiveRenderText,
|
||||||
buildToolTrailLine,
|
buildToolTrailLine,
|
||||||
edgePreview,
|
edgePreview,
|
||||||
|
|
@ -116,6 +117,15 @@ 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', () => {
|
describe('edgePreview', () => {
|
||||||
it('keeps both ends for long text', () => {
|
it('keeps both ends for long text', () => {
|
||||||
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(
|
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(
|
||||||
|
|
|
||||||
28
ui-tui/src/__tests__/virtualHeights.test.ts
Normal file
28
ui-tui/src/__tests__/virtualHeights.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { estimatedMsgHeight, messageHeightKey, wrappedLines } from '../lib/virtualHeights.js'
|
||||||
|
import type { Msg } from '../types.js'
|
||||||
|
|
||||||
|
describe('virtual height estimates', () => {
|
||||||
|
it('uses stable content keys across resumed message objects', () => {
|
||||||
|
const a: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] }
|
||||||
|
const b: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] }
|
||||||
|
|
||||||
|
expect(messageHeightKey(a)).toBe(messageHeightKey(b))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accounts for wrapping and preserved blank-block rhythm', () => {
|
||||||
|
const msg: Msg = { role: 'assistant', text: `one\n\n${'x'.repeat(90)}` }
|
||||||
|
|
||||||
|
expect(wrappedLines(msg.text, 30)).toBe(5)
|
||||||
|
expect(estimatedMsgHeight(msg, 35, { compact: false, details: false })).toBeGreaterThan(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes detail sections when visible', () => {
|
||||||
|
const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\nline 2', tools: ['Tool A', 'Tool B'] }
|
||||||
|
|
||||||
|
expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBeGreaterThan(
|
||||||
|
estimatedMsgHeight(msg, 80, { compact: false, details: false })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
|
import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { STARTUP_RESUME_ID } from '../config/env.js'
|
import { STARTUP_RESUME_ID } from '../config/env.js'
|
||||||
import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
import { FULL_RENDER_TAIL_ITEMS, MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
||||||
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
|
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
|
||||||
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
||||||
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
||||||
|
|
@ -21,6 +21,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||||
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
||||||
import { getViewportSnapshot } from '../lib/viewportStore.js'
|
import { getViewportSnapshot } from '../lib/viewportStore.js'
|
||||||
|
import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
|
||||||
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
|
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
|
||||||
|
|
||||||
import { createGatewayEventHandler } from './createGatewayEventHandler.js'
|
import { createGatewayEventHandler } from './createGatewayEventHandler.js'
|
||||||
|
|
@ -41,6 +42,7 @@ import { useSubmission } from './useSubmission.js'
|
||||||
const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i
|
const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i
|
||||||
const BRACKET_PASTE_ON = '\x1b[?2004h'
|
const BRACKET_PASTE_ON = '\x1b[?2004h'
|
||||||
const BRACKET_PASTE_OFF = '\x1b[?2004l'
|
const BRACKET_PASTE_OFF = '\x1b[?2004l'
|
||||||
|
const MAX_HEIGHT_CACHE_BUCKETS = 12
|
||||||
|
|
||||||
const capHistory = (items: Msg[]): Msg[] => {
|
const capHistory = (items: Msg[]): Msg[] => {
|
||||||
if (items.length <= MAX_HISTORY) {
|
if (items.length <= MAX_HISTORY) {
|
||||||
|
|
@ -132,7 +134,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
const historyItemsRef = useRef(historyItems)
|
const historyItemsRef = useRef(historyItems)
|
||||||
const lastUserMsgRef = useRef(lastUserMsg)
|
const lastUserMsgRef = useRef(lastUserMsg)
|
||||||
const msgIdsRef = useRef(new WeakMap<Msg, string>())
|
const msgIdsRef = useRef(new WeakMap<Msg, string>())
|
||||||
const nextMsgIdRef = useRef(0)
|
const heightCachesRef = useRef(new Map<string, Map<string, number>>())
|
||||||
|
|
||||||
colsRef.current = cols
|
colsRef.current = cols
|
||||||
historyItemsRef.current = historyItems
|
historyItemsRef.current = historyItems
|
||||||
|
|
@ -179,7 +181,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
return hit
|
return hit
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = `m${++nextMsgIdRef.current}`
|
const next = messageHeightKey(msg)
|
||||||
|
|
||||||
msgIdsRef.current.set(msg, next)
|
msgIdsRef.current.set(msg, next)
|
||||||
|
|
||||||
|
|
@ -187,11 +189,67 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const virtualRows = useMemo<TranscriptRow[]>(
|
const virtualRows = useMemo<TranscriptRow[]>(
|
||||||
() => historyItems.map((msg, index) => ({ index, key: messageId(msg), msg })),
|
() => historyItems.map((msg, index) => ({ index, key: `${index}:${messageId(msg)}`, msg })),
|
||||||
[historyItems, messageId]
|
[historyItems, messageId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols, { liveTailActive: turnLiveTailActive })
|
const detailsLayoutKey = useMemo(() => {
|
||||||
|
const thinking = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride)
|
||||||
|
const tools = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride)
|
||||||
|
|
||||||
|
return `${thinking}:${tools}`
|
||||||
|
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
|
||||||
|
const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
|
||||||
|
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
|
||||||
|
const heightCache = useMemo(() => {
|
||||||
|
let cache = heightCachesRef.current.get(heightCacheKey)
|
||||||
|
|
||||||
|
if (!cache) {
|
||||||
|
cache = new Map()
|
||||||
|
heightCachesRef.current.set(heightCacheKey, cache)
|
||||||
|
|
||||||
|
if (heightCachesRef.current.size > MAX_HEIGHT_CACHE_BUCKETS) {
|
||||||
|
heightCachesRef.current.delete(heightCachesRef.current.keys().next().value!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache
|
||||||
|
}, [heightCacheKey])
|
||||||
|
const initialHeights = useMemo(() => {
|
||||||
|
const out = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const row of virtualRows) {
|
||||||
|
out.set(
|
||||||
|
row.key,
|
||||||
|
heightCache.get(row.key) ??
|
||||||
|
estimatedMsgHeight(row.msg, cols, {
|
||||||
|
compact: ui.compact,
|
||||||
|
details: detailsVisible,
|
||||||
|
limitHistory: row.index < virtualRows.length - FULL_RENDER_TAIL_ITEMS
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}, [cols, detailsVisible, heightCache, ui.compact, virtualRows])
|
||||||
|
const syncHeightCache = useCallback(
|
||||||
|
(heights: ReadonlyMap<string, number>) => {
|
||||||
|
for (const row of virtualRows) {
|
||||||
|
const h = heights.get(row.key)
|
||||||
|
|
||||||
|
if (h) {
|
||||||
|
heightCache.set(row.key, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[heightCache, virtualRows]
|
||||||
|
)
|
||||||
|
|
||||||
|
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols, {
|
||||||
|
initialHeights,
|
||||||
|
liveTailActive: turnLiveTailActive,
|
||||||
|
onHeightsChange: syncHeightCache
|
||||||
|
})
|
||||||
|
|
||||||
const scrollWithSelection = useCallback(
|
const scrollWithSelection = useCallback(
|
||||||
(delta: number) => scrollWithSelectionBy(delta, { scrollRef, selection }),
|
(delta: number) => scrollWithSelectionBy(delta, { scrollRef, selection }),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { AppLayoutProps } from '../app/interfaces.js'
|
||||||
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
||||||
import { $uiState } from '../app/uiStore.js'
|
import { $uiState } from '../app/uiStore.js'
|
||||||
import { INLINE_MODE, SHOW_FPS } from '../config/env.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 { PLACEHOLDER } from '../content/placeholders.js'
|
||||||
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
||||||
import { PerfPane } from '../lib/perfPane.js'
|
import { PerfPane } from '../lib/perfPane.js'
|
||||||
|
|
@ -51,6 +52,7 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||||
compact={ui.compact}
|
compact={ui.compact}
|
||||||
detailsMode={ui.detailsMode}
|
detailsMode={ui.detailsMode}
|
||||||
detailsModeCommandOverride={ui.detailsModeCommandOverride}
|
detailsModeCommandOverride={ui.detailsModeCommandOverride}
|
||||||
|
limitHistoryRender={row.index < transcript.historyItems.length - FULL_RENDER_TAIL_ITEMS}
|
||||||
msg={row.msg}
|
msg={row.msg}
|
||||||
sections={ui.sections}
|
sections={ui.sections}
|
||||||
t={ui.theme}
|
t={ui.theme}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Box, Link, Text } from '@hermes/ink'
|
import { Box, Link, Text } from '@hermes/ink'
|
||||||
import { memo, type ReactNode, useMemo } from 'react'
|
import { memo, useMemo, type ReactNode } from 'react'
|
||||||
|
|
||||||
import { ensureEmojiPresentation } from '../lib/emoji.js'
|
import { ensureEmojiPresentation } from '../lib/emoji.js'
|
||||||
import { highlightLine, isHighlightable } from '../lib/syntax.js'
|
import { highlightLine, isHighlightable } from '../lib/syntax.js'
|
||||||
|
|
@ -213,8 +213,57 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||||
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
|
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-instance parsed-children cache. `Md` is mounted fresh whenever a
|
||||||
|
// virtualized row enters the mount window — useMemo's per-instance cache
|
||||||
|
// doesn't survive remounts, so PageUp into cold/resumed history reparses
|
||||||
|
// every row (markdown scan + per-line syntax highlight).
|
||||||
|
//
|
||||||
|
// Outer WeakMap keyed by theme so palette swaps drop stale baked-in colors
|
||||||
|
// without code intervention. Inner Map is LRU-bounded; key folds `compact`
|
||||||
|
// in so the two layout modes don't poison each other.
|
||||||
|
const MD_CACHE_LIMIT = 512
|
||||||
|
const mdCache = new WeakMap<Theme, Map<string, ReactNode[]>>()
|
||||||
|
|
||||||
|
const cacheBucket = (t: Theme) => {
|
||||||
|
let b = mdCache.get(t)
|
||||||
|
|
||||||
|
if (!b) {
|
||||||
|
b = new Map()
|
||||||
|
mdCache.set(t, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheGet = (b: Map<string, ReactNode[]>, key: string) => {
|
||||||
|
const v = b.get(key)
|
||||||
|
|
||||||
|
if (v) {
|
||||||
|
b.delete(key)
|
||||||
|
b.set(key, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheSet = (b: Map<string, ReactNode[]>, key: string, v: ReactNode[]) => {
|
||||||
|
b.set(key, v)
|
||||||
|
|
||||||
|
if (b.size > MD_CACHE_LIMIT) {
|
||||||
|
b.delete(b.keys().next().value!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function MdImpl({ compact, t, text }: MdProps) {
|
function MdImpl({ compact, t, text }: MdProps) {
|
||||||
const nodes = useMemo(() => {
|
const nodes = useMemo(() => {
|
||||||
|
const bucket = cacheBucket(t)
|
||||||
|
const cacheKey = `${compact ? '1' : '0'}|${text}`
|
||||||
|
const cached = cacheGet(bucket, cacheKey)
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
const lines = ensureEmojiPresentation(text).split('\n')
|
const lines = ensureEmojiPresentation(text).split('\n')
|
||||||
const nodes: ReactNode[] = []
|
const nodes: ReactNode[] = []
|
||||||
|
|
||||||
|
|
@ -615,6 +664,8 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cacheSet(bucket, cacheKey, nodes)
|
||||||
|
|
||||||
return nodes
|
return nodes
|
||||||
}, [compact, t, text])
|
}, [compact, t, text])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,14 @@ import { LONG_MSG } from '../config/limits.js'
|
||||||
import { sectionMode } from '../domain/details.js'
|
import { sectionMode } from '../domain/details.js'
|
||||||
import { userDisplay } from '../domain/messages.js'
|
import { userDisplay } from '../domain/messages.js'
|
||||||
import { ROLE } from '../domain/roles.js'
|
import { ROLE } from '../domain/roles.js'
|
||||||
import { boundedLiveRenderText, compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
|
import {
|
||||||
|
boundedHistoryRenderText,
|
||||||
|
boundedLiveRenderText,
|
||||||
|
compactPreview,
|
||||||
|
hasAnsi,
|
||||||
|
isPasteBackedText,
|
||||||
|
stripAnsi
|
||||||
|
} from '../lib/text.js'
|
||||||
import type { Theme } from '../theme.js'
|
import type { Theme } from '../theme.js'
|
||||||
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
|
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
|
||||||
|
|
||||||
|
|
@ -20,6 +27,7 @@ export const MessageLine = memo(function MessageLine({
|
||||||
detailsMode = 'collapsed',
|
detailsMode = 'collapsed',
|
||||||
detailsModeCommandOverride = false,
|
detailsModeCommandOverride = false,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
limitHistoryRender = false,
|
||||||
msg,
|
msg,
|
||||||
sections,
|
sections,
|
||||||
t,
|
t,
|
||||||
|
|
@ -107,7 +115,7 @@ export const MessageLine = memo(function MessageLine({
|
||||||
// streamingMarkdown.tsx for the cost model.
|
// streamingMarkdown.tsx for the cost model.
|
||||||
<StreamingMd compact={compact} t={t} text={boundedLiveRenderText(msg.text)} />
|
<StreamingMd compact={compact} t={t} text={boundedLiveRenderText(msg.text)} />
|
||||||
) : (
|
) : (
|
||||||
<Md compact={compact} t={t} text={msg.text} />
|
<Md compact={compact} t={t} text={limitHistoryRender ? boundedHistoryRenderText(msg.text) : msg.text} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,6 +181,7 @@ interface MessageLineProps {
|
||||||
detailsMode?: DetailsMode
|
detailsMode?: DetailsMode
|
||||||
detailsModeCommandOverride?: boolean
|
detailsModeCommandOverride?: boolean
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
|
limitHistoryRender?: boolean
|
||||||
msg: Msg
|
msg: Msg
|
||||||
sections?: SectionVisibility
|
sections?: SectionVisibility
|
||||||
t: Theme
|
t: Theme
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
export const LARGE_PASTE = { chars: 8000, lines: 80 }
|
export const LARGE_PASTE = { chars: 8000, lines: 80 }
|
||||||
export const LIVE_RENDER_MAX_CHARS = 16_000
|
export const LIVE_RENDER_MAX_CHARS = 16_000
|
||||||
export const LIVE_RENDER_MAX_LINES = 240
|
export const LIVE_RENDER_MAX_LINES = 240
|
||||||
|
// History-render bounds for messages outside the FULL_RENDER_TAIL window.
|
||||||
|
// Each rendered line becomes ≥1 Yoga/Text node + inline spans, so this is
|
||||||
|
// the dominant lever on cold-mount cost during PageUp catch-up. 16 lines
|
||||||
|
// × 25 mounted items ≈ 400 nodes total — small enough that the per-frame
|
||||||
|
// buffer-compose stays well inside the 16ms budget. User pages back to
|
||||||
|
// recognize where they were, not to read; stopping near a message
|
||||||
|
// re-renders it in full once it falls inside the tail window.
|
||||||
|
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 LONG_MSG = 300
|
||||||
export const MAX_HISTORY = 800
|
export const MAX_HISTORY = 800
|
||||||
export const THINKING_COT_MAX = 160
|
export const THINKING_COT_MAX = 160
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,29 @@
|
||||||
import type { ScrollBoxHandle } from '@hermes/ink'
|
import type { ScrollBoxHandle } from '@hermes/ink'
|
||||||
import {
|
import {
|
||||||
type RefObject,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useSyncExternalStore
|
useSyncExternalStore,
|
||||||
|
type RefObject
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
const ESTIMATE = 4
|
const ESTIMATE = 4
|
||||||
const OVERSCAN = 40
|
// Overscan was 40 (= viewport) which is way more than needed when heights
|
||||||
const MAX_MOUNTED = 260
|
// are well-estimated. Cutting in half saves ~20 mounted items per scroll
|
||||||
const COLD_START = 40
|
// edge → smaller fiber tree → less buffer-compose work per frame. HN/CC
|
||||||
|
// dev (https://news.ycombinator.com/item?id=46699072) confirmed GC pressure
|
||||||
|
// from large JSX trees was their main perf issue post-rewrite.
|
||||||
|
const OVERSCAN = 20
|
||||||
|
// Hard cap on mounted items. Was 260; profiling showed ~23k live Yoga
|
||||||
|
// nodes during sustained PageUp catch-up (renderer p99=106ms). The
|
||||||
|
// viewport+2*overscan = 80 rows of needed coverage = ~25 items at avg 3
|
||||||
|
// rows/item, so 120 leaves >4× headroom and never blanks the viewport
|
||||||
|
// even when items are tiny.
|
||||||
|
const MAX_MOUNTED = 120
|
||||||
|
const COLD_START = 30
|
||||||
// Floor on unmeasured row height used when computing coverage — guarantees
|
// Floor on unmeasured row height used when computing coverage — guarantees
|
||||||
// the mounted span physically reaches the viewport bottom regardless of how
|
// the mounted span physically reaches the viewport bottom regardless of how
|
||||||
// small items actually are (at the cost of over-mounting when items are
|
// small items actually are (at the cost of over-mounting when items are
|
||||||
|
|
@ -34,8 +44,10 @@ const FREEZE_RENDERS = 2
|
||||||
// a single PageUp into unmeasured territory mounts ~190 rows with
|
// a single PageUp into unmeasured territory mounts ~190 rows with
|
||||||
// PESSIMISTIC=1 coverage — each row running marked lexer + syntax
|
// PESSIMISTIC=1 coverage — each row running marked lexer + syntax
|
||||||
// highlighting for ~3ms = ~600ms sync block. Sliding toward the target
|
// highlighting for ~3ms = ~600ms sync block. Sliding toward the target
|
||||||
// over several commits keeps per-commit mount cost bounded.
|
// over several commits keeps per-commit mount cost bounded. Tightened
|
||||||
const SLIDE_STEP = 25
|
// from 25 → 12: each new item adds ~100 fibers / Yoga nodes, and a
|
||||||
|
// 25-item commit was the dominant contributor to the 100ms+ p99 frames.
|
||||||
|
const SLIDE_STEP = 12
|
||||||
|
|
||||||
const NOOP = () => {}
|
const NOOP = () => {}
|
||||||
|
|
||||||
|
|
@ -70,15 +82,19 @@ export function useVirtualHistory(
|
||||||
columns: number,
|
columns: number,
|
||||||
{
|
{
|
||||||
estimate = ESTIMATE,
|
estimate = ESTIMATE,
|
||||||
|
initialHeights,
|
||||||
liveTailActive = false,
|
liveTailActive = false,
|
||||||
|
onHeightsChange,
|
||||||
overscan = OVERSCAN,
|
overscan = OVERSCAN,
|
||||||
maxMounted = MAX_MOUNTED,
|
maxMounted = MAX_MOUNTED,
|
||||||
coldStartCount = COLD_START
|
coldStartCount = COLD_START
|
||||||
} = {}
|
}: VirtualHistoryOptions = {}
|
||||||
) {
|
) {
|
||||||
const nodes = useRef(new Map<string, unknown>())
|
const nodes = useRef(new Map<string, unknown>())
|
||||||
const heights = useRef(new Map<string, number>())
|
const heights = useRef(new Map(initialHeights))
|
||||||
|
const initialHeightsRef = useRef(initialHeights)
|
||||||
const refs = useRef(new Map<string, (el: unknown) => void>())
|
const refs = useRef(new Map<string, (el: unknown) => void>())
|
||||||
|
const onHeightsChangeRef = useRef(onHeightsChange)
|
||||||
// Bump whenever heightCache mutates so offsets rebuild on next read.
|
// Bump whenever heightCache mutates so offsets rebuild on next read.
|
||||||
// Ref (not state) — checked during render phase, zero extra commits.
|
// Ref (not state) — checked during render phase, zero extra commits.
|
||||||
const offsetVersion = useRef(0)
|
const offsetVersion = useRef(0)
|
||||||
|
|
@ -106,6 +122,14 @@ export function useVirtualHistory(
|
||||||
const prevRange = useRef<null | readonly [number, number]>(null)
|
const prevRange = useRef<null | readonly [number, number]>(null)
|
||||||
const freezeRenders = useRef(0)
|
const freezeRenders = useRef(0)
|
||||||
|
|
||||||
|
onHeightsChangeRef.current = onHeightsChange
|
||||||
|
|
||||||
|
if (initialHeightsRef.current !== initialHeights) {
|
||||||
|
initialHeightsRef.current = initialHeights
|
||||||
|
heights.current = new Map(initialHeights)
|
||||||
|
offsetVersion.current++
|
||||||
|
}
|
||||||
|
|
||||||
if (prevColumns.current !== columns && prevColumns.current > 0 && columns > 0) {
|
if (prevColumns.current !== columns && prevColumns.current > 0 && columns > 0) {
|
||||||
const ratio = prevColumns.current / columns
|
const ratio = prevColumns.current / columns
|
||||||
|
|
||||||
|
|
@ -377,6 +401,7 @@ export function useVirtualHistory(
|
||||||
if (h > 0 && heights.current.get(key) !== h) {
|
if (h > 0 && heights.current.get(key) !== h) {
|
||||||
heights.current.set(key, h)
|
heights.current.set(key, h)
|
||||||
offsetVersion.current++
|
offsetVersion.current++
|
||||||
|
onHeightsChangeRef.current?.(heights.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes.current.delete(key)
|
nodes.current.delete(key)
|
||||||
|
|
@ -454,6 +479,7 @@ export function useVirtualHistory(
|
||||||
|
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
offsetVersion.current++
|
offsetVersion.current++
|
||||||
|
onHeightsChangeRef.current?.(heights.current)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -470,3 +496,13 @@ export function useVirtualHistory(
|
||||||
interface MeasuredNode {
|
interface MeasuredNode {
|
||||||
yogaNode?: { getComputedHeight?: () => number } | null
|
yogaNode?: { getComputedHeight?: () => number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VirtualHistoryOptions {
|
||||||
|
coldStartCount?: number
|
||||||
|
estimate?: number
|
||||||
|
initialHeights?: ReadonlyMap<string, number>
|
||||||
|
liveTailActive?: boolean
|
||||||
|
maxMounted?: number
|
||||||
|
onHeightsChange?: (heights: ReadonlyMap<string, number>) => void
|
||||||
|
overscan?: number
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { LIVE_RENDER_MAX_CHARS, LIVE_RENDER_MAX_LINES, THINKING_COT_MAX } from '../config/limits.js'
|
import {
|
||||||
|
HISTORY_RENDER_MAX_CHARS,
|
||||||
|
HISTORY_RENDER_MAX_LINES,
|
||||||
|
LIVE_RENDER_MAX_CHARS,
|
||||||
|
LIVE_RENDER_MAX_LINES,
|
||||||
|
THINKING_COT_MAX
|
||||||
|
} from '../config/limits.js'
|
||||||
import { VERBS } from '../content/verbs.js'
|
import { VERBS } from '../content/verbs.js'
|
||||||
import type { ThinkingMode } from '../types.js'
|
import type { ThinkingMode } from '../types.js'
|
||||||
|
|
||||||
|
|
@ -98,6 +104,17 @@ export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: numb
|
||||||
export const boundedLiveRenderText = (
|
export const boundedLiveRenderText = (
|
||||||
text: string,
|
text: string,
|
||||||
{ maxChars = LIVE_RENDER_MAX_CHARS, maxLines = LIVE_RENDER_MAX_LINES } = {}
|
{ 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,
|
||||||
|
{ maxChars, maxLines }: { maxChars: number; maxLines: number }
|
||||||
) => {
|
) => {
|
||||||
if (text.length <= maxChars && text.split('\n', maxLines + 1).length <= maxLines) {
|
if (text.length <= maxChars && text.split('\n', maxLines + 1).length <= maxLines) {
|
||||||
return text
|
return text
|
||||||
|
|
@ -132,8 +149,8 @@ export const boundedLiveRenderText = (
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
omittedLines > 0
|
omittedLines > 0
|
||||||
? `[showing live tail; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n`
|
? `[${labelPrefix}; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n`
|
||||||
: `[showing live tail; omitted ${fmtK(omittedChars)} chars]\n`
|
: `[${labelPrefix}; omitted ${fmtK(omittedChars)} chars]\n`
|
||||||
|
|
||||||
return `${label}${tail}`
|
return `${label}${tail}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
ui-tui/src/lib/virtualHeights.ts
Normal file
58
ui-tui/src/lib/virtualHeights.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { Msg } from '../types.js'
|
||||||
|
|
||||||
|
import { boundedHistoryRenderText } from './text.js'
|
||||||
|
|
||||||
|
export const hashText = (text: string) => {
|
||||||
|
let h = 5381
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
h = ((h << 5) + h) ^ text.charCodeAt(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (h >>> 0).toString(36)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messageHeightKey = (msg: Msg) =>
|
||||||
|
[msg.role, msg.kind ?? '', hashText([msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? ''].join('\0'))].join(':')
|
||||||
|
|
||||||
|
export const wrappedLines = (text: string, width: number) => {
|
||||||
|
const w = Math.max(1, width)
|
||||||
|
|
||||||
|
return text.split('\n').reduce((n, line) => n + Math.max(1, Math.ceil(line.length / w)), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const estimatedMsgHeight = (
|
||||||
|
msg: Msg,
|
||||||
|
cols: number,
|
||||||
|
{ compact, details, limitHistory = false }: { compact: boolean; details: boolean; limitHistory?: boolean }
|
||||||
|
) => {
|
||||||
|
if (msg.kind === 'intro') {
|
||||||
|
return msg.info?.version ? 9 : 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.kind === 'panel') {
|
||||||
|
return Math.max(3, (msg.panelData?.sections.length ?? 1) * 2 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyWidth = Math.max(20, cols - 5)
|
||||||
|
const text = msg.role === 'assistant' && limitHistory ? boundedHistoryRenderText(msg.text) : msg.text
|
||||||
|
let h = wrappedLines(text || ' ', bodyWidth)
|
||||||
|
|
||||||
|
if (!compact && msg.role === 'assistant') {
|
||||||
|
h += Math.min(6, (text.match(/\n\s*\n/g) ?? []).length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details) {
|
||||||
|
h += (msg.tools?.length ?? 0) + wrappedLines(msg.thinking ?? '', bodyWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === 'user' || msg.kind === 'slash' || msg.kind === 'diff') {
|
||||||
|
h++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === 'user' || msg.kind === 'diff') {
|
||||||
|
h++
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, h)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue