pi-umbra 0.3.0 → 0.4.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 +3 -4
- package/node_modules/@shikijs/cli/dist/cli.d.mts +13 -6
- package/node_modules/@shikijs/cli/dist/cli.mjs +32 -27
- package/node_modules/@shikijs/cli/dist/{code-to-ansi-BNrZay-4.mjs → code-to-ansi-Dmm_CuZ9.mjs} +15 -7
- package/node_modules/@shikijs/cli/dist/index.d.mts +24 -3
- package/node_modules/@shikijs/cli/dist/index.mjs +1 -1
- package/node_modules/@shikijs/cli/package.json +3 -3
- package/node_modules/@shikijs/core/dist/index.d.mts +28 -28
- package/node_modules/@shikijs/core/dist/index.mjs +15 -12
- package/node_modules/@shikijs/core/package.json +4 -4
- package/node_modules/@shikijs/engine-javascript/dist/{engine-compile-Bdn9ihA2.d.mts → engine-compile-CtqiSkRk.d.mts} +5 -0
- package/node_modules/@shikijs/engine-javascript/dist/engine-compile.d.mts +1 -1
- package/node_modules/@shikijs/engine-javascript/dist/engine-compile.mjs +2 -4
- package/node_modules/@shikijs/engine-javascript/dist/engine-raw.d.mts +2 -3
- package/node_modules/@shikijs/engine-javascript/dist/engine-raw.mjs +2 -4
- package/node_modules/@shikijs/engine-javascript/dist/index.d.mts +1 -1
- package/node_modules/@shikijs/engine-javascript/dist/index.mjs +1 -1
- package/node_modules/@shikijs/engine-javascript/dist/string-TLTPtCPs.mjs +115 -0
- package/node_modules/@shikijs/engine-javascript/package.json +3 -3
- package/node_modules/@shikijs/engine-oniguruma/package.json +3 -3
- package/node_modules/@shikijs/langs/dist/ada.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/ahk.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/beancount.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/coq.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/csharp.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/css.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/dart.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/dotenv.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/emacs-lisp.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/gn.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/handlebars.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/julia.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/latex.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/luau.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/marko.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/moonbit.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/nextflow-groovy.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/nextflow.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/nushell.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/php.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/powershell.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/ruby.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/rust.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/sql.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/svelte.mjs +1 -1
- package/node_modules/@shikijs/langs/dist/twig.mjs +3 -3
- package/node_modules/@shikijs/langs/dist/typespec.mjs +1 -1
- package/node_modules/@shikijs/langs/package.json +3 -3
- package/node_modules/@shikijs/primitive/dist/index.d.mts +31 -32
- package/node_modules/@shikijs/primitive/dist/index.mjs +2 -1
- package/node_modules/@shikijs/primitive/package.json +3 -3
- package/node_modules/@shikijs/themes/dist/monokai.mjs +1 -1
- package/node_modules/@shikijs/themes/dist/one-dark-pro.mjs +1 -1
- package/node_modules/@shikijs/themes/dist/red.mjs +1 -1
- package/node_modules/@shikijs/themes/dist/solarized-dark.mjs +1 -1
- package/node_modules/@shikijs/themes/dist/solarized-light.mjs +1 -1
- package/node_modules/@shikijs/themes/package.json +3 -3
- package/node_modules/@shikijs/types/dist/index.d.mts +76 -76
- package/node_modules/@shikijs/types/package.json +1 -1
- package/node_modules/pi-umbra-copy-chat/README.md +4 -7
- package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +16 -54
- package/node_modules/pi-umbra-copy-chat/package.json +1 -1
- package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +10 -1
- package/node_modules/pi-umbra-help/extensions/umbra-help.ts +31 -0
- package/node_modules/pi-umbra-help/package.json +1 -1
- package/node_modules/pi-umbra-inputbar/README.md +1 -1
- package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +1 -1
- package/node_modules/pi-umbra-inputbar/package.json +2 -2
- package/node_modules/pi-umbra-shimmer/package.json +1 -1
- package/node_modules/pi-umbra-shimmer/patch.mjs +23 -21
- package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
- package/node_modules/pi-umbra-skill-matcher/patch.mjs +7 -0
- package/node_modules/pi-umbra-subagents/README.md +24 -6
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +250 -102
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +133 -66
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.ts +59 -27
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +53 -8
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +24 -14
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +132 -5
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +156 -64
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/worktree.ts +156 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.ts +1 -1
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +19 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +25 -4
- package/node_modules/pi-umbra-subagents/package.json +2 -2
- package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +7 -1
- package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +2 -1
- package/node_modules/pi-umbra-theme/package.json +3 -3
- package/node_modules/shiki/dist/bundle-full.d.mts +4 -4
- package/node_modules/shiki/dist/bundle-web.d.mts +9 -9
- package/node_modules/shiki/dist/types.d.mts +3 -3
- package/node_modules/shiki/package.json +10 -10
- package/package.json +9 -12
- package/node_modules/@shikijs/engine-javascript/dist/scanner-DX8LRFGE.mjs +0 -77
- package/node_modules/pi-umbra-rename/LICENSE +0 -21
- package/node_modules/pi-umbra-rename/README.md +0 -18
- package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +0 -55
- package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +0 -22
- package/node_modules/pi-umbra-rename/package.json +0 -36
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import { MAX_ROWS, layoutList, renderKey, renderLines } from "./bar/bar-line.ts";
|
|
2
|
+
import { MAX_ROWS, countsOf, layoutList, linesFor, renderKey, renderLines, said, stateOf, windowOf, type View } from "./bar/bar-line.ts";
|
|
3
3
|
import type { BranchView, RunState } from "./skills/delegate/state.ts";
|
|
4
4
|
|
|
5
5
|
// The widget sits directly above the input box, so two things can hurt and both are checked
|
|
6
6
|
// here rather than argued in a comment:
|
|
7
7
|
//
|
|
8
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
|
|
9
|
+
// 2. the `❯` column, the tree guides or the key hint pushing a row past the width does the same
|
|
10
10
|
//
|
|
11
11
|
// Everything under test is pure, so this needs no terminal, no theme and no pi.
|
|
12
12
|
// Run with: bun run bar.check.ts
|
|
@@ -63,17 +63,15 @@ const run = (over: Partial<RunState> = {}): RunState =>
|
|
|
63
63
|
|
|
64
64
|
const now = 278_000; // 4m 38s
|
|
65
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
66
|
// A finished run inside its linger window, and the same run once it has faded.
|
|
69
67
|
const settled = run({ live: false, done: 4, branches: [branch({ updatedAt: now, settled: true, alive: false })] });
|
|
70
68
|
|
|
71
|
-
//
|
|
72
|
-
|
|
69
|
+
// One phase: plain subagents, so the branches are the top level. `general-purpose` has one
|
|
70
|
+
// agent under it, which stays folded until enter opens it.
|
|
73
71
|
const flat = run({
|
|
74
|
-
phases: ["map"],
|
|
72
|
+
phases: ["map"],
|
|
75
73
|
done: 0,
|
|
76
|
-
total:
|
|
74
|
+
total: 3,
|
|
77
75
|
branches: [
|
|
78
76
|
branch(),
|
|
79
77
|
branch({
|
|
@@ -86,22 +84,82 @@ const flat = run({
|
|
|
86
84
|
activity: "Confirming store.ts is unused",
|
|
87
85
|
tokens: 86_300,
|
|
88
86
|
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
87
|
idleMs: 20_000,
|
|
94
88
|
}),
|
|
89
|
+
branch({ name: "routes", stem: "map-routes", label: "map:routes", index: 3, activity: "Reading src/server.ts", settled: true, alive: false, status: "done", exitCode: 0 }),
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Two phases: a workflow, one row named after the run, phases under it, branches under those.
|
|
94
|
+
const phased = run({
|
|
95
|
+
branches: [
|
|
96
|
+
branch(),
|
|
97
|
+
branch({ phase: "design", name: "proposal", stem: "design-proposal", label: "design:proposal", activity: "Writing it up" }),
|
|
95
98
|
],
|
|
96
99
|
});
|
|
97
100
|
|
|
101
|
+
const ink = { fg: (_color: unknown, text: string) => text, bold: (text: string) => text }; // measure the plain frame
|
|
102
|
+
const typing: View = { picking: false, cursor: "main", open: [] };
|
|
103
|
+
const pick = (cursor: string, open: string[] = []): View => ({ picking: true, cursor, open });
|
|
104
|
+
|
|
105
|
+
// ---------- the tree ----------
|
|
106
|
+
|
|
107
|
+
const top = linesFor(flat, now, []);
|
|
108
|
+
if (top.map((line) => line.node.label).join() !== "general-purpose,routes") fail(`top level: ${top.map((line) => line.node.label)}`);
|
|
109
|
+
if (!top[0]?.folded || top[1]?.folded) fail("only a row with agents under it is folded");
|
|
110
|
+
const opened = linesFor(flat, now, ["b:map-general-purpose"]);
|
|
111
|
+
if (opened.map((line) => `${line.guide}${line.node.label}`).join() !== "general-purpose,└ Explore,routes") fail(`opened: ${opened.map((line) => line.guide + line.node.label)}`);
|
|
112
|
+
if (opened[1]?.path.join() !== "b:map-general-purpose") fail("a child's path is its parent");
|
|
113
|
+
// The open path is one path: an id that is not on it opens nothing.
|
|
114
|
+
if (linesFor(flat, now, ["b:map-routes"]).length !== 2) fail("a leaf in the open path grew rows");
|
|
115
|
+
|
|
116
|
+
const workflow = linesFor(phased, now, []);
|
|
117
|
+
if (workflow.length !== 1 || workflow[0]?.node.label !== "pi-toolcall-render") fail("a phased run is one workflow row");
|
|
118
|
+
const phases = linesFor(phased, now, ["r:.pi-out/20260905-030000", "p:design"]);
|
|
119
|
+
if (phases.map((line) => `${line.guide}${line.node.label}`).join() !== "pi-toolcall-render,├ Map,└ Design, └ proposal") {
|
|
120
|
+
fail(`workflow open: ${phases.map((line) => line.guide + line.node.label)}`);
|
|
121
|
+
}
|
|
122
|
+
if (!phases[1]?.folded) fail("the phase that is not on the open path stays folded");
|
|
123
|
+
|
|
124
|
+
// ---------- states and counts ----------
|
|
125
|
+
|
|
126
|
+
if (stateOf(branch()) !== "running") fail("a branch that just wrote is running");
|
|
127
|
+
if (stateOf(branch({ idleMs: 20_000 })) !== "idle") fail("a quiet branch is idle");
|
|
128
|
+
if (stateOf(branch({ settled: true, exitCode: 0, status: "done" })) !== "done") fail("a clean exit is done");
|
|
129
|
+
if (stateOf(branch({ settled: true, exitCode: 143 })) !== "stopped") fail("a killed branch is stopped");
|
|
130
|
+
if (stateOf(branch({ status: "error", settled: true })) !== "stopped") fail("a failed branch is stopped");
|
|
131
|
+
if (stateOf(branch({ pid: null, status: "starting" })) !== "waiting") fail("a branch with no process yet is waiting");
|
|
132
|
+
if (countsOf(flat.branches) !== "1 done · 1 running · 1 idle") fail(`counts: ${countsOf(flat.branches)}`);
|
|
133
|
+
|
|
134
|
+
// ---------- the window ----------
|
|
135
|
+
|
|
136
|
+
// The cursor is always inside, and the window plus its "more" rows never outgrow the room.
|
|
137
|
+
for (let n = 0; n <= 30; n++) {
|
|
138
|
+
for (let room = 0; room <= 10; room++) {
|
|
139
|
+
for (let at = 0; at < Math.max(1, n); at++) {
|
|
140
|
+
const [first, end] = windowOf(n, at, room);
|
|
141
|
+
if (n && room && (at < first || at >= end)) fail(`window ${n}/${room}/${at}: cursor outside [${first}, ${end})`);
|
|
142
|
+
const arrows = room >= 3 ? Number(first > 0) + Number(end < n) : 0;
|
|
143
|
+
if (end - first + arrows > room) fail(`window ${n}/${room}/${at}: ${end - first} rows + ${arrows} arrows over ${room}`);
|
|
144
|
+
if (n > room && room >= 3 && end - first + arrows !== room) fail(`window ${n}/${room}/${at}: a row left empty`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------- the list ----------
|
|
150
|
+
|
|
98
151
|
for (let width = 8; width <= 200; width++) {
|
|
99
|
-
for (const state of [
|
|
100
|
-
|
|
152
|
+
for (const [state, view] of [
|
|
153
|
+
[flat, typing],
|
|
154
|
+
[flat, pick("b:map-explore", ["b:map-general-purpose"])],
|
|
155
|
+
[phased, pick("p:design", ["r:.pi-out/20260905-030000", "p:design"])],
|
|
156
|
+
[undefined, typing],
|
|
157
|
+
[settled, typing],
|
|
158
|
+
] as [typeof flat | undefined, View][]) {
|
|
159
|
+
const rows = layoutList(width, state, now, MAX_ROWS, view);
|
|
101
160
|
if (rows.length === 0) fail(`list width ${width}: zero rows un-mounts the widget`);
|
|
102
161
|
for (const row of rows) {
|
|
103
|
-
const used =
|
|
104
|
-
visibleWidth(row.head) + visibleWidth(row.activity) + row.fill.length + visibleWidth(row.right);
|
|
162
|
+
const used = visibleWidth(row.head) + visibleWidth(row.activity) + row.fill.length + visibleWidth(row.right);
|
|
105
163
|
if (used !== width - 2) fail(`list width ${width}: row composed ${used}, expected ${width - 2}`);
|
|
106
164
|
}
|
|
107
165
|
// The activity column has to start in the same place on every row, or the sentences
|
|
@@ -111,80 +169,80 @@ for (let width = 8; width <= 200; width++) {
|
|
|
111
169
|
}
|
|
112
170
|
}
|
|
113
171
|
|
|
114
|
-
const list = layoutList(
|
|
115
|
-
if (list.length !== 3) fail(`expected main + two agents, got ${list.length}`);
|
|
172
|
+
const list = layoutList(120, flat, now, MAX_ROWS);
|
|
173
|
+
if (list.length !== 3) fail(`expected main + two top-level agents, got ${list.length}`);
|
|
116
174
|
// SAFETY: length checked immediately above.
|
|
117
|
-
const [main,
|
|
118
|
-
if (main.head.trimEnd() !== "
|
|
119
|
-
if (main.
|
|
120
|
-
if (
|
|
121
|
-
if (
|
|
122
|
-
if (
|
|
123
|
-
if (
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
175
|
+
const [main, parent, leaf] = list as [(typeof list)[0], (typeof list)[0], (typeof list)[0]];
|
|
176
|
+
if (main.head.trimEnd() !== "● main") fail(`main row: ${JSON.stringify(main.head)}`);
|
|
177
|
+
if (main.activity !== "" || main.right.trim() !== "") fail("the main row carries counters it cannot stand behind");
|
|
178
|
+
if (parent.head.trimEnd() !== "○ general-purpose (+1)") fail(`folded row: ${JSON.stringify(parent.head)}`);
|
|
179
|
+
if (!parent.activity.includes("1 idle · Grepping localStorage keys")) fail(`folded activity: ${parent.activity}`);
|
|
180
|
+
if (parent.right !== "3m 52s · ↓ 56.5k tokens") fail(`right column: ${JSON.stringify(parent.right)}`);
|
|
181
|
+
if (parent.tone !== "accent") fail(`a running row is accent: ${parent.tone}`);
|
|
182
|
+
if (leaf.tone !== "success") fail(`a done row is green: ${leaf.tone}`);
|
|
183
|
+
// However tight the width, a truncated sentence keeps two columns before the clock.
|
|
184
|
+
for (let width = 40; width <= 120; width++) {
|
|
185
|
+
for (const row of layoutList(width, flat, now, MAX_ROWS).slice(1)) {
|
|
186
|
+
if (row.activity && row.right.trim() && !/\S {2,}\S/.test(row.activity + row.fill + row.right)) fail(`width ${width}: the sentence runs into the clock`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
136
190
|
const quiet = layoutList(100, run({ phases: ["map"], branches: [branch({ idleMs: 60_000 })] }), now, MAX_ROWS);
|
|
137
|
-
if (
|
|
191
|
+
if (quiet[1]?.tone !== "warning") fail(`an idle branch is yellow: ${quiet[1]?.tone}`);
|
|
138
192
|
|
|
139
193
|
const many = run({ phases: ["map"], branches: Array.from({ length: 40 }, (_, i) => branch({ stem: `map-b${i}` })) });
|
|
140
194
|
const capped = layoutList(100, many, now, MAX_ROWS);
|
|
141
195
|
if (capped.length !== MAX_ROWS) fail(`row budget ignored: ${capped.length}`);
|
|
142
|
-
if (!capped[MAX_ROWS - 1]?.head.startsWith("
|
|
143
|
-
if (capped[MAX_ROWS - 1]?.dotAt !== -1) fail("the
|
|
144
|
-
|
|
196
|
+
if (!capped[MAX_ROWS - 1]?.head.startsWith("↓ 33 more")) fail(`no "more" row: ${capped[MAX_ROWS - 1]?.head}`);
|
|
197
|
+
if (capped[MAX_ROWS - 1]?.dotAt !== -1) fail("the more row was given a status dot");
|
|
198
|
+
const scrolled = layoutList(100, many, now, MAX_ROWS, pick("b:map-b20"));
|
|
199
|
+
if (!scrolled[1]?.head.startsWith("↑ ") || !scrolled.some((row) => row.id === "b:map-b20")) fail("the window did not follow the cursor");
|
|
200
|
+
// A budget of one is `● main` and nothing else.
|
|
145
201
|
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
202
|
|
|
148
|
-
// ---------- the
|
|
203
|
+
// ---------- the frame ----------
|
|
149
204
|
|
|
150
|
-
const paint = (_color: unknown, text: string) => text; // identity: measure the plain frame
|
|
151
205
|
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
206
|
for (const [label, lines] of [
|
|
154
|
-
["
|
|
155
|
-
["
|
|
156
|
-
["
|
|
157
|
-
["
|
|
158
|
-
["
|
|
159
|
-
["faded", renderLines(width, settled, now + 31_000, MAX_ROWS,
|
|
207
|
+
["panel open", renderLines(width, phased, now, MAX_ROWS, ink, true)],
|
|
208
|
+
["idle", renderLines(width, undefined, now, MAX_ROWS, ink)],
|
|
209
|
+
["typing", renderLines(width, flat, now, MAX_ROWS, ink)],
|
|
210
|
+
["picking", renderLines(width, flat, now, MAX_ROWS, ink, false, pick("b:map-explore", ["b:map-general-purpose"]))],
|
|
211
|
+
["workflow", renderLines(width, phased, now, MAX_ROWS, ink, false, pick("r:.pi-out/20260905-030000"))],
|
|
212
|
+
["faded", renderLines(width, settled, now + 31_000, MAX_ROWS, ink)],
|
|
160
213
|
] as [string, string[]][]) {
|
|
161
|
-
// idle and faded
|
|
162
|
-
//
|
|
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.
|
|
214
|
+
// idle and faded draw NOTHING, at every width. The slot is belowEditor, where pi passes
|
|
215
|
+
// spacerWhenEmpty and leadingSpacer both false, so zero lines is zero rows.
|
|
166
216
|
if (label === "idle" || label === "faded") {
|
|
167
217
|
if (lines.length !== 0) fail(`${label} drew ${lines.length} lines at width ${width}, expected none`);
|
|
168
218
|
} else if (lines.length < 1) {
|
|
169
219
|
fail(`${label} rendered nothing at width ${width}`);
|
|
170
220
|
}
|
|
171
221
|
for (const line of lines) {
|
|
172
|
-
if (visibleWidth(line) !== Math.max(0, width)) fail(`${label} overflowed at width ${width}: ${line
|
|
222
|
+
if (visibleWidth(line) !== Math.max(0, width)) fail(`${label} overflowed at width ${width}: ${visibleWidth(line)}`);
|
|
173
223
|
}
|
|
174
224
|
}
|
|
175
225
|
}
|
|
176
|
-
if (renderLines(80, phased, now, MAX_ROWS,
|
|
226
|
+
if (renderLines(80, phased, now, MAX_ROWS, ink, true)[0]?.trim() !== "") fail("the open panel did not blank the widget");
|
|
177
227
|
|
|
178
228
|
// ---------- the cursor ----------
|
|
179
229
|
|
|
180
|
-
// `❯`
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (!
|
|
186
|
-
if (
|
|
187
|
-
|
|
230
|
+
// Typing: no `❯` and no hint. Picking: one `❯`, on the cursor's row, and the hint on top.
|
|
231
|
+
const typed = renderLines(100, flat, now, MAX_ROWS, ink);
|
|
232
|
+
if (typed.some((line) => line.includes("❯")) || typed.some((line) => line.includes("esc back"))) fail("typing drew the cursor or the hint");
|
|
233
|
+
const picked = renderLines(100, flat, now, MAX_ROWS, ink, false, pick("b:map-explore", ["b:map-general-purpose"]));
|
|
234
|
+
if (!picked[0]?.includes("↑↓ select") || picked[0]?.includes("enter")) fail(`hint on a leaf: ${picked[0]}`);
|
|
235
|
+
if (!picked[3]?.startsWith(" ❯ └ ○ Explore")) fail(`cursor not on the chosen agent: ${picked[3]}`);
|
|
236
|
+
if (picked.filter((line) => line.includes("❯")).length !== 1) fail("more than one cursor");
|
|
237
|
+
const onMain = renderLines(100, flat, now, MAX_ROWS, ink, false, pick("main", ["b:map-general-purpose"]));
|
|
238
|
+
if (!onMain[1]?.startsWith(" ❯ ● main") || !onMain[0]?.includes("enter collapse all")) fail(`cursor on main: ${onMain.slice(0, 2)}`);
|
|
239
|
+
if (!renderLines(100, flat, now, MAX_ROWS, ink, false, pick("b:map-general-purpose"))[0]?.includes("enter expand")) fail("a folded row does not offer enter");
|
|
240
|
+
|
|
241
|
+
// A declared phase with no branches yet has not run: grey, not a green "done".
|
|
242
|
+
const declared = layoutList(120, run({ phases: ["map", "design"], branches: [branch()] }), now, MAX_ROWS, pick("p:design", ["r:.pi-out/20260905-030000"]));
|
|
243
|
+
if (declared.find((row) => row.id === "p:design")?.tone !== "dim") fail("an empty phase looks finished");
|
|
244
|
+
// An open path that matches nothing opens nothing, so main does not offer to fold it.
|
|
245
|
+
if (renderLines(100, flat, now, MAX_ROWS, ink, false, pick("main", ["b:gone"]))[0]?.includes("collapse all")) fail("main offered to fold a tree that is not open");
|
|
188
246
|
|
|
189
247
|
// ---------- the no-op frame ----------
|
|
190
248
|
|
|
@@ -195,4 +253,13 @@ if (renderKey(flat, now) === renderKey(flat, now + 1_000)) fail("the clock moved
|
|
|
195
253
|
const rewritten = run({ ...flat, branches: [branch({ activity: "Reading hooks.server.ts and app.d.ts" }), ...flat.branches.slice(1)] });
|
|
196
254
|
if (renderKey(flat, now) === renderKey(rewritten, now)) fail("the activity sentence changed and the key did not");
|
|
197
255
|
|
|
256
|
+
// A write branch that finished with changes says where they are; a read-only one says what it said.
|
|
257
|
+
const git = { root: "/r", branch: "fan/r/build-fix", base: "abc", stat: "+1 −0 · 1 file" };
|
|
258
|
+
const wrote = branch({ settled: true, exitCode: 0, activity: "Done", git });
|
|
259
|
+
if (said(wrote) !== "Changed +1 −0 · 1 file on fan/r/build-fix") fail(`write branch row: ${said(wrote)}`);
|
|
260
|
+
// Committed work outlives a timeout, and the row says both.
|
|
261
|
+
const late = branch({ settled: true, exitCode: 124, timedOut: true, activity: "Editing", git });
|
|
262
|
+
if (said(late) !== "Timed out · Changed +1 −0 · 1 file on fan/r/build-fix") fail(`timed-out write row: ${said(late)}`);
|
|
263
|
+
if (said(branch({ settled: true, exitCode: 0, activity: "Done" })) !== "Done") fail("a read-only branch keeps its own sentence");
|
|
264
|
+
|
|
198
265
|
console.log("ok");
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { CustomEditor, type ExtensionAPI, type ExtensionContext, type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Editor, isKeyRelease, matchesKey, type Component, type EditorTheme, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
3
|
-
import { faded,
|
|
4
|
-
import type { RunState } from "./skills/delegate/state.ts";
|
|
3
|
+
import { faded, linesFor, MAIN, MAX_ROWS, renderKey, renderLines, type Line } from "./bar/bar-line.ts";
|
|
4
|
+
import type { BranchView, RunState } from "./skills/delegate/state.ts";
|
|
5
5
|
|
|
6
6
|
// Part 3: everything the widget slot below the input box draws, and the ways it opens into the
|
|
7
7
|
// full panel of reference 1.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// any other key hands the keyboard back to the
|
|
9
|
+
// Claude Code's agent tree: `● main` on top, one row per agent, one level open at a time. Down at
|
|
10
|
+
// the end of the editor moves the `❯` into the list; ↑↓ walk it, enter opens or closes the row
|
|
11
|
+
// under the cursor (on main it folds everything), x stops the row and everything under it, and
|
|
12
|
+
// alt+a opens the panel on it. Up past main, esc or any other key hands the keyboard back to the
|
|
13
|
+
// editor.
|
|
13
14
|
//
|
|
14
15
|
// The widget is mounted once at session_start and never removed. It asks for belowEditor, and
|
|
15
16
|
// repatch.mjs moves that slot below pi's status line. The slot grows a Spacer(1) the moment it holds anything, so registering and
|
|
@@ -37,10 +38,12 @@ class BarComponent implements Component {
|
|
|
37
38
|
private tui: TUI,
|
|
38
39
|
private theme: Theme,
|
|
39
40
|
private onOpen: (stem?: string) => void,
|
|
41
|
+
private onStop: (branch: BranchView) => void,
|
|
40
42
|
) {}
|
|
41
43
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
+
// Every row of the open tree, not just the ones in the window: the list scrolls to follow
|
|
45
|
+
// the cursor, so the cursor walks all of them.
|
|
46
|
+
private lines: Line[] = [];
|
|
44
47
|
private release: (() => void) | undefined;
|
|
45
48
|
|
|
46
49
|
render(width: number): string[] {
|
|
@@ -48,24 +51,27 @@ class BarComponent implements Component {
|
|
|
48
51
|
// it per frame costs nothing and keeps the list inside a shrunk window.
|
|
49
52
|
const budget = Math.max(1, Math.min(MAX_ROWS, this.tui.terminal.rows - RESERVE));
|
|
50
53
|
const now = Date.now();
|
|
51
|
-
this.
|
|
52
|
-
// A row that left the
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
this.lines = linesFor(current, now, open);
|
|
55
|
+
// A row that left the tree takes the cursor back to main; a tree with nothing left in it
|
|
56
|
+
// hands the keyboard back to the editor.
|
|
57
|
+
if (this.release && !this.lines.length) this.leave();
|
|
58
|
+
else if (cursor !== MAIN && !this.lines.some((line) => line.node.id === cursor)) cursor = MAIN;
|
|
59
|
+
return renderLines(width, current, now, budget, this.theme, panelOpen, { picking: !!this.release, cursor, open });
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/** Down at the end of the editor: the `❯` moves onto the first agent. The keys go through an
|
|
58
63
|
* input listener rather than a focus change, because the editor keeps its text, its cursor
|
|
59
64
|
* and its focus while the user looks down the list. */
|
|
60
65
|
enter(): void {
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
const first = this.lines[0];
|
|
67
|
+
if (this.release || panelOpen || !first) return;
|
|
68
|
+
cursor = first.node.id;
|
|
63
69
|
this.release = this.tui.addInputListener((data) => this.key(data));
|
|
64
70
|
this.refresh();
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
private leave(): void {
|
|
68
|
-
cursor =
|
|
74
|
+
cursor = MAIN;
|
|
69
75
|
this.release?.();
|
|
70
76
|
this.release = undefined;
|
|
71
77
|
this.refresh();
|
|
@@ -81,15 +87,33 @@ class BarComponent implements Component {
|
|
|
81
87
|
this.leave();
|
|
82
88
|
return undefined;
|
|
83
89
|
}
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
// Read now, not from the last frame: an enter that opened or closed a row may not have
|
|
91
|
+
// been painted yet when the next key arrives.
|
|
92
|
+
this.lines = linesFor(current, Date.now(), open);
|
|
93
|
+
const ids = [MAIN, ...this.lines.map((line) => line.node.id)];
|
|
94
|
+
const index = ids.indexOf(cursor);
|
|
95
|
+
const line = this.lines.find((candidate) => candidate.node.id === cursor);
|
|
96
|
+
if (matchesKey(data, "down")) cursor = ids[Math.min(index + 1, ids.length - 1)] ?? MAIN;
|
|
86
97
|
else if (matchesKey(data, "up")) {
|
|
98
|
+
// Up from main is up out of the list, and the key is spent: the editor would
|
|
99
|
+
// otherwise read it as a walk back through the prompt history.
|
|
87
100
|
if (index <= 0) this.leave();
|
|
88
|
-
else cursor =
|
|
89
|
-
} else if (matchesKey(data, "enter")
|
|
90
|
-
|
|
101
|
+
else cursor = ids[index - 1] ?? MAIN;
|
|
102
|
+
} else if (matchesKey(data, "enter")) {
|
|
103
|
+
// Enter on main folds everything back to the top level. On a row with agents under
|
|
104
|
+
// it, it opens that row and closes every other, or closes it again. On a leaf there
|
|
105
|
+
// is nothing to open.
|
|
106
|
+
if (!line) open = [];
|
|
107
|
+
else if (line.node.children.length) open = open[line.path.length] === line.node.id ? line.path : [...line.path, line.node.id];
|
|
108
|
+
} else if (line && (matchesKey(data, "x") || matchesKey(data, "shift+x"))) {
|
|
109
|
+
// The row and everything under it. A settled branch's pid belongs to whatever the OS
|
|
110
|
+
// handed it to next, so it is skipped here as well as in the producer.
|
|
111
|
+
for (const branch of line.node.branches) if (!branch.settled) this.onStop(branch);
|
|
112
|
+
} else if (matchesKey(data, "alt+a")) {
|
|
113
|
+
const stem = line?.node.branch?.stem;
|
|
91
114
|
this.leave();
|
|
92
115
|
this.onOpen(stem);
|
|
116
|
+
return { consume: true };
|
|
93
117
|
} else if (matchesKey(data, "escape")) this.leave();
|
|
94
118
|
else {
|
|
95
119
|
// Typing goes on where it was: the key reaches the editor as if the list were not there.
|
|
@@ -162,13 +186,17 @@ let current: RunState | undefined;
|
|
|
162
186
|
let panelOpen = false;
|
|
163
187
|
let bar: BarComponent | undefined;
|
|
164
188
|
let lastKey = "";
|
|
165
|
-
// The
|
|
166
|
-
|
|
189
|
+
// The row the `❯` is on while the list has the keyboard, and the one path of the tree that is
|
|
190
|
+
// open. Both outlive a rebuilt widget, so a /reload does not fold the tree back up.
|
|
191
|
+
let cursor = MAIN;
|
|
192
|
+
let open: string[] = [];
|
|
167
193
|
|
|
168
194
|
/** The bar holds no state and owns no timer: the reader that already polls `<cwd>/.pi-out` for
|
|
169
195
|
* the panel calls this every tick, so the two views can never disagree about the same run and
|
|
170
196
|
* only one thing is ever walking the disk. */
|
|
171
197
|
export const setRun = (run: RunState | undefined) => {
|
|
198
|
+
// A different run starts folded: ids from the last one could match a new row by stem.
|
|
199
|
+
if (run?.dir !== current?.dir) open = [];
|
|
172
200
|
current = run;
|
|
173
201
|
const key = renderKey(run, Date.now());
|
|
174
202
|
if (key === lastKey) return;
|
|
@@ -176,11 +204,15 @@ export const setRun = (run: RunState | undefined) => {
|
|
|
176
204
|
bar?.refresh();
|
|
177
205
|
};
|
|
178
206
|
|
|
179
|
-
export const installBar = (
|
|
207
|
+
export const installBar = (
|
|
208
|
+
pi: ExtensionAPI,
|
|
209
|
+
openPanel: (ctx: ExtensionContext, stem?: string) => Promise<void> | void,
|
|
210
|
+
stop: (branch: BranchView) => void,
|
|
211
|
+
) => {
|
|
180
212
|
// `quiet` is the down arrow and the click: both fire whether or not a run exists, and a
|
|
181
213
|
// notification on every press of a key the user meant as "move the cursor" is noise. The
|
|
182
214
|
// command and the shortcut were typed on purpose, so those get told why nothing happened.
|
|
183
|
-
const
|
|
215
|
+
const show = (ctx: ExtensionContext, quiet = false, stem?: string) => {
|
|
184
216
|
if (panelOpen) return quiet ? undefined : ctx.ui.notify("agent panel is already open");
|
|
185
217
|
// Opening on nothing would draw an empty box the user cannot fill. The quiet doors also
|
|
186
218
|
// skip a run the bar has already let go of: a key meant as "move the cursor" should not
|
|
@@ -210,7 +242,7 @@ export const installBar = (pi: ExtensionAPI, openPanel: (ctx: ExtensionContext,
|
|
|
210
242
|
ctx.ui.setWidget(
|
|
211
243
|
BAR_KEY,
|
|
212
244
|
(tui, theme) => {
|
|
213
|
-
bar = new BarComponent(tui, theme, (stem) =>
|
|
245
|
+
bar = new BarComponent(tui, theme, (stem) => show(ctx, true, stem), stop);
|
|
214
246
|
return bar;
|
|
215
247
|
},
|
|
216
248
|
{ placement: "belowEditor" },
|
|
@@ -224,6 +256,6 @@ export const installBar = (pi: ExtensionAPI, openPanel: (ctx: ExtensionContext,
|
|
|
224
256
|
|
|
225
257
|
// Not ctrl+<letter>: every one is already bound by pi, and extension shortcuts are matched
|
|
226
258
|
// before app keybindings, so one would silently shadow a built-in.
|
|
227
|
-
pi.registerShortcut("alt+a", { description: "Open the agent panel", handler: (ctx) =>
|
|
228
|
-
pi.registerCommand("umb-agents", { description: "Open the agent panel", handler: async (_args, ctx) =>
|
|
259
|
+
pi.registerShortcut("alt+a", { description: "Open the agent panel", handler: (ctx) => show(ctx) });
|
|
260
|
+
pi.registerCommand("umb-agents", { description: "Open the agent panel", handler: async (_args, ctx) => show(ctx) });
|
|
229
261
|
};
|
|
@@ -2,8 +2,8 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@e
|
|
|
2
2
|
import { installBar, setRun } from "../bar.ts";
|
|
3
3
|
import { openPanel, type PanelSource } from "../panel.ts";
|
|
4
4
|
import { canReach, describeOffer, fanSettings, forget, offerModels, reachableModels, RUN_SH, type Offer } from "./models.ts";
|
|
5
|
-
import { findBlock, parseSpec, TEMPLATE
|
|
6
|
-
import { piCommand, reportOf, store } from "./store.ts";
|
|
5
|
+
import { findBlock, parseSpec, TEMPLATE } from "./spec.ts";
|
|
6
|
+
import { piCommand, reportOf, store, type RunSpec } from "./store.ts";
|
|
7
7
|
import type { RunState } from "../skills/delegate/state.ts";
|
|
8
8
|
|
|
9
9
|
// Wiring only. store.ts owns the branches, bar.ts and panel.ts render them.
|
|
@@ -16,7 +16,8 @@ import type { RunState } from "../skills/delegate/state.ts";
|
|
|
16
16
|
// A branch that stopped on a question goes to the model first: most questions are answered by
|
|
17
17
|
// the conversation the branch never saw, and only the rest reach the user.
|
|
18
18
|
const askingNote = (run: RunState): string => {
|
|
19
|
-
|
|
19
|
+
// A write branch cannot be continued: its worktree closed when it ended.
|
|
20
|
+
const asking = run.branches.filter((branch) => branch.report === "ASKING" && !branch.git);
|
|
20
21
|
if (!asking.length) return "";
|
|
21
22
|
return (
|
|
22
23
|
`\n\n${asking.map((branch) => branch.stem).join(", ")} stopped on a question. Answer it from this ` +
|
|
@@ -27,6 +28,14 @@ const askingNote = (run: RunState): string => {
|
|
|
27
28
|
);
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
// A `[write]` branch leaves its work on a git branch, and merging it is always the user's call:
|
|
32
|
+
// the model asks, then does what they chose. reportOf already carries each branch's merge and
|
|
33
|
+
// discard commands.
|
|
34
|
+
const mergeNote = (run: RunState): string =>
|
|
35
|
+
run.branches.some((branch) => branch.git?.stat)
|
|
36
|
+
? "\n\nAsk the user with ask_user_question, one question per changed branch, whether to merge it, and do only what they choose."
|
|
37
|
+
: "";
|
|
38
|
+
|
|
30
39
|
// A branch starts with --no-extensions, so a model that only an extension provides is not there
|
|
31
40
|
// for it. Rather than guess another one, the model asks the user: which model to spend on is
|
|
32
41
|
// theirs to choose.
|
|
@@ -52,7 +61,7 @@ const source: PanelSource = {
|
|
|
52
61
|
export default function (pi: ExtensionAPI) {
|
|
53
62
|
// The widget above the editor, the alt+a shortcut, the /umb-agents command and the down arrow
|
|
54
63
|
// out of the last editor line. All four open the same panel over the same source.
|
|
55
|
-
installBar(pi, (ctx, stem) => openPanel(ctx, source, stem));
|
|
64
|
+
installBar(pi, (ctx, stem) => openPanel(ctx, source, stem), source.stop);
|
|
56
65
|
// The bar is pushed to rather than polling a second time: store already ticks once a
|
|
57
66
|
// second while branches are live, and a second reader could only disagree with it.
|
|
58
67
|
store.subscribe(() => setRun(store.run()));
|
|
@@ -61,10 +70,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
61
70
|
// A run the delegate skill started in bash is left alone: that caller reads $run/*.md
|
|
62
71
|
// itself, and a follow-up would hand the model the same text twice.
|
|
63
72
|
let awaiting: string | undefined;
|
|
73
|
+
// A run the user started with /umb-fan is theirs to read on the panel, but work a write
|
|
74
|
+
// branch committed must not go unmentioned once the row has faded.
|
|
75
|
+
let watching: { dir: string; ctx: ExtensionContext } | undefined;
|
|
64
76
|
|
|
65
77
|
// Branches must not outlive the pi that started them: nothing would be watching, and a
|
|
66
78
|
// branch left running keeps spending. Their reports are already on disk.
|
|
67
|
-
pi.on("session_shutdown", () =>
|
|
79
|
+
pi.on("session_shutdown", () => {
|
|
80
|
+
// A ctx from the session that is ending throws on every use once it is gone, and the
|
|
81
|
+
// children killed below still settle after this.
|
|
82
|
+
awaiting = undefined;
|
|
83
|
+
watching = undefined;
|
|
84
|
+
store.stopAll();
|
|
85
|
+
});
|
|
68
86
|
|
|
69
87
|
// A bash call may be the delegate skill mid-run. Arming the tick when one starts is what
|
|
70
88
|
// makes a skill-started run appear on the bar from its first frame rather than at the end;
|
|
@@ -93,7 +111,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
93
111
|
if (reply) pi.sendUserMessage(modelNote(missing, offer), { deliverAs: "followUp" });
|
|
94
112
|
return undefined;
|
|
95
113
|
}
|
|
96
|
-
|
|
114
|
+
let dir: string | undefined;
|
|
115
|
+
try {
|
|
116
|
+
dir = store.start(spec, fallback, ctx.cwd, brief, settings);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// A [write] branch outside a git repo, a worktree git refused, a full disk: nothing started.
|
|
119
|
+
const reason = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
120
|
+
ctx.ui.notify(`fan: ${reason}`, "warning");
|
|
121
|
+
if (reply) pi.sendUserMessage(`The fan run did not start: ${reason}`, { deliverAs: "followUp" });
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
97
124
|
if (!dir) ctx.ui.notify("fan: a run is already in flight — stop it from the panel first", "warning");
|
|
98
125
|
return dir;
|
|
99
126
|
};
|
|
@@ -121,9 +148,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
121
148
|
// that asked for them already ended, so the model reads them at the top of the next one.
|
|
122
149
|
store.subscribe(() => {
|
|
123
150
|
const run = store.run();
|
|
151
|
+
if (run && run.dir === watching?.dir && !run.live) {
|
|
152
|
+
const { ctx } = watching;
|
|
153
|
+
watching = undefined;
|
|
154
|
+
const left = run.branches.filter((branch) => branch.git?.stat || branch.git?.error);
|
|
155
|
+
if (left.length) {
|
|
156
|
+
ctx.ui.notify(
|
|
157
|
+
`fan: ${left.map((branch) => (branch.git?.error ? `${branch.stem} could not be committed (${branch.git.error})` : `${branch.stem} changed ${branch.git?.stat} on ${branch.git?.branch}`)).join("; ")}. Ask the session about it.`,
|
|
158
|
+
"info",
|
|
159
|
+
);
|
|
160
|
+
// The session learns the branches and their merge commands with the next message the
|
|
161
|
+
// user sends, without a turn of its own: asked to merge, it must not guess `git merge`.
|
|
162
|
+
pi.sendMessage(
|
|
163
|
+
{ customType: "umbra-fan", content: `Branch results for ${run.name}, a run the user started:\n\n${reportOf(run)}`, display: false },
|
|
164
|
+
{ deliverAs: "nextTurn" },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
124
168
|
if (!run || run.dir !== awaiting || run.live) return;
|
|
125
169
|
awaiting = undefined;
|
|
126
|
-
pi.sendUserMessage(`Branch results for ${run.name}:\n\n${reportOf(run)}${askingNote(run)}`, { deliverAs: "followUp" });
|
|
170
|
+
pi.sendUserMessage(`Branch results for ${run.name}:\n\n${reportOf(run)}${askingNote(run)}${mergeNote(run)}`, { deliverAs: "followUp" });
|
|
127
171
|
});
|
|
128
172
|
|
|
129
173
|
pi.registerCommand("umb-fan", {
|
|
@@ -135,7 +179,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
135
179
|
if (!spec) return ctx.ui.notify("fan: no branches in that spec", "warning");
|
|
136
180
|
// Started by the user, so the results are theirs to read on the panel; the model is
|
|
137
181
|
// only told about a run it asked for itself. launch says why when nothing started.
|
|
138
|
-
await launch(spec, ctx, text, false);
|
|
182
|
+
const dir = await launch(spec, ctx, text, false);
|
|
183
|
+
if (dir) watching = { dir, ctx };
|
|
139
184
|
},
|
|
140
185
|
});
|
|
141
186
|
}
|