@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
package/src/ui/chrome.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export const UI_TICK_MS = 100;
|
|
5
|
+
|
|
6
|
+
export const BRAILLE_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
7
|
+
export const QUADRANT_SPINNER_FRAMES = ["◐", "◓", "◑", "◒"] as const;
|
|
8
|
+
|
|
9
|
+
export interface BoxGlyphs {
|
|
10
|
+
topLeft: string;
|
|
11
|
+
topRight: string;
|
|
12
|
+
bottomLeft: string;
|
|
13
|
+
bottomRight: string;
|
|
14
|
+
vertical: string;
|
|
15
|
+
horizontal: string;
|
|
16
|
+
leftJoin: string;
|
|
17
|
+
rightJoin: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const ROUNDED_SINGLE_BOX: BoxGlyphs = {
|
|
21
|
+
topLeft: "╭",
|
|
22
|
+
topRight: "╮",
|
|
23
|
+
bottomLeft: "╰",
|
|
24
|
+
bottomRight: "╯",
|
|
25
|
+
vertical: "│",
|
|
26
|
+
horizontal: "─",
|
|
27
|
+
leftJoin: "├",
|
|
28
|
+
rightJoin: "┤",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const SQUARE_SINGLE_BOX: BoxGlyphs = {
|
|
32
|
+
topLeft: "┌",
|
|
33
|
+
topRight: "┐",
|
|
34
|
+
bottomLeft: "└",
|
|
35
|
+
bottomRight: "┘",
|
|
36
|
+
vertical: "│",
|
|
37
|
+
horizontal: "─",
|
|
38
|
+
leftJoin: "├",
|
|
39
|
+
rightJoin: "┤",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface Frame {
|
|
43
|
+
frameWidth: number;
|
|
44
|
+
bodyWidth: number;
|
|
45
|
+
top(): string;
|
|
46
|
+
separator(): string;
|
|
47
|
+
bottom(): string;
|
|
48
|
+
row(content: string, background?: Parameters<Theme["bg"]>[0]): string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Shared selection glyph for list rows across menus and pickers.
|
|
53
|
+
* SELECTOR is 1 visible column; SELECTOR_POINTER appends a trailing space
|
|
54
|
+
* (2 columns) so it lines up with UNSELECTED_POINTER, which is 2 spaces.
|
|
55
|
+
*/
|
|
56
|
+
export const SELECTOR = ">";
|
|
57
|
+
export const SELECTOR_POINTER = `${SELECTOR} `;
|
|
58
|
+
export const UNSELECTED_POINTER = " ";
|
|
59
|
+
|
|
60
|
+
export function safeRenderWidth(
|
|
61
|
+
suppliedWidth: number,
|
|
62
|
+
terminalWidth = 80,
|
|
63
|
+
minimum = 20,
|
|
64
|
+
): number {
|
|
65
|
+
const safeTerminalWidth = Math.max(minimum, Math.floor(terminalWidth));
|
|
66
|
+
return Number.isFinite(suppliedWidth)
|
|
67
|
+
? Math.max(minimum, Math.min(Math.floor(suppliedWidth), safeTerminalWidth))
|
|
68
|
+
: safeTerminalWidth;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function fitVisible(
|
|
72
|
+
content: string,
|
|
73
|
+
width: number,
|
|
74
|
+
options: { truncationMark: string; padToWidth: boolean },
|
|
75
|
+
): string {
|
|
76
|
+
return truncateToWidth(content, Math.max(0, width), options.truncationMark, options.padToWidth);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createFrame(
|
|
80
|
+
theme: Pick<Theme, "fg" | "bg">,
|
|
81
|
+
frameWidth: number,
|
|
82
|
+
options: {
|
|
83
|
+
glyphs: BoxGlyphs;
|
|
84
|
+
horizontalPadding: number;
|
|
85
|
+
truncationMark: string;
|
|
86
|
+
padToWidth: boolean;
|
|
87
|
+
minimumBodyWidth?: number;
|
|
88
|
+
},
|
|
89
|
+
): Frame {
|
|
90
|
+
const width = Math.max(2, frameWidth);
|
|
91
|
+
const padding = Math.max(0, Math.floor(options.horizontalPadding));
|
|
92
|
+
const bodyWidth = Math.max(options.minimumBodyWidth ?? 0, width - 2 - padding * 2);
|
|
93
|
+
const border = (text: string) => theme.fg("border", text);
|
|
94
|
+
const ruleWidth = Math.max(0, width - 2);
|
|
95
|
+
const fit = (content: string) => fitVisible(content, bodyWidth, {
|
|
96
|
+
truncationMark: options.truncationMark,
|
|
97
|
+
padToWidth: options.padToWidth,
|
|
98
|
+
});
|
|
99
|
+
const sidePadding = " ".repeat(padding);
|
|
100
|
+
return {
|
|
101
|
+
frameWidth: width,
|
|
102
|
+
bodyWidth,
|
|
103
|
+
top: () => border(options.glyphs.topLeft + options.glyphs.horizontal.repeat(ruleWidth) + options.glyphs.topRight),
|
|
104
|
+
separator: () => border(options.glyphs.leftJoin + options.glyphs.horizontal.repeat(ruleWidth) + options.glyphs.rightJoin),
|
|
105
|
+
bottom: () => border(options.glyphs.bottomLeft + options.glyphs.horizontal.repeat(ruleWidth) + options.glyphs.bottomRight),
|
|
106
|
+
row: (content: string, background?: Parameters<Theme["bg"]>[0]) => {
|
|
107
|
+
// Borders stay unhighlighted; only the padded inner cell picks up the background.
|
|
108
|
+
const inner = sidePadding + fit(content) + sidePadding;
|
|
109
|
+
// Array.map supplies a numeric index as the second callback argument;
|
|
110
|
+
// ignore non-theme values so existing frame.row callbacks stay safe.
|
|
111
|
+
const highlighted = typeof background === "string" ? theme.bg(background, inner) : inner;
|
|
112
|
+
return border(options.glyphs.vertical) + highlighted + border(options.glyphs.vertical);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Word-wrap `text` into lines of at most `width` visible columns. */
|
|
118
|
+
export function wrapWords(text: string, width: number, truncationMark = "…"): string[] {
|
|
119
|
+
if (width <= 0) return [];
|
|
120
|
+
const lines: string[] = [];
|
|
121
|
+
let current = "";
|
|
122
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
123
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
124
|
+
if (visibleWidth(candidate) <= width) {
|
|
125
|
+
current = candidate;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (current) lines.push(current);
|
|
129
|
+
current = truncateToWidth(word, width, truncationMark);
|
|
130
|
+
}
|
|
131
|
+
if (current) lines.push(current);
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function ratioViewport(
|
|
136
|
+
terminalRows: number | undefined,
|
|
137
|
+
options: {
|
|
138
|
+
fallbackRows: number;
|
|
139
|
+
ratio: number;
|
|
140
|
+
minimum: number;
|
|
141
|
+
margin?: number;
|
|
142
|
+
chromeRows?: number;
|
|
143
|
+
},
|
|
144
|
+
): number {
|
|
145
|
+
const rows = terminalRows ?? options.fallbackRows;
|
|
146
|
+
const margin = options.margin ?? 0;
|
|
147
|
+
const ratioHeight = Math.floor(rows * options.ratio);
|
|
148
|
+
const availableHeight = rows - margin * 2;
|
|
149
|
+
const overlayHeight = Math.min(ratioHeight, availableHeight);
|
|
150
|
+
return Math.max(options.minimum, overlayHeight - (options.chromeRows ?? 0));
|
|
151
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conflict-review overlay — confirms/overrides synthesizer-flagged tradeoffs.
|
|
3
|
+
*
|
|
4
|
+
* Between MoA synthesis and the plan-approval gate, the synthesizer emits
|
|
5
|
+
* a `## Conflicts` section for every substantive disagreement between proposer
|
|
6
|
+
* plans (see agents/moa-synthesizer.md). This overlay presents each conflict
|
|
7
|
+
* as one tab: the synthesizer's recommendation (labeled
|
|
8
|
+
* "(Recommended)"), the other proposers' alternatives (blinded `Proposer N`
|
|
9
|
+
* attribution), and a trailing "Chat about this" option that opens an inline
|
|
10
|
+
* `Editor` for free-form clarifying text. Modeled on the tab-bar + Editor
|
|
11
|
+
* interaction from examples/extensions/questionnaire.ts, rendered inside the
|
|
12
|
+
* bordered-box chrome from cancelOverlay.ts / planReviewOverlay.ts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
Editor,
|
|
18
|
+
type EditorTheme,
|
|
19
|
+
Key,
|
|
20
|
+
matchesKey,
|
|
21
|
+
truncateToWidth,
|
|
22
|
+
wrapTextWithAnsi,
|
|
23
|
+
type Component,
|
|
24
|
+
type TUI,
|
|
25
|
+
} from "@earendil-works/pi-tui";
|
|
26
|
+
import { CHAT_VALUE, chatOption, type Conflict, type ConflictAnswer, type ConflictOption } from "../moa/conflicts.ts";
|
|
27
|
+
import {
|
|
28
|
+
ROUNDED_SINGLE_BOX,
|
|
29
|
+
SELECTOR_POINTER,
|
|
30
|
+
UNSELECTED_POINTER,
|
|
31
|
+
createFrame,
|
|
32
|
+
safeRenderWidth,
|
|
33
|
+
} from "./chrome.ts";
|
|
34
|
+
|
|
35
|
+
const OVERLAY_MARGIN = 1;
|
|
36
|
+
|
|
37
|
+
export interface ConflictReviewResult {
|
|
38
|
+
cancelled: boolean;
|
|
39
|
+
answers: Map<string, ConflictAnswer>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Show the conflict-review overlay. Resolves when the user submits or cancels (Esc). */
|
|
43
|
+
export async function showConflictReview(
|
|
44
|
+
ctx: ExtensionContext,
|
|
45
|
+
conflicts: Conflict[],
|
|
46
|
+
): Promise<ConflictReviewResult> {
|
|
47
|
+
if (ctx.mode !== "tui" || conflicts.length === 0) {
|
|
48
|
+
return { cancelled: false, answers: new Map() };
|
|
49
|
+
}
|
|
50
|
+
const result = await ctx.ui.custom<ConflictReviewResult>(
|
|
51
|
+
(tui, theme, _keybindings, done) => new ConflictReviewComponent(tui, theme, conflicts, done),
|
|
52
|
+
{
|
|
53
|
+
overlay: true,
|
|
54
|
+
overlayOptions: {
|
|
55
|
+
anchor: "center",
|
|
56
|
+
width: "80%",
|
|
57
|
+
minWidth: 60,
|
|
58
|
+
maxHeight: "85%",
|
|
59
|
+
margin: OVERLAY_MARGIN,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
return result ?? { cancelled: true, answers: new Map() };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Ensures every conflict's options end with a trailing chat option. */
|
|
67
|
+
function withChatOption(conflict: Conflict): Conflict {
|
|
68
|
+
const hasChat = conflict.options.some((o) => o.value === CHAT_VALUE);
|
|
69
|
+
return hasChat ? conflict : { ...conflict, options: [...conflict.options, chatOption()] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class ConflictReviewComponent implements Component {
|
|
73
|
+
private readonly conflicts: Conflict[];
|
|
74
|
+
private currentTab = 0; // 0..conflicts.length-1 = conflict tabs, conflicts.length = Submit
|
|
75
|
+
private optionIndex = 0;
|
|
76
|
+
private inputMode = false;
|
|
77
|
+
private chatConflictId: string | null = null;
|
|
78
|
+
private readonly answers = new Map<string, ConflictAnswer>();
|
|
79
|
+
private readonly editor: Editor;
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
private readonly tui: TUI,
|
|
83
|
+
private readonly theme: Theme,
|
|
84
|
+
conflicts: Conflict[],
|
|
85
|
+
private readonly done: (result: ConflictReviewResult) => void,
|
|
86
|
+
) {
|
|
87
|
+
this.conflicts = conflicts.map(withChatOption);
|
|
88
|
+
|
|
89
|
+
const editorTheme: EditorTheme = {
|
|
90
|
+
borderColor: (s) => theme.fg("accent", s),
|
|
91
|
+
selectList: {
|
|
92
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
93
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
94
|
+
description: (t) => theme.fg("muted", t),
|
|
95
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
96
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
this.editor = new Editor(tui, editorTheme);
|
|
100
|
+
this.editor.onSubmit = (value) => {
|
|
101
|
+
if (!this.chatConflictId) return;
|
|
102
|
+
const trimmed = value.trim() || "(no response)";
|
|
103
|
+
this.saveAnswer(this.chatConflictId, CHAT_VALUE, "chat", trimmed);
|
|
104
|
+
this.inputMode = false;
|
|
105
|
+
this.chatConflictId = null;
|
|
106
|
+
this.editor.setText("");
|
|
107
|
+
this.advanceAfterAnswer();
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private totalTabs(): number {
|
|
112
|
+
return this.conflicts.length + 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private currentConflict(): Conflict | undefined {
|
|
116
|
+
return this.conflicts[this.currentTab];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private allAnswered(): boolean {
|
|
120
|
+
return this.conflicts.every((c) => this.answers.has(c.id));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private answeredCount(): number {
|
|
124
|
+
return this.conflicts.filter((c) => this.answers.has(c.id)).length;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private saveAnswer(
|
|
128
|
+
conflictId: string,
|
|
129
|
+
optionValue: string,
|
|
130
|
+
kind: ConflictAnswer["kind"],
|
|
131
|
+
chatText?: string,
|
|
132
|
+
): void {
|
|
133
|
+
this.answers.set(conflictId, { optionValue, kind, chatText });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private advanceAfterAnswer(): void {
|
|
137
|
+
if (this.currentTab < this.conflicts.length - 1) {
|
|
138
|
+
this.currentTab++;
|
|
139
|
+
} else {
|
|
140
|
+
this.currentTab = this.conflicts.length; // Submit tab
|
|
141
|
+
}
|
|
142
|
+
this.optionIndex = 0;
|
|
143
|
+
this.tui.requestRender();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private finish(cancelled: boolean): void {
|
|
147
|
+
this.done({ cancelled, answers: this.answers });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
handleInput(data: string): void {
|
|
151
|
+
if (this.inputMode) {
|
|
152
|
+
if (matchesKey(data, Key.escape)) {
|
|
153
|
+
this.inputMode = false;
|
|
154
|
+
this.chatConflictId = null;
|
|
155
|
+
this.editor.setText("");
|
|
156
|
+
this.tui.requestRender();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.editor.handleInput(data);
|
|
160
|
+
this.tui.requestRender();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
165
|
+
this.currentTab = (this.currentTab + 1) % this.totalTabs();
|
|
166
|
+
this.optionIndex = 0;
|
|
167
|
+
this.tui.requestRender();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
171
|
+
this.currentTab = (this.currentTab - 1 + this.totalTabs()) % this.totalTabs();
|
|
172
|
+
this.optionIndex = 0;
|
|
173
|
+
this.tui.requestRender();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Submit tab
|
|
178
|
+
if (this.currentTab === this.conflicts.length) {
|
|
179
|
+
if (matchesKey(data, Key.enter) && this.allAnswered()) {
|
|
180
|
+
this.finish(false);
|
|
181
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
182
|
+
this.finish(true);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const conflict = this.currentConflict();
|
|
188
|
+
if (!conflict) return;
|
|
189
|
+
|
|
190
|
+
if (matchesKey(data, Key.up)) {
|
|
191
|
+
this.optionIndex = Math.max(0, this.optionIndex - 1);
|
|
192
|
+
this.tui.requestRender();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (matchesKey(data, Key.down)) {
|
|
196
|
+
this.optionIndex = Math.min(conflict.options.length - 1, this.optionIndex + 1);
|
|
197
|
+
this.tui.requestRender();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (matchesKey(data, Key.enter)) {
|
|
202
|
+
const opt = conflict.options[this.optionIndex];
|
|
203
|
+
if (!opt) return;
|
|
204
|
+
if (opt.value === CHAT_VALUE) {
|
|
205
|
+
this.inputMode = true;
|
|
206
|
+
this.chatConflictId = conflict.id;
|
|
207
|
+
this.editor.setText("");
|
|
208
|
+
this.tui.requestRender();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const kind: ConflictAnswer["kind"] = opt.recommended ? "recommended" : "alternative";
|
|
212
|
+
this.saveAnswer(conflict.id, opt.value, kind);
|
|
213
|
+
this.advanceAfterAnswer();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (matchesKey(data, Key.escape)) {
|
|
218
|
+
this.finish(true);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
render(width: number): string[] {
|
|
223
|
+
const terminalWidth = Math.max(20, process.stdout.columns ?? 80);
|
|
224
|
+
const safeWidth = safeRenderWidth(width, terminalWidth);
|
|
225
|
+
const innerWidth = Math.max(20, safeWidth - 4);
|
|
226
|
+
const frame = createFrame(this.theme, innerWidth, {
|
|
227
|
+
glyphs: ROUNDED_SINGLE_BOX,
|
|
228
|
+
horizontalPadding: 1,
|
|
229
|
+
truncationMark: "…",
|
|
230
|
+
padToWidth: true,
|
|
231
|
+
minimumBodyWidth: 10,
|
|
232
|
+
});
|
|
233
|
+
const bodyWidth = frame.bodyWidth;
|
|
234
|
+
|
|
235
|
+
const th = this.theme;
|
|
236
|
+
const top = frame.top();
|
|
237
|
+
const sep = frame.separator();
|
|
238
|
+
const bottom = frame.bottom();
|
|
239
|
+
const row = frame.row;
|
|
240
|
+
|
|
241
|
+
const lines: string[] = [];
|
|
242
|
+
lines.push(top);
|
|
243
|
+
lines.push(
|
|
244
|
+
row(
|
|
245
|
+
th.fg("accent", "Resolve conflicts") +
|
|
246
|
+
th.fg("dim", ` Review choices · ${this.answeredCount()}/${this.conflicts.length} answered`),
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
lines.push(sep);
|
|
250
|
+
|
|
251
|
+
// Tab bar
|
|
252
|
+
const maxTabLabelLen = this.conflicts.length > 8 ? 3 : 12;
|
|
253
|
+
const tabParts: string[] = [];
|
|
254
|
+
for (let i = 0; i < this.conflicts.length; i++) {
|
|
255
|
+
const conflict = this.conflicts[i];
|
|
256
|
+
const isActive = i === this.currentTab;
|
|
257
|
+
const isAnswered = this.answers.has(conflict.id);
|
|
258
|
+
const box = isAnswered ? "■" : "□";
|
|
259
|
+
const color = isAnswered ? "success" : "muted";
|
|
260
|
+
const lbl = truncateToWidth(conflict.label, maxTabLabelLen, "…", false);
|
|
261
|
+
const text = ` ${box} ${lbl} `;
|
|
262
|
+
tabParts.push(isActive ? th.bg("selectedBg", th.fg("text", text)) : th.fg(color, text));
|
|
263
|
+
}
|
|
264
|
+
const canSubmit = this.allAnswered();
|
|
265
|
+
const isSubmitTab = this.currentTab === this.conflicts.length;
|
|
266
|
+
const submitText = " ✓ Submit ";
|
|
267
|
+
tabParts.push(
|
|
268
|
+
isSubmitTab
|
|
269
|
+
? th.bg("selectedBg", th.fg("text", submitText))
|
|
270
|
+
: th.fg(canSubmit ? "success" : "dim", submitText),
|
|
271
|
+
);
|
|
272
|
+
for (const tabLine of wrapTextWithAnsi(tabParts.join(""), bodyWidth)) {
|
|
273
|
+
lines.push(row(tabLine));
|
|
274
|
+
}
|
|
275
|
+
lines.push(sep);
|
|
276
|
+
|
|
277
|
+
if (this.inputMode && this.chatConflictId) {
|
|
278
|
+
const conflict = this.conflicts.find((c) => c.id === this.chatConflictId);
|
|
279
|
+
if (conflict) {
|
|
280
|
+
lines.push(row(th.fg("accent", "What you're deciding")));
|
|
281
|
+
for (const l of wrapTextWithAnsi(th.fg("text", conflict.prompt), bodyWidth)) lines.push(row(l));
|
|
282
|
+
lines.push(row(th.fg("muted", "Select the option that best matches your preference.")));
|
|
283
|
+
lines.push(row(""));
|
|
284
|
+
this.renderOptions(conflict, lines, row, bodyWidth, th, true);
|
|
285
|
+
lines.push(row(""));
|
|
286
|
+
lines.push(row(th.fg("muted", "Your message:")));
|
|
287
|
+
for (const l of this.editor.render(Math.max(1, bodyWidth - 2))) lines.push(row(l));
|
|
288
|
+
}
|
|
289
|
+
} else if (isSubmitTab) {
|
|
290
|
+
lines.push(row(th.fg("accent", "Ready to submit")));
|
|
291
|
+
lines.push(row(""));
|
|
292
|
+
for (const conflict of this.conflicts) {
|
|
293
|
+
const answer = this.answers.get(conflict.id);
|
|
294
|
+
if (!answer) continue;
|
|
295
|
+
let summary: string;
|
|
296
|
+
if (answer.kind === "chat") {
|
|
297
|
+
summary = `from chat: ${answer.chatText}`;
|
|
298
|
+
} else {
|
|
299
|
+
const opt = conflict.options.find((o) => o.value === answer.optionValue);
|
|
300
|
+
summary = opt?.label ?? answer.optionValue;
|
|
301
|
+
}
|
|
302
|
+
for (const l of wrapTextWithAnsi(
|
|
303
|
+
`${th.fg("muted", `${conflict.label}: `)}${th.fg("text", summary)}`,
|
|
304
|
+
bodyWidth,
|
|
305
|
+
)) {
|
|
306
|
+
lines.push(row(l));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
lines.push(row(""));
|
|
310
|
+
if (this.allAnswered()) {
|
|
311
|
+
lines.push(row(th.fg("success", "Press Enter to submit")));
|
|
312
|
+
} else {
|
|
313
|
+
const missing = this.conflicts
|
|
314
|
+
.filter((c) => !this.answers.has(c.id))
|
|
315
|
+
.map((c) => c.label)
|
|
316
|
+
.join(", ");
|
|
317
|
+
lines.push(row(th.fg("warning", `Unanswered: ${missing}`)));
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
const conflict = this.currentConflict();
|
|
321
|
+
if (conflict) {
|
|
322
|
+
lines.push(row(th.fg("accent", "What you're deciding")));
|
|
323
|
+
for (const l of wrapTextWithAnsi(th.fg("text", conflict.prompt), bodyWidth)) lines.push(row(l));
|
|
324
|
+
lines.push(row(th.fg("muted", "Select the option that best matches your preference.")));
|
|
325
|
+
lines.push(row(""));
|
|
326
|
+
this.renderOptions(conflict, lines, row, bodyWidth, th, false);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
lines.push(sep);
|
|
331
|
+
lines.push(row(th.fg("dim", "Tab/←→ tabs · ↑↓ select · Enter confirm · Esc cancel")));
|
|
332
|
+
lines.push(bottom);
|
|
333
|
+
return lines;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private renderOptions(
|
|
337
|
+
conflict: Conflict,
|
|
338
|
+
lines: string[],
|
|
339
|
+
row: (s: string) => string,
|
|
340
|
+
bodyWidth: number,
|
|
341
|
+
th: Theme,
|
|
342
|
+
greyed: boolean,
|
|
343
|
+
): void {
|
|
344
|
+
for (let i = 0; i < conflict.options.length; i++) {
|
|
345
|
+
const opt = conflict.options[i];
|
|
346
|
+
const selected = !greyed && i === this.optionIndex;
|
|
347
|
+
const marker = selected ? SELECTOR_POINTER : UNSELECTED_POINTER;
|
|
348
|
+
const label = `${marker}${i + 1}. ${opt.label}`;
|
|
349
|
+
const color = greyed ? "muted" : selected ? "accent" : "text";
|
|
350
|
+
for (const l of wrapTextWithAnsi(th.fg(color, label), bodyWidth)) lines.push(row(l));
|
|
351
|
+
const desc = opt.description;
|
|
352
|
+
if (desc) {
|
|
353
|
+
const indent = " ";
|
|
354
|
+
const descWidth = Math.max(1, bodyWidth - indent.length);
|
|
355
|
+
for (const l of wrapTextWithAnsi(th.fg("muted", desc), descWidth)) lines.push(row(indent + l));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
invalidate(): void {}
|
|
361
|
+
|
|
362
|
+
dispose(): void {}
|
|
363
|
+
}
|