From f5f80fcd4846152e816ca4a5af60711095819a89 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 15:02:24 +0900 Subject: [PATCH] feat: add web console modes and run status --- .../workspace/console/ConsoleLineItem.svelte | 82 ++++-- .../lib/workspace/console/SequenceLoop.svelte | 45 +++ .../src/lib/workspace/console/Spinner.svelte | 35 +++ .../workspace/console/WorkerRunStatus.svelte | 48 ++++ .../src/lib/workspace/console/model.test.ts | 195 ++++++++++++- .../src/lib/workspace/console/model.ts | 257 +++++++++++++++++- .../lib/workspace/console/run-status.test.ts | 96 +++++++ .../src/lib/workspace/console/run-status.ts | 71 +++++ .../console/worker-control-shortcuts.test.ts | 80 ++++++ .../console/worker-control-shortcuts.ts | 53 ++++ .../workers/[workerId]/console/+page.svelte | 159 +++++++---- .../tests/console-spinner-source.test.ts | 74 +++++ .../tests/console-view-mode-source.test.ts | 28 ++ 13 files changed, 1143 insertions(+), 80 deletions(-) create mode 100644 web/workspace/src/lib/workspace/console/SequenceLoop.svelte create mode 100644 web/workspace/src/lib/workspace/console/Spinner.svelte create mode 100644 web/workspace/src/lib/workspace/console/WorkerRunStatus.svelte create mode 100644 web/workspace/src/lib/workspace/console/run-status.test.ts create mode 100644 web/workspace/src/lib/workspace/console/run-status.ts create mode 100644 web/workspace/src/lib/workspace/console/worker-control-shortcuts.test.ts create mode 100644 web/workspace/src/lib/workspace/console/worker-control-shortcuts.ts create mode 100644 web/workspace/tests/console-spinner-source.test.ts create mode 100644 web/workspace/tests/console-view-mode-source.test.ts 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
- {workerState} · protocol {protocolState} + +
- - -