mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
28fd590868
commit
b485fa3128
11 changed files with 221 additions and 112 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) : "?";
|
||||
|
|
|
|||
|
|
@ -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, "");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
26
packages/tui/test/visible-width.test.ts
Normal file
26
packages/tui/test/visible-width.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue