feat: render bash ANSI colors in web console

This commit is contained in:
2026-08-21 13:36:03 +09:00
parent cbedc76d06
commit dd47dba5b0
6 changed files with 373 additions and 5 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "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", "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", "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", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "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"> <script lang="ts">
import AnsiText from '$lib/workspace/console/AnsiText.svelte';
import RichMarkdown from '$lib/workspace/console/RichMarkdown.svelte'; import RichMarkdown from '$lib/workspace/console/RichMarkdown.svelte';
import type { ConsoleLine } from '$lib/workspace/console/model'; import type { ConsoleLine } from '$lib/workspace/console/model';
@@ -18,6 +19,10 @@
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' '); 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 { function shouldRenderHeading(line: ConsoleLine): boolean {
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool' && return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool' &&
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats'; line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
@@ -60,7 +65,13 @@
{/if} {/if}
{#if item.kind === 'tool'} {#if item.kind === 'tool'}
{#if bodyTextAfterToolSummary(item)} {#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} {/if}
{:else if item.kind === 'user'} {:else if item.kind === 'user'}
<div class="user-message"> <div class="user-message">
@@ -193,6 +204,10 @@
color: var(--tui-gray); color: var(--tui-gray);
} }
.console-line.tool.tool-bash .console-plain-text {
color: var(--text);
}
.tool-summary { .tool-summary {
display: flex; display: flex;
align-items: baseline; 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( assert(
consoleLine.includes("function shouldRenderMarkdown") && consoleLine.includes("function shouldRenderMarkdown") &&
consoleLine.includes("item.kind === 'tool'") && consoleLine.includes("item.kind === 'tool'") &&
consoleLine.includes("{#if isBashTool(item)}") &&
consoleLine.includes( 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("{:else if shouldRenderMarkdown(item)}") &&
consoleLine.includes("<RichMarkdown text={item.body || '—'} />"), consoleLine.includes("<RichMarkdown text={item.body || '—'} />") &&
"Console should keep markdown rendering to user/assistant/system message bodies and render tool text literally", !consoleLine.includes("{@html"),
"Console should keep markdown rendering to message bodies, safely project Bash ANSI, and render other tool text literally",
); );
}); });
+78
View File
@@ -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")));
});