@underactive/pi-topping-moa-fusion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +437 -0
  4. package/agents/mf-plan.md +43 -0
  5. package/agents/moa-debater.md +37 -0
  6. package/agents/moa-explore.md +56 -0
  7. package/agents/moa-opinion.md +29 -0
  8. package/agents/moa-proposer.md +49 -0
  9. package/agents/moa-synthesizer.md +124 -0
  10. package/agents/moa-verifier.md +67 -0
  11. package/index.ts +3 -0
  12. package/package.json +61 -0
  13. package/src/activityMeter.ts +193 -0
  14. package/src/agents/authoritative.ts +91 -0
  15. package/src/agents/defaults.ts +123 -0
  16. package/src/agents/discovery.ts +119 -0
  17. package/src/config/modelCatalogue.ts +54 -0
  18. package/src/config/planName.ts +74 -0
  19. package/src/config/rosters.ts +118 -0
  20. package/src/config/settings.ts +161 -0
  21. package/src/debate/debateContract.ts +89 -0
  22. package/src/debate/debateFanout.ts +285 -0
  23. package/src/debate/debateFile.ts +38 -0
  24. package/src/debate/debateResults.ts +115 -0
  25. package/src/debate/debateRounds.ts +61 -0
  26. package/src/debate/runDebate.ts +143 -0
  27. package/src/index.ts +283 -0
  28. package/src/moa/conflictContract.ts +49 -0
  29. package/src/moa/conflicts.ts +153 -0
  30. package/src/moa/contextContract.ts +52 -0
  31. package/src/moa/fanout.ts +152 -0
  32. package/src/moa/fanoutWiring.ts +88 -0
  33. package/src/moa/implementationRetry.ts +292 -0
  34. package/src/moa/modelRuntime.ts +87 -0
  35. package/src/moa/orchestration.ts +105 -0
  36. package/src/moa/planInfo.ts +57 -0
  37. package/src/moa/planlessRetry.ts +72 -0
  38. package/src/moa/reviewLoop.ts +170 -0
  39. package/src/moa/runContext.ts +118 -0
  40. package/src/moa/synthesis.ts +420 -0
  41. package/src/moa/verdicts.ts +81 -0
  42. package/src/moa/verification.ts +791 -0
  43. package/src/moa/verificationCriteria.ts +127 -0
  44. package/src/moa/verifyGate.ts +137 -0
  45. package/src/opinion/opinionContract.ts +21 -0
  46. package/src/opinion/opinionFanout.ts +135 -0
  47. package/src/opinion/opinionFile.ts +38 -0
  48. package/src/opinion/opinionResults.ts +73 -0
  49. package/src/opinion/runOpinion.ts +156 -0
  50. package/src/planning/askUserQuestion.ts +83 -0
  51. package/src/planning/instructions.ts +146 -0
  52. package/src/planning/modeState.ts +61 -0
  53. package/src/planning/planFile.ts +273 -0
  54. package/src/planning/planMode.ts +673 -0
  55. package/src/planning/tools/enterPlanMode.ts +165 -0
  56. package/src/planning/tools/exitPlanMode.ts +159 -0
  57. package/src/planning/tools/mfPlanSubagent.ts +311 -0
  58. package/src/planning/tools/shared.ts +19 -0
  59. package/src/planning/tools/writePlan.ts +33 -0
  60. package/src/runtime/activityTracking.ts +141 -0
  61. package/src/runtime/cancelRun.ts +134 -0
  62. package/src/runtime/mutationTripwire.ts +251 -0
  63. package/src/runtime/processPool.ts +55 -0
  64. package/src/runtime/results.ts +103 -0
  65. package/src/runtime/runner.ts +538 -0
  66. package/src/runtime/wire.ts +177 -0
  67. package/src/shared/functionKeys.ts +30 -0
  68. package/src/shared/modelRefs.ts +91 -0
  69. package/src/ui/agentStatus.ts +84 -0
  70. package/src/ui/agentTranscript.ts +112 -0
  71. package/src/ui/cancelOverlay.ts +191 -0
  72. package/src/ui/chrome.ts +151 -0
  73. package/src/ui/conflictOverlay.ts +363 -0
  74. package/src/ui/debateModelPicker.ts +273 -0
  75. package/src/ui/menu.ts +679 -0
  76. package/src/ui/moaModelPicker.ts +900 -0
  77. package/src/ui/moaProgressWidget.ts +910 -0
  78. package/src/ui/moaSetupOverlay.ts +368 -0
  79. package/src/ui/modelLabel.ts +61 -0
  80. package/src/ui/observeOverlay.ts +206 -0
  81. package/src/ui/opinionModelPicker.ts +246 -0
  82. package/src/ui/planReviewOverlay.ts +315 -0
  83. package/src/ui/promptEditor.ts +87 -0
  84. package/src/ui/rosterEditor.ts +310 -0
  85. package/src/ui/shimmer.ts +77 -0
  86. package/src/ui/toolActivity.ts +35 -0
  87. package/src/ui/twoPaneModelThinking.ts +272 -0
  88. package/src/ui/verificationFindingsOverlay.ts +137 -0
