feat: add web console modes and run status
This commit is contained in:
@@ -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)}
|
||||
<div class="message-heading">
|
||||
<span>{item.title}</span>
|
||||
{#if item.streaming}<small>streaming</small>{/if}
|
||||
</div>
|
||||
{:else if item.kind === 'tool'}
|
||||
<div class="tool-summary">
|
||||
<span class="tool-label">{toolSummary(item).label}</span>
|
||||
<span class="tool-separator"> — </span>
|
||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
||||
{#if item.streaming}<small>streaming</small>{/if}
|
||||
</div>
|
||||
{:else if item.streaming}
|
||||
<div class="message-heading streaming-heading">
|
||||
<small>streaming</small>
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.kind === 'tool'}
|
||||
{#if bodyTextAfterToolSummary(item)}
|
||||
<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>
|
||||
{/if}
|
||||
{:else if item.kind === 'user'}
|
||||
<div class="user-message">
|
||||
<span class="user-prompt" aria-hidden="true">></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)}
|
||||
<RichMarkdown text={item.body || '—'} />
|
||||
{: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);
|
||||
|
||||
@@ -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 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");
|
||||
});
|
||||
|
||||
@@ -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<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(
|
||||
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: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+113
-46
@@ -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<ConsoleViewMode>("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));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleWorkerControlShortcut} />
|
||||
|
||||
<svelte:head>
|
||||
<title>Worker Console · Yoi Workspace</title>
|
||||
<meta
|
||||
@@ -1119,35 +1175,27 @@
|
||||
<section class="console-header card" aria-label="Worker controls">
|
||||
<div class="console-header-actions">
|
||||
<div
|
||||
class="console-status-pill"
|
||||
class:warn={protocolState !== "open"}
|
||||
class="console-view-modes"
|
||||
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>
|
||||
<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
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
@@ -1264,7 +1312,7 @@
|
||||
<p>No output yet.</p>
|
||||
{:else}
|
||||
<ol class="console-log">
|
||||
{#each internal.console.lines as item (item.id)}
|
||||
{#each projectConsoleLines(internal.console.lines, consoleViewMode) as item (item.id)}
|
||||
<ConsoleLineItem {item} />
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -1371,6 +1419,15 @@
|
||||
</aside>
|
||||
{/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" />
|
||||
|
||||
<form class="console-composer card" onsubmit={sendMessage}>
|
||||
@@ -1481,25 +1538,40 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.console-status-pill {
|
||||
min-width: 14rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
.console-view-modes {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
border-radius: 0.55rem;
|
||||
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 {
|
||||
color: var(--warning);
|
||||
.console-view-modes button {
|
||||
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 {
|
||||
@@ -1822,10 +1894,5 @@
|
||||
.console-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.console-status-pill {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user