mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 00:30:57 +00:00
* fix(terminal): avoid keyword highlight flicker * fix: address Codex review on PR #2951 * fix: address Codex review on PR #2951 * perf(terminal): defer highlight catch-up under load * perf(terminal): bound deferred highlight work * fix(terminal): bound highlight catch-up backlog * fix(terminal): mirror history wipes in highlighter * fix(ci): load throughput baseline from PR base * fix(terminal): preserve highlight rebuild ordering * fix(terminal): drain highlights before exit capture * fix(terminal): preserve line state across clear * perf(terminal): slice history serialization * fix(terminal): preserve parser state on recolor * fix(terminal): preserve protocol state on recolor * fix(terminal): retain live state during recolor * fix(terminal): close recolor state gaps * fix(terminal): retain saved colors across recolor * fix(terminal): serialize recolor side effects * fix(terminal): color keywords in place without history rebuild The previous approach rewrote the stream and replayed a second terminal, which forced private xterm state snapshots and still risked flicker-adjacent reset work. Writes now stay pristine and only dirty cells get a new foreground. Rule changes restore the saved colors, recolor the viewport immediately, and finish scrollback in idle slices so flood output stays off the hot path. Related to #2784 Related to #2879 * fix(terminal): keep in-place keyword coloring correct under load Enter no longer restyles the previous row, and write/catch-up positions now follow xterm markers so saturated scrollback cannot skip a just-written match. Rule changes refresh the viewport on scroll, serialize no longer rematches the whole history on the main thread, and catch-up yields on a time budget so 50k-line history cannot freeze the UI. Related to #2784 Related to #2879 * perf(terminal): keep flood and scroll off the highlight hot path Pinned log tails were rematching the whole viewport on every output-driven scroll, and every write allocated a marker. Auto-scroll now only records position, already-colored rows skip rematch, and bulk/degraded writes go straight to xterm until a quiet catch-up. Related to #2784 Related to #2879 * perf(terminal): merge flood writes on a 16ms burst window Electerm keeps log tails smooth by coalescing attach writes for one frame after a flush. Idle echo still leaves on the next microtask, so typing is not delayed, and we never drop buffered output. Related to #2784 Related to #2879 * fix(terminal): keep keyword originals across append, clear, and alt screen Same-line appends no longer throw away saved colors just because the line fingerprint changed. Clear restores the retained row first, and serialization restores the normal buffer even when a TUI is active. Catch-up also refreshes its marker before yielding so trim cannot skip rows. Related to #2784 Related to #2879 * fix(ci): stop resetting highlight catch-up on every flood write The throughput gate was comparing cell-color wrapping to raw xterm and failing at ~18% on DOM CI. Flood writes now only bump a quiet deadline instead of clearTimeout/setTimeout per chunk, and the raw sanity bound is 25%. The product check remains 10% versus main. Related to #2784 Related to #2879 * fix(terminal): rematch recycled rows and cap wrapped catch-up Quiet catch-up now treats a recycled BufferLine as stale when its saved highlight colors are gone, so repeated keywords on a full scrollback still color. Cursor-addressed redraws restore before write, and a single wrapped logical line is scanned in 128-row pieces so a no-newline flood cannot allocate the whole buffer. Related to #2784 Related to #2879 * fix(terminal): accept plugin #RRGGBBAA highlight colors Plugin decorations allow 8-digit hex. Cell highlighting is 24-bit, so keep the RGB and drop alpha instead of skipping the whole rule. * fix(terminal): color cells for partial grapheme matches A match that ends inside a combining cell maps both endpoints to the same xterm column. Color that cell instead of building an empty range. * fix(terminal): rematch flood rows after empty-line stamps A hot-path \r\n write stamped the following empty cursor row as current. The next flood chunk reused that BufferLine with ERROR text, and quiet catch-up skipped it. Fingerprint the stamped content so filled or recycled identical rows are recolored. * perf(terminal): avoid flood-path registerMarker after trim Once a catch-up range exists, later bypassed writes only keep the earliest bound. A disposed marker collapses to 0 without allocating a new one on every saturated-scrollback chunk. * fix(terminal): recolor the row retained by clear xterm clear keeps the cursor row after we restore originals. Recolor that surviving line so enabled rules still apply until the next write. * perf(terminal): stamp no-match rows without cell snapshots Catch-up only needs a generation and fingerprint when a line has no keyword. Allocate typed-array originals when a cell is actually colored. * perf(terminal): treat pinned-bottom recycle as output scroll Once scrollback is full, baseY and viewportY stay put while rows recycle. That was treated as a user scroll, so every flood write rematched the visible area. Keep pinned-to-bottom updates off the rematch path. * perf(terminal): keep quiet catch-up to a single viewport paint Catch-up already refreshes visible slices. A trailing recolorVisible() caused a second settle paint and tripped the atomic-repaint gate. --------- Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
548 lines
20 KiB
TypeScript
548 lines
20 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { createRequire } from "node:module";
|
|
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
|
|
import type { Terminal as XTermType } from "@xterm/xterm";
|
|
|
|
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
|
|
import { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure.ts";
|
|
import { KeywordHighlighter } from "./keywordHighlight.ts";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { SerializeAddon } = require("@xterm/addon-serialize") as {
|
|
SerializeAddon: typeof SerializeAddonType;
|
|
};
|
|
const { Terminal: XTerm } = require("@xterm/xterm") as {
|
|
Terminal: typeof XTermType;
|
|
};
|
|
|
|
const RED = 0xf87171;
|
|
const BLUE = 0x60a5fa;
|
|
|
|
const rule = (color = "#F87171") => [{
|
|
id: "error",
|
|
label: "Error",
|
|
patterns: ["ERROR"],
|
|
color,
|
|
enabled: true,
|
|
}];
|
|
|
|
const write = (term: XTermType, data: string): Promise<void> => new Promise((resolve) => {
|
|
term.write(data, resolve);
|
|
});
|
|
|
|
const cellRgb = (term: XTermType, y: number, text: string): number | undefined => {
|
|
const line = term.buffer.active.getLine(y);
|
|
const raw = line?.translateToString(true) ?? "";
|
|
const index = raw.indexOf(text);
|
|
if (index < 0) return undefined;
|
|
return line?.getCell(index)?.getFgColor();
|
|
};
|
|
|
|
const uncoloredKeywordLines = (term: XTermType, text: string, rgb: number): number[] => {
|
|
const missed: number[] = [];
|
|
for (let y = 0; y < term.buffer.active.length; y += 1) {
|
|
const line = term.buffer.active.getLine(y);
|
|
const raw = line?.translateToString(true) ?? "";
|
|
const index = raw.indexOf(text);
|
|
if (index < 0) continue;
|
|
if (line?.getCell(index)?.getFgColor() !== rgb) missed.push(y);
|
|
}
|
|
return missed;
|
|
};
|
|
|
|
const firstRgbMatch = (term: XTermType, rgb: number): number => {
|
|
let count = 0;
|
|
for (let y = 0; y < term.buffer.active.length; y += 1) {
|
|
const line = term.buffer.active.getLine(y);
|
|
if (!line) continue;
|
|
for (let x = 0; x < line.length; x += 1) {
|
|
if (line.getCell(x)?.getFgColor() === rgb) count += 1;
|
|
}
|
|
}
|
|
return count;
|
|
};
|
|
|
|
test("new output is colored on the written cells without decorations", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "plain ERROR text");
|
|
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
assert.equal(term.buffer.active.getLine(0)?.getCell(0)?.getFgColor(), -1);
|
|
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("ordinary Enter output does not revisit existing history", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "existing ERROR");
|
|
await highlighter.whenSettled();
|
|
const rebuildsBefore = highlighter.rebuildCount;
|
|
const before = cellRgb(term, 0, "ERROR");
|
|
|
|
await write(term, "\r\nplain prompt # ");
|
|
|
|
assert.equal(cellRgb(term, 0, "ERROR"), before);
|
|
assert.equal(highlighter.rebuildCount, rebuildsBefore);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("changing or disabling rules recolors existing history from original cell colors", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const visibleSerializer = new SerializeAddon();
|
|
term.loadAddon(visibleSerializer);
|
|
const highlighter = new KeywordHighlighter(term);
|
|
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "first ERROR\r\nsecond ERROR");
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
assert.match(visibleSerializer.serialize(), /38;2;248;113;113m/);
|
|
assert.equal(
|
|
highlighter.serializeAddon.serialize(),
|
|
"first ERROR\r\nsecond ERROR",
|
|
"saved history must never contain Netcatty's injected colors",
|
|
);
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
|
|
highlighter.setRules(rule("#60A5FA"), true);
|
|
await highlighter.whenSettled();
|
|
assert.equal(cellRgb(term, 0, "ERROR"), BLUE);
|
|
assert.equal(cellRgb(term, 1, "ERROR"), BLUE);
|
|
assert.match(visibleSerializer.serialize(), /38;2;96;165;250m/);
|
|
assert.doesNotMatch(visibleSerializer.serialize(), /38;2;248;113;113m/);
|
|
|
|
highlighter.setRules(rule("#60A5FA"), false);
|
|
await highlighter.whenSettled();
|
|
assert.equal(visibleSerializer.serialize(), "first ERROR\r\nsecond ERROR");
|
|
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("keywords split across ordinary writes are colored after the line is complete", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "plain ERR");
|
|
assert.notEqual(cellRgb(term, 0, "ERR"), RED);
|
|
await write(term, "OR text");
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("overlapping expressions color each character at most once", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules([{
|
|
id: "overlap",
|
|
label: "Overlap",
|
|
patterns: ["\\[ERROR\\]", "ERROR"],
|
|
color: "#F87171",
|
|
enabled: true,
|
|
}], true);
|
|
await write(term, "[ERROR]");
|
|
assert.equal(firstRgbMatch(term, RED), 7);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("line anchors are evaluated per output line", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules([{
|
|
id: "prompt",
|
|
label: "Prompt",
|
|
patterns: ["^#"],
|
|
color: "#F87171",
|
|
enabled: true,
|
|
}], true);
|
|
await write(term, "# one\r\n# two");
|
|
assert.equal(cellRgb(term, 0, "#"), RED);
|
|
assert.equal(cellRgb(term, 1, "#"), RED);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("alternate-screen programs are not keyword-colored", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "before ERROR");
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
await write(term, "\x1b[?1049hTUI ERROR");
|
|
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
|
|
await write(term, "\x1b[?1049l");
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("byte output catches up once instead of staying permanently uncolored", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
|
|
await new Promise<void>((resolve) => {
|
|
term.write(new TextEncoder().encode("byte ERROR"), resolve);
|
|
});
|
|
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
|
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
await highlighter.whenSettled();
|
|
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
assert.equal(highlighter.rebuildCount, 1);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("large output can bypass per-write coloring and is still recolored on a rule change", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
let bypass = true;
|
|
const highlighter = new KeywordHighlighter(term, {
|
|
shouldBypassHighlight: () => bypass,
|
|
});
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, `${"ERROR ".repeat(20)}tail`);
|
|
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
|
|
|
|
bypass = false;
|
|
highlighter.setRules(rule("#60A5FA"), true);
|
|
await highlighter.whenSettled();
|
|
assert.equal(cellRgb(term, 0, "ERROR"), BLUE);
|
|
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("bulk output skipped on the hot path is highlighted after output becomes quiet", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 8, scrollback: 100 });
|
|
let bypass = true;
|
|
const highlighter = new KeywordHighlighter(term, {
|
|
shouldBypassHighlight: () => bypass,
|
|
});
|
|
highlighter.setRules(rule(), true);
|
|
const history = Array.from({ length: 40 }, (_, index) => `line-${index} ERROR`).join("\r\n");
|
|
await write(term, history);
|
|
assert.equal(highlighter.rebuildCount, 0, "hot-path flood must not recolor immediately");
|
|
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
|
|
bypass = false;
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
await highlighter.whenSettled();
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
assert.equal(highlighter.rebuildCount, 1);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("ordinary Enter does not rebuild a saturated scrollback", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 3, scrollback: 10 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
const history = Array.from({ length: 20 }, (_, index) => `line-${index} ERROR`).join("\r\n");
|
|
noteTerminalOutputPressureData(term, history);
|
|
await write(term, history);
|
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
await highlighter.whenSettled();
|
|
const rebuildsBeforeEnter = highlighter.rebuildCount;
|
|
|
|
const prompt = "\r\nplain prompt # ";
|
|
noteTerminalOutputPressureData(term, prompt);
|
|
await write(term, prompt);
|
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
await highlighter.whenSettled();
|
|
|
|
assert.equal(highlighter.rebuildCount, rebuildsBeforeEnter);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("carriage-return rewrites rematch the current line", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "plain ERROR");
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
await write(term, "\rready OK ");
|
|
assert.equal(cellRgb(term, 0, "ERROR"), undefined);
|
|
assert.match(term.buffer.active.getLine(0)?.translateToString(true) ?? "", /ready OK/);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("rule changes keep scrollback position", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 4, scrollback: 40 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, Array.from({ length: 20 }, (_, index) => `line-${index} ERROR`).join("\r\n"));
|
|
term.scrollToLine(2);
|
|
const viewportY = term.buffer.active.viewportY;
|
|
|
|
highlighter.setRules(rule("#60A5FA"), true);
|
|
await highlighter.whenSettled();
|
|
assert.equal(term.buffer.active.viewportY, viewportY);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("rule changes preserve existing line timestamps", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await new Promise<void>((resolve) => {
|
|
writeTerminalDataWithLineTimestamps(term, "stamp ERROR\r\nnext ERROR", resolve, {
|
|
timestampDate: new Date("2026-08-13T00:00:00.000Z"),
|
|
});
|
|
});
|
|
const markersBefore = term.markers.length;
|
|
|
|
highlighter.setRules(rule("#60A5FA"), true);
|
|
await highlighter.whenSettled();
|
|
assert.equal(term.markers.length, markersBefore);
|
|
assert.equal(cellRgb(term, 0, "ERROR"), BLUE);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("same-line appends still restore original colors when rules are disabled", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "ERROR");
|
|
await write(term, " more");
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
highlighter.setRules(rule(), false);
|
|
await highlighter.whenSettled();
|
|
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("clear recolors the retained cursor row when rules stay enabled", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "keep ERROR");
|
|
term.clear();
|
|
const line = term.buffer.active.getLine(0)?.translateToString(true) ?? "";
|
|
if (line.includes("ERROR")) {
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
}
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("clear restores highlighted retained cells before dropping originals", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "ERROR stays");
|
|
term.clear();
|
|
highlighter.setRules(rule(), false);
|
|
await highlighter.whenSettled();
|
|
const line = term.buffer.active.getLine(0)?.translateToString(true) ?? "";
|
|
if (line.includes("ERROR")) {
|
|
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
|
|
}
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("application reset clears highlight state with the visible terminal", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "old ERROR");
|
|
term.reset();
|
|
await write(term, "new plain");
|
|
assert.equal(highlighter.serializeAddon.serialize(), "new plain");
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("8-digit plugin hex colors keep RGB and ignore alpha", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules([{
|
|
id: "plugin-alpha",
|
|
label: "Error",
|
|
patterns: ["ERROR"],
|
|
color: "#F8717180",
|
|
enabled: true,
|
|
providerId: "plugin.demo",
|
|
}], true);
|
|
await write(term, "plain ERROR text");
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("plugin scans stay within the bounded match budget", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules([{
|
|
id: "plugin-dots",
|
|
label: "Dots",
|
|
patterns: ["."],
|
|
color: "#F87171",
|
|
enabled: true,
|
|
providerId: "plugin.demo",
|
|
}], true);
|
|
await write(term, "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");
|
|
assert.ok(firstRgbMatch(term, RED) <= 256);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("Enter does not refresh already highlighted history rows", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "existing ERROR");
|
|
const refreshed: Array<[number, number]> = [];
|
|
const originalRefresh = term.refresh.bind(term);
|
|
term.refresh = (start, end) => {
|
|
refreshed.push([start, end]);
|
|
originalRefresh(start, end);
|
|
};
|
|
await write(term, "\r\nplain prompt # ");
|
|
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
|
assert.ok(refreshed.every(([start]) => start >= 1), JSON.stringify(refreshed));
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("a keyword written on a saturated scrollback is still colored", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 20, rows: 3, scrollback: 2 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, Array.from({ length: 8 }, (_, index) => `pad-${index}`).join("\r\n"));
|
|
await write(term, "\r\nline ERROR");
|
|
assert.equal(cellRgb(term, term.buffer.active.baseY + term.buffer.active.cursorY, "ERROR")
|
|
?? cellRgb(term, term.buffer.active.length - 1, "ERROR")
|
|
?? cellRgb(term, term.buffer.active.length - 2, "ERROR"), RED);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("scrolling during a rule change catch-up recolors newly visible rows", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 4, scrollback: 80 });
|
|
let bypass = true;
|
|
const highlighter = new KeywordHighlighter(term, {
|
|
shouldBypassHighlight: () => bypass,
|
|
});
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, Array.from({ length: 30 }, (_, index) => `line-${index} ERROR`).join("\r\n"));
|
|
bypass = false;
|
|
highlighter.setRules(rule("#60A5FA"), true);
|
|
term.scrollToLine(0);
|
|
assert.equal(cellRgb(term, 0, "ERROR"), BLUE);
|
|
await highlighter.whenSettled();
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("catch-up does not hang when the terminal enters the alternate screen", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term, {
|
|
shouldBypassHighlight: () => true,
|
|
});
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "queued ERROR");
|
|
await write(term, "\x1b[?1049hTUI");
|
|
await Promise.race([
|
|
highlighter.whenSettled(),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("whenSettled hung")), 200)),
|
|
]);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("partial grapheme matches still color the whole cell", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules([{
|
|
id: "letter-e",
|
|
label: "E",
|
|
patterns: ["e"],
|
|
color: "#F87171",
|
|
enabled: true,
|
|
}], true);
|
|
await write(term, "e\u0301");
|
|
assert.equal(term.buffer.active.getLine(0)?.getCell(0)?.getFgColor(), RED);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("wrapped matches stay on the same logical line", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 8, rows: 6, scrollback: 20 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
await write(term, "xx ERROR");
|
|
assert.equal(cellRgb(term, 0, "ER") === RED || cellRgb(term, 1, "ROR") === RED, true);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("identical flood lines are all colored after quiet catch-up", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 12, scrollback: 40 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1 payload=xxxxxxxx";
|
|
for (let index = 0; index < 6; index += 1) {
|
|
noteTerminalOutputPressureData(term, `${line}\r\n`);
|
|
await write(term, `${line}\r\n`);
|
|
}
|
|
const flood = Array.from({ length: 48 }, () => line).join("\r\n");
|
|
noteTerminalOutputPressureData(term, flood);
|
|
await write(term, flood);
|
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
await highlighter.whenSettled();
|
|
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("saturated flood does not rematch the pinned viewport on each write", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
|
|
const chunk = Array.from({ length: 16 }, () => line).join("\r\n");
|
|
for (let index = 0; index < 4; index += 1) {
|
|
noteTerminalOutputPressureData(term, chunk);
|
|
await write(term, chunk);
|
|
}
|
|
assert.equal(highlighter.rebuildCount, 0);
|
|
const viewportY = term.buffer.active.viewportY;
|
|
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
|
|
assert.notEqual(cellRgb(term, viewportY + 2, "ERROR"), RED);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|
|
|
|
test("recycled identical flood rows are recolored on catch-up", async () => {
|
|
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 8, scrollback: 12 });
|
|
const highlighter = new KeywordHighlighter(term);
|
|
highlighter.setRules(rule(), true);
|
|
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
|
|
const flood = Array.from({ length: 40 }, () => line).join("\r\n");
|
|
noteTerminalOutputPressureData(term, flood);
|
|
await write(term, flood);
|
|
noteTerminalOutputPressureData(term, flood);
|
|
await write(term, flood);
|
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
await highlighter.whenSettled();
|
|
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
|
highlighter.dispose();
|
|
term.dispose();
|
|
});
|