@entelligentsia/forgecli 1.0.10 → 1.0.14

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 (167) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/CHANGELOG-forge-plugin.md +150 -0
  3. package/dist/bin/forge.js +0 -0
  4. package/dist/extensions/forgecli/config-layer.d.ts +16 -0
  5. package/dist/extensions/forgecli/config-layer.js +5 -0
  6. package/dist/extensions/forgecli/config-layer.js.map +1 -1
  7. package/dist/extensions/forgecli/dashboard/component.d.ts +102 -0
  8. package/dist/extensions/forgecli/dashboard/component.js +882 -0
  9. package/dist/extensions/forgecli/dashboard/component.js.map +1 -0
  10. package/dist/extensions/forgecli/dashboard/register.d.ts +2 -0
  11. package/dist/extensions/forgecli/dashboard/register.js +45 -0
  12. package/dist/extensions/forgecli/dashboard/register.js.map +1 -0
  13. package/dist/extensions/forgecli/dashboard/view-model.d.ts +35 -0
  14. package/dist/extensions/forgecli/dashboard/view-model.js +54 -0
  15. package/dist/extensions/forgecli/dashboard/view-model.js.map +1 -0
  16. package/dist/extensions/forgecli/fix-bug.js +72 -7
  17. package/dist/extensions/forgecli/fix-bug.js.map +1 -1
  18. package/dist/extensions/forgecli/forge-cli-schema.json +4 -0
  19. package/dist/extensions/forgecli/forge-commands.js +1 -0
  20. package/dist/extensions/forgecli/forge-commands.js.map +1 -1
  21. package/dist/extensions/forgecli/forge-init/phase4-register.js +53 -0
  22. package/dist/extensions/forgecli/forge-init/phase4-register.js.map +1 -1
  23. package/dist/extensions/forgecli/forge-subagent.js +6 -4
  24. package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
  25. package/dist/extensions/forgecli/index.js +5 -0
  26. package/dist/extensions/forgecli/index.js.map +1 -1
  27. package/dist/extensions/forgecli/lib/halt-advisor.d.ts +54 -0
  28. package/dist/extensions/forgecli/lib/halt-advisor.js +90 -0
  29. package/dist/extensions/forgecli/lib/halt-advisor.js.map +1 -0
  30. package/dist/extensions/forgecli/migration-engine.js +25 -12
  31. package/dist/extensions/forgecli/migration-engine.js.map +1 -1
  32. package/dist/extensions/forgecli/orchestrator-status-bar.d.ts +25 -0
  33. package/dist/extensions/forgecli/orchestrator-status-bar.js +183 -0
  34. package/dist/extensions/forgecli/orchestrator-status-bar.js.map +1 -0
  35. package/dist/extensions/forgecli/orchestrator-tree.d.ts +96 -0
  36. package/dist/extensions/forgecli/orchestrator-tree.js +390 -0
  37. package/dist/extensions/forgecli/orchestrator-tree.js.map +1 -0
  38. package/dist/extensions/forgecli/project-orientation.js +12 -8
  39. package/dist/extensions/forgecli/project-orientation.js.map +1 -1
  40. package/dist/extensions/forgecli/regenerate.d.ts +16 -0
  41. package/dist/extensions/forgecli/regenerate.js +110 -0
  42. package/dist/extensions/forgecli/regenerate.js.map +1 -1
  43. package/dist/extensions/forgecli/run-sprint.js +33 -3
  44. package/dist/extensions/forgecli/run-sprint.js.map +1 -1
  45. package/dist/extensions/forgecli/run-task.d.ts +32 -0
  46. package/dist/extensions/forgecli/run-task.js +185 -12
  47. package/dist/extensions/forgecli/run-task.js.map +1 -1
  48. package/dist/extensions/forgecli/thread-switcher.js +105 -764
  49. package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
  50. package/dist/extensions/forgecli/viewport-events.js +32 -0
  51. package/dist/extensions/forgecli/viewport-events.js.map +1 -1
  52. package/dist/forge-payload/.base-pack/commands/fix-bug.md +1 -1
  53. package/dist/forge-payload/.base-pack/commands/run-sprint.md +1 -1
  54. package/dist/forge-payload/.base-pack/commands/run-task.md +1 -1
  55. package/dist/forge-payload/.base-pack/personas/architect.md +1 -1
  56. package/dist/forge-payload/.base-pack/personas/bug-fixer.md +1 -1
  57. package/dist/forge-payload/.base-pack/personas/collator.md +3 -3
  58. package/dist/forge-payload/.base-pack/personas/engineer.md +1 -1
  59. package/dist/forge-payload/.base-pack/personas/librarian.md +1 -1
  60. package/dist/forge-payload/.base-pack/personas/orchestrator.md +1 -1
  61. package/dist/forge-payload/.base-pack/personas/product-manager.md +1 -1
  62. package/dist/forge-payload/.base-pack/personas/qa-engineer.md +1 -1
  63. package/dist/forge-payload/.base-pack/personas/supervisor.md +1 -1
  64. package/dist/forge-payload/.base-pack/workflows/_fragments/event-emission-schema.md +1 -1
  65. package/dist/forge-payload/.base-pack/workflows/_fragments/friction-emit.md +1 -1
  66. package/dist/forge-payload/.base-pack/workflows/_fragments/iron-laws.md +1 -1
  67. package/dist/forge-payload/.base-pack/workflows/_fragments/progress-reporting.md +2 -2
  68. package/dist/forge-payload/.base-pack/workflows/_fragments/store-cli-verbs.md +11 -2
  69. package/dist/forge-payload/.base-pack/workflows/architect_approve.md +6 -7
  70. package/dist/forge-payload/.base-pack/workflows/architect_review_sprint_completion.md +2 -2
  71. package/dist/forge-payload/.base-pack/workflows/architect_sprint_intake.md +2 -2
  72. package/dist/forge-payload/.base-pack/workflows/architect_sprint_plan.md +5 -5
  73. package/dist/forge-payload/.base-pack/workflows/collator_agent.md +4 -6
  74. package/dist/forge-payload/.base-pack/workflows/commit_task.md +5 -6
  75. package/dist/forge-payload/.base-pack/workflows/enhance.md +5 -5
  76. package/dist/forge-payload/.base-pack/workflows/implement_plan.md +6 -7
  77. package/dist/forge-payload/.base-pack/workflows/migrate_structural.md +12 -13
  78. package/dist/forge-payload/.base-pack/workflows/plan_task.md +5 -6
  79. package/dist/forge-payload/.base-pack/workflows/review_code.md +8 -8
  80. package/dist/forge-payload/.base-pack/workflows/review_plan.md +8 -8
  81. package/dist/forge-payload/.base-pack/workflows/sprint_retrospective.md +3 -3
  82. package/dist/forge-payload/.base-pack/workflows/triage.md +12 -9
  83. package/dist/forge-payload/.base-pack/workflows/update_implementation.md +2 -2
  84. package/dist/forge-payload/.base-pack/workflows/update_plan.md +2 -2
  85. package/dist/forge-payload/.base-pack/workflows/validate_task.md +5 -6
  86. package/dist/forge-payload/.base-pack/workflows-js/wfl-fix-bug.js +490 -0
  87. package/dist/forge-payload/.base-pack/workflows-js/wfl-run-sprint.js +416 -0
  88. package/dist/forge-payload/.base-pack/workflows-js/wfl-run-task.js +608 -0
  89. package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
  90. package/dist/forge-payload/.schemas/config.schema.json +2 -3
  91. package/dist/forge-payload/.schemas/enum-catalog.json +2 -2
  92. package/dist/forge-payload/.schemas/event.schema.json +16 -0
  93. package/dist/forge-payload/.schemas/migrations.json +236 -0
  94. package/dist/forge-payload/commands/health.md +29 -0
  95. package/dist/forge-payload/commands/rebuild.md +143 -15
  96. package/dist/forge-payload/commands/update.md +28 -27
  97. package/dist/forge-payload/hooks/preflight-session.cjs +99 -0
  98. package/dist/forge-payload/init/phases/phase-3-materialize.md +18 -5
  99. package/dist/forge-payload/integrity.json +7 -6
  100. package/dist/forge-payload/meta/fragments/tool-discipline.md +1 -1
  101. package/dist/forge-payload/meta/personas/meta-architect.md +1 -1
  102. package/dist/forge-payload/meta/personas/meta-bug-fixer.md +1 -1
  103. package/dist/forge-payload/meta/personas/meta-collator.md +7 -7
  104. package/dist/forge-payload/meta/personas/meta-engineer.md +1 -1
  105. package/dist/forge-payload/meta/personas/meta-orchestrator.md +1 -1
  106. package/dist/forge-payload/meta/personas/meta-supervisor.md +1 -1
  107. package/dist/forge-payload/meta/tool-specs/store-cli.spec.md +1 -1
  108. package/dist/forge-payload/meta/workflows/_fragments/event-emission-schema.md +1 -1
  109. package/dist/forge-payload/meta/workflows/_fragments/friction-emit.md +1 -1
  110. package/dist/forge-payload/meta/workflows/_fragments/iron-laws.md +1 -1
  111. package/dist/forge-payload/meta/workflows/_fragments/progress-reporting.md +2 -2
  112. package/dist/forge-payload/meta/workflows/_fragments/store-cli-verbs.md +11 -2
  113. package/dist/forge-payload/meta/workflows/meta-approve.md +6 -7
  114. package/dist/forge-payload/meta/workflows/meta-bug-triage.md +12 -9
  115. package/dist/forge-payload/meta/workflows/meta-collate.md +5 -7
  116. package/dist/forge-payload/meta/workflows/meta-commit.md +5 -6
  117. package/dist/forge-payload/meta/workflows/meta-enhance.md +5 -5
  118. package/dist/forge-payload/meta/workflows/meta-fix-bug.md +35 -11
  119. package/dist/forge-payload/meta/workflows/meta-implement.md +15 -7
  120. package/dist/forge-payload/meta/workflows/meta-migrate.md +13 -14
  121. package/dist/forge-payload/meta/workflows/meta-new-sprint.md +3 -3
  122. package/dist/forge-payload/meta/workflows/meta-orchestrate.md +138 -39
  123. package/dist/forge-payload/meta/workflows/meta-plan-sprint.md +6 -6
  124. package/dist/forge-payload/meta/workflows/meta-plan-task.md +12 -6
  125. package/dist/forge-payload/meta/workflows/meta-retro.md +4 -4
  126. package/dist/forge-payload/meta/workflows/meta-retrospective.md +4 -4
  127. package/dist/forge-payload/meta/workflows/meta-review-implementation.md +8 -8
  128. package/dist/forge-payload/meta/workflows/meta-review-plan.md +8 -8
  129. package/dist/forge-payload/meta/workflows/meta-review-sprint-completion.md +3 -3
  130. package/dist/forge-payload/meta/workflows/meta-sprint-intake.md +3 -3
  131. package/dist/forge-payload/meta/workflows/meta-sprint-plan.md +6 -6
  132. package/dist/forge-payload/meta/workflows/meta-update-implementation.md +2 -2
  133. package/dist/forge-payload/meta/workflows/meta-update-plan.md +2 -2
  134. package/dist/forge-payload/meta/workflows/meta-validate.md +5 -6
  135. package/dist/forge-payload/schemas/config.schema.json +2 -3
  136. package/dist/forge-payload/schemas/enum-catalog.json +2 -2
  137. package/dist/forge-payload/schemas/event.schema.json +16 -0
  138. package/dist/forge-payload/schemas/structure-manifest.json +75 -73
  139. package/dist/forge-payload/skills/refresh-kb-links/SKILL.md +14 -7
  140. package/dist/forge-payload/tools/banners.cjs +29 -10
  141. package/dist/forge-payload/tools/check-structure.cjs +88 -7
  142. package/dist/forge-payload/tools/collate.cjs +16 -2
  143. package/dist/forge-payload/tools/manage-config.cjs +5 -7
  144. package/dist/forge-payload/tools/parse-gates.cjs +73 -1
  145. package/dist/forge-payload/tools/postflight-gate.cjs +252 -0
  146. package/dist/forge-payload/tools/preflight-gate.cjs +47 -0
  147. package/dist/forge-payload/tools/substitute-placeholders.cjs +5 -4
  148. package/dist/forge-payload/tools/verify-phase.cjs +17 -0
  149. package/package.json +1 -1
  150. package/dist/bin/forgecli.d.ts +0 -2
  151. package/dist/bin/forgecli.js +0 -6
  152. package/dist/bin/forgecli.js.map +0 -1
  153. package/dist/extensions/forgecli/config-tui/index.d.ts +0 -5
  154. package/dist/extensions/forgecli/config-tui/index.js +0 -5
  155. package/dist/extensions/forgecli/config-tui/index.js.map +0 -1
  156. package/dist/extensions/forgecli/loaders/persona-skill-loader.d.ts +0 -45
  157. package/dist/extensions/forgecli/loaders/persona-skill-loader.js +0 -227
  158. package/dist/extensions/forgecli/loaders/persona-skill-loader.js.map +0 -1
  159. package/dist/extensions/forgecli/loaders/template-render.d.ts +0 -20
  160. package/dist/extensions/forgecli/loaders/template-render.js +0 -85
  161. package/dist/extensions/forgecli/loaders/template-render.js.map +0 -1
  162. package/dist/extensions/forgecli/loaders/workflow-loader.d.ts +0 -41
  163. package/dist/extensions/forgecli/loaders/workflow-loader.js +0 -164
  164. package/dist/extensions/forgecli/loaders/workflow-loader.js.map +0 -1
  165. package/dist/forge-payload/.base-pack/workflows/fix_bug.md +0 -446
  166. package/dist/forge-payload/.base-pack/workflows/orchestrate_task.md +0 -928
  167. package/dist/forge-payload/.base-pack/workflows/run_sprint.md +0 -225
