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
@@ -3,9 +3,10 @@ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
3
3
  import { LINGER_MS, type BranchView, type RunState } from "../skills/delegate/state.ts";
4
4
 
5
5
  // Every column the widget below the input box occupies, as pure functions over one run
6
- // snapshot: the flat live list of reference 3, `○ main` on top and one row per agent, with
7
- // Claude Code's `❯` on the row the user is on. A phased run draws the same list; the panel is
8
- // where its phases are.
6
+ // snapshot: Claude Code's agent tree. `● main` on top, one row per agent, and only one level of
7
+ // the tree open at a time: an agent with agents under it shows `(+N)` and how they are doing,
8
+ // and enter opens it. A phased run is one `workflow` row whose children are its phases, whose
9
+ // children are their branches.
9
10
  //
10
11
  // All of it lives here rather than in the component because the arithmetic is the part that can
11
12
  // be wrong in a way that hurts: a composed line one column too wide wraps in the terminal, and
@@ -56,97 +57,237 @@ export const faded = (run: RunState | undefined, now: number): boolean => {
56
57
  return ended > 0 && now - ended > LINGER_MS;
57
58
  };
58
59
 
59
- // ---------- REFERENCE 3: the flat live list ----------
60
+ // ---------- THE TREE ----------
60
61
 
