@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.
- package/CHANGELOG.md +49 -0
- package/dist/CHANGELOG-forge-plugin.md +150 -0
- package/dist/bin/forge.js +0 -0
- package/dist/extensions/forgecli/config-layer.d.ts +16 -0
- package/dist/extensions/forgecli/config-layer.js +5 -0
- package/dist/extensions/forgecli/config-layer.js.map +1 -1
- package/dist/extensions/forgecli/dashboard/component.d.ts +102 -0
- package/dist/extensions/forgecli/dashboard/component.js +882 -0
- package/dist/extensions/forgecli/dashboard/component.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/register.d.ts +2 -0
- package/dist/extensions/forgecli/dashboard/register.js +45 -0
- package/dist/extensions/forgecli/dashboard/register.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/view-model.d.ts +35 -0
- package/dist/extensions/forgecli/dashboard/view-model.js +54 -0
- package/dist/extensions/forgecli/dashboard/view-model.js.map +1 -0
- package/dist/extensions/forgecli/fix-bug.js +72 -7
- package/dist/extensions/forgecli/fix-bug.js.map +1 -1
- package/dist/extensions/forgecli/forge-cli-schema.json +4 -0
- package/dist/extensions/forgecli/forge-commands.js +1 -0
- package/dist/extensions/forgecli/forge-commands.js.map +1 -1
- package/dist/extensions/forgecli/forge-init/phase4-register.js +53 -0
- package/dist/extensions/forgecli/forge-init/phase4-register.js.map +1 -1
- package/dist/extensions/forgecli/forge-subagent.js +6 -4
- package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
- package/dist/extensions/forgecli/index.js +5 -0
- package/dist/extensions/forgecli/index.js.map +1 -1
- package/dist/extensions/forgecli/lib/halt-advisor.d.ts +54 -0
- package/dist/extensions/forgecli/lib/halt-advisor.js +90 -0
- package/dist/extensions/forgecli/lib/halt-advisor.js.map +1 -0
- package/dist/extensions/forgecli/migration-engine.js +25 -12
- package/dist/extensions/forgecli/migration-engine.js.map +1 -1
- package/dist/extensions/forgecli/orchestrator-status-bar.d.ts +25 -0
- package/dist/extensions/forgecli/orchestrator-status-bar.js +183 -0
- package/dist/extensions/forgecli/orchestrator-status-bar.js.map +1 -0
- package/dist/extensions/forgecli/orchestrator-tree.d.ts +96 -0
- package/dist/extensions/forgecli/orchestrator-tree.js +390 -0
- package/dist/extensions/forgecli/orchestrator-tree.js.map +1 -0
- package/dist/extensions/forgecli/project-orientation.js +12 -8
- package/dist/extensions/forgecli/project-orientation.js.map +1 -1
- package/dist/extensions/forgecli/regenerate.d.ts +16 -0
- package/dist/extensions/forgecli/regenerate.js +110 -0
- package/dist/extensions/forgecli/regenerate.js.map +1 -1
- package/dist/extensions/forgecli/run-sprint.js +33 -3
- package/dist/extensions/forgecli/run-sprint.js.map +1 -1
- package/dist/extensions/forgecli/run-task.d.ts +32 -0
- package/dist/extensions/forgecli/run-task.js +185 -12
- package/dist/extensions/forgecli/run-task.js.map +1 -1
- package/dist/extensions/forgecli/thread-switcher.js +105 -764
- package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
- package/dist/extensions/forgecli/viewport-events.js +32 -0
- package/dist/extensions/forgecli/viewport-events.js.map +1 -1
- package/dist/forge-payload/.base-pack/commands/fix-bug.md +1 -1
- package/dist/forge-payload/.base-pack/commands/run-sprint.md +1 -1
- package/dist/forge-payload/.base-pack/commands/run-task.md +1 -1
- package/dist/forge-payload/.base-pack/personas/architect.md +1 -1
- package/dist/forge-payload/.base-pack/personas/bug-fixer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/collator.md +3 -3
- package/dist/forge-payload/.base-pack/personas/engineer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/librarian.md +1 -1
- package/dist/forge-payload/.base-pack/personas/orchestrator.md +1 -1
- package/dist/forge-payload/.base-pack/personas/product-manager.md +1 -1
- package/dist/forge-payload/.base-pack/personas/qa-engineer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/supervisor.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/event-emission-schema.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/friction-emit.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/iron-laws.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/progress-reporting.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/_fragments/store-cli-verbs.md +11 -2
- package/dist/forge-payload/.base-pack/workflows/architect_approve.md +6 -7
- package/dist/forge-payload/.base-pack/workflows/architect_review_sprint_completion.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/architect_sprint_intake.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/architect_sprint_plan.md +5 -5
- package/dist/forge-payload/.base-pack/workflows/collator_agent.md +4 -6
- package/dist/forge-payload/.base-pack/workflows/commit_task.md +5 -6
- package/dist/forge-payload/.base-pack/workflows/enhance.md +5 -5
- package/dist/forge-payload/.base-pack/workflows/implement_plan.md +6 -7
- package/dist/forge-payload/.base-pack/workflows/migrate_structural.md +12 -13
- package/dist/forge-payload/.base-pack/workflows/plan_task.md +5 -6
- package/dist/forge-payload/.base-pack/workflows/review_code.md +8 -8
- package/dist/forge-payload/.base-pack/workflows/review_plan.md +8 -8
- package/dist/forge-payload/.base-pack/workflows/sprint_retrospective.md +3 -3
- package/dist/forge-payload/.base-pack/workflows/triage.md +12 -9
- package/dist/forge-payload/.base-pack/workflows/update_implementation.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/update_plan.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/validate_task.md +5 -6
- package/dist/forge-payload/.base-pack/workflows-js/wfl-fix-bug.js +490 -0
- package/dist/forge-payload/.base-pack/workflows-js/wfl-run-sprint.js +416 -0
- package/dist/forge-payload/.base-pack/workflows-js/wfl-run-task.js +608 -0
- package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
- package/dist/forge-payload/.schemas/config.schema.json +2 -3
- package/dist/forge-payload/.schemas/enum-catalog.json +2 -2
- package/dist/forge-payload/.schemas/event.schema.json +16 -0
- package/dist/forge-payload/.schemas/migrations.json +236 -0
- package/dist/forge-payload/commands/health.md +29 -0
- package/dist/forge-payload/commands/rebuild.md +143 -15
- package/dist/forge-payload/commands/update.md +28 -27
- package/dist/forge-payload/hooks/preflight-session.cjs +99 -0
- package/dist/forge-payload/init/phases/phase-3-materialize.md +18 -5
- package/dist/forge-payload/integrity.json +7 -6
- package/dist/forge-payload/meta/fragments/tool-discipline.md +1 -1
- package/dist/forge-payload/meta/personas/meta-architect.md +1 -1
- package/dist/forge-payload/meta/personas/meta-bug-fixer.md +1 -1
- package/dist/forge-payload/meta/personas/meta-collator.md +7 -7
- package/dist/forge-payload/meta/personas/meta-engineer.md +1 -1
- package/dist/forge-payload/meta/personas/meta-orchestrator.md +1 -1
- package/dist/forge-payload/meta/personas/meta-supervisor.md +1 -1
- package/dist/forge-payload/meta/tool-specs/store-cli.spec.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/event-emission-schema.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/friction-emit.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/iron-laws.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/progress-reporting.md +2 -2
- package/dist/forge-payload/meta/workflows/_fragments/store-cli-verbs.md +11 -2
- package/dist/forge-payload/meta/workflows/meta-approve.md +6 -7
- package/dist/forge-payload/meta/workflows/meta-bug-triage.md +12 -9
- package/dist/forge-payload/meta/workflows/meta-collate.md +5 -7
- package/dist/forge-payload/meta/workflows/meta-commit.md +5 -6
- package/dist/forge-payload/meta/workflows/meta-enhance.md +5 -5
- package/dist/forge-payload/meta/workflows/meta-fix-bug.md +35 -11
- package/dist/forge-payload/meta/workflows/meta-implement.md +15 -7
- package/dist/forge-payload/meta/workflows/meta-migrate.md +13 -14
- package/dist/forge-payload/meta/workflows/meta-new-sprint.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-orchestrate.md +138 -39
- package/dist/forge-payload/meta/workflows/meta-plan-sprint.md +6 -6
- package/dist/forge-payload/meta/workflows/meta-plan-task.md +12 -6
- package/dist/forge-payload/meta/workflows/meta-retro.md +4 -4
- package/dist/forge-payload/meta/workflows/meta-retrospective.md +4 -4
- package/dist/forge-payload/meta/workflows/meta-review-implementation.md +8 -8
- package/dist/forge-payload/meta/workflows/meta-review-plan.md +8 -8
- package/dist/forge-payload/meta/workflows/meta-review-sprint-completion.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-sprint-intake.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-sprint-plan.md +6 -6
- package/dist/forge-payload/meta/workflows/meta-update-implementation.md +2 -2
- package/dist/forge-payload/meta/workflows/meta-update-plan.md +2 -2
- package/dist/forge-payload/meta/workflows/meta-validate.md +5 -6
- package/dist/forge-payload/schemas/config.schema.json +2 -3
- package/dist/forge-payload/schemas/enum-catalog.json +2 -2
- package/dist/forge-payload/schemas/event.schema.json +16 -0
- package/dist/forge-payload/schemas/structure-manifest.json +75 -73
- package/dist/forge-payload/skills/refresh-kb-links/SKILL.md +14 -7
- package/dist/forge-payload/tools/banners.cjs +29 -10
- package/dist/forge-payload/tools/check-structure.cjs +88 -7
- package/dist/forge-payload/tools/collate.cjs +16 -2
- package/dist/forge-payload/tools/manage-config.cjs +5 -7
- package/dist/forge-payload/tools/parse-gates.cjs +73 -1
- package/dist/forge-payload/tools/postflight-gate.cjs +252 -0
- package/dist/forge-payload/tools/preflight-gate.cjs +47 -0
- package/dist/forge-payload/tools/substitute-placeholders.cjs +5 -4
- package/dist/forge-payload/tools/verify-phase.cjs +17 -0
- package/package.json +1 -1
- package/dist/bin/forgecli.d.ts +0 -2
- package/dist/bin/forgecli.js +0 -6
- package/dist/bin/forgecli.js.map +0 -1
- package/dist/extensions/forgecli/config-tui/index.d.ts +0 -5
- package/dist/extensions/forgecli/config-tui/index.js +0 -5
- package/dist/extensions/forgecli/config-tui/index.js.map +0 -1
- package/dist/extensions/forgecli/loaders/persona-skill-loader.d.ts +0 -45
- package/dist/extensions/forgecli/loaders/persona-skill-loader.js +0 -227
- package/dist/extensions/forgecli/loaders/persona-skill-loader.js.map +0 -1
- package/dist/extensions/forgecli/loaders/template-render.d.ts +0 -20
- package/dist/extensions/forgecli/loaders/template-render.js +0 -85
- package/dist/extensions/forgecli/loaders/template-render.js.map +0 -1
- package/dist/extensions/forgecli/loaders/workflow-loader.d.ts +0 -41
- package/dist/extensions/forgecli/loaders/workflow-loader.js +0 -164
- package/dist/extensions/forgecli/loaders/workflow-loader.js.map +0 -1
- package/dist/forge-payload/.base-pack/workflows/fix_bug.md +0 -446
- package/dist/forge-payload/.base-pack/workflows/orchestrate_task.md +0 -928
- package/dist/forge-payload/.base-pack/workflows/run_sprint.md +0 -225
|
@@ -1,121 +1,37 @@
|
|
|
1
|
-
// thread-switcher.ts —
|
|
1
|
+
// thread-switcher.ts — orchestrator status bar + dashboard overlay for forge.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
45
|
-
import { paintFooterLine
|
|
46
|
-
const
|
|
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
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
// ──
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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 (
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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: "
|
|
816
|
-
"
|
|
817
|
-
"
|
|
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
|
-
|
|
165
|
+
openDashboardTui(ctx);
|
|
821
166
|
},
|
|
822
167
|
});
|
|
823
|
-
// Mount at session_start so the
|
|
824
|
-
//
|
|
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
|
});
|