diff --git a/README.md b/README.md
index 946fe48..41ca544 100644
--- a/README.md
+++ b/README.md
@@ -2,15 +2,29 @@
An open-source, in-app inspector for [eve](https://github.com/vercel/eve) agents. It turns an agent's event stream into a live visual trace of conversations, reasoning, tool calls, token usage, and timing.
-The inspector runs as a floating panel inside your app, making it easier to understand agent behavior without switching to a separate dashboard.
+The inspector runs in a side panel that makes room for itself beside your app, making it easier to understand agent behavior without switching to a separate dashboard.
## Features
- Inspect turns, reasoning, tool inputs and outputs, errors, and usage as they happen.
+- Resize the side panel while the normal page layout makes room for it.
+- Use the full-screen panel on narrow mobile viewports.
- Drop it into any browser app with a small framework-agnostic API or the React provider.
- Keep application styles isolated with a shadow root.
- Debug locally without sending data anywhere. The package makes no network requests.
+### Fixed host elements
+
+The panel resizes normal and sticky page layouts without changing their positioning contexts. Browser-fixed elements remain anchored to the viewport by design. If your app has a control fixed to the right edge, offset it with the panel width exposed by the devtools:
+
+```css
+.fixed-control {
+ right: var(--eve-devtools-panel-width, 0px);
+}
+```
+
+The property returns to its previous value when the panel is collapsed or the devtools are unmounted.
+
## Install
```sh
@@ -67,10 +81,40 @@ Issues and pull requests are welcome. To run the project locally:
bun install
bun run dev # demo page with a minimal eve agent traced live
bun run check # lint, format, and types
+bun run test # behavior tests
```
The demo lives in `demo/` and calls its model through the Vercel AI Gateway. Set `AI_GATEWAY_API_KEY` in `demo/.env.local` before starting it.
+## Releasing
+
+Publish releases manually from a clean, up-to-date `main` branch:
+
+```sh
+git checkout main
+git pull --ff-only origin main
+
+npm login # only needed when not already authenticated
+bun pm whoami
+bun pm pkg set version=0.0.4
+
+bun run check
+bun run test
+bun run build
+(cd demo && bun run build)
+bun publish --dry-run
+
+git add package.json
+git commit -m "Release v0.0.4"
+git tag v0.0.4
+git push origin main
+git push origin v0.0.4
+
+bun publish --access public
+```
+
+After publishing, verify the registry entry with `bun pm view eve-devtools@0.0.4`.
+
## License
MIT
diff --git a/bun.lock b/bun.lock
index b994a23..0803108 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,6 +7,7 @@
"devDependencies": {
"@biomejs/biome": "^2.5.2",
"@tailwindcss/vite": "^4.3.2",
+ "@types/bun": "^1.3.14",
"@types/react": "^19.2.17",
"cnfast": "^0.0.8",
"lucide-preact": "^1.23.0",
@@ -29,6 +30,7 @@
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"eve": "0.22.3",
+ "microsandbox": "^0.6.6",
"tailwindcss": "^4.3.2",
"vite": "^8.1.3",
"zod": "^4.4.3",
@@ -116,6 +118,16 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@superradcompany/microsandbox-darwin-arm64": ["@superradcompany/microsandbox-darwin-arm64@0.6.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rw7cg0G9mj1c9/14K/K6PMg78H11rGRA5tDzRbx67kAeoKWpAJnFsIYLvOCHcD+6aM0Xdxote90FVVlt1Kknjw=="],
+
+ "@superradcompany/microsandbox-linux-arm64-gnu": ["@superradcompany/microsandbox-linux-arm64-gnu@0.6.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-y4CdSPkSOtCwBNbG8Yhkahxg9yh/jpp4cBMOItCOj0JshU7luE2r4+6ceIrPNAkb8sF0fX7tNTXMDK4fJA8iFA=="],
+
+ "@superradcompany/microsandbox-linux-x64-gnu": ["@superradcompany/microsandbox-linux-x64-gnu@0.6.6", "", { "os": "linux", "cpu": "x64" }, "sha512-9yo6/G454L8ebzZP1qMxjIXRD3zJqQ68w2p9Ust7B7MOj5hr+VyuVZ0kYjSJAc4GSI0fno2xQe5z2tp5ynk78A=="],
+
+ "@superradcompany/microsandbox-win32-arm64-msvc": ["@superradcompany/microsandbox-win32-arm64-msvc@0.6.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-q22t1DVlB8FR90adjBxTf0i//f3yzm7QYsKbsar29zoUAHC/24h6R4LE6YfIDWtPKB8jvTSpCeMehFoyXyLN1Q=="],
+
+ "@superradcompany/microsandbox-win32-x64-msvc": ["@superradcompany/microsandbox-win32-x64-msvc@0.6.6", "", { "os": "win32", "cpu": "x64" }, "sha512-m2ir3DNw+Sc7i2DEg1kgY9bFmFHCFt2KamnXzJMUL9qtkE2n11GqIUhQJtKXwcWyM7nH0AxooMmnbx7UDMG8Ww=="],
+
"@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="],
@@ -148,6 +160,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="],
@@ -168,6 +182,8 @@
"ai": ["ai@7.0.19", "", { "dependencies": { "@ai-sdk/gateway": "4.0.15", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.7" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7kxMNiy6JqCvruCY2qGMNzeqEt9Ey3XqrtYGHWyipDJTXHFN7gOyO1UR2UIXhw112vAUkrO+OJJzemKJD8P3bA=="],
+ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
+
"cnfast": ["cnfast@0.0.8", "", { "bin": { "cnfast": "bin/cli.js" } }, "sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q=="],
"compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="],
@@ -248,6 +264,8 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+ "microsandbox": ["microsandbox@0.6.6", "", { "optionalDependencies": { "@superradcompany/microsandbox-darwin-arm64": "0.6.6", "@superradcompany/microsandbox-linux-arm64-gnu": "0.6.6", "@superradcompany/microsandbox-linux-x64-gnu": "0.6.6", "@superradcompany/microsandbox-win32-arm64-msvc": "0.6.6", "@superradcompany/microsandbox-win32-x64-msvc": "0.6.6" }, "bin": { "microsandbox": "bin/microsandbox.cjs", "msb": "bin/microsandbox.cjs" } }, "sha512-KF1HcZH61qw3QUEDQouIbtaS6LcrKIYf5fJOyauje+8sqzUcjcMZVqwngligNhEyLCH1+TtHnZbIOp9Ndf7k7g=="],
+
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
diff --git a/demo/app/index.ts b/demo/app/index.ts
index 9a018c2..9b96e1c 100644
--- a/demo/app/index.ts
+++ b/demo/app/index.ts
@@ -1,4 +1,9 @@
-import { Client } from "eve/client";
+import {
+ Client,
+ type InputOption,
+ type InputRequest,
+ type SendTurnInput,
+} from "eve/client";
import { mount } from "../../index";
const devtools = mount();
@@ -20,12 +25,54 @@ const addMessage = (role: "user" | "assistant", text: string) => {
return message;
};
-const send = async (text: string) => {
+const createOptionButton = (request: InputRequest, option: InputOption) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = option.label;
+ button.addEventListener("click", () => {
+ const siblings = button.parentElement?.querySelectorAll("button") ?? [];
+ for (const sibling of siblings) {
+ sibling.disabled = true;
+ }
+ const response = { requestId: request.requestId, optionId: option.id };
+ devtools.onEvent({
+ type: "client.input.responded",
+ data: { responses: [response] },
+ });
+ void send(option.label, {
+ inputResponses: [response],
+ });
+ });
+ return button;
+};
+
+const renderInputRequests = (
+ reply: HTMLParagraphElement,
+ requests: readonly InputRequest[],
+) => {
+ reply.replaceChildren();
+ reply.classList.add("question");
+ for (const request of requests) {
+ const prompt = document.createElement("span");
+ prompt.textContent = request.prompt;
+ reply.append(prompt);
+ if (request.options !== undefined && request.options.length > 0) {
+ const options = document.createElement("span");
+ options.className = "options";
+ options.append(
+ ...request.options.map((option) => createOptionButton(request, option)),
+ );
+ reply.append(options);
+ }
+ }
+};
+
+const send = async (text: string, turnInput: SendTurnInput = text) => {
input.disabled = true;
addMessage("user", text);
const reply = addMessage("assistant", "…");
try {
- const response = await session.send(text);
+ const response = await session.send(turnInput);
for await (const event of response) {
devtools.onEvent(event);
if (event.type === "message.appended") {
@@ -34,6 +81,9 @@ const send = async (text: string) => {
if (event.type === "message.completed" && event.data.message !== null) {
reply.textContent = event.data.message;
}
+ if (event.type === "input.requested") {
+ renderInputRequests(reply, event.data.requests);
+ }
messages.scrollTop = messages.scrollHeight;
}
} finally {
diff --git a/demo/app/styles.css b/demo/app/styles.css
index c88d7a8..a336af5 100644
--- a/demo/app/styles.css
+++ b/demo/app/styles.css
@@ -50,6 +50,37 @@ main p {
align-self: flex-start;
}
+.question {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.options button {
+ padding: 0.25rem 0.625rem;
+ border: 1px solid #404040;
+ border-radius: 1rem;
+ background: #232323;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+}
+
+.options button:hover {
+ background: #2c2c2c;
+}
+
+.options button:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
form {
width: 100%;
max-width: 640px;
diff --git a/demo/package.json b/demo/package.json
index fe07d25..e639f47 100644
--- a/demo/package.json
+++ b/demo/package.json
@@ -9,6 +9,7 @@
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"eve": "0.22.3",
+ "microsandbox": "^0.6.6",
"tailwindcss": "^4.3.2",
"vite": "^8.1.3",
"zod": "^4.4.3"
diff --git a/package.json b/package.json
index 2cff99f..0894ef0 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,8 @@
"build": "vite build",
"prepack": "bun run build",
"check": "biome check . && tsc --noEmit",
- "fix": "biome check --write ."
+ "fix": "biome check --write .",
+ "test": "bun test"
},
"peerDependencies": {
"react": ">=17"
@@ -47,6 +48,7 @@
"devDependencies": {
"@biomejs/biome": "^2.5.2",
"@tailwindcss/vite": "^4.3.2",
+ "@types/bun": "^1.3.14",
"@types/react": "^19.2.17",
"cnfast": "^0.0.8",
"lucide-preact": "^1.23.0",
diff --git a/src/components/disclosure-chevron.tsx b/src/components/disclosure-chevron.tsx
new file mode 100644
index 0000000..8eb4d26
--- /dev/null
+++ b/src/components/disclosure-chevron.tsx
@@ -0,0 +1,13 @@
+import { cn } from "cnfast";
+import { ChevronLeft } from "lucide-preact";
+
+export function DisclosureChevron({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/src/components/entry.tsx b/src/components/entry.tsx
index 36f6d3b..597488c 100644
--- a/src/components/entry.tsx
+++ b/src/components/entry.tsx
@@ -9,8 +9,10 @@ export function Entry({
children: ComponentChildren;
}) {
return (
-
-
{icon}
+
);
diff --git a/src/components/json-view.tsx b/src/components/json-view.tsx
index 575ac58..510536a 100644
--- a/src/components/json-view.tsx
+++ b/src/components/json-view.tsx
@@ -2,7 +2,7 @@ import { stringifyJson } from "@/trace/format";
export function JsonView({ value }: { value: unknown }) {
return (
-
+
{stringifyJson(value)}
);
diff --git a/src/components/metric.tsx b/src/components/metric.tsx
index 5a4f2c2..470f620 100644
--- a/src/components/metric.tsx
+++ b/src/components/metric.tsx
@@ -1,15 +1,45 @@
+import { cn } from "cnfast";
import type { ComponentChildren } from "preact";
-// An icon paired with a compact value, e.g. a token count or a duration.
+type MetricTone = "neutral" | "input" | "output";
+
+const toneClass: Record = {
+ neutral: "text-neutral-300",
+ input: "text-metric-input",
+ output: "text-metric-output",
+};
+
+const gapClass: Record = {
+ neutral: "gap-[3px]",
+ input: "gap-0.5",
+ output: "gap-0.5",
+};
+
+// A compact labeled value, optionally paired with an icon.
export function Metric({
icon,
+ label,
+ tabularNumbers = false,
+ tone = "neutral",
children,
}: {
- icon: ComponentChildren;
+ icon?: ComponentChildren;
+ label: string;
+ tabularNumbers?: boolean;
+ tone?: MetricTone;
children: ComponentChildren;
}) {
return (
-
+ svg]:size-3.5",
+ gapClass[tone],
+ tabularNumbers && "tabular-nums",
+ toneClass[tone],
+ )}
+ >
+ {label}:
{icon}
{children}
diff --git a/src/components/panel.tsx b/src/components/panel.tsx
index 275bbb0..526094c 100644
--- a/src/components/panel.tsx
+++ b/src/components/panel.tsx
@@ -1,90 +1,137 @@
-import {
- ArrowDownToLine,
- ArrowUpToLine,
- LucideProvider,
- Wrench,
-} from "lucide-preact";
+import { LucideProvider, PanelRightClose } from "lucide-preact";
+import { useEffect, useState } from "preact/hooks";
import { Logo } from "@/components/logo";
-import { Metric } from "@/components/metric";
import { Resizable } from "@/components/resizable";
import { ScrollArea } from "@/components/scroll-area";
import { Turn } from "@/components/turn";
import { useLocalStorage } from "@/hooks/use-local-storage";
import { useTurns } from "@/hooks/use-turns";
-import { formatTokens } from "@/trace/format";
-import { summarizeTurns } from "@/trace/summary";
+import {
+ canDock,
+ defaultPanelWidth,
+ maximumPanelWidth,
+ minimumPanelWidth,
+ panelWidth,
+} from "@/panel-size";
import type { Trace } from "@/trace/trace";
import type { Turn as TurnData } from "@/trace/types";
-const defaultSize = { width: 380, height: 480 };
-const minSize = { width: 280, height: 132 };
+function useViewportWidth(): number {
+ const [width, setWidth] = useState(window.innerWidth);
-function turnsLabel(count: number): string {
- if (count === 1) {
- return "1 turn";
- }
- return `${count} turns`;
+ useEffect(() => {
+ function updateWidth() {
+ setWidth(window.innerWidth);
+ }
+
+ window.addEventListener("resize", updateWidth);
+ return () => window.removeEventListener("resize", updateWidth);
+ }, []);
+
+ return width;
}
-function Footer({ turns }: { turns: TurnData[] }) {
- const summary = summarizeTurns(turns);
+function Header() {
return (
-
+
+ );
+}
+
+function CollapseButton({ onCollapse }: { onCollapse: () => void }) {
+ return (
+
);
}
function Body({ turns }: { turns: TurnData[] }) {
return (
-
-
- {turns.length === 0 && (
-
- No agent activity yet.
-
- )}
- {turns.map((turn, index) => (
-
- ))}
-
-
-
+
+ {turns.length === 0 && (
+
+ No agent activity yet.
+
+ )}
+ {turns.map((turn, index) => (
+
+ ))}
+
);
}
-export function Panel({ trace }: { trace: Trace }) {
+export function Panel({
+ trace,
+ onResize,
+}: {
+ trace: Trace;
+ onResize: (width: number | null) => void;
+}) {
const turns = useTurns(trace);
const [isOpen, setIsOpen] = useLocalStorage("open", false);
- const [size, setSize] = useLocalStorage("size", defaultSize);
+ const [storedWidth, setStoredWidth] = useLocalStorage(
+ "width",
+ defaultPanelWidth,
+ );
+ const viewportWidth = useViewportWidth();
+ const isDocked = canDock(viewportWidth);
+ const width = panelWidth(storedWidth, viewportWidth);
+
+ useEffect(() => {
+ if (isOpen && isDocked) {
+ onResize(width);
+ return;
+ }
+ onResize(null);
+ }, [isDocked, isOpen, onResize, width]);
+
+ if (!isOpen) {
+ return (
+
+
+
+ );
+ }
return (
- setIsOpen(event.currentTarget.open)}
- class="group fixed right-3 bottom-3 z-2147483647 size-8.5 overflow-hidden rounded-xl border border-line-2 bg-background text-foreground shadow-panel transition-all duration-300 ease-in-out [corner-shape:squircle] [interpolate-size:allow-keywords] open:size-auto open:rounded-2xl open:bg-surface-1 open:shadow-panel-open"
+
+ setIsOpen(false)} />
-
+
);
}
diff --git a/src/components/reasoning.tsx b/src/components/reasoning.tsx
index 7cde0ff..774cbfe 100644
--- a/src/components/reasoning.tsx
+++ b/src/components/reasoning.tsx
@@ -1,39 +1,100 @@
import { cn } from "cnfast";
import { Brain } from "lucide-preact";
-import { useState } from "preact/hooks";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { DisclosureChevron } from "@/components/disclosure-chevron";
import { Entry } from "@/components/entry";
-// Reasoning longer than this starts collapsed to a two-line preview.
-const clampThreshold = 200;
-
export function Reasoning({ text }: { text: string }) {
const [expanded, setExpanded] = useState(false);
- const isLong = text.length > clampThreshold;
+ const [collapsible, setCollapsible] = useState(false);
+ const contentRef = useRef(null);
+
+ useEffect(() => {
+ const element = contentRef.current;
+ if (element === null || expanded) {
+ return;
+ }
+
+ const update = () => {
+ if (!element.classList.contains("line-clamp-5")) {
+ return;
+ }
+ setCollapsible(element.scrollHeight > element.clientHeight);
+ };
+ let frame: number | undefined;
+ const scheduleUpdate = () => {
+ if (frame !== undefined) {
+ cancelAnimationFrame(frame);
+ }
+ frame = requestAnimationFrame(() => {
+ frame = undefined;
+ update();
+ });
+ };
+
+ update();
+ const resizeObserver = new ResizeObserver(scheduleUpdate);
+ const mutationObserver = new MutationObserver(scheduleUpdate);
+ resizeObserver.observe(element);
+ mutationObserver.observe(element, {
+ childList: true,
+ characterData: true,
+ subtree: true,
+ });
+ return () => {
+ if (frame !== undefined) {
+ cancelAnimationFrame(frame);
+ }
+ resizeObserver.disconnect();
+ mutationObserver.disconnect();
+ };
+ }, [expanded]);
+
+ function toggle() {
+ setExpanded((value) => !value);
+ }
- let toggleLabel = "Show more";
+ let toggleLabel = "Expand reasoning";
if (expanded) {
- toggleLabel = "Show less";
+ toggleLabel = "Collapse reasoning";
}
+ const content = (
+
+ {text}
+
+ );
+
return (
}>
-
+ {content}
+ {collapsible && (
+ <>
+
+
+
+
+ >
)}
- >
- {text}
-
- {isLong && (
-
- )}
+
);
}
diff --git a/src/components/resizable.tsx b/src/components/resizable.tsx
index 3f4e5d5..d2960ba 100644
--- a/src/components/resizable.tsx
+++ b/src/components/resizable.tsx
@@ -2,45 +2,28 @@ import { cn } from "cnfast";
import type { ComponentChildren } from "preact";
import { useRef } from "preact/hooks";
-export type Size = { width: number; height: number };
+type DragStart = { x: number; width: number };
-type Edge = "left" | "top" | "corner";
-
-type DragStart = { x: number; y: number; size: Size };
+function clampWidth(width: number, minWidth: number, maxWidth: number): number {
+ return Math.min(maxWidth, Math.max(minWidth, width));
+}
-function resizeFrom(
+function resizedWidth(
start: DragStart,
- edge: Edge,
event: PointerEvent,
- minSize: Size,
-): Size {
+ minWidth: number,
+ maxWidth: number,
+): number {
const dx = start.x - event.clientX;
- const dy = start.y - event.clientY;
- const size = { ...start.size };
- if (edge !== "top") {
- size.width = Math.max(minSize.width, start.size.width + dx);
- }
- if (edge !== "left") {
- size.height = Math.max(minSize.height, start.size.height + dy);
- }
- return size;
+ return clampWidth(start.width + dx, minWidth, maxWidth);
}
-const handleClass = "absolute hidden touch-none group-open:block";
-
-const handles: { edge: Edge; class: string }[] = [
- { edge: "left", class: "inset-y-3 -left-0.75 w-1.5 cursor-ew-resize" },
- { edge: "top", class: "inset-x-3 -top-0.75 h-1.5 cursor-ns-resize" },
- { edge: "corner", class: "-top-0.75 -left-0.75 size-3 cursor-nwse-resize" },
-];
-
-// The wrapper div is intentionally not positioned, so the absolute handles
-// anchor to the panel edges (the nearest positioned ancestor) while the
-// width and height styles apply to the content area only.
export function Resizable(props: {
- size: Size;
- minSize: Size;
- onResize: (size: Size) => void;
+ width: number;
+ minWidth: number;
+ maxWidth: number;
+ canResize: boolean;
+ onResize: (width: number) => void;
children: ComponentChildren;
}) {
const dragStart = useRef(null);
@@ -51,39 +34,67 @@ export function Resizable(props: {
handle.setPointerCapture(event.pointerId);
dragStart.current = {
x: event.clientX,
- y: event.clientY,
- size: props.size,
+ width: props.width,
};
}
- function onPointerMove(event: PointerEvent, edge: Edge) {
+ function onPointerMove(event: PointerEvent) {
if (dragStart.current === null) {
return;
}
- props.onResize(resizeFrom(dragStart.current, edge, event, props.minSize));
+ props.onResize(
+ resizedWidth(dragStart.current, event, props.minWidth, props.maxWidth),
+ );
}
function onPointerUp() {
dragStart.current = null;
}
+ function onKeyDown(event: KeyboardEvent) {
+ const step = event.shiftKey ? 32 : 8;
+ let nextWidth = props.width;
+
+ if (event.key === "ArrowLeft") {
+ nextWidth += step;
+ } else if (event.key === "ArrowRight") {
+ nextWidth -= step;
+ } else {
+ return;
+ }
+
+ event.preventDefault();
+ props.onResize(clampWidth(nextWidth, props.minWidth, props.maxWidth));
+ }
+
return (
{props.children}
- {handles.map((handle) => (
-
onPointerMove(event, handle.edge)}
+ onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
/>
- ))}
+ )}
);
}
diff --git a/src/components/scroll-area.tsx b/src/components/scroll-area.tsx
index d48efd0..a9c9902 100644
--- a/src/components/scroll-area.tsx
+++ b/src/components/scroll-area.tsx
@@ -1,5 +1,9 @@
import type { ComponentChildren } from "preact";
export function ScrollArea({ children }: { children: ComponentChildren }) {
- return
{children}
;
+ return (
+
+ {children}
+
+ );
}
diff --git a/src/components/summary-metrics.tsx b/src/components/summary-metrics.tsx
new file mode 100644
index 0000000..06bd105
--- /dev/null
+++ b/src/components/summary-metrics.tsx
@@ -0,0 +1,20 @@
+import { ArrowDown, ArrowUp, Wrench } from "lucide-preact";
+import { Metric } from "@/components/metric";
+import { formatTokens } from "@/trace/format";
+import type { TurnSummary } from "@/trace/summary";
+
+export function SummaryMetrics({ summary }: { summary: TurnSummary }) {
+ return (
+ <>
+
}>
+ {formatTokens(summary.inputTokens)}
+
+
}>
+ {formatTokens(summary.outputTokens)}
+
+
}>
+ {summary.tools}
+
+ >
+ );
+}
diff --git a/src/components/tool-call.tsx b/src/components/tool-call.tsx
index fbba51d..5cc52e2 100644
--- a/src/components/tool-call.tsx
+++ b/src/components/tool-call.tsx
@@ -1,4 +1,5 @@
-import { ChevronRight, Clock, Wrench } from "lucide-preact";
+import { Wrench } from "lucide-preact";
+import { DisclosureChevron } from "@/components/disclosure-chevron";
import { JsonView } from "@/components/json-view";
import { Metric } from "@/components/metric";
import { StatusDot, type StatusTone } from "@/components/status-dot";
@@ -27,20 +28,22 @@ function Field({ label, value }: { label: string; value: unknown }) {
export function ToolCall({ action }: { action: Action }) {
return (
-
-
-
+
+
+
+ {action.name}
+
- {action.name}
{action.durationMs !== undefined && (
-
- }>
- {formatDuration(action.durationMs)}
-
-
+
+ {formatDuration(action.durationMs)}
+
)}
+
+
+
-
+
{action.input !== undefined && (
)}
diff --git a/src/components/turn.tsx b/src/components/turn.tsx
index 5e0541b..6e4ba17 100644
--- a/src/components/turn.tsx
+++ b/src/components/turn.tsx
@@ -1,21 +1,21 @@
import {
- ArrowDownToLine,
- ArrowUpToLine,
CircleAlert,
- Clock,
+ CircleHelp,
CornerDownRight,
MessageSquare,
- Wrench,
} from "lucide-preact";
+import { useState } from "preact/hooks";
+import { DisclosureChevron } from "@/components/disclosure-chevron";
import { Entry } from "@/components/entry";
import { Metric } from "@/components/metric";
import { Reasoning } from "@/components/reasoning";
import { StatusDot } from "@/components/status-dot";
+import { SummaryMetrics } from "@/components/summary-metrics";
import { ToolCall } from "@/components/tool-call";
import { useNow } from "@/hooks/use-now";
-import { formatDuration, formatTime, formatTokens } from "@/trace/format";
+import { formatDuration, formatTime } from "@/trace/format";
import { summarizeTurn } from "@/trace/summary";
-import type { Step, Turn as TurnData } from "@/trace/types";
+import type { Action, Step, Turn as TurnData } from "@/trace/types";
// A completed turn has its final duration; a running one measures from its
// start to the ticking clock.
@@ -32,37 +32,63 @@ function elapsedMs(turn: TurnData, now: number): number {
function TurnHeader({ index, turn }: { index: number; turn: TurnData }) {
const summary = summarizeTurn(turn);
const now = useNow(turn.status === "running");
+ let startedAt: string | undefined;
+ if (turn.startedAt !== undefined) {
+ startedAt = `Started at ${formatTime(turn.startedAt)}`;
+ }
+
return (
-
-
+
+
- Turn {index}
- {turn.startedAt !== undefined && (
-
- {formatTime(turn.startedAt)}
-
- )}
-
-
- }>{summary.tools}
- }>
- {formatTokens(summary.inputTokens)}
-
- }>
- {formatTokens(summary.outputTokens)}
+
+ Turn {index}
+
+
+ {formatDuration(elapsedMs(turn, now))}
- }>{formatDuration(elapsedMs(turn, now))}
-
-
+
+
+
+
+
+
+
+
);
}
+function ActionEntry({ action }: { action: Action }) {
+ if (action.question !== undefined) {
+ return (
+
}>
+
+
{action.question.prompt}
+ {action.question.options.length > 0 && (
+
+ {action.question.options.map((option) => (
+ - {option.label}
+ ))}
+
+ )}
+ {action.question.answer !== undefined && (
+
+ Answer: {action.question.answer}
+
+ )}
+
+
+ );
+ }
+ return
;
+}
+
function StepEntries({ step }: { step: Step }) {
return (
<>
{step.reasoning !== undefined &&
}
{step.actions.map((action) => (
-
+
))}
{step.response !== undefined && (
}>
@@ -87,20 +113,28 @@ function TurnError({ error }: { error: NonNullable
}) {
}
export function Turn({ turn, index }: { turn: TurnData; index: number }) {
+ const [expanded, setExpanded] = useState(true);
+
return (
-
-
- {turn.prompt !== undefined && (
-
}>
-
{turn.prompt}
-
- )}
- {turn.steps.map((step) => (
-
- ))}
- {turn.error !== undefined &&
}
-
+ setExpanded(event.currentTarget.open)}
+ >
+
+
+ {turn.prompt !== undefined && (
+
}>
+
{turn.prompt}
+
+ )}
+ {turn.steps.map((step) => (
+
+ ))}
+ {turn.error !== undefined &&
}
+
+
);
}
diff --git a/src/mount.tsx b/src/mount.tsx
index 98344e5..4c310ac 100644
--- a/src/mount.tsx
+++ b/src/mount.tsx
@@ -1,15 +1,39 @@
import { render } from "preact";
import { Panel } from "@/components/panel";
+import { createPageLayout } from "@/page-layout";
import styles from "@/styles.css?inline";
import { createTrace } from "@/trace/trace";
+function isDocument(root: Element | Document): root is Document {
+ return root.nodeType === Node.DOCUMENT_NODE;
+}
+
function parentFor(root: Element | Document): Element {
- if (root instanceof Document) {
+ if (isDocument(root)) {
return root.body;
}
return root;
}
+function pageFor(root: Element | Document): HTMLElement {
+ if (isDocument(root)) {
+ return root.documentElement;
+ }
+
+ const HTMLElement = root.ownerDocument.defaultView?.HTMLElement;
+ if (HTMLElement !== undefined && root instanceof HTMLElement) {
+ return root as HTMLElement;
+ }
+ return root.ownerDocument.documentElement;
+}
+
+function documentFor(root: Element | Document): Document {
+ if (isDocument(root)) {
+ return root;
+ }
+ return root.ownerDocument;
+}
+
function applyStyles(shadow: ShadowRoot): CSSStyleSheet {
const sheet = new CSSStyleSheet();
sheet.replaceSync(styles);
@@ -21,7 +45,10 @@ function applyStyles(shadow: ShadowRoot): CSSStyleSheet {
// ignored inside a shadow root: they only register from document-level
// stylesheets. They define no styles, so the host page is unaffected.
// Returns a function that releases them again.
-function adoptPropertyRules(sheet: CSSStyleSheet): () => void {
+function adoptPropertyRules(
+ sheet: CSSStyleSheet,
+ document: Document,
+): () => void {
const properties = new CSSStyleSheet();
for (const rule of sheet.cssRules) {
if (rule instanceof CSSPropertyRule) {
@@ -44,13 +71,15 @@ export type EveDevtools = {
export function mount(root: Element | Document = document): EveDevtools {
const trace = createTrace();
- const host = document.createElement("div");
+ const pageLayout = createPageLayout(pageFor(root));
+ const ownerDocument = documentFor(root);
+ const host = ownerDocument.createElement("div");
parentFor(root).appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
const sheet = applyStyles(shadow);
- const releaseProperties = adoptPropertyRules(sheet);
- render(, shadow);
+ const releaseProperties = adoptPropertyRules(sheet, ownerDocument);
+ render(, shadow);
return {
onEvent: trace.push,
@@ -58,6 +87,7 @@ export function mount(root: Element | Document = document): EveDevtools {
render(null, shadow);
host.remove();
releaseProperties();
+ pageLayout.restore();
},
};
}
diff --git a/src/page-layout.test.ts b/src/page-layout.test.ts
new file mode 100644
index 0000000..58fb0d9
--- /dev/null
+++ b/src/page-layout.test.ts
@@ -0,0 +1,106 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { createPageLayout } from "@/page-layout";
+
+const pageAttribute = "data-eve-devtools-page";
+const widthProperty = "--eve-devtools-panel-width";
+const originalStyleSheet = globalThis.CSSStyleSheet;
+
+class TestStyleSheet {
+ replaceSync() {}
+}
+
+class TestStyle {
+ private values = new Map();
+ private priorities = new Map();
+
+ getPropertyPriority(name: string): string {
+ return this.priorities.get(name) ?? "";
+ }
+
+ getPropertyValue(name: string): string {
+ return this.values.get(name) ?? "";
+ }
+
+ removeProperty(name: string) {
+ this.values.delete(name);
+ this.priorities.delete(name);
+ }
+
+ setProperty(name: string, value: string, priority = "") {
+ this.values.set(name, value);
+ this.priorities.set(name, priority);
+ }
+}
+
+function createPage() {
+ const attributes = new Map();
+ const style = new TestStyle();
+ const ownerDocument = { adoptedStyleSheets: [] as CSSStyleSheet[] };
+ const page = {
+ ownerDocument,
+ style,
+ getAttribute(name: string) {
+ return attributes.get(name) ?? null;
+ },
+ hasAttribute(name: string) {
+ return attributes.has(name);
+ },
+ removeAttribute(name: string) {
+ attributes.delete(name);
+ },
+ setAttribute(name: string, value: string) {
+ attributes.set(name, value);
+ },
+ } as unknown as HTMLElement;
+ return { attributes, ownerDocument, page, style };
+}
+
+beforeAll(() => {
+ Object.defineProperty(globalThis, "CSSStyleSheet", {
+ configurable: true,
+ value: TestStyleSheet,
+ });
+});
+
+afterAll(() => {
+ if (originalStyleSheet === undefined) {
+ Reflect.deleteProperty(globalThis, "CSSStyleSheet");
+ return;
+ }
+ Object.defineProperty(globalThis, "CSSStyleSheet", {
+ configurable: true,
+ value: originalStyleSheet,
+ });
+});
+
+describe("createPageLayout", () => {
+ test("resizes and restores an untouched page", () => {
+ const { attributes, ownerDocument, page, style } = createPage();
+ const layout = createPageLayout(page);
+
+ expect(ownerDocument.adoptedStyleSheets).toHaveLength(1);
+ layout.resize(380);
+ expect(attributes.get(pageAttribute)).toBe("");
+ expect(style.getPropertyValue(widthProperty)).toBe("380px");
+
+ layout.restore();
+ expect(attributes.has(pageAttribute)).toBe(false);
+ expect(style.getPropertyValue(widthProperty)).toBe("");
+ expect(ownerDocument.adoptedStyleSheets).toHaveLength(0);
+ });
+
+ test("preserves existing host values", () => {
+ const { attributes, page, style } = createPage();
+ attributes.set(pageAttribute, "host-value");
+ style.setProperty(widthProperty, "24px", "important");
+ const layout = createPageLayout(page);
+
+ layout.resize(420);
+ layout.resize(null);
+
+ expect(attributes.get(pageAttribute)).toBe("host-value");
+ expect(style.getPropertyValue(widthProperty)).toBe("24px");
+ expect(style.getPropertyPriority(widthProperty)).toBe("important");
+ layout.restore();
+ });
+});
diff --git a/src/page-layout.ts b/src/page-layout.ts
new file mode 100644
index 0000000..34f043c
--- /dev/null
+++ b/src/page-layout.ts
@@ -0,0 +1,96 @@
+import styles from "@/page.css?inline";
+
+const pageAttribute = "data-eve-devtools-page";
+const widthProperty = "--eve-devtools-panel-width";
+
+type AttributeSnapshot = {
+ isPresent: boolean;
+ value: string;
+};
+
+type PropertySnapshot = {
+ value: string;
+ priority: string;
+};
+
+function readAttribute(element: Element, name: string): AttributeSnapshot {
+ return {
+ isPresent: element.hasAttribute(name),
+ value: element.getAttribute(name) ?? "",
+ };
+}
+
+function restoreAttribute(
+ element: Element,
+ name: string,
+ snapshot: AttributeSnapshot,
+) {
+ if (snapshot.isPresent) {
+ element.setAttribute(name, snapshot.value);
+ return;
+ }
+ element.removeAttribute(name);
+}
+
+function readProperty(
+ style: CSSStyleDeclaration,
+ name: string,
+): PropertySnapshot {
+ return {
+ value: style.getPropertyValue(name),
+ priority: style.getPropertyPriority(name),
+ };
+}
+
+function restoreProperty(
+ style: CSSStyleDeclaration,
+ name: string,
+ snapshot: PropertySnapshot,
+) {
+ if (snapshot.value !== "") {
+ style.setProperty(name, snapshot.value, snapshot.priority);
+ return;
+ }
+ style.removeProperty(name);
+}
+
+function adoptStyles(document: Document): () => void {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(styles);
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
+
+ return () => {
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
+ (adopted) => adopted !== sheet,
+ );
+ };
+}
+
+export type PageLayout = {
+ resize: (width: number | null) => void;
+ restore: () => void;
+};
+
+export function createPageLayout(page: HTMLElement): PageLayout {
+ const attribute = readAttribute(page, pageAttribute);
+ const property = readProperty(page.style, widthProperty);
+ const releaseStyles = adoptStyles(page.ownerDocument);
+
+ function resize(width: number | null) {
+ if (width === null) {
+ restoreAttribute(page, pageAttribute, attribute);
+ restoreProperty(page.style, widthProperty, property);
+ return;
+ }
+
+ page.setAttribute(pageAttribute, "");
+ page.style.setProperty(widthProperty, `${width}px`);
+ }
+
+ function restore() {
+ resize(null);
+ releaseStyles();
+ }
+
+ return { resize, restore };
+}
diff --git a/src/page.css b/src/page.css
new file mode 100644
index 0000000..5d82a5d
--- /dev/null
+++ b/src/page.css
@@ -0,0 +1,4 @@
+/* Changing only the width keeps the host DOM and positioning contexts intact. */
+[data-eve-devtools-page] {
+ width: calc(100% - var(--eve-devtools-panel-width));
+}
diff --git a/src/panel-size.test.ts b/src/panel-size.test.ts
new file mode 100644
index 0000000..2ed42b4
--- /dev/null
+++ b/src/panel-size.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, test } from "bun:test";
+import { canDock, maximumPanelWidth, panelWidth } from "@/panel-size";
+
+describe("panel sizing", () => {
+ test("uses the full viewport on mobile", () => {
+ expect(canDock(599)).toBe(false);
+ expect(panelWidth(380, 599)).toBe(599);
+ });
+
+ test("preserves a minimum width for the page and panel", () => {
+ expect(canDock(600)).toBe(true);
+ expect(panelWidth(200, 600)).toBe(280);
+ expect(panelWidth(500, 600)).toBe(280);
+ });
+
+ test("clamps the stored width on larger viewports", () => {
+ expect(maximumPanelWidth(1280)).toBe(960);
+ expect(panelWidth(200, 1280)).toBe(280);
+ expect(panelWidth(380, 1280)).toBe(380);
+ expect(panelWidth(1200, 1280)).toBe(960);
+ });
+});
diff --git a/src/panel-size.ts b/src/panel-size.ts
new file mode 100644
index 0000000..0102789
--- /dev/null
+++ b/src/panel-size.ts
@@ -0,0 +1,21 @@
+export const defaultPanelWidth = 380;
+export const minimumPanelWidth = 280;
+export const minimumPageWidth = 320;
+
+export function canDock(viewportWidth: number): boolean {
+ return viewportWidth >= minimumPanelWidth + minimumPageWidth;
+}
+
+export function maximumPanelWidth(viewportWidth: number): number {
+ return Math.max(minimumPanelWidth, viewportWidth - minimumPageWidth);
+}
+
+export function panelWidth(storedWidth: number, viewportWidth: number): number {
+ if (!canDock(viewportWidth)) {
+ return viewportWidth;
+ }
+ return Math.min(
+ maximumPanelWidth(viewportWidth),
+ Math.max(minimumPanelWidth, storedWidth),
+ );
+}
diff --git a/src/styles.css b/src/styles.css
index 2f5450b..659d33e 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -13,13 +13,20 @@
--color-line-1: #262626;
--color-line-2: #404040;
--color-line-3: #525252;
+ --color-focus: #8c8c8c;
+ --color-metric-input: oklch(0.77 0.11 235);
+ --color-metric-output: oklch(0.78 0.12 55);
--font-mono: ui-monospace, SFMono-Regular, monospace;
- --shadow-panel: 0 2px 12px #00000059;
- --shadow-panel-open: 0 4px 12px #0000004d, 0 16px 40px #00000066;
+ --shadow-launcher: 0 2px 12px #00000059;
}
:host {
all: initial;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 0;
+ height: 0;
color-scheme: dark;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 13px;
@@ -28,7 +35,70 @@
-moz-osx-font-smoothing: grayscale;
}
+@keyframes panel-enter {
+ from {
+ opacity: 0;
+ transform: translateX(4px);
+ }
+}
+
+@keyframes control-enter {
+ from {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+}
+
+@keyframes disclosure-enter {
+ from {
+ opacity: 0;
+ transform: translateY(-2px);
+ }
+}
+
+.panel-enter {
+ animation: panel-enter 160ms ease-out;
+}
+
+.launcher {
+ right: max(0.75rem, env(safe-area-inset-right));
+ bottom: max(0.75rem, env(safe-area-inset-bottom));
+ animation: control-enter 120ms ease-out;
+}
+
+.safe-area {
+ padding: env(safe-area-inset-top) env(safe-area-inset-right)
+ env(safe-area-inset-bottom) env(safe-area-inset-left);
+}
+
+.disclosure-summary {
+ list-style: none;
+}
+
+.disclosure-summary::-webkit-details-marker {
+ display: none;
+}
+
+.disclosure-summary:focus,
+.reasoning-toggle:focus {
+ outline: none;
+}
+
+.disclosure-summary:focus-visible,
+.reasoning-toggle:focus-visible {
+ box-shadow: inset 2px 0 var(--color-focus);
+}
+
+.disclosure-summary:focus-visible {
+ background-color: var(--color-surface-3);
+}
+
+details[open] > .disclosure-content {
+ animation: disclosure-enter 120ms ease-out;
+}
+
.scroll {
+ scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--color-line-2) transparent;
}
@@ -43,8 +113,16 @@
border-radius: 3px;
}
-/* Animate only the panel's open/close; nested details (tool calls) toggle
- instantly instead of lingering for the transition. */
-:host > details::details-content {
- transition: content-visibility 300ms allow-discrete;
+@media (prefers-reduced-motion: reduce) {
+ .panel-enter,
+ .launcher,
+ details[open] > .disclosure-content {
+ animation: none;
+ }
+
+ .disclosure-summary,
+ .disclosure-chevron,
+ .reasoning-toggle {
+ transition-duration: 0.01ms;
+ }
}
diff --git a/src/trace/events.ts b/src/trace/events.ts
index 8444c5d..abe7d5d 100644
--- a/src/trace/events.ts
+++ b/src/trace/events.ts
@@ -16,6 +16,26 @@ export type ActionRequest = {
input?: unknown;
};
+type InputRequest = {
+ requestId: string;
+ prompt: string;
+ options?: readonly {
+ id: string;
+ label: string;
+ }[];
+ action: {
+ callId: string;
+ toolName: string;
+ input?: unknown;
+ };
+};
+
+type InputResponse = {
+ requestId: string;
+ optionId?: string;
+ text?: string;
+};
+
type TraceEventBody =
| { type: "turn.started"; data: { turnId: string } }
| { type: "turn.completed"; data: { turnId: string } }
@@ -50,6 +70,18 @@ type TraceEventBody =
actions: readonly ActionRequest[];
};
}
+ | {
+ type: "input.requested";
+ data: {
+ turnId: string;
+ stepIndex: number;
+ requests: readonly InputRequest[];
+ };
+ }
+ | {
+ type: "client.input.responded";
+ data: { responses: readonly InputResponse[] };
+ }
| {
type: "action.result";
data: {
diff --git a/src/trace/format.test.ts b/src/trace/format.test.ts
new file mode 100644
index 0000000..0a4301a
--- /dev/null
+++ b/src/trace/format.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, test } from "bun:test";
+import { formatDuration, formatTokens } from "@/trace/format";
+
+describe("formatDuration", () => {
+ test("keeps milliseconds compact", () => {
+ expect(formatDuration(-1)).toBe("0ms");
+ expect(formatDuration(1)).toBe("1ms");
+ expect(formatDuration(12)).toBe("12ms");
+ expect(formatDuration(999)).toBe("999ms");
+ });
+
+ test("uses three significant digits for larger units", () => {
+ expect(formatDuration(1000)).toBe("1.00s");
+ expect(formatDuration(12_345)).toBe("12.3s");
+ expect(formatDuration(60_000)).toBe("1.00m");
+ expect(formatDuration(3_600_000)).toBe("1.00h");
+ });
+
+ test("promotes values that round across unit boundaries", () => {
+ expect(formatDuration(59_950)).toBe("1.00m");
+ expect(formatDuration(3_597_000)).toBe("1.00h");
+ });
+});
+
+describe("formatTokens", () => {
+ test("uses compact rounded units", () => {
+ expect(formatTokens(999)).toBe("999");
+ expect(formatTokens(1000)).toBe("1k");
+ expect(formatTokens(7100)).toBe("7k");
+ expect(formatTokens(999_499)).toBe("999k");
+ expect(formatTokens(999_500)).toBe("1m");
+ });
+});
diff --git a/src/trace/format.ts b/src/trace/format.ts
index 416a759..ee89778 100644
--- a/src/trace/format.ts
+++ b/src/trace/format.ts
@@ -1,21 +1,36 @@
export function formatDuration(ms: number): string {
if (ms < 1000) {
- return `${Math.round(ms)}ms`;
+ const milliseconds = Math.min(999, Math.max(0, Math.round(ms)));
+ return `${milliseconds}ms`;
}
const seconds = ms / 1000;
if (seconds < 60) {
- return `${seconds.toFixed(1)}s`;
+ const formattedSeconds = seconds.toPrecision(3);
+ if (Number(formattedSeconds) < 60) {
+ return `${formattedSeconds}s`;
+ }
+ return `${(Number(formattedSeconds) / 60).toPrecision(3)}m`;
}
- const minutes = Math.floor(seconds / 60);
- const rest = Math.floor(seconds % 60);
- return `${minutes}:${rest.toString().padStart(2, "0")}`;
+ const minutes = seconds / 60;
+ if (minutes < 60) {
+ const formattedMinutes = minutes.toPrecision(3);
+ if (Number(formattedMinutes) < 60) {
+ return `${formattedMinutes}m`;
+ }
+ return `${(Number(formattedMinutes) / 60).toPrecision(3)}h`;
+ }
+ return `${(minutes / 60).toPrecision(3)}h`;
}
export function formatTokens(count: number): string {
if (count < 1000) {
return `${count}`;
}
- return `${(count / 1000).toFixed(1)}k`;
+ const thousands = Math.round(count / 1000);
+ if (thousands < 1000) {
+ return `${thousands}k`;
+ }
+ return `${Math.round(count / 1_000_000)}m`;
}
export function formatTime(iso: string): string {
diff --git a/src/trace/projector.test.ts b/src/trace/projector.test.ts
new file mode 100644
index 0000000..944a4da
--- /dev/null
+++ b/src/trace/projector.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, test } from "bun:test";
+import type { TraceEvent } from "@/trace/events";
+import { createProjector } from "@/trace/projector";
+import { summarizeTurn } from "@/trace/summary";
+import type { Turn } from "@/trace/types";
+
+describe("createProjector", () => {
+ test("presents ask_question calls as questions", () => {
+ let turns: Turn[] = [];
+ const projector = createProjector((nextTurns) => {
+ turns = nextTurns;
+ });
+ const events: TraceEvent[] = [
+ { type: "turn.started", data: { turnId: "turn-1" } },
+ {
+ type: "message.appended",
+ data: { turnId: "turn-1", stepIndex: 0, messageSoFar: " \n " },
+ },
+ {
+ type: "message.completed",
+ data: { turnId: "turn-1", stepIndex: 0, message: "\n" },
+ },
+ {
+ type: "input.requested",
+ data: {
+ turnId: "turn-1",
+ stepIndex: 0,
+ requests: [
+ {
+ requestId: "request-1",
+ prompt: "Which city?",
+ options: [
+ { id: "tokyo", label: "Tokyo" },
+ { id: "paris", label: "Paris" },
+ ],
+ action: {
+ callId: "question-1",
+ toolName: "ask_question",
+ input: { prompt: "Which city?" },
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "client.input.responded",
+ data: {
+ responses: [{ requestId: "request-1", optionId: "paris" }],
+ },
+ },
+ ];
+
+ for (const event of events) {
+ projector.push(event);
+ }
+
+ const turn = turns[0];
+ if (turn === undefined) {
+ throw new Error("Expected the projected turn.");
+ }
+ expect(turn.steps[0]?.actions[0]?.question).toEqual({
+ requestId: "request-1",
+ prompt: "Which city?",
+ options: [
+ { id: "tokyo", label: "Tokyo" },
+ { id: "paris", label: "Paris" },
+ ],
+ answer: "Paris",
+ });
+ expect(turn.steps[0]?.response).toBeUndefined();
+ expect(summarizeTurn(turn).tools).toBe(0);
+ });
+});
diff --git a/src/trace/projector.ts b/src/trace/projector.ts
index 90c8145..a676cbc 100644
--- a/src/trace/projector.ts
+++ b/src/trace/projector.ts
@@ -1,5 +1,5 @@
import type { ActionRequest, TraceEvent } from "@/trace/events";
-import type { Action, Step, Turn } from "@/trace/types";
+import type { Action, Question, Step, Turn } from "@/trace/types";
export type Projector = {
push: (event: TraceEvent) => void;
@@ -19,12 +19,30 @@ function millisBetween(
return Date.parse(end) - Date.parse(start);
}
+function answerFor(
+ question: Question,
+ response: { optionId?: string; text?: string },
+): string | undefined {
+ const text = response.text?.trim();
+ if (text !== undefined && text !== "") {
+ return text;
+ }
+ if (response.optionId === undefined) {
+ return undefined;
+ }
+ return (
+ question.options.find((option) => option.id === response.optionId)?.label ??
+ response.optionId
+ );
+}
+
// Folds eve stream events into the Turn tree, calling onChange with a fresh
// snapshot after every event. The wire only reports completed, failed, and
// rejected action results; running and aborted are synthesized here.
export function createProjector(onChange: (turns: Turn[]) => void): Projector {
const turns: Turn[] = [];
const actionsByCallId = new Map();
+ const questionsByRequestId = new Map();
function turnById(turnId: string): Turn | undefined {
return turns.find((turn) => turn.id === turnId);
@@ -91,7 +109,7 @@ export function createProjector(onChange: (turns: Turn[]) => void): Projector {
return;
}
case "message.appended": {
- if (event.data.messageSoFar === "") {
+ if (event.data.messageSoFar.trim() === "") {
return;
}
const step = stepAt(event.data.turnId, event.data.stepIndex);
@@ -101,7 +119,7 @@ export function createProjector(onChange: (turns: Turn[]) => void): Projector {
return;
}
case "message.completed": {
- if (event.data.message === null) {
+ if (event.data.message === null || event.data.message.trim() === "") {
return;
}
const step = stepAt(event.data.turnId, event.data.stepIndex);
@@ -135,6 +153,46 @@ export function createProjector(onChange: (turns: Turn[]) => void): Projector {
}
return;
}
+ case "input.requested": {
+ for (const request of event.data.requests) {
+ if (request.action.toolName !== "ask_question") {
+ continue;
+ }
+ let action = actionsByCallId.get(request.action.callId);
+ if (action === undefined) {
+ const step = stepAt(event.data.turnId, event.data.stepIndex);
+ if (step === undefined) {
+ continue;
+ }
+ action = {
+ callId: request.action.callId,
+ name: request.action.toolName,
+ status: "running",
+ startedAt: event.meta?.at,
+ input: request.action.input,
+ };
+ actionsByCallId.set(action.callId, action);
+ step.actions.push(action);
+ }
+ action.question = {
+ requestId: request.requestId,
+ prompt: request.prompt,
+ options: request.options ?? [],
+ };
+ questionsByRequestId.set(request.requestId, action);
+ }
+ return;
+ }
+ case "client.input.responded": {
+ for (const response of event.data.responses) {
+ const action = questionsByRequestId.get(response.requestId);
+ if (action?.question === undefined) {
+ continue;
+ }
+ action.question.answer = answerFor(action.question, response);
+ }
+ return;
+ }
case "action.result": {
const action = actionsByCallId.get(event.data.result.callId);
if (action === undefined) {
@@ -176,6 +234,7 @@ export function createProjector(onChange: (turns: Turn[]) => void): Projector {
case "session.started": {
turns.length = 0;
actionsByCallId.clear();
+ questionsByRequestId.clear();
return;
}
case "session.completed":
diff --git a/src/trace/summary.ts b/src/trace/summary.ts
index 099180a..ba78cab 100644
--- a/src/trace/summary.ts
+++ b/src/trace/summary.ts
@@ -11,7 +11,11 @@ export function summarizeTurn(turn: Turn): TurnSummary {
let inputTokens = 0;
let outputTokens = 0;
for (const step of turn.steps) {
- tools += step.actions.length;
+ for (const action of step.actions) {
+ if (action.question === undefined) {
+ tools += 1;
+ }
+ }
inputTokens += step.usage?.inputTokens ?? 0;
outputTokens += step.usage?.outputTokens ?? 0;
}
@@ -21,14 +25,3 @@ export function summarizeTurn(turn: Turn): TurnSummary {
outputTokens,
};
}
-
-export function summarizeTurns(turns: Turn[]): TurnSummary {
- const total = { tools: 0, inputTokens: 0, outputTokens: 0 };
- for (const turn of turns) {
- const summary = summarizeTurn(turn);
- total.tools += summary.tools;
- total.inputTokens += summary.inputTokens;
- total.outputTokens += summary.outputTokens;
- }
- return total;
-}
diff --git a/src/trace/types.ts b/src/trace/types.ts
index 8de785e..c8a6c7e 100644
--- a/src/trace/types.ts
+++ b/src/trace/types.ts
@@ -19,10 +19,21 @@ export type TraceError = {
details?: unknown;
};
+export type Question = {
+ requestId: string;
+ prompt: string;
+ options: readonly {
+ id: string;
+ label: string;
+ }[];
+ answer?: string;
+};
+
export type Action = {
callId: string;
name: string;
status: ActionStatus;
+ question?: Question;
startedAt?: string; // ISO timestamp, from the event's `meta.at`
durationMs?: number;
input?: unknown;
diff --git a/tsconfig.json b/tsconfig.json
index 6a4a274..bbc3054 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
- "types": ["vite/client"],
+ "types": ["bun", "vite/client"],
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "bundler",
diff --git a/vite.config.ts b/vite.config.ts
index ac45fc2..2e30598 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,7 +3,13 @@ import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
- plugins: [tailwindcss(), dts({ include: ["index.ts", "react.ts", "src"] })],
+ plugins: [
+ tailwindcss(),
+ dts({
+ include: ["index.ts", "react.ts", "src"],
+ exclude: ["src/**/*.test.ts"],
+ }),
+ ],
resolve: { alias: { "@": `${import.meta.dirname}/src` } },
build: {
lib: {