@@ -1,121 +1,37 @@
1
- // thread-switcher.ts — single-viewport thread switcher for forge:run-task.
1
+ // thread-switcher.ts — orchestrator status bar + dashboard overlay for forge.
2
2
  //
3
- // One-row strip below the editor with two render modes:
3
+ // Two elements:
4
+ // 1. OrchestratorStatusBar (belowEditor widget): a one-line summary of
5
+ // running orchestrations — label, current phase, status glyph, spinner,
6
+ // turn preview, token meter. Press ↓ to open the dashboard overlay.
7
+ // Hidden when no orchestration is running.
4
8
  //
5
- // INACTIVE (default,not pressed):
6
- // threads ─ [HLO-S01-T04 · plan ⠋] "Now update the task status…"
9
+ // 2. Dashboard overlay (/forge:dashboard command or from status bar):
10
+ // full-screen two-panel tree browser + detail view. See dashboard/.
7
11
  //
8
- // Compact summary line orchestrator chip with current phase + cycling
9
- // spinner, followed by the latest assistant-turn preview. Replaces the
10
- // legacy ctx.ui.setStatus bottom line (the chip strip IS the live status).
11
- //
12
- // ACTIVE (user pressed ↓):
13
- // threads ─ ▸● HLO-S01-T04 ◇ plan ◆ review-plan ✓ implement ⠋ "…preview"
14
- //
15
- // Full chip list with cursor/focus glyphs. ←→ navigates; Enter focuses
16
- // a chip into the main chat viewport via ctx.ui.setOutputSource; ↑
17
- // returns to editor without changing the viewport; Esc returns to
18
- // editor AND snaps viewport back to main.
19
- //
20
- // The strip is HIDDEN entirely (zero rows) when no run-task session has
21
- // ever started in this pi conversation — pi default chat occupies the
22
- // space normally.
23
- //
24
- // Activation key: ↓ from the editor when (a) the editor has no newlines
25
- // (preserves multi-line Down nav) and (b) there's at least one session
26
- // in the registry. /forge:threads slash command works as a fallback.
27
- //
28
- // Chip glyphs:
29
- // ▸<label> cursor (only one)
30
- // ●<label> currently the focused source of the chat viewport
31
- // ○<label> orchestrator chip when something else is focused
32
- // ◇<label> live subagent, no unread warnings
33
- // ◆<label> live subagent with unread warnings since last focused
34
- // ✓<label> subagent that completed cleanly
35
- // ✗<label> subagent that failed
36
- //
37
- // Data plane: SessionRegistry (session-registry.ts) — chips read phases
38
- // from the most-recent run-task session; tail-view reads getTailLines(...)
39
- // for the focused phase. All re-renders are driven by tui.requestRender()
40
- // (registry events → invalidationCb → requestRender → next render tick).
41
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
+ // The chip strip and TailViewComponent have been retired the dashboard
13
+ // replaces them for all orchestration visualization. The main viewport
14
+ // stays clean for user prompts and new tasks.
42
15
  import { getInputRouter } from "./input-router.js";
