pi-umbra 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +7 -5
  2. package/node_modules/pi-umbra-help/README.md +1 -0
  3. package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +7 -0
  4. package/node_modules/pi-umbra-help/extensions/umbra-help.ts +12 -1
  5. package/node_modules/pi-umbra-help/package.json +1 -1
  6. package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +2 -1
  7. package/node_modules/pi-umbra-inputbar/package.json +1 -1
  8. package/node_modules/pi-umbra-shimmer/package.json +1 -1
  9. package/node_modules/pi-umbra-shimmer/patch.mjs +21 -7
  10. package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
  11. package/node_modules/pi-umbra-skill-matcher/patch.mjs +21 -7
  12. package/node_modules/pi-umbra-subagents/LICENSE +21 -0
  13. package/node_modules/pi-umbra-subagents/README.md +101 -0
  14. package/node_modules/pi-umbra-subagents/extensions/umbra-loop.ts +102 -0
  15. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +262 -0
  16. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +198 -0
  17. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.ts +229 -0
  18. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +141 -0
  19. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/models.ts +137 -0
  20. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +88 -0
  21. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +140 -0
  22. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +490 -0
  23. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.check.ts +237 -0
  24. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.ts +378 -0
  25. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/SKILL.md +95 -0
  26. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/beacon.ts +210 -0
  27. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/delegate.env +12 -0
  28. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/report.md +15 -0
  29. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.check.sh +120 -0
  30. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.sh +170 -0
  31. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.check.ts +137 -0
  32. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +295 -0
  33. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +52 -0
  34. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents.ts +4 -0
  35. package/node_modules/pi-umbra-subagents/package.json +43 -0
  36. package/node_modules/pi-umbra-subagents/patch.mjs +97 -0
  37. package/node_modules/pi-umbra-theme/README.md +39 -16
  38. package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +44 -1
  39. package/node_modules/pi-umbra-theme/checks/umbra-image-viewer.check.ts +85 -0
  40. package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +20 -1
  41. package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +17 -10
  42. package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +52 -1
  43. package/node_modules/pi-umbra-theme/extensions/umbra-image-viewer.ts +230 -0
  44. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/compact-mode.ts +3 -3
  45. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/hover.ts +0 -4
  46. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/interaction.ts +20 -68
  47. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/layout.ts +0 -4
  48. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/scroll.ts +6 -190
  49. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/diff-palette.ts +44 -3
  50. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/shiki-highlight.ts +5 -3
  51. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/names.ts +1 -0
  52. package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +16 -6
  53. package/node_modules/pi-umbra-theme/package.json +1 -1
  54. package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +1 -1
  55. package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +4 -4
  56. package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +10 -10
  57. package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +5 -5
  58. package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +20 -20
  59. package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +4 -4
  60. package/package.json +16 -9
