diff --git a/web/workspace/deno.json b/web/workspace/deno.json
index bec65306..66a8f851 100644
--- a/web/workspace/deno.json
+++ b/web/workspace/deno.json
@@ -6,7 +6,7 @@
"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",
"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",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css
index 03028145..6119f8c2 100644
--- a/web/workspace/src/app.css
+++ b/web/workspace/src/app.css
@@ -96,6 +96,10 @@
}
@layer base {
+ html {
+ font-size: 14px;
+ }
+
body {
background: var(--bg);
color: var(--text);
@@ -1048,8 +1052,7 @@
}
.console-log li {
- display: grid;
- gap: var(--space-1);
+ min-width: 0;
padding: 0.2rem 0;
background: transparent;
}
@@ -1059,6 +1062,22 @@
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 {
margin-top: 0;
}
@@ -1174,7 +1193,7 @@
font-style: italic;
}
-.console-log li.thinking .rich-markdown {
+.console-log li.thinking .console-plain-text {
color: var(--tui-dark-gray);
font-style: italic;
}
@@ -1197,7 +1216,20 @@
}
.tool-label {
+ flex: 0 0 auto;
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,
@@ -1219,7 +1251,16 @@
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);
}
@@ -1324,13 +1365,16 @@
}
.console-composer textarea {
+ box-sizing: border-box;
width: 100%;
- min-height: 7rem;
- resize: vertical;
+ min-height: 0;
+ resize: none;
+ overflow-y: hidden;
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.85rem 1rem;
font: inherit;
+ line-height: 1.45;
color: var(--text-strong);
}
diff --git a/web/workspace/src/lib/workspace-console/ConsoleLineItem.svelte b/web/workspace/src/lib/workspace-console/ConsoleLineItem.svelte
new file mode 100644
index 00000000..56bdf76f
--- /dev/null
+++ b/web/workspace/src/lib/workspace-console/ConsoleLineItem.svelte
@@ -0,0 +1,81 @@
+
+
+
+ {#if shouldRenderHeading(item)}
+
+ {item.title}
+ {#if item.streaming}streaming{/if}
+
+ {:else if item.kind === 'tool'}
+
+ {toolSummary(item).label}
+ —
+ {toolSummary(item).suffix}
+ {#if item.streaming}streaming{/if}
+
+ {:else if item.streaming}
+
+ streaming
+
+ {/if}
+ {#if item.kind === 'tool'}
+ {#if bodyTextAfterToolSummary(item)}
+ {bodyTextAfterToolSummary(item)}
+ {/if}
+ {:else if shouldRenderMarkdown(item)}
+
+ {:else}
+ {item.body || '—'}
+ {/if}
+ {#if item.diff}
+ {#each item.diff as diffLine}
+{diffLine.oldNumber ?? ''}{diffLine.newNumber ?? ''}{diffLine.kind === 'add' ? '+' : diffLine.kind === 'remove' ? '-' : ' '}{diffLine.content}{/each}
+ {/if}
+ {#if item.detail}
+
+ detail
+ {item.detail}
+
+ {/if}
+
diff --git a/web/workspace/src/lib/workspace-console/chat-submit.test.ts b/web/workspace/src/lib/workspace-console/chat-submit.test.ts
new file mode 100644
index 00000000..1b45d169
--- /dev/null
+++ b/web/workspace/src/lib/workspace-console/chat-submit.test.ts
@@ -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(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);
+});
diff --git a/web/workspace/src/lib/workspace-console/chat-submit.ts b/web/workspace/src/lib/workspace-console/chat-submit.ts
new file mode 100644
index 00000000..070a58a0
--- /dev/null
+++ b/web/workspace/src/lib/workspace-console/chat-submit.ts
@@ -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;
+
+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 & {
+ 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);
+ },
+ };
+}
diff --git a/web/workspace/src/lib/workspace-console/model.test.ts b/web/workspace/src/lib/workspace-console/model.test.ts
index f2c3eda4..89cf674d 100644
--- a/web/workspace/src/lib/workspace-console/model.test.ts
+++ b/web/workspace/src/lib/workspace-console/model.test.ts
@@ -1,5 +1,10 @@
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: {
test(name: string, fn: () => void): void;
@@ -175,7 +180,7 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
data: {
id: "call-1",
summary: "command completed",
- output: "/repo\n",
+ output: "/repo\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12",
is_error: false,
},
} satisfies Event,
@@ -197,12 +202,153 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
toolLines[0].body.includes("/repo"),
"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(
toolLines[0].detail?.includes("id: call-1"),
"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", () => {
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", () => {
const projection = projectConsole([
{
diff --git a/web/workspace/src/lib/workspace-console/model.ts b/web/workspace/src/lib/workspace-console/model.ts
index a865d7f5..f7d6271c 100644
--- a/web/workspace/src/lib/workspace-console/model.ts
+++ b/web/workspace/src/lib/workspace-console/model.ts
@@ -95,15 +95,42 @@ export function workerConsolePath(
export type ConsoleEventInput = { eventId: string; event: ProtocolEvent };
-export function projectConsole(
- events: ConsoleEventInput[] = [],
-): ConsoleProjection {
- const projection = events.reduce(applyProtocolEvent, {
+export function emptyConsoleProjection(): ConsoleProjection {
+ return {
lines: [],
status: null,
usage: 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 {
...projection,
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(
projection: ConsoleProjection,
eventId: string,
@@ -321,12 +360,17 @@ function appendStreaming(
title: string,
delta: string,
): void {
- const existing = [...projection.lines].reverse().find((item) =>
- item.kind === kind && item.streaming
+ const index = findLastLineIndex(
+ projection.lines,
+ (item) => item.kind === kind && item.streaming === true,
);
- if (existing) {
- existing.body += delta;
- existing.eventId = eventId;
+ if (index >= 0) {
+ const existing = projection.lines[index];
+ projection.lines[index] = {
+ ...existing,
+ body: `${existing.body}${delta}`,
+ eventId,
+ };
return;
}
projection.lines.push(line(eventId, kind, title, delta, undefined, true));
@@ -339,14 +383,19 @@ function finalizeStreaming(
title: string,
body: string,
): void {
- const existing = [...projection.lines].reverse().find((item) =>
- item.kind === kind && item.streaming
+ const index = findLastLineIndex(
+ projection.lines,
+ (item) => item.kind === kind && item.streaming === true,
);
- if (existing) {
- existing.body = body || existing.body;
- existing.streaming = false;
- existing.title = title;
- existing.eventId = eventId;
+ if (index >= 0) {
+ const existing = projection.lines[index];
+ projection.lines[index] = {
+ ...existing,
+ body: body || existing.body,
+ streaming: false,
+ title,
+ eventId,
+ };
return;
}
projection.lines.push(line(eventId, kind, title, body));
@@ -358,9 +407,9 @@ function upsertToolCall(
id: string,
update: Partial>,
): ConsoleLine {
- let existing = findToolCallLine(projection, id);
- if (!existing) {
- existing = toolLine(eventId, {
+ const index = findToolCallLineIndex(projection, id);
+ if (index < 0) {
+ const created = toolLine(eventId, {
id,
name: update.name ?? "Tool",
argsStream: update.argsStream ?? "",
@@ -370,18 +419,23 @@ function upsertToolCall(
output: update.output,
isError: update.isError,
});
- projection.lines.push(existing);
- } else {
- existing.eventId = eventId;
- existing.toolCall = {
+ projection.lines.push(created);
+ return created;
+ }
+
+ const existing = projection.lines[index];
+ const updated = refreshedToolLine({
+ ...existing,
+ eventId,
+ toolCall: {
...existing.toolCall!,
...update,
id,
name: update.name ?? existing.toolCall!.name,
- };
- }
- refreshToolLine(existing);
- return existing;
+ },
+ });
+ projection.lines[index] = updated;
+ return updated;
}
function appendToolArgs(
@@ -390,11 +444,27 @@ function appendToolArgs(
id: string,
delta: string,
): void {
- const existing = upsertToolCall(projection, eventId, id, {
- state: "streaming_args",
+ const index = findToolCallLineIndex(projection, id);
+ 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(
@@ -403,42 +473,47 @@ function attachToolResult(
id: string,
result: Pick,
): void {
- const existing = findToolCallLine(projection, id);
- if (!existing) {
- const fallback = toolLine(eventId, {
- id,
- name: "Tool",
- argsStream: "",
- state: result.isError ? "error" : "done",
- ...result,
+ const index = findToolCallLineIndex(projection, id);
+ if (index < 0) {
+ const fallback = refreshedToolLine({
+ ...toolLine(eventId, {
+ id,
+ name: "Tool",
+ argsStream: "",
+ 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);
return;
}
- existing.eventId = eventId;
- existing.toolCall = {
- ...existing.toolCall!,
- ...result,
- state: result.isError ? "error" : "done",
- };
- refreshToolLine(existing);
+ const existing = projection.lines[index];
+ projection.lines[index] = refreshedToolLine({
+ ...existing,
+ eventId,
+ toolCall: {
+ ...existing.toolCall!,
+ ...result,
+ state: result.isError ? "error" : "done",
+ },
+ });
}
-function findToolCallLine(
+function findToolCallLineIndex(
projection: ConsoleProjection,
id: string,
-): ConsoleLine | undefined {
- return [...projection.lines].reverse().find((item) =>
- item.toolCall?.id === id
- );
+): number {
+ for (let index = projection.lines.length - 1; index >= 0; index -= 1) {
+ if (projection.lines[index].toolCall?.id === id) {
+ return index;
+ }
+ }
+ return -1;
}
function toolLine(eventId: string, toolCall: ToolCallView): ConsoleLine {
- const item: ConsoleLine = {
+ return refreshedToolLine({
id: `tool-call-${toolCall.id}`,
kind: "tool",
title: `Call · ${toolCall.name}`,
@@ -449,24 +524,25 @@ function toolLine(eventId: string, toolCall: ToolCallView): ConsoleLine {
streaming: true,
error: false,
toolCall,
- };
- refreshToolLine(item);
- return item;
+ });
}
-function refreshToolLine(item: ConsoleLine): void {
+function refreshedToolLine(item: ConsoleLine): ConsoleLine {
const toolCall = item.toolCall;
if (!toolCall) {
- return;
+ return item;
}
- item.title = item.title.startsWith("Call · Tool result")
- ? item.title
- : `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";
+ return {
+ ...item,
+ title: item.title.startsWith("Call · Tool result")
+ ? item.title
+ : `Call · ${toolCall.name}`,
+ body: renderToolCall(toolCall),
+ detail: toolCallDetail(toolCall),
+ diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
+ streaming: !["done", "error"].includes(toolCall.state),
+ error: toolCall.state === "error",
+ };
}
function renderToolCall(toolCall: ToolCallView): string {
@@ -478,8 +554,9 @@ function renderToolCall(toolCall: ToolCallView): string {
case "Edit":
return renderEditTool(toolCall);
case "Glob":
- case "Grep":
return renderSearchTool(toolCall);
+ case "Grep":
+ return renderGrepTool(toolCall);
case "Bash":
return renderBashTool(toolCall);
default:
@@ -665,28 +742,55 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
function renderSearchTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim();
return compactLines([
- `${toolCall.name} — ${
- summary ? firstLine(summary) : stateSuffix(toolCall.state)
- }`,
+ `${toolCall.name} — ${toolHeaderSuffix(toolCall, summary)}`,
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 {
const args = parsedArgs(toolCall);
const command = stringField(args, "command");
return compactLines([
`Bash — ${stateSuffix(toolCall.state)}`,
command ? `$ ${command}` : argsText(toolCall),
- resultText(toolCall),
+ cappedDisplaySection(resultText(toolCall), 10),
]);
}
function renderDefaultTool(toolCall: ToolCallView): string {
return compactLines([
`${toolCall.name} — ${stateSuffix(toolCall.state)}`,
- argsText(toolCall),
- resultText(toolCall),
+ cappedDisplaySection(argsText(toolCall), 3),
+ cappedDisplaySection(resultText(toolCall), 3),
]);
}
@@ -754,6 +858,42 @@ function cappedSection(
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(
data: {
input_tokens: number | null;
diff --git a/web/workspace/src/lib/workspace-console/textarea-fit.ts b/web/workspace/src/lib/workspace-console/textarea-fit.ts
new file mode 100644
index 00000000..e4942c2d
--- /dev/null
+++ b/web/workspace/src/lib/workspace-console/textarea-fit.ts
@@ -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);
+ },
+ };
+}
diff --git a/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts
index d3801023..b602f494 100644
--- a/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts
+++ b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts
@@ -69,13 +69,49 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
consolePage.includes(
"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("WorkerTranscriptProjection"),
"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('{bodyTextAfterToolSummary(item)}
') &&
+ consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
+ consoleLine.includes(""),
+ "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 () => {
const editor = await Deno.readTextFile(
new URL(
diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte
index e48a0b28..8459bc7b 100644
--- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte
+++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte
@@ -1,9 +1,12 @@