@entelligentsia/forgecli 1.0.10 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/dist/CHANGELOG-forge-plugin.md +211 -0
- package/dist/bin/forge.js +0 -0
- package/dist/extensions/forgecli/config-layer.js.map +1 -1
- package/dist/extensions/forgecli/context-governor-compaction.d.ts +83 -0
- package/dist/extensions/forgecli/context-governor-compaction.js +302 -0
- package/dist/extensions/forgecli/context-governor-compaction.js.map +1 -0
- package/dist/extensions/forgecli/context-governor.d.ts +173 -0
- package/dist/extensions/forgecli/context-governor.js +618 -0
- package/dist/extensions/forgecli/context-governor.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/component.d.ts +105 -0
- package/dist/extensions/forgecli/dashboard/component.js +861 -0
- package/dist/extensions/forgecli/dashboard/component.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/register.d.ts +2 -0
- package/dist/extensions/forgecli/dashboard/register.js +31 -0
- package/dist/extensions/forgecli/dashboard/register.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/theme.d.ts +27 -0
- package/dist/extensions/forgecli/dashboard/theme.js +91 -0
- package/dist/extensions/forgecli/dashboard/theme.js.map +1 -0
- package/dist/extensions/forgecli/dashboard/view-model.d.ts +35 -0
- package/dist/extensions/forgecli/dashboard/view-model.js +54 -0
- package/dist/extensions/forgecli/dashboard/view-model.js.map +1 -0
- package/dist/extensions/forgecli/fix-bug.js +126 -7
- package/dist/extensions/forgecli/fix-bug.js.map +1 -1
- package/dist/extensions/forgecli/forge-artifact-tool.js +2 -1
- package/dist/extensions/forgecli/forge-artifact-tool.js.map +1 -1
- package/dist/extensions/forgecli/forge-commands.js +1 -0
- package/dist/extensions/forgecli/forge-commands.js.map +1 -1
- package/dist/extensions/forgecli/forge-init/phase4-register.js +53 -0
- package/dist/extensions/forgecli/forge-init/phase4-register.js.map +1 -1
- package/dist/extensions/forgecli/forge-subagent.d.ts +20 -1
- package/dist/extensions/forgecli/forge-subagent.js +23 -7
- package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
- package/dist/extensions/forgecli/forge-tools.js +3 -1
- package/dist/extensions/forgecli/forge-tools.js.map +1 -1
- package/dist/extensions/forgecli/hook-dispatcher.d.ts +3 -1
- package/dist/extensions/forgecli/hook-dispatcher.js +37 -3
- package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
- package/dist/extensions/forgecli/index.js +38 -1
- package/dist/extensions/forgecli/index.js.map +1 -1
- package/dist/extensions/forgecli/lib/halt-advisor.d.ts +59 -0
- package/dist/extensions/forgecli/lib/halt-advisor.js +113 -0
- package/dist/extensions/forgecli/lib/halt-advisor.js.map +1 -0
- package/dist/extensions/forgecli/migration-engine.js +25 -12
- package/dist/extensions/forgecli/migration-engine.js.map +1 -1
- package/dist/extensions/forgecli/orchestrator-status-bar.d.ts +26 -0
- package/dist/extensions/forgecli/orchestrator-status-bar.js +213 -0
- package/dist/extensions/forgecli/orchestrator-status-bar.js.map +1 -0
- package/dist/extensions/forgecli/orchestrator-tree.d.ts +96 -0
- package/dist/extensions/forgecli/orchestrator-tree.js +390 -0
- package/dist/extensions/forgecli/orchestrator-tree.js.map +1 -0
- package/dist/extensions/forgecli/project-orientation.js +12 -8
- package/dist/extensions/forgecli/project-orientation.js.map +1 -1
- package/dist/extensions/forgecli/regenerate.d.ts +16 -0
- package/dist/extensions/forgecli/regenerate.js +110 -0
- package/dist/extensions/forgecli/regenerate.js.map +1 -1
- package/dist/extensions/forgecli/run-sprint.d.ts +3 -1
- package/dist/extensions/forgecli/run-sprint.js +34 -3
- package/dist/extensions/forgecli/run-sprint.js.map +1 -1
- package/dist/extensions/forgecli/run-task.d.ts +66 -1
- package/dist/extensions/forgecli/run-task.js +323 -12
- package/dist/extensions/forgecli/run-task.js.map +1 -1
- package/dist/extensions/forgecli/thread-switcher.d.ts +4 -1
- package/dist/extensions/forgecli/thread-switcher.js +118 -762
- package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
- package/dist/extensions/forgecli/viewport-events.js +32 -0
- package/dist/extensions/forgecli/viewport-events.js.map +1 -1
- package/dist/forge-payload/.base-pack/commands/fix-bug.md +1 -1
- package/dist/forge-payload/.base-pack/commands/run-sprint.md +1 -1
- package/dist/forge-payload/.base-pack/commands/run-task.md +1 -1
- package/dist/forge-payload/.base-pack/personas/architect.md +1 -1
- package/dist/forge-payload/.base-pack/personas/bug-fixer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/collator.md +3 -3
- package/dist/forge-payload/.base-pack/personas/engineer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/librarian.md +1 -1
- package/dist/forge-payload/.base-pack/personas/orchestrator.md +1 -1
- package/dist/forge-payload/.base-pack/personas/product-manager.md +1 -1
- package/dist/forge-payload/.base-pack/personas/qa-engineer.md +1 -1
- package/dist/forge-payload/.base-pack/personas/supervisor.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/event-emission-schema.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/friction-emit.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/iron-laws.md +1 -1
- package/dist/forge-payload/.base-pack/workflows/_fragments/progress-reporting.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/_fragments/store-cli-verbs.md +11 -2
- package/dist/forge-payload/.base-pack/workflows/architect_approve.md +6 -7
- package/dist/forge-payload/.base-pack/workflows/architect_review_sprint_completion.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/architect_sprint_intake.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/architect_sprint_plan.md +5 -5
- package/dist/forge-payload/.base-pack/workflows/collator_agent.md +4 -6
- package/dist/forge-payload/.base-pack/workflows/commit_task.md +5 -6
- package/dist/forge-payload/.base-pack/workflows/enhance.md +5 -5
- package/dist/forge-payload/.base-pack/workflows/implement_plan.md +15 -7
- package/dist/forge-payload/.base-pack/workflows/migrate_structural.md +12 -13
- package/dist/forge-payload/.base-pack/workflows/plan_task.md +12 -6
- package/dist/forge-payload/.base-pack/workflows/review_code.md +12 -11
- package/dist/forge-payload/.base-pack/workflows/review_plan.md +12 -11
- package/dist/forge-payload/.base-pack/workflows/sprint_retrospective.md +3 -3
- package/dist/forge-payload/.base-pack/workflows/triage.md +12 -9
- package/dist/forge-payload/.base-pack/workflows/update_implementation.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/update_plan.md +2 -2
- package/dist/forge-payload/.base-pack/workflows/validate_task.md +9 -9
- package/dist/forge-payload/.base-pack/workflows-js/wfl-fix-bug.js +490 -0
- package/dist/forge-payload/.base-pack/workflows-js/wfl-run-sprint.js +416 -0
- package/dist/forge-payload/.base-pack/workflows-js/wfl-run-task.js +608 -0
- package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
- package/dist/forge-payload/.schemas/config.schema.json +2 -3
- package/dist/forge-payload/.schemas/enum-catalog.json +2 -2
- package/dist/forge-payload/.schemas/event.schema.json +16 -0
- package/dist/forge-payload/.schemas/migrations.json +359 -18
- package/dist/forge-payload/commands/health.md +29 -0
- package/dist/forge-payload/commands/rebuild.md +143 -15
- package/dist/forge-payload/commands/update.md +28 -27
- package/dist/forge-payload/hooks/preflight-session.cjs +99 -0
- package/dist/forge-payload/init/phases/phase-3-materialize.md +18 -5
- package/dist/forge-payload/integrity.json +7 -6
- package/dist/forge-payload/meta/fragments/tool-discipline.md +1 -1
- package/dist/forge-payload/meta/personas/meta-architect.md +1 -1
- package/dist/forge-payload/meta/personas/meta-bug-fixer.md +1 -1
- package/dist/forge-payload/meta/personas/meta-collator.md +7 -7
- package/dist/forge-payload/meta/personas/meta-engineer.md +1 -1
- package/dist/forge-payload/meta/personas/meta-orchestrator.md +1 -1
- package/dist/forge-payload/meta/personas/meta-supervisor.md +1 -1
- package/dist/forge-payload/meta/tool-specs/store-cli.spec.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/event-emission-schema.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/friction-emit.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/iron-laws.md +1 -1
- package/dist/forge-payload/meta/workflows/_fragments/progress-reporting.md +2 -2
- package/dist/forge-payload/meta/workflows/_fragments/store-cli-verbs.md +11 -2
- package/dist/forge-payload/meta/workflows/meta-approve.md +6 -7
- package/dist/forge-payload/meta/workflows/meta-bug-triage.md +12 -9
- package/dist/forge-payload/meta/workflows/meta-collate.md +5 -7
- package/dist/forge-payload/meta/workflows/meta-commit.md +5 -6
- package/dist/forge-payload/meta/workflows/meta-enhance.md +5 -5
- package/dist/forge-payload/meta/workflows/meta-fix-bug.md +35 -11
- package/dist/forge-payload/meta/workflows/meta-implement.md +15 -7
- package/dist/forge-payload/meta/workflows/meta-migrate.md +13 -14
- package/dist/forge-payload/meta/workflows/meta-new-sprint.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-orchestrate.md +138 -39
- package/dist/forge-payload/meta/workflows/meta-plan-sprint.md +6 -6
- package/dist/forge-payload/meta/workflows/meta-plan-task.md +12 -6
- package/dist/forge-payload/meta/workflows/meta-retro.md +4 -4
- package/dist/forge-payload/meta/workflows/meta-retrospective.md +4 -4
- package/dist/forge-payload/meta/workflows/meta-review-implementation.md +12 -11
- package/dist/forge-payload/meta/workflows/meta-review-plan.md +12 -11
- package/dist/forge-payload/meta/workflows/meta-review-sprint-completion.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-sprint-intake.md +3 -3
- package/dist/forge-payload/meta/workflows/meta-sprint-plan.md +6 -6
- package/dist/forge-payload/meta/workflows/meta-update-implementation.md +2 -2
- package/dist/forge-payload/meta/workflows/meta-update-plan.md +2 -2
- package/dist/forge-payload/meta/workflows/meta-validate.md +9 -9
- package/dist/forge-payload/schemas/config.schema.json +2 -3
- package/dist/forge-payload/schemas/enum-catalog.json +2 -2
- package/dist/forge-payload/schemas/event.schema.json +16 -0
- package/dist/forge-payload/schemas/structure-manifest.json +75 -73
- package/dist/forge-payload/skills/refresh-kb-links/SKILL.md +14 -7
- package/dist/forge-payload/tools/banners.cjs +29 -10
- package/dist/forge-payload/tools/check-structure.cjs +88 -7
- package/dist/forge-payload/tools/collate.cjs +48 -2
- package/dist/forge-payload/tools/manage-config.cjs +5 -7
- package/dist/forge-payload/tools/parse-gates.cjs +73 -1
- package/dist/forge-payload/tools/postflight-gate.cjs +298 -0
- package/dist/forge-payload/tools/preflight-gate.cjs +47 -0
- package/dist/forge-payload/tools/substitute-placeholders.cjs +5 -4
- package/dist/forge-payload/tools/verify-phase.cjs +17 -0
- package/package.json +2 -2
- package/dist/bin/forgecli.d.ts +0 -2
- package/dist/bin/forgecli.js +0 -6
- package/dist/bin/forgecli.js.map +0 -1
- package/dist/extensions/forgecli/config-tui/index.d.ts +0 -5
- package/dist/extensions/forgecli/config-tui/index.js +0 -5
- package/dist/extensions/forgecli/config-tui/index.js.map +0 -1
- package/dist/extensions/forgecli/loaders/persona-skill-loader.d.ts +0 -45
- package/dist/extensions/forgecli/loaders/persona-skill-loader.js +0 -227
- package/dist/extensions/forgecli/loaders/persona-skill-loader.js.map +0 -1
- package/dist/extensions/forgecli/loaders/template-render.d.ts +0 -20
- package/dist/extensions/forgecli/loaders/template-render.js +0 -85
- package/dist/extensions/forgecli/loaders/template-render.js.map +0 -1
- package/dist/extensions/forgecli/loaders/workflow-loader.d.ts +0 -41
- package/dist/extensions/forgecli/loaders/workflow-loader.js +0 -164
- package/dist/extensions/forgecli/loaders/workflow-loader.js.map +0 -1
- package/dist/forge-payload/.base-pack/workflows/fix_bug.md +0 -446
- package/dist/forge-payload/.base-pack/workflows/orchestrate_task.md +0 -928
- package/dist/forge-payload/.base-pack/workflows/run_sprint.md +0 -225
|
@@ -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,129 @@ 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";
|
|
68
|
+
// ── Registration ─────────────────────────────────────────────────────────────
|
|
69
|
+
function isDownArrow(data) {
|
|
70
|
+
return data === "\x1b[B" || data === "\x1bOB";
|
|
507
71
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
};
|
|
72
|
+
let statusBarRef;
|
|
73
|
+
let statusBarTui;
|
|
74
|
+
// Module-level tree reference so openDashboardTui can access the singleton
|
|
75
|
+
// without being nested inside registerThreadSwitcher.
|
|
76
|
+
let treeRef;
|
|
77
|
+
/** Open the dashboard overlay. Shared by /forge:threads, /forge:dashboard,
|
|
78
|
+
* and the status bar Enter action. */
|
|
79
|
+
export function openDashboardTui(ctx) {
|
|
80
|
+
const tree = treeRef ?? getOrchestratorTree();
|
|
81
|
+
if (!treeRef)
|
|
82
|
+
treeRef = tree;
|
|
83
|
+
const controller = new DashboardController(tree);
|
|
84
|
+
const router = getInputRouter();
|
|
85
|
+
router.pushOverlay();
|
|
86
|
+
ctx.ui.custom((tui, theme, _kb, done) => {
|
|
87
|
+
const component = new DashboardComponent(controller, tui, theme, done);
|
|
88
|
+
return component;
|
|
89
|
+
}, {
|
|
90
|
+
overlay: true,
|
|
91
|
+
overlayOptions: {
|
|
92
|
+
width: "100%",
|
|
93
|
+
anchor: "center",
|
|
94
|
+
margin: 0,
|
|
95
|
+
},
|
|
96
|
+
}).finally(() => {
|
|
97
|
+
router.popOverlay();
|
|
562
98
|
});
|
|
563
99
|
}
|
|
564
100
|
export function registerThreadSwitcher(pi) {
|
|
565
|
-
registerTurnMessageRenderer(pi);
|
|
566
101
|
const registry = getSessionRegistry();
|
|
567
|
-
|
|
568
|
-
|
|
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;
|
|
102
|
+
const tree = getOrchestratorTree();
|
|
103
|
+
treeRef = tree;
|
|
574
104
|
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
105
|
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
106
|
function mount(ctx) {
|
|
602
107
|
currentCtx = ctx;
|
|
603
108
|
if (mounted)
|
|
604
109
|
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;
|
|
110
|
+
// ── Orchestrator status bar (belowEditor) ────────────────────────
|
|
111
|
+
ctx.ui.setWidget(STATUS_BAR_WIDGET_KEY, (tui, theme) => {
|
|
112
|
+
statusBarTui = tui;
|
|
113
|
+
const bar = new OrchestratorStatusBar(tree, theme);
|
|
114
|
+
bar.setInvalidationCallback(() => tui.requestRender());
|
|
115
|
+
bar.setOnAction(() => {
|
|
116
|
+
// Enter on focused status bar → open dashboard.
|
|
672
117
|
const live = currentCtx;
|
|
673
|
-
if (
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
118
|
+
if (live)
|
|
119
|
+
openDashboardTui(live);
|
|
120
|
+
});
|
|
121
|
+
statusBarRef = bar;
|
|
122
|
+
return bar;
|
|
123
|
+
}, { placement: "belowEditor" });
|
|
124
|
+
// ── Aggregate token footer (aboveEditor) ─────────────────────────
|
|
125
|
+
ctx.ui.setWidget(FOOTER_WIDGET_KEY, (tui, theme) => {
|
|
126
|
+
const getOrchestratorModel = () => {
|
|
127
|
+
try {
|
|
128
|
+
const m = ctx.model;
|
|
129
|
+
if (!m)
|
|
683
130
|
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 };
|
|
131
|
+
return { provider: m.provider, model: m.id };
|
|
692
132
|
}
|
|
693
|
-
|
|
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 };
|
|
714
|
-
}
|
|
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
|
-
}
|
|
133
|
+
catch {
|
|
745
134
|
return undefined;
|
|
746
135
|
}
|
|
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
|
-
}
|
|
136
|
+
};
|
|
137
|
+
const footer = new ViewportFooterComponent(registry, theme, getOrchestratorModel);
|
|
138
|
+
footer.setInvalidationCallback(() => tui.requestRender());
|
|
139
|
+
return footer;
|
|
140
|
+
}, { placement: "aboveEditor" });
|
|
141
|
+
// ── Input routing: ↓ focuses status bar; Enter opens dashboard ────────
|
|
142
|
+
getInputRouter().register((data) => {
|
|
143
|
+
if (!statusBarRef || !statusBarTui)
|
|
768
144
|
return undefined;
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
const
|
|
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
|
-
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
|
-
}
|
|
145
|
+
// Allow ↓ navigation whenever orchestration roots are visible —
|
|
146
|
+
// this includes completed/failed/escalated roots so the user
|
|
147
|
+
// can review results even after execution ends.
|
|
148
|
+
const activeRoots = tree.getActiveRoots();
|
|
149
|
+
if (activeRoots.length === 0)
|
|
150
|
+
return undefined;
|
|
151
|
+
// ↓ focuses the status bar (makes it active).
|
|
152
|
+
if (isDownArrow(data)) {
|
|
153
|
+
statusBarRef.setActive(true);
|
|
154
|
+
return { consume: true };
|
|
155
|
+
}
|
|
156
|
+
// When the status bar is focused, ↑ / Esc return focus to the prompt.
|
|
157
|
+
if (statusBarRef.isActive() && (matchesKey(data, Key.up) || matchesKey(data, Key.escape))) {
|
|
158
|
+
statusBarRef.setActive(false);
|
|
159
|
+
return { consume: true };
|
|
160
|
+
}
|
|
161
|
+
// When the status bar is focused, Enter opens the dashboard.
|
|
162
|
+
if (statusBarRef.isActive() && matchesKey(data, Key.enter)) {
|
|
163
|
+
statusBarRef.setActive(false);
|
|
164
|
+
const live = currentCtx;
|
|
165
|
+
if (live)
|
|
166
|
+
openDashboardTui(live);
|
|
167
|
+
return { consume: true };
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}, { name: "orchestrator-status-bar", skipWhenOverlayActive: true });
|
|
171
|
+
mounted = true;
|
|
813
172
|
}
|
|
814
173
|
pi.registerCommand("forge:threads", {
|
|
815
|
-
description: "
|
|
816
|
-
"
|
|
817
|
-
"
|
|
174
|
+
description: "Open the Forge orchestrator dashboard. " +
|
|
175
|
+
"Shows a two-panel view of sprint/task/phase tree with status, " +
|
|
176
|
+
"metrics, and live activity. You can also press ↓ from the prompt " +
|
|
177
|
+
"when an orchestration is running.",
|
|
818
178
|
async handler(_args, ctx) {
|
|
819
179
|
mount(ctx);
|
|
820
|
-
|
|
180
|
+
openDashboardTui(ctx);
|
|
821
181
|
},
|
|
822
182
|
});
|
|
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.
|
|
183
|
+
// Mount at session_start so the status bar + ↓ listener are live from
|
|
184
|
+
// the first keystroke. mount() is idempotent.
|
|
829
185
|
pi.on("session_start", async (_event, ctx) => {
|
|
830
186
|
mount(ctx);
|
|
831
187
|
});
|