pi-bro 0.24.0 → 0.25.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 +24 -0
- package/README.md +29 -15
- package/docs/README.md +32 -14
- package/docs/elevate.md +125 -0
- package/docs/examples.md +268 -0
- package/docs/features/advisor.md +70 -0
- package/docs/features/btw.md +51 -0
- package/docs/features/explain.md +92 -0
- package/docs/features/show.md +154 -0
- package/docs/getting-started.md +47 -0
- package/docs/pig-compatibility.md +1 -0
- package/docs/reference/commands.md +50 -0
- package/docs/reference/data-and-permissions.md +130 -0
- package/docs/reference/settings.md +108 -0
- package/docs/reference/troubleshooting.md +55 -0
- package/docs/user-guide.md +50 -900
- package/package.json +12 -3
- package/src/bro.ts +25 -0
- package/src/elevate-engine.ts +214 -0
- package/src/elevate.ts +597 -0
- package/src/explain-modal.ts +12 -6
- package/src/modal-kit.ts +35 -2
package/src/elevate.ts
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { copyToClipboard, getMarkdownTheme, getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Editor, Markdown, matchesKey, wrapTextWithAnsi, type EditorTheme, type Focusable, type TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import { runBackendText } from "./backend.ts";
|
|
5
|
+
import {
|
|
6
|
+
buildElevateDiscoveryPrompt, buildElevateUpgradePrompt, parseElevateDiscovery,
|
|
7
|
+
selectedFindings, stripElevateFence, elevateInsertAllowed, captureElevateContext, isElevateContextOverflow,
|
|
8
|
+
reduceElevateContext, elevateDisplayText, type ElevateContextMode, type ElevateDiscovery, type ElevateInput,
|
|
9
|
+
} from "./elevate-engine.ts";
|
|
10
|
+
import { BRO_OVERLAY, broOverlayArea, buttonLabels, delegateMouse, formatNotice, modalHeight, openBroOverlay, renderFrame, Scroller, wrapButtons, type Area, type Theme, type TuiLike } from "./modal-kit.ts";
|
|
11
|
+
import { readPreferences, readSettings, selectionForCapability, selectionLabel } from "./settings.ts";
|
|
12
|
+
import { canBroInsertIntoEditor, insertBroDesktopText, type BroMouseEvent, type BroMouseResult } from "./ui-capabilities.ts";
|
|
13
|
+
import { errorMessage } from "./util.ts";
|
|
14
|
+
|
|
15
|
+
type Stage = "compose" | "discovering" | "review" | "upgrading" | "ready" | "failed";
|
|
16
|
+
/** Which zone owns the keyboard. Tab switches; only the focused zone acts on Enter/Space. */
|
|
17
|
+
type Zone = "editor" | "list" | "actions";
|
|
18
|
+
|
|
19
|
+
// The Editor needs room for its own borders and minimum text rows; below that the modal asks for a
|
|
20
|
+
// bigger terminal instead of drawing it cut short.
|
|
21
|
+
/**
|
|
22
|
+
* The modal never sends messages to, or calls tools on, the main agent. Compose uses the same
|
|
23
|
+
* body Editor as /bro preferences, and every consequential action is a focusable button so a stray
|
|
24
|
+
* keypress cannot toggle a finding, rewrite the request, or replace the editor draft.
|
|
25
|
+
*/
|
|
26
|
+
export class ElevateModal implements Focusable {
|
|
27
|
+
private _focused = false;
|
|
28
|
+
private readonly editor: Editor;
|
|
29
|
+
private readonly editorViewport = { rows: 24 };
|
|
30
|
+
private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
|
|
31
|
+
private readonly scroller = new Scroller(() => this.tui.requestRender());
|
|
32
|
+
private rawArea: Area | undefined;
|
|
33
|
+
private editorArea: Area = { top: 0, left: 0, width: 0, height: 0 };
|
|
34
|
+
private stage: Stage = "compose";
|
|
35
|
+
private zone: Zone = "editor";
|
|
36
|
+
private notice = "";
|
|
37
|
+
private model = "";
|
|
38
|
+
private contextMode: ElevateContextMode = "messages";
|
|
39
|
+
private contextReduced = false;
|
|
40
|
+
private discovery?: ElevateDiscovery;
|
|
41
|
+
private readonly accepted = new Set<number>();
|
|
42
|
+
private cursor = 0;
|
|
43
|
+
private listOffset = 0;
|
|
44
|
+
private action = 0;
|
|
45
|
+
private original = "";
|
|
46
|
+
private revised = "";
|
|
47
|
+
/** Review pins this checklist above the scrollable explanation. */
|
|
48
|
+
private listText: string[] = [];
|
|
49
|
+
private disposed = false;
|
|
50
|
+
/** The Editor did not render whole at the last layout, so editing could save text the user never saw. */
|
|
51
|
+
private tooSmall = true;
|
|
52
|
+
private findingVisible = false;
|
|
53
|
+
|
|
54
|
+
get focused(): boolean { return this._focused; }
|
|
55
|
+
set focused(value: boolean) { this._focused = value; this.syncEditorFocus(); }
|
|
56
|
+
|
|
57
|
+
/** The Editor shows its cursor only when it actually owns the keyboard, so exactly one thing looks focused. */
|
|
58
|
+
private syncEditorFocus(): void {
|
|
59
|
+
this.editor.focused = this._focused && this.stage === "compose" && this.zone === "editor";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
private readonly tui: TuiLike,
|
|
64
|
+
private readonly theme: Theme,
|
|
65
|
+
private readonly onClose: () => void,
|
|
66
|
+
private readonly onDiscover: (objective: string) => void,
|
|
67
|
+
private readonly onUpgrade: (indexes: ReadonlySet<number>) => void,
|
|
68
|
+
private readonly onInsert: (text: string) => void,
|
|
69
|
+
private readonly onRetry: () => void,
|
|
70
|
+
private readonly onDispose: () => void,
|
|
71
|
+
private readonly onContextChange: (mode: ElevateContextMode) => void = () => {},
|
|
72
|
+
) {
|
|
73
|
+
const editorTheme: EditorTheme = { borderColor: (value: string) => this.theme.fg("border", value), selectList: getSelectListTheme() };
|
|
74
|
+
// Editor only needs terminal.rows and requestRender; keep its viewport local to this modal.
|
|
75
|
+
this.editor = new Editor({ terminal: this.editorViewport, requestRender: () => this.tui.requestRender() } as unknown as TUI, editorTheme);
|
|
76
|
+
this.editor.onChange = () => { this.notice = ""; };
|
|
77
|
+
this.draw();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get currentStage(): Stage { return this.stage; }
|
|
81
|
+
get currentContextMode(): ElevateContextMode { return this.contextMode; }
|
|
82
|
+
setContextReduced(value: boolean): void { this.contextReduced = value; if (!this.disposed) this.tui.requestRender(); }
|
|
83
|
+
|
|
84
|
+
private switchContext(): void {
|
|
85
|
+
const objective = this.stage === "compose" ? this.editor.getExpandedText() : this.original;
|
|
86
|
+
this.contextMode = this.contextMode === "messages" ? "full" : "messages";
|
|
87
|
+
this.discovery = undefined;
|
|
88
|
+
this.accepted.clear();
|
|
89
|
+
this.revised = "";
|
|
90
|
+
this.contextReduced = false;
|
|
91
|
+
this.onContextChange(this.contextMode);
|
|
92
|
+
this.setCompose(objective);
|
|
93
|
+
}
|
|
94
|
+
get currentZone(): Zone { return this.zone; }
|
|
95
|
+
get currentDiscovery(): ElevateDiscovery | undefined { return this.discovery; }
|
|
96
|
+
get currentObjective(): string { return this.original; }
|
|
97
|
+
get currentRevised(): string { return this.revised; }
|
|
98
|
+
get currentSelection(): ReadonlySet<number> { return this.accepted; }
|
|
99
|
+
setModel(value: string): void { this.model = value; if (!this.disposed) this.tui.requestRender(); }
|
|
100
|
+
setNotice(value: string): void { if (!this.disposed) { this.notice = value; this.tui.requestRender(); } }
|
|
101
|
+
|
|
102
|
+
// Every state setter is inert after disposal: a late backend result must never touch an
|
|
103
|
+
// unmounted component.
|
|
104
|
+
setDiscovering(objective: string): void {
|
|
105
|
+
if (this.disposed) return;
|
|
106
|
+
this.original = objective;
|
|
107
|
+
this.revised = "";
|
|
108
|
+
this.discovery = undefined;
|
|
109
|
+
this.accepted.clear();
|
|
110
|
+
this.stage = "discovering";
|
|
111
|
+
// Waiting stages have no zones; focusing actions keeps Page/wheel scrolling reachable.
|
|
112
|
+
this.zone = "actions";
|
|
113
|
+
this.notice = "";
|
|
114
|
+
this.syncEditorFocus();
|
|
115
|
+
this.drawFromTop();
|
|
116
|
+
}
|
|
117
|
+
setDiscovery(discovery: ElevateDiscovery): void {
|
|
118
|
+
if (this.disposed) return;
|
|
119
|
+
this.discovery = discovery;
|
|
120
|
+
// Nothing is approved by default: accepting model suggestions the user never read is the
|
|
121
|
+
// failure this whole stage exists to prevent.
|
|
122
|
+
this.accepted.clear();
|
|
123
|
+
this.cursor = 0;
|
|
124
|
+
this.listOffset = 0;
|
|
125
|
+
this.action = 0;
|
|
126
|
+
this.zone = discovery.findings.length ? "list" : "actions";
|
|
127
|
+
if (!discovery.findings.length) {
|
|
128
|
+
this.revised = this.original;
|
|
129
|
+
this.stage = "ready";
|
|
130
|
+
this.notice = "No material improvement proposed; the original request is preserved.";
|
|
131
|
+
} else this.stage = "review";
|
|
132
|
+
this.syncEditorFocus();
|
|
133
|
+
this.drawFromTop();
|
|
134
|
+
}
|
|
135
|
+
setUpgrading(): void { if (this.disposed) return; this.stage = "upgrading"; this.zone = "actions"; this.notice = ""; this.syncEditorFocus(); this.drawFromTop(); }
|
|
136
|
+
setReady(value: string): void { if (this.disposed) return; this.stage = "ready"; this.revised = value.trim(); this.notice = ""; this.zone = "actions"; this.action = 0; this.syncEditorFocus(); this.drawFromTop(); }
|
|
137
|
+
setFailed(message: string): void { if (this.disposed) return; this.stage = "failed"; this.notice = message; this.zone = "actions"; this.action = 0; this.syncEditorFocus(); this.drawFromTop(); }
|
|
138
|
+
/** Back to the objective for editing, keeping what the user already wrote. */
|
|
139
|
+
setCompose(objective?: string): void {
|
|
140
|
+
if (this.disposed) return;
|
|
141
|
+
if (objective !== undefined) { this.original = objective; this.editor.setText(objective); }
|
|
142
|
+
this.stage = "compose";
|
|
143
|
+
this.zone = "editor";
|
|
144
|
+
this.notice = "";
|
|
145
|
+
this.syncEditorFocus();
|
|
146
|
+
this.drawFromTop();
|
|
147
|
+
}
|
|
148
|
+
/** Back to the findings without losing the approvals, when a rewrite needs reconsidering. */
|
|
149
|
+
setReview(): void {
|
|
150
|
+
if (this.disposed || !this.discovery) return;
|
|
151
|
+
this.stage = "review";
|
|
152
|
+
this.notice = "";
|
|
153
|
+
this.zone = this.discovery.findings.length ? "list" : "actions";
|
|
154
|
+
this.syncEditorFocus();
|
|
155
|
+
this.drawFromTop();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private draw(): void {
|
|
159
|
+
let text = "";
|
|
160
|
+
if (this.stage === "compose") text = "";
|
|
161
|
+
else if (this.stage === "discovering") text = `## Discovering gaps\n\n**Original request**\n\n${this.original}\n\nLooking for expert methods, missing evidence and tests. This does not change the main session.`;
|
|
162
|
+
else if (this.stage === "review" && this.discovery) {
|
|
163
|
+
// The checklist stands alone: display order, the cursor, and the tick on each row. No markdown
|
|
164
|
+
// document, so the approval target stays visible while its reason scrolls.
|
|
165
|
+
const list = this.discovery.findings.map((f, i) =>
|
|
166
|
+
`${i === this.cursor && this.zone === "list" ? "›" : " "} ${this.accepted.has(i) ? "[x]" : "[ ]"} ${i + 1}. ${elevateDisplayText(f.title, true)} · ${f.dimension}`);
|
|
167
|
+
this.listText = list;
|
|
168
|
+
// The header already carries the count; keeping the intro to one dim line leaves the rows for
|
|
169
|
+
// the checklist itself, which is the part the user acts on.
|
|
170
|
+
text = this.discovery.assessment;
|
|
171
|
+
} else if (this.stage === "upgrading") text = "## Upgrading your request\n\nPreserving your outcome and constraints. Incorporating only the ideas you approved…";
|
|
172
|
+
else if (this.stage === "ready") text = `## Revised request\n\n${this.revised}\n\n*Review before inserting. Nothing has been submitted to Pi.*`;
|
|
173
|
+
else if (this.stage === "failed") text = "## Elevate stopped\n\nYour original request has not changed. Retry, or close this window.";
|
|
174
|
+
this.markdown.setText(text);
|
|
175
|
+
this.tui.requestRender();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A new stage means new content, so start at the top. */
|
|
179
|
+
private drawFromTop(): void {
|
|
180
|
+
this.draw();
|
|
181
|
+
this.scroller.toStart();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Findings whose box is ticked, in display order. */
|
|
185
|
+
private approved(): ReadonlySet<number> {
|
|
186
|
+
return new Set(this.accepted);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private zoneIsList(): boolean {
|
|
190
|
+
return this.zone === "list" && !!this.discovery?.findings.length;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private buttonRow(): { label: string; short: string; run: () => void }[] {
|
|
194
|
+
// `[Edit request]` belongs to screens that already have findings; never to compose, where it
|
|
195
|
+
// would silently reset the text the user is typing.
|
|
196
|
+
const contextButton = { label: `Context: ${this.contextMode === "messages" ? "Messages" : "Full"}`, short: this.contextMode === "messages" ? "Messages" : "Full", run: () => this.switchContext() };
|
|
197
|
+
const editRequest = [{ label: "Edit request", short: "Edit", run: () => this.setCompose(this.original) }];
|
|
198
|
+
if (this.stage === "compose") return [
|
|
199
|
+
{ label: "Discover gaps", short: "Discover", run: () => this.discoverFromEditor() },
|
|
200
|
+
{ label: "Cancel", short: "Cancel", run: () => this.onClose() },
|
|
201
|
+
contextButton,
|
|
202
|
+
];
|
|
203
|
+
if (this.stage === "review" && this.discovery) {
|
|
204
|
+
const count = this.accepted.size;
|
|
205
|
+
return [
|
|
206
|
+
{ label: `Rewrite with ${count} selected`, short: `Rewrite (${count})`, run: () => this.onUpgrade(this.approved()) },
|
|
207
|
+
{ label: "Select all", short: "All", run: () => { this.discovery!.findings.forEach((_, i) => this.accepted.add(i)); this.notice = ""; this.draw(); } },
|
|
208
|
+
{ label: "Select none", short: "None", run: () => { this.accepted.clear(); this.notice = ""; this.draw(); } },
|
|
209
|
+
...editRequest,
|
|
210
|
+
{ label: "Cancel", short: "Cancel", run: () => this.onClose() },
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
if (this.stage === "ready") return [
|
|
214
|
+
{ label: "Insert into editor", short: "Insert", run: () => this.onInsert(this.revised) },
|
|
215
|
+
{ label: "Copy", short: "Copy", run: () => void this.copy(this.revised, "Copied revised request.") },
|
|
216
|
+
contextButton,
|
|
217
|
+
// Back to the findings with the approvals intact, so a rewrite can be reconsidered without
|
|
218
|
+
// paying for another discovery call.
|
|
219
|
+
...(this.discovery?.findings.length ? [{ label: "Back", short: "Back", run: () => this.setReview() }] : []),
|
|
220
|
+
...editRequest,
|
|
221
|
+
{ label: "Close", short: "Close", run: () => this.onClose() },
|
|
222
|
+
];
|
|
223
|
+
if (this.stage === "failed") return [
|
|
224
|
+
{ label: "Retry", short: "Retry", run: () => this.onRetry() },
|
|
225
|
+
contextButton,
|
|
226
|
+
...(this.discovery?.findings.length ? [{ label: "Back", short: "Back", run: () => this.setReview() }] : []),
|
|
227
|
+
...editRequest,
|
|
228
|
+
{ label: "Close", short: "Close", run: () => this.onClose() },
|
|
229
|
+
];
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private discoverFromEditor(): void {
|
|
234
|
+
if (this.tooSmall) { this.setNotice("Enlarge the terminal before starting discovery."); return; }
|
|
235
|
+
const objective = this.editor.getExpandedText().trim();
|
|
236
|
+
if (!objective) { this.setNotice("Type what the main agent should achieve first."); return; }
|
|
237
|
+
this.original = objective;
|
|
238
|
+
this.onDiscover(objective);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private copy(text: string, success: string): Promise<void> {
|
|
242
|
+
return copyToClipboard(text).then(() => this.setNotice(success)).catch((error: unknown) => this.setNotice(`Copy failed: ${errorMessage(error)}`));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The zones a stage actually offers. Ready/failed/waiting have no list, so Tab must not move focus
|
|
247
|
+
* somewhere with no keys: that stranded the user with every key dead.
|
|
248
|
+
*/
|
|
249
|
+
private zones(): Zone[] {
|
|
250
|
+
if (this.stage === "compose") return ["editor", "actions"];
|
|
251
|
+
if (this.stage === "review") return this.discovery?.findings.length ? ["list", "actions"] : ["actions"];
|
|
252
|
+
return ["actions"];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private cycleZone(backwards: boolean): void {
|
|
256
|
+
const zones = this.zones();
|
|
257
|
+
if (zones.length < 2) return;
|
|
258
|
+
const at = zones.indexOf(this.zone);
|
|
259
|
+
const next = ((at === -1 ? 0 : at + (backwards ? -1 : 1)) + zones.length) % zones.length;
|
|
260
|
+
this.zone = zones[next]!;
|
|
261
|
+
if (this.zone === "actions") this.action = 0;
|
|
262
|
+
this.notice = "";
|
|
263
|
+
this.syncEditorFocus();
|
|
264
|
+
// The focus marker is part of the rendered list, so it must be redrawn, not just re-requested.
|
|
265
|
+
this.draw();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
handleInput(data: string): void {
|
|
269
|
+
if (this.disposed) return;
|
|
270
|
+
if (matchesKey(data, "escape")) { this.onClose(); return; }
|
|
271
|
+
// Ctrl+C rescues the draft in every stage, so closing never loses pasted work. Before a rewrite
|
|
272
|
+
// exists, the objective is the only thing worth copying.
|
|
273
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
274
|
+
const text = (this.revised.trim() || this.editor.getExpandedText().trim() || this.original).trim();
|
|
275
|
+
if (text) void this.copy(text, "Copied.");
|
|
276
|
+
else this.setNotice("Nothing to copy yet.");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// PageUp/PageDown and the wheel always scroll the body; Up/Down belong to the list when it has
|
|
280
|
+
// focus, so handleKey is not even called there (it scrolls as a side effect).
|
|
281
|
+
if (this.zone !== "editor" && !this.zoneIsList() && this.scroller.handleKey(data, this.rawArea)) return;
|
|
282
|
+
|
|
283
|
+
if (this.stage === "compose") {
|
|
284
|
+
if (this.zone === "editor") {
|
|
285
|
+
// Ctrl+S is the fallback because many terminals never deliver Ctrl+Enter (it needs the kitty
|
|
286
|
+
// keyboard protocol); bare Enter cannot be used, since it must insert a newline here.
|
|
287
|
+
if (matchesKey(data, "ctrl+enter") || matchesKey(data, "ctrl+s")) { this.discoverFromEditor(); return; }
|
|
288
|
+
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { this.cycleZone(matchesKey(data, "shift+tab")); return; }
|
|
289
|
+
// While the Editor cannot be drawn whole, typing would edit text the user cannot see, and a
|
|
290
|
+
// notice from a previous key must stay clearable.
|
|
291
|
+
if (this.tooSmall) {
|
|
292
|
+
if (this.notice) { this.notice = ""; this.tui.requestRender(); }
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// Enter is a newline in a multi-line objective, matching /bro preferences.
|
|
296
|
+
if (matchesKey(data, "enter")) { this.editor.insertTextAtCursor("\n"); this.tui.requestRender(); return; }
|
|
297
|
+
this.editor.handleInput(data);
|
|
298
|
+
if (!this.disposed) this.tui.requestRender();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
} else if (this.stage === "review" && this.discovery) {
|
|
302
|
+
if (this.zone === "list") {
|
|
303
|
+
if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
304
|
+
const delta = matchesKey(data, "down") ? 1 : -1;
|
|
305
|
+
this.cursor = Math.max(0, Math.min(this.discovery.findings.length - 1, this.cursor + delta));
|
|
306
|
+
this.notice = "";
|
|
307
|
+
this.scroller.toStart();
|
|
308
|
+
this.draw();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Page keys still scroll, so a long detail stays readable without losing the cursor.
|
|
312
|
+
if (this.scroller.handleKey(data, this.rawArea)) return;
|
|
313
|
+
// Space or Enter toggles the highlighted finding: the target is always on screen, and either
|
|
314
|
+
// key does the same thing so there is no wrong one to press. Tab is what leaves this zone.
|
|
315
|
+
if (matchesKey(data, "space") || matchesKey(data, "enter")) {
|
|
316
|
+
if (!this.findingVisible) { this.setNotice("Enlarge the terminal to review findings."); return; }
|
|
317
|
+
if (this.accepted.has(this.cursor)) this.accepted.delete(this.cursor);
|
|
318
|
+
else this.accepted.add(this.cursor);
|
|
319
|
+
this.notice = "";
|
|
320
|
+
this.draw();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { this.cycleZone(matchesKey(data, "shift+tab")); return; }
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (this.zone === "actions") {
|
|
329
|
+
const buttons = this.buttonRow();
|
|
330
|
+
if (!buttons.length) return;
|
|
331
|
+
if (matchesKey(data, "left") || matchesKey(data, "right")) {
|
|
332
|
+
const delta = matchesKey(data, "right") ? 1 : -1;
|
|
333
|
+
this.action = (this.action + delta + buttons.length) % buttons.length;
|
|
334
|
+
this.tui.requestRender();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { this.cycleZone(matchesKey(data, "shift+tab")); return; }
|
|
338
|
+
if (matchesKey(data, "enter") || matchesKey(data, "space")) {
|
|
339
|
+
const button = buttons[this.action];
|
|
340
|
+
if (this.stage === "review" && !this.findingVisible && (this.action === 0 || this.action === 1)) {
|
|
341
|
+
this.setNotice("Enlarge the terminal to review findings.");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// [Rewrite with 0 selected] would be a dead end; say why instead of doing nothing.
|
|
345
|
+
if (this.stage === "review" && !this.accepted.size && button?.label.startsWith("Rewrite")) {
|
|
346
|
+
this.setNotice("Choose at least one finding with Space, or press Esc to keep the original.");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
button?.run();
|
|
350
|
+
if (!this.disposed) this.tui.requestRender();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (!this.disposed) this.tui.requestRender();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
handleMouse(event: BroMouseEvent): BroMouseResult {
|
|
358
|
+
// Clicks reach the Editor so the cursor lands where the user clicked, like the other editor modals.
|
|
359
|
+
if (this.stage === "compose" && this.zone === "editor") return delegateMouse(this.editor, event, this.editorArea);
|
|
360
|
+
return this.scroller.handleMouse(event);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
render(width: number): string[] {
|
|
364
|
+
const height = modalHeight(this.tui, BRO_OVERLAY, 34);
|
|
365
|
+
this.rawArea = broOverlayArea(this.tui, width, height);
|
|
366
|
+
// Reset before layout: a stale area would forward clicks to a rect the Editor no longer occupies.
|
|
367
|
+
this.editorArea = { top: 0, left: 0, width: 0, height: 0 };
|
|
368
|
+
// Assume the Editor does not fit until the body says otherwise: when the frame has no body rows
|
|
369
|
+
// at all the body callback never runs, and a stale `false` would let the user edit blind.
|
|
370
|
+
if (this.stage === "compose") this.tooSmall = true;
|
|
371
|
+
this.findingVisible = false;
|
|
372
|
+
const inner = Math.max(1, width - 2);
|
|
373
|
+
const small = this.theme.fg("warning", "Enlarge the terminal to edit (about 20 rows). Ctrl+C still copies your text.");
|
|
374
|
+
// Switch to short labels when the full set cannot share one line, measuring with the focus marker
|
|
375
|
+
// in place so the row does not change height merely because focus arrived.
|
|
376
|
+
const labels = this.buttonRow();
|
|
377
|
+
const withMarker = labels.map((button, index) => `${index === 0 && this.zone === "actions" ? "›" : ""}[${button.label}]`);
|
|
378
|
+
const narrow = wrapButtons(withMarker, inner).length > 1;
|
|
379
|
+
const buttonRow = () => {
|
|
380
|
+
if (!labels.length) return "";
|
|
381
|
+
const rendered = buttonLabels(this.theme, labels.map((button) => (narrow ? button.short : button.label)), this.zone === "actions" ? this.action : undefined);
|
|
382
|
+
return wrapButtons(rendered, inner).join("\n");
|
|
383
|
+
};
|
|
384
|
+
// On a terminal too small for the guidance line, the guard rides in the hints so it still shows.
|
|
385
|
+
// Constant height on purpose: hints that wrap to a different number of lines when `tooSmall`
|
|
386
|
+
// changes would flip the fit guard on every render (see createTextEditorModal).
|
|
387
|
+
const hints = this.stage === "compose"
|
|
388
|
+
? "Enter newline · Ctrl+S or Ctrl+Enter discover · Tab actions · Esc cancel"
|
|
389
|
+
: this.stage === "review" ? "↑↓ move · Space/Enter toggle · Tab actions · Ctrl+C copy · Esc cancel"
|
|
390
|
+
: this.stage === "ready" ? "↑↓ scroll · Ctrl+C copy · Esc close"
|
|
391
|
+
: this.stage === "failed" ? "Ctrl+C copy · Esc close"
|
|
392
|
+
: "Thinking… · Esc cancel";
|
|
393
|
+
return renderFrame(this.theme, width, height, {
|
|
394
|
+
header: () => this.theme.fg("accent", this.theme.bold("Bro · elevate"))
|
|
395
|
+
+ this.theme.fg("dim", ` · ${this.contextMode === "messages" ? "Messages" : "Full"}${this.contextReduced ? " (reduced)" : ""}`)
|
|
396
|
+
+ (this.model ? this.theme.fg("dim", ` · ${this.model}`) : "")
|
|
397
|
+
+ (this.stage === "review" && this.discovery?.findings.length ? this.theme.fg("dim", ` · ${this.accepted.size}/${this.discovery.findings.length} selected`) : "")
|
|
398
|
+
+ (this.stage !== "compose" && this.scroller.indicator() ? this.theme.fg("dim", ` · ${this.scroller.indicator()}`) : ""),
|
|
399
|
+
body: (rows, innerWidth) => {
|
|
400
|
+
if (this.stage !== "review" || !this.discovery) {
|
|
401
|
+
if (this.stage === "compose") {
|
|
402
|
+
// Compose: the objective is the whole body, and the Editor is never drawn cut short.
|
|
403
|
+
const intro = [
|
|
404
|
+
this.theme.bold("Your objective"),
|
|
405
|
+
this.theme.fg("dim", "What should the main agent achieve? Enter adds a line."),
|
|
406
|
+
"",
|
|
407
|
+
];
|
|
408
|
+
// shortcut: Editor uses max(5, floor(terminal.rows * 0.3)); use a height API when Pi provides one.
|
|
409
|
+
this.editorViewport.rows = Math.ceil(Math.max(5, rows - intro.length - 2) / 0.3);
|
|
410
|
+
this.tooSmall = rows < intro.length + 7;
|
|
411
|
+
if (this.tooSmall) return wrapTextWithAnsi(small, innerWidth).slice(0, rows);
|
|
412
|
+
const editorLines = this.editor.render(innerWidth);
|
|
413
|
+
// The frame draws a top border, a header and a rule above the body, and one column of
|
|
414
|
+
// left border, so mouse coordinates need that same origin.
|
|
415
|
+
this.editorArea = { top: 3 + intro.length, left: 1, width: innerWidth, height: editorLines.length };
|
|
416
|
+
return [...intro, ...editorLines].slice(0, rows);
|
|
417
|
+
}
|
|
418
|
+
return this.scroller.view(this.markdown.render(innerWidth), rows);
|
|
419
|
+
}
|
|
420
|
+
// Pin the approval target; only its explanation scrolls.
|
|
421
|
+
const current = this.discovery.findings[this.cursor];
|
|
422
|
+
const label = (name: string, value: string) => wrapTextWithAnsi(`${this.theme.fg("dim", `${name}: `)}${elevateDisplayText(value)}`, innerWidth);
|
|
423
|
+
const detail = current ? [
|
|
424
|
+
...label("Finding", `${current.title} · ${current.dimension}`),
|
|
425
|
+
...label("Why", current.why), ...label("Move", current.expertMove),
|
|
426
|
+
"", ...label("Assessment", this.discovery.assessment),
|
|
427
|
+
] : [];
|
|
428
|
+
const listRows = Math.min(this.listText.length, Math.max(1, rows - 2));
|
|
429
|
+
this.listOffset = Math.max(0, Math.min(this.listOffset, this.listText.length - listRows));
|
|
430
|
+
if (this.cursor < this.listOffset) this.listOffset = this.cursor;
|
|
431
|
+
else if (this.cursor >= this.listOffset + listRows) this.listOffset = this.cursor - listRows + 1;
|
|
432
|
+
const list = this.listText.slice(this.listOffset, this.listOffset + listRows);
|
|
433
|
+
const detailRows = rows - list.length;
|
|
434
|
+
this.findingVisible = detailRows > 0;
|
|
435
|
+
const visibleDetail = this.scroller.view(detailRows > 0 ? detail : [], detailRows);
|
|
436
|
+
return [...list, ...visibleDetail];
|
|
437
|
+
},
|
|
438
|
+
footer: [],
|
|
439
|
+
notice: this.notice ? formatNotice(this.theme, this.notice, this.stage === "failed" ? "error" : "info") : undefined,
|
|
440
|
+
hints,
|
|
441
|
+
footerAfterHints: buttonRow(),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
invalidate(): void { this.markdown.invalidate(); this.editor.invalidate(); }
|
|
445
|
+
dispose(): void {
|
|
446
|
+
if (this.disposed) return;
|
|
447
|
+
this.disposed = true;
|
|
448
|
+
// The host can unmount the overlay without a key press; abort the in-flight call there too,
|
|
449
|
+
// otherwise a background subprocess keeps running with no window left to show its result.
|
|
450
|
+
this.onDispose();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** A user-driven two-call workflow with isolated transcript and abort-on-close. */
|
|
455
|
+
export async function openElevateModal(ctx: ExtensionCommandContext, request = ""): Promise<void> {
|
|
456
|
+
const initialEditor = ctx.ui.getEditorText();
|
|
457
|
+
const initialObjective = request.trim() || initialEditor.trim();
|
|
458
|
+
// Whether the editor draft is the thing being elevated. Only then may insertion replace it.
|
|
459
|
+
const elevatingEditorDraft = !request.trim() && !!initialEditor.trim();
|
|
460
|
+
const branch = ctx.sessionManager.getBranch();
|
|
461
|
+
await openBroOverlay<void>(ctx, (tui, theme, _keybindings, done) => {
|
|
462
|
+
let closed = false;
|
|
463
|
+
let controller: AbortController | undefined;
|
|
464
|
+
let inserting = false;
|
|
465
|
+
let original = initialObjective;
|
|
466
|
+
let contextMessages = captureElevateContext(branch, "messages");
|
|
467
|
+
let reduced = false;
|
|
468
|
+
let reduceOnRetry = false;
|
|
469
|
+
const contextText = () => (reduced ? "[Older context removed after a context-overload error; remaining history may be partial.]\n" : "") + contextMessages.join("\n");
|
|
470
|
+
let input: ElevateInput = { objective: original, context: contextText() };
|
|
471
|
+
/** Which approvals a retry should reuse, and which call to repeat. */
|
|
472
|
+
let pending: { kind: "discover" } | { kind: "upgrade"; indexes: ReadonlySet<number> } = { kind: "discover" };
|
|
473
|
+
const close = () => {
|
|
474
|
+
if (closed) return;
|
|
475
|
+
closed = true;
|
|
476
|
+
controller?.abort();
|
|
477
|
+
done(undefined);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const modal = new ElevateModal(tui, theme, close, discover, upgrade, insert, retryStage, close, (mode) => {
|
|
481
|
+
contextMessages = captureElevateContext(branch, mode);
|
|
482
|
+
reduced = false;
|
|
483
|
+
reduceOnRetry = false;
|
|
484
|
+
pending = { kind: "discover" };
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
function fail(error: unknown): void {
|
|
488
|
+
const message = errorMessage(error);
|
|
489
|
+
reduceOnRetry = isElevateContextOverflow(message) && contextMessages.length > 0;
|
|
490
|
+
modal.setFailed((reduceOnRetry
|
|
491
|
+
? "Context overload: Retry removes older history; objective and approvals stay.\n"
|
|
492
|
+
: isElevateContextOverflow(message) ? "No history remains to reduce. Shorten the objective or use a larger-context backend.\n" : "") + message);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function discover(objective: string): Promise<void> {
|
|
496
|
+
if (closed || controller) return;
|
|
497
|
+
original = objective.trim();
|
|
498
|
+
input = { objective: original, context: contextText() };
|
|
499
|
+
reduceOnRetry = false;
|
|
500
|
+
const run = new AbortController();
|
|
501
|
+
controller = run;
|
|
502
|
+
pending = { kind: "discover" };
|
|
503
|
+
modal.setDiscovering(original);
|
|
504
|
+
try {
|
|
505
|
+
const [settings, preferences] = await Promise.all([readSettings(), readPreferences()]);
|
|
506
|
+
if (closed || run.signal.aborted) return;
|
|
507
|
+
input.preferences = preferences;
|
|
508
|
+
const selection = selectionForCapability(settings, "btw");
|
|
509
|
+
modal.setModel(selectionLabel(selection));
|
|
510
|
+
const raw = await runBackendText(buildElevateDiscoveryPrompt(input), selection, run.signal);
|
|
511
|
+
if (closed || run.signal.aborted) return;
|
|
512
|
+
modal.setDiscovery(parseElevateDiscovery(raw));
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (!closed && !run.signal.aborted) fail(error);
|
|
515
|
+
} finally { if (controller === run) controller = undefined; }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function upgrade(indexes: ReadonlySet<number>): Promise<void> {
|
|
519
|
+
if (closed || controller) return;
|
|
520
|
+
const run = new AbortController();
|
|
521
|
+
controller = run;
|
|
522
|
+
// A failed rewrite retries the rewrite; re-running discovery would discard the user's choices.
|
|
523
|
+
pending = { kind: "upgrade", indexes };
|
|
524
|
+
input.context = contextText();
|
|
525
|
+
reduceOnRetry = false;
|
|
526
|
+
modal.setUpgrading();
|
|
527
|
+
try {
|
|
528
|
+
const settings = await readSettings();
|
|
529
|
+
if (closed || run.signal.aborted) return;
|
|
530
|
+
// Re-read so an edit between Discovery and Upgrade applies, as settings.md promises.
|
|
531
|
+
input.preferences = await readPreferences();
|
|
532
|
+
if (closed || run.signal.aborted) return;
|
|
533
|
+
// Preserve the selection and avoid accidentally adding rejected ideas.
|
|
534
|
+
const discovery = modal.currentDiscovery;
|
|
535
|
+
if (!discovery) throw new Error("Discovery is missing. Retry.");
|
|
536
|
+
const approved = selectedFindings(discovery, indexes);
|
|
537
|
+
const selection = selectionForCapability(settings, "btw");
|
|
538
|
+
modal.setModel(selectionLabel(selection));
|
|
539
|
+
const revised = await runBackendText(buildElevateUpgradePrompt(input, approved), selection, run.signal);
|
|
540
|
+
if (closed || run.signal.aborted) return;
|
|
541
|
+
if (!revised.trim()) throw new Error("Bro returned an empty request. Retry.");
|
|
542
|
+
// Validate after stripping: a fence containing only whitespace is still empty output, and
|
|
543
|
+
// inserting it would erase the draft.
|
|
544
|
+
const cleaned = stripElevateFence(revised);
|
|
545
|
+
if (!cleaned) throw new Error("Bro returned an empty request. Retry.");
|
|
546
|
+
modal.setReady(cleaned);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (!closed && !run.signal.aborted) fail(error);
|
|
549
|
+
} finally { if (controller === run) controller = undefined; }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Retry whichever call failed, with the objective and approvals the user already chose. */
|
|
553
|
+
function retryStage(): void {
|
|
554
|
+
if (closed || controller) return;
|
|
555
|
+
if (reduceOnRetry) {
|
|
556
|
+
contextMessages = reduceElevateContext(contextMessages);
|
|
557
|
+
reduced = true;
|
|
558
|
+
reduceOnRetry = false;
|
|
559
|
+
modal.setContextReduced(true);
|
|
560
|
+
}
|
|
561
|
+
if (pending.kind === "upgrade") void upgrade(pending.indexes);
|
|
562
|
+
else void discover(original);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function insert(text: string): Promise<void> {
|
|
566
|
+
if (closed || inserting) return;
|
|
567
|
+
if (canBroInsertIntoEditor(ctx)) {
|
|
568
|
+
const current = ctx.ui.getEditorText();
|
|
569
|
+
if (!elevateInsertAllowed(elevatingEditorDraft, initialEditor, current)) {
|
|
570
|
+
modal.setNotice(elevatingEditorDraft
|
|
571
|
+
? "Main editor changed. Copy instead; your draft was not overwritten."
|
|
572
|
+
: "Main editor has a draft. Clear it first, or press Copy instead.");
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
ctx.ui.setEditorText(text);
|
|
576
|
+
close();
|
|
577
|
+
ctx.ui.notify("Elevate inserted the revised request. Review it before submitting.", "info");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
// Desktop has an atomic empty-editor insertion API. Never overwrite a draft there.
|
|
581
|
+
inserting = true;
|
|
582
|
+
let result: Awaited<ReturnType<typeof insertBroDesktopText>>;
|
|
583
|
+
try { result = await insertBroDesktopText(ctx, text); }
|
|
584
|
+
finally { inserting = false; }
|
|
585
|
+
if (closed) return;
|
|
586
|
+
modal.setNotice(result === "inserted"
|
|
587
|
+
? "Inserted into the main editor. Review it before submitting."
|
|
588
|
+
: result === "not_empty"
|
|
589
|
+
? "Desktop editor has a draft; copy the revised request instead."
|
|
590
|
+
: "Desktop cannot insert here; copy the revised request instead.");
|
|
591
|
+
if (result === "inserted") close();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
modal.setCompose(initialObjective);
|
|
595
|
+
return modal;
|
|
596
|
+
}, { mouse: true });
|
|
597
|
+
}
|
package/src/explain-modal.ts
CHANGED
|
@@ -395,25 +395,31 @@ Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/bl
|
|
|
395
395
|
## Guided Review
|
|
396
396
|
|
|
397
397
|
- \`/bro guided-review <PR number or URL>\` — automatically explain and assess a PR with captured code and optional private questions (local Pi interactive only; Git and authenticated gh required).
|
|
398
|
-
-
|
|
399
|
-
- Enter opens
|
|
400
|
-
- Copy finding
|
|
398
|
+
- Reviews save automatically. Reopen the same PR without a model call; bare \`/bro guided-review\` lists saved reviews (\`resume\` is an alias). Captured code stays unchanged.
|
|
399
|
+
- Enter opens items or activates focused actions. Tab changes focus; Esc goes back, then closes from Contents. Close saves; Stop and close cancels active work first.
|
|
400
|
+
- Copy finding uses the clipboard, not GitHub. Regenerate requires confirmation and reviews the same capture. For untrusted PRs, prefer Claude/Muse: file-only tools, no shell/writes/web. Codex/Grok allow shell in a read-only sandbox; Agy's sandbox permits project writes. All three show an advisory. Choose a review override in /bro config. Repository text can mislead; citations are not proof.
|
|
401
|
+
|
|
402
|
+
## Elevate
|
|
403
|
+
|
|
404
|
+
- \`/bro elevate [request]\` — improve a task before sending it. Ctrl+Shift+E uses the draft. Choose Messages/Full context; Space/Enter ticks findings; Tab focuses actions.
|
|
405
|
+
- Enter adds a line; Ctrl+S or Ctrl+Enter discovers gaps. Use labelled buttons for actions.
|
|
406
|
+
- Shares BTW settings; see docs/elevate.md.
|
|
401
407
|
|
|
402
408
|
## Side conversation
|
|
403
409
|
|
|
404
410
|
- \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
|
|
405
|
-
-
|
|
411
|
+
- Enter asks; empty Enter or \`/retry\` re-asks. \`/mode\` toggles conversation-only/full workspace access, retaining the thread. \`/copy\`, \`/copy-all\` copy; \`/insert\`, \`/insert-all\` fill an empty main editor without submitting. \`/clear\` resets; Esc closes.
|
|
406
412
|
|
|
407
413
|
## Advisor
|
|
408
414
|
|
|
409
415
|
- \`bro_advisor\` — a tool the executor agent may call for a second opinion from a fresh backend process with real, auto-approved workspace access; it is told to advise, not edit, but that is not enforced
|
|
410
416
|
- \`/bro advisor\` — whether \`bro_advisor\` is available right now
|
|
411
417
|
- \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
|
|
412
|
-
- Standing advisor steering
|
|
418
|
+
- Standing advisor steering sets cross-session priorities; session steering takes precedence.
|
|
413
419
|
|
|
414
420
|
## Configure and check
|
|
415
421
|
|
|
416
|
-
- \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
|
|
422
|
+
- \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, Elevate and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
|
|
417
423
|
- \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately. If the terminal is too small, Esc closes config after pending saves finish; a failed save keeps it open.
|
|
418
424
|
- \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
|
|
419
425
|
- \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
|