pi-umbra-subagents 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 grkn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # pi-umbra-subagents
2
+
3
+ Parallel, read-only pi branches you can watch while they work. A branch is a separate `pi -p`
4
+ process with an empty context; a live list above the input box shows what each one is doing,
5
+ and their answers come back to the session when the last one ends. Also `/umb-loop`, which
6
+ sends a prompt again after every reply, on a count or a timer.
7
+
8
+ ![Three branches running under the input box](https://raw.githubusercontent.com/grknbyk/pi-umbra/main/assets/subagents.webp)
9
+
10
+ ```sh
11
+ pi install npm:pi-umbra-subagents
12
+ node ~/.pi/agent/npm/node_modules/pi-umbra-subagents/patch.mjs
13
+ ```
14
+
15
+ Restart pi after the patch. Only `/umb-loop` needs it; the branches work without it.
16
+
17
+ Nothing is added to the model's prompt and no tool is registered. The model starts a run by
18
+ ending its answer with a fenced `fan` block, which the bundled `fan` skill teaches it:
19
+
20
+ ````
21
+ ```fan
22
+ name: weather-map
23
+ desc: Map the weather API
24
+ # Map
25
+ routes: List every route in src/server.ts with file:line.
26
+ upstream: List what src/forecast.ts fetches, with file:line.
27
+ # Plan
28
+ design: Given the Map results, propose the change.
29
+ ```
30
+ ````
31
+
32
+ `# Title` starts a phase. Phases run in order; the branches inside one run at the same time.
33
+ `label@provider/model: task` picks a model for one branch, otherwise it runs on the session's
34
+ model. The results arrive as a follow-up message at the start of the next turn.
35
+
36
+ ## Commands and keys
37
+
38
+ | Command or key | Effect |
39
+ |---|---|
40
+ | `/umb-fan [spec]` | start a run yourself; with no argument an editor opens with a template |
41
+ | `/umb-agents`, `alt+a`, `↓` from the last input line | open the agent panel |
42
+ | `↑` `↓` in the panel | select a branch |
43
+ | `x` in the panel | stop the selected branch |
44
+ | `esc` in the panel | back to the input box, unsent text kept |
45
+ | `/umb-loop [count\|duration] [prompt]` | send the prompt again after each reply; run it again to stop |
46
+
47
+ `/umb-loop 5 fix the next failing test` runs five times, `/umb-loop 10m continue` for ten
48
+ minutes, `/umb-loop 0 …` until stopped. Without a prompt it sends `Continue.`. Escape cancels
49
+ one round and keeps the loop.
50
+
51
+ ## Skills
52
+
53
+ | Skill | Use |
54
+ |---|---|
55
+ | `fan` | the fenced block above; the extension owns the branches |
56
+ | `delegate` | the same branches started from a bash call (`dstart`, `branch`, `dwait`), for runs the model wants to read back itself, or continue with `dresume` after a branch asks a question |
57
+
58
+ Both write every branch's answer to `.pi-out/<run>/<phase>-<name>.md` and its errors to the
59
+ matching `.err`. Add `.pi-out/` to `.gitignore`. Quitting pi stops the branches; what they
60
+ wrote stays on disk.
61
+
62
+ ## Settings
63
+
64
+ | Variable | Default | Effect |
65
+ |---|---|---|
66
+ | `FAN_MODEL` | the session's model | model for `fan` branches that do not name one |
67
+ | `FAN_TOOLS` | `read,grep,find,ls` | tools a branch may use |
68
+ | `FAN_LOAD` | empty | extra `-e <path>` flags; branches start with `--no-extensions`, so a provider that comes from an extension has to be listed here |
69
+ | `FAN_TIMEOUT_MS` | `300000` | a branch still running after this long is cut off |
70
+
71
+ The `delegate` skill reads the same knobs as `FAST`, `LOAD` and `DELEGATE_TIMEOUT` from its
72
+ `delegate.env`, then from `~/.pi/agent/delegate.env`.
73
+
74
+ ## The patch
75
+
76
+ `/umb-loop` sends its prompt the way the input box does, and the extension API has no way to
77
+ do that. `patch.mjs` makes one small additive edit to pi's installed bundle that exposes it. A
78
+ pi update removes the patch without any error, so run the script again after every update.
79
+
80
+ | Command | Effect |
81
+ |---|---|
82
+ | `node .../patch.mjs` | apply the patch; a part already applied is skipped |
83
+ | `node .../patch.mjs --check` | change nothing, exit 1 if the patch is missing |
84
+
85
+ The script patches the `pi` on your PATH. Set `PI_UMBRA_PI` to the `pi-coding-agent`
86
+ directory to patch another one.
87
+
88
+ MIT.
@@ -0,0 +1,102 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ // The editor's submit path is not on the extension API, so repatch.mjs publishes it.
4
+ const submit = (text: string) => (globalThis as any).__piSubmit?.(text);
5
+
6
+ const DEFAULT_PROMPT = "Continue.";
7
+ const UNIT_MS: Record<string, number> = { h: 3_600_000, m: 60_000, s: 1000 };
8
+ // "90s", "10 min", "1h30m" — a run of number+unit pairs at the very start of the args. A unit
9
+ // ends at any non-letter, not at \b, which never falls between "h" and "30".
10
+ const DURATION = /^(?:\d+\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![a-z])\s*)+/i;
11
+ const COUNT = /^(\d+)\s*/;
12
+ const RESUBMIT_DELAY_MS = 50;
13
+
14
+ type Loop = { prompt: string; iterations?: number; deadline?: number; done: number };
15
+
16
+ const durationMs = (text: string) =>
17
+ [...text.matchAll(/(\d+)\s*([a-z]+)/gi)].reduce(
18
+ (sum, [, amount, unit]) => sum + Number(amount) * UNIT_MS[unit[0].toLowerCase()],
19
+ 0,
20
+ );
21
+
22
+ const parse = (args: string): Loop => {
23
+ const trimmed = args.trim();
24
+ const rest = (from: string) => trimmed.slice(from.length).trim() || DEFAULT_PROMPT;
25
+
26
+ const duration = trimmed.match(DURATION);
27
+ if (duration) return { prompt: rest(duration[0]), deadline: Date.now() + durationMs(duration[0]), done: 0 };
28
+
29
+ const count = trimmed.match(COUNT);
30
+ if (count) {
31
+ // 0 means "no limit". Either branch strips the number from the prompt, so
32
+ // "/umb-loop 0 fix the tests" submits "fix the tests", not "0 fix the tests".
33
+ const iterations = Number(count[1]);
34
+ return iterations > 0
35
+ ? { prompt: rest(count[0]), iterations, done: 0 }
36
+ : { prompt: rest(count[0]), done: 0 };
37
+ }
38
+
39
+ return { prompt: trimmed || DEFAULT_PROMPT, done: 0 };
40
+ };
41
+
42
+ export default function (pi: ExtensionAPI) {
43
+ let loop: Loop | undefined;
44
+ let cancelled = false;
45
+
46
+ const describe = () => {
47
+ if (!loop) return undefined;
48
+ if (loop.iterations) return `loop ${loop.done}/${loop.iterations}`;
49
+ if (loop.deadline) return `loop ${Math.max(0, Math.round((loop.deadline - Date.now()) / 1000))}s`;
50
+ return `loop ${loop.done}`;
51
+ };
52
+
53
+ const stop = (ctx: ExtensionContext, reason: string) => {
54
+ loop = undefined;
55
+ ctx.ui.setStatus("loop", undefined);
56
+ ctx.ui.notify(reason);
57
+ };
58
+
59
+ pi.registerCommand("umb-loop", {
60
+ description: "Toggle automatic resubmission after each yield: /umb-loop [count|duration] [prompt]",
61
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
62
+ if (loop) return stop(ctx, "loop off");
63
+ // Without the patch nothing would ever be sent, while the status claimed a running loop.
64
+ if (!(globalThis as any).__piSubmit) {
65
+ return ctx.ui.notify("umb-loop needs its patch: run pi-umbra-subagents/patch.mjs, then restart pi", "warning");
66
+ }
67
+
68
+ loop = parse(args);
69
+ cancelled = false;
70
+ ctx.ui.setStatus("loop", describe());
71
+ ctx.ui.notify(`loop on — "${loop.prompt}"`);
72
+ if (ctx.isIdle()) {
73
+ loop.done++;
74
+ submit(loop.prompt);
75
+ }
76
+ },
77
+ });
78
+
79
+ // An aborted run (Escape) keeps the loop armed but skips this yield, so the turn comes back to
80
+ // the user. Read from how the run ended, not from the Escape key: an Escape that only closed a
81
+ // panel or an autocomplete list aborts nothing and must not stall the loop.
82
+ pi.on("agent_end", (event) => {
83
+ const last = [...event.messages].reverse().find((message) => message.role === "assistant");
84
+ if (loop && last && "stopReason" in last && last.stopReason === "aborted") cancelled = true;
85
+ });
86
+
87
+ pi.on("agent_settled", (_event, ctx) => {
88
+ if (!loop) return;
89
+ if (cancelled) {
90
+ cancelled = false;
91
+ ctx.ui.notify("loop: iteration cancelled");
92
+ return;
93
+ }
94
+ if (loop.deadline && Date.now() >= loop.deadline) return stop(ctx, "loop done (time up)");
95
+ if (loop.iterations && loop.done >= loop.iterations) return stop(ctx, "loop done");
96
+
97
+ loop.done++;
98
+ ctx.ui.setStatus("loop", describe());
99
+ const prompt = loop.prompt;
100
+ setTimeout(() => loop && submit(prompt), RESUBMIT_DELAY_MS);
101
+ });
102
+ }
@@ -0,0 +1,344 @@
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 above the input box occupies, as pure functions over one run
6
+ // snapshot. Two shapes: the flat live list of reference 3, which is the default, and the
7
+ // one-line collapsed bar of reference 2, which replaces it while a phased run is going.
8
+ //
9
+ // All of it lives here rather than in the component because the arithmetic is the part that can
10
+ // be wrong in a way that hurts: a composed line one column too wide wraps in the terminal, and
11
+ // a wrapped line above the editor pushes the input box down by a row. Pure functions can be
12
+ // walked across every width by bar.check.ts with no terminal, no theme and no pi — and pi
13
+ // cannot even be imported by a check, because loading it drags in the experimental server.
14
+ // The one thing this file borrows from pi is `ThemeColor`, as a type-only import, which is
15
+ // erased before anything runs.
16
+ //
17
+ // Widths are measured before colour goes on. Every piece returned by the two layout functions
18
+ // is plain text whose width is exactly what it will occupy, and renderLines() below only ever
19
+ // concatenates them in order — which is also why a row hands back `dotAt`, the one cell that
20
+ // takes the status colour, instead of a pre-coloured glyph.
21
+
22
+ export const PAD = 1; // one column of gutter each side, matching the rows pi draws itself
23
+ const GAP = 2; // collapsed bar: name -> description, and description -> counters
24
+ const COL_GAP = 3; // list: agent name -> activity, as in reference 3
25
+ const MIN_DESC = 12; // below this the description is noise, so the columns go to the counters
26
+ const MIN_ACTIVITY = 10; // ditto for the activity sentence, which is the point of the list
27
+ const NAME_CAP = 24; // columns the run name may claim before the counters start shrinking
28
+ const HEAD_CAP = 28; // ditto for the list's marker + dot + name column
29
+ const MIN_INNER = 8; // under this nothing but a stub of the main row fits
30
+
31
+ /** Reference 1 shows "idle 53s", so a branch counts as quiet well before a minute. */
32
+ export const IDLE_MS = 15_000;
33
+ /** How long a finished run stays on screen before the widget falls back to bare `○ main`. */
34
+ export { LINGER_MS };
35
+
36
+ /** "45s", "3m 52s" — the reference-2 and reference-3 spelling, with the space. Reference 1
37
+ * writes "3m15s" instead; that clock lives in panel.ts, where the columns are tighter. */
38
+ export const clock = (ms: number) => {
39
+ const seconds = Math.max(0, Math.round(ms / 1000));
40
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
41
+ };
42
+
43
+ // A dash, not "0": a provider that reports usage only at completion leaves the sum at zero for
44
+ // minutes, and a fake number in the column the user glances at is worse than an honest gap.
45
+ export const compact = (tokens: number) =>
46
+ tokens <= 0 ? "–" : tokens < 1000 ? `${tokens}` : `${(tokens / 1000).toFixed(1)}k`;
47
+
48
+ // RunState carries no end time — liveness is per branch — so the last beacon write in the run
49
+ // stands in for one. It is what freezes the elapsed clock, so a finished run stops counting.
50
+ export const endedAt = (run: RunState) => (run.live ? 0 : Math.max(0, ...run.branches.map((branch) => branch.updatedAt)));
51
+
52
+ /** Past its linger window a finished run is the same thing as no run at all: one bare `○ main`
53
+ * row. Both shapes ask that question, so they ask it through one function. */
54
+ export const faded = (run: RunState | undefined, now: number): boolean => {
55
+ if (!run || run.total === 0) return true;
56
+ const ended = endedAt(run);
57
+ return ended > 0 && now - ended > LINGER_MS;
58
+ };
59
+
60
+ /** Which of the two screens to draw. A run that declares more than one phase is a workflow: it
61
+ * has an order, a sidebar, and an "N/M agents done" worth summarising, so it collapses to
62
+ * reference 2 and zooms into reference 1. One wave of parallel branches has no structure to
63
+ * hide, so it stays the flat list — which is what reference 3 is: the no-workflow case. */
64
+ export const isStructured = (run: RunState | undefined): run is RunState => !!run && run.phases.length > 1;
65
+
66
+ // ---------- REFERENCE 2: the collapsed bar ----------
67
+
68
+ // Counters first, widest tier that fits: the numbers are the reason to glance at the bar, so a
69
+ // narrow terminal drops the description and then the run name, never the right-hand block.
70
+ const counterTiers = (run: RunState, until: number) => {
71
+ const time = clock(until - run.startedAt);
72
+ const agents = `${run.done}/${run.total}`;
73
+ return [
74
+ `${agents} agents done · ${time} · ↓ ${compact(run.tokens)} tokens`,
75
+ `${agents} · ${time} · ↓ ${compact(run.tokens)}`,
76
+ `${agents} · ${time}`,
77
+ agents,
78
+ ];
79
+ };
80
+
81
+ export type BarParts = { left: string; description: string; fill: string; right: string; live: boolean };
82
+
83
+ /** Reference 2, whole: `○ pi-toolcall-render Map how pi renders tool calls... 2/4 agents done
84
+ * · 4m 38s · ↓ 397.8k tokens`. Returns undefined when there is nothing to collapse and the
85
+ * caller falls back to the list, so the widget is never zero lines. */
86
+ export const layoutBar = (width: number, run: RunState | undefined, now: number): BarParts | undefined => {
87
+ if (!run || faded(run, now)) return undefined;
88
+ const inner = width - PAD * 2;
89
+ if (inner < MIN_INNER) return undefined;
90
+ const ended = endedAt(run);
91
+
92
+ // Same rule as the list rows, asked of the whole run: filled while at least one branch is
93
+ // producing, hollow while they are all waiting or done. No animation - a spinner costs a
94
+ // timer of its own, and the counters already tick.
95
+ const name = `${run.live && run.branches.some(working) ? "●" : "○"} ${run.name}`;
96
+ // Pick the counters around the name rather than the other way round: a full counter block
97
+ // that leaves "pi-toolca..." identifies nothing. The cap stops one long name from forcing
98
+ // the stubbiest counters on a wide terminal.
99
+ const wanted = Math.min(visibleWidth(name), NAME_CAP);
100
+ const tiers = counterTiers(run, ended || now);
101
+ const right = tiers.find((tier) => visibleWidth(tier) <= inner - wanted - GAP) ?? tiers[tiers.length - 1] ?? "";
102
+ const rightWidth = visibleWidth(right);
103
+ const leftBudget = inner - rightWidth - GAP;
104
+ // Under six columns the name is all ellipsis, so the glyph alone says a run is there.
105
+ const left = leftBudget >= 6 ? truncateToWidth(name, leftBudget) : name.slice(0, 1);
106
+ const leftWidth = visibleWidth(left);
107
+ // The description carries its own leading gap: spaces take no colour, so the pieces stay
108
+ // four plain strings that concatenate in order to exactly `inner` columns.
109
+ const descBudget = inner - leftWidth - rightWidth - GAP * 2;
110
+ const description = descBudget >= MIN_DESC ? " ".repeat(GAP) + truncateToWidth(run.description, descBudget) : "";
111
+ const fill = Math.max(0, inner - leftWidth - visibleWidth(description) - rightWidth);
112
+ return { left, description, fill: " ".repeat(fill), right, live: run.live };
113
+ };
114
+
115
+ // ---------- REFERENCE 3: the flat live list ----------
116
+
117
+ /** One row of reference 3. `head` is the nesting marker, the status dot and the agent name,
118
+ * padded so every row's activity starts in the same column; `dotAt` is the one cell inside it
119
+ * that takes the status colour, or -1 on a row that has no dot. `branch` is undefined on the
120
+ * `○ main` row, and is what tone() below reads to pick that cell's colour. */
121
+ export type ListRow = {
122
+ head: string;
123
+ dotAt: number;
124
+ activity: string;
125
+ fill: string;
126
+ right: string;
127
+ branch?: BranchView;
128
+ };
129
+
130
+ // Reference 3's nesting marker, two columns per level with `└ ` on the last. Same shape as
131
+ // panel.ts's, because the panel is the zoomed-in view of these same rows.
132
+ const nest = (depth: number) => (depth > 0 ? `${" ".repeat(depth - 1)}└ ` : "");
133
+
134
+ // Filled while the branch is working, hollow when it is pending, quiet or finished. `○ main`
135
+ // is hollow for the same reason the reference draws it hollow: the session is not a branch.
136
+ /** Filled while a branch is actually producing; hollow once it goes quiet or finishes. This is
137
+ * the ONLY status-glyph rule in the file. The collapsed bar asks it of the whole run instead
138
+ * of keeping a second opinion, which is what made reference 2 draw a filled dot on a run whose
139
+ * branches were all waiting. */
140
+ export const working = (branch: BranchView) =>
141
+ branch.alive && branch.status === "running" && branch.idleMs < IDLE_MS;
142
+
143
+ const dot = (branch: BranchView) => (working(branch) ? "●" : "○");
144
+
145
+ /** What the row says it is doing. A branch that was killed never got to write "done", so its
146
+ * own `activity` is frozen on whatever it was mid-way through — a row reading "Running cd"
147
+ * on a process that has been dead for four minutes. The exit file already knows better. */
148
+ export const said = (branch: BranchView): string => {
149
+ if (branch.timedOut) return "Timed out";
150
+ if (branch.status === "error") return branch.error ? `Failed: ${branch.error}` : "Failed";
151
+ // Killed by hand or by a parent going away: a non-zero exit with no error text of its own.
152
+ if (branch.settled && branch.exitCode !== null && branch.exitCode !== 0 && !branch.report) return "Stopped";
153
+ return branch.activity ?? "";
154
+ };
155
+
156
+ // Chosen once for the whole list rather than per row, so the right-hand block is one column
157
+ // instead of four ragged ones. Same order of sacrifice as the bar: the tokens go first, then
158
+ // the arrow, and the clock last.
159
+ const rowRight = (branch: BranchView, tier: number) => {
160
+ const time = clock(branch.elapsedMs);
161
+ if (tier === 0) return `${time} · ↓ ${compact(branch.tokens ?? 0)} tokens`;
162
+ if (tier === 1) return `${time} · ↓ ${compact(branch.tokens ?? 0)}`;
163
+ if (tier === 2) return time;
164
+ return "";
165
+ };
166
+
167
+ const widest = (values: string[]) => Math.max(0, ...values.map(visibleWidth));
168
+
169
+ /** Reference 3, whole. Always at least one row, because a widget that renders zero lines
170
+ * un-mounts itself and the input box drops a line the moment it does.
171
+ *
172
+ * `max` is the row budget the component computes from the terminal height. Anything past it
173
+ * collapses into a final "+N more", so the list has a ceiling but never a hidden agent. */
174
+ export const layoutList = (width: number, run: RunState | undefined, now: number, max: number): ListRow[] => {
175
+ const inner = width - PAD * 2;
176
+ const main = "○ main";
177
+ // Sub-8 columns is not a terminal anyone reads a table in, and one stub row still holds the
178
+ // height the input box is sitting on.
179
+ if (inner < MIN_INNER) {
180
+ return [{ head: truncateToWidth(main, Math.max(0, inner), "…", true), dotAt: 0, activity: "", fill: "", right: "" }];
181
+ }
182
+
183
+ // readRun() already orders parents immediately above their children by launch order, so
184
+ // taking a prefix keeps the tree readable and, more importantly, keeps a row from sliding
185
+ // under the eye when a sibling settles. Sorting the live ones to the top would do that.
186
+ const all = !run || faded(run, now) ? [] : run.branches;
187
+ // The overflow row spends the budget's last slot rather than adding to it, so a run of forty
188
+ // branches is exactly as tall as a run of `max` and never one row taller.
189
+ const room = Math.max(0, max - 1); // rows left after `○ main`
190
+ const shown = all.length <= room ? all : all.slice(0, Math.max(0, room - 1));
191
+ const hidden = room > 0 ? all.length - shown.length : 0;
192
+ const overflow = hidden > 0 ? `+${hidden} more` : "";
193
+
194
+ const heads = shown.map((branch) => `${nest(branch.depth)}${dot(branch)} ${branch.name}`);
195
+ const headWidth = Math.min(inner, HEAD_CAP, Math.max(1, widest([main, ...heads, overflow])));
196
+
197
+ // One tier for the list, not one per row: the widest that still leaves the activity
198
+ // sentence a readable column next to the longest head.
199
+ let tier = 0;
200
+ let rightWidth = 0;
201
+ for (; tier < 3; tier++) {
202
+ rightWidth = widest(shown.map((branch) => rowRight(branch, tier)));
203
+ if (headWidth + COL_GAP + MIN_ACTIVITY + rightWidth <= inner) break;
204
+ }
205
+ if (tier === 3) rightWidth = 0;
206
+ // Clamped after the choice as well as before it: on a narrow terminal even the bare clock
207
+ // can be too wide, and an over-wide row is the one failure that moves the input box.
208
+ rightWidth = Math.min(rightWidth, Math.max(0, inner - headWidth));
209
+ const activityBudget = inner - headWidth - COL_GAP - rightWidth;
210
+
211
+ const row = (headText: string, at: number, text: string, right: string): ListRow => {
212
+ const head = truncateToWidth(headText, headWidth, "…", true);
213
+ const activity =
214
+ text && activityBudget >= MIN_ACTIVITY ? " ".repeat(COL_GAP) + truncateToWidth(text, activityBudget) : "";
215
+ // Padded on the left, so the clocks sit under each other instead of only the right edge
216
+ // lining up. Spaces take no colour, so this stays one string the component can dim.
217
+ const padded = " ".repeat(Math.max(0, rightWidth - visibleWidth(right))) + right;
218
+ const fill = Math.max(0, inner - headWidth - visibleWidth(activity) - visibleWidth(padded));
219
+ return { head, dotAt: at, activity, fill: " ".repeat(fill), right: padded };
220
+ };
221
+
222
+ // `○ main` is the session itself and always sits at the top, with no activity and no
223
+ // counters — exactly as the reference draws it. The widget factory is handed a tui and a
224
+ // theme and nothing else, so there is no ExtensionContext here to ask for the session's own
225
+ // usage, and a number the row could not stand behind is worse than the blank the screen
226
+ // actually shows.
227
+ const rows: ListRow[] = [row(main, 0, "", "")];
228
+ shown.forEach((branch, index) => {
229
+ // SAFETY: heads is built by mapping `shown` in order, so this index is populated.
230
+ const head = heads[index] as string;
231
+ rows.push({ ...row(head, visibleWidth(nest(branch.depth)), said(branch), rowRight(branch, tier)), branch });
232
+ });
233
+ // No dot on the overflow row: it stands for agents rather than being one.
234
+ if (overflow) rows.push(row(overflow, -1, "", ""));
235
+ return rows;
236
+ };
237
+
238
+ // ---------- THE FRAME ----------
239
+
240
+ // The list's ceiling: `○ main` plus eight agents. Past that a "+N more" row stands in, so the
241
+ // widget can never eat the screen it is sitting on top of.
242
+ export const MAX_ROWS = 9;
243
+
244
+ // The same distinctions panel.ts draws, because the panel is the zoomed-in view of these rows:
245
+ // a timed-out branch answered nothing but did not crash, and a soft report is not a success.
246
+ const tone = (branch: BranchView | undefined): ThemeColor => {
247
+ if (!branch) return "dim";
248
+ if (branch.timedOut) return "warning";
249
+ if (branch.status === "error" || (branch.exitCode !== null && branch.exitCode !== 0)) return "error";
250
+ if (branch.report === "PARTIAL" || branch.report === "NEED_STRONGER" || branch.report === "ASKING") return "warning";
251
+ if (branch.settled) return "success";
252
+ return branch.alive && branch.idleMs < IDLE_MS ? "accent" : "dim";
253
+ };
254
+
255
+ /** Everything on screen, in one string. A frame identical to the last one is never requested:
256
+ * pi coalesces renders at 16 ms, but the cheapest frame is the one nobody asks for, and an
257
+ * idle session whose reader ticks once a second would otherwise repaint a blank row for as
258
+ * long as pi stays open. Width is deliberately absent — a resize repaints everything anyway. */
259
+ export const renderKey = (run: RunState | undefined, now: number): string => {
260
+ if (!run || faded(run, now)) return "-";
261
+ const at = endedAt(run) || now;
262
+ const head = [run.name, run.description, run.done, run.total, run.tokens, Math.round((at - run.startedAt) / 1000)];
263
+ const rows = run.branches.map((branch) =>
264
+ [
265
+ branch.stem,
266
+ branch.depth,
267
+ branch.status,
268
+ said(branch),
269
+ branch.tokens,
270
+ Math.round(branch.elapsedMs / 1000),
271
+ branch.alive ? 1 : 0,
272
+ branch.idleMs < IDLE_MS ? 1 : 0,
273
+ ].join(","),
274
+ );
275
+ return [...head, ...rows].join("|");
276
+ };
277
+
278
+ /** `theme.fg`, narrowed to the one method used, so the frame can be composed without a TUI. */
279
+ export type Paint = (color: ThemeColor, text: string) => string;
280
+
281
+ /**
282
+ * The whole frame. Three returns, and every one of them is provably the right height:
283
+ *
284
+ * 1. the panel is open, or the terminal is too narrow to hold anything -> `[blank]`, one element
285
+ * 2. a phased run is live -> `[collapsed]`, one element, reference 2
286
+ * 3. anything else -> layoutList(), which always yields at least the `○ main` row
287
+ *
288
+ * There is no fourth path, no early `return []`, and no branch that appends to a one-line
289
+ * array. That is the fixed-height rule: a widget above the editor that changes height moves the
290
+ * input box, and reference 2's line is the one the user is watching while typing.
291
+ */
292
+ export const renderLines = (
293
+ width: number,
294
+ run: RunState | undefined,
295
+ now: number,
296
+ budget: number,
297
+ paint: Paint,
298
+ panelIsOpen = false,
299
+ ): string[] => {
300
+ const total = Math.max(0, width);
301
+
302
+ // Nothing running: draw nothing at all, not a bare `○ main`. pi renders the belowEditor
303
+ // container with spacerWhenEmpty and leadingSpacer both false, so zero lines really is zero
304
+ // rows and the footer above it does not move when a run starts or ends. That was not true
305
+ // while this slot sat above the editor - there, appearing and disappearing would have
306
+ // shifted the input box under the user's hands, which is why the idle row existed.
307
+ if (faded(run, now)) return [];
308
+
309
+ // The panel already draws all of this, and it took the editor's slot to do it. The width
310
+ // guard rides along: below two gutters plus a couple of columns there is nothing to say, and
311
+ // a blank line of the right height beats a two-column line in a one-column terminal.
312
+ if (panelIsOpen || total < PAD * 2 + 4) return [" ".repeat(total)];
313
+
314
+ const parts = isStructured(run) ? layoutBar(total, run, now) : undefined;
315
+ if (parts) {
316
+ // Composed in order, nothing padded here: layoutBar() already made the pieces sum to
317
+ // exactly the inner width, and a second opinion measured in code units rather than in
318
+ // terminal columns would be wrong the first time a description carried a wide
319
+ // character. Colour goes on after truncation, so a coloured line is never wider than
320
+ // the plain one that was measured.
321
+ return [
322
+ " ".repeat(PAD) +
323
+ paint(parts.live ? "accent" : "dim", parts.left.slice(0, 1)) +
324
+ paint("text", parts.left.slice(1)) +
325
+ paint("muted", parts.description) +
326
+ parts.fill +
327
+ paint("dim", parts.right) +
328
+ " ".repeat(PAD),
329
+ ];
330
+ }
331
+
332
+ return layoutList(total, run, now, budget).map((row) => {
333
+ // `head` is plain text, and everything left of the dot is `└`, a space, or a name
334
+ // matching ^[a-z0-9][a-z0-9._-]*$ — so a visible column and a code-unit index are the
335
+ // same number here and slicing cannot cut a character in half.
336
+ const head =
337
+ row.dotAt >= 0
338
+ ? paint("dim", row.head.slice(0, row.dotAt)) +
339
+ paint(tone(row.branch), row.head.slice(row.dotAt, row.dotAt + 1)) +
340
+ paint(row.branch ? "text" : "dim", row.head.slice(row.dotAt + 1))
341
+ : paint("dim", row.head);
342
+ return " ".repeat(PAD) + head + paint("muted", row.activity) + row.fill + paint("dim", row.right) + " ".repeat(PAD);
343
+ });
344
+ };