fix(tui,coding-agent): reduce render cost of theme changes and streaming

Theme previews re-render the whole transcript, and streaming re-renders
every frame, so per-line and per-session work dominated in long sessions.

- visibleWidth: fast path for ANSI-styled ASCII, allocation-free escape scanning
- Box: compare unpadded child lines and skip a second width measurement
- Markdown: reuse parsed tokens across invalidation
- footer: cache usage totals and context usage per session state
- bash results: cache the collapsed preview including the expand hint
- sanitizeBinaryOutput: single regex replace
This commit is contained in:
Armin Ronacher 2026-09-29 08:23:22 +02:00
parent 28fd590868
commit b485fa3128
11 changed files with 221 additions and 112 deletions

View file

@ -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

View file

@ -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

View file

@ -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;
},
});
}

View file

@ -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) : "?";

View file

@ -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, "");
}
/**

View file

@ -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: "",

View file

@ -8,6 +8,7 @@
- Fixed path and `@` autocomplete not working after opening wrappers such as `(`, `[`, `{`, `<`, or a backtick, e.g. `(~/Dev<Tab>`.
- 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

View file

@ -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;
}
}

View file

@ -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[] = [];

View file

@ -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<string> {
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 = "";

View file

@ -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);
});
});