pi-bro 0.22.2 → 0.23.1
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 +39 -0
- package/README.md +5 -7
- package/THIRD_PARTY_NOTICES.md +57 -0
- package/docs/pig-compatibility.md +1 -1
- package/docs/user-guide.md +19 -8
- package/package.json +25 -18
- package/src/advisor.ts +573 -0
- package/{backend.ts → src/backend.ts} +35 -0
- package/src/bro.ts +424 -0
- package/src/btw.ts +572 -0
- package/{config-ui.ts → src/config-ui.ts} +17 -7
- package/src/doctor.ts +541 -0
- package/src/explain-modal.ts +758 -0
- package/{prompt.ts → src/prompt.ts} +3 -1
- package/{review-ui.ts → src/review-ui.ts} +5 -1
- package/{settings.ts → src/settings.ts} +45 -3
- package/src/show.ts +243 -0
- package/{ui-capabilities.ts → src/ui-capabilities.ts} +25 -0
- package/{util.ts → src/util.ts} +16 -0
- package/src/vendor/mermaid.min.js +4328 -0
- package/bro.ts +0 -2967
- /package/{review-modal.ts → src/review-modal.ts} +0 -0
- /package/{review-source.ts → src/review-source.ts} +0 -0
- /package/{review.ts → src/review.ts} +0 -0
- /package/{sources.ts → src/sources.ts} +0 -0
package/src/btw.ts
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { copyToClipboard, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Input, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Focusable } from "@earendil-works/pi-tui";
|
|
4
|
+
import { execute as executeBackend, type BackendProgress, type BackendSelection } from "./backend.ts";
|
|
5
|
+
import type { Theme, TuiLike } from "./config-ui.ts";
|
|
6
|
+
import { buildBtwPrompt } from "./prompt.ts";
|
|
7
|
+
import {
|
|
8
|
+
type BackendName,
|
|
9
|
+
type BroSettings,
|
|
10
|
+
capabilityBackend,
|
|
11
|
+
readPreferences,
|
|
12
|
+
readSettings,
|
|
13
|
+
selectionForCapability,
|
|
14
|
+
selectionLabel,
|
|
15
|
+
} from "./settings.ts";
|
|
16
|
+
import { captureShowTranscript } from "./show.ts";
|
|
17
|
+
import {
|
|
18
|
+
broModalRows,
|
|
19
|
+
canBroInsertIntoEditor,
|
|
20
|
+
insertBroDesktopText,
|
|
21
|
+
setRegularMouseReporting,
|
|
22
|
+
wheelDelta,
|
|
23
|
+
type BroMouseEvent,
|
|
24
|
+
type BroMouseResult,
|
|
25
|
+
} from "./ui-capabilities.ts";
|
|
26
|
+
import { errorMessage } from "./util.ts";
|
|
27
|
+
|
|
28
|
+
export const BTW_CONTEXT_TURNS = 8;
|
|
29
|
+
export const BTW_CONTEXT_MAX = 40_000;
|
|
30
|
+
|
|
31
|
+
export type BtwTurn = { question: string; answer: string };
|
|
32
|
+
// `context` keeps the main-session seed so a fresh native session can be reseeded with the whole
|
|
33
|
+
// thread; `sessionFull` and `sessionPreferences` are the access mode and preferences the native
|
|
34
|
+
// session last ran with. `preferences` records whether the latest turn used them (header tag).
|
|
35
|
+
export type BtwThread = {
|
|
36
|
+
turns: BtwTurn[];
|
|
37
|
+
conversationId?: string;
|
|
38
|
+
full: boolean;
|
|
39
|
+
backend?: BackendName;
|
|
40
|
+
model?: string;
|
|
41
|
+
preferences?: boolean;
|
|
42
|
+
context?: string;
|
|
43
|
+
sessionFull?: boolean;
|
|
44
|
+
sessionPreferences?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function parseBtwArguments(value: string): { question: string; invalid?: string } {
|
|
48
|
+
const rest = value.trim();
|
|
49
|
+
if (!rest.startsWith("--")) return { question: rest };
|
|
50
|
+
const token = rest.split(/\s/, 1)[0]!;
|
|
51
|
+
if (token === "--full" || token === "--sandbox") {
|
|
52
|
+
return { question: "", invalid: `${token} was removed. Open /bro btw and type /mode to switch between conversation-only and full permission.` };
|
|
53
|
+
}
|
|
54
|
+
if (token === "--fresh") {
|
|
55
|
+
return { question: "", invalid: "--fresh was removed. Every new thread starts with main-session context; type /clear inside /bro btw to start over." };
|
|
56
|
+
}
|
|
57
|
+
return { question: "", invalid: `Unknown /bro btw flag: ${token}` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A new thread starts conversation-only; reopening keeps the thread and its mode.
|
|
61
|
+
export function resolveBtwThread(existing: BtwThread | undefined): BtwThread {
|
|
62
|
+
return existing ?? { turns: [], full: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function bindBtwBackend(thread: BtwThread, backend: BackendName): boolean {
|
|
66
|
+
const changed = thread.backend !== undefined && thread.backend !== backend;
|
|
67
|
+
if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; thread.sessionPreferences = undefined; }
|
|
68
|
+
thread.backend = backend;
|
|
69
|
+
return changed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function btwModeLabel(full: boolean): string {
|
|
73
|
+
return full ? "full permission" : "conversation-only";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// /mode keeps the transcript; whether the native session survives is decided by
|
|
77
|
+
// nativeBtwContinuation on the next turn.
|
|
78
|
+
export function toggleBtwMode(thread: BtwThread): void {
|
|
79
|
+
thread.full = !thread.full;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Claude, Grok, Codex, and Muse run btw in the workspace for both modes and resume one native session across
|
|
83
|
+
// /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
|
|
84
|
+
// dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
|
|
85
|
+
// with the main-session context and the whole thread.
|
|
86
|
+
// A native session remembers every earlier prompt, including old preferences, so a preferences
|
|
87
|
+
// change (or deletion) on any backend starts a fresh session reseeded with the quoted thread.
|
|
88
|
+
export function nativeBtwContinuation(thread: BtwThread, backend: BackendName, preferences = ""): string | undefined {
|
|
89
|
+
if (backend === "agy" && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
|
|
90
|
+
thread.conversationId = undefined;
|
|
91
|
+
}
|
|
92
|
+
if (thread.conversationId && (thread.sessionPreferences ?? "") !== preferences) thread.conversationId = undefined;
|
|
93
|
+
return thread.conversationId;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function btwHeaderLabel(thread: Pick<BtwThread, "model" | "preferences">): string {
|
|
97
|
+
return thread.model ? `${thread.model}${thread.preferences ? " · prefs" : ""}` : "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
|
|
101
|
+
return turns
|
|
102
|
+
.map((turn) => {
|
|
103
|
+
const question = turn.question.split(/\r?\n/).map((line) => (line ? `> ${line}` : ">")).join("\n");
|
|
104
|
+
// A turn aborted mid-stream can end inside an unclosed code fence, which would swallow the
|
|
105
|
+
// separator and every later turn; close it so each turn renders as its own block.
|
|
106
|
+
const answer = (turn.answer.match(/^```/gm)?.length ?? 0) % 2 === 1 ? `${turn.answer}\n\`\`\`` : turn.answer;
|
|
107
|
+
return `> **You**\n>\n${question}\n\n**Bro**\n\n${answer}`;
|
|
108
|
+
})
|
|
109
|
+
.join("\n\n---\n\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type BtwComposerAction =
|
|
113
|
+
| { kind: "clear" }
|
|
114
|
+
| { kind: "mode" }
|
|
115
|
+
| { kind: "retry" }
|
|
116
|
+
| { kind: "clipboard"; all: boolean }
|
|
117
|
+
| { kind: "insert"; all: boolean }
|
|
118
|
+
| { kind: "removed"; command: string }
|
|
119
|
+
| { kind: "question"; text: string };
|
|
120
|
+
|
|
121
|
+
export function parseBtwComposerCommand(value: string): BtwComposerAction {
|
|
122
|
+
const command = value.trim();
|
|
123
|
+
if (command === "/clear") return { kind: "clear" };
|
|
124
|
+
if (command === "/mode") return { kind: "mode" };
|
|
125
|
+
if (command === "/retry" || command === "") return { kind: "retry" };
|
|
126
|
+
if (command === "/copy" || command === "/copy-all") {
|
|
127
|
+
return { kind: "clipboard", all: command === "/copy-all" };
|
|
128
|
+
}
|
|
129
|
+
if (command === "/insert" || command === "/insert-all") return { kind: "insert", all: command === "/insert-all" };
|
|
130
|
+
if (command === "/insert!" || command === "/insert-all!") return { kind: "removed", command };
|
|
131
|
+
return { kind: "question", text: command };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
|
|
135
|
+
// existing 75ms cadence and translates the backend's tagged outcome back into this function's
|
|
136
|
+
// existing throw-on-failure / { text, conversationId } contract. A conversationId is only ever
|
|
137
|
+
// returned on success (see backend.ts's continuation handling), preserving current behavior on
|
|
138
|
+
// failed turns.
|
|
139
|
+
export async function runBtwTurn(
|
|
140
|
+
prompt: string,
|
|
141
|
+
selection: BackendSelection,
|
|
142
|
+
options: { full: boolean; cwd: string; conversationId?: string },
|
|
143
|
+
signal: AbortSignal,
|
|
144
|
+
onProgress?: (text: string) => void,
|
|
145
|
+
): Promise<{ text: string; conversationId?: string }> {
|
|
146
|
+
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
147
|
+
let latest: string | undefined;
|
|
148
|
+
const throttledProgress = onProgress
|
|
149
|
+
? (progress: BackendProgress) => {
|
|
150
|
+
if (progress.kind !== "text") return;
|
|
151
|
+
latest = progress.text;
|
|
152
|
+
if (!updateTimer) {
|
|
153
|
+
updateTimer = setTimeout(() => {
|
|
154
|
+
updateTimer = undefined;
|
|
155
|
+
if (!signal.aborted && latest !== undefined) onProgress(latest);
|
|
156
|
+
}, 75);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
: undefined;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const outcome = await executeBackend(
|
|
163
|
+
{
|
|
164
|
+
feature: "btw",
|
|
165
|
+
prompt,
|
|
166
|
+
access: options.full ? "workspace-full" : "restricted",
|
|
167
|
+
cwd: options.cwd,
|
|
168
|
+
continuation: options.conversationId ? { id: options.conversationId } : undefined,
|
|
169
|
+
},
|
|
170
|
+
selection,
|
|
171
|
+
signal,
|
|
172
|
+
throttledProgress,
|
|
173
|
+
);
|
|
174
|
+
if (outcome.status === "success") return { text: outcome.text, conversationId: outcome.continuation?.id };
|
|
175
|
+
throw new Error(outcome.message);
|
|
176
|
+
} finally {
|
|
177
|
+
if (updateTimer) clearTimeout(updateTimer);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export class BtwModal implements Focusable {
|
|
182
|
+
private _focused = false;
|
|
183
|
+
private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
|
|
184
|
+
private readonly input = new Input();
|
|
185
|
+
private notice = "";
|
|
186
|
+
private offset = 0;
|
|
187
|
+
// Pinned to the newest text until the reader scrolls up; scrolling back to the end re-pins.
|
|
188
|
+
private follow = false;
|
|
189
|
+
private maxOffset = 0;
|
|
190
|
+
private bodyHeight = 1;
|
|
191
|
+
private running = false;
|
|
192
|
+
private full = false;
|
|
193
|
+
private model = "";
|
|
194
|
+
private disposed = false;
|
|
195
|
+
|
|
196
|
+
get focused(): boolean {
|
|
197
|
+
return this._focused;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
set focused(value: boolean) {
|
|
201
|
+
this._focused = value;
|
|
202
|
+
this.input.focused = value;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
constructor(
|
|
206
|
+
private readonly tui: TuiLike,
|
|
207
|
+
private readonly theme: Theme,
|
|
208
|
+
private readonly onClose: () => void,
|
|
209
|
+
private readonly onSubmit: (value: string) => void,
|
|
210
|
+
private readonly onDispose: () => void,
|
|
211
|
+
) {
|
|
212
|
+
setRegularMouseReporting(this.tui, true);
|
|
213
|
+
this.input.onSubmit = (value) => {
|
|
214
|
+
if (!this.running) this.onSubmit(value);
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Keeps the reader's place; render clamps the offset when the text shrinks (e.g. /clear).
|
|
219
|
+
setText(text: string): void {
|
|
220
|
+
this.markdown.setText(text);
|
|
221
|
+
this.tui.requestRender();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Show the newest text and keep following it while a reply streams. */
|
|
225
|
+
scrollToEnd(): void {
|
|
226
|
+
this.follow = true;
|
|
227
|
+
this.tui.requestRender();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
setNotice(notice: string): void {
|
|
231
|
+
if (this.disposed) return;
|
|
232
|
+
this.notice = notice;
|
|
233
|
+
this.tui.requestRender();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
setRunning(running: boolean): void {
|
|
237
|
+
this.running = running;
|
|
238
|
+
this.tui.requestRender();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setModel(model: string): void {
|
|
242
|
+
this.model = model;
|
|
243
|
+
this.tui.requestRender();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
setFull(full: boolean): void {
|
|
247
|
+
this.full = full;
|
|
248
|
+
this.tui.requestRender();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
clearComposer(): void {
|
|
252
|
+
this.input.setValue("");
|
|
253
|
+
this.tui.requestRender();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
invalidate(): void {
|
|
257
|
+
this.markdown.invalidate();
|
|
258
|
+
this.input.invalidate();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Pi 1.2+ fullscreen delivers the wheel here and no longer forwards raw wheel input to handleInput.
|
|
262
|
+
handleMouse(event: BroMouseEvent): BroMouseResult {
|
|
263
|
+
if (event.type !== "wheel") return;
|
|
264
|
+
const delta = Math.trunc(event.wheelDelta ?? 0);
|
|
265
|
+
if (delta) this.scroll(delta);
|
|
266
|
+
return { handled: true, render: false };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private scroll(delta: number): void {
|
|
270
|
+
this.offset = Math.max(0, Math.min(this.offset + delta, this.maxOffset));
|
|
271
|
+
this.follow = this.offset >= this.maxOffset;
|
|
272
|
+
this.notice = "";
|
|
273
|
+
this.tui.requestRender();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
handleInput(data: string): void {
|
|
277
|
+
if (matchesKey(data, "escape")) {
|
|
278
|
+
this.onClose();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const delta = wheelDelta(data) || (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0);
|
|
282
|
+
if (delta) {
|
|
283
|
+
this.scroll(delta);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.input.handleInput(data);
|
|
287
|
+
this.tui.requestRender();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private frameLine(content: string, innerWidth: number): string {
|
|
291
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
292
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
293
|
+
return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
297
|
+
const left = edge === "top" ? "┌" : "└";
|
|
298
|
+
const right = edge === "top" ? "┐" : "┘";
|
|
299
|
+
return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private ruleLine(innerWidth: number): string {
|
|
303
|
+
return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
render(width: number): string[] {
|
|
307
|
+
const dialogWidth = Math.max(24, width);
|
|
308
|
+
const innerWidth = Math.max(22, dialogWidth - 2);
|
|
309
|
+
const terminalRows = broModalRows(this.tui, process.stdout.rows);
|
|
310
|
+
const dialogHeight = Math.min(34, Math.max(8, Math.floor(terminalRows * 0.82)));
|
|
311
|
+
const footer = [
|
|
312
|
+
...wrapTextWithAnsi(this.theme.fg("accent", this.theme.bold("Your message to Bro")), innerWidth),
|
|
313
|
+
this.input.render(innerWidth)[0] ?? "",
|
|
314
|
+
...wrapTextWithAnsi(this.running ? "Bro is thinking… · Esc: close (stops reply)" : "Enter: send message · Esc: close panel", innerWidth),
|
|
315
|
+
...wrapTextWithAnsi("Commands: /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry", innerWidth),
|
|
316
|
+
...(this.notice ? wrapTextWithAnsi(this.theme.fg("accent", this.notice), innerWidth) : []),
|
|
317
|
+
];
|
|
318
|
+
this.bodyHeight = Math.max(1, dialogHeight - 5 - footer.length);
|
|
319
|
+
|
|
320
|
+
const rendered = this.markdown.render(innerWidth);
|
|
321
|
+
this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
|
|
322
|
+
if (this.follow) this.offset = this.maxOffset;
|
|
323
|
+
this.offset = Math.max(0, Math.min(this.offset, this.maxOffset));
|
|
324
|
+
const visible = rendered.slice(this.offset, this.offset + this.bodyHeight);
|
|
325
|
+
const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
|
|
326
|
+
const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
|
|
327
|
+
|
|
328
|
+
const model = this.model ? this.theme.fg("dim", ` · ${this.model}`) : "";
|
|
329
|
+
const mode = this.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
|
|
330
|
+
const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll);
|
|
331
|
+
|
|
332
|
+
const lines = [
|
|
333
|
+
this.borderLine(innerWidth, "top"),
|
|
334
|
+
this.frameLine(header, innerWidth),
|
|
335
|
+
this.ruleLine(innerWidth),
|
|
336
|
+
];
|
|
337
|
+
for (const line of visible) lines.push(this.frameLine(line, innerWidth));
|
|
338
|
+
for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
|
|
339
|
+
lines.push(this.ruleLine(innerWidth));
|
|
340
|
+
for (const line of footer) lines.push(this.frameLine(line, innerWidth));
|
|
341
|
+
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
342
|
+
return lines;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
dispose(): void {
|
|
346
|
+
if (this.disposed) return;
|
|
347
|
+
this.disposed = true;
|
|
348
|
+
setRegularMouseReporting(this.tui, false);
|
|
349
|
+
this.onDispose();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function openBtwModal(
|
|
354
|
+
ctx: ExtensionCommandContext,
|
|
355
|
+
options: { thread: BtwThread; initialQuestion?: string },
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
const thread = options.thread;
|
|
358
|
+
|
|
359
|
+
await ctx.ui.custom<void>(
|
|
360
|
+
(tui, theme, _keybindings, done) => {
|
|
361
|
+
let closed = false;
|
|
362
|
+
let controller: AbortController | undefined;
|
|
363
|
+
|
|
364
|
+
const transcript = () => formatBtwTranscript(thread.turns);
|
|
365
|
+
|
|
366
|
+
const close = () => {
|
|
367
|
+
if (closed) return;
|
|
368
|
+
closed = true;
|
|
369
|
+
controller?.abort();
|
|
370
|
+
done(undefined);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const modal = new BtwModal(tui, theme, close, submit, () => {
|
|
374
|
+
closed = true;
|
|
375
|
+
controller?.abort();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// `replaced` is the turn /retry removed; it is put back if the turn never starts.
|
|
379
|
+
const runTurn = async (question: string, replaced?: BtwTurn) => {
|
|
380
|
+
if (controller) return;
|
|
381
|
+
const turnController = new AbortController();
|
|
382
|
+
controller = turnController;
|
|
383
|
+
modal.setRunning(true);
|
|
384
|
+
modal.clearComposer();
|
|
385
|
+
|
|
386
|
+
let settings: BroSettings;
|
|
387
|
+
let preferences: string;
|
|
388
|
+
try {
|
|
389
|
+
settings = await readSettings();
|
|
390
|
+
preferences = await readPreferences();
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (replaced) thread.turns.push(replaced);
|
|
393
|
+
if (controller === turnController) controller = undefined;
|
|
394
|
+
if (!closed) { modal.setRunning(false); modal.setNotice(errorMessage(error)); }
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// Closing (or reopening) during the reads above must not touch the shared thread.
|
|
398
|
+
if (closed || turnController.signal.aborted) {
|
|
399
|
+
if (replaced) thread.turns.push(replaced);
|
|
400
|
+
if (controller === turnController) controller = undefined;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (bindBtwBackend(thread, capabilityBackend(settings, "btw"))) modal.setNotice("Backend changed — started a fresh side thread.");
|
|
404
|
+
if (thread.turns.length === 0) {
|
|
405
|
+
thread.conversationId = undefined;
|
|
406
|
+
const context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS, BTW_CONTEXT_MAX)?.text;
|
|
407
|
+
thread.context = context;
|
|
408
|
+
}
|
|
409
|
+
// Resume natively when possible; otherwise seed the fresh native session with the main-session
|
|
410
|
+
// context plus every earlier turn so nothing is silently lost.
|
|
411
|
+
const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"), preferences);
|
|
412
|
+
const history = !conversationId && thread.turns.length ? formatBtwTranscript(thread.turns) : undefined;
|
|
413
|
+
const context = conversationId ? undefined : thread.context;
|
|
414
|
+
const full = thread.full;
|
|
415
|
+
|
|
416
|
+
thread.turns.push({ question, answer: "…" });
|
|
417
|
+
modal.setText(transcript());
|
|
418
|
+
modal.scrollToEnd();
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
const selection = selectionForCapability(settings, "btw");
|
|
422
|
+
thread.model = selectionLabel(selection);
|
|
423
|
+
thread.preferences = Boolean(preferences);
|
|
424
|
+
modal.setModel(btwHeaderLabel(thread));
|
|
425
|
+
const result = await runBtwTurn(
|
|
426
|
+
buildBtwPrompt(context, question, { full, history, preferences }),
|
|
427
|
+
selection,
|
|
428
|
+
{ full, cwd: ctx.cwd, conversationId },
|
|
429
|
+
turnController.signal,
|
|
430
|
+
(partial) => {
|
|
431
|
+
if (closed || turnController.signal.aborted) return;
|
|
432
|
+
thread.turns[thread.turns.length - 1]!.answer = partial;
|
|
433
|
+
modal.setText(transcript());
|
|
434
|
+
},
|
|
435
|
+
);
|
|
436
|
+
if (turnController.signal.aborted) return;
|
|
437
|
+
if (result.conversationId) {
|
|
438
|
+
thread.conversationId = result.conversationId;
|
|
439
|
+
thread.sessionFull = full;
|
|
440
|
+
thread.sessionPreferences = preferences;
|
|
441
|
+
}
|
|
442
|
+
thread.turns[thread.turns.length - 1]!.answer = result.text;
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (turnController.signal.aborted || closed) return;
|
|
445
|
+
thread.turns[thread.turns.length - 1]!.answer = `_${errorMessage(error)}_`;
|
|
446
|
+
modal.setNotice(errorMessage(error));
|
|
447
|
+
} finally {
|
|
448
|
+
if (controller === turnController) controller = undefined;
|
|
449
|
+
if (!closed) {
|
|
450
|
+
modal.setRunning(false);
|
|
451
|
+
modal.setText(transcript());
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const toggleMode = () => {
|
|
457
|
+
toggleBtwMode(thread);
|
|
458
|
+
modal.setFull(thread.full);
|
|
459
|
+
modal.setNotice(`Mode: ${btwModeLabel(thread.full)}`);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const clear = () => {
|
|
463
|
+
thread.turns = [];
|
|
464
|
+
thread.conversationId = undefined;
|
|
465
|
+
thread.context = undefined;
|
|
466
|
+
thread.sessionFull = undefined;
|
|
467
|
+
thread.sessionPreferences = undefined;
|
|
468
|
+
modal.clearComposer();
|
|
469
|
+
modal.setNotice("");
|
|
470
|
+
modal.setText("");
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const retry = () => {
|
|
474
|
+
if (controller) return;
|
|
475
|
+
const last = thread.turns.at(-1);
|
|
476
|
+
if (!last) {
|
|
477
|
+
modal.setNotice("Nothing to retry yet.");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
thread.turns.pop();
|
|
481
|
+
void runTurn(last.question, last);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const copyOut = async (all: boolean) => {
|
|
485
|
+
const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
|
|
486
|
+
if (!text.trim()) {
|
|
487
|
+
modal.setNotice("Nothing to copy yet.");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
await copyToClipboard(text);
|
|
492
|
+
modal.setNotice(all ? "Copied the full thread to the clipboard." : "Copied the latest answer to the clipboard.");
|
|
493
|
+
} catch (error) {
|
|
494
|
+
modal.setNotice(`Copy failed: ${errorMessage(error)}`);
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const insert = async (all: boolean) => {
|
|
499
|
+
const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
|
|
500
|
+
if (!text.trim()) {
|
|
501
|
+
modal.setNotice("Nothing to insert yet.");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (!canBroInsertIntoEditor(ctx)) {
|
|
505
|
+
const result = await insertBroDesktopText(ctx, text);
|
|
506
|
+
modal.setNotice(result === "inserted" ? "Inserted into the main editor. Review it before sending." : result === "not_empty" ? "Main editor has a draft or attachment. Clear it first, then insert again." : "Could not confirm insertion. Check the main editor before trying again.");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (ctx.ui.getEditorText().trim()) {
|
|
510
|
+
modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
ctx.ui.setEditorText(text);
|
|
514
|
+
modal.setNotice(all ? "Inserted the full thread into the main editor." : "Inserted the latest answer into the main editor.");
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
function submit(value: string): void {
|
|
518
|
+
const action = parseBtwComposerCommand(value);
|
|
519
|
+
if (action.kind === "clear") {
|
|
520
|
+
modal.clearComposer();
|
|
521
|
+
clear();
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (action.kind === "mode") {
|
|
525
|
+
modal.clearComposer();
|
|
526
|
+
toggleMode();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (action.kind === "clipboard") {
|
|
530
|
+
modal.clearComposer();
|
|
531
|
+
void copyOut(action.all);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (action.kind === "insert") {
|
|
535
|
+
modal.clearComposer();
|
|
536
|
+
void insert(action.all);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (action.kind === "removed") {
|
|
540
|
+
modal.clearComposer();
|
|
541
|
+
modal.setNotice(`${action.command} was removed. Edit or clear the main editor draft, then use /insert or /insert-all.`);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (action.kind === "retry") {
|
|
545
|
+
modal.clearComposer();
|
|
546
|
+
retry();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
void runTurn(action.text);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
modal.setFull(thread.full);
|
|
553
|
+
modal.setModel(btwHeaderLabel(thread));
|
|
554
|
+
modal.setText(transcript());
|
|
555
|
+
modal.scrollToEnd();
|
|
556
|
+
|
|
557
|
+
if (options.initialQuestion) void runTurn(options.initialQuestion);
|
|
558
|
+
|
|
559
|
+
return modal;
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
overlay: true,
|
|
563
|
+
overlayOptions: {
|
|
564
|
+
width: "78%",
|
|
565
|
+
minWidth: 48,
|
|
566
|
+
maxHeight: "82%",
|
|
567
|
+
anchor: "top-center",
|
|
568
|
+
margin: { top: 1, left: 2, right: 2 },
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
);
|
|
572
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
SelectList,
|
|
8
8
|
SettingsList,
|
|
9
9
|
Text,
|
|
10
|
+
TruncatedText,
|
|
10
11
|
matchesKey,
|
|
11
12
|
truncateToWidth,
|
|
12
13
|
visibleWidth,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
withCapabilityOverride,
|
|
35
36
|
} from "./settings.ts";
|
|
36
37
|
import { BRO_MODES, parseBroMode } from "./prompt.ts";
|
|
38
|
+
import { broModalRows, consumeWheel } from "./ui-capabilities.ts";
|
|
37
39
|
import { errorMessage } from "./util.ts";
|
|
38
40
|
|
|
39
41
|
export type Theme = ExtensionCommandContext["ui"]["theme"];
|
|
@@ -58,6 +60,10 @@ export function createConfigModal(
|
|
|
58
60
|
persistSettings: (settings: BroSettings) => Promise<void>,
|
|
59
61
|
): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
60
62
|
return (tui, theme, _keybindings, done) => {
|
|
63
|
+
// The overlay (maxHeight 78%) clips from the bottom: leave room for the borders, title,
|
|
64
|
+
// subtitle, a two-line notice and SettingsList's own scroll, blank and hint rows.
|
|
65
|
+
// shortcut: sized once at open, not on resize; fine while config stays short-lived.
|
|
66
|
+
const listRows = Math.max(3, Math.floor(broModalRows(tui, process.stdout.rows) * 0.78) - 9);
|
|
61
67
|
let settings = initialSettings;
|
|
62
68
|
// The last settings actually confirmed on disk. A failed save reverts `settings` (and the
|
|
63
69
|
// whole displayed row set) back to this, so the screen never shows state that doesn't exist.
|
|
@@ -101,7 +107,7 @@ export function createConfigModal(
|
|
|
101
107
|
pickerDone(`${meta.prefix}${value.trim()}`);
|
|
102
108
|
}
|
|
103
109
|
};
|
|
104
|
-
const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
|
|
110
|
+
const picker = new SelectList(options, Math.min(options.length, 8, listRows), getSelectListTheme());
|
|
105
111
|
const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
|
|
106
112
|
picker.setSelectedIndex(Math.max(0, selectedIndex));
|
|
107
113
|
picker.onSelect = (item) => {
|
|
@@ -112,7 +118,10 @@ export function createConfigModal(
|
|
|
112
118
|
picker.onCancel = () => pickerDone();
|
|
113
119
|
const customLabel = () => enteringBackend ? EXTERNAL_BACKENDS[enteringBackend].label : "";
|
|
114
120
|
return {
|
|
115
|
-
|
|
121
|
+
// SettingsList draws an open submenu instead of its own list and hints, so the picker brings its own.
|
|
122
|
+
render: (width: number) => enteringBackend
|
|
123
|
+
? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)]
|
|
124
|
+
: [...picker.render(width), "", truncateToWidth(theme.fg("dim", " ↑/↓ choose · Enter select · Esc back"), width)],
|
|
116
125
|
invalidate: () => { picker.invalidate(); input.invalidate(); },
|
|
117
126
|
handleInput: (data: string) => {
|
|
118
127
|
if (enteringBackend && matchesKey(data, "escape")) pickerDone();
|
|
@@ -197,7 +206,7 @@ export function createConfigModal(
|
|
|
197
206
|
...CAPABILITIES.flatMap((capability) => [capabilityItems[capability].model, capabilityItems[capability].effort]),
|
|
198
207
|
];
|
|
199
208
|
|
|
200
|
-
const noticeText = new Text("");
|
|
209
|
+
const noticeText = new Text("", 1, 0);
|
|
201
210
|
|
|
202
211
|
function runSave(toSave: BroSettings): void {
|
|
203
212
|
saving = true;
|
|
@@ -298,13 +307,13 @@ export function createConfigModal(
|
|
|
298
307
|
done(undefined);
|
|
299
308
|
};
|
|
300
309
|
|
|
301
|
-
const settingsList = new SettingsList(items, Math.min(items.length
|
|
310
|
+
const settingsList = new SettingsList(items, Math.min(items.length, listRows), getSettingsListTheme(), onChange, requestClose);
|
|
302
311
|
const container = new Container();
|
|
303
|
-
container.addChild(new Text(theme.fg("accent", theme.bold("Bro · config"))));
|
|
304
|
-
container.addChild(new
|
|
312
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Bro · config")), 1, 0));
|
|
313
|
+
container.addChild(new TruncatedText(theme.fg("dim", "Shared defaults, with optional overrides per capability"), 1, 0));
|
|
314
|
+
// SettingsList prints its own key hints, so Bro adds none.
|
|
305
315
|
container.addChild(settingsList);
|
|
306
316
|
container.addChild(noticeText);
|
|
307
|
-
container.addChild(new Text(theme.fg("dim", "↑/↓ navigate · Enter select/change · Esc back/close")));
|
|
308
317
|
|
|
309
318
|
return {
|
|
310
319
|
render: (w: number) => {
|
|
@@ -316,6 +325,7 @@ export function createConfigModal(
|
|
|
316
325
|
}), border("└", "┘")];
|
|
317
326
|
},
|
|
318
327
|
invalidate: () => container.invalidate(),
|
|
328
|
+
handleMouse: consumeWheel,
|
|
319
329
|
handleInput: (data: string) => {
|
|
320
330
|
settingsList.handleInput?.(data);
|
|
321
331
|
tui.requestRender();
|