Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/log-dock-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { Component } from "@mariozechner/pi-tui";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { LIVE_STATUSES } from "../constants";
import type { ProcessManager } from "../manager";
import { stripAnsi } from "../utils";
import { LogFileViewer } from "./log-file-viewer";

const PROCESS_COLORS: ThemeColor[] = [
Expand Down Expand Up @@ -153,7 +154,7 @@ export class LogDockComponent implements Component {
const lastLogs = this.manager.getCombinedOutput(running[0].id, 1);
if (lastLogs && lastLogs.length > 0) {
const lastLog = truncateToWidth(
lastLogs[lastLogs.length - 1].text,
stripAnsi(lastLogs[lastLogs.length - 1].text),
innerWidth,
);
lines.push(padLine(dim(lastLog)));
Expand Down
22 changes: 22 additions & 0 deletions src/utils/ansi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { stripAnsi } from "./ansi";

describe("stripAnsi", () => {
it("strips CSI styling sequences", () => {
expect(stripAnsi("\u001b[31mred\u001b[0m")).toBe("red");
});

it("strips generic OSC sequences", () => {
expect(stripAnsi("\u001b]0;title\u0007hello")).toBe("hello");
});

it("strips carriage returns and other control chars that can corrupt TUI rendering", () => {
expect(stripAnsi("step 1\rstep 2\b\b done")).toBe("step 1step 2 done");
expect(stripAnsi("null\u0000byte")).toBe("nullbyte");
expect(stripAnsi("delete\u007fchar")).toBe("deletechar");
});

it("preserves tabs and newlines", () => {
expect(stripAnsi("one\ttwo\nthree")).toBe("one\ttwo\nthree");
});
});
47 changes: 28 additions & 19 deletions src/utils/ansi.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,45 @@
/**
* Strip ANSI escape codes from a string.
* Strip ANSI escape codes and other terminal control characters from a string.
*
* Removes:
* - All CSI sequences (\x1b[...X) - SGR, cursor movement, erase, scroll, etc.
* - OSC 8 hyperlinks (\x1b]8;;URL\x07)
* - OSC sequences (\x1b]...\x07 or \x1b]...\x1b\\)
* - APC sequences (\x1b_...\x07 or \x1b_...\x1b\\)
* - Remaining C0 control chars except tab/newline
*/
const ESC = String.fromCodePoint(0x001b);
const BEL = String.fromCodePoint(0x0007);

const ANSI_REPLACEMENTS: RegExp[] = [
// CSI sequences: SGR, cursor movement, erase, scroll, etc.
new RegExp(`${ESC}\\[[0-9;]*[A-Za-z]`, "gu"),
// OSC sequences: ESC]...<BEL> or ESC]...<ESC>\\.
new RegExp(`${ESC}\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, "gu"),
// APC sequences: ESC_...<BEL> or ESC_...<ESC>\\.
new RegExp(`${ESC}_[^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, "gu"),
];

// Strip C0 terminal control characters that can corrupt TUI layout when
// rendered back into pi, such as carriage return and backspace. Keep tabs and
// newlines because logs use them as printable whitespace/line breaks.
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally targets terminal control characters.
const TERMINAL_CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f]/gu;

/**
* Check if a string contains ANSI escape codes.
*/
export function hasAnsi(str: string): boolean {
return str.includes(String.fromCodePoint(0x001b));
return str.includes(ESC);
}

export function stripAnsi(str: string): string {
// ESC = \u001b, BEL = \u0007
const ESC = String.fromCodePoint(0x001b);
const BEL = String.fromCodePoint(0x0007);
let clean = str;

if (!str.includes(ESC)) {
return str;
if (str.includes(ESC)) {
for (const pattern of ANSI_REPLACEMENTS) {
clean = clean.replace(pattern, "");
}
}

// Strip all CSI sequences (ESC[...X where X is any letter)
let clean = str.replace(new RegExp(`${ESC}\\[[0-9;]*[A-Za-z]`, "gu"), "");
// Strip OSC 8 hyperlinks: ESC]8;;URL<BEL> and ESC]8;;<BEL>
clean = clean.replace(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, "gu"), "");
// Strip APC sequences: ESC_...<BEL> or ESC_...<ESC>\\ (used for cursor marker)
clean = clean.replace(
new RegExp(`${ESC}_[^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, "gu"),
"",
);

return clean;
return clean.replace(TERMINAL_CONTROL_CHARS, "");
}