From 43b86e4370e1e141aa2cdccdd91e2845169bfbcd Mon Sep 17 00:00:00 2001 From: Chrok Date: Sun, 25 Oct 2026 18:16:06 +0800 Subject: [PATCH 1/3] Show lineage heads and the full execution history as two views of one graph in the Pi panel (#94) --- ROADMAP.md | 2 +- integrations/pi/README.md | 44 +++- integrations/pi/command.ts | 125 +++++++--- integrations/pi/display.ts | 286 ++++++++++++++++++----- integrations/pi/test/graph-views.test.ts | 189 +++++++++++++++ integrations/pi/test/render.test.ts | 8 +- 6 files changed, 554 insertions(+), 100 deletions(-) create mode 100644 integrations/pi/test/graph-views.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 85a64b0..faf48f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,7 +47,7 @@ pauses, reminders) instead of hard limits that fail the job. Tracking issue: - [x] Replace the schedule/execution split with an execution-only graph of lineages, where a started node is changed by replacing it ([#113](https://github.com/Epsirom/braid/issues/113)). - [ ] Accept unconditional, nested, and overlapping cycles ([#114](https://github.com/Epsirom/braid/issues/114)). -- [ ] Show lineage heads and the full execution history in one Pi graph ([#94](https://github.com/Epsirom/braid/issues/94)). +- [x] Show lineage heads and the full execution history in one Pi graph ([#94](https://github.com/Epsirom/braid/issues/94)). - [ ] Persist run state and recover runs after a restart ([#115](https://github.com/Epsirom/braid/issues/115)). - [ ] Keep memory and disk bounded in long-running runs ([#116](https://github.com/Epsirom/braid/issues/116)). - [ ] Consume outputs from runs that never finish ([#117](https://github.com/Epsirom/braid/issues/117)). diff --git a/integrations/pi/README.md b/integrations/pi/README.md index 7d223b9..8ca66bb 100644 --- a/integrations/pi/README.md +++ b/integrations/pi/README.md @@ -410,11 +410,45 @@ dotted, while the closing retry edge stays solid. For example, `/braid:review` s `scope → check` handoff dotted and `check → scope` (`retry`) solid. All edges and choice labels remain visible; runtime dependencies and handoffs are unchanged. -Press `v` to select a node: the arrow keys move a `◆` marker through the -flowchart (starting at a running node), and the view pans horizontally and -scrolls vertically to keep the selected node in sight. If the flowchart cannot be -drawn, a node list replaces it while selecting. Enter opens that node's latest -invocation as a live session. The session is drawn with Pi's own chat +### Heads and history views + +The panel shows the job's one graph in two views. Press `g` to switch between +them; the panel title (`Braid · heads view` or `Braid · history view`) and the +footer show the active one, and the panel keeps your choice while it is open, +across jobs and sessions. + +- **Heads view** (default): one box per node, showing its latest execution (the + lineage head), with edges between nodes, so a cycle is drawn as you would draw + it. A box shows the head's execution ID once the node has rerun (`review@3`), + its status, the reason a pending head waits (`waits predecessors`, `stale`, + `hold`, `run paused`, or `slot`), what a running head is doing (`model` or + `tool`, `· hold`, or paused), the node's number of runs, and the tokens all its + executions used. +- **History view**: one box per execution, oldest first, in one flat graph: + every round of a cycle appears below the round before it, labelled by its + execution ID. Solid edges are the inputs an execution ran with. Dotted edges + show the input a pending execution will use or the upstream result a skipped + one saw, and a dotted `replaces` edge links a replaced execution to its + replacement. Skipped executions show `⊘` and the reason, pending ones `○`. + Replaced executions stay and show `superseded → `, and executions + of removed nodes show `removed`. + +So long runs stay readable, the history view shows the last two generations of +each node and folds older finished executions into one `≡` box per node +(`≡ review@1–3`); pending, running, and held executions are never folded. Press +`e` to show every generation, and `e` again to fold them. Either way at most 80 +boxes are drawn; the oldest finished executions fold first. + +Press `v` to select a node in the heads view, or an execution in the history +view: the arrow keys move a `◆` marker through the flowchart (starting at a +running node in the heads view, and at a running or the newest execution in the +history view), and the view pans horizontally and scrolls vertically to keep +the selection in sight. If the flowchart cannot be drawn, a list replaces it +while selecting. Pressing `g` while selecting keeps the same node selected: its +head in the history view. In the heads view, Enter opens that node's latest +invocation as a live session; in the history view, it opens that exact +execution, and `p`, `h`, and `x` act on it. Escape returns to the same node or +execution. The session is drawn with Pi's own chat components, as in the main conversation: the worker's prompt, streamed assistant text and thinking, and tool cards whose arguments and output update while they run. The node summary—status, elapsed time, usage, context, and live diff --git a/integrations/pi/command.ts b/integrations/pi/command.ts index 7b9cbb5..bd1a7c9 100644 --- a/integrations/pi/command.ts +++ b/integrations/pi/command.ts @@ -13,9 +13,10 @@ import { type TuiMouseEventResult, } from "@earendil-works/pi-tui"; import { BraidJobs, type JobSnapshot } from "./jobs.js"; -import { renderGraphResult, renderNodeDetails, renderSessionHeader, SELECTED_NODE_MARK } from "./display.js"; +import { historyLayout, renderGraphResult, renderNodeDetails, renderSessionHeader, SELECTED_NODE_MARK, type GraphView } from "./display.js"; import { NodeSessionView } from "./session-view.js"; import type { AssistantModel } from "./assistant.js"; +import type { NodeExecution } from "@chrok/braid"; // Match the overlay height exactly so Pi never clips the footer or bottom border. const panelHeight = (rows: number): number => @@ -35,13 +36,21 @@ export type NodeFocus = { nodeId: string } | { executionId: string }; /** * A live, scrollable panel. Esc steps back one level: session → node selection → * graph → close. Closing the panel leaves jobs running. Tab toggles an assistant - * that answers questions about the job and can message running executions. + * that answers questions about the job and can message running executions. g + * switches between lineage heads and the execution history of the same graph. */ export class BraidPanel implements Component { private selected: string | undefined; private focus: NodeFocus | undefined; - /** Node highlighted in selection mode (`v`); undefined in the plain graph view. */ + /** + * Highlighted in selection mode (`v`): a node ID in the heads view, an + * execution ID in the history view; undefined in the plain graph view. + */ private choosing: string | undefined; + /** Kept for the life of the panel, across jobs and sessions. */ + private view: GraphView = "heads"; + /** The history view expands every generation instead of the most recent ones. */ + private allHistory = false; /** Keep the newest transcript lines in view until the user scrolls up. */ private follow = true; /** The selection last scrolled into view, so manual scrolling is not overridden. */ @@ -141,7 +150,7 @@ export class BraidPanel implements Component { : "muted"; const focusLabel = this.focus ? "nodeId" in this.focus ? `node ${plain(this.focus.nodeId)}` : `execution ${plain(this.focus.executionId)}` - : this.choosing !== undefined ? "select a node" : ""; + : this.choosing !== undefined ? this.view === "history" ? "select an execution" : "select a node" : ""; const heading = current ? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}${focusLabel ? ` · ${focusLabel}` : ""}` : this.theme.fg("muted", "No background jobs"); @@ -154,14 +163,15 @@ export class BraidPanel implements Component { : ["Ask the agent to run a Braid graph."]), ]; return [ - rule("╭", "╮", "Braid"), + rule("╭", "╮", `Braid · ${this.view} view`), ...compact.slice(0, rows - 3).map(row), row("Esc close"), rule("╰", "╯"), ]; } + const title = `Braid · ${this.view} view`; const header = [ - rule("╭", "╮", "Braid"), + rule("╭", "╮", title), row(heading), row( current ? plain(current.goal) : "Ask the agent to run a Braid graph.", @@ -177,7 +187,7 @@ export class BraidPanel implements Component { rule("├", "┤"), ]; if (current && this.choosing !== undefined && !this.nodeIds(current).includes(this.choosing)) { - this.choosing = this.nodeIds(current)[0]; + this.choosing = this.view === "history" ? this.nodeIds(current).at(-1) : this.nodeIds(current)[0]; } if (!current) this.assistantOpen = false; const available = rows - header.length - 4; @@ -196,18 +206,26 @@ export class BraidPanel implements Component { " " + border("│") + " " + (chat[at] ?? "")) : graph?.lines ?? chat; const tab = current ? "Tab assistant " : ""; - const [wide, medium, narrow] = open + const history = this.view === "history"; + const toggle = history ? "g heads view" : "g history view"; + const expand = history ? ` e ${this.allHistory ? "recent" : "all"} history` : ""; + const noun = history ? "execution" : "node"; + // Longest first; the footer shows the first that fits. + const hints = open ? ["Enter send Esc stop or hide Tab hide PgUp/PgDn or wheel scroll ctrl+c close", "Enter send · Esc hide · Tab hide", "Enter send · Tab hide"] : this.focus ? [`${tab}Esc back ←/→ executions ↑/↓ scroll End follow ctrl+o tools p pause h hold x cancel execution c cancel job q close`, + "Esc back ←/→ executions ↑/↓ scroll End follow ctrl+o tools p pause h hold x cancel q close", "Esc back · ←/→ executions · p pause · q close", "Esc back · q close"] : this.choosing !== undefined - ? [`${tab}↑/↓/←/→ choose node Enter open session p pause h hold x cancel execution Esc back PgUp/PgDn page q close`, - "↑/↓ choose · Enter open · p pause · Esc back", "Enter open · Esc back"] - : [`${tab}v select node ←/→ jobs ↑/↓ scroll p pause job c cancel Esc close`, - "v select node · ←/→ jobs · p pause job · Esc close", "v select · Esc close"]; - const help = contentWidth >= 80 ? wide : contentWidth >= 42 ? medium : narrow; + ? [`${tab}↑/↓/←/→ choose ${noun} Enter open session ${toggle}${expand} p pause h hold x cancel execution Esc back PgUp/PgDn page q close`, + `↑/↓ choose ${noun} Enter open session ${toggle}${expand} p pause h hold x cancel Esc back`, + `↑/↓ choose · Enter open · ${toggle} · Esc back`, "Enter open · Esc back"] + : [`${tab}v select ${noun} ${toggle}${expand} ←/→ jobs ↑/↓ scroll p pause job c cancel Esc close`, + `v select ${noun} ${toggle}${expand} ←/→ jobs p pause job c cancel Esc close`, + `v select · ${toggle} · ←/→ jobs · Esc close`, `v select · g ${history ? "heads" : "history"} · Esc close`]; + const help = hints.find(hint => visibleWidth(hint) <= contentWidth) ?? hints.at(-1)!; const status = graph ? graph.range : "Assistant"; return [ ...header, @@ -245,6 +263,7 @@ export class BraidPanel implements Component { ...(current.result ?? current.live), progress: current.live.progress, ...(current.workspaces ? { workspaces: current.workspaces } : {}), + executions: this.executions(current), }, true, false, @@ -252,6 +271,7 @@ export class BraidPanel implements Component { "", false, this.choosing, + { view: this.view, allHistory: this.allHistory }, ).render(width), ] : ["No Braid jobs in this session."]; @@ -329,7 +349,7 @@ export class BraidPanel implements Component { /** The node whose conversation the assistant shows: the open session's, or the selected one. */ private assistantNode(job: JobSnapshot): string | undefined { - if (!this.focus) return this.choosing; + if (!this.focus) return this.choosing !== undefined && this.view === "history" ? this.executions(job)[this.choosing]?.id : this.choosing; if ("nodeId" in this.focus) return this.focus.nodeId; try { return this.jobs.getNode(job.jobId, undefined, this.focus.executionId).id; } catch { return undefined; } @@ -345,24 +365,53 @@ export class BraidPanel implements Component { return "a node that is no longer in the graph"; } } + if (this.choosing !== undefined && this.view === "history") { + const execution = this.executions(job)[this.choosing]; + return `job execution history with execution ${this.choosing} of node ${JSON.stringify(execution?.id)} selected (${execution?.status ?? "unknown"})`; + } if (this.choosing !== undefined) { const node = (job.result?.nodes ?? job.live.nodes)[this.choosing]; return `job graph with node ${JSON.stringify(this.choosing)} selected (${node?.status ?? "pending"})`; } - return `job graph overview (${job.status})`; + return `job graph ${this.view === "history" ? "execution history" : "overview"} (${job.status})`; } - /** Graph definition order, which is also the flowchart's declaration order. */ + /** Every instance of the job, oldest first, including removed and superseded ones. */ + private executions(job: JobSnapshot): Record { + return job.result?.executions ?? job.execution?.executions ?? {}; + } + + /** + * What selection moves through, in the flowchart's declaration order: node IDs + * in the heads view, the unfolded execution IDs in the history view. + */ private nodeIds(job: JobSnapshot): string[] { + if (this.view === "history") return historyLayout(this.executions(job), this.allHistory).shown.map(record => record.executionId); return Object.keys(job.result?.nodes ?? job.live.nodes); } private choosingLine(job: JobSnapshot): string { - const nodes = job.result?.nodes ?? job.live.nodes; - const node = this.choosing !== undefined ? nodes[this.choosing] : undefined; + const history = this.view === "history"; + const node = this.choosing === undefined ? undefined + : history ? this.executions(job)[this.choosing] : (job.result?.nodes ?? job.live.nodes)[this.choosing]; const ids = this.nodeIds(job); return this.theme.fg("warning", `${SELECTED_NODE_MARK} Selected ${ids.indexOf(this.choosing ?? "") + 1}/${ids.length}: `) + - this.theme.bold(plain(this.choosing ?? "")) + this.theme.fg("muted", ` · ${node?.status ?? "pending"} · Enter to open its session`); + this.theme.bold(plain(this.choosing ?? "")) + this.theme.fg("muted", ` · ${node?.status ?? "pending"} · Enter to open ${history ? "this execution's" : "its"} session`); + } + + /** g switches views and keeps the selection on the same lineage. */ + private toggleView(job: JobSnapshot | undefined): void { + const choosing = this.choosing; + const executions = job ? this.executions(job) : {}; + this.view = this.view === "heads" ? "history" : "heads"; + this.offset = 0; + this.revealed = undefined; + if (choosing === undefined || !job) return; + if (this.view === "heads") this.choosing = executions[choosing]?.id; + else { + const head = (job.result?.nodes ?? job.live.nodes)[choosing]?.executionId; + this.choosing = head && this.nodeIds(job).includes(head) ? head : this.executionsOf(job, choosing).filter(id => this.nodeIds(job).includes(id)).at(-1); + } } /** A focused node can disappear after a live graph update; say so instead of failing. */ @@ -407,8 +456,9 @@ export class BraidPanel implements Component { return Object.values(executions).filter(execution => execution.id === nodeId).map(execution => execution.executionId); } + /** In the history view the selection is already an exact execution. */ private open(job: JobSnapshot, nodeId: string): void { - const executionId = this.executionsOf(job, nodeId).at(-1); + const executionId = this.view === "history" ? nodeId : this.executionsOf(job, nodeId).at(-1); this.focus = executionId ? { executionId } : { nodeId }; this.follow = true; this.offset = 0; @@ -457,14 +507,21 @@ export class BraidPanel implements Component { this.refresh(); return; } - if (this.focus) this.sessionInput(data, job); + if (!this.focus && data === "g") this.toggleView(job); + else if (!this.focus && data === "e" && this.view === "history") { + this.allHistory = !this.allHistory; + this.notice = this.allHistory ? "Showing every generation" : "Showing recent generations · older settled executions are folded"; + } else if (this.focus) this.sessionInput(data, job); else if (this.choosing !== undefined && job) this.chooseInput(data, job); else if (matchesKey(data, "escape")) { this.close(); return; } else if (data === "v" && job) { const ids = this.nodeIds(job); - this.choosing = ids.find(id => (job.result?.nodes ?? job.live.nodes)[id]?.status === "running") ?? ids[0]; + const history = this.view === "history"; + const status = (id: string) => history ? this.executions(job)[id]?.status : (job.result?.nodes ?? job.live.nodes)[id]?.status; + // The history view starts at the newest execution, the heads view at the first node. + this.choosing = ids.find(id => status(id) === "running") ?? (history ? ids.at(-1) : ids[0]); } else if (matchesKey(data, "left") || matchesKey(data, "right")) { const step = matchesKey(data, "right") ? 1 : -1; this.selected = jobs[(index + step + jobs.length) % jobs.length]?.jobId; @@ -510,9 +567,7 @@ export class BraidPanel implements Component { const execution = job.execution; try { if (!execution || job.status !== "running") throw new Error("Only a running job can hold results"); - const executionId = this.focus - ? "executionId" in this.focus ? this.focus.executionId : this.executionsOf(job, this.focus.nodeId).at(-1) - : this.executionsOf(job, this.choosing!).at(-1); + const executionId = this.executionInView(job); if (!executionId) throw new Error("This node has not run yet"); if (execution.pausedExecutionIds.includes(executionId)) { this.jobs.resume(job.jobId, [executionId], execution.revision, "user"); @@ -529,11 +584,11 @@ export class BraidPanel implements Component { } } - /** The open execution, or the selected node's latest one. */ + /** The open execution, the selected execution, or the selected node's latest one. */ private executionInView(job: JobSnapshot): string | undefined { - return this.focus - ? "executionId" in this.focus ? this.focus.executionId : this.executionsOf(job, this.focus.nodeId).at(-1) - : this.choosing !== undefined ? this.executionsOf(job, this.choosing).at(-1) : undefined; + if (this.focus) return "executionId" in this.focus ? this.focus.executionId : this.executionsOf(job, this.focus.nodeId).at(-1); + if (this.choosing === undefined) return undefined; + return this.view === "history" ? this.choosing : this.executionsOf(job, this.choosing).at(-1); } /** @@ -569,13 +624,15 @@ export class BraidPanel implements Component { private sessionInput(data: string, job: JobSnapshot | undefined): void { if (matchesKey(data, "escape") || matchesKey(data, "backspace")) { - // Return to selection on the same node, so another node is one keypress away. + // Return to selection on the same node or execution, so another is one keypress away. const focus = this.focus!; - let nodeId: string | undefined; - try { nodeId = job && ("nodeId" in focus ? focus.nodeId : this.jobs.getNode(job.jobId, undefined, focus.executionId).id); } - catch { /* The node was removed; fall back to the plain graph. */ } + let target: string | undefined; + try { + const node = job && ("nodeId" in focus ? this.jobs.getNode(job.jobId, focus.nodeId) : this.jobs.getNode(job.jobId, undefined, focus.executionId)); + target = this.view === "history" ? node?.executionId : node?.id; + } catch { /* The node was removed; fall back to the plain graph. */ } this.focus = undefined; - this.choosing = nodeId !== undefined && job && this.nodeIds(job).includes(nodeId) ? nodeId : undefined; + this.choosing = target !== undefined && job && this.nodeIds(job).includes(target) ? target : undefined; this.offset = 0; } else if ((matchesKey(data, "left") || matchesKey(data, "right")) && job) { let node; diff --git a/integrations/pi/display.ts b/integrations/pi/display.ts index 428920f..4058d47 100644 --- a/integrations/pi/display.ts +++ b/integrations/pi/display.ts @@ -17,6 +17,7 @@ import { type ExecutionActivity, type ExecutionError, type ExecutionEvent, + type NodeExecution, type NodeResult, type NodeWorkspace as PiNodeWorkspace, } from "@chrok/braid"; @@ -43,6 +44,8 @@ export interface BraidLiveState { pause?: { source: string; pausedAt: number }; /** The generation of each node's head, from its execution ID. */ generations?: Record; + /** Every instance, when a run snapshot is attached for display; the stored state never holds it. */ + executions?: Record; } export type BraidToolDetails = | ((BraidResult | BraidLiveState) & { @@ -216,14 +219,42 @@ function nodeElapsed(node: NodeResult, now: number): string { /** Replaces the selected node's one-column status icon, so selection never moves the layout. */ export const SELECTED_NODE_MARK = "◆"; -function mermaidLabelLines( - id: string, - result: BraidResult | BraidLiveState, - selected = false, -): string[] { - const progress = "progress" in result ? result.progress?.[id] : undefined; - const node = result.nodes[id]; - if (!node) return [compact(id, 24), "pending"]; +/** The two views of the execution-only graph in the panel. */ +export type GraphView = "heads" | "history"; +/** Generations of each lineage the history view keeps expanded by default. */ +export const HISTORY_ROUNDS = 2; + +/** One box per instance; older settled instances of a lineage fold into one box per lineage. */ +export interface HistoryLayout { + shown: NodeExecution[]; + folded: Map; +} + +const settled = (record: NodeResult) => + (record.status === "completed" || record.status === "failed" || record.status === "skipped") && !record.held; + +/** + * Pending, running, and held instances always stay expanded, as do the last + * HISTORY_ROUNDS generations of each lineage (every generation with `all`). + * Past MAX_VISIBLE_NODES boxes, the oldest settled instances fold first. + */ +export function historyLayout(executions: Record, all = false): HistoryLayout { + const records = Object.values(executions); + const latest = new Map(); + for (const record of records) latest.set(record.id, Math.max(latest.get(record.id) ?? 0, record.generation)); + const keep = new Set(records.filter(record => all || !settled(record) || record.generation > latest.get(record.id)! - HISTORY_ROUNDS)); + for (const record of records) { + if (keep.size <= MAX_VISIBLE_NODES) break; + if (settled(record) && record.generation < latest.get(record.id)!) keep.delete(record); + } + const folded = new Map(); + for (const record of records) { + if (!keep.has(record)) (folded.get(record.id) ?? folded.set(record.id, []).get(record.id)!).push(record); + } + return { shown: records.filter(record => keep.has(record)), folded }; +} + +function statusIcon(node: NodeResult, selected: boolean): string { const suspended = node.suspension && node.suspension.state !== "pausing"; const icon = suspended @@ -233,63 +264,131 @@ function mermaidLabelLines( : { completed: "✓", failed: "✗", - skipped: "·", + skipped: "⊘", pending: "○", }[node.status]; - const status = node.suspension - ? `${node.suspension.state}${node.suspension.source === "node_timeout" ? " · timed out" : ""}` - : node.status === "completed" ? "done" : node.status; - const decision = - node.decision === undefined ? "" : ` → ${compact(node.decision, 16)}`; + return selected ? SELECTED_NODE_MARK + icon.slice(1) : icon; +} + +/** Status with the pending wait reason, the running sub-state, or the hold. */ +function statusText(node: NodeResult, now: number, progress?: PiNodeProgress): string { + if (node.suspension) + return `${node.suspension.state}${node.suspension.source === "node_timeout" ? " · timed out" : ""} · ${nodeElapsed(node, now)}`; + const decision = node.decision === undefined ? "" : ` → ${compact(node.decision, 16)}`; + switch (node.status) { + case "pending": + return node.waitReason ? `pending · waits ${node.waitReason.replace("_", " ")}` : "pending"; + case "running": + return `running${progress && (!progress.executionId || progress.executionId === node.executionId) ? ` · ${progress.phase}` : ""}${node.holdAfter ? " · hold" : ""} · ${nodeElapsed(node, now)}`; + case "skipped": + return `skipped${node.skipReason ? ` · ${node.skipReason.replace("_", " ")}` : ""}`; + default: + return `${node.status === "completed" ? "done" : "failed"}${decision}${node.held ? " · held" : ""} · ${nodeElapsed(node, now)}`; + } +} + +function progressText(progress: PiNodeProgress): string { + const window = progress.contextWindow === undefined ? "—" : compactCount(progress.contextWindow); + const prefix = progress.contextSource === "estimate" ? "~" : ""; + return `${prefix}${compactCount(progress.contextTokens)}/${window} · T${progress.toolCalls}`; +} + +/** Started instances and reported tokens of each lineage. */ +function lineageTotals(executions: Record | undefined): Map { + const totals = new Map(); + for (const record of Object.values(executions ?? {})) { + const total = totals.get(record.id) ?? totals.set(record.id, { runs: 0, tokens: 0 }).get(record.id)!; + if (record.status !== "pending" && record.status !== "skipped") total.runs++; + if (record.usage) total.tokens += record.usage.inputTokens + record.usage.outputTokens; + } + return totals; +} + +/** A lineage head: status, runs, and the lineage's usage. */ +function mermaidLabelLines( + id: string, + result: BraidResult | BraidLiveState, + selected = false, + totals = lineageTotals(result.executions), +): string[] { + const progress = "progress" in result ? result.progress?.[id] : undefined; + const live = result.nodes[id]; + if (!live) return [compact(id, 24), "pending"]; + const head = live.executionId ? result.executions?.[live.executionId] : undefined; + // Snapshot records carry the derived wait reason and hold marker. + const node = { ...live, ...(head?.waitReason ? { waitReason: head.waitReason } : {}), ...(head?.held ? { held: true } : {}) }; const now = "observedAt" in result ? result.observedAt : Date.now(); - const generation = "executions" in result ? result.executions[node.executionId ?? ""]?.generation : result.generations?.[id]; + const generation = head?.generation ?? ("generations" in result ? result.generations?.[id] : undefined); + const { runs, tokens: used } = totals.get(id) ?? { runs: 0, tokens: 0 }; const lines = [ - `${selected ? SELECTED_NODE_MARK + icon.slice(1) : icon} ${compact(id, 24)}${generation && generation > 1 ? ` #${generation}` : ""}`, - `${status}${node.holdAfter && node.status === "running" ? " · hold" : ""}${decision} · ${nodeElapsed(node, now)}`, + `${statusIcon(node, selected)} ${compact(id, 24)}${generation && generation > 1 ? `@${generation}` : ""}`, + statusText(node, now, progress), ]; - if (progress) { - const window = - progress.contextWindow === undefined - ? "—" - : compactCount(progress.contextWindow); - const prefix = progress.contextSource === "estimate" ? "~" : ""; - lines.push( - `${prefix}${compactCount(progress.contextTokens)}/${window} · T${progress.toolCalls}`, - ); - } + if (runs || used) lines.push(`${runs} run${runs === 1 ? "" : "s"}${used ? ` · ${compactCount(used)} tok` : ""}`); + if (progress && node.status === "running") lines.push(progressText(progress)); + return lines; +} + +/** One instance: its exact ID, status, and whether it was superseded or removed. */ +function historyLabelLines(record: NodeExecution, result: BraidResult | BraidLiveState, selected: boolean): string[] { + const progress = "progress" in result ? result.progress?.[record.id] : undefined; + const now = "observedAt" in result ? result.observedAt : Date.now(); + const lines = [`${statusIcon(record, selected)} ${compact(record.executionId, 24)}`, statusText(record, now, progress)]; + if (record.supersededBy) lines.push(`superseded → ${compact(record.supersededBy, 16)}`); + else if (!Object.hasOwn(result.nodes, record.id)) lines.push("removed"); + if (progress && record.status === "running" && progress.executionId === record.executionId) lines.push(progressText(progress)); return lines; } +function foldedLabelLines(records: readonly NodeExecution[]): string[] { + const first = records[0]!; + const last = records.at(-1)!; + const counts = (["completed", "failed", "skipped"] as const) + .map(status => [status, records.filter(record => record.status === status).length] as const) + .filter(([, count]) => count) + .map(([status, count]) => `${count} ${status === "completed" ? "done" : status}`); + return [ + `≡ ${compact(first.id, 18)}@${first.generation}${records.length > 1 ? `–${last.generation}` : ""}`, + `${records.length} earlier · ${counts.join(" ")}`, + ]; +} + +const escapeMermaidText = (value: string) => + stripTerminalSequences(value) + .replace(/\p{Cc}/gu, "") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/\|/g, "|") + .replace( + /[[\]{}<>]/g, + (character) => + ({ + "[": "[", + "]": "]", + "{": "{", + "}": "}", + "<": "<", + ">": ">", + })[character] ?? character, + ); + +function closingOf(parts: ReturnType): Set { + return new Set(closingEdges({ nodes: Object.entries(parts.nodeTypes).map(([id, type]) => ({ id, type })), edges: parts.edges })); +} + function mermaidSource(result: BraidResult | BraidLiveState, selected?: string): string { const parts = graphParts(result); - const closing = new Set(closingEdges({ nodes: Object.entries(parts.nodeTypes).map(([id, type]) => ({ id, type })), edges: parts.edges })); + const closing = closingOf(parts); const closingSources = new Map([...closing].map(edge => [edge.to, edge.from])); const ids = Object.keys(parts.nodes); const names = new Map(ids.map((id, index) => [id, `n${index}`])); - const escapeMermaidText = (value: string) => - stripTerminalSequences(value) - .replace(/\p{Cc}/gu, "") - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(/\|/g, "|") - .replace( - /[[\]{}<>]/g, - (character) => - ({ - "[": "[", - "]": "]", - "{": "{", - "}": "}", - "<": "<", - ">": ">", - })[character] ?? character, - ); + const totals = lineageTotals(result.executions); const lines = ["flowchart TD"]; for (const id of ids) { const shape = parts.nodeTypes[id] === "decision" ? "{" : "["; const close = parts.nodeTypes[id] === "decision" ? "}" : "]"; lines.push( - ` ${names.get(id)}${shape}"${mermaidLabelLines(id, result, id === selected).map(escapeMermaidText).join("
")}"${close}`, + ` ${names.get(id)}${shape}"${mermaidLabelLines(id, result, id === selected, totals).map(escapeMermaidText).join("
")}"${close}`, ); } for (const edge of parts.edges) { @@ -302,14 +401,74 @@ function mermaidSource(result: BraidResult | BraidLiveState, selected?: string): return lines.join("\n"); } +/** + * The history view: a flat, acyclic graph of instances, so each round of a cycle + * is drawn below the one before it. Solid edges are the inputs an instance ran + * with; dotted edges are the inputs a pending instance will use or a skipped one + * saw, and "replaces" links a replaced instance to its replacement. + */ +function historySource(result: BraidResult | BraidLiveState, all: boolean, selected?: string): string { + const executions = result.executions ?? {}; + const { shown, folded } = historyLayout(executions, all); + const parts = graphParts(result); + const closing = closingOf(parts); + const names = new Map(); + const lines = ["flowchart TD"]; + for (const [id, records] of folded) { + const name = `f${names.size}`; + for (const record of records) names.set(record.executionId, name); + lines.push(` ${name}("${foldedLabelLines(records).map(escapeMermaidText).join("
")}")`); + } + shown.forEach((record, index) => { + names.set(record.executionId, `e${index}`); + const [open, close] = record.node.type === "decision" ? ["{", "}"] : ["[", "]"]; + lines.push(` e${index}${open}"${historyLabelLines(record, result, record.executionId === selected).map(escapeMermaidText).join("
")}"${close}`); + }); + const drawn = new Set(); + const link = (from: NodeExecution | undefined, to: NodeExecution, arrow: string, label?: string) => { + const source = from && names.get(from.executionId); + const target = names.get(to.executionId); + const line = ` ${source} ${arrow}${label ? `|${escapeMermaidText(label)}|` : ""} ${target}`; + if (!source || !target || source === target || drawn.has(line)) return; + drawn.add(line); + lines.push(line); + }; + const decision = (record: NodeExecution) => record.node.type === "decision" ? record.decision : undefined; + const records = Object.values(executions); + const lineages = new Map(); + for (const record of records) (lineages.get(record.id) ?? lineages.set(record.id, []).get(record.id)!).push(record); + for (const record of records) { + for (const id of record.predecessorExecutionIds) { + const source = executions[id]; + if (source) link(source, record, "-->", decision(source)); + } + if (record.supersedes) link(executions[record.supersedes], record, "-.->", "replaces"); + if (record.status !== "pending" && record.status !== "skipped") continue; + // Not started, or skipped without inputs: draw the upstream instance it will use or saw. + for (const edge of parts.edges) { + if (edge.to !== record.id) continue; + const lineage = lineages.get(edge.from) ?? []; + const source = record.status === "pending" + ? lineage.at(-1) + : lineage.filter(source => source.finishedAt !== undefined && source.finishedAt <= record.finishedAt!).at(-1); + if (!source || record.predecessorExecutionIds.includes(source.executionId)) continue; + // A cycle-closing edge reaches an instance only after its source fired. + if (closing.has(edge) && (record.status === "skipped" || !settled(source))) continue; + link(source, record, "-.->", edge.choice); + } + } + return lines.join("\n"); +} + function mermaidLines( result: BraidResult | BraidLiveState, theme: Palette, selected?: string, + options: GraphViewOptions = {}, ): string[] { let art: ReturnType; try { - art = renderMermaid(mermaidSource(result, selected)); + art = renderMermaid(options.view === "history" ? historySource(result, options.allHistory ?? false, selected) : mermaidSource(result, selected)); } catch { // The chart is a view of state already listed elsewhere; never fail the panel for it. return []; @@ -761,6 +920,14 @@ export function renderNodeDetails( ]; } +export interface GraphViewOptions { + /** Default "heads": one box per lineage head. */ + view?: GraphView; + /** In the history view, expand every generation instead of the last HISTORY_ROUNDS. */ + allHistory?: boolean; +} + +/** In the history view, `selectedNodeId` is an execution ID. */ export function renderGraphResult( result: BraidToolDetails, expanded: boolean, @@ -769,6 +936,7 @@ export function renderGraphResult( fallback = "", isError = false, selectedNodeId?: string, + options: GraphViewOptions = {}, ): Component { if (isError || !result?.nodes) { const message = @@ -814,16 +982,22 @@ export function renderGraphResult( if ("pause" in result && result.pause) lines.push(theme.fg("warning", `job paused by ${compact(result.pause.source, 20)} · no new executions start`)); if ("pausedExecutionIds" in result && result.pausedExecutionIds?.length) lines.push(theme.fg("warning", `${result.pausedExecutionIds.length} paused executions · revision ${result.revision ?? 0}`)); if ("suspendedExecutionIds" in result && result.suspendedExecutionIds?.length) lines.push(theme.fg("warning", `${result.suspendedExecutionIds.length} suspended executions · revision ${result.revision ?? 0}`)); - if ("executions" in result) lines.push(theme.fg("dim", `${Object.keys(result.executions).length} executions · revision ${result.revision}`)); + const history = options.view === "history"; + if (result.executions) lines.push(theme.fg("dim", `${Object.keys(result.executions).length} executions · revision ${result.revision ?? 0}`)); + const layout = history ? historyLayout(result.executions ?? {}, options.allHistory) : undefined; + const folded = layout ? [...layout.folded.values()].reduce((sum, records) => sum + records.length, 0) : 0; + if (layout && folded) lines.push(theme.fg("dim", `${folded} older settled executions folded into ${layout.folded.size} ≡ boxes`)); const chartStart = lines.length; - const chart = mermaidLines(result, theme, selectedNodeId); + const chart = mermaidLines(result, theme, selectedNodeId, options); lines.push(...chart); // Selection must stay usable when the chart cannot be drawn or does not fit. + const entries = layout + ? layout.shown.map(record => [record.executionId, record.status] as const) + : Object.keys(graphParts(result).nodes).map(id => [id, result.nodes[id]?.status ?? "pending"] as const); const nodeList = selectedNodeId === undefined ? [] : [ - theme.fg("dim", chart.length ? "nodes (selected node not found in the flowchart):" : "nodes (flowchart unavailable):"), - ...Object.keys(graphParts(result).nodes).map(id => { - const node = result.nodes[id]; - const text = `${id === selectedNodeId ? `${SELECTED_NODE_MARK} ` : " "}${compact(id, 60)} · ${node?.status ?? "pending"}`; + theme.fg("dim", chart.length ? `${history ? "executions" : "nodes"} (selected ${history ? "execution" : "node"} not found in the flowchart):` : `${history ? "executions" : "nodes"} (flowchart unavailable):`), + ...entries.map(([id, status]) => { + const text = `${id === selectedNodeId ? `${SELECTED_NODE_MARK} ` : " "}${compact(id, 60)} · ${status}`; return id === selectedNodeId ? theme.bg("selectedBg", theme.bold(theme.fg("warning", text))) : theme.fg("muted", text); }), ]; @@ -847,7 +1021,7 @@ export function renderGraphResult( ), ); return new FixedLines(lines, chartStart, chart.length, nodeList, [ - ...(selectedNodeId === undefined ? [] : [{ mark: SELECTED_NODE_MARK, label: "selected node" }]), - { mark: "▶ ACTIVE", label: "running node" }, + ...(selectedNodeId === undefined ? [] : [{ mark: SELECTED_NODE_MARK, label: history ? "selected execution" : "selected node" }]), + { mark: "▶ ACTIVE", label: history ? "running execution" : "running node" }, ]); } diff --git a/integrations/pi/test/graph-views.test.ts b/integrations/pi/test/graph-views.test.ts new file mode 100644 index 0000000..80dd7f3 --- /dev/null +++ b/integrations/pi/test/graph-views.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Context } from "@earendil-works/pi-ai"; +import { stripTerminalSequences } from "@earendil-works/pi-tui"; +import type { NodeExecution } from "@chrok/braid"; +import { BraidPanel } from "../command.js"; +import { createLiveState, historyLayout, HISTORY_ROUNDS, MAX_VISIBLE_NODES, renderGraphResult } from "../display.js"; +import { BraidJobs } from "../jobs.js"; +import { context, response } from "./helpers.js"; + +const theme = { + fg: (_color: string, value: string) => value, + bg: (_color: string, value: string) => value, + bold: (value: string) => value, +} as never; + +function execution(id: string, generation: number, fields: Partial = {}): NodeExecution { + return { + id, executionId: `${id}@${generation}`, generation, revision: 0, status: "completed", predecessorExecutionIds: [], + node: { type: "execute", id, prompt: id }, ...fields, + }; +} + +/** draft was replaced while check waits for it; the old lineage was removed after it ran. */ +function replacedState() { + const executions: Record = { + "draft@1": execution("draft", 1, { supersededBy: "draft@2", usage: { inputTokens: 2_000, outputTokens: 1_000 }, latencyMs: 2_000 }), + "old@1": execution("old", 1, { latencyMs: 1_000 }), + "draft@2": execution("draft", 2, { status: "running", supersedes: "draft@1", startedAt: 0 }), + "check@1": execution("check", 1, { status: "pending", waitReason: "predecessors" }), + }; + const live = createLiveState(); + live.observedAt = 3_000; + live.nodeTypes = { draft: "execute", check: "execute" }; + live.edges = [{ from: "draft", to: "check" }]; + live.nodes = { + draft: { id: "draft", executionId: "draft@2", status: "running", startedAt: 0 }, + check: { id: "check", executionId: "check@1", status: "pending" }, + }; + live.progress = { draft: { nodeId: "draft", executionId: "draft@2", contextTokens: 2_000, contextSource: "reported", toolCalls: 3, toolRounds: 2, phase: "tool" } }; + return { ...live, executions }; +} + +const chart = (details: ReturnType, view: "heads" | "history", selected?: string) => + renderGraphResult(details, false, false, theme, "", false, selected, { view }).render(400).join("\n"); + +test("the heads view shows each head's wait reason, running sub-state, runs, and lineage usage", () => { + const view = chart(replacedState(), "heads"); + // Labels wrap at 24 columns, so match words rather than whole lines. + assert.match(view, /draft@2 running/); + assert.match(view, /· tool ·/, "the running sub-state is the worker's phase"); + assert.match(view, /2 runs/, "runs count started instances"); + assert.match(view, /\b3K\b/, "usage sums the lineage"); + assert.match(view, /pending · waits/); + assert.match(view, /predecessors/); + assert.doesNotMatch(view, /old/, "a removed lineage has no head"); + assert.doesNotMatch(view, /draft@1/); +}); + +test("the history view keeps superseded and removed instances and links replacements", () => { + const view = chart(replacedState(), "history"); + for (const id of ["draft@1", "draft@2", "check@1", "old@1"]) assert.match(view, new RegExp(id)); + assert.match(view, /superseded → draft@2/); + assert.match(view, /replaces/, "the replaced instance links to its replacement"); + assert.match(view, /removed/, "an instance of a removed lineage is kept and marked"); + assert.match(view, /╎/, "a pending instance's expected input is dotted"); + assert.doesNotMatch(view, /folded/); + const selected = chart(replacedState(), "history", "draft@1"); + assert.match(selected, /◆ draft@1/, "selection marks the exact execution"); + const cropped = renderGraphResult(replacedState(), false, false, theme, "", false, "missing@1", { view: "history" }).render(30).join("\n"); + assert.match(cropped, /executions \(selected execution/, "a cropped chart without the selection lists executions"); + assert.match(cropped, /draft@1 · completed/); +}); + +test("skipped instances are drawn differently from pending ones", () => { + const details = replacedState(); + details.executions["check@1"] = execution("check", 1, { status: "skipped", skipReason: "inactive", finishedAt: 5 }); + details.executions["draft@2"] = execution("draft", 2, { supersedes: "draft@1", finishedAt: 4 }); + const view = chart(details, "history"); + assert.match(view, /⊘ check@1/); + assert.match(view, /skipped ·/); + assert.match(view, /inactive/); + assert.doesNotMatch(view, /○ check@1/); + assert.match(view, /╎/, "the skipped instance shows the upstream result it saw"); +}); + +test("history folds older settled generations per lineage, never active or held ones", () => { + const executions: Record = {}; + for (let generation = 1; generation <= 5; generation++) { + executions[`edit@${generation}`] = execution("edit", generation, generation === 2 ? { held: true } : {}); + executions[`review@${generation}`] = execution("review", generation, generation === 5 ? { status: "running" } : {}); + } + const recent = historyLayout(executions); + assert.deepEqual(recent.shown.map(record => record.executionId), ["edit@2", "edit@4", "review@4", "edit@5", "review@5"]); + assert.equal(HISTORY_ROUNDS, 2); + assert.deepEqual(recent.folded.get("edit")!.map(record => record.executionId), ["edit@1", "edit@3"]); + assert.deepEqual(recent.folded.get("review")!.map(record => record.executionId), ["review@1", "review@2", "review@3"]); + assert.equal(historyLayout(executions, true).shown.length, 10); + assert.equal(historyLayout(executions, true).folded.size, 0); + + const many: Record = {}; + for (let generation = 1; generation <= MAX_VISIBLE_NODES + 20; generation++) many[`step@${generation}`] = execution("step", generation); + const capped = historyLayout(many, true); + assert.equal(capped.shown.length, MAX_VISIBLE_NODES, "even all history stays within the box limit"); + assert.equal(capped.shown.at(-1)!.generation, MAX_VISIBLE_NODES + 20, "the oldest generations fold first"); + + const view = renderGraphResult({ ...createLiveState(), nodes: { edit: { id: "edit", status: "completed" }, review: { id: "review", status: "running" } }, executions }, + false, false, theme, "", false, undefined, { view: "history" }).render(400).join("\n"); + assert.match(view, /5 older settled executions folded into 2 ≡ boxes/); + assert.match(view, /≡ edit@1–3/); + assert.match(view, /≡ review@1–3/); +}); + +test("g switches the panel between heads and history; selection and Enter follow exact executions", async t => { + const ctx = context(async () => response()); + t.mock.method(ctx.modelRegistry, "complete", async (_model: unknown, worker: Context) => { + const payload = JSON.parse(worker.messages[0]!.content as string) as { nodeId: string; execution: { generation: number } }; + if (payload.nodeId === "review" && worker.messages.length === 1) return { + ...response(), stopReason: "toolUse" as const, + content: [{ type: "toolCall" as const, id: "choice", name: "decide", arguments: { choice: payload.execution.generation === 4 ? "done" : "again" } }], + }; + return response(`round ${payload.execution.generation}`); + }); + const jobs = new BraidJobs(); + t.after(() => jobs.dispose()); + const job = jobs.start({ goal: "Loop", nodes: [ + { type: "execute", id: "work", prompt: "work" }, + { type: "decision", id: "review", prompt: "review", choices: ["again", "done", "escalate"] }, + { type: "execute", id: "ship", prompt: "ship" }, + { type: "execute", id: "human", prompt: "ask" }, + ], edges: [ + { from: "work", to: "review" }, { from: "review", to: "work", choice: "again", limit: 5 }, + { from: "review", to: "ship", choice: "done" }, { from: "review", to: "human", choice: "escalate" }, + ] }, {}, ctx); + await jobs.wait(job.jobId); + const panel = new BraidPanel(jobs, { requestRender: () => {}, terminal: { rows: 80 } } as never, theme, () => {}, job.handle); + t.after(() => panel.dispose()); + const text = () => stripTerminalSequences(panel.render(120).join("\n")); + + let view = text(); + assert.match(view, /^╭ Braid · heads view /); + assert.match(view, /g history view/); + assert.match(view, /work@4/); + assert.match(view, /4 runs/); + assert.doesNotMatch(view, /✓ review@3/); + + panel.handleInput("g"); + view = text(); + assert.match(view, /^╭ Braid · history view /); + assert.match(view, /g heads view/); + assert.match(view, /e all history/); + assert.match(view, /≡ work@1–2/); + assert.match(view, /✓ review@3/); + assert.match(view, /⊘ human@1/, "the branch that was never taken is skipped, not pending"); + assert.doesNotMatch(view, /✓ work@1/); + + panel.handleInput("e"); + view = text(); + assert.match(view, /✓ work@1/); + assert.doesNotMatch(view, /≡/); + assert.match(view, /e recent history/); + + panel.handleInput("v"); + assert.match(text(), /◆ Selected 10\/10: review@4 · completed · Enter to open this execution's session/); + assert.match(text(), /select an execution/); + panel.handleInput("\x1b[D"); + assert.match(text(), /◆ Selected 9\/10: work@4/); + for (let index = 0; index < 4; index++) panel.handleInput("\x1b[D"); + assert.match(text(), /◆ Selected 5\/10: work@2/); + panel.handleInput("\r"); + view = text(); + assert.match(view, /Braid node work · completed/); + assert.match(view, /work@2/); + assert.match(view, /execution 2 of 4 for this node/, "Enter opens that exact execution, not the head"); + + panel.handleInput("\x1b"); + assert.match(text(), /◆ Selected 5\/10: work@2/, "Esc returns to the same execution"); + panel.handleInput("g"); + view = text(); + assert.match(view, /^╭ Braid · heads view /); + assert.match(view, /◆ Selected 1\/4: work/, "switching views keeps the lineage selected"); + panel.handleInput("\r"); + assert.match(text(), /execution 4 of 4 for this node/, "Enter in the heads view opens the head"); + panel.handleInput("\x1b"); + panel.handleInput("g"); + assert.match(text(), /◆ Selected \d+\/10: work@4/, "switching back selects the head's execution"); + panel.handleInput("\x1b"); + assert.match(text(), /^╭ Braid · history view /, "the view is remembered after leaving selection"); +}); diff --git a/integrations/pi/test/render.test.ts b/integrations/pi/test/render.test.ts index 1590297..98aef34 100644 --- a/integrations/pi/test/render.test.ts +++ b/integrations/pi/test/render.test.ts @@ -333,10 +333,10 @@ test("cycle charts draw forward handoffs dotted and the closing edge solid witho }; const withoutShortcut = (edges: readonly Edge[]) => edges.filter(edge => edge.from !== "start" || edge.to !== "route"); const expectedSource = `flowchart TD - n0{"○ start
pending · —"} - n1["○ inspect
pending · —"] - n2{"○ route
pending · —"} - n3["○ end
pending · —"] + n0{"○ start
pending"} + n1["○ inspect
pending"] + n2{"○ route
pending"} + n3["○ end
pending"] n0 --> n1 n1 --> n2 n0 -.-> n2 From 46864bfc3c5ac8c9afab97d1bde78904429d5ebc Mon Sep 17 00:00:00 2001 From: Chrok Date: Sun, 25 Oct 2026 21:43:45 +0800 Subject: [PATCH 2/3] Replace the heads view with a live view of pending, running, and held executions; rename history to timeline --- ROADMAP.md | 2 +- integrations/pi/README.md | 89 ++++++------- integrations/pi/command.ts | 98 +++++++-------- integrations/pi/display.ts | 67 +++++----- integrations/pi/test/assistant.test.ts | 10 +- integrations/pi/test/graph-views.test.ts | 140 +++++++++++++-------- integrations/pi/test/panel.test.ts | 13 +- integrations/pi/test/session.test.ts | 27 ++-- integrations/pi/test/timeout-pause.test.ts | 2 +- 9 files changed, 244 insertions(+), 204 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index faf48f2..f03c96d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,7 +47,7 @@ pauses, reminders) instead of hard limits that fail the job. Tracking issue: - [x] Replace the schedule/execution split with an execution-only graph of lineages, where a started node is changed by replacing it ([#113](https://github.com/Epsirom/braid/issues/113)). - [ ] Accept unconditional, nested, and overlapping cycles ([#114](https://github.com/Epsirom/braid/issues/114)). -- [x] Show lineage heads and the full execution history in one Pi graph ([#94](https://github.com/Epsirom/braid/issues/94)). +- [x] Show live executions and a timeline of every execution in the Pi panel ([#94](https://github.com/Epsirom/braid/issues/94)). - [ ] Persist run state and recover runs after a restart ([#115](https://github.com/Epsirom/braid/issues/115)). - [ ] Keep memory and disk bounded in long-running runs ([#116](https://github.com/Epsirom/braid/issues/116)). - [ ] Consume outputs from runs that never finish ([#117](https://github.com/Epsirom/braid/issues/117)). diff --git a/integrations/pi/README.md b/integrations/pi/README.md index 8ca66bb..05b2290 100644 --- a/integrations/pi/README.md +++ b/integrations/pi/README.md @@ -393,7 +393,7 @@ you scroll the flow and event log. It refreshes as nodes start, finish, fail, and pass outputs downstream. Use Left/Right to select jobs, Up/Down or Page Up/Page Down to scroll, `c` to cancel the selected job, `p` to pause or resume it, and Escape or `q` to close the panel. -In node selection or an open session, `p` pauses or resumes that node's latest +In selection or an open session, `p` pauses or resumes the selected or open execution instead; a suspended node shows `‖ PAUSED`, and one paused by its node timeout also shows `timed out`. `h` holds that execution's result once it finishes, removes the hold if one is set (its status shows `· hold`), or @@ -402,60 +402,56 @@ that execution; the parent gets a notice with its next turn. Closing the panel leaves jobs running. In RPC or noninteractive modes, use `braid_status`. A flowchart wider than the terminal is cropped to the terminal width around the -first running node, or from its left edge; a `[chart 1–76 of 245 cols]` line +first running execution, or from its left edge; a `[chart 1–76 of 245 cols]` line shows the visible columns. -For cycles, a forward edge opposite the edge that closes the cycle is drawn -dotted, while the closing retry edge stays solid. For example, `/braid:review` shows the direct -`scope → check` handoff dotted and `check → scope` (`retry`) solid. All edges and -choice labels remain visible; runtime dependencies and handoffs are unchanged. - -### Heads and history views - -The panel shows the job's one graph in two views. Press `g` to switch between -them; the panel title (`Braid · heads view` or `Braid · history view`) and the -footer show the active one, and the panel keeps your choice while it is open, -across jobs and sessions. - -- **Heads view** (default): one box per node, showing its latest execution (the - lineage head), with edges between nodes, so a cycle is drawn as you would draw - it. A box shows the head's execution ID once the node has rerun (`review@3`), - its status, the reason a pending head waits (`waits predecessors`, `stale`, - `hold`, `run paused`, or `slot`), what a running head is doing (`model` or - `tool`, `· hold`, or paused), the node's number of runs, and the tokens all its - executions used. -- **History view**: one box per execution, oldest first, in one flat graph: - every round of a cycle appears below the round before it, labelled by its - execution ID. Solid edges are the inputs an execution ran with. Dotted edges - show the input a pending execution will use or the upstream result a skipped - one saw, and a dotted `replaces` edge links a replaced execution to its - replacement. Skipped executions show `⊘` and the reason, pending ones `○`. +### Live and timeline views + +The panel draws one box per execution, in one of two views. Press `g` to switch +between them; the panel title (`Braid · live view` or `Braid · timeline view`) +and the footer show the active one, and the panel keeps your choice while it is +open, across jobs and sessions. + +- **Live view** (default): only executions that have not released their result + downstream yet: pending, running, and held. It shows what the job is doing + now and what comes next, and stays small however long the job runs. A pending + execution shows why it waits (`waits predecessors`, `stale`, `hold`, + `run paused`, or `slot`); a running one shows whether its worker is in a + `model` request or a `tool`, `· hold` if its result will be held, and paused + or pausing. When nothing is live, for example once the job has finished, the + view says so and points to the timeline. +- **Timeline view**: every execution, oldest first, in one flat graph: every + round of a cycle appears below the round before it, labelled by its execution + ID (`review@2`). Skipped executions show `⊘` and the reason, pending ones `○`. Replaced executions stay and show `superseded → `, and executions of removed nodes show `removed`. -So long runs stay readable, the history view shows the last two generations of -each node and folds older finished executions into one `≡` box per node +In both views, solid edges are the inputs an execution ran with. Dotted edges +show the input a pending execution will use or the upstream result a skipped +one saw, and a dotted `replaces` edge links a replaced execution to its +replacement. Edges to executions that a view does not draw are left out. + +So long runs stay readable, the timeline shows the last two generations of each +node and folds older finished executions into one `≡` box per node (`≡ review@1–3`); pending, running, and held executions are never folded. Press `e` to show every generation, and `e` again to fold them. Either way at most 80 boxes are drawn; the oldest finished executions fold first. -Press `v` to select a node in the heads view, or an execution in the history -view: the arrow keys move a `◆` marker through the flowchart (starting at a -running node in the heads view, and at a running or the newest execution in the -history view), and the view pans horizontally and scrolls vertically to keep -the selection in sight. If the flowchart cannot be drawn, a list replaces it -while selecting. Pressing `g` while selecting keeps the same node selected: its -head in the history view. In the heads view, Enter opens that node's latest -invocation as a live session; in the history view, it opens that exact -execution, and `p`, `h`, and `x` act on it. Escape returns to the same node or -execution. The session is drawn with Pi's own chat +Press `v` to select an execution: the arrow keys move a `◆` marker through the +flowchart (starting at a running execution, or in the timeline at the newest +one), and the view pans horizontally and scrolls vertically to keep the +selection in sight. If the flowchart cannot be drawn, a list replaces it while +selecting. Pressing `g` while selecting keeps the same execution selected when +the other view draws it, or moves to the newest drawn execution of the same +node. Enter opens that exact execution as a live session, and `p`, `h`, and `x` +act on it. Escape returns to the same execution. The session is drawn with Pi's own chat components, as in the main conversation: the worker's prompt, streamed assistant text and thinking, and tool cards whose arguments and output update while they run. The node summary—status, elapsed time, usage, context, and live activity (see below)—stays pinned at the top while the session scrolls. The session follows the newest lines; scroll up to pause, and press End to follow again. Left/Right switch between invocations of the same node (for example rounds of a cycle), and ctrl+o expands -tool output. Escape steps back one level—session, then node selection, then the +tool output. Escape steps back one level—session, then selection, then the graph—and closes the panel from the graph; `q` closes it from anywhere. Running invocations and the 20 most recently finished ones per job keep their session; older ones show the summary and activity history. @@ -521,10 +517,15 @@ quote use JSON string syntax, such as `/braid job-1 " review "` or `/braid job-1 "review\nstep"`. Tab inserts this form automatically and displays control characters as escapes. -The panel renders a Mermaid flowchart, node states, elapsed times, context-token -estimates or provider-reported usage, context-window sizes, filesystem tool-call -counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status -tool also renders a flowchart; expand its result to see more log events. +The panel renders a Mermaid flowchart of executions, their states, elapsed +times, context-token estimates or provider-reported usage, context-window sizes, +filesystem tool-call counts, and the execution log. Active executions are marked +`▶ ACTIVE`. The `braid` and status tools render a flowchart with one box per node, +its latest execution, showing its status, number of runs, and the tokens all its +executions used; expand the result to see more log events. In that chart, a +forward edge opposite the edge that closes a cycle is drawn dotted, while the +closing retry edge stays solid: `/braid:review` shows the direct `scope → check` +handoff dotted and `check → scope` (`retry`) solid. When selecting `nodeId` or `executionId`, it instead shows that invocation's output/error and activity, with the full text and history available on expansion. diff --git a/integrations/pi/command.ts b/integrations/pi/command.ts index bd1a7c9..eeee66c 100644 --- a/integrations/pi/command.ts +++ b/integrations/pi/command.ts @@ -13,7 +13,7 @@ import { type TuiMouseEventResult, } from "@earendil-works/pi-tui"; import { BraidJobs, type JobSnapshot } from "./jobs.js"; -import { historyLayout, renderGraphResult, renderNodeDetails, renderSessionHeader, SELECTED_NODE_MARK, type GraphView } from "./display.js"; +import { executionLayout, renderGraphResult, renderNodeDetails, renderSessionHeader, SELECTED_NODE_MARK, type GraphView } from "./display.js"; import { NodeSessionView } from "./session-view.js"; import type { AssistantModel } from "./assistant.js"; import type { NodeExecution } from "@chrok/braid"; @@ -37,19 +37,16 @@ export type NodeFocus = { nodeId: string } | { executionId: string }; * A live, scrollable panel. Esc steps back one level: session → node selection → * graph → close. Closing the panel leaves jobs running. Tab toggles an assistant * that answers questions about the job and can message running executions. g - * switches between lineage heads and the execution history of the same graph. + * switches between the live executions and the timeline of every execution. */ export class BraidPanel implements Component { private selected: string | undefined; private focus: NodeFocus | undefined; - /** - * Highlighted in selection mode (`v`): a node ID in the heads view, an - * execution ID in the history view; undefined in the plain graph view. - */ + /** Execution highlighted in selection mode (`v`); undefined in the plain graph view. */ private choosing: string | undefined; /** Kept for the life of the panel, across jobs and sessions. */ - private view: GraphView = "heads"; - /** The history view expands every generation instead of the most recent ones. */ + private view: GraphView = "live"; + /** The timeline expands every generation instead of the most recent ones. */ private allHistory = false; /** Keep the newest transcript lines in view until the user scrolls up. */ private follow = true; @@ -150,7 +147,7 @@ export class BraidPanel implements Component { : "muted"; const focusLabel = this.focus ? "nodeId" in this.focus ? `node ${plain(this.focus.nodeId)}` : `execution ${plain(this.focus.executionId)}` - : this.choosing !== undefined ? this.view === "history" ? "select an execution" : "select a node" : ""; + : this.choosing !== undefined ? "select an execution" : ""; const heading = current ? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}${focusLabel ? ` · ${focusLabel}` : ""}` : this.theme.fg("muted", "No background jobs"); @@ -187,7 +184,10 @@ export class BraidPanel implements Component { rule("├", "┤"), ]; if (current && this.choosing !== undefined && !this.nodeIds(current).includes(this.choosing)) { - this.choosing = this.view === "history" ? this.nodeIds(current).at(-1) : this.nodeIds(current)[0]; + // A finished execution leaves the live view: follow its node's next one, if any. + const nodeId = this.executions(current)[this.choosing]?.id; + const ids = this.nodeIds(current); + this.choosing = ids.find(id => this.executions(current)[id]?.id === nodeId) ?? (this.view === "timeline" ? ids.at(-1) : ids[0]); } if (!current) this.assistantOpen = false; const available = rows - header.length - 4; @@ -206,10 +206,10 @@ export class BraidPanel implements Component { " " + border("│") + " " + (chat[at] ?? "")) : graph?.lines ?? chat; const tab = current ? "Tab assistant " : ""; - const history = this.view === "history"; - const toggle = history ? "g heads view" : "g history view"; - const expand = history ? ` e ${this.allHistory ? "recent" : "all"} history` : ""; - const noun = history ? "execution" : "node"; + const timeline = this.view === "timeline"; + const toggle = timeline ? "g live view" : "g timeline view"; + const expand = timeline ? ` e ${this.allHistory ? "recent" : "all"} history` : ""; + const noun = "execution"; // Longest first; the footer shows the first that fits. const hints = open ? ["Enter send Esc stop or hide Tab hide PgUp/PgDn or wheel scroll ctrl+c close", @@ -224,7 +224,7 @@ export class BraidPanel implements Component { `↑/↓ choose · Enter open · ${toggle} · Esc back`, "Enter open · Esc back"] : [`${tab}v select ${noun} ${toggle}${expand} ←/→ jobs ↑/↓ scroll p pause job c cancel Esc close`, `v select ${noun} ${toggle}${expand} ←/→ jobs p pause job c cancel Esc close`, - `v select · ${toggle} · ←/→ jobs · Esc close`, `v select · g ${history ? "heads" : "history"} · Esc close`]; + `v select · ${toggle} · ←/→ jobs · Esc close`, `v select · g ${timeline ? "live" : "timeline"} · Esc close`]; const help = hints.find(hint => visibleWidth(hint) <= contentWidth) ?? hints.at(-1)!; const status = graph ? graph.range : "Assistant"; return [ @@ -349,7 +349,7 @@ export class BraidPanel implements Component { /** The node whose conversation the assistant shows: the open session's, or the selected one. */ private assistantNode(job: JobSnapshot): string | undefined { - if (!this.focus) return this.choosing !== undefined && this.view === "history" ? this.executions(job)[this.choosing]?.id : this.choosing; + if (!this.focus) return this.choosing === undefined ? undefined : this.executions(job)[this.choosing]?.id; if ("nodeId" in this.focus) return this.focus.nodeId; try { return this.jobs.getNode(job.jobId, undefined, this.focus.executionId).id; } catch { return undefined; } @@ -365,15 +365,12 @@ export class BraidPanel implements Component { return "a node that is no longer in the graph"; } } - if (this.choosing !== undefined && this.view === "history") { - const execution = this.executions(job)[this.choosing]; - return `job execution history with execution ${this.choosing} of node ${JSON.stringify(execution?.id)} selected (${execution?.status ?? "unknown"})`; - } + const scope = this.view === "live" ? "live executions (pending, running, held)" : "timeline of every execution"; if (this.choosing !== undefined) { - const node = (job.result?.nodes ?? job.live.nodes)[this.choosing]; - return `job graph with node ${JSON.stringify(this.choosing)} selected (${node?.status ?? "pending"})`; + const execution = this.executions(job)[this.choosing]; + return `job graph, ${scope}, with execution ${this.choosing} of node ${JSON.stringify(execution?.id)} selected (${execution?.status ?? "unknown"})`; } - return `job graph ${this.view === "history" ? "execution history" : "overview"} (${job.status})`; + return `job graph, ${scope} (${job.status})`; } /** Every instance of the job, oldest first, including removed and superseded ones. */ @@ -381,37 +378,30 @@ export class BraidPanel implements Component { return job.result?.executions ?? job.execution?.executions ?? {}; } - /** - * What selection moves through, in the flowchart's declaration order: node IDs - * in the heads view, the unfolded execution IDs in the history view. - */ + /** The executions the current view draws, in the flowchart's declaration order. */ private nodeIds(job: JobSnapshot): string[] { - if (this.view === "history") return historyLayout(this.executions(job), this.allHistory).shown.map(record => record.executionId); - return Object.keys(job.result?.nodes ?? job.live.nodes); + return executionLayout(this.executions(job), this.view, this.allHistory).shown.map(record => record.executionId); } private choosingLine(job: JobSnapshot): string { - const history = this.view === "history"; - const node = this.choosing === undefined ? undefined - : history ? this.executions(job)[this.choosing] : (job.result?.nodes ?? job.live.nodes)[this.choosing]; + const execution = this.choosing === undefined ? undefined : this.executions(job)[this.choosing]; const ids = this.nodeIds(job); return this.theme.fg("warning", `${SELECTED_NODE_MARK} Selected ${ids.indexOf(this.choosing ?? "") + 1}/${ids.length}: `) + - this.theme.bold(plain(this.choosing ?? "")) + this.theme.fg("muted", ` · ${node?.status ?? "pending"} · Enter to open ${history ? "this execution's" : "its"} session`); + this.theme.bold(plain(this.choosing ?? "")) + this.theme.fg("muted", ` · ${execution?.status ?? "pending"} · Enter to open this execution's session`); } - /** g switches views and keeps the selection on the same lineage. */ + /** + * g switches views. A selection that the other view also draws stays; otherwise + * it moves to the newest drawn execution of the same node, or selection ends. + */ private toggleView(job: JobSnapshot | undefined): void { - const choosing = this.choosing; - const executions = job ? this.executions(job) : {}; - this.view = this.view === "heads" ? "history" : "heads"; + this.view = this.view === "live" ? "timeline" : "live"; this.offset = 0; this.revealed = undefined; - if (choosing === undefined || !job) return; - if (this.view === "heads") this.choosing = executions[choosing]?.id; - else { - const head = (job.result?.nodes ?? job.live.nodes)[choosing]?.executionId; - this.choosing = head && this.nodeIds(job).includes(head) ? head : this.executionsOf(job, choosing).filter(id => this.nodeIds(job).includes(id)).at(-1); - } + if (this.choosing === undefined || !job) return; + const ids = this.nodeIds(job); + const nodeId = this.executions(job)[this.choosing]?.id; + if (!ids.includes(this.choosing)) this.choosing = ids.filter(id => this.executions(job)[id]?.id === nodeId).at(-1); } /** A focused node can disappear after a live graph update; say so instead of failing. */ @@ -456,10 +446,8 @@ export class BraidPanel implements Component { return Object.values(executions).filter(execution => execution.id === nodeId).map(execution => execution.executionId); } - /** In the history view the selection is already an exact execution. */ - private open(job: JobSnapshot, nodeId: string): void { - const executionId = this.view === "history" ? nodeId : this.executionsOf(job, nodeId).at(-1); - this.focus = executionId ? { executionId } : { nodeId }; + private open(executionId: string): void { + this.focus = { executionId }; this.follow = true; this.offset = 0; } @@ -508,7 +496,7 @@ export class BraidPanel implements Component { return; } if (!this.focus && data === "g") this.toggleView(job); - else if (!this.focus && data === "e" && this.view === "history") { + else if (!this.focus && data === "e" && this.view === "timeline") { this.allHistory = !this.allHistory; this.notice = this.allHistory ? "Showing every generation" : "Showing recent generations · older settled executions are folded"; } else if (this.focus) this.sessionInput(data, job); @@ -518,10 +506,9 @@ export class BraidPanel implements Component { return; } else if (data === "v" && job) { const ids = this.nodeIds(job); - const history = this.view === "history"; - const status = (id: string) => history ? this.executions(job)[id]?.status : (job.result?.nodes ?? job.live.nodes)[id]?.status; - // The history view starts at the newest execution, the heads view at the first node. - this.choosing = ids.find(id => status(id) === "running") ?? (history ? ids.at(-1) : ids[0]); + // Start at a running execution; otherwise the timeline starts at the newest one. + this.choosing = ids.find(id => this.executions(job)[id]?.status === "running") ?? (this.view === "timeline" ? ids.at(-1) : ids[0]); + if (this.choosing === undefined) this.notice = "Nothing to select · press g for the timeline"; } else if (matchesKey(data, "left") || matchesKey(data, "right")) { const step = matchesKey(data, "right") ? 1 : -1; this.selected = jobs[(index + step + jobs.length) % jobs.length]?.jobId; @@ -587,8 +574,7 @@ export class BraidPanel implements Component { /** The open execution, the selected execution, or the selected node's latest one. */ private executionInView(job: JobSnapshot): string | undefined { if (this.focus) return "executionId" in this.focus ? this.focus.executionId : this.executionsOf(job, this.focus.nodeId).at(-1); - if (this.choosing === undefined) return undefined; - return this.view === "history" ? this.choosing : this.executionsOf(job, this.choosing).at(-1); + return this.choosing; } /** @@ -618,7 +604,7 @@ export class BraidPanel implements Component { if (matchesKey(data, "escape") || matchesKey(data, "backspace")) this.choosing = undefined; else if (matchesKey(data, "up") || matchesKey(data, "left")) this.choosing = ids[(at - 1 + ids.length) % ids.length]; else if (matchesKey(data, "down") || matchesKey(data, "right")) this.choosing = ids[(at + 1) % ids.length]; - else if (matchesKey(data, "enter") && this.choosing !== undefined) this.open(job, this.choosing); + else if (matchesKey(data, "enter") && this.choosing !== undefined) this.open(this.choosing); else this.scroll(data); } @@ -629,7 +615,7 @@ export class BraidPanel implements Component { let target: string | undefined; try { const node = job && ("nodeId" in focus ? this.jobs.getNode(job.jobId, focus.nodeId) : this.jobs.getNode(job.jobId, undefined, focus.executionId)); - target = this.view === "history" ? node?.executionId : node?.id; + target = node?.executionId; } catch { /* The node was removed; fall back to the plain graph. */ } this.focus = undefined; this.choosing = target !== undefined && job && this.nodeIds(job).includes(target) ? target : undefined; diff --git a/integrations/pi/display.ts b/integrations/pi/display.ts index 4058d47..b744d88 100644 --- a/integrations/pi/display.ts +++ b/integrations/pi/display.ts @@ -219,13 +219,17 @@ function nodeElapsed(node: NodeResult, now: number): string { /** Replaces the selected node's one-column status icon, so selection never moves the layout. */ export const SELECTED_NODE_MARK = "◆"; -/** The two views of the execution-only graph in the panel. */ -export type GraphView = "heads" | "history"; -/** Generations of each lineage the history view keeps expanded by default. */ -export const HISTORY_ROUNDS = 2; +/** + * The panel's views of the execution-only graph, one box per execution: live + * shows what has not released its result yet (pending, running, held); timeline + * shows every execution. + */ +export type GraphView = "live" | "timeline"; +/** Generations of each lineage the timeline keeps expanded by default. */ +export const TIMELINE_ROUNDS = 2; -/** One box per instance; older settled instances of a lineage fold into one box per lineage. */ -export interface HistoryLayout { +/** The executions a view draws; older settled ones of a lineage fold into one box per lineage. */ +export interface ExecutionLayout { shown: NodeExecution[]; folded: Map; } @@ -234,15 +238,17 @@ const settled = (record: NodeResult) => (record.status === "completed" || record.status === "failed" || record.status === "skipped") && !record.held; /** - * Pending, running, and held instances always stay expanded, as do the last - * HISTORY_ROUNDS generations of each lineage (every generation with `all`). - * Past MAX_VISIBLE_NODES boxes, the oldest settled instances fold first. + * The live view draws pending, running, and held executions only. The timeline + * always expands those too, plus the last TIMELINE_ROUNDS generations of each + * lineage (every generation with `all`); past MAX_VISIBLE_NODES boxes, the + * oldest settled executions fold first. */ -export function historyLayout(executions: Record, all = false): HistoryLayout { +export function executionLayout(executions: Record, view: GraphView, all = false): ExecutionLayout { const records = Object.values(executions); + if (view === "live") return { shown: records.filter(record => !settled(record)), folded: new Map() }; const latest = new Map(); for (const record of records) latest.set(record.id, Math.max(latest.get(record.id) ?? 0, record.generation)); - const keep = new Set(records.filter(record => all || !settled(record) || record.generation > latest.get(record.id)! - HISTORY_ROUNDS)); + const keep = new Set(records.filter(record => all || !settled(record) || record.generation > latest.get(record.id)! - TIMELINE_ROUNDS)); for (const record of records) { if (keep.size <= MAX_VISIBLE_NODES) break; if (settled(record) && record.generation < latest.get(record.id)!) keep.delete(record); @@ -330,7 +336,7 @@ function mermaidLabelLines( } /** One instance: its exact ID, status, and whether it was superseded or removed. */ -function historyLabelLines(record: NodeExecution, result: BraidResult | BraidLiveState, selected: boolean): string[] { +function executionLabelLines(record: NodeExecution, result: BraidResult | BraidLiveState, selected: boolean): string[] { const progress = "progress" in result ? result.progress?.[record.id] : undefined; const now = "observedAt" in result ? result.observedAt : Date.now(); const lines = [`${statusIcon(record, selected)} ${compact(record.executionId, 24)}`, statusText(record, now, progress)]; @@ -402,14 +408,14 @@ function mermaidSource(result: BraidResult | BraidLiveState, selected?: string): } /** - * The history view: a flat, acyclic graph of instances, so each round of a cycle - * is drawn below the one before it. Solid edges are the inputs an instance ran - * with; dotted edges are the inputs a pending instance will use or a skipped one - * saw, and "replaces" links a replaced instance to its replacement. + * A flat, acyclic graph of executions, so each round of a cycle is drawn below + * the one before it. Solid edges are the inputs an execution ran with; dotted + * edges are the inputs a pending execution will use or a skipped one saw, and + * "replaces" links a replaced execution to its replacement. Edges to executions + * the layout leaves out are not drawn. */ -function historySource(result: BraidResult | BraidLiveState, all: boolean, selected?: string): string { +function executionSource(result: BraidResult | BraidLiveState, { shown, folded }: ExecutionLayout, selected?: string): string { const executions = result.executions ?? {}; - const { shown, folded } = historyLayout(executions, all); const parts = graphParts(result); const closing = closingOf(parts); const names = new Map(); @@ -422,7 +428,7 @@ function historySource(result: BraidResult | BraidLiveState, all: boolean, selec shown.forEach((record, index) => { names.set(record.executionId, `e${index}`); const [open, close] = record.node.type === "decision" ? ["{", "}"] : ["[", "]"]; - lines.push(` e${index}${open}"${historyLabelLines(record, result, record.executionId === selected).map(escapeMermaidText).join("
")}"${close}`); + lines.push(` e${index}${open}"${executionLabelLines(record, result, record.executionId === selected).map(escapeMermaidText).join("
")}"${close}`); }); const drawn = new Set(); const link = (from: NodeExecution | undefined, to: NodeExecution, arrow: string, label?: string) => { @@ -468,7 +474,9 @@ function mermaidLines( ): string[] { let art: ReturnType; try { - art = renderMermaid(options.view === "history" ? historySource(result, options.allHistory ?? false, selected) : mermaidSource(result, selected)); + art = renderMermaid(options.view + ? executionSource(result, executionLayout(result.executions ?? {}, options.view, options.allHistory), selected) + : mermaidSource(result, selected)); } catch { // The chart is a view of state already listed elsewhere; never fail the panel for it. return []; @@ -921,13 +929,13 @@ export function renderNodeDetails( } export interface GraphViewOptions { - /** Default "heads": one box per lineage head. */ + /** One box per execution. Without it, tool results draw one box per node: its latest execution. */ view?: GraphView; - /** In the history view, expand every generation instead of the last HISTORY_ROUNDS. */ + /** In the timeline, expand every generation instead of the last TIMELINE_ROUNDS. */ allHistory?: boolean; } -/** In the history view, `selectedNodeId` is an execution ID. */ +/** With a view, `selectedNodeId` is an execution ID. */ export function renderGraphResult( result: BraidToolDetails, expanded: boolean, @@ -982,20 +990,21 @@ export function renderGraphResult( if ("pause" in result && result.pause) lines.push(theme.fg("warning", `job paused by ${compact(result.pause.source, 20)} · no new executions start`)); if ("pausedExecutionIds" in result && result.pausedExecutionIds?.length) lines.push(theme.fg("warning", `${result.pausedExecutionIds.length} paused executions · revision ${result.revision ?? 0}`)); if ("suspendedExecutionIds" in result && result.suspendedExecutionIds?.length) lines.push(theme.fg("warning", `${result.suspendedExecutionIds.length} suspended executions · revision ${result.revision ?? 0}`)); - const history = options.view === "history"; + const byExecution = options.view !== undefined; if (result.executions) lines.push(theme.fg("dim", `${Object.keys(result.executions).length} executions · revision ${result.revision ?? 0}`)); - const layout = history ? historyLayout(result.executions ?? {}, options.allHistory) : undefined; + const layout = options.view ? executionLayout(result.executions ?? {}, options.view, options.allHistory) : undefined; const folded = layout ? [...layout.folded.values()].reduce((sum, records) => sum + records.length, 0) : 0; if (layout && folded) lines.push(theme.fg("dim", `${folded} older settled executions folded into ${layout.folded.size} ≡ boxes`)); + if (options.view === "live" && !layout!.shown.length) lines.push(theme.fg("muted", "Nothing is pending, running, or held · press g for the timeline")); const chartStart = lines.length; - const chart = mermaidLines(result, theme, selectedNodeId, options); + const chart = layout && !layout.shown.length && !folded ? [] : mermaidLines(result, theme, selectedNodeId, options); lines.push(...chart); // Selection must stay usable when the chart cannot be drawn or does not fit. const entries = layout ? layout.shown.map(record => [record.executionId, record.status] as const) : Object.keys(graphParts(result).nodes).map(id => [id, result.nodes[id]?.status ?? "pending"] as const); const nodeList = selectedNodeId === undefined ? [] : [ - theme.fg("dim", chart.length ? `${history ? "executions" : "nodes"} (selected ${history ? "execution" : "node"} not found in the flowchart):` : `${history ? "executions" : "nodes"} (flowchart unavailable):`), + theme.fg("dim", chart.length ? `${byExecution ? "executions" : "nodes"} (selected ${byExecution ? "execution" : "node"} not found in the flowchart):` : `${byExecution ? "executions" : "nodes"} (flowchart unavailable):`), ...entries.map(([id, status]) => { const text = `${id === selectedNodeId ? `${SELECTED_NODE_MARK} ` : " "}${compact(id, 60)} · ${status}`; return id === selectedNodeId ? theme.bg("selectedBg", theme.bold(theme.fg("warning", text))) : theme.fg("muted", text); @@ -1021,7 +1030,7 @@ export function renderGraphResult( ), ); return new FixedLines(lines, chartStart, chart.length, nodeList, [ - ...(selectedNodeId === undefined ? [] : [{ mark: SELECTED_NODE_MARK, label: history ? "selected execution" : "selected node" }]), - { mark: "▶ ACTIVE", label: history ? "running execution" : "running node" }, + ...(selectedNodeId === undefined ? [] : [{ mark: SELECTED_NODE_MARK, label: byExecution ? "selected execution" : "selected node" }]), + { mark: "▶ ACTIVE", label: byExecution ? "running execution" : "running node" }, ]); } diff --git a/integrations/pi/test/assistant.test.ts b/integrations/pi/test/assistant.test.ts index 94c1dc8..fbb5bb9 100644 --- a/integrations/pi/test/assistant.test.ts +++ b/integrations/pi/test/assistant.test.ts @@ -173,12 +173,13 @@ test("Tab toggles the assistant: beside the graph in a wide panel, in place of i assert.doesNotMatch(view, /Assistant ·/, "hidden until Tab"); assert.match(view, /Tab assistant/); + panel.handleInput("g"); panel.handleInput("\t"); view = text(panel); assert.match(view, /Assistant · job/, "the graph view talks about the whole job"); - assert.match(view, /a done/, "a wide panel keeps the graph beside it"); + assert.match(view, /a@1 done/, "a wide panel keeps the graph beside it"); assert.ok(view.split("\n").every(line => line.length === view.split("\n")[0]!.length), "both panes fit the panel width"); - assert.doesNotMatch(text(panel, 90), /a done/, "a narrow panel shows only the assistant"); + assert.doesNotMatch(text(panel, 90), /a@1 done/, "a narrow panel shows only the assistant"); for (const key of "qc what happened?") panel.handleInput(key); assert.equal(closed, 0, "q is typed, not a close"); @@ -188,7 +189,7 @@ test("Tab toggles the assistant: beside the graph in a wide panel, in place of i view = text(panel); assert.match(view, /qc what happened\?/); assert.match(view, /Node a printed its result\./); - assert.match(String(requests[0]!.messages[0]!.content), /\[Panel view: job graph overview \(completed\)\]/); + assert.match(String(requests[0]!.messages[0]!.content), /\[Panel view: job graph, timeline of every execution \(completed\)\]/); panel.handleInput("\t"); assert.doesNotMatch(text(panel), /Assistant ·/, "Tab hides it again"); @@ -226,7 +227,10 @@ test("node conversations are separate from the job's, bound to the node across i panel.handleInput("\t"); }; + // The timeline shows finished executions; selection starts at the newest one. + panel.handleInput("g"); panel.handleInput("v"); + panel.handleInput("\x1b[A"); await ask("what did a do"); panel.handleInput("\x1b[B"); await ask("what did b do"); diff --git a/integrations/pi/test/graph-views.test.ts b/integrations/pi/test/graph-views.test.ts index 80dd7f3..936bf5a 100644 --- a/integrations/pi/test/graph-views.test.ts +++ b/integrations/pi/test/graph-views.test.ts @@ -4,9 +4,9 @@ import type { Context } from "@earendil-works/pi-ai"; import { stripTerminalSequences } from "@earendil-works/pi-tui"; import type { NodeExecution } from "@chrok/braid"; import { BraidPanel } from "../command.js"; -import { createLiveState, historyLayout, HISTORY_ROUNDS, MAX_VISIBLE_NODES, renderGraphResult } from "../display.js"; +import { createLiveState, executionLayout, MAX_VISIBLE_NODES, renderGraphResult, TIMELINE_ROUNDS, type GraphView } from "../display.js"; import { BraidJobs } from "../jobs.js"; -import { context, response } from "./helpers.js"; +import { context, deferred, response } from "./helpers.js"; const theme = { fg: (_color: string, value: string) => value, @@ -41,11 +41,11 @@ function replacedState() { return { ...live, executions }; } -const chart = (details: ReturnType, view: "heads" | "history", selected?: string) => - renderGraphResult(details, false, false, theme, "", false, selected, { view }).render(400).join("\n"); +const chart = (details: ReturnType, view?: GraphView, selected?: string) => + renderGraphResult(details, false, false, theme, "", false, selected, view ? { view } : {}).render(400).join("\n"); -test("the heads view shows each head's wait reason, running sub-state, runs, and lineage usage", () => { - const view = chart(replacedState(), "heads"); +test("tool-result charts show each node's wait reason, running sub-state, runs, and lineage usage", () => { + const view = chart(replacedState()); // Labels wrap at 24 columns, so match words rather than whole lines. assert.match(view, /draft@2 running/); assert.match(view, /· tool ·/, "the running sub-state is the worker's phase"); @@ -53,65 +53,89 @@ test("the heads view shows each head's wait reason, running sub-state, runs, and assert.match(view, /\b3K\b/, "usage sums the lineage"); assert.match(view, /pending · waits/); assert.match(view, /predecessors/); - assert.doesNotMatch(view, /old/, "a removed lineage has no head"); + assert.doesNotMatch(view, /old/, "a removed lineage has no box"); assert.doesNotMatch(view, /draft@1/); }); -test("the history view keeps superseded and removed instances and links replacements", () => { - const view = chart(replacedState(), "history"); +test("the live view draws only pending, running, and held executions", () => { + const details = replacedState(); + let view = chart(details, "live"); + assert.match(view, /▶ ACTIVE draft@2/); + assert.match(view, /○ check@1/); + assert.match(view, /waits/); + assert.match(view, /╎/, "a pending execution's expected input is dotted"); + assert.doesNotMatch(view, /draft@1|old@1/, "settled executions are left out"); + details.executions["old@1"] = { ...details.executions["old@1"]!, held: true }; + view = chart(details, "live"); + assert.match(view, /old@1/, "a held result has not gone downstream yet"); + assert.match(view, /held/); + for (const id of Object.keys(details.executions)) details.executions[id] = { ...details.executions[id]!, status: "completed", held: false }; + view = chart(details, "live"); + assert.match(view, /Nothing is pending, running, or held · press g for the timeline/); + assert.doesNotMatch(view, /┌|╭/, "no empty chart is drawn"); +}); + +test("the timeline keeps superseded and removed executions and links replacements", () => { + const view = chart(replacedState(), "timeline"); for (const id of ["draft@1", "draft@2", "check@1", "old@1"]) assert.match(view, new RegExp(id)); assert.match(view, /superseded → draft@2/); - assert.match(view, /replaces/, "the replaced instance links to its replacement"); - assert.match(view, /removed/, "an instance of a removed lineage is kept and marked"); - assert.match(view, /╎/, "a pending instance's expected input is dotted"); + assert.match(view, /replaces/, "the replaced execution links to its replacement"); + assert.match(view, /removed/, "an execution of a removed node is kept and marked"); + assert.match(view, /╎/, "a pending execution's expected input is dotted"); assert.doesNotMatch(view, /folded/); - const selected = chart(replacedState(), "history", "draft@1"); + const selected = chart(replacedState(), "timeline", "draft@1"); assert.match(selected, /◆ draft@1/, "selection marks the exact execution"); - const cropped = renderGraphResult(replacedState(), false, false, theme, "", false, "missing@1", { view: "history" }).render(30).join("\n"); + const cropped = renderGraphResult(replacedState(), false, false, theme, "", false, "missing@1", { view: "timeline" }).render(30).join("\n"); assert.match(cropped, /executions \(selected execution/, "a cropped chart without the selection lists executions"); assert.match(cropped, /draft@1 · completed/); }); -test("skipped instances are drawn differently from pending ones", () => { +test("skipped executions are drawn differently from pending ones", () => { const details = replacedState(); details.executions["check@1"] = execution("check", 1, { status: "skipped", skipReason: "inactive", finishedAt: 5 }); details.executions["draft@2"] = execution("draft", 2, { supersedes: "draft@1", finishedAt: 4 }); - const view = chart(details, "history"); + const view = chart(details, "timeline"); assert.match(view, /⊘ check@1/); assert.match(view, /skipped ·/); assert.match(view, /inactive/); assert.doesNotMatch(view, /○ check@1/); - assert.match(view, /╎/, "the skipped instance shows the upstream result it saw"); + assert.match(view, /╎/, "the skipped execution shows the upstream result it saw"); }); -test("history folds older settled generations per lineage, never active or held ones", () => { +test("the timeline folds older settled generations per node, never active or held ones", () => { const executions: Record = {}; for (let generation = 1; generation <= 5; generation++) { executions[`edit@${generation}`] = execution("edit", generation, generation === 2 ? { held: true } : {}); executions[`review@${generation}`] = execution("review", generation, generation === 5 ? { status: "running" } : {}); } - const recent = historyLayout(executions); - assert.deepEqual(recent.shown.map(record => record.executionId), ["edit@2", "edit@4", "review@4", "edit@5", "review@5"]); - assert.equal(HISTORY_ROUNDS, 2); - assert.deepEqual(recent.folded.get("edit")!.map(record => record.executionId), ["edit@1", "edit@3"]); - assert.deepEqual(recent.folded.get("review")!.map(record => record.executionId), ["review@1", "review@2", "review@3"]); - assert.equal(historyLayout(executions, true).shown.length, 10); - assert.equal(historyLayout(executions, true).folded.size, 0); + const ids = (records: readonly NodeExecution[]) => records.map(record => record.executionId); + const recent = executionLayout(executions, "timeline"); + assert.deepEqual(ids(recent.shown), ["edit@2", "edit@4", "review@4", "edit@5", "review@5"]); + assert.equal(TIMELINE_ROUNDS, 2); + assert.deepEqual(ids(recent.folded.get("edit")!), ["edit@1", "edit@3"]); + assert.deepEqual(ids(recent.folded.get("review")!), ["review@1", "review@2", "review@3"]); + assert.equal(executionLayout(executions, "timeline", true).shown.length, 10); + assert.equal(executionLayout(executions, "timeline", true).folded.size, 0); + const live = executionLayout(executions, "live"); + assert.deepEqual(ids(live.shown), ["edit@2", "review@5"]); + assert.equal(live.folded.size, 0); const many: Record = {}; for (let generation = 1; generation <= MAX_VISIBLE_NODES + 20; generation++) many[`step@${generation}`] = execution("step", generation); - const capped = historyLayout(many, true); + const capped = executionLayout(many, "timeline", true); assert.equal(capped.shown.length, MAX_VISIBLE_NODES, "even all history stays within the box limit"); assert.equal(capped.shown.at(-1)!.generation, MAX_VISIBLE_NODES + 20, "the oldest generations fold first"); const view = renderGraphResult({ ...createLiveState(), nodes: { edit: { id: "edit", status: "completed" }, review: { id: "review", status: "running" } }, executions }, - false, false, theme, "", false, undefined, { view: "history" }).render(400).join("\n"); + false, false, theme, "", false, undefined, { view: "timeline" }).render(400).join("\n"); assert.match(view, /5 older settled executions folded into 2 ≡ boxes/); assert.match(view, /≡ edit@1–3/); assert.match(view, /≡ review@1–3/); }); -test("g switches the panel between heads and history; selection and Enter follow exact executions", async t => { +test("g switches the panel between live and timeline; selection and Enter follow exact executions", { timeout: 5_000 }, async t => { + const started = deferred(); + const gate = deferred(); const ctx = context(async () => response()); t.mock.method(ctx.modelRegistry, "complete", async (_model: unknown, worker: Context) => { const payload = JSON.parse(worker.messages[0]!.content as string) as { nodeId: string; execution: { generation: number } }; @@ -119,6 +143,10 @@ test("g switches the panel between heads and history; selection and Enter follow ...response(), stopReason: "toolUse" as const, content: [{ type: "toolCall" as const, id: "choice", name: "decide", arguments: { choice: payload.execution.generation === 4 ? "done" : "again" } }], }; + if (payload.nodeId === "work" && payload.execution.generation === 3) { + started.resolve(); + await gate.promise; + } return response(`round ${payload.execution.generation}`); }); const jobs = new BraidJobs(); @@ -132,28 +160,36 @@ test("g switches the panel between heads and history; selection and Enter follow { from: "work", to: "review" }, { from: "review", to: "work", choice: "again", limit: 5 }, { from: "review", to: "ship", choice: "done" }, { from: "review", to: "human", choice: "escalate" }, ] }, {}, ctx); - await jobs.wait(job.jobId); + await started.promise; const panel = new BraidPanel(jobs, { requestRender: () => {}, terminal: { rows: 80 } } as never, theme, () => {}, job.handle); t.after(() => panel.dispose()); const text = () => stripTerminalSequences(panel.render(120).join("\n")); let view = text(); - assert.match(view, /^╭ Braid · heads view /); - assert.match(view, /g history view/); - assert.match(view, /work@4/); - assert.match(view, /4 runs/); - assert.doesNotMatch(view, /✓ review@3/); + assert.match(view, /^╭ Braid · live view /, "the live view is the default"); + assert.match(view, /g timeline view/); + assert.match(view, /▶ ACTIVE work@3/); + assert.match(view, /○ ship@1/, "pending executions are live"); + assert.doesNotMatch(view, /✓ (work|review)@/, "settled executions are not"); + panel.handleInput("v"); + assert.match(text(), /◆ Selected 3\/3: work@3 · running · Enter to open this execution's session/); panel.handleInput("g"); view = text(); - assert.match(view, /^╭ Braid · history view /); - assert.match(view, /g heads view/); + assert.match(view, /^╭ Braid · timeline view /); + assert.match(view, /g live view/); assert.match(view, /e all history/); - assert.match(view, /≡ work@1–2/); - assert.match(view, /✓ review@3/); + assert.match(view, /◆ Selected 6\/6: work@3/, "a selection both views draw stays"); + assert.match(view, /≡ work@1/); + assert.match(view, /✓ review@2/); + + gate.resolve(); + await jobs.wait(job.jobId); + panel.handleInput("\x1b"); + view = text(); assert.match(view, /⊘ human@1/, "the branch that was never taken is skipped, not pending"); + assert.match(view, /≡ work@1–2/); assert.doesNotMatch(view, /✓ work@1/); - panel.handleInput("e"); view = text(); assert.match(view, /✓ work@1/); @@ -161,29 +197,23 @@ test("g switches the panel between heads and history; selection and Enter follow assert.match(view, /e recent history/); panel.handleInput("v"); - assert.match(text(), /◆ Selected 10\/10: review@4 · completed · Enter to open this execution's session/); - assert.match(text(), /select an execution/); - panel.handleInput("\x1b[D"); - assert.match(text(), /◆ Selected 9\/10: work@4/); - for (let index = 0; index < 4; index++) panel.handleInput("\x1b[D"); + assert.match(text(), /◆ Selected 10\/10: review@4 · completed/, "the timeline starts at the newest execution"); + for (let index = 0; index < 5; index++) panel.handleInput("\x1b[D"); assert.match(text(), /◆ Selected 5\/10: work@2/); panel.handleInput("\r"); view = text(); assert.match(view, /Braid node work · completed/); - assert.match(view, /work@2/); - assert.match(view, /execution 2 of 4 for this node/, "Enter opens that exact execution, not the head"); - + assert.match(view, /execution 2 of 4 for this node/, "Enter opens that exact execution, not the latest"); panel.handleInput("\x1b"); assert.match(text(), /◆ Selected 5\/10: work@2/, "Esc returns to the same execution"); + panel.handleInput("g"); view = text(); - assert.match(view, /^╭ Braid · heads view /); - assert.match(view, /◆ Selected 1\/4: work/, "switching views keeps the lineage selected"); - panel.handleInput("\r"); - assert.match(text(), /execution 4 of 4 for this node/, "Enter in the heads view opens the head"); - panel.handleInput("\x1b"); + assert.match(view, /^╭ Braid · live view /); + assert.match(view, /Nothing is pending, running, or held · press g for the timeline/); + assert.doesNotMatch(view, /Selected/, "a selection the live view does not draw ends"); + panel.handleInput("v"); + assert.match(text(), /Nothing to select/); panel.handleInput("g"); - assert.match(text(), /◆ Selected \d+\/10: work@4/, "switching back selects the head's execution"); - panel.handleInput("\x1b"); - assert.match(text(), /^╭ Braid · history view /, "the view is remembered after leaving selection"); + assert.match(text(), /^╭ Braid · timeline view /, "the view is remembered after leaving selection"); }); diff --git a/integrations/pi/test/panel.test.ts b/integrations/pi/test/panel.test.ts index a515d91..d4046b6 100644 --- a/integrations/pi/test/panel.test.ts +++ b/integrations/pi/test/panel.test.ts @@ -215,10 +215,15 @@ test("panel focuses one node with live details and returns to the graph", async assert.doesNotMatch(focused, /Braid completed/); assert.ok(panel.render(100).every((line) => visibleWidth(line) === 100)); panel.handleInput("\x7f"); - const selecting = panel.render(100).join("\n"); - assert.match(selecting, /Braid completed/); - assert.match(selecting, /◆ Selected 2\/2: b/, "leaving a session returns to selection on the same node"); - assert.doesNotMatch(selecting, /Braid node b|Esc back ←\/→ executions/); + const live = panel.render(100).join("\n"); + assert.match(live, /Braid completed/); + assert.doesNotMatch(live, /Selected/, "the live view does not draw a finished execution, so selection ends"); + assert.doesNotMatch(live, /Braid node b|Esc back ←\/→ executions/); + panel.handleInput("g"); + panel.handleInput("v"); + panel.handleInput("\r"); + panel.handleInput("\x7f"); + assert.match(panel.render(100).join("\n"), /◆ Selected 2\/2: b@1/, "leaving a session returns to selection on the same execution"); panel.handleInput("\x1b"); assert.doesNotMatch(panel.render(100).join("\n"), /Selected/); diff --git a/integrations/pi/test/session.test.ts b/integrations/pi/test/session.test.ts index c2016ba..0e26ba1 100644 --- a/integrations/pi/test/session.test.ts +++ b/integrations/pi/test/session.test.ts @@ -31,7 +31,7 @@ async function until(check: () => boolean): Promise { } } -test("v selects nodes with arrow keys, Enter opens a session, and Esc steps back one level", async t => { +test("v selects executions with arrow keys, Enter opens a session, and Esc steps back one level", async t => { const jobs = new BraidJobs(); t.after(() => jobs.dispose()); const job = jobs.start(chain, {}, context(async () => response("Chain step finished"))); @@ -39,19 +39,20 @@ test("v selects nodes with arrow keys, Enter opens a session, and Esc steps back let closed = 0; const panel = new BraidPanel(jobs, tui, theme, () => { closed++; }, job.handle); t.after(() => panel.dispose()); - assert.match(text(panel), /v select node/); + assert.match(text(panel), /v select execution/); assert.doesNotMatch(text(panel), /◆/); + // Finished executions are drawn in the timeline, where selection starts at the newest. + panel.handleInput("g"); panel.handleInput("v"); let view = text(panel); - assert.match(view, /◆ Selected 1\/2: a · completed/); - assert.match(view, /◆ a/, "the chart marks the selected node in place of its status icon"); + assert.match(view, /◆ Selected 2\/2: b@1 · completed/); + assert.match(view, /◆ b@1/, "the chart marks the selected execution in place of its status icon"); assert.match(view, /Enter open session/); panel.handleInput("\x1b[B"); - assert.match(text(panel), /◆ Selected 2\/2: b/); + assert.match(text(panel), /◆ Selected 1\/2: a@1/, "selection wraps around"); panel.handleInput("\x1b[C"); - assert.match(text(panel), /◆ Selected 1\/2: a/, "selection wraps around"); - panel.handleInput("\x1b[D"); + assert.match(text(panel), /◆ Selected 2\/2: b@1/); panel.handleInput("\r"); view = text(panel); @@ -61,7 +62,7 @@ test("v selects nodes with arrow keys, Enter opens a session, and Esc steps back assert.match(view, /context: 1 predecessor output/); panel.handleInput("\x1b"); - assert.match(text(panel), /◆ Selected 2\/2: b/); + assert.match(text(panel), /◆ Selected 2\/2: b@1/); panel.handleInput("\x1b"); assert.doesNotMatch(text(panel), /Selected/); assert.equal(closed, 0); @@ -187,15 +188,17 @@ test("a flowchart wider than the panel is cropped around the selected node", asy const panel = new BraidPanel(jobs, { requestRender: () => {}, terminal: { rows: 40 } } as never, theme, () => {}, job.handle); t.after(() => panel.dispose()); const lines = () => text(panel, 80).split("\n"); + panel.handleInput("g"); assert.ok(lines().some(line => /\[chart 1–76 of \d+ cols\]/.test(line)), "outside selection the left edge is shown"); assert.ok(lines().some(line => line.includes("reviewer-number-0"))); panel.handleInput("v"); + panel.handleInput("\x1b[C"); for (let index = 0; index < 6; index++) panel.handleInput("\x1b[C"); let view = lines(); - assert.ok(view.some(line => /\[chart \d+–\d+ of \d+ cols · selected node\]/.test(line)), view.join("\n")); + assert.ok(view.some(line => /\[chart \d+–\d+ of \d+ cols · selected execution\]/.test(line)), view.join("\n")); assert.ok(view.some(line => line.includes("◆ reviewer-number-6")), "the selected node's box is in the window"); - assert.ok(!view.some(line => line.includes("reviewer-number-0 done")), "distant nodes are cropped away"); - assert.ok(!view.some(line => line.startsWith("│ nodes")), "no fallback list while the selection is drawn"); + assert.ok(!view.some(line => line.includes("reviewer-number-0@1 done")), "distant executions are cropped away"); + assert.ok(!view.some(line => line.startsWith("│ executions")), "no fallback list while the selection is drawn"); assert.ok(view.every(line => line.length === view[0]!.length), "cropping keeps every row within the panel"); panel.handleInput("\x1b[D"); view = lines(); @@ -213,7 +216,9 @@ test("selecting a node scrolls a tall flowchart to keep it in view", async t => await jobs.wait(job.jobId); const panel = new BraidPanel(jobs, { requestRender: () => {}, terminal: { rows: 24 } } as never, theme, () => {}, job.handle); t.after(() => panel.dispose()); + panel.handleInput("g"); panel.handleInput("v"); + panel.handleInput("\x1b[B"); const body = () => text(panel, 100).split("\n").slice(7); assert.ok(body().some(line => line.includes("◆ step-0"))); for (let index = 0; index < 7; index++) panel.handleInput("\x1b[B"); diff --git a/integrations/pi/test/timeout-pause.test.ts b/integrations/pi/test/timeout-pause.test.ts index dfb3597..fac3ce0 100644 --- a/integrations/pi/test/timeout-pause.test.ts +++ b/integrations/pi/test/timeout-pause.test.ts @@ -134,7 +134,7 @@ test("a timeout pause wakes the parent, and x in /braid cancels the execution af await commands.get("braid")!.handler("", { mode: "tui", ui: { custom: async (factory: (...args: unknown[]) => BraidPanel) => { panel = factory({ requestRender: () => {}, terminal: { rows: 30 } }, plainTheme, {}, () => {}); } } }); - assert.match(panel.render(160).join("\n"), /‖ PAUSED a suspended ·[\s│]*timed out/); + assert.match(panel.render(160).join("\n"), /‖ PAUSED a@1 suspended[\s│]*·[\s│]*timed out/); panel.handleInput("v"); panel.handleInput("x"); assert.match(panel.render(160).join("\n"), /Press x again to cancel this execution/); From f6a6c333ac17dc644af1301ea3c00f0bd8732583 Mon Sep 17 00:00:00 2001 From: Chrok Date: Sun, 25 Oct 2026 22:03:08 +0800 Subject: [PATCH 3/3] Return from a session to the node's newest drawn execution when the view does not draw the open one --- integrations/pi/README.md | 5 ++++- integrations/pi/command.ts | 20 +++++++++++++------- integrations/pi/test/graph-views.test.ts | 7 +++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/integrations/pi/README.md b/integrations/pi/README.md index 05b2290..906e2bc 100644 --- a/integrations/pi/README.md +++ b/integrations/pi/README.md @@ -444,7 +444,10 @@ selection in sight. If the flowchart cannot be drawn, a list replaces it while selecting. Pressing `g` while selecting keeps the same execution selected when the other view draws it, or moves to the newest drawn execution of the same node. Enter opens that exact execution as a live session, and `p`, `h`, and `x` -act on it. Escape returns to the same execution. The session is drawn with Pi's own chat +act on it. Left/Right in the session switch to the node's other executions, +including folded and settled ones. Escape returns to the execution you were +viewing, or, when the current view does not draw it, to the newest drawn +execution of the same node. The session is drawn with Pi's own chat components, as in the main conversation: the worker's prompt, streamed assistant text and thinking, and tool cards whose arguments and output update while they run. The node summary—status, elapsed time, usage, context, and live diff --git a/integrations/pi/command.ts b/integrations/pi/command.ts index eeee66c..9a25d8d 100644 --- a/integrations/pi/command.ts +++ b/integrations/pi/command.ts @@ -393,15 +393,22 @@ export class BraidPanel implements Component { /** * g switches views. A selection that the other view also draws stays; otherwise * it moves to the newest drawn execution of the same node, or selection ends. + * Leaving a session does the same. */ private toggleView(job: JobSnapshot | undefined): void { this.view = this.view === "live" ? "timeline" : "live"; this.offset = 0; this.revealed = undefined; if (this.choosing === undefined || !job) return; - const ids = this.nodeIds(job); const nodeId = this.executions(job)[this.choosing]?.id; - if (!ids.includes(this.choosing)) this.choosing = ids.filter(id => this.executions(job)[id]?.id === nodeId).at(-1); + this.choosing = nodeId === undefined ? undefined : this.drawn(job, nodeId, this.choosing); + } + + /** The execution if the current view draws it, otherwise the newest drawn one of its node. */ + private drawn(job: JobSnapshot, nodeId: string, executionId: string | undefined): string | undefined { + const ids = this.nodeIds(job); + if (executionId !== undefined && ids.includes(executionId)) return executionId; + return ids.filter(id => this.executions(job)[id]?.id === nodeId).at(-1); } /** A focused node can disappear after a live graph update; say so instead of failing. */ @@ -610,15 +617,14 @@ export class BraidPanel implements Component { private sessionInput(data: string, job: JobSnapshot | undefined): void { if (matchesKey(data, "escape") || matchesKey(data, "backspace")) { - // Return to selection on the same node or execution, so another is one keypress away. + // Return to selection on the same execution, so another is one keypress away. const focus = this.focus!; - let target: string | undefined; + let node; try { - const node = job && ("nodeId" in focus ? this.jobs.getNode(job.jobId, focus.nodeId) : this.jobs.getNode(job.jobId, undefined, focus.executionId)); - target = node?.executionId; + node = job && ("nodeId" in focus ? this.jobs.getNode(job.jobId, focus.nodeId) : this.jobs.getNode(job.jobId, undefined, focus.executionId)); } catch { /* The node was removed; fall back to the plain graph. */ } this.focus = undefined; - this.choosing = target !== undefined && job && this.nodeIds(job).includes(target) ? target : undefined; + this.choosing = node && job ? this.drawn(job, node.id, node.executionId) : undefined; this.offset = 0; } else if ((matchesKey(data, "left") || matchesKey(data, "right")) && job) { let node; diff --git a/integrations/pi/test/graph-views.test.ts b/integrations/pi/test/graph-views.test.ts index 936bf5a..0d88f35 100644 --- a/integrations/pi/test/graph-views.test.ts +++ b/integrations/pi/test/graph-views.test.ts @@ -190,6 +190,13 @@ test("g switches the panel between live and timeline; selection and Enter follow assert.match(view, /⊘ human@1/, "the branch that was never taken is skipped, not pending"); assert.match(view, /≡ work@1–2/); assert.doesNotMatch(view, /✓ work@1/); + panel.handleInput("v"); + panel.handleInput("\r"); + for (let index = 0; index < 3; index++) panel.handleInput("\x1b[D"); + assert.match(text(), /execution 1 of 4 for this node/, "←/→ in a session reach folded executions too"); + panel.handleInput("\x1b"); + assert.match(text(), /◆ Selected \d+\/\d+: review@4/, "leaving a folded execution selects its node's newest drawn one"); + panel.handleInput("\x1b"); panel.handleInput("e"); view = text(); assert.match(view, /✓ work@1/);