workspace: add rich console rendering

This commit is contained in:
2026-07-11 07:46:03 +09:00
parent 5d07e9b9d5
commit db5c1d64d1
11 changed files with 891 additions and 15 deletions
+153 -1
View File
@@ -42,6 +42,14 @@
--success: oklch(48% 0.11 145);
--warning: oklch(62% 0.12 85);
--danger: oklch(54% 0.14 25);
--tui-green: #76946a;
--tui-red: #c34043;
--tui-yellow: #c0a36e;
--tui-blue: #7e9cd8;
--tui-cyan: #6a9589;
--tui-magenta: #957fb8;
--tui-gray: #727169;
--tui-dark-gray: #54546d;
--space-1: 4px;
--space-2: 8px;
@@ -1033,13 +1041,157 @@
.console-log li {
display: grid;
gap: var(--space-1);
padding: 0.2rem 0;
padding: 0.45rem 0 0.45rem 0.75rem;
border-left: 3px solid var(--line);
background: transparent;
}
.console-log li.console-line.assistant {
border-left-color: var(--tui-blue);
}
.console-log li.console-line.user {
border-left-color: var(--tui-green);
}
.console-log li.console-line.thinking {
border-left-color: var(--tui-magenta);
}
.console-log li.console-line.tool {
border-left-color: var(--tui-cyan);
}
.console-log li.console-line.tool-read {
border-left-color: var(--tui-blue);
}
.console-log li.console-line.tool-write,
.console-log li.console-line.tool-edit {
border-left-color: var(--tui-yellow);
}
.console-log li.console-line.tool-bash {
border-left-color: var(--tui-magenta);
}
.console-log li.console-line.tool-state-running,
.console-log li.console-line.tool-state-streaming_args,
.console-log li.console-line.tool-state-pending {
border-left-color: var(--tui-yellow);
}
.console-log li.console-line.tool-state-done {
border-left-color: var(--tui-green);
}
.console-log li.console-line.tool-state-error,
.console-log li.console-line.error-line {
border-left-color: var(--tui-red);
}
.rich-markdown {
color: inherit;
line-height: 1.55;
}
.rich-markdown > :first-child {
margin-top: 0;
}
.rich-markdown > :last-child {
margin-bottom: 0;
}
.rich-markdown p,
.rich-markdown ul,
.rich-markdown blockquote,
.rich-markdown pre {
margin: 0.45rem 0;
}
.rich-markdown ul {
padding-left: 1.2rem;
}
.rich-markdown h1,
.rich-markdown h2,
.rich-markdown h3,
.rich-markdown h4 {
margin: 0.7rem 0 0.35rem;
color: var(--text-strong);
font-size: 1rem;
}
.rich-markdown blockquote {
border-left: 2px solid var(--tui-dark-gray);
color: var(--text-muted);
padding-left: 0.8rem;
}
.rich-markdown :not(pre) > code {
background: color-mix(in oklch, var(--bg-raised) 80%, var(--tui-blue));
border: 1px solid var(--line);
border-radius: 0.35rem;
color: var(--tui-blue);
padding: 0.05rem 0.28rem;
}
.rich-markdown a {
color: var(--tui-cyan);
}
.rich-markdown .shiki {
border: 1px solid var(--line);
border-radius: 0.65rem;
overflow: auto;
padding: 0.75rem;
}
.console-diff {
background: color-mix(in oklch, var(--bg-raised) 85%, black);
border: 1px solid var(--line);
border-radius: 0.65rem;
color: var(--text);
font-size: 0.78rem;
line-height: 1.45;
margin: 0.6rem 0 0;
overflow-x: auto;
padding: 0.45rem 0;
}
.diff-line {
display: grid;
grid-template-columns: 3.2rem 3.2rem 1.4rem minmax(0, 1fr);
min-width: max-content;
}
.diff-line.add {
background: color-mix(in oklch, var(--tui-green) 18%, transparent);
color: color-mix(in oklch, var(--tui-green) 75%, white);
}
.diff-line.remove {
background: color-mix(in oklch, var(--tui-red) 18%, transparent);
color: color-mix(in oklch, var(--tui-red) 72%, white);
}
.diff-line.context {
color: var(--text-muted);
}
.diff-gutter,
.diff-marker {
color: var(--tui-gray);
padding: 0 0.5rem;
text-align: right;
user-select: none;
}
.diff-content {
padding-right: 0.75rem;
white-space: pre;
}
.console-log li.error-line {
color: var(--danger);
@@ -0,0 +1,39 @@
<script lang="ts">
import { markdownToHtml } from "$lib/workspace-console/markdown";
type Props = {
text: string;
class?: string;
};
let { text, class: className = "" }: Props = $props();
let html = $state("");
let rendering = $state(false);
async function render(value: string): Promise<void> {
const current = value;
rendering = true;
try {
const next = await markdownToHtml(current);
if (text === current) {
html = next;
}
} finally {
if (text === current) {
rendering = false;
}
}
}
$effect(() => {
void render(text);
});
</script>
<div class={`rich-markdown ${className}`} class:is-rendering={rendering}>
{#if html}
{@html html}
{:else}
<p>{text}</p>
{/if}
</div>
@@ -0,0 +1,36 @@
import { markdownToHtml } from "./markdown.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
Deno.test("markdownToHtml escapes raw html and renders basic markdown", async () => {
const html = await markdownToHtml(
"# Title\n\nhello **world** `<x>`\n\n<script>bad()</script>",
);
assert(html.includes("<h1>Title</h1>"), "heading should render");
assert(html.includes("<strong>world</strong>"), "strong should render");
assert(
html.includes("&lt;script&gt;bad()&lt;/script&gt;"),
"raw html should be escaped",
);
assert(
!html.includes("<script>bad()</script>"),
"raw html must not pass through",
);
});
Deno.test("markdownToHtml renders fenced code through shiki", async () => {
const html = await markdownToHtml("```ts\nconst answer: number = 42;\n```");
assert(
html.includes("shiki"),
"highlighted code should include shiki markup",
);
assert(html.includes("answer"), "code content should be present");
});
@@ -0,0 +1,225 @@
import { codeToHtml } from "shiki";
const SHIKI_THEME = "kanagawa-wave";
export async function markdownToHtml(markdown: string): Promise<string> {
if (!markdown.trim()) {
return "";
}
const blocks = splitFencedCode(markdown);
const html: string[] = [];
let textBuffer: string[] = [];
async function flushText(): Promise<void> {
if (textBuffer.length === 0) {
return;
}
html.push(renderMarkdownText(textBuffer.join("\n")));
textBuffer = [];
}
for (const block of blocks) {
if (block.kind === "text") {
textBuffer.push(block.text);
continue;
}
await flushText();
html.push(await highlightCode(block.code, block.lang));
}
await flushText();
return html.join("\n");
}
type MarkdownBlock =
| { kind: "text"; text: string }
| { kind: "code"; lang: string; code: string };
function splitFencedCode(markdown: string): MarkdownBlock[] {
const lines = markdown.split(/\r?\n/);
const blocks: MarkdownBlock[] = [];
let text: string[] = [];
let code: string[] | null = null;
let lang = "text";
for (const line of lines) {
const fence = line.match(/^```\s*([^`]*)\s*$/);
if (fence) {
if (code) {
blocks.push({ kind: "text", text: text.join("\n") });
text = [];
blocks.push({ kind: "code", lang, code: code.join("\n") });
code = null;
lang = "text";
} else {
if (text.length > 0) {
blocks.push({ kind: "text", text: text.join("\n") });
text = [];
}
lang = normalizeLanguage(fence[1]);
code = [];
}
continue;
}
if (code) {
code.push(line);
} else {
text.push(line);
}
}
if (code) {
text.push("```" + (lang === "text" ? "" : lang));
text.push(...code);
}
if (text.length > 0) {
blocks.push({ kind: "text", text: text.join("\n") });
}
return blocks.filter((block) =>
block.kind === "code" || block.text.trim().length > 0
);
}
function renderMarkdownText(markdown: string): string {
const lines = markdown.split(/\r?\n/);
const html: string[] = [];
let paragraph: string[] = [];
let list: string[] = [];
let blockquote: string[] = [];
function flushParagraph(): void {
if (paragraph.length === 0) {
return;
}
html.push(`<p>${paragraph.map(renderInline).join("<br>")}</p>`);
paragraph = [];
}
function flushList(): void {
if (list.length === 0) {
return;
}
html.push(
`<ul>${
list.map((item) => `<li>${renderInline(item)}</li>`).join("")
}</ul>`,
);
list = [];
}
function flushBlockquote(): void {
if (blockquote.length === 0) {
return;
}
html.push(
`<blockquote>${blockquote.map(renderInline).join("<br>")}</blockquote>`,
);
blockquote = [];
}
function flushAll(): void {
flushParagraph();
flushList();
flushBlockquote();
}
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line.trim()) {
flushAll();
continue;
}
const heading = line.match(/^(#{1,4})\s+(.+)$/);
if (heading) {
flushAll();
const level = heading[1].length;
html.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
continue;
}
const listItem = line.match(/^\s*[-*]\s+(.+)$/);
if (listItem) {
flushParagraph();
flushBlockquote();
list.push(listItem[1]);
continue;
}
const quote = line.match(/^>\s?(.*)$/);
if (quote) {
flushParagraph();
flushList();
blockquote.push(quote[1]);
continue;
}
flushList();
flushBlockquote();
paragraph.push(line);
}
flushAll();
return html.join("\n");
}
function renderInline(value: string): string {
let escaped = escapeHtml(value);
escaped = escaped.replace(/`([^`]+)`/g, "<code>$1</code>");
escaped = escaped.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
escaped = escaped.replace(/\b(https?:\/\/[^\s<]+[^\s<.,;:)])/g, (url) => {
const safe = escapeAttribute(url);
return `<a href="${safe}" rel="noreferrer" target="_blank">${url}</a>`;
});
return escaped;
}
async function highlightCode(code: string, lang: string): Promise<string> {
try {
return await codeToHtml(code, {
lang: lang || "text",
theme: SHIKI_THEME,
});
} catch {
return `<pre class="shiki fallback"><code>${escapeHtml(code)}</code></pre>`;
}
}
function normalizeLanguage(value: string | undefined): string {
const raw = value?.trim().split(/\s+/, 1)[0] ?? "";
if (!raw) {
return "text";
}
switch (raw) {
case "sh":
case "shell":
case "zsh":
return "bash";
case "ts":
return "typescript";
case "js":
return "javascript";
default:
return raw;
}
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"]/g, (char) => {
switch (char) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return char;
}
});
}
function escapeAttribute(value: string): string {
return escapeHtml(value).replace(/'/g, "&#39;");
}
@@ -304,6 +304,49 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
);
});
Deno.test("projectConsole renders Edit calls with structured diff lines", () => {
const projection = projectConsole([
{
cursor: "60",
event: {
event: "tool_call_done",
data: {
id: "edit-1",
name: "Edit",
arguments: JSON.stringify({
file_path: "/tmp/a.md",
old_string: "one\ntwo\nthree",
new_string: "one\nTWO\nthree\nfour",
}),
},
} satisfies Event,
},
{
cursor: "61",
event: {
event: "tool_result",
data: {
id: "edit-1",
summary: "edited",
output: "ok",
is_error: false,
},
} satisfies Event,
},
]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Edit");
assert(line.body.includes("diff: -1 +2"), "diff summary should be shown");
assertEquals(line.diff?.map((row) => row.kind), [
"context",
"remove",
"add",
"context",
"add",
]);
});
Deno.test("projectConsole preserves in-progress assistant protocol stream", () => {
const projection = projectConsole([
{
@@ -35,12 +35,20 @@ type ToolCallView = {
isError?: boolean;
};
export type ConsoleDiffLine = {
kind: "context" | "add" | "remove";
oldNumber?: number;
newNumber?: number;
content: string;
};
export type ConsoleLine = {
id: string;
kind: ConsoleLineKind;
title: string;
body: string;
detail?: string;
diff?: ConsoleDiffLine[];
cursor?: string | null;
source: "event";
streaming?: boolean;
@@ -455,6 +463,7 @@ function refreshToolLine(item: ConsoleLine): void {
: `Call · ${toolCall.name}`;
item.body = renderToolCall(toolCall);
item.detail = toolCallDetail(toolCall);
item.diff = toolCall.name === "Edit" ? editDiff(toolCall) : undefined;
item.streaming = !["done", "error"].includes(toolCall.state);
item.error = toolCall.state === "error";
}
@@ -563,21 +572,95 @@ function renderWriteTool(toolCall: ToolCallView): string {
function renderEditTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const path = stringField(args, "file_path") ?? "?";
const oldString = stringField(args, "old_string");
const newString = stringField(args, "new_string");
const change = oldString || newString
? compactLines([
oldString ? `- ${firstLine(oldString)}` : undefined,
newString ? `+ ${firstLine(newString)}` : undefined,
])
: undefined;
const diff = editDiff(toolCall) ?? [];
const removes = diff.filter((line) => line.kind === "remove").length;
const adds = diff.filter((line) => line.kind === "add").length;
return compactLines([
`Edit — ${path} (${stateSuffix(toolCall.state)})`,
change,
diff.length > 0 ? `diff: -${removes} +${adds}` : undefined,
resultText(toolCall),
]);
}
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
const args = parsedArgs(toolCall);
const oldString = stringField(args, "old_string");
const newString = stringField(args, "new_string");
if (oldString === undefined && newString === undefined) {
return undefined;
}
return diffLines(oldString ?? "", newString ?? "");
}
function diffLines(oldText: string, newText: string): ConsoleDiffLine[] {
const oldLines = oldText.split(/\r?\n/);
const newLines = newText.split(/\r?\n/);
const table = lcsTable(oldLines, newLines);
const rows: ConsoleDiffLine[] = [];
let oldIndex = 0;
let newIndex = 0;
while (oldIndex < oldLines.length && newIndex < newLines.length) {
if (oldLines[oldIndex] === newLines[newIndex]) {
rows.push({
kind: "context",
oldNumber: oldIndex + 1,
newNumber: newIndex + 1,
content: oldLines[oldIndex],
});
oldIndex += 1;
newIndex += 1;
} else if (
table[oldIndex + 1]?.[newIndex] >= table[oldIndex]?.[newIndex + 1]
) {
rows.push({
kind: "remove",
oldNumber: oldIndex + 1,
content: oldLines[oldIndex],
});
oldIndex += 1;
} else {
rows.push({
kind: "add",
newNumber: newIndex + 1,
content: newLines[newIndex],
});
newIndex += 1;
}
}
while (oldIndex < oldLines.length) {
rows.push({
kind: "remove",
oldNumber: oldIndex + 1,
content: oldLines[oldIndex],
});
oldIndex += 1;
}
while (newIndex < newLines.length) {
rows.push({
kind: "add",
newNumber: newIndex + 1,
content: newLines[newIndex],
});
newIndex += 1;
}
return rows;
}
function lcsTable(oldLines: string[], newLines: string[]): number[][] {
const rows = Array.from(
{ length: oldLines.length + 1 },
() => Array(newLines.length + 1).fill(0),
);
for (let oldIndex = oldLines.length - 1; oldIndex >= 0; oldIndex -= 1) {
for (let newIndex = newLines.length - 1; newIndex >= 0; newIndex -= 1) {
rows[oldIndex][newIndex] = oldLines[oldIndex] === newLines[newIndex]
? rows[oldIndex + 1][newIndex + 1] + 1
: Math.max(rows[oldIndex + 1][newIndex], rows[oldIndex][newIndex + 1]);
}
}
return rows;
}
function renderSearchTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim();
return compactLines([
@@ -1,5 +1,6 @@
<script lang="ts">
import { tick } from 'svelte';
import RichMarkdown from '$lib/workspace-console/RichMarkdown.svelte';
import {
projectConsole,
type ConsoleLine
@@ -243,6 +244,12 @@
return line.error ? 'error' : line.kind;
}
function toolClass(line: ConsoleLine): string {
const name = line.toolCall?.name?.toLowerCase() ?? '';
const state = line.toolCall?.state ?? (line.streaming ? 'streaming' : 'done');
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' ');
}
function isNearConsoleBottom(element: HTMLElement): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD_PX;
}
@@ -326,7 +333,7 @@
{:else}
<ol class="console-log">
{#each lines as item}
<li class:assistant={lineClass(item) === 'assistant'} class:user={lineClass(item) === 'user'} class:system={lineClass(item) !== 'assistant' && lineClass(item) !== 'user'} class:error-line={item.error}>
<li class={`console-line ${lineClass(item)} ${toolClass(item)}`} class:error-line={item.error}>
{#if lineClass(item) !== 'assistant' && lineClass(item) !== 'user'}
<div class="message-heading">
<span>{item.title}</span>
@@ -337,7 +344,11 @@
<small>streaming</small>
</div>
{/if}
<pre>{item.body || '—'}</pre>
<RichMarkdown text={item.body || '—'} />
{#if item.diff}
<pre class="console-diff" aria-label="Edit diff">{#each item.diff as diffLine}
<span class={`diff-line ${diffLine.kind}`}><span class="diff-gutter">{diffLine.oldNumber ?? ''}</span><span class="diff-gutter">{diffLine.newNumber ?? ''}</span><span class="diff-marker">{diffLine.kind === 'add' ? '+' : diffLine.kind === 'remove' ? '-' : ' '}</span><span class="diff-content">{diffLine.content}</span></span>{/each}</pre>
{/if}
{#if item.detail}
<details class="message-detail">
<summary>detail</summary>