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 +21 -0
- package/README.md +88 -0
- package/extensions/umbra-loop.ts +102 -0
- package/extensions/umbra-subagents/bar/bar-line.ts +344 -0
- package/extensions/umbra-subagents/bar.check.ts +229 -0
- package/extensions/umbra-subagents/bar.ts +173 -0
- package/extensions/umbra-subagents/fan/index.ts +110 -0
- package/extensions/umbra-subagents/fan/spec.ts +81 -0
- package/extensions/umbra-subagents/fan/store.check.ts +140 -0
- package/extensions/umbra-subagents/fan/store.ts +491 -0
- package/extensions/umbra-subagents/panel.check.ts +193 -0
- package/extensions/umbra-subagents/panel.ts +333 -0
- package/extensions/umbra-subagents/skills/delegate/SKILL.md +95 -0
- package/extensions/umbra-subagents/skills/delegate/beacon.ts +210 -0
- package/extensions/umbra-subagents/skills/delegate/delegate.env +12 -0
- package/extensions/umbra-subagents/skills/delegate/report.md +15 -0
- package/extensions/umbra-subagents/skills/delegate/run.check.sh +120 -0
- package/extensions/umbra-subagents/skills/delegate/run.sh +170 -0
- package/extensions/umbra-subagents/skills/delegate/state.check.ts +137 -0
- package/extensions/umbra-subagents/skills/delegate/state.ts +295 -0
- package/extensions/umbra-subagents/skills/fan/SKILL.md +52 -0
- package/extensions/umbra-subagents.ts +4 -0
- package/package.json +43 -0
- package/patch.mjs +97 -0
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
|
+

|
|
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
|
+
};
|