@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,246 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { loadMoaConfig, type MoaConfig } from "../config/settings.ts";
4
+ import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
5
+ import {
6
+ SELECTOR_POINTER,
7
+ SQUARE_SINGLE_BOX,
8
+ UNSELECTED_POINTER,
9
+ createFrame,
10
+ ratioViewport,
11
+ } from "./chrome.ts";
12
+ import { getAvailableModelRefs } from "./moaModelPicker.ts";
13
+ import { smartTruncateModelLabel } from "./modelLabel.ts";
14
+ import { TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
15
+
16
+ const HINT_BASE = "type filters models • ↑↓ navigate pane • tab panes/buttons • ←→ switch/select • enter select";
17
+
18
+ export const MAX_OPINION_MODELS = 5;
19
+ export const MIN_OPINION_MODELS = 1;
20
+ export const START_ROW = MAX_OPINION_MODELS;
21
+ export const OVERVIEW_ROW_COUNT = MAX_OPINION_MODELS + 1;
22
+ const OVERVIEW_SCREEN = 0;
23
+ const SLOT_TITLES = ["Opinion 1", "Opinion 2", "Opinion 3", "Opinion 4", "Opinion 5"];
24
+
25
+ export interface OpinionPickerResult {
26
+ models: ModelRef[];
27
+ thinking: (ThinkingLevel | undefined)[];
28
+ thinkingSelections: Record<string, ThinkingLevel>;
29
+ }
30
+
31
+ export class OpinionModelPickerComponent implements Component {
32
+ private screen = OVERVIEW_SCREEN;
33
+ private overviewIndex = 0;
34
+ private readonly refs: (ModelRef | undefined)[] = Array.from({ length: MAX_OPINION_MODELS }, () => undefined);
35
+ private readonly thinking: (ThinkingLevel | undefined)[] = Array.from({ length: MAX_OPINION_MODELS }, () => undefined);
36
+ private readonly thinkingSelections: Record<string, ThinkingLevel> = {};
37
+ private readonly tui: TUI;
38
+ private readonly theme: Theme;
39
+ private readonly defaults: (ModelRef | undefined)[];
40
+ private readonly done: (result: OpinionPickerResult | undefined) => void;
41
+ private readonly twoPane: TwoPaneModelThinking;
42
+
43
+ constructor(
44
+ tui: TUI,
45
+ theme: Theme,
46
+ availableRefs: ModelRef[],
47
+ defaults: (ModelRef | undefined)[],
48
+ config: MoaConfig,
49
+ currentThinking: ThinkingLevel,
50
+ ctx: ExtensionContext,
51
+ done: (result: OpinionPickerResult | undefined) => void,
52
+ ) {
53
+ this.tui = tui;
54
+ this.theme = theme;
55
+ this.defaults = defaults;
56
+ this.done = done;
57
+ this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
58
+ }
59
+
60
+ private assignedCount(): number {
61
+ return this.refs.reduce((count, ref) => count + (ref ? 1 : 0), 0);
62
+ }
63
+
64
+ private isReady(): boolean {
65
+ return this.assignedCount() >= MIN_OPINION_MODELS;
66
+ }
67
+
68
+ private openSlot(slot: number): void {
69
+ const committed = this.refs[slot];
70
+ if (committed) this.twoPane.reset(committed, this.thinking[slot]);
71
+ else this.twoPane.reset(this.defaults[slot]);
72
+ this.screen = slot + 1;
73
+ this.tui.requestRender();
74
+ }
75
+
76
+ private commitSlot(selection: { ref: ModelRef; thinking: ThinkingLevel }): void {
77
+ const slot = this.screen - 1;
78
+ this.refs[slot] = selection.ref;
79
+ this.thinking[slot] = selection.thinking;
80
+ this.thinkingSelections[modelRefLabel(selection.ref)] = selection.thinking;
81
+ this.screen = OVERVIEW_SCREEN;
82
+ this.tui.requestRender();
83
+ }
84
+
85
+ finish(): void {
86
+ if (!this.isReady()) return;
87
+ const models: ModelRef[] = [];
88
+ const thinking: (ThinkingLevel | undefined)[] = [];
89
+ for (let index = 0; index < MAX_OPINION_MODELS; index++) {
90
+ const ref = this.refs[index];
91
+ if (!ref) continue;
92
+ models.push(ref);
93
+ thinking.push(this.thinking[index]);
94
+ }
95
+ this.done({ models, thinking, thinkingSelections: { ...this.thinkingSelections } });
96
+ }
97
+
98
+ handleInput(data: string): void {
99
+ if (this.screen === OVERVIEW_SCREEN) {
100
+ if (matchesKey(data, Key.up)) {
101
+ if (this.overviewIndex > 0) {
102
+ this.overviewIndex--;
103
+ this.tui.requestRender();
104
+ }
105
+ return;
106
+ }
107
+ if (matchesKey(data, Key.down)) {
108
+ if (this.overviewIndex < OVERVIEW_ROW_COUNT - 1) {
109
+ this.overviewIndex++;
110
+ this.tui.requestRender();
111
+ }
112
+ return;
113
+ }
114
+ if (matchesKey(data, Key.enter)) {
115
+ if (this.overviewIndex < START_ROW) this.openSlot(this.overviewIndex);
116
+ else this.finish();
117
+ return;
118
+ }
119
+ if (matchesKey(data, Key.escape)) this.done(undefined);
120
+ return;
121
+ }
122
+
123
+ const action = this.twoPane.handleInput(data);
124
+ if (action === "confirm") this.commitSlot(this.twoPane.getSelected());
125
+ else if (action === "back") {
126
+ this.screen = OVERVIEW_SCREEN;
127
+ this.tui.requestRender();
128
+ }
129
+ }
130
+
131
+ render(width: number): string[] {
132
+ const th = this.theme;
133
+ const innerWidth = Math.max(20, width - 4);
134
+ const frame = createFrame(th, innerWidth, {
135
+ glyphs: SQUARE_SINGLE_BOX,
136
+ horizontalPadding: 0,
137
+ truncationMark: "...",
138
+ padToWidth: true,
139
+ minimumBodyWidth: 10,
140
+ });
141
+ const bodyWidth = frame.bodyWidth;
142
+ const row = (content: string) => frame.row(` ${content}`);
143
+ const viewport = ratioViewport(process.stdout.rows, {
144
+ fallbackRows: 24,
145
+ ratio: 0.7,
146
+ minimum: 6,
147
+ });
148
+
149
+ if (this.screen === OVERVIEW_SCREEN) {
150
+ const ready = this.isReady();
151
+ const slotRow = (label: string, detail: string, index: number, disabled = false) => {
152
+ const active = index === this.overviewIndex;
153
+ const pointer = active ? th.fg("accent", SELECTOR_POINTER) : UNSELECTED_POINTER;
154
+ const labelText = disabled
155
+ ? (active ? th.bold(th.fg("muted", label)) : th.fg("dim", label))
156
+ : (active ? th.bold(th.fg("accent", label)) : th.bold(label));
157
+ return frame.row(` ${pointer}${labelText}${detail ? ` ${th.fg("muted", detail)}` : ""}`);
158
+ };
159
+ const detail = (label: string, ref: ModelRef | undefined, thinking: ThinkingLevel | undefined) => {
160
+ if (!ref) return "(none)";
161
+ const suffix = ` · thinking: ${thinking ?? "—"}`;
162
+ const modelWidth = Math.max(4, bodyWidth - 5 - visibleWidth(label) - visibleWidth(suffix));
163
+ return `${smartTruncateModelLabel(modelRefLabel(ref), modelWidth)}${suffix}`;
164
+ };
165
+ const slotRows = this.refs.map((ref, index) => {
166
+ const label = SLOT_TITLES[index] ?? `Opinion ${index + 1}`;
167
+ return slotRow(label, detail(label, ref, this.thinking[index]), index);
168
+ });
169
+ const action = slotRow("Get opinions", ready ? "" : "needs 1 opinion model", START_ROW, !ready);
170
+ const fullRows = [...slotRows, row(""), action];
171
+ const lines = [
172
+ frame.top(),
173
+ row(th.fg("accent", "Select Opinion Models")),
174
+ frame.separator(),
175
+ ...fullRows,
176
+ frame.separator(),
177
+ row(th.fg("dim", "↑↓ navigate • enter select • esc cancel")),
178
+ frame.bottom(),
179
+ ];
180
+ if (lines.length <= viewport) return lines;
181
+ const maxRows = Math.max(1, viewport - 4);
182
+ const compactRows = [...slotRows, action];
183
+ const start = Math.max(0, Math.min(this.overviewIndex - Math.floor(maxRows / 2), compactRows.length - maxRows));
184
+ return [
185
+ frame.top(),
186
+ row(th.fg("accent", "Select Opinion Models")),
187
+ frame.separator(),
188
+ ...compactRows.slice(start, start + maxRows),
189
+ frame.bottom(),
190
+ ].slice(0, viewport);
191
+ }
192
+
193
+ const slot = this.screen - 1;
194
+ const { actionRow, hintRows } = this.twoPane.renderFooter(bodyWidth, `${HINT_BASE} • esc back`);
195
+ this.twoPane.setMaxVisibleRows(Math.max(1, viewport - 8 - (hintRows.length - 1)));
196
+ const pane = this.twoPane.render(bodyWidth).map((line) => frame.row(line));
197
+ const lines = [
198
+ frame.top(),
199
+ row(th.fg("accent", `Opinion ${slot + 1} (${slot + 1}/${MAX_OPINION_MODELS})`)),
200
+ frame.separator(),
201
+ ...pane,
202
+ frame.separator(),
203
+ frame.row(actionRow),
204
+ frame.separator(),
205
+ ...hintRows.map((line) => frame.row(line)),
206
+ frame.bottom(),
207
+ ];
208
+ if (lines.length <= viewport) return lines;
209
+ return [frame.top(), row(th.fg("accent", `Opinion ${slot + 1}`)), ...pane.slice(0, Math.max(1, viewport - 4)), frame.row(actionRow), frame.bottom()].slice(0, viewport);
210
+ }
211
+
212
+ invalidate(): void {
213
+ this.twoPane.invalidate();
214
+ }
215
+ }
216
+
217
+ export async function showOpinionModelPicker(
218
+ ctx: ExtensionContext,
219
+ currentThinking: ThinkingLevel,
220
+ ): Promise<OpinionPickerResult | undefined> {
221
+ if (!ctx.hasUI) return undefined;
222
+ const available = getAvailableModelRefs(ctx);
223
+ if (available.length === 0) return undefined;
224
+
225
+ const saved = loadMoaConfig();
226
+ const current = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
227
+ const fallback = current ?? saved.opinionModels[0];
228
+ const defaults = Array.from({ length: MAX_OPINION_MODELS }, (_, index) =>
229
+ saved.opinionModels[index] ?? fallback,
230
+ );
231
+
232
+ return await ctx.ui.custom<OpinionPickerResult | undefined>(
233
+ (tui, theme, _keybindings, done) =>
234
+ new OpinionModelPickerComponent(tui, theme, available, defaults, saved, currentThinking, ctx, done),
235
+ {
236
+ overlay: true,
237
+ overlayOptions: {
238
+ anchor: "center",
239
+ width: "82%",
240
+ minWidth: 64,
241
+ maxHeight: "70%",
242
+ margin: 1,
243
+ },
244
+ },
245
+ );
246
+ }
@@ -0,0 +1,315 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
3
+ import { Key, Markdown, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
4
+ import { shortModelName, type ModelRef } from "../shared/modelRefs.ts";
5
+ import type { MfPlanInfo, PlanReviewDecision } from "../moa/planInfo.ts";
6
+ import { ROUNDED_SINGLE_BOX, createFrame, ratioViewport, safeRenderWidth } from "./chrome.ts";
7
+ import { saveRepoPlanFile } from "../planning/planFile.ts";
8
+
9
+ const OVERLAY_HEIGHT_RATIO = 0.9;
10
+ const OVERLAY_MARGIN = 1;
11
+ /** Fixed chrome: top border, title, separator, separator, bottom border (help row(s) added separately). */
12
+ const BASE_CHROME_LINES = 5;
13
+ const MOA_META_LINES = 1; // proposing row
14
+
15
+ function getChromeLines(moaInfo: MfPlanInfo | undefined, helpLines: number): number {
16
+ return BASE_CHROME_LINES + helpLines + (moaInfo ? MOA_META_LINES : 0);
17
+ }
18
+
19
+ function getViewportLines(moaInfo: MfPlanInfo | undefined, helpLines: number): number {
20
+ return ratioViewport(process.stdout.rows, {
21
+ fallbackRows: 24,
22
+ ratio: OVERLAY_HEIGHT_RATIO,
23
+ minimum: 6,
24
+ margin: OVERLAY_MARGIN,
25
+ chromeRows: getChromeLines(moaInfo, helpLines),
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Wrap a `·`-delimited help/status line to fit within bodyWidth, spilling to a
31
+ * second line if needed instead of truncating the whole thing to an ellipsis.
32
+ */
33
+ function wrapDimLine(text: string, bodyWidth: number): string[] {
34
+ if (visibleWidth(text) <= bodyWidth) return [text];
35
+
36
+ const parts = text.split(" · ");
37
+ const lines: string[] = [];
38
+ let current = "";
39
+ for (const part of parts) {
40
+ const candidate = current ? `${current} · ${part}` : part;
41
+ if (visibleWidth(candidate) <= bodyWidth) {
42
+ current = candidate;
43
+ } else {
44
+ if (current) lines.push(current);
45
+ current = part;
46
+ }
47
+ }
48
+ if (current) lines.push(current);
49
+
50
+ if (lines.length > 2) {
51
+ const first = lines[0];
52
+ const rest = lines.slice(1).join(" · ");
53
+ lines.length = 0;
54
+ lines.push(first, rest);
55
+ }
56
+
57
+ return lines.slice(0, 2).map((line) => truncateToWidth(line, bodyWidth, "…", false));
58
+ }
59
+
60
+ const MOA_SYNTHESIZED_PREFIX = "Synthesized by: ";
61
+
62
+ function padRow(content: string, bodyWidth: number): string {
63
+ return truncateToWidth(content, bodyWidth, "…", true);
64
+ }
65
+
66
+ function formatProposersList(proposers: ModelRef[]): string {
67
+ const content = proposers
68
+ .map((ref, index) => `P${index + 1}: ${shortModelName(ref)}`)
69
+ .join(", ");
70
+ return `(${content})`;
71
+ }
72
+
73
+ function formatSplitRow(left: string, right: string | undefined, bodyWidth: number): string {
74
+ const leftText = left ?? "";
75
+ if (!right) {
76
+ return padRow(leftText, bodyWidth);
77
+ }
78
+
79
+ const minLeft = Math.min(visibleWidth(leftText), 18);
80
+ const maxRight = Math.max(1, bodyWidth - minLeft);
81
+ const rightPart = truncateToWidth(right, maxRight, "…", false);
82
+ const rightVis = visibleWidth(rightPart);
83
+ const leftPart = truncateToWidth(leftText, Math.max(1, bodyWidth - rightVis), "…", false);
84
+ const gap = bodyWidth - visibleWidth(leftPart) - visibleWidth(rightPart);
85
+ const row = gap > 0 ? leftPart + " ".repeat(gap) + rightPart : leftPart + rightPart;
86
+ return padRow(row, bodyWidth);
87
+ }
88
+
89
+ function formatRightRow(content: string, bodyWidth: number): string {
90
+ const truncated = truncateToWidth(content, bodyWidth, "…", false);
91
+ const gap = bodyWidth - visibleWidth(truncated);
92
+ return padRow(gap > 0 ? " ".repeat(gap) + truncated : truncated, bodyWidth);
93
+ }
94
+
95
+ export async function showPlanReview(
96
+ ctx: ExtensionContext,
97
+ planMarkdown: string,
98
+ moaInfo?: MfPlanInfo,
99
+ planName?: string,
100
+ allowChat = false,
101
+ ): Promise<PlanReviewDecision> {
102
+ const result = await ctx.ui.custom<PlanReviewDecision>(
103
+ (tui, theme, _keybindings, done) => new PlanReviewOverlay(tui, theme, planMarkdown, moaInfo, ctx.cwd, planName, allowChat, done),
104
+ {
105
+ overlay: true,
106
+ overlayOptions: {
107
+ anchor: "center",
108
+ width: "90%",
109
+ minWidth: 60,
110
+ maxHeight: "90%",
111
+ margin: OVERLAY_MARGIN,
112
+ },
113
+ },
114
+ );
115
+
116
+ return result ?? "keep";
117
+ }
118
+
119
+ class PlanReviewOverlay implements Component {
120
+ private readonly markdown: Markdown;
121
+ private readonly proposerMarkdown = new Map<number, Markdown>();
122
+ private readonly proposerLabels = new Map<number, string>();
123
+ private readonly verdictsMarkdown: Markdown | undefined;
124
+ private activeProposerIndex: number | undefined;
125
+ private showVerdicts = false;
126
+ private scrollOffset = 0;
127
+ /** Number of help-row lines the last render used; kept for scroll math between renders. */
128
+ private helpLineCount = 1;
129
+ private renderCache: { markdown: Markdown; width: number; rendered: string[] } | undefined;
130
+
131
+ constructor(
132
+ private readonly tui: TUI,
133
+ private readonly theme: Theme,
134
+ private readonly planMarkdown: string,
135
+ private readonly moaInfo: MfPlanInfo | undefined,
136
+ private readonly repoCwd: string,
137
+ private readonly planName: string | undefined,
138
+ private readonly allowChat: boolean,
139
+ private readonly done: (decision: PlanReviewDecision) => void,
140
+ ) {
141
+ this.markdown = new Markdown(stripTerminalSequences(planMarkdown), 0, 0, getMarkdownTheme());
142
+ for (const proposal of moaInfo?.proposerPlans ?? []) {
143
+ this.proposerMarkdown.set(proposal.proposerIndex, new Markdown(stripTerminalSequences(proposal.markdown), 0, 0, getMarkdownTheme()));
144
+ this.proposerLabels.set(proposal.proposerIndex, shortModelName(proposal.model));
145
+ }
146
+ if (moaInfo?.verdictsMarkdown) {
147
+ this.verdictsMarkdown = new Markdown(stripTerminalSequences(moaInfo.verdictsMarkdown), 0, 0, getMarkdownTheme());
148
+ }
149
+ }
150
+
151
+ handleInput(data: string): void {
152
+ const proposerIndex = Number.parseInt(data, 10) - 1;
153
+ if (/^[1-9]$/.test(data) && this.proposerMarkdown.has(proposerIndex)) {
154
+ this.activeProposerIndex = proposerIndex;
155
+ this.showVerdicts = false;
156
+ this.scrollOffset = 0;
157
+ this.tui.requestRender();
158
+ return;
159
+ }
160
+ if (data === "0" || data === "`") {
161
+ this.activeProposerIndex = undefined;
162
+ this.showVerdicts = false;
163
+ this.scrollOffset = 0;
164
+ this.tui.requestRender();
165
+ return;
166
+ }
167
+ if (this.verdictsMarkdown && (data === "v" || data === "V")) {
168
+ this.showVerdicts = !this.showVerdicts;
169
+ this.activeProposerIndex = undefined;
170
+ this.scrollOffset = 0;
171
+ this.tui.requestRender();
172
+ return;
173
+ }
174
+
175
+ if (matchesKey(data, Key.enter) || data === "a" || data === "A") {
176
+ if (this.planName) {
177
+ try {
178
+ saveRepoPlanFile(this.planMarkdown, this.repoCwd, this.planName, "plan");
179
+ } catch {
180
+ // Don't block approval if the repo save fails.
181
+ }
182
+ }
183
+ this.done("approve");
184
+ return;
185
+ }
186
+ if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
187
+ this.done("keep");
188
+ return;
189
+ }
190
+ if (data === "e" || data === "E") {
191
+ this.done("edit");
192
+ return;
193
+ }
194
+ if (this.allowChat && (data === "c" || data === "C")) {
195
+ this.done("chat");
196
+ return;
197
+ }
198
+
199
+ let delta = 0;
200
+ if (matchesKey(data, Key.down) || data === "j") delta = 1;
201
+ else if (matchesKey(data, Key.up) || data === "k") delta = -1;
202
+ else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f")) || data === "d") delta = getViewportLines(this.moaInfo, this.helpLineCount) - 2;
203
+ else if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("b")) || data === "u") delta = -(getViewportLines(this.moaInfo, this.helpLineCount) - 2);
204
+ else if (matchesKey(data, Key.home)) this.scrollOffset = 0;
205
+ else if (matchesKey(data, Key.end)) this.scrollOffset = Number.MAX_SAFE_INTEGER;
206
+ else return;
207
+
208
+ this.scrollOffset += delta;
209
+ this.tui.requestRender();
210
+ }
211
+
212
+ render(width: number): string[] {
213
+ // Overlay re-renders can briefly receive a bogus width while handling input.
214
+ // Never pass an unbounded value to Markdown.render(), which pads via String.repeat().
215
+ const terminalWidth = Math.max(20, process.stdout.columns ?? 80);
216
+ const safeWidth = safeRenderWidth(width, terminalWidth);
217
+ const innerWidth = Math.max(20, safeWidth - 4);
218
+ const frame = createFrame(this.theme, innerWidth, {
219
+ glyphs: ROUNDED_SINGLE_BOX,
220
+ horizontalPadding: 1,
221
+ truncationMark: "…",
222
+ padToWidth: true,
223
+ minimumBodyWidth: 10,
224
+ });
225
+ const bodyWidth = frame.bodyWidth;
226
+ const activeMarkdown = this.showVerdicts && this.verdictsMarkdown
227
+ ? this.verdictsMarkdown
228
+ : this.activeProposerIndex === undefined
229
+ ? this.markdown
230
+ : this.proposerMarkdown.get(this.activeProposerIndex) ?? this.markdown;
231
+ const helpParts = [...this.proposerMarkdown.keys()]
232
+ .map((index) => index + 1)
233
+ .sort((a, b) => a - b)
234
+ .join(",");
235
+ const proposalHelp = helpParts ? ` · ${helpParts} proposer plans · \`/0 synthesized` : "";
236
+ const verdictsHelp = this.verdictsMarkdown ? " · v verdicts" : "";
237
+ const chatHelp = this.allowChat ? " · c chat" : "";
238
+ const helpLines = wrapDimLine(
239
+ `↑/↓ or j/k scroll · u/d page${proposalHelp}${verdictsHelp} · e edit${chatHelp} · a/Enter approve · q/Esc keep planning`,
240
+ bodyWidth,
241
+ );
242
+ this.helpLineCount = helpLines.length;
243
+
244
+ if (!this.renderCache || this.renderCache.markdown !== activeMarkdown || this.renderCache.width !== bodyWidth) {
245
+ this.renderCache = { markdown: activeMarkdown, width: bodyWidth, rendered: activeMarkdown.render(bodyWidth) };
246
+ }
247
+ const renderedPlan = this.renderCache.rendered;
248
+ const viewport = Math.min(getViewportLines(this.moaInfo, this.helpLineCount), Math.max(6, renderedPlan.length));
249
+ const maxOffset = Math.max(0, renderedPlan.length - viewport);
250
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
251
+
252
+ const visible = renderedPlan.slice(this.scrollOffset, this.scrollOffset + viewport);
253
+ while (visible.length < viewport) visible.push("");
254
+
255
+ const th = this.theme;
256
+ const position = renderedPlan.length > viewport
257
+ ? ` lines ${this.scrollOffset + 1}-${Math.min(this.scrollOffset + viewport, renderedPlan.length)}/${renderedPlan.length}`
258
+ : ` ${renderedPlan.length} lines`;
259
+
260
+ const showingVerdicts = this.showVerdicts && this.verdictsMarkdown !== undefined;
261
+ const planTitle = showingVerdicts
262
+ ? "Proposer Verdicts"
263
+ : this.activeProposerIndex === undefined
264
+ ? "Proposed Plan"
265
+ : `Proposer ${this.activeProposerIndex + 1} Plan`;
266
+ const proposerModel = this.activeProposerIndex === undefined
267
+ ? undefined
268
+ : this.proposerLabels.get(this.activeProposerIndex);
269
+ const nameSuffix = this.planName && this.activeProposerIndex === undefined && !showingVerdicts
270
+ ? th.fg("dim", ` · ${this.planName}`)
271
+ : "";
272
+ const leftTitle = th.fg("accent", planTitle)
273
+ + nameSuffix
274
+ + (proposerModel ? th.fg("dim", ` · ${proposerModel}`) : "")
275
+ + th.fg("dim", position);
276
+ let rightTitle: string | undefined;
277
+ if (this.moaInfo && this.activeProposerIndex === undefined) {
278
+ rightTitle = th.fg("dim", MOA_SYNTHESIZED_PREFIX)
279
+ + th.fg("text", shortModelName(this.moaInfo.synthesizer));
280
+ }
281
+
282
+ const lines: string[] = [
283
+ frame.top(),
284
+ frame.row(formatSplitRow(leftTitle, rightTitle, bodyWidth)),
285
+ ];
286
+
287
+ if (this.moaInfo) {
288
+ const proposedLine = th.fg("text", formatProposersList(this.moaInfo.proposers));
289
+ lines.push(frame.row(formatRightRow(proposedLine, bodyWidth)));
290
+ }
291
+
292
+ lines.push(frame.separator());
293
+
294
+ for (const line of visible) {
295
+ lines.push(frame.row(line));
296
+ }
297
+
298
+ lines.push(frame.separator());
299
+ for (const helpLine of helpLines) {
300
+ lines.push(frame.row(th.fg("dim", helpLine)));
301
+ }
302
+ lines.push(frame.bottom());
303
+
304
+ return lines;
305
+ }
306
+
307
+ invalidate(): void {
308
+ this.markdown.invalidate();
309
+ this.verdictsMarkdown?.invalidate();
310
+ for (const markdown of this.proposerMarkdown.values()) markdown.invalidate();
311
+ this.renderCache = undefined;
312
+ }
313
+
314
+ dispose(): void {}
315
+ }
@@ -0,0 +1,87 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { ExtensionEditorComponent, getAgentDir, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { CombinedAutocompleteProvider, Container, Editor, Text, type Focusable, type TUI } from "@earendil-works/pi-tui";
7
+
8
+ type KeybindingsManager = ConstructorParameters<typeof ExtensionEditorComponent>[1];
9
+
10
+ let fdPath: string | null | undefined;
11
+
12
+ /** Find pi's bundled fd binary, then a compatible binary available on PATH. */
13
+ export function resolveFdPath(): string | null {
14
+ if (fdPath !== undefined) return fdPath;
15
+
16
+ try {
17
+ const bundledPath = join(getAgentDir(), "bin", process.platform === "win32" ? "fd.exe" : "fd");
18
+ if (existsSync(bundledPath)) return fdPath = bundledPath;
19
+
20
+ for (const command of ["fd", "fdfind"]) {
21
+ if (spawnSync(command, ["--version"], { stdio: "ignore" }).status === 0) return fdPath = command;
22
+ }
23
+ } catch {
24
+ // Path lookup is an optional enhancement; regular path completion still works.
25
+ }
26
+
27
+ return fdPath = null;
28
+ }
29
+
30
+ export class PromptEditorComponent extends Container implements Focusable {
31
+ private readonly editor: Editor;
32
+ private readonly inner: ExtensionEditorComponent;
33
+
34
+ constructor(
35
+ tui: TUI,
36
+ keybindings: KeybindingsManager,
37
+ title: string,
38
+ prefill: string | undefined,
39
+ done: (value: string | undefined) => void,
40
+ cwd: string,
41
+ ) {
42
+ super();
43
+ this.inner = new ExtensionEditorComponent(tui, keybindings, title, prefill, done, () => done(undefined));
44
+ this.addChild(this.inner);
45
+
46
+ // pi-coding-agent may resolve a nested pi-tui copy, so retain a structural
47
+ // fallback when the primary instanceof guard has a different class identity.
48
+ const editor = this.inner.children.find((child): child is Editor => (
49
+ child instanceof Editor
50
+ || (
51
+ typeof (child as Editor).setAutocompleteProvider === "function"
52
+ && typeof (child as Editor).isShowingAutocomplete === "function"
53
+ && typeof (child as Editor).getText === "function"
54
+ )
55
+ ));
56
+ if (!editor) throw new Error("ExtensionEditorComponent did not contain an Editor");
57
+ this.editor = editor;
58
+ this.editor.setAutocompleteProvider(new CombinedAutocompleteProvider([], cwd, resolveFdPath()));
59
+
60
+ this.addChild(new Text("Tab completes file paths · @name searches the repo", 1, 0));
61
+ }
62
+
63
+ get focused(): boolean {
64
+ return this.inner.focused;
65
+ }
66
+
67
+ set focused(value: boolean) {
68
+ this.inner.focused = value;
69
+ }
70
+
71
+ handleInput(data: string): void {
72
+ if (this.editor.isShowingAutocomplete()) {
73
+ this.editor.handleInput(data);
74
+ return;
75
+ }
76
+ this.inner.handleInput(data);
77
+ }
78
+
79
+ dispose(): void {}
80
+ }
81
+
82
+ export function showPromptEditor(ctx: ExtensionContext, title: string, prefill?: string): Promise<string | undefined> {
83
+ if (ctx.mode !== "tui") return ctx.ui.editor(title, prefill);
84
+ return ctx.ui.custom<string | undefined>((tui, _theme, keybindings, done) => (
85
+ new PromptEditorComponent(tui, keybindings, title, prefill, done, ctx.cwd)
86
+ ));
87
+ }