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.
Files changed (99) hide show
  1. package/README.md +3 -4
  2. package/node_modules/@shikijs/cli/dist/cli.d.mts +13 -6
  3. package/node_modules/@shikijs/cli/dist/cli.mjs +32 -27
  4. package/node_modules/@shikijs/cli/dist/{code-to-ansi-BNrZay-4.mjs → code-to-ansi-Dmm_CuZ9.mjs} +15 -7
  5. package/node_modules/@shikijs/cli/dist/index.d.mts +24 -3
  6. package/node_modules/@shikijs/cli/dist/index.mjs +1 -1
  7. package/node_modules/@shikijs/cli/package.json +3 -3
  8. package/node_modules/@shikijs/core/dist/index.d.mts +28 -28
  9. package/node_modules/@shikijs/core/dist/index.mjs +15 -12
  10. package/node_modules/@shikijs/core/package.json +4 -4
  11. package/node_modules/@shikijs/engine-javascript/dist/{engine-compile-Bdn9ihA2.d.mts → engine-compile-CtqiSkRk.d.mts} +5 -0
  12. package/node_modules/@shikijs/engine-javascript/dist/engine-compile.d.mts +1 -1
  13. package/node_modules/@shikijs/engine-javascript/dist/engine-compile.mjs +2 -4
  14. package/node_modules/@shikijs/engine-javascript/dist/engine-raw.d.mts +2 -3
  15. package/node_modules/@shikijs/engine-javascript/dist/engine-raw.mjs +2 -4
  16. package/node_modules/@shikijs/engine-javascript/dist/index.d.mts +1 -1
  17. package/node_modules/@shikijs/engine-javascript/dist/index.mjs +1 -1
  18. package/node_modules/@shikijs/engine-javascript/dist/string-TLTPtCPs.mjs +115 -0
  19. package/node_modules/@shikijs/engine-javascript/package.json +3 -3
  20. package/node_modules/@shikijs/engine-oniguruma/package.json +3 -3
  21. package/node_modules/@shikijs/langs/dist/ada.mjs +1 -1
  22. package/node_modules/@shikijs/langs/dist/ahk.mjs +1 -1
  23. package/node_modules/@shikijs/langs/dist/beancount.mjs +1 -1
  24. package/node_modules/@shikijs/langs/dist/coq.mjs +1 -1
  25. package/node_modules/@shikijs/langs/dist/csharp.mjs +1 -1
  26. package/node_modules/@shikijs/langs/dist/css.mjs +1 -1
  27. package/node_modules/@shikijs/langs/dist/dart.mjs +1 -1
  28. package/node_modules/@shikijs/langs/dist/dotenv.mjs +1 -1
  29. package/node_modules/@shikijs/langs/dist/emacs-lisp.mjs +1 -1
  30. package/node_modules/@shikijs/langs/dist/gn.mjs +1 -1
  31. package/node_modules/@shikijs/langs/dist/handlebars.mjs +1 -1
  32. package/node_modules/@shikijs/langs/dist/julia.mjs +1 -1
  33. package/node_modules/@shikijs/langs/dist/latex.mjs +1 -1
  34. package/node_modules/@shikijs/langs/dist/luau.mjs +1 -1
  35. package/node_modules/@shikijs/langs/dist/marko.mjs +1 -1
  36. package/node_modules/@shikijs/langs/dist/moonbit.mjs +1 -1
  37. package/node_modules/@shikijs/langs/dist/nextflow-groovy.mjs +1 -1
  38. package/node_modules/@shikijs/langs/dist/nextflow.mjs +1 -1
  39. package/node_modules/@shikijs/langs/dist/nushell.mjs +1 -1
  40. package/node_modules/@shikijs/langs/dist/php.mjs +1 -1
  41. package/node_modules/@shikijs/langs/dist/powershell.mjs +1 -1
  42. package/node_modules/@shikijs/langs/dist/ruby.mjs +1 -1
  43. package/node_modules/@shikijs/langs/dist/rust.mjs +1 -1
  44. package/node_modules/@shikijs/langs/dist/sql.mjs +1 -1
  45. package/node_modules/@shikijs/langs/dist/svelte.mjs +1 -1
  46. package/node_modules/@shikijs/langs/dist/twig.mjs +3 -3
  47. package/node_modules/@shikijs/langs/dist/typespec.mjs +1 -1
  48. package/node_modules/@shikijs/langs/package.json +3 -3
  49. package/node_modules/@shikijs/primitive/dist/index.d.mts +31 -32
  50. package/node_modules/@shikijs/primitive/dist/index.mjs +2 -1
  51. package/node_modules/@shikijs/primitive/package.json +3 -3
  52. package/node_modules/@shikijs/themes/dist/monokai.mjs +1 -1
  53. package/node_modules/@shikijs/themes/dist/one-dark-pro.mjs +1 -1
  54. package/node_modules/@shikijs/themes/dist/red.mjs +1 -1
  55. package/node_modules/@shikijs/themes/dist/solarized-dark.mjs +1 -1
  56. package/node_modules/@shikijs/themes/dist/solarized-light.mjs +1 -1
  57. package/node_modules/@shikijs/themes/package.json +3 -3
  58. package/node_modules/@shikijs/types/dist/index.d.mts +76 -76
  59. package/node_modules/@shikijs/types/package.json +1 -1
  60. package/node_modules/pi-umbra-copy-chat/README.md +4 -7
  61. package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +16 -54
  62. package/node_modules/pi-umbra-copy-chat/package.json +1 -1
  63. package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +10 -1
  64. package/node_modules/pi-umbra-help/extensions/umbra-help.ts +31 -0
  65. package/node_modules/pi-umbra-help/package.json +1 -1
  66. package/node_modules/pi-umbra-inputbar/README.md +1 -1
  67. package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +1 -1
  68. package/node_modules/pi-umbra-inputbar/package.json +2 -2
  69. package/node_modules/pi-umbra-shimmer/package.json +1 -1
  70. package/node_modules/pi-umbra-shimmer/patch.mjs +23 -21
  71. package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
  72. package/node_modules/pi-umbra-skill-matcher/patch.mjs +7 -0
  73. package/node_modules/pi-umbra-subagents/README.md +24 -6
  74. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +250 -102
  75. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +133 -66
  76. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.ts +59 -27
  77. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +53 -8
  78. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +24 -14
  79. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +132 -5
  80. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +156 -64
  81. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/worktree.ts +156 -0
  82. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.ts +1 -1
  83. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +19 -0
  84. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +25 -4
  85. package/node_modules/pi-umbra-subagents/package.json +2 -2
  86. package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +7 -1
  87. package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +2 -1
  88. package/node_modules/pi-umbra-theme/package.json +3 -3
  89. package/node_modules/shiki/dist/bundle-full.d.mts +4 -4
  90. package/node_modules/shiki/dist/bundle-web.d.mts +9 -9
  91. package/node_modules/shiki/dist/types.d.mts +3 -3
  92. package/node_modules/shiki/package.json +10 -10
  93. package/package.json +9 -12
  94. package/node_modules/@shikijs/engine-javascript/dist/scanner-DX8LRFGE.mjs +0 -77
  95. package/node_modules/pi-umbra-rename/LICENSE +0 -21
  96. package/node_modules/pi-umbra-rename/README.md +0 -18
  97. package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +0 -55
  98. package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +0 -22
  99. 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
