feat: add web console modes and run status
This commit is contained in:
@@ -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">></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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
-46
@@ -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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user