import { type Component, MouseRegion, Text } from "@earendil-works/pi-tui"; import { visibleWidth } from "../../shared/ansi.js"; import type { BoxTheme } from "../../shared/box.js"; import { formatElapsedMs } from "../../shared/elapsed.js"; import { observeThoughtMessage, parseThinkingRuns, resetAgentThoughtRuns, toggleThoughtGroup, } from "./thought-summary.js"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; const OSC133_ZONE_FINAL = "\x1b]133;C\x07"; type OscParts = { start: string; body: string; end: string }; type LineAnalysis = { visibleWidth: number; hasContent: boolean; oscEnvelope: OscParts | undefined; hasOscStart: boolean; leadingMarkers: { head: string; rest: string }; isBackgroundWrapped: boolean; backgroundAnsi: string; backgroundBody: string | undefined; backgroundBodyWidth: number | undefined; }; type DecoratedRenderCacheEntry = { nativeRef: readonly string[]; nativeLines: readonly string[]; result: readonly string[]; }; type ChildrenScanState = { childrenRef: readonly unknown[]; length: number }; type MessageDecorationTestState = { decoratePasses: number; cacheHits: number; cacheMisses: number; lineCacheHits: number; lineCacheMisses: number; }; const BG_RESET = "\x1b[49m"; const MAX_RENDER_CACHE_KEYS_PER_INSTANCE = 8; const MAX_LINE_ANALYSIS_ENTRIES = 4096; let renderCacheByInstance = new WeakMap>(); let lineAnalysisCache = new Map(); // Live iterator over the analysis cache's insertion (= recency) order, reused // across evictions: `keys().next()` allocates a fresh iterator (~1.4µs) each // call, which dominates the per-insert eviction cost during streaming. A live // Map iterator skips deleted entries and always hands back the current // least-recently-used key, so eviction choice is identical to a fresh iterator. let lineCacheEvictionCursor: Iterator | undefined; // Per-assistant-message guard for the updateContent children scan: skips the // blank/interim scans when the contentContainer children array is unchanged. let childrenScanByInstance = new WeakMap(); const messageDecorationTestState: MessageDecorationTestState = { decoratePasses: 0, cacheHits: 0, cacheMisses: 0, lineCacheHits: 0, lineCacheMisses: 0, }; function extractOscEnvelope(line: string): OscParts | undefined { if (!line.startsWith(OSC133_ZONE_START)) return undefined; const bodyEnd = line.indexOf(OSC133_ZONE_END, OSC133_ZONE_START.length); if (bodyEnd < 0 || !line.endsWith(OSC133_ZONE_FINAL)) return undefined; return { start: OSC133_ZONE_START, body: line.slice(OSC133_ZONE_START.length, bodyEnd), end: line.slice(bodyEnd) }; } /** Leading zero-width OSC sequences (e.g. OSC133 markers) of a line. */ function splitLeadingMarkers(line: string): { head: string; rest: string } { let index = 0; while (line.startsWith("\x1b]", index)) { const bel = line.indexOf("\x07", index + 2); const st = line.indexOf("\x1b\\", index + 2); const end = bel === -1 ? st : st === -1 ? bel : Math.min(bel, st); if (end === -1) break; index = end + 1; } // Strings are immutable: with no markers, `rest` can alias the line itself // instead of allocating a full copy on every analyzed line. return index === 0 ? { head: "", rest: line } : { head: line.slice(0, index), rest: line.slice(index) }; } /** Leading SGR escape sequence of a line ("" when none). */ function leadingSgr(line: string): string { if (!line.startsWith("\x1b[")) return ""; let index = 2; while (index < line.length) { const code = line.charCodeAt(index); if (code >= 64 && code <= 126) return line.slice(0, index + 1); index++; } return ""; } /** Whether an SGR sequence sets/resets the terminal background color (allocation-free `Number`-equivalent parse). */ function isBackgroundSgr(sequence: string): boolean { if (!sequence.startsWith("\x1b[") || !sequence.endsWith("m")) return false; // Splits on ";" and applies Number() per token: Number("") === 0, all-digit // tokens parse as integers, anything else is NaN (matches no range). let value = 0; let empty = true; let valid = true; for (let index = 2; index < sequence.length - 1; index++) { const code = sequence.charCodeAt(index); if (code === 0x3b) { if (valid && matchesBackgroundCode(empty ? 0 : value)) return true; value = 0; empty = true; valid = true; continue; } if (code < 0x30 || code > 0x39) { valid = false; continue; } value = value * 10 + (code - 0x30); empty = false; } return valid && matchesBackgroundCode(empty ? 0 : value); } function matchesBackgroundCode(value: number): boolean { return value === 48 || value === 49 || (value >= 40 && value <= 47) || (value >= 100 && value <= 107); } /** * Width of a string whose visible content is printable ASCII carrying only * escapes pi-tui recognizes (CSI ending in m/G/K/H/J, OSC/APC ending in BEL or * ST) — computed in one scan, no Intl.Segmenter pass. Returns undefined whenever * the string can leave that domain (tabs, controls, non-ASCII, or * unrecognized/unterminated escapes); callers then delegate to `visibleWidth`, * which makes the result provably identical to pi-tui's while skipping grapheme * segmentation for the streaming-hot line shapes. The escape scan mirrors * pi-tui's `extractAnsiCode` exactly (including tab-in-sequence handling, since * pi-tui replaces tabs before scanning but still consumes the same sequence). */ function certifiedAsciiWidth(value: string): number | undefined { let width = 0; let index = 0; const length = value.length; while (index < length) { const code = value.charCodeAt(index); if (code === 0x1b) { const next = index + 1 < length ? value.charCodeAt(index + 1) : -1; if (next === 0x5b) { // CSI: pi-tui consumes through the first m/G/K/H/J byte. let scan = index + 2; while (scan < length) { const terminator = value.charCodeAt(scan); if ( terminator === 0x6d || // m terminator === 0x47 || // G terminator === 0x4b || // K terminator === 0x48 || // H terminator === 0x4a // J ) { index = scan + 1; break; } scan++; } if (scan >= length) return undefined; // unterminated: pi-tui emits the ESC visibly continue; } if (next === 0x5d || next === 0x5f) { // OSC/APC: consumed through BEL or ST (ESC \). let scan = index + 2; let end = -1; while (scan < length) { const terminator = value.charCodeAt(scan); if (terminator === 0x07) { end = scan + 1; break; } if (terminator === 0x1b && scan + 1 < length && value.charCodeAt(scan + 1) === 0x5c) { end = scan + 2; break; } scan++; } if (end < 0) return undefined; // unterminated: delegate index = end; continue; } return undefined; // any other escape form: delegate } if (code < 0x20 || code > 0x7e) return undefined; // tab/control/non-ASCII: delegate width++; index++; } return width; } /** visibleWidth with a single-scan fast path; identical results, cheaper for streaming-hot lines. */ function certifiedVisibleWidth(value: string): number { return certifiedAsciiWidth(value) ?? visibleWidth(value); } /** * Memoized prefix width: the prefix (typically non-ASCII, e.g. "│ ") always * delegates to pi-tui's visibleWidth, and the streaming-hot unique lines evict * it from pi-tui's internal FIFO width cache, re-segmenting it every pass. * visibleWidth is pure, so a one-entry memo is exactly equivalent. */ let prefixWidthMemo: { prefix: string; width: number } | undefined; function prefixWidthOf(prefix: string): number { if (prefixWidthMemo?.prefix === prefix) return prefixWidthMemo.width; const width = certifiedVisibleWidth(prefix); prefixWidthMemo = { prefix, width }; return width; } function contentText(line: string): string { // Fast path: every OSC133 marker contains ESC and the strip loop below copies // every non-ESC char verbatim — an ESC-free line is its own content text. if (!line.includes("\x1b")) return line; // Slice-based build: one concatenation per escape span instead of per char. let output = ""; let sliceStart = 0; for (let index = 0; index < line.length; index++) { if (line.charCodeAt(index) !== 27) continue; output += line.slice(sliceStart, index); const next = line[index + 1]; if (next === "]") { index += 2; while (index < line.length && line.charCodeAt(index) !== 7) index++; } else if (next === "[") { index += 2; while (index < line.length && (line.charCodeAt(index) < 64 || line.charCodeAt(index) > 126)) index++; } sliceStart = index + 1; } output += line.slice(sliceStart); // The strip above can never leave an ESC in the output (every ESC consumes at // least itself, and both escape branches run to their terminator or end of // line), so these marker removals are a provably-untaken safety net. if (output.includes("\x1b]133;")) { return output.replaceAll(OSC133_ZONE_START, "").replaceAll(OSC133_ZONE_END, "").replaceAll(OSC133_ZONE_FINAL, ""); } return output; } /** Whether a BMP code unit is whitespace with exact `\s` regex semantics (ECMAScript WhiteSpace + LineTerminator). */ function isWhitespaceCode(code: number): boolean { // Fast path: ASCII whitespace — space, \t, \n, \v, \f, \r. if (code === 0x20 || (code >= 0x09 && code <= 0x0d)) return true; if (code < 0x80) return false; // The non-ASCII members of \s (no surrogates or astral code points are whitespace). return ( code === 0x00a0 || code === 0x1680 || (code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 || code === 0x202f || code === 0x205f || code === 0x3000 || code === 0xfeff ); } /** Whether the line carries any non-whitespace content, in one ANSI-skipping scan (no content string built, no code-point array, no per-char regex). */ function hasContent(line: string): boolean { const length = line.length; let index = 0; while (index < length) { const code = line.charCodeAt(index); if (code === 0x1b) { // Skip exactly the spans contentText strips: OSC through BEL, CSI through // its final byte; a lone ESC drops just itself. const next = index + 1 < length ? line.charCodeAt(index + 1) : -1; if (next === 0x5d) { index += 2; while (index < length && line.charCodeAt(index) !== 0x07) index++; } else if (next === 0x5b) { index += 2; while (index < length) { const inner = line.charCodeAt(index); if (inner >= 64 && inner <= 126) break; index++; } } index++; continue; } // Surrogate halves never match \s: an astral code point (or a lone surrogate) // always counts as content, equivalent to the previous per-code-point regex. if (code >= 0xd800 && code <= 0xdfff) return true; if (!isWhitespaceCode(code)) return true; index++; } return false; } function getLineAnalysis(line: string): LineAnalysis { const cached = lineAnalysisCache.get(line); if (cached) { messageDecorationTestState.lineCacheHits++; // True LRU: re-insert on hit so recency is refreshed; the eviction below then // drops the least recently used entry instead of the oldest inserted one. lineAnalysisCache.delete(line); lineAnalysisCache.set(line, cached); return cached; } messageDecorationTestState.lineCacheMisses++; const leadingMarkers = splitLeadingMarkers(line); const backgroundAnsi = leadingSgr(leadingMarkers.rest); const isBackgroundWrapped = backgroundAnsi !== "" && isBackgroundSgr(backgroundAnsi) && leadingMarkers.rest.endsWith(BG_RESET); const backgroundBody = isBackgroundWrapped ? leadingMarkers.rest.slice(backgroundAnsi.length, leadingMarkers.rest.length - BG_RESET.length) : undefined; const hasOscStart = line.startsWith(OSC133_ZONE_START); const analysis: LineAnalysis = { visibleWidth: certifiedVisibleWidth(line), hasContent: hasContent(line), oscEnvelope: hasOscStart ? extractOscEnvelope(line) : undefined, hasOscStart, leadingMarkers, isBackgroundWrapped, backgroundAnsi, backgroundBody, backgroundBodyWidth: backgroundBody === undefined ? undefined : certifiedVisibleWidth(backgroundBody), }; lineAnalysisCache.set(line, analysis); if (lineAnalysisCache.size > MAX_LINE_ANALYSIS_ENTRIES) { let cursor = lineCacheEvictionCursor; if (cursor === undefined) cursor = lineAnalysisCache.keys(); let oldest = cursor.next(); if (oldest.done) { // Every not-yet-visited entry was refreshed past the cursor; restart from the true LRU head. cursor = lineAnalysisCache.keys(); oldest = cursor.next(); } lineCacheEvictionCursor = cursor; if (!oldest.done && oldest.value !== undefined) lineAnalysisCache.delete(oldest.value); } return analysis; } function rebuildAtWidth( line: string, width: number, lead: string, leadWidth: number, analysis = getLineAnalysis(line), ): string { if ( analysis.isBackgroundWrapped && analysis.backgroundBody !== undefined && analysis.backgroundBodyWidth !== undefined ) { const pad = " ".repeat(Math.max(0, width - leadWidth - analysis.backgroundBodyWidth)); return `${analysis.leadingMarkers.head}${analysis.backgroundAnsi}${lead}${analysis.backgroundBody}${pad}${BG_RESET}`; } const pad = " ".repeat(Math.max(0, width - leadWidth - analysis.visibleWidth)); return `${lead}${line}${pad}`; } function decorateMessageLine( line: string, index: number, lastIndex: number, contentIndex: number, width: number, options: { firstEnvelope: OscParts | undefined; firstHasStart: boolean; multilineEnvelope: boolean; prefix: string; prefixWidth: number; continuationLead: string; /** Rail range (inclusive): lines of an expanded leading thinking region * that render with the role-prefix rail instead of no lead. */ railStart: number | undefined; railEnd: number | undefined; }, analysis = getLineAnalysis(line), ): string { const { firstEnvelope, firstHasStart, multilineEnvelope, prefix, prefixWidth, continuationLead, railStart, railEnd } = options; const railed = railStart !== undefined && railEnd !== undefined && index >= railStart && index <= railEnd; const lead = railed ? prefix : index === contentIndex ? prefix : index > contentIndex ? continuationLead : ""; const leadWidth = railed || index >= contentIndex ? prefixWidth : 0; if (index === contentIndex && firstEnvelope) return `${firstEnvelope.start}${rebuildAtWidth(firstEnvelope.body, width, prefix, prefixWidth)}${firstEnvelope.end}`; if (index === contentIndex && firstHasStart) return `${OSC133_ZONE_START}${rebuildAtWidth(line.slice(OSC133_ZONE_START.length), width, prefix, prefixWidth)}`; if (index === lastIndex && multilineEnvelope && index !== contentIndex) return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${rebuildAtWidth( line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length), width, lead, leadWidth, )}`; if ( index === contentIndex && index === lastIndex && multilineEnvelope && line.startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL) ) return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${rebuildAtWidth( line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length), width, prefix, prefixWidth, )}`; return rebuildAtWidth(line, width, lead, leadWidth, analysis); } function sameLines(left: readonly string[], right: readonly string[]): boolean { if (left === right) return true; if (left.length !== right.length) return false; for (let index = 0; index < left.length; index++) { if (left[index] !== right[index]) return false; } return true; } function cacheKey(width: number, prefix: string, skip: number | undefined): string { return `${width}\u0000${prefix}\u0000${skip ?? ""}`; } function getRenderCache(instance: object): Map { let cache = renderCacheByInstance.get(instance); if (!cache) { cache = new Map(); renderCacheByInstance.set(instance, cache); } return cache; } function storeRenderCache( instance: object, width: number, prefix: string, native: readonly string[], result: readonly string[], skip: number | undefined, ): void { const cache = getRenderCache(instance); const key = cacheKey(width, prefix, skip); if (cache.has(key)) cache.delete(key); cache.set(key, { nativeRef: native, nativeLines: [...native], result: [...result] }); while (cache.size > MAX_RENDER_CACHE_KEYS_PER_INSTANCE) { const oldestKey = cache.keys().next().value; if (oldestKey === undefined) break; cache.delete(oldestKey); } } /** Whether a rendered line's visible text is a thought-summary row (collapsed * label or expanded header). Such rows carry their own `▸`/`∨` (or ASCII * `>`/`v`) state glyph, so the assistant-role prefix skips them: the glyph * marks the row, `│ ` keeps marking the message's first real content line. */ function isThoughtSummaryRenderLine(line: string, glyph: string): boolean { const text = contentText(line).trim(); if (text.startsWith(`${glyph} Thought`)) return true; if (!text.startsWith(`${glyph} `)) return false; const suffix = text.slice(glyph.length + 1); const separator = suffix.indexOf(" "); if (separator <= 0) return false; const count = suffix.slice(0, separator); const noun = suffix.slice(separator + 1); return /^\d+$/.test(count) && (noun === "thought" || noun.startsWith("thought ·") || noun.startsWith("thoughts")); } /** Leading thought-region children (top spacer, label/header rows, expanded * thinking content, trailing spacer) of instances whose message STARTS with * an expanded thinking run. At render time their line count tells the prefix * decoration exactly where the first answer line begins, so thinking content * never receives the `│ ` role prefix. */ type ThoughtLeadingSkip = { children: readonly object[] }; let thoughtLeadingSkipByInstance = new WeakMap(); /** Render-time line accounting over the recorded leading children; `undefined` * when the state is absent or a child cannot be rendered (fall back to the * content scan). Children render deterministically and pi-tui caches renders * by width, so the accounting re-uses the upcoming full-render work. */ function leadingSkipLineCount(instance: object, width: number): number | undefined { const state = thoughtLeadingSkipByInstance.get(instance); if (!state || state.children.length === 0) return undefined; let count = 0; for (const child of state.children) { const render = (child as { render?: unknown }).render; if (typeof render !== "function") return undefined; const lines = (render as (width: number) => unknown).call(child, width); if (!Array.isArray(lines) || !lines.every((line) => typeof line === "string")) return undefined; count += lines.length; } return count; } function prefixNative( lines: unknown, width: number, prefix: string, thoughtGlyph: string | undefined, /** Structural first-content override: when an expanded thinking block leads * the message, the prefix must land on the answer's first line, not on the * thinking content — computed by child accounting in decorateMessageRender. */ forcedFirstContentIndex: number | undefined, ): string[] | undefined { if (!Array.isArray(lines) || lines.length === 0 || !lines.every((line) => typeof line === "string")) return undefined; messageDecorationTestState.decoratePasses++; const nativeLines = lines as string[]; const prefixWidth = prefixWidthOf(prefix); if (width <= prefixWidth) return undefined; const bodyWidth = width - prefixWidth; // One cache lookup per line per pass; every later consumer reuses this array // instead of re-requesting analysis (and re-churning LRU recency) per line. const analyses = nativeLines.map((line) => getLineAnalysis(line)); const last = nativeLines.at(-1) ?? ""; const lastAnalysis = analyses[analyses.length - 1] ?? getLineAnalysis(last); const multilineEnvelope = nativeLines.length > 1 && last.startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL); // The last line is a content-start candidate only when the envelope is // single-line, or when no earlier line carries content. Assistant messages // with a single content line render as a multiline envelope whose only body // sits on the final line ([OSC133_A, OSC133_END+FINAL+body]); excluding it // would drop the prefix for every short assistant reply. let firstContentIndex = -1; let scanForFirstContent = true; if (forcedFirstContentIndex !== undefined) { if (forcedFirstContentIndex > nativeLines.length) return nativeLines; // A leading expanded thinking region may consume the entire assistant // component (thinking + tool call, with the tool rendered separately). Keep // its structural end index so the thought rail can still be decorated even // though there is no answer line on which to land the role prefix. if (forcedFirstContentIndex === nativeLines.length) { firstContentIndex = forcedFirstContentIndex; scanForFirstContent = false; } else { const forcedAnalysis = analyses[forcedFirstContentIndex] ?? getLineAnalysis(nativeLines[forcedFirstContentIndex] ?? ""); if (forcedAnalysis.hasContent) { firstContentIndex = forcedFirstContentIndex; scanForFirstContent = false; } } } if (scanForFirstContent) for (let index = 0; index < nativeLines.length; index++) { const analysis = analyses[index] ?? getLineAnalysis(nativeLines[index] ?? ""); if (index !== nativeLines.length - 1 || !multilineEnvelope) { if (analysis.hasContent) { // Thought-summary rows render with their own state glyph and take the // continuation indent instead of the role prefix. if (thoughtGlyph && isThoughtSummaryRenderLine(nativeLines[index] ?? "", thoughtGlyph)) continue; firstContentIndex = index; break; } continue; } let earlierHasContent = false; for (let earlier = 0; earlier < index; earlier++) { const earlierAnalysis = analyses[earlier] ?? getLineAnalysis(nativeLines[earlier] ?? ""); // Thought-summary rows are content-start candidates themselves, so they do // not count as "earlier content" either — otherwise the skipped row would // suppress the prefix on the first real content line. if ( earlierAnalysis.hasContent && !(thoughtGlyph && isThoughtSummaryRenderLine(nativeLines[earlier] ?? "", thoughtGlyph)) ) { earlierHasContent = true; break; } } if ( !earlierHasContent && lastAnalysis.hasContent && !(thoughtGlyph && isThoughtSummaryRenderLine(last, thoughtGlyph)) ) { firstContentIndex = index; } break; } if (firstContentIndex < 0) return nativeLines; const firstAnalysis = analyses[0] ?? getLineAnalysis(nativeLines[0] ?? ""); const firstEnvelope = firstContentIndex === 0 ? firstAnalysis.oscEnvelope : undefined; const firstHasStart = firstContentIndex === 0 && firstAnalysis.hasOscStart; const continuationLead = " ".repeat(prefixWidth); // Rail range: when a leading expanded thinking block precedes the first // content line, its content lines (after the summary header, up to the // region's last content line — blank lines inside keep the rail for a // continuous quote bar, trailing blanks do not) render with the `│ ` rail. let railStart: number | undefined; let railEnd: number | undefined; if (forcedFirstContentIndex !== undefined && forcedFirstContentIndex > 0) { let headerIndex = -1; let lastContent = -1; for (let regionIndex = 0; regionIndex < forcedFirstContentIndex; regionIndex++) { const regionLine = nativeLines[regionIndex] ?? ""; const regionAnalysis = analyses[regionIndex] ?? getLineAnalysis(regionLine); if (!regionAnalysis.hasContent) continue; if (headerIndex < 0 && thoughtGlyph && isThoughtSummaryRenderLine(regionLine, thoughtGlyph)) { headerIndex = regionIndex; continue; } lastContent = regionIndex; } if (lastContent >= 0) { // Aggregate headers exist only on the segment leader. Later assistant // messages in the same expanded segment still render as one continuous // thought quote, beginning at their first substantive region line. railStart = headerIndex >= 0 ? headerIndex + 1 : nativeLines.findIndex((line, index) => { if (index >= forcedFirstContentIndex) return false; return (analyses[index] ?? getLineAnalysis(line)).hasContent; }); if (railStart >= 0) railEnd = lastContent; else railStart = undefined; } } const decorated = nativeLines.map((line, index) => decorateMessageLine( line, index, nativeLines.length - 1, firstContentIndex, width, { firstEnvelope, firstHasStart, multilineEnvelope, prefix, prefixWidth, continuationLead, railStart, railEnd, }, analyses[index], ), ); if (!decorated.every((line) => certifiedVisibleWidth(line) <= width)) return undefined; if (!analyses.every((analysis) => analysis.visibleWidth <= bodyWidth)) return undefined; return decorated; } export type MessageDecorationSnapshot = Readonly<{ assistantPrefix: string; assistantEnabled: boolean; /** Drop the hidden-thinking label row and its trailing spacer (zero-trace collapse). */ collapseHiddenThinking: boolean; /** Replace per-run labels with one clickable ` N thoughts ·