perf(packages/tui): optimized tui core by skipping redundant work paths

- Added animated message-color gating so loader timers run at 30fps only when needed.
- Added no-op checks in loader and text updates to skip work for unchanged values.
- Optimized key matching by canonicalizing key IDs and precomputing alias sets.
- Added prepared-line caching and bounded terminal/width scans to reduce repaint overhead.
This commit is contained in:
can1357
2026-06-07 08:42:51 +02:00
parent 87a5ae77a1
commit 9d80a7d5c6
7 changed files with 265 additions and 92 deletions
+8
View File
@@ -20,11 +20,19 @@
### Changed
- Changed static `Loader` messages to repaint only at the spinner's 80 ms cadence; time-dependent message colorizers can opt into 16 ms redraws with `animated: true`.
- Changed keybinding matching to precompute canonical key sets so each input sequence is parsed once per binding check instead of once per candidate key.
- Made `Component.invalidate()` optional so leaf components without render caches no longer need no-op invalidation hooks.
- `TERMINAL` is now a `RuntimeTerminal` whose post-construction capabilities (image protocol and the probe-driven flags) are writable, replacing the `as unknown as MutableTerminalInfo` cast pattern and the positional `withTerminalOverrides` rebuild with a prototype-preserving `clone()`.
### Fixed
- Fixed `Loader` text updates to skip identical messages and preserve the rendered `Text` cache instead of invalidating it every timer tick.
- Fixed fullscreen overlay alt-frame rendering to reuse the current line-preparation path instead of calling removed fitting helpers.
- Reduced TUI render-path line fitting by deferring overlay base-frame fitting until an overlay rebuild and by reusing already-fitted lines in emitters.
- Reduced live-region pinned repaint output by diffing unchanged viewport rows when no sealed rows are being committed to native scrollback.
- Optimized terminal image-line detection and Thai/Lao AM normalization checks to avoid hot-path regex scans and substring allocations.
- Fixed `Markdown.render()` cache hits returning the cache's mutable backing array, which let callers that append extra rows corrupt cached Markdown and duplicate those rows on every redraw.
- Fixed first-paint full replays for callers that intentionally replace terminal history by allowing `TUI.start({ clearScrollback: true })`, so they do not briefly append an entire initial frame before the first clean replay.
- Fixed ED3-risk streaming cap accounting to preserve the native scrollback high-water mark for rows that were already physically committed before transient frames were viewport-capped.
+22 -17
View File
@@ -3,21 +3,20 @@ import { sliceByColumn, visibleWidth } from "../utils";
import { Text } from "./text";
/**
* Loader component that drives display refresh at ~60fps so callers whose
* message colorizer is time-dependent (e.g. shimmer/KITT) animate smoothly.
* Loader component. Spinner frames advance at `SPINNER_ADVANCE_MS`.
*
* Two cadences are interleaved on a single timer:
* - **Render tick** (every `RENDER_INTERVAL_MS`) → asks the TUI to redraw.
* The TUI already throttles at 16ms (`MIN_RENDER_INTERVAL_MS`), so this
* is the natural upper bound; static messageColorFns produce identical
* output and the differ drops the no-op redraw at ~zero cost.
* - **Spinner advance** (every `SPINNER_ADVANCE_MS`) → bumps the spinner
* frame index. Decoupled from the render cadence so the spinner keeps
* its classic ~12.5fps step pace regardless of shimmer state.
* Message colorizers that are time-dependent can opt into 30fps redraws by
* setting `animated` to `true` on the function object.
*/
const RENDER_INTERVAL_MS = 16;
const RENDER_INTERVAL_MS = 1000 / 30;
const SPINNER_ADVANCE_MS = 80;
type ColorFn = (str: string) => string;
export type LoaderMessageColorFn = ColorFn & {
readonly animated?: true;
};
export class Loader extends Text {
#frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
#currentFrame = 0;
@@ -27,8 +26,8 @@ export class Loader extends Text {
constructor(
ui: TUI,
private spinnerColorFn: (str: string) => string,
private messageColorFn: (str: string) => string,
private spinnerColorFn: ColorFn,
private messageColorFn: LoaderMessageColorFn,
private message: string = "Loading...",
spinnerFrames?: string[],
) {
@@ -54,14 +53,17 @@ export class Loader extends Text {
start() {
this.#lastSpinnerTick = performance.now();
this.#updateDisplay();
const intervalMs = this.messageColorFn.animated === true ? RENDER_INTERVAL_MS : SPINNER_ADVANCE_MS;
this.#intervalId = setInterval(() => {
const now = performance.now();
if (now - this.#lastSpinnerTick >= SPINNER_ADVANCE_MS) {
this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length;
this.#lastSpinnerTick = now;
const elapsed = now - this.#lastSpinnerTick;
if (elapsed >= SPINNER_ADVANCE_MS) {
const steps = Math.floor(elapsed / SPINNER_ADVANCE_MS);
this.#currentFrame = (this.#currentFrame + steps) % this.#frames.length;
this.#lastSpinnerTick += steps * SPINNER_ADVANCE_MS;
}
this.#updateDisplay();
}, RENDER_INTERVAL_MS);
}, intervalMs);
}
stop() {
@@ -77,6 +79,9 @@ export class Loader extends Text {
}
setMessage(message: string) {
if (message === this.message) {
return;
}
this.message = message;
this.#updateDisplay();
}
+3
View File
@@ -27,6 +27,9 @@ export class Text implements Component {
}
setText(text: string): void {
if (text === this.#text) {
return;
}
this.#text = text;
this.#cachedText = undefined;
this.#cachedWidth = undefined;
+49 -11
View File
@@ -1,4 +1,4 @@
import { type KeyId, matchesKey, parseKey } from "./keys";
import { type KeyId, parseKey } from "./keys";
/**
* Global keybinding registry.
@@ -164,6 +164,44 @@ const SHIFTED_SYMBOL_KEYS = new Set<string>([
"~",
]);
const MODIFIER_ORDER = ["ctrl", "shift", "alt", "super"] as const;
function canonicalKeyId(key: string): string {
let remaining = key.toLowerCase();
const modifiers: string[] = [];
let foundModifier = true;
while (foundModifier) {
foundModifier = false;
for (const modifier of MODIFIER_ORDER) {
const prefix = `${modifier}+`;
if (remaining.startsWith(prefix)) {
modifiers.push(modifier);
remaining = remaining.slice(prefix.length);
foundModifier = true;
break;
}
}
}
const base = remaining === "esc" ? "escape" : remaining === "return" ? "enter" : remaining;
if (modifiers.length === 0) return base;
modifiers.sort(
(left, right) =>
MODIFIER_ORDER.indexOf(left as (typeof MODIFIER_ORDER)[number]) -
MODIFIER_ORDER.indexOf(right as (typeof MODIFIER_ORDER)[number]),
);
return `${modifiers.join("+")}+${base}`;
}
function addKeyAliases(keys: Set<string>, key: KeyId): void {
const canonical = canonicalKeyId(key);
keys.add(canonical);
if (SHIFTED_SYMBOL_KEYS.has(canonical)) {
keys.add(`shift+${canonical}`);
}
}
const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
@@ -185,6 +223,7 @@ export class KeybindingsManager {
#definitions: KeybindingDefinitions;
#userBindings: KeybindingsConfig;
#keysById = new Map<Keybinding, KeyId[]>();
#matchKeysById = new Map<Keybinding, Set<string>>();
#conflicts: KeybindingConflict[] = [];
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
@@ -195,6 +234,7 @@ export class KeybindingsManager {
#rebuild(): void {
this.#keysById.clear();
this.#matchKeysById.clear();
this.#conflicts = [];
const userClaims = new Map<KeyId, Set<Keybinding>>();
@@ -217,21 +257,19 @@ export class KeybindingsManager {
const userKeys = this.#userBindings[id];
const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys);
this.#keysById.set(id as Keybinding, keys);
const matchKeys = new Set<string>();
for (const key of keys) {
addKeyAliases(matchKeys, key);
}
this.#matchKeysById.set(id as Keybinding, matchKeys);
}
}
matches(data: string, keybinding: Keybinding): boolean {
const keys = this.#keysById.get(keybinding) ?? [];
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
// Handle shifted symbol keys (e.g., shift+- produces _ on US layout)
const parsed = parseKey(data);
if (!parsed?.startsWith("shift+")) return false;
const keyName = parsed.slice("shift+".length);
if (!SHIFTED_SYMBOL_KEYS.has(keyName)) return false;
return keys.includes(keyName as KeyId);
if (parsed === undefined) return false;
const matchKeys = this.#matchKeysById.get(keybinding);
return matchKeys?.has(canonicalKeyId(parsed)) ?? false;
}
getKeys(keybinding: Keybinding): KeyId[] {
+27 -4
View File
@@ -23,7 +23,31 @@ export enum NotifyProtocol {
export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "base" | "trueColor";
const SIXEL_DCS_START_REGEX = /\x1bP(?:[0-9;]*)q/u;
function hasNeedleBefore(line: string, needle: string, limit: number): boolean {
const index = line.indexOf(needle);
return index !== -1 && index + needle.length <= limit;
}
function hasSixelDcsStart(line: string): boolean {
const limit = Math.min(line.length, 128);
let from = 0;
for (;;) {
const start = line.indexOf("\x1bP", from);
if (start === -1 || start + 3 > limit) return false;
let i = start + 2;
while (i < limit) {
const code = line.charCodeAt(i);
if ((code >= 0x30 && code <= 0x39) || code === 0x3b) {
i++;
continue;
}
break;
}
if (i < limit && line.charCodeAt(i) === 0x71) return true;
from = start + 2;
}
}
/** Terminal capability details used for rendering and protocol selection. */
export class TerminalInfo {
constructor(
@@ -61,10 +85,9 @@ export class TerminalInfo {
isImageLine(line: string): boolean {
if (!this.imageProtocol) return false;
if (this.imageProtocol === ImageProtocol.Sixel) {
return SIXEL_DCS_START_REGEX.test(line.slice(0, 128));
return hasSixelDcsStart(line);
}
const head = line.slice(0, 64);
return head.includes(this.imageProtocol) || head.includes(KITTY_PLACEHOLDER);
return hasNeedleBefore(line, this.imageProtocol, 64) || hasNeedleBefore(line, KITTY_PLACEHOLDER, 64);
}
formatNotification(message: string | TerminalNotification): string {
+155 -58
View File
@@ -39,10 +39,10 @@ import {
const SEGMENT_RESET = "\x1b[0m";
/**
* Per-line terminator written at the end of every non-image line. Closes both
* Per-line terminator written after every non-image content row. It closes both
* SGR state and any in-flight OSC 8 hyperlink so styles/links cannot bleed
* across lines in scrollback. Applied by {@link TUI.#applyLineResets} before
* diffing so `#previousLines` mirrors what was actually written.
* across lines in scrollback. Kept out of the diff/width cache because reset
* bytes are deterministic write framing, not content.
*/
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
const ERASE_LINE = "\x1b[2K";
@@ -433,6 +433,12 @@ type RenderIntent =
| { kind: "shrink" }
| { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
interface PreparedLine {
raw: string;
width: number;
line: string;
}
/**
* TUI - Main class for managing terminal UI with differential rendering
*/
@@ -451,7 +457,7 @@ export class TUI extends Container {
#renderTimer: RenderTimer | undefined;
#renderScheduler: RenderScheduler;
#lastRenderAt = 0;
static readonly #MIN_RENDER_INTERVAL_MS = 16;
static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30;
#cursorRow = 0; // Logical cursor row (end of rendered content)
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
#viewportTopRow = 0; // Content row currently mapped to screen row 0
@@ -525,6 +531,11 @@ export class TUI extends Container {
#altEnterWidth = 0;
#altEnterHeight = 0;
// Last-frame line preparation cache. Entries store normalized, width-fitted
// content rows without the per-line terminal terminator; terminators are
// appended only at write time so width checks stay on content, not reset bytes.
#preparedLineCache: PreparedLine[] = [];
// Overlay stack for modal components rendered on top of base content
overlayStack: {
component: Component;
@@ -808,7 +819,7 @@ export class TUI extends Container {
// Repaint immediately rather than via the throttled path: a resize must
// clear and replay at the fresh geometry before the terminal's reflow
// settles into a state a throttled frame would race. Forced render skips
// the 16ms coalescing window, matching resetDisplay()'s prompt repaint.
// the 30fps coalescing window, matching resetDisplay()'s prompt repaint.
this.#resizeEventPending = true;
this.requestRender(true);
},
@@ -1528,24 +1539,9 @@ export class TUI extends Container {
return cursor;
}
/**
* Append the per-line terminator ({@link LINE_TERMINATOR}) to every
* non-image line and normalize for terminal rendering. Mutates the input
* array in place so downstream diffing/storage sees exactly the bytes
* written to the terminal — without this, the diff cache disagrees with
* emitted output and OSC 8 hyperlink state can leak across lines.
*/
#applyLineResets(lines: string[]): string[] {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (TERMINAL.isImageLine(line)) continue;
const normalized = normalizeTerminalOutput(line);
// Only close OSC 8 hyperlinks when the line actually opened one;
// emitting `\x1b]8;;\x07` on every line just feeds the terminal's OSC
// parser for no reason (measurable cost in xterm.js parse loop).
lines[i] = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
}
return lines;
#terminalLine(line: string): string {
if (TERMINAL.isImageLine(line)) return line;
return line + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
}
/**
@@ -1608,11 +1604,7 @@ export class TUI extends Container {
const overlayVisibilityReduced = this.#overlayVisibilityReduced(visibleOverlayComponents);
let lines = visibleOverlayComponents.length > 0 ? this.#compositeOverlays(baseLines, width, height) : baseLines;
const cursorPos = this.#extractCursorPosition(lines, height);
lines = this.#fitLinesToWidth(this.#applyLineResets(lines), width);
if (lines !== baseLines) {
this.#extractCursorPosition(baseLines, height);
baseLines = this.#fitLinesToWidth(this.#applyLineResets(baseLines), width);
}
lines = this.#prepareLines(lines, width, true);
// 2. Capture transition + pre-render state before any emitter runs.
const prevViewportTop = this.#viewportTopRow;
@@ -1793,6 +1785,8 @@ export class TUI extends Container {
return;
case "overlayRebuild":
this.#clearNativeScrollbackDirty();
this.#extractCursorPosition(baseLines, height);
baseLines = this.#prepareLines(baseLines, width, false);
this.#emitFullPaint(baseLines, width, height, null, {
clearViewport: true,
clearScrollback: !isMultiplexerSession(),
@@ -1814,7 +1808,7 @@ export class TUI extends Container {
return;
case "viewportRepaint":
if (intent.appendFrom !== undefined) {
this.#emitAppendTail(lines, intent.appendFrom, height, width, prevViewportTop, prevHardwareCursorRow);
this.#emitAppendTail(lines, intent.appendFrom, height, prevViewportTop, prevHardwareCursorRow);
}
this.#emitViewportRepaint(lines, width, height, cursorPos);
return;
@@ -2460,31 +2454,98 @@ export class TUI extends Container {
if (lines.length >= paddedLength) return lines;
return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
}
/**
* Truncate a line to the visible viewport width. Image lines are left
* alone, narrow lines pass through unchanged. Truncation re-appends the
* per-line terminator so SGR/OSC 8 state does not leak across rows when
* `truncateToWidth` drops the trailing bytes appended by
* {@link #applyLineResets}.
*/
#fitLinesToWidth(lines: string[], width: number): string[] {
#prepareLines(lines: string[], width: number, useCache: boolean): string[] {
const prepared: string[] = new Array(lines.length);
const previous = useCache ? this.#preparedLineCache : [];
const nextCache: PreparedLine[] | undefined = useCache ? new Array(lines.length) : undefined;
for (let i = 0; i < lines.length; i++) {
lines[i] = this.#fitLineToWidth(lines[i], width);
const raw = lines[i]!;
const cached = previous[i];
if (cached && cached.raw === raw && cached.width === width) {
prepared[i] = cached.line;
if (nextCache) nextCache[i] = cached;
continue;
}
const entry = this.#prepareLine(raw, width);
prepared[i] = entry.line;
if (nextCache) nextCache[i] = entry;
}
return lines;
if (nextCache) this.#preparedLineCache = nextCache;
return prepared;
}
#fitLineToWidth(line: string, width: number): string {
if (TERMINAL.isImageLine(line)) return line;
if (visibleWidth(line) <= width) return line;
const truncated = truncateToWidth(line, width, Ellipsis.Omit);
return truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
#prepareLine(raw: string, width: number): PreparedLine {
if (TERMINAL.isImageLine(raw)) {
return { raw, width, line: raw };
}
const normalized = normalizeTerminalOutput(raw);
const asciiWidth = this.#ansiAsciiLineWidth(normalized, width);
if ((asciiWidth ?? visibleWidth(normalized)) <= width) {
return { raw, width, line: normalized };
}
const line = truncateToWidth(normalized, width, Ellipsis.Omit);
return { raw, width, line };
}
#ansiAsciiLineWidth(line: string, maxWidth: number): number | undefined {
let col = 0;
for (let i = 0; i < line.length; ) {
const code = line.charCodeAt(i);
if (code === 0x1b) {
const next = line.charCodeAt(i + 1);
if (next === 0x5b) {
let j = i + 2;
while (j < line.length) {
const final = line.charCodeAt(j);
if (final >= 0x40 && final <= 0x7e) break;
j++;
}
if (j >= line.length) return undefined;
i = j + 1;
continue;
}
if (next === 0x5d) {
// OSC 66 text-sizing spans carry visible payload inside the OSC.
// Fall back to visibleWidth() so scaled cells stay exact.
if (
line.charCodeAt(i + 2) === 0x36 &&
line.charCodeAt(i + 3) === 0x36 &&
line.charCodeAt(i + 4) === 0x3b
) {
return undefined;
}
let j = i + 2;
while (j < line.length) {
const osc = line.charCodeAt(j);
if (osc === 0x07) {
i = j + 1;
break;
}
if (osc === 0x1b && line.charCodeAt(j + 1) === 0x5c) {
i = j + 2;
break;
}
j++;
}
if (j >= line.length) return undefined;
continue;
}
return undefined;
}
if (code < 0x20 || code > 0x7e) return undefined;
col++;
if (col > maxWidth) return col;
i++;
}
return col;
}
#lineRewriteSequence(line: string, width: number): string {
const fitted = this.#fitLineToWidth(line, width);
if (TERMINAL.isImageLine(fitted)) return ERASE_LINE + fitted;
return visibleWidth(fitted) >= width ? fitted : fitted + ERASE_TO_END_OF_LINE;
if (TERMINAL.isImageLine(line)) return ERASE_LINE + line;
const terminalLine = this.#terminalLine(line);
const asciiWidth = this.#ansiAsciiLineWidth(line, width);
const lineWidth = asciiWidth ?? visibleWidth(line);
return lineWidth >= width ? terminalLine : terminalLine + ERASE_TO_END_OF_LINE;
}
/**
@@ -2550,7 +2611,7 @@ export class TUI extends Container {
if (this.#deccaraFillsEnabled() && visibleStart < lines.length) {
const visible: string[] = new Array(lines.length - visibleStart);
for (let k = 0; k < visible.length; k++) {
visible[k] = this.#fitLineToWidth(lines[visibleStart + k], width);
visible[k] = lines[visibleStart + k] ?? "";
}
const plan = planDeccaraFills(visible, width);
visibleTexts = plan.texts;
@@ -2558,8 +2619,9 @@ export class TUI extends Container {
}
for (let i = 0; i < lines.length; i++) {
if (i > 0) buffer += "\r\n";
buffer +=
visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : this.#fitLineToWidth(lines[i], width);
buffer += this.#terminalLine(
visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (lines[i] ?? ""),
);
}
buffer += fillSequence;
const finalRow = Math.max(0, lines.length - 1);
@@ -2608,12 +2670,12 @@ export class TUI extends Container {
let wroteLine = false;
for (let i = 0; i < appendTo; i++) {
if (wroteLine) buffer += "\r\n";
buffer += this.#fitLineToWidth(lines[i] ?? "", width);
buffer += this.#terminalLine(lines[i] ?? "");
wroteLine = true;
}
for (let screenRow = 0; screenRow < height; screenRow++) {
if (wroteLine) buffer += "\r\n";
buffer += this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width);
buffer += this.#terminalLine(lines[viewportTop + screenRow] ?? "");
wroteLine = true;
}
@@ -2664,7 +2726,7 @@ export class TUI extends Container {
// Each visible screen row, bottom-anchored, blank past content.
const visible: string[] = new Array(height);
for (let screenRow = 0; screenRow < height; screenRow++) {
visible[screenRow] = this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width);
visible[screenRow] = lines[viewportTop + screenRow] ?? "";
}
const { texts, sequence } = this.#deccaraFillsEnabled()
? planDeccaraFills(visible, width)
@@ -2723,7 +2785,7 @@ export class TUI extends Container {
const base: string[] = new Array(Math.max(0, height)).fill("");
let lines = this.#compositeOverlays(base, width, height);
this.#extractCursorPosition(lines, height);
lines = this.#fitLinesToWidth(this.#applyLineResets(lines), width);
lines = this.#prepareLines(lines, width, false);
this.#emitAltFrame(lines, width, height);
}
@@ -2735,7 +2797,7 @@ export class TUI extends Container {
*/
#emitAltFrame(lines: string[], width: number, height: number): void {
const fitted: string[] = new Array(height);
for (let r = 0; r < height; r++) fitted[r] = this.#fitLineToWidth(lines[r] ?? "", width);
for (let r = 0; r < height; r++) fitted[r] = lines[r] ?? "";
// Skip an identical repaint (the modal is mostly static between keystrokes).
if (this.#altPreviousLines.length === height) {
let same = true;
@@ -2787,6 +2849,42 @@ export class TUI extends Container {
const boundedAppendTo = Math.max(0, Math.min(appendTo, naturalViewportTop, lines.length));
const boundedAppendFrom = Math.max(0, Math.min(appendFrom, boundedAppendTo));
if (boundedAppendFrom === boundedAppendTo) {
let firstChangedScreenRow = -1;
let lastChangedScreenRow = -1;
for (let screenRow = 0; screenRow < height; screenRow++) {
const nextLine = lines[viewportTop + screenRow] ?? "";
const previousLine = this.#previousLines[prevViewportTop + screenRow] ?? "";
if (nextLine === previousLine) continue;
if (firstChangedScreenRow === -1) firstChangedScreenRow = screenRow;
lastChangedScreenRow = screenRow;
}
let buffer = this.#paintBeginSequence;
let cursorFromRow = prevHardwareCursorRow;
if (firstChangedScreenRow !== -1) {
const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
const rowDelta = firstChangedScreenRow - currentScreenRow;
if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
buffer += "\r";
for (let screenRow = firstChangedScreenRow; screenRow <= lastChangedScreenRow; screenRow++) {
if (screenRow > firstChangedScreenRow) buffer += "\r\n";
buffer += this.#lineRewriteSequence(lines[viewportTop + screenRow] ?? "", width);
}
cursorFromRow = viewportTop + lastChangedScreenRow;
}
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, cursorFromRow);
buffer += seq;
buffer += this.#paintEndSequence;
this.terminal.write(buffer);
this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
this.#commit(lines, width, height, viewportTop, toRow);
return;
}
// Position at the top visible row with a relative move. Terminals clamp the
// hardware cursor to the viewport on resize, so clamp our tracking to match
// before computing the delta (mirrors #emitDiff).
@@ -2839,7 +2937,6 @@ export class TUI extends Container {
lines: string[],
start: number,
height: number,
width: number,
prevViewportTop: number,
prevHardwareCursorRow: number,
): void {
@@ -2854,7 +2951,7 @@ export class TUI extends Container {
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
for (let i = start; i < lines.length; i++) {
buffer += "\r\n";
buffer += this.#fitLineToWidth(lines[i], width);
buffer += this.#terminalLine(lines[i] ?? "");
}
buffer += this.#paintEndSequence;
this.terminal.write(buffer);
@@ -3039,7 +3136,7 @@ export class TUI extends Container {
) {
const slice: string[] = new Array(renderEnd - fillStart + 1);
for (let i = fillStart; i <= renderEnd; i++) {
slice[i - fillStart] = this.#fitLineToWidth(lines[i], width);
slice[i - fillStart] = lines[i] ?? "";
}
const plan = planDeccaraFills(slice, width, fillStart - fillViewportTop);
fillTexts = plan.texts;
+1 -2
View File
@@ -251,7 +251,6 @@ export function visibleWidth(str: string): number {
return width;
}
const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
/**
@@ -261,7 +260,7 @@ const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
* width but avoid stale-cell artifacts in terminal renderers.
*/
export function normalizeTerminalOutput(str: string): string {
if (!THAI_LAO_AM_REGEX.test(str)) return str;
if (str.indexOf("\u0e33") === -1 && str.indexOf("\u0eb3") === -1) return str;
return str.replace(THAI_LAO_AM_GLOBAL_REGEX, char => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2"));
}