@@ -0,0 +1,237 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { visibleWidth, type TUI } from "@earendil-works/pi-tui";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import type { BranchView, RunState } from "./skills/delegate/state.ts";
5
+ import { createPanel, type PanelSource } from "./panel.ts";
6
+
7
+ // The one runnable check for the panel. Run it with:
8
+ // node --experimental-strip-types build/panel.check.ts
9
+ // (node resolves "@earendil-works/*" through build/node_modules, a junction to the copy pi
10
+ // already has: mklink /J build\node_modules %USERPROFILE%\.bun\install\global\node_modules)
11
+ //
12
+ // It renders the component against a stub theme, a stub TUI and a stub source, so it never
13
+ // starts pi, never reads a run directory and never kills anything. What it protects is the
14
+ // arithmetic: the height and the two box widths must be identical on every frame whatever
15
+ // the data does, because a line that wraps or a box that disagrees costs a row and moves
16
+ // everything under it. The keys are checked for what they promise in the footer and
17
+ // nothing more.
18
+
19
+ // SAFETY: the panel only ever calls fg, bold and terminal.rows. Uncoloured output also
20
+ // makes every assertion below a plain string comparison.
21
+ const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text } as unknown as Theme;
22
+ let renders = 0;
23
+ // SAFETY: same, for requestRender and terminal.rows.
24
+ const tui = { terminal: { rows: 40 }, requestRender: () => renders++ } as unknown as TUI;
25
+
26
+ const now = Date.now();
27
+ const branch = (phase: string, name: string, index: number, over: Partial<BranchView> = {}): BranchView => ({
28
+ phase,
29
+ name,
30
+ index,
31
+ parent: null,
32
+ model: "Opus 5",
33
+ pid: 1000 + index,
34
+ status: "running",
35
+ activity: "Reading vite.config.ts",
36
+ tokens: 72_700,
37
+ startedAt: now - 195_000,
38
+ updatedAt: now,
39
+ report: null,
40
+ error: null,
41
+ stem: `${phase}-${name}`,
42
+ label: `${phase}:${name}`,
43
+ depth: 0,
44
+ elapsedMs: 195_000,
45
+ idleMs: 0,
46
+ exitCode: null,
47
+ timedOut: false,
48
+ alive: true,
49
+ settled: false,
50
+ ...over,
51
+ });
52
+
53
+ const run: RunState = {
54
+ dir: "/tmp/.pi-out/20260905-030000-pi-toolcall-render",
55
+ name: "pi-toolcall-render",
56
+ description: "Map how pi renders tool calls and whether an extension can override it",
57
+ startedAt: now - 196_000,
58
+ phases: ["map", "design"],
59
+ activePhase: 0,
60
+ branches: [
61
+ branch("map", "core-render", 1, { idleMs: 53_000 }),
62
+ branch("map", "omp-intercept", 2, { tokens: 89_600 }),
63
+ branch("map", "api-surface", 3, { tokens: 108_600 }),
64
+ branch("map", "flicker", 4, { tokens: 106_900 }),
65
+ ],
66
+ done: 0,
67
+ total: 4,
68
+ tokens: 397_800,
69
+ live: true,
70
+ };
71
+
72
+ const stopped: string[] = [];
73
+ const source: PanelSource = {
74
+ run: () => run,
75
+ stop: (dead) => stopped.push(dead.stem),
76
+ subscribe: () => () => {},
77
+ };
78
+
79
+ let closed = 0;
80
+ const panel = createPanel(tui, theme, source, () => closed++);
81
+ // handleInput is optional on Component, so bind it once here rather than asserting non-null at
82
+ // each of the fourteen call sites. A panel that did not implement it is a failure worth naming.
83
+ if (!panel.handleInput) throw new Error("the panel implements no handleInput; ↑↓, x and esc cannot work");
84
+ const press = panel.handleInput.bind(panel);
85
+ const KEY = { up: "\x1b[A", down: "\x1b[B", right: "\x1b[C", left: "\x1b[D", escape: "\x1b", stop: "x" };
86
+ const WIDTH = 88;
87
+ const height = panel.render(WIDTH).length;
88
+
89
+ const boxLines = (lines: string[]) => lines.filter((line) => /[┌│└]/.test(line));
90
+ // Height and width are one assertion, not two: a line one column too wide wraps in the
91
+ // terminal, which costs a row the array cannot show. So the header, the footer and every
92
+ // other piece of chrome is measured, not just the box.
93
+ const fits = (lines: string[], width: number, label: string) => {
94
+ for (const line of lines) {
95
+ assert.ok(visibleWidth(line) <= width, `${label}: a line is ${visibleWidth(line)} wide, over ${width}`);
96
+ }
97
+ const widths = new Set(boxLines(lines).map(visibleWidth));
98
+ assert.equal(widths.size, 1, `${label}: box lines disagree on width: ${[...widths]}`);
99
+ return [...widths][0];
100
+ };
101
+
102
+ const frame = (label: string) => {
103
+ const lines = panel.render(WIDTH);
104
+ assert.equal(lines.length, height, `${label}: height changed`);
105
+ fits(lines, WIDTH, label);
106
+ return lines.join("\n");
107
+ };
108
+
109
+ // The reference-1 frame: header, sidebar, four rows, footer.
110
+ const first = frame("initial");
111
+ assert.match(first, /pi-toolcall-render\s+0\/4 agents · 3m16s/);
112
+ assert.match(first, /> 1 Map 0\/4/);
113
+ assert.match(first, /2 Design/);
114
+ assert.match(first, /Map · 4 agents/);
115
+ assert.match(first, /● map:core-render\s+Opus 5 · 72\.7k tok · idle 53s\s+3m15s/);
116
+ assert.match(first, /↑↓ phase · → agents · x stop phase · esc back/);
117
+ assert.ok(!first.includes("pause") && !first.includes("save"), "the dropped keys are back in the footer");
118
+ assert.ok(!/> ● /.test(first), "no agent cursor while the phase list has the focus");
119
+
120
+ // With the phase list focused, ↑↓ move between phases and the right box previews the one
121
+ // under the cursor.
122
+ run.branches.push(branch("design", "api", 5));
123
+ run.total = 5;
124
+ press(KEY.down);
125
+ const crossed = frame("after moving to Design");
126
+ assert.match(crossed, /> 2 Design 0\/1/);
127
+ assert.match(crossed, /Design · 1 agents/);
128
+ assert.equal(renders, 1, "every handled key asks for exactly one render");
129
+
130
+ // → enters the phase's agents, and x there stops the selected branch, and only an unsettled
131
+ // one: a finished branch's pid belongs to whatever the OS handed it to next.
132
+ press(KEY.right);
133
+ const inside = frame("inside Design");
134
+ assert.match(inside, /↑↓ agent · ← phases · x stop agent · esc back/);
135
+ assert.match(inside, /> ● design:api/);
136
+ press(KEY.stop);
137
+ assert.deepEqual(stopped, ["design-api"]);
138
+ run.branches[4] = branch("design", "api", 5, { status: "done", settled: true, alive: false, exitCode: 0, report: "OK" });
139
+ press(KEY.stop);
140
+ assert.deepEqual(stopped, ["design-api"], "a settled branch must not be killed twice");
141
+
142
+ // A branch arriving mid-run must not slide the cursor onto another row, and the panel must
143
+ // not grow a line for it either: the box scrolls instead.
144
+ run.branches.unshift(branch("design", "late", 6));
145
+ run.total = 6;
146
+ const arrived = frame("after a branch arrives");
147
+ assert.match(arrived, /Design · 2 agents/, "the new branch joined the shown phase");
148
+ assert.match(arrived, /> ● design:api/, "the selection is still on its own row");
149
+
150
+ // Inside a phase ↑↓ wrap within it and never cross into the next one.
151
+ for (let n = 0; n < 5; n++) press(KEY.down);
152
+ assert.match(frame("wrapping inside Design"), /> 2 Design 1\/2/);
153
+
154
+ // A nested branch gets reference 3's marker here too, and the marker is part of the label
155
+ // column, so the model column stays aligned with its parent's.
156
+ run.branches[0] = branch("design", "late", 6, { parent: "design-api", depth: 1 });
157
+ assert.match(frame("nested branch"), /└ design:late\s+Opus 5/);
158
+
159
+ // The honest dash, not a fake 0, and the three states that are not "running".
160
+ run.branches[0] = branch("design", "late", 6, { tokens: null });
161
+ assert.match(frame("no usage reported yet"), /design:late\s+Opus 5 · –/);
162
+ run.branches[0] = branch("design", "late", 6, { timedOut: true, exitCode: 124, settled: true, status: "running" });
163
+ assert.match(frame("timed out"), /design:late\s+Opus 5 · 72\.7k tok · timeout/);
164
+ run.branches[0] = branch("design", "late", 6, { status: "error", error: "no credentials", settled: true });
165
+ assert.match(frame("errored"), /design:late\s+Opus 5 · 72\.7k tok · error/);
166
+
167
+ // ← goes back to the phase list, and x there stops every unsettled branch of that phase.
168
+ press(KEY.left);
169
+ assert.match(frame("back on the phases"), /↑↓ phase · → agents · x stop phase · esc back/);
170
+ press(KEY.up);
171
+ stopped.length = 0;
172
+ press(KEY.stop);
173
+ assert.deepEqual(stopped, ["map-core-render", "map-omp-intercept", "map-api-surface", "map-flicker"]);
174
+
175
+ // A phase that run.json never declared still gets its own place in the phase list, so no
176
+ // row can vanish into another phase's box.
177
+ run.branches[0] = branch("verify", "late", 6);
178
+ press(KEY.down);
179
+ press(KEY.down);
180
+ const undeclared = frame("undeclared phase");
181
+ assert.match(undeclared, /> 3 Verify 0\/1/);
182
+ assert.match(undeclared, /Verify · 1 agents/);
183
+ assert.match(undeclared, /● verify:late/);
184
+
185
+ // Every width the boxes claim to support draws to exactly that width. One column too wide
186
+ // wraps, and a wrapped line costs a row that everything below it then moves by.
187
+ for (let width = 13; width <= 200; width++) {
188
+ const lines = panel.render(width);
189
+ assert.equal(lines.length, height, `width ${width}: height changed`);
190
+ assert.equal(fits(lines, width, `width ${width}`), width, `width ${width}: the boxes are not ${width} wide`);
191
+ }
192
+
193
+ // Nothing to draw is not a crash, and neither is a run that ends under the panel: an empty
194
+ // list must not reach Math.max() with no seed, which is the -Infinity the seed extension
195
+ // still carries.
196
+ run.branches.length = 0;
197
+ run.done = 0;
198
+ frame("empty run");
199
+ run.live = false;
200
+ frame("ended run");
201
+ press(KEY.stop);
202
+ press(KEY.down);
203
+
204
+ // esc closes, and closing is the only thing that closes.
205
+ press("q");
206
+ assert.equal(closed, 0);
207
+ press(KEY.escape);
208
+ assert.equal(closed, 1);
209
+ panel.dispose();
210
+
211
+ // One phase is a plain set of subagents, not a workflow: no phase list, no `phase:` prefix,
212
+ // and the agents have the cursor from the start.
213
+ const lone: RunState = { ...run, phases: ["Branches"], activePhase: 0, live: true,
214
+ branches: [branch("Branches", "alpha", 1), branch("Branches", "beta", 2)], total: 2 };
215
+ stopped.length = 0;
216
+ const flat = createPanel(tui, theme, { ...source, run: () => lone }, () => {});
217
+ const flatLines = flat.render(WIDTH).join("\n");
218
+ assert.ok(!flatLines.includes("Phases"), "a single phase draws no phase list");
219
+ assert.match(flatLines, /2 agents/);
220
+ assert.match(flatLines, /> ● alpha\s/);
221
+ assert.match(flatLines, /↑↓ agent · x stop agent · esc back/);
222
+ flat.handleInput?.(KEY.down);
223
+ flat.handleInput?.(KEY.left);
224
+ flat.handleInput?.(KEY.stop);
225
+ assert.deepEqual(stopped, ["Branches-beta"], "← has no phase list to go back to");
226
+ flat.dispose();
227
+
228
+ // Opened from the bar's `❯`: the panel starts inside that agent's phase, on that row.
229
+ const aimedRun: RunState = { ...run, live: true, branches: [branch("map", "core", 1), branch("design", "api", 2)] };
230
+ const aimed = createPanel(tui, theme, { ...source, run: () => aimedRun }, () => {}, () => {}, "design-api");
231
+ const aimedFrame = aimed.render(WIDTH).join("\n");
232
+ assert.match(aimedFrame, /> 2 Design/);
233
+ assert.match(aimedFrame, /> ● design:api/);
234
+ assert.match(aimedFrame, /↑↓ agent · ← phases/);
235
+ aimed.dispose();
236
+
237
+ console.log(`ok - panel: ${height} lines, ${WIDTH} columns, ${renders} renders`);
@@ -0,0 +1,378 @@
1
+ import { Editor, truncateToWidth, visibleWidth, type Component, type TUI, matchesKey } from "@earendil-works/pi-tui";
2
+ import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import type { BranchView, RunState } from "./skills/delegate/state.ts";
4
+
5
+ // Reference 1, the full-screen panel: the zoomed-in view of the run the collapsed bar
6
+ // summarises. Phases on the left, the selected phase's branches on the right, one header
7
+ // line and one footer line.
8
+ //
9
+ // Opened with ctx.ui.custom() and NO `overlay` option, so it takes editorContainer's slot
10
+ // instead of floating over it. pi snapshots the editor's unsent text before the swap and
11
+ // calls setText with it on close, so "esc returns to the input box with unsent text
12
+ // intact" costs zero lines here — the overlay path is the one that would not.
13
+ //
14
+ // The panel reads a snapshot and nothing else: no file, no process, no kill. The bar
15
+ // already polls readRun() once a second for both views, so a second reader could only
16
+ // disagree with it by a frame, and a component with no I/O cannot be hung by a branch that
17
+ // dies. Its one timer asks for a frame and reads nothing, because elapsed and "idle Ns"
18
+ // move with the clock even on a run whose files have stopped changing.
19
+
20
+ /** What the panel needs from whoever owns the poll. `stop` is the producer's kill. */
21
+ export type PanelSource = {
22
+ /** undefined when no run has been seen yet. */
23
+ run(): RunState | undefined;
24
+ /** Called only for a branch the panel drew as unsettled. */
25
+ stop(branch: BranchView): void;
26
+ /** Fires on every change; returns the unsubscribe. */
27
+ subscribe(fn: () => void): () => void;
28
+ };
29
+
30
+ // A finished run closes the panel on its own after this long, so a panel left open does not sit
31
+ // over the editor. Only a run the panel watched finish: one opened already finished stays.
32
+ const AUTO_CLOSE_MS = 8_000;
33
+
34
+ // The user dropped `p pause` and `s save`: they said they never used them. Two focuses, like a
35
+ // file manager: the phase list on the left, and the agents of one phase on the right, entered
36
+ // with → and left with ←. x stops whatever the cursor is on: a whole phase, or one agent.
37
+ const FOOTER = {
38
+ phases: "↑↓ phase · → agents · x stop phase · esc back",
39
+ agents: "↑↓ agent · ← phases · x stop agent · esc back",
40
+ single: "↑↓ agent · x stop agent · esc back",
41
+ };
42
+
43
+ // Rule, header, description, box top, box bottom, footer — the lines that are not body.
44
+ const CHROME = 6;
45
+ // Rows left to the transcript, the status line, the collapsed bar and pi's own footer.
46
+ const RESERVE = 4;
47
+ const MIN_BODY = 3;
48
+ // Reference 1 ends every agent row `3m15s │`, three columns clear of the border.
49
+ const RIGHT_GUTTER = 3;
50
+ // Reference 1 shows "idle 53s", so the marker has to appear well before a minute.
51
+ const IDLE_MS = 15_000;
52
+
53
+ // "45s", "3m15s". No space before the seconds: this string sits at the right edge of every
54
+ // row, and the panel's columns are tighter than the flat list's.
55
+ const clock = (ms: number) => {
56
+ const total = Math.max(0, Math.round(ms / 1000));
57
+ const minutes = Math.floor(total / 60);
58
+ return minutes ? `${minutes}m${String(total % 60).padStart(2, "0")}s` : `${total}s`;
59
+ };
60
+
61
+ // A timed-out branch answered nothing but is not a crash, and a soft report is not a
62
+ // success. Colour carries the difference; note() repeats it in text, because a monochrome
63
+ // theme would otherwise make the two identical.
64
+ const dotColor = (branch: BranchView): ThemeColor => {
65
+ if (branch.timedOut) return "warning";
66
+ if (branch.status === "error" || (branch.exitCode !== null && branch.exitCode !== 0)) return "error";
67
+ if (branch.report === "PARTIAL" || branch.report === "NEED_STRONGER" || branch.report === "ASKING") return "warning";
68
+ if (branch.settled) return "success";
69
+ return branch.status === "running" ? "accent" : "dim";
70
+ };
71
+
72
+ const note = (branch: BranchView): string => {
73
+ if (branch.timedOut) return " · timeout";
74
+ if (branch.status === "error") return " · error";
75
+ // A settled branch stopped emitting because it finished, not because it is stuck.
76
+ if (!branch.settled && branch.idleMs >= IDLE_MS) return ` · idle ${clock(branch.idleMs)}`;
77
+ return "";
78
+ };
79
+
80
+ // The producer leaves tokens at 0 or null until the provider reports, and some report only
81
+ // at completion. "0 tok" would be a fake number for minutes; the dash is an honest one.
82
+ const tokenText = (count: number | null) =>
83
+ !count ? "–" : count < 1000 ? `${count} tok` : `${(count / 1000).toFixed(1)}k tok`;
84
+
85
+ // Run.phases holds the branch's own identifier ("map"), because the stem is
86
+ // `${phase}-${name}` and has to be a legal NTFS filename and a legal --session-id.
87
+ // Reference 1's sidebar reads "Map" while its rows read "map:core-render", so the capital
88
+ // is put back at render time rather than stored a second time in the producer.
89
+ const phaseTitle = (name: string) => name.charAt(0).toUpperCase() + name.slice(1);
90
+
91
+ // Reference 3's nesting marker, kept here too: a branch that was spawned by another is
92
+ // otherwise indistinguishable from its siblings, and readRun() already orders parents
93
+ // before children, so the prefix needs no tree walk.
94
+ const nest = (depth: number) => (depth > 0 ? `${" ".repeat(depth - 1)}└ ` : "");
95
+
96
+ /**
97
+ * The panel component. `close` is the `done` callback ctx.ui.custom hands the factory:
98
+ * calling it restores the editor, resolves the promise, then calls dispose().
99
+ */
100
+ export const createPanel = (
101
+ tui: TUI,
102
+ theme: Theme,
103
+ source: PanelSource,
104
+ close: () => void,
105
+ orphaned: () => void = () => {},
106
+ // The agent the bar's `❯` was on: the panel opens inside that agent's phase, on that row.
107
+ stem?: string,
108
+ ): Component & { dispose(): void } => {
109
+ let shown = source.run();
110
+ const opened = shown?.branches.find((branch) => branch.stem === stem);
111
+ let sawLive = shown?.live === true;
112
+ let finishedAt: number | undefined;
113
+ // The phase list has the cursor until → moves it into that phase's agents.
114
+ let focus: "phases" | "agents" = opened ? "agents" : "phases";
115
+ // The phase follows the run (Map, then Design) until the user picks one.
116
+ let selectedPhase = opened?.phase;
117
+ // Selection is keyed on the stem, never on a row index: a branch arriving mid-run would
118
+ // otherwise slide the cursor onto a different row under the user's hands.
119
+ let selectedStem = opened?.stem;
120
+ let top = 0;
121
+
122
+ // Declared phases in order, then any a branch names that run.json did not declare, so no
123
+ // branch is ever left without a box to be drawn in.
124
+ const phasesOf = (run: RunState) => [
125
+ ...new Set([...run.phases, ...run.branches.map((branch) => branch.phase)]),
126
+ ];
127
+ const phaseNow = (run: RunState) => {
128
+ const phases = phasesOf(run);
129
+ return selectedPhase && phases.includes(selectedPhase) ? selectedPhase : (run.phases[run.activePhase] ?? phases[0] ?? "");
130
+ };
131
+
132
+ // Fixed for the life of the panel. Growing the box as branches arrive would move every
133
+ // line below it, and a height change is one of the things that forces a full repaint.
134
+ const bodyAtOpen = Math.max(MIN_BODY, shown?.phases.length ?? 0, shown?.branches.length ?? 0);
135
+
136
+ // Never requestRender(true): that resets the render state and repaints the whole screen.
137
+ const unsubscribe = source.subscribe(() => tui.requestRender());
138
+ // Elapsed and "idle Ns" move with the clock rather than with the files, so the panel keeps
139
+ // a second hand of its own instead of inheriting whatever cadence the reader polls at. It
140
+ // only asks for a frame — nothing is re-read here — and pi coalesces the request at 16 ms.
141
+ const tick = setInterval(() => {
142
+ // Another dialog (ask_user_question, a select) that takes the editor slot while the panel
143
+ // is up swaps it out without calling done(), and pi later restores the editor, not the
144
+ // panel. Focus back on an editor while this panel still thinks it is open is that case:
145
+ // let go, or every door stays shut until /reload. Not done(): pi's restore would put back
146
+ // text saved before the dialog over whatever was typed since.
147
+ const focused = tui.getFocusedComponent();
148
+ if (focused !== self && focused instanceof Editor) {
149
+ self.dispose();
150
+ orphaned();
151
+ return;
152
+ }
153
+ const run = source.run();
154
+ if (run?.live) {
155
+ sawLive = true;
156
+ finishedAt = undefined;
157
+ } else if (sawLive) {
158
+ finishedAt ??= Date.now();
159
+ if (Date.now() - finishedAt >= AUTO_CLOSE_MS) return close();
160
+ }
161
+ tui.requestRender();
162
+ }, 1000);
163
+ // A panel timer must never be the reason node refuses to exit.
164
+ tick.unref();
165
+
166
+ const box = {
167
+ top(title: string, inner: number): string {
168
+ const text = truncateToWidth(title, Math.max(0, inner - 4), "…");
169
+ // "┌ " + text + " " + dashes + "┐" has to come to inner + 2 columns.
170
+ const dashes = Math.max(0, inner - visibleWidth(text) - 2);
171
+ return theme.fg("border", "┌ ") + theme.fg("text", text) + theme.fg("border", ` ${"─".repeat(dashes)}┐`);
172
+ },
173
+ row(content: string, inner: number): string {
174
+ // truncateToWidth pads as well as cuts, so a row is exactly `inner` wide however
175
+ // much colour is inside it and the two boxes can never drift apart.
176
+ return theme.fg("border", "│") + truncateToWidth(content, inner, "…", true) + theme.fg("border", "│");
177
+ },
178
+ bottom(inner: number): string {
179
+ return theme.fg("border", `└${"─".repeat(inner)}┘`);
180
+ },
181
+ };
182
+
183
+ const self = {
184
+ render(width: number): string[] {
185
+ // A run can be replaced under the panel; keeping the last one means the component
186
+ // never blanks for a frame while the reader is between ticks.
187
+ shown = source.run() ?? shown;
188
+ const run = shown;
189
+ const now = Date.now();
190
+ // One leading space, like the reference. The floor keeps the two boxes summing to
191
+ // exactly `inner`: under 13 columns they do not fit, the line wraps, and a wrapped
192
+ // line costs a row that everything below it then moves by.
193
+ const inner = Math.max(12, width - 1);
194
+ // Every line is cut to `width`, ask.ts style: one column too many wraps in the
195
+ // terminal, and a wrapped line costs a row that everything below it then moves by.
196
+ // The box rows are built to exactly `width` already, so only the chrome needs it.
197
+ const fit = (line: string) => truncateToWidth(line, width, "…");
198
+ if (!run) return [fit(` ${theme.fg("dim", "no run")}`), fit(` ${theme.fg("dim", FOOTER[focus])}`)];
199
+
200
+ const branches = run.branches;
201
+ const phases = phasesOf(run);
202
+ const title = phaseNow(run);
203
+ const list = branches.filter((branch) => branch.phase === title);
204
+ // The agent cursor exists only while the agents have the focus; before that the
205
+ // right box is a preview of the phase the left cursor is on.
206
+ // One phase is a plain set of subagents, not a workflow: no phase list, just the agents.
207
+ const single = phases.length <= 1;
208
+ const mode = single ? "agents" : focus;
209
+ const selected = mode === "agents" ? (list.find((branch) => branch.stem === selectedStem) ?? list[0]) : undefined;
210
+
211
+ const sidebarRows = phases.map((name, index) => {
212
+ const mine = branches.filter((branch) => branch.phase === name);
213
+ const done = mine.filter((branch) => branch.settled).length;
214
+ return `${name === title ? ">" : " "} ${index + 1} ${phaseTitle(name)}${mine.length ? ` ${done}/${mine.length}` : ""}`;
215
+ });
216
+ // 10 is "┌ Phases ─┐" without its borders: the box title has to fit as well as the
217
+ // rows. A third of the width is the ceiling, so a long phase name cannot squeeze
218
+ // the agent list down to nothing.
219
+ const sidebarInner = single
220
+ ? 0
221
+ : Math.min(Math.max(10, ...sidebarRows.map((row) => row.length)) + 1, Math.max(3, Math.floor(inner / 3)));
222
+ // Two boxes are inner + 2 columns each with their borders; one box takes it all.
223
+ const agentInner = Math.max(3, single ? inner - 2 : inner - sidebarInner - 4);
224
+
225
+ // terminal.rows only changes on a resize, which repaints everything anyway, so
226
+ // reading it per frame costs nothing and keeps the panel inside a shrunk window.
227
+ const body = Math.min(bodyAtOpen, Math.max(MIN_BODY, tui.terminal.rows - CHROME - RESERVE));
228
+
229
+ const index = Math.max(0, list.findIndex((branch) => branch.stem === selected?.stem));
230
+ if (index < top) top = index;
231
+ if (index >= top + body) top = index - body + 1;
232
+ top = Math.max(0, Math.min(top, list.length - body));
233
+
234
+ // Seeded, because Math.max() over an empty list is -Infinity — the bug the seed
235
+ // extension carries at agents-panel.ts:75.
236
+ // With one phase the `phase:` prefix says nothing: every row would carry the same one.
237
+ const labelOf = (branch: BranchView) => (single ? branch.label.slice(branch.phase.length + 1) : branch.label);
238
+ const labelWidth = Math.max(0, ...list.map((branch) => nest(branch.depth).length + labelOf(branch).length));
239
+ const agentRows = list.slice(top, top + body).map((branch) => {
240
+ const right = theme.fg("dim", clock(branch.elapsedMs));
241
+ const head = (nest(branch.depth) + labelOf(branch)).padEnd(labelWidth);
242
+ const left =
243
+ `${theme.fg(dotColor(branch), "●")} ` +
244
+ theme.fg(branch.stem === selected?.stem ? "accent" : "text", head) +
245
+ ` ${theme.fg("dim", `${branch.model} · ${tokenText(branch.tokens)}${note(branch)}`)}`;
246
+ // One space in from the left border, three out to the right one: reference 1
247
+ // writes `3m15s │`, and a clock hard against the border reads as an overflow.
248
+ // Widths are measured, never assumed: `left` is full of escape codes.
249
+ // The sidebar's `>` marks the phase the selection is in, not a second focus, so this
250
+ // row needs a marker of its own. Without one the accent colour was the only sign of
251
+ // the cursor, the sidebar's arrow was the only `>` on screen, and `x` read as though
252
+ // it would stop a phase. One column makes the footer's "x stop" true to the eye.
253
+ const cursor = branch.stem === selected?.stem ? theme.fg("accent", ">") : " ";
254
+ const room = Math.max(0, agentInner - visibleWidth(right) - RIGHT_GUTTER - 3);
255
+ return `${cursor} ${truncateToWidth(left, room, "…", true)} ${right}${" ".repeat(RIGHT_GUTTER)}`;
256
+ });
257
+ if (!agentRows.length) agentRows.push(` ${theme.fg("dim", "no branches in this phase")}`);
258
+
259
+ // RunState has no endedAt, so a finished run's clock is the last branch to stop
260
+ // rather than a wall clock that keeps ticking after everything is done.
261
+ const elapsed = run.live
262
+ ? now - run.startedAt
263
+ : Math.max(0, ...branches.map((branch) => branch.startedAt + branch.elapsedMs - run.startedAt));
264
+ const stat = `${run.done}/${run.total} agents · ${clock(elapsed)}`;
265
+ const name = truncateToWidth(
266
+ theme.bold(theme.fg("text", run.name)),
267
+ Math.max(0, inner - visibleWidth(stat) - 3),
268
+ "…",
269
+ );
270
+ const gap = Math.max(1, inner - 2 - visibleWidth(name) - visibleWidth(stat));
271
+
272
+ const lines = [
273
+ ` ${theme.fg("borderMuted", "─".repeat(inner))}`,
274
+ fit(` ${name}${" ".repeat(gap)}${theme.fg("dim", stat)}`),
275
+ ` ${theme.fg("muted", truncateToWidth(run.description, Math.max(0, inner - 2), "…"))}`,
276
+ single
277
+ ? ` ${box.top(`${list.length} agents`, agentInner)}`
278
+ : ` ${box.top("Phases", sidebarInner)}${box.top(`${title ? phaseTitle(title) : "Agents"} · ${list.length} agents`, agentInner)}`,
279
+ ];
280
+ for (let row = 0; row < body; row++) {
281
+ const agents = box.row(agentRows[row] ?? "", agentInner);
282
+ lines.push(single ? ` ${agents}` : ` ${box.row(sidebarRows[row] ?? "", sidebarInner)}${agents}`);
283
+ }
284
+ lines.push(single ? ` ${box.bottom(agentInner)}` : ` ${box.bottom(sidebarInner)}${box.bottom(agentInner)}`);
285
+ lines.push(fit(` ${theme.fg("dim", single ? FOOTER.single : FOOTER[focus])}`));
286
+ return lines;
287
+ },
288
+
289
+ handleInput(data: string): void {
290
+ // ctrl+c is pi's own select cancel, and alt+a opened the panel, so it closes it too.
291
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "alt+a")) return close();
292
+ // Read the source, not the last frame: a key can land between renders, and `x` has
293
+ // to judge a branch by its status now rather than by the one drawn 16 ms ago.
294
+ shown = source.run() ?? shown;
295
+ if (!shown?.branches.length) return;
296
+ const phases = phasesOf(shown);
297
+ const phase = phaseNow(shown);
298
+ const list = shown.branches.filter((branch) => branch.phase === phase);
299
+ const step = matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0;
300
+ // matchesKey, not a byte compare: Caps Lock or Shift sends "X", and the kitty protocol
301
+ // can send x with lock bits set.
302
+ const stop = matchesKey(data, "x") || matchesKey(data, "shift+x");
303
+ // A settled branch's pid belongs to whatever the OS handed it to next, so the guard is
304
+ // here as well as in the producer. Stopping is silent and immediate: the footer
305
+ // promises `x stop`, not a confirmation.
306
+ const stopAll = (branches: BranchView[]) => {
307
+ for (const branch of branches) if (!branch.settled) source.stop(branch);
308
+ };
309
+
310
+ // A single phase has no phase list to go back to: the agents always have the cursor.
311
+ if (phases.length > 1 && focus === "phases") {
312
+ // Up and down stay in the phase list; the right box follows as a preview.
313
+ if (step) selectedPhase = phases[(phases.indexOf(phase) + step + phases.length) % phases.length];
314
+ else if (matchesKey(data, "right") && list.length) {
315
+ focus = "agents";
316
+ if (!list.some((branch) => branch.stem === selectedStem)) selectedStem = list[0]?.stem;
317
+ } else if (stop) stopAll(list);
318
+ else return;
319
+ } else {
320
+ // Inside a phase the cursor never leaves it: past the last agent it wraps to the first.
321
+ // The same fallback render draws, so x stops the row that carries the cursor.
322
+ const current = list.find((branch) => branch.stem === selectedStem) ?? list[0];
323
+ const index = current ? list.indexOf(current) : 0;
324
+ if (step) selectedStem = list[(index + step + list.length) % list.length]?.stem;
325
+ else if (matchesKey(data, "left") && phases.length > 1) focus = "phases";
326
+ else if (stop && current) stopAll([current]);
327
+ else return;
328
+ // Pinned: the run moving on to its next phase must not pull the box out from under
329
+ // the cursor.
330
+ selectedPhase = phase;
331
+ }
332
+ tui.requestRender();
333
+ },
334
+
335
+ invalidate(): void {
336
+ // Nothing is cached between frames; render() reads the snapshot fresh every time.
337
+ },
338
+
339
+ dispose(): void {
340
+ unsubscribe();
341
+ clearInterval(tick);
342
+ },
343
+ };
344
+ return self;
345
+ };
346
+
347
+ // One panel at a time. A second ctx.ui.custom() would clear editorContainer again, and the
348
+ // first panel's close would then restore an editor the second one had already replaced.
349
+ let showing = false;
350
+
351
+ /**
352
+ * The one door in. The bar owns `/umb-agents`, `alt+a` and the down-arrow probe and calls this
353
+ * from all three, so the panel registers no command and no shortcut of its own — a second
354
+ * registration of the same name is a collision, not a third way in.
355
+ */
356
+ export const openPanel = async (ctx: ExtensionContext, source: PanelSource, stem?: string): Promise<void> => {
357
+ if (ctx.mode !== "tui" || showing) return;
358
+ // Opening on nothing draws an empty box the user cannot fill, and the down-arrow door
359
+ // fires whether or not a run exists.
360
+ if (!source.run()) return ctx.ui.notify("no agent run to show");
361
+ showing = true;
362
+ let orphan = () => {};
363
+ const orphaned = new Promise<void>((resolve) => (orphan = resolve));
364
+ try {
365
+ // No `overlay` option: the panel takes the editor's slot rather than floating over
366
+ // it, which is the path that saves and restores the user's unsent text. The promise
367
+ // resolves when the component calls done(), and pi calls dispose() straight after;
368
+ // `orphaned` resolves when another dialog took the slot and pi never will.
369
+ await Promise.race([
370
+ ctx.ui.custom<undefined>((tui, theme, _keybindings, done) =>
371
+ createPanel(tui, theme, source, () => done(undefined), orphan, stem),
372
+ ),
373
+ orphaned,
374
+ ]);
375
+ } finally {
376
+ showing = false;
377
+ }
378
+ };