fix(run): Give forked and debugged JVMs the console encoding and render colours in the Debug console (#220)

Only the JVM the IDE launched directly was told the console's output encoding, so forked JVMs (spring-boot:run, Surefire, bootRun, Gradle workers), debugger-launched JVMs and Build-window builds wrote GBK on Windows while the console decoded UTF-8. Every JVM the IDE starts on Windows now gets the console encoding: java gets -Dfile.encoding alongside the std* properties, build tools and their forks get JAVA_TOOL_OPTIONS (std* only) with the IDE's own 'Picked up JAVA_TOOL_OPTIONS' line filtered, shell command lines are recognised by their first word, and java-debug launches get the matching vmArgs. The Debug console now shares the Run console's ANSI parser. Encoding helpers moved to ide-core::output_encoding.

Not verified on a Windows machine.

Fixes #180
Fixes #182

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 11:58:19 +08:00
1 джерело 6767a5ff8c
коміт c821ab144c
17 files changed
+861 -360

No files matched your search

+46
Переглянути файл
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from "vitest";
import { appendConsole, clearConsole, useDebug } from "../state";
const lines = (session: number) => useDebug.getState().console[session] ?? [];
afterEach(() => useDebug.setState({ console: {} }));
describe("debug console", () => {
it("renders ANSI colours instead of showing the escapes", () => {
// A logback pattern whose colour spans the line break (`%boldMagenta(%logger{36}%n)`), with CRLF.
appendConsole(
5,
"stdout",
"\x1b[36m2026-10-29 09:13:55\x1b[0;39m \x1b[32m[main]\x1b[0;39m \x1b[1;35mo.d.c.p.l.MessageTopicListener\r\n",
);
appendConsole(5, "stdout", "\x1b[0;39m - 初始化消息主题订阅监听器成功\r\n");
const [first, second] = lines(5);
expect(first!.text).toBe("2026-10-29 09:13:55 [main] o.d.c.p.l.MessageTopicListener\n");
expect(first!.spans[0]).toEqual({ text: "2026-10-29 09:13:55", fg: "var(--ansi-cyan)" });
expect(first!.spans).toContainEqual({ text: "[main]", fg: "var(--ansi-green)" });
expect(first!.spans.at(-1)).toEqual({
text: "o.d.c.p.l.MessageTopicListener\n",
fg: "var(--ansi-magenta)",
bold: true,
});
expect(second!.text).toBe(" - 初始化消息主题订阅监听器成功\n");
expect(second!.spans).toEqual([{ text: " - 初始化消息主题订阅监听器成功\n" }]);
expect(lines(5).some((l) => l.text.includes("\x1b"))).toBe(false);
});
it("keeps a colour across output events and an escape split between them", () => {
appendConsole(6, "stderr", "\x1b[3");
appendConsole(6, "stderr", "1mred ");
appendConsole(6, "stderr", "still\x1b[0m plain\n");
const [line] = lines(6);
expect(line!.text).toBe("red still plain\n");
expect(line!.spans).toEqual([{ text: "red still", fg: "var(--ansi-red)" }, { text: " plain\n" }]);
// Streams of other categories and sessions have their own state.
appendConsole(6, "stdout", "\x1b[32mgreen");
appendConsole(7, "stdout", "plain\n");
expect(lines(7)[0]!.spans).toEqual([{ text: "plain\n" }]);
clearConsole(6);
appendConsole(6, "stdout", "after clear\n");
expect(lines(6)[0]!.spans).toEqual([{ text: "after clear\n" }]);
});
});
+23 -3
Переглянути файл
@@ -5,6 +5,7 @@
import { workflowSettings } from "@/platform/workflowSettings";
import { executeAction } from "@/platform/actions";
import { create } from "zustand";
import { AnsiStream, appendSpans, type Span } from "@/ui/ansi";
import { setContext } from "@/platform/context";
import {
call,
@@ -55,7 +56,10 @@ export interface DebugSession {
export interface ConsoleLine {
id: number;
category: string;
/** Plain text without escape sequences. */
text: string;
/** `text` styled by its ANSI colours. */
spans: Span[];
}
export interface WatchResult {
@@ -95,6 +99,16 @@ export const useExecutionPoint = create<ExecutionPoint>(() => ({ path: null, lin
const MAX_CONSOLE_LINES = 5000;
let lineSeq = 0;
/** ANSI state per session and output category (a colour can span several output events). */
const ansiStreams = new Map<DebugSessionId, Map<string, AnsiStream>>();
function ansiStream(session: DebugSessionId, category: string): AnsiStream {
let streams = ansiStreams.get(session);
if (!streams) ansiStreams.set(session, (streams = new Map()));
let stream = streams.get(category);
if (!stream) streams.set(category, (stream = new AnsiStream()));
return stream;
}
// --- accessors ---------------------------------------------------------------
@@ -215,7 +229,10 @@ export function publishContext() {
// --- console -----------------------------------------------------------------
export function appendConsole(session: DebugSessionId, category: string, text: string) {
export function appendConsole(session: DebugSessionId, category: string, raw: string) {
if (!raw) return;
const spans = ansiStream(session, category).parse(raw);
const text = spans.map((sp) => sp.text).join("");
if (!text) return;
useDebug.setState((s) => {
const prev = s.console[session] ?? [];
@@ -223,9 +240,9 @@ export function appendConsole(session: DebugSessionId, category: string, text: s
const last = next[next.length - 1];
// Merge partial lines of the same category.
if (last && last.category === category && !last.text.endsWith("\n")) {
next[next.length - 1] = { ...last, text: last.text + text };
next[next.length - 1] = { ...last, text: last.text + text, spans: appendSpans(last.spans, spans) };
} else {
next.push({ id: ++lineSeq, category, text });
next.push({ id: ++lineSeq, category, text, spans });
}
if (next.length > MAX_CONSOLE_LINES) next.splice(0, next.length - MAX_CONSOLE_LINES);
return { console: { ...s.console, [session]: next } };
@@ -233,6 +250,7 @@ export function appendConsole(session: DebugSessionId, category: string, text: s
}
export function clearConsole(session: DebugSessionId) {
ansiStreams.delete(session);
useDebug.setState((s) => ({ console: { ...s.console, [session]: [] } }));
}
@@ -255,6 +273,7 @@ export async function startSession(
useDebug.setState((s) => {
const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v)));
const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k]));
for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id);
return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null };
});
try {
@@ -657,6 +676,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
export function resetSessions() {
const { sessions } = useDebug.getState();
for (const s of Object.values(sessions)) if (s.parent == null && isLive(s)) void stopSession(s.id);
ansiStreams.clear();
useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 });
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} });
publishContext();
+8 -2
Переглянути файл
@@ -42,6 +42,8 @@ import { VariablesView, type VarRoot } from "./VariablesView";
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
import { sortVariables } from "../values";
import { workflowSettings } from "@/platform/workflowSettings";
import { spanStyle } from "@/ui/ansi";
import ansi from "@/ui/ansi.module.css";
import styles from "./DebugToolWindow.module.css";
import { tx } from "@/platform/i18n";
@@ -609,7 +611,7 @@ function ConsoleView({ session }: { session: DebugSession }) {
<div className={styles.console}>
<div
ref={scroller}
className={styles.consoleOutput}
className={clsx(styles.consoleOutput, ansi.palette)}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
@@ -624,7 +626,11 @@ function ConsoleView({ session }: { session: DebugSession }) {
>
{(lines ?? []).map((l) => (
<span key={l.id} className={clsx(styles.out, styles[`cat-${l.category}`])}>
{l.text}
{l.spans.map((sp, i) => (
<span key={i} style={spanStyle(sp)}>
{sp.text}
</span>
))}
</span>
))}
</div>
+4 -16
Переглянути файл
@@ -6,7 +6,6 @@ import {
useRef,
useState,
type ClipboardEvent,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
} from "react";
@@ -14,7 +13,9 @@ import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { Icon, showContextMenu } from "@/ui";
import type { ConsoleLine, ConsoleModel, SpanStyle } from "./console";
import { spanStyle } from "@/ui/ansi";
import ansi from "@/ui/ansi.module.css";
import type { ConsoleLine, ConsoleModel } from "./console";
import { findOutputLinks, type OutputLink } from "./links";
import { openTarget, openUrl } from "./navigate";
import { segmentLine } from "./segments";
@@ -23,19 +24,6 @@ import { tx } from "@/platform/i18n";
const ROW = 20;
function spanStyle(s: SpanStyle): CSSProperties | undefined {
if (!s.fg && !s.bg && !s.bold && !s.dim && !s.italic && !s.underline && !s.strike) return undefined;
const deco = [s.underline && "underline", s.strike && "line-through"].filter(Boolean).join(" ");
return {
color: s.fg,
background: s.bg,
fontWeight: s.bold ? "var(--fw-code-bold, 700)" : undefined,
opacity: s.dim ? 0.6 : undefined,
fontStyle: s.italic ? "italic" : undefined,
textDecoration: deco || undefined,
};
}
const Line = memo(
function Line({ line, cwd }: { line: ConsoleLine; length: number; cwd: string }) {
// Not memoised: the open (last) line mutates in place; `memo` below re-renders on length changes only.
@@ -289,7 +277,7 @@ export function ConsoleView({
return (
<div
ref={scrollRef}
className={clsx(styles.console, softWrap && styles.wrap)}
className={clsx(styles.console, ansi.palette, softWrap && styles.wrap)}
role="log"
aria-live="off"
aria-label={aria["aria-label"] ?? "Console"}
-21
Переглянути файл
@@ -1,24 +1,3 @@
/* ANSI palette derived from tokens (console spans reference these). */
.root,
.console {
--ansi-black: var(--control-off);
--ansi-red: var(--danger);
--ansi-green: var(--success);
--ansi-yellow: var(--warning);
--ansi-blue: var(--syn-keyword);
--ansi-magenta: var(--syn-annotation);
--ansi-cyan: color-mix(in srgb, var(--syn-keyword) 50%, var(--syn-string));
--ansi-white: var(--fg);
--ansi-bright-black: var(--fg-subtle);
--ansi-bright-red: color-mix(in srgb, var(--danger) 72%, var(--fg-strong));
--ansi-bright-green: color-mix(in srgb, var(--success) 72%, var(--fg-strong));
--ansi-bright-yellow: color-mix(in srgb, var(--warning) 72%, var(--fg-strong));
--ansi-bright-blue: color-mix(in srgb, var(--syn-keyword) 72%, var(--fg-strong));
--ansi-bright-magenta: color-mix(in srgb, var(--syn-annotation) 72%, var(--fg-strong));
--ansi-bright-cyan: color-mix(in srgb, var(--ansi-cyan) 72%, var(--fg-strong));
--ansi-bright-white: var(--fg-strong);
}
.root {
flex: 1;
display: flex;
+5 -124
Переглянути файл
@@ -1,29 +1,15 @@
/**
* Console line model for run output: splits streamed chunks into lines,
* parses ANSI SGR colours (via `anser`, one stateful parser per stream),
* parses ANSI SGR colours (`ui/ansi`, one stateful parser per stream),
* handles `\r` overwrites (progress bars) and caps the buffer.
*/
/* eslint-disable no-control-regex -- escape-sequence parsing matches control characters by design */
import Anser from "anser";
import type { OutputChunk } from "@/platform/ipc";
import { AnsiStream, sameStyle, type Span } from "@/ui/ansi";
export { paletteColor, type Span, type SpanStyle } from "@/ui/ansi";
export type ChunkKind = OutputChunk["kind"];
export interface SpanStyle {
/** CSS colour (`var(--ansi-red)`, `rgb(…)`). */
fg?: string;
bg?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
}
export interface Span extends SpanStyle {
text: string;
}
export interface ConsoleLine {
/** Monotonic id (stable across trimming). */
id: number;
@@ -37,111 +23,6 @@ export const MAX_LINES = 50_000;
/** Very long lines are broken up so a single minified blob can't stall rendering. */
export const MAX_LINE_LENGTH = 8_000;
const NAMED = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"];
/** xterm 256-colour palette entry (16–255) as `rgb(…)`. */
export function paletteColor(n: number): string {
if (n < 16) {
const bright = n >= 8;
return `var(--ansi-${bright ? "bright-" : ""}${NAMED[n % 8]})`;
}
if (n >= 232) {
const v = 8 + (n - 232) * 10;
return `rgb(${v}, ${v}, ${v})`;
}
const i = n - 16;
const steps = [0, 95, 135, 175, 215, 255];
return `rgb(${steps[Math.floor(i / 36)]}, ${steps[Math.floor(i / 6) % 6]}, ${steps[i % 6]})`;
}
function cssColor(cls: string | null, truecolor: string | null): string | undefined {
if (!cls) return undefined;
if (cls === "ansi-truecolor") return truecolor ? `rgb(${truecolor})` : undefined;
const palette = /^ansi-palette-(\d+)$/.exec(cls);
if (palette) return paletteColor(Number(palette[1]));
if (cls.startsWith("ansi-")) return `var(--${cls})`;
return undefined;
}
function sameStyle(a: SpanStyle, b: SpanStyle) {
return (
a.fg === b.fg &&
a.bg === b.bg &&
!!a.bold === !!b.bold &&
!!a.dim === !!b.dim &&
!!a.italic === !!b.italic &&
!!a.underline === !!b.underline &&
!!a.strike === !!b.strike
);
}
// OSC (…BEL or …ST), and escape sequences other than CSI.
const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
const NON_CSI_ESC_RE = /\x1b(?:[()][0-9A-Za-z]|[=>78DEHMNOZc])/g;
// CSI that isn't SGR: erase-line becomes a carriage return, everything else is dropped.
const CSI_RE = /\x1b\[([0-9;?]*)([ -/]*)([@-~])/g;
// An escape sequence cut off at the end of a chunk.
const INCOMPLETE_RE = /\x1b(?:\[[0-9;?]*[ -/]*|\][^\x07\x1b]*\x1b?)?$/;
class StreamParser {
private anser = new Anser();
private carry = "";
/** Normalises escapes; returns text whose only escapes are SGR (`ESC[…m`). */
clean(text: string): string {
let s = this.carry + text;
this.carry = "";
const cut = INCOMPLETE_RE.exec(s);
if (cut && cut[0].length > 0) {
this.carry = cut[0];
s = s.slice(0, cut.index);
}
return s
.replace(OSC_RE, "")
.replace(NON_CSI_ESC_RE, "")
.replace(CSI_RE, (all, params: string, _i: string, final: string) => {
if (final === "m") return all;
if (final === "K" && (params === "2" || params === "1")) return "\r";
if (final === "G" && (params === "" || params === "1" || params === "0")) return "\r";
return "";
})
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f]/g, "");
}
/** Style in effect at the end of the previous call (anser only carries it into escaped chunks). */
private carryStyle: SpanStyle = {};
spans(text: string): Span[] {
const out: Span[] = [];
const entries = this.anser.ansiToJson(text, { json: true, use_classes: true });
entries.forEach((e, i) => {
// Entry 0 is the text before the first escape: it continues the previous style.
const style = i === 0 ? this.carryStyle : entryStyle(e);
if (i === entries.length - 1) this.carryStyle = style;
if (e.content) out.push({ text: e.content, ...style });
});
return out;
}
}
function entryStyle(e: Anser.AnserJsonEntry): SpanStyle {
const decorations = e.decorations ?? [];
const style: SpanStyle = {};
let fg = cssColor(e.fg, e.fg_truecolor);
let bg = cssColor(e.bg, e.bg_truecolor);
if ((e as { isInverted?: boolean }).isInverted) {
[fg, bg] = [bg ?? "var(--bg-app)", fg ?? "var(--fg)"];
}
if (fg) style.fg = fg;
if (bg) style.bg = bg;
if (decorations.includes("bold")) style.bold = true;
if (decorations.includes("dim")) style.dim = true;
if (decorations.includes("italic")) style.italic = true;
if (decorations.includes("underline")) style.underline = true;
if (decorations.includes("strikethrough")) style.strike = true;
return style;
}
export interface ConsoleModelOptions {
maxLines?: number;
/** Cycle buffer: total characters kept (Settings › Console); unlimited when absent. */
@@ -157,7 +38,7 @@ export class ConsoleModel {
private nextId = 1;
private open: ConsoleLine | null = null;
private pendingCR = false;
private readonly parsers = { stdout: new StreamParser(), stderr: new StreamParser(), system: new StreamParser() };
private readonly parsers = { stdout: new AnsiStream(), stderr: new AnsiStream(), system: new AnsiStream() };
private readonly maxLines: number;
private maxChars: number | null;
/** Characters currently buffered (line text + one per line break). */
+19
Переглянути файл
@@ -0,0 +1,19 @@
/* ANSI palette of the Run and Debug consoles, derived from tokens (spans reference these). */
.palette {
--ansi-black: var(--control-off);
--ansi-red: var(--danger);
--ansi-green: var(--success);
--ansi-yellow: var(--warning);
--ansi-blue: var(--syn-keyword);
--ansi-magenta: var(--syn-annotation);
--ansi-cyan: color-mix(in srgb, var(--syn-keyword) 50%, var(--syn-string));
--ansi-white: var(--fg);
--ansi-bright-black: var(--fg-subtle);
--ansi-bright-red: color-mix(in srgb, var(--danger) 72%, var(--fg-strong));
--ansi-bright-green: color-mix(in srgb, var(--success) 72%, var(--fg-strong));
--ansi-bright-yellow: color-mix(in srgb, var(--warning) 72%, var(--fg-strong));
--ansi-bright-blue: color-mix(in srgb, var(--syn-keyword) 72%, var(--fg-strong));
--ansi-bright-magenta: color-mix(in srgb, var(--syn-annotation) 72%, var(--fg-strong));
--ansi-bright-cyan: color-mix(in srgb, var(--ansi-cyan) 72%, var(--fg-strong));
--ansi-bright-white: var(--fg-strong);
}
+160
Переглянути файл
@@ -0,0 +1,160 @@
/**
* ANSI escape handling for process output (Run and Debug consoles): one stateful
* parser per stream turns text with SGR colours into styled spans, keeping a style
* across chunks and dropping the other escapes. Colours are the `--ansi-*` palette
* (`ansi.module.css`).
*/
/* eslint-disable no-control-regex -- escape-sequence parsing matches control characters by design */
import type { CSSProperties } from "react";
import Anser from "anser";
export interface SpanStyle {
/** CSS colour (`var(--ansi-red)`, `rgb(…)`). */
fg?: string;
bg?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
}
export interface Span extends SpanStyle {
text: string;
}
const NAMED = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"];
/** xterm 256-colour palette entry (16–255) as `rgb(…)`. */
export function paletteColor(n: number): string {
if (n < 16) {
const bright = n >= 8;
return `var(--ansi-${bright ? "bright-" : ""}${NAMED[n % 8]})`;
}
if (n >= 232) {
const v = 8 + (n - 232) * 10;
return `rgb(${v}, ${v}, ${v})`;
}
const i = n - 16;
const steps = [0, 95, 135, 175, 215, 255];
return `rgb(${steps[Math.floor(i / 36)]}, ${steps[Math.floor(i / 6) % 6]}, ${steps[i % 6]})`;
}
function cssColor(cls: string | null, truecolor: string | null): string | undefined {
if (!cls) return undefined;
if (cls === "ansi-truecolor") return truecolor ? `rgb(${truecolor})` : undefined;
const palette = /^ansi-palette-(\d+)$/.exec(cls);
if (palette) return paletteColor(Number(palette[1]));
if (cls.startsWith("ansi-")) return `var(--${cls})`;
return undefined;
}
export function sameStyle(a: SpanStyle, b: SpanStyle) {
return (
a.fg === b.fg &&
a.bg === b.bg &&
!!a.bold === !!b.bold &&
!!a.dim === !!b.dim &&
!!a.italic === !!b.italic &&
!!a.underline === !!b.underline &&
!!a.strike === !!b.strike
);
}
/** Inline style of a span (none for plain text). */
export function spanStyle(s: SpanStyle): CSSProperties | undefined {
if (!s.fg && !s.bg && !s.bold && !s.dim && !s.italic && !s.underline && !s.strike) return undefined;
const deco = [s.underline && "underline", s.strike && "line-through"].filter(Boolean).join(" ");
return {
color: s.fg,
background: s.bg,
fontWeight: s.bold ? "var(--fw-code-bold, 700)" : undefined,
opacity: s.dim ? 0.6 : undefined,
fontStyle: s.italic ? "italic" : undefined,
textDecoration: deco || undefined,
};
}
// OSC (…BEL or …ST), and escape sequences other than CSI.
const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
const NON_CSI_ESC_RE = /\x1b(?:[()][0-9A-Za-z]|[=>78DEHMNOZc])/g;
// CSI that isn't SGR: erase-line becomes a carriage return, everything else is dropped.
const CSI_RE = /\x1b\[([0-9;?]*)([ -/]*)([@-~])/g;
// An escape sequence cut off at the end of a chunk.
const INCOMPLETE_RE = /\x1b(?:\[[0-9;?]*[ -/]*|\][^\x07\x1b]*\x1b?)?$/;
function entryStyle(e: Anser.AnserJsonEntry): SpanStyle {
const decorations = e.decorations ?? [];
const style: SpanStyle = {};
let fg = cssColor(e.fg, e.fg_truecolor);
let bg = cssColor(e.bg, e.bg_truecolor);
if ((e as { isInverted?: boolean }).isInverted) {
[fg, bg] = [bg ?? "var(--bg-app)", fg ?? "var(--fg)"];
}
if (fg) style.fg = fg;
if (bg) style.bg = bg;
if (decorations.includes("bold")) style.bold = true;
if (decorations.includes("dim")) style.dim = true;
if (decorations.includes("italic")) style.italic = true;
if (decorations.includes("underline")) style.underline = true;
if (decorations.includes("strikethrough")) style.strike = true;
return style;
}
/** The ANSI state of one output stream. */
export class AnsiStream {
private anser = new Anser();
private carry = "";
/** Style in effect at the end of the previous call (anser only carries it into escaped chunks). */
private carryStyle: SpanStyle = {};
/** Normalises escapes; returns text whose only escapes are SGR (`ESC[…m`). */
clean(text: string): string {
let s = this.carry + text;
this.carry = "";
const cut = INCOMPLETE_RE.exec(s);
if (cut && cut[0].length > 0) {
this.carry = cut[0];
s = s.slice(0, cut.index);
}
return s
.replace(OSC_RE, "")
.replace(NON_CSI_ESC_RE, "")
.replace(CSI_RE, (all, params: string, _i: string, final: string) => {
if (final === "m") return all;
if (final === "K" && (params === "2" || params === "1")) return "\r";
if (final === "G" && (params === "" || params === "1" || params === "0")) return "\r";
return "";
})
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f]/g, "");
}
/** Spans of text already passed through [`clean`]. */
spans(text: string): Span[] {
const out: Span[] = [];
const entries = this.anser.ansiToJson(text, { json: true, use_classes: true });
entries.forEach((e, i) => {
// Entry 0 is the text before the first escape: it continues the previous style.
const style = i === 0 ? this.carryStyle : entryStyle(e);
if (i === entries.length - 1) this.carryStyle = style;
if (e.content) out.push({ text: e.content, ...style });
});
return out;
}
/** [`clean`] then [`spans`], with `\r` dropped: for consoles that don't overwrite lines. */
parse(text: string): Span[] {
return this.spans(this.clean(text).replace(/\r(?=\n|$)/g, ""));
}
}
/** Appends `spans` to `into`, merging a span into the previous one of the same style. */
export function appendSpans(into: Span[], spans: readonly Span[]): Span[] {
const out = into.slice();
for (const span of spans) {
const prev = out[out.length - 1];
if (prev && sameStyle(prev, span)) out[out.length - 1] = { ...prev, text: prev.text + span.text };
else out.push(span);
}
return out;
}