@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,310 @@
1
+ /**
2
+ * Agent-roster manager for `/mf-plan-settings`.
3
+ *
4
+ * A staged CRUD surface over the saved `PlanRoster` list: list → name prompt →
5
+ * slot editor → per-slot model/thinking picker, ported from persona-audit's
6
+ * RosterEditor. Every mutation happens on in-memory copies; persistence stays
7
+ * with the settings overlay's Save button, so closing without saving (or the
8
+ * overlay's Cancel) leaves settings.json untouched.
9
+ *
10
+ * Each roster carries one model + thinking level for every fan-out role —
11
+ * 2–5 proposers plus synthesizer, implementer, and verifier. A roster must be
12
+ * complete before it can be saved; drafts are never persisted.
13
+ */
14
+
15
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
16
+ import { type Component, Key, matchesKey, type TUI } from "@earendil-works/pi-tui";
17
+ import {
18
+ MAX_ROSTER_COUNT,
19
+ MAX_ROSTER_NAME_LENGTH,
20
+ MAX_ROSTER_PROPOSERS,
21
+ rosterNameError,
22
+ rosterReadinessError,
23
+ rosterSummary,
24
+ type DraftRoster,
25
+ type PlanRoster,
26
+ type RosterSlot,
27
+ } from "../config/rosters.ts";
28
+ import type { ThinkingLevel } from "../shared/modelRefs.ts";
29
+ import { modelRefLabel } from "../shared/modelRefs.ts";
30
+ import { ratioViewport } from "./chrome.ts";
31
+ import { MenuComponent, showOverlayPrompt, type ActionMenuItem } from "./menu.ts";
32
+ import { showModelThinkingPicker } from "./moaModelPicker.ts";
33
+
34
+ const BACKSPACE = "\x7f";
35
+
36
+ type SlotRole = "synthesizer" | "implementer" | "verifier";
37
+ type SlotTarget = { kind: "proposer"; index: number } | { kind: "role"; role: SlotRole };
38
+ type ListResult = { action: "create" } | { action: "edit"; index: number } | { action: "back" };
39
+ type EditorResult =
40
+ | { action: "save"; draft: DraftRoster }
41
+ | { action: "pick"; target: SlotTarget; draft: DraftRoster }
42
+ | { action: "rename"; draft: DraftRoster }
43
+ | { action: "delete" }
44
+ | { action: "cancel" };
45
+
46
+ const ROLE_LABELS: Record<SlotRole, string> = {
47
+ synthesizer: "Synthesizer",
48
+ implementer: "Implementer",
49
+ verifier: "Verifier",
50
+ };
51
+
52
+ const proposerLabel = (index: number): string => `Proposer ${index + 1}`;
53
+ const targetLabel = (target: SlotTarget): string => target.kind === "proposer" ? proposerLabel(target.index) : ROLE_LABELS[target.role];
54
+ const targetItemId = (target: SlotTarget): string => target.kind === "proposer" ? `proposer-${target.index}` : `role-${target.role}`;
55
+
56
+ function parseTargetItemId(id: string): SlotTarget | undefined {
57
+ if (id.startsWith("proposer-")) {
58
+ const index = Number(id.slice("proposer-".length));
59
+ return Number.isInteger(index) && index >= 0 && index < MAX_ROSTER_PROPOSERS ? { kind: "proposer", index } : undefined;
60
+ }
61
+ if (id.startsWith("role-")) {
62
+ const role = id.slice("role-".length);
63
+ return role === "synthesizer" || role === "implementer" || role === "verifier" ? { kind: "role", role } : undefined;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ function slotValue(slot: RosterSlot | undefined): string {
69
+ return slot ? `${modelRefLabel(slot.ref)} (thinking: ${slot.thinking})` : "(none)";
70
+ }
71
+
72
+ function cloneSlot(slot: RosterSlot | undefined): RosterSlot | undefined {
73
+ return slot ? { ref: { ...slot.ref }, thinking: slot.thinking } : undefined;
74
+ }
75
+
76
+ function toDraft(roster: PlanRoster): DraftRoster {
77
+ return {
78
+ name: roster.name,
79
+ proposers: Array.from({ length: MAX_ROSTER_PROPOSERS }, (_unused, index) => cloneSlot(roster.proposers[index])),
80
+ synthesizer: cloneSlot(roster.synthesizer),
81
+ implementer: cloneSlot(roster.implementer),
82
+ verifier: cloneSlot(roster.verifier),
83
+ };
84
+ }
85
+
86
+ function cloneDraft(draft: DraftRoster): DraftRoster {
87
+ return {
88
+ name: draft.name,
89
+ proposers: draft.proposers.map(cloneSlot),
90
+ synthesizer: cloneSlot(draft.synthesizer),
91
+ implementer: cloneSlot(draft.implementer),
92
+ verifier: cloneSlot(draft.verifier),
93
+ };
94
+ }
95
+
96
+ /** Compacts the sparse proposer slots into a dense, slot-ordered list — the same rule the picker's finish() uses. */
97
+ function fromDraft(draft: DraftRoster): PlanRoster {
98
+ return {
99
+ name: draft.name,
100
+ proposers: draft.proposers.filter((slot): slot is RosterSlot => slot !== undefined),
101
+ synthesizer: draft.synthesizer!,
102
+ implementer: draft.implementer!,
103
+ verifier: draft.verifier!,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * MenuComponent renders through this wrapper so its scrollable viewport tracks
109
+ * the terminal rows the way every other overlay in this extension does.
110
+ */
111
+ class ViewportMenu implements Component {
112
+ constructor(private readonly menu: MenuComponent) {}
113
+
114
+ handleInput(data: string): void {
115
+ this.menu.handleInput(data);
116
+ }
117
+
118
+ render(width: number): string[] {
119
+ this.menu.setViewport(ratioViewport(process.stdout.rows, { fallbackRows: 24, ratio: 0.7, minimum: 6 }));
120
+ return this.menu.render(width);
121
+ }
122
+
123
+ invalidate(): void {
124
+ this.menu.invalidate();
125
+ }
126
+
127
+ dispose(): void {
128
+ this.menu.dispose();
129
+ }
130
+ }
131
+
132
+ async function promptRosterName(ctx: ExtensionContext, rosters: PlanRoster[], currentName = "", currentIndex?: number): Promise<string | undefined> {
133
+ for (;;) {
134
+ const entered = await ctx.ui.input("Roster name", currentName || `1–${MAX_ROSTER_NAME_LENGTH} letters or numbers`);
135
+ if (entered === undefined) return undefined;
136
+ const name = entered.trim();
137
+ const error = rosterNameError(name, rosters, currentIndex);
138
+ if (!error) return name;
139
+ ctx.ui.notify(error, "warning");
140
+ }
141
+ }
142
+
143
+ function showRosterList(ctx: ExtensionContext, rosters: PlanRoster[]): Promise<ListResult> {
144
+ return showOverlayPrompt(ctx, (tui, theme, finish) => {
145
+ const items: ActionMenuItem[] = rosters
146
+ .map((roster, index) => ({ roster, index }))
147
+ .sort((a, b) => a.roster.name.localeCompare(b.roster.name))
148
+ .map(({ roster, index }) => ({
149
+ id: `roster-${index}`,
150
+ label: roster.name,
151
+ displayValue: `${roster.proposers.length + 3} roles assigned`,
152
+ description: rosterSummary(roster),
153
+ onSelect: () => finish({ action: "edit", index }),
154
+ }));
155
+ if (rosters.length < MAX_ROSTER_COUNT) {
156
+ items.push({ id: "create", label: "Create roster", onSelect: () => finish({ action: "create" }) });
157
+ }
158
+ return new ViewportMenu(new MenuComponent(
159
+ {
160
+ title: "MoA Fusion: Agent rosters",
161
+ fullWidth: true,
162
+ maxItemsPerSection: 8,
163
+ sections: [{ title: "Rosters", items }],
164
+ buttons: [{ id: "back", label: "Back", primary: true, onSelect: () => finish({ action: "back" }) }],
165
+ },
166
+ theme,
167
+ () => finish({ action: "back" }),
168
+ tui,
169
+ ));
170
+ });
171
+ }
172
+
173
+ function showSlotEditor(ctx: ExtensionContext, draft: DraftRoster, focusItemId: string, canDelete: boolean): Promise<EditorResult> {
174
+ const working = cloneDraft(draft);
175
+ return showOverlayPrompt(ctx, (tui, theme, finish) => {
176
+ const proposerItems: ActionMenuItem[] = working.proposers.map((slot, index) => ({
177
+ id: `proposer-${index}`,
178
+ label: proposerLabel(index),
179
+ displayValue: slotValue(slot),
180
+ onSelect: () => finish({ action: "pick", target: { kind: "proposer", index }, draft: cloneDraft(working) }),
181
+ }));
182
+ const roleItems: ActionMenuItem[] = (["synthesizer", "implementer", "verifier"] as const).map((role) => ({
183
+ id: `role-${role}`,
184
+ label: ROLE_LABELS[role],
185
+ displayValue: slotValue(working[role]),
186
+ onSelect: () => finish({ action: "pick", target: { kind: "role", role }, draft: cloneDraft(working) }),
187
+ }));
188
+ const items = [...proposerItems, ...roleItems];
189
+ const itemById = new Map(items.map((item) => [item.id, item]));
190
+ return new ViewportMenu(new MenuComponent(
191
+ {
192
+ title: `Agent roster: ${working.name}`,
193
+ fullWidth: true,
194
+ initialItemId: focusItemId,
195
+ hints: ["↑↓ slot", "⏎ choose", "⌫ clear", "⇥ buttons", "esc cancel"],
196
+ sections: [
197
+ { title: "proposers", items: proposerItems },
198
+ { title: "roles", items: roleItems },
199
+ ],
200
+ onItemKey: (item, data) => {
201
+ if (data !== BACKSPACE && data !== "\b" && !matchesKey(data, Key.delete)) return false;
202
+ const target = parseTargetItemId(item.id);
203
+ if (!target) return true;
204
+ if (target.kind === "proposer") working.proposers[target.index] = undefined;
205
+ else working[target.role] = undefined;
206
+ const row = itemById.get(item.id);
207
+ if (row) row.displayValue = "(none)";
208
+ return true;
209
+ },
210
+ buttons: [
211
+ {
212
+ id: "save", label: "Save roster", primary: true,
213
+ onSelect: () => {
214
+ const error = rosterReadinessError(working);
215
+ if (error) {
216
+ ctx.ui.notify(error, "warning");
217
+ return;
218
+ }
219
+ finish({ action: "save", draft: cloneDraft(working) });
220
+ },
221
+ },
222
+ { id: "rename", label: "Rename", onSelect: () => finish({ action: "rename", draft: cloneDraft(working) }) },
223
+ ...(canDelete ? [{ id: "delete", label: "Delete", onSelect: () => finish({ action: "delete" } as const) }] : []),
224
+ { id: "cancel", label: "Cancel", onSelect: () => finish({ action: "cancel" }) },
225
+ ],
226
+ },
227
+ theme,
228
+ () => finish({ action: "cancel" }),
229
+ tui,
230
+ ));
231
+ });
232
+ }
233
+
234
+ async function editRoster(
235
+ ctx: ExtensionContext,
236
+ rosters: PlanRoster[],
237
+ roster: DraftRoster,
238
+ currentIndex: number | undefined,
239
+ currentThinking: ThinkingLevel,
240
+ ): Promise<PlanRoster | "delete" | undefined> {
241
+ let draft = cloneDraft(roster);
242
+ let focusItemId = "proposer-0";
243
+ for (;;) {
244
+ const result = await showSlotEditor(ctx, draft, focusItemId, currentIndex !== undefined);
245
+ if (result.action === "cancel") return undefined;
246
+ if (result.action === "save") return fromDraft(result.draft);
247
+ if (result.action === "delete") {
248
+ if (await ctx.ui.confirm("Delete agent roster?", `Delete ${draft.name}? This is staged until settings are saved.`)) return "delete";
249
+ continue;
250
+ }
251
+ if (result.action === "rename") {
252
+ const name = await promptRosterName(ctx, rosters, result.draft.name, currentIndex);
253
+ draft = { ...result.draft, name: name ?? result.draft.name };
254
+ continue;
255
+ }
256
+ const target = result.target;
257
+ const current = target.kind === "proposer" ? result.draft.proposers[target.index] : result.draft[target.role];
258
+ const pick = await showModelThinkingPicker(
259
+ ctx,
260
+ currentThinking,
261
+ `${targetLabel(target)} — choose a model and thinking level`,
262
+ current?.ref,
263
+ current?.thinking,
264
+ );
265
+ draft = cloneDraft(result.draft);
266
+ if (pick) {
267
+ const slot: RosterSlot = { ref: pick.ref, thinking: pick.thinking };
268
+ if (target.kind === "proposer") draft.proposers[target.index] = slot;
269
+ else draft[target.role] = slot;
270
+ }
271
+ focusItemId = targetItemId(target);
272
+ }
273
+ }
274
+
275
+ /** Manage a staged roster collection; persistence remains the settings overlay's Save action's responsibility. */
276
+ export async function showRosterManager(
277
+ ctx: ExtensionContext,
278
+ initial: PlanRoster[],
279
+ deps: { currentThinking: ThinkingLevel },
280
+ ): Promise<PlanRoster[]> {
281
+ const rosters = initial.map((roster) => ({ ...roster, proposers: [...roster.proposers] }));
282
+ for (;;) {
283
+ const action = await showRosterList(ctx, rosters);
284
+ if (action.action === "back") return rosters;
285
+ if (action.action === "create") {
286
+ const name = await promptRosterName(ctx, rosters);
287
+ if (!name) continue;
288
+ const created = await editRoster(
289
+ ctx,
290
+ rosters,
291
+ {
292
+ name,
293
+ proposers: Array.from({ length: MAX_ROSTER_PROPOSERS }, () => undefined),
294
+ synthesizer: undefined,
295
+ implementer: undefined,
296
+ verifier: undefined,
297
+ },
298
+ undefined,
299
+ deps.currentThinking,
300
+ );
301
+ if (created && created !== "delete") rosters.push(created);
302
+ continue;
303
+ }
304
+ const current = rosters[action.index];
305
+ if (!current) continue;
306
+ const edited = await editRoster(ctx, rosters, toDraft(current), action.index, deps.currentThinking);
307
+ if (edited === "delete") rosters.splice(action.index, 1);
308
+ else if (edited) rosters[action.index] = edited;
309
+ }
310
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Codex-style light-sweep text shimmer.
3
+ *
4
+ * Vendored from pi-topping-persona-audit (`src/shimmer.ts`) rather than
5
+ * depended on, so the extensions stay independently installable. The sweep
6
+ * geometry — band half-width, padding, speed scaling — is load-bearing for
7
+ * visual parity, so port changes upstream rather than tuning them here.
8
+ * test/shimmer.test.mjs mirrors the upstream tests to catch drift.
9
+ */
10
+
11
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
12
+
13
+ const SHIMMER_SWEEP_S = 2.0;
14
+ const SHIMMER_BAND_HALF = 5.0;
15
+ const SHIMMER_PADDING = 10;
16
+
17
+ /** The slice of pi's `Theme` shimmerString needs. Structural so callers can supply a stub. */
18
+ export interface ShimmerTheme {
19
+ getFgAnsi(color: ThemeColor): string;
20
+ fg(color: ThemeColor, text: string): string;
21
+ }
22
+
23
+ /**
24
+ * With `invert=true`, the text color is used at rest and the sweep moves toward
25
+ * the theme's dim color.
26
+ */
27
+ export function shimmerString(
28
+ text: string,
29
+ elapsedMs: number,
30
+ theme: ShimmerTheme,
31
+ direction: "ltr" | "rtl" = "ltr",
32
+ speed: "slow" | "normal" | "fast" = "normal",
33
+ invert = false,
34
+ ): string {
35
+ const chars = [...text];
36
+ if (chars.length === 0) return "";
37
+ const shimmerBase = ansiToRgb(theme.getFgAnsi(invert ? "text" : "dim"));
38
+ const shimmerHighlight = ansiToRgb(theme.getFgAnsi(invert ? "dim" : "text"));
39
+ if (!shimmerBase || !shimmerHighlight) return theme.fg("text", text);
40
+ const period = chars.length + SHIMMER_PADDING * 2;
41
+ const unitsPerS = period / SHIMMER_SWEEP_S;
42
+ // The band crosses padding at either end while every character is still dim. Scaling only
43
+ // the stretch where it actually overlaps the text keeps that dark pause identical at every
44
+ // speed, so `speed` changes the sweep alone rather than the whole cycle.
45
+ const litEnter = SHIMMER_PADDING - SHIMMER_BAND_HALF;
46
+ const litExit = SHIMMER_PADDING + chars.length - 1 + SHIMMER_BAND_HALF;
47
+ const enterS = litEnter / unitsPerS;
48
+ const litS = (litExit - litEnter) / unitsPerS / (speed === "slow" ? 0.5 : speed === "fast" ? 2 : 1);
49
+ const phase = (elapsedMs / 1000) % (enterS + litS + (period - litExit) / unitsPerS);
50
+ const linear =
51
+ phase < enterS
52
+ ? phase * unitsPerS
53
+ : phase < enterS + litS
54
+ ? litEnter + ((phase - enterS) / litS) * (litExit - litEnter)
55
+ : litExit + (phase - enterS - litS) * unitsPerS;
56
+ const pos = direction === "rtl" ? period - linear : linear;
57
+
58
+ let out = "";
59
+ for (let i = 0; i < chars.length; i++) {
60
+ const ch = chars[i]!;
61
+ const dist = Math.abs(i + SHIMMER_PADDING - pos);
62
+ const t = dist <= SHIMMER_BAND_HALF ? 0.5 * (1 + Math.cos((Math.PI * dist) / SHIMMER_BAND_HALF)) : 0;
63
+ const alpha = t * 0.9;
64
+ const r = Math.round(shimmerHighlight[0] * alpha + shimmerBase[0] * (1 - alpha));
65
+ const g = Math.round(shimmerHighlight[1] * alpha + shimmerBase[1] * (1 - alpha));
66
+ const b = Math.round(shimmerHighlight[2] * alpha + shimmerBase[2] * (1 - alpha));
67
+ const bold = !invert && t > 0.2 ? "\x1b[1m" : "";
68
+ out += `${bold}\x1b[38;2;${r};${g};${b}m${ch}\x1b[22m`;
69
+ }
70
+ return out + "\x1b[0m";
71
+ }
72
+
73
+ function ansiToRgb(ansi: string): [number, number, number] | null {
74
+ const match = ansi.match(/^\x1b\[38;2;(\d+);(\d+);(\d+)m$/);
75
+ if (!match) return null;
76
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
77
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared renderer for one-line tool-activity labels (`formatToolActivity`
3
+ * output), so the MoA progress table and the cancel overlay color a tool
4
+ * name and its argument identically, following pi's own read/grep/edit/write
5
+ * renderers (bold `toolTitle` name, `accent` argument).
6
+ *
7
+ * Splits only at the first run of whitespace — the boundary
8
+ * `formatToolActivity` itself produces between a tool name and its argument
9
+ * — and never rewrites the text on either side, so activity strings stay
10
+ * byte-identical for the runtime's equality-based loop detection.
11
+ */
12
+
13
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
14
+
15
+ /** The slice of pi's `Theme` this renderer needs. */
16
+ export interface ActivityTheme {
17
+ fg(color: ThemeColor, text: string): string;
18
+ bold(text: string): string;
19
+ }
20
+
21
+ const ACTIVITY_SPLIT = /^(\s*)(\S+)(\s*)([\s\S]*)$/;
22
+
23
+ /**
24
+ * Highlight a plain activity string as a bold `toolTitle` tool name followed
25
+ * by its `accent` argument, preserving every character — including leading
26
+ * and repeated whitespace — exactly. Falls back to the raw string when no
27
+ * tool token can be found at all (empty or whitespace-only input).
28
+ */
29
+ export function highlightActivity(theme: ActivityTheme, activity: string): string {
30
+ const match = activity.match(ACTIVITY_SPLIT);
31
+ if (!match) return activity;
32
+ const [, leading, tool, sep, detail] = match;
33
+ const styledTool = theme.fg("toolTitle", theme.bold(tool));
34
+ return detail ? `${leading}${styledTool}${sep}${theme.fg("accent", detail)}` : `${leading}${styledTool}${sep}`;
35
+ }
@@ -0,0 +1,272 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
3
+ import { fuzzyFilter, Key, matchesKey, SelectList, truncateToWidth, visibleWidth, type SelectItem, type TUI } from "@earendil-works/pi-tui";
4
+ import { SELECTOR_POINTER } from "./chrome.ts";
5
+ import { renderMenuFooterContents } from "./menu.ts";
6
+ import { MODEL_LIST_LAYOUT } from "./modelLabel.ts";
7
+ import { getModelCatalogue } from "../config/modelCatalogue.ts";
8
+ import {
9
+ defaultThinkingForModel,
10
+ thinkingOptionsForModel,
11
+ type MoaConfig,
12
+ } from "../config/settings.ts";
13
+ import {
14
+ isThinkingLevel,
15
+ modelRefLabel,
16
+ parseRef,
17
+ THINKING_LEVELS,
18
+ type ModelRef,
19
+ type ThinkingLevel,
20
+ } from "../shared/modelRefs.ts";
21
+
22
+ const MAX_VISIBLE_ROWS = 10;
23
+
24
+ /** SelectList hardcodes "→ "; the extension's pointer is a plain "> " everywhere. */
25
+ const withPointer = (line: string): string => line.replace("→ ", SELECTOR_POINTER);
26
+
27
+ function modelThinkingPaneWidths(bodyWidth: number): { left: number; right: number } {
28
+ const divider = 1;
29
+ const longestLevel = THINKING_LEVELS.reduce((longest, level) => level.length > longest.length ? level : longest);
30
+ // One composed inset plus SelectList's two safety columns after its selector.
31
+ const rightMin = Math.max(visibleWidth(" Thinking"), visibleWidth(`→ ${longestLevel}`) + 3);
32
+ const right = Math.max(1, Math.min(rightMin, Math.max(1, bodyWidth - divider - 1)));
33
+ return { left: Math.max(1, bodyWidth - divider - right), right };
34
+ }
35
+
36
+ function toModelItems(refs: ModelRef[]): SelectItem[] {
37
+ return refs.map((ref) => ({ value: modelRefLabel(ref), label: modelRefLabel(ref) }));
38
+ }
39
+
40
+ /**
41
+ * Reusable two-pane selector: models on the left and model-specific thinking
42
+ * levels on the right. The model filter always owns text input; Tab or the
43
+ * left/right arrows move the selection focus between panes.
44
+ */
45
+ export class TwoPaneModelThinking {
46
+ private modelList: SelectList;
47
+ private levelList: SelectList;
48
+ private filter = "";
49
+ private activePane: "model" | "level" | "buttons" = "model";
50
+ private activeButton = 0;
51
+ private maxVisibleRows = MAX_VISIBLE_ROWS;
52
+ private readonly modelItems: SelectItem[];
53
+
54
+ constructor(
55
+ private readonly tui: TUI,
56
+ private readonly theme: Theme,
57
+ availableRefs: ModelRef[],
58
+ private readonly config: MoaConfig,
59
+ private readonly currentThinking: ThinkingLevel,
60
+ private readonly ctx: ExtensionContext,
61
+ ) {
62
+ this.modelItems = toModelItems(availableRefs);
63
+ this.modelList = new SelectList([], 1, getSelectListTheme());
64
+ this.levelList = new SelectList([], 1, getSelectListTheme());
65
+ this.reset();
66
+ }
67
+
68
+ isModelPane(): boolean {
69
+ return this.activePane === "model";
70
+ }
71
+
72
+ /** Limit the complete two-pane body, including filter/header/scroll rows. */
73
+ setMaxVisibleRows(limit: number): void {
74
+ const next = Math.max(1, Math.floor(limit));
75
+ if (next === this.maxVisibleRows) return;
76
+ this.maxVisibleRows = next;
77
+ this.rebuildPreservingSelection();
78
+ }
79
+
80
+ reset(defaultRef?: ModelRef, defaultThinking?: ThinkingLevel): void {
81
+ this.filter = "";
82
+ this.activePane = "model";
83
+ this.activeButton = 0;
84
+ this.rebuildModelList();
85
+ if (defaultRef) {
86
+ const defaultIndex = this.modelItems.findIndex((item) => item.value === modelRefLabel(defaultRef));
87
+ if (defaultIndex >= 0) this.modelList.setSelectedIndex(defaultIndex);
88
+ }
89
+ this.rebuildThinkingList();
90
+ if (defaultThinking) this.selectThinking(defaultThinking);
91
+ }
92
+
93
+ private selectThinking(thinking: ThinkingLevel): void {
94
+ const key = this.modelList.getSelectedItem()?.value;
95
+ if (!key) return;
96
+ const levels = thinkingOptionsForModel(getModelCatalogue(this.ctx.modelRegistry).thinkingLevelsFor(parseRef(key)));
97
+ const index = levels.indexOf(thinking);
98
+ if (index >= 0) this.levelList.setSelectedIndex(index);
99
+ }
100
+
101
+ private listItemRows(itemCount: number): number {
102
+ const chromeRows = 1 + (this.filter ? 1 : 0);
103
+ const available = Math.max(1, this.maxVisibleRows - chromeRows);
104
+ return itemCount > available ? Math.max(1, available - 1) : available;
105
+ }
106
+
107
+ private filteredModelItems(): SelectItem[] {
108
+ return this.filter.trim()
109
+ ? fuzzyFilter(this.modelItems, this.filter, (item) => item.label)
110
+ : this.modelItems;
111
+ }
112
+
113
+ private rebuildModelList(): SelectItem[] {
114
+ const items = this.filteredModelItems();
115
+ this.modelList = new SelectList(
116
+ items,
117
+ Math.min(Math.max(items.length, 1), this.listItemRows(items.length)),
118
+ getSelectListTheme(),
119
+ MODEL_LIST_LAYOUT,
120
+ );
121
+ this.modelList.onSelectionChange = () => {
122
+ this.rebuildThinkingList();
123
+ this.tui.requestRender();
124
+ };
125
+ return items;
126
+ }
127
+
128
+ private rebuildPreservingSelection(): void {
129
+ const selectedModel = this.modelList.getSelectedItem()?.value;
130
+ const selectedLevel = this.levelList.getSelectedItem()?.value;
131
+ const items = this.rebuildModelList();
132
+ if (selectedModel) {
133
+ const index = items.findIndex((item) => item.value === selectedModel);
134
+ if (index >= 0) this.modelList.setSelectedIndex(index);
135
+ }
136
+ this.rebuildThinkingList();
137
+ if (selectedLevel && isThinkingLevel(selectedLevel)) this.selectThinking(selectedLevel);
138
+ }
139
+
140
+ private rebuildThinkingList(): void {
141
+ const key = this.modelList.getSelectedItem()?.value;
142
+ const registryLevels = key
143
+ ? getModelCatalogue(this.ctx.modelRegistry).thinkingLevelsFor(parseRef(key))
144
+ : [];
145
+ const levels = thinkingOptionsForModel(registryLevels);
146
+ const items = (levels.length > 0 ? levels : ["off"]).map((level) => ({ value: level, label: level }));
147
+ this.levelList = new SelectList(items, Math.min(Math.max(items.length, 1), this.listItemRows(items.length)), getSelectListTheme());
148
+ if (!key) return;
149
+ const preferred = defaultThinkingForModel(key, this.config, this.currentThinking, registryLevels);
150
+ const preferredIndex = levels.indexOf(preferred);
151
+ if (preferredIndex >= 0) this.levelList.setSelectedIndex(preferredIndex);
152
+ }
153
+
154
+ handleInput(data: string): "confirm" | "back" | undefined {
155
+ if (matchesKey(data, Key.escape)) return "back";
156
+ if (matchesKey(data, Key.tab)) {
157
+ // Tab cycles Models → Thinking → action buttons → Models;
158
+ // left/right continue to switch between Models and Thinking (or
159
+ // between buttons, once the action bar is focused).
160
+ this.activePane = this.activePane === "model" ? "level" : this.activePane === "level" ? "buttons" : "model";
161
+ this.tui.requestRender();
162
+ return undefined;
163
+ }
164
+ if (this.activePane === "buttons") {
165
+ if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
166
+ this.activeButton = this.activeButton === 0 ? 1 : 0;
167
+ this.tui.requestRender();
168
+ return undefined;
169
+ }
170
+ if (matchesKey(data, Key.enter)) {
171
+ return this.activeButton === 0 && this.modelList.getSelectedItem() && this.levelList.getSelectedItem()
172
+ ? "confirm"
173
+ : "back";
174
+ }
175
+ return undefined;
176
+ }
177
+ if (matchesKey(data, Key.enter)) {
178
+ return this.modelList.getSelectedItem() && this.levelList.getSelectedItem() ? "confirm" : undefined;
179
+ }
180
+ if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
181
+ this.activePane = this.activePane === "model" ? "level" : "model";
182
+ this.tui.requestRender();
183
+ return undefined;
184
+ }
185
+ if (data === "\x7f" || data === "\b") {
186
+ this.activePane = "model";
187
+ if (this.filter.length > 0) {
188
+ this.filter = this.filter.slice(0, -1);
189
+ this.rebuildPreservingSelection();
190
+ this.tui.requestRender();
191
+ }
192
+ return undefined;
193
+ }
194
+ if (data.length === 1 && data >= " " && data !== "\x7f") {
195
+ this.activePane = "model";
196
+ this.filter += data;
197
+ this.rebuildPreservingSelection();
198
+ this.tui.requestRender();
199
+ return undefined;
200
+ }
201
+ (this.activePane === "model" ? this.modelList : this.levelList).handleInput(data);
202
+ this.tui.requestRender();
203
+ return undefined;
204
+ }
205
+
206
+ getSelected(): { ref: ModelRef; thinking: ThinkingLevel } {
207
+ const model = this.modelList.getSelectedItem();
208
+ const level = this.levelList.getSelectedItem();
209
+ if (!model || !level || !isThinkingLevel(level.value)) {
210
+ throw new Error("Cannot confirm an empty model or thinking-level selection");
211
+ }
212
+ return { ref: parseRef(model.value), thinking: level.value };
213
+ }
214
+
215
+ render(bodyWidth: number): string[] {
216
+ const { left: leftWidth, right: rightWidth } = modelThinkingPaneWidths(bodyWidth);
217
+ const column = (text: string, width: number) => truncateToWidth(text, width, "", true);
218
+ const paneDivider = this.theme.fg("border", "│");
219
+ const headers = column(
220
+ this.activePane === "model" ? this.theme.bold(this.theme.fg("accent", " Models")) : this.theme.bold(" Models"),
221
+ leftWidth,
222
+ ) + paneDivider + column(
223
+ ` ${this.activePane === "level" ? this.theme.bold(this.theme.fg("accent", "Thinking")) : this.theme.bold("Thinking")}`,
224
+ rightWidth,
225
+ );
226
+ const modelLines = this.modelList.render(Math.max(1, leftWidth - 1));
227
+ const levelLines = this.levelList.render(Math.max(1, rightWidth - 1));
228
+ const rows = Math.max(modelLines.length, levelLines.length);
229
+ const lines = [
230
+ ...(this.filter ? [column(this.theme.fg("muted", ` filter: ${this.filter}`), bodyWidth)] : []),
231
+ headers,
232
+ ];
233
+ for (let index = 0; index < rows; index++) {
234
+ const modelLine = modelLines[index] ?? "";
235
+ const levelLine = levelLines[index] ?? "";
236
+ let leftCell = column(` ${withPointer(modelLine)}`, leftWidth);
237
+ let rightCell = column(` ${withPointer(levelLine)}`, rightWidth);
238
+ // SelectList renders "→ " only on the selected row; other rows use " ".
239
+ if (this.activePane === "model" && modelLine.includes("→")) {
240
+ leftCell = this.theme.bg("selectedBg", leftCell);
241
+ }
242
+ if (this.activePane === "level" && levelLine.includes("→")) {
243
+ rightCell = this.theme.bg("selectedBg", rightCell);
244
+ }
245
+ lines.push(leftCell + paneDivider + rightCell);
246
+ }
247
+ return lines.slice(0, this.maxVisibleRows);
248
+ }
249
+
250
+ renderFooter(bodyWidth: number, hints: string): { actionRow: string; hintRows: string[] } {
251
+ const buttonText = (label: string, index: number) => {
252
+ const isActive = this.activeButton === index;
253
+ const text = isActive ? `[ ${label} ]` : `‹ ${label} ›`;
254
+ return isActive && this.activePane === "buttons"
255
+ ? this.theme.bold(this.theme.fg("accent", text))
256
+ : this.theme.fg("muted", text);
257
+ };
258
+ const rows = renderMenuFooterContents(
259
+ this.theme,
260
+ bodyWidth,
261
+ `${buttonText("Select", 0)} ${buttonText("Cancel", 1)}`,
262
+ hints,
263
+ { wrapHints: true },
264
+ );
265
+ return { actionRow: rows[1] ?? "", hintRows: rows.slice(3) };
266
+ }
267
+
268
+ invalidate(): void {
269
+ this.modelList.invalidate();
270
+ this.levelList.invalidate();
271
+ }
272
+ }