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