feat: render bash ANSI colors in web console
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { ansiSegments } from "./ansi.ts";
|
||||
|
||||
let { text }: { text: string } = $props();
|
||||
let segments = $derived(ansiSegments(text));
|
||||
</script>
|
||||
|
||||
{#each segments as segment}
|
||||
<span
|
||||
class:ansi-bold={segment.bold}
|
||||
class:ansi-dim={segment.dim}
|
||||
class:ansi-italic={segment.italic}
|
||||
class:ansi-underline={segment.underline}
|
||||
class:ansi-strikethrough={segment.strikethrough}
|
||||
class:ansi-concealed={segment.concealed}
|
||||
style:color={segment.foreground}
|
||||
style:background-color={segment.background}
|
||||
>{segment.text}</span>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.ansi-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ansi-dim {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.ansi-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ansi-underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.ansi-strikethrough {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
|
||||
.ansi-underline.ansi-strikethrough {
|
||||
text-decoration-line: underline line-through;
|
||||
}
|
||||
|
||||
.ansi-concealed {
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import AnsiText from '$lib/workspace/console/AnsiText.svelte';
|
||||
import RichMarkdown from '$lib/workspace/console/RichMarkdown.svelte';
|
||||
import type { ConsoleLine } from '$lib/workspace/console/model';
|
||||
|
||||
@@ -18,6 +19,10 @@
|
||||
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function isBashTool(line: ConsoleLine): boolean {
|
||||
return line.toolCall?.name?.toLowerCase() === 'bash';
|
||||
}
|
||||
|
||||
function shouldRenderHeading(line: ConsoleLine): boolean {
|
||||
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool' &&
|
||||
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
||||
@@ -60,7 +65,13 @@
|
||||
{/if}
|
||||
{#if item.kind === 'tool'}
|
||||
{#if bodyTextAfterToolSummary(item)}
|
||||
<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>
|
||||
<p class="console-plain-text">
|
||||
{#if isBashTool(item)}
|
||||
<AnsiText text={bodyTextAfterToolSummary(item)} />
|
||||
{:else}
|
||||
{bodyTextAfterToolSummary(item)}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{:else if item.kind === 'user'}
|
||||
<div class="user-message">
|
||||
@@ -193,6 +204,10 @@
|
||||
color: var(--tui-gray);
|
||||
}
|
||||
|
||||
.console-line.tool.tool-bash .console-plain-text {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tool-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
export type AnsiSegment = {
|
||||
text: string;
|
||||
foreground?: string;
|
||||
background?: string;
|
||||
bold: boolean;
|
||||
dim: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strikethrough: boolean;
|
||||
concealed: boolean;
|
||||
};
|
||||
|
||||
type AnsiState = Omit<AnsiSegment, "text"> & { inverse: boolean };
|
||||
|
||||
const ANSI_PALETTE = [
|
||||
"#1e1e1e",
|
||||
"#cd3131",
|
||||
"#0dbc79",
|
||||
"#e5e510",
|
||||
"#2472c8",
|
||||
"#bc3fbc",
|
||||
"#11a8cd",
|
||||
"#e5e5e5",
|
||||
"#666666",
|
||||
"#f14c4c",
|
||||
"#23d18b",
|
||||
"#f5f543",
|
||||
"#3b8eea",
|
||||
"#d670d6",
|
||||
"#29b8db",
|
||||
"#ffffff",
|
||||
] as const;
|
||||
|
||||
export function ansiSegments(input: string): AnsiSegment[] {
|
||||
const segments: AnsiSegment[] = [];
|
||||
let state = defaultState();
|
||||
let text = "";
|
||||
|
||||
const flush = () => {
|
||||
if (!text) return;
|
||||
segments.push(segmentFromState(text, state));
|
||||
text = "";
|
||||
};
|
||||
|
||||
for (let index = 0; index < input.length;) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code !== 0x1b) {
|
||||
if (code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) {
|
||||
text += input[index];
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flush();
|
||||
const next = input[index + 1];
|
||||
if (next === "[") {
|
||||
const finalIndex = findCsiFinal(input, index + 2);
|
||||
if (finalIndex < 0) break;
|
||||
if (input[finalIndex] === "m") {
|
||||
state = applySgr(state, input.slice(index + 2, finalIndex));
|
||||
}
|
||||
index = finalIndex + 1;
|
||||
continue;
|
||||
}
|
||||
if (next === "]") {
|
||||
const finalIndex = findOscFinal(input, index + 2);
|
||||
if (finalIndex < 0) break;
|
||||
index = finalIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
index += next === undefined ? 1 : 2;
|
||||
}
|
||||
|
||||
flush();
|
||||
return segments;
|
||||
}
|
||||
|
||||
function defaultState(): AnsiState {
|
||||
return {
|
||||
foreground: undefined,
|
||||
background: undefined,
|
||||
bold: false,
|
||||
dim: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
concealed: false,
|
||||
inverse: false,
|
||||
};
|
||||
}
|
||||
|
||||
function segmentFromState(text: string, state: AnsiState): AnsiSegment {
|
||||
const foreground = state.inverse
|
||||
? state.background ?? "var(--bg)"
|
||||
: state.foreground;
|
||||
const background = state.inverse
|
||||
? state.foreground ?? "var(--text)"
|
||||
: state.background;
|
||||
return {
|
||||
text,
|
||||
foreground,
|
||||
background,
|
||||
bold: state.bold,
|
||||
dim: state.dim,
|
||||
italic: state.italic,
|
||||
underline: state.underline,
|
||||
strikethrough: state.strikethrough,
|
||||
concealed: state.concealed,
|
||||
};
|
||||
}
|
||||
|
||||
function findCsiFinal(input: string, start: number): number {
|
||||
for (let index = start; index < input.length; index += 1) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code >= 0x40 && code <= 0x7e) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findOscFinal(input: string, start: number): number {
|
||||
for (let index = start; index < input.length; index += 1) {
|
||||
if (input.charCodeAt(index) === 0x07) return index + 1;
|
||||
if (input.charCodeAt(index) === 0x1b && input[index + 1] === "\\") {
|
||||
return index + 2;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function applySgr(current: AnsiState, raw: string): AnsiState {
|
||||
const state = { ...current };
|
||||
const values = raw === "" ? [0] : raw.split(";").map(parseSgrValue);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const code = values[index];
|
||||
if (code === null) continue;
|
||||
if (code === 0) Object.assign(state, defaultState());
|
||||
else if (code === 1) state.bold = true;
|
||||
else if (code === 2) state.dim = true;
|
||||
else if (code === 3) state.italic = true;
|
||||
else if (code === 4) state.underline = true;
|
||||
else if (code === 7) state.inverse = true;
|
||||
else if (code === 8) state.concealed = true;
|
||||
else if (code === 9) state.strikethrough = true;
|
||||
else if (code === 22) {
|
||||
state.bold = false;
|
||||
state.dim = false;
|
||||
} else if (code === 23) state.italic = false;
|
||||
else if (code === 24) state.underline = false;
|
||||
else if (code === 27) state.inverse = false;
|
||||
else if (code === 28) state.concealed = false;
|
||||
else if (code === 29) state.strikethrough = false;
|
||||
else if (code >= 30 && code <= 37) {
|
||||
state.foreground = ANSI_PALETTE[code - 30];
|
||||
} else if (code === 38 || code === 48) {
|
||||
const parsed = parseExtendedColor(values, index + 1);
|
||||
if (parsed) {
|
||||
if (code === 38) state.foreground = parsed.color;
|
||||
else state.background = parsed.color;
|
||||
index += parsed.consumed;
|
||||
}
|
||||
} else if (code === 39) state.foreground = undefined;
|
||||
else if (code >= 40 && code <= 47) {
|
||||
state.background = ANSI_PALETTE[code - 40];
|
||||
} else if (code === 49) state.background = undefined;
|
||||
else if (code >= 90 && code <= 97) {
|
||||
state.foreground = ANSI_PALETTE[code - 82];
|
||||
} else if (code >= 100 && code <= 107) {
|
||||
state.background = ANSI_PALETTE[code - 92];
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function parseSgrValue(value: string): number | null {
|
||||
if (!/^\d+$/.test(value)) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseExtendedColor(
|
||||
values: Array<number | null>,
|
||||
start: number,
|
||||
): { color: string; consumed: number } | null {
|
||||
const mode = values[start];
|
||||
const first = values[start + 1];
|
||||
if (mode === 5 && isByte(first)) {
|
||||
return { color: palette256(first), consumed: 2 };
|
||||
}
|
||||
const second = values[start + 2];
|
||||
const third = values[start + 3];
|
||||
if (mode === 2 && isByte(first) && isByte(second) && isByte(third)) {
|
||||
return {
|
||||
color: `rgb(${first}, ${second}, ${third})`,
|
||||
consumed: 4,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isByte(value: number | null | undefined): value is number {
|
||||
return value !== null && value !== undefined && value >= 0 && value <= 255;
|
||||
}
|
||||
|
||||
function palette256(index: number): string {
|
||||
if (index < 16) return ANSI_PALETTE[index];
|
||||
if (index < 232) {
|
||||
const offset = index - 16;
|
||||
const red = Math.floor(offset / 36);
|
||||
const green = Math.floor((offset % 36) / 6);
|
||||
const blue = offset % 6;
|
||||
return `rgb(${colorCube(red)}, ${colorCube(green)}, ${colorCube(blue)})`;
|
||||
}
|
||||
const gray = 8 + (index - 232) * 10;
|
||||
return `rgb(${gray}, ${gray}, ${gray})`;
|
||||
}
|
||||
|
||||
function colorCube(value: number): number {
|
||||
return value === 0 ? 0 : 55 + value * 40;
|
||||
}
|
||||
@@ -365,12 +365,17 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
|
||||
assert(
|
||||
consoleLine.includes("function shouldRenderMarkdown") &&
|
||||
consoleLine.includes("item.kind === 'tool'") &&
|
||||
consoleLine.includes("{#if isBashTool(item)}") &&
|
||||
consoleLine.includes(
|
||||
'<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>',
|
||||
"<AnsiText text={bodyTextAfterToolSummary(item)} />",
|
||||
) &&
|
||||
consoleLine.includes(
|
||||
".console-line.tool.tool-bash .console-plain-text",
|
||||
) &&
|
||||
consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
|
||||
consoleLine.includes("<RichMarkdown text={item.body || '—'} />"),
|
||||
"Console should keep markdown rendering to user/assistant/system message bodies and render tool text literally",
|
||||
consoleLine.includes("<RichMarkdown text={item.body || '—'} />") &&
|
||||
!consoleLine.includes("{@html"),
|
||||
"Console should keep markdown rendering to message bodies, safely project Bash ANSI, and render other tool text literally",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ansiSegments } from "../../src/lib/workspace/console/ansi.ts";
|
||||
|
||||
type TestRegistrar = (name: string, body: () => void) => void;
|
||||
|
||||
const test =
|
||||
(globalThis as unknown as { Deno: { test: TestRegistrar } }).Deno.test;
|
||||
|
||||
function assert(
|
||||
condition: boolean,
|
||||
message = "assertion failed",
|
||||
): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(
|
||||
`values differ:\nactual: ${actualJson}\nexpected: ${expectedJson}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test("ansiSegments projects standard colors and reset", () => {
|
||||
const segments = ansiSegments("plain \x1b[31mred\x1b[0m normal");
|
||||
|
||||
assertEquals(
|
||||
segments.map(({ text, foreground }) => ({ text, foreground })),
|
||||
[
|
||||
{ text: "plain ", foreground: undefined },
|
||||
{ text: "red", foreground: "#cd3131" },
|
||||
{ text: " normal", foreground: undefined },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("ansiSegments supports terminal styles, 256 colors, and truecolor", () => {
|
||||
const segments = ansiSegments(
|
||||
"\x1b[1;4;38;5;202mindexed\x1b[22;24;48;2;1;2;3mbackground\x1b[0m",
|
||||
);
|
||||
|
||||
assertEquals(segments[0], {
|
||||
text: "indexed",
|
||||
foreground: "rgb(255, 95, 0)",
|
||||
background: undefined,
|
||||
bold: true,
|
||||
dim: false,
|
||||
italic: false,
|
||||
underline: true,
|
||||
strikethrough: false,
|
||||
concealed: false,
|
||||
});
|
||||
assertEquals(segments[1], {
|
||||
text: "background",
|
||||
foreground: "rgb(255, 95, 0)",
|
||||
background: "rgb(1, 2, 3)",
|
||||
bold: false,
|
||||
dim: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
concealed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("ansiSegments keeps output as text and strips terminal control sequences", () => {
|
||||
const input =
|
||||
"\x1b]8;;https://example.invalid\x07<script>alert(1)</script>\x1b]8;;\x07" +
|
||||
"\x1b[2Ksafe\x00\x1b[31";
|
||||
const segments = ansiSegments(input);
|
||||
|
||||
assertEquals(
|
||||
segments.map((segment) => segment.text).join(""),
|
||||
"<script>alert(1)</script>safe",
|
||||
);
|
||||
assert(!segments.some((segment) => segment.text.includes("\x1b")));
|
||||
});
|
||||
Reference in New Issue
Block a user