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