// src/renderer/src/terminal/highlightEngine.ts var ESCAPE_RE = /(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>])/g; var MAX_PER_RULE = 300; var MAX_CHUNK = 512 * 1024; function hexToRgb(hex) { let h = (hex || "").trim().replace(/^#/, ""); if (h.length === 3) h = h.replace(/./g, (c) => c + c); if (!/^[0-9a-fA-F]{6}$/.test(h)) return { r: 255, g: 255, b: 255 }; const n = parseInt(h, 16); return { r: n >> 16 & 255, g: n >> 8 & 255, b: n & 255 }; } function tokenize(input) { const out = []; let acc = ""; let i = 0; let m; while ((m = ESCAPE_RE.exec(input)) !== null) { if (m.index > i) acc += input.slice(i, m.index); if (acc.length > 0) { out.push({ kind: "text", value: acc }); acc = ""; } out.push({ kind: "seq", value: m[0] }); i = m.index + m[0].length; } if (i < input.length) acc += input.slice(i); if (acc.length > 0) out.push({ kind: "text", value: acc }); return out; } function compileRules(rules2) { const out = []; for (const rule of rules2) { if (!rule || rule.enabled === false) continue; const pattern = rule.pattern; if (typeof pattern !== "string" || pattern.length === 0) continue; let regex; try { regex = new RegExp(pattern, "g"); } catch { continue; } const fg = typeof rule.color?.fg === "string" ? rule.color.fg : ""; if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue; const bg = typeof rule.color?.bg === "string" ? rule.color.bg : void 0; out.push({ id: rule.id, regex, fg, bg, priority: rule.priority }); } out.sort((a, b) => a.priority - b.priority); return out; } function wrap(text, fgHex, bgHex) { const f = hexToRgb(fgHex); let s = `\x1B[38;2;${f.r};${f.g};${f.b}`; if (bgHex) { const b = hexToRgb(bgHex); s += `;48;2;${b.r};${b.g};${b.b}`; } return `${s}m${text}\x1B[0m`; } function overlaps(claimed, s, e) { for (const c of claimed) { if (s < c.end && e > c.start) return true; } return false; } function applyRun(text, rules2, budgets) { if (text.length === 0) return ""; const claimed = []; for (let ri = 0; ri < rules2.length; ri++) { const rule = rules2[ri]; if (budgets[ri] <= 0) continue; rule.regex.lastIndex = 0; let m; let made = 0; while ((m = rule.regex.exec(text)) !== null) { const start = m.index; const end = start + m[0].length; if (end <= start) { rule.regex.lastIndex++; continue; } if (!overlaps(claimed, start, end)) { claimed.push({ start, end, fg: rule.fg, bg: rule.bg }); made++; budgets[ri]--; if (budgets[ri] <= 0) break; } if (rule.regex.lastIndex === start) rule.regex.lastIndex++; } } if (claimed.length === 0) return text; claimed.sort((a, b) => a.start - b.start); let out = ""; let pos = 0; for (const c of claimed) { if (c.start > pos) out += text.slice(pos, c.start); out += wrap(text.slice(c.start, c.end), c.fg, c.bg); pos = c.end; } if (pos < text.length) out += text.slice(pos); return out; } function applyHighlights(chunk, rules2) { if (!chunk || rules2.length === 0) return chunk; if (chunk.length > MAX_CHUNK) return chunk; let budgets = rules2.map(() => MAX_PER_RULE); let usedBudget = false; let out = ""; for (const token of tokenize(chunk)) { if (token.kind === "seq") { out += token.value; continue; } if (token.value.includes("\x1B")) { out += token.value; continue; } const before = budgets.reduce((a, b) => a + b, 0); out += applyRun(token.value, rules2, budgets); const after = budgets.reduce((a, b) => a + b, 0); if (after < before) usedBudget = true; } return usedBudget ? out : chunk; } function isIncompleteEscape(seg) { if (seg === "\x1B") return true; if (/^\x1b\[[0-9;:?]*$/.test(seg)) return true; if (seg.startsWith("\x1B]") && !seg.includes("\x07") && !seg.includes("\x1B\\")) return true; return false; } function lastEscapeEnd(input) { let end = -1; let m; ESCAPE_RE.lastIndex = 0; while ((m = ESCAPE_RE.exec(input)) !== null) end = m.index + m[0].length; return end; } function findIncompleteIndex(input) { const idx = input.lastIndexOf("\x1B"); if (idx < 0) return -1; return isIncompleteEscape(input.slice(idx)) ? idx : -1; } var MAX_CARRY = 1024; var HighlightStream = class { rules; buffer = ""; constructor(rules2) { this.rules = rules2; } /** Swap the active rules without touching any buffered content. */ setRules(rules2) { this.rules = rules2; } /** True while content is held back (a pending flush will release it). */ hasPending() { return this.buffer.length > 0; } /** Join held bytes with `chunk`, return content safe to hand to xterm. */ push(chunk) { this.buffer += chunk; const incompleteAt = findIncompleteIndex(this.buffer); if (incompleteAt >= 0) { const head2 = this.buffer.slice(0, incompleteAt); const tail = this.buffer.slice(incompleteAt); if (tail.length > MAX_CARRY) { this.buffer = ""; return applyHighlights(head2, this.rules) + tail; } this.buffer = tail; return applyHighlights(head2, this.rules); } const lastEnd = lastEscapeEnd(this.buffer); if (lastEnd < 0) { if (this.buffer.length > MAX_CHUNK) { const raw = this.buffer; this.buffer = ""; return applyHighlights(raw, this.rules); } return ""; } const head = this.buffer.slice(0, lastEnd); this.buffer = this.buffer.slice(lastEnd); return applyHighlights(head, this.rules); } /** Flush any held content (session end) — highlighted as a final run. */ flush() { const b = this.buffer; this.buffer = ""; return applyHighlights(b, this.rules); } }; // tests/hl-split-smoke.mjs var rules = compileRules([ { id: "okstate", pattern: "\\b(SUCCESS|PASS|OK|DONE)\\b", enabled: true, priority: 5, color: { fg: "#3fb950" } }, { id: "badstate", pattern: "\\b(ERROR|FAILED)\\b", enabled: true, priority: 6, color: { fg: "#f85149" } }, { id: "numbers", pattern: "\\b\\d+(?:\\.\\d+)?(?:%)?\\b", enabled: true, priority: 25, color: { fg: "#f2cc60" } }, { id: "url", pattern: "https?://[^\\s]+", enabled: true, priority: 30, color: { fg: "#58a6ff" } } ]); var banner = "\x1B]0;kimi \u2014 session\x07\x1B[38;2;79;168;255m\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E\x1B[0m\r\n\x1B[38;2;136;136;136mWelcome to Kimi Code!\x1B[0m \x1B[38;2;79;168;255m\u2502\x1B[0m send /help\r\n\x1B[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1B[0m\r\n"; var reference = applyHighlights(banner, rules); var failed = 0; var splitAt = (s, i) => [s.slice(0, i), s.slice(i)]; for (let i = 1; i < banner.length - 1; i++) { const [a, b] = splitAt(banner, i); const stream = new HighlightStream(rules); const out = stream.push(a) + stream.push(b) + stream.flush(); if (out !== reference) { failed++; if (failed <= 3) { console.log(`MISMATCH at split ${i}:`); console.log(" got: " + JSON.stringify(out.slice(0, 120))); console.log(" expected: " + JSON.stringify(reference.slice(0, 120))); } } } { const i = Math.floor(banner.length / 3); const j = Math.floor(banner.length * 2 / 3); const stream = new HighlightStream(rules); const out = stream.push(banner.slice(0, i)) + stream.push(banner.slice(i, j)) + stream.push(banner.slice(j)) + stream.flush(); if (out !== reference) failed++; } { const stream = new HighlightStream(rules); let out = ""; for (const ch of banner) out += stream.push(ch); out += stream.flush(); if (out !== reference) failed++; } if (failed === 0) { console.log("SPLIT-SMOKE PASS: all split points byte-identical to single-chunk reference"); process.exit(0); } else { console.log(`SPLIT-SMOKE FAIL: ${failed} mismatches`); process.exit(1); }