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.
- package/README.md +7 -5
- package/node_modules/pi-umbra-help/README.md +1 -0
- package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +7 -0
- package/node_modules/pi-umbra-help/extensions/umbra-help.ts +12 -1
- package/node_modules/pi-umbra-help/package.json +1 -1
- package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +2 -1
- package/node_modules/pi-umbra-inputbar/package.json +1 -1
- package/node_modules/pi-umbra-shimmer/package.json +1 -1
- package/node_modules/pi-umbra-shimmer/patch.mjs +21 -7
- package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
- package/node_modules/pi-umbra-skill-matcher/patch.mjs +21 -7
- package/node_modules/pi-umbra-subagents/LICENSE +21 -0
- package/node_modules/pi-umbra-subagents/README.md +101 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-loop.ts +102 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +262 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +198 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.ts +229 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +141 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/models.ts +137 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +88 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +140 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +490 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.check.ts +237 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.ts +378 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/SKILL.md +95 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/beacon.ts +210 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/delegate.env +12 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/report.md +15 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.check.sh +120 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.sh +170 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.check.ts +137 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +295 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +52 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents.ts +4 -0
- package/node_modules/pi-umbra-subagents/package.json +43 -0
- package/node_modules/pi-umbra-subagents/patch.mjs +97 -0
- package/node_modules/pi-umbra-theme/README.md +39 -16
- package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +44 -1
- package/node_modules/pi-umbra-theme/checks/umbra-image-viewer.check.ts +85 -0
- package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +20 -1
- package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +17 -10
- package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +52 -1
- package/node_modules/pi-umbra-theme/extensions/umbra-image-viewer.ts +230 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/compact-mode.ts +3 -3
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/hover.ts +0 -4
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/interaction.ts +20 -68
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/layout.ts +0 -4
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/scroll.ts +6 -190
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/diff-palette.ts +44 -3
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/shiki-highlight.ts +5 -3
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/names.ts +1 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +16 -6
- package/node_modules/pi-umbra-theme/package.json +1 -1
- package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +1 -1
- package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +4 -4
- package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +10 -10
- package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +5 -5
- package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +20 -20
- package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +4 -4
- 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");
|