diff --git a/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte b/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte
index b4190090..1d38cebb 100644
--- a/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte
+++ b/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte
@@ -19,7 +19,8 @@
}
function shouldRenderHeading(line: ConsoleLine): boolean {
- return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool';
+ return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool' &&
+ line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
}
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
@@ -49,24 +50,29 @@
{#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 item.kind === 'user'}
+
+ {:else if item.kind === 'activity'}
+ {item.body || '—'}
+ {:else if item.kind === 'task_reminder'}
+ {item.body || 'task reminder'}
+ {:else if item.kind === 'run_stats'}
+ {item.body}
{:else if shouldRenderMarkdown(item)}
{:else}
@@ -102,6 +108,48 @@
color: var(--tui-green);
}
+ .user-message {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 0.55rem;
+ align-items: start;
+ }
+
+ .user-prompt {
+ color: var(--tui-green);
+ font-weight: 700;
+ line-height: 1.55;
+ }
+
+ .activity-summary,
+ .task-reminder-summary {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ line-height: 1.55;
+ white-space: pre-line;
+ }
+
+ .task-reminder-summary {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .console-line.error .activity-summary {
+ color: var(--tui-error);
+ }
+
+ .run-stats {
+ margin: 0;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ white-space: nowrap;
+ }
+
.console-line.assistant {
color: var(--text-strong);
}
@@ -154,14 +202,6 @@
font-weight: 750;
}
- .tool-summary small {
- margin-left: var(--space-2);
- color: var(--text-muted);
- font-size: 0.74rem;
- font-weight: 700;
- text-transform: uppercase;
- }
-
.tool-label {
flex: 0 0 auto;
color: var(--tui-cyan);
@@ -208,18 +248,6 @@
font-weight: 750;
}
- .message-heading.streaming-heading {
- justify-content: flex-start;
- }
-
- .message-heading small {
- margin: 0;
- color: var(--text-muted);
- font-size: 0.74rem;
- font-weight: 700;
- text-transform: uppercase;
- }
-
.console-diff {
background: color-mix(in oklch, var(--bg-raised) 85%, black);
border: 1px solid var(--line);
diff --git a/web/workspace/src/lib/workspace/console/SequenceLoop.svelte b/web/workspace/src/lib/workspace/console/SequenceLoop.svelte
new file mode 100644
index 00000000..97777b5d
--- /dev/null
+++ b/web/workspace/src/lib/workspace/console/SequenceLoop.svelte
@@ -0,0 +1,45 @@
+
+
+{value}
+
+
diff --git a/web/workspace/src/lib/workspace/console/Spinner.svelte b/web/workspace/src/lib/workspace/console/Spinner.svelte
new file mode 100644
index 00000000..620e9b71
--- /dev/null
+++ b/web/workspace/src/lib/workspace/console/Spinner.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/workspace/src/lib/workspace/console/WorkerRunStatus.svelte b/web/workspace/src/lib/workspace/console/WorkerRunStatus.svelte
new file mode 100644
index 00000000..f8438c49
--- /dev/null
+++ b/web/workspace/src/lib/workspace/console/WorkerRunStatus.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+ {elapsed}
+ ・
+ {requests} {requestLabel}
+ |
+ ↑{formatRunTokens(uploadTokens)}/↓{formatRunTokens(outputTokens)}
+
+
+
diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts
index 041fdbfb..450bdc74 100644
--- a/web/workspace/src/lib/workspace/console/model.test.ts
+++ b/web/workspace/src/lib/workspace/console/model.test.ts
@@ -1,9 +1,12 @@
import type { Event } from "$lib/generated/protocol";
import {
+ type ConsoleEventInput,
type ConsoleLine,
createConsoleProjector,
isConsoleProjectionEvent,
projectConsole,
+ projectConsoleLines,
+ projectOverviewLines,
segmentsToText,
selectConsoleTimelineLines,
workerConsoleHref,
@@ -758,6 +761,9 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
!toolLines[0].body.includes("another content"),
"Read aggregate should not display file contents",
);
+ const overview = projectOverviewLines(projection.lines);
+ assertEquals(overview.length, 1);
+ assertEquals(overview[0].body, "2 files read");
});
Deno.test("projectConsole renders Edit calls with structured diff lines", () => {
@@ -856,11 +862,13 @@ Deno.test("projectConsole hides lifecycle events and renders system items", () =
},
]);
- assertEquals(projection.lines.length, 1);
- assertEquals(projection.lines[0].kind, "system");
- assertEquals(projection.lines[0].title, "System · notification");
+ assertEquals(projection.lines.length, 2);
+ assertEquals(projection.lines[0].kind, "run_stats");
+ assertEquals(projection.lines[0].body, "0s ・0 reqs ↑0/↓0");
+ assertEquals(projection.lines[1].kind, "system");
+ assertEquals(projection.lines[1].title, "System · notification");
assertEquals(
- projection.lines[0].body,
+ projection.lines[1].body,
"Reread Ticket 00001KZ6TSGG5 before acting.",
);
assertEquals(projection.status, "running");
@@ -1400,3 +1408,182 @@ Deno.test("snapshot restores TaskStore state from system history", () => {
}]);
assertEquals(projection.taskNextId, 4);
});
+
+Deno.test("overview hides typed task reminders after restoring TaskStore state", () => {
+ const body =
+ `[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 8, "status": "inprogress", "subject": "Visible in Tasks", "description": "Hidden in overview"}]\n}\n\`\`\``;
+ const projection = projectConsole([{
+ eventId: "task-reminder",
+ event: {
+ event: "system_item",
+ data: {
+ item: { kind: "task_reminder", body },
+ },
+ },
+ }]);
+
+ assertEquals(projection.tasks[0]?.taskid, 8);
+ assertEquals(projection.lines[0]?.systemItemKind, "task_reminder");
+ assertEquals(projectConsoleLines(projection.lines, "overview"), []);
+ const normal = projectConsoleLines(projection.lines, "normal");
+ assertEquals(normal.length, 1);
+ assertEquals(normal[0].kind, "task_reminder");
+ assertEquals(
+ normal[0].body,
+ "task reminder: [Session TaskStore snapshot]",
+ );
+});
+
+Deno.test("overview hides thinking and aggregates uninterrupted tool activity", () => {
+ const toolLine = (
+ id: string,
+ name: string,
+ diff?: ConsoleLine["diff"],
+ ): ConsoleLine => ({
+ id,
+ kind: "tool",
+ title: `Call · ${name}`,
+ body: name,
+ source: "event",
+ diff,
+ toolCall: {
+ id,
+ name,
+ argsStream: "",
+ state: "done",
+ },
+ });
+
+ const overview = projectOverviewLines([
+ consoleLine("user", "user"),
+ consoleLine("assistant-before", "assistant"),
+ consoleLine("thought-before-tools", "thinking"),
+ toolLine("read-a", "Read"),
+ consoleLine("thought-between-tools", "thinking"),
+ toolLine("read-b", "Read"),
+ toolLine("bash-a", "Bash"),
+ consoleLine("assistant-after-tools", "assistant"),
+ toolLine("edit-a", "Edit", [
+ { kind: "remove", oldNumber: 1, content: "old" },
+ { kind: "add", newNumber: 1, content: "new" },
+ { kind: "add", newNumber: 2, content: "next" },
+ ]),
+ ]);
+
+ assertEquals(overview.map((line) => line.kind), [
+ "user",
+ "assistant",
+ "activity",
+ "assistant",
+ "activity",
+ ]);
+ assertEquals(overview[2].body, "2 files read・ran 1 command");
+ assertEquals(overview[4].body, "edited +2/-1");
+});
+
+Deno.test("overview hides in-flight thinking and keeps tool failures visible", () => {
+ const overview = projectOverviewLines([
+ {
+ ...consoleLine("thinking-in-flight", "in_flight"),
+ title: "in-flight thinking",
+ },
+ {
+ ...consoleLine("failed-read", "tool"),
+ error: true,
+ toolCall: {
+ id: "failed-read",
+ name: "Read",
+ argsStream: "",
+ state: "error",
+ isError: true,
+ },
+ },
+ ]);
+
+ assertEquals(overview.length, 1);
+ assertEquals(overview[0].kind, "activity");
+ assertEquals(overview[0].body, "1 file read\n1 failed");
+ assertEquals(overview[0].error, true);
+});
+
+Deno.test("RunEnd appends TUI-compatible request and token stats", () => {
+ const events: ConsoleEventInput[] = [
+ {
+ eventId: "invoke",
+ observedAtMs: 1_000,
+ event: { event: "invoke_start", data: { kind: "user_send" } },
+ },
+ ...Array.from({ length: 5 }, (_, index) => ({
+ eventId: `turn-${index}`,
+ observedAtMs: 1_010 + index,
+ event: { event: "turn_start", data: { turn: index + 1 } } as Event,
+ })),
+ {
+ eventId: "usage",
+ observedAtMs: 1_020,
+ event: {
+ event: "usage",
+ data: {
+ input_tokens: 60_000,
+ cache_read_input_tokens: 3_500,
+ output_tokens: 1_200,
+ },
+ },
+ },
+ {
+ eventId: "run-end",
+ observedAtMs: 621_000,
+ event: { event: "run_end", data: { result: "finished" } },
+ },
+ ];
+
+ const projection = projectConsole(events);
+ const stats = projection.lines.filter((line) => line.kind === "run_stats");
+ assertEquals(stats.length, 1);
+ assertEquals(stats[0].body, "10m20s ・5 reqs ↑56.5k/↓1.2k");
+ assertEquals(
+ projectConsoleLines(projection.lines, "overview").at(-1)?.kind,
+ "run_stats",
+ );
+ assertEquals(
+ projectConsoleLines(projection.lines, "normal").at(-1)?.kind,
+ "run_stats",
+ );
+});
+
+Deno.test("new invoke resets stats before the next RunEnd", () => {
+ const projector = createConsoleProjector();
+ projector.append([
+ {
+ eventId: "first-invoke",
+ event: { event: "invoke_start", data: { kind: "user_send" } },
+ },
+ {
+ eventId: "first-turn",
+ event: { event: "turn_start", data: { turn: 1 } },
+ },
+ {
+ eventId: "first-usage",
+ event: {
+ event: "usage",
+ data: { input_tokens: 1_000, output_tokens: 100 },
+ },
+ },
+ {
+ eventId: "first-end",
+ event: { event: "run_end", data: { result: "finished" } },
+ },
+ ]);
+ const projection = projector.append([
+ {
+ eventId: "second-invoke",
+ event: { event: "invoke_start", data: { kind: "notify" } },
+ },
+ {
+ eventId: "second-end",
+ event: { event: "run_end", data: { result: "finished" } },
+ },
+ ]);
+
+ assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0");
+});
diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts
index 2e632fff..ea383d76 100644
--- a/web/workspace/src/lib/workspace/console/model.ts
+++ b/web/workspace/src/lib/workspace/console/model.ts
@@ -8,6 +8,13 @@ import type {
Segment,
} from "$lib/generated/protocol";
import { workspaceRoute } from "$lib/workspace/api/http";
+import {
+ applyRunActivityEvent,
+ emptyRunActivityStats,
+ formatRunElapsedCompact,
+ formatRunTokens,
+ type RunActivityStats,
+} from "./run-status.ts";
import {
applyTaskSnapshotText,
applyTaskToolCall,
@@ -19,6 +26,9 @@ export type ConsoleLineKind =
| "assistant"
| "thinking"
| "tool"
+ | "activity"
+ | "task_reminder"
+ | "run_stats"
| "status"
| "error"
| "usage"
@@ -51,6 +61,8 @@ export type ConsoleDiffLine = {
content: string;
};
+export type ConsoleViewMode = "overview" | "normal";
+
export type ConsoleLine = {
id: string;
kind: ConsoleLineKind;
@@ -63,6 +75,10 @@ export type ConsoleLine = {
streaming?: boolean;
error?: boolean;
toolCall?: ToolCallView;
+ /** Number of calls represented by a lower-level aggregate line. */
+ toolCallCount?: number;
+ /** Typed `SystemItem.kind` used by presentation-only projections. */
+ systemItemKind?: string;
};
export type InternalWorkerProjection = {
@@ -91,6 +107,7 @@ export type ConsoleProjection = {
taskNextId: number;
status: string | null;
usage: string | null;
+ runActivity: RunActivityStats;
cwd: string | null;
lastEventId: string | null;
internalWorkers: InternalWorkerProjection[];
@@ -176,6 +193,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
taskNextId: 1,
status: null,
usage: null,
+ runActivity: emptyRunActivityStats(),
cwd: null,
lastEventId: null,
internalWorkers: [],
@@ -224,6 +242,199 @@ function projectVisibleConsole(
};
}
+function isOverviewThinkingLine(line: ConsoleLine): boolean {
+ return line.kind === "thinking" ||
+ (line.kind === "in_flight" && line.title === "in-flight thinking");
+}
+
+function representedToolCallCount(line: ConsoleLine): number {
+ return Math.max(1, line.toolCallCount ?? 1);
+}
+
+function overviewToolActivityLine(group: ConsoleLine[]): ConsoleLine {
+ const first = group[0]!;
+ const last = group[group.length - 1]!;
+ let readCount = 0;
+ let searchCount = 0;
+ let commandCount = 0;
+ let editCount = 0;
+ let writeCount = 0;
+ let additions = 0;
+ let deletions = 0;
+ let failedCount = 0;
+ let activeCount = 0;
+ let readActive = false;
+ let searchActive = false;
+ let commandActive = false;
+ let editActive = false;
+ let writeActive = false;
+ const otherCounts = new Map();
+
+ for (const line of group) {
+ const count = representedToolCallCount(line);
+ const name = line.toolCall?.name ?? "Tool";
+ const state = line.toolCall?.state;
+ if (state === "error" || line.error || line.toolCall?.isError) {
+ failedCount += count;
+ }
+ const callActive = state === "pending" || state === "streaming_args" ||
+ state === "running";
+ if (callActive) activeCount += count;
+
+ switch (name) {
+ case "Read":
+ readCount += count;
+ readActive ||= callActive;
+ break;
+ case "Glob":
+ case "Grep":
+ case "WebSearch":
+ case "SearchSessionEntries":
+ searchCount += count;
+ searchActive ||= callActive;
+ break;
+ case "Bash":
+ commandCount += count;
+ commandActive ||= callActive;
+ break;
+ case "Edit":
+ editCount += count;
+ editActive ||= callActive;
+ if (state === "done") {
+ additions += line.diff?.filter((diff) =>
+ diff.kind === "add"
+ ).length ?? 0;
+ deletions += line.diff?.filter((diff) =>
+ diff.kind === "remove"
+ ).length ?? 0;
+ }
+ break;
+ case "Write":
+ writeCount += count;
+ writeActive ||= callActive;
+ break;
+ default:
+ otherCounts.set(name, (otherCounts.get(name) ?? 0) + count);
+ break;
+ }
+ }
+
+ const active = activeCount > 0;
+ const primary: string[] = [];
+ if (readCount > 0) {
+ primary.push(
+ readActive
+ ? `reading ${readCount} file${readCount === 1 ? "" : "s"}`
+ : `${readCount} file${readCount === 1 ? "" : "s"} read`,
+ );
+ }
+ if (searchCount > 0) {
+ primary.push(
+ searchActive
+ ? `searching ${searchCount} time${searchCount === 1 ? "" : "s"}`
+ : `searched ${searchCount} time${searchCount === 1 ? "" : "s"}`,
+ );
+ }
+ if (commandCount > 0) {
+ primary.push(
+ commandActive
+ ? `running ${commandCount} command${commandCount === 1 ? "" : "s"}`
+ : `ran ${commandCount} command${commandCount === 1 ? "" : "s"}`,
+ );
+ }
+ for (
+ const [name, count] of [...otherCounts].sort(([left], [right]) =>
+ left.localeCompare(right)
+ )
+ ) {
+ primary.push(count === 1 ? name : `${count} ${name}`);
+ }
+
+ const changes: string[] = [];
+ if (editCount > 0) {
+ if (editActive) {
+ changes.push(`editing ${editCount} file${editCount === 1 ? "" : "s"}`);
+ } else if (additions > 0 || deletions > 0) {
+ changes.push(`edited +${additions}/-${deletions}`);
+ } else {
+ changes.push(`edited ${editCount} file${editCount === 1 ? "" : "s"}`);
+ }
+ }
+ if (writeCount > 0) {
+ changes.push(
+ writeActive
+ ? `writing ${writeCount} file${writeCount === 1 ? "" : "s"}`
+ : `wrote ${writeCount} file${writeCount === 1 ? "" : "s"}`,
+ );
+ }
+ if (failedCount > 0) changes.push(`${failedCount} failed`);
+
+ return {
+ id: `activity-${first.id}-${last.id}`,
+ kind: "activity",
+ title: "Activity",
+ body: [primary.join("・"), ...changes].filter(Boolean).join("\n"),
+ source: "event",
+ streaming: active,
+ error: failedCount > 0,
+ };
+}
+
+/**
+ * Builds the overview-only Console presentation. Protocol projection retains
+ * full tool and thinking state for reconciliation, but the visible history
+ * hides thinking and folds each uninterrupted tool run into one activity.
+ */
+export function projectOverviewLines(lines: ConsoleLine[]): ConsoleLine[] {
+ const overview: ConsoleLine[] = [];
+ let toolGroup: ConsoleLine[] = [];
+
+ const flushTools = () => {
+ if (toolGroup.length === 0) return;
+ overview.push(overviewToolActivityLine(toolGroup));
+ toolGroup = [];
+ };
+
+ for (const line of lines) {
+ if (line.systemItemKind === "task_reminder") continue;
+ if (isOverviewThinkingLine(line)) continue;
+ if (line.kind === "tool" && line.toolCall) {
+ toolGroup.push(line);
+ continue;
+ }
+ flushTools();
+ overview.push(line);
+ }
+ flushTools();
+ return overview;
+}
+
+export function projectNormalLines(lines: ConsoleLine[]): ConsoleLine[] {
+ return lines.map((line) => {
+ if (line.systemItemKind !== "task_reminder") return line;
+ const first = line.body
+ .split("\n")
+ .map((part) => part.trim())
+ .find(Boolean);
+ return {
+ ...line,
+ kind: "task_reminder",
+ title: "Task reminder",
+ body: first ? `task reminder: ${first}` : "task reminder",
+ detail: undefined,
+ };
+ });
+}
+
+export function projectConsoleLines(
+ lines: ConsoleLine[],
+ mode: ConsoleViewMode,
+): ConsoleLine[] {
+ return mode === "overview"
+ ? projectOverviewLines(lines)
+ : projectNormalLines(lines);
+}
+
function appendSnapshotInFlightLines(
projection: ConsoleProjection,
blocks: InFlightBlock[],
@@ -275,19 +486,24 @@ function projectInternalWorkerSnapshot(
export function applyProtocolEvent(
projection: ConsoleProjection,
- envelope: { eventId: string; event: ProtocolEvent },
+ envelope: ConsoleEventInput,
): ConsoleProjection {
+ const event = envelope.event;
const next: ConsoleProjection = {
lines: [...projection.lines],
tasks: [...projection.tasks],
taskNextId: projection.taskNextId,
status: projection.status,
usage: projection.usage,
+ runActivity: applyRunActivityEvent(
+ projection.runActivity,
+ event,
+ envelope.observedAtMs ?? 0,
+ ),
cwd: projection.cwd,
lastEventId: envelope.eventId,
internalWorkers: [...projection.internalWorkers],
};
- const event = envelope.event;
switch (event.event) {
case "user_message":
@@ -427,6 +643,7 @@ export function applyProtocolEvent(
eventId:
`${envelope.eventId}:internal:${event.data.worker.session_id}:${event.data.revision}`,
event: event.data.event,
+ observedAtMs: envelope.observedAtMs,
}),
};
if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated;
@@ -455,7 +672,15 @@ export function applyProtocolEvent(
case "llm_call_end":
case "llm_retry":
case "llm_continuation":
+ break;
case "run_end":
+ next.lines.push(
+ runStatsLine(
+ envelope.eventId,
+ next.runActivity,
+ envelope.observedAtMs ?? next.runActivity.startedAtMs ?? 0,
+ ),
+ );
break;
case "alert":
appendAlertLine(next, envelope.eventId, event.data);
@@ -512,6 +737,22 @@ export function segmentsToText(segments: Segment[]): string {
.join("\n");
}
+function runStatsLine(
+ eventId: string,
+ stats: RunActivityStats,
+ endedAtMs: number,
+): ConsoleLine {
+ const elapsedMs = endedAtMs - (stats.startedAtMs ?? endedAtMs);
+ return line(
+ eventId,
+ "run_stats",
+ "Run stats",
+ `${formatRunElapsedCompact(elapsedMs)} ・${stats.requests} reqs ↑${
+ formatRunTokens(stats.uploadTokens)
+ }/↓${formatRunTokens(stats.outputTokens)}`,
+ );
+}
+
function line(
eventId: string,
kind: ConsoleLineKind,
@@ -542,7 +783,10 @@ function systemItemLine(eventId: string, item: unknown): ConsoleLine {
const title = `System · ${itemKind.replaceAll("_", " ")}`;
const body = stringField(item, "body") ?? stringField(item, "message") ??
stringField(item, "content") ?? jsonPreview(item);
- return line(eventId, "system", title, body);
+ return {
+ ...line(eventId, "system", title, body),
+ systemItemKind: itemKind,
+ };
}
function upsertStatusLine(
@@ -867,6 +1111,12 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
source: "event",
streaming: inProgress,
error: hasError,
+ toolCall: {
+ ...calls[0]!,
+ state: hasError ? "error" : inProgress ? "running" : "done",
+ isError: hasError,
+ },
+ toolCallCount: count,
};
}
@@ -1291,6 +1541,7 @@ function snapshotProjectionFromEntries(
taskNextId: 1,
status: null,
usage: null,
+ runActivity: emptyRunActivityStats(),
cwd,
lastEventId: eventId,
internalWorkers: [],
diff --git a/web/workspace/src/lib/workspace/console/run-status.test.ts b/web/workspace/src/lib/workspace/console/run-status.test.ts
new file mode 100644
index 00000000..575ce733
--- /dev/null
+++ b/web/workspace/src/lib/workspace/console/run-status.test.ts
@@ -0,0 +1,96 @@
+// @ts-nocheck
+import {
+ applyRunActivityEvent,
+ emptyRunActivityStats,
+ formatRunElapsed,
+ formatRunElapsedCompact,
+ formatRunTokens,
+} from "./run-status.ts";
+
+function assertEquals(actual: unknown, expected: unknown): void {
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
+ throw new Error(
+ `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
+ );
+ }
+}
+
+Deno.test("run activity follows TUI request and net-token accounting", () => {
+ let stats = applyRunActivityEvent(
+ emptyRunActivityStats(),
+ { event: "invoke_start", data: { kind: "user_send" } },
+ 1_000,
+ );
+ stats = applyRunActivityEvent(
+ stats,
+ { event: "turn_start", data: { turn: 1 } },
+ 1_010,
+ );
+ stats = applyRunActivityEvent(
+ stats,
+ {
+ event: "usage",
+ data: {
+ input_tokens: 25_000,
+ cache_read_input_tokens: 20_000,
+ output_tokens: 3_000,
+ },
+ },
+ 1_020,
+ );
+ stats = applyRunActivityEvent(
+ stats,
+ { event: "turn_start", data: { turn: 2 } },
+ 1_030,
+ );
+
+ assertEquals(stats, {
+ startedAtMs: 1_000,
+ requests: 2,
+ uploadTokens: 5_000,
+ outputTokens: 3_000,
+ });
+});
+
+Deno.test("new invoke and running snapshot reset run activity", () => {
+ const previous = {
+ startedAtMs: 1,
+ requests: 3,
+ uploadTokens: 100,
+ outputTokens: 20,
+ };
+ assertEquals(
+ applyRunActivityEvent(
+ previous,
+ { event: "invoke_start", data: { kind: "notify" } },
+ 9_000,
+ ),
+ { startedAtMs: 9_000, requests: 0, uploadTokens: 0, outputTokens: 0 },
+ );
+ assertEquals(
+ applyRunActivityEvent(
+ previous,
+ {
+ event: "snapshot",
+ data: {
+ entries: [],
+ greeting: { text: "", profile: "" },
+ status: "idle",
+ in_flight: {},
+ internal_workers: [],
+ },
+ },
+ 10_000,
+ ),
+ emptyRunActivityStats(),
+ );
+});
+
+Deno.test("run status formatting matches the compact TUI shape", () => {
+ assertEquals(formatRunElapsed(88_900), "1m 28s");
+ assertEquals(formatRunElapsed(3_723_000), "1h 2m 3s");
+ assertEquals(formatRunElapsedCompact(620_000), "10m20s");
+ assertEquals(formatRunTokens(25_000), "25.0k");
+ assertEquals(formatRunTokens(3_000), "3.0k");
+ assertEquals(formatRunTokens(999), "999");
+});
diff --git a/web/workspace/src/lib/workspace/console/run-status.ts b/web/workspace/src/lib/workspace/console/run-status.ts
new file mode 100644
index 00000000..aabb45c2
--- /dev/null
+++ b/web/workspace/src/lib/workspace/console/run-status.ts
@@ -0,0 +1,71 @@
+import type { Event as ProtocolEvent } from "$lib/generated/protocol";
+
+export type RunActivityStats = {
+ startedAtMs: number | null;
+ requests: number;
+ uploadTokens: number;
+ outputTokens: number;
+};
+
+export function emptyRunActivityStats(): RunActivityStats {
+ return {
+ startedAtMs: null,
+ requests: 0,
+ uploadTokens: 0,
+ outputTokens: 0,
+ };
+}
+
+export function applyRunActivityEvent(
+ current: RunActivityStats,
+ event: ProtocolEvent,
+ observedAtMs: number,
+): RunActivityStats {
+ switch (event.event) {
+ case "invoke_start":
+ return { ...emptyRunActivityStats(), startedAtMs: observedAtMs };
+ case "snapshot":
+ return event.data.status === "running"
+ ? { ...emptyRunActivityStats(), startedAtMs: observedAtMs }
+ : emptyRunActivityStats();
+ case "turn_start":
+ return {
+ ...current,
+ startedAtMs: current.startedAtMs ?? observedAtMs,
+ requests: current.requests + 1,
+ };
+ case "usage": {
+ const input = event.data.input_tokens ?? 0;
+ const cacheRead = event.data.cache_read_input_tokens ?? 0;
+ return {
+ ...current,
+ startedAtMs: current.startedAtMs ?? observedAtMs,
+ uploadTokens: current.uploadTokens + Math.max(0, input - cacheRead),
+ outputTokens: current.outputTokens + (event.data.output_tokens ?? 0),
+ };
+ }
+ default:
+ return current;
+ }
+}
+
+export function formatRunElapsed(elapsedMs: number): string {
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1_000));
+ const hours = Math.floor(totalSeconds / 3_600);
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
+ const seconds = totalSeconds % 60;
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
+ return `${seconds}s`;
+}
+
+export function formatRunElapsedCompact(elapsedMs: number): string {
+ return formatRunElapsed(elapsedMs).replaceAll(" ", "");
+}
+
+/** Match the TUI token abbreviation contract. */
+export function formatRunTokens(tokens: number): string {
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
+ return String(tokens);
+}
diff --git a/web/workspace/src/lib/workspace/console/worker-control-shortcuts.test.ts b/web/workspace/src/lib/workspace/console/worker-control-shortcuts.test.ts
new file mode 100644
index 00000000..97bcebbe
--- /dev/null
+++ b/web/workspace/src/lib/workspace/console/worker-control-shortcuts.test.ts
@@ -0,0 +1,80 @@
+// @ts-nocheck
+import { resolveWorkerControlShortcut } from "./worker-control-shortcuts.ts";
+
+function assertEquals(actual: unknown, expected: unknown): void {
+ if (actual !== expected) {
+ throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
+ }
+}
+
+const base = {
+ protocolOpen: true,
+ running: false,
+ paused: false,
+ composerFocused: false,
+ draftBlank: true,
+ editableTarget: false,
+ hasSelection: false,
+};
+
+Deno.test("Worker control shortcuts match TUI pause cancel and resume keys", () => {
+ assertEquals(
+ resolveWorkerControlShortcut(
+ { key: "c", ctrlKey: true },
+ { ...base, running: true },
+ ),
+ "pause",
+ );
+ assertEquals(
+ resolveWorkerControlShortcut(
+ { key: "x", ctrlKey: true },
+ { ...base, paused: true },
+ ),
+ "cancel",
+ );
+ assertEquals(
+ resolveWorkerControlShortcut(
+ { key: "Enter" },
+ { ...base, paused: true, composerFocused: true },
+ ),
+ "resume",
+ );
+});
+
+Deno.test("Worker control shortcuts preserve browser editing operations", () => {
+ for (
+ const state of [
+ { ...base, running: true, editableTarget: true },
+ { ...base, running: true, hasSelection: true },
+ ]
+ ) {
+ assertEquals(
+ resolveWorkerControlShortcut({ key: "c", ctrlKey: true }, state),
+ null,
+ );
+ }
+ assertEquals(
+ resolveWorkerControlShortcut(
+ { key: "x", ctrlKey: true },
+ { ...base, running: true, editableTarget: true },
+ ),
+ null,
+ );
+});
+
+Deno.test("Resume requires a blank focused composer and paused Worker", () => {
+ assertEquals(
+ resolveWorkerControlShortcut(
+ { key: "Enter" },
+ { ...base, paused: true, composerFocused: true, draftBlank: false },
+ ),
+ null,
+ );
+ assertEquals(
+ resolveWorkerControlShortcut(
+ { key: "Enter" },
+ { ...base, paused: true, composerFocused: false },
+ ),
+ null,
+ );
+});
diff --git a/web/workspace/src/lib/workspace/console/worker-control-shortcuts.ts b/web/workspace/src/lib/workspace/console/worker-control-shortcuts.ts
new file mode 100644
index 00000000..4b8110c3
--- /dev/null
+++ b/web/workspace/src/lib/workspace/console/worker-control-shortcuts.ts
@@ -0,0 +1,53 @@
+export type WorkerControlShortcut = "pause" | "cancel" | "resume";
+
+export type WorkerControlShortcutEvent = {
+ key: string;
+ ctrlKey?: boolean;
+ metaKey?: boolean;
+ altKey?: boolean;
+ shiftKey?: boolean;
+ repeat?: boolean;
+ isComposing?: boolean;
+};
+
+export type WorkerControlShortcutState = {
+ protocolOpen: boolean;
+ running: boolean;
+ paused: boolean;
+ composerFocused: boolean;
+ draftBlank: boolean;
+ editableTarget: boolean;
+ hasSelection: boolean;
+};
+
+/** Resolve the TUI-compatible Worker control shortcut without side effects. */
+export function resolveWorkerControlShortcut(
+ event: WorkerControlShortcutEvent,
+ state: WorkerControlShortcutState,
+): WorkerControlShortcut | null {
+ if (!state.protocolOpen || event.repeat || event.isComposing) return null;
+
+ if (
+ event.key === "Enter" && state.paused && state.composerFocused &&
+ state.draftBlank && !event.ctrlKey && !event.metaKey && !event.altKey &&
+ !event.shiftKey
+ ) {
+ return "resume";
+ }
+
+ if (
+ !event.ctrlKey || event.metaKey || event.altKey || event.shiftKey ||
+ state.editableTarget || state.hasSelection
+ ) {
+ return null;
+ }
+
+ switch (event.key.toLowerCase()) {
+ case "c":
+ return state.running ? "pause" : null;
+ case "x":
+ return state.running || state.paused ? "cancel" : null;
+ default:
+ return null;
+ }
+}
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 f2e4a23b..1eea394c 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
@@ -15,15 +15,19 @@
type ComposerCompletionEntry,
type ComposerCompletionToken,
} from "$lib/workspace/console/composer-completion";
+ import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
+ import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
import {
createConsoleProjector,
flattenInternalWorkers,
isConsoleProjectionEvent,
+ projectConsoleLines,
selectConsoleTimelineLines,
type ConsoleEventInput,
type ConsoleLine,
type ConsoleProjection,
+ type ConsoleViewMode,
} from "$lib/workspace/console/model";
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
import { workspaceApiPath } from "$lib/workspace/api/http";
@@ -119,6 +123,7 @@
let workerDetailsOpen = $state(false);
let taskPaneOpen = $state(false);
let timelineOpen = $state(false);
+ let consoleViewMode = $state("overview");
let consoleBodyElement: HTMLElement | null = null;
let composerTextareaElement: HTMLTextAreaElement | null = null;
let timelineRailDragCleanup: (() => void) | null = null;
@@ -151,7 +156,9 @@
const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
- const lines = $derived(consoleProjection.lines);
+ const lines = $derived(
+ projectConsoleLines(consoleProjection.lines, consoleViewMode),
+ );
const tasks = $derived(consoleProjection.tasks);
const internalWorkers = $derived(
flattenInternalWorkers(consoleProjection.internalWorkers),
@@ -171,6 +178,7 @@
);
const workerState = $derived(liveWorkerState ?? worker?.state ?? "loading");
const workerRunning = $derived(workerState === "running");
+ const workerPaused = $derived(workerState === "paused");
const inputReady = $derived(workerState === "idle");
const composerEditable = $derived(protocolState === "open" && !sending);
const canSubmitDraft = $derived(inputReady && composerEditable);
@@ -406,6 +414,52 @@
}
}
+ function sendWorkerControl(command: "pause" | "cancel" | "resume") {
+ const label = command[0].toUpperCase() + command.slice(1);
+ sendControl({ method: command }, label);
+ }
+
+ function isEditableTarget(target: EventTarget | null): boolean {
+ return (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target instanceof HTMLSelectElement ||
+ (target instanceof HTMLElement && target.isContentEditable)
+ );
+ }
+
+ function targetHasSelection(target: EventTarget | null): boolean {
+ if (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement
+ ) {
+ return (
+ target.selectionStart !== null &&
+ target.selectionEnd !== null &&
+ target.selectionStart !== target.selectionEnd
+ );
+ }
+ return Boolean(window.getSelection()?.toString());
+ }
+
+ function handleWorkerControlShortcut(event: KeyboardEvent) {
+ const composerFocused = event.target === composerTextareaElement;
+ const command = resolveWorkerControlShortcut(event, {
+ protocolOpen: protocolState === "open",
+ running: workerRunning,
+ paused: workerPaused,
+ composerFocused,
+ draftBlank: draft.trim().length === 0,
+ editableTarget: isEditableTarget(event.target) && !composerFocused,
+ hasSelection: targetHasSelection(event.target),
+ });
+ if (!command) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ sendWorkerControl(command);
+ }
+
function requestRewindTargets() {
sendControl({ method: "list_rewind_targets" }, "Rewind target request");
}
@@ -1107,6 +1161,8 @@
$effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget));
+
+
Worker Console · Yoi Workspace