@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.
Files changed (183) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/dist/CHANGELOG-forge-plugin.md +211 -0
  3. package/dist/bin/forge.js +0 -0
  4. package/dist/extensions/forgecli/config-layer.js.map +1 -1
  5. package/dist/extensions/forgecli/context-governor-compaction.d.ts +83 -0
  6. package/dist/extensions/forgecli/context-governor-compaction.js +302 -0
  7. package/dist/extensions/forgecli/context-governor-compaction.js.map +1 -0
  8. package/dist/extensions/forgecli/context-governor.d.ts +173 -0
  9. package/dist/extensions/forgecli/context-governor.js +618 -0
  10. package/dist/extensions/forgecli/context-governor.js.map +1 -0
  11. package/dist/extensions/forgecli/dashboard/component.d.ts +105 -0
  12. package/dist/extensions/forgecli/dashboard/component.js +861 -0
  13. package/dist/extensions/forgecli/dashboard/component.js.map +1 -0
  14. package/dist/extensions/forgecli/dashboard/register.d.ts +2 -0
  15. package/dist/extensions/forgecli/dashboard/register.js +31 -0
  16. package/dist/extensions/forgecli/dashboard/register.js.map +1 -0
  17. package/dist/extensions/forgecli/dashboard/theme.d.ts +27 -0
  18. package/dist/extensions/forgecli/dashboard/theme.js +91 -0
  19. package/dist/extensions/forgecli/dashboard/theme.js.map +1 -0
  20. package/dist/extensions/forgecli/dashboard/view-model.d.ts +35 -0
  21. package/dist/extensions/forgecli/dashboard/view-model.js +54 -0
  22. package/dist/extensions/forgecli/dashboard/view-model.js.map +1 -0
  23. package/dist/extensions/forgecli/fix-bug.js +126 -7
  24. package/dist/extensions/forgecli/fix-bug.js.map +1 -1
  25. package/dist/extensions/forgecli/forge-artifact-tool.js +2 -1
  26. package/dist/extensions/forgecli/forge-artifact-tool.js.map +1 -1
  27. package/dist/extensions/forgecli/forge-commands.js +1 -0
  28. package/dist/extensions/forgecli/forge-commands.js.map +1 -1
  29. package/dist/extensions/forgecli/forge-init/phase4-register.js +53 -0
  30. package/dist/extensions/forgecli/forge-init/phase4-register.js.map +1 -1
  31. package/dist/extensions/forgecli/forge-subagent.d.ts +20 -1
  32. package/dist/extensions/forgecli/forge-subagent.js +23 -7
  33. package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
  34. package/dist/extensions/forgecli/forge-tools.js +3 -1
  35. package/dist/extensions/forgecli/forge-tools.js.map +1 -1
  36. package/dist/extensions/forgecli/hook-dispatcher.d.ts +3 -1
  37. package/dist/extensions/forgecli/hook-dispatcher.js +37 -3
  38. package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
  39. package/dist/extensions/forgecli/index.js +38 -1
  40. package/dist/extensions/forgecli/index.js.map +1 -1
  41. package/dist/extensions/forgecli/lib/halt-advisor.d.ts +59 -0
  42. package/dist/extensions/forgecli/lib/halt-advisor.js +113 -0
  43. package/dist/extensions/forgecli/lib/halt-advisor.js.map +1 -0
  44. package/dist/extensions/forgecli/migration-engine.js +25 -12
  45. package/dist/extensions/forgecli/migration-engine.js.map +1 -1
  46. package/dist/extensions/forgecli/orchestrator-status-bar.d.ts +26 -0
  47. package/dist/extensions/forgecli/orchestrator-status-bar.js +213 -0
  48. package/dist/extensions/forgecli/orchestrator-status-bar.js.map +1 -0
  49. package/dist/extensions/forgecli/orchestrator-tree.d.ts +96 -0
  50. package/dist/extensions/forgecli/orchestrator-tree.js +390 -0
  51. package/dist/extensions/forgecli/orchestrator-tree.js.map +1 -0
  52. package/dist/extensions/forgecli/project-orientation.js +12 -8
  53. package/dist/extensions/forgecli/project-orientation.js.map +1 -1
  54. package/dist/extensions/forgecli/regenerate.d.ts +16 -0
  55. package/dist/extensions/forgecli/regenerate.js +110 -0
  56. package/dist/extensions/forgecli/regenerate.js.map +1 -1
  57. package/dist/extensions/forgecli/run-sprint.d.ts +3 -1
  58. package/dist/extensions/forgecli/run-sprint.js +34 -3
  59. package/dist/extensions/forgecli/run-sprint.js.map +1 -1
  60. package/dist/extensions/forgecli/run-task.d.ts +66 -1
  61. package/dist/extensions/forgecli/run-task.js +323 -12
  62. package/dist/extensions/forgecli/run-task.js.map +1 -1
  63. package/dist/extensions/forgecli/thread-switcher.d.ts +4 -1
  64. package/dist/extensions/forgecli/thread-switcher.js +118 -762
  65. package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
  66. package/dist/extensions/forgecli/viewport-events.js +32 -0
  67. package/dist/extensions/forgecli/viewport-events.js.map +1 -1
  68. package/dist/forge-payload/.base-pack/commands/fix-bug.md +1 -1
  69. package/dist/forge-payload/.base-pack/commands/run-sprint.md +1 -1
  70. package/dist/forge-payload/.base-pack/commands/run-task.md +1 -1
  71. package/dist/forge-payload/.base-pack/personas/architect.md +1 -1
  72. package/dist/forge-payload/.base-pack/personas/bug-fixer.md +1 -1
  73. package/dist/forge-payload/.base-pack/personas/collator.md +3 -3
  74. package/dist/forge-payload/.base-pack/personas/engineer.md +1 -1
  75. package/dist/forge-payload/.base-pack/personas/librarian.md +1 -1
  76. package/dist/forge-payload/.base-pack/personas/orchestrator.md +1 -1
  77. package/dist/forge-payload/.base-pack/personas/product-manager.md +1 -1
  78. package/dist/forge-payload/.base-pack/personas/qa-engineer.md +1 -1
  79. package/dist/forge-payload/.base-pack/personas/supervisor.md +1 -1
  80. package/dist/forge-payload/.base-pack/workflows/_fragments/event-emission-schema.md +1 -1
  81. package/dist/forge-payload/.base-pack/workflows/_fragments/friction-emit.md +1 -1
  82. package/dist/forge-payload/.base-pack/workflows/_fragments/iron-laws.md +1 -1
  83. package/dist/forge-payload/.base-pack/workflows/_fragments/progress-reporting.md +2 -2
  84. package/dist/forge-payload/.base-pack/workflows/_fragments/store-cli-verbs.md +11 -2
  85. package/dist/forge-payload/.base-pack/workflows/architect_approve.md +6 -7
  86. package/dist/forge-payload/.base-pack/workflows/architect_review_sprint_completion.md +2 -2
  87. package/dist/forge-payload/.base-pack/workflows/architect_sprint_intake.md +2 -2
  88. package/dist/forge-payload/.base-pack/workflows/architect_sprint_plan.md +5 -5
  89. package/dist/forge-payload/.base-pack/workflows/collator_agent.md +4 -6
  90. package/dist/forge-payload/.base-pack/workflows/commit_task.md +5 -6
  91. package/dist/forge-payload/.base-pack/workflows/enhance.md +5 -5
  92. package/dist/forge-payload/.base-pack/workflows/implement_plan.md +15 -7
  93. package/dist/forge-payload/.base-pack/workflows/migrate_structural.md +12 -13
  94. package/dist/forge-payload/.base-pack/workflows/plan_task.md +12 -6
  95. package/dist/forge-payload/.base-pack/workflows/review_code.md +12 -11
  96. package/dist/forge-payload/.base-pack/workflows/review_plan.md +12 -11
  97. package/dist/forge-payload/.base-pack/workflows/sprint_retrospective.md +3 -3
  98. package/dist/forge-payload/.base-pack/workflows/triage.md +12 -9
  99. package/dist/forge-payload/.base-pack/workflows/update_implementation.md +2 -2
  100. package/dist/forge-payload/.base-pack/workflows/update_plan.md +2 -2
  101. package/dist/forge-payload/.base-pack/workflows/validate_task.md +9 -9
  102. package/dist/forge-payload/.base-pack/workflows-js/wfl-fix-bug.js +490 -0
  103. package/dist/forge-payload/.base-pack/workflows-js/wfl-run-sprint.js +416 -0
  104. package/dist/forge-payload/.base-pack/workflows-js/wfl-run-task.js +608 -0
  105. package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
  106. package/dist/forge-payload/.schemas/config.schema.json +2 -3
  107. package/dist/forge-payload/.schemas/enum-catalog.json +2 -2
  108. package/dist/forge-payload/.schemas/event.schema.json +16 -0
  109. package/dist/forge-payload/.schemas/migrations.json +359 -18
  110. package/dist/forge-payload/commands/health.md +29 -0
  111. package/dist/forge-payload/commands/rebuild.md +143 -15
  112. package/dist/forge-payload/commands/update.md +28 -27
  113. package/dist/forge-payload/hooks/preflight-session.cjs +99 -0
  114. package/dist/forge-payload/init/phases/phase-3-materialize.md +18 -5
  115. package/dist/forge-payload/integrity.json +7 -6
  116. package/dist/forge-payload/meta/fragments/tool-discipline.md +1 -1
  117. package/dist/forge-payload/meta/personas/meta-architect.md +1 -1
  118. package/dist/forge-payload/meta/personas/meta-bug-fixer.md +1 -1
  119. package/dist/forge-payload/meta/personas/meta-collator.md +7 -7
  120. package/dist/forge-payload/meta/personas/meta-engineer.md +1 -1
  121. package/dist/forge-payload/meta/personas/meta-orchestrator.md +1 -1
  122. package/dist/forge-payload/meta/personas/meta-supervisor.md +1 -1
  123. package/dist/forge-payload/meta/tool-specs/store-cli.spec.md +1 -1
  124. package/dist/forge-payload/meta/workflows/_fragments/event-emission-schema.md +1 -1
  125. package/dist/forge-payload/meta/workflows/_fragments/friction-emit.md +1 -1
  126. package/dist/forge-payload/meta/workflows/_fragments/iron-laws.md +1 -1
  127. package/dist/forge-payload/meta/workflows/_fragments/progress-reporting.md +2 -2
  128. package/dist/forge-payload/meta/workflows/_fragments/store-cli-verbs.md +11 -2
  129. package/dist/forge-payload/meta/workflows/meta-approve.md +6 -7
  130. package/dist/forge-payload/meta/workflows/meta-bug-triage.md +12 -9
  131. package/dist/forge-payload/meta/workflows/meta-collate.md +5 -7
  132. package/dist/forge-payload/meta/workflows/meta-commit.md +5 -6
  133. package/dist/forge-payload/meta/workflows/meta-enhance.md +5 -5
  134. package/dist/forge-payload/meta/workflows/meta-fix-bug.md +35 -11
  135. package/dist/forge-payload/meta/workflows/meta-implement.md +15 -7
  136. package/dist/forge-payload/meta/workflows/meta-migrate.md +13 -14
  137. package/dist/forge-payload/meta/workflows/meta-new-sprint.md +3 -3
  138. package/dist/forge-payload/meta/workflows/meta-orchestrate.md +138 -39
  139. package/dist/forge-payload/meta/workflows/meta-plan-sprint.md +6 -6
  140. package/dist/forge-payload/meta/workflows/meta-plan-task.md +12 -6
  141. package/dist/forge-payload/meta/workflows/meta-retro.md +4 -4
  142. package/dist/forge-payload/meta/workflows/meta-retrospective.md +4 -4
  143. package/dist/forge-payload/meta/workflows/meta-review-implementation.md +12 -11
  144. package/dist/forge-payload/meta/workflows/meta-review-plan.md +12 -11
  145. package/dist/forge-payload/meta/workflows/meta-review-sprint-completion.md +3 -3
  146. package/dist/forge-payload/meta/workflows/meta-sprint-intake.md +3 -3
  147. package/dist/forge-payload/meta/workflows/meta-sprint-plan.md +6 -6
  148. package/dist/forge-payload/meta/workflows/meta-update-implementation.md +2 -2
  149. package/dist/forge-payload/meta/workflows/meta-update-plan.md +2 -2
  150. package/dist/forge-payload/meta/workflows/meta-validate.md +9 -9
  151. package/dist/forge-payload/schemas/config.schema.json +2 -3
  152. package/dist/forge-payload/schemas/enum-catalog.json +2 -2
  153. package/dist/forge-payload/schemas/event.schema.json +16 -0
  154. package/dist/forge-payload/schemas/structure-manifest.json +75 -73
  155. package/dist/forge-payload/skills/refresh-kb-links/SKILL.md +14 -7
  156. package/dist/forge-payload/tools/banners.cjs +29 -10
  157. package/dist/forge-payload/tools/check-structure.cjs +88 -7
  158. package/dist/forge-payload/tools/collate.cjs +48 -2
  159. package/dist/forge-payload/tools/manage-config.cjs +5 -7
  160. package/dist/forge-payload/tools/parse-gates.cjs +73 -1
  161. package/dist/forge-payload/tools/postflight-gate.cjs +298 -0
  162. package/dist/forge-payload/tools/preflight-gate.cjs +47 -0
  163. package/dist/forge-payload/tools/substitute-placeholders.cjs +5 -4
  164. package/dist/forge-payload/tools/verify-phase.cjs +17 -0
  165. package/package.json +2 -2
  166. package/dist/bin/forgecli.d.ts +0 -2
  167. package/dist/bin/forgecli.js +0 -6
  168. package/dist/bin/forgecli.js.map +0 -1
  169. package/dist/extensions/forgecli/config-tui/index.d.ts +0 -5
  170. package/dist/extensions/forgecli/config-tui/index.js +0 -5
  171. package/dist/extensions/forgecli/config-tui/index.js.map +0 -1
  172. package/dist/extensions/forgecli/loaders/persona-skill-loader.d.ts +0 -45
  173. package/dist/extensions/forgecli/loaders/persona-skill-loader.js +0 -227
  174. package/dist/extensions/forgecli/loaders/persona-skill-loader.js.map +0 -1
  175. package/dist/extensions/forgecli/loaders/template-render.d.ts +0 -20
  176. package/dist/extensions/forgecli/loaders/template-render.js +0 -85
  177. package/dist/extensions/forgecli/loaders/template-render.js.map +0 -1
  178. package/dist/extensions/forgecli/loaders/workflow-loader.d.ts +0 -41
  179. package/dist/extensions/forgecli/loaders/workflow-loader.js +0 -164
  180. package/dist/extensions/forgecli/loaders/workflow-loader.js.map +0 -1
  181. package/dist/forge-payload/.base-pack/workflows/fix_bug.md +0 -446
  182. package/dist/forge-payload/.base-pack/workflows/orchestrate_task.md +0 -928
  183. 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