diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 572b4e79f..dfcf52f63 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -28,6 +28,7 @@ - Fixed the Fireworks default model pointing at the removed Kimi K2.6 model; it now defaults to Kimi K3. - Fixed the OpenCode Go default model pointing at the removed Kimi K2.6 model; it now defaults to Kimi K3. - Fixed the Together default model pointing at the removed Kimi K2.6 model; it now defaults to Kimi K3. +- Reduced CPU use while streaming in long sessions and when previewing themes: the footer caches session usage totals, collapsed bash results cache their preview, and `sanitizeBinaryOutput()` no longer splits output into per-character arrays. ## [0.87.1] - 2026-09-22 diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 6ef060fa9..df5281a0a 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1317,10 +1317,10 @@ export class SessionManager { /** Get the current session name from the latest session_info entry, if any. */ getSessionName(): string | undefined { // Walk entries in reverse to find the latest session_info entry. - // Empty names explicitly clear the session title. - const entries = this.getEntries(); - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i]; + // Empty names explicitly clear the session title. Reads fileEntries directly: the footer + // calls this on every frame, and getEntries() copies the whole session. + for (let i = this.fileEntries.length - 1; i >= 0; i--) { + const entry = this.fileEntries[i]; if (entry.type === "session_info") { return entry.name?.trim() || undefined; } @@ -1507,6 +1507,11 @@ export class SessionManager { return h ? (h as SessionHeader) : null; } + /** Number of session entries (excludes header), without copying them like `getEntries()`. */ + getEntryCount(): number { + return this.byId.size; + } + /** * Get all session entries (excludes header). Returns a shallow copy. * The session is append-only: use appendXXX() to add entries, branch() to diff --git a/packages/coding-agent/src/core/tools/renderers/bash.ts b/packages/coding-agent/src/core/tools/renderers/bash.ts index 853db078e..b92ea291d 100644 --- a/packages/coding-agent/src/core/tools/renderers/bash.ts +++ b/packages/coding-agent/src/core/tools/renderers/bash.ts @@ -20,13 +20,11 @@ export const BASH_UPDATE_THROTTLE_MS = 100; type BashResultRenderState = { cachedWidth: number | undefined; cachedLines: string[] | undefined; - cachedSkipped: number | undefined; }; class BashResultRenderComponent extends Container { state: BashResultRenderState = { cachedWidth: undefined, cachedLines: undefined, - cachedSkipped: undefined, }; } function formatDuration(ms: number): string { @@ -82,24 +80,24 @@ function rebuildBashResultRenderComponent( } else { component.addChild({ render: (width: number) => { + // Cache the complete output: this renders on every frame for every bash result in the transcript. if (state.cachedLines === undefined || state.cachedWidth !== width) { const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width); - state.cachedLines = preview.visualLines; - state.cachedSkipped = preview.skippedCount; + const hintLines: string[] = []; + if (preview.skippedCount > 0) { + const hint = + theme.fg("muted", `... (${preview.skippedCount} earlier lines,`) + + ` ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + hintLines.push(truncateToWidth(hint, width, "...")); + } + state.cachedLines = ["", ...hintLines, ...preview.visualLines]; state.cachedWidth = width; } - if (state.cachedSkipped && state.cachedSkipped > 0) { - const hint = - theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) + - ` ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; - return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])]; - } - return ["", ...(state.cachedLines ?? [])]; + return state.cachedLines; }, invalidate: () => { state.cachedWidth = undefined; state.cachedLines = undefined; - state.cachedSkipped = undefined; }, }); } diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 51356a0a5..8bb7b353e 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -2,8 +2,9 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { AgentSession } from "../../../core/agent-session.ts"; import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts"; +import type { ContextUsage } from "../../../core/extensions/types.ts"; import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; -import { addUsageToTotals, createUsageTotals } from "../../../core/usage-totals.ts"; +import { addUsageToTotals, createUsageTotals, type UsageTotals } from "../../../core/usage-totals.ts"; import { theme } from "../theme/theme.ts"; /** @@ -43,6 +44,17 @@ export function formatCwdForFooter(cwd: string, home: string | undefined): strin return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`; } +interface SessionStats { + session: AgentSession; + sessionId: string; + leafId: string | null; + entryCount: number; + limitsModel: unknown; + usageTotals: UsageTotals; + latestCacheHitRate: number | undefined; + contextUsage: ContextUsage | undefined; +} + /** * Footer component that shows pwd, token stats, and context usage. * Computes token/context stats from session, gets git branch and extension statuses from provider. @@ -51,6 +63,7 @@ export class FooterComponent implements Component { private autoCompactEnabled = true; private session: AgentSession; private footerData: ReadonlyFooterDataProvider; + private sessionStats?: SessionStats; constructor(session: AgentSession, footerData: ReadonlyFooterDataProvider) { this.session = session; @@ -81,14 +94,34 @@ export class FooterComponent implements Component { // Git watcher cleanup handled by provider } - render(width: number): string[] { - const state = this.session.state; + /** + * Usage totals and context usage scan the whole session, and the footer renders on every frame. + * Entries are append-only and every append moves the leaf, so the results only change with the + * session, leaf, entry count, or the model whose context window applies. + */ + private getSessionStats(): SessionStats { + const sessionManager = this.session.sessionManager; + const entryCount = sessionManager.getEntryCount(); + const sessionId = sessionManager.getSessionId(); + const leafId = sessionManager.getLeafId(); + const limitsModel = this.session.routedModel?.model ?? this.session.model; + const cached = this.sessionStats; + if ( + cached && + cached.session === this.session && + cached.sessionId === sessionId && + cached.leafId === leafId && + cached.entryCount === entryCount && + cached.limitsModel === limitsModel + ) { + return cached; + } // Calculate cumulative usage from ALL session entries (not just post-compaction messages) const usageTotals = createUsageTotals(); let latestCacheHitRate: number | undefined; - for (const entry of this.session.sessionManager.getEntries()) { + for (const entry of sessionManager.getEntries()) { if (entry.type === "usage") { addUsageToTotals(usageTotals, entry.usage); } else if (entry.type === "message" && entry.message.role === "assistant") { @@ -108,6 +141,22 @@ export class FooterComponent implements Component { // Calculate context usage from session (handles compaction correctly). // After compaction, tokens are unknown until the next LLM response. const contextUsage = this.session.getContextUsage(); + this.sessionStats = { + session: this.session, + sessionId, + leafId, + entryCount, + limitsModel, + usageTotals, + latestCacheHitRate, + contextUsage, + }; + return this.sessionStats; + } + + render(width: number): string[] { + const state = this.session.state; + const { usageTotals, latestCacheHitRate, contextUsage } = this.getSessionStats(); const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0; const contextPercentValue = contextUsage?.percent ?? 0; const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?"; diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index c352a3482..14436cff2 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -153,40 +153,11 @@ export function getShellEnv(): NodeJS.ProcessEnv { * Sanitize binary output for display/storage. * Removes characters that crash string-width or cause display issues: * - Control characters (except tab, newline, carriage return) - * - Lone surrogates - * - Unicode Format characters (crash string-width due to a bug) - * - Characters with undefined code points + * - Unicode interlinear annotation characters U+FFF9..U+FFFB (crash string-width due to a bug) */ export function sanitizeBinaryOutput(str: string): string { - // Use Array.from to properly iterate over code points (not code units) - // This handles surrogate pairs correctly and catches edge cases where - // codePointAt() might return undefined - return Array.from(str) - .filter((char) => { - // Filter out characters that cause string-width to crash - // This includes: - // - Unicode format characters - // - Lone surrogates (already filtered by Array.from) - // - Control chars except \t \n \r - // - Characters with undefined code points - - const code = char.codePointAt(0); - - // Skip if code point is undefined (edge case with invalid strings) - if (code === undefined) return false; - - // Allow tab, newline, carriage return - if (code === 0x09 || code === 0x0a || code === 0x0d) return true; - - // Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d) - if (code <= 0x1f) return false; - - // Filter out Unicode format characters - if (code >= 0xfff9 && code <= 0xfffb) return false; - - return true; - }) - .join(""); + // All removed characters are single UTF-16 code units, so surrogate pairs are never split. + return str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g, ""); } /** diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index 299268bf4..12c074d01 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -76,6 +76,9 @@ function createSession(options: { }, sessionManager: { getEntries: () => entries, + getEntryCount: () => entries.length, + getSessionId: () => "test-session", + getLeafId: () => null, getSessionName: () => options.sessionName, getCwd: () => "/tmp/project", }, @@ -207,6 +210,16 @@ describe("FooterComponent width handling", () => { expect(statsLine).toContain("$1.250"); }); + it("updates cached usage totals after an entry is appended", () => { + const usage = { input: 10, output: 1, cacheRead: 0, cacheWrite: 0, cost: { total: 0.5 } }; + const session = createSession({ sessionName: "", usage }); + const footer = new FooterComponent(session, createFooterData(1)); + expect(stripAnsi(footer.render(120)[1])).toContain("$0.500"); + + session.sessionManager.getEntries().push({ type: "message", message: { role: "assistant", usage } } as never); + expect(stripAnsi(footer.render(120)[1])).toContain("$1.000"); + }); + it("shows the latest cache hit rate when cache usage is present", () => { const session = createSession({ sessionName: "", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4dedeb75e..80b0b70b1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -8,6 +8,7 @@ - Fixed path and `@` autocomplete not working after opening wrappers such as `(`, `[`, `{`, `<`, or a backtick, e.g. `(~/Dev`. - Reduced image stretching in terminals that use the Kitty graphics protocol ([#8938](https://github.com/earendil-works/pi/issues/8938)). - Fixed the shell cursor staying hidden after exit when an extension closed an overlay during shutdown ([#10026](https://github.com/earendil-works/pi/issues/10026)). +- Improved rendering performance for styled text: `visibleWidth()` measures ANSI-styled ASCII without grapheme segmentation, `Box` checks its render cache without re-padding every line, and `Markdown` reuses parsed tokens across theme and width changes. ## [0.87.1] - 2026-09-22 diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index c66f70f67..7c880d914 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -1,5 +1,5 @@ import { type Component, dispatchMouseEvent, type TuiMouseDispatchResult, type TuiMouseEvent } from "../tui.ts"; -import { applyBackgroundToLine, visibleWidth } from "../utils.ts"; +import { visibleWidth } from "../utils.ts"; type RenderCache = { childLines: string[]; @@ -106,14 +106,16 @@ export class Box implements Component { const contentWidth = Math.max(1, width - this.paddingX * 2); const leftPad = " ".repeat(this.paddingX); - // Render all children + // Render all children. Keep the child lines unpadded: children usually return the same string + // objects every frame, so the cache check below is a cheap identity comparison per line. + // Padding here would create new strings that must be compared character by character. const childLines: string[] = []; const mouseChildren: Array<{ component: Component; height: number }> = []; for (const child of this.children) { const lines = child.render(contentWidth); mouseChildren.push({ component: child, height: lines.length }); for (const line of lines) { - childLines.push(leftPad + line); + childLines.push(line); } } this.mouseLayout = { width: contentWidth, children: mouseChildren }; @@ -140,7 +142,7 @@ export class Box implements Component { // Content for (const line of childLines) { - result.push(this.applyBg(line, width)); + result.push(this.applyBg(leftPad + line, width)); } // Bottom padding @@ -158,10 +160,7 @@ export class Box implements Component { const visLen = visibleWidth(line); const padNeeded = Math.max(0, width - visLen); const padded = line + " ".repeat(padNeeded); - - if (this.bgFn) { - return applyBackgroundToLine(padded, width, this.bgFn); - } - return padded; + // Already padded to width, so apply the background directly instead of measuring the line again. + return this.bgFn ? this.bgFn(padded) : padded; } } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index f66666ea9..30c40c171 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -246,6 +246,8 @@ export class Markdown implements Component { private cachedText?: string; private cachedWidth?: number; private cachedLines?: string[]; + // Parsed tokens depend only on the source, so they survive theme and width invalidation. + private cachedTokens?: { source: string; tokens: Token[] }; constructor( text: string, @@ -298,8 +300,12 @@ export class Markdown implements Component { const normalizedText = text.replace(/\t/g, " "); // Parse markdown to HTML-like tokens - const tokens = markdownParser.lexer(normalizedText); - trimPartialClosingFences(tokens); + let tokens = this.cachedTokens?.source === normalizedText ? this.cachedTokens.tokens : undefined; + if (!tokens) { + tokens = markdownParser.lexer(normalizedText); + trimPartialClosingFences(tokens); + this.cachedTokens = { source: normalizedText, tokens }; + } // Convert tokens to styled terminal output const renderedLines: string[] = []; diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index a7e381295..f537ad7e3 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -180,8 +180,10 @@ function finalizeTruncatedResult( * check to avoid running the RGI_Emoji regex unnecessarily. */ function graphemeWidth(segment: string): number { - if (segment === "\t") { - return 3; + if (segment.length === 1) { + const code = segment.charCodeAt(0); + if (code >= 0x20 && code <= 0x7e) return 1; + if (code === 0x09) return 3; } // Some marks occupy cells even without a base character. @@ -250,9 +252,11 @@ export function visibleWidth(str: string): number { return 0; } - // Fast path: pure ASCII printable - if (isPrintableAscii(str)) { - return str.length; + // Fast path: printable ASCII, tabs, and ANSI escape sequences. Styled lines take this path, so + // re-rendering after a theme change does not run grapheme segmentation on every line. + const asciiWidth = asciiVisibleWidth(str); + if (asciiWidth !== -1) { + return asciiWidth; } // Check cache @@ -266,22 +270,25 @@ export function visibleWidth(str: string): number { if (str.includes("\t")) { clean = clean.replace(/\t/g, " "); } - if (clean.includes("\x1b")) { + let escapeIndex = clean.indexOf("\x1b"); + if (escapeIndex !== -1) { // Strip supported ANSI/OSC/APC escape sequences in one pass. // This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers, // and APC sequences like CURSOR_MARKER. let stripped = ""; - let i = 0; - while (i < clean.length) { - const ansi = extractAnsiCode(clean, i); - if (ansi) { - i += ansi.length; - continue; + let copyFrom = 0; + while (escapeIndex !== -1) { + const length = ansiCodeLength(clean, escapeIndex); + if (length > 0) { + stripped += clean.slice(copyFrom, escapeIndex); + escapeIndex += length; + copyFrom = escapeIndex; + } else { + escapeIndex++; } - stripped += clean[i]; - i++; + escapeIndex = clean.indexOf("\x1b", escapeIndex); } - clean = stripped; + clean = stripped + clean.slice(copyFrom); } // Calculate width @@ -412,43 +419,66 @@ export function normalizeTerminalOutput(str: string): string { * Extract ANSI escape sequences from a string at the given position. */ export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null { - if (pos >= str.length || str[pos] !== "\x1b") return null; + const length = ansiCodeLength(str, pos); + return length > 0 ? { code: str.substring(pos, pos + length), length } : null; +} + +/** + * Width of a string made of printable ASCII, tabs, and ANSI escape sequences, or -1 if it contains + * anything else. Matches `visibleWidth` for those strings without allocating. + */ +function asciiVisibleWidth(str: string): number { + let width = 0; + let i = 0; + while (i < str.length) { + const code = str.charCodeAt(i); + if (code >= 0x20 && code <= 0x7e) { + width++; + i++; + } else if (code === 0x09) { + width += 3; + i++; + } else if (code === 0x1b) { + const length = ansiCodeLength(str, i); + if (length === 0) return -1; + i += length; + } else { + return -1; + } + } + return width; +} + +/** Length of the ANSI/OSC/APC escape sequence starting at `pos`, or 0 if there is none. */ +function ansiCodeLength(str: string, pos: number): number { + if (pos >= str.length || str.charCodeAt(pos) !== 0x1b) return 0; const next = str[pos + 1]; // CSI sequence: ESC [ ... m/G/K/H/J if (next === "[") { - let j = pos + 2; - while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++; - if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos }; - return null; + for (let j = pos + 2; j < str.length; j++) { + const c = str.charCodeAt(j); + // m, G, K, H, J + if (c === 0x6d || c === 0x47 || c === 0x4b || c === 0x48 || c === 0x4a) return j + 1 - pos; + } + return 0; } // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \) // Used for hyperlinks (OSC 8), window titles, etc. - if (next === "]") { - let j = pos + 2; - while (j < str.length) { - if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos }; - if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos }; - j++; - } - return null; - } - // APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \) // Used for cursor marker and application-specific commands - if (next === "_") { - let j = pos + 2; - while (j < str.length) { - if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos }; - if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos }; - j++; + if (next === "]" || next === "_") { + for (let j = pos + 2; j < str.length; j++) { + const c = str.charCodeAt(j); + if (c === 0x07) return j + 1 - pos; + if (c === 0x1b && str[j + 1] === "\\") return j + 2 - pos; } - return null; + return 0; } - return null; + return 0; } type Osc8Terminator = "\x07" | "\x1b\\"; @@ -739,15 +769,16 @@ class AnsiCodeTracker { } function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void { - let i = 0; - while (i < text.length) { - const ansiResult = extractAnsiCode(text, i); - if (ansiResult) { - tracker.process(ansiResult.code); - i += ansiResult.length; + let i = text.indexOf("\x1b"); + while (i !== -1) { + const length = ansiCodeLength(text, i); + if (length > 0) { + tracker.process(text.substring(i, i + length)); + i += length; } else { i++; } + i = text.indexOf("\x1b", i); } } @@ -758,6 +789,10 @@ export function getActiveBackgroundAnsi(text: string): string { return tracker.getActiveBackgroundCode(); } +function* graphemeSegments(text: string): Generator { + for (const { segment } of graphemeSegmenter.segment(text)) yield segment; +} + /** * Split text into words while keeping ANSI codes attached. */ @@ -786,14 +821,19 @@ function splitIntoTokensWithAnsi(text: string): string[] { continue; } - let end = i; - while (end < text.length && !extractAnsiCode(text, end)) { - end++; + // Visible text runs up to the next escape sequence. + let end = text.indexOf("\x1b", i + 1); + while (end !== -1 && ansiCodeLength(text, end) === 0) { + end = text.indexOf("\x1b", end + 1); } + if (end === -1) end = text.length; - for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const chunk = text.slice(i, end); + // Printable ASCII characters are single graphemes, so skip the segmenter for them. + const ascii = isPrintableAscii(chunk); + for (const segment of ascii ? chunk : graphemeSegments(chunk)) { const segmentIsSpace = segment === " "; - if (!segmentIsSpace && cjkBreakRegex.test(segment)) { + if (!ascii && !segmentIsSpace && cjkBreakRegex.test(segment)) { flushCurrent(); const token = pendingAnsi + segment; pendingAnsi = ""; diff --git a/packages/tui/test/visible-width.test.ts b/packages/tui/test/visible-width.test.ts new file mode 100644 index 000000000..09d305e79 --- /dev/null +++ b/packages/tui/test/visible-width.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { visibleWidth } from "../src/utils.ts"; + +describe("visibleWidth", () => { + it("measures styled ASCII without counting escape sequences", () => { + assert.strictEqual(visibleWidth("\x1b[38;5;4mhello\x1b[39m world"), 11); + assert.strictEqual(visibleWidth("\x1b]8;;https://example.com\x07link\x1b]8;;\x07"), 4); + assert.strictEqual(visibleWidth("\x1b]133;A\x1b\\prompt"), 6); + assert.strictEqual(visibleWidth("\x1b_pi:c\x07cursor"), 6); + }); + + it("counts tabs as three columns in styled text", () => { + assert.strictEqual(visibleWidth("\x1b[1ma\tb\x1b[22m"), 5); + }); + + it("measures styled non-ASCII text", () => { + assert.strictEqual(visibleWidth("\x1b[31m日本\x1b[39m ok"), 7); + assert.strictEqual(visibleWidth("\x1b[31m─→\x1b[39m"), 2); + }); + + it("treats unterminated escape sequences as zero-width control characters", () => { + assert.strictEqual(visibleWidth("\x1b[31"), 3); + assert.strictEqual(visibleWidth("a\x1b"), 1); + }); +});