61
- /** One row of reference 3. `head` is the nesting marker, the status dot and the agent name,
62
- * padded so every row's activity starts in the same column; `dotAt` is the one cell inside it
63
- * that takes the status colour, or -1 on a row that has no dot. `branch` is undefined on the
64
- * `○ main` row, and is what tone() below reads to pick that cell's colour. */
65
- export type ListRow = {
66
- head: string;
67
- dotAt: number;
68
- activity: string;
69
- fill: string;
70
- right: string;
62
+ /** One row the tree can show: a branch, or for a phased run the run itself and each phase.
63
+ * `branches` is every branch at or under the row, which is what its counts, its clock and
64
+ * `x` act on. */
65
+ export type Node = {
66
+ id: string;
67
+ label: string;
71
68
  branch?: BranchView;
69
+ branches: BranchView[];
70
+ children: Node[];
71
+ elapsedMs: number;
72
+ tokens: number;
73
+ };
74
+
75
+ /** A visible row: the tree guide drawn in front of it, the ids of the rows above it in the
76
+ * tree, and whether it has children that are not shown. */
77
+ export type Line = { node: Node; guide: string; path: string[]; folded: boolean };
78
+
79
+ /** The cursor's id for the `● main` row. Every other id carries a prefix, so none can clash. */
80
+ export const MAIN = "main";
81
+
82
+ export const phaseTitle = (name: string) => name.charAt(0).toUpperCase() + name.slice(1);
83
+
84
+ const branchNode = (branch: BranchView, all: BranchView[]): Node => {
85
+ const children = all.filter((child) => child.parent === branch.stem).map((child) => branchNode(child, all));
86
+ return {
87
+ id: `b:${branch.stem}`,
88
+ label: branch.name,
89
+ branch,
90
+ branches: [branch, ...children.flatMap((child) => child.branches)],
91
+ children,
92
+ elapsedMs: branch.elapsedMs,
93
+ tokens: branch.tokens ?? 0,
94
+ };
95
+ };
96
+
97
+ // A branch whose parent is not in the run (it finished and was pruned, or never wrote a
98
+ // beacon) is a root rather than a row that never shows.
99
+ const rootsOf = (list: BranchView[], all: BranchView[]) =>
100
+ list.filter((branch) => !branch.parent || !all.some((other) => other.stem === branch.parent)).map((branch) => branchNode(branch, all));
101
+
102
+ const group = (id: string, label: string, children: Node[]): Node => ({
103
+ id,
104
+ label,
105
+ branches: children.flatMap((child) => child.branches),
106
+ children,
107
+ elapsedMs: Math.max(0, ...children.map((child) => child.elapsedMs)),
108
+ tokens: children.reduce((sum, child) => sum + child.tokens, 0),
109
+ });
110
+
111
+ /** One phase is plain subagents, so the branches are the top level. More than one is a
112
+ * workflow: a single row named after the run, its phases under it, their branches under them. */
113
+ export const treeOf = (run: RunState, now: number): Node[] => {
114
+ const all = run.branches;
115
+ const phases = [...new Set([...run.phases, ...all.map((branch) => branch.phase)])];
116
+ if (phases.length <= 1) return rootsOf(all, all);
117
+ const workflow = group(
118
+ `r:${run.dir}`,
119
+ run.name || "workflow",
120
+ phases.map((phase) => group(`p:${phase}`, phaseTitle(phase), rootsOf(all.filter((branch) => branch.phase === phase), all))),
121
+ );
122
+ return [{ ...workflow, elapsedMs: (endedAt(run) || now) - run.startedAt, tokens: run.tokens }];
72
123
  };
73
124
 
74
- // Reference 3's nesting marker, two columns per level with `└ ` on the last. Same shape as
75
- // panel.ts's, because the panel is the zoomed-in view of these same rows.
76
- const nest = (depth: number) => (depth > 0 ? `${" ".repeat(depth - 1)}└ ` : "");
125
+ /** The rows on screen. `open` is the one path that is expanded, top level first: opening a row
126
+ * closes every other, so only one agent's tree is ever branched out. */
127
+ export const linesOf = (nodes: Node[], open: string[], depth = 0, prefix = "", path: string[] = []): Line[] =>
128
+ nodes.flatMap((node, index) => {
129
+ const last = index === nodes.length - 1;
130
+ const guide = depth === 0 ? "" : prefix + (last ? "└ " : "├ ");
131
+ const expanded = node.children.length > 0 && open[depth] === node.id;
132
+ const line: Line = { node, guide, path, folded: node.children.length > 0 && !expanded };
133
+ if (!expanded) return [line];
134
+ const below = depth === 0 ? "" : prefix + (last ? " " : "│ ");
135
+ return [line, ...linesOf(node.children, open, depth + 1, below, [...path, node.id])];
136
+ });
137
+
138
+ export const linesFor = (run: RunState | undefined, now: number, open: string[]): Line[] =>
139
+ !run || faded(run, now) ? [] : linesOf(treeOf(run, now), open);
77
140
 
78
- // Filled while the branch is working, hollow when it is pending, quiet or finished. `○ main`
79
- // is hollow for the same reason the reference draws it hollow: the session is not a branch.
80
- const working = (branch: BranchView) =>
81
- branch.alive && branch.status === "running" && branch.idleMs < IDLE_MS;
141
+ // ---------- STATUS ----------
82
142
 
83
- const dot = (branch: BranchView) => (working(branch) ? "●" : "○");
143
+ /** What a branch is doing, in the words the counts use. A stopped and a failed branch are one
144
+ * state here: both ended without an answer, and both are red. A branch of a later phase that
145
+ * has not been started yet is waiting, not running. */
146
+ export type State = "done" | "running" | "idle" | "waiting" | "stopped";
147
+ const STATES: State[] = ["done", "running", "idle", "waiting", "stopped"];
148
+
149
+ export const stateOf = (branch: BranchView): State => {
150
+ if (branch.timedOut || branch.status === "error") return "stopped";
151
+ // No process yet: still queued, or never started because the run was abandoned.
152
+ if (branch.pid === null) return branch.settled ? "stopped" : "waiting";
153
+ if (branch.settled) return branch.exitCode !== null && branch.exitCode !== 0 ? "stopped" : "done";
154
+ return branch.alive && branch.idleMs < IDLE_MS ? "running" : "idle";
155
+ };
156
+
157
+ const COLOR: Record<State, ThemeColor> = { done: "success", running: "accent", idle: "warning", waiting: "dim", stopped: "error" };
158
+
159
+ // A row standing for many branches takes the state that most needs a look: one still going
160
+ // outranks the finished ones. A phase with no branches yet has not run, so it is waiting.
161
+ const stateOfAll = (branches: BranchView[]): State => {
162
+ const states = branches.map(stateOf);
163
+ return (["running", "idle", "stopped", "waiting", "done"] as State[]).find((state) => states.includes(state)) ?? (states.length ? "done" : "waiting");
164
+ };
165
+
166
+ /** "3 done · 5 running · 1 idle", leaving out the states nobody is in. */
167
+ export const countsOf = (branches: BranchView[]) =>
168
+ STATES.map((state) => [state, branches.filter((branch) => stateOf(branch) === state).length] as const)
169
+ .filter(([, count]) => count > 0)
170
+ .map(([state, count]) => `${count} ${state}`)
171
+ .join(" · ");
84
172
 
85
173
  /** What the row says it is doing. A branch that was killed never got to write "done", so its
86
174
  * own `activity` is frozen on whatever it was mid-way through — a row reading "Running cd"
87
175
  * on a process that has been dead for four minutes. The exit file already knows better. */
88
- export const said = (branch: BranchView): string => {
176
+ const endedAs = (branch: BranchView): string => {
89
177
  if (branch.timedOut) return "Timed out";
90
178
  if (branch.status === "error") return branch.error ? `Failed: ${branch.error}` : "Failed";
91
179
  // Killed by hand or by a parent going away: a non-zero exit with no error text of its own.
92
180
  if (branch.settled && branch.exitCode !== null && branch.exitCode !== 0 && !branch.report) return "Stopped";
93
- return branch.activity ?? "";
181
+ return "";
182
+ };
183
+
184
+ export const said = (branch: BranchView): string => {
185
+ // A write branch's answer is its diff, committed even when it was stopped or timed out.
186
+ const { git } = branch;
187
+ const changed = git?.error ? `Not committed: ${git.error}` : git?.stat ? `Changed ${git.stat} on ${git.branch}` : "";
188
+ return [endedAs(branch), changed].filter(Boolean).join(" · ") || (branch.activity ?? "");
189
+ };
190
+
191
+ // A branch says what it is doing; a row with hidden agents under it says how they are doing
192
+ // first, since that is what the folded row is hiding. A phase or the workflow has no activity
193
+ // of its own, so it says only the counts.
194
+ const activityOf = (line: Line) => {
195
+ const { node } = line;
196
+ if (!node.branch) return countsOf(node.branches);
197
+ const own = said(node.branch);
198
+ if (!line.folded) return own;
199
+ const counts = countsOf(node.branches.slice(1));
200
+ return own ? `${counts} · ${own}` : counts;
201
+ };
202
+
203
+ // ---------- THE LIST ----------
204
+
205
+ /** One row on screen. `head` is the tree guide, the dot and the name, padded so every row's
206
+ * activity starts in the same column; `dotAt` is the cell inside it that takes `tone`, or -1 on
207
+ * a row with no dot. `id` is the row's tree id, MAIN for the session, undefined for "↑ 3 more". */
208
+ export type ListRow = {
209
+ head: string;
210
+ dotAt: number;
211
+ activity: string;
212
+ fill: string;
213
+ right: string;
214
+ tone?: ThemeColor;
215
+ id?: string;
94
216
  };
95
217
 
218
+ /** Whether the list has the keyboard, which row the `❯` is on, and which path is open. */
219
+ export type View = { picking: boolean; cursor: string; open: string[] };
220
+ export const TYPING: View = { picking: false, cursor: MAIN, open: [] };
221
+
96
222
  // Chosen once for the whole list rather than per row, so the right-hand block is one column
97
- // instead of four ragged ones. Same order of sacrifice as the bar: the tokens go first, then
98
- // the arrow, and the clock last.
99
- const rowRight = (branch: BranchView, tier: number) => {
100
- const time = clock(branch.elapsedMs);
101
- if (tier === 0) return `${time} · ↓ ${compact(branch.tokens ?? 0)} tokens`;
102
- if (tier === 1) return `${time} · ↓ ${compact(branch.tokens ?? 0)}`;
223
+ // instead of four ragged ones. The tokens go first, then the arrow, and the clock last.
224
+ const rowRight = (node: Node, tier: number) => {
225
+ const time = clock(node.elapsedMs);
226
+ if (tier === 0) return `${time} · ↓ ${compact(node.tokens)} tokens`;
227
+ if (tier === 1) return `${time} · ↓ ${compact(node.tokens)}`;
103
228
  if (tier === 2) return time;
104
229
  return "";
105
230
  };
106
231
 
107
232
  const widest = (values: string[]) => Math.max(0, ...values.map(visibleWidth));
108
233
 
109
- /** Reference 3, whole. Always at least one row, because a widget that renders zero lines
110
- * un-mounts itself and the input box drops a line the moment it does.
111
- *
112
- * `max` is the row budget the component computes from the terminal height. Anything past it
113
- * collapses into a final "+N more", so the list has a ceiling but never a hidden agent. */
114
- export const layoutList = (width: number, run: RunState | undefined, now: number, max: number): ListRow[] => {
234
+ /** The slice of `n` rows that fits `room`, kept around the cursor at `at`. The window scrolls
235
+ * instead of hiding anyone; a row of it is spent on "↑ N more" or "↓ N more" only when there is
236
+ * something that way. Returns [first, end). */
237
+ export const windowOf = (n: number, at: number, room: number): [number, number] => {
238
+ if (n <= room) return [0, n];
239
+ if (room < 3) {
240
+ const top = Math.min(Math.max(0, at), n - Math.max(0, room));
241
+ return [top, top + Math.max(0, room)];
242
+ }
243
+ if (at < room - 1) return [0, room - 1];
244
+ if (at >= n - room + 1) return [n - room + 1, n];
245
+ const slots = room - 2;
246
+ const top = Math.min(Math.max(1, at - Math.floor((slots - 1) / 2)), n - slots - 1);
247
+ return [top, top + slots];
248
+ };
249
+
250
+ // windowOf() spends rows on "↑ N more" and "↓ N more" only when it has three or more to play
251
+ // with; under that it shows what fits and nothing else.
252
+ const arrows = (max: number) => max - 1 >= 3;
253
+
254
+ /** The list, whole. Always at least the `● main` row, because a widget that renders zero lines
255
+ * while a run is live would un-mount itself. `max` is the row budget the component computes
256
+ * from the terminal height; past it the list scrolls with the cursor. */
257
+ export const layoutList = (width: number, run: RunState | undefined, now: number, max: number, view: View = TYPING): ListRow[] => {
115
258
  const inner = width - PAD * 2;
116
- const main = "○ main";
117
- // Sub-8 columns is not a terminal anyone reads a table in, and one stub row still holds the
118
- // height the input box is sitting on.
259
+ const main = "● main";
119
260
  if (inner < MIN_INNER) {
120
- return [{ head: truncateToWidth(main, Math.max(0, inner), "…", true), dotAt: 0, activity: "", fill: "", right: "" }];
261
+ return [{ head: truncateToWidth(main, Math.max(0, inner), "…", true), dotAt: 0, activity: "", fill: "", right: "", id: MAIN }];
121
262
  }
122
263
 
123
- // readRun() already orders parents immediately above their children by launch order, so
124
- // taking a prefix keeps the tree readable and, more importantly, keeps a row from sliding
125
- // under the eye when a sibling settles. Sorting the live ones to the top would do that.
126
- const all = !run || faded(run, now) ? [] : run.branches;
127
- // The overflow row spends the budget's last slot rather than adding to it, so a run of forty
128
- // branches is exactly as tall as a run of `max` and never one row taller.
129
- const room = Math.max(0, max - 1); // rows left after `○ main`
130
- const shown = all.length <= room ? all : all.slice(0, Math.max(0, room - 1));
131
- const hidden = room > 0 ? all.length - shown.length : 0;
132
- const overflow = hidden > 0 ? `+${hidden} more` : "";
264
+ const lines = linesFor(run, now, view.open);
265
+ const at = view.picking ? lines.findIndex((line) => line.node.id === view.cursor) : -1;
266
+ const [first, end] = windowOf(lines.length, Math.max(0, at), Math.max(0, max - 1));
267
+ const shown = lines.slice(first, end);
268
+ const above = arrows(max) ? first : 0;
269
+ const below = arrows(max) ? lines.length - end : 0;
133
270
 
134
- const heads = shown.map((branch) => `${nest(branch.depth)}${dot(branch)} ${branch.name}`);
135
- const headWidth = Math.min(inner, HEAD_CAP, Math.max(1, widest([main, ...heads, overflow])));
271
+ const nameOf = (line: Line) => `${line.guide}○ ${line.node.label}${line.folded ? ` (+${line.node.branches.length - (line.node.branch ? 1 : 0)})` : ""}`;
272
+ const heads = shown.map(nameOf);
273
+ const moreUp = above ? `↑ ${above} more` : "";
274
+ const moreDown = below ? `↓ ${below} more` : "";
275
+ const headWidth = Math.min(inner, HEAD_CAP, Math.max(1, widest([main, ...heads, moreUp, moreDown])));
136
276
 
137
277
  // One tier for the list, not one per row: the widest that still leaves the activity
138
278
  // sentence a readable column next to the longest head.
139
279
  let tier = 0;
140
280
  let rightWidth = 0;
141
281
  for (; tier < 3; tier++) {
142
- rightWidth = widest(shown.map((branch) => rowRight(branch, tier)));
143
- if (headWidth + COL_GAP + MIN_ACTIVITY + rightWidth <= inner) break;
282
+ rightWidth = widest(shown.map((line) => rowRight(line.node, tier)));
283
+ if (headWidth + COL_GAP + MIN_ACTIVITY + rightWidth + 2 <= inner) break;
144
284
  }
145
285
  if (tier === 3) rightWidth = 0;
146
286
  // Clamped after the choice as well as before it: on a narrow terminal even the bare clock
147
287
  // can be too wide, and an over-wide row is the one failure that moves the input box.
148
288
  rightWidth = Math.min(rightWidth, Math.max(0, inner - headWidth));
149
- const activityBudget = inner - headWidth - COL_GAP - rightWidth;
289
+ // Two columns before the clock, so a truncated sentence never runs into it.
290
+ const activityBudget = inner - headWidth - COL_GAP - rightWidth - (rightWidth ? 2 : 0);
150
291
 
151
292
  const row = (headText: string, at: number, text: string, right: string): ListRow => {
152
293
  const head = truncateToWidth(headText, headWidth, "…", true);
@@ -159,43 +300,33 @@ export const layoutList = (width: number, run: RunState | undefined, now: number
159
300
  return { head, dotAt: at, activity, fill: " ".repeat(fill), right: padded };
160
301
  };
161
302
 
162
- // `○ main` is the session itself and always sits at the top, with no activity and no
163
- // counters — exactly as the reference draws it. The widget factory is handed a tui and a
164
- // theme and nothing else, so there is no ExtensionContext here to ask for the session's own
165
- // usage, and a number the row could not stand behind is worse than the blank the screen
166
- // actually shows.
167
- const rows: ListRow[] = [row(main, 0, "", "")];
168
- shown.forEach((branch, index) => {
303
+ // `● main` is the session itself, and the filled dot says this is where the user is. The
304
+ // widget factory is handed a tui and a theme and nothing else, so there is no context here to
305
+ // ask for the session's own usage, and a number the row could not stand behind is worse than
306
+ // a blank.
307
+ const rows: ListRow[] = [{ ...row(main, 0, "", ""), id: MAIN }];
308
+ if (moreUp) rows.push(row(moreUp, -1, "", ""));
309
+ shown.forEach((line, index) => {
310
+ const { node } = line;
311
+ const tone = COLOR[node.branch && !line.folded ? stateOf(node.branch) : stateOfAll(node.branches)];
169
312
  // SAFETY: heads is built by mapping `shown` in order, so this index is populated.
170
- const head = heads[index] as string;
171
- rows.push({ ...row(head, visibleWidth(nest(branch.depth)), said(branch), rowRight(branch, tier)), branch });
313
+ rows.push({ ...row(heads[index] as string, visibleWidth(line.guide), activityOf(line), rowRight(node, tier)), tone, id: node.id });
172
314
  });
173
- // No dot on the overflow row: it stands for agents rather than being one.
174
- if (overflow) rows.push(row(overflow, -1, "", ""));
315
+ if (moreDown) rows.push(row(moreDown, -1, "", ""));
175
316
  return rows;
176
317
  };
177
318
 
178
319
  // ---------- THE FRAME ----------
179
320
 
180
- // The list's ceiling: `○ main` plus eight agents. Past that a "+N more" row stands in, so the
181
- // widget can never eat the screen it is sitting on top of.
321
+ // The list's ceiling: `● main` plus eight rows. Past that the list scrolls with the cursor, so
322
+ // the widget can never eat the screen it is sitting on top of.
182
323
  export const MAX_ROWS = 9;
183
324
 
184
- // The same distinctions panel.ts draws, because the panel is the zoomed-in view of these rows:
185
- // a timed-out branch answered nothing but did not crash, and a soft report is not a success.
186
- const tone = (branch: BranchView | undefined): ThemeColor => {
187
- if (!branch) return "dim";
188
- if (branch.timedOut) return "warning";
189
- if (branch.status === "error" || (branch.exitCode !== null && branch.exitCode !== 0)) return "error";
190
- if (branch.report === "PARTIAL" || branch.report === "NEED_STRONGER" || branch.report === "ASKING") return "warning";
191
- if (branch.settled) return "success";
192
- return branch.alive && branch.idleMs < IDLE_MS ? "accent" : "dim";
193
- };
194
-
195
325
  /** Everything on screen, in one string. A frame identical to the last one is never requested:
196
326
  * pi coalesces renders at 16 ms, but the cheapest frame is the one nobody asks for, and an
197
327
  * idle session whose reader ticks once a second would otherwise repaint a blank row for as
198
- * long as pi stays open. Width is deliberately absent — a resize repaints everything anyway. */
328
+ * long as pi stays open. Width is deliberately absent — a resize repaints everything anyway.
329
+ * The cursor and the open path are absent too: the bar repaints itself when either moves. */
199
330
  export const renderKey = (run: RunState | undefined, now: number): string => {
200
331
  if (!run || faded(run, now)) return "-";
201
332
  const at = endedAt(run) || now;
@@ -215,29 +346,39 @@ export const renderKey = (run: RunState | undefined, now: number): string => {
215
346
  return [...head, ...rows].join("|");
216
347
  };
217
348
 
218
- /** `theme.fg`, narrowed to the one method used, so the frame can be composed without a TUI. */
219
- export type Paint = (color: ThemeColor, text: string) => string;
349
+ /** The two theme methods the frame uses, so it can be composed without a TUI. */
350
+ export type Ink = { fg(color: ThemeColor, text: string): string; bold(text: string): string };
351
+
352
+ /** The keys that work on the row the cursor is on. Enter only appears where it does something. */
353
+ export const hintOf = (view: View, lines: Line[]): string => {
354
+ const line = lines.find((candidate) => candidate.node.id === view.cursor);
355
+ const expanded = lines.some((candidate) => candidate.node.children.length > 0 && !candidate.folded);
356
+ const enter = line ? (line.node.children.length ? (line.folded ? "enter expand" : "enter collapse") : "") : expanded ? "enter collapse all" : "";
357
+ return ["↑↓ select", enter, line ? "x stop" : "", "alt+a panel", "esc back"].filter(Boolean).join(" · ");
358
+ };
220
359
 
221
360
  /**
222
- * The whole frame, always the list. `cursor` is the stem the `❯` sits on; undefined puts it on
223
- * `○ main`, which is where the user is while they type.
361
+ * The whole frame:
362
+ *
363
+ * 1. nothing running, or the run faded -> no lines at all
364
+ * 2. the panel is open, or no room to draw -> one blank line
365
+ * 3. anything else -> the list, with a key hint on top while the
366
+ * list has the keyboard
224
367
  */
225
368
  export const renderLines = (
226
369
  width: number,
227
370
  run: RunState | undefined,
228
371
  now: number,
229
372
  budget: number,
230
- paint: Paint,
373
+ ink: Ink,
231
374
  panelIsOpen = false,
232
- cursor?: string,
375
+ view: View = TYPING,
233
376
  ): string[] => {
234
377
  const total = Math.max(0, width);
235
378
 
236
- // Nothing running: draw nothing at all, not a bare `○ main`. pi renders the belowEditor
237
- // container with spacerWhenEmpty and leadingSpacer both false, so zero lines really is zero
238
- // rows and the footer above it does not move when a run starts or ends. That was not true
239
- // while this slot sat above the editor - there, appearing and disappearing would have
240
- // shifted the input box under the user's hands, which is why the idle row existed.
379
+ // Nothing running: draw nothing at all. pi renders the belowEditor container with
380
+ // spacerWhenEmpty and leadingSpacer both false, so zero lines really is zero rows and the
381
+ // footer above it does not move when a run starts or ends.
241
382
  if (faded(run, now)) return [];
242
383
 
243
384
  // The panel already draws all of this, and it took the editor's slot to do it. The width
@@ -245,18 +386,25 @@ export const renderLines = (
245
386
  // a blank line of the right height beats a two-column line in a one-column terminal.
246
387
  if (panelIsOpen || total < PAD * 2 + 4) return [" ".repeat(total)];
247
388
 
389
+ const hint = view.picking ? [" ".repeat(PAD + MARK) + ink.fg("dim", truncateToWidth(hintOf(view, linesFor(run, now, view.open)), Math.max(0, total - PAD * 2 - MARK), "…", true)) + " ".repeat(PAD)] : [];
248
390
  // The `❯` column comes off the list's width, so a marked row is exactly as wide as the rest.
249
- return layoutList(total - MARK, run, now, budget).map((row) => {
250
- // `head` is plain text, and everything left of the dot is `└`, a space, or a name
251
- // matching ^[a-z0-9][a-z0-9._-]*$ — so a visible column and a code-unit index are the
252
- // same number here and slicing cannot cut a character in half.
391
+ const rows = layoutList(total - MARK, run, now, view.picking ? budget - 1 : budget, view).map((row) => {
392
+ // `head` is plain text, and everything left of the dot is a tree guide or a space, so a
393
+ // visible column and a code-unit index are the same number here and slicing cannot cut a
394
+ // character in half.
395
+ // The name, then a grey "(+N)" when the row hides agents under it.
396
+ const name = row.head.slice(row.dotAt + 1);
397
+ const plus = name.search(/ \(\+\d+\)/);
398
+ const label = plus === -1 ? ink.fg("text", name) : ink.fg("text", name.slice(0, plus)) + ink.fg("dim", name.slice(plus));
253
399
  const head =
254
- row.dotAt >= 0
255
- ? paint("dim", row.head.slice(0, row.dotAt)) +
256
- paint(tone(row.branch), row.head.slice(row.dotAt, row.dotAt + 1)) +
257
- paint(row.branch ? "text" : "dim", row.head.slice(row.dotAt + 1))
258
- : paint("dim", row.head);
259
- const mark = (row.branch ? row.branch.stem === cursor : !cursor && row.dotAt === 0) ? paint("accent", "❯ ") : " ";
260
- return " ".repeat(PAD) + mark + head + paint("muted", row.activity) + row.fill + paint("dim", row.right) + " ".repeat(PAD);
400
+ row.dotAt < 0
401
+ ? ink.fg("dim", row.head)
402
+ : ink.fg("dim", row.head.slice(0, row.dotAt)) +
403
+ (row.id === MAIN
404
+ ? ink.bold(ink.fg("text", row.head.slice(row.dotAt)))
405
+ : ink.fg(row.tone ?? "dim", row.head.slice(row.dotAt, row.dotAt + 1)) + label);
406
+ const mark = view.picking && row.id === view.cursor ? ink.fg("accent", "❯ ") : " ";
407
+ return " ".repeat(PAD) + mark + head + ink.fg("muted", row.activity) + row.fill + ink.fg("dim", row.right) + " ".repeat(PAD);
261
408
  });
409
+ return [...hint, ...rows];
262
410
  };