@entelligentsia/forgecli 1.0.10 → 1.0.20
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/CHANGELOG.md +191 -0
- package/dist/CHANGELOG-forge-plugin.md +211 -0
- package/dist/bin/forge.js +0 -0
- package/dist/extensions/forgecli/config-layer.js.map +1 -1
- package/dist/extensions/forgecli/context-governor-compaction.d.ts +83 -0
- package/dist/extensions/forgecli/context-governor-compaction.js +302 -0
- package/dist/extensions/forgecli/context-governor-compaction.js.map +1 -0
- package/dist/extensions/forgecli/context-governor.d.ts +173 -0
- package/dist/extensions/forgecli/context-governor.js +618 -0
- package/dist/extensions/forgecli/context-governor.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/component.d.ts +105 -0
- package/dist/extensions/forgecli/dashboard/component.js +861 -0
- package/dist/extensions/forgecli/dashboard/component.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/register.d.ts +2 -0
- package/dist/extensions/forgecli/dashboard/register.js +31 -0
- package/dist/extensions/forgecli/dashboard/register.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/theme.d.ts +27 -0
- package/dist/extensions/forgecli/dashboard/theme.js +91 -0
- package/dist/extensions/forgecli/dashboard/theme.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/view-model.d.ts +35 -0
- package/dist/extensions/forgecli/dashboard/view-model.js +54 -0
- package/dist/extensions/forgecli/dashboard/view-model.js.map +1 -0
- package/dist/extensions/forgecli/fix-bug.js +126 -7
- package/dist/extensions/forgecli/fix-bug.js.map +1 -1
- package/dist/extensions/forgecli/forge-artifact-tool.js +2 -1
- package/dist/extensions/forgecli/forge-artifact-tool.js.map +1 -1
- package/dist/extensions/forgecli/forge-commands.js +1 -0
- package/dist/extensions/forgecli/forge-commands.js.map +1 -1
- package/dist/extensions/forgecli/forge-init/phase4-register.js +53 -0
- package/dist/extensions/forgecli/forge-init/phase4-register.js.map +1 -1
- package/dist/extensions/forgecli/forge-subagent.d.ts +20 -1
- package/dist/extensions/forgecli/forge-subagent.js +23 -7
- package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
- package/dist/extensions/forgecli/forge-tools.js +3 -1
- package/dist/extensions/forgecli/forge-tools.js.map +1 -1
- package/dist/extensions/forgecli/hook-dispatcher.d.ts +3 -1
- package/dist/extensions/forgecli/hook-dispatcher.js +37 -3
- package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
- package/dist/extensions/forgecli/index.js +38 -1
- package/dist/extensions/forgecli/index.js.map +1 -1
- package/dist/extensions/forgecli/lib/halt-advisor.d.ts +59 -0
- package/dist/extensions/forgecli/lib/halt-advisor.js +113 -0
- package/dist/extensions/forgecli/lib/halt-advisor.js.map +1 -0
- package/dist/extensions/forgecli/migration-engine.js +25 -12
- package/dist/extensions/forgecli/migration-engine.js.map +1 -1
- package/dist/extensions/forgecli/orchestrator-status-bar.d.ts +26 -0
- package/dist/extensions/forgecli/orchestrator-status-bar.js +213 -0
- package/dist/extensions/forgecli/orchestrator-status-bar.js.map +1 -0
- package/dist/extensions/forgecli/orchestrator-tree.d.ts +96 -0
- package/dist/extensions/forgecli/orchestrator-tree.js +390 -0
- package/dist/extensions/forgecli/orchestrator-tree.js.map +1 -0
- package/dist/extensions/forgecli/project-orientation.js +12 -8
- package/dist/extensions/forgecli/project-orientation.js.map +1 -1
- package/dist/extensions/forgecli/regenerate.d.ts +16 -0
- package/dist/extensions/forgecli/regenerate.js +110 -0
- package/dist/extensions/forgecli/regenerate.js.map +1 -1
- package/dist/extensions/forgecli/run-sprint.d.ts +3 -1
- package/dist/extensions/forgecli/run-sprint.js +34 -3
- package/dist/extensions/forgecli/run-sprint.js.map +1 -1
- package/dist/extensions/forgecli/run-task.d.ts +66 -1
- package/dist/extensions/forgecli/run-task.js +323 -12
- package/dist/extensions/forgecli/run-task.js.map +1 -1
- package/dist/extensions/forgecli/thread-switcher.d.ts +4 -1
- package/dist/extensions/forgecli/thread-switcher.js +118 -762
- package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
- package/dist/extensions/forgecli/viewport-events.js +32 -0
- package/dist/extensions/forgecli/viewport-events.js.map +1 -1
- package/dist/forge-payload/.base-pack/commands/fix-bug.md +1 -1
- package/dist/forge-payload/.base-pack/commands/run-sprint.md +1 -1
- package/dist/forge-payload/.base-pack/commands/run-task.md +1 -1
- package/dist/forge-payload/.base-pack/personas/architect.md +1 -1
- package/dist/forge-payload/.base-pack/personas/bug-fixer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/collator.md +3 -3
- package/dist/forge-payload/.base-pack/personas/engineer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/librarian.md +1 -1
- package/dist/forge-payload/.base-pack/personas/orchestrator.md +1 -1
- package/dist/forge-payload/.base-pack/personas/product-manager.md +1 -1
- package/dist/forge-payload/.base-pack/personas/qa-engineer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/supervisor.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/event-emission-schema.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/friction-emit.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/iron-laws.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/progress-reporting.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/_fragments/store-cli-verbs.md +11 -2
- package/dist/forge-payload/.base-pack/workflows/architect_approve.md +6 -7
- package/dist/forge-payload/.base-pack/workflows/architect_review_sprint_completion.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/architect_sprint_intake.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/architect_sprint_plan.md +5 -5
- package/dist/forge-payload/.base-pack/workflows/collator_agent.md +4 -6
- package/dist/forge-payload/.base-pack/workflows/commit_task.md +5 -6
- package/dist/forge-payload/.base-pack/workflows/enhance.md +5 -5
- package/dist/forge-payload/.base-pack/workflows/implement_plan.md +15 -7
- package/dist/forge-payload/.base-pack/workflows/migrate_structural.md +12 -13
- package/dist/forge-payload/.base-pack/workflows/plan_task.md +12 -6
- package/dist/forge-payload/.base-pack/workflows/review_code.md +12 -11
- package/dist/forge-payload/.base-pack/workflows/review_plan.md +12 -11
- package/dist/forge-payload/.base-pack/workflows/sprint_retrospective.md +3 -3
- package/dist/forge-payload/.base-pack/workflows/triage.md +12 -9
- package/dist/forge-payload/.base-pack/workflows/update_implementation.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/update_plan.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/validate_task.md +9 -9
- package/dist/forge-payload/.base-pack/workflows-js/wfl-fix-bug.js +490 -0
- package/dist/forge-payload/.base-pack/workflows-js/wfl-run-sprint.js +416 -0
- package/dist/forge-payload/.base-pack/workflows-js/wfl-run-task.js +608 -0
- package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
- package/dist/forge-payload/.schemas/config.schema.json +2 -3
- package/dist/forge-payload/.schemas/enum-catalog.json +2 -2
- package/dist/forge-payload/.schemas/event.schema.json +16 -0
- package/dist/forge-payload/.schemas/migrations.json +359 -18
- package/dist/forge-payload/commands/health.md +29 -0
- package/dist/forge-payload/commands/rebuild.md +143 -15
- package/dist/forge-payload/commands/update.md +28 -27
- package/dist/forge-payload/hooks/preflight-session.cjs +99 -0
- package/dist/forge-payload/init/phases/phase-3-materialize.md +18 -5
- package/dist/forge-payload/integrity.json +7 -6
- package/dist/forge-payload/meta/fragments/tool-discipline.md +1 -1
- package/dist/forge-payload/meta/personas/meta-architect.md +1 -1
- package/dist/forge-payload/meta/personas/meta-bug-fixer.md +1 -1
- package/dist/forge-payload/meta/personas/meta-collator.md +7 -7
- package/dist/forge-payload/meta/personas/meta-engineer.md +1 -1
- package/dist/forge-payload/meta/personas/meta-orchestrator.md +1 -1
- package/dist/forge-payload/meta/personas/meta-supervisor.md +1 -1
- package/dist/forge-payload/meta/tool-specs/store-cli.spec.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/event-emission-schema.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/friction-emit.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/iron-laws.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/progress-reporting.md +2 -2
- package/dist/forge-payload/meta/workflows/_fragments/store-cli-verbs.md +11 -2
- package/dist/forge-payload/meta/workflows/meta-approve.md +6 -7
- package/dist/forge-payload/meta/workflows/meta-bug-triage.md +12 -9
- package/dist/forge-payload/meta/workflows/meta-collate.md +5 -7
- package/dist/forge-payload/meta/workflows/meta-commit.md +5 -6
- package/dist/forge-payload/meta/workflows/meta-enhance.md +5 -5
- package/dist/forge-payload/meta/workflows/meta-fix-bug.md +35 -11
- package/dist/forge-payload/meta/workflows/meta-implement.md +15 -7
- package/dist/forge-payload/meta/workflows/meta-migrate.md +13 -14
- package/dist/forge-payload/meta/workflows/meta-new-sprint.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-orchestrate.md +138 -39
- package/dist/forge-payload/meta/workflows/meta-plan-sprint.md +6 -6
- package/dist/forge-payload/meta/workflows/meta-plan-task.md +12 -6
- package/dist/forge-payload/meta/workflows/meta-retro.md +4 -4
- package/dist/forge-payload/meta/workflows/meta-retrospective.md +4 -4
- package/dist/forge-payload/meta/workflows/meta-review-implementation.md +12 -11
- package/dist/forge-payload/meta/workflows/meta-review-plan.md +12 -11
- package/dist/forge-payload/meta/workflows/meta-review-sprint-completion.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-sprint-intake.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-sprint-plan.md +6 -6
- package/dist/forge-payload/meta/workflows/meta-update-implementation.md +2 -2
- package/dist/forge-payload/meta/workflows/meta-update-plan.md +2 -2
- package/dist/forge-payload/meta/workflows/meta-validate.md +9 -9
- package/dist/forge-payload/schemas/config.schema.json +2 -3
- package/dist/forge-payload/schemas/enum-catalog.json +2 -2
- package/dist/forge-payload/schemas/event.schema.json +16 -0
- package/dist/forge-payload/schemas/structure-manifest.json +75 -73
- package/dist/forge-payload/skills/refresh-kb-links/SKILL.md +14 -7
- package/dist/forge-payload/tools/banners.cjs +29 -10
- package/dist/forge-payload/tools/check-structure.cjs +88 -7
- package/dist/forge-payload/tools/collate.cjs +48 -2
- package/dist/forge-payload/tools/manage-config.cjs +5 -7
- package/dist/forge-payload/tools/parse-gates.cjs +73 -1
- package/dist/forge-payload/tools/postflight-gate.cjs +298 -0
- package/dist/forge-payload/tools/preflight-gate.cjs +47 -0
- package/dist/forge-payload/tools/substitute-placeholders.cjs +5 -4
- package/dist/forge-payload/tools/verify-phase.cjs +17 -0
- package/package.json +2 -2
- package/dist/bin/forgecli.d.ts +0 -2
- package/dist/bin/forgecli.js +0 -6
- package/dist/bin/forgecli.js.map +0 -1
- package/dist/extensions/forgecli/config-tui/index.d.ts +0 -5
- package/dist/extensions/forgecli/config-tui/index.js +0 -5
- package/dist/extensions/forgecli/config-tui/index.js.map +0 -1
- package/dist/extensions/forgecli/loaders/persona-skill-loader.d.ts +0 -45
- package/dist/extensions/forgecli/loaders/persona-skill-loader.js +0 -227
- package/dist/extensions/forgecli/loaders/persona-skill-loader.js.map +0 -1
- package/dist/extensions/forgecli/loaders/template-render.d.ts +0 -20
- package/dist/extensions/forgecli/loaders/template-render.js +0 -85
- package/dist/extensions/forgecli/loaders/template-render.js.map +0 -1
- package/dist/extensions/forgecli/loaders/workflow-loader.d.ts +0 -41
- package/dist/extensions/forgecli/loaders/workflow-loader.js +0 -164
- package/dist/extensions/forgecli/loaders/workflow-loader.js.map +0 -1
- package/dist/forge-payload/.base-pack/workflows/fix_bug.md +0 -446
- package/dist/forge-payload/.base-pack/workflows/orchestrate_task.md +0 -928
- package/dist/forge-payload/.base-pack/workflows/run_sprint.md +0 -225
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
// dashboard/component.ts — Two-panel orchestrator tree dashboard.
|
|
2
|
+
//
|
|
3
|
+
// Renders as a pi-tui overlay (ctx.ui.custom) with:
|
|
4
|
+
// Left panel: tree browser with depth-based indentation, status glyphs,
|
|
5
|
+
// progress labels, and cursor highlighting.
|
|
6
|
+
// Right panel: detail view of the selected node — status, model, metrics,
|
|
7
|
+
// prompt preview, tail-buffer activity, outcome, and children.
|
|
8
|
+
//
|
|
9
|
+
// Keyboard:
|
|
10
|
+
// ↑/↓ move cursor in tree
|
|
11
|
+
// → expand orchestrator / focus leaf detail
|
|
12
|
+
// ← collapse orchestrator / back to tree
|
|
13
|
+
// Enter toggle expand on orchestrator; focus detail on leaf
|
|
14
|
+
// Esc close overlay
|
|
15
|
+
// x request cancellation (with y/n confirm)
|
|
16
|
+
// p (reserved for pause)
|
|
17
|
+
//
|
|
18
|
+
// Architecture (post-MVC-refactor, dashboard-mvc-audit.md V1–V7):
|
|
19
|
+
// DashboardController owns the TreeViewModel, subscribes to model events,
|
|
20
|
+
// manages the refresh timer, and handles input. DashboardComponent reads
|
|
21
|
+
// only from the controller — never directly from the OrchestratorTree model.
|
|
22
|
+
// This eliminates the boundary violations catalogued in the audit.
|
|
23
|
+
//
|
|
24
|
+
// Iron Laws conformance:
|
|
25
|
+
// IL1 — All visible strings route through dashboard/theme.ts helpers or
|
|
26
|
+
// theme.fg()/bg()/bold(). No raw glyphs.
|
|
27
|
+
// IL2 — Dual-layer width safety: screen renderers call truncateLines(),
|
|
28
|
+
// orchestrator applies truncateToWidth per line.
|
|
29
|
+
// IL3 — DashboardComponent implements Focusable (focused: boolean = false).
|
|
30
|
+
// IL7 — Timer unmount-safety: disposed flag guards interval callbacks.
|
|
31
|
+
import { matchesKey, Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
32
|
+
import { getSessionRegistry } from "../session-registry.js";
|
|
33
|
+
import { buildViewModel } from "./view-model.js";
|
|
34
|
+
import { fmtTokenMeter } from "../viewport-renderer.js";
|
|
35
|
+
import { paintTailLine } from "../viewport-theme.js";
|
|
36
|
+
import { cursor, nodeGlyph, statusLabel, dim, accentBold, warn, bold, border, collapseIndicator, promptExpandIcon, truncateLines, } from "./theme.js";
|
|
37
|
+
// ── Word-wrap helper ───────────────────────────────────────────────────────────
|
|
38
|
+
//
|
|
39
|
+
// Splits a line (which may contain ANSI escape sequences) into multiple
|
|
40
|
+
// lines at word boundaries so it fits within `maxWidth` visible columns.
|
|
41
|
+
// Preserves ANSI styling across wrapped lines. Falls back to character-level
|
|
42
|
+
// truncation for individual tokens wider than maxWidth.
|
|
43
|
+
function wrapLine(line, maxWidth) {
|
|
44
|
+
if (maxWidth <= 0)
|
|
45
|
+
return [line];
|
|
46
|
+
const visW = visibleWidth(line);
|
|
47
|
+
if (visW <= maxWidth)
|
|
48
|
+
return [line];
|
|
49
|
+
const segments = [];
|
|
50
|
+
const ANSI_RE = /(\x1b\[[0-9;]*m|\x1b\].*?(?:\x07|\x1b\\)|\x1b\[[^m]*m)/g;
|
|
51
|
+
let currentPrefix = "";
|
|
52
|
+
let lastIdx = 0;
|
|
53
|
+
let m;
|
|
54
|
+
while ((m = ANSI_RE.exec(line)) !== null) {
|
|
55
|
+
if (m.index > lastIdx) {
|
|
56
|
+
segments.push({ prefix: currentPrefix, text: line.slice(lastIdx, m.index) });
|
|
57
|
+
}
|
|
58
|
+
currentPrefix += m[0];
|
|
59
|
+
lastIdx = m.index + m[0].length;
|
|
60
|
+
}
|
|
61
|
+
if (lastIdx < line.length) {
|
|
62
|
+
segments.push({ prefix: currentPrefix, text: line.slice(lastIdx) });
|
|
63
|
+
}
|
|
64
|
+
if (segments.length === 0) {
|
|
65
|
+
return [truncateToWidth(line, maxWidth)];
|
|
66
|
+
}
|
|
67
|
+
const tokens = [];
|
|
68
|
+
for (const seg of segments) {
|
|
69
|
+
const words = seg.text.split(/\s+/);
|
|
70
|
+
for (const w of words) {
|
|
71
|
+
if (w.length === 0)
|
|
72
|
+
continue;
|
|
73
|
+
tokens.push({ prefix: seg.prefix, text: w, visLen: visibleWidth(w) });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Greedy line-fill: accumulate tokens with single-space gaps until the
|
|
77
|
+
// next token would exceed maxWidth, then start a new line.
|
|
78
|
+
const lines = [];
|
|
79
|
+
let curLine = "";
|
|
80
|
+
let curLen = 0;
|
|
81
|
+
for (const tok of tokens) {
|
|
82
|
+
const gapLen = curLen > 0 ? 1 : 0; // one space between tokens
|
|
83
|
+
const addedLen = tok.visLen + gapLen;
|
|
84
|
+
if (curLen > 0 && curLen + addedLen > maxWidth) {
|
|
85
|
+
// Emit current line and start fresh
|
|
86
|
+
if (curLine.length > 0)
|
|
87
|
+
lines.push(curLine);
|
|
88
|
+
curLine = "";
|
|
89
|
+
curLen = 0;
|
|
90
|
+
}
|
|
91
|
+
if (curLen === 0) {
|
|
92
|
+
curLine = tok.prefix + tok.text;
|
|
93
|
+
curLen = tok.visLen;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
curLine += " " + tok.text;
|
|
97
|
+
curLen += 1 + tok.visLen;
|
|
98
|
+
}
|
|
99
|
+
// If a single token is wider than maxWidth, hard-break via truncation.
|
|
100
|
+
if (curLen > maxWidth) {
|
|
101
|
+
const truncated = truncateToWidth(curLine, maxWidth);
|
|
102
|
+
lines.push(truncated);
|
|
103
|
+
curLine = "";
|
|
104
|
+
curLen = 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (curLen > 0) {
|
|
108
|
+
lines.push(curLine);
|
|
109
|
+
}
|
|
110
|
+
return lines.length > 0 ? lines : [line];
|
|
111
|
+
}
|
|
112
|
+
// ── Refresh timer ───────────────────────────────────────────────────────────
|
|
113
|
+
const REFRESH_INTERVAL_MS = 1000;
|
|
114
|
+
// ── Controller ──────────────────────────────────────────────────────────────
|
|
115
|
+
export class DashboardController {
|
|
116
|
+
tree;
|
|
117
|
+
vm;
|
|
118
|
+
state;
|
|
119
|
+
onInvalidate;
|
|
120
|
+
refreshTimer;
|
|
121
|
+
disposed = false; // IL7: guards interval callbacks after dispose
|
|
122
|
+
_handlers;
|
|
123
|
+
constructor(tree, initialCursorId) {
|
|
124
|
+
this.tree = tree;
|
|
125
|
+
this.vm = buildViewModel(tree);
|
|
126
|
+
// Default cursor to the first active root, or empty string if tree is empty.
|
|
127
|
+
const firstVisible = this.vm.roots.length > 0 ? this.vm.roots[0] : "";
|
|
128
|
+
this.state = {
|
|
129
|
+
cursorId: initialCursorId ?? firstVisible,
|
|
130
|
+
expanded: new Set(),
|
|
131
|
+
focusPanel: "tree",
|
|
132
|
+
promptExpanded: false,
|
|
133
|
+
cancelTargetId: null,
|
|
134
|
+
detailScroll: 0,
|
|
135
|
+
};
|
|
136
|
+
this._handlers = [];
|
|
137
|
+
// V2 fix: subscriptions now owned by controller, not view.
|
|
138
|
+
// On every model event, rebuild VM then invalidate the view.
|
|
139
|
+
const onModelChange = () => {
|
|
140
|
+
this.rebuildViewModel();
|
|
141
|
+
if (!this.disposed)
|
|
142
|
+
this.onInvalidate?.();
|
|
143
|
+
};
|
|
144
|
+
const onTreeChange = (id) => {
|
|
145
|
+
this.rebuildViewModel();
|
|
146
|
+
this.autoExpandNewNode(id);
|
|
147
|
+
if (!this.disposed)
|
|
148
|
+
this.onInvalidate?.();
|
|
149
|
+
};
|
|
150
|
+
this.tree.on("change", onModelChange);
|
|
151
|
+
this.tree.on("tail", onModelChange);
|
|
152
|
+
this.tree.on("preview", onModelChange);
|
|
153
|
+
this.tree.on("tree", onTreeChange);
|
|
154
|
+
this._handlers = [
|
|
155
|
+
{ event: "change", handler: onModelChange },
|
|
156
|
+
{ event: "tail", handler: onModelChange },
|
|
157
|
+
{ event: "preview", handler: onModelChange },
|
|
158
|
+
{ event: "tree", handler: onTreeChange },
|
|
159
|
+
];
|
|
160
|
+
// V3 fix: timer now owned by controller, not view.
|
|
161
|
+
this.ensureRefreshTimer();
|
|
162
|
+
}
|
|
163
|
+
setOnInvalidate(cb) {
|
|
164
|
+
this.onInvalidate = cb;
|
|
165
|
+
}
|
|
166
|
+
// ── ViewModel projection ────────────────────────────────────────────────
|
|
167
|
+
/** Rebuild the ViewModel from the live model. Called on every model event. */
|
|
168
|
+
rebuildViewModel() {
|
|
169
|
+
this.vm = buildViewModel(this.tree);
|
|
170
|
+
}
|
|
171
|
+
/** Get a node from the current ViewModel. */
|
|
172
|
+
getNode(id) {
|
|
173
|
+
return this.vm.nodes.get(id);
|
|
174
|
+
}
|
|
175
|
+
/** Get a node's children from the current ViewModel. */
|
|
176
|
+
getChildren(id) {
|
|
177
|
+
const node = this.vm.nodes.get(id);
|
|
178
|
+
if (!node)
|
|
179
|
+
return [];
|
|
180
|
+
return node.children
|
|
181
|
+
.map((cid) => this.vm.nodes.get(cid))
|
|
182
|
+
.filter((n) => n !== undefined);
|
|
183
|
+
}
|
|
184
|
+
/** Get the progress (completed/total leaves) for a subtree. */
|
|
185
|
+
getSubtreeProgress(id) {
|
|
186
|
+
let completed = 0;
|
|
187
|
+
let total = 0;
|
|
188
|
+
const stack = [id];
|
|
189
|
+
while (stack.length > 0) {
|
|
190
|
+
const current = stack.pop();
|
|
191
|
+
const node = this.vm.nodes.get(current);
|
|
192
|
+
if (!node)
|
|
193
|
+
continue;
|
|
194
|
+
if (node.kind === "leaf") {
|
|
195
|
+
total++;
|
|
196
|
+
if (node.status === "completed")
|
|
197
|
+
completed++;
|
|
198
|
+
}
|
|
199
|
+
stack.push(...node.children);
|
|
200
|
+
}
|
|
201
|
+
return { completed, total };
|
|
202
|
+
}
|
|
203
|
+
/** Aggregate token usage across all active roots. */
|
|
204
|
+
getAggregateUsage() {
|
|
205
|
+
const agg = { input: 0, output: 0, cacheRead: 0 };
|
|
206
|
+
for (const rootId of this.vm.roots) {
|
|
207
|
+
const stack = [rootId];
|
|
208
|
+
while (stack.length > 0) {
|
|
209
|
+
const current = stack.pop();
|
|
210
|
+
const node = this.vm.nodes.get(current);
|
|
211
|
+
if (!node)
|
|
212
|
+
continue;
|
|
213
|
+
agg.input += node.usage.input;
|
|
214
|
+
agg.output += node.usage.output;
|
|
215
|
+
agg.cacheRead += node.usage.cacheRead;
|
|
216
|
+
stack.push(...node.children);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return agg;
|
|
220
|
+
}
|
|
221
|
+
/** Aggregate compression across all active roots. */
|
|
222
|
+
getAggregateCompression() {
|
|
223
|
+
const agg = { calls: 0, tokensSaved: 0 };
|
|
224
|
+
for (const rootId of this.vm.roots) {
|
|
225
|
+
const stack = [rootId];
|
|
226
|
+
while (stack.length > 0) {
|
|
227
|
+
const current = stack.pop();
|
|
228
|
+
const node = this.vm.nodes.get(current);
|
|
229
|
+
if (!node)
|
|
230
|
+
continue;
|
|
231
|
+
if (node.compression) {
|
|
232
|
+
agg.calls += node.compression.calls;
|
|
233
|
+
agg.tokensSaved += node.compression.tokensSaved;
|
|
234
|
+
}
|
|
235
|
+
stack.push(...node.children);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return agg;
|
|
239
|
+
}
|
|
240
|
+
/** Model/provider from the first running root, if any. */
|
|
241
|
+
getActiveModel() {
|
|
242
|
+
for (const rootId of this.vm.roots) {
|
|
243
|
+
const node = this.vm.nodes.get(rootId);
|
|
244
|
+
if (node && node.model) {
|
|
245
|
+
return { provider: node.provider, model: node.model };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
/** Check whether any active root is running or cancelling. */
|
|
251
|
+
hasRunningNodes() {
|
|
252
|
+
for (const rootId of this.vm.roots) {
|
|
253
|
+
const node = this.vm.nodes.get(rootId);
|
|
254
|
+
if (node && (node.status === "running" || node.status === "cancelling")) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
// ── Visible node list (DFS respecting expand state) ────────────────────
|
|
261
|
+
getVisibleNodes() {
|
|
262
|
+
const result = [];
|
|
263
|
+
const visit = (id) => {
|
|
264
|
+
const node = this.vm.nodes.get(id);
|
|
265
|
+
if (!node)
|
|
266
|
+
return;
|
|
267
|
+
result.push(id);
|
|
268
|
+
if (node.kind === "orchestrator" && this.state.expanded.has(id)) {
|
|
269
|
+
for (const childId of node.children) {
|
|
270
|
+
visit(childId);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
for (const rootId of this.vm.roots) {
|
|
275
|
+
visit(rootId);
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
// ── Input handling ──────────────────────────────────────────────────────
|
|
280
|
+
handleInput(data) {
|
|
281
|
+
// Cancel confirmation mode takes priority.
|
|
282
|
+
if (this.state.cancelTargetId) {
|
|
283
|
+
this.handleCancelConfirm(data);
|
|
284
|
+
if (!this.disposed)
|
|
285
|
+
this.onInvalidate?.();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
switch (this.state.focusPanel) {
|
|
289
|
+
case "tree":
|
|
290
|
+
this.handleTreeInput(data);
|
|
291
|
+
break;
|
|
292
|
+
case "detail":
|
|
293
|
+
this.handleDetailInput(data);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
if (!this.disposed)
|
|
297
|
+
this.onInvalidate?.();
|
|
298
|
+
}
|
|
299
|
+
handleTreeInput(data) {
|
|
300
|
+
if (matchesKey(data, Key.up)) {
|
|
301
|
+
this.moveCursor(-1);
|
|
302
|
+
}
|
|
303
|
+
else if (matchesKey(data, Key.down)) {
|
|
304
|
+
this.moveCursor(1);
|
|
305
|
+
}
|
|
306
|
+
else if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
|
|
307
|
+
this.activateCursor();
|
|
308
|
+
}
|
|
309
|
+
else if (matchesKey(data, Key.left)) {
|
|
310
|
+
this.collapseCursor();
|
|
311
|
+
}
|
|
312
|
+
else if (data === "x") {
|
|
313
|
+
this.startCancel();
|
|
314
|
+
}
|
|
315
|
+
// ESC in tree panel is handled in handleInput above — it closes
|
|
316
|
+
// the overlay rather than navigating or setting a sentinel.
|
|
317
|
+
}
|
|
318
|
+
handleDetailInput(data) {
|
|
319
|
+
// ESC in detail panel is handled in handleInput above — it closes
|
|
320
|
+
// the overlay rather than just switching panels.
|
|
321
|
+
if (matchesKey(data, Key.left)) {
|
|
322
|
+
this.state.focusPanel = "tree";
|
|
323
|
+
this.state.detailScroll = 0;
|
|
324
|
+
}
|
|
325
|
+
else if (matchesKey(data, Key.down) || data === "j" || data === "J") {
|
|
326
|
+
this.state.detailScroll++;
|
|
327
|
+
}
|
|
328
|
+
else if (matchesKey(data, Key.up) || data === "k" || data === "K") {
|
|
329
|
+
this.state.detailScroll = Math.max(0, this.state.detailScroll - 1);
|
|
330
|
+
}
|
|
331
|
+
else if (matchesKey(data, Key.enter)) {
|
|
332
|
+
this.state.promptExpanded = !this.state.promptExpanded;
|
|
333
|
+
}
|
|
334
|
+
else if (data === "x") {
|
|
335
|
+
this.startCancel();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
handleCancelConfirm(data) {
|
|
339
|
+
if (data === "y" || matchesKey(data, Key.enter)) {
|
|
340
|
+
if (this.state.cancelTargetId) {
|
|
341
|
+
this.cancelNodeAndSessions(this.state.cancelTargetId);
|
|
342
|
+
}
|
|
343
|
+
this.state.cancelTargetId = null;
|
|
344
|
+
}
|
|
345
|
+
else if (data === "n") {
|
|
346
|
+
this.state.cancelTargetId = null;
|
|
347
|
+
}
|
|
348
|
+
// All other keys consumed silently in cancel mode. ESC is handled
|
|
349
|
+
// by the view's handleInput (closes the overlay entirely).
|
|
350
|
+
}
|
|
351
|
+
/** Cancel the node in the OrchestratorTree for immediate visual
|
|
352
|
+
* feedback, and propagate cancellation through SessionRegistry
|
|
353
|
+
* for actual pipeline abort. Reads from VM; writes to model. */
|
|
354
|
+
cancelNodeAndSessions(nodeId) {
|
|
355
|
+
// Mutation: mark node as "cancelling" in the model for immediate
|
|
356
|
+
// visual feedback. The model emits "change", which triggers a VM
|
|
357
|
+
// rebuild via the controller's subscription.
|
|
358
|
+
this.tree.requestCancel(nodeId);
|
|
359
|
+
const registry = getSessionRegistry();
|
|
360
|
+
// Walk up the tree to find the session (task-level) node in the registry.
|
|
361
|
+
// Reads from the VM (pre-rebuild stale state is fine — parentId doesn't
|
|
362
|
+
// change on cancel, and we re-read from VM).
|
|
363
|
+
let currentId = nodeId;
|
|
364
|
+
while (currentId) {
|
|
365
|
+
const session = registry.getSession(currentId);
|
|
366
|
+
if (session && (session.status === "running" || session.status === "cancelling")) {
|
|
367
|
+
registry.requestCancel(currentId);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const node = this.vm.nodes.get(currentId);
|
|
371
|
+
currentId = node?.parentId ?? null;
|
|
372
|
+
}
|
|
373
|
+
// No session found for this node or ancestors. If this is a
|
|
374
|
+
// sprint-level orchestrator, cancel all running child sessions.
|
|
375
|
+
const node = this.vm.nodes.get(nodeId);
|
|
376
|
+
if (node?.kind === "orchestrator") {
|
|
377
|
+
for (const childId of node.children) {
|
|
378
|
+
const session = registry.getSession(childId);
|
|
379
|
+
if (session && (session.status === "running" || session.status === "cancelling")) {
|
|
380
|
+
registry.requestCancel(childId);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
moveCursor(delta) {
|
|
386
|
+
const visible = this.getVisibleNodes();
|
|
387
|
+
if (visible.length === 0)
|
|
388
|
+
return;
|
|
389
|
+
const idx = visible.indexOf(this.state.cursorId);
|
|
390
|
+
const next = Math.max(0, Math.min(visible.length - 1, idx + delta));
|
|
391
|
+
this.state.cursorId = visible[next];
|
|
392
|
+
// Auto-expand ancestors so the cursor is always visible.
|
|
393
|
+
this.ensureAncestorsExpanded(this.state.cursorId);
|
|
394
|
+
}
|
|
395
|
+
activateCursor() {
|
|
396
|
+
const node = this.vm.nodes.get(this.state.cursorId);
|
|
397
|
+
if (!node)
|
|
398
|
+
return;
|
|
399
|
+
if (node.kind === "orchestrator") {
|
|
400
|
+
this.toggleExpand(node.id);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
this.state.focusPanel = "detail";
|
|
404
|
+
this.state.detailScroll = 0;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
toggleExpand(id) {
|
|
408
|
+
if (this.state.expanded.has(id)) {
|
|
409
|
+
this.state.expanded.delete(id);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
this.state.expanded.add(id);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
collapseCursor() {
|
|
416
|
+
const node = this.vm.nodes.get(this.state.cursorId);
|
|
417
|
+
if (!node)
|
|
418
|
+
return;
|
|
419
|
+
if (node.kind === "orchestrator" && this.state.expanded.has(node.id)) {
|
|
420
|
+
this.state.expanded.delete(node.id);
|
|
421
|
+
}
|
|
422
|
+
else if (node.parentId) {
|
|
423
|
+
// Move cursor to parent.
|
|
424
|
+
this.state.cursorId = node.parentId;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
startCancel() {
|
|
428
|
+
const node = this.vm.nodes.get(this.state.cursorId);
|
|
429
|
+
if (!node)
|
|
430
|
+
return;
|
|
431
|
+
// Can cancel any node under a running subtree — find the nearest
|
|
432
|
+
// cancellable ancestor (running or cancelling).
|
|
433
|
+
let target = null;
|
|
434
|
+
let current = node;
|
|
435
|
+
while (current) {
|
|
436
|
+
if (current.status === "running" || current.status === "cancelling") {
|
|
437
|
+
target = current;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
current = current.parentId ? this.vm.nodes.get(current.parentId) : undefined;
|
|
441
|
+
}
|
|
442
|
+
if (target) {
|
|
443
|
+
this.state.cancelTargetId = target.id;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
ensureAncestorsExpanded(id) {
|
|
447
|
+
let currentId = id;
|
|
448
|
+
while (currentId) {
|
|
449
|
+
const node = this.vm.nodes.get(currentId);
|
|
450
|
+
if (!node)
|
|
451
|
+
break;
|
|
452
|
+
if (node.kind === "orchestrator") {
|
|
453
|
+
this.state.expanded.add(currentId);
|
|
454
|
+
}
|
|
455
|
+
currentId = node.parentId;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// ── Auto-expand: only expand the root and the direct parent of a new
|
|
459
|
+
// leaf node (phase). Intermediate orchestrator nodes (tasks) are NOT
|
|
460
|
+
// auto-expanded — the user expands them manually. This prevents the tree
|
|
461
|
+
// from exploding into a fully-expanded state on every new node.
|
|
462
|
+
autoExpandNewNode(id) {
|
|
463
|
+
const node = this.vm.nodes.get(id);
|
|
464
|
+
if (!node)
|
|
465
|
+
return;
|
|
466
|
+
// Auto-expand the root level only: if this node IS a root (no parent),
|
|
467
|
+
// expand it so it's visible. If this node is a leaf (phase), expand
|
|
468
|
+
// only its direct parent (the task). Intermediate orchestrators
|
|
469
|
+
// (tasks under a sprint) are NOT auto-expanded.
|
|
470
|
+
if (node.kind === "leaf" && node.parentId) {
|
|
471
|
+
// Leaf node (phase): expand its parent so the phase is visible,
|
|
472
|
+
// but only if the parent is already visible (i.e., the user
|
|
473
|
+
// has expanded the root).
|
|
474
|
+
const parent = this.vm.nodes.get(node.parentId);
|
|
475
|
+
if (parent && this.state.expanded.has(node.parentId) && parent.kind === "orchestrator") {
|
|
476
|
+
// Parent is visible and is an orchestrator — leaf will show.
|
|
477
|
+
// No need to expand further.
|
|
478
|
+
}
|
|
479
|
+
// Also ensure ancestors of the leaf are expanded so it's visible.
|
|
480
|
+
this.ensureAncestorsExpanded(node.id);
|
|
481
|
+
}
|
|
482
|
+
else if (!node.parentId) {
|
|
483
|
+
// Root node (sprint-level orchestrator): always expand.
|
|
484
|
+
this.state.expanded.add(id);
|
|
485
|
+
}
|
|
486
|
+
// Default cursor to the newest running node if no cursor set.
|
|
487
|
+
if (!this.state.cursorId || this.state.cursorId === "") {
|
|
488
|
+
this.state.cursorId = id;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// ── Refresh timer (V3 fix: owned by controller, not view) ──────────────
|
|
492
|
+
ensureRefreshTimer() {
|
|
493
|
+
if (this.refreshTimer)
|
|
494
|
+
return;
|
|
495
|
+
this.refreshTimer = setInterval(() => {
|
|
496
|
+
// IL7: guard against firing after dispose.
|
|
497
|
+
if (this.disposed) {
|
|
498
|
+
this.stopRefreshTimer();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (!this.hasRunningNodes()) {
|
|
502
|
+
// One last render to settle final frame, then stop timer.
|
|
503
|
+
this.onInvalidate?.();
|
|
504
|
+
this.stopRefreshTimer();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
this.onInvalidate?.();
|
|
508
|
+
}, REFRESH_INTERVAL_MS);
|
|
509
|
+
}
|
|
510
|
+
stopRefreshTimer() {
|
|
511
|
+
if (this.refreshTimer) {
|
|
512
|
+
clearInterval(this.refreshTimer);
|
|
513
|
+
this.refreshTimer = undefined;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// ── Cleanup ────────────────────────────────────────────────────────────
|
|
517
|
+
dispose() {
|
|
518
|
+
this.disposed = true; // IL7: set before clearing timer so callback sees it
|
|
519
|
+
this.stopRefreshTimer();
|
|
520
|
+
for (const { event, handler } of this._handlers) {
|
|
521
|
+
this.tree.off(event, handler);
|
|
522
|
+
}
|
|
523
|
+
this._handlers = [];
|
|
524
|
+
}
|
|
525
|
+
getState() {
|
|
526
|
+
return this.state;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// ── View ────────────────────────────────────────────────────────────────────
|
|
530
|
+
export class DashboardComponent {
|
|
531
|
+
/** IL3: Focusable — pi sets this to true when the overlay has
|
|
532
|
+
* keyboard focus. Without this, arrow-key and Escape events are
|
|
533
|
+
* swallowed at the overlay layer. (Lesson from config-TUI commit 07e886f.) */
|
|
534
|
+
focused = false;
|
|
535
|
+
controller;
|
|
536
|
+
theme;
|
|
537
|
+
tui;
|
|
538
|
+
done;
|
|
539
|
+
constructor(controller, tui, theme, done) {
|
|
540
|
+
this.controller = controller;
|
|
541
|
+
this.tui = tui;
|
|
542
|
+
this.theme = theme;
|
|
543
|
+
this.done = done;
|
|
544
|
+
// V2 fix: subscriptions are now owned by the controller.
|
|
545
|
+
// The view only registers an invalidation callback for re-rendering.
|
|
546
|
+
this.controller.setOnInvalidate(() => this.tui.requestRender());
|
|
547
|
+
}
|
|
548
|
+
// ── Component interface ──────────────────────────────────────────────────
|
|
549
|
+
render(width) {
|
|
550
|
+
// Layout: left panel ~20% (min 22), right panel fills rest, borders.
|
|
551
|
+
const leftWidth = Math.max(22, Math.floor(width * 0.20));
|
|
552
|
+
const separatorWidth = 1;
|
|
553
|
+
const rightWidth = Math.max(20, width - leftWidth - separatorWidth - 3);
|
|
554
|
+
const contentWidth = width - 2; // minus left and right border chars
|
|
555
|
+
const visible = this.controller.getVisibleNodes();
|
|
556
|
+
const state = this.controller.getState();
|
|
557
|
+
const selectedNode = this.controller.getNode(state.cursorId);
|
|
558
|
+
// ── Left panel: tree browser ───────────────────────────────────
|
|
559
|
+
const leftLines = this.renderTreePanel(visible, state, leftWidth);
|
|
560
|
+
// ── Right panel: detail ─────────────────────────────────────────
|
|
561
|
+
const rightLines = selectedNode
|
|
562
|
+
? this.renderDetailPanel(selectedNode, rightWidth)
|
|
563
|
+
: [dim("Select a node in the tree", this.theme)];
|
|
564
|
+
// ── Compose ─────────────────────────────────────────────────────
|
|
565
|
+
const termHeight = this.tui.terminal.rows;
|
|
566
|
+
// Deduct 3 lines for top border, bottom border, and hints footer
|
|
567
|
+
const contentHeight = Math.max(8, termHeight - 3);
|
|
568
|
+
// Compute detail panel scroll with auto-scrolling fallback for tree focus
|
|
569
|
+
let activeScroll = state.detailScroll;
|
|
570
|
+
if (state.focusPanel === "tree") {
|
|
571
|
+
// Auto-scroll to the bottom so live dispatches are visible immediately
|
|
572
|
+
activeScroll = Math.max(0, rightLines.length - contentHeight);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
// Clamp user manual scroll within valid bounds
|
|
576
|
+
const maxScroll = Math.max(0, rightLines.length - contentHeight);
|
|
577
|
+
if (activeScroll > maxScroll) {
|
|
578
|
+
activeScroll = maxScroll;
|
|
579
|
+
state.detailScroll = maxScroll; // sync controller state
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const paddedLeftLines = [];
|
|
583
|
+
for (let i = 0; i < contentHeight; i++) {
|
|
584
|
+
paddedLeftLines.push(leftLines[i] ?? "");
|
|
585
|
+
}
|
|
586
|
+
const paddedRightLines = [];
|
|
587
|
+
for (let i = 0; i < contentHeight; i++) {
|
|
588
|
+
paddedRightLines.push(rightLines[i + activeScroll] ?? "");
|
|
589
|
+
}
|
|
590
|
+
const lines = [];
|
|
591
|
+
// ── Header ──────────────────────────────────────────────────────
|
|
592
|
+
const headerText = ` Orchestrator Dashboard `;
|
|
593
|
+
const headerPad = contentWidth - visibleWidth(border("", this.theme)) - visibleWidth(accentBold(headerText, this.theme));
|
|
594
|
+
lines.push(border("╭", this.theme) +
|
|
595
|
+
accentBold(headerText, this.theme) +
|
|
596
|
+
border("─".repeat(Math.max(0, headerPad)), this.theme) +
|
|
597
|
+
border("╮", this.theme));
|
|
598
|
+
for (let i = 0; i < contentHeight; i++) {
|
|
599
|
+
const left = truncateToWidth(paddedLeftLines[i], leftWidth);
|
|
600
|
+
const right = truncateToWidth(paddedRightLines[i], rightWidth);
|
|
601
|
+
const lPad = leftWidth - visibleWidth(left);
|
|
602
|
+
const rPad = rightWidth - visibleWidth(right);
|
|
603
|
+
lines.push(border("│", this.theme) +
|
|
604
|
+
left +
|
|
605
|
+
" ".repeat(Math.max(0, lPad)) +
|
|
606
|
+
border("│", this.theme) +
|
|
607
|
+
" " +
|
|
608
|
+
right +
|
|
609
|
+
" ".repeat(Math.max(0, rPad)) +
|
|
610
|
+
border("│", this.theme));
|
|
611
|
+
}
|
|
612
|
+
// ── Footer: bottom border + key hints + model/token meter ────────
|
|
613
|
+
const hintsBase = state.cancelTargetId
|
|
614
|
+
? " y confirm · n dismiss · esc close"
|
|
615
|
+
: " ↑↓ nav · → expand · ← back · ⏎ focus · x cancel · esc close";
|
|
616
|
+
lines.push(border("╰", this.theme) + border("─".repeat(contentWidth), this.theme) + border("╯", this.theme));
|
|
617
|
+
// Aggregate model + token footer (mirrors ViewportFooterComponent).
|
|
618
|
+
const aggUsage = this.controller.getAggregateUsage();
|
|
619
|
+
const aggCompression = this.controller.getAggregateCompression();
|
|
620
|
+
const activeModel = this.controller.getActiveModel();
|
|
621
|
+
const meter = fmtTokenMeter(aggUsage);
|
|
622
|
+
const compSuffix = aggCompression.tokensSaved > 0 ? ` ⇌${aggCompression.tokensSaved}t` : "";
|
|
623
|
+
const modelLabel = activeModel?.provider && activeModel?.model
|
|
624
|
+
? `${activeModel.provider} ${activeModel.model}`
|
|
625
|
+
: (activeModel?.provider ?? activeModel?.model ?? "");
|
|
626
|
+
let footerLine = "";
|
|
627
|
+
if (modelLabel && meter) {
|
|
628
|
+
footerLine = `${hintsBase} ${dim(modelLabel, this.theme)} Σ ${meter}${compSuffix}`;
|
|
629
|
+
}
|
|
630
|
+
else if (meter) {
|
|
631
|
+
footerLine = `${hintsBase} Σ ${meter}${compSuffix}`;
|
|
632
|
+
}
|
|
633
|
+
else if (modelLabel) {
|
|
634
|
+
footerLine = `${hintsBase} ${dim(modelLabel, this.theme)}`;
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
footerLine = hintsBase;
|
|
638
|
+
}
|
|
639
|
+
lines.push(dim(truncateToWidth(footerLine, width), this.theme));
|
|
640
|
+
// ── Overlay cancel confirmation on top of dashboard content ────
|
|
641
|
+
if (state.cancelTargetId) {
|
|
642
|
+
return this.overlayCancelConfirm(lines, width, state.cancelTargetId);
|
|
643
|
+
}
|
|
644
|
+
return lines;
|
|
645
|
+
}
|
|
646
|
+
handleInput(data) {
|
|
647
|
+
// ESC always closes the overlay, regardless of focus panel or
|
|
648
|
+
// cancel-confirmation state. This takes priority over the controller's
|
|
649
|
+
// ESC handling (which only dismissed cancel-confirm but didn't close).
|
|
650
|
+
if (matchesKey(data, Key.escape)) {
|
|
651
|
+
this.dispose();
|
|
652
|
+
this.done(null);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
this.controller.handleInput(data);
|
|
656
|
+
}
|
|
657
|
+
invalidate() {
|
|
658
|
+
// No cached render state — pure function of VM + controller state.
|
|
659
|
+
}
|
|
660
|
+
dispose() {
|
|
661
|
+
this.controller.dispose();
|
|
662
|
+
}
|
|
663
|
+
// ── Tree panel renderer ─────────────────────────────────────────────────
|
|
664
|
+
renderTreePanel(visibleIds, state, width) {
|
|
665
|
+
if (visibleIds.length === 0) {
|
|
666
|
+
return [dim("No session running", this.theme)];
|
|
667
|
+
}
|
|
668
|
+
const lines = [];
|
|
669
|
+
// Header
|
|
670
|
+
lines.push(accentBold(" Phases", this.theme));
|
|
671
|
+
for (const id of visibleIds) {
|
|
672
|
+
const node = this.controller.getNode(id);
|
|
673
|
+
if (!node)
|
|
674
|
+
continue;
|
|
675
|
+
const isCursor = id === state.cursorId;
|
|
676
|
+
const depth = node.depth;
|
|
677
|
+
const indent = " ".repeat(depth * 2);
|
|
678
|
+
// IL1: themed cursor and glyphs — no raw characters.
|
|
679
|
+
const cursorChar = cursor(isCursor, this.theme);
|
|
680
|
+
// Status glyph
|
|
681
|
+
const glyph = nodeGlyph(node.status, this.theme);
|
|
682
|
+
// Progress label for orchestrators
|
|
683
|
+
let label = node.label;
|
|
684
|
+
if (node.kind === "orchestrator") {
|
|
685
|
+
const prog = this.controller.getSubtreeProgress(id);
|
|
686
|
+
label += ` ${prog.completed}/${prog.total}`;
|
|
687
|
+
}
|
|
688
|
+
// Expand/collapse indicator — themed
|
|
689
|
+
const expandIndicator = node.kind === "orchestrator"
|
|
690
|
+
? state.expanded.has(id)
|
|
691
|
+
? " "
|
|
692
|
+
: collapseIndicator(this.theme)
|
|
693
|
+
: " ";
|
|
694
|
+
// Combine
|
|
695
|
+
const prefix = `${indent}${cursorChar} ${glyph} ${expandIndicator} `;
|
|
696
|
+
const styled = isCursor
|
|
697
|
+
? this.theme.bold(this.theme.fg("accent", `${prefix}${label}`))
|
|
698
|
+
: `${prefix}${label}`;
|
|
699
|
+
lines.push(truncateToWidth(styled, width));
|
|
700
|
+
}
|
|
701
|
+
// IL2: dual-layer width safety — first guard in screen renderer.
|
|
702
|
+
return truncateLines(lines, width);
|
|
703
|
+
}
|
|
704
|
+
renderDetailPanel(node, width) {
|
|
705
|
+
const lines = [];
|
|
706
|
+
// ── Header: label + status (wrap long model strings) ──────────────────
|
|
707
|
+
const statusLabelStr = statusLabel(node.status);
|
|
708
|
+
const modelPart = node.model ? ` · ${node.provider ?? ""} ${node.model}` : "";
|
|
709
|
+
lines.push(...wrapLine(`${nodeGlyph(node.status, this.theme)} ${bold(statusLabelStr, this.theme)}${dim(modelPart, this.theme)}`, width));
|
|
710
|
+
// ── Metrics line ────────────────────────────────────────────────
|
|
711
|
+
const metrics = this.formatMetrics(node);
|
|
712
|
+
if (metrics)
|
|
713
|
+
lines.push(...wrapLine(dim(metrics, this.theme), width));
|
|
714
|
+
lines.push("");
|
|
715
|
+
// ── Orchestrator node: list children ─────────────────────────────
|
|
716
|
+
if (node.kind === "orchestrator" && node.children.length > 0) {
|
|
717
|
+
const children = this.controller.getChildren(node.id);
|
|
718
|
+
lines.push(...wrapLine(dim(bold(`Agents · ${children.length}`, this.theme), this.theme), width));
|
|
719
|
+
for (const child of children) {
|
|
720
|
+
const cglyph = nodeGlyph(child.status, this.theme);
|
|
721
|
+
const cmodel = child.model ? ` ${child.provider ?? ""} ${child.model}` : "";
|
|
722
|
+
const cmetrics = this.formatMetrics(child);
|
|
723
|
+
const cmetricsPart = cmetrics ? ` ${cmetrics}` : "";
|
|
724
|
+
lines.push(...wrapLine(` ${cglyph} ${child.label}${dim(cmodel, this.theme)}${dim(cmetricsPart, this.theme)}`, width));
|
|
725
|
+
}
|
|
726
|
+
lines.push("");
|
|
727
|
+
}
|
|
728
|
+
// ── Prompt preview (expandable) ─────────────────────────────────
|
|
729
|
+
if (node.promptPreview) {
|
|
730
|
+
const expandIcon = promptExpandIcon(this.controller.getState().promptExpanded, this.theme);
|
|
731
|
+
const lineCount = node.promptPreview.split("\n").length;
|
|
732
|
+
lines.push(...wrapLine(dim(`${expandIcon} Prompt · ${lineCount} lines · ⏎ expand`, this.theme), width));
|
|
733
|
+
if (this.controller.getState().promptExpanded) {
|
|
734
|
+
for (const pline of node.promptPreview.split("\n").slice(0, 20)) {
|
|
735
|
+
// Indent wrapped prompt lines by 2 spaces
|
|
736
|
+
const wrapped = wrapLine(pline, Math.max(0, width - 4));
|
|
737
|
+
for (let i = 0; i < wrapped.length; i++) {
|
|
738
|
+
lines.push(dim(i === 0 ? ` ${wrapped[i]}` : ` ${wrapped[i]}`, this.theme));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (lineCount > 20) {
|
|
742
|
+
lines.push(dim(` … ${lineCount - 20} more lines`, this.theme));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
lines.push("");
|
|
746
|
+
}
|
|
747
|
+
// ── Activity: running full log of all turns ──────────────────────
|
|
748
|
+
if (node.kind === "leaf" && node.tailBuffer.length > 0) {
|
|
749
|
+
const total = node.tailBuffer.length;
|
|
750
|
+
lines.push(...wrapLine(dim(`Activity · ${total} log line${total === 1 ? "" : "s"}`, this.theme), width));
|
|
751
|
+
for (const tline of node.tailBuffer) {
|
|
752
|
+
const painted = paintTailLine(tline, this.theme);
|
|
753
|
+
lines.push(...wrapLine(painted, width));
|
|
754
|
+
}
|
|
755
|
+
lines.push("");
|
|
756
|
+
}
|
|
757
|
+
// ── Outcome ─────────────────────────────────────────────────────
|
|
758
|
+
if (node.outcomePreview) {
|
|
759
|
+
lines.push(dim("Outcome", this.theme));
|
|
760
|
+
for (const oline of node.outcomePreview.split("\n").slice(0, 8)) {
|
|
761
|
+
const wrapped = wrapLine(oline, Math.max(0, width - 4));
|
|
762
|
+
for (const wl of wrapped) {
|
|
763
|
+
lines.push(` ${wl}`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const lineCount = node.outcomePreview.split("\n").length;
|
|
767
|
+
if (lineCount > 8) {
|
|
768
|
+
lines.push(dim(` … ${lineCount - 8} more lines`, this.theme));
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
// IL2: dual-layer width safety — first guard in screen renderer.
|
|
772
|
+
return truncateLines(lines, width);
|
|
773
|
+
}
|
|
774
|
+
/** Render a cancel-confirmation dialog overlaid on the dashboard.
|
|
775
|
+
* The dashboard content stays visible above and below the dialog.
|
|
776
|
+
* The outer frame borders (│ on each side) are preserved across the
|
|
777
|
+
* dialog rows so the two-panel layout doesn't visually collapse.
|
|
778
|
+
* The dialog is centered and accepts y/Enter to confirm or n/Esc to
|
|
779
|
+
* dismiss. The overlay stays until the user decides — Esc only
|
|
780
|
+
* dismisses the cancel prompt, not the dashboard.
|
|
781
|
+
*/
|
|
782
|
+
overlayCancelConfirm(baseLines, width, targetId) {
|
|
783
|
+
const node = this.controller.getNode(targetId);
|
|
784
|
+
const label = node?.label ?? targetId;
|
|
785
|
+
// Content width is the full width minus the two outer │ borders.
|
|
786
|
+
const contentWidth = width - 2;
|
|
787
|
+
// Build the dialog box (narrower than the full content area so it
|
|
788
|
+
// floats with padding on each side).
|
|
789
|
+
const dialogW = Math.min(contentWidth, 60);
|
|
790
|
+
const diagLines = [];
|
|
791
|
+
const prompt = warn(`⚠ Cancel ${bold(label, this.theme)}?`, this.theme);
|
|
792
|
+
const actions = dim("y confirm · n dismiss · esc close", this.theme);
|
|
793
|
+
const promptW = visibleWidth(prompt);
|
|
794
|
+
const actionsW = visibleWidth(actions);
|
|
795
|
+
diagLines.push(border("╭", this.theme) + border("─".repeat(dialogW - 2), this.theme) + border("╮", this.theme));
|
|
796
|
+
diagLines.push(border("│", this.theme) + " ".repeat(dialogW - 2) + border("│", this.theme));
|
|
797
|
+
{
|
|
798
|
+
// Place prompt and actions side by side when they fit; stack otherwise.
|
|
799
|
+
const sideBySide = promptW + actionsW + 4 <= dialogW - 2;
|
|
800
|
+
if (sideBySide) {
|
|
801
|
+
const contentGap = Math.max(1, dialogW - 4 - promptW - actionsW);
|
|
802
|
+
const contentLine = border("│", this.theme) + " " + prompt + " ".repeat(contentGap) + actions + " " + border("│", this.theme);
|
|
803
|
+
if (visibleWidth(contentLine) < dialogW) {
|
|
804
|
+
// Pad content line to full dialog width.
|
|
805
|
+
const pad = dialogW - 2 - visibleWidth(contentLine.slice(1, -1));
|
|
806
|
+
diagLines.push(border("│", this.theme) +
|
|
807
|
+
" " + prompt + " ".repeat(contentGap + Math.max(0, pad)) + actions +
|
|
808
|
+
" " + border("│", this.theme));
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
diagLines.push(contentLine);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
// Stacked: prompt on one line, actions on the next.
|
|
816
|
+
const promptPad = Math.max(0, dialogW - 2 - 1 - promptW - 1);
|
|
817
|
+
diagLines.push(border("│", this.theme) + " " + prompt + " ".repeat(promptPad) + border("│", this.theme));
|
|
818
|
+
const actionsPad = Math.max(0, dialogW - 2 - 1 - actionsW - 1);
|
|
819
|
+
diagLines.push(border("│", this.theme) + " " + actions + " ".repeat(actionsPad) + border("│", this.theme));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
diagLines.push(border("│", this.theme) + " ".repeat(dialogW - 2) + border("│", this.theme));
|
|
823
|
+
diagLines.push(border("╰", this.theme) + border("─".repeat(dialogW - 2), this.theme) + border("╯", this.theme));
|
|
824
|
+
// Center the dialog vertically within the content rows and
|
|
825
|
+
// horizontally within the content area, preserving the outer │
|
|
826
|
+
// borders on every row so the frame stays intact.
|
|
827
|
+
const totalLines = baseLines.length;
|
|
828
|
+
const diagH = diagLines.length;
|
|
829
|
+
const startRow = Math.max(0, Math.floor((totalLines - diagH) / 2));
|
|
830
|
+
const leftPad = Math.max(0, Math.floor((contentWidth - dialogW) / 2));
|
|
831
|
+
const result = [...baseLines];
|
|
832
|
+
for (let i = 0; i < diagH && startRow + i < result.length; i++) {
|
|
833
|
+
const diagLine = diagLines[i];
|
|
834
|
+
const diagVisW = visibleWidth(diagLine);
|
|
835
|
+
const rightPad = Math.max(0, contentWidth - leftPad - diagVisW);
|
|
836
|
+
// Preserve the outer │ borders: left border + centered dialog + right border.
|
|
837
|
+
result[startRow + i] =
|
|
838
|
+
border("│", this.theme) + " ".repeat(leftPad) + diagLine + " ".repeat(rightPad) + border("│", this.theme);
|
|
839
|
+
}
|
|
840
|
+
return result;
|
|
841
|
+
}
|
|
842
|
+
formatMetrics(node) {
|
|
843
|
+
const parts = [];
|
|
844
|
+
if (node.usage.input || node.usage.output || node.usage.cacheRead) {
|
|
845
|
+
parts.push(fmtTokenMeter(node.usage));
|
|
846
|
+
}
|
|
847
|
+
if (node.metrics.toolCount) {
|
|
848
|
+
parts.push(`${node.metrics.toolCount} tool${node.metrics.toolCount === 1 ? "" : "s"}`);
|
|
849
|
+
}
|
|
850
|
+
if (node.metrics.errCount) {
|
|
851
|
+
parts.push(`${node.metrics.errCount} err`);
|
|
852
|
+
}
|
|
853
|
+
if (node.startedAt) {
|
|
854
|
+
const end = node.endedAt ?? Date.now();
|
|
855
|
+
const secs = Math.floor((end - node.startedAt) / 1000);
|
|
856
|
+
parts.push(secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${String(secs % 60).padStart(2, "0")}s`);
|
|
857
|
+
}
|
|
858
|
+
return parts.join(" · ");
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
//# sourceMappingURL=component.js.map
|