- // ---------- reference 3, the flat list ----------
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"], // one phase: no workflow to collapse, so this is the list case
72
+ phases: ["map"],
75
73
  done: 0,
76
- total: 2,
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 [flat, undefined, settled]) {
100
- const rows = layoutList(width, state, now, MAX_ROWS);
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(100, flat, now, MAX_ROWS);
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, 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.
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 (!quiet[1]?.head.startsWith("○")) fail(`an idle branch kept its filled dot: ${quiet[1]?.head}`);
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("+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.
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 fixed-height rule ----------
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
- ["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)],
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 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.
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.length}`);
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, paint, true)[0]?.trim() !== "") fail("the open panel did not blank the widget");
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
- // `❯` 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");
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, layoutList, MARK, MAX_ROWS, renderKey, renderLines } from "./bar/bar-line.ts";
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
- // One shape, the way Claude Code draws it: `○ main` on top, one row per agent, `└` for a nested
10
- // one, and `❯` on the row the user is on. Down at the end of the editor moves the `❯` into the
11
- // list, ↑↓ walk it, enter opens the panel on that agent, and up past the first agent, esc or
12
- // any other key hands the keyboard back to the editor.
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
- // The stems the last frame drew, in order: the cursor only walks rows the user can see.
43
- private stems: string[] = [];
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.stems = layoutList(width - MARK, current, now, budget).flatMap((row) => (row.branch ? [row.branch.stem] : []));
52
- // A row that left the list (the run faded, the panel took over) takes the cursor with it.
53
- if (cursor && !this.stems.includes(cursor)) this.leave();
54
- return renderLines(width, current, now, budget, (color, text) => this.theme.fg(color, text), panelOpen, cursor);
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
- if (this.release || panelOpen || !this.stems[0]) return;
62
- cursor = this.stems[0];
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 = undefined;
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
- const index = this.stems.indexOf(cursor ?? "");
85
- if (matchesKey(data, "down")) cursor = this.stems[Math.min(index + 1, this.stems.length - 1)];
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 = this.stems[index - 1];
89
- } else if (matchesKey(data, "enter") || matchesKey(data, "right")) {
90
- const stem = cursor;
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 stem the `❯` is on while the list has the keyboard; undefined leaves it on `○ main`.
166
- let cursor: string | undefined;
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 = (pi: ExtensionAPI, openPanel: (ctx: ExtensionContext, stem?: string) => Promise<void> | void) => {
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 open = (ctx: ExtensionContext, quiet = false, stem?: string) => {
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) => open(ctx, true, 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) => open(ctx) });
228
- pi.registerCommand("umb-agents", { description: "Open the agent panel", handler: async (_args, ctx) => open(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, type RunSpec } from "./spec.ts";
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
- const asking = run.branches.filter((branch) => branch.report === "ASKING");
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", () => store.stopAll());
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
- const dir = store.start(spec, fallback, ctx.cwd, brief, settings);
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
  }