@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
package/src/ui/menu.ts ADDED
@@ -0,0 +1,679 @@
1
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ type KeybindingsManager,
5
+ Key,
6
+ matchesKey,
7
+ type OverlayOptions,
8
+ truncateToWidth,
9
+ type TUI,
10
+ visibleWidth,
11
+ } from "@earendil-works/pi-tui";
12
+ import { fitVisible, SELECTOR, wrapWords } from "./chrome.ts";
13
+
14
+ /** A boolean setting that can be changed with Space. */
15
+ export interface ToggleMenuItem {
16
+ id: string;
17
+ label: string;
18
+ value: boolean;
19
+ onChange?: (value: boolean) => void;
20
+ }
21
+
22
+ /** An Enter-activated row, optionally with a right-aligned displayed value. */
23
+ export interface ActionMenuItem {
24
+ id: string;
25
+ label: string;
26
+ displayValue?: string;
27
+ /** Dimmed helper text wrapped under the row. */
28
+ description?: string;
29
+ onSelect: () => void;
30
+ }
31
+
32
+ /** A setting whose values are selected inline with the left/right keys. */
33
+ export interface ChoiceMenuItem {
34
+ id: string;
35
+ label: string;
36
+ values: string[];
37
+ valueIndex: number;
38
+ /** Dimmed helper text wrapped under the row. */
39
+ description?: string;
40
+ onChange?: (valueIndex: number, displayValue: string) => void;
41
+ }
42
+
43
+ export type MenuItem = ToggleMenuItem | ActionMenuItem | ChoiceMenuItem;
44
+
45
+ export interface MenuButton {
46
+ id: string;
47
+ label: string;
48
+ primary?: boolean;
49
+ onSelect: () => void;
50
+ }
51
+
52
+ export interface MenuSection {
53
+ title: string;
54
+ items: MenuItem[];
55
+ }
56
+
57
+ export interface MenuConfig {
58
+ title: string;
59
+ sections: MenuSection[];
60
+ buttons?: MenuButton[];
61
+ /** Expand the menu frame to the available overlay width rather than its content width. */
62
+ fullWidth?: boolean;
63
+ hints?: string[];
64
+ /**
65
+ * Cap the item rows a section renders at once, scrolling the rest with the
66
+ * cursor, so a tall menu stays inside the overlay's height budget.
67
+ */
68
+ maxItemsPerSection?: number;
69
+ /** Handle a key against the selected item before standard menu navigation. Returning true consumes the key. */
70
+ onItemKey?: (item: MenuItem, data: string) => boolean;
71
+ /** Initially focused item, used when reopening sequential overlays. */
72
+ initialItemId?: string;
73
+ preview?: (values: Record<string, boolean>, elapsedMs: number) => string[];
74
+ previewIntervalMs?: number;
75
+ }
76
+
77
+ export interface MenuResult<T> {
78
+ applied: boolean;
79
+ values: T;
80
+ }
81
+
82
+ const DEFAULT_HINTS = ["↑↓ item", "←→ value", "⏎ select", "esc cancel"];
83
+ const MIN_WIDTH = 36;
84
+ const MAX_WIDTH = 76;
85
+ const UNSELECTED_SELECTOR = " ".repeat(SELECTOR.length);
86
+
87
+ function isToggleItem(item: MenuItem): item is ToggleMenuItem {
88
+ return "value" in item;
89
+ }
90
+
91
+ function isChoiceItem(item: MenuItem): item is ChoiceMenuItem {
92
+ return "values" in item;
93
+ }
94
+
95
+ // ── Shared box-drawing primitives ──────────────────────────────────────────
96
+
97
+ export function renderMenuTopBorder(theme: Theme, innerWidth: number, title: string): string {
98
+ const shown = truncateToWidth(title, Math.max(0, innerWidth - 5));
99
+ const fill = "═".repeat(Math.max(0, innerWidth - 5 - visibleWidth(shown)));
100
+ return theme.fg("border", "╔═[ ")
101
+ + theme.bold(theme.fg("text", shown))
102
+ + theme.fg("border", ` ]${fill}╗`);
103
+ }
104
+
105
+ export function renderMenuBottomBorder(theme: Theme, innerWidth: number, counter = ""): string {
106
+ return theme.fg("border", "╚")
107
+ + theme.fg("border", "═".repeat(Math.max(0, innerWidth - visibleWidth(counter))) + counter)
108
+ + theme.fg("border", "╝");
109
+ }
110
+
111
+ export function renderMenuSectionDivider(theme: Theme, innerWidth: number, title: string): string {
112
+ const shown = truncateToWidth(title, Math.max(0, innerWidth - 3));
113
+ return theme.fg("border", "╟")
114
+ + theme.fg("border", `─ ${shown} ${"─".repeat(Math.max(0, innerWidth - 3 - visibleWidth(shown)))}`)
115
+ + theme.fg("border", "╢");
116
+ }
117
+
118
+ export function renderMenuSeparator(theme: Theme, innerWidth: number): string {
119
+ return theme.fg("border", "╟" + "─".repeat(innerWidth) + "╢");
120
+ }
121
+
122
+ export function renderMenuContentRow(theme: Theme, innerWidth: number, content: string): string {
123
+ const shown = fitVisible(content, innerWidth, { truncationMark: "", padToWidth: false });
124
+ return theme.fg("border", "║") + shown + " ".repeat(Math.max(0, innerWidth - visibleWidth(shown))) + theme.fg("border", "║");
125
+ }
126
+
127
+ export function twoPaneWidths(innerWidth: number): { left: number; right: number } {
128
+ const left = Math.max(1, Math.floor((innerWidth - 1) / 2));
129
+ return { left, right: Math.max(0, innerWidth - left - 1) };
130
+ }
131
+
132
+ export function renderMenuPaneRow(theme: Theme, leftWidth: number, rightWidth: number, left: string, right: string): string {
133
+ const shownLeft = fitVisible(left, leftWidth, { truncationMark: "", padToWidth: true });
134
+ const shownRight = fitVisible(right, rightWidth, { truncationMark: "", padToWidth: true });
135
+ return theme.fg("border", "║")
136
+ + shownLeft + " ".repeat(Math.max(0, leftWidth - visibleWidth(shownLeft)))
137
+ + theme.fg("border", "│")
138
+ + shownRight + " ".repeat(Math.max(0, rightWidth - visibleWidth(shownRight)))
139
+ + theme.fg("border", "║");
140
+ }
141
+
142
+ /** Standard two-row footer contents for nested TUI windows: actions, then hints. */
143
+ export function renderMenuFooterContents(
144
+ theme: Theme,
145
+ innerWidth: number,
146
+ actions: string,
147
+ hints: string,
148
+ options?: { wrapHints?: boolean },
149
+ ): string[] {
150
+ /** Keep every returned content row frame-safe for callers that add borders directly. */
151
+ const fit = (content: string) => {
152
+ const shown = fitVisible(content, innerWidth, { truncationMark: "", padToWidth: false });
153
+ return shown + " ".repeat(Math.max(0, innerWidth - visibleWidth(shown)));
154
+ };
155
+ const rightAlignedActions = `${" ".repeat(Math.max(0, innerWidth - visibleWidth(actions) - 2))}${actions} `;
156
+ const hintLines = options?.wrapHints
157
+ ? wrapWords(hints, Math.max(0, innerWidth - 2)).map((line) => theme.fg("dim", fit(` ${line}`)))
158
+ : [theme.fg("dim", fit(` ${hints}`))];
159
+ return [
160
+ theme.fg("border", "─".repeat(innerWidth)),
161
+ fit(rightAlignedActions),
162
+ theme.fg("border", "─".repeat(innerWidth)),
163
+ ...hintLines,
164
+ ];
165
+ }
166
+
167
+ /** Standard framed two-row footer: right-justified actions above keyboard hints. */
168
+ export function renderMenuFooter(theme: Theme, innerWidth: number, actions: string, hints: string): string[] {
169
+ const [topDivider, actionRow, hintDivider, hintRow] = renderMenuFooterContents(theme, innerWidth, actions, hints);
170
+ return [
171
+ theme.fg("border", "╟") + topDivider + theme.fg("border", "╢"),
172
+ renderMenuContentRow(theme, innerWidth, actionRow),
173
+ theme.fg("border", "╟") + hintDivider + theme.fg("border", "╢"),
174
+ renderMenuContentRow(theme, innerWidth, hintRow),
175
+ ];
176
+ }
177
+
178
+ // ── MenuComponent──────────────────────────────────────────────────────────
179
+
180
+ /** Reusable box-drawing menu supporting toggle settings and action/value rows. */
181
+ export class MenuComponent implements Component {
182
+ private readonly title: string;
183
+ private readonly sections: MenuSection[];
184
+ private readonly hints: string[];
185
+ private readonly buttons: MenuButton[];
186
+ private readonly fullWidth: boolean;
187
+ private readonly maxItemsPerSection: number | undefined;
188
+ private readonly onItemKey: ((item: MenuItem, data: string) => boolean) | undefined;
189
+ private readonly theme: Theme;
190
+ private readonly done: (result: MenuResult<Record<string, boolean>>) => void;
191
+ private readonly tui: TUI | undefined;
192
+ private readonly initialValues: Record<string, boolean>;
193
+ private readonly values: Record<string, boolean> = {};
194
+ private readonly items: MenuItem[] = [];
195
+ private readonly previewFn: MenuConfig["preview"];
196
+ private readonly previewOrigin: number | undefined;
197
+ private previewTimer: ReturnType<typeof setInterval> | undefined;
198
+ private cursor = 0;
199
+ private focusedPane: "items" | "buttons" = "items";
200
+ private buttonIndex = 0;
201
+ private cachedWidth: number | undefined;
202
+ private cachedLines: string[] | undefined;
203
+ private viewport: number | undefined;
204
+ private scrollOffset = 0;
205
+
206
+ constructor(
207
+ config: MenuConfig,
208
+ theme: Theme,
209
+ done: (result: MenuResult<Record<string, boolean>>) => void,
210
+ tui?: TUI,
211
+ ) {
212
+ this.theme = theme;
213
+ this.done = done;
214
+ this.tui = tui;
215
+ this.title = config.title;
216
+ this.sections = config.sections;
217
+ this.buttons = config.buttons ?? [];
218
+ this.fullWidth = config.fullWidth ?? false;
219
+ this.maxItemsPerSection = config.maxItemsPerSection;
220
+ this.onItemKey = config.onItemKey;
221
+ this.hints = config.hints ?? (this.buttons.length > 0
222
+ ? ["↑↓ item", "←→ value", "⇥ switch btn", "⏎ select", "esc cancel"]
223
+ : DEFAULT_HINTS);
224
+ this.buttonIndex = Math.max(0, this.buttons.findIndex((button) => button.primary));
225
+ for (const section of this.sections) {
226
+ for (const item of section.items) {
227
+ this.items.push(item);
228
+ if (isToggleItem(item)) this.values[item.id] = item.value;
229
+ }
230
+ }
231
+ this.initialValues = { ...this.values };
232
+ const initialCursor = config.initialItemId
233
+ ? this.items.findIndex((item) => item.id === config.initialItemId)
234
+ : -1;
235
+ if (initialCursor >= 0) this.cursor = initialCursor;
236
+ this.previewFn = config.preview;
237
+ if (this.previewFn) {
238
+ this.previewOrigin = Date.now();
239
+ if (this.tui) {
240
+ this.previewTimer = setInterval(() => {
241
+ this.invalidate();
242
+ this.tui?.requestRender();
243
+ }, config.previewIntervalMs ?? 50);
244
+ }
245
+ }
246
+ }
247
+
248
+ dispose(): void {
249
+ if (this.previewTimer) clearInterval(this.previewTimer);
250
+ this.previewTimer = undefined;
251
+ }
252
+
253
+ /** Limit rendered height while keeping the title and action footer visible. */
254
+ setViewport(lines: number | undefined): void {
255
+ const next = lines === undefined ? undefined : Math.max(6, Math.floor(lines));
256
+ if (next === this.viewport) return;
257
+ this.viewport = next;
258
+ this.invalidate();
259
+ }
260
+
261
+ /** Update an action row in place without rebuilding the menu. */
262
+ setItemValue(id: string, displayValue: string | undefined): void {
263
+ const item = this.items.find((i) => i.id === id);
264
+ if (!item || isToggleItem(item) || isChoiceItem(item)) return;
265
+ item.displayValue = displayValue;
266
+ this.invalidate();
267
+ this.tui?.requestRender();
268
+ }
269
+
270
+ handleInput(data: string): void {
271
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
272
+ this.done({ applied: false, values: { ...this.initialValues } });
273
+ return;
274
+ }
275
+ if (matchesKey(data, Key.tab) && this.buttons.length > 0) {
276
+ this.focusedPane = this.focusedPane === "items" ? "buttons" : "items";
277
+ this.invalidate();
278
+ this.tui?.requestRender();
279
+ return;
280
+ }
281
+ if (this.focusedPane === "buttons") {
282
+ if (matchesKey(data, Key.left)) {
283
+ this.buttonIndex = (this.buttonIndex - 1 + this.buttons.length) % this.buttons.length;
284
+ this.invalidate();
285
+ } else if (matchesKey(data, Key.right)) {
286
+ this.buttonIndex = (this.buttonIndex + 1) % this.buttons.length;
287
+ this.invalidate();
288
+ } else if (matchesKey(data, Key.enter)) {
289
+ this.buttons[this.buttonIndex]?.onSelect();
290
+ }
291
+ this.tui?.requestRender();
292
+ return;
293
+ }
294
+ if (this.items.length === 0) return;
295
+ if (this.onItemKey?.(this.items[this.cursor]!, data)) {
296
+ // The callback may have mutated the item, so the render cache must go.
297
+ this.invalidate();
298
+ this.tui?.requestRender();
299
+ return;
300
+ }
301
+ if (matchesKey(data, Key.up)) {
302
+ this.cursor = (this.cursor - 1 + this.items.length) % this.items.length;
303
+ this.invalidate();
304
+ } else if (matchesKey(data, Key.down)) {
305
+ this.cursor = (this.cursor + 1) % this.items.length;
306
+ this.invalidate();
307
+ } else if (matchesKey(data, Key.pageUp)) {
308
+ this.cursor = Math.max(0, this.cursor - Math.max(1, (this.viewport ?? 10) - 6));
309
+ this.invalidate();
310
+ } else if (matchesKey(data, Key.pageDown)) {
311
+ this.cursor = Math.min(this.items.length - 1, this.cursor + Math.max(1, (this.viewport ?? 10) - 6));
312
+ this.invalidate();
313
+ } else if (matchesKey(data, Key.home)) {
314
+ this.cursor = 0;
315
+ this.invalidate();
316
+ } else if (matchesKey(data, Key.end)) {
317
+ this.cursor = this.items.length - 1;
318
+ this.invalidate();
319
+ } else if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
320
+ const item = this.items[this.cursor]!;
321
+ if (isChoiceItem(item) && item.values.length > 0) {
322
+ const delta = matchesKey(data, Key.left) ? -1 : 1;
323
+ item.valueIndex = (item.valueIndex + delta + item.values.length) % item.values.length;
324
+ item.onChange?.(item.valueIndex, item.values[item.valueIndex]!);
325
+ this.invalidate();
326
+ }
327
+ } else if (matchesKey(data, Key.space)) {
328
+ const item = this.items[this.cursor]!;
329
+ if (isToggleItem(item)) {
330
+ const value = !this.values[item.id];
331
+ this.values[item.id] = value;
332
+ item.onChange?.(value);
333
+ this.invalidate();
334
+ }
335
+ } else if (matchesKey(data, Key.enter)) {
336
+ const item = this.items[this.cursor]!;
337
+ if (!isToggleItem(item) && !isChoiceItem(item)) item.onSelect();
338
+ else if (this.buttons.length === 0 && this.items.every(isToggleItem)) {
339
+ this.done({ applied: true, values: { ...this.values } });
340
+ }
341
+ }
342
+ this.tui?.requestRender();
343
+ }
344
+
345
+ invalidate(): void {
346
+ this.cachedWidth = undefined;
347
+ this.cachedLines = undefined;
348
+ }
349
+
350
+ render(width: number): string[] {
351
+ if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
352
+ const lines = this.buildLines(width);
353
+ this.cachedWidth = width;
354
+ this.cachedLines = lines;
355
+ return lines;
356
+ }
357
+
358
+ private preferredWidth(previewLines: string[] | undefined): number {
359
+ const candidates = [MIN_WIDTH - 2, 5 + this.title.length, 2 + this.hints.join(" ").length];
360
+ for (const line of previewLines ?? []) candidates.push(visibleWidth(line) + 1);
361
+ for (const section of this.sections) {
362
+ candidates.push(3 + section.title.length);
363
+ for (const item of section.items) {
364
+ if (isToggleItem(item)) candidates.push(15 + item.label.length);
365
+ else if (isChoiceItem(item)) candidates.push(8 + item.label.length + item.values.reduce((sum, value) => sum + visibleWidth(value) + 2, 0));
366
+ else candidates.push(6 + item.label.length + visibleWidth(item.displayValue ?? "Not set"));
367
+ }
368
+ }
369
+ if (this.buttons.length > 0) candidates.push(this.buttons.reduce((sum, button) => sum + button.label.length + 8, 0));
370
+ return 2 + Math.max(...candidates);
371
+ }
372
+
373
+ private buildLines(maxWidth: number): string[] {
374
+ const { theme } = this;
375
+ const previewLines = this.previewFn?.(this.values, this.previewOrigin === undefined ? 0 : Date.now() - this.previewOrigin);
376
+ const desiredWidth = this.fullWidth
377
+ ? maxWidth
378
+ : Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, this.preferredWidth(previewLines)));
379
+ const boxWidth = Math.max(0, Math.min(desiredWidth, maxWidth));
380
+ const innerWidth = Math.max(0, boxWidth - 2);
381
+ const lines = [renderMenuTopBorder(theme, innerWidth, this.title)];
382
+ if (previewLines?.length) {
383
+ lines.push(renderMenuSectionDivider(theme, innerWidth, "Preview"), renderMenuContentRow(theme, innerWidth, ""));
384
+ for (const line of previewLines) lines.push(renderMenuContentRow(theme, innerWidth, ` ${line}`));
385
+ lines.push(renderMenuContentRow(theme, innerWidth, ""));
386
+ }
387
+ let flatIndex = 0;
388
+ const itemLineIndices: number[] = [];
389
+ for (const section of this.sections) {
390
+ lines.push(renderMenuSectionDivider(theme, innerWidth, section.title));
391
+ const [from, to] = this.itemWindow(section, flatIndex);
392
+ if (from > 0) lines.push(renderMenuContentRow(theme, innerWidth, theme.fg("dim", ` ↑ ${from} more`)));
393
+ for (const [index, item] of section.items.entries()) {
394
+ // The cursor indexes every item, so rows hidden by the window still advance it.
395
+ const selected = flatIndex++ === this.cursor;
396
+ if (index < from || index >= to) continue;
397
+ itemLineIndices[flatIndex - 1] = lines.length;
398
+ lines.push(this.renderItemRow(item, selected, innerWidth));
399
+ const descriptionRows = this.renderDescriptionRows(item, innerWidth);
400
+ lines.push(...descriptionRows);
401
+ if (descriptionRows.length > 0 && index < to - 1) {
402
+ lines.push(renderMenuContentRow(theme, innerWidth, ""));
403
+ }
404
+ }
405
+ const below = section.items.length - to;
406
+ if (below > 0) lines.push(renderMenuContentRow(theme, innerWidth, theme.fg("dim", ` ↓ ${below} more`)));
407
+ lines.push(renderMenuContentRow(theme, innerWidth, ""));
408
+ }
409
+ const footerStart = lines.length;
410
+ if (this.buttons.length > 0) {
411
+ const texts = this.buttons.map((button, index) => {
412
+ const isActive = index === this.buttonIndex;
413
+ const text = isActive ? `[ ${button.label} ]` : `‹ ${button.label} ›`;
414
+ return isActive && this.focusedPane === "buttons"
415
+ ? theme.bold(theme.fg("accent", text))
416
+ : theme.fg("muted", text);
417
+ });
418
+ lines.push(...renderMenuFooter(theme, innerWidth, texts.join(" "), this.hints.join(" ")));
419
+ } else {
420
+ lines.push(renderMenuSeparator(theme, innerWidth), renderMenuContentRow(theme, innerWidth, theme.fg("dim", ` ${this.hints.join(" ")}`)));
421
+ }
422
+ const changed = Object.entries(this.values).filter(([id, value]) => this.initialValues[id] !== value).length;
423
+ const counter = changed > 0 ? `[ ${changed} changed ]` : `[ ${this.cursor + 1}/${this.items.length} ]`;
424
+ lines.push(renderMenuBottomBorder(theme, innerWidth, counter));
425
+ let visibleLines = lines;
426
+ if (this.viewport !== undefined && lines.length > this.viewport) {
427
+ const top = lines.slice(0, 1);
428
+ const footer = lines.slice(footerStart);
429
+ const middle = lines.slice(1, footerStart);
430
+ const middleHeight = Math.max(0, this.viewport - top.length - footer.length);
431
+ const selectedMiddleLine = Math.max(0, (itemLineIndices[this.cursor] ?? 1) - 1);
432
+ if (selectedMiddleLine < this.scrollOffset) this.scrollOffset = selectedMiddleLine;
433
+ if (selectedMiddleLine >= this.scrollOffset + middleHeight) this.scrollOffset = selectedMiddleLine - middleHeight + 1;
434
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, Math.max(0, middle.length - middleHeight)));
435
+ visibleLines = [...top, ...middle.slice(this.scrollOffset, this.scrollOffset + middleHeight), ...footer];
436
+ }
437
+ return visibleLines.map((line) => truncateToWidth(line, boxWidth, ""));
438
+ }
439
+
440
+ /** Slice of a capped section's items to render, kept centred on the cursor. */
441
+ private itemWindow(section: MenuSection, sectionStart: number): [number, number] {
442
+ const cap = this.maxItemsPerSection;
443
+ const total = section.items.length;
444
+ if (!cap || total <= cap) return [0, total];
445
+ const local = this.cursor - sectionStart;
446
+ const anchor = local >= 0 && local < total ? local : 0;
447
+ const from = Math.min(Math.max(0, anchor - Math.floor((cap - 1) / 2)), total - cap);
448
+ return [from, from + cap];
449
+ }
450
+
451
+ /** Dimmed helper text wrapped under an action/choice row. */
452
+ private renderDescriptionRows(item: MenuItem, innerWidth: number): string[] {
453
+ if (isToggleItem(item) || !item.description) return [];
454
+ const indent = " ";
455
+ return wrapWords(item.description, Math.max(0, innerWidth - indent.length - 2)).map((line) =>
456
+ renderMenuContentRow(this.theme, innerWidth, this.theme.fg("dim", indent + line)),
457
+ );
458
+ }
459
+
460
+ private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
461
+ const th = this.theme;
462
+ const marker = selected ? th.fg("accent", SELECTOR) : UNSELECTED_SELECTOR;
463
+ if (isToggleItem(item)) {
464
+ const value = this.values[item.id]!;
465
+ const state = value ? "ON" : "OFF";
466
+ const label = truncateToWidth(item.label, Math.max(0, innerWidth - 15));
467
+ const left = ` ${marker} [${value ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}`;
468
+ return renderMenuContentRow(th, innerWidth, `${left}${" ".repeat(Math.max(1, innerWidth - visibleWidth(left) - state.length - 2))}${value ? th.fg("success", state) : th.fg("muted", state)} `);
469
+ }
470
+ if (isChoiceItem(item)) {
471
+ const parts = item.values.map((value, index) => {
472
+ const text = index === item.valueIndex ? `‹${value}›` : value;
473
+ return index === item.valueIndex ? th.fg("accent", text) : th.fg("muted", text);
474
+ }).join(" ");
475
+ const left = ` ${marker} ${th.fg("text", item.label)}`;
476
+ return renderMenuContentRow(th, innerWidth, `${left}${" ".repeat(Math.max(2, innerWidth - visibleWidth(left) - visibleWidth(parts) - 2))}${parts} `);
477
+ }
478
+ const value = item.displayValue ?? "Not set";
479
+ const maxValue = Math.max(0, innerWidth - Math.min(16, visibleWidth(item.label)) - 7);
480
+ const shownValue = truncateToWidth(value, maxValue, "...");
481
+ const maxLabel = Math.max(0, innerWidth - visibleWidth(shownValue) - 7);
482
+ const label = truncateToWidth(item.label, maxLabel, "...");
483
+ const left = ` ${marker} ${selected ? th.fg("accent", label) : th.fg("text", label)}`;
484
+ const right = selected ? th.fg("text", shownValue) : th.fg("muted", shownValue);
485
+ return renderMenuContentRow(th, innerWidth, `${left}${" ".repeat(Math.max(1, innerWidth - visibleWidth(left) - visibleWidth(right) - 2))}${right} `);
486
+ }
487
+ }
488
+
489
+ // ── showMenu helper ────────────────────────────────────────────────────────
490
+
491
+ export async function showMenu<T extends Record<string, boolean>>(ctx: ExtensionCommandContext, config: MenuConfig): Promise<MenuResult<T>> {
492
+ const initialValues = Object.fromEntries(config.sections.flatMap((section) => section.items.filter(isToggleItem).map((item) => [item.id, item.value]))) as T;
493
+ if (ctx.mode !== "tui") return { applied: false, values: initialValues };
494
+ return ctx.ui.custom<MenuResult<T>>((tui, theme, _keybindings, done) => new MenuComponent(config, theme, done as (result: MenuResult<Record<string, boolean>>) => void, tui), { overlay: true });
495
+ }
496
+
497
+ // ── Shared custom-overlay prompt handshake ────────────────────────────────
498
+
499
+ /**
500
+ * Shared geometry for every sequential overlay prompt (the setup overlay, the
501
+ * roster manager, one-shot model pickers): a centered overlay at 90% width and
502
+ * 70% height, so consecutive `ctx.ui.custom` calls read as one continuous
503
+ * settings surface.
504
+ */
505
+ export const PLAN_OVERLAY_OPTIONS = {
506
+ overlay: true,
507
+ overlayOptions: { anchor: "center", width: "90%", minWidth: 50, maxHeight: "70%", margin: 1 },
508
+ } as const;
509
+
510
+ /** The slice of pi's extension `ui` an overlay prompt needs. Structural so tests can supply a stub. */
511
+ interface OverlayPromptUi {
512
+ custom<T>(
513
+ factory: (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (value: T) => void) => Component,
514
+ options?: { overlay?: boolean; overlayOptions?: OverlayOptions },
515
+ ): Promise<T>;
516
+ }
517
+
518
+ /**
519
+ * Open a focused custom overlay with the shared prompt geometry and resolve
520
+ * with whatever the component hands to `done`.
521
+ */
522
+ export function showOverlayPrompt<T>(
523
+ ctx: { ui: OverlayPromptUi },
524
+ create: (tui: TUI, theme: Theme, done: (value: T) => void) => Component,
525
+ ): Promise<T> {
526
+ return ctx.ui.custom<T>((tui, theme, _keybindings, done) => create(tui, theme, done), PLAN_OVERLAY_OPTIONS);
527
+ }
528
+
529
+ // ── TwoPane types ──────────────────────────────────────────────────────────
530
+
531
+ export interface TwoPaneOption {
532
+ id: string;
533
+ label: string;
534
+ valueIndex: number;
535
+ values: string[];
536
+ onChange?: (valueIndex: number, displayValue: string) => void;
537
+ }
538
+
539
+ export interface TwoPaneCategory {
540
+ id: string;
541
+ label: string;
542
+ options: TwoPaneOption[];
543
+ }
544
+
545
+ export interface TwoPaneButton {
546
+ id: string;
547
+ label: string;
548
+ primary?: boolean;
549
+ onSelect: () => void;
550
+ }
551
+
552
+ export interface TwoPaneMenuConfig {
553
+ title: string;
554
+ categories: TwoPaneCategory[];
555
+ buttons: TwoPaneButton[];
556
+ hints?: string[];
557
+ }
558
+
559
+ type TwoPane = "categories" | "options" | "buttons";
560
+
561
+ /** A reusable category/detail configuration window with cycling option values. */
562
+ export class TwoPaneMenuComponent implements Component {
563
+ private readonly config: TwoPaneMenuConfig;
564
+ private readonly theme: Theme;
565
+ private readonly done: () => void;
566
+ private readonly tui: TUI | undefined;
567
+ private readonly hints: string[];
568
+ private activePane: TwoPane = "categories";
569
+ private categoryIndex = 0;
570
+ private optionIndex = 0;
571
+ private buttonIndex = 0;
572
+ private cachedWidth: number | undefined;
573
+ private cachedLines: string[] | undefined;
574
+
575
+ constructor(
576
+ config: TwoPaneMenuConfig,
577
+ theme: Theme,
578
+ done: () => void,
579
+ tui?: TUI,
580
+ ) {
581
+ this.config = config;
582
+ this.theme = theme;
583
+ this.done = done;
584
+ this.tui = tui;
585
+ this.hints = config.hints ?? ["↑↓ select", "tab switch pane", "←→ value", "⏎ apply", "esc cancel"];
586
+ }
587
+
588
+ private get category(): TwoPaneCategory | undefined { return this.config.categories[this.categoryIndex]; }
589
+ private get options(): TwoPaneOption[] { return this.category?.options ?? []; }
590
+
591
+ handleInput(data: string): void {
592
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
593
+ this.done();
594
+ return;
595
+ }
596
+ if (matchesKey(data, Key.tab)) {
597
+ this.activePane = this.activePane === "categories" ? "options" : this.activePane === "options" ? "buttons" : "categories";
598
+ this.invalidate();
599
+ this.tui?.requestRender();
600
+ return;
601
+ }
602
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
603
+ const delta = matchesKey(data, Key.up) ? -1 : 1;
604
+ if (this.activePane === "categories" && this.config.categories.length) {
605
+ this.categoryIndex = (this.categoryIndex + delta + this.config.categories.length) % this.config.categories.length;
606
+ this.optionIndex = 0;
607
+ } else if (this.activePane === "options" && this.options.length) {
608
+ this.optionIndex = (this.optionIndex + delta + this.options.length) % this.options.length;
609
+ } else if (this.activePane === "buttons" && this.config.buttons.length) {
610
+ this.buttonIndex = (this.buttonIndex + delta + this.config.buttons.length) % this.config.buttons.length;
611
+ }
612
+ this.invalidate();
613
+ } else if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
614
+ if (this.activePane === "options") {
615
+ const option = this.options[this.optionIndex];
616
+ if (option?.values.length) {
617
+ const delta = matchesKey(data, Key.left) ? -1 : 1;
618
+ option.valueIndex = (option.valueIndex + delta + option.values.length) % option.values.length;
619
+ option.onChange?.(option.valueIndex, option.values[option.valueIndex]!);
620
+ this.invalidate();
621
+ }
622
+ }
623
+ } else if (matchesKey(data, Key.enter) && this.activePane === "buttons") {
624
+ this.config.buttons[this.buttonIndex]?.onSelect();
625
+ }
626
+ this.tui?.requestRender();
627
+ }
628
+
629
+ invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; }
630
+ dispose(): void {}
631
+
632
+ render(width: number): string[] {
633
+ if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
634
+ const { theme, config } = this;
635
+ const longestCategory = Math.max(0, ...config.categories.map((c) => visibleWidth(c.label) + 4));
636
+ const longestOption = Math.max(0, ...config.categories.flatMap((c) => c.options.map((o) => visibleWidth(o.label) + Math.max(0, ...o.values.map(visibleWidth)) + 9)));
637
+ const desired = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, 2 + config.title.length + 5, 2 + longestCategory + longestOption, 2 + this.hints.join(" ").length));
638
+ const boxWidth = Math.max(0, Math.min(desired, width));
639
+ const innerWidth = Math.max(0, boxWidth - 2);
640
+ const leftWidth = Math.max(12, Math.floor((innerWidth - 1) * 0.4));
641
+ const rightWidth = Math.max(0, innerWidth - leftWidth - 1);
642
+ const lines = [renderMenuTopBorder(theme, innerWidth, config.title)];
643
+ const rowCount = Math.max(config.categories.length + 1, this.options.length + 1, 6);
644
+ for (let index = 0; index < rowCount; index++) {
645
+ let left = "";
646
+ let right = "";
647
+ if (index === 0) {
648
+ left = theme.fg("dim", " Categories");
649
+ right = theme.fg("dim", ` ${this.category?.label ?? ""}`);
650
+ } else {
651
+ const category = config.categories[index - 1];
652
+ if (category) left = ` ${this.activePane === "categories" && index - 1 === this.categoryIndex ? theme.fg("accent", SELECTOR) : UNSELECTED_SELECTOR} ${category.label}`;
653
+ const option = this.options[index - 1];
654
+ if (option) {
655
+ const marker = this.activePane === "options" && index - 1 === this.optionIndex ? `${theme.fg("accent", SELECTOR)} ` : `${UNSELECTED_SELECTOR} `;
656
+ const value = option.values[option.valueIndex] ?? "";
657
+ const valueText = `‹ ${value} ›`;
658
+ const label = truncateToWidth(option.label, Math.max(0, rightWidth - visibleWidth(valueText) - 3), "...");
659
+ right = `${marker}${label}${" ".repeat(Math.max(1, rightWidth - visibleWidth(marker) - visibleWidth(label) - visibleWidth(valueText)))}${theme.fg("muted", valueText)}`;
660
+ }
661
+ }
662
+ lines.push(renderMenuPaneRow(theme, leftWidth, rightWidth, left, right));
663
+ }
664
+ lines.push(theme.fg("border", "╟") + theme.fg("border", "─".repeat(leftWidth) + "┴" + "─".repeat(rightWidth)) + theme.fg("border", "╢"));
665
+ const buttons = config.buttons.map((button, index) => {
666
+ const selected = this.activePane === "buttons" && index === this.buttonIndex;
667
+ const text = button.primary ? `‹ ${button.label} ›` : `[ ${button.label} ]`;
668
+ return selected ? theme.fg("accent", theme.bold(text)) : text;
669
+ }).join(" ");
670
+ lines.push(renderMenuContentRow(theme, innerWidth, buttons.padStart(Math.max(0, Math.floor((innerWidth + visibleWidth(buttons)) / 2)))));
671
+ lines.push(renderMenuSeparator(theme, innerWidth));
672
+ lines.push(renderMenuContentRow(theme, innerWidth, theme.fg("dim", ` ${this.hints.join(" ")}`)));
673
+ const counter = `[ ${this.category?.label ?? ""} · ${this.options.length} opts ]`;
674
+ lines.push(renderMenuBottomBorder(theme, innerWidth, counter));
675
+ this.cachedWidth = width;
676
+ this.cachedLines = lines.map((line) => truncateToWidth(line, boxWidth, ""));
677
+ return this.cachedLines;
678
+ }
679
+ }