workspace: add rich console rendering
This commit is contained in:
+153
-1
@@ -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("<script>bad()</script>"),
|
||||
"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 "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return escapeHtml(value).replace(/'/g, "'");
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
+13
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user