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} +
+ + {icon} +
{children}
); 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 ( -
- {turnsLabel(turns.length)} -
- }>{summary.tools} - }> - {formatTokens(summary.inputTokens)} - - }> - {formatTokens(summary.outputTokens)} - -
-
+
+ + + eve-devtools + +
+ ); +} + +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" +
+
); } 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 && ( + <> + - )} +

); } 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: {