@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
|
@@ -1,30 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Card scaffolding for RLM tool results (rlm + repl).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The chat transcript renders ONE line per call — status glyph, title, stats —
|
|
5
|
+
* plus the expand hint. Agent trees are NOT rendered here: the live tree
|
|
6
|
+
* widget and agent modal (ui/tree/, ui/modal/) own all agent visualization.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
10
10
|
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import type {
|
|
12
|
-
import {
|
|
13
|
-
import { previewText } from "../text/preview.ts";
|
|
11
|
+
import type { SubcallStatus } from "./rlm-details.ts";
|
|
12
|
+
import { formatTokens, spinnerFrame } from "../ui/theme.ts";
|
|
14
13
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
14
|
|
|
16
|
-
/** Preview budgets for the expanded tree (args are terse, results get more room). */
|
|
17
|
-
const ARGS_PREVIEW_CHARS = 80;
|
|
18
|
-
const RESULT_PREVIEW_CHARS = 120;
|
|
19
|
-
|
|
20
15
|
// ── Glyphs ──
|
|
21
16
|
|
|
22
|
-
export function subcallStatusGlyph(sc: Pick<RlmSubcall, "status">, theme: Theme): string {
|
|
23
|
-
if (sc.status === "running") return theme.fg("warning", "⏳");
|
|
24
|
-
if (sc.status === "error") return theme.fg("error", "✗");
|
|
25
|
-
return theme.fg("success", "✓");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
17
|
export function headlineStatusGlyph(status: SubcallStatus | "aborted" | "done", theme: Theme): string {
|
|
29
18
|
switch (status) {
|
|
30
19
|
case "done": return theme.fg("success", "✓");
|
|
@@ -36,40 +25,24 @@ export function headlineStatusGlyph(status: SubcallStatus | "aborted" | "done",
|
|
|
36
25
|
|
|
37
26
|
// ── Stats formatting ──
|
|
38
27
|
|
|
39
|
-
|
|
40
|
-
const parts: string[] = [];
|
|
41
|
-
if (sc.costUsd > 0) parts.push(formatCost(sc.costUsd));
|
|
42
|
-
if (sc.tokens > 0) parts.push(`${formatTokens(sc.tokens)} tok`);
|
|
43
|
-
// Explicit undefined checks: a 0 timestamp is falsy but legitimate (fixtures, epoch clocks).
|
|
44
|
-
if (sc.endedAt !== undefined && sc.startedAt !== undefined) parts.push(formatDuration(sc.endedAt - sc.startedAt));
|
|
45
|
-
return parts.join(" · ");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ── Shared card scaffolding (rlm + repl render the same shape) ──
|
|
49
|
-
|
|
50
|
-
/** The `$0.0123 · 4.2k tok · 812ms` run of a card header. Omits any zero component. */
|
|
28
|
+
/** The `4.2k tok · 812ms` run of a card header. Omits any zero component. */
|
|
51
29
|
export function cardStatsLine(
|
|
52
|
-
totals: { readonly
|
|
30
|
+
totals: { readonly tokens: number },
|
|
53
31
|
theme: Theme,
|
|
54
32
|
extra?: string,
|
|
55
33
|
backgroundPending?: number,
|
|
56
34
|
): string {
|
|
57
|
-
const parts: string[] = [
|
|
35
|
+
const parts: string[] = [];
|
|
58
36
|
if (totals.tokens > 0) parts.push(`${formatTokens(totals.tokens)} tok`);
|
|
59
37
|
if (extra) parts.push(extra);
|
|
60
38
|
const line = theme.fg("dim", parts.join(" · "));
|
|
61
|
-
// The one thing no
|
|
39
|
+
// The one thing no single line can show: spawned work that may outlive this block.
|
|
62
40
|
return backgroundPending !== undefined && backgroundPending > 0
|
|
63
41
|
? `${line} ${theme.fg("warning", `↯${backgroundPending} bg`)}`
|
|
64
42
|
: line;
|
|
65
43
|
}
|
|
66
44
|
|
|
67
|
-
/**
|
|
68
|
-
function backgroundTag(sc: RlmSubcall, theme: Theme): string {
|
|
69
|
-
return sc.id.startsWith("bg") ? ` ${theme.fg("warning", "↯bg")}` : "";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** `<glyph> <TITLE> <stats>` — the first line of both tools' collapsed and expanded views. */
|
|
45
|
+
/** `<glyph> <TITLE> <stats>` — the card's single header line. */
|
|
73
46
|
export function cardHeader(
|
|
74
47
|
title: string,
|
|
75
48
|
status: SubcallStatus | "aborted" | "done",
|
|
@@ -93,101 +66,13 @@ function expandHint(theme: Theme): string {
|
|
|
93
66
|
return theme.fg("muted", key ? `${key} to expand` : "to expand");
|
|
94
67
|
}
|
|
95
68
|
|
|
96
|
-
/** The collapsed card: header
|
|
69
|
+
/** The collapsed card: header line, then the expand hint once settled. */
|
|
97
70
|
export function renderCollapsedCard(
|
|
98
71
|
title: string,
|
|
99
72
|
status: SubcallStatus | "aborted" | "done",
|
|
100
73
|
stats: string,
|
|
101
|
-
subcalls: readonly RlmSubcall[],
|
|
102
74
|
theme: Theme,
|
|
103
75
|
): Text {
|
|
104
|
-
const body = subcalls.length > 0 ? `\n${renderCollapsedSubcallTree(subcalls, theme)}` : "";
|
|
105
76
|
const hint = status === "running" ? "" : `\n${expandHint(theme)}`;
|
|
106
|
-
return new Text(`${cardHeader(title, status, stats, theme)}${
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ── Tree building ──
|
|
110
|
-
|
|
111
|
-
function buildParentMap(subcalls: readonly RlmSubcall[]): Map<string | undefined, RlmSubcall[]> {
|
|
112
|
-
const map = new Map<string | undefined, RlmSubcall[]>();
|
|
113
|
-
for (const sc of subcalls) {
|
|
114
|
-
const list = map.get(sc.parentId) ?? [];
|
|
115
|
-
list.push(sc);
|
|
116
|
-
map.set(sc.parentId, list);
|
|
117
|
-
}
|
|
118
|
-
return map;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ── Collapsed tree (ASCII) ──
|
|
122
|
-
|
|
123
|
-
export function renderCollapsedSubcallTree(
|
|
124
|
-
subcalls: readonly RlmSubcall[],
|
|
125
|
-
theme: Theme,
|
|
126
|
-
): string {
|
|
127
|
-
if (subcalls.length === 0) return "";
|
|
128
|
-
|
|
129
|
-
const byParent = buildParentMap(subcalls);
|
|
130
|
-
|
|
131
|
-
function walk(parentId: string | undefined, prefix: string): string[] {
|
|
132
|
-
const lines: string[] = [];
|
|
133
|
-
const direct = byParent.get(parentId) ?? [];
|
|
134
|
-
for (let i = 0; i < direct.length; i++) {
|
|
135
|
-
const sc = direct[i];
|
|
136
|
-
if (!sc) continue;
|
|
137
|
-
const isLast = i === direct.length - 1;
|
|
138
|
-
const branch = isLast ? "└─" : "├─";
|
|
139
|
-
const gGlyph = subcallStatusGlyph(sc, theme);
|
|
140
|
-
const gStats = subcallStatsLine(sc);
|
|
141
|
-
const gBg = backgroundTag(sc, theme);
|
|
142
|
-
lines.push(`${prefix}${branch} ${sc.label} ${gGlyph} ${gStats}${gBg}`);
|
|
143
|
-
const childPrefix = prefix + (isLast ? " " : "│ ");
|
|
144
|
-
lines.push(...walk(sc.id, childPrefix));
|
|
145
|
-
}
|
|
146
|
-
return lines;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return walk(undefined, " ").join("\n");
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ── Expanded tree (Container) ──
|
|
153
|
-
|
|
154
|
-
export function renderExpandedSubcallTree(
|
|
155
|
-
subcalls: readonly RlmSubcall[],
|
|
156
|
-
theme: Theme,
|
|
157
|
-
): Component {
|
|
158
|
-
const container = new Container();
|
|
159
|
-
if (subcalls.length === 0) return container;
|
|
160
|
-
|
|
161
|
-
const byParent = buildParentMap(subcalls);
|
|
162
|
-
|
|
163
|
-
function renderNode(sc: RlmSubcall, indent: number): void {
|
|
164
|
-
const pad = " ".repeat(indent);
|
|
165
|
-
const sGlyph = subcallStatusGlyph(sc, theme);
|
|
166
|
-
const sKind = theme.fg("muted", sc.label);
|
|
167
|
-
const sModel = sc.model ? theme.fg("dim", ` ${sc.model}`) : "";
|
|
168
|
-
const sStats = sc.endedAt ? ` ${theme.fg("dim", subcallStatsLine(sc))}` : "";
|
|
169
|
-
const sBg = backgroundTag(sc, theme);
|
|
170
|
-
let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}${sBg}`;
|
|
171
|
-
|
|
172
|
-
if (sc.args) {
|
|
173
|
-
line += `\n${pad} ${theme.fg("dim", previewText(sc.args, ARGS_PREVIEW_CHARS))}`;
|
|
174
|
-
}
|
|
175
|
-
if (sc.status === "error" && sc.detail) {
|
|
176
|
-
line += `\n${pad} ${theme.fg("error", `✗ ${sc.detail}`)}`;
|
|
177
|
-
} else if (sc.resultPreview) {
|
|
178
|
-
line += `\n${pad} ${theme.fg("toolOutput", previewText(sc.resultPreview, RESULT_PREVIEW_CHARS))}`;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
container.addChild(new Text(line, 0, 0));
|
|
182
|
-
|
|
183
|
-
for (const child of (byParent.get(sc.id) ?? [])) {
|
|
184
|
-
renderNode(child, indent + 1);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
for (const sc of (byParent.get(undefined) ?? [])) {
|
|
189
|
-
renderNode(sc, 1);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return container;
|
|
77
|
+
return new Text(`${cardHeader(title, status, stats, theme)}${hint}`, 0, 0);
|
|
193
78
|
}
|
|
@@ -25,6 +25,8 @@ export class SubcallStore extends EmitterListener {
|
|
|
25
25
|
|
|
26
26
|
private totalCostUsd = 0;
|
|
27
27
|
private totalTokens = 0;
|
|
28
|
+
private rootCostUsd = 0;
|
|
29
|
+
private rootTokens = 0;
|
|
28
30
|
|
|
29
31
|
constructor(emitter: RlmEmitter, private readonly onChange?: () => void) {
|
|
30
32
|
super();
|
|
@@ -61,6 +63,7 @@ export class SubcallStore extends EmitterListener {
|
|
|
61
63
|
sc.status = event.status;
|
|
62
64
|
if (event.status !== "running") sc.endedAt = Date.now();
|
|
63
65
|
}
|
|
66
|
+
if (event.phase !== undefined) sc.phase = event.phase;
|
|
64
67
|
if (event.detail !== undefined) sc.detail = event.detail;
|
|
65
68
|
if (event.args !== undefined) sc.args = event.args;
|
|
66
69
|
if (event.resultPreview !== undefined) sc.resultPreview = event.resultPreview;
|
|
@@ -93,7 +96,7 @@ export class SubcallStore extends EmitterListener {
|
|
|
93
96
|
* from the running totals so the caller can add them without double-counting.
|
|
94
97
|
*
|
|
95
98
|
* A root whose subtree still has a running node stays put. That matters because
|
|
96
|
-
* `
|
|
99
|
+
* The tree model (`ui/tree/tree-model.ts`) walks down from `parentId === undefined`: a subcall handed
|
|
97
100
|
* over without its parent has no path from a root and is silently dropped from the tree.
|
|
98
101
|
* Handing over whole subtrees is what keeps adopted nodes renderable.
|
|
99
102
|
*/
|
|
@@ -144,5 +147,12 @@ export class SubcallStore extends EmitterListener {
|
|
|
144
147
|
addRootUsage(costUsd: number, tokens: number): void {
|
|
145
148
|
this.totalCostUsd += costUsd;
|
|
146
149
|
this.totalTokens += tokens;
|
|
150
|
+
this.rootCostUsd += costUsd;
|
|
151
|
+
this.rootTokens += tokens;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Root engine's OWN spend (driver-model turns only) — never blends sub-call models. */
|
|
155
|
+
getRootUsage(): SubcallTotals {
|
|
156
|
+
return { costUsd: this.rootCostUsd, tokens: this.rootTokens };
|
|
147
157
|
}
|
|
148
158
|
}
|
package/src/ui/intro.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { modelLabel } from "./status.ts";
|
|
6
6
|
|
|
7
7
|
export const RLM_GUIDE = `# RLM mode
|
|
8
8
|
|
|
@@ -11,10 +11,19 @@ export const RLM_GUIDE = `# RLM mode
|
|
|
11
11
|
## Commands
|
|
12
12
|
|
|
13
13
|
- \`/rlm\` — toggle RLM mode (shortcut: Ctrl+Shift+R). Turning it OFF also stops a running query.
|
|
14
|
-
- \`/rlm-
|
|
15
|
-
- \`/rlm-
|
|
14
|
+
- \`/rlm-llm\` — pin the LLM model for llm_query / llm_batch / map_files
|
|
15
|
+
- \`/rlm-rlm\` — pin the model for rlm_query / rlm_batch child engines (default: session model)
|
|
16
|
+
- \`/rlm-config\` — run limits and engine settings
|
|
17
|
+
- \`/rlm-stop\` — cancel the current run but stay in RLM mode (use /rlm or Ctrl+Shift+R to leave)
|
|
18
|
+
|
|
19
|
+
## Live tree
|
|
20
|
+
|
|
21
|
+
While agents run, a tree shows below the editor — Ctrl+R focuses it, Enter opens an agent's timeline.`;
|
|
16
22
|
|
|
17
23
|
export function postRlmGuide(pi: ExtensionAPI, controller: RlmController): void {
|
|
18
|
-
const
|
|
24
|
+
const state = controller.enabled
|
|
25
|
+
? `● RLM ON — llm=${modelLabel(controller.llmModel, controller.savedLlmRef ?? "cheapest")} · rlm=${modelLabel(controller.rlmModel, controller.savedRlmRef ?? "session")}`
|
|
26
|
+
: "○ RLM OFF";
|
|
27
|
+
const content = RLM_GUIDE.replace("{state}", state);
|
|
19
28
|
pi.sendMessage({ customType: "rlm-intro", content, display: true });
|
|
20
29
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-modal — focused overlay showing one agent's live detail.
|
|
3
|
+
*
|
|
4
|
+
* Opened via ctx.ui.custom (pi's overlay API): pi routes keyboard input to the
|
|
5
|
+
* component until done() fires. This class holds only view state (scroll
|
|
6
|
+
* offset, subscription); every line is built by the pure modal-view builder.
|
|
7
|
+
* Live updates: any registry change triggers a re-render, so the timeline
|
|
8
|
+
* scrolls itself while the agent works.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { RunEntry, RunRegistry } from "../panel/run-registry.ts";
|
|
14
|
+
import { buildModalLines, MODAL_LAYOUT, type AgentViewData } from "./modal-view.ts";
|
|
15
|
+
|
|
16
|
+
const OVERLAY_OPTIONS = Object.freeze({ width: "70%" as const, maxHeight: "70%" as const, anchor: "center" as const });
|
|
17
|
+
|
|
18
|
+
export function openAgentModal(ctx: ExtensionContext, registry: RunRegistry, runId: string, nodeId: string): Promise<void> {
|
|
19
|
+
return ctx.ui.custom<void>(
|
|
20
|
+
(tui, theme, _keybindings, done) => new AgentModalComponent(tui, theme, registry, runId, nodeId, done),
|
|
21
|
+
{ overlay: true, overlayOptions: OVERLAY_OPTIONS },
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class AgentModalComponent implements Component {
|
|
26
|
+
private scroll = 0;
|
|
27
|
+
private timelineLength = 0;
|
|
28
|
+
private readonly unsubscribe: () => void;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly tui: TUI,
|
|
32
|
+
private readonly theme: Theme,
|
|
33
|
+
private readonly registry: RunRegistry,
|
|
34
|
+
private readonly runId: string,
|
|
35
|
+
private readonly nodeId: string,
|
|
36
|
+
private readonly done: (result: void) => void,
|
|
37
|
+
) {
|
|
38
|
+
this.unsubscribe = registry.onChange(() => this.tui.requestRender());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
render(width: number): string[] {
|
|
42
|
+
const data = this.collect();
|
|
43
|
+
if (data === undefined) {
|
|
44
|
+
return ["┌─ agent ─────────────────────┐", "│ run ended — data unavailable │", "│ esc close │", "└──────────────────────────────┘"];
|
|
45
|
+
}
|
|
46
|
+
this.timelineLength = data.timeline.length;
|
|
47
|
+
return buildModalLines(data, this.scroll, width, this.theme);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
handleInput(data: string): void {
|
|
51
|
+
if (data === "\x1b" || data === "q") {
|
|
52
|
+
this.dispose();
|
|
53
|
+
this.done();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (data === "\x1b[A") this.scroll = Math.max(0, this.scroll - 1);
|
|
57
|
+
else if (data === "\x1b[B") this.scroll = Math.min(Math.max(0, this.timelineLength - MODAL_LAYOUT.timelineVisible), this.scroll + 1);
|
|
58
|
+
this.tui.requestRender();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** No cached render state — every render rebuilds from the live registry. */
|
|
62
|
+
invalidate(): void {}
|
|
63
|
+
|
|
64
|
+
dispose(): void {
|
|
65
|
+
this.unsubscribe();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Snapshot the node (or the run root) as view data; undefined once the run unregisters. */
|
|
69
|
+
private collect(): AgentViewData | undefined {
|
|
70
|
+
const run = this.registry.find(this.runId);
|
|
71
|
+
if (run === undefined) return undefined;
|
|
72
|
+
return this.nodeId === this.runId ? rootView(run) : nodeView(run, this.nodeId);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rootView(run: RunEntry): AgentViewData {
|
|
77
|
+
const status = run.rootStatus();
|
|
78
|
+
return {
|
|
79
|
+
label: run.label,
|
|
80
|
+
icon: status === "aborted" ? "error" : status,
|
|
81
|
+
phase: run.rootPhase(),
|
|
82
|
+
depth: 0,
|
|
83
|
+
tokens: run.totals().tokens,
|
|
84
|
+
turns: run.turns(),
|
|
85
|
+
timeline: run.timeline.forNode(run.runId),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function nodeView(run: RunEntry, nodeId: string): AgentViewData | undefined {
|
|
90
|
+
const node = run.subcalls().find((sc) => sc.id === nodeId);
|
|
91
|
+
if (node === undefined) return undefined;
|
|
92
|
+
return {
|
|
93
|
+
label: node.label,
|
|
94
|
+
icon: node.status,
|
|
95
|
+
phase: node.phase,
|
|
96
|
+
depth: node.depth,
|
|
97
|
+
model: node.model,
|
|
98
|
+
tokens: node.tokens,
|
|
99
|
+
elapsedMs: (node.endedAt ?? Date.now()) - node.startedAt,
|
|
100
|
+
detail: node.detail,
|
|
101
|
+
args: node.args,
|
|
102
|
+
timeline: run.timeline.forNode(nodeId),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modal-view — pure line builder for the agent detail modal.
|
|
3
|
+
*
|
|
4
|
+
* Takes an immutable AgentViewData + scroll offset, returns bordered lines.
|
|
5
|
+
* No TUI state, no side effects — the stateful wrapper lives in agent-modal.ts.
|
|
6
|
+
* All assembly is segment arrays + join; ANSI-safe width math via pi-tui.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { formatDuration, formatTokens, spinnerFrame } from "../theme.ts";
|
|
12
|
+
import { modelShort } from "../tree/tree-rows.ts";
|
|
13
|
+
import type { SubcallPhase, SubcallStatus } from "../../tool/rlm-details.ts";
|
|
14
|
+
import type { TimelineEntry, TimelineIcon } from "./timeline-store.ts";
|
|
15
|
+
|
|
16
|
+
export const MODAL_LAYOUT = Object.freeze({
|
|
17
|
+
/** Timeline rows visible at once (scroll window). */
|
|
18
|
+
timelineVisible: 10,
|
|
19
|
+
/** Prompt section line budget. */
|
|
20
|
+
promptLines: 3,
|
|
21
|
+
minWidth: 44,
|
|
22
|
+
} as const);
|
|
23
|
+
|
|
24
|
+
export interface AgentViewData {
|
|
25
|
+
readonly label: string;
|
|
26
|
+
readonly icon: SubcallStatus;
|
|
27
|
+
readonly phase?: SubcallPhase;
|
|
28
|
+
readonly depth: number;
|
|
29
|
+
readonly model?: string;
|
|
30
|
+
readonly tokens: number;
|
|
31
|
+
/** Root rows only — omitted for subcall nodes. */
|
|
32
|
+
readonly turns?: { readonly current: number; readonly max: number };
|
|
33
|
+
readonly elapsedMs?: number;
|
|
34
|
+
readonly detail?: string;
|
|
35
|
+
readonly args?: string;
|
|
36
|
+
readonly timeline: readonly TimelineEntry[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TIMELINE_GLYPHS: Readonly<Record<TimelineIcon, string>> = Object.freeze({
|
|
40
|
+
spawn: "▸",
|
|
41
|
+
phase: "◌",
|
|
42
|
+
done: "✓",
|
|
43
|
+
error: "✗",
|
|
44
|
+
note: "·",
|
|
45
|
+
turn: "↻",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function statusGlyph(icon: SubcallStatus, theme: Theme): string {
|
|
49
|
+
switch (icon) {
|
|
50
|
+
case "done": return theme.fg("success", "✓");
|
|
51
|
+
case "error": return theme.fg("error", "✗");
|
|
52
|
+
default: return theme.fg("warning", spinnerFrame());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** "┌─ title ───────┐" at exactly width columns. */
|
|
57
|
+
function topBorder(title: string, width: number, theme: Theme): string {
|
|
58
|
+
const head = `┌─ ${title} `;
|
|
59
|
+
const fill = Math.max(0, width - visibleWidth(head) - 1);
|
|
60
|
+
return theme.fg("border", `${head}${"─".repeat(fill)}┐`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** "│ content … │" padded to width; content pre-truncated to the inner width. */
|
|
64
|
+
function row(content: string, width: number): string {
|
|
65
|
+
const inner = width - 4;
|
|
66
|
+
const cut = truncateToWidth(content, inner);
|
|
67
|
+
const pad = Math.max(0, inner - visibleWidth(cut));
|
|
68
|
+
return `│ ${cut}${" ".repeat(pad)} │`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function timelineLine(entry: TimelineEntry, theme: Theme): string {
|
|
72
|
+
const glyph = TIMELINE_GLYPHS[entry.icon];
|
|
73
|
+
const colored = entry.icon === "error" ? theme.fg("error", glyph) : entry.icon === "done" ? theme.fg("success", glyph) : theme.fg("dim", glyph);
|
|
74
|
+
return `${colored} ${entry.text}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildModalLines(data: AgentViewData, scroll: number, width: number, theme: Theme): string[] {
|
|
78
|
+
const w = Math.max(width, MODAL_LAYOUT.minWidth);
|
|
79
|
+
const inner = w - 4;
|
|
80
|
+
const lines: string[] = [];
|
|
81
|
+
|
|
82
|
+
lines.push(topBorder(data.label, w, theme));
|
|
83
|
+
|
|
84
|
+
// Status row: "⠸ running · thinking depth 1 model gpt-5-mini"
|
|
85
|
+
const statusParts = [`${statusGlyph(data.icon, theme)} ${data.icon}`];
|
|
86
|
+
if (data.phase !== undefined && data.icon === "running") statusParts.push(`· ${data.phase}`);
|
|
87
|
+
const stats: string[] = [`depth ${data.depth}`];
|
|
88
|
+
if (data.model !== undefined) stats.push(`model ${modelShort(data.model)}`);
|
|
89
|
+
lines.push(row(`${statusParts.join(" ")} ${theme.fg("dim", stats.join(" "))}`, w));
|
|
90
|
+
|
|
91
|
+
// Stats row: "tokens 4.3k turn 3/25 elapsed 12.0s"
|
|
92
|
+
const metricParts = [`tokens ${formatTokens(data.tokens)}`];
|
|
93
|
+
if (data.turns !== undefined && data.turns.max > 0) metricParts.push(`turn ${data.turns.current}/${data.turns.max}`);
|
|
94
|
+
if (data.elapsedMs !== undefined) metricParts.push(`elapsed ${formatDuration(data.elapsedMs)}`);
|
|
95
|
+
lines.push(row(theme.fg("dim", metricParts.join(" ")), w));
|
|
96
|
+
lines.push(row("", w));
|
|
97
|
+
|
|
98
|
+
// Prompt section: detail first, args as fallback, capped to the line budget.
|
|
99
|
+
const prompt = data.detail ?? data.args ?? "";
|
|
100
|
+
lines.push(row(theme.fg("accent", "prompt"), w));
|
|
101
|
+
if (prompt.length === 0) {
|
|
102
|
+
lines.push(row(theme.fg("dim", "(none)"), w));
|
|
103
|
+
} else {
|
|
104
|
+
const words = prompt.split(" ");
|
|
105
|
+
const wrapped: string[] = [];
|
|
106
|
+
let line = "";
|
|
107
|
+
for (const word of words) {
|
|
108
|
+
const next = line.length === 0 ? word : `${line} ${word}`;
|
|
109
|
+
if (visibleWidth(next) > inner - 2) { wrapped.push(line); line = word; } else { line = next; }
|
|
110
|
+
if (wrapped.length >= MODAL_LAYOUT.promptLines) break;
|
|
111
|
+
}
|
|
112
|
+
if (wrapped.length < MODAL_LAYOUT.promptLines && line.length > 0) wrapped.push(line);
|
|
113
|
+
for (const part of wrapped.slice(0, MODAL_LAYOUT.promptLines)) lines.push(row(` ${part}`, w));
|
|
114
|
+
}
|
|
115
|
+
lines.push(row("", w));
|
|
116
|
+
|
|
117
|
+
// Timeline window.
|
|
118
|
+
const total = data.timeline.length;
|
|
119
|
+
const maxScroll = Math.max(0, total - MODAL_LAYOUT.timelineVisible);
|
|
120
|
+
const at = Math.min(scroll, maxScroll);
|
|
121
|
+
const hint = total > MODAL_LAYOUT.timelineVisible ? `timeline ${at + 1}–${Math.min(total, at + MODAL_LAYOUT.timelineVisible)}/${total} · ↑↓ scroll` : "timeline";
|
|
122
|
+
lines.push(row(theme.fg("accent", hint), w));
|
|
123
|
+
const windowed = data.timeline.slice(at, at + MODAL_LAYOUT.timelineVisible);
|
|
124
|
+
for (const entry of windowed) lines.push(row(` ${timelineLine(entry, theme)}`, w));
|
|
125
|
+
// Stable height: pad unused timeline rows.
|
|
126
|
+
for (let i = windowed.length; i < MODAL_LAYOUT.timelineVisible; i++) lines.push(row("", w));
|
|
127
|
+
|
|
128
|
+
lines.push(row("", w));
|
|
129
|
+
lines.push(row(theme.fg("dim", "esc close"), w));
|
|
130
|
+
lines.push(theme.fg("border", `└${"─".repeat(Math.max(0, w - 2))}┘`));
|
|
131
|
+
return lines;
|
|
132
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TimelineStore — per-run ring buffer of notable events for the agent modal.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to the same emitter as SubcallStore (via the shared EmitterListener
|
|
5
|
+
* base) but records HISTORY instead of latest state: spawns, phase transitions,
|
|
6
|
+
* settlements, turn ticks. Bounded — oldest entries drop off past TIMELINE_CAP.
|
|
7
|
+
* The modal filters by nodeId, so one store serves every agent of the run.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
RlmEmitter,
|
|
12
|
+
RootPhaseEvent,
|
|
13
|
+
SubcallCreatedEvent,
|
|
14
|
+
SubcallUpdatedEvent,
|
|
15
|
+
TurnEvent,
|
|
16
|
+
} from "../../tool/rlm-events.ts";
|
|
17
|
+
import { EmitterListener } from "../../tool/emitter-listener.ts";
|
|
18
|
+
|
|
19
|
+
export const TIMELINE_CAP = 200;
|
|
20
|
+
|
|
21
|
+
export type TimelineIcon = "spawn" | "phase" | "done" | "error" | "note" | "turn";
|
|
22
|
+
|
|
23
|
+
export interface TimelineEntry {
|
|
24
|
+
readonly at: number;
|
|
25
|
+
/** Node the entry belongs to: the subcall it describes; spawns attach to the PARENT. */
|
|
26
|
+
readonly nodeId: string;
|
|
27
|
+
readonly icon: TimelineIcon;
|
|
28
|
+
readonly text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class TimelineStore extends EmitterListener {
|
|
32
|
+
private entries: TimelineEntry[] = [];
|
|
33
|
+
|
|
34
|
+
constructor(emitter: RlmEmitter, private readonly runId: string) {
|
|
35
|
+
super();
|
|
36
|
+
this.trackAll([
|
|
37
|
+
emitter.onSubcallCreated((e) => this.handleCreated(e)),
|
|
38
|
+
emitter.onSubcallUpdated((e) => this.handleUpdated(e)),
|
|
39
|
+
emitter.onTurn((e) => this.handleTurn(e)),
|
|
40
|
+
emitter.onRootPhase((e) => this.handleRootPhase(e)),
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Entries about a node, oldest first. Allocates — call from modal render only. */
|
|
45
|
+
forNode(nodeId: string): readonly TimelineEntry[] {
|
|
46
|
+
return this.entries.filter((e) => e.nodeId === nodeId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private handleCreated(event: SubcallCreatedEvent): void {
|
|
50
|
+
this.push({
|
|
51
|
+
at: Date.now(),
|
|
52
|
+
nodeId: event.parentId ?? this.runId,
|
|
53
|
+
icon: "spawn",
|
|
54
|
+
text: `spawned ${event.kind}: ${event.label}`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private handleUpdated(event: SubcallUpdatedEvent): void {
|
|
59
|
+
if (event.phase !== undefined) {
|
|
60
|
+
this.push({ at: Date.now(), nodeId: event.id, icon: "phase", text: event.phase });
|
|
61
|
+
}
|
|
62
|
+
if (event.detail !== undefined) {
|
|
63
|
+
this.push({ at: Date.now(), nodeId: event.id, icon: "note", text: event.detail });
|
|
64
|
+
}
|
|
65
|
+
if (event.status === "done") {
|
|
66
|
+
this.push({ at: Date.now(), nodeId: event.id, icon: "done", text: "settled" });
|
|
67
|
+
} else if (event.status === "error") {
|
|
68
|
+
this.push({ at: Date.now(), nodeId: event.id, icon: "error", text: event.detail ?? "failed" });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private handleTurn(event: TurnEvent): void {
|
|
73
|
+
this.push({ at: Date.now(), nodeId: this.runId, icon: "turn", text: `turn ${event.current}/${event.max}` });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private handleRootPhase(event: RootPhaseEvent): void {
|
|
77
|
+
this.push({ at: Date.now(), nodeId: this.runId, icon: "phase", text: event.phase });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private push(entry: TimelineEntry): void {
|
|
81
|
+
// Ring semantics: at cap, the oldest entry goes. Cap is small (200), shift is fine.
|
|
82
|
+
if (this.entries.length >= TIMELINE_CAP) this.entries.shift();
|
|
83
|
+
this.entries.push(entry);
|
|
84
|
+
}
|
|
85
|
+
}
|