feat: add web console modes and run status

This commit is contained in:
2026-08-20 15:02:24 +09:00
parent 7dd4539f50
commit f5f80fcd48
13 changed files with 1143 additions and 80 deletions
@@ -19,7 +19,8 @@
} }
function shouldRenderHeading(line: ConsoleLine): boolean { 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 } { function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
@@ -49,24 +50,29 @@
{#if shouldRenderHeading(item)} {#if shouldRenderHeading(item)}
<div class="message-heading"> <div class="message-heading">
<span>{item.title}</span> <span>{item.title}</span>
{#if item.streaming}<small>streaming</small>{/if}
</div> </div>
{:else if item.kind === 'tool'} {:else if item.kind === 'tool'}
<div class="tool-summary"> <div class="tool-summary">
<span class="tool-label">{toolSummary(item).label}</span> <span class="tool-label">{toolSummary(item).label}</span>
<span class="tool-separator"></span> <span class="tool-separator"></span>
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</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> </div>
{/if} {/if}
{#if item.kind === 'tool'} {#if item.kind === 'tool'}
{#if bodyTextAfterToolSummary(item)} {#if bodyTextAfterToolSummary(item)}
<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p> <p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>
{/if} {/if}
{:else if item.kind === 'user'}
<div class="user-message">
<span class="user-prompt" aria-hidden="true">&gt;</span>
<div><RichMarkdown text={item.body || '—'} /></div>
</div>
{:else if item.kind === 'activity'}
<p class="activity-summary">{item.body || '—'}</p>
{:else if item.kind === 'task_reminder'}
<p class="task-reminder-summary">{item.body || 'task reminder'}</p>
{:else if item.kind === 'run_stats'}
<p class="run-stats">{item.body}</p>
{:else if shouldRenderMarkdown(item)} {:else if shouldRenderMarkdown(item)}
<RichMarkdown text={item.body || '—'} /> <RichMarkdown text={item.body || '—'} />
{:else} {:else}
@@ -102,6 +108,48 @@
color: var(--tui-green); 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 { .console-line.assistant {
color: var(--text-strong); color: var(--text-strong);
} }
@@ -154,14 +202,6 @@
font-weight: 750; 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 { .tool-label {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--tui-cyan); color: var(--tui-cyan);
@@ -208,18 +248,6 @@
font-weight: 750; 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 { .console-diff {
background: color-mix(in oklch, var(--bg-raised) 85%, black); background: color-mix(in oklch, var(--bg-raised) 85%, black);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -0,0 +1,45 @@
<script lang="ts">
type Props = {
values: readonly string[];
intervalMs?: number;
ariaLabel?: string;
class?: string;
};
let {
values,
intervalMs = 100,
ariaLabel,
class: className,
}: Props = $props();
let index = $state(0);
const value = $derived(values.length > 0 ? values[index % values.length] : "");
$effect(() => {
const length = values.length;
const delay = Math.max(16, intervalMs);
index = 0;
if (length <= 1) return;
const timer = window.setInterval(() => {
index = (index + 1) % length;
}, delay);
return () => window.clearInterval(timer);
});
</script>
<span
class={className}
class:sequence-loop={true}
aria-label={ariaLabel}
aria-hidden={ariaLabel ? undefined : "true"}
>{value}</span>
<style>
.sequence-loop {
display: inline-block;
min-width: 1ch;
text-align: center;
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,35 @@
<script module lang="ts">
export const SPINNER_FRAMES = [
"⣷",
"⣯",
"⣟",
"⡿",
"⢿",
"⣻",
"⣽",
"⣾",
] as const;
</script>
<script lang="ts">
import SequenceLoop from "./SequenceLoop.svelte";
type Props = {
intervalMs?: number;
label?: string;
};
let { intervalMs = 90, label = "Running" }: Props = $props();
</script>
<span class="spinner" role="img" aria-label={label}>
<SequenceLoop values={SPINNER_FRAMES} {intervalMs} />
</span>
<style>
.spinner {
display: inline-flex;
color: var(--accent);
line-height: 1;
}
</style>
@@ -0,0 +1,48 @@
<script lang="ts">
import Spinner from "./Spinner.svelte";
import { formatRunElapsed, formatRunTokens } from "./run-status";
type Props = {
startedAtMs: number | null;
requests: number;
uploadTokens: number;
outputTokens: number;
};
let { startedAtMs, requests, uploadTokens, outputTokens }: Props = $props();
let nowMs = $state(Date.now());
$effect(() => {
startedAtMs;
nowMs = Date.now();
const timer = window.setInterval(() => {
nowMs = Date.now();
}, 1_000);
return () => window.clearInterval(timer);
});
const elapsed = $derived(formatRunElapsed(nowMs - (startedAtMs ?? nowMs)));
const requestLabel = $derived(requests === 1 ? "req" : "reqs");
</script>
<div class="worker-run-status" role="status" aria-live="off">
<Spinner />
<span>{elapsed}</span>
<span aria-hidden="true"></span>
<span>{requests} {requestLabel}</span>
<span aria-hidden="true">|</span>
<span>{formatRunTokens(uploadTokens)}/↓{formatRunTokens(outputTokens)}</span>
</div>
<style>
.worker-run-status {
display: flex;
align-items: center;
gap: 0.42rem;
min-height: 1.35rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
}
</style>
@@ -1,9 +1,12 @@
import type { Event } from "$lib/generated/protocol"; import type { Event } from "$lib/generated/protocol";
import { import {
type ConsoleEventInput,
type ConsoleLine, type ConsoleLine,
createConsoleProjector, createConsoleProjector,
isConsoleProjectionEvent, isConsoleProjectionEvent,
projectConsole, projectConsole,
projectConsoleLines,
projectOverviewLines,
segmentsToText, segmentsToText,
selectConsoleTimelineLines, selectConsoleTimelineLines,
workerConsoleHref, workerConsoleHref,
@@ -758,6 +761,9 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
!toolLines[0].body.includes("another content"), !toolLines[0].body.includes("another content"),
"Read aggregate should not display file contents", "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", () => { 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.length, 2);
assertEquals(projection.lines[0].kind, "system"); assertEquals(projection.lines[0].kind, "run_stats");
assertEquals(projection.lines[0].title, "System · notification"); assertEquals(projection.lines[0].body, "0s ・0 reqs ↑0/↓0");
assertEquals(projection.lines[1].kind, "system");
assertEquals(projection.lines[1].title, "System · notification");
assertEquals( assertEquals(
projection.lines[0].body, projection.lines[1].body,
"Reread Ticket 00001KZ6TSGG5 before acting.", "Reread Ticket 00001KZ6TSGG5 before acting.",
); );
assertEquals(projection.status, "running"); assertEquals(projection.status, "running");
@@ -1400,3 +1408,182 @@ Deno.test("snapshot restores TaskStore state from system history", () => {
}]); }]);
assertEquals(projection.taskNextId, 4); 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");
});
@@ -8,6 +8,13 @@ import type {
Segment, Segment,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import { workspaceRoute } from "$lib/workspace/api/http"; import { workspaceRoute } from "$lib/workspace/api/http";
import {
applyRunActivityEvent,
emptyRunActivityStats,
formatRunElapsedCompact,
formatRunTokens,
type RunActivityStats,
} from "./run-status.ts";
import { import {
applyTaskSnapshotText, applyTaskSnapshotText,
applyTaskToolCall, applyTaskToolCall,
@@ -19,6 +26,9 @@ export type ConsoleLineKind =
| "assistant" | "assistant"
| "thinking" | "thinking"
| "tool" | "tool"
| "activity"
| "task_reminder"
| "run_stats"
| "status" | "status"
| "error" | "error"
| "usage" | "usage"
@@ -51,6 +61,8 @@ export type ConsoleDiffLine = {
content: string; content: string;
}; };
export type ConsoleViewMode = "overview" | "normal";
export type ConsoleLine = { export type ConsoleLine = {
id: string; id: string;
kind: ConsoleLineKind; kind: ConsoleLineKind;
@@ -63,6 +75,10 @@ export type ConsoleLine = {
streaming?: boolean; streaming?: boolean;
error?: boolean; error?: boolean;
toolCall?: ToolCallView; 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 = { export type InternalWorkerProjection = {
@@ -91,6 +107,7 @@ export type ConsoleProjection = {
taskNextId: number; taskNextId: number;
status: string | null; status: string | null;
usage: string | null; usage: string | null;
runActivity: RunActivityStats;
cwd: string | null; cwd: string | null;
lastEventId: string | null; lastEventId: string | null;
internalWorkers: InternalWorkerProjection[]; internalWorkers: InternalWorkerProjection[];
@@ -176,6 +193,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
taskNextId: 1, taskNextId: 1,
status: null, status: null,
usage: null, usage: null,
runActivity: emptyRunActivityStats(),
cwd: null, cwd: null,
lastEventId: null, lastEventId: null,
internalWorkers: [], 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<string, number>();
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( function appendSnapshotInFlightLines(
projection: ConsoleProjection, projection: ConsoleProjection,
blocks: InFlightBlock[], blocks: InFlightBlock[],
@@ -275,19 +486,24 @@ function projectInternalWorkerSnapshot(
export function applyProtocolEvent( export function applyProtocolEvent(
projection: ConsoleProjection, projection: ConsoleProjection,
envelope: { eventId: string; event: ProtocolEvent }, envelope: ConsoleEventInput,
): ConsoleProjection { ): ConsoleProjection {
const event = envelope.event;
const next: ConsoleProjection = { const next: ConsoleProjection = {
lines: [...projection.lines], lines: [...projection.lines],
tasks: [...projection.tasks], tasks: [...projection.tasks],
taskNextId: projection.taskNextId, taskNextId: projection.taskNextId,
status: projection.status, status: projection.status,
usage: projection.usage, usage: projection.usage,
runActivity: applyRunActivityEvent(
projection.runActivity,
event,
envelope.observedAtMs ?? 0,
),
cwd: projection.cwd, cwd: projection.cwd,
lastEventId: envelope.eventId, lastEventId: envelope.eventId,
internalWorkers: [...projection.internalWorkers], internalWorkers: [...projection.internalWorkers],
}; };
const event = envelope.event;
switch (event.event) { switch (event.event) {
case "user_message": case "user_message":
@@ -427,6 +643,7 @@ export function applyProtocolEvent(
eventId: eventId:
`${envelope.eventId}:internal:${event.data.worker.session_id}:${event.data.revision}`, `${envelope.eventId}:internal:${event.data.worker.session_id}:${event.data.revision}`,
event: event.data.event, event: event.data.event,
observedAtMs: envelope.observedAtMs,
}), }),
}; };
if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated; if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated;
@@ -455,7 +672,15 @@ export function applyProtocolEvent(
case "llm_call_end": case "llm_call_end":
case "llm_retry": case "llm_retry":
case "llm_continuation": case "llm_continuation":
break;
case "run_end": case "run_end":
next.lines.push(
runStatsLine(
envelope.eventId,
next.runActivity,
envelope.observedAtMs ?? next.runActivity.startedAtMs ?? 0,
),
);
break; break;
case "alert": case "alert":
appendAlertLine(next, envelope.eventId, event.data); appendAlertLine(next, envelope.eventId, event.data);
@@ -512,6 +737,22 @@ export function segmentsToText(segments: Segment[]): string {
.join("\n"); .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( function line(
eventId: string, eventId: string,
kind: ConsoleLineKind, kind: ConsoleLineKind,
@@ -542,7 +783,10 @@ function systemItemLine(eventId: string, item: unknown): ConsoleLine {
const title = `System · ${itemKind.replaceAll("_", " ")}`; const title = `System · ${itemKind.replaceAll("_", " ")}`;
const body = stringField(item, "body") ?? stringField(item, "message") ?? const body = stringField(item, "body") ?? stringField(item, "message") ??
stringField(item, "content") ?? jsonPreview(item); stringField(item, "content") ?? jsonPreview(item);
return line(eventId, "system", title, body); return {
...line(eventId, "system", title, body),
systemItemKind: itemKind,
};
} }
function upsertStatusLine( function upsertStatusLine(
@@ -867,6 +1111,12 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
source: "event", source: "event",
streaming: inProgress, streaming: inProgress,
error: hasError, error: hasError,
toolCall: {
...calls[0]!,
state: hasError ? "error" : inProgress ? "running" : "done",
isError: hasError,
},
toolCallCount: count,
}; };
} }
@@ -1291,6 +1541,7 @@ function snapshotProjectionFromEntries(
taskNextId: 1, taskNextId: 1,
status: null, status: null,
usage: null, usage: null,
runActivity: emptyRunActivityStats(),
cwd, cwd,
lastEventId: eventId, lastEventId: eventId,
internalWorkers: [], internalWorkers: [],
@@ -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");
});
@@ -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);
}
@@ -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,
);
});
@@ -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;
}
}
@@ -15,15 +15,19 @@
type ComposerCompletionEntry, type ComposerCompletionEntry,
type ComposerCompletionToken, type ComposerCompletionToken,
} from "$lib/workspace/console/composer-completion"; } from "$lib/workspace/console/composer-completion";
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
import { fitTextarea } from "$lib/workspace/console/textarea-fit"; import { fitTextarea } from "$lib/workspace/console/textarea-fit";
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
import { import {
createConsoleProjector, createConsoleProjector,
flattenInternalWorkers, flattenInternalWorkers,
isConsoleProjectionEvent, isConsoleProjectionEvent,
projectConsoleLines,
selectConsoleTimelineLines, selectConsoleTimelineLines,
type ConsoleEventInput, type ConsoleEventInput,
type ConsoleLine, type ConsoleLine,
type ConsoleProjection, type ConsoleProjection,
type ConsoleViewMode,
} from "$lib/workspace/console/model"; } from "$lib/workspace/console/model";
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol"; import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
import { workspaceApiPath } from "$lib/workspace/api/http"; import { workspaceApiPath } from "$lib/workspace/api/http";
@@ -119,6 +123,7 @@
let workerDetailsOpen = $state(false); let workerDetailsOpen = $state(false);
let taskPaneOpen = $state(false); let taskPaneOpen = $state(false);
let timelineOpen = $state(false); let timelineOpen = $state(false);
let consoleViewMode = $state<ConsoleViewMode>("overview");
let consoleBodyElement: HTMLElement | null = null; let consoleBodyElement: HTMLElement | null = null;
let composerTextareaElement: HTMLTextAreaElement | null = null; let composerTextareaElement: HTMLTextAreaElement | null = null;
let timelineRailDragCleanup: (() => void) | null = null; let timelineRailDragCleanup: (() => void) | null = null;
@@ -151,7 +156,9 @@
const consoleTarget = $derived({ workspaceId, runtimeId, workerId }); const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
const lines = $derived(consoleProjection.lines); const lines = $derived(
projectConsoleLines(consoleProjection.lines, consoleViewMode),
);
const tasks = $derived(consoleProjection.tasks); const tasks = $derived(consoleProjection.tasks);
const internalWorkers = $derived( const internalWorkers = $derived(
flattenInternalWorkers(consoleProjection.internalWorkers), flattenInternalWorkers(consoleProjection.internalWorkers),
@@ -171,6 +178,7 @@
); );
const workerState = $derived(liveWorkerState ?? worker?.state ?? "loading"); const workerState = $derived(liveWorkerState ?? worker?.state ?? "loading");
const workerRunning = $derived(workerState === "running"); const workerRunning = $derived(workerState === "running");
const workerPaused = $derived(workerState === "paused");
const inputReady = $derived(workerState === "idle"); const inputReady = $derived(workerState === "idle");
const composerEditable = $derived(protocolState === "open" && !sending); const composerEditable = $derived(protocolState === "open" && !sending);
const canSubmitDraft = $derived(inputReady && composerEditable); 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() { function requestRewindTargets() {
sendControl({ method: "list_rewind_targets" }, "Rewind target request"); sendControl({ method: "list_rewind_targets" }, "Rewind target request");
} }
@@ -1107,6 +1161,8 @@
$effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget)); $effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget));
</script> </script>
<svelte:window onkeydown={handleWorkerControlShortcut} />
<svelte:head> <svelte:head>
<title>Worker Console · Yoi Workspace</title> <title>Worker Console · Yoi Workspace</title>
<meta <meta
@@ -1119,35 +1175,27 @@
<section class="console-header card" aria-label="Worker controls"> <section class="console-header card" aria-label="Worker controls">
<div class="console-header-actions"> <div class="console-header-actions">
<div <div
class="console-status-pill" class="console-view-modes"
class:warn={protocolState !== "open"} role="group"
aria-label="Console display mode"
> >
{workerState} · protocol {protocolState} <button
type="button"
class:active={consoleViewMode === "overview"}
aria-pressed={consoleViewMode === "overview"}
onclick={() => (consoleViewMode = "overview")}
>
Overview
</button>
<button
type="button"
class:active={consoleViewMode === "normal"}
aria-pressed={consoleViewMode === "normal"}
onclick={() => (consoleViewMode = "normal")}
>
Normal
</button>
</div> </div>
<button
type="button"
class="secondary-button"
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "cancel" }, "Cancel")}
>
Cancel
</button>
<button
type="button"
class="secondary-button"
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "pause" }, "Pause")}
>
Pause
</button>
<button
type="button"
class="secondary-button"
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "resume" }, "Resume")}
>
Resume
</button>
<button <button
type="button" type="button"
class="secondary-button" class="secondary-button"
@@ -1264,7 +1312,7 @@
<p>No output yet.</p> <p>No output yet.</p>
{:else} {:else}
<ol class="console-log"> <ol class="console-log">
{#each internal.console.lines as item (item.id)} {#each projectConsoleLines(internal.console.lines, consoleViewMode) as item (item.id)}
<ConsoleLineItem {item} /> <ConsoleLineItem {item} />
{/each} {/each}
</ol> </ol>
@@ -1371,6 +1419,15 @@
</aside> </aside>
{/if} {/if}
{#if workerRunning}
<WorkerRunStatus
startedAtMs={consoleProjection.runActivity.startedAtMs}
requests={consoleProjection.runActivity.requests}
uploadTokens={consoleProjection.runActivity.uploadTokens}
outputTokens={consoleProjection.runActivity.outputTokens}
/>
{/if}
<ConsoleTasks {tasks} mode="mini" /> <ConsoleTasks {tasks} mode="mini" />
<form class="console-composer card" onsubmit={sendMessage}> <form class="console-composer card" onsubmit={sendMessage}>
@@ -1481,25 +1538,40 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-2); gap: var(--space-2);
} }
.console-status-pill { .console-view-modes {
min-width: 14rem; display: inline-flex;
padding: 0.75rem 0.9rem; overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 16px; border-radius: 0.55rem;
background: var(--bg-raised); background: var(--bg-raised);
color: var(--text-muted);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.76rem;
text-align: right;
} }
.console-status-pill.warn { .console-view-modes button {
color: var(--warning); border: 0;
background: transparent;
color: var(--text-muted);
padding: 0.42rem 0.65rem;
font: inherit;
font-size: 0.7rem;
font-weight: 700;
cursor: pointer;
}
.console-view-modes button + button {
border-left: 1px solid var(--line);
}
.console-view-modes button:hover {
color: var(--text-strong);
}
.console-view-modes button.active {
background: var(--accent);
color: var(--bg);
} }
.console-notice { .console-notice {
@@ -1822,10 +1894,5 @@
.console-header { .console-header {
flex-direction: column; flex-direction: column;
} }
.console-status-pill {
width: 100%;
text-align: left;
}
} }
</style> </style>
@@ -0,0 +1,74 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Console spinner wraps a reusable timed sequence loop", async () => {
const sequenceLoop = await Deno.readTextFile(
new URL(
"../src/lib/workspace/console/SequenceLoop.svelte",
import.meta.url,
),
);
const spinner = await Deno.readTextFile(
new URL("../src/lib/workspace/console/Spinner.svelte", import.meta.url),
);
for (
const token of ["values", "intervalMs", "setInterval", "clearInterval"]
) {
assert(
sequenceLoop.includes(token),
`missing sequence-loop token: ${token}`,
);
}
for (const frame of ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"]) {
assert(spinner.includes(frame), `missing spinner frame: ${frame}`);
}
assert(spinner.includes("SequenceLoop"), "Spinner should wrap SequenceLoop");
});
Deno.test("running status is Composer-side above mini Tasks", async () => {
const page = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const runStatus = await Deno.readTextFile(
new URL(
"../src/lib/workspace/console/WorkerRunStatus.svelte",
import.meta.url,
),
);
const status = page.indexOf("<WorkerRunStatus");
const tasks = page.indexOf('<ConsoleTasks {tasks} mode="mini"');
const composer = page.indexOf('<form class="console-composer card"');
assert(status >= 0, "WorkerRunStatus should be rendered");
assert(status < tasks, "WorkerRunStatus should be above mini Tasks");
assert(tasks < composer, "mini Tasks should remain above Composer");
assert(
runStatus.includes("nowMs - (startedAtMs ?? nowMs)"),
"running elapsed should be recomputed from timestamps",
);
});
Deno.test("RunEnd stats render as a right-aligned Console item", async () => {
const lineItem = await Deno.readTextFile(
new URL(
"../src/lib/workspace/console/ConsoleLineItem.svelte",
import.meta.url,
),
);
for (
const token of [
"item.kind === 'run_stats'",
'class="run-stats"',
"text-align: right",
]
) {
assert(lineItem.includes(token), `missing run stats token: ${token}`);
}
});
@@ -0,0 +1,28 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Worker Console exposes Overview and Normal display modes", async () => {
const page = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
for (
const token of [
'consoleViewMode = $state<ConsoleViewMode>("overview")',
'aria-label="Console display mode"',
'consoleViewMode = "overview"',
'consoleViewMode = "normal"',
"projectConsoleLines(consoleProjection.lines, consoleViewMode)",
"projectConsoleLines(internal.console.lines, consoleViewMode)",
"resolveWorkerControlShortcut",
"handleWorkerControlShortcut",
]
) {
assert(page.includes(token), `missing Console view-mode token: ${token}`);
}
});