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
|
@@ -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:
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
// ----------
|
|
60
|
+
// ---------- THE TREE ----------
|
|
60
61
|
|
|
61
|
-
/** One row
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
if (tier ===
|
|
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
|
-
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 = "
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
135
|
-
const
|
|
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((
|
|
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
|
-
|
|
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
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
shown.forEach((
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
219
|
-
export type
|
|
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
|
|
223
|
-
*
|
|
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
|
-
|
|
373
|
+
ink: Ink,
|
|
231
374
|
panelIsOpen = false,
|
|
232
|
-
|
|
375
|
+
view: View = TYPING,
|
|
233
376
|
): string[] => {
|
|
234
377
|
const total = Math.max(0, width);
|
|
235
378
|
|
|
236
|
-
// Nothing running: draw nothing at all
|
|
237
|
-
//
|
|
238
|
-
//
|
|
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
|
-
|
|
250
|
-
// `head` is plain text, and everything left of the dot is
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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
|
|
255
|
-
?
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
};
|