@@ -0,0 +1,900 @@
1
+ /**
2
+ * MoA model-select TUI: shown right after the plan-prompt editor closes.
3
+ *
4
+ * Screen 0 is the mode chooser ("Single model" vs "Mixture of Agents Fusion").
5
+ * Choosing "Single model" resolves immediately with `{ mode: "single" }`.
6
+ * Choosing "Mixture of Agents" opens the overview ("MoA Fusion Pre-flight") with
7
+ * the eight slots — five proposers plus synthesizer, implementer, and verifier —
8
+ * starting unassigned at `(none)`, plus a Load Roster row that applies a saved
9
+ * roster to every slot wholesale, and a Start fan-out action. Enter on a slot
10
+ * opens its two-pane model/thinking picker; confirming writes only that slot
11
+ * and returns to the overview, cancelling returns without changing it. The
12
+ * overview's "Start fan-out" action stays visually disabled until at least two
13
+ * proposer slots and all three required roles are assigned, then it finishes
14
+ * the picker.
15
+ * Choosing every role up front means approval applies the pre-chosen
16
+ * implementer with no second picker, and the verifier is ready to judge the
17
+ * result once implementation settles.
18
+ *
19
+ * All screens live in one Component instance. Esc from a slot picker returns to
20
+ * the overview without committing; Esc from the overview returns to the mode
21
+ * chooser (assignments are preserved); Esc from the mode chooser cancels the
22
+ * whole picker.
23
+ *
24
+ * Saved and current-model choices only seed a slot picker's initial highlight
25
+ * while that slot is still empty — they never count as assignments, so
26
+ * readiness always reflects explicit selections.
27
+ *
28
+ * Models and thinking levels both come from the model registry (see
29
+ * modelCatalogue.ts), so a confirmed selection is usable by construction and
30
+ * nothing verifies it after the picker closes. Whether a model actually answers
31
+ * is decided when it runs.
32
+ */
33
+
34
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
35
+ import { Key, matchesKey, visibleWidth, type SelectItem, type Component, type TUI } from "@earendil-works/pi-tui";
36
+ import {
37
+ SELECTOR_POINTER,
38
+ SQUARE_SINGLE_BOX,
39
+ UNSELECTED_POINTER,
40
+ createFrame,
41
+ ratioViewport,
42
+ wrapWords,
43
+ } from "./chrome.ts";
44
+ import { PLAN_OVERLAY_OPTIONS } from "./menu.ts";
45
+ import { TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
46
+ import { smartTruncateModelLabel } from "./modelLabel.ts";
47
+ import { getModelCatalogue } from "../config/modelCatalogue.ts";
48
+ import {
49
+ loadMoaConfig,
50
+ moaSettingsExist,
51
+ type MoaConfig,
52
+ } from "../config/settings.ts";
53
+ import {
54
+ modelRefLabel,
55
+ type ModelRef,
56
+ type ThinkingLevel,
57
+ } from "../shared/modelRefs.ts";
58
+ import { defaultThinkingForModel, thinkingOptionsForModel } from "../config/settings.ts";
59
+ import { rosterSummary, type PlanRoster, type RosterSlot } from "../config/rosters.ts";
60
+
61
+ const HINT_BASE = "type filters models • ↑↓ navigate pane • tab panes/buttons • ←→ switch/select • enter select";
62
+
63
+ export const MAX_PROPOSERS = 5;
64
+ const MIN_PROPOSERS = 2;
65
+ const IMPLEMENTER_SCREEN = MAX_PROPOSERS + 2;
66
+ const VERIFIER_SCREEN = MAX_PROPOSERS + 3;
67
+ const CONFIRM_SCREEN = MAX_PROPOSERS + 4;
68
+ const ROSTER_SCREEN = MAX_PROPOSERS + 5;
69
+
70
+ // Overview row indices, in render order: Load Roster, then proposer slots,
71
+ // the three required roles, and the start action. The blank lines are visual
72
+ // separators and do not have focusable row indices.
73
+ const LOAD_ROW = 0;
74
+ const SYNTH_ROW = MAX_PROPOSERS + 1;
75
+ const IMPLEMENTER_ROW = MAX_PROPOSERS + 2;
76
+ const VERIFIER_ROW = MAX_PROPOSERS + 3;
77
+ const START_ROW = MAX_PROPOSERS + 4;
78
+ const OVERVIEW_ROW_COUNT = MAX_PROPOSERS + 5;
79
+
80
+ export type MoaPickerResult =
81
+ | { mode: "single" }
82
+ | {
83
+ mode: "moa";
84
+ proposers: ModelRef[];
85
+ synthesizer: ModelRef;
86
+ implementer: ModelRef;
87
+ verifier: ModelRef;
88
+ proposerThinking: (ThinkingLevel | undefined)[];
89
+ synthesizerThinking: ThinkingLevel | undefined;
90
+ implementerThinking: ThinkingLevel | undefined;
91
+ verifierThinking: ThinkingLevel | undefined;
92
+ thinkingSelections: Record<string, ThinkingLevel>;
93
+ };
94
+
95
+ const MODE_ITEMS: SelectItem[] = [
96
+ {
97
+ value: "moa",
98
+ label: "Mixture of Agents Fusion",
99
+ description: "Fan out to up to 5 proposer models, then reconcile their plans with a synthesizer model, implement, and verify",
100
+ },
101
+ {
102
+ value: "single",
103
+ label: "Single model",
104
+ description: "The active model runs the full plan-mode workflow itself.",
105
+ },
106
+ ];
107
+
108
+ /**
109
+ * Selectable registry models for the MoA pickers and setup overlay. Every model
110
+ * pi reports as available is offered — no locally maintained API allowlist
111
+ * narrows this — so whether a model actually answers is still decided at run
112
+ * time, not here.
113
+ */
114
+ export function getAvailableModelRefs(ctx: ExtensionContext): ModelRef[] {
115
+ return getModelCatalogue(ctx.modelRegistry).availableRefs();
116
+ }
117
+
118
+ const SLOT_TITLES = ["Proposer 1", "Proposer 2", "Proposer 3", "Proposer 4", "Proposer 5"];
119
+
120
+ class MoaModelPickerComponent implements Component {
121
+ // 0 = mode chooser; 1..MAX_PROPOSERS = proposer slots; MAX_PROPOSERS+1 =
122
+ // synthesizer; IMPLEMENTER_SCREEN = implementer; VERIFIER_SCREEN = verifier;
123
+ // CONFIRM_SCREEN = the overview. The overview is the hub: every slot picker is
124
+ // opened from it and returns to it.
125
+ private screen = 0;
126
+ private modeIndex = 0;
127
+ // Overview rows, in order: Load Roster, MAX_PROPOSERS proposer slots, then
128
+ // synthesizer, implementer, verifier, and the start action (row START_ROW).
129
+ private confirmIndex = 0;
130
+ // Cursor on the Load Roster screen's roster list.
131
+ private rosterIndex = 0;
132
+ private readonly currentThinking: ThinkingLevel;
133
+ private readonly availableLabels: Set<string>;
134
+ private readonly sortedRosters: { roster: PlanRoster; availableSlots: number }[];
135
+ private readonly twoPane: TwoPaneModelThinking;
136
+ // Explicit per-slot assignments; `undefined` means the slot is still `(none)`.
137
+ private readonly proposerRefs: (ModelRef | undefined)[] = Array.from({ length: MAX_PROPOSERS }, () => undefined);
138
+ private readonly proposerThinking: (ThinkingLevel | undefined)[] = Array.from({ length: MAX_PROPOSERS }, () => undefined);
139
+ private synthesizerRef: ModelRef | undefined;
140
+ private synthesizerThinking: ThinkingLevel | undefined;
141
+ private implementerRef: ModelRef | undefined;
142
+ private implementerThinking: ThinkingLevel | undefined;
143
+ private verifierRef: ModelRef | undefined;
144
+ private verifierThinking: ThinkingLevel | undefined;
145
+ private readonly thinkingSelections: Record<string, ThinkingLevel> = {};
146
+
147
+ constructor(
148
+ private readonly tui: TUI,
149
+ private readonly theme: Theme,
150
+ availableRefs: ModelRef[],
151
+ private readonly defaults: { mode: SelectItem["value"]; slots: ModelRef[] },
152
+ private readonly config: MoaConfig,
153
+ currentThinking: ThinkingLevel,
154
+ rosters: PlanRoster[],
155
+ private readonly ctx: ExtensionContext,
156
+ private readonly done: (result: MoaPickerResult | undefined) => void,
157
+ ) {
158
+ this.modeIndex = Math.max(0, MODE_ITEMS.findIndex((i) => i.value === this.defaults.mode));
159
+ this.currentThinking = currentThinking;
160
+ this.availableLabels = new Set(availableRefs.map(modelRefLabel));
161
+ this.sortedRosters = rosters
162
+ .map((roster) => ({
163
+ roster,
164
+ availableSlots: [
165
+ ...roster.proposers,
166
+ roster.synthesizer,
167
+ roster.implementer,
168
+ roster.verifier,
169
+ ].filter((slot) => this.availableLabels.has(modelRefLabel(slot.ref))).length,
170
+ }))
171
+ .sort((a, b) => a.roster.name.localeCompare(b.roster.name));
172
+ this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
173
+ }
174
+
175
+ /** The committed model/thinking pair for a slot picker screen, if assigned. */
176
+ private slotSelection(screen: number): { ref: ModelRef | undefined; thinking: ThinkingLevel | undefined } {
177
+ const role = this.nonProposerRole(screen);
178
+ if (role) return this.getRole(role);
179
+ return { ref: this.proposerRefs[screen - 1], thinking: this.proposerThinking[screen - 1] };
180
+ }
181
+
182
+ /**
183
+ * Open a slot's two-pane picker from the overview. A committed slot re-opens
184
+ * on its own choice; an empty slot highlights the saved/current default — a
185
+ * hint only, never an assignment until the pick is confirmed.
186
+ */
187
+ private openSlot(screen: number): void {
188
+ const committed = this.slotSelection(screen);
189
+ if (committed.ref) this.twoPane.reset(committed.ref, committed.thinking);
190
+ else this.twoPane.reset(this.defaults.slots[screen - 1]);
191
+ this.screen = screen;
192
+ this.tui.requestRender();
193
+ }
194
+
195
+ private confirmModeScreen(): void {
196
+ const item = MODE_ITEMS[this.modeIndex];
197
+ if (!item) return;
198
+ if (item.value === "single") {
199
+ this.done({ mode: "single" });
200
+ return;
201
+ }
202
+ this.screen = CONFIRM_SCREEN;
203
+ this.confirmIndex = 0;
204
+ this.tui.requestRender();
205
+ }
206
+
207
+ /** Which single-slot role a non-proposer screen configures, if any. */
208
+ private nonProposerRole(screen: number): "synthesizer" | "implementer" | "verifier" | undefined {
209
+ if (screen === MAX_PROPOSERS + 1) return "synthesizer";
210
+ if (screen === IMPLEMENTER_SCREEN) return "implementer";
211
+ if (screen === VERIFIER_SCREEN) return "verifier";
212
+ return undefined;
213
+ }
214
+
215
+ private setRole(role: "synthesizer" | "implementer" | "verifier", ref: ModelRef, thinking: ThinkingLevel): void {
216
+ if (role === "synthesizer") { this.synthesizerRef = ref; this.synthesizerThinking = thinking; }
217
+ else if (role === "implementer") { this.implementerRef = ref; this.implementerThinking = thinking; }
218
+ else { this.verifierRef = ref; this.verifierThinking = thinking; }
219
+ }
220
+
221
+ private getRole(role: "synthesizer" | "implementer" | "verifier"): { ref: ModelRef | undefined; thinking: ThinkingLevel | undefined } {
222
+ if (role === "synthesizer") return { ref: this.synthesizerRef, thinking: this.synthesizerThinking };
223
+ if (role === "implementer") return { ref: this.implementerRef, thinking: this.implementerThinking };
224
+ return { ref: this.verifierRef, thinking: this.verifierThinking };
225
+ }
226
+
227
+ /** Store the confirmed pair for the active slot and return to the overview. */
228
+ private commitSlot(selection: { ref: ModelRef; thinking: ThinkingLevel }): void {
229
+ const { ref, thinking } = selection;
230
+ this.thinkingSelections[modelRefLabel(ref)] = thinking;
231
+ const role = this.nonProposerRole(this.screen);
232
+ if (role) {
233
+ this.setRole(role, ref, thinking);
234
+ } else {
235
+ this.proposerRefs[this.screen - 1] = ref;
236
+ this.proposerThinking[this.screen - 1] = thinking;
237
+ }
238
+ this.screen = CONFIRM_SCREEN;
239
+ this.tui.requestRender();
240
+ }
241
+
242
+ /** Count of proposer slots holding an explicit assignment. */
243
+ private assignedProposerCount(): number {
244
+ return this.proposerRefs.reduce((n, ref) => (ref ? n + 1 : n), 0);
245
+ }
246
+
247
+ /**
248
+ * Ready to start once at least MIN_PROPOSERS proposer slots plus all three
249
+ * required roles hold explicit assignments. Counts assigned slots, not
250
+ * distinct models — the same model may fill several slots.
251
+ */
252
+ private isReady(): boolean {
253
+ return this.assignedProposerCount() >= MIN_PROPOSERS
254
+ && this.synthesizerRef !== undefined
255
+ && this.implementerRef !== undefined
256
+ && this.verifierRef !== undefined;
257
+ }
258
+
259
+ /** Concise list of what "Start fan-out" is still waiting on. */
260
+ private missingSummary(): string {
261
+ const parts: string[] = [];
262
+ const shortfall = MIN_PROPOSERS - this.assignedProposerCount();
263
+ if (shortfall > 0) parts.push(`${shortfall} more proposer${shortfall === 1 ? "" : "s"}`);
264
+ if (this.synthesizerRef === undefined) parts.push("synthesizer");
265
+ if (this.implementerRef === undefined) parts.push("implementer");
266
+ if (this.verifierRef === undefined) parts.push("verifier");
267
+ return parts.length ? `needs ${parts.join(", ")}` : "";
268
+ }
269
+
270
+ /**
271
+ * Registry-supported thinking level for a roster slot: the roster's level
272
+ * when the model still offers it, otherwise the picker's usual fallback
273
+ * chain (saved override, current level, medium, whatever exists).
274
+ */
275
+ private rosterThinking(ref: ModelRef, wanted: ThinkingLevel): ThinkingLevel {
276
+ const registryLevels = getModelCatalogue(this.ctx.modelRegistry).thinkingLevelsFor(ref);
277
+ const options = thinkingOptionsForModel(registryLevels);
278
+ if (options.includes(wanted)) return wanted;
279
+ return defaultThinkingForModel(modelRefLabel(ref), this.config, this.currentThinking, registryLevels);
280
+ }
281
+
282
+ /** Overview row of the first still-unassigned required slot, or the Start action when ready. */
283
+ private firstIncompleteRow(): number {
284
+ if (this.assignedProposerCount() < MIN_PROPOSERS) {
285
+ for (let i = 0; i < MAX_PROPOSERS; i++) {
286
+ if (this.proposerRefs[i] === undefined) return i + 1;
287
+ }
288
+ }
289
+ if (this.synthesizerRef === undefined) return SYNTH_ROW;
290
+ if (this.implementerRef === undefined) return IMPLEMENTER_ROW;
291
+ if (this.verifierRef === undefined) return VERIFIER_ROW;
292
+ return START_ROW;
293
+ }
294
+
295
+ /**
296
+ * Replace every slot assignment with a roster's — wholesale, so a 2-proposer
297
+ * roster clears stale picks from slots 3–5. Slots whose model is no longer
298
+ * available are cleared and reported; the roster definition itself is never
299
+ * modified.
300
+ */
301
+ private applyRoster(roster: PlanRoster): void {
302
+ const isAvailable = (slot: RosterSlot) => this.availableLabels.has(modelRefLabel(slot.ref));
303
+ const skipped: string[] = [];
304
+ const apply = (label: string, slot: RosterSlot | undefined, set: (ref: ModelRef, thinking: ThinkingLevel) => void, clear: () => void): void => {
305
+ if (slot && isAvailable(slot)) {
306
+ const thinking = this.rosterThinking(slot.ref, slot.thinking);
307
+ set(slot.ref, thinking);
308
+ this.thinkingSelections[modelRefLabel(slot.ref)] = thinking;
309
+ } else {
310
+ clear();
311
+ if (slot) skipped.push(label);
312
+ }
313
+ };
314
+ for (let i = 0; i < MAX_PROPOSERS; i++) {
315
+ apply(SLOT_TITLES[i] ?? `Proposer ${i + 1}`, roster.proposers[i],
316
+ (ref, thinking) => { this.proposerRefs[i] = ref; this.proposerThinking[i] = thinking; },
317
+ () => { this.proposerRefs[i] = undefined; this.proposerThinking[i] = undefined; });
318
+ }
319
+ apply("Synthesizer", roster.synthesizer,
320
+ (ref, thinking) => { this.synthesizerRef = ref; this.synthesizerThinking = thinking; },
321
+ () => { this.synthesizerRef = undefined; this.synthesizerThinking = undefined; });
322
+ apply("Implementer", roster.implementer,
323
+ (ref, thinking) => { this.implementerRef = ref; this.implementerThinking = thinking; },
324
+ () => { this.implementerRef = undefined; this.implementerThinking = undefined; });
325
+ apply("Verifier", roster.verifier,
326
+ (ref, thinking) => { this.verifierRef = ref; this.verifierThinking = thinking; },
327
+ () => { this.verifierRef = undefined; this.verifierThinking = undefined; });
328
+ if (skipped.length > 0) {
329
+ this.ctx.ui.notify(`Roster ${roster.name}: skipped ${skipped.join(", ")} — model not available`, "warning");
330
+ }
331
+ this.screen = CONFIRM_SCREEN;
332
+ this.confirmIndex = this.isReady() ? START_ROW : this.firstIncompleteRow();
333
+ this.tui.requestRender();
334
+ }
335
+
336
+ /** Emit the assembled MoA result — a no-op unless every requirement is met. */
337
+ private finish(): void {
338
+ if (!this.isReady()) return;
339
+ // Compact the sparse proposer slots into dense, slot-ordered arrays so the
340
+ // paired model/thinking indices stay aligned downstream.
341
+ const proposers: ModelRef[] = [];
342
+ const proposerThinking: (ThinkingLevel | undefined)[] = [];
343
+ for (let i = 0; i < MAX_PROPOSERS; i++) {
344
+ const ref = this.proposerRefs[i];
345
+ if (!ref) continue;
346
+ proposers.push(ref);
347
+ proposerThinking.push(this.proposerThinking[i]);
348
+ }
349
+ this.done({
350
+ mode: "moa",
351
+ proposers,
352
+ synthesizer: this.synthesizerRef!,
353
+ implementer: this.implementerRef!,
354
+ verifier: this.verifierRef!,
355
+ proposerThinking,
356
+ synthesizerThinking: this.synthesizerThinking,
357
+ implementerThinking: this.implementerThinking,
358
+ verifierThinking: this.verifierThinking,
359
+ thinkingSelections: { ...this.thinkingSelections },
360
+ });
361
+ }
362
+
363
+ handleInput(data: string): void {
364
+ if (this.screen === 0) {
365
+ if (matchesKey(data, Key.enter)) {
366
+ this.confirmModeScreen();
367
+ return;
368
+ }
369
+ if (matchesKey(data, Key.escape)) {
370
+ this.done(undefined);
371
+ return;
372
+ }
373
+ if (matchesKey(data, Key.up)) {
374
+ if (this.modeIndex > 0) {
375
+ this.modeIndex--;
376
+ this.tui.requestRender();
377
+ }
378
+ return;
379
+ }
380
+ if (matchesKey(data, Key.down)) {
381
+ if (this.modeIndex < MODE_ITEMS.length - 1) {
382
+ this.modeIndex++;
383
+ this.tui.requestRender();
384
+ }
385
+ return;
386
+ }
387
+ return;
388
+ }
389
+
390
+ if (this.screen === CONFIRM_SCREEN) {
391
+ if (matchesKey(data, Key.up)) {
392
+ if (this.confirmIndex > 0) {
393
+ this.confirmIndex--;
394
+ this.tui.requestRender();
395
+ }
396
+ return;
397
+ }
398
+ if (matchesKey(data, Key.down)) {
399
+ if (this.confirmIndex < OVERVIEW_ROW_COUNT - 1) {
400
+ this.confirmIndex++;
401
+ this.tui.requestRender();
402
+ }
403
+ return;
404
+ }
405
+ if (matchesKey(data, Key.enter)) {
406
+ if (this.confirmIndex === LOAD_ROW) {
407
+ if (this.sortedRosters.length > 0) {
408
+ this.screen = ROSTER_SCREEN;
409
+ this.rosterIndex = 0;
410
+ this.tui.requestRender();
411
+ }
412
+ // With no rosters saved the row is a disabled no-op: Enter is swallowed.
413
+ } else if (this.confirmIndex === START_ROW) {
414
+ if (this.isReady()) {
415
+ // Start action — activation is gated on readiness here, and
416
+ // finish() re-checks so an incomplete roster can never escape.
417
+ this.finish();
418
+ }
419
+ } else {
420
+ // Rows 1..VERIFIER_ROW map to slot picker screens 1..VERIFIER_SCREEN.
421
+ this.openSlot(this.confirmIndex);
422
+ }
423
+ // A disabled Start swallows Enter with no state change.
424
+ return;
425
+ }
426
+ if (matchesKey(data, Key.escape)) {
427
+ // Back to the mode chooser; assignments are preserved.
428
+ this.screen = 0;
429
+ this.tui.requestRender();
430
+ return;
431
+ }
432
+ // Consume everything else — the hidden twoPane must not see input here.
433
+ return;
434
+ }
435
+
436
+ if (this.screen === ROSTER_SCREEN) {
437
+ if (matchesKey(data, Key.escape)) {
438
+ this.screen = CONFIRM_SCREEN;
439
+ this.tui.requestRender();
440
+ return;
441
+ }
442
+ if (matchesKey(data, Key.up)) {
443
+ if (this.rosterIndex > 0) {
444
+ this.rosterIndex--;
445
+ this.tui.requestRender();
446
+ }
447
+ return;
448
+ }
449
+ if (matchesKey(data, Key.down)) {
450
+ if (this.rosterIndex < this.sortedRosters.length - 1) {
451
+ this.rosterIndex++;
452
+ this.tui.requestRender();
453
+ }
454
+ return;
455
+ }
456
+ if (matchesKey(data, Key.enter)) {
457
+ const entry = this.sortedRosters[this.rosterIndex];
458
+ // A roster with no callable models is dimmed and cannot be loaded.
459
+ if (entry && entry.availableSlots > 0) this.applyRoster(entry.roster);
460
+ return;
461
+ }
462
+ return;
463
+ }
464
+
465
+ // Model/thinking screens (1..VERIFIER_SCREEN): confirm commits the slot and
466
+ // returns to the overview; back returns without changing the committed pick.
467
+ const action = this.twoPane.handleInput(data);
468
+ if (action === "confirm") {
469
+ this.commitSlot(this.twoPane.getSelected());
470
+ return;
471
+ }
472
+ if (action === "back") {
473
+ this.screen = CONFIRM_SCREEN;
474
+ this.tui.requestRender();
475
+ }
476
+ }
477
+
478
+ render(width: number): string[] {
479
+ const th = this.theme;
480
+ const innerWidth = Math.max(20, width - 4);
481
+ const frame = createFrame(th, innerWidth, {
482
+ glyphs: SQUARE_SINGLE_BOX,
483
+ horizontalPadding: 0,
484
+ truncationMark: "...",
485
+ padToWidth: true,
486
+ minimumBodyWidth: 10,
487
+ });
488
+ const bodyWidth = frame.bodyWidth;
489
+ const row = (content: string) => frame.row(` ${content}`);
490
+ const topBorder = frame.top();
491
+ const sepBorder = frame.separator();
492
+ const botBorder = frame.bottom();
493
+
494
+ const viewport = ratioViewport(process.stdout.rows, {
495
+ fallbackRows: 24,
496
+ ratio: 0.7,
497
+ minimum: 6,
498
+ });
499
+ if (this.screen === 0) {
500
+ const lines: string[] = [
501
+ topBorder,
502
+ row(th.fg("accent", "Plan mode \u2014 choose how to generate this plan")),
503
+ sepBorder,
504
+ row(""),
505
+ ];
506
+
507
+ for (let i = 0; i < MODE_ITEMS.length; i++) {
508
+ const item = MODE_ITEMS[i];
509
+ if (!item) continue;
510
+ const isCurrent = i === this.modeIndex;
511
+ const pointer = isCurrent ? th.fg("accent", SELECTOR_POINTER) : UNSELECTED_POINTER;
512
+ const labelText = isCurrent
513
+ ? th.bold(th.fg("accent", item.label))
514
+ : th.bold(item.label);
515
+ lines.push(row(pointer + labelText));
516
+
517
+ // Description: word-wrapped, indented, muted
518
+ const descWidth = bodyWidth - 4; // 2-char indent inside pad's leading space
519
+ if (item.description && descWidth > 0) {
520
+ const descLines = wrapWords(item.description, descWidth);
521
+ for (const dl of descLines) {
522
+ lines.push(row(th.fg("muted", " " + dl)));
523
+ }
524
+ }
525
+
526
+ // Blank line between cards
527
+ lines.push(row(""));
528
+ }
529
+
530
+ lines.push(
531
+ row(th.fg("dim", "\u2191\u2193 navigate \u2022 enter select \u2022 esc cancel")),
532
+ botBorder,
533
+ );
534
+ if (lines.length <= viewport) return lines;
535
+ const compactItems = MODE_ITEMS.map((item, index) => {
536
+ const isCurrent = index === this.modeIndex;
537
+ const pointer = isCurrent ? th.fg("accent", SELECTOR_POINTER) : UNSELECTED_POINTER;
538
+ const label = isCurrent ? th.bold(th.fg("accent", item.label)) : th.bold(item.label);
539
+ return row(pointer + label);
540
+ });
541
+ return [
542
+ topBorder,
543
+ row(th.fg("accent", "Plan mode \u2014 choose how to generate this plan")),
544
+ ...compactItems,
545
+ row(th.fg("dim", "\u2191\u2193 navigate \u2022 enter select \u2022 esc cancel")),
546
+ botBorder,
547
+ ].slice(0, viewport);
548
+ }
549
+
550
+ if (this.screen === CONFIRM_SCREEN) {
551
+ const ready = this.isReady();
552
+ const active = (index: number) => index === this.confirmIndex;
553
+ // `disabled` keeps a focusable row (pointer still shows) but muted, so
554
+ // the not-yet-ready Start action reads as inactive even when selected.
555
+ const slotRow = (label: string, detailText: string, index: number, disabled = false) => {
556
+ const isActive = active(index);
557
+ const pointer = isActive ? th.fg("accent", SELECTOR_POINTER) : UNSELECTED_POINTER;
558
+ const labelText = disabled
559
+ ? (isActive ? th.bold(th.fg("muted", label)) : th.fg("dim", label))
560
+ : (isActive ? th.bold(th.fg("accent", label)) : th.bold(label));
561
+ const detailSuffix = detailText ? ` ${th.fg("muted", detailText)}` : "";
562
+ return frame.row(` ${pointer}${labelText}${detailSuffix}`, isActive ? "selectedBg" : undefined);
563
+ };
564
+ const detail = (label: string, ref: ModelRef | undefined, thinking: ThinkingLevel | undefined) => {
565
+ if (!ref) return "(none)";
566
+ const suffix = ` \u00B7 thinking: ${thinking ?? "\u2014"}`;
567
+ // row = leading space (1) + pointer (2) + label + 2-space gap (2) + detail
568
+ const modelWidth = Math.max(4, bodyWidth - 5 - visibleWidth(label) - visibleWidth(suffix));
569
+ return `${smartTruncateModelLabel(modelRefLabel(ref), modelWidth)}${suffix}`;
570
+ };
571
+ const startRow = slotRow("Start fan-out", ready ? "" : this.missingSummary(), START_ROW, !ready);
572
+ const loadEnabled = this.sortedRosters.length > 0;
573
+ const loadRow = slotRow(
574
+ "Load Roster",
575
+ loadEnabled ? `${this.sortedRosters.length} saved` : "no rosters saved — add one in /mf-plan-settings",
576
+ LOAD_ROW,
577
+ !loadEnabled,
578
+ );
579
+ const slotRows = [
580
+ loadRow,
581
+ ...this.proposerRefs.map((ref, i) => {
582
+ const label = SLOT_TITLES[i] ?? `Proposer ${i + 1}`;
583
+ return slotRow(label, detail(label, ref, this.proposerThinking[i]), i + 1);
584
+ }),
585
+ slotRow("Synthesizer", detail("Synthesizer", this.synthesizerRef, this.synthesizerThinking), SYNTH_ROW),
586
+ slotRow("Implementer", detail("Implementer", this.implementerRef, this.implementerThinking), IMPLEMENTER_ROW),
587
+ slotRow("Verifier", detail("Verifier", this.verifierRef, this.verifierThinking), VERIFIER_ROW),
588
+ startRow,
589
+ ];
590
+ const reviewRows = [loadRow, row(""), ...slotRows.slice(1, -1), row(""), startRow];
591
+ const lines = [
592
+ topBorder,
593
+ row(th.fg("accent", "MoA Fusion Pre-flight")),
594
+ sepBorder,
595
+ ...reviewRows,
596
+ sepBorder,
597
+ row(th.fg("dim", "\u2191\u2193 navigate \u2022 enter select \u2022 esc back")),
598
+ botBorder,
599
+ ];
600
+ if (lines.length <= viewport) return lines;
601
+ // Compact: drop the hints row, then window the visual rows around the
602
+ // pointer so the selected row stays visible without losing separators.
603
+ const maxSlotRows = Math.max(1, viewport - 4);
604
+ const selectedVisualIndex = this.confirmIndex === LOAD_ROW
605
+ ? 0
606
+ : this.confirmIndex === START_ROW
607
+ ? reviewRows.length - 1
608
+ : this.confirmIndex + 1;
609
+ const start = Math.max(0, Math.min(selectedVisualIndex - Math.floor(maxSlotRows / 2), reviewRows.length - maxSlotRows));
610
+ return [
611
+ topBorder,
612
+ row(th.fg("accent", "MoA Fusion Pre-flight")),
613
+ sepBorder,
614
+ ...reviewRows.slice(start, start + maxSlotRows),
615
+ botBorder,
616
+ ].slice(0, viewport);
617
+ }
618
+
619
+ if (this.screen === ROSTER_SCREEN) {
620
+ const rosterRow = (label: string, summary: string, index: number, disabled: boolean) => {
621
+ const isActive = index === this.rosterIndex;
622
+ const pointer = isActive ? th.fg("accent", SELECTOR_POINTER) : UNSELECTED_POINTER;
623
+ const labelText = disabled
624
+ ? (isActive ? th.bold(th.fg("muted", label)) : th.fg("dim", label))
625
+ : (isActive ? th.bold(th.fg("accent", label)) : th.bold(label));
626
+ const detailSuffix = summary ? ` ${th.fg("muted", summary)}` : "";
627
+ return frame.row(` ${pointer}${labelText}${detailSuffix}`);
628
+ };
629
+ const rosterRows = this.sortedRosters.map((entry, index) =>
630
+ rosterRow(entry.roster.name, rosterSummary(entry.roster), index, entry.availableSlots === 0));
631
+ const lines: string[] = [
632
+ topBorder,
633
+ row(th.fg("accent", "Load Roster")),
634
+ sepBorder,
635
+ ...(rosterRows.length > 0 ? rosterRows : [row(th.fg("dim", "No rosters saved — create them in /mf-plan-settings."))]),
636
+ sepBorder,
637
+ row(th.fg("dim", "\u2191\u2193 navigate \u2022 enter load \u2022 esc back")),
638
+ botBorder,
639
+ ];
640
+ if (lines.length <= viewport) return lines;
641
+ // Window roster rows around the pointer, mirroring the overview's compact path.
642
+ const maxRosterRows = Math.max(1, viewport - 4);
643
+ const start = Math.max(0, Math.min(this.rosterIndex - Math.floor(maxRosterRows / 2), rosterRows.length - maxRosterRows));
644
+ return [
645
+ topBorder,
646
+ row(th.fg("accent", "Load Roster")),
647
+ sepBorder,
648
+ ...rosterRows.slice(start, start + maxRosterRows),
649
+ botBorder,
650
+ ].slice(0, viewport);
651
+ }
652
+
653
+ // Screens 1..VERIFIER_SCREEN — model-select screens (proposers 1-5,
654
+ // synthesizer, implementer, verifier).
655
+ const isProposerScreen = this.screen >= 1 && this.screen <= MAX_PROPOSERS;
656
+ const nonProposerLabel = this.screen === IMPLEMENTER_SCREEN
657
+ ? "Implementer"
658
+ : this.screen === VERIFIER_SCREEN
659
+ ? "Verifier"
660
+ : "Synthesizer";
661
+ const slotLabel = isProposerScreen ? (SLOT_TITLES[this.screen - 1] ?? "Synthesizer") : nonProposerLabel;
662
+ const roleSubtitle = this.screen === IMPLEMENTER_SCREEN
663
+ ? " \u2014 writes the approved plan"
664
+ : this.screen === VERIFIER_SCREEN
665
+ ? " \u2014 checks the implementation against the plan"
666
+ : "";
667
+ const title = isProposerScreen
668
+ ? `${slotLabel} (${this.screen}/${MAX_PROPOSERS})`
669
+ : `${slotLabel} model${roleSubtitle}`;
670
+ const hints = HINT_BASE + " • esc back";
671
+ const { actionRow, hintRows } = this.twoPane.renderFooter(bodyWidth, hints);
672
+ this.twoPane.setMaxVisibleRows(Math.max(1, viewport - 8 - (hintRows.length - 1)));
673
+ const twoPaneLines = this.twoPane.render(bodyWidth);
674
+ const framedPane = twoPaneLines.map((line) => frame.row(line));
675
+ const lines: string[] = [
676
+ topBorder,
677
+ row(th.fg("accent", title)),
678
+ sepBorder,
679
+ ...framedPane,
680
+ sepBorder,
681
+ frame.row(actionRow),
682
+ sepBorder,
683
+ ...hintRows.map((line) => frame.row(line)),
684
+ botBorder,
685
+ ];
686
+ if (lines.length <= viewport) return lines;
687
+ return [
688
+ topBorder,
689
+ row(th.fg("accent", title)),
690
+ ...framedPane.slice(0, Math.max(1, viewport - 4)),
691
+ frame.row(actionRow),
692
+ botBorder,
693
+ ].slice(0, viewport);
694
+ }
695
+
696
+ invalidate(): void {
697
+ this.twoPane.invalidate();
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Show the MoA model-select picker. In MoA mode it covers every role up front
703
+ * — proposers, synthesizer, implementer, and verifier — so approval applies
704
+ * the pre-picked implementer with no second picker. Returns `undefined` if the
705
+ * user cancelled outright (Esc from the mode screen) — cancels and keeps the
706
+ * current model without prompting. Returns `{ mode: "single" }` when the
707
+ * user chose "Single model", which additionally opens the
708
+ * implementing-model picker downstream.
709
+ */
710
+ export async function showMoaModelPicker(
711
+ ctx: ExtensionContext,
712
+ currentThinking: ThinkingLevel,
713
+ ): Promise<MoaPickerResult | undefined> {
714
+ if (!ctx.hasUI) return { mode: "single" };
715
+
716
+ const available = getAvailableModelRefs(ctx);
717
+
718
+ if (available.length === 0) {
719
+ // No models to pick from — fall back silently to single-model behavior.
720
+ return { mode: "single" };
721
+ }
722
+
723
+ const saved = loadMoaConfig();
724
+ const defaultMode = moaSettingsExist() ? saved.mode : "moa";
725
+ const currentModelRef: ModelRef | undefined = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
726
+ const hasValidSaved = saved.proposers.length >= MIN_PROPOSERS
727
+ && saved.proposers.length <= MAX_PROPOSERS
728
+ && saved.synthesizer !== undefined;
729
+ const defaultSlots: ModelRef[] = hasValidSaved
730
+ ? (() => {
731
+ const fill = currentModelRef ?? saved.proposers[0]!;
732
+ const slots = Array.from({ length: MAX_PROPOSERS + 3 }, () => fill);
733
+ for (let i = 0; i < saved.proposers.length && i < MAX_PROPOSERS; i++) {
734
+ slots[i] = saved.proposers[i]!;
735
+ }
736
+ slots[MAX_PROPOSERS] = saved.synthesizer!;
737
+ slots[MAX_PROPOSERS + 1] = saved.implementer ?? fill;
738
+ slots[MAX_PROPOSERS + 2] = saved.verifier ?? fill;
739
+ return slots;
740
+ })()
741
+ : currentModelRef
742
+ ? Array.from({ length: MAX_PROPOSERS + 3 }, () => currentModelRef)
743
+ : [];
744
+
745
+ return await ctx.ui.custom<MoaPickerResult | undefined>(
746
+ (tui, theme, _keybindings, done) =>
747
+ new MoaModelPickerComponent(
748
+ tui,
749
+ theme,
750
+ available,
751
+ { mode: defaultMode, slots: defaultSlots },
752
+ saved,
753
+ currentThinking,
754
+ saved.rosters,
755
+ ctx,
756
+ done,
757
+ ),
758
+ {
759
+ overlay: true,
760
+ overlayOptions: {
761
+ anchor: "center",
762
+ width: "82%",
763
+ minWidth: 64,
764
+ maxHeight: "70%",
765
+ margin: 1,
766
+ },
767
+ },
768
+ );
769
+ }
770
+
771
+ class ImplementingModelPickerComponent implements Component {
772
+ private readonly twoPane: TwoPaneModelThinking;
773
+
774
+ constructor(
775
+ private readonly tui: TUI,
776
+ private readonly theme: Theme,
777
+ availableRefs: ModelRef[],
778
+ config: MoaConfig,
779
+ currentThinking: ThinkingLevel,
780
+ private readonly ctx: ExtensionContext,
781
+ defaultRef: ModelRef | undefined,
782
+ defaultThinking: ThinkingLevel | undefined,
783
+ private readonly title: string,
784
+ private readonly done: (result: { ref: ModelRef; thinking: ThinkingLevel } | undefined) => void,
785
+ ) {
786
+ this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
787
+ this.twoPane.reset(defaultRef, defaultThinking);
788
+ }
789
+
790
+ handleInput(data: string): void {
791
+ const action = this.twoPane.handleInput(data);
792
+ if (action === "confirm") this.done(this.twoPane.getSelected());
793
+ else if (action === "back") this.done(undefined);
794
+ }
795
+
796
+ render(width: number): string[] {
797
+ const th = this.theme;
798
+ const innerWidth = Math.max(20, width - 4);
799
+ const frame = createFrame(th, innerWidth, {
800
+ glyphs: SQUARE_SINGLE_BOX,
801
+ horizontalPadding: 0,
802
+ truncationMark: "...",
803
+ padToWidth: true,
804
+ minimumBodyWidth: 10,
805
+ });
806
+ const bodyWidth = frame.bodyWidth;
807
+ const row = (content: string) => frame.row(` ${content}`);
808
+ const viewport = ratioViewport(process.stdout.rows, {
809
+ fallbackRows: 24,
810
+ ratio: 0.7,
811
+ minimum: 6,
812
+ });
813
+ const { actionRow, hintRows } = this.twoPane.renderFooter(bodyWidth, HINT_BASE + " • esc keep current");
814
+ this.twoPane.setMaxVisibleRows(Math.max(1, viewport - 8 - (hintRows.length - 1)));
815
+ const twoPaneLines = this.twoPane.render(bodyWidth);
816
+ const framedPane = twoPaneLines.map((line) => frame.row(line));
817
+ const lines = [
818
+ frame.top(),
819
+ row(th.fg("accent", this.title)),
820
+ frame.separator(),
821
+ ...framedPane,
822
+ frame.separator(),
823
+ frame.row(actionRow),
824
+ frame.separator(),
825
+ ...hintRows.map((line) => frame.row(line)),
826
+ frame.bottom(),
827
+ ];
828
+ if (lines.length <= viewport) return lines;
829
+ return [
830
+ frame.top(),
831
+ row(th.fg("accent", this.title)),
832
+ ...framedPane.slice(0, Math.max(1, viewport - 4)),
833
+ frame.row(actionRow),
834
+ frame.bottom(),
835
+ ].slice(0, viewport);
836
+ }
837
+
838
+ invalidate(): void {
839
+ this.twoPane.invalidate();
840
+ }
841
+ }
842
+
843
+ /**
844
+ * Show a one-shot two-pane picker for one model + thinking level, shared by the
845
+ * implementing-model prompt and the roster editor's per-slot picks. Defaults
846
+ * the highlight to `defaultRef`/`defaultThinking` when given. Returns the
847
+ * chosen pair, or `undefined` if the user cancelled or no UI is available
848
+ * (non-interactive mode).
849
+ */
850
+ export async function showModelThinkingPicker(
851
+ ctx: ExtensionContext,
852
+ currentThinking: ThinkingLevel,
853
+ title: string,
854
+ defaultRef?: ModelRef,
855
+ defaultThinking?: ThinkingLevel,
856
+ ): Promise<{ ref: ModelRef; thinking: ThinkingLevel } | undefined> {
857
+ if (!ctx.hasUI) return undefined;
858
+
859
+ const available = getAvailableModelRefs(ctx);
860
+ if (available.length === 0) return undefined;
861
+
862
+ const saved = loadMoaConfig();
863
+
864
+ return await ctx.ui.custom<{ ref: ModelRef; thinking: ThinkingLevel } | undefined>(
865
+ (tui, theme, _keybindings, done) =>
866
+ new ImplementingModelPickerComponent(
867
+ tui,
868
+ theme,
869
+ available,
870
+ saved,
871
+ currentThinking,
872
+ ctx,
873
+ defaultRef,
874
+ defaultThinking,
875
+ title,
876
+ done,
877
+ ),
878
+ PLAN_OVERLAY_OPTIONS,
879
+ );
880
+ }
881
+
882
+ /**
883
+ * Show a one-shot picker for which model implements the approved MoA plan.
884
+ * Defaults the highlighted selection to the saved implementing model, then the active model.
885
+ * Returns the chosen model and thinking level, or `undefined` if the user cancelled or
886
+ * no UI is available (non-interactive mode).
887
+ */
888
+ export async function showImplementingModelPicker(
889
+ ctx: ExtensionContext,
890
+ currentThinking: ThinkingLevel,
891
+ title = "Implementing model — choose a model and thinking level",
892
+ ): Promise<{ ref: ModelRef; thinking: ThinkingLevel } | undefined> {
893
+ if (!ctx.hasUI) return undefined;
894
+
895
+ const saved = loadMoaConfig();
896
+ const preferredRef = saved.implementer
897
+ ?? (ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined);
898
+
899
+ return await showModelThinkingPicker(ctx, currentThinking, title, preferredRef);
900
+ }