ui: refine worker console streaming
This commit is contained in:
parent
ddbcd595ef
commit
520c10d807
|
|
@ -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 -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --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 --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.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/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/chat-submit.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.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/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|
@ -1048,8 +1052,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-log li {
|
.console-log li {
|
||||||
display: grid;
|
min-width: 0;
|
||||||
gap: var(--space-1);
|
|
||||||
padding: 0.2rem 0;
|
padding: 0.2rem 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
@ -1059,6 +1062,22 @@
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.console-plain-text {
|
||||||
|
margin: 0.45rem 0;
|
||||||
|
color: inherit;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-plain-text:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-plain-text:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.rich-markdown > :first-child {
|
.rich-markdown > :first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -1174,7 +1193,7 @@
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-log li.thinking .rich-markdown {
|
.console-log li.thinking .console-plain-text {
|
||||||
color: var(--tui-dark-gray);
|
color: var(--tui-dark-gray);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
@ -1197,7 +1216,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-label {
|
.tool-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
color: var(--tui-cyan);
|
color: var(--tui-cyan);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-separator {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-suffix {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-separator,
|
.tool-separator,
|
||||||
|
|
@ -1219,7 +1251,16 @@
|
||||||
color: var(--tui-dark-gray);
|
color: var(--tui-dark-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-line.tool .rich-markdown {
|
.console-line.tool-bash .console-plain-text {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-line.tool .console-plain-text {
|
||||||
color: var(--tui-gray);
|
color: var(--tui-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1324,13 +1365,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-composer textarea {
|
.console-composer textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 7rem;
|
min-height: 0;
|
||||||
resize: vertical;
|
resize: none;
|
||||||
|
overflow-y: hidden;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
line-height: 1.45;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import RichMarkdown from '$lib/workspace-console/RichMarkdown.svelte';
|
||||||
|
import type { ConsoleLine } from '$lib/workspace-console/model';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: ConsoleLine;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { item }: Props = $props();
|
||||||
|
|
||||||
|
function lineClass(line: ConsoleLine): string {
|
||||||
|
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 shouldRenderHeading(line: ConsoleLine): boolean {
|
||||||
|
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
|
||||||
|
const [firstLine = '', ...rest] = line.body.split('\n');
|
||||||
|
const [label, suffix = ''] = firstLine.split(' — ', 2);
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
suffix,
|
||||||
|
rest: rest.join('\n')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRenderMarkdown(line: ConsoleLine): boolean {
|
||||||
|
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyTextAfterToolSummary(line: ConsoleLine): string {
|
||||||
|
return toolSummary(line).rest;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li class={`console-line ${lineClass(item)} ${toolClass(item)}`} class:error-line={item.error}>
|
||||||
|
{#if shouldRenderHeading(item)}
|
||||||
|
<div class="message-heading">
|
||||||
|
<span>{item.title}</span>
|
||||||
|
{#if item.streaming}<small>streaming</small>{/if}
|
||||||
|
</div>
|
||||||
|
{:else if item.kind === 'tool'}
|
||||||
|
<div class="tool-summary">
|
||||||
|
<span class="tool-label">{toolSummary(item).label}</span>
|
||||||
|
<span class="tool-separator"> — </span>
|
||||||
|
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
||||||
|
{#if item.streaming}<small>streaming</small>{/if}
|
||||||
|
</div>
|
||||||
|
{:else if item.streaming}
|
||||||
|
<div class="message-heading streaming-heading">
|
||||||
|
<small>streaming</small>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if item.kind === 'tool'}
|
||||||
|
{#if bodyTextAfterToolSummary(item)}
|
||||||
|
<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>
|
||||||
|
{/if}
|
||||||
|
{:else if shouldRenderMarkdown(item)}
|
||||||
|
<RichMarkdown text={item.body || '—'} />
|
||||||
|
{:else}
|
||||||
|
<p class="console-plain-text">{item.body || '—'}</p>
|
||||||
|
{/if}
|
||||||
|
{#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>
|
||||||
|
<p>{item.detail}</p>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
65
web/workspace/src/lib/workspace-console/chat-submit.test.ts
Normal file
65
web/workspace/src/lib/workspace-console/chat-submit.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { shouldSubmitChatKey } from "./chat-submit.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEquals<T>(actual: T, expected: T): void {
|
||||||
|
const actualJson = JSON.stringify(actual);
|
||||||
|
const expectedJson = JSON.stringify(expected);
|
||||||
|
if (actualJson !== expectedJson) {
|
||||||
|
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("shouldSubmitChatKey defaults to Cmd+Enter submit behavior", () => {
|
||||||
|
assert(
|
||||||
|
shouldSubmitChatKey(
|
||||||
|
{ key: "Enter", metaKey: true },
|
||||||
|
{ mode: "mod-enter", modKey: "meta", enabled: true },
|
||||||
|
),
|
||||||
|
"Cmd+Enter should submit by default",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldSubmitChatKey(
|
||||||
|
{ key: "Enter" },
|
||||||
|
{ mode: "mod-enter", modKey: "meta", enabled: true },
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldSubmitChatKey(
|
||||||
|
{ key: "Enter", ctrlKey: true },
|
||||||
|
{ mode: "mod-enter", modKey: "meta", enabled: true },
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("shouldSubmitChatKey ignores IME composition and repeated Enter", () => {
|
||||||
|
const options = { mode: "mod-enter" as const, modKey: "meta" as const, enabled: true };
|
||||||
|
assertEquals(
|
||||||
|
shouldSubmitChatKey({ key: "Enter", metaKey: true, isComposing: true }, options),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldSubmitChatKey({ key: "Enter", metaKey: true, keyCode: 229 }, options),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldSubmitChatKey({ key: "Enter", metaKey: true, repeat: true }, options),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("shouldSubmitChatKey supports enter submit mode", () => {
|
||||||
|
const options = { mode: "enter" as const, modKey: "meta" as const, enabled: true };
|
||||||
|
assert(shouldSubmitChatKey({ key: "Enter" }, options), "Enter should submit");
|
||||||
|
assertEquals(shouldSubmitChatKey({ key: "Enter", shiftKey: true }, options), false);
|
||||||
|
});
|
||||||
144
web/workspace/src/lib/workspace-console/chat-submit.ts
Normal file
144
web/workspace/src/lib/workspace-console/chat-submit.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
export type ChatSubmitMode = "mod-enter" | "enter";
|
||||||
|
export type ChatSubmitModKey = "meta" | "ctrl" | "auto";
|
||||||
|
|
||||||
|
export type ChatSubmitOptions = {
|
||||||
|
onSubmit: (value: string, ctx: { target: HTMLTextAreaElement }) => void;
|
||||||
|
mode?: ChatSubmitMode;
|
||||||
|
modKey?: ChatSubmitModKey;
|
||||||
|
allowEmptySubmit?: boolean;
|
||||||
|
stopPropagation?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NormalizedChatSubmitOptions = Required<ChatSubmitOptions>;
|
||||||
|
|
||||||
|
export type ChatSubmitKeyEventLike = {
|
||||||
|
key: string;
|
||||||
|
shiftKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
repeat?: boolean;
|
||||||
|
isComposing?: boolean;
|
||||||
|
keyCode?: number;
|
||||||
|
which?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeOptions(options: ChatSubmitOptions): NormalizedChatSubmitOptions {
|
||||||
|
return {
|
||||||
|
mode: "mod-enter",
|
||||||
|
modKey: "meta",
|
||||||
|
allowEmptySubmit: false,
|
||||||
|
stopPropagation: false,
|
||||||
|
enabled: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImeEvent(event: ChatSubmitKeyEventLike): boolean {
|
||||||
|
return event.isComposing === true ||
|
||||||
|
event.key === "Process" ||
|
||||||
|
event.keyCode === 229 ||
|
||||||
|
event.which === 229;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApplePlatform(): boolean {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const platform = navigator.platform || "";
|
||||||
|
const userAgent = navigator.userAgent || "";
|
||||||
|
return /Mac|iPhone|iPad|iPod/.test(platform) || /Mac|iPhone|iPad|iPod/.test(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModKey(modKey: ChatSubmitModKey): "meta" | "ctrl" {
|
||||||
|
if (modKey === "auto") {
|
||||||
|
return isApplePlatform() ? "meta" : "ctrl";
|
||||||
|
}
|
||||||
|
return modKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModPressed(event: ChatSubmitKeyEventLike, modKey: ChatSubmitModKey): boolean {
|
||||||
|
return resolveModKey(modKey) === "meta" ? event.metaKey === true : event.ctrlKey === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSubmitChatKey(
|
||||||
|
event: ChatSubmitKeyEventLike,
|
||||||
|
options: Pick<NormalizedChatSubmitOptions, "mode" | "modKey" | "enabled"> & {
|
||||||
|
isComposing?: boolean;
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
if (!options.enabled || event.repeat || event.key !== "Enter") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (options.isComposing || isImeEvent(event)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (options.mode === "enter") {
|
||||||
|
return event.shiftKey !== true;
|
||||||
|
}
|
||||||
|
return isModPressed(event, options.modKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chatSubmit(node: HTMLTextAreaElement, options: ChatSubmitOptions) {
|
||||||
|
let current = normalizeOptions(options);
|
||||||
|
let isComposing = false;
|
||||||
|
|
||||||
|
function triggerSubmit() {
|
||||||
|
if (!current.enabled || node.disabled || node.readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = node.value ?? "";
|
||||||
|
if (!current.allowEmptySubmit && value.trim() === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.onSubmit(value, { target: node });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (isImeEvent(event)) {
|
||||||
|
isComposing = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shouldSubmitChatKey(event, { ...current, isComposing })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (current.stopPropagation) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
triggerSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(event: KeyboardEvent) {
|
||||||
|
if (!event.isComposing) {
|
||||||
|
isComposing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCompositionStart() {
|
||||||
|
isComposing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearComposition() {
|
||||||
|
isComposing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener("keydown", onKeyDown);
|
||||||
|
node.addEventListener("keyup", onKeyUp);
|
||||||
|
node.addEventListener("compositionstart", onCompositionStart);
|
||||||
|
node.addEventListener("compositionend", clearComposition);
|
||||||
|
node.addEventListener("compositioncancel", clearComposition);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(nextOptions: ChatSubmitOptions) {
|
||||||
|
current = normalizeOptions(nextOptions);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener("keydown", onKeyDown);
|
||||||
|
node.removeEventListener("keyup", onKeyUp);
|
||||||
|
node.removeEventListener("compositionstart", onCompositionStart);
|
||||||
|
node.removeEventListener("compositionend", clearComposition);
|
||||||
|
node.removeEventListener("compositioncancel", clearComposition);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import type { Event } from "$lib/generated/protocol";
|
import type { Event } from "$lib/generated/protocol";
|
||||||
import { projectConsole, segmentsToText, workerConsoleHref } from "./model.ts";
|
import {
|
||||||
|
createConsoleProjector,
|
||||||
|
projectConsole,
|
||||||
|
segmentsToText,
|
||||||
|
workerConsoleHref,
|
||||||
|
} from "./model.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => void): void;
|
test(name: string, fn: () => void): void;
|
||||||
|
|
@ -175,7 +180,7 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
|
||||||
data: {
|
data: {
|
||||||
id: "call-1",
|
id: "call-1",
|
||||||
summary: "command completed",
|
summary: "command completed",
|
||||||
output: "/repo\n",
|
output: "/repo\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12",
|
||||||
is_error: false,
|
is_error: false,
|
||||||
},
|
},
|
||||||
} satisfies Event,
|
} satisfies Event,
|
||||||
|
|
@ -197,12 +202,153 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
|
||||||
toolLines[0].body.includes("/repo"),
|
toolLines[0].body.includes("/repo"),
|
||||||
"tool result should be folded into the Call block",
|
"tool result should be folded into the Call block",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
toolLines[0].body.includes("line9"),
|
||||||
|
"Bash result preview should include the ninth output line",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!toolLines[0].body.includes("line10") &&
|
||||||
|
!toolLines[0].body.includes("line12"),
|
||||||
|
"Bash result preview should be capped at ten display lines",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
toolLines[0].body.includes("… +3 more lines"),
|
||||||
|
"Bash result preview should show omitted output count",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].detail?.includes("id: call-1"),
|
toolLines[0].detail?.includes("id: call-1"),
|
||||||
"call id should remain in detail",
|
"call id should remain in detail",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole caps default tool request and result previews", () => {
|
||||||
|
const projection = projectConsole([
|
||||||
|
{
|
||||||
|
eventId: "70",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: {
|
||||||
|
id: "custom-1",
|
||||||
|
name: "CustomTool",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
first: "one",
|
||||||
|
second: "two",
|
||||||
|
third: "three",
|
||||||
|
fourth: "four",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "71",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "custom-1",
|
||||||
|
summary: "custom completed",
|
||||||
|
output: "out1\nout2\nout3\nout4\nout5",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
|
assertEquals(line.title, "Call · CustomTool");
|
||||||
|
assertEquals(line.body.split("\n").length, 7);
|
||||||
|
assert(line.body.includes("CustomTool — done"), "tool state should be shown");
|
||||||
|
assert(line.body.includes('"first": "one"'), "request preview should be shown");
|
||||||
|
assert(line.body.includes("out1"), "result preview should be shown");
|
||||||
|
assert(!line.body.includes("third"), "request preview should be capped");
|
||||||
|
assert(!line.body.includes("out3"), "result preview should be capped");
|
||||||
|
assert(line.body.includes("… +"), "overflow marker should be shown");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole shows Grep query and caps result preview to five entries", () => {
|
||||||
|
const projection = projectConsole([
|
||||||
|
{
|
||||||
|
eventId: "72",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: {
|
||||||
|
id: "grep-1",
|
||||||
|
name: "Grep",
|
||||||
|
arguments: JSON.stringify({ pattern: "needle", path: "/repo" }),
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "73",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "grep-1",
|
||||||
|
summary: "6 matches",
|
||||||
|
output: "hit1\nhit2\nhit3\nhit4\nhit5\nhit6",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
|
assertEquals(line.title, "Call · Grep");
|
||||||
|
assert(line.body.includes("Grep — 6 matches"), "Grep summary should be shown");
|
||||||
|
assert(line.body.includes("query: needle"), "Grep query should be shown");
|
||||||
|
assert(line.body.includes("hit1"), "first result should be shown");
|
||||||
|
assert(line.body.includes("hit5"), "fifth result should be shown");
|
||||||
|
assert(!line.body.includes("hit6"), "sixth result should be capped");
|
||||||
|
assert(
|
||||||
|
line.body.includes("… +1 more results"),
|
||||||
|
"overflow marker should be shown",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole keeps Grep error detail in the body", () => {
|
||||||
|
const message =
|
||||||
|
"Tool execution failed: Invalid argument: path is outside allowed scope: /home/hare/.yoi/workdirs/working-directories/0019f5bce74f1000000/root/main/ghq.local/github/openai/codex";
|
||||||
|
const projection = projectConsole([
|
||||||
|
{
|
||||||
|
eventId: "74",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: {
|
||||||
|
id: "grep-error-1",
|
||||||
|
name: "Grep",
|
||||||
|
arguments: JSON.stringify({ pattern: "needle", path: "/outside" }),
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "75",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "grep-error-1",
|
||||||
|
summary: message,
|
||||||
|
output: message,
|
||||||
|
is_error: true,
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
|
assertEquals(line.title, "Call · Grep");
|
||||||
|
assert(
|
||||||
|
line.body.includes("Grep — Failed"),
|
||||||
|
"error suffix should stay short",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
line.body.includes(message),
|
||||||
|
"error detail should remain visible in the body",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!line.body.includes(`Grep — ${message}`),
|
||||||
|
"error detail should not be repeated in the suffix",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
|
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
|
||||||
const projection = projectConsole([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
|
|
@ -232,6 +378,85 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("createConsoleProjector replaces only the updated protocol block", () => {
|
||||||
|
const projector = createConsoleProjector();
|
||||||
|
let projection = projector.append([
|
||||||
|
{
|
||||||
|
eventId: "identity-1",
|
||||||
|
event: {
|
||||||
|
event: "user_message",
|
||||||
|
data: { segments: [{ kind: "text", content: "hello" }] },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "identity-2",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_start",
|
||||||
|
data: { id: "grep-a", name: "Grep" },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "identity-3",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_start",
|
||||||
|
data: { id: "grep-b", name: "Grep" },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userLine = projection.lines[0];
|
||||||
|
const grepA = projection.lines[1];
|
||||||
|
const grepB = projection.lines[2];
|
||||||
|
|
||||||
|
projection = projector.append([
|
||||||
|
{
|
||||||
|
eventId: "identity-4",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_args_delta",
|
||||||
|
data: { id: "grep-a", json: '{"pattern":"needle"}' },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
projection.lines[0] === userLine,
|
||||||
|
"unrelated message line should keep object identity",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
projection.lines[1] !== grepA,
|
||||||
|
"updated tool line should get a new object identity",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
projection.lines[2] === grepB,
|
||||||
|
"parallel unrelated tool line should keep object identity",
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedGrepA = projection.lines[1];
|
||||||
|
projection = projector.append([
|
||||||
|
{
|
||||||
|
eventId: "identity-5",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "grep-b",
|
||||||
|
summary: "done",
|
||||||
|
output: "hit",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
projection.lines[1] === updatedGrepA,
|
||||||
|
"previously updated but now unrelated tool line should keep identity",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
projection.lines[2] !== grepB,
|
||||||
|
"completed parallel tool line should get a new object identity",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole aggregates Read calls without showing file content", () => {
|
Deno.test("projectConsole aggregates Read calls without showing file content", () => {
|
||||||
const projection = projectConsole([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -95,15 +95,42 @@ export function workerConsolePath(
|
||||||
|
|
||||||
export type ConsoleEventInput = { eventId: string; event: ProtocolEvent };
|
export type ConsoleEventInput = { eventId: string; event: ProtocolEvent };
|
||||||
|
|
||||||
export function projectConsole(
|
export function emptyConsoleProjection(): ConsoleProjection {
|
||||||
events: ConsoleEventInput[] = [],
|
return {
|
||||||
): ConsoleProjection {
|
|
||||||
const projection = events.reduce(applyProtocolEvent, {
|
|
||||||
lines: [],
|
lines: [],
|
||||||
status: null,
|
status: null,
|
||||||
usage: null,
|
usage: null,
|
||||||
lastEventId: null,
|
lastEventId: null,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectConsole(
|
||||||
|
events: ConsoleEventInput[] = [],
|
||||||
|
): ConsoleProjection {
|
||||||
|
const projection = events.reduce(applyProtocolEvent, emptyConsoleProjection());
|
||||||
|
return projectVisibleConsole(projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConsoleProjector() {
|
||||||
|
let projection = emptyConsoleProjection();
|
||||||
|
return {
|
||||||
|
reset(): ConsoleProjection {
|
||||||
|
projection = emptyConsoleProjection();
|
||||||
|
return projectVisibleConsole(projection);
|
||||||
|
},
|
||||||
|
append(events: ConsoleEventInput[]): ConsoleProjection {
|
||||||
|
for (const event of events) {
|
||||||
|
projection = applyProtocolEvent(projection, event);
|
||||||
|
}
|
||||||
|
return projectVisibleConsole(projection);
|
||||||
|
},
|
||||||
|
snapshot(): ConsoleProjection {
|
||||||
|
return projectVisibleConsole(projection);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectVisibleConsole(projection: ConsoleProjection): ConsoleProjection {
|
||||||
return {
|
return {
|
||||||
...projection,
|
...projection,
|
||||||
lines: aggregateReadToolLines(projection.lines),
|
lines: aggregateReadToolLines(projection.lines),
|
||||||
|
|
@ -314,6 +341,18 @@ function line(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findLastLineIndex(
|
||||||
|
lines: ConsoleLine[],
|
||||||
|
predicate: (line: ConsoleLine) => boolean,
|
||||||
|
): number {
|
||||||
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (predicate(lines[index])) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
function appendStreaming(
|
function appendStreaming(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
|
|
@ -321,12 +360,17 @@ function appendStreaming(
|
||||||
title: string,
|
title: string,
|
||||||
delta: string,
|
delta: string,
|
||||||
): void {
|
): void {
|
||||||
const existing = [...projection.lines].reverse().find((item) =>
|
const index = findLastLineIndex(
|
||||||
item.kind === kind && item.streaming
|
projection.lines,
|
||||||
|
(item) => item.kind === kind && item.streaming === true,
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (index >= 0) {
|
||||||
existing.body += delta;
|
const existing = projection.lines[index];
|
||||||
existing.eventId = eventId;
|
projection.lines[index] = {
|
||||||
|
...existing,
|
||||||
|
body: `${existing.body}${delta}`,
|
||||||
|
eventId,
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
projection.lines.push(line(eventId, kind, title, delta, undefined, true));
|
projection.lines.push(line(eventId, kind, title, delta, undefined, true));
|
||||||
|
|
@ -339,14 +383,19 @@ function finalizeStreaming(
|
||||||
title: string,
|
title: string,
|
||||||
body: string,
|
body: string,
|
||||||
): void {
|
): void {
|
||||||
const existing = [...projection.lines].reverse().find((item) =>
|
const index = findLastLineIndex(
|
||||||
item.kind === kind && item.streaming
|
projection.lines,
|
||||||
|
(item) => item.kind === kind && item.streaming === true,
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (index >= 0) {
|
||||||
existing.body = body || existing.body;
|
const existing = projection.lines[index];
|
||||||
existing.streaming = false;
|
projection.lines[index] = {
|
||||||
existing.title = title;
|
...existing,
|
||||||
existing.eventId = eventId;
|
body: body || existing.body,
|
||||||
|
streaming: false,
|
||||||
|
title,
|
||||||
|
eventId,
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
projection.lines.push(line(eventId, kind, title, body));
|
projection.lines.push(line(eventId, kind, title, body));
|
||||||
|
|
@ -358,9 +407,9 @@ function upsertToolCall(
|
||||||
id: string,
|
id: string,
|
||||||
update: Partial<Omit<ToolCallView, "id">>,
|
update: Partial<Omit<ToolCallView, "id">>,
|
||||||
): ConsoleLine {
|
): ConsoleLine {
|
||||||
let existing = findToolCallLine(projection, id);
|
const index = findToolCallLineIndex(projection, id);
|
||||||
if (!existing) {
|
if (index < 0) {
|
||||||
existing = toolLine(eventId, {
|
const created = toolLine(eventId, {
|
||||||
id,
|
id,
|
||||||
name: update.name ?? "Tool",
|
name: update.name ?? "Tool",
|
||||||
argsStream: update.argsStream ?? "",
|
argsStream: update.argsStream ?? "",
|
||||||
|
|
@ -370,18 +419,23 @@ function upsertToolCall(
|
||||||
output: update.output,
|
output: update.output,
|
||||||
isError: update.isError,
|
isError: update.isError,
|
||||||
});
|
});
|
||||||
projection.lines.push(existing);
|
projection.lines.push(created);
|
||||||
} else {
|
return created;
|
||||||
existing.eventId = eventId;
|
}
|
||||||
existing.toolCall = {
|
|
||||||
|
const existing = projection.lines[index];
|
||||||
|
const updated = refreshedToolLine({
|
||||||
|
...existing,
|
||||||
|
eventId,
|
||||||
|
toolCall: {
|
||||||
...existing.toolCall!,
|
...existing.toolCall!,
|
||||||
...update,
|
...update,
|
||||||
id,
|
id,
|
||||||
name: update.name ?? existing.toolCall!.name,
|
name: update.name ?? existing.toolCall!.name,
|
||||||
};
|
},
|
||||||
}
|
});
|
||||||
refreshToolLine(existing);
|
projection.lines[index] = updated;
|
||||||
return existing;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendToolArgs(
|
function appendToolArgs(
|
||||||
|
|
@ -390,11 +444,27 @@ function appendToolArgs(
|
||||||
id: string,
|
id: string,
|
||||||
delta: string,
|
delta: string,
|
||||||
): void {
|
): void {
|
||||||
const existing = upsertToolCall(projection, eventId, id, {
|
const index = findToolCallLineIndex(projection, id);
|
||||||
state: "streaming_args",
|
if (index < 0) {
|
||||||
|
projection.lines.push(toolLine(eventId, {
|
||||||
|
id,
|
||||||
|
name: "Tool",
|
||||||
|
argsStream: delta,
|
||||||
|
state: "streaming_args",
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = projection.lines[index];
|
||||||
|
const toolCall = existing.toolCall!;
|
||||||
|
projection.lines[index] = refreshedToolLine({
|
||||||
|
...existing,
|
||||||
|
eventId,
|
||||||
|
toolCall: {
|
||||||
|
...toolCall,
|
||||||
|
argsStream: `${toolCall.argsStream}${delta}`,
|
||||||
|
state: "streaming_args",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
existing.toolCall!.argsStream += delta;
|
|
||||||
refreshToolLine(existing);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachToolResult(
|
function attachToolResult(
|
||||||
|
|
@ -403,42 +473,47 @@ function attachToolResult(
|
||||||
id: string,
|
id: string,
|
||||||
result: Pick<ToolCallView, "summary" | "output" | "isError">,
|
result: Pick<ToolCallView, "summary" | "output" | "isError">,
|
||||||
): void {
|
): void {
|
||||||
const existing = findToolCallLine(projection, id);
|
const index = findToolCallLineIndex(projection, id);
|
||||||
if (!existing) {
|
if (index < 0) {
|
||||||
const fallback = toolLine(eventId, {
|
const fallback = refreshedToolLine({
|
||||||
id,
|
...toolLine(eventId, {
|
||||||
name: "Tool",
|
id,
|
||||||
argsStream: "",
|
name: "Tool",
|
||||||
state: result.isError ? "error" : "done",
|
argsStream: "",
|
||||||
...result,
|
state: result.isError ? "error" : "done",
|
||||||
|
...result,
|
||||||
|
}),
|
||||||
|
title: result.isError ? "Call · Tool result error" : "Call · Tool result",
|
||||||
});
|
});
|
||||||
fallback.title = result.isError
|
|
||||||
? "Call · Tool result error"
|
|
||||||
: "Call · Tool result";
|
|
||||||
refreshToolLine(fallback);
|
|
||||||
projection.lines.push(fallback);
|
projection.lines.push(fallback);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
existing.eventId = eventId;
|
const existing = projection.lines[index];
|
||||||
existing.toolCall = {
|
projection.lines[index] = refreshedToolLine({
|
||||||
...existing.toolCall!,
|
...existing,
|
||||||
...result,
|
eventId,
|
||||||
state: result.isError ? "error" : "done",
|
toolCall: {
|
||||||
};
|
...existing.toolCall!,
|
||||||
refreshToolLine(existing);
|
...result,
|
||||||
|
state: result.isError ? "error" : "done",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function findToolCallLine(
|
function findToolCallLineIndex(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
id: string,
|
id: string,
|
||||||
): ConsoleLine | undefined {
|
): number {
|
||||||
return [...projection.lines].reverse().find((item) =>
|
for (let index = projection.lines.length - 1; index >= 0; index -= 1) {
|
||||||
item.toolCall?.id === id
|
if (projection.lines[index].toolCall?.id === id) {
|
||||||
);
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolLine(eventId: string, toolCall: ToolCallView): ConsoleLine {
|
function toolLine(eventId: string, toolCall: ToolCallView): ConsoleLine {
|
||||||
const item: ConsoleLine = {
|
return refreshedToolLine({
|
||||||
id: `tool-call-${toolCall.id}`,
|
id: `tool-call-${toolCall.id}`,
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
title: `Call · ${toolCall.name}`,
|
title: `Call · ${toolCall.name}`,
|
||||||
|
|
@ -449,24 +524,25 @@ function toolLine(eventId: string, toolCall: ToolCallView): ConsoleLine {
|
||||||
streaming: true,
|
streaming: true,
|
||||||
error: false,
|
error: false,
|
||||||
toolCall,
|
toolCall,
|
||||||
};
|
});
|
||||||
refreshToolLine(item);
|
|
||||||
return item;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshToolLine(item: ConsoleLine): void {
|
function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
||||||
const toolCall = item.toolCall;
|
const toolCall = item.toolCall;
|
||||||
if (!toolCall) {
|
if (!toolCall) {
|
||||||
return;
|
return item;
|
||||||
}
|
}
|
||||||
item.title = item.title.startsWith("Call · Tool result")
|
return {
|
||||||
? item.title
|
...item,
|
||||||
: `Call · ${toolCall.name}`;
|
title: item.title.startsWith("Call · Tool result")
|
||||||
item.body = renderToolCall(toolCall);
|
? item.title
|
||||||
item.detail = toolCallDetail(toolCall);
|
: `Call · ${toolCall.name}`,
|
||||||
item.diff = toolCall.name === "Edit" ? editDiff(toolCall) : undefined;
|
body: renderToolCall(toolCall),
|
||||||
item.streaming = !["done", "error"].includes(toolCall.state);
|
detail: toolCallDetail(toolCall),
|
||||||
item.error = toolCall.state === "error";
|
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||||
|
streaming: !["done", "error"].includes(toolCall.state),
|
||||||
|
error: toolCall.state === "error",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToolCall(toolCall: ToolCallView): string {
|
function renderToolCall(toolCall: ToolCallView): string {
|
||||||
|
|
@ -478,8 +554,9 @@ function renderToolCall(toolCall: ToolCallView): string {
|
||||||
case "Edit":
|
case "Edit":
|
||||||
return renderEditTool(toolCall);
|
return renderEditTool(toolCall);
|
||||||
case "Glob":
|
case "Glob":
|
||||||
case "Grep":
|
|
||||||
return renderSearchTool(toolCall);
|
return renderSearchTool(toolCall);
|
||||||
|
case "Grep":
|
||||||
|
return renderGrepTool(toolCall);
|
||||||
case "Bash":
|
case "Bash":
|
||||||
return renderBashTool(toolCall);
|
return renderBashTool(toolCall);
|
||||||
default:
|
default:
|
||||||
|
|
@ -665,28 +742,55 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
|
||||||
function renderSearchTool(toolCall: ToolCallView): string {
|
function renderSearchTool(toolCall: ToolCallView): string {
|
||||||
const summary = toolCall.summary?.trim();
|
const summary = toolCall.summary?.trim();
|
||||||
return compactLines([
|
return compactLines([
|
||||||
`${toolCall.name} — ${
|
`${toolCall.name} — ${toolHeaderSuffix(toolCall, summary)}`,
|
||||||
summary ? firstLine(summary) : stateSuffix(toolCall.state)
|
|
||||||
}`,
|
|
||||||
resultText(toolCall),
|
resultText(toolCall),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderGrepTool(toolCall: ToolCallView): string {
|
||||||
|
const summary = toolCall.summary?.trim();
|
||||||
|
return compactLines([
|
||||||
|
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
|
||||||
|
grepQueryText(toolCall),
|
||||||
|
cappedResultSection(resultText(toolCall), 5),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolHeaderSuffix(
|
||||||
|
toolCall: ToolCallView,
|
||||||
|
summary?: string,
|
||||||
|
): string {
|
||||||
|
if (toolCall.state === "error") {
|
||||||
|
return "Failed";
|
||||||
|
}
|
||||||
|
return summary ? firstLine(summary) : stateSuffix(toolCall.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function grepQueryText(toolCall: ToolCallView): string | undefined {
|
||||||
|
const args = parsedArgs(toolCall);
|
||||||
|
const pattern = stringField(args, "pattern");
|
||||||
|
if (pattern) {
|
||||||
|
return `query: ${pattern}`;
|
||||||
|
}
|
||||||
|
const renderedArgs = argsText(toolCall);
|
||||||
|
return renderedArgs ? `query:\n${renderedArgs}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function renderBashTool(toolCall: ToolCallView): string {
|
function renderBashTool(toolCall: ToolCallView): string {
|
||||||
const args = parsedArgs(toolCall);
|
const args = parsedArgs(toolCall);
|
||||||
const command = stringField(args, "command");
|
const command = stringField(args, "command");
|
||||||
return compactLines([
|
return compactLines([
|
||||||
`Bash — ${stateSuffix(toolCall.state)}`,
|
`Bash — ${stateSuffix(toolCall.state)}`,
|
||||||
command ? `$ ${command}` : argsText(toolCall),
|
command ? `$ ${command}` : argsText(toolCall),
|
||||||
resultText(toolCall),
|
cappedDisplaySection(resultText(toolCall), 10),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDefaultTool(toolCall: ToolCallView): string {
|
function renderDefaultTool(toolCall: ToolCallView): string {
|
||||||
return compactLines([
|
return compactLines([
|
||||||
`${toolCall.name} — ${stateSuffix(toolCall.state)}`,
|
`${toolCall.name} — ${stateSuffix(toolCall.state)}`,
|
||||||
argsText(toolCall),
|
cappedDisplaySection(argsText(toolCall), 3),
|
||||||
resultText(toolCall),
|
cappedDisplaySection(resultText(toolCall), 3),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -754,6 +858,42 @@ function cappedSection(
|
||||||
return shown.join("\n");
|
return shown.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cappedDisplaySection(
|
||||||
|
value: string | undefined,
|
||||||
|
maxLines: number,
|
||||||
|
): string | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const lines = value.split(/\r?\n/);
|
||||||
|
if (lines.length <= maxLines) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (maxLines <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const shown = lines.slice(0, maxLines);
|
||||||
|
shown[maxLines - 1] = `… +${lines.length - maxLines + 1} more lines`;
|
||||||
|
return shown.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cappedResultSection(
|
||||||
|
value: string | undefined,
|
||||||
|
maxResults: number,
|
||||||
|
): string | undefined {
|
||||||
|
if (!value || maxResults <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const lines = value.split(/\r?\n/).filter((line) => line.length > 0);
|
||||||
|
if (lines.length <= maxResults) {
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...lines.slice(0, maxResults),
|
||||||
|
`… +${lines.length - maxResults} more results`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function usageText(
|
function usageText(
|
||||||
data: {
|
data: {
|
||||||
input_tokens: number | null;
|
input_tokens: number | null;
|
||||||
|
|
|
||||||
87
web/workspace/src/lib/workspace-console/textarea-fit.ts
Normal file
87
web/workspace/src/lib/workspace-console/textarea-fit.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
export type TextareaFitOptions = {
|
||||||
|
value?: string;
|
||||||
|
maxRows?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function positiveNumber(value: number | undefined, fallback: number): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||||
|
? value
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixelValue(value: string): number {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineHeightPx(style: CSSStyleDeclaration): number {
|
||||||
|
const parsed = pixelValue(style.lineHeight);
|
||||||
|
if (parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
const fontSize = pixelValue(style.fontSize);
|
||||||
|
return fontSize > 0 ? fontSize * 1.2 : 16.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verticalBoxPx(style: CSSStyleDeclaration): number {
|
||||||
|
return pixelValue(style.paddingTop) +
|
||||||
|
pixelValue(style.paddingBottom) +
|
||||||
|
pixelValue(style.borderTopWidth) +
|
||||||
|
pixelValue(style.borderBottomWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitTextareaHeight(node: HTMLTextAreaElement, maxRows: number) {
|
||||||
|
const style = globalThis.getComputedStyle(node);
|
||||||
|
const maxHeight = lineHeightPx(style) * maxRows + verticalBoxPx(style);
|
||||||
|
node.style.height = "auto";
|
||||||
|
const nextHeight = Math.min(node.scrollHeight, maxHeight);
|
||||||
|
node.style.height = `${nextHeight}px`;
|
||||||
|
node.style.overflowY = node.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fitTextarea(
|
||||||
|
node: HTMLTextAreaElement,
|
||||||
|
options: TextareaFitOptions = {},
|
||||||
|
) {
|
||||||
|
let current = {
|
||||||
|
value: options.value,
|
||||||
|
maxRows: positiveNumber(options.maxRows, 10),
|
||||||
|
};
|
||||||
|
let frame: number | null = null;
|
||||||
|
|
||||||
|
function scheduleFit() {
|
||||||
|
if (frame !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frame = window.requestAnimationFrame(() => {
|
||||||
|
frame = null;
|
||||||
|
fitTextareaHeight(node, current.maxRows);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
scheduleFit();
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener("input", onInput);
|
||||||
|
scheduleFit();
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(nextOptions: TextareaFitOptions = {}) {
|
||||||
|
const next = {
|
||||||
|
value: nextOptions.value,
|
||||||
|
maxRows: positiveNumber(nextOptions.maxRows, 10),
|
||||||
|
};
|
||||||
|
if (next.value !== current.value || next.maxRows !== current.maxRows) {
|
||||||
|
current = next;
|
||||||
|
scheduleFit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (frame !== null) {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
node.removeEventListener("input", onInput);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -69,13 +69,49 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
|
||||||
consolePage.includes(
|
consolePage.includes(
|
||||||
"rememberObservationEvent(frame.envelope.event_id)",
|
"rememberObservationEvent(frame.envelope.event_id)",
|
||||||
) &&
|
) &&
|
||||||
consolePage.includes("projectConsole(observedEvents.map") &&
|
consolePage.includes("createConsoleProjector") &&
|
||||||
|
consolePage.includes("consoleProjector.append(eventBatch)") &&
|
||||||
|
consolePage.includes("{#each lines as item (item.id)}") &&
|
||||||
|
!consolePage.includes("projectConsole(observedEvents.map") &&
|
||||||
!consolePage.includes("/transcript") &&
|
!consolePage.includes("/transcript") &&
|
||||||
!consolePage.includes("WorkerTranscriptProjection"),
|
!consolePage.includes("WorkerTranscriptProjection"),
|
||||||
"Console should render protocol observation replay/live events directly and dedupe repeated frames by event id",
|
"Console should render protocol observation replay/live events directly and dedupe repeated frames by event id",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker Console renders markdown only for message rows", async () => {
|
||||||
|
const consoleLine = await Deno.readTextFile(
|
||||||
|
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
consoleLine.includes("function shouldRenderMarkdown") &&
|
||||||
|
consoleLine.includes("item.kind === 'tool'") &&
|
||||||
|
consoleLine.includes('<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>') &&
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker Console composer fits to content without manual resize", async () => {
|
||||||
|
const consolePage = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const css = await Deno.readTextFile(new URL("../../app.css", import.meta.url));
|
||||||
|
|
||||||
|
assert(
|
||||||
|
consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") &&
|
||||||
|
css.includes(".console-composer textarea") &&
|
||||||
|
css.includes("resize: none") &&
|
||||||
|
css.includes("overflow-y: hidden"),
|
||||||
|
"Console composer should autosize to content, cap at ten rows, and disable manual resize",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
|
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
|
||||||
const editor = await Deno.readTextFile(
|
const editor = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import RichMarkdown from '$lib/workspace-console/RichMarkdown.svelte';
|
import ConsoleLineItem from '$lib/workspace-console/ConsoleLineItem.svelte';
|
||||||
|
import { chatSubmit } from '$lib/workspace-console/chat-submit';
|
||||||
|
import { fitTextarea } from '$lib/workspace-console/textarea-fit';
|
||||||
import {
|
import {
|
||||||
projectConsole,
|
createConsoleProjector,
|
||||||
type ConsoleLine
|
type ConsoleEventInput,
|
||||||
|
type ConsoleProjection
|
||||||
} from '$lib/workspace-console/model';
|
} from '$lib/workspace-console/model';
|
||||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -44,8 +47,13 @@
|
||||||
let consoleBodyElement: HTMLElement | null = null;
|
let consoleBodyElement: HTMLElement | null = null;
|
||||||
let autoFollowConsole = $state(true);
|
let autoFollowConsole = $state(true);
|
||||||
const CONSOLE_BOTTOM_THRESHOLD_PX = 48;
|
const CONSOLE_BOTTOM_THRESHOLD_PX = 48;
|
||||||
let observedEvents = $state<Array<{ eventId: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
|
const consoleProjector = createConsoleProjector();
|
||||||
|
let consoleProjection = $state.raw<ConsoleProjection>(consoleProjector.snapshot());
|
||||||
let seenObservationEventIds = new Set<string>();
|
let seenObservationEventIds = new Set<string>();
|
||||||
|
let pendingObservationEvents: ConsoleEventInput[] = [];
|
||||||
|
let pendingObservedStates: Array<string | null> = [];
|
||||||
|
let pendingStreamDiagnostics: Diagnostic[] = [];
|
||||||
|
let observationFlushHandle: number | null = null;
|
||||||
let nextReloadToken = 0;
|
let nextReloadToken = 0;
|
||||||
let reloadToken = $state(0);
|
let reloadToken = $state(0);
|
||||||
|
|
||||||
|
|
@ -56,10 +64,7 @@
|
||||||
|
|
||||||
const consoleTarget = $derived({ runtimeId, workerId });
|
const consoleTarget = $derived({ runtimeId, workerId });
|
||||||
|
|
||||||
const projection = $derived(
|
const lines = $derived(consoleProjection.lines);
|
||||||
projectConsole(observedEvents.map((item) => ({ eventId: item.eventId, event: item.event.envelope.payload })))
|
|
||||||
);
|
|
||||||
const lines = $derived(projection.lines);
|
|
||||||
const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics));
|
const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics));
|
||||||
const workerState = $derived(liveWorkerState ?? worker?.state ?? 'loading');
|
const workerState = $derived(liveWorkerState ?? worker?.state ?? 'loading');
|
||||||
const inputReady = $derived(workerState === 'idle');
|
const inputReady = $derived(workerState === 'idle');
|
||||||
|
|
@ -124,10 +129,69 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetObservedEvents() {
|
function resetObservedEvents() {
|
||||||
observedEvents = [];
|
cancelObservationFlush();
|
||||||
|
consoleProjection = consoleProjector.reset();
|
||||||
seenObservationEventIds = new Set();
|
seenObservationEventIds = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelObservationFlush() {
|
||||||
|
if (observationFlushHandle !== null) {
|
||||||
|
window.cancelAnimationFrame(observationFlushHandle);
|
||||||
|
observationFlushHandle = null;
|
||||||
|
}
|
||||||
|
pendingObservationEvents = [];
|
||||||
|
pendingObservedStates = [];
|
||||||
|
pendingStreamDiagnostics = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleObservationFlush() {
|
||||||
|
if (observationFlushHandle !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
observationFlushHandle = window.requestAnimationFrame(() => {
|
||||||
|
flushObservationBatch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushObservationBatch() {
|
||||||
|
observationFlushHandle = null;
|
||||||
|
const eventBatch = pendingObservationEvents;
|
||||||
|
const stateBatch = pendingObservedStates;
|
||||||
|
const diagnosticBatch = pendingStreamDiagnostics;
|
||||||
|
pendingObservationEvents = [];
|
||||||
|
pendingObservedStates = [];
|
||||||
|
pendingStreamDiagnostics = [];
|
||||||
|
|
||||||
|
if (eventBatch.length > 0) {
|
||||||
|
const latestState = stateBatch.findLast((state) => state !== null);
|
||||||
|
if (latestState) {
|
||||||
|
liveWorkerState = latestState;
|
||||||
|
}
|
||||||
|
consoleProjection = consoleProjector.append(eventBatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagnosticBatch.length > 0) {
|
||||||
|
streamDiagnostics = [...streamDiagnostics, ...diagnosticBatch];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueObservationEvent(frame: ClientWorkerEventWsFrame & { kind: 'event' }) {
|
||||||
|
if (!rememberObservationEvent(frame.envelope.event_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingObservationEvents.push({
|
||||||
|
eventId: frame.envelope.event_id,
|
||||||
|
event: frame.envelope.payload
|
||||||
|
});
|
||||||
|
pendingObservedStates.push(workerStateFromProtocolEvent(frame.envelope.payload));
|
||||||
|
scheduleObservationFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueObservationDiagnostic(diagnostic: Diagnostic) {
|
||||||
|
pendingStreamDiagnostics.push(diagnostic);
|
||||||
|
scheduleObservationFlush();
|
||||||
|
}
|
||||||
|
|
||||||
function rememberObservationEvent(eventId: string): boolean {
|
function rememberObservationEvent(eventId: string): boolean {
|
||||||
if (seenObservationEventIds.has(eventId)) {
|
if (seenObservationEventIds.has(eventId)) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -136,9 +200,8 @@
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(event: SubmitEvent) {
|
async function submitDraft(value = draft) {
|
||||||
event.preventDefault();
|
const content = value.trim();
|
||||||
const content = draft.trim();
|
|
||||||
if (!content || sending || !inputReady) {
|
if (!content || sending || !inputReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +226,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendMessage(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
await submitDraft();
|
||||||
|
}
|
||||||
|
|
||||||
function workerStateFromProtocolEvent(event: PodProtocolEvent): string | null {
|
function workerStateFromProtocolEvent(event: PodProtocolEvent): string | null {
|
||||||
switch (event.event) {
|
switch (event.event) {
|
||||||
case 'snapshot':
|
case 'snapshot':
|
||||||
|
|
@ -199,39 +267,20 @@
|
||||||
try {
|
try {
|
||||||
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
|
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
|
||||||
if (frame.kind === 'event') {
|
if (frame.kind === 'event') {
|
||||||
if (!rememberObservationEvent(frame.envelope.event_id)) {
|
queueObservationEvent(frame);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const observedState = workerStateFromProtocolEvent(frame.envelope.payload);
|
|
||||||
if (observedState) {
|
|
||||||
liveWorkerState = observedState;
|
|
||||||
}
|
|
||||||
observedEvents = [
|
|
||||||
...observedEvents,
|
|
||||||
{
|
|
||||||
eventId: frame.envelope.event_id,
|
|
||||||
event: frame
|
|
||||||
}
|
|
||||||
].slice(-500);
|
|
||||||
} else {
|
} else {
|
||||||
streamDiagnostics = [
|
queueObservationDiagnostic({
|
||||||
...streamDiagnostics,
|
code: frame.diagnostic.code,
|
||||||
{
|
severity: 'warning',
|
||||||
code: frame.diagnostic.code,
|
message: frame.diagnostic.message
|
||||||
severity: 'warning',
|
});
|
||||||
message: frame.diagnostic.message
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
streamDiagnostics = [
|
queueObservationDiagnostic({
|
||||||
...streamDiagnostics,
|
code: 'worker_observation_frame_invalid',
|
||||||
{
|
severity: 'warning',
|
||||||
code: 'worker_observation_frame_invalid',
|
message: error instanceof Error ? error.message : String(error)
|
||||||
severity: 'warning',
|
});
|
||||||
message: error instanceof Error ? error.message : String(error)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
|
|
@ -264,34 +313,6 @@
|
||||||
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
|
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineClass(line: ConsoleLine): string {
|
|
||||||
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 shouldRenderHeading(line: ConsoleLine): boolean {
|
|
||||||
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
|
|
||||||
const [firstLine = '', ...rest] = line.body.split('\n');
|
|
||||||
const [label, suffix = ''] = firstLine.split(' — ', 2);
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
suffix,
|
|
||||||
rest: rest.join('\n')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bodyTextAfterToolSummary(line: ConsoleLine): string {
|
|
||||||
return toolSummary(line).rest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNearConsoleBottom(element: HTMLElement): boolean {
|
function isNearConsoleBottom(element: HTMLElement): boolean {
|
||||||
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD_PX;
|
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD_PX;
|
||||||
}
|
}
|
||||||
|
|
@ -359,11 +380,11 @@
|
||||||
|
|
||||||
<section class="console-body" bind:this={consoleBodyElement} onscroll={handleConsoleScroll}>
|
<section class="console-body" bind:this={consoleBodyElement} onscroll={handleConsoleScroll}>
|
||||||
<article class="card console-card worker-console-card">
|
<article class="card console-card worker-console-card">
|
||||||
{#if projection.status || projection.usage}
|
{#if consoleProjection.status || consoleProjection.usage}
|
||||||
<p class="section-note">
|
<p class="section-note">
|
||||||
{#if projection.status}status: {projection.status}{/if}
|
{#if consoleProjection.status}status: {consoleProjection.status}{/if}
|
||||||
{#if projection.status && projection.usage} · {/if}
|
{#if consoleProjection.status && consoleProjection.usage} · {/if}
|
||||||
{#if projection.usage}usage: {projection.usage}{/if}
|
{#if consoleProjection.usage}usage: {consoleProjection.usage}{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -375,43 +396,8 @@
|
||||||
<p>No console output is available for this Worker yet.</p>
|
<p>No console output is available for this Worker yet.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ol class="console-log">
|
<ol class="console-log">
|
||||||
{#each lines as item}
|
{#each lines as item (item.id)}
|
||||||
<li class={`console-line ${lineClass(item)} ${toolClass(item)}`} class:error-line={item.error}>
|
<ConsoleLineItem {item} />
|
||||||
{#if shouldRenderHeading(item)}
|
|
||||||
<div class="message-heading">
|
|
||||||
<span>{item.title}</span>
|
|
||||||
{#if item.streaming}<small>streaming</small>{/if}
|
|
||||||
</div>
|
|
||||||
{:else if item.kind === 'tool'}
|
|
||||||
<div class="tool-summary">
|
|
||||||
<span class="tool-label">{toolSummary(item).label}</span>
|
|
||||||
<span class="tool-separator"> — </span>
|
|
||||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
|
||||||
{#if item.streaming}<small>streaming</small>{/if}
|
|
||||||
</div>
|
|
||||||
{:else if item.streaming}
|
|
||||||
<div class="message-heading streaming-heading">
|
|
||||||
<small>streaming</small>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if item.kind === 'tool'}
|
|
||||||
{#if bodyTextAfterToolSummary(item)}
|
|
||||||
<RichMarkdown text={bodyTextAfterToolSummary(item)} />
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<RichMarkdown text={item.body || '—'} />
|
|
||||||
{/if}
|
|
||||||
{#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>
|
|
||||||
<p>{item.detail}</p>
|
|
||||||
</details>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -484,7 +470,13 @@
|
||||||
<textarea
|
<textarea
|
||||||
id="worker-console-message"
|
id="worker-console-message"
|
||||||
aria-label="Console input"
|
aria-label="Console input"
|
||||||
|
aria-keyshortcuts="Meta+Enter"
|
||||||
bind:value={draft}
|
bind:value={draft}
|
||||||
|
use:chatSubmit={{
|
||||||
|
enabled: inputReady && !sending,
|
||||||
|
onSubmit: (value) => void submitDraft(value)
|
||||||
|
}}
|
||||||
|
use:fitTextarea={{ value: draft, maxRows: 10 }}
|
||||||
disabled={!inputReady || sending}
|
disabled={!inputReady || sending}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="composer-actions">
|
<div class="composer-actions">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user