diff --git a/.changeset/log-viewer-tui.md b/.changeset/log-viewer-tui.md
new file mode 100644
index 00000000..0a249175
--- /dev/null
+++ b/.changeset/log-viewer-tui.md
@@ -0,0 +1,5 @@
+---
+"@perstack/tui-components": patch
+---
+
+Add interactive log viewer TUI with delegation tree, run list, event/checkpoint drill-down screens
diff --git a/bun.lock b/bun.lock
index 9484c9a6..0477c403 100644
--- a/bun.lock
+++ b/bun.lock
@@ -49,7 +49,7 @@
},
"apps/perstack": {
"name": "perstack",
- "version": "0.0.123",
+ "version": "0.0.124",
"dependencies": {
"commander": "^14.0.3",
},
@@ -304,7 +304,7 @@
},
"packages/runtime": {
"name": "@perstack/runtime",
- "version": "0.0.134",
+ "version": "0.0.136",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.60",
"@ai-sdk/anthropic": "^3.0.44",
@@ -396,6 +396,7 @@
"version": "0.0.34",
"dependencies": {
"@perstack/core": "workspace:*",
+ "@perstack/log": "workspace:*",
"@perstack/react": "workspace:*",
"ink": "^6.7.0",
"react": "^19.2.4",
diff --git a/packages/tui-components/package.json b/packages/tui-components/package.json
index 077be7ff..d8f7aed3 100644
--- a/packages/tui-components/package.json
+++ b/packages/tui-components/package.json
@@ -29,6 +29,7 @@
},
"dependencies": {
"@perstack/core": "workspace:*",
+ "@perstack/log": "workspace:*",
"@perstack/react": "workspace:*",
"ink": "^6.7.0",
"react": "^19.2.4"
diff --git a/packages/tui-components/src/components/bottom-panel.tsx b/packages/tui-components/src/components/bottom-panel.tsx
new file mode 100644
index 00000000..e152248c
--- /dev/null
+++ b/packages/tui-components/src/components/bottom-panel.tsx
@@ -0,0 +1,55 @@
+import { Box, Text, useInput } from "ink"
+import type React from "react"
+import { colors } from "../colors.js"
+import { useTextInput } from "../hooks/use-text-input.js"
+
+type BottomPanelProps = {
+ children: React.ReactNode
+ onSubmit: (query: string) => void
+ canSubmit?: boolean
+ inputPlaceholder?: string
+}
+
+export const BottomPanel = ({
+ children,
+ onSubmit,
+ canSubmit = true,
+ inputPlaceholder,
+}: BottomPanelProps) => {
+ const { input, handleInput } = useTextInput({
+ onSubmit,
+ canSubmit,
+ })
+
+ useInput(handleInput)
+
+ return (
+
+ {children}
+
+ >
+ {input ? (
+ <>
+ {input}
+ _
+ >
+ ) : inputPlaceholder ? (
+
+ {inputPlaceholder}
+ _
+
+ ) : (
+ _
+ )}
+
+
+ )
+}
diff --git a/packages/tui-components/src/execution/hooks/use-delegation-tree.ts b/packages/tui-components/src/execution/hooks/use-delegation-tree.ts
index 5ff45038..9fcfd503 100644
--- a/packages/tui-components/src/execution/hooks/use-delegation-tree.ts
+++ b/packages/tui-components/src/execution/hooks/use-delegation-tree.ts
@@ -20,6 +20,9 @@ export type DelegationTreeNode = {
parentRunId: string | undefined
childRunIds: string[]
totalTokens: number
+ inputTokens: number
+ outputTokens: number
+ cachedInputTokens: number
}
export type DelegationTreeState = {
@@ -130,6 +133,31 @@ export function getStatusCounts(state: DelegationTreeState): {
return { running, waiting }
}
+/**
+ * Flatten the entire tree without pruning — includes all nodes regardless of status.
+ * Useful for testing and debugging where the full tree structure matters.
+ */
+export function flattenTreeAll(state: DelegationTreeState): FlatTreeNode[] {
+ if (!state.rootRunId) return []
+ const root = state.nodes.get(state.rootRunId)
+ if (!root) return []
+
+ const result: FlatTreeNode[] = []
+
+ function dfs(nodeId: string, depth: number, isLast: boolean, ancestorIsLast: boolean[]) {
+ const node = state.nodes.get(nodeId)
+ if (!node) return
+ result.push({ node, depth, isLast, ancestorIsLast: [...ancestorIsLast] })
+ for (let i = 0; i < node.childRunIds.length; i++) {
+ const childIsLast = i === node.childRunIds.length - 1
+ dfs(node.childRunIds[i]!, depth + 1, childIsLast, [...ancestorIsLast, isLast])
+ }
+ }
+
+ dfs(state.rootRunId, 0, true, [])
+ return result
+}
+
export function flattenTree(state: DelegationTreeState): FlatTreeNode[] {
if (!state.rootRunId) return []
const root = state.nodes.get(state.rootRunId)
@@ -236,6 +264,9 @@ export function processDelegationTreeEvent(
parentRunId,
childRunIds: [],
totalTokens: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedInputTokens: 0,
}
state.nodes.set(event.runId, node)
diff --git a/packages/tui-components/src/index.ts b/packages/tui-components/src/index.ts
index f6553db2..9b2fd2fc 100644
--- a/packages/tui-components/src/index.ts
+++ b/packages/tui-components/src/index.ts
@@ -2,6 +2,9 @@
export type { ExecutionParams, ExecutionResult } from "./execution/index.js"
export { renderExecution } from "./execution/index.js"
+export type { JobListItem, LogViewerParams, LogViewerScreen, RunInfo } from "./log-viewer/index.js"
+// Log Viewer
+export { renderLogViewer } from "./log-viewer/index.js"
export type { SelectionParams, SelectionResult } from "./selection/index.js"
// Selection
export { renderSelection } from "./selection/index.js"
diff --git a/packages/tui-components/src/log-viewer/app.tsx b/packages/tui-components/src/log-viewer/app.tsx
new file mode 100644
index 00000000..38ce000b
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/app.tsx
@@ -0,0 +1,320 @@
+import type { Checkpoint, Job, RunEvent } from "@perstack/core"
+import type { LogDataFetcher } from "@perstack/log"
+import { Box, Text, useApp, useInput } from "ink"
+import { useCallback, useEffect, useRef, useState } from "react"
+import { colors } from "../colors.js"
+import { BottomPanel } from "../components/bottom-panel.js"
+import type { DelegationTreeNode } from "../execution/hooks/use-delegation-tree.js"
+import { buildRunTreeFromEvents, extractQueryFromStartRun } from "./build-run-tree.js"
+import {
+ CheckpointInfoContent,
+ EventInfoContent,
+ JobInfoContent,
+ RunInfoContent,
+} from "./components/log-info-content.js"
+import { CheckpointDetailScreen } from "./screens/checkpoint-detail.js"
+import { CheckpointListScreen } from "./screens/checkpoint-list.js"
+import { EventDetailScreen } from "./screens/event-detail.js"
+import { EventListScreen } from "./screens/event-list.js"
+import { JobDetailScreen } from "./screens/job-detail.js"
+import { JobListScreen } from "./screens/job-list.js"
+import { RunListScreen } from "./screens/run-list.js"
+import type { JobListItem, LogViewerScreen, RunInfo } from "./types.js"
+
+type LogViewerAppProps = {
+ fetcher: LogDataFetcher
+ initialJobId?: string
+ initialRunId?: string
+}
+
+async function extractJobQuery(fetcher: LogDataFetcher, job: Job): Promise {
+ try {
+ const runs = await fetcher.getRuns(job.id)
+ if (runs.length === 0) return undefined
+ const firstRun = runs[0]
+ const events = await fetcher.getEvents(job.id, firstRun.runId)
+ const startRunEvent = events.find((e: RunEvent) => e.type === "startRun")
+ if (!startRunEvent) return undefined
+ return extractQueryFromStartRun(startRunEvent)
+ } catch {
+ return undefined
+ }
+}
+
+async function buildRunTree(fetcher: LogDataFetcher, jobId: string) {
+ const allEvents = await fetcher.getAllEventsForJob(jobId)
+ return buildRunTreeFromEvents(allEvents)
+}
+
+export const LogViewerApp = ({ fetcher, initialJobId, initialRunId }: LogViewerAppProps) => {
+ const { exit } = useApp()
+ const [screen, setScreen] = useState({ type: "jobList" })
+ const [jobItems, setJobItems] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState()
+
+ // Selected items from list screens
+ const [selectedJob, setSelectedJob] = useState()
+ const [selectedRun, setSelectedRun] = useState()
+ const [selectedTreeNode, setSelectedTreeNode] = useState()
+ const [selectedCheckpoint, setSelectedCheckpoint] = useState()
+ const [selectedEvent, setSelectedEvent] = useState()
+
+ // Global quit handler
+ useInput((char) => {
+ if (char === "q") {
+ exit()
+ }
+ })
+
+ // Load initial data
+ useEffect(() => {
+ const loadInitial = async () => {
+ try {
+ setLoading(true)
+
+ if (initialJobId && initialRunId) {
+ const job = await fetcher.getJob(initialJobId)
+ if (!job) {
+ setError(`Job ${initialJobId} not found`)
+ setLoading(false)
+ return
+ }
+ const checkpoints = await fetcher.getCheckpoints(initialJobId)
+ const runCheckpoints = checkpoints.filter((cp: Checkpoint) => cp.runId === initialRunId)
+ const run: RunInfo = { jobId: initialJobId, runId: initialRunId }
+ setScreen({ type: "checkpointList", job, run, checkpoints: runCheckpoints })
+ setLoading(false)
+ return
+ }
+
+ if (initialJobId) {
+ const job = await fetcher.getJob(initialJobId)
+ if (!job) {
+ setError(`Job ${initialJobId} not found`)
+ setLoading(false)
+ return
+ }
+ const { treeState, runQueries, runStats } = await buildRunTree(fetcher, initialJobId)
+ setScreen({ type: "runList", job, treeState, runQueries, runStats })
+ setLoading(false)
+ return
+ }
+
+ const latest = await fetcher.getLatestJob()
+ if (latest) {
+ const query = await extractJobQuery(fetcher, latest)
+ setJobItems([{ job: latest, query }])
+ } else {
+ setJobItems([])
+ }
+ setLoading(false)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err))
+ setLoading(false)
+ }
+ }
+ loadInitial()
+ }, [fetcher, initialJobId, initialRunId])
+
+ const handleChatSubmit = useCallback((_query: string) => {
+ // TODO: AI chat integration
+ }, [])
+
+ // Navigation handlers
+ const navigateToJobDetail = useCallback((job: Job) => {
+ setScreen({ type: "jobDetail", job })
+ }, [])
+
+ const navigateToRunList = useCallback(
+ async (job: Job) => {
+ setLoading(true)
+ try {
+ const { treeState, runQueries, runStats } = await buildRunTree(fetcher, job.id)
+ setScreen({ type: "runList", job, treeState, runQueries, runStats })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err))
+ }
+ setLoading(false)
+ },
+ [fetcher],
+ )
+
+ const navigateToCheckpointList = useCallback(
+ async (job: Job, run: RunInfo) => {
+ setLoading(true)
+ try {
+ const checkpoints = await fetcher.getCheckpoints(job.id)
+ const runCheckpoints = checkpoints.filter((cp: Checkpoint) => cp.runId === run.runId)
+ setScreen({ type: "checkpointList", job, run, checkpoints: runCheckpoints })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err))
+ }
+ setLoading(false)
+ },
+ [fetcher],
+ )
+
+ const navigateToCheckpointDetail = useCallback(
+ (job: Job, run: RunInfo, checkpoint: Checkpoint) => {
+ setScreen({ type: "checkpointDetail", job, run, checkpoint })
+ },
+ [],
+ )
+
+ const navigateToEventList = useCallback(
+ async (job: Job, run: RunInfo) => {
+ setLoading(true)
+ try {
+ const events = await fetcher.getEvents(job.id, run.runId)
+ setScreen({ type: "eventList", job, run, events })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err))
+ }
+ setLoading(false)
+ },
+ [fetcher],
+ )
+
+ const navigateToEventDetail = useCallback((job: Job, run: RunInfo, event: RunEvent) => {
+ setScreen({ type: "eventDetail", job, run, event })
+ }, [])
+
+ // Stable callback for run tree selection — uses ref to avoid re-render loop
+ const screenRef = useRef(screen)
+ screenRef.current = screen
+ const handleRunSelectedChange = useCallback((node: DelegationTreeNode | undefined) => {
+ const currentScreen = screenRef.current
+ if (currentScreen.type !== "runList") return
+ setSelectedTreeNode(node)
+ setSelectedRun(
+ node
+ ? { jobId: currentScreen.job.id, runId: node.runId, expertKey: node.expertKey }
+ : undefined,
+ )
+ }, [])
+
+ // Build info content based on current screen and selection
+ const renderInfoContent = () => {
+ switch (screen.type) {
+ case "jobList":
+ return selectedJob ? : null
+ case "jobDetail":
+ return
+ case "runList": {
+ if (!selectedTreeNode) return null
+ const stats = screen.runStats.get(selectedTreeNode.runId)
+ return (
+
+ )
+ }
+ case "checkpointList":
+ return selectedCheckpoint ? : null
+ case "checkpointDetail":
+ return
+ case "eventList":
+ return selectedEvent ? : null
+ case "eventDetail":
+ return
+ default:
+ return null
+ }
+ }
+
+ if (error) {
+ return (
+
+ Error: {error}
+ Press q to quit
+
+ )
+ }
+
+ if (loading) {
+ return Loading...
+ }
+
+ return (
+
+
+ {screen.type === "jobList" && (
+ navigateToRunList(job)}
+ onViewJobDetail={navigateToJobDetail}
+ onSelectedChange={setSelectedJob}
+ />
+ )}
+ {screen.type === "jobDetail" && (
+ setScreen({ type: "jobList" })}
+ onViewRuns={navigateToRunList}
+ />
+ )}
+ {screen.type === "runList" && (
+ {
+ const run: RunInfo = {
+ jobId: screen.job.id,
+ runId: node.runId,
+ expertKey: node.expertKey,
+ }
+ navigateToCheckpointList(screen.job, run)
+ }}
+ onBack={() => setScreen({ type: "jobList" })}
+ onSelectedChange={handleRunSelectedChange}
+ />
+ )}
+ {screen.type === "checkpointList" && (
+ navigateToCheckpointDetail(screen.job, screen.run, cp)}
+ onBack={() => navigateToRunList(screen.job)}
+ onSelectedChange={setSelectedCheckpoint}
+ />
+ )}
+ {screen.type === "checkpointDetail" && (
+ navigateToCheckpointList(screen.job, screen.run)}
+ />
+ )}
+ {screen.type === "eventList" && (
+ navigateToEventDetail(screen.job, screen.run, ev)}
+ onBack={() => navigateToRunList(screen.job)}
+ onSelectedChange={setSelectedEvent}
+ />
+ )}
+ {screen.type === "eventDetail" && (
+ navigateToEventList(screen.job, screen.run)}
+ />
+ )}
+
+
+ {renderInfoContent() ?? Select an item to view details}
+
+
+ )
+}
diff --git a/packages/tui-components/src/log-viewer/build-run-tree.test.ts b/packages/tui-components/src/log-viewer/build-run-tree.test.ts
new file mode 100644
index 00000000..4bdad4a5
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/build-run-tree.test.ts
@@ -0,0 +1,710 @@
+import { describe, expect, it } from "bun:test"
+import type { Checkpoint, RunEvent, Usage } from "@perstack/core"
+import { flattenTreeAll } from "../execution/hooks/use-delegation-tree.js"
+import { buildRunTreeFromEvents } from "./build-run-tree.js"
+
+// --- Helpers ---
+
+const baseUsage: Usage = {
+ inputTokens: 100,
+ outputTokens: 50,
+ reasoningTokens: 0,
+ totalTokens: 150,
+ cachedInputTokens: 0,
+}
+
+let eventCounter = 0
+let timestamp = 1000000
+
+function makeCheckpoint(overrides: Partial = {}): Checkpoint {
+ return {
+ id: `cp-${eventCounter}`,
+ jobId: "job-1",
+ runId: "run-1",
+ status: "proceeding",
+ stepNumber: 1,
+ messages: [],
+ expert: { key: "test", name: "test", version: "1.0.0" },
+ usage: { ...baseUsage },
+ ...overrides,
+ }
+}
+
+function makeEvent(
+ type: string,
+ runId: string,
+ expertKey: string,
+ payload: Record,
+): RunEvent {
+ eventCounter++
+ timestamp += 100
+ return {
+ id: `evt-${eventCounter}`,
+ type,
+ expertKey,
+ timestamp,
+ jobId: "job-1",
+ runId,
+ stepNumber: 1,
+ ...payload,
+ } as RunEvent
+}
+
+function startRun(
+ runId: string,
+ expertKey: string,
+ parentRunId?: string,
+ model = "test-model",
+): RunEvent {
+ return makeEvent("startRun", runId, expertKey, {
+ initialCheckpoint: makeCheckpoint({
+ runId,
+ delegatedBy: parentRunId
+ ? {
+ expert: { key: "parent", name: "parent", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-parent",
+ runId: parentRunId,
+ }
+ : undefined,
+ }),
+ inputMessages: [],
+ model,
+ })
+}
+
+function stopByDelegate(
+ runId: string,
+ expertKey: string,
+ delegateTo?: Array<{ key: string; query?: string }>,
+): RunEvent {
+ return makeEvent("stopRunByDelegate", runId, expertKey, {
+ checkpoint: makeCheckpoint({
+ runId,
+ status: "stoppedByDelegate",
+ delegateTo: delegateTo?.map((d, i) => ({
+ expert: { key: d.key, name: d.key, version: "1.0.0" },
+ toolCallId: `tc-${i}`,
+ toolName: d.key.split("/").pop() ?? d.key,
+ query: d.query ?? "",
+ })),
+ }),
+ step: {},
+ })
+}
+
+function completeRun(runId: string, expertKey: string): RunEvent {
+ return makeEvent("completeRun", runId, expertKey, {
+ checkpoint: makeCheckpoint({ runId, status: "completed" }),
+ step: {},
+ text: "done",
+ usage: baseUsage,
+ })
+}
+
+function errorRun(runId: string, expertKey: string, _parentRunId: string): RunEvent {
+ return makeEvent("stopRunByError", runId, expertKey, {
+ checkpoint: makeCheckpoint({
+ runId,
+ status: "stoppedByError",
+ error: { name: "Error", message: "Executable not found", isRetryable: false },
+ }),
+ step: {},
+ error: { name: "Error", message: "Executable not found", isRetryable: false },
+ })
+}
+
+function resumeFromStop(newRunId: string, expertKey: string, model = "test-model"): RunEvent {
+ // In real data, checkpoint.runId === event.runId (always the new runId)
+ return makeEvent("resumeFromStop", newRunId, expertKey, {
+ checkpoint: makeCheckpoint({ runId: newRunId, status: "proceeding" }),
+ model,
+ })
+}
+
+// --- Tests ---
+
+describe("buildRunTreeFromEvents", () => {
+ it("builds a simple single-run tree", () => {
+ const events = [startRun("root", "coordinator"), completeRun("root", "coordinator")]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+ expect(treeState.rootRunId).toBe("root")
+ expect(treeState.nodes.size).toBe(1)
+ expect(treeState.nodes.get("root")?.status).toBe("completed")
+ })
+
+ it("builds parent-child with delegation", () => {
+ const events = [
+ startRun("parent", "coordinator"),
+ stopByDelegate("parent", "coordinator"),
+ startRun("child-1", "worker-a", "parent"),
+ startRun("child-2", "worker-b", "parent"),
+ completeRun("child-1", "worker-a"),
+ completeRun("child-2", "worker-b"),
+ resumeFromStop("parent-resume", "coordinator"),
+ completeRun("parent-resume", "coordinator"),
+ ]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+
+ // Resume should be merged into original parent
+ expect(treeState.nodes.size).toBe(3) // parent, child-1, child-2
+ expect(treeState.rootRunId).toBe("parent")
+
+ const parent = treeState.nodes.get("parent")!
+ expect(parent.status).toBe("completed")
+ expect(parent.childRunIds).toEqual(["child-1", "child-2"])
+
+ // Children should have correct parent
+ expect(treeState.nodes.get("child-1")?.parentRunId).toBe("parent")
+ expect(treeState.nodes.get("child-2")?.parentRunId).toBe("parent")
+
+ // Resume runId should not create a separate node
+ expect(treeState.nodes.has("parent-resume")).toBe(false)
+ })
+
+ it("handles multi-level delegation (grandchildren)", () => {
+ const events = [
+ // Root starts and delegates to child
+ startRun("root", "coordinator"),
+ stopByDelegate("root", "coordinator"),
+ // Child starts and delegates to grandchild
+ startRun("child", "worker", "root"),
+ stopByDelegate("child", "worker"),
+ // Grandchild runs and completes
+ startRun("grandchild", "sub-worker", "child"),
+ completeRun("grandchild", "sub-worker"),
+ // Child resumes and completes
+ resumeFromStop("child-resume", "worker"),
+ completeRun("child-resume", "worker"),
+ // Root resumes and completes
+ resumeFromStop("root-resume", "coordinator"),
+ completeRun("root-resume", "coordinator"),
+ ]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+
+ expect(treeState.nodes.size).toBe(3) // root, child, grandchild
+ expect(treeState.rootRunId).toBe("root")
+
+ const root = treeState.nodes.get("root")!
+ expect(root.childRunIds).toEqual(["child"])
+ expect(root.status).toBe("completed")
+
+ const child = treeState.nodes.get("child")!
+ expect(child.parentRunId).toBe("root")
+ expect(child.childRunIds).toEqual(["grandchild"])
+ expect(child.status).toBe("completed")
+
+ const grandchild = treeState.nodes.get("grandchild")!
+ expect(grandchild.parentRunId).toBe("child")
+ expect(grandchild.status).toBe("completed")
+ })
+
+ it("handles multiple delegation rounds from the same parent", () => {
+ // coordinator delegates to plan, resumes, delegates to build, resumes
+ const events = [
+ startRun("root", "coordinator"),
+ stopByDelegate("root", "coordinator"),
+ startRun("plan", "@coordinator/plan", "root"),
+ completeRun("plan", "@coordinator/plan"),
+ resumeFromStop("root-r1", "coordinator"),
+ stopByDelegate("root-r1", "coordinator"),
+ startRun("build", "@coordinator/build", "root-r1"),
+ completeRun("build", "@coordinator/build"),
+ resumeFromStop("root-r2", "coordinator"),
+ completeRun("root-r2", "coordinator"),
+ ]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+
+ // root-r1 and root-r2 merged into root
+ expect(treeState.nodes.size).toBe(3) // root, plan, build
+ const root = treeState.nodes.get("root")!
+ expect(root.childRunIds).toEqual(["plan", "build"])
+ expect(root.status).toBe("completed")
+
+ // build's parent should resolve to root (since root-r1 is alias of root)
+ expect(treeState.nodes.get("build")?.parentRunId).toBe("root")
+ })
+
+ it("handles parallel delegates with the same expertKey", () => {
+ // build delegates to 3 test-expert instances in parallel
+ const events = [
+ startRun("build", "@coordinator/build"),
+ stopByDelegate("build", "@coordinator/build"),
+ startRun("test-1", "@coordinator/test-expert", "build"),
+ startRun("test-2", "@coordinator/test-expert", "build"),
+ startRun("test-3", "@coordinator/test-expert", "build"),
+ // test-3 completes first (no delegation)
+ completeRun("test-3", "@coordinator/test-expert"),
+ // test-1 delegates then resumes
+ stopByDelegate("test-1", "@coordinator/test-expert"),
+ startRun("game-1", "bash-gaming", "test-1"),
+ completeRun("game-1", "bash-gaming"),
+ resumeFromStop("test-1-resume", "@coordinator/test-expert"),
+ completeRun("test-1-resume", "@coordinator/test-expert"),
+ // test-2 delegates then resumes
+ stopByDelegate("test-2", "@coordinator/test-expert"),
+ startRun("game-2", "bash-gaming", "test-2"),
+ completeRun("game-2", "bash-gaming"),
+ resumeFromStop("test-2-resume", "@coordinator/test-expert"),
+ completeRun("test-2-resume", "@coordinator/test-expert"),
+ // build resumes
+ resumeFromStop("build-resume", "@coordinator/build"),
+ completeRun("build-resume", "@coordinator/build"),
+ ]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+
+ // 6 logical nodes: build, test-1, test-2, test-3, game-1, game-2
+ expect(treeState.nodes.size).toBe(6)
+
+ const build = treeState.nodes.get("build")!
+ expect(build.childRunIds).toEqual(["test-1", "test-2", "test-3"])
+ expect(build.status).toBe("completed")
+
+ // test-1-resume merged into test-1
+ expect(treeState.nodes.has("test-1-resume")).toBe(false)
+ const test1 = treeState.nodes.get("test-1")!
+ expect(test1.childRunIds).toEqual(["game-1"])
+ expect(test1.status).toBe("completed")
+
+ // test-2-resume merged into test-2
+ expect(treeState.nodes.has("test-2-resume")).toBe(false)
+ const test2 = treeState.nodes.get("test-2")!
+ expect(test2.childRunIds).toEqual(["game-2"])
+ expect(test2.status).toBe("completed")
+ })
+
+ it("produces correct flat tree ordering", () => {
+ const events = [
+ startRun("root", "coordinator"),
+ stopByDelegate("root", "coordinator"),
+ startRun("child-a", "worker-a", "root"),
+ startRun("child-b", "worker-b", "root"),
+ completeRun("child-a", "worker-a"),
+ completeRun("child-b", "worker-b"),
+ resumeFromStop("root-resume", "coordinator"),
+ completeRun("root-resume", "coordinator"),
+ ]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+ const flat = flattenTreeAll(treeState)
+
+ expect(flat.map((f) => f.node.runId)).toEqual(["root", "child-a", "child-b"])
+ expect(flat[0].depth).toBe(0)
+ expect(flat[1].depth).toBe(1)
+ expect(flat[2].depth).toBe(1)
+ })
+
+ it("accumulates tokens across resume segments", () => {
+ const events = [
+ startRun("root", "coordinator"),
+ makeEvent("callTools", "root", "coordinator", {
+ newMessage: {},
+ toolCalls: [],
+ usage: { ...baseUsage, inputTokens: 100, outputTokens: 50, totalTokens: 150 },
+ }),
+ stopByDelegate("root", "coordinator"),
+ startRun("child", "worker", "root"),
+ completeRun("child", "worker"),
+ resumeFromStop("root-resume", "coordinator"),
+ makeEvent("callTools", "root-resume", "coordinator", {
+ newMessage: {},
+ toolCalls: [],
+ usage: { ...baseUsage, inputTokens: 200, outputTokens: 100, totalTokens: 300 },
+ }),
+ completeRun("root-resume", "coordinator"),
+ ]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+ const root = treeState.nodes.get("root")!
+
+ // Tokens from both segments should be accumulated
+ // callTools(100) + completeRun(100) + callTools(200) + completeRun(100) = 400 input
+ expect(root.inputTokens).toBe(400)
+ expect(root.outputTokens).toBe(200)
+ expect(root.totalTokens).toBe(600)
+ })
+
+ it("aggregates runStats across resume segments", () => {
+ const events = [
+ startRun("root", "coordinator"),
+ stopByDelegate("root", "coordinator"),
+ startRun("child", "worker", "root"),
+ completeRun("child", "worker"),
+ resumeFromStop("root-resume", "coordinator"),
+ completeRun("root-resume", "coordinator"),
+ ]
+
+ const { runStats } = buildRunTreeFromEvents(events)
+
+ // root-resume stats should be merged into root
+ expect(runStats.has("root-resume")).toBe(false)
+ const rootStats = runStats.get("root")!
+ expect(rootStats.eventCount).toBeGreaterThan(0)
+ })
+
+ it("extracts query from startRun input messages", () => {
+ const events = [
+ makeEvent("startRun", "root", "coordinator", {
+ initialCheckpoint: makeCheckpoint({ runId: "root" }),
+ inputMessages: [
+ {
+ type: "userMessage",
+ contents: [{ text: "Build a snake\ngame" }],
+ },
+ ],
+ model: "test-model",
+ }),
+ completeRun("root", "coordinator"),
+ ]
+
+ const { runQueries } = buildRunTreeFromEvents(events)
+ expect(runQueries.get("root")).toBe("Build a snake game") // newlines replaced
+ })
+
+ it("handles the full bash-gaming-like scenario", () => {
+ // Simulates the real data structure:
+ // create-expert (4 segments: root -> r1 -> r2 -> r3)
+ // ├── plan (completes)
+ // ├── design-roles (2 segments: dr -> dr-r1)
+ // │ └── (inline find-skill, no separate runs)
+ // └── build (2 segments: build -> build-r1)
+ // ├── test-expert-1 (2 segments: te1 -> te1-r1)
+ // │ └── bash-gaming-1 (3 segments: bg1 -> bg1-r1 -> bg1-r2)
+ // │ └── game-designer-1 (completes)
+ // ├── test-expert-2 (2 segments: te2 -> te2-r1)
+ // │ └── bash-gaming-2 (3 segments: bg2 -> bg2-r1 -> bg2-r2)
+ // │ └── game-designer-2 (completes)
+ // └── test-expert-3 (completes without delegation)
+ const events = [
+ // Root create-expert starts
+ startRun("root", "create-expert"),
+ stopByDelegate("root", "create-expert"),
+
+ // plan child
+ startRun("plan", "@create-expert/plan", "root"),
+ completeRun("plan", "@create-expert/plan"),
+
+ // Root resumes (r1), delegates to design-roles
+ resumeFromStop("root-r1", "create-expert"),
+ stopByDelegate("root-r1", "create-expert"),
+
+ // design-roles starts, delegates to find-skill (inline, no separate runs), resumes
+ startRun("dr", "@create-expert/design-roles", "root-r1"),
+ stopByDelegate("dr", "@create-expert/design-roles"),
+ // (inline find-skill runs have no events in event store)
+ resumeFromStop("dr-r1", "@create-expert/design-roles"),
+ completeRun("dr-r1", "@create-expert/design-roles"),
+
+ // Root resumes (r2), delegates to build
+ resumeFromStop("root-r2", "create-expert"),
+ stopByDelegate("root-r2", "create-expert"),
+
+ // build starts, delegates to 3 test-experts
+ startRun("build", "@create-expert/build", "root-r2"),
+ stopByDelegate("build", "@create-expert/build"),
+
+ // 3 parallel test-experts
+ startRun("te1", "@create-expert/test-expert", "build"),
+ startRun("te2", "@create-expert/test-expert", "build"),
+ startRun("te3", "@create-expert/test-expert", "build"),
+
+ // te1 delegates to bash-gaming-1
+ stopByDelegate("te1", "@create-expert/test-expert"),
+ startRun("bg1", "bash-gaming", "te1"),
+ // bg1 delegates to game-designer
+ stopByDelegate("bg1", "bash-gaming"),
+ startRun("gd1", "@bash-gaming/game-designer", "bg1"),
+ completeRun("gd1", "@bash-gaming/game-designer"),
+ // bg1 resumes, delegates again (inline), resumes again
+ resumeFromStop("bg1-r1", "bash-gaming"),
+ stopByDelegate("bg1-r1", "bash-gaming"),
+ resumeFromStop("bg1-r2", "bash-gaming"),
+ completeRun("bg1-r2", "bash-gaming"),
+ // te1 resumes
+ resumeFromStop("te1-r1", "@create-expert/test-expert"),
+ completeRun("te1-r1", "@create-expert/test-expert"),
+
+ // te2 delegates to bash-gaming-2
+ stopByDelegate("te2", "@create-expert/test-expert"),
+ startRun("bg2", "bash-gaming", "te2"),
+ stopByDelegate("bg2", "bash-gaming"),
+ startRun("gd2", "@bash-gaming/game-designer", "bg2"),
+ completeRun("gd2", "@bash-gaming/game-designer"),
+ resumeFromStop("bg2-r1", "bash-gaming"),
+ stopByDelegate("bg2-r1", "bash-gaming"),
+ resumeFromStop("bg2-r2", "bash-gaming"),
+ completeRun("bg2-r2", "bash-gaming"),
+ resumeFromStop("te2-r1", "@create-expert/test-expert"),
+ completeRun("te2-r1", "@create-expert/test-expert"),
+
+ // te3 completes without delegation
+ completeRun("te3", "@create-expert/test-expert"),
+
+ // build resumes
+ resumeFromStop("build-r1", "@create-expert/build"),
+ completeRun("build-r1", "@create-expert/build"),
+
+ // Root resumes (r3) and completes
+ resumeFromStop("root-r3", "create-expert"),
+ completeRun("root-r3", "create-expert"),
+ ]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+
+ // Expected logical nodes: 12
+ // root, plan, dr, build, te1, te2, te3, bg1, bg2, gd1, gd2
+ // (root-r1/r2/r3, dr-r1, build-r1, te1-r1, te2-r1, bg1-r1/r2, bg2-r1/r2 all merged)
+ expect(treeState.nodes.size).toBe(11)
+ expect(treeState.rootRunId).toBe("root")
+
+ // Verify tree structure
+ const root = treeState.nodes.get("root")!
+ expect(root.status).toBe("completed")
+ expect(root.childRunIds.sort()).toEqual(["build", "dr", "plan"])
+
+ const dr = treeState.nodes.get("dr")!
+ expect(dr.status).toBe("completed")
+ expect(dr.parentRunId).toBe("root")
+
+ const build = treeState.nodes.get("build")!
+ expect(build.status).toBe("completed")
+ expect(build.parentRunId).toBe("root")
+ expect(build.childRunIds.sort()).toEqual(["te1", "te2", "te3"])
+
+ const te1 = treeState.nodes.get("te1")!
+ expect(te1.status).toBe("completed")
+ expect(te1.childRunIds).toEqual(["bg1"])
+
+ const bg1 = treeState.nodes.get("bg1")!
+ expect(bg1.status).toBe("completed")
+ expect(bg1.childRunIds).toEqual(["gd1"])
+
+ const te3 = treeState.nodes.get("te3")!
+ expect(te3.status).toBe("completed")
+ expect(te3.childRunIds).toEqual([])
+
+ // Verify no resume runIds leaked as separate nodes
+ for (const key of treeState.nodes.keys()) {
+ expect(key).not.toContain("-r")
+ }
+
+ // Verify flat tree has correct nesting
+ const flat = flattenTreeAll(treeState)
+ expect(flat.length).toBe(11)
+ const rootFlat = flat.find((f) => f.node.runId === "root")!
+ expect(rootFlat.depth).toBe(0)
+ const gd1Flat = flat.find((f) => f.node.runId === "gd1")!
+ expect(gd1Flat.depth).toBe(4) // root > build > te1 > bg1 > gd1
+ })
+
+ it("shows failed delegates as error nodes (startRun + stopRunByError)", () => {
+ // With the runtime fix, failed delegates now emit startRun + stopRunByError events.
+ // design-roles delegates to 4x find-skill, all fail immediately.
+ const events = [
+ startRun("root", "coordinator"),
+ stopByDelegate("root", "coordinator"),
+ startRun("dr", "@coordinator/design-roles", "root"),
+ stopByDelegate("dr", "@coordinator/design-roles"),
+ // Failed delegates: startRun + stopRunByError pairs
+ startRun("fs1", "@coordinator/find-skill", "dr"),
+ errorRun("fs1", "@coordinator/find-skill", "dr"),
+ startRun("fs2", "@coordinator/find-skill", "dr"),
+ errorRun("fs2", "@coordinator/find-skill", "dr"),
+ startRun("fs3", "@coordinator/find-skill", "dr"),
+ errorRun("fs3", "@coordinator/find-skill", "dr"),
+ startRun("fs4", "@coordinator/find-skill", "dr"),
+ errorRun("fs4", "@coordinator/find-skill", "dr"),
+ // design-roles resumes
+ resumeFromStop("dr-r1", "@coordinator/design-roles"),
+ completeRun("dr-r1", "@coordinator/design-roles"),
+ resumeFromStop("root-r1", "coordinator"),
+ completeRun("root-r1", "coordinator"),
+ ]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+
+ const dr = treeState.nodes.get("dr")!
+ expect(dr.status).toBe("completed")
+ expect(dr.childRunIds.length).toBe(4)
+
+ for (const childId of dr.childRunIds) {
+ const child = treeState.nodes.get(childId)!
+ expect(child.expertKey).toBe("@coordinator/find-skill")
+ expect(child.status).toBe("error")
+ expect(child.parentRunId).toBe("dr")
+ }
+ })
+
+ it("shows failed parallel delegates alongside successful ones", () => {
+ // bash-gaming delegates to game-designer (success) then logic/tui/cli (all fail)
+ const events = [
+ startRun("bg", "bash-gaming"),
+ stopByDelegate("bg", "bash-gaming"),
+ startRun("gd", "@bash-gaming/game-designer", "bg"),
+ completeRun("gd", "@bash-gaming/game-designer"),
+ resumeFromStop("bg-r1", "bash-gaming"),
+ // Second delegation: 3 delegates that fail
+ stopByDelegate("bg-r1", "bash-gaming"),
+ startRun("le", "@bash-gaming/logic-engineer", "bg-r1"),
+ errorRun("le", "@bash-gaming/logic-engineer", "bg-r1"),
+ startRun("te", "@bash-gaming/tui-engineer", "bg-r1"),
+ errorRun("te", "@bash-gaming/tui-engineer", "bg-r1"),
+ startRun("ce", "@bash-gaming/cli-engineer", "bg-r1"),
+ errorRun("ce", "@bash-gaming/cli-engineer", "bg-r1"),
+ resumeFromStop("bg-r2", "bash-gaming"),
+ completeRun("bg-r2", "bash-gaming"),
+ ]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+
+ const bg = treeState.nodes.get("bg")!
+ expect(bg.status).toBe("completed")
+ // game-designer + 3 failed delegates = 4 children
+ expect(bg.childRunIds.length).toBe(4)
+
+ expect(treeState.nodes.get("gd")?.status).toBe("completed")
+
+ const errorChildren = bg.childRunIds
+ .map((id) => treeState.nodes.get(id)!)
+ .filter((n) => n.status === "error")
+ expect(errorChildren.length).toBe(3)
+ const errorKeys = errorChildren.map((n) => n.expertKey).sort()
+ expect(errorKeys).toEqual([
+ "@bash-gaming/cli-engineer",
+ "@bash-gaming/logic-engineer",
+ "@bash-gaming/tui-engineer",
+ ])
+ })
+
+ it("handles full bash-gaming scenario with failed delegates", () => {
+ // Full realistic scenario with startRun + stopRunByError for failed delegates
+ const events = [
+ startRun("root", "create-expert"),
+ stopByDelegate("root", "create-expert"),
+
+ startRun("plan", "@create-expert/plan", "root"),
+ completeRun("plan", "@create-expert/plan"),
+
+ resumeFromStop("root-r1", "create-expert"),
+ stopByDelegate("root-r1", "create-expert"),
+
+ startRun("dr", "@create-expert/design-roles", "root-r1"),
+ stopByDelegate("dr", "@create-expert/design-roles"),
+ // 4 failed find-skill delegates
+ startRun("fs1", "@create-expert/find-skill", "dr"),
+ errorRun("fs1", "@create-expert/find-skill", "dr"),
+ startRun("fs2", "@create-expert/find-skill", "dr"),
+ errorRun("fs2", "@create-expert/find-skill", "dr"),
+ startRun("fs3", "@create-expert/find-skill", "dr"),
+ errorRun("fs3", "@create-expert/find-skill", "dr"),
+ startRun("fs4", "@create-expert/find-skill", "dr"),
+ errorRun("fs4", "@create-expert/find-skill", "dr"),
+ resumeFromStop("dr-r1", "@create-expert/design-roles"),
+ completeRun("dr-r1", "@create-expert/design-roles"),
+
+ resumeFromStop("root-r2", "create-expert"),
+ stopByDelegate("root-r2", "create-expert"),
+
+ startRun("build", "@create-expert/build", "root-r2"),
+ stopByDelegate("build", "@create-expert/build"),
+
+ startRun("te1", "@create-expert/test-expert", "build"),
+ startRun("te2", "@create-expert/test-expert", "build"),
+ startRun("te3", "@create-expert/test-expert", "build"),
+
+ // te1 -> bash-gaming(snake) -> game-designer + 3 failed
+ stopByDelegate("te1", "@create-expert/test-expert"),
+ startRun("bg1", "bash-gaming", "te1"),
+ stopByDelegate("bg1", "bash-gaming"),
+ startRun("gd1", "@bash-gaming/game-designer", "bg1"),
+ completeRun("gd1", "@bash-gaming/game-designer"),
+ resumeFromStop("bg1-r1", "bash-gaming"),
+ stopByDelegate("bg1-r1", "bash-gaming"),
+ startRun("bg1-le", "@bash-gaming/logic-engineer", "bg1-r1"),
+ errorRun("bg1-le", "@bash-gaming/logic-engineer", "bg1-r1"),
+ startRun("bg1-te", "@bash-gaming/tui-engineer", "bg1-r1"),
+ errorRun("bg1-te", "@bash-gaming/tui-engineer", "bg1-r1"),
+ startRun("bg1-ce", "@bash-gaming/cli-engineer", "bg1-r1"),
+ errorRun("bg1-ce", "@bash-gaming/cli-engineer", "bg1-r1"),
+ resumeFromStop("bg1-r2", "bash-gaming"),
+ completeRun("bg1-r2", "bash-gaming"),
+ resumeFromStop("te1-r1", "@create-expert/test-expert"),
+ completeRun("te1-r1", "@create-expert/test-expert"),
+
+ // te2 -> bash-gaming(roguelike) -> game-designer + 3 failed
+ stopByDelegate("te2", "@create-expert/test-expert"),
+ startRun("bg2", "bash-gaming", "te2"),
+ stopByDelegate("bg2", "bash-gaming"),
+ startRun("gd2", "@bash-gaming/game-designer", "bg2"),
+ completeRun("gd2", "@bash-gaming/game-designer"),
+ resumeFromStop("bg2-r1", "bash-gaming"),
+ stopByDelegate("bg2-r1", "bash-gaming"),
+ startRun("bg2-le", "@bash-gaming/logic-engineer", "bg2-r1"),
+ errorRun("bg2-le", "@bash-gaming/logic-engineer", "bg2-r1"),
+ startRun("bg2-te", "@bash-gaming/tui-engineer", "bg2-r1"),
+ errorRun("bg2-te", "@bash-gaming/tui-engineer", "bg2-r1"),
+ startRun("bg2-ce", "@bash-gaming/cli-engineer", "bg2-r1"),
+ errorRun("bg2-ce", "@bash-gaming/cli-engineer", "bg2-r1"),
+ resumeFromStop("bg2-r2", "bash-gaming"),
+ completeRun("bg2-r2", "bash-gaming"),
+ resumeFromStop("te2-r1", "@create-expert/test-expert"),
+ completeRun("te2-r1", "@create-expert/test-expert"),
+
+ // te3 completes without delegation
+ completeRun("te3", "@create-expert/test-expert"),
+
+ // build resumes
+ resumeFromStop("build-r1", "@create-expert/build"),
+ completeRun("build-r1", "@create-expert/build"),
+
+ // root completes
+ resumeFromStop("root-r3", "create-expert"),
+ completeRun("root-r3", "create-expert"),
+ ]
+
+ const { treeState } = buildRunTreeFromEvents(events)
+
+ expect(treeState.rootRunId).toBe("root")
+
+ // Root children: plan, dr, build
+ const root = treeState.nodes.get("root")!
+ expect(root.status).toBe("completed")
+ expect(root.childRunIds.sort()).toEqual(["build", "dr", "plan"])
+
+ // design-roles has 4 failed find-skill children
+ const dr = treeState.nodes.get("dr")!
+ expect(dr.childRunIds.length).toBe(4)
+ for (const childId of dr.childRunIds) {
+ const child = treeState.nodes.get(childId)!
+ expect(child.expertKey).toBe("@create-expert/find-skill")
+ expect(child.status).toBe("error")
+ }
+
+ // bg1 has game-designer + 3 failed inline delegates
+ const bg1 = treeState.nodes.get("bg1")!
+ expect(bg1.childRunIds.length).toBe(4) // gd1 + 3 failed
+ expect(treeState.nodes.get("gd1")?.status).toBe("completed")
+ const bg1Errors = bg1.childRunIds
+ .map((id) => treeState.nodes.get(id)!)
+ .filter((n) => n.status === "error")
+ expect(bg1Errors.length).toBe(3)
+
+ // bg2 same structure
+ const bg2 = treeState.nodes.get("bg2")!
+ expect(bg2.childRunIds.length).toBe(4)
+ expect(treeState.nodes.get("gd2")?.status).toBe("completed")
+ const bg2Errors = bg2.childRunIds
+ .map((id) => treeState.nodes.get(id)!)
+ .filter((n) => n.status === "error")
+ expect(bg2Errors.length).toBe(3)
+
+ // Total: 11 real + 4 find-skill + 6 logic/tui/cli = 21
+ expect(treeState.nodes.size).toBe(21)
+ })
+})
diff --git a/packages/tui-components/src/log-viewer/build-run-tree.ts b/packages/tui-components/src/log-viewer/build-run-tree.ts
new file mode 100644
index 00000000..0fab8f5d
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/build-run-tree.ts
@@ -0,0 +1,303 @@
+import type { RunEvent } from "@perstack/core"
+import { parseExpertKey } from "@perstack/core"
+import type {
+ DelegationTreeNode,
+ DelegationTreeState,
+} from "../execution/hooks/use-delegation-tree.js"
+import { createInitialDelegationTreeState } from "../execution/hooks/use-delegation-tree.js"
+
+export type RunStats = { eventCount: number; stepCount: number }
+
+export type RunTreeResult = {
+ treeState: DelegationTreeState
+ runQueries: Map
+ runStats: Map
+}
+
+function parseExpertName(expertKey: string): string {
+ try {
+ return parseExpertKey(expertKey).name
+ } catch {
+ return expertKey
+ }
+}
+
+function createTreeNode(
+ runId: string,
+ expertKey: string,
+ model: string | undefined,
+ parentRunId: string | undefined,
+): DelegationTreeNode {
+ return {
+ runId,
+ expertName: parseExpertName(expertKey),
+ expertKey,
+ status: "running",
+ actionLabel: "",
+ actionFileArg: undefined,
+ contextWindowUsage: 0,
+ model,
+ parentRunId,
+ childRunIds: [],
+ totalTokens: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedInputTokens: 0,
+ }
+}
+
+function addNodeToTree(state: DelegationTreeState, node: DelegationTreeNode) {
+ state.nodes.set(node.runId, node)
+ if (node.parentRunId) {
+ const parent = state.nodes.get(node.parentRunId)
+ if (parent && !parent.childRunIds.includes(node.runId)) {
+ parent.childRunIds.push(node.runId)
+ }
+ } else {
+ state.rootRunId = node.runId
+ }
+}
+
+function accumulateTokens(
+ node: DelegationTreeNode,
+ usage: {
+ totalTokens: number
+ inputTokens: number
+ outputTokens: number
+ cachedInputTokens: number
+ },
+) {
+ node.totalTokens += usage.totalTokens
+ node.inputTokens += usage.inputTokens
+ node.outputTokens += usage.outputTokens
+ node.cachedInputTokens += usage.cachedInputTokens
+}
+
+export function extractQueryFromStartRun(event: RunEvent): string | undefined {
+ if (event.type !== "startRun") return undefined
+ for (const msg of event.inputMessages) {
+ if (msg.type === "userMessage") {
+ for (const part of msg.contents) {
+ if ("text" in part && part.text) {
+ return part.text.replace(/\n/g, " ")
+ }
+ }
+ }
+ }
+ return undefined
+}
+
+/**
+ * Build a delegation tree from a list of RunEvents.
+ *
+ * Parent-child relationships are established via `startRun.delegatedBy.runId`.
+ *
+ * Resume runs (`resumeFromStop`) are merged into the first run of the same
+ * expert that is waiting for delegates. The linkage works as follows:
+ * - `startRun` with `delegatedBy.runId` records child → parent
+ * - When a child completes, its parent runId is marked as "awaiting resume"
+ * - `resumeFromStop` matches the awaiting parent by expertKey
+ */
+export function buildRunTreeFromEvents(events: RunEvent[]): RunTreeResult {
+ const treeState = createInitialDelegationTreeState()
+ const runQueries = new Map()
+ const runStats = new Map()
+
+ // Maps new resume runId → original first runId (for merging)
+ const runIdAliases = new Map()
+
+ // Track parent→child relationships for resume matching
+ // parentRunId → Set of child runIds that were delegated from this parent
+ const parentToChildren = new Map>()
+ // parentRunId → number of children that have completed
+ const parentCompletedChildren = new Map()
+ // Set of runIds whose all children have completed (ready for resume)
+ const awaitingResume = new Set()
+
+ function resolveRunId(runId: string): string {
+ let current = runId
+ const visited = new Set()
+ while (runIdAliases.has(current)) {
+ if (visited.has(current)) break
+ visited.add(current)
+ current = runIdAliases.get(current)!
+ }
+ return current
+ }
+
+ function resolveNode(runId: string): DelegationTreeNode | undefined {
+ return treeState.nodes.get(resolveRunId(runId))
+ }
+
+ function mergeStats(fromRunId: string, toRunId: string) {
+ const fromStats = runStats.get(fromRunId)
+ if (fromStats) {
+ const toStats = runStats.get(toRunId) ?? { eventCount: 0, stepCount: 0 }
+ toStats.eventCount += fromStats.eventCount
+ if (fromStats.stepCount > toStats.stepCount) {
+ toStats.stepCount = fromStats.stepCount
+ }
+ runStats.set(toRunId, toStats)
+ runStats.delete(fromRunId)
+ }
+ }
+
+ function recordStats(runId: string, stepNumber: number) {
+ const resolvedId = resolveRunId(runId)
+ const stats = runStats.get(resolvedId) ?? { eventCount: 0, stepCount: 0 }
+ stats.eventCount++
+ if (stepNumber > stats.stepCount) {
+ stats.stepCount = stepNumber
+ }
+ runStats.set(resolvedId, stats)
+ }
+
+ // When a child run completes, check if all siblings under the same parent
+ // have completed. If so, mark the parent as awaiting resume.
+ function onChildCompleted(childRunId: string) {
+ const childNode = treeState.nodes.get(childRunId)
+ if (!childNode?.parentRunId) return
+
+ const parentRunId = childNode.parentRunId
+ const count = (parentCompletedChildren.get(parentRunId) ?? 0) + 1
+ parentCompletedChildren.set(parentRunId, count)
+
+ const totalChildren = parentToChildren.get(parentRunId)?.size ?? 0
+ if (count >= totalChildren) {
+ awaitingResume.add(parentRunId)
+ }
+ }
+
+ for (const event of events) {
+ recordStats(event.runId, event.stepNumber)
+
+ switch (event.type) {
+ case "startRun": {
+ const parentRawRunId = event.initialCheckpoint.delegatedBy?.runId
+ const parentRunId = parentRawRunId ? resolveRunId(parentRawRunId) : undefined
+ const node = createTreeNode(event.runId, event.expertKey, event.model, parentRunId)
+ node.contextWindowUsage = event.initialCheckpoint.contextWindowUsage ?? 0
+ addNodeToTree(treeState, node)
+
+ // Track parent → child relationship
+ if (parentRunId) {
+ const children = parentToChildren.get(parentRunId) ?? new Set()
+ children.add(event.runId)
+ parentToChildren.set(parentRunId, children)
+ }
+
+ const query = extractQueryFromStartRun(event)
+ if (query) {
+ runQueries.set(event.runId, query)
+ }
+ break
+ }
+
+ case "resumeFromStop": {
+ // Find the original run to merge into.
+ // The resume run has the same expertKey as the original run that delegated.
+ // We look for a run that is awaiting resume with the same expertKey.
+ let originalRunId: string | undefined
+
+ for (const candidateRunId of awaitingResume) {
+ const candidateNode = treeState.nodes.get(candidateRunId)
+ if (candidateNode && candidateNode.expertKey === event.expertKey) {
+ originalRunId = candidateRunId
+ break
+ }
+ }
+
+ // Fallback: find any suspending node with same expertKey
+ if (!originalRunId) {
+ for (const [nodeId, node] of treeState.nodes) {
+ if (node.expertKey === event.expertKey && node.status === "suspending") {
+ originalRunId = nodeId
+ break
+ }
+ }
+ }
+
+ if (originalRunId && originalRunId !== event.runId) {
+ runIdAliases.set(event.runId, originalRunId)
+ awaitingResume.delete(originalRunId)
+ mergeStats(event.runId, originalRunId)
+ }
+
+ const node = resolveNode(event.runId)
+ if (node) {
+ node.status = "running"
+ node.model = event.model
+ if (event.checkpoint.contextWindowUsage !== undefined) {
+ node.contextWindowUsage = event.checkpoint.contextWindowUsage
+ }
+ }
+ break
+ }
+
+ case "stopRunByDelegate": {
+ const node = resolveNode(event.runId)
+ if (node) {
+ node.status = "suspending"
+ if (event.checkpoint.contextWindowUsage !== undefined) {
+ node.contextWindowUsage = event.checkpoint.contextWindowUsage
+ }
+ }
+ break
+ }
+
+ case "callTools": {
+ const node = resolveNode(event.runId)
+ if (node) {
+ accumulateTokens(node, event.usage)
+ }
+ break
+ }
+
+ case "completeRun": {
+ const resolvedId = resolveRunId(event.runId)
+ const node = treeState.nodes.get(resolvedId)
+ if (node) {
+ node.status = "completed"
+ accumulateTokens(node, event.usage)
+ if (event.checkpoint.contextWindowUsage !== undefined) {
+ node.contextWindowUsage = event.checkpoint.contextWindowUsage
+ }
+ }
+ onChildCompleted(resolvedId)
+ break
+ }
+
+ case "stopRunByError": {
+ const resolvedId = resolveRunId(event.runId)
+ const node = treeState.nodes.get(resolvedId)
+ if (node) {
+ node.status = "error"
+ if (event.checkpoint.contextWindowUsage !== undefined) {
+ node.contextWindowUsage = event.checkpoint.contextWindowUsage
+ }
+ }
+ // Error also counts as "done" for resume tracking
+ onChildCompleted(resolvedId)
+ break
+ }
+
+ case "retry": {
+ const node = resolveNode(event.runId)
+ if (node) {
+ accumulateTokens(node, event.usage)
+ }
+ break
+ }
+
+ case "continueToNextStep": {
+ const node = resolveNode(event.runId)
+ if (node && event.nextCheckpoint.contextWindowUsage !== undefined) {
+ node.contextWindowUsage = event.nextCheckpoint.contextWindowUsage
+ }
+ break
+ }
+ }
+ }
+
+ return { treeState, runQueries, runStats }
+}
diff --git a/packages/tui-components/src/log-viewer/components/log-info-content.tsx b/packages/tui-components/src/log-viewer/components/log-info-content.tsx
new file mode 100644
index 00000000..eeab8b6a
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/components/log-info-content.tsx
@@ -0,0 +1,260 @@
+import type { Checkpoint, Job, RunEvent } from "@perstack/core"
+import { Text } from "ink"
+import type React from "react"
+import { colors } from "../../colors.js"
+import { USAGE_INDICATORS } from "../../constants.js"
+import type { DelegationTreeNode } from "../../execution/hooks/use-delegation-tree.js"
+import { formatTokenCount } from "../../execution/hooks/use-delegation-tree.js"
+import type { RunInfo } from "../types.js"
+
+function formatDuration(startedAt: number, finishedAt?: number): string {
+ const end = finishedAt ?? Date.now()
+ const seconds = Math.floor((end - startedAt) / 1000)
+ if (seconds < 60) return `${seconds}s`
+ const minutes = Math.floor(seconds / 60)
+ const remainingSeconds = seconds % 60
+ if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
+ const hours = Math.floor(minutes / 60)
+ const remainingMinutes = minutes % 60
+ return `${hours}h ${remainingMinutes}m`
+}
+
+function formatShortDate(timestamp: number): string {
+ const d = new Date(timestamp)
+ return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
+}
+
+function getUsageIcon(ratio: number): string {
+ const percent = ratio * 100
+ if (percent >= 75) return USAGE_INDICATORS.FULL
+ if (percent >= 50) return USAGE_INDICATORS.HIGH
+ if (percent >= 25) return USAGE_INDICATORS.MEDIUM
+ if (percent >= 5) return USAGE_INDICATORS.LOW
+ return USAGE_INDICATORS.EMPTY
+}
+
+function statusColor(status: string): string {
+ if (status === "completed") return colors.success
+ if (status === "running" || status === "proceeding" || status === "init") return colors.accent
+ return colors.destructive
+}
+
+// --- Job ---
+
+export function JobInfoContent({ job }: { job: Job }): React.ReactNode {
+ const duration = formatDuration(job.startedAt, job.finishedAt)
+ const totalInput = job.usage.inputTokens + (job.usage.cachedInputTokens ?? 0)
+ const cacheRate = totalInput > 0 ? ((job.usage.cachedInputTokens ?? 0) / totalInput) * 100 : 0
+ return (
+ <>
+
+ {job.coordinatorExpertKey}
+ ·
+ {job.status}
+ ·
+ {job.id}
+
+
+ {job.totalSteps} steps · {duration} · Started {formatShortDate(job.startedAt)}
+ {job.finishedAt ? ` · Finished ${formatShortDate(job.finishedAt)}` : ""}
+
+
+ Tokens: In {formatTokenCount(job.usage.inputTokens)} · Out{" "}
+ {formatTokenCount(job.usage.outputTokens)}
+ {(job.usage.cachedInputTokens ?? 0) > 0 ? (
+ <>
+ {" · Cache "}
+ {formatTokenCount(job.usage.cachedInputTokens ?? 0)}/{cacheRate.toFixed(2)}%
+ >
+ ) : null}
+
+ >
+ )
+}
+
+// --- Run ---
+
+type RunInfoContentProps = {
+ job: Job
+ run: RunInfo
+ treeNode?: DelegationTreeNode
+ providerName?: string
+ checkpointCount?: number
+ eventCount?: number
+}
+
+export function RunInfoContent({
+ run,
+ treeNode,
+ providerName,
+ checkpointCount,
+ eventCount,
+}: RunInfoContentProps): React.ReactNode {
+ const parts: string[] = []
+ if (providerName) parts.push(providerName)
+ if (treeNode?.model) parts.push(treeNode.model)
+ if (checkpointCount !== undefined) parts.push(`${checkpointCount} steps`)
+ if (eventCount !== undefined) parts.push(`${eventCount} events`)
+ return (
+ <>
+
+ {run.expertKey ? {run.expertKey} : null}
+ {run.expertKey ? · : null}
+
+ {treeNode ? treeNode.status : "unknown"}
+
+
+ {parts.length > 0 ? (
+
+ {parts.join(" · ")}
+
+ ) : null}
+ {treeNode ? (
+
+ Tokens: In {formatTokenCount(treeNode.inputTokens)} · Out{" "}
+ {formatTokenCount(treeNode.outputTokens)}
+ {treeNode.cachedInputTokens > 0 ? (
+ <>
+ {" · Cache "}
+ {formatTokenCount(treeNode.cachedInputTokens)}/
+ {(
+ (treeNode.cachedInputTokens / (treeNode.inputTokens + treeNode.cachedInputTokens)) *
+ 100
+ ).toFixed(2)}
+ %
+ >
+ ) : null}
+
+ ) : null}
+ >
+ )
+}
+
+// --- Checkpoint ---
+
+export function CheckpointInfoContent({ checkpoint }: { checkpoint: Checkpoint }): React.ReactNode {
+ const cp = checkpoint
+ const usageIcon =
+ cp.contextWindowUsage !== undefined ? ` ${getUsageIcon(cp.contextWindowUsage)}` : ""
+ const usagePercent =
+ cp.contextWindowUsage !== undefined ? ` ${(cp.contextWindowUsage * 100).toFixed(1)}%` : ""
+
+ return (
+ <>
+
+ Step {cp.stepNumber}
+ ·
+ {cp.status}
+ ·
+ {cp.expert.key}
+ ·
+ {cp.id}
+
+
+ {cp.messages.length} msgs
+ {usageIcon ? (
+ <>
+ {" ·"}
+ {usageIcon}
+ {usagePercent}
+ >
+ ) : null}
+ {" · "}Tokens: In {formatTokenCount(cp.usage.inputTokens)} · Out{" "}
+ {formatTokenCount(cp.usage.outputTokens)}
+
+ {cp.error ? (
+
+ Error: {cp.error.name}: {cp.error.message}
+
+ ) : null}
+ {cp.delegateTo && cp.delegateTo.length > 0 ? (
+
+ Delegates: {cp.delegateTo.map((d) => d.expert.key).join(", ")}
+
+ ) : null}
+ >
+ )
+}
+
+// --- Event ---
+
+export function EventInfoContent({ event }: { event: RunEvent }): React.ReactNode {
+ return (
+ <>
+
+ {event.type}
+ ·
+ Step {event.stepNumber}
+ ·
+ {event.expertKey}
+ ·
+ {event.id}
+
+
+ {formatShortDate(event.timestamp)} · run:{event.runId}
+
+
+ >
+ )
+}
+
+function EventSummaryLine({ event }: { event: RunEvent }): React.ReactNode {
+ switch (event.type) {
+ case "callTools":
+ return (
+ <>
+
+ Tools: {event.toolCalls.map((tc) => `${tc.skillName}/${tc.toolName}`).join(", ")}
+
+
+ Tokens: In {formatTokenCount(event.usage.inputTokens)} · Out{" "}
+ {formatTokenCount(event.usage.outputTokens)}
+
+ >
+ )
+ case "completeRun":
+ return (
+ <>
+
+ Result: {event.text}
+
+
+ Tokens: In {formatTokenCount(event.usage.inputTokens)} · Out{" "}
+ {formatTokenCount(event.usage.outputTokens)}
+
+ >
+ )
+ case "stopRunByError":
+ return (
+
+ Error: {event.error.name}: {event.error.message}
+
+ )
+ case "retry":
+ return (
+
+ Reason: {event.reason}
+
+ )
+ case "startRun":
+ return (
+
+ Model: {event.model} · {event.inputMessages.length} input messages
+
+ )
+ case "stopRunByDelegate":
+ return event.checkpoint.delegateTo ? (
+
+ Delegates: {event.checkpoint.delegateTo.map((d) => d.expert.key).join(", ")}
+
+ ) : null
+ case "startGeneration":
+ return (
+
+ {event.messages.length} messages
+
+ )
+ default:
+ return null
+ }
+}
diff --git a/packages/tui-components/src/log-viewer/index.ts b/packages/tui-components/src/log-viewer/index.ts
new file mode 100644
index 00000000..06d9a61f
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/index.ts
@@ -0,0 +1,2 @@
+export { renderLogViewer } from "./render.js"
+export type { JobListItem, LogViewerParams, LogViewerScreen, RunInfo } from "./types.js"
diff --git a/packages/tui-components/src/log-viewer/render.tsx b/packages/tui-components/src/log-viewer/render.tsx
new file mode 100644
index 00000000..a62151c3
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/render.tsx
@@ -0,0 +1,14 @@
+import { render } from "ink"
+import { LogViewerApp } from "./app.js"
+import type { LogViewerParams } from "./types.js"
+
+export async function renderLogViewer(params: LogViewerParams): Promise {
+ const { waitUntilExit } = render(
+ ,
+ )
+ await waitUntilExit()
+}
diff --git a/packages/tui-components/src/log-viewer/screens/checkpoint-detail.tsx b/packages/tui-components/src/log-viewer/screens/checkpoint-detail.tsx
new file mode 100644
index 00000000..dbd07a1b
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/screens/checkpoint-detail.tsx
@@ -0,0 +1,129 @@
+import type { Checkpoint, Job } from "@perstack/core"
+import { Box, Text, useInput } from "ink"
+import { colors } from "../../colors.js"
+import { truncateText } from "../../helpers.js"
+import type { RunInfo } from "../types.js"
+
+type CheckpointDetailScreenProps = {
+ job: Job
+ run: RunInfo
+ checkpoint: Checkpoint
+ onBack: () => void
+}
+
+export const CheckpointDetailScreen = ({ checkpoint, onBack }: CheckpointDetailScreenProps) => {
+ useInput((char, key) => {
+ if (key.escape || char === "b") {
+ onBack()
+ }
+ })
+
+ const cp = checkpoint
+
+ return (
+
+
+
+ Checkpoint Detail
+
+ b:Back q:Quit
+
+
+
+ ID:
+ {cp.id}
+
+
+ Status:
+
+ {cp.status}
+
+
+
+ Step:
+ {cp.stepNumber}
+
+
+ Expert:
+
+ {cp.expert.name} ({cp.expert.key}@{cp.expert.version})
+
+
+
+ Messages:
+ {cp.messages.length}
+
+
+ Input Tokens:
+ {cp.usage.inputTokens.toLocaleString()}
+
+
+ Output Tokens:
+ {cp.usage.outputTokens.toLocaleString()}
+
+ {cp.contextWindow && (
+
+ Context Window:
+ {cp.contextWindow.toLocaleString()}
+
+ )}
+ {cp.contextWindowUsage !== undefined && (
+
+ Context Usage:
+ {(cp.contextWindowUsage * 100).toFixed(1)}%
+
+ )}
+ {cp.error && (
+
+
+ Error:
+
+
+ {cp.error.name}: {truncateText(cp.error.message, 100)}
+
+ {cp.error.statusCode && (
+ Status Code: {cp.error.statusCode}
+ )}
+ Retryable: {cp.error.isRetryable ? "yes" : "no"}
+
+ )}
+ {cp.delegateTo && cp.delegateTo.length > 0 && (
+
+
+ Delegating to:
+
+ {cp.delegateTo.map((d) => (
+
+ - {d.expert.key}: {truncateText(d.query, 60)}
+
+ ))}
+
+ )}
+ {cp.delegatedBy && (
+
+
+ Delegated by:
+
+
+ {cp.delegatedBy.expert.key} via {cp.delegatedBy.toolName}
+
+
+ )}
+ {cp.pendingToolCalls && cp.pendingToolCalls.length > 0 && (
+
+ Pending Tool Calls:
+ {cp.pendingToolCalls.length}
+
+ )}
+
+
+ )
+}
diff --git a/packages/tui-components/src/log-viewer/screens/checkpoint-list.tsx b/packages/tui-components/src/log-viewer/screens/checkpoint-list.tsx
new file mode 100644
index 00000000..bdea0190
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/screens/checkpoint-list.tsx
@@ -0,0 +1,147 @@
+import type { Checkpoint, Job } from "@perstack/core"
+import { Box, Text, useInput } from "ink"
+import { useEffect, useMemo } from "react"
+import { colors } from "../../colors.js"
+import { USAGE_INDICATORS } from "../../constants.js"
+import { formatTokenCount } from "../../execution/hooks/use-delegation-tree.js"
+import { useListNavigation } from "../../hooks/use-list-navigation.js"
+import type { RunInfo } from "../types.js"
+
+type CheckpointListScreenProps = {
+ job: Job
+ run: RunInfo
+ checkpoints: Checkpoint[]
+ onSelectCheckpoint: (checkpoint: Checkpoint) => void
+ onBack: () => void
+ onSelectedChange: (checkpoint: Checkpoint | undefined) => void
+}
+
+const MAX_VISIBLE = 10
+
+function statusColor(status: string): string {
+ if (status === "completed") return colors.success
+ if (status === "proceeding" || status === "init") return colors.accent
+ return colors.destructive
+}
+
+function getUsageIcon(ratio: number): string {
+ const percent = ratio * 100
+ if (percent >= 75) return USAGE_INDICATORS.FULL
+ if (percent >= 50) return USAGE_INDICATORS.HIGH
+ if (percent >= 25) return USAGE_INDICATORS.MEDIUM
+ if (percent >= 5) return USAGE_INDICATORS.LOW
+ return USAGE_INDICATORS.EMPTY
+}
+
+export const CheckpointListScreen = ({
+ run,
+ checkpoints,
+ onSelectCheckpoint,
+ onBack,
+ onSelectedChange,
+}: CheckpointListScreenProps) => {
+ const { selectedIndex, handleNavigation } = useListNavigation({
+ items: checkpoints,
+ onSelect: onSelectCheckpoint,
+ onBack,
+ })
+
+ useInput((char, key) => {
+ handleNavigation(char, key)
+ })
+
+ const selectedCp = checkpoints[selectedIndex]
+
+ useEffect(() => {
+ onSelectedChange(selectedCp)
+ }, [selectedCp, onSelectedChange])
+
+ const { scrollOffset, displayItems } = useMemo(() => {
+ const offset = Math.max(
+ 0,
+ Math.min(selectedIndex - MAX_VISIBLE + 1, checkpoints.length - MAX_VISIBLE),
+ )
+ return {
+ scrollOffset: offset,
+ displayItems: checkpoints.slice(offset, offset + MAX_VISIBLE),
+ }
+ }, [checkpoints, selectedIndex])
+
+ const shortRunId = run.runId.length > 12 ? `${run.runId.slice(0, 12)}...` : run.runId
+
+ return (
+
+
+
+ Checkpoints
+
+ ({shortRunId})
+ {checkpoints.length > MAX_VISIBLE && (
+
+ {" "}
+ [{selectedIndex + 1}/{checkpoints.length}]
+
+ )}
+ Enter:Select b:Back q:Quit
+
+ {scrollOffset > 0 && ...}
+ {displayItems.length === 0 ? (
+ No checkpoints found
+ ) : (
+ displayItems.map((cp, i) => {
+ const actualIndex = scrollOffset + i
+ const isSelected = actualIndex === selectedIndex
+ const hasUsage = cp.contextWindowUsage !== undefined
+ const hasDelegates = cp.delegateTo && cp.delegateTo.length > 0
+ const hasError = !!cp.error
+ return (
+
+
+
+ {isSelected ? " \u25B8 " : " "}
+
+
+ Step {cp.stepNumber}
+ ·
+ {cp.status}
+ ·
+ {cp.expert.key}
+ ·
+ {cp.messages.length} msgs
+ {hasUsage ? (
+ <>
+ ·
+
+ {getUsageIcon(cp.contextWindowUsage!)}{" "}
+ {(cp.contextWindowUsage! * 100).toFixed(0)}%
+
+ >
+ ) : null}
+
+
+
+
+ Tokens: In {formatTokenCount(cp.usage.inputTokens)} · Out{" "}
+ {formatTokenCount(cp.usage.outputTokens)}
+ {hasDelegates ? (
+ <>
+ {" · Delegates: "}
+ {cp.delegateTo!.map((d) => d.expert.key).join(", ")}
+ >
+ ) : null}
+ {hasError ? (
+
+ {" · "}
+ {cp.error!.name}
+
+ ) : null}
+
+
+
+ )
+ })
+ )}
+ {scrollOffset + MAX_VISIBLE < checkpoints.length && ...}
+
+ )
+}
diff --git a/packages/tui-components/src/log-viewer/screens/event-detail.tsx b/packages/tui-components/src/log-viewer/screens/event-detail.tsx
new file mode 100644
index 00000000..2cc22504
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/screens/event-detail.tsx
@@ -0,0 +1,191 @@
+import type { Job, RunEvent } from "@perstack/core"
+import { Box, Text, useInput } from "ink"
+import { colors } from "../../colors.js"
+import { formatTimestamp, truncateText } from "../../helpers.js"
+import type { RunInfo } from "../types.js"
+
+type EventDetailScreenProps = {
+ job: Job
+ run: RunInfo
+ event: RunEvent
+ onBack: () => void
+}
+
+export const EventDetailScreen = ({ event, onBack }: EventDetailScreenProps) => {
+ useInput((char, key) => {
+ if (key.escape || char === "b") {
+ onBack()
+ }
+ })
+
+ return (
+
+
+
+ Event Detail
+
+ b:Back q:Quit
+
+
+
+ ID:
+ {event.id}
+
+
+ Type:
+ {event.type}
+
+
+ Step:
+ {event.stepNumber}
+
+
+ Expert:
+ {event.expertKey}
+
+
+ Run:
+ {event.runId}
+
+
+ Job:
+ {event.jobId}
+
+
+ Timestamp:
+ {formatTimestamp(event.timestamp)}
+
+
+
+ Payload:
+
+
+ {renderEventPayload(event)}
+
+
+ )
+}
+
+function renderEventPayload(event: RunEvent) {
+ switch (event.type) {
+ case "startRun":
+ return (
+
+
+ Model:
+ {event.model}
+
+
+ Input Messages:
+ {event.inputMessages.length}
+
+
+ Checkpoint Status:
+ {event.initialCheckpoint.status}
+
+
+ )
+ case "callTools":
+ return (
+
+
+ Tool Calls:
+ {event.toolCalls.length}
+
+ {event.toolCalls.map((tc) => (
+
+
+ {tc.skillName}/{tc.toolName} ({tc.id})
+
+ {truncateText(JSON.stringify(tc.args), 120)}
+
+ ))}
+
+ Input Tokens:
+ {event.usage.inputTokens.toLocaleString()}
+
+
+ Output Tokens:
+ {event.usage.outputTokens.toLocaleString()}
+
+
+ )
+ case "completeRun":
+ return (
+
+
+ Result:
+ {truncateText(event.text, 200)}
+
+
+ Input Tokens:
+ {event.usage.inputTokens.toLocaleString()}
+
+
+ Output Tokens:
+ {event.usage.outputTokens.toLocaleString()}
+
+
+ )
+ case "stopRunByError":
+ return (
+
+
+ Error:
+
+ {event.error.name}: {event.error.message}
+
+
+ {event.error.statusCode && (
+
+ Status Code:
+ {event.error.statusCode}
+
+ )}
+
+ Retryable:
+ {event.error.isRetryable ? "yes" : "no"}
+
+
+ )
+ case "retry":
+ return (
+
+
+ Reason:
+ {event.reason}
+
+
+ )
+ case "startGeneration":
+ return (
+
+
+ Messages:
+ {event.messages.length}
+
+
+ )
+ case "stopRunByDelegate":
+ return (
+
+
+ Step:
+ {event.step.stepNumber}
+
+ {event.checkpoint.delegateTo && (
+
+ Delegates:
+ {event.checkpoint.delegateTo.map((d) => (
+
+ - {d.expert.key}: {truncateText(d.query, 60)}
+
+ ))}
+
+ )}
+
+ )
+ default:
+ return {truncateText(JSON.stringify(event, null, 2), 500)}
+ }
+}
diff --git a/packages/tui-components/src/log-viewer/screens/event-list.tsx b/packages/tui-components/src/log-viewer/screens/event-list.tsx
new file mode 100644
index 00000000..921328ee
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/screens/event-list.tsx
@@ -0,0 +1,170 @@
+import type { RunEvent } from "@perstack/core"
+import { Box, Text, useInput } from "ink"
+import { useEffect, useMemo } from "react"
+import { colors } from "../../colors.js"
+import { formatTokenCount } from "../../execution/hooks/use-delegation-tree.js"
+import { useListNavigation } from "../../hooks/use-list-navigation.js"
+import type { RunInfo } from "../types.js"
+
+type EventListScreenProps = {
+ run: RunInfo
+ events: RunEvent[]
+ onSelectEvent: (event: RunEvent) => void
+ onBack: () => void
+ onSelectedChange: (event: RunEvent | undefined) => void
+}
+
+const MAX_VISIBLE = 10
+
+function formatTime(timestamp: number): string {
+ const d = new Date(timestamp)
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`
+}
+
+function eventTypeColor(type: string): string {
+ switch (type) {
+ case "startRun":
+ return colors.accent
+ case "completeRun":
+ return colors.success
+ case "stopRunByError":
+ return colors.destructive
+ case "stopRunByDelegate":
+ case "stopRunByInteractiveTool":
+ return colors.warn
+ case "callTools":
+ return "magenta"
+ case "retry":
+ return colors.destructive
+ default:
+ return colors.muted
+ }
+}
+
+function getEventSummary(event: RunEvent): string | undefined {
+ switch (event.type) {
+ case "callTools":
+ return `Tools: ${event.toolCalls.map((tc) => `${tc.skillName}/${tc.toolName}`).join(", ")}`
+ case "completeRun":
+ return `Result: ${event.text}`
+ case "stopRunByError":
+ return `${event.error.name}: ${event.error.message}`
+ case "retry":
+ return `Reason: ${event.reason}`
+ case "startRun":
+ return `Model: ${event.model}`
+ case "stopRunByDelegate":
+ return event.checkpoint.delegateTo
+ ? `Delegates: ${event.checkpoint.delegateTo.map((d) => d.expert.key).join(", ")}`
+ : undefined
+ default:
+ return undefined
+ }
+}
+
+export const EventListScreen = ({
+ run,
+ events,
+ onSelectEvent,
+ onBack,
+ onSelectedChange,
+}: EventListScreenProps) => {
+ const { selectedIndex, handleNavigation } = useListNavigation({
+ items: events,
+ onSelect: onSelectEvent,
+ onBack,
+ })
+
+ useInput((char, key) => {
+ handleNavigation(char, key)
+ })
+
+ const selectedEvent = events[selectedIndex]
+
+ useEffect(() => {
+ onSelectedChange(selectedEvent)
+ }, [selectedEvent, onSelectedChange])
+
+ const { scrollOffset, displayItems } = useMemo(() => {
+ const offset = Math.max(
+ 0,
+ Math.min(selectedIndex - MAX_VISIBLE + 1, events.length - MAX_VISIBLE),
+ )
+ return {
+ scrollOffset: offset,
+ displayItems: events.slice(offset, offset + MAX_VISIBLE),
+ }
+ }, [events, selectedIndex])
+
+ const shortRunId = run.runId.length > 12 ? `${run.runId.slice(0, 12)}...` : run.runId
+
+ return (
+
+
+
+ Events
+
+ ({shortRunId})
+ {events.length > MAX_VISIBLE && (
+
+ {" "}
+ [{selectedIndex + 1}/{events.length}]
+
+ )}
+ Enter:Select b:Back q:Quit
+
+ {scrollOffset > 0 && ...}
+ {displayItems.length === 0 ? (
+ No events found
+ ) : (
+ displayItems.map((ev, i) => {
+ const actualIndex = scrollOffset + i
+ const isSelected = actualIndex === selectedIndex
+ const summary = getEventSummary(ev)
+ const hasUsage = "usage" in ev && ev.usage
+ return (
+
+
+
+ {isSelected ? " \u25B8 " : " "}
+
+
+ Step {ev.stepNumber}
+ ·
+ {ev.type}
+ ·
+ {ev.expertKey}
+ {hasUsage ? (
+ <>
+ ·
+
+ In{" "}
+ {formatTokenCount(
+ (ev as { usage: { inputTokens: number } }).usage.inputTokens,
+ )}{" "}
+ · Out{" "}
+ {formatTokenCount(
+ (ev as { usage: { outputTokens: number } }).usage.outputTokens,
+ )}
+
+ >
+ ) : null}
+ {" "}
+ {formatTime(ev.timestamp)}
+
+
+ {summary ? (
+
+
+ {summary}
+
+
+ ) : null}
+
+ )
+ })
+ )}
+ {scrollOffset + MAX_VISIBLE < events.length && ...}
+
+ )
+}
diff --git a/packages/tui-components/src/log-viewer/screens/job-detail.tsx b/packages/tui-components/src/log-viewer/screens/job-detail.tsx
new file mode 100644
index 00000000..ada14439
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/screens/job-detail.tsx
@@ -0,0 +1,89 @@
+import type { Job } from "@perstack/core"
+import { Box, Text, useInput } from "ink"
+import { colors } from "../../colors.js"
+import { formatTimestamp } from "../../helpers.js"
+
+type JobDetailScreenProps = {
+ job: Job
+ onBack: () => void
+ onViewRuns: (job: Job) => void
+}
+
+export const JobDetailScreen = ({ job, onBack, onViewRuns }: JobDetailScreenProps) => {
+ useInput((char, key) => {
+ if (key.escape || char === "b") {
+ onBack()
+ return
+ }
+ if (key.return || char === "r") {
+ onViewRuns(job)
+ }
+ })
+
+ const statusColor =
+ job.status === "completed"
+ ? colors.success
+ : job.status === "running"
+ ? colors.accent
+ : colors.destructive
+
+ const duration =
+ job.finishedAt && job.startedAt
+ ? `${((job.finishedAt - job.startedAt) / 1000).toFixed(1)}s`
+ : "running..."
+
+ return (
+
+
+
+ Job Detail
+
+ Enter/r:View Runs b:Back q:Quit
+
+
+
+ ID:
+ {job.id}
+
+
+ Status:
+ {job.status}
+
+
+ Expert:
+ {job.coordinatorExpertKey}
+
+
+ Version:
+ {job.runtimeVersion}
+
+
+ Steps:
+ {job.totalSteps}
+
+
+ Duration:
+ {duration}
+
+
+ Started:
+ {formatTimestamp(job.startedAt)}
+
+ {job.finishedAt && (
+
+ Finished:
+ {formatTimestamp(job.finishedAt)}
+
+ )}
+
+ Input Tokens:
+ {job.usage.inputTokens.toLocaleString()}
+
+
+ Output Tokens:
+ {job.usage.outputTokens.toLocaleString()}
+
+
+
+ )
+}
diff --git a/packages/tui-components/src/log-viewer/screens/job-list.tsx b/packages/tui-components/src/log-viewer/screens/job-list.tsx
new file mode 100644
index 00000000..8940f3e1
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/screens/job-list.tsx
@@ -0,0 +1,137 @@
+import type { Job } from "@perstack/core"
+import { Box, Text, useInput } from "ink"
+import { useEffect, useMemo } from "react"
+import { colors } from "../../colors.js"
+import { formatTokenCount } from "../../execution/hooks/use-delegation-tree.js"
+import { useListNavigation } from "../../hooks/use-list-navigation.js"
+import type { JobListItem } from "../types.js"
+
+type JobListScreenProps = {
+ items: JobListItem[]
+ onSelectJob: (job: Job) => void
+ onViewJobDetail: (job: Job) => void
+ onSelectedChange: (job: Job | undefined) => void
+}
+
+const MAX_VISIBLE = 10
+
+function formatDuration(startedAt: number, finishedAt?: number): string {
+ const end = finishedAt ?? Date.now()
+ const seconds = Math.floor((end - startedAt) / 1000)
+ if (seconds < 60) return `${seconds}s`
+ const minutes = Math.floor(seconds / 60)
+ const remainingSeconds = seconds % 60
+ if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
+ const hours = Math.floor(minutes / 60)
+ const remainingMinutes = minutes % 60
+ return `${hours}h ${remainingMinutes}m`
+}
+
+function formatShortDate(timestamp: number): string {
+ const d = new Date(timestamp)
+ return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
+}
+
+function statusColor(status: string): string {
+ if (status === "completed") return colors.success
+ if (status === "running" || status === "proceeding" || status === "init") return colors.accent
+ return colors.destructive
+}
+
+export const JobListScreen = ({
+ items,
+ onSelectJob,
+ onViewJobDetail,
+ onSelectedChange,
+}: JobListScreenProps) => {
+ const { selectedIndex, handleNavigation } = useListNavigation({
+ items,
+ onSelect: (item) => onSelectJob(item.job),
+ })
+
+ useInput((char, key) => {
+ if (char === "d" && items[selectedIndex]) {
+ onViewJobDetail(items[selectedIndex].job)
+ return
+ }
+ handleNavigation(char, key)
+ })
+
+ const selectedItem = items[selectedIndex]
+
+ useEffect(() => {
+ onSelectedChange(selectedItem?.job)
+ }, [selectedItem, onSelectedChange])
+
+ const { scrollOffset, displayItems } = useMemo(() => {
+ const offset = Math.max(
+ 0,
+ Math.min(selectedIndex - MAX_VISIBLE + 1, items.length - MAX_VISIBLE),
+ )
+ return {
+ scrollOffset: offset,
+ displayItems: items.slice(offset, offset + MAX_VISIBLE),
+ }
+ }, [items, selectedIndex])
+
+ return (
+
+
+
+ Jobs
+
+ {items.length > MAX_VISIBLE && (
+
+ {" "}
+ ({selectedIndex + 1}/{items.length})
+
+ )}
+ Enter:Select d:Detail q:Quit
+
+ {scrollOffset > 0 && ...}
+ {displayItems.length === 0 ? (
+ No jobs found
+ ) : (
+ displayItems.map((item, i) => {
+ const actualIndex = scrollOffset + i
+ const isSelected = actualIndex === selectedIndex
+ const { job, query } = item
+ return (
+
+
+
+ {isSelected ? " \u25B8 " : " "}
+
+
+ {job.coordinatorExpertKey}
+ ·
+ {job.status}
+ ·
+ {job.totalSteps} steps
+ ·
+ {formatDuration(job.startedAt, job.finishedAt)}
+ {" "}
+ {formatShortDate(job.startedAt)}
+
+
+ {query ? (
+
+
+ {">"} {query}
+
+
+ ) : null}
+
+
+ Tokens: In {formatTokenCount(job.usage.inputTokens)} · Out{" "}
+ {formatTokenCount(job.usage.outputTokens)}
+
+
+
+ )
+ })
+ )}
+ {scrollOffset + MAX_VISIBLE < items.length && ...}
+
+ )
+}
diff --git a/packages/tui-components/src/log-viewer/screens/run-list.tsx b/packages/tui-components/src/log-viewer/screens/run-list.tsx
new file mode 100644
index 00000000..02a8bc2f
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/screens/run-list.tsx
@@ -0,0 +1,116 @@
+import type { Job } from "@perstack/core"
+import { Box, Text, useInput } from "ink"
+import { useEffect, useMemo } from "react"
+import { colors } from "../../colors.js"
+import type {
+ DelegationTreeNode,
+ DelegationTreeState,
+} from "../../execution/hooks/use-delegation-tree.js"
+import { flattenTreeAll } from "../../execution/hooks/use-delegation-tree.js"
+import { useListNavigation } from "../../hooks/use-list-navigation.js"
+
+type RunListScreenProps = {
+ job: Job
+ treeState: DelegationTreeState
+ runQueries: Map
+ onSelectRun: (node: DelegationTreeNode) => void
+ onBack: () => void
+ onSelectedChange: (node: DelegationTreeNode | undefined) => void
+}
+
+const MAX_VISIBLE = 50
+
+function statusColor(status: string): string | undefined {
+ switch (status) {
+ case "completed":
+ return colors.success
+ case "error":
+ return colors.destructive
+ case "running":
+ return colors.accent
+ default:
+ return undefined
+ }
+}
+
+export const RunListScreen = ({
+ job,
+ treeState,
+ runQueries,
+ onSelectRun,
+ onBack,
+ onSelectedChange,
+}: RunListScreenProps) => {
+ const flatNodes = useMemo(() => flattenTreeAll(treeState), [treeState])
+
+ const { selectedIndex, handleNavigation } = useListNavigation({
+ items: flatNodes,
+ onSelect: (flatNode) => onSelectRun(flatNode.node),
+ onBack,
+ })
+
+ useInput((char, key) => {
+ handleNavigation(char, key)
+ })
+
+ const selectedNode = flatNodes[selectedIndex]?.node
+
+ useEffect(() => {
+ onSelectedChange(selectedNode)
+ }, [selectedNode, onSelectedChange])
+
+ const { scrollOffset, displayItems } = useMemo(() => {
+ const offset = Math.max(
+ 0,
+ Math.min(selectedIndex - MAX_VISIBLE + 1, flatNodes.length - MAX_VISIBLE),
+ )
+ return {
+ scrollOffset: offset,
+ displayItems: flatNodes.slice(offset, offset + MAX_VISIBLE),
+ }
+ }, [flatNodes, selectedIndex])
+
+ return (
+
+
+
+ Runs
+
+ ({job.coordinatorExpertKey})
+ {flatNodes.length > MAX_VISIBLE && (
+
+ {" "}
+ [{selectedIndex + 1}/{flatNodes.length}]
+
+ )}
+ Enter:Select b:Back q:Quit
+
+ {scrollOffset > 0 && ...}
+ {displayItems.length === 0 ? (
+ No runs found
+ ) : (
+ displayItems.map((flatNode, i) => {
+ const actualIndex = scrollOffset + i
+ const isSelected = actualIndex === selectedIndex
+ const { node } = flatNode
+ const nameColor = statusColor(node.status)
+ const query = runQueries.get(node.runId)
+ const indent = " ".repeat(flatNode.depth * 2)
+ return (
+
+
+ {isSelected ? " > " : " "}
+
+ {indent}
+
+ {node.expertName}
+
+ {query ? {query} : null}
+
+ )
+ })
+ )}
+ {scrollOffset + MAX_VISIBLE < flatNodes.length && ...}
+
+ )
+}
diff --git a/packages/tui-components/src/log-viewer/types.ts b/packages/tui-components/src/log-viewer/types.ts
new file mode 100644
index 00000000..0390008d
--- /dev/null
+++ b/packages/tui-components/src/log-viewer/types.ts
@@ -0,0 +1,36 @@
+import type { Checkpoint, Job, RunEvent } from "@perstack/core"
+import type { LogDataFetcher } from "@perstack/log"
+import type { DelegationTreeState } from "../execution/hooks/use-delegation-tree.js"
+
+export type LogViewerParams = {
+ fetcher: LogDataFetcher
+ initialJobId?: string
+ initialRunId?: string
+}
+
+export type LogViewerScreen =
+ | { type: "jobList" }
+ | { type: "jobDetail"; job: Job }
+ | {
+ type: "runList"
+ job: Job
+ treeState: DelegationTreeState
+ runQueries: Map
+ runStats: Map
+ }
+ | { type: "checkpointList"; job: Job; run: RunInfo; checkpoints: Checkpoint[] }
+ | { type: "checkpointDetail"; job: Job; run: RunInfo; checkpoint: Checkpoint }
+ | { type: "eventList"; job: Job; run: RunInfo; events: RunEvent[] }
+ | { type: "eventDetail"; job: Job; run: RunInfo; event: RunEvent }
+
+export type RunInfo = {
+ jobId: string
+ runId: string
+ expertKey?: string
+ startedAt?: number
+}
+
+export type JobListItem = {
+ job: Job
+ query?: string
+}