@hicaru/pi-rlm 0.3.8 → 0.3.13
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/package.json +1 -1
- package/src/bridge/handlers/completion.ts +5 -0
- package/src/bridge/handlers/emitting.ts +33 -23
- package/src/bridge/handlers/index.ts +1 -1
- package/src/bridge/handlers/llm-query.ts +23 -24
- package/src/bridge/handlers/rlm-query.ts +10 -32
- package/src/bridge/handlers/types.ts +8 -1
- package/src/bridge/model.ts +33 -15
- package/src/commands/pins.ts +51 -0
- package/src/commands/rlm-config.ts +4 -88
- package/src/commands/rlm-llm.ts +59 -0
- package/src/commands/rlm-rlm.ts +58 -0
- package/src/commands/rlm.ts +2 -2
- package/src/config/defaults.ts +14 -4
- package/src/config/settings.ts +26 -3
- package/src/core/budget.ts +1 -1
- package/src/core/compaction.ts +4 -0
- package/src/core/engine.ts +21 -4
- package/src/core/iteration.ts +12 -0
- package/src/core/ledger.ts +15 -123
- package/src/core/memory.ts +13 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/types.ts +14 -0
- package/src/index.ts +53 -4
- package/src/mode/rlm-mode.ts +11 -1
- package/src/prompts/glossary.ts +11 -3
- package/src/prompts/native.ts +1 -1
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/tool/repl-render.ts +4 -10
- package/src/tool/repl-tool.ts +30 -17
- package/src/tool/rlm-aggregator.ts +16 -3
- package/src/tool/rlm-details.ts +8 -0
- package/src/tool/rlm-events.ts +17 -1
- package/src/tool/rlm-tool.ts +25 -14
- package/src/tool/subcall-render.ts +14 -129
- package/src/tool/subcall-store.ts +11 -1
- package/src/ui/intro.ts +13 -4
- package/src/ui/modal/agent-modal.ts +104 -0
- package/src/ui/modal/modal-view.ts +132 -0
- package/src/ui/modal/timeline-store.ts +85 -0
- package/src/ui/model-picker/drilldown.ts +173 -0
- package/src/ui/model-picker/grouping.ts +81 -0
- package/src/ui/model-picker/levels.ts +63 -0
- package/src/ui/model-picker.ts +7 -197
- package/src/ui/panel/run-registry.ts +135 -0
- package/src/ui/panel/tree-panel.ts +46 -0
- package/src/ui/status.ts +26 -13
- package/src/ui/theme.ts +0 -4
- package/src/ui/tree/tree-model.ts +226 -0
- package/src/ui/tree/tree-rows.ts +74 -0
- package/src/ui/tree/tree-widget.ts +186 -0
- package/src/util/retry.ts +180 -0
- package/src/util/throttle.ts +90 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tree-panel — the ONE wiring point between pi's UI and the RLM tree.
|
|
3
|
+
*
|
|
4
|
+
* Installs the persistent below-editor widget and mediates keyboard focus:
|
|
5
|
+
* the widget never grabs keys on its own; this panel intercepts Ctrl+R via
|
|
6
|
+
* onTerminalInput, forwards keys while focused, and opens the agent modal on
|
|
7
|
+
* enter. Everything else (tree building, formatting, timelines) is delegated.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { TreeWidget } from "../tree/tree-widget.ts";
|
|
12
|
+
import { openAgentModal } from "../modal/agent-modal.ts";
|
|
13
|
+
import type { RunRegistry } from "./run-registry.ts";
|
|
14
|
+
|
|
15
|
+
const KEY_CTRL_R = "\x12";
|
|
16
|
+
const WIDGET_KEY = "rlm-tree";
|
|
17
|
+
|
|
18
|
+
export function installTreePanel(ctx: ExtensionContext, registry: RunRegistry): void {
|
|
19
|
+
if (ctx.mode !== "tui") return;
|
|
20
|
+
|
|
21
|
+
let widget: TreeWidget | undefined;
|
|
22
|
+
ctx.ui.setWidget(
|
|
23
|
+
WIDGET_KEY,
|
|
24
|
+
(tui, theme) => {
|
|
25
|
+
widget?.dispose();
|
|
26
|
+
widget = new TreeWidget(tui, theme, registry);
|
|
27
|
+
return widget;
|
|
28
|
+
},
|
|
29
|
+
{ placement: "belowEditor" },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
ctx.ui.onTerminalInput((data) => {
|
|
33
|
+
const current = widget;
|
|
34
|
+
if (current === undefined) return undefined;
|
|
35
|
+
if (!current.isFocused) {
|
|
36
|
+
if (data === KEY_CTRL_R && registry.hasActive()) {
|
|
37
|
+
current.setFocused(true);
|
|
38
|
+
return { consume: true };
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const action = current.handleKey(data);
|
|
43
|
+
if (action.type === "open") void openAgentModal(ctx, registry, action.runId, action.nodeId);
|
|
44
|
+
return { consume: true };
|
|
45
|
+
});
|
|
46
|
+
}
|
package/src/ui/status.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Status widget for RLM mode and active runs — rendered above the editor.
|
|
2
|
+
*
|
|
3
|
+
* The footer's extension-status row is sanitized to a single line, so the
|
|
4
|
+
* two-model layout lives in a dedicated multi-line widget instead: one line
|
|
5
|
+
* for the mode, one per model lane (llm = leaf sub-calls, rlm = child engines),
|
|
6
|
+
* each with the live context token spend.
|
|
7
|
+
*/
|
|
2
8
|
|
|
3
|
-
import type { ContextUsage,
|
|
9
|
+
import type { ContextUsage, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
10
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
11
|
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
12
|
+
import { formatTokens } from "./theme.ts";
|
|
6
13
|
|
|
7
14
|
const KEY = "rlm";
|
|
8
15
|
|
|
@@ -10,19 +17,25 @@ export function modelLabel(model: Model<Api> | undefined, fallback: string): str
|
|
|
10
17
|
return model ? `${model.provider}/${model.id}` : fallback;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
|
-
export function
|
|
14
|
-
|
|
20
|
+
export function formatRlmStatusLines(
|
|
21
|
+
controller: RlmController,
|
|
22
|
+
contextUsage?: ContextUsage,
|
|
23
|
+
): readonly string[] {
|
|
24
|
+
if (!controller.enabled) return ["○ RLM OFF"];
|
|
25
|
+
const tokens = contextUsage?.tokens;
|
|
26
|
+
const tokSuffix = tokens === null || tokens === undefined ? "" : ` · ${formatTokens(tokens)} tok`;
|
|
15
27
|
const llm = modelLabel(controller.llmModel, controller.savedLlmRef ?? "cheapest");
|
|
16
28
|
const llmSuffix = controller.config.subSampling.reasoning ? `:${controller.config.subSampling.reasoning}` : "";
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
const rlm = modelLabel(controller.rlmModel, controller.savedRlmRef ?? "session");
|
|
30
|
+
const rlmSuffix = controller.config.rootSampling?.reasoning ? `:${controller.config.rootSampling.reasoning}` : "";
|
|
31
|
+
return [
|
|
32
|
+
"● RLM ON",
|
|
33
|
+
` llm=${llm}${llmSuffix}${tokSuffix}`,
|
|
34
|
+
` rlm=${rlm}${rlmSuffix}${tokSuffix}`,
|
|
35
|
+
];
|
|
24
36
|
}
|
|
25
37
|
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
/** Set the above-editor status widget. Idempotent — call on every state change. */
|
|
39
|
+
export function setRlmModeStatus(ctx: ExtensionContext, controller: RlmController, contextUsage?: ContextUsage): void {
|
|
40
|
+
ctx.ui.setWidget(KEY, [...formatRlmStatusLines(controller, contextUsage)], { placement: "aboveEditor" });
|
|
28
41
|
}
|
package/src/ui/theme.ts
CHANGED
|
@@ -6,10 +6,6 @@ export function spinnerFrame(): string {
|
|
|
6
6
|
return SPINNER[Math.floor(Date.now() / 100) % SPINNER.length] ?? "⠋";
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export function formatCost(usd: number): string {
|
|
10
|
-
return `$${usd.toFixed(usd < 1 ? 4 : 2)}`;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
9
|
export function formatTokens(n: number): string {
|
|
14
10
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
15
11
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tree-model — pure projection of one run's subcall state into display rows.
|
|
3
|
+
*
|
|
4
|
+
* No TUI imports, no side effects: same inputs → same rows. The widget caches
|
|
5
|
+
* the result and only rebuilds when the underlying store reports a change.
|
|
6
|
+
*
|
|
7
|
+
* Nothing is ever hidden: every sub-call renders as its own row (parity with
|
|
8
|
+
* pi, which shows each concurrent tool call individually) — except runs of
|
|
9
|
+
* IDENTICAL sibling leaves (same label+model+status), which collapse into one
|
|
10
|
+
* expandable "label ×N" group row so a 20-item llm_batch is one line, not 20.
|
|
11
|
+
* Error leaves are NEVER grouped — each keeps its own row and reason.
|
|
12
|
+
* Collapsed subtrees are skipped at the user's explicit request (chevron flips).
|
|
13
|
+
* Token rows are own-spend only — a row never blends models.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { RlmSubcall, RlmRunStatus, SubcallPhase, SubcallStatus } from "../../tool/rlm-details.ts";
|
|
17
|
+
|
|
18
|
+
/** Immutable per-run view the model consumes (built by RunRegistry from a live store). */
|
|
19
|
+
export interface RunSnapshot {
|
|
20
|
+
readonly runId: string;
|
|
21
|
+
/** Root row label — prompt preview or "repl". */
|
|
22
|
+
readonly rootLabel: string;
|
|
23
|
+
readonly status: RlmRunStatus;
|
|
24
|
+
readonly rootPhase?: SubcallPhase;
|
|
25
|
+
/** Root's OWN model ("provider/id") — the default/session model driving the run. */
|
|
26
|
+
readonly rootModel?: string;
|
|
27
|
+
/** Root's OWN token spend (driver-model turns) — never a subtree sum. */
|
|
28
|
+
readonly rootTokens: number;
|
|
29
|
+
readonly subcalls: readonly RlmSubcall[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NodeRow {
|
|
33
|
+
readonly type: "node";
|
|
34
|
+
/** Subcall id, or the run id for the synthetic root row. */
|
|
35
|
+
readonly id: string;
|
|
36
|
+
/** Owning run — the modal resolves its timeline through this. */
|
|
37
|
+
readonly runId: string;
|
|
38
|
+
readonly depth: number;
|
|
39
|
+
/** Tree guide prefix, e.g. "│ ├─ " — formatter stays dumb. */
|
|
40
|
+
readonly prefix: string;
|
|
41
|
+
readonly expandable: boolean;
|
|
42
|
+
readonly expanded: boolean;
|
|
43
|
+
/** SubcallStatus, or "queued" while the node parks on the rate-limit cooldown. */
|
|
44
|
+
readonly icon: SubcallStatus | "queued";
|
|
45
|
+
readonly phase?: SubcallPhase;
|
|
46
|
+
readonly label: string;
|
|
47
|
+
/** The row's OWN token spend for its OWN model — never a subtree sum. */
|
|
48
|
+
readonly tokens: number;
|
|
49
|
+
readonly model?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type TreeRow = NodeRow | GroupRow;
|
|
53
|
+
|
|
54
|
+
/** A run of identical sibling leaves (label+model+status) shown as one row. */
|
|
55
|
+
export interface GroupRow {
|
|
56
|
+
readonly type: "group";
|
|
57
|
+
/** Synthetic id — "grp:" prefix keeps it disjoint from subcall ids. */
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly runId: string;
|
|
60
|
+
readonly depth: number;
|
|
61
|
+
readonly prefix: string;
|
|
62
|
+
readonly count: number;
|
|
63
|
+
readonly label: string;
|
|
64
|
+
readonly model?: string;
|
|
65
|
+
/** Sum over members — one model only (the group key pins it), so never a blend. */
|
|
66
|
+
readonly tokens: number;
|
|
67
|
+
/** SubcallStatus, or "queued" while any member parks on the rate-limit cooldown. */
|
|
68
|
+
readonly icon: SubcallStatus | "queued";
|
|
69
|
+
readonly expandable: boolean;
|
|
70
|
+
readonly expanded: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Internal build-time entry: a real node or an accumulating group. */
|
|
74
|
+
type Entry =
|
|
75
|
+
| { readonly type: "node"; readonly sc: RlmSubcall }
|
|
76
|
+
| { readonly type: "group"; readonly key: string; readonly label: string; readonly model?: string; readonly status: SubcallStatus; readonly members: RlmSubcall[] };
|
|
77
|
+
|
|
78
|
+
/** Errors never group — each keeps its own row and its own reason. */
|
|
79
|
+
const groupable = (sc: RlmSubcall, byParent: ReadonlyMap<string | undefined, RlmSubcall[]>): boolean =>
|
|
80
|
+
sc.kind === "llm" && sc.status !== "error" && (byParent.get(sc.id)?.length ?? 0) === 0;
|
|
81
|
+
|
|
82
|
+
const groupKey = (sc: RlmSubcall): string => `${sc.label}|${sc.model ?? ""}|${sc.status}`;
|
|
83
|
+
|
|
84
|
+
/** Merge consecutive identical sibling leaves into group entries; keep order. */
|
|
85
|
+
function partition(children: readonly RlmSubcall[], byParent: ReadonlyMap<string | undefined, RlmSubcall[]>): readonly Entry[] {
|
|
86
|
+
const out: Entry[] = [];
|
|
87
|
+
for (const sc of children) {
|
|
88
|
+
if (groupable(sc, byParent)) {
|
|
89
|
+
const key = groupKey(sc);
|
|
90
|
+
const last = out[out.length - 1];
|
|
91
|
+
if (last !== undefined && last.type === "group" && last.key === key) {
|
|
92
|
+
last.members.push(sc);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
out.push({ type: "group", key, label: sc.label, model: sc.model, status: sc.status, members: [sc] });
|
|
96
|
+
} else {
|
|
97
|
+
out.push({ type: "node", sc });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** RlmRunStatus has "aborted"; the row icon set does not — aborted renders as error.
|
|
104
|
+
* A RUNNING node parked on the rate-limit cooldown renders as "queued" (◷). */
|
|
105
|
+
function iconOf(status: SubcallStatus | RlmRunStatus, phase: SubcallPhase | undefined): SubcallStatus | "queued" {
|
|
106
|
+
if (status === "aborted") return "error";
|
|
107
|
+
if (status === "running" && phase === "queued") return "queued";
|
|
108
|
+
return status;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Flatten a run snapshot into visible rows. Depth-first, children ordered by
|
|
113
|
+
* startedAt. Pure: allocates fresh arrays, never mutates the snapshot.
|
|
114
|
+
*/
|
|
115
|
+
export function buildRows(
|
|
116
|
+
run: RunSnapshot,
|
|
117
|
+
collapsed: ReadonlySet<string>,
|
|
118
|
+
expandedGroups: ReadonlySet<string> = new Set(),
|
|
119
|
+
): readonly TreeRow[] {
|
|
120
|
+
const byParent = new Map<string | undefined, RlmSubcall[]>();
|
|
121
|
+
for (const sc of run.subcalls) {
|
|
122
|
+
const siblings = byParent.get(sc.parentId);
|
|
123
|
+
if (siblings === undefined) byParent.set(sc.parentId, [sc]);
|
|
124
|
+
else siblings.push(sc);
|
|
125
|
+
}
|
|
126
|
+
// Containers (agents) sort before leaves so agent rows stay adjacent; within
|
|
127
|
+
// a group, stable by start time.
|
|
128
|
+
for (const siblings of byParent.values()) {
|
|
129
|
+
siblings.sort((a, b) => Number((byParent.get(b.id)?.length ?? 0) > 0) - Number((byParent.get(a.id)?.length ?? 0) > 0) || a.startedAt - b.startedAt);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const rows: TreeRow[] = [];
|
|
133
|
+
const roots = byParent.get(undefined) ?? [];
|
|
134
|
+
|
|
135
|
+
const visit = (sc: RlmSubcall, depth: number, prefix: string, childGuide: string): void => {
|
|
136
|
+
const children = byParent.get(sc.id) ?? [];
|
|
137
|
+
const expanded = !collapsed.has(sc.id);
|
|
138
|
+
rows.push({
|
|
139
|
+
type: "node",
|
|
140
|
+
id: sc.id,
|
|
141
|
+
runId: run.runId,
|
|
142
|
+
depth,
|
|
143
|
+
prefix,
|
|
144
|
+
expandable: children.length > 0,
|
|
145
|
+
expanded,
|
|
146
|
+
icon: iconOf(sc.status, sc.phase),
|
|
147
|
+
phase: sc.phase,
|
|
148
|
+
label: sc.label,
|
|
149
|
+
tokens: sc.tokens,
|
|
150
|
+
model: sc.model,
|
|
151
|
+
});
|
|
152
|
+
if (!expanded || children.length === 0) return;
|
|
153
|
+
visitChildren(children, depth, childGuide);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const visitChildren = (children: readonly RlmSubcall[], parentDepth: number, guide: string): void => {
|
|
157
|
+
const entries = partition(children, byParent);
|
|
158
|
+
for (let i = 0; i < entries.length; i++) {
|
|
159
|
+
const entry = entries[i];
|
|
160
|
+
if (entry === undefined) continue;
|
|
161
|
+
const isLast = i === entries.length - 1;
|
|
162
|
+
const prefix = `${guide}${isLast ? "└─ " : "├─ "}`;
|
|
163
|
+
const childGuide = `${guide}${isLast ? " " : "│ "}`;
|
|
164
|
+
if (entry.type === "node") visit(entry.sc, parentDepth + 1, prefix, childGuide);
|
|
165
|
+
else if (entry.members.length === 1) {
|
|
166
|
+
// A single identical leaf is not a group — render it as the plain row it is.
|
|
167
|
+
const only = entry.members[0];
|
|
168
|
+
if (only !== undefined) visit(only, parentDepth + 1, prefix, childGuide);
|
|
169
|
+
} else visitGroup(entry, parentDepth + 1, prefix, childGuide);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const visitGroup = (
|
|
174
|
+
entry: Extract<Entry, { type: "group" }>,
|
|
175
|
+
depth: number,
|
|
176
|
+
prefix: string,
|
|
177
|
+
guide: string,
|
|
178
|
+
): void => {
|
|
179
|
+
const first = entry.members[0];
|
|
180
|
+
if (first === undefined) return;
|
|
181
|
+
const id = `grp:${run.runId}:${entry.key}:${first.id}`;
|
|
182
|
+
const expanded = expandedGroups.has(id);
|
|
183
|
+
let tokens = 0;
|
|
184
|
+
for (const m of entry.members) tokens += m.tokens;
|
|
185
|
+
rows.push({
|
|
186
|
+
type: "group",
|
|
187
|
+
id,
|
|
188
|
+
runId: run.runId,
|
|
189
|
+
depth,
|
|
190
|
+
prefix,
|
|
191
|
+
count: entry.members.length,
|
|
192
|
+
label: entry.label,
|
|
193
|
+
model: entry.model,
|
|
194
|
+
tokens,
|
|
195
|
+
icon: iconOf(entry.status, entry.members.some((m) => m.phase === "queued") ? "queued" : undefined),
|
|
196
|
+
expandable: true,
|
|
197
|
+
expanded,
|
|
198
|
+
});
|
|
199
|
+
if (!expanded) return;
|
|
200
|
+
for (let i = 0; i < entry.members.length; i++) {
|
|
201
|
+
const member = entry.members[i];
|
|
202
|
+
if (member === undefined) continue;
|
|
203
|
+
const last = i === entry.members.length - 1;
|
|
204
|
+
visit(member, depth, `${guide}${last ? "└─ " : "├─ "}`, `${guide}${last ? " " : "│ "}`);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Synthetic root row for the run itself.
|
|
209
|
+
rows.push({
|
|
210
|
+
type: "node",
|
|
211
|
+
id: run.runId,
|
|
212
|
+
runId: run.runId,
|
|
213
|
+
depth: 0,
|
|
214
|
+
prefix: "",
|
|
215
|
+
expandable: roots.length > 0,
|
|
216
|
+
expanded: !collapsed.has(run.runId),
|
|
217
|
+
icon: iconOf(run.status, run.rootPhase),
|
|
218
|
+
phase: run.rootPhase,
|
|
219
|
+
label: run.rootLabel,
|
|
220
|
+
tokens: run.rootTokens,
|
|
221
|
+
model: run.rootModel,
|
|
222
|
+
});
|
|
223
|
+
if (!collapsed.has(run.runId)) visitChildren(roots, 0, "");
|
|
224
|
+
|
|
225
|
+
return Object.freeze(rows);
|
|
226
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tree-rows — pure formatting of TreeRow[] into terminal lines.
|
|
3
|
+
*
|
|
4
|
+
* String building is segment-array + join (no += chains). Width math uses
|
|
5
|
+
* pi-tui's visibleWidth/truncateToWidth so ANSI colors never break alignment.
|
|
6
|
+
* Glyphs come from ui/theme.ts (single spinner/format source — no duplicates).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { formatTokens, spinnerFrame } from "../theme.ts";
|
|
12
|
+
import type { GroupRow, NodeRow, TreeRow } from "./tree-model.ts";
|
|
13
|
+
|
|
14
|
+
const GLYPHS = Object.freeze({ done: "✓", error: "✗", queued: "◷", expanded: "▾", collapsed: "▸", leaf: " " } as const);
|
|
15
|
+
const MODEL_MAX = 14;
|
|
16
|
+
|
|
17
|
+
/** "openai/gpt-5-mini" → "gpt-5-mini", hard-capped so rows stay on one line. */
|
|
18
|
+
export function modelShort(model: string): string {
|
|
19
|
+
const last = model.slice(model.lastIndexOf("/") + 1);
|
|
20
|
+
return last.length > MODEL_MAX ? `${last.slice(0, MODEL_MAX - 1)}…` : last;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function iconGlyph(row: NodeRow, theme: Theme): string {
|
|
24
|
+
switch (row.icon) {
|
|
25
|
+
case "done": return theme.fg("success", GLYPHS.done);
|
|
26
|
+
case "error": return theme.fg("error", GLYPHS.error);
|
|
27
|
+
case "queued": return theme.fg("warning", GLYPHS.queued);
|
|
28
|
+
default: return theme.fg("warning", spinnerFrame());
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Right-hand stats: "4.3k tok · gpt-5-mini" (model only when present). */
|
|
33
|
+
function statsText(tokens: number, model: string | undefined): string {
|
|
34
|
+
const parts = [`${formatTokens(tokens)} tok`];
|
|
35
|
+
if (model !== undefined) parts.push(modelShort(model));
|
|
36
|
+
return parts.join(" · ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Left/right assembly shared by node and group rows — one padding rule, no fork. */
|
|
40
|
+
function assembleLine(left: string, tokens: number, model: string | undefined, selected: boolean, width: number, theme: Theme): string {
|
|
41
|
+
const right = theme.fg("dim", statsText(tokens, model));
|
|
42
|
+
const gap = width - visibleWidth(left) - visibleWidth(right) - 1;
|
|
43
|
+
const line = gap > 0 ? `${left}${" ".repeat(gap)}${right}` : `${truncateToWidth(left, width - 1)} `;
|
|
44
|
+
return selected ? theme.fg("accent", line) : line;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatNode(row: NodeRow, selected: boolean, width: number, theme: Theme): string {
|
|
48
|
+
const chevron = row.expandable ? (row.expanded ? GLYPHS.expanded : GLYPHS.collapsed) : GLYPHS.leaf;
|
|
49
|
+
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
50
|
+
const left = `${cursor} ${row.prefix}${chevron} ${iconGlyph(row, theme)} ${row.label}`;
|
|
51
|
+
return assembleLine(left, row.tokens, row.model, selected, width, theme);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatGroup(row: GroupRow, selected: boolean, width: number, theme: Theme): string {
|
|
55
|
+
const chevron = row.expanded ? GLYPHS.expanded : GLYPHS.collapsed;
|
|
56
|
+
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
57
|
+
const icon = row.icon === "done" ? theme.fg("success", GLYPHS.done) : row.icon === "error" ? theme.fg("error", GLYPHS.error) : theme.fg("warning", spinnerFrame());
|
|
58
|
+
const left = `${cursor} ${row.prefix}${chevron} ${icon} ${row.label} ×${row.count}`;
|
|
59
|
+
return assembleLine(left, row.tokens, row.model, selected, width, theme);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatRow(row: TreeRow, selected: boolean, width: number, theme: Theme): string {
|
|
63
|
+
return row.type === "group" ? formatGroup(row, selected, width, theme) : formatNode(row, selected, width, theme);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Pre-sized output — row count is known, no push-in-loop. */
|
|
67
|
+
export function formatRows(rows: readonly TreeRow[], selectedId: string | undefined, width: number, theme: Theme): string[] {
|
|
68
|
+
const lines = new Array<string>(rows.length);
|
|
69
|
+
for (let i = 0; i < rows.length; i++) {
|
|
70
|
+
const row = rows[i];
|
|
71
|
+
lines[i] = formatRow(row, row !== undefined && row.id === selectedId, width, theme);
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeWidget — the persistent live agent tree below the editor.
|
|
3
|
+
*
|
|
4
|
+
* Implements pi's Component contract (render / handleInput via panel-mediated
|
|
5
|
+
* keys / invalidate / dispose). All heavy lifting is delegated: buildRows and
|
|
6
|
+
* formatRows are pure; this class only holds view state — collapsed nodes,
|
|
7
|
+
* cursor, focus mode, dirty flag — and owns the spinner timer, which runs
|
|
8
|
+
* ONLY while some node is running and is always cleared in dispose().
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { RunRegistry } from "../panel/run-registry.ts";
|
|
14
|
+
import { buildRows, type TreeRow } from "./tree-model.ts";
|
|
15
|
+
import { formatRows } from "./tree-rows.ts";
|
|
16
|
+
|
|
17
|
+
const SPINNER_INTERVAL_MS = 100;
|
|
18
|
+
/** Right-aligned stats never drift past this many columns, however wide the terminal. */
|
|
19
|
+
const ROW_WIDTH_CAP = 110;
|
|
20
|
+
|
|
21
|
+
const KEYS = Object.freeze({
|
|
22
|
+
up: "\x1b[A",
|
|
23
|
+
down: "\x1b[B",
|
|
24
|
+
right: "\x1b[C",
|
|
25
|
+
left: "\x1b[D",
|
|
26
|
+
enter: "\r",
|
|
27
|
+
escape: "\x1b",
|
|
28
|
+
} as const);
|
|
29
|
+
|
|
30
|
+
/** What the panel should do after a keypress. Discriminated — never a boolean soup. */
|
|
31
|
+
export type KeyAction =
|
|
32
|
+
| { readonly type: "swallowed" }
|
|
33
|
+
| { readonly type: "unfocus" }
|
|
34
|
+
| { readonly type: "open"; readonly runId: string; readonly nodeId: string };
|
|
35
|
+
|
|
36
|
+
export class TreeWidget implements Component {
|
|
37
|
+
private readonly collapsed = new Set<string>();
|
|
38
|
+
/** Group rows default COLLAPSED — membership here means expanded. */
|
|
39
|
+
private readonly expandedGroups = new Set<string>();
|
|
40
|
+
private rows: readonly TreeRow[] = [];
|
|
41
|
+
private selectedId: string | undefined;
|
|
42
|
+
private focused = false;
|
|
43
|
+
private dirty = true;
|
|
44
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
45
|
+
private readonly unsubscribe: () => void;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly tui: TUI,
|
|
49
|
+
private readonly theme: Theme,
|
|
50
|
+
private readonly registry: RunRegistry,
|
|
51
|
+
) {
|
|
52
|
+
this.unsubscribe = registry.onChange(() => {
|
|
53
|
+
this.dirty = true;
|
|
54
|
+
this.tui.requestRender();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get isFocused(): boolean {
|
|
59
|
+
return this.focused;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setFocused(focused: boolean): void {
|
|
63
|
+
this.focused = focused;
|
|
64
|
+
if (focused) {
|
|
65
|
+
this.rebuild();
|
|
66
|
+
this.selectedId = this.selectedId ?? this.rows[0]?.id;
|
|
67
|
+
}
|
|
68
|
+
this.tui.requestRender();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Panel-mediated key handling (widgets don't own terminal focus). */
|
|
72
|
+
handleKey(data: string): KeyAction {
|
|
73
|
+
if (data === KEYS.escape) {
|
|
74
|
+
this.setFocused(false);
|
|
75
|
+
return { type: "unfocus" };
|
|
76
|
+
}
|
|
77
|
+
if (data === KEYS.up) this.move(-1);
|
|
78
|
+
else if (data === KEYS.down) this.move(1);
|
|
79
|
+
else if (data === KEYS.left) this.collapseSelected();
|
|
80
|
+
else if (data === KEYS.right) this.expandSelected();
|
|
81
|
+
else if (data === KEYS.enter) {
|
|
82
|
+
const row = this.selectedRow();
|
|
83
|
+
if (row !== undefined) {
|
|
84
|
+
if (row.type === "group") this.toggleGroup(row.id);
|
|
85
|
+
else return { type: "open", runId: row.runId, nodeId: row.id };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
this.tui.requestRender();
|
|
89
|
+
return { type: "swallowed" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
render(width: number): string[] {
|
|
93
|
+
if (this.dirty) this.rebuild();
|
|
94
|
+
if (this.rows.length === 0) return [];
|
|
95
|
+
// pi renders belowEditor widgets flush under the editor's own bottom rule —
|
|
96
|
+
// no full-width header from us, it would stack a third rule and eat space.
|
|
97
|
+
// Unfocused: rows only. Focused: short accent tag + key hint.
|
|
98
|
+
const body = formatRows(this.rows, this.focused ? this.selectedId : undefined, Math.min(width, ROW_WIDTH_CAP), this.theme);
|
|
99
|
+
if (!this.focused) return body;
|
|
100
|
+
return [
|
|
101
|
+
this.theme.fg("accent", "─ RLM ─"),
|
|
102
|
+
...body,
|
|
103
|
+
this.theme.fg("dim", "↑↓ move · ←→ collapse · enter details · esc unfocus"),
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
invalidate(): void {
|
|
108
|
+
this.dirty = true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
dispose(): void {
|
|
112
|
+
this.unsubscribe();
|
|
113
|
+
if (this.timer !== undefined) {
|
|
114
|
+
clearInterval(this.timer);
|
|
115
|
+
this.timer = undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Internal ──
|
|
120
|
+
|
|
121
|
+
private rebuild(): void {
|
|
122
|
+
const all: TreeRow[] = [];
|
|
123
|
+
for (const run of this.registry.snapshots()) {
|
|
124
|
+
for (const row of buildRows(run, this.collapsed, this.expandedGroups)) all.push(row);
|
|
125
|
+
}
|
|
126
|
+
this.rows = Object.freeze(all);
|
|
127
|
+
this.dirty = false;
|
|
128
|
+
// A node that disappeared (run unregistered) must not stay selected.
|
|
129
|
+
if (this.selectedId !== undefined && !this.rows.some((r) => r.id === this.selectedId)) {
|
|
130
|
+
this.selectedId = this.rows[0]?.id;
|
|
131
|
+
}
|
|
132
|
+
this.syncTimer();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Spinner ticks only while something is running; idle widgets cost zero. */
|
|
136
|
+
private syncTimer(): void {
|
|
137
|
+
const anyRunning = this.rows.some((r) => r.icon === "running");
|
|
138
|
+
if (anyRunning && this.timer === undefined) {
|
|
139
|
+
const timer = setInterval(() => this.tui.requestRender(), SPINNER_INTERVAL_MS);
|
|
140
|
+
timer.unref();
|
|
141
|
+
this.timer = timer;
|
|
142
|
+
} else if (!anyRunning && this.timer !== undefined) {
|
|
143
|
+
clearInterval(this.timer);
|
|
144
|
+
this.timer = undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every row is navigable — node and group alike. */
|
|
149
|
+
private selectableRows(): readonly TreeRow[] {
|
|
150
|
+
return this.rows;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private selectedRow(): TreeRow | undefined {
|
|
154
|
+
return this.rows.find((r) => r.id === this.selectedId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private move(delta: number): void {
|
|
158
|
+
const rows = this.selectableRows();
|
|
159
|
+
if (rows.length === 0) return;
|
|
160
|
+
const at = rows.findIndex((r) => r.id === this.selectedId);
|
|
161
|
+
const next = rows[Math.min(rows.length - 1, Math.max(0, (at < 0 ? 0 : at) + delta))];
|
|
162
|
+
if (next !== undefined) this.selectedId = next.id;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private toggleGroup(id: string): void {
|
|
166
|
+
if (this.expandedGroups.has(id)) this.expandedGroups.delete(id);
|
|
167
|
+
else this.expandedGroups.add(id);
|
|
168
|
+
this.dirty = true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private collapseSelected(): void {
|
|
172
|
+
const row = this.selectedRow();
|
|
173
|
+
if (row === undefined || !row.expandable || !row.expanded) return;
|
|
174
|
+
if (row.type === "group") this.expandedGroups.delete(row.id);
|
|
175
|
+
else this.collapsed.add(row.id);
|
|
176
|
+
this.dirty = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private expandSelected(): void {
|
|
180
|
+
const row = this.selectedRow();
|
|
181
|
+
if (row === undefined || !row.expandable || row.expanded) return;
|
|
182
|
+
if (row.type === "group") this.expandedGroups.add(row.id);
|
|
183
|
+
else this.collapsed.delete(row.id);
|
|
184
|
+
this.dirty = true;
|
|
185
|
+
}
|
|
186
|
+
}
|