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,262 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import { LINGER_MS, type BranchView, type RunState } from "../skills/delegate/state.ts";
4
+
5
+ // Every column the widget below the input box occupies, as pure functions over one run
6
+ // snapshot: the flat live list of reference 3, `○ main` on top and one row per agent, with
7
+ // Claude Code's `❯` on the row the user is on. A phased run draws the same list; the panel is
8
+ // where its phases are.
9
+ //
10
+ // All of it lives here rather than in the component because the arithmetic is the part that can
11
+ // be wrong in a way that hurts: a composed line one column too wide wraps in the terminal, and
12
+ // a wrapped line above the editor pushes the input box down by a row. Pure functions can be
13
+ // walked across every width by bar.check.ts with no terminal, no theme and no pi — and pi
14
+ // cannot even be imported by a check, because loading it drags in the experimental server.
15
+ // The one thing this file borrows from pi is `ThemeColor`, as a type-only import, which is
16
+ // erased before anything runs.
17
+ //
18
+ // Widths are measured before colour goes on. Every piece returned by the two layout functions
19
+ // is plain text whose width is exactly what it will occupy, and renderLines() below only ever
20
+ // concatenates them in order — which is also why a row hands back `dotAt`, the one cell that
21
+ // takes the status colour, instead of a pre-coloured glyph.
22
+
23
+ export const MARK = 2; // "❯ " in front of the row the cursor is on, blank in front of the rest
24
+ export const PAD = 1; // one column of gutter each side, matching the rows pi draws itself
25
+ const COL_GAP = 3; // list: agent name -> activity, as in reference 3
26
+ const MIN_ACTIVITY = 10; // ditto for the activity sentence, which is the point of the list
27
+ const HEAD_CAP = 28; // ditto for the list's marker + dot + name column
28
+ const MIN_INNER = 8; // under this nothing but a stub of the main row fits
29
+
30
+ /** Reference 1 shows "idle 53s", so a branch counts as quiet well before a minute. */
31
+ export const IDLE_MS = 15_000;
32
+ /** How long a finished run stays on screen before the widget draws nothing again. */
33
+ export { LINGER_MS };
34
+
35
+ /** "45s", "3m 52s" — the reference-2 and reference-3 spelling, with the space. Reference 1
36
+ * writes "3m15s" instead; that clock lives in panel.ts, where the columns are tighter. */
37
+ export const clock = (ms: number) => {
38
+ const seconds = Math.max(0, Math.round(ms / 1000));
39
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
40
+ };
41
+
42
+ // A dash, not "0": a provider that reports usage only at completion leaves the sum at zero for
43
+ // minutes, and a fake number in the column the user glances at is worse than an honest gap.
44
+ export const compact = (tokens: number) =>
45
+ tokens <= 0 ? "–" : tokens < 1000 ? `${tokens}` : `${(tokens / 1000).toFixed(1)}k`;
46
+
47
+ // RunState carries no end time — liveness is per branch — so the last beacon write in the run
48
+ // stands in for one. It is what freezes the elapsed clock, so a finished run stops counting.
49
+ export const endedAt = (run: RunState) => (run.live ? 0 : Math.max(0, ...run.branches.map((branch) => branch.updatedAt)));
50
+
51
+ /** Past its linger window a finished run is the same thing as no run at all: one bare `○ main`
52
+ * row. Both shapes ask that question, so they ask it through one function. */
53
+ export const faded = (run: RunState | undefined, now: number): boolean => {
54
+ if (!run || run.total === 0) return true;
55
+ const ended = endedAt(run);
56
+ return ended > 0 && now - ended > LINGER_MS;
57
+ };
58
+
59
+ // ---------- REFERENCE 3: the flat live list ----------
60
+
61
+ /** One row of reference 3. `head` is the nesting marker, the status dot and the agent name,
62
+ * padded so every row's activity starts in the same column; `dotAt` is the one cell inside it
63
+ * that takes the status colour, or -1 on a row that has no dot. `branch` is undefined on the
64
+ * `○ main` row, and is what tone() below reads to pick that cell's colour. */
65
+ export type ListRow = {
66
+ head: string;
67
+ dotAt: number;
68
+ activity: string;
69
+ fill: string;
70
+ right: string;
71
+ branch?: BranchView;
72
+ };
73
+
74
+ // Reference 3's nesting marker, two columns per level with `└ ` on the last. Same shape as
75
+ // panel.ts's, because the panel is the zoomed-in view of these same rows.
76
+ const nest = (depth: number) => (depth > 0 ? `${" ".repeat(depth - 1)}└ ` : "");
77
+
78
+ // Filled while the branch is working, hollow when it is pending, quiet or finished. `○ main`
79
+ // is hollow for the same reason the reference draws it hollow: the session is not a branch.
80
+ const working = (branch: BranchView) =>
81
+ branch.alive && branch.status === "running" && branch.idleMs < IDLE_MS;
82
+
83
+ const dot = (branch: BranchView) => (working(branch) ? "●" : "○");
84
+
85
+ /** What the row says it is doing. A branch that was killed never got to write "done", so its
86
+ * own `activity` is frozen on whatever it was mid-way through — a row reading "Running cd"
87
+ * on a process that has been dead for four minutes. The exit file already knows better. */
88
+ export const said = (branch: BranchView): string => {
89
+ if (branch.timedOut) return "Timed out";
90
+ if (branch.status === "error") return branch.error ? `Failed: ${branch.error}` : "Failed";
91
+ // Killed by hand or by a parent going away: a non-zero exit with no error text of its own.
92
+ if (branch.settled && branch.exitCode !== null && branch.exitCode !== 0 && !branch.report) return "Stopped";
93
+ return branch.activity ?? "";
94
+ };
95
+
96
+ // Chosen once for the whole list rather than per row, so the right-hand block is one column
97
+ // instead of four ragged ones. Same order of sacrifice as the bar: the tokens go first, then
98
+ // the arrow, and the clock last.
99
+ const rowRight = (branch: BranchView, tier: number) => {
100
+ const time = clock(branch.elapsedMs);
101
+ if (tier === 0) return `${time} · ↓ ${compact(branch.tokens ?? 0)} tokens`;
102
+ if (tier === 1) return `${time} · ↓ ${compact(branch.tokens ?? 0)}`;
103
+ if (tier === 2) return time;
104
+ return "";
105
+ };
106
+
107
+ const widest = (values: string[]) => Math.max(0, ...values.map(visibleWidth));
108
+
109
+ /** Reference 3, whole. Always at least one row, because a widget that renders zero lines
110
+ * un-mounts itself and the input box drops a line the moment it does.
111
+ *
112
+ * `max` is the row budget the component computes from the terminal height. Anything past it
113
+ * collapses into a final "+N more", so the list has a ceiling but never a hidden agent. */
114
+ export const layoutList = (width: number, run: RunState | undefined, now: number, max: number): ListRow[] => {
115
+ const inner = width - PAD * 2;
116
+ const main = "○ main";
117
+ // Sub-8 columns is not a terminal anyone reads a table in, and one stub row still holds the
118
+ // height the input box is sitting on.
119
+ if (inner < MIN_INNER) {
120
+ return [{ head: truncateToWidth(main, Math.max(0, inner), "…", true), dotAt: 0, activity: "", fill: "", right: "" }];
121
+ }
122
+
123
+ // readRun() already orders parents immediately above their children by launch order, so
124
+ // taking a prefix keeps the tree readable and, more importantly, keeps a row from sliding
125
+ // under the eye when a sibling settles. Sorting the live ones to the top would do that.
126
+ const all = !run || faded(run, now) ? [] : run.branches;
127
+ // The overflow row spends the budget's last slot rather than adding to it, so a run of forty
128
+ // branches is exactly as tall as a run of `max` and never one row taller.
129
+ const room = Math.max(0, max - 1); // rows left after `○ main`
130
+ const shown = all.length <= room ? all : all.slice(0, Math.max(0, room - 1));
131
+ const hidden = room > 0 ? all.length - shown.length : 0;
132
+ const overflow = hidden > 0 ? `+${hidden} more` : "";
133
+
134
+ const heads = shown.map((branch) => `${nest(branch.depth)}${dot(branch)} ${branch.name}`);
135
+ const headWidth = Math.min(inner, HEAD_CAP, Math.max(1, widest([main, ...heads, overflow])));
136
+
137
+ // One tier for the list, not one per row: the widest that still leaves the activity
138
+ // sentence a readable column next to the longest head.
139
+ let tier = 0;
140
+ let rightWidth = 0;
141
+ for (; tier < 3; tier++) {
142
+ rightWidth = widest(shown.map((branch) => rowRight(branch, tier)));
143
+ if (headWidth + COL_GAP + MIN_ACTIVITY + rightWidth <= inner) break;
144
+ }
145
+ if (tier === 3) rightWidth = 0;
146
+ // Clamped after the choice as well as before it: on a narrow terminal even the bare clock
147
+ // can be too wide, and an over-wide row is the one failure that moves the input box.
148
+ rightWidth = Math.min(rightWidth, Math.max(0, inner - headWidth));
149
+ const activityBudget = inner - headWidth - COL_GAP - rightWidth;
150
+
151
+ const row = (headText: string, at: number, text: string, right: string): ListRow => {
152
+ const head = truncateToWidth(headText, headWidth, "…", true);
153
+ const activity =
154
+ text && activityBudget >= MIN_ACTIVITY ? " ".repeat(COL_GAP) + truncateToWidth(text, activityBudget) : "";
155
+ // Padded on the left, so the clocks sit under each other instead of only the right edge
156
+ // lining up. Spaces take no colour, so this stays one string the component can dim.
157
+ const padded = " ".repeat(Math.max(0, rightWidth - visibleWidth(right))) + right;
158
+ const fill = Math.max(0, inner - headWidth - visibleWidth(activity) - visibleWidth(padded));
159
+ return { head, dotAt: at, activity, fill: " ".repeat(fill), right: padded };
160
+ };
161
+
162
+ // `○ main` is the session itself and always sits at the top, with no activity and no
163
+ // counters — exactly as the reference draws it. The widget factory is handed a tui and a
164
+ // theme and nothing else, so there is no ExtensionContext here to ask for the session's own
165
+ // usage, and a number the row could not stand behind is worse than the blank the screen
166
+ // actually shows.
167
+ const rows: ListRow[] = [row(main, 0, "", "")];
168
+ shown.forEach((branch, index) => {
169
+ // SAFETY: heads is built by mapping `shown` in order, so this index is populated.
170
+ const head = heads[index] as string;
171
+ rows.push({ ...row(head, visibleWidth(nest(branch.depth)), said(branch), rowRight(branch, tier)), branch });
172
+ });
173
+ // No dot on the overflow row: it stands for agents rather than being one.
174
+ if (overflow) rows.push(row(overflow, -1, "", ""));
175
+ return rows;
176
+ };
177
+
178
+ // ---------- THE FRAME ----------
179
+
180
+ // The list's ceiling: `○ main` plus eight agents. Past that a "+N more" row stands in, so the
181
+ // widget can never eat the screen it is sitting on top of.
182
+ export const MAX_ROWS = 9;
183
+
184
+ // The same distinctions panel.ts draws, because the panel is the zoomed-in view of these rows:
185
+ // a timed-out branch answered nothing but did not crash, and a soft report is not a success.
186
+ const tone = (branch: BranchView | undefined): ThemeColor => {
187
+ if (!branch) return "dim";
188
+ if (branch.timedOut) return "warning";
189
+ if (branch.status === "error" || (branch.exitCode !== null && branch.exitCode !== 0)) return "error";
190
+ if (branch.report === "PARTIAL" || branch.report === "NEED_STRONGER" || branch.report === "ASKING") return "warning";
191
+ if (branch.settled) return "success";
192
+ return branch.alive && branch.idleMs < IDLE_MS ? "accent" : "dim";
193
+ };
194
+
195
+ /** Everything on screen, in one string. A frame identical to the last one is never requested:
196
+ * pi coalesces renders at 16 ms, but the cheapest frame is the one nobody asks for, and an
197
+ * idle session whose reader ticks once a second would otherwise repaint a blank row for as
198
+ * long as pi stays open. Width is deliberately absent — a resize repaints everything anyway. */
199
+ export const renderKey = (run: RunState | undefined, now: number): string => {
200
+ if (!run || faded(run, now)) return "-";
201
+ const at = endedAt(run) || now;
202
+ const head = [run.name, run.description, run.done, run.total, run.tokens, Math.round((at - run.startedAt) / 1000)];
203
+ const rows = run.branches.map((branch) =>
204
+ [
205
+ branch.stem,
206
+ branch.depth,
207
+ branch.status,
208
+ said(branch),
209
+ branch.tokens,
210
+ Math.round(branch.elapsedMs / 1000),
211
+ branch.alive ? 1 : 0,
212
+ branch.idleMs < IDLE_MS ? 1 : 0,
213
+ ].join(","),
214
+ );
215
+ return [...head, ...rows].join("|");
216
+ };
217
+
218
+ /** `theme.fg`, narrowed to the one method used, so the frame can be composed without a TUI. */
219
+ export type Paint = (color: ThemeColor, text: string) => string;
220
+
221
+ /**
222
+ * The whole frame, always the list. `cursor` is the stem the `❯` sits on; undefined puts it on
223
+ * `○ main`, which is where the user is while they type.
224
+ */
225
+ export const renderLines = (
226
+ width: number,
227
+ run: RunState | undefined,
228
+ now: number,
229
+ budget: number,
230
+ paint: Paint,
231
+ panelIsOpen = false,
232
+ cursor?: string,
233
+ ): string[] => {
234
+ const total = Math.max(0, width);
235
+
236
+ // Nothing running: draw nothing at all, not a bare `○ main`. pi renders the belowEditor
237
+ // container with spacerWhenEmpty and leadingSpacer both false, so zero lines really is zero
238
+ // rows and the footer above it does not move when a run starts or ends. That was not true
239
+ // while this slot sat above the editor - there, appearing and disappearing would have
240
+ // shifted the input box under the user's hands, which is why the idle row existed.
241
+ if (faded(run, now)) return [];
242
+
243
+ // The panel already draws all of this, and it took the editor's slot to do it. The width
244
+ // guard rides along: below two gutters plus a couple of columns there is nothing to say, and
245
+ // a blank line of the right height beats a two-column line in a one-column terminal.
246
+ if (panelIsOpen || total < PAD * 2 + 4) return [" ".repeat(total)];
247
+
248
+ // The `❯` column comes off the list's width, so a marked row is exactly as wide as the rest.
249
+ return layoutList(total - MARK, run, now, budget).map((row) => {
250
+ // `head` is plain text, and everything left of the dot is `└`, a space, or a name
251
+ // matching ^[a-z0-9][a-z0-9._-]*$ — so a visible column and a code-unit index are the
252
+ // same number here and slicing cannot cut a character in half.
253
+ const head =
254
+ row.dotAt >= 0
255
+ ? paint("dim", row.head.slice(0, row.dotAt)) +
256
+ paint(tone(row.branch), row.head.slice(row.dotAt, row.dotAt + 1)) +
257
+ paint(row.branch ? "text" : "dim", row.head.slice(row.dotAt + 1))
258
+ : paint("dim", row.head);
259
+ const mark = (row.branch ? row.branch.stem === cursor : !cursor && row.dotAt === 0) ? paint("accent", "❯ ") : " ";
260
+ return " ".repeat(PAD) + mark + head + paint("muted", row.activity) + row.fill + paint("dim", row.right) + " ".repeat(PAD);
261
+ });
262
+ };
@@ -0,0 +1,198 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { MAX_ROWS, layoutList, renderKey, renderLines } from "./bar/bar-line.ts";
3
+ import type { BranchView, RunState } from "./skills/delegate/state.ts";
4
+
5
+ // The widget sits directly above the input box, so two things can hurt and both are checked
6
+ // here rather than argued in a comment:
7
+ //
8
+ // 1. a composed line one column too wide wraps in the terminal and pushes the box down
9
+ // 2. the `❯` column pushing a row past the width does the same
10
+ //
11
+ // Everything under test is pure, so this needs no terminal, no theme and no pi.
12
+ // Run with: bun run bar.check.ts
13
+
14
+ // A function declaration, not a const arrow: TypeScript only narrows past a never-returning
15
+ // call when it is declared this way, and every assertion below relies on that narrowing.
16
+ function fail(message: string): never {
17
+ throw new Error(message);
18
+ }
19
+
20
+ // SAFETY: only the fields the layouts read are populated; every one of them is here.
21
+ const branch = (over: Partial<BranchView> = {}): BranchView =>
22
+ ({
23
+ phase: "map",
24
+ name: "general-purpose",
25
+ parent: null,
26
+ index: 1,
27
+ model: "Opus 5",
28
+ pid: 4242,
29
+ status: "running",
30
+ activity: "Grepping localStorage keys in office-persist.ts",
31
+ tokens: 56_500,
32
+ startedAt: 0,
33
+ updatedAt: 232_000,
34
+ report: null,
35
+ error: null,
36
+ stem: "map-general-purpose",
37
+ label: "map:general-purpose",
38
+ depth: 0,
39
+ elapsedMs: 232_000, // 3m 52s
40
+ idleMs: 0,
41
+ exitCode: null,
42
+ timedOut: false,
43
+ alive: true,
44
+ settled: false,
45
+ ...over,
46
+ }) as BranchView;
47
+
48
+ const run = (over: Partial<RunState> = {}): RunState =>
49
+ ({
50
+ dir: ".pi-out/20260905-030000",
51
+ name: "pi-toolcall-render",
52
+ description: "Map how pi renders tool calls and whether an extension can override it",
53
+ startedAt: 0,
54
+ phases: ["map", "design"],
55
+ activePhase: 0,
56
+ branches: [],
57
+ done: 2,
58
+ total: 4,
59
+ tokens: 397_800,
60
+ live: true,
61
+ ...over,
62
+ }) as RunState;
63
+
64
+ const now = 278_000; // 4m 38s
65
+
66
+ // A phased run draws the same list as a flat one; the panel is where its phases are.
67
+ const phased = run({ branches: [branch()] });
68
+ // A finished run inside its linger window, and the same run once it has faded.
69
+ const settled = run({ live: false, done: 4, branches: [branch({ updatedAt: now, settled: true, alive: false })] });
70
+
71
+ // ---------- reference 3, the flat list ----------
72
+
73
+ const flat = run({
74
+ phases: ["map"], // one phase: no workflow to collapse, so this is the list case
75
+ done: 0,
76
+ total: 2,
77
+ branches: [
78
+ branch(),
79
+ branch({
80
+ name: "Explore",
81
+ stem: "map-explore",
82
+ label: "map:Explore",
83
+ depth: 1,
84
+ parent: "map-general-purpose",
85
+ index: 2,
86
+ activity: "Confirming store.ts is unused",
87
+ tokens: 86_300,
88
+ elapsedMs: 172_000, // 2m 52s
89
+ // Hollow in the reference while its sibling is filled, and a branch that has not
90
+ // emitted for a quarter of a minute is the one state that can honestly produce that.
91
+ // The screen cannot tell us which of the two it was; the rule has to mean something,
92
+ // so it means the thing the panel already tracks.
93
+ idleMs: 20_000,
94
+ }),
95
+ ],
96
+ });
97
+
98
+ for (let width = 8; width <= 200; width++) {
99
+ for (const state of [flat, undefined, settled]) {
100
+ const rows = layoutList(width, state, now, MAX_ROWS);
101
+ if (rows.length === 0) fail(`list width ${width}: zero rows un-mounts the widget`);
102
+ for (const row of rows) {
103
+ const used =
104
+ visibleWidth(row.head) + visibleWidth(row.activity) + row.fill.length + visibleWidth(row.right);
105
+ if (used !== width - 2) fail(`list width ${width}: row composed ${used}, expected ${width - 2}`);
106
+ }
107
+ // The activity column has to start in the same place on every row, or the sentences
108
+ // stagger and the list stops being readable at a glance.
109
+ const heads = new Set(rows.map((row) => visibleWidth(row.head)));
110
+ if (heads.size !== 1) fail(`list width ${width}: ragged head column ${[...heads].join()}`);
111
+ }
112
+ }
113
+
114
+ const list = layoutList(100, flat, now, MAX_ROWS);
115
+ if (list.length !== 3) fail(`expected main + two agents, got ${list.length}`);
116
+ // SAFETY: length checked immediately above.
117
+ const [main, first, nested] = list as [(typeof list)[0], (typeof list)[0], (typeof list)[0]];
118
+ if (main.head.trimEnd() !== "○ main") fail(`main row: ${JSON.stringify(main.head)}`);
119
+ if (main.branch !== undefined) fail("the main row is not a branch");
120
+ if (main.activity !== "" || main.right.trim() !== "") fail("the main row carries counters the reference does not show");
121
+ if (first.head.trimEnd() !== "● general-purpose") fail(`agent row: ${JSON.stringify(first.head)}`);
122
+ if (first.dotAt !== 0) fail(`dot column on a top-level row: ${first.dotAt}`);
123
+ if (!first.activity.endsWith("Grepping localStorage keys in office-persist.ts")) fail(`activity: ${first.activity}`);
124
+ if (first.right !== "3m 52s · ↓ 56.5k tokens") fail(`right column: ${JSON.stringify(first.right)}`);
125
+ if (nested.head.trimEnd() !== "└ ○ Explore") fail(`nested row: ${JSON.stringify(nested.head)}`);
126
+ if (nested.dotAt !== 2) fail(`dot column on a nested row: ${nested.dotAt}`);
127
+ if (nested.right !== "2m 52s · ↓ 86.3k tokens") fail(`nested right column: ${JSON.stringify(nested.right)}`);
128
+
129
+ // An idle session is one bare row.
130
+ const idle = layoutList(100, undefined, now, MAX_ROWS);
131
+ if (idle.length !== 1 || idle[0]?.head.trimEnd() !== "○ main") fail("idle is not exactly one `○ main` row");
132
+ if (layoutList(100, settled, now + 31_000, MAX_ROWS).length !== 1) fail("finished run outstayed LINGER_MS in the list");
133
+
134
+ // A quiet branch goes hollow, a working one is filled, and the row budget is a ceiling rather
135
+ // than a suggestion.
136
+ const quiet = layoutList(100, run({ phases: ["map"], branches: [branch({ idleMs: 60_000 })] }), now, MAX_ROWS);
137
+ if (!quiet[1]?.head.startsWith("○")) fail(`an idle branch kept its filled dot: ${quiet[1]?.head}`);
138
+
139
+ const many = run({ phases: ["map"], branches: Array.from({ length: 40 }, (_, i) => branch({ stem: `map-b${i}` })) });
140
+ const capped = layoutList(100, many, now, MAX_ROWS);
141
+ if (capped.length !== MAX_ROWS) fail(`row budget ignored: ${capped.length}`);
142
+ if (!capped[MAX_ROWS - 1]?.head.startsWith("+33 more")) fail(`no overflow row: ${capped[MAX_ROWS - 1]?.head}`);
143
+ if (capped[MAX_ROWS - 1]?.dotAt !== -1) fail("the overflow row was given a status dot");
144
+ // A budget of one is `○ main` and nothing else, not `○ main` plus an apology.
145
+ if (layoutList(100, many, now, 1).length !== 1) fail("a one-row budget grew a second row");
146
+ if (layoutList(100, many, now, 2).length !== 2) fail("a two-row budget is main plus the overflow");
147
+
148
+ // ---------- the fixed-height rule ----------
149
+
150
+ const paint = (_color: unknown, text: string) => text; // identity: measure the plain frame
151
+ for (let width = 0; width <= 200; width++) {
152
+ // Every path through renderLines, at every width, with the cursor on main and on an agent.
153
+ for (const [label, lines] of [
154
+ ["phased", renderLines(width, phased, now, MAX_ROWS, paint)],
155
+ ["cursor on an agent", renderLines(width, flat, now, MAX_ROWS, paint, false, "map-explore")],
156
+ ["panel open", renderLines(width, phased, now, MAX_ROWS, paint, true)],
157
+ ["idle", renderLines(width, undefined, now, MAX_ROWS, paint)],
158
+ ["list", renderLines(width, flat, now, MAX_ROWS, paint)],
159
+ ["faded", renderLines(width, settled, now + 31_000, MAX_ROWS, paint)],
160
+ ] as [string, string[]][]) {
161
+ // idle and faded now draw NOTHING, at every width, and that is the assertion - not a
162
+ // blank line. The slot is belowEditor, where pi passes spacerWhenEmpty and leadingSpacer
163
+ // both false, so zero lines is zero rows and the footer above never moves. The check
164
+ // runs width 0..200 because the faded test sits ahead of the narrow-terminal guard;
165
+ // putting it after would have made a 5-column terminal the one place a bare row survived.
166
+ if (label === "idle" || label === "faded") {
167
+ if (lines.length !== 0) fail(`${label} drew ${lines.length} lines at width ${width}, expected none`);
168
+ } else if (lines.length < 1) {
169
+ fail(`${label} rendered nothing at width ${width}`);
170
+ }
171
+ for (const line of lines) {
172
+ if (visibleWidth(line) !== Math.max(0, width)) fail(`${label} overflowed at width ${width}: ${line.length}`);
173
+ }
174
+ }
175
+ }
176
+ if (renderLines(80, phased, now, MAX_ROWS, paint, true)[0]?.trim() !== "") fail("the open panel did not blank the widget");
177
+
178
+ // ---------- the cursor ----------
179
+
180
+ // `❯` sits on `○ main` while the user types, and on exactly one agent once they move down.
181
+ const onMain = renderLines(100, flat, now, MAX_ROWS, paint);
182
+ if (!onMain[0]?.startsWith(" ❯ ○ main")) fail(`no cursor on main: ${onMain[0]}`);
183
+ if (onMain.filter((line) => line.includes("❯")).length !== 1) fail("more than one cursor with none chosen");
184
+ const onAgent = renderLines(100, flat, now, MAX_ROWS, paint, false, "map-explore");
185
+ if (!onAgent[2]?.startsWith(" ❯ └ ○ Explore")) fail(`cursor not on the chosen agent: ${onAgent[2]}`);
186
+ if (onAgent.filter((line) => line.includes("❯")).length !== 1) fail("the cursor stayed on main as well");
187
+ if (!renderLines(100, phased, now, MAX_ROWS, paint)[1]?.includes("● general-purpose")) fail("a phased run did not draw its agents");
188
+
189
+ // ---------- the no-op frame ----------
190
+
191
+ if (renderKey(flat, now) !== renderKey(flat, now)) fail("render key is not stable");
192
+ if (renderKey(undefined, now) !== "-") fail("an empty screen is not one key");
193
+ if (renderKey(settled, now + 31_000) !== "-") fail("a faded run is not the empty screen");
194
+ if (renderKey(flat, now) === renderKey(flat, now + 1_000)) fail("the clock moved and the key did not");
195
+ const rewritten = run({ ...flat, branches: [branch({ activity: "Reading hooks.server.ts and app.d.ts" }), ...flat.branches.slice(1)] });
196
+ if (renderKey(flat, now) === renderKey(rewritten, now)) fail("the activity sentence changed and the key did not");
197
+
198
+ console.log("ok");