@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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +437 -0
- package/agents/mf-plan.md +43 -0
- package/agents/moa-debater.md +37 -0
- package/agents/moa-explore.md +56 -0
- package/agents/moa-opinion.md +29 -0
- package/agents/moa-proposer.md +49 -0
- package/agents/moa-synthesizer.md +124 -0
- package/agents/moa-verifier.md +67 -0
- package/index.ts +3 -0
- package/package.json +61 -0
- package/src/activityMeter.ts +193 -0
- package/src/agents/authoritative.ts +91 -0
- package/src/agents/defaults.ts +123 -0
- package/src/agents/discovery.ts +119 -0
- package/src/config/modelCatalogue.ts +54 -0
- package/src/config/planName.ts +74 -0
- package/src/config/rosters.ts +118 -0
- package/src/config/settings.ts +161 -0
- package/src/debate/debateContract.ts +89 -0
- package/src/debate/debateFanout.ts +285 -0
- package/src/debate/debateFile.ts +38 -0
- package/src/debate/debateResults.ts +115 -0
- package/src/debate/debateRounds.ts +61 -0
- package/src/debate/runDebate.ts +143 -0
- package/src/index.ts +283 -0
- package/src/moa/conflictContract.ts +49 -0
- package/src/moa/conflicts.ts +153 -0
- package/src/moa/contextContract.ts +52 -0
- package/src/moa/fanout.ts +152 -0
- package/src/moa/fanoutWiring.ts +88 -0
- package/src/moa/implementationRetry.ts +292 -0
- package/src/moa/modelRuntime.ts +87 -0
- package/src/moa/orchestration.ts +105 -0
- package/src/moa/planInfo.ts +57 -0
- package/src/moa/planlessRetry.ts +72 -0
- package/src/moa/reviewLoop.ts +170 -0
- package/src/moa/runContext.ts +118 -0
- package/src/moa/synthesis.ts +420 -0
- package/src/moa/verdicts.ts +81 -0
- package/src/moa/verification.ts +791 -0
- package/src/moa/verificationCriteria.ts +127 -0
- package/src/moa/verifyGate.ts +137 -0
- package/src/opinion/opinionContract.ts +21 -0
- package/src/opinion/opinionFanout.ts +135 -0
- package/src/opinion/opinionFile.ts +38 -0
- package/src/opinion/opinionResults.ts +73 -0
- package/src/opinion/runOpinion.ts +156 -0
- package/src/planning/askUserQuestion.ts +83 -0
- package/src/planning/instructions.ts +146 -0
- package/src/planning/modeState.ts +61 -0
- package/src/planning/planFile.ts +273 -0
- package/src/planning/planMode.ts +673 -0
- package/src/planning/tools/enterPlanMode.ts +165 -0
- package/src/planning/tools/exitPlanMode.ts +159 -0
- package/src/planning/tools/mfPlanSubagent.ts +311 -0
- package/src/planning/tools/shared.ts +19 -0
- package/src/planning/tools/writePlan.ts +33 -0
- package/src/runtime/activityTracking.ts +141 -0
- package/src/runtime/cancelRun.ts +134 -0
- package/src/runtime/mutationTripwire.ts +251 -0
- package/src/runtime/processPool.ts +55 -0
- package/src/runtime/results.ts +103 -0
- package/src/runtime/runner.ts +538 -0
- package/src/runtime/wire.ts +177 -0
- package/src/shared/functionKeys.ts +30 -0
- package/src/shared/modelRefs.ts +91 -0
- package/src/ui/agentStatus.ts +84 -0
- package/src/ui/agentTranscript.ts +112 -0
- package/src/ui/cancelOverlay.ts +191 -0
- package/src/ui/chrome.ts +151 -0
- package/src/ui/conflictOverlay.ts +363 -0
- package/src/ui/debateModelPicker.ts +273 -0
- package/src/ui/menu.ts +679 -0
- package/src/ui/moaModelPicker.ts +900 -0
- package/src/ui/moaProgressWidget.ts +910 -0
- package/src/ui/moaSetupOverlay.ts +368 -0
- package/src/ui/modelLabel.ts +61 -0
- package/src/ui/observeOverlay.ts +206 -0
- package/src/ui/opinionModelPicker.ts +246 -0
- package/src/ui/planReviewOverlay.ts +315 -0
- package/src/ui/promptEditor.ts +87 -0
- package/src/ui/rosterEditor.ts +310 -0
- package/src/ui/shimmer.ts +77 -0
- package/src/ui/toolActivity.ts +35 -0
- package/src/ui/twoPaneModelThinking.ts +272 -0
- package/src/ui/verificationFindingsOverlay.ts +137 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent MoA model setup overlay.
|
|
3
|
+
*
|
|
4
|
+
* Lets users configure the planning-subagent and cheap/fast model defaults,
|
|
5
|
+
* the plan options, and the named MoA agent rosters without entering plan
|
|
6
|
+
* mode. The overview and filtered slot picker share one component so Escape
|
|
7
|
+
* returns from a slot to the overview before it can cancel the overlay.
|
|
8
|
+
*
|
|
9
|
+
* Two kinds of slot are configured here. Planning-subagent slots persist into
|
|
10
|
+
* the frontmatter of the installed agent files; MoA fan-out roles are no
|
|
11
|
+
* longer set per role here — they live in named rosters, edited by the staged
|
|
12
|
+
* roster manager and persisted only when this overlay's Save is chosen.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { type Component, type TUI } from "@earendil-works/pi-tui";
|
|
17
|
+
import {
|
|
18
|
+
defaultThinkingForModel,
|
|
19
|
+
loadMoaConfig,
|
|
20
|
+
saveMoaConfig,
|
|
21
|
+
type MoaConfig,
|
|
22
|
+
} from "../config/settings.ts";
|
|
23
|
+
import type { PlanRoster } from "../config/rosters.ts";
|
|
24
|
+
import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
25
|
+
import { CONFIGURABLE_AGENTS, readAgentDefault, writeAgentDefault } from "../agents/defaults.ts";
|
|
26
|
+
import { getAvailableModelRefs } from "./moaModelPicker.ts";
|
|
27
|
+
import { showRosterManager } from "./rosterEditor.ts";
|
|
28
|
+
import { TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
|
|
29
|
+
import { getModelCatalogue } from "../config/modelCatalogue.ts";
|
|
30
|
+
import {
|
|
31
|
+
MenuComponent,
|
|
32
|
+
PLAN_OVERLAY_OPTIONS,
|
|
33
|
+
renderMenuBottomBorder,
|
|
34
|
+
renderMenuContentRow,
|
|
35
|
+
renderMenuSeparator,
|
|
36
|
+
renderMenuTopBorder,
|
|
37
|
+
type MenuItem,
|
|
38
|
+
} from "./menu.ts";
|
|
39
|
+
import { ratioViewport, wrapWords } from "./chrome.ts";
|
|
40
|
+
|
|
41
|
+
const AGENT_SLOT_COUNT = CONFIGURABLE_AGENTS.length;
|
|
42
|
+
|
|
43
|
+
interface SetupSlot {
|
|
44
|
+
/** Row label in the overview menu. */
|
|
45
|
+
menuLabel: string;
|
|
46
|
+
/** Title bar of the slot's picker. */
|
|
47
|
+
title: string;
|
|
48
|
+
/** Guidance on what kind of model suits this role, shown under the picker title. */
|
|
49
|
+
description: string;
|
|
50
|
+
/** Whether the overview row appends the selected thinking level. */
|
|
51
|
+
showThinkingValue: boolean;
|
|
52
|
+
/** Where a confirmed selection is persisted. */
|
|
53
|
+
target: { kind: "agent"; agentName: string } | { kind: "role" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SLOTS: readonly SetupSlot[] = [
|
|
57
|
+
...CONFIGURABLE_AGENTS.map((agent): SetupSlot => ({
|
|
58
|
+
menuLabel: agent.menuLabel,
|
|
59
|
+
title: agent.title,
|
|
60
|
+
description: agent.description,
|
|
61
|
+
showThinkingValue: true,
|
|
62
|
+
target: { kind: "agent", agentName: agent.name },
|
|
63
|
+
})),
|
|
64
|
+
{
|
|
65
|
+
menuLabel: "cheap/fast agent",
|
|
66
|
+
title: "Cheap / fast agent — plan-file naming",
|
|
67
|
+
description: "Only summarizes a plan prompt into a short file name. Always called with thinking off and falls back safely if it cannot respond, so pick the cheapest model available.",
|
|
68
|
+
showThinkingValue: false,
|
|
69
|
+
target: { kind: "role" },
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** Working state carried across the staged settings ↔ roster-manager round trip. */
|
|
74
|
+
interface SetupDraft {
|
|
75
|
+
selections: (ModelRef | undefined)[];
|
|
76
|
+
slotThinking: (ThinkingLevel | undefined)[];
|
|
77
|
+
thinkingOverrides: Record<string, ThinkingLevel>;
|
|
78
|
+
autoResolveConflicts: boolean;
|
|
79
|
+
useSummaryName: boolean;
|
|
80
|
+
rosters: PlanRoster[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type SetupResult = { action: "save"; draft: SetupDraft } | { action: "rosters"; draft: SetupDraft } | undefined;
|
|
84
|
+
type View = "overview" | "slot";
|
|
85
|
+
|
|
86
|
+
class MoaSetupComponent implements Component {
|
|
87
|
+
private view: View = "overview";
|
|
88
|
+
private selectedSlot = 0;
|
|
89
|
+
private readonly twoPane: TwoPaneModelThinking;
|
|
90
|
+
private readonly menu: MenuComponent;
|
|
91
|
+
private autoResolveConflicts: boolean;
|
|
92
|
+
private useSummaryName: boolean;
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
private readonly tui: TUI,
|
|
96
|
+
private readonly theme: Theme,
|
|
97
|
+
availableRefs: ModelRef[],
|
|
98
|
+
private readonly config: MoaConfig,
|
|
99
|
+
private readonly currentThinking: ThinkingLevel,
|
|
100
|
+
private readonly ctx: ExtensionContext,
|
|
101
|
+
private readonly draft: SetupDraft,
|
|
102
|
+
title: string,
|
|
103
|
+
private readonly done: (result: SetupResult) => void,
|
|
104
|
+
) {
|
|
105
|
+
this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
|
|
106
|
+
this.autoResolveConflicts = draft.autoResolveConflicts;
|
|
107
|
+
this.useSummaryName = draft.useSummaryName;
|
|
108
|
+
const slotItem = (slotIndex: number): MenuItem => ({
|
|
109
|
+
id: `role-${slotIndex}`,
|
|
110
|
+
label: SLOTS[slotIndex]!.menuLabel,
|
|
111
|
+
displayValue: this.roleDisplayValue(slotIndex),
|
|
112
|
+
onSelect: () => this.openSlot(slotIndex),
|
|
113
|
+
});
|
|
114
|
+
this.menu = new MenuComponent({
|
|
115
|
+
title,
|
|
116
|
+
fullWidth: true,
|
|
117
|
+
sections: [
|
|
118
|
+
{ title: "planning subagents", items: SLOTS.slice(0, AGENT_SLOT_COUNT).map((_unused, index) => slotItem(index)) },
|
|
119
|
+
{ title: "model roles", items: SLOTS.slice(AGENT_SLOT_COUNT).map((_unused, index) => slotItem(AGENT_SLOT_COUNT + index)) },
|
|
120
|
+
{
|
|
121
|
+
title: "rosters",
|
|
122
|
+
items: [{
|
|
123
|
+
id: "agent-rosters",
|
|
124
|
+
label: "agent rosters",
|
|
125
|
+
displayValue: `${this.draft.rosters.length} configured`,
|
|
126
|
+
description: "Reusable model + thinking assignments for every MoA role. Load one from the picker's Load Roster row.",
|
|
127
|
+
onSelect: () => this.done({ action: "rosters", draft: this.draftOut() }),
|
|
128
|
+
}],
|
|
129
|
+
},
|
|
130
|
+
{ title: "options", items: [
|
|
131
|
+
{ id: "auto-resolve", label: "auto resolve conflicts", value: this.autoResolveConflicts, onChange: (value: boolean) => { this.autoResolveConflicts = value; } },
|
|
132
|
+
{ id: "use-summary-name", label: "summarize plan names", value: this.useSummaryName, onChange: (value: boolean) => { this.useSummaryName = value; } },
|
|
133
|
+
] },
|
|
134
|
+
],
|
|
135
|
+
buttons: [
|
|
136
|
+
{ id: "save", label: "Save and Close", primary: true, onSelect: () => this.saveAndClose() },
|
|
137
|
+
{ id: "cancel", label: "Cancel", onSelect: () => this.done(undefined) },
|
|
138
|
+
],
|
|
139
|
+
}, theme, () => {
|
|
140
|
+
this.done(undefined);
|
|
141
|
+
}, tui);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private openSlot(slotIndex: number): void {
|
|
145
|
+
this.view = "slot";
|
|
146
|
+
this.selectedSlot = slotIndex;
|
|
147
|
+
const selectedRef = this.draft.selections[slotIndex];
|
|
148
|
+
this.twoPane.reset(selectedRef, this.draft.slotThinking[slotIndex]);
|
|
149
|
+
this.tui.requestRender();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private closeSlot(): void {
|
|
153
|
+
this.view = "overview";
|
|
154
|
+
this.tui.requestRender();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private roleDisplayValue(slotIndex: number): string | undefined {
|
|
158
|
+
const selected = this.draft.selections[slotIndex];
|
|
159
|
+
if (!selected) return undefined;
|
|
160
|
+
if (!SLOTS[slotIndex]!.showThinkingValue) return modelRefLabel(selected);
|
|
161
|
+
return `${modelRefLabel(selected)} (thinking: ${this.thinkingForSlot(slotIndex, selected)})`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private draftOut(): SetupDraft {
|
|
165
|
+
return {
|
|
166
|
+
selections: [...this.draft.selections],
|
|
167
|
+
slotThinking: [...this.draft.slotThinking],
|
|
168
|
+
thinkingOverrides: { ...this.draft.thinkingOverrides },
|
|
169
|
+
autoResolveConflicts: this.autoResolveConflicts,
|
|
170
|
+
useSummaryName: this.useSummaryName,
|
|
171
|
+
rosters: [...this.draft.rosters],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private saveAndClose(): void {
|
|
176
|
+
this.done({ action: "save", draft: this.draftOut() });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private thinkingForSlot(slotIndex: number, ref: ModelRef): ThinkingLevel {
|
|
180
|
+
return this.draft.slotThinking[slotIndex]
|
|
181
|
+
?? defaultThinkingForModel(
|
|
182
|
+
modelRefLabel(ref),
|
|
183
|
+
this.config,
|
|
184
|
+
this.currentThinking,
|
|
185
|
+
getModelCatalogue(this.ctx.modelRegistry).thinkingLevelsFor(ref),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private confirmSlot(): void {
|
|
190
|
+
const selection = this.twoPane.getSelected();
|
|
191
|
+
this.draft.selections[this.selectedSlot] = selection.ref;
|
|
192
|
+
this.draft.slotThinking[this.selectedSlot] = selection.thinking;
|
|
193
|
+
this.draft.thinkingOverrides[modelRefLabel(selection.ref)] = selection.thinking;
|
|
194
|
+
// `config` is this invocation's own copy, so recording the choice here is
|
|
195
|
+
// what makes the level stick when the same slot is reopened before saving.
|
|
196
|
+
this.config.thinkingOverrides[modelRefLabel(selection.ref)] = selection.thinking;
|
|
197
|
+
this.menu.setItemValue(`role-${this.selectedSlot}`, this.roleDisplayValue(this.selectedSlot));
|
|
198
|
+
this.closeSlot();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private handleSlotInput(data: string): void {
|
|
202
|
+
const action = this.twoPane.handleInput(data);
|
|
203
|
+
if (action === "confirm") this.confirmSlot();
|
|
204
|
+
else if (action === "back") this.closeSlot();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
handleInput(data: string): void {
|
|
208
|
+
if (this.view === "overview") this.menu.handleInput(data);
|
|
209
|
+
else this.handleSlotInput(data);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
render(width: number): string[] {
|
|
213
|
+
const viewport = ratioViewport(process.stdout.rows, {
|
|
214
|
+
fallbackRows: 24,
|
|
215
|
+
ratio: 0.7,
|
|
216
|
+
minimum: 6,
|
|
217
|
+
});
|
|
218
|
+
if (this.view === "overview") {
|
|
219
|
+
this.menu.setViewport(viewport);
|
|
220
|
+
return this.menu.render(width);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const th = this.theme;
|
|
224
|
+
const innerWidth = Math.max(20, width - 2);
|
|
225
|
+
const bodyWidth = Math.max(10, innerWidth - 2);
|
|
226
|
+
const slot = SLOTS[this.selectedSlot]!;
|
|
227
|
+
const descriptionLines = wrapWords(slot.description, bodyWidth).slice(0, viewport >= 16 ? 2 : 1);
|
|
228
|
+
const hint = "type filters models • ↑↓ navigate pane • tab panes/buttons • ←→ switch/select • enter select • esc back";
|
|
229
|
+
const { actionRow, hintRows } = this.twoPane.renderFooter(bodyWidth, hint);
|
|
230
|
+
this.twoPane.setMaxVisibleRows(Math.max(1, viewport - 7 - descriptionLines.length - (hintRows.length - 1)));
|
|
231
|
+
const pickerLines = this.twoPane.render(bodyWidth);
|
|
232
|
+
const top = renderMenuTopBorder(th, innerWidth, slot.title);
|
|
233
|
+
const bottom = renderMenuBottomBorder(th, innerWidth);
|
|
234
|
+
const framedPicker = pickerLines.map((line) => renderMenuContentRow(th, innerWidth, ` ${line}`));
|
|
235
|
+
const lines = [
|
|
236
|
+
top,
|
|
237
|
+
...descriptionLines.map((line) => renderMenuContentRow(th, innerWidth, th.fg("muted", ` ${line}`))),
|
|
238
|
+
renderMenuSeparator(th, innerWidth),
|
|
239
|
+
...framedPicker,
|
|
240
|
+
renderMenuSeparator(th, innerWidth),
|
|
241
|
+
renderMenuContentRow(th, innerWidth, actionRow),
|
|
242
|
+
renderMenuSeparator(th, innerWidth),
|
|
243
|
+
...hintRows.map((line) => renderMenuContentRow(th, innerWidth, line)),
|
|
244
|
+
bottom,
|
|
245
|
+
];
|
|
246
|
+
if (lines.length <= viewport) return lines;
|
|
247
|
+
return [
|
|
248
|
+
top,
|
|
249
|
+
...framedPicker.slice(0, Math.max(1, viewport - 3)),
|
|
250
|
+
renderMenuContentRow(th, innerWidth, actionRow),
|
|
251
|
+
bottom,
|
|
252
|
+
].slice(0, viewport);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
invalidate(): void {
|
|
256
|
+
this.menu.invalidate();
|
|
257
|
+
this.twoPane.invalidate();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
dispose(): void {
|
|
261
|
+
this.menu.dispose();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Match a frontmatter model value, which may be a bare id rather than `provider/id`. */
|
|
266
|
+
function resolveModelRef(raw: string | undefined, available: ModelRef[]): ModelRef | undefined {
|
|
267
|
+
if (!raw) return undefined;
|
|
268
|
+
return available.find((ref) => modelRefLabel(ref) === raw) ?? available.find((ref) => ref.id === raw);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Show the interactive MoA defaults setup overlay and persist selections on save. */
|
|
272
|
+
export async function showMoaSetup(
|
|
273
|
+
ctx: ExtensionContext,
|
|
274
|
+
currentThinking: ThinkingLevel,
|
|
275
|
+
options: { firstRun?: boolean } = {},
|
|
276
|
+
): Promise<boolean> {
|
|
277
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
278
|
+
ctx.ui.notify("MoA Fusion Settings requires interactive mode.", "warning");
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const available = getAvailableModelRefs(ctx);
|
|
283
|
+
if (available.length === 0) {
|
|
284
|
+
ctx.ui.notify("No callable models are available for MoA Fusion Settings.", "warning");
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const saved = loadMoaConfig();
|
|
289
|
+
const currentModelRef: ModelRef = ctx.model
|
|
290
|
+
? { provider: ctx.model.provider, id: ctx.model.id }
|
|
291
|
+
: available[0]!;
|
|
292
|
+
|
|
293
|
+
// Agent slots read their current value from the installed agent files, which
|
|
294
|
+
// stay authoritative even if someone hand-edits them.
|
|
295
|
+
const agentDefaults = CONFIGURABLE_AGENTS.map((agent) => readAgentDefault(agent.name));
|
|
296
|
+
const draft: SetupDraft = {
|
|
297
|
+
selections: [...agentDefaults.map((entry) => resolveModelRef(entry.model, available)), saved.cheap ?? currentModelRef],
|
|
298
|
+
slotThinking: [...agentDefaults.map((entry) => entry.thinking), undefined],
|
|
299
|
+
thinkingOverrides: {},
|
|
300
|
+
autoResolveConflicts: saved.autoResolveConflicts,
|
|
301
|
+
useSummaryName: saved.useSummaryName,
|
|
302
|
+
rosters: [...saved.rosters],
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Seed the shared thinking map so an agent's frontmatter level is preselected
|
|
306
|
+
// the first time its picker opens, not just shown on the overview row.
|
|
307
|
+
const seeded: MoaConfig = { ...saved, thinkingOverrides: { ...saved.thinkingOverrides } };
|
|
308
|
+
for (const [index, entry] of agentDefaults.entries()) {
|
|
309
|
+
const ref = draft.selections[index];
|
|
310
|
+
if (ref && entry.thinking) seeded.thinkingOverrides[modelRefLabel(ref)] = entry.thinking;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const title = options.firstRun
|
|
314
|
+
? "MoA Fusion Settings — set up agents and rosters before planning"
|
|
315
|
+
: "MoA Fusion Settings — configure agents and rosters";
|
|
316
|
+
|
|
317
|
+
// Staged loop: the overlay closes around the roster manager and reopens with
|
|
318
|
+
// the draft preserved, so one settings visit can edit slots and rosters.
|
|
319
|
+
let result: SetupResult;
|
|
320
|
+
for (;;) {
|
|
321
|
+
result = await ctx.ui.custom<SetupResult>(
|
|
322
|
+
(tui, theme, _keybindings, done) =>
|
|
323
|
+
new MoaSetupComponent(tui, theme, available, seeded, currentThinking, ctx, draft, title, done),
|
|
324
|
+
PLAN_OVERLAY_OPTIONS,
|
|
325
|
+
);
|
|
326
|
+
if (result === undefined) return false;
|
|
327
|
+
if (result.action === "rosters") {
|
|
328
|
+
draft.rosters = await showRosterManager(ctx, draft.rosters, { currentThinking });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
const finalDraft = result.draft;
|
|
334
|
+
|
|
335
|
+
const unwritable: string[] = [];
|
|
336
|
+
for (const [index, agent] of CONFIGURABLE_AGENTS.entries()) {
|
|
337
|
+
const ref = finalDraft.selections[index];
|
|
338
|
+
if (!ref) continue;
|
|
339
|
+
const thinking = finalDraft.slotThinking[index]
|
|
340
|
+
?? defaultThinkingForModel(
|
|
341
|
+
modelRefLabel(ref),
|
|
342
|
+
seeded,
|
|
343
|
+
currentThinking,
|
|
344
|
+
getModelCatalogue(ctx.modelRegistry).thinkingLevelsFor(ref),
|
|
345
|
+
);
|
|
346
|
+
if (!writeAgentDefault(agent.name, modelRefLabel(ref), thinking)) unwritable.push(agent.name);
|
|
347
|
+
}
|
|
348
|
+
if (unwritable.length > 0) {
|
|
349
|
+
ctx.ui.notify(`Could not update agent frontmatter for: ${unwritable.join(", ")}.`, "warning");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The per-role fan-out keys (proposers/synthesizer/implementer/verifier) are
|
|
353
|
+
// intentionally left untouched: they remain load-bearing for resume,
|
|
354
|
+
// verifier fallback, and picker seeding, and are only written by a run's
|
|
355
|
+
// own confirmations.
|
|
356
|
+
const current = loadMoaConfig();
|
|
357
|
+
saveMoaConfig({
|
|
358
|
+
...current,
|
|
359
|
+
mode: "moa",
|
|
360
|
+
agentDefaultsConfigured: true,
|
|
361
|
+
autoResolveConflicts: finalDraft.autoResolveConflicts,
|
|
362
|
+
useSummaryName: finalDraft.useSummaryName,
|
|
363
|
+
cheap: finalDraft.selections[AGENT_SLOT_COUNT],
|
|
364
|
+
rosters: finalDraft.rosters,
|
|
365
|
+
thinkingOverrides: { ...current.thinkingOverrides, ...finalDraft.thinkingOverrides },
|
|
366
|
+
});
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth, type SelectListLayoutOptions } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Truncates a slash-namespaced model ref (`provider/accounts/.../model-name`)
|
|
5
|
+
* so the parts that matter to identify the model survive first. Priority:
|
|
6
|
+
* the model name (last segment) is mandatory, then the first segment (the
|
|
7
|
+
* namespace root), then the segment right before the model name, then
|
|
8
|
+
* whatever else still fits. Skipped runs collapse to a single ellipsis.
|
|
9
|
+
*/
|
|
10
|
+
export function smartTruncateModelLabel(text: string, maxWidth: number): string {
|
|
11
|
+
const ELLIPSIS = "…";
|
|
12
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
13
|
+
if (visibleWidth(text) <= maxWidth) return text;
|
|
14
|
+
const segments = text.split("/");
|
|
15
|
+
const n = segments.length;
|
|
16
|
+
if (n === 1) return truncateToWidth(text, maxWidth, ELLIPSIS).replace(ANSI_RE, "");
|
|
17
|
+
|
|
18
|
+
const lastIndex = n - 1;
|
|
19
|
+
const kept = new Set<number>([lastIndex]);
|
|
20
|
+
const priority: number[] = [];
|
|
21
|
+
priority.push(0);
|
|
22
|
+
if (n - 2 > 0) priority.push(n - 2);
|
|
23
|
+
for (let i = 1; i <= n - 3; i++) priority.push(i);
|
|
24
|
+
|
|
25
|
+
const render = () => {
|
|
26
|
+
const parts: string[] = [];
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < n) {
|
|
29
|
+
if (kept.has(i)) {
|
|
30
|
+
parts.push(segments[i]!);
|
|
31
|
+
i++;
|
|
32
|
+
} else {
|
|
33
|
+
while (i < n && !kept.has(i)) i++;
|
|
34
|
+
parts.push(ELLIPSIS);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parts.join("/");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let current = render();
|
|
41
|
+
if (visibleWidth(current) > maxWidth) {
|
|
42
|
+
const skeleton = `${ELLIPSIS}/`;
|
|
43
|
+
const budget = Math.max(0, maxWidth - visibleWidth(skeleton));
|
|
44
|
+
return (skeleton + truncateToWidth(segments[lastIndex]!, budget, ELLIPSIS)).replace(ANSI_RE, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const index of priority) {
|
|
48
|
+
kept.add(index);
|
|
49
|
+
const candidate = render();
|
|
50
|
+
if (visibleWidth(candidate) > maxWidth) {
|
|
51
|
+
kept.delete(index);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
current = candidate;
|
|
55
|
+
}
|
|
56
|
+
return current;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const MODEL_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
60
|
+
truncatePrimary: ({ text, maxWidth }) => smartTruncateModelLabel(text, maxWidth),
|
|
61
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getMarkdownTheme, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Key, Markdown, matchesKey, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import { transcriptBody, truncateFromStart } from "./agentTranscript.ts";
|
|
5
|
+
import { ROUNDED_SINGLE_BOX, UI_TICK_MS, createFrame, ratioViewport, safeRenderWidth } from "./chrome.ts";
|
|
6
|
+
|
|
7
|
+
export interface ObserveAgentData {
|
|
8
|
+
label: string;
|
|
9
|
+
model: string;
|
|
10
|
+
task: string;
|
|
11
|
+
messages: Message[];
|
|
12
|
+
partial?: Message;
|
|
13
|
+
state: "working" | "done" | "error" | "cancelled";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ObserveSession {
|
|
17
|
+
title: string;
|
|
18
|
+
phase: "fanout" | "synthesizing";
|
|
19
|
+
agents: ObserveAgentData[];
|
|
20
|
+
synthesizer?: ObserveAgentData;
|
|
21
|
+
overlayOpen: boolean;
|
|
22
|
+
closeOverlay?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const OVERLAY_MARGIN = 1;
|
|
26
|
+
const OVERLAY_HEIGHT_RATIO = 0.9;
|
|
27
|
+
const MAX_RENDERED_CHARS = 40_000;
|
|
28
|
+
const MAX_TASK_WIDTH = 120;
|
|
29
|
+
|
|
30
|
+
/** Render completed plus currently streaming message content for an observed agent. */
|
|
31
|
+
export function formatAgentMarkdown(agent: ObserveAgentData): string {
|
|
32
|
+
const task = truncateToWidth(agent.task.replace(/\s+/g, " ").trim(), MAX_TASK_WIDTH, "…", false);
|
|
33
|
+
const header = [`## ${agent.label}`, `_${agent.model} · ${agent.state}_`, "", `**Task:** ${task}`, ""].join("\n");
|
|
34
|
+
const contentBudget = Math.max(0, MAX_RENDERED_CHARS - header.length - 1);
|
|
35
|
+
const body = transcriptBody(agent.messages, agent.partial, agent.state, contentBudget);
|
|
36
|
+
const result = `${header}${body}`;
|
|
37
|
+
return result.length <= MAX_RENDERED_CHARS ? result : truncateFromStart(result, MAX_RENDERED_CHARS);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function showObserveOverlay(ctx: ExtensionContext, session: ObserveSession): Promise<void> {
|
|
41
|
+
if (ctx.mode !== "tui") return;
|
|
42
|
+
await ctx.ui.custom<void>(
|
|
43
|
+
(tui, theme, _keybindings, done) => new ObserveOverlayComponent(tui, theme, session, done),
|
|
44
|
+
{
|
|
45
|
+
overlay: true,
|
|
46
|
+
overlayOptions: { anchor: "center", width: "90%", minWidth: 60, maxHeight: "90%", margin: OVERLAY_MARGIN },
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class ObserveOverlayComponent implements Component {
|
|
52
|
+
private selected = 0;
|
|
53
|
+
private scrollOffset = 0;
|
|
54
|
+
/** When true, viewport stays pinned to the newest output (default). */
|
|
55
|
+
private pinnedToBottom = true;
|
|
56
|
+
private closed = false;
|
|
57
|
+
private markdownCache: {
|
|
58
|
+
agent: ObserveAgentData | undefined;
|
|
59
|
+
messagesLength: number;
|
|
60
|
+
partial: Message | undefined;
|
|
61
|
+
state: ObserveAgentData["state"] | undefined;
|
|
62
|
+
width: number;
|
|
63
|
+
rendered: string[];
|
|
64
|
+
} | undefined;
|
|
65
|
+
private readonly timer: ReturnType<typeof setInterval>;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly tui: TUI,
|
|
69
|
+
private readonly theme: Theme,
|
|
70
|
+
private readonly session: ObserveSession,
|
|
71
|
+
private readonly done: (value: void) => void,
|
|
72
|
+
) {
|
|
73
|
+
this.timer = setInterval(() => tui.requestRender(), UI_TICK_MS);
|
|
74
|
+
session.closeOverlay = () => this.finish();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private finish(): void {
|
|
78
|
+
if (this.closed) return;
|
|
79
|
+
this.closed = true;
|
|
80
|
+
this.done(undefined);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private activeAgent(): ObserveAgentData | undefined {
|
|
84
|
+
return this.session.phase === "synthesizing" ? this.session.synthesizer : this.session.agents[this.selected];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
handleInput(data: string): void {
|
|
88
|
+
if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
|
|
89
|
+
this.finish();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (data === "p" || data === "P") {
|
|
93
|
+
this.pinnedToBottom = !this.pinnedToBottom;
|
|
94
|
+
if (this.pinnedToBottom) this.scrollOffset = Number.MAX_SAFE_INTEGER;
|
|
95
|
+
this.tui.requestRender();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (this.session.phase === "fanout" && /^[1-9]$/.test(data)) {
|
|
99
|
+
const index = Number.parseInt(data, 10) - 1;
|
|
100
|
+
if (this.session.agents[index]) {
|
|
101
|
+
this.selected = index;
|
|
102
|
+
this.scrollOffset = 0;
|
|
103
|
+
this.pinnedToBottom = true;
|
|
104
|
+
this.tui.requestRender();
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (this.pinnedToBottom && (matchesKey(data, Key.down) || data === "j" || matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f")) || data === "d")) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let delta = 0;
|
|
112
|
+
if (matchesKey(data, Key.down) || data === "j") delta = 1;
|
|
113
|
+
else if (matchesKey(data, Key.up) || data === "k") {
|
|
114
|
+
delta = -1;
|
|
115
|
+
this.pinnedToBottom = false;
|
|
116
|
+
}
|
|
117
|
+
else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f")) || data === "d") delta = 12;
|
|
118
|
+
else if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("b")) || data === "u") {
|
|
119
|
+
delta = -12;
|
|
120
|
+
this.pinnedToBottom = false;
|
|
121
|
+
}
|
|
122
|
+
else if (matchesKey(data, Key.home)) {
|
|
123
|
+
this.scrollOffset = 0;
|
|
124
|
+
this.pinnedToBottom = false;
|
|
125
|
+
}
|
|
126
|
+
else if (matchesKey(data, Key.end)) {
|
|
127
|
+
this.scrollOffset = Number.MAX_SAFE_INTEGER;
|
|
128
|
+
this.pinnedToBottom = true;
|
|
129
|
+
}
|
|
130
|
+
else return;
|
|
131
|
+
if (delta < 0) this.pinnedToBottom = false;
|
|
132
|
+
this.scrollOffset += delta;
|
|
133
|
+
this.tui.requestRender();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
render(width: number): string[] {
|
|
137
|
+
const terminalWidth = Math.max(20, process.stdout.columns ?? 80);
|
|
138
|
+
const safeWidth = safeRenderWidth(width, terminalWidth);
|
|
139
|
+
const innerWidth = Math.max(20, safeWidth - 4);
|
|
140
|
+
const frame = createFrame(this.theme, innerWidth, {
|
|
141
|
+
glyphs: ROUNDED_SINGLE_BOX,
|
|
142
|
+
horizontalPadding: 1,
|
|
143
|
+
truncationMark: "…",
|
|
144
|
+
padToWidth: true,
|
|
145
|
+
minimumBodyWidth: 10,
|
|
146
|
+
});
|
|
147
|
+
const bodyWidth = frame.bodyWidth;
|
|
148
|
+
const agent = this.activeAgent();
|
|
149
|
+
const messagesLength = agent?.messages.length ?? 0;
|
|
150
|
+
if (!this.markdownCache
|
|
151
|
+
|| this.markdownCache.agent !== agent
|
|
152
|
+
|| this.markdownCache.messagesLength !== messagesLength
|
|
153
|
+
|| this.markdownCache.partial !== agent?.partial
|
|
154
|
+
|| this.markdownCache.state !== agent?.state
|
|
155
|
+
|| this.markdownCache.width !== bodyWidth) {
|
|
156
|
+
const markdown = new Markdown(agent ? formatAgentMarkdown(agent) : "_(no agent selected)_", 0, 0, getMarkdownTheme());
|
|
157
|
+
this.markdownCache = {
|
|
158
|
+
agent,
|
|
159
|
+
messagesLength,
|
|
160
|
+
partial: agent?.partial,
|
|
161
|
+
state: agent?.state,
|
|
162
|
+
width: bodyWidth,
|
|
163
|
+
rendered: markdown.render(bodyWidth),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const rendered = this.markdownCache.rendered;
|
|
167
|
+
const viewport = Math.max(4, Math.min(rendered.length, ratioViewport(process.stdout.rows, {
|
|
168
|
+
fallbackRows: 24,
|
|
169
|
+
ratio: OVERLAY_HEIGHT_RATIO,
|
|
170
|
+
minimum: 4,
|
|
171
|
+
chromeRows: 6,
|
|
172
|
+
})));
|
|
173
|
+
const maxOffset = Math.max(0, rendered.length - viewport);
|
|
174
|
+
if (this.pinnedToBottom) this.scrollOffset = maxOffset;
|
|
175
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
|
|
176
|
+
const visible = rendered.slice(this.scrollOffset, this.scrollOffset + viewport);
|
|
177
|
+
while (visible.length < viewport) visible.push("");
|
|
178
|
+
const running = (this.session.phase === "fanout" ? this.session.agents : [this.session.synthesizer]).filter((item) => item?.state === "working").length;
|
|
179
|
+
const pin = this.pinnedToBottom ? " · pinned" : "";
|
|
180
|
+
const modelTag = this.activeAgent()?.model;
|
|
181
|
+
const title = `${this.session.title} · ${this.session.phase === "fanout" ? "fan-out" : "synthesizing"} · ${running} running${pin}`;
|
|
182
|
+
const tab = this.session.phase === "fanout" ? ` · ${this.selected + 1}/${this.session.agents.length}` : "";
|
|
183
|
+
const modelName = modelTag ? ` · ${modelTag}` : "";
|
|
184
|
+
const pinHelp = this.pinnedToBottom ? "p unpin" : "p pin bottom";
|
|
185
|
+
const help = this.session.phase === "fanout"
|
|
186
|
+
? `↑↓/jk scroll · u/d page · ${pinHelp} · # switch agent · esc/q close`
|
|
187
|
+
: `↑↓/jk scroll · u/d page · ${pinHelp} · esc/q close`;
|
|
188
|
+
const th = this.theme;
|
|
189
|
+
return [
|
|
190
|
+
frame.top(),
|
|
191
|
+
frame.row(th.fg("accent", "Observe agents") + th.fg("dim", ` ${title}${tab}${modelName}`)),
|
|
192
|
+
frame.separator(),
|
|
193
|
+
...visible.map((line) => frame.row(line)),
|
|
194
|
+
frame.separator(),
|
|
195
|
+
frame.row(th.fg("dim", help)),
|
|
196
|
+
frame.bottom(),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
invalidate(): void {}
|
|
201
|
+
|
|
202
|
+
dispose(): void {
|
|
203
|
+
clearInterval(this.timer);
|
|
204
|
+
if (this.session.closeOverlay) this.session.closeOverlay = undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|