16
+ import { matchesKey, Key } from "@earendil-works/pi-tui";
17
+ import { getOrchestratorTree } from "./orchestrator-tree.js";
18
+ import { DashboardComponent, DashboardController } from "./dashboard/component.js";
19
+ import { OrchestratorStatusBar } from "./orchestrator-status-bar.js";
43
20
  import { getSessionRegistry, } from "./session-registry.js";
44
- import { fmtModelAndTokenFooter, fmtModelLabel, fmtTokenFooter } from "./viewport-renderer.js";
45
- import { paintFooterLine, paintTailLine } from "./viewport-theme.js";
46
- const WIDGET_KEY = "forge:thread-switcher";
21
+ import { fmtModelLabel, fmtTokenFooter } from "./viewport-renderer.js";
22
+ import { paintFooterLine } from "./viewport-theme.js";
23
+ const STATUS_BAR_WIDGET_KEY = "forge:orchestrator-status-bar";
47
24
  const FOOTER_WIDGET_KEY = "forge:viewport-footer";
48
- // Braille spinner frames universally supported, 10 frames feels smooth at
49
- // 100ms cadence.
50
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
51
- const SPINNER_INTERVAL_MS = 100;
52
- // ── Tail-view component: rendered into the chat viewport when a chip is focused ──
53
- class TailViewComponent {
54
- registry;
55
- taskId;
56
- phaseRole;
57
- theme;
58
- invalidationCb;
59
- constructor(registry, taskId, phaseRole, theme) {
60
- this.registry = registry;
61
- this.taskId = taskId;
62
- this.phaseRole = phaseRole;
63
- this.theme = theme;
64
- const onTail = (e) => {
65
- if (e.taskId === this.taskId && e.phaseRole === this.phaseRole) {
66
- this.invalidationCb?.();
67
- }
68
- };
69
- registry.on("tail", onTail);
70
- this.dispose = () => registry.off("tail", onTail);
71
- }
72
- render(width) {
73
- const lines = this.registry.getTailLines(this.taskId, this.phaseRole);
74
- const session = this.registry.getSession(this.taskId);
75
- const phase = session?.phases.find((p) => p.role === this.phaseRole);
76
- const footerText = fmtModelAndTokenFooter(phase ? { provider: phase.provider, model: phase.model } : undefined, phase?.usage, phase?.compression);
77
- const bodyLines = lines.length === 0
78
- ? [truncateToWidth(`(no output yet for ${this.phaseRole})`, width)]
79
- : lines.map((line) => {
80
- const painted = paintTailLine(line, this.theme);
81
- return visibleWidth(painted) <= width ? painted : truncateToWidth(painted, width);
82
- });
83
- if (!footerText)
84
- return bodyLines;
85
- // Footer = right-aligned token summary on its own line. Sits at the
86
- // bottom of the tail view (right above the prompt) when pi-tui autoscroll
87
- // is at the tail end, which is the default after new output.
88
- const footer = paintFooterLine(footerText, width, this.theme);
89
- return [...bodyLines, footer];
90
- }
91
- invalidate() {
92
- // Re-render is driven by external invalidationCb → tui.requestRender().
93
- }
94
- setInvalidationCallback(cb) {
95
- this.invalidationCb = cb;
96
- }
97
- dispose;
98
- }
99
- // ── Main-viewport footer: sticky Σ aggregate token meter ──
100
- //
101
- // Rendered as a widget at `aboveEditor` placement so it sits at the bottom
102
- // of the main chat viewport (matching the position of the per-phase TailView
103
- // footer). Subscribes to registry events and right-aligns
104
- // `Σ ↑input ↓output ⇪cacheRead`.
25
+ // ── Aggregate token footer ──────────────────────────────────────────────────
26
+ // Mirrors the per-phase footer rendered inside TailView when a subagent chip
27
+ // was focused. Now shown at the bottom of the main viewport whenever an
28
+ // orchestration is active.
105
29
  class ViewportFooterComponent {
106
30
  registry;
107
31
  theme;
108
32
  getOrchestratorModel;
109
33
  invalidationCb;
110
- constructor(registry, theme,
111
- /**
112
- * Returns the parent pi session's current (provider, model) — the
113
- * "outer orchestrator" model. Caller closes over ExtensionContext
114
- * and is responsible for guarding stale-ctx access; return undefined
115
- * on failure or when no model is set. When undefined, the footer
116
- * just shows `Σ ↑X ↓Y`.
117
- */
118
- getOrchestratorModel) {
34
+ constructor(registry, theme, getOrchestratorModel) {
119
35
  this.registry = registry;
120
36
  this.theme = theme;
121
37
  this.getOrchestratorModel = getOrchestratorModel;
@@ -130,10 +46,6 @@ class ViewportFooterComponent {
130
46
  };
131
47
  }
132
48
  render(width) {
133
- // Only show the footer when an orchestrator session is active.
134
- // When all sessions are terminal (completed/failed/cancelled) or
135
- // when no session exists, hide the footer — main viewport has no
136
- // subagent aggregate to display.
137
49
  const sessions = this.registry.listSessions();
138
50
  const hasActive = sessions.some((s) => s.status === "running" || s.status === "cancelling");
139
51
  if (!hasActive)
@@ -147,685 +59,114 @@ class ViewportFooterComponent {
147
59
  const text = left && right ? `${left} ${right}` : left || right;
148
60
  return [paintFooterLine(text, width, this.theme)];
149
61
  }
150
- invalidate() {
151
- // Re-render driven by external invalidationCb → tui.requestRender().
152
- }
62
+ invalidate() { }
153
63
  setInvalidationCallback(cb) {
154
64
  this.invalidationCb = cb;
155
65
  }
156
66
  dispose;
157
67
  }
158
- // ── Chip-strip component: one row below the editor ──
159
- class ChipStripComponent {
160
- registry;
161
- theme;
162
- cursorIdx = 0;
163
- /** id of the chip whose tail is currently mirrored in the chat viewport.
164
- * "main" = pi default (no override). */
165
- focusedChipId = "main";
166
- stripActive = false;
167
- invalidationCb;
168
- /** When non-null, the strip shows a cancellation confirmation prompt
169
- * for this chip instead of the normal chip view. */
170
- cancelTarget = null;
171
- constructor(registry, theme) {
172
- this.registry = registry;
173
- this.theme = theme;
174
- const onChange = () => this.invalidationCb?.();
175
- registry.on("change", onChange);
176
- registry.on("tail", onChange);
177
- registry.on("preview", onChange);
178
- registry.on("turn", onChange);
179
- this.dispose = () => {
180
- registry.off("change", onChange);
181
- registry.off("tail", onChange);
182
- registry.off("preview", onChange);
183
- registry.off("turn", onChange);
184
- };
185
- }
186
- setInvalidationCallback(cb) {
187
- this.invalidationCb = cb;
188
- }
189
- activeSession() {
190
- // Most-recently-updated session (running or recently terminal).
191
- return this.registry.listSessions()[0];
192
- }
193
- hasSession() {
194
- return this.activeSession() !== undefined;
195
- }
196
- /** Snapshot of available chips at render time. Empty when no session. */
197
- chips() {
198
- const session = this.activeSession();
199
- if (!session)
200
- return [];
201
- // Orchestrator chip: label = taskId (the orchestrator's identity in
202
- // this pi conversation). id stays "main" so focus/output-source
203
- // semantics ("main" = setOutputSource(null) = pi default) are stable.
204
- const out = [{ id: "main", label: session.taskId, taskId: null }];
205
- // Dedupe phases by role, keep most-recent attempt (review loops),
206
- // then restore pipeline order via findIndex.
207
- const seen = new Set();
208
- for (let i = session.phases.length - 1; i >= 0; i--) {
209
- const p = session.phases[i];
210
- if (seen.has(p.role))
211
- continue;
212
- seen.add(p.role);
213
- out.push({ id: p.role, label: p.role, taskId: session.taskId });
214
- }
215
- out.sort((a, b) => {
216
- if (a.id === "main")
217
- return -1;
218
- if (b.id === "main")
219
- return 1;
220
- const ia = session.phases.findIndex((p) => p.role === a.id);
221
- const ib = session.phases.findIndex((p) => p.role === b.id);
222
- return ia - ib;
223
- });
224
- return out;
225
- }
226
- chipPhase(chip) {
227
- if (chip.id === "main" || !chip.taskId)
228
- return undefined;
229
- const s = this.registry.getSession(chip.taskId);
230
- if (!s)
231
- return undefined;
232
- for (let i = s.phases.length - 1; i >= 0; i--) {
233
- if (s.phases[i].role === chip.id)
234
- return s.phases[i];
235
- }
236
- return undefined;
237
- }
238
- chipGlyph(chip) {
239
- if (chip.id === "main")
240
- return this.focusedChipId === "main" ? "●" : "○";
241
- const session = this.activeSession();
242
- const p = this.chipPhase(chip);
243
- if (!p)
244
- return "·";
245
- if (this.focusedChipId === chip.id)
246
- return "●";
247
- // Cancelling/cancelled glyphs are session-level, not phase-level.
248
- // Show ⏳ for any phase when the session is cancelling, ⊘ when cancelled.
249
- if (session?.status === "cancelled" && p.status !== "completed" && p.status !== "failed")
250
- return "⊘";
251
- if (session?.status === "cancelling" && p.status === "running")
252
- return "⏳";
253
- if (p.status === "cancelled")
254
- return "⊘";
255
- if (p.status === "completed")
256
- return "✓";
257
- if (p.status === "failed")
258
- return "✗";
259
- if (p.unreadWarnings > 0)
260
- return "◆";
261
- return "◇";
262
- }
263
- currentPhaseRole(session) {
264
- // Prefer a currently-running phase; else fall back to the most-recent
265
- // phase (whatever happened last, even if completed).
266
- for (let i = session.phases.length - 1; i >= 0; i--) {
267
- if (session.phases[i].status === "running")
268
- return session.phases[i].role;
269
- }
270
- return session.phases[session.phases.length - 1]?.role;
271
- }
272
- spinnerFrame(session) {
273
- if (session.status !== "running" && session.status !== "cancelling")
274
- return "";
275
- const idx = Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length;
276
- return SPINNER_FRAMES[idx];
277
- }
278
- render(width) {
279
- const session = this.activeSession();
280
- if (!session)
281
- return []; // UX-B: hide entirely when no session.
282
- // If user is confirming cancellation, show the confirmation prompt.
283
- if (this.cancelTarget) {
284
- return this.renderCancelPrompt(width, this.cancelTarget);
285
- }
286
- const chips = this.chips();
287
- // Clamp cursor.
288
- if (this.cursorIdx >= chips.length)
289
- this.cursorIdx = chips.length - 1;
290
- if (this.cursorIdx < 0)
291
- this.cursorIdx = 0;
292
- return this.stripActive ? this.renderActive(width, session, chips) : this.renderInactive(width, session);
293
- }
294
- renderInactive(width, session) {
295
- const dim = (s) => this.theme.fg("dim", s);
296
- const accent = (s) => this.theme.fg("accent", s);
297
- const chips = this.chips();
298
- const spin = this.spinnerFrame(session);
299
- // Orchestrator chip: bracketed + accent-colored (anchor identity).
300
- // Subagent chips: <glyph> <role>, dimmed.
301
- const orchChip = accent(`[${session.taskId}]`);
302
- const phaseChips = chips.filter((c) => c.id !== "main").map((c) => dim(`${this.chipGlyph(c)} ${c.label}`));
303
- const chipsLine = [orchChip, ...phaseChips].join(" ");
304
- // Right-side: status · spinner · command hints.
305
- // Cancelled sessions show "r resume" affordance; all others show "↓ to navigate".
306
- const statusLabel = session.status === "cancelled" ? "cancelled" : session.status === "cancelling" ? "cancelling…" : "";
307
- const statusPart = statusLabel ? dim(` ${statusLabel}`) : "";
308
- const spinPart = spin ? ` ${spin}` : "";
309
- const hint = session.status === "cancelled" ? dim(" ↓ nav · r resume") : dim(" ↓ to navigate");
310
- // Truncate preview text from the MIDDLE of the line to keep chips and hints visible.
311
- const previewText = session.currentTurnPreview ? `"${session.currentTurnPreview}"` : "";
312
- // Priority: chips + status + spinner + hint are fixed.
313
- // Truncate preview first, then truncate from the right as fallback.
314
- const fixedRight = visibleWidth(statusPart) + visibleWidth(spinPart) + visibleWidth(hint);
315
- const previewBudget = Math.max(0, width - fixedRight - 4); // 4 = safety margin
316
- let preview = "";
317
- if (previewText) {
318
- // Truncate the preview text itself to fit the budget
319
- const truncated = truncateToWidth(previewText, previewBudget);
320
- if (visibleWidth(truncated) > 0)
321
- preview = dim(` ${truncated}`);
322
- }
323
- // Build line; truncate from the right (preview tail) if still over-width.
324
- let line = `${chipsLine}${statusPart}${spinPart}${preview}${hint}`;
325
- if (visibleWidth(line) > width) {
326
- // Truncate preview tail first (not chips)
327
- const budget = Math.max(0, width - visibleWidth(chipsLine) - fixedRight);
328
- const previewOnly = truncateToWidth(previewText, budget);
329
- preview = previewOnly ? dim(` ${previewOnly}`) : "";
330
- line = `${chipsLine}${statusPart}${spinPart}${preview}${hint}`;
331
- }
332
- if (visibleWidth(line) > width)
333
- line = truncateToWidth(line, width);
334
- return [line];
335
- }
336
- renderActive(width, session, chips) {
337
- const dim = (s) => this.theme.fg("dim", s);
338
- const accent = (s) => this.theme.fg("accent", s);
339
- const bold = (s) => this.theme.bold(s);
340
- const parts = chips.map((c, i) => {
341
- const isCursor = i === this.cursorIdx;
342
- const glyph = this.chipGlyph(c);
343
- const label = c.label;
344
- const inner = `${glyph} ${label}`;
345
- if (isCursor)
346
- return accent(bold(`▸${inner}`));
347
- if (this.focusedChipId === c.id)
348
- return bold(inner);
349
- return dim(inner);
350
- });
351
- const prefix = "";
352
- // "r resume" shown only for cancelled sessions; "x cancel" for running ones.
353
- const cancelWord = session.status === "cancelled" ? dim("r resume") : dim("x cancel");
354
- const navHints = dim(" ←→ · enter · ↑ back · esc back+main");
355
- // Show status-based text for non-running sessions
356
- let statusPart = "";
357
- if (session.status === "cancelling") {
358
- statusPart = " cancelling…";
359
- }
360
- else if (session.status === "cancelled") {
361
- statusPart = " cancelled";
362
- }
363
- const spin = this.spinnerFrame(session);
364
- const spinPart = spin ? ` ${spin}` : "";
365
- const previewText = session.currentTurnPreview ? ` "${session.currentTurnPreview}"` : "";
366
- const chipsJoined = parts.join(" ");
367
- // Use visibleWidth (strips ANSI) so truncation maths are correct.
368
- const fixed = visibleWidth(prefix) +
369
- visibleWidth(chipsJoined) +
370
- visibleWidth(spinPart) +
371
- visibleWidth(statusPart) +
372
- visibleWidth(cancelWord) +
373
- visibleWidth(navHints);
374
- const previewBudget = Math.max(0, width - fixed);
375
- const preview = previewText ? dim(truncateToWidth(previewText, previewBudget)) : "";
376
- let line = `${prefix}${chipsJoined}${spinPart}${statusPart}${preview} ${cancelWord} ${navHints}`;
377
- // Hard cap as last-resort defence (visibleWidth is best-effort).
378
- if (visibleWidth(line) > width)
379
- line = truncateToWidth(line, width);
380
- return [line];
381
- }
382
- /**
383
- * Render the cancellation confirmation prompt. Replaces the normal
384
- * chip strip when cancelTarget is non-null.
385
- * ⚠ Cancel [taskId] → [phaseRole]? y/n · esc to abort
386
- */
387
- renderCancelPrompt(width, target) {
388
- const dim = (s) => this.theme.fg("dim", s);
389
- const warning = (s) => this.theme.fg("warning", s);
390
- const bold = (s) => this.theme.bold(s);
391
- const taskLabel = target.taskId ?? target.label;
392
- const phaseLabel = target.id === "main" ? "session" : target.label;
393
- // "cancel" sits right after the prompt — most visible position.
394
- // Truncation sacrifices the dim dismiss-hints from the END, keeping
395
- // the action word and the warning always readable.
396
- const actionWord = dim("cancel");
397
- const hints = dim(" · n/esc dismiss · y confirm");
398
- const prompt = warning(`⚠ Cancel ${bold(taskLabel)} → ${bold(phaseLabel)}? `);
399
- const budget = Math.max(0, width - visibleWidth(prompt) - visibleWidth(actionWord));
400
- const tail = budget > 0 ? dim(` · n/esc dismiss · y confirm`) : "";
401
- const budgetedTail = visibleWidth(tail) > budget ? dim(truncateToWidth(` · n/esc dismiss · y confirm`, budget)) : tail;
402
- let line = `${prompt}${actionWord}${budgetedTail}`;
403
- if (visibleWidth(line) > width)
404
- line = truncateToWidth(line, width);
405
- return [line];
406
- }
407
- invalidate() {
408
- // Re-render driven by external invalidationCb → tui.requestRender().
409
- }
410
- dispose;
411
- // ── Input handling ──────────────────────────────────────────────────────
412
- setStripActive(active) {
413
- if (this.stripActive === active)
414
- return;
415
- this.stripActive = active;
416
- this.invalidationCb?.();
417
- }
418
- /** Initiate cancel confirmation for a chip. Sets cancelTarget so the
419
- * next render shows the confirmation prompt. */
420
- requestCancelChip(chip) {
421
- this.cancelTarget = chip;
422
- this.invalidationCb?.();
423
- }
424
- /** Confirm the pending cancellation (user pressed y). */
425
- confirmCancel() {
426
- const target = this.cancelTarget;
427
- this.cancelTarget = null;
428
- this.invalidationCb?.();
429
- return target;
430
- }
431
- /** Dismiss the cancel prompt (user pressed n/Esc). */
432
- dismissCancel() {
433
- this.cancelTarget = null;
434
- this.invalidationCb?.();
435
- }
436
- /** Whether a cancel confirmation prompt is active. */
437
- isCancelPromptActive() {
438
- return this.cancelTarget !== null;
439
- }
440
- /** Check if the chip at the current cursor is a running phase that can be cancelled. */
441
- isCursorCancellable() {
442
- const chip = this.cursorChip();
443
- if (!chip)
444
- return false;
445
- if (chip.id === "main") {
446
- const session = this.activeSession();
447
- return (session?.status ?? "") === "running";
448
- }
449
- const p = this.chipPhase(chip);
450
- if (!p)
451
- return false;
452
- return p.status === "running";
453
- }
454
- /** True when the current session is cancelled — r key triggers resume. */
455
- isCursorResumable() {
456
- const session = this.activeSession();
457
- return session?.status === "cancelled";
458
- }
459
- getStripActive() {
460
- return this.stripActive;
461
- }
462
- moveCursor(delta) {
463
- const chips = this.chips();
464
- this.cursorIdx = Math.max(0, Math.min(chips.length - 1, this.cursorIdx + delta));
465
- this.invalidationCb?.();
466
- }
467
- setCursor(idx) {
468
- const chips = this.chips();
469
- this.cursorIdx = Math.max(0, Math.min(chips.length - 1, idx));
470
- this.invalidationCb?.();
471
- }
472
- /**
473
- * Park the cursor on the currently-running subagent chip if there is one,
474
- * else fall back to the orchestrator chip (index 0). Called on ↓ activation
475
- * so the user lands on the most interesting chip by default — the live
476
- * phase — instead of having to ←→ walk to find it.
477
- */
478
- parkCursorOnCurrentPhase() {
479
- const chips = this.chips();
480
- const session = this.activeSession();
481
- const runningRole = session ? this.currentPhaseRole(session) : undefined;
482
- if (runningRole) {
483
- const idx = chips.findIndex((c) => c.id === runningRole);
484
- if (idx >= 0) {
485
- this.cursorIdx = idx;
486
- this.invalidationCb?.();
487
- return;
488
- }
489
- }
490
- this.cursorIdx = 0;
491
- this.invalidationCb?.();
492
- }
493
- chipCount() {
494
- return this.chips().length;
495
- }
496
- cursorChip() {
497
- return this.chips()[this.cursorIdx];
498
- }
499
- setFocusedChipId(id) {
500
- this.focusedChipId = id;
501
- this.invalidationCb?.();
502
- }
503
- }
504
- // ── Key recognition ─────────────────────────────────────────────────────────
505
- function isLeftArrow(d) {
506
- return d === "\x1b[D";
507
- }
508
- function isRightArrow(d) {
509
- return d === "\x1b[C";
510
- }
511
- function isDownArrow(d) {
512
- return d === "\x1b[B" || d === "\x1bOB";
513
- }
514
- function isUpArrow(d) {
515
- return d === "\x1b[A" || d === "\x1bOA";
516
- }
517
- function isEnter(d) {
518
- return d === "\r" || d === "\n";
519
- }
520
- function isEsc(d) {
521
- // Bare ESC. Multi-byte arrow sequences start with ESC but are matched
522
- // by the arrow checks above first.
523
- return d === "\x1b";
524
- }
525
- function isXKey(d) {
526
- return d === "x";
527
- }
528
- function isRKey(d) {
529
- return d === "r" || d === "R";
530
- }
531
- function isYKey(d) {
532
- return d === "y" || d === "Y";
533
- }
534
- function isNKey(d) {
535
- return d === "n" || d === "N";
536
- }
537
- // ── Registrar ───────────────────────────────────────────────────────────────
538
- /**
539
- * Custom renderer for the `forge:turn` chat-history rows we append on every
540
- * subagent turn_end. Paints the `[displayRole]` prefix accent + bold, the
541
- * `tN` marker dim, and leaves the preview body in the default text colour.
542
- */
543
- function registerTurnMessageRenderer(pi) {
544
- pi.registerMessageRenderer("forge:turn", (message, _opts, theme) => {
545
- const rawContent = typeof message.content === "string"
546
- ? message.content
547
- : message.content.map((c) => c.text ?? "").join("");
548
- const m = rawContent.match(/^\[([^\]]+)\]\s+t(\d+)\s+(.*)$/);
549
- let line;
550
- if (m) {
551
- const [, role, turn, body] = m;
552
- line = `${theme.bold(theme.fg("accent", `[${role}]`))} ${theme.fg("dim", `t${turn}`)} ${body}`;
553
- }
554
- else {
555
- line = rawContent;
556
- }
557
- return {
558
- render: (_w) => [line],
559
- invalidate: () => { },
560
- setInvalidationCallback: () => { },
561
- };
562
- });
68
+ // ── Registration ─────────────────────────────────────────────────────────────
69
+ function isDownArrow(data) {
70
+ return data === "\x1b[B" || data === "\x1bOB";
563
71
  }
72
+ let statusBarRef;
73
+ let statusBarTui;
564
74
  export function registerThreadSwitcher(pi) {
565
- registerTurnMessageRenderer(pi);
566
75
  const registry = getSessionRegistry();
567
- let stripRef;
568
- let tailRef;
569
- let tuiRef;
570
- // Theme captured at widget mount — needed for paintTailLine in the tail
571
- // component, which is constructed lazily on chip focus (not at mount time).
572
- let themeRef;
573
- let spinnerTimer;
76
+ const tree = getOrchestratorTree();
574
77
  let mounted = false;
575
- // Pi invalidates the ExtensionContext after newSession / fork /
576
- // switchSession / reload. The input-router handler and the focus
577
- // helpers are registered once at mount but fire after arbitrary
578
- // session replacements — they must read the *live* ctx, not the
579
- // one captured at mount time. We refresh this on every
580
- // session_start and on every forge:threads command invocation.
581
78
  let currentCtx;
582
- function ensureSpinnerTimer() {
583
- // Tick re-renders while any session is "running" or "cancelling" so the
584
- // spinner glyph animates and the preview text refreshes between user input.
585
- // When all sessions are terminal, the timer stops itself.
586
- if (spinnerTimer)
587
- return;
588
- spinnerTimer = setInterval(() => {
589
- const anyActive = registry.listSessions().some((s) => s.status === "running" || s.status === "cancelling");
590
- if (!anyActive) {
591
- if (spinnerTimer)
592
- clearInterval(spinnerTimer);
593
- spinnerTimer = undefined;
594
- // One last render to settle the spinner into its final frame.
595
- tuiRef?.requestRender();
596
- return;
597
- }
598
- tuiRef?.requestRender();
599
- }, SPINNER_INTERVAL_MS);
600
- }
601
79
  function mount(ctx) {
602
80
  currentCtx = ctx;
603
81
  if (mounted)
604
82
  return;
605
- process.stderr.write("[forge:threads] mount() invoked\n");
606
- try {
607
- ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
608
- tuiRef = tui;
609
- themeRef = theme;
610
- const strip = new ChipStripComponent(registry, theme);
611
- strip.setInvalidationCallback(() => tui.requestRender());
612
- stripRef = strip;
613
- return strip;
614
- }, { placement: "belowEditor" });
615
- // Aggregate Σ token meter — sticky right-bottom of the main chat
616
- // viewport (mirrors the per-phase footer rendered inside TailView
617
- // when a subagent chip is focused).
618
- ctx.ui.setWidget(FOOTER_WIDGET_KEY, (tui, theme) => {
619
- // Guard against stale-ctx access after session replacement
620
- // (newSession / fork / switchSession / reload) — touching
621
- // ctx.model on a stale ctx throws and would crash the row.
622
- const getOrchestratorModel = () => {
623
- try {
624
- const m = ctx.model;
625
- if (!m)
626
- return undefined;
627
- return { provider: m.provider, model: m.id };
628
- }
629
- catch {
630
- return undefined;
631
- }
632
- };
633
- const footer = new ViewportFooterComponent(registry, theme, getOrchestratorModel);
634
- footer.setInvalidationCallback(() => tui.requestRender());
635
- return footer;
636
- }, { placement: "aboveEditor" });
637
- // Bubble each subagent's turn-complete event into the parent
638
- // (main) viewport as a new custom message — pi APPENDS one row per
639
- // call rather than replacing a single notification line. Users see
640
- // every subagent's turns stream into the main chat history in
641
- // order, identified by `[displayRole]`. triggerTurn:false so no
642
- // LLM round-trip; the message is render-only.
643
- registry.on("turn", (evt) => {
644
- // Skip silent turns (no preview, no thinking) — would just add
645
- // noise rows to the parent chat history.
646
- if (!evt.preview && !evt.thinking)
647
- return;
648
- const body = evt.preview ? `"${evt.preview}"` : `✱ ${evt.thinking}`;
649
- try {
650
- pi.sendMessage({
651
- customType: "forge:turn",
652
- content: `[${evt.displayRole}] t${evt.turn} ${body}`,
653
- display: true,
654
- details: { ...evt },
655
- }, { triggerTurn: false });
656
- }
657
- catch {
658
- // pi.sendMessage may throw if called before session is
659
- // fully ready or if the session has shut down — non-fatal.
660
- }
661
- });
662
- mounted = true;
663
- // Bootstrap the spinner ticker on any session start so the
664
- // inactive-mode summary animates immediately.
665
- registry.on("change", () => ensureSpinnerTimer());
666
- ensureSpinnerTimer();
667
- // Plan 16 Slice 4c: register via forge-input-router so that overlays
668
- // (e.g. /forge:config) suppress the ↓ activator while mounted.
669
- getInputRouter().register((data) => {
670
- if (!stripRef)
671
- return undefined;
83
+ // ── Orchestrator status bar (belowEditor) ────────────────────────
84
+ ctx.ui.setWidget(STATUS_BAR_WIDGET_KEY, (tui, theme) => {
85
+ statusBarTui = tui;
86
+ const bar = new OrchestratorStatusBar(tree, theme);
87
+ bar.setInvalidationCallback(() => tui.requestRender());
88
+ bar.setOnAction(() => {
89
+ // Enter on focused status bar → open dashboard.
672
90
  const live = currentCtx;
673
- if (!live)
674
- return undefined;
675
- if (!stripRef.getStripActive()) {
676
- if (!isDownArrow(data))
677
- return undefined;
678
- let editorText = "";
679
- try {
680
- editorText = live.ui.getEditorText();
681
- }
682
- catch {
91
+ if (live)
92
+ openDashboardTui(live);
93
+ });
94
+ statusBarRef = bar;
95
+ return bar;
96
+ }, { placement: "belowEditor" });
97
+ // ── Aggregate token footer (aboveEditor) ─────────────────────────
98
+ ctx.ui.setWidget(FOOTER_WIDGET_KEY, (tui, theme) => {
99
+ const getOrchestratorModel = () => {
100
+ try {
101
+ const m = ctx.model;
102
+ if (!m)
683
103
  return undefined;
684
- }
685
- if (editorText.includes("\n"))
686
- return undefined; // multi-line nav
687
- if (!stripRef.hasSession())
688
- return undefined; // strip hidden anyway
689
- stripRef.setStripActive(true);
690
- stripRef.parkCursorOnCurrentPhase();
691
- return { consume: true };
692
- }
693
- // ── Cancel-confirmation handling (cancelTarget active) ────────
694
- // When the strip shows a cancel prompt, y/Enter confirms,
695
- // n/Esc dismisses. All other keys are consumed (no passthrough).
696
- if (stripRef.isCancelPromptActive()) {
697
- if (isYKey(data) || isEnter(data)) {
698
- const target = stripRef.confirmCancel();
699
- if (target?.taskId) {
700
- registry.requestCancel(target.taskId);
701
- }
702
- stripRef.setStripActive(false);
703
- setFocusToMain(live);
704
- return { consume: true };
705
- }
706
- // Dismiss: n, Esc
707
- if (isNKey(data) || isEsc(data)) {
708
- stripRef.dismissCancel();
709
- stripRef.setStripActive(false);
710
- return { consume: true };
711
- }
712
- // Any other key in cancel-confirmation mode is consumed silently.
713
- return { consume: true };
104
+ return { provider: m.provider, model: m.id };
714
105
  }
715
- if (isXKey(data)) {
716
- const chip = stripRef.cursorChip();
717
- if (chip && stripRef.isCursorCancellable()) {
718
- stripRef.requestCancelChip(chip);
719
- return { consume: true };
720
- }
721
- return undefined;
722
- }
723
- if (isRKey(data)) {
724
- // Resume a cancelled session. The state file is preserved on cancel
725
- // (ADR-S21-01). Write the slash command to the editor and simulate
726
- // Enter — exactly mirrors how a user types and submits the command.
727
- const session = registry.listSessions()[0];
728
- if (session && stripRef.isCursorResumable()) {
729
- const entityId = session.taskId;
730
- const cmd = entityId.startsWith("FORGE-BUG-")
731
- ? `forge:fix-bug ${entityId}`
732
- : `forge:run-task ${entityId}`;
733
- stripRef.setStripActive(false);
734
- try {
735
- live.ui.setEditorText(`/${cmd}`);
736
- }
737
- catch {
738
- // Non-fatal — editor may not be accessible in all contexts.
739
- live.ui.notify(`↻ Resume: /${cmd}`, "info");
740
- }
741
- // Return Enter to submit the command. The router dispatches
742
- // normally; pi processes it as a slash-command submit.
743
- return { data: "\r" };
744
- }
106
+ catch {
745
107
  return undefined;
746
108
  }
747
- if (isLeftArrow(data)) {
748
- stripRef.moveCursor(-1);
749
- return { consume: true };
750
- }
751
- if (isRightArrow(data)) {
752
- stripRef.moveCursor(1);
753
- return { consume: true };
754
- }
755
- if (isUpArrow(data)) {
756
- stripRef.setStripActive(false);
757
- return { consume: true };
758
- }
759
- if (isEnter(data)) {
760
- commitFocus(live);
761
- return { consume: true };
762
- }
763
- if (isEsc(data)) {
764
- stripRef.setStripActive(false);
765
- setFocusToMain(live);
766
- return { consume: true };
767
- }
109
+ };
110
+ const footer = new ViewportFooterComponent(registry, theme, getOrchestratorModel);
111
+ footer.setInvalidationCallback(() => tui.requestRender());
112
+ return footer;
113
+ }, { placement: "aboveEditor" });
114
+ // ── Input routing: ↓ focuses status bar; Enter opens dashboard ────────
115
+ getInputRouter().register((data) => {
116
+ if (!statusBarRef || !statusBarTui)
768
117
  return undefined;
769
- }, { name: "thread-switcher-strip", skipWhenOverlayActive: true });
770
- }
771
- catch (err) {
772
- const e = err;
773
- ctx.ui.notify(`forge:threads failed to mount: ${e.message ?? "unknown"}`, "error");
774
- }
775
- }
776
- function commitFocus(ctx) {
777
- if (!stripRef)
778
- return;
779
- const chip = stripRef.cursorChip();
780
- if (!chip)
781
- return;
782
- stripRef.setFocusedChipId(chip.id);
783
- if (chip.id === "main" || !chip.taskId) {
784
- setFocusToMain(ctx);
785
- return;
786
- }
787
- tailRef?.dispose?.();
788
- const tail = new TailViewComponent(registry, chip.taskId, chip.id, themeRef);
789
- // Wire the same requestRender hook so new tail lines surface
790
- // without needing user input.
791
- if (tuiRef)
792
- tail.setInvalidationCallback(() => tuiRef?.requestRender());
793
- tailRef = tail;
794
- try {
795
- ctx.ui.setOutputSource(tail);
796
- }
797
- catch {
798
- // ctx went stale between keypress and dispatch — drop quietly;
799
- // the next session_start will refresh currentCtx.
800
- }
801
- registry.markRead(chip.taskId, chip.id);
802
- }
803
- function setFocusToMain(ctx) {
804
- stripRef?.setFocusedChipId("main");
805
- tailRef?.dispose?.();
806
- tailRef = undefined;
807
- try {
808
- ctx.ui.setOutputSource(null);
809
- }
810
- catch {
811
- // see commitFocus
812
- }
118
+ // Only process when an orchestration is active.
119
+ const activeRoots = tree.getActiveRoots();
120
+ const anyActive = activeRoots.some((r) => r.status === "running" || r.status === "cancelling");
121
+ if (!anyActive)
122
+ return undefined;
123
+ // ↓ focuses the status bar (makes it active).
124
+ if (isDownArrow(data)) {
125
+ statusBarRef.setActive(true);
126
+ return { consume: true };
127
+ }
128
+ // When the status bar is focused, Enter opens the dashboard.
129
+ if (statusBarRef.isActive() && matchesKey(data, Key.enter)) {
130
+ statusBarRef.setActive(false);
131
+ const live = currentCtx;
132
+ if (live)
133
+ openDashboardTui(live);
134
+ return { consume: true };
135
+ }
136
+ return undefined;
137
+ }, { name: "orchestrator-status-bar", skipWhenOverlayActive: true });
138
+ mounted = true;
139
+ }
140
+ function openDashboardTui(ctx) {
141
+ const controller = new DashboardController(tree);
142
+ const router = getInputRouter();
143
+ router.pushOverlay();
144
+ ctx.ui.custom((tui, theme, _kb, done) => {
145
+ const component = new DashboardComponent(controller, tui, theme, done);
146
+ return component;
147
+ }, {
148
+ overlay: true,
149
+ overlayOptions: {
150
+ width: "100%",
151
+ anchor: "center",
152
+ margin: 0,
153
+ },
154
+ }).finally(() => {
155
+ router.popOverlay();
156
+ });
813
157
  }
814
158
  pi.registerCommand("forge:threads", {
815
- description: "Activate the Forge thread-switcher strip below the editor. " +
816
- "Easier: press from the prompt when a run-task is active. " +
817
- "While active: ←→ navigate · enter focus · back to editor · esc back to editor + viewport to main.",
159
+ description: "Open the Forge orchestrator dashboard. " +
160
+ "Shows a two-panel view of sprint/task/phase tree with status, " +
161
+ "metrics, and live activity. You can also press from the prompt " +
162
+ "when an orchestration is running.",
818
163
  async handler(_args, ctx) {
819
164
  mount(ctx);
820
- stripRef?.setStripActive(true);
165
+ openDashboardTui(ctx);
821
166
  },
822
167
  });
823
- // Mount at session_start so the Down listener + chip strip are live
824
- // from the first keystroke. mount() is idempotent.
825
- // session_start fires for the initial session and for every
826
- // post-replacement session (newSession / fork / switchSession /
827
- // reload), so mount() refreshing currentCtx here is the single
828
- // chokepoint for keeping the input-router handler's ctx live.
168
+ // Mount at session_start so the status bar + listener are live from
169
+ // the first keystroke. mount() is idempotent.
829
170
  pi.on("session_start", async (_event, ctx) => {
830
171
  mount(ctx);
831
172
  });