@underactive/pi-topping-moa-fusion 0.2.0 → 0.3.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.
@@ -8,7 +8,8 @@
8
8
  * starting unassigned at `(none)`, plus a Load Roster row that applies a saved
9
9
  * roster to every slot wholesale, and a Start fan-out action. Enter on a slot
10
10
  * opens its two-pane model/thinking picker; confirming writes only that slot
11
- * and returns to the overview, cancelling returns without changing it. The
11
+ * and returns to the overview, cancelling returns without changing it.
12
+ * Backspace or Delete on a slot clears it back to `(none)`. The
12
13
  * overview's "Start fan-out" action stays visually disabled until at least two
13
14
  * proposer slots and all three required roles are assigned, then it finishes
14
15
  * the picker.
@@ -213,7 +214,7 @@ class MoaModelPickerComponent implements Component {
213
214
  return undefined;
214
215
  }
215
216
 
216
- private setRole(role: "synthesizer" | "implementer" | "verifier", ref: ModelRef, thinking: ThinkingLevel): void {
217
+ private setRole(role: "synthesizer" | "implementer" | "verifier", ref: ModelRef | undefined, thinking: ThinkingLevel | undefined): void {
217
218
  if (role === "synthesizer") { this.synthesizerRef = ref; this.synthesizerThinking = thinking; }
218
219
  else if (role === "implementer") { this.implementerRef = ref; this.implementerThinking = thinking; }
219
220
  else { this.verifierRef = ref; this.verifierThinking = thinking; }
@@ -240,6 +241,18 @@ class MoaModelPickerComponent implements Component {
240
241
  this.tui.requestRender();
241
242
  }
242
243
 
244
+ /** Return a slot to `(none)`; the overview cursor stays on its row. */
245
+ private clearSlot(screen: number): void {
246
+ const role = this.nonProposerRole(screen);
247
+ if (role) {
248
+ this.setRole(role, undefined, undefined);
249
+ } else {
250
+ this.proposerRefs[screen - 1] = undefined;
251
+ this.proposerThinking[screen - 1] = undefined;
252
+ }
253
+ this.tui.requestRender();
254
+ }
255
+
243
256
  /** Count of proposer slots holding an explicit assignment. */
244
257
  private assignedProposerCount(): number {
245
258
  return this.proposerRefs.reduce((n, ref) => (ref ? n + 1 : n), 0);
@@ -429,6 +442,11 @@ class MoaModelPickerComponent implements Component {
429
442
  // A disabled Start swallows Enter with no state change.
430
443
  return;
431
444
  }
445
+ if (matchesKey(data, Key.backspace) || matchesKey(data, Key.delete)) {
446
+ // Load Roster and Start have nothing to clear; every other row is a slot.
447
+ if (this.confirmIndex !== LOAD_ROW && this.confirmIndex !== START_ROW) this.clearSlot(this.confirmIndex);
448
+ return;
449
+ }
432
450
  if (matchesKey(data, Key.escape)) {
433
451
  // Back to the mode chooser; assignments are preserved.
434
452
  this.screen = 0;
@@ -604,7 +622,7 @@ class MoaModelPickerComponent implements Component {
604
622
  sepBorder,
605
623
  ...reviewRows,
606
624
  sepBorder,
607
- row(th.fg("dim", "\u2191\u2193 navigate \u2022 enter select \u2022 esc back")),
625
+ row(th.fg("dim", "\u2191\u2193 navigate \u2022 enter select \u2022 \u232B clear \u2022 esc back")),
608
626
  botBorder,
609
627
  ];
610
628
  if (lines.length <= viewport) return lines;
@@ -27,7 +27,7 @@
27
27
 
28
28
  import type { Message } from "@earendil-works/pi-ai";
29
29
  import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
30
- import { visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
30
+ import { visibleWidth, wrapTextWithAnsi, type Component, type TUI } from "@earendil-works/pi-tui";
31
31
  import { ACTIVITY_METER_WIDTH, ActivityMeter, rateToLevel, TokRateTracker } from "../activityMeter.ts";
32
32
  import { modelRefLabel, shortModelName, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
33
33
  import { contextPercent, CTX_COL_WIDTH, formatCost, formatElapsed, formatTokens } from "./agentStatus.ts";
@@ -122,6 +122,14 @@ const AGENT_COL_MIN = 8;
122
122
  /** Agent width below which a model ref stops being distinguishable from its siblings. */
123
123
  const AGENT_COL_READABLE = 24;
124
124
  const ACTIVITY_COL_MIN = 10;
125
+ /** Gutter marking a sub-row as belonging to the agent row above it. */
126
+ const SUB_ROW_GUTTER = "↳ ";
127
+ /**
128
+ * Most wrapped lines one failed row may spend on its error detail. A provider
129
+ * error fits well inside this at any usable width; the cap only bites on a
130
+ * pathological message, which must not swallow the whole table.
131
+ */
132
+ const ERROR_DETAIL_LINES = 8;
125
133
  const PREVIEW_LINES = 4;
126
134
  const PREVIEW_INDENT = STATUS_COL_WIDTH + 2;
127
135
  const PREVIEW_GUTTER = "│ ";
@@ -819,10 +827,24 @@ export class MoaProgressTableComponent implements Component {
819
827
  0,
820
828
  this.rowBudget() - TABLE_CHROME_ROWS - rows.length - phaseSectionRows(rows) - (band.length > 0 ? PHASE_BAND_ROWS : 0),
821
829
  );
830
+ // A failed row's message is the one status a reader has to act on, and it
831
+ // routinely outruns the ACTIVITY cell, so its wrapped detail claims budget
832
+ // ahead of live activity and previews. Failed rows are settled, so they
833
+ // compete for nothing else.
834
+ const subRowWidth = Math.max(0, bodyWidth - STATUS_COL_WIDTH - visibleWidth(SUB_ROW_GUTTER));
835
+ const errorDetails = new Map<number, string[]>();
836
+ let remaining = free;
837
+ for (let i = 0; i < rows.length && remaining > 0; i++) {
838
+ const detail = this.errorDetailLines(rows[i], cols.activity, subRowWidth, Math.min(ERROR_DETAIL_LINES, remaining));
839
+ if (detail.length === 0) continue;
840
+ errorDetails.set(i, detail);
841
+ remaining -= detail.length;
842
+ }
843
+
822
844
  const activeIndices = rows.flatMap((r, index) => isActive(r.state) ? [index] : []);
823
845
  const activityIndices = activeIndices.filter((index) => rows[index]?.activity);
824
- const renderedActivityIndices = new Set(activityIndices.slice(0, free));
825
- let remaining = Math.max(0, free - renderedActivityIndices.size);
846
+ const renderedActivityIndices = new Set(activityIndices.slice(0, remaining));
847
+ remaining = Math.max(0, remaining - renderedActivityIndices.size);
826
848
 
827
849
  const previewWidth = Math.max(0, leftTableWidth - PREVIEW_INDENT - visibleWidth(PREVIEW_GUTTER));
828
850
  const previewIndices = this.view.previewVisible && previewWidth >= PREVIEW_MIN_WIDTH
@@ -895,6 +917,9 @@ export class MoaProgressTableComponent implements Component {
895
917
  ),
896
918
  );
897
919
 
920
+ const errorDetail = errorDetails.get(i) ?? [];
921
+ for (const [offset, text] of errorDetail.entries()) lines.push(row(this.errorDetailRow(text, offset === 0, subRowWidth)));
922
+
898
923
  if (renderedActivityIndices.has(i)) lines.push(row(this.activitySubRow(r, bodyWidth)));
899
924
  else if (reservedActivityIndices.has(i)) lines.push(row(""));
900
925
 
@@ -932,11 +957,37 @@ export class MoaProgressTableComponent implements Component {
932
957
  /** Merged-cell tool activity line, spanning everything right of the status icon. */
933
958
  private activitySubRow(r: ProgressRow, bodyWidth: number): string {
934
959
  const room = Math.max(0, bodyWidth - STATUS_COL_WIDTH);
935
- const gutter = this.theme.fg("dim", "↳ ");
960
+ const gutter = this.theme.fg("dim", SUB_ROW_GUTTER);
936
961
  const activity = highlightActivity(this.theme, r.activity ?? "");
937
962
  return `${" ".repeat(STATUS_COL_WIDTH)}${cell(`${gutter}${activity}`, room)}`;
938
963
  }
939
964
 
965
+ /**
966
+ * A failed row's message re-laid across the table body, or nothing when the
967
+ * ACTIVITY cell already shows it whole. Provider failures arrive as one long
968
+ * JSON payload whose cause sits well past the column edge, so the text is
969
+ * wrapped — breaking the payload mid-token when it has no spaces to break on
970
+ * — rather than cut off at a width the reader cannot change.
971
+ */
972
+ private errorDetailLines(row: ProgressRow, activityWidth: number, width: number, maxLines: number): string[] {
973
+ if (row.state !== "error" || !row.statusText) return [];
974
+ if (width <= 0 || maxLines <= 0) return [];
975
+ if (visibleWidth(row.statusText) <= activityWidth) return [];
976
+ const wrapped = wrapTextWithAnsi(row.statusText, width);
977
+ if (wrapped.length <= maxLines) return wrapped;
978
+ // Mark the cut, so a clipped payload never reads as the whole message.
979
+ const shown = wrapped.slice(0, maxLines);
980
+ shown[maxLines - 1] = fitVisible(`${shown[maxLines - 1]}…`, width, { truncationMark: "…", padToWidth: false });
981
+ return shown;
982
+ }
983
+
984
+ /** One wrapped error line: the gutter carries the first, the rest hang under it. */
985
+ private errorDetailRow(text: string, first: boolean, width: number): string {
986
+ const gutter = first ? this.theme.fg("dim", SUB_ROW_GUTTER) : " ".repeat(visibleWidth(SUB_ROW_GUTTER));
987
+ const detail = fitVisible(text, width, { truncationMark: "…", padToWidth: false });
988
+ return `${" ".repeat(STATUS_COL_WIDTH)}${gutter}${this.theme.fg("error", detail)}`;
989
+ }
990
+
940
991
  private topBorder(innerWidth: number, border: (s: string) => string): string {
941
992
  const title = ` ${this.view.title} `;
942
993
  const head = `${border("══")}${this.theme.fg("accent", title)}`;
@@ -8,8 +8,8 @@
8
8
  *
9
9
  * Two kinds of slot are configured here. Planning-subagent slots persist into
10
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.
11
+ * longer set per role here — they live in named rosters whose completed edits
12
+ * are persisted immediately by the roster manager.
13
13
  */
14
14
 
15
15
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
@@ -361,7 +361,13 @@ export async function showMoaSetup(
361
361
  draft.useSummaryName = result.draft.useSummaryName;
362
362
  draft.maxConcurrentAgents = result.draft.maxConcurrentAgents;
363
363
  draft.maxVerificationRepairs = result.draft.maxVerificationRepairs;
364
- draft.rosters = await showRosterManager(ctx, draft.rosters, { currentThinking });
364
+ draft.rosters = await showRosterManager(ctx, draft.rosters, {
365
+ currentThinking,
366
+ onChange: (rosters) => {
367
+ const current = loadMoaConfig();
368
+ saveMoaConfig({ ...current, rosters });
369
+ },
370
+ });
365
371
  continue;
366
372
  }
367
373
  break;
@@ -71,16 +71,15 @@ function formatProposersList(proposers: ModelRef[]): string {
71
71
  }
72
72
 
73
73
  function formatSplitRow(left: string, right: string | undefined, bodyWidth: number): string {
74
- const leftText = left ?? "";
75
74
  if (!right) {
76
- return padRow(leftText, bodyWidth);
75
+ return padRow(left, bodyWidth);
77
76
  }
78
77
 
79
- const minLeft = Math.min(visibleWidth(leftText), 18);
78
+ const minLeft = Math.min(visibleWidth(left), 18);
80
79
  const maxRight = Math.max(1, bodyWidth - minLeft);
81
80
  const rightPart = truncateToWidth(right, maxRight, "…", false);
82
81
  const rightVis = visibleWidth(rightPart);
83
- const leftPart = truncateToWidth(leftText, Math.max(1, bodyWidth - rightVis), "…", false);
82
+ const leftPart = truncateToWidth(left, Math.max(1, bodyWidth - rightVis), "…", false);
84
83
  const gap = bodyWidth - visibleWidth(leftPart) - visibleWidth(rightPart);
85
84
  const row = gap > 0 ? leftPart + " ".repeat(gap) + rightPart : leftPart + rightPart;
86
85
  return padRow(row, bodyWidth);
@@ -92,15 +91,23 @@ function formatRightRow(content: string, bodyWidth: number): string {
92
91
  return padRow(gap > 0 ? " ".repeat(gap) + truncated : truncated, bodyWidth);
93
92
  }
94
93
 
94
+ export interface PlanReviewLabels {
95
+ title?: string;
96
+ approveHint?: string;
97
+ keepHint?: string;
98
+ chatHint?: string;
99
+ }
100
+
95
101
  export async function showPlanReview(
96
102
  ctx: ExtensionContext,
97
103
  planMarkdown: string,
98
104
  moaInfo?: MfPlanInfo,
99
105
  planName?: string,
100
106
  allowChat = false,
107
+ labels?: PlanReviewLabels,
101
108
  ): Promise<PlanReviewDecision> {
102
109
  const result = await ctx.ui.custom<PlanReviewDecision>(
103
- (tui, theme, _keybindings, done) => new PlanReviewOverlay(tui, theme, planMarkdown, moaInfo, ctx.cwd, planName, allowChat, done),
110
+ (tui, theme, _keybindings, done) => new PlanReviewOverlay(tui, theme, planMarkdown, moaInfo, ctx.cwd, planName, allowChat, labels, done),
104
111
  {
105
112
  overlay: true,
106
113
  overlayOptions: {
@@ -136,6 +143,7 @@ class PlanReviewOverlay implements Component {
136
143
  private readonly repoCwd: string,
137
144
  private readonly planName: string | undefined,
138
145
  private readonly allowChat: boolean,
146
+ private readonly labels: PlanReviewLabels | undefined,
139
147
  private readonly done: (decision: PlanReviewDecision) => void,
140
148
  ) {
141
149
  this.markdown = new Markdown(stripTerminalSequences(planMarkdown), 0, 0, getMarkdownTheme());
@@ -234,9 +242,9 @@ class PlanReviewOverlay implements Component {
234
242
  .join(",");
235
243
  const proposalHelp = helpParts ? ` · ${helpParts} proposer plans · \`/0 synthesized` : "";
236
244
  const verdictsHelp = this.verdictsMarkdown ? " · v verdicts" : "";
237
- const chatHelp = this.allowChat ? " · c chat" : "";
245
+ const chatHelp = this.allowChat ? ` · c ${this.labels?.chatHint ?? "chat"}` : "";
238
246
  const helpLines = wrapDimLine(
239
- `↑/↓ or j/k scroll · u/d page${proposalHelp}${verdictsHelp} · e edit${chatHelp} · a/Enter approve · q/Esc keep planning`,
247
+ `↑/↓ or j/k scroll · u/d page${proposalHelp}${verdictsHelp} · e edit${chatHelp} · a/Enter ${this.labels?.approveHint ?? "approve"} · q/Esc ${this.labels?.keepHint ?? "keep planning"}`,
240
248
  bodyWidth,
241
249
  );
242
250
  this.helpLineCount = helpLines.length;
@@ -261,7 +269,7 @@ class PlanReviewOverlay implements Component {
261
269
  const planTitle = showingVerdicts
262
270
  ? "Proposer Verdicts"
263
271
  : this.activeProposerIndex === undefined
264
- ? "Proposed Plan"
272
+ ? this.labels?.title ?? "Proposed Plan"
265
273
  : `Proposer ${this.activeProposerIndex + 1} Plan`;
266
274
  const proposerModel = this.activeProposerIndex === undefined
267
275
  ? undefined
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * A staged CRUD surface over the saved `PlanRoster` list: list → name prompt →
5
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.
6
+ * RosterEditor. Draft slot changes remain in memory until Save roster is
7
+ * chosen; completed roster saves and confirmed deletions are then persisted
8
+ * immediately through the manager's onChange callback.
9
9
  *
10
10
  * Each roster carries one model + thinking level for every fan-out role —
11
11
  * 2–5 proposers plus synthesizer, implementer, and verifier. A roster must be
@@ -31,8 +31,6 @@ import { ratioViewport } from "./chrome.ts";
31
31
  import { MenuComponent, showOverlayPrompt, type ActionMenuItem } from "./menu.ts";
32
32
  import { showModelThinkingPicker } from "./moaModelPicker.ts";
33
33
 
34
- const BACKSPACE = "\x7f";
35
-
36
34
  type SlotRole = "synthesizer" | "implementer" | "verifier";
37
35
  type SlotTarget = { kind: "proposer"; index: number } | { kind: "role"; role: SlotRole };
38
36
  type ListResult = { action: "create" } | { action: "edit"; index: number } | { action: "back" };
@@ -198,7 +196,7 @@ function showSlotEditor(ctx: ExtensionContext, draft: DraftRoster, focusItemId:
198
196
  { title: "roles", items: roleItems },
199
197
  ],
200
198
  onItemKey: (item, data) => {
201
- if (data !== BACKSPACE && data !== "\b" && !matchesKey(data, Key.delete)) return false;
199
+ if (!matchesKey(data, Key.backspace) && !matchesKey(data, Key.delete)) return false;
202
200
  const target = parseTargetItemId(item.id);
203
201
  if (!target) return true;
204
202
  if (target.kind === "proposer") working.proposers[target.index] = undefined;
@@ -245,7 +243,7 @@ async function editRoster(
245
243
  if (result.action === "cancel") return undefined;
246
244
  if (result.action === "save") return fromDraft(result.draft);
247
245
  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";
246
+ if (await ctx.ui.confirm("Delete agent roster?", `Delete ${draft.name}? This takes effect immediately.`)) return "delete";
249
247
  continue;
250
248
  }
251
249
  if (result.action === "rename") {
@@ -272,11 +270,11 @@ async function editRoster(
272
270
  }
273
271
  }
274
272
 
275
- /** Manage a staged roster collection; persistence remains the settings overlay's Save action's responsibility. */
273
+ /** Manage roster drafts and publish completed saves immediately through onChange. */
276
274
  export async function showRosterManager(
277
275
  ctx: ExtensionContext,
278
276
  initial: PlanRoster[],
279
- deps: { currentThinking: ThinkingLevel },
277
+ deps: { currentThinking: ThinkingLevel; onChange?: (rosters: PlanRoster[]) => void },
280
278
  ): Promise<PlanRoster[]> {
281
279
  const rosters = initial.map((roster) => ({ ...roster, proposers: [...roster.proposers] }));
282
280
  for (;;) {
@@ -298,13 +296,21 @@ export async function showRosterManager(
298
296
  undefined,
299
297
  deps.currentThinking,
300
298
  );
301
- if (created && created !== "delete") rosters.push(created);
299
+ if (created && created !== "delete") {
300
+ rosters.push(created);
301
+ deps.onChange?.(rosters);
302
+ }
302
303
  continue;
303
304
  }
304
305
  const current = rosters[action.index];
305
306
  if (!current) continue;
306
307
  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;
308
+ if (edited === "delete") {
309
+ rosters.splice(action.index, 1);
310
+ deps.onChange?.(rosters);
311
+ } else if (edited) {
312
+ rosters[action.index] = edited;
313
+ deps.onChange?.(rosters);
314
+ }
309
315
  }
310
316
  }
@@ -150,8 +150,8 @@ export async function withWorkingOverlay<T>(
150
150
  return unwrap(await settled);
151
151
  }
152
152
 
153
- /** Copy for the plan/opinion/debate naming wait. No trailing ellipses — see CHANGELOG 0.2.0. */
154
- export function planNamingOverlay(subject: "plan" | "opinion" | "debate", skip: () => string): WorkingOverlayOptions<string> {
153
+ /** Copy for the plan/opinion/debate/spec naming wait. No trailing ellipses — see CHANGELOG 0.2.0. */
154
+ export function planNamingOverlay(subject: "plan" | "opinion" | "debate" | "spec", skip: () => string): WorkingOverlayOptions<string> {
155
155
  return {
156
156
  title: `Naming the ${subject}`,
157
157
  detail: `summarizing your prompt into a short ${subject} name`,