pi-btw 0.4.1 → 0.6.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/README.md +62 -6
- package/extensions/btw.ts +668 -116
- package/package.json +10 -5
- package/skills/btw/SKILL.md +200 -0
package/extensions/btw.ts
CHANGED
|
@@ -2,20 +2,29 @@ import {
|
|
|
2
2
|
buildSessionContext,
|
|
3
3
|
createAgentSession,
|
|
4
4
|
createExtensionRuntime,
|
|
5
|
+
getMarkdownTheme,
|
|
6
|
+
ModelRuntime,
|
|
5
7
|
SessionManager,
|
|
6
8
|
type AgentSession,
|
|
9
|
+
type CreateAgentSessionOptions,
|
|
7
10
|
type AgentSessionEvent,
|
|
8
11
|
type ExtensionAPI,
|
|
9
12
|
type ExtensionCommandContext,
|
|
10
13
|
type ExtensionContext,
|
|
11
14
|
type ResourceLoader,
|
|
12
15
|
} from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
type AssistantMessage,
|
|
18
|
+
type Message,
|
|
19
|
+
type ThinkingLevel as AiThinkingLevel,
|
|
20
|
+
type UserMessage,
|
|
21
|
+
} from "@earendil-works/pi-ai";
|
|
14
22
|
import {
|
|
15
23
|
Box,
|
|
16
24
|
Container,
|
|
17
25
|
Input,
|
|
18
26
|
Key,
|
|
27
|
+
Markdown,
|
|
19
28
|
Text,
|
|
20
29
|
matchesKey,
|
|
21
30
|
truncateToWidth,
|
|
@@ -23,7 +32,10 @@ import {
|
|
|
23
32
|
wrapTextWithAnsi,
|
|
24
33
|
type Focusable,
|
|
25
34
|
type KeybindingsManager,
|
|
35
|
+
type KeyId,
|
|
36
|
+
type MarkdownTheme,
|
|
26
37
|
type OverlayHandle,
|
|
38
|
+
type OverlayOptions,
|
|
27
39
|
type TUI,
|
|
28
40
|
} from "@earendil-works/pi-tui";
|
|
29
41
|
|
|
@@ -32,12 +44,123 @@ const BTW_ENTRY_TYPE = "btw-thread-entry";
|
|
|
32
44
|
const BTW_RESET_TYPE = "btw-thread-reset";
|
|
33
45
|
const BTW_MODEL_OVERRIDE_TYPE = "btw-model-override";
|
|
34
46
|
const BTW_THINKING_OVERRIDE_TYPE = "btw-thinking-override";
|
|
35
|
-
const
|
|
47
|
+
const BTW_DEFAULT_FOCUS_SHORTCUTS: readonly KeyId[] = [Key.alt("/"), Key.super("/"), Key.ctrlAlt("w")];
|
|
48
|
+
const BTW_FOCUS_KEYS_ENV = "PI_BTW_FOCUS_KEYS";
|
|
49
|
+
const BTW_FOCUS_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
50
|
+
// Mirrors the SpecialKey union in @earendil-works/pi-tui keys.d.ts (lower-cased).
|
|
51
|
+
const BTW_FOCUS_SPECIAL_KEYS = new Set([
|
|
52
|
+
"escape", "esc", "enter", "return", "tab", "space", "backspace", "delete", "insert", "clear",
|
|
53
|
+
"home", "end", "pageup", "pagedown", "up", "down", "left", "right",
|
|
54
|
+
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12",
|
|
55
|
+
]);
|
|
56
|
+
// Symbols from the SymbolKey union (letters/digits are matched directly).
|
|
57
|
+
const BTW_FOCUS_SYMBOL_KEYS = new Set([
|
|
58
|
+
"`", "-", "=", "[", "]", "\\", ";", "'", ",", ".", "/", "!", "@", "#", "$", "%", "^", "&", "*",
|
|
59
|
+
"(", ")", "_", "+", "|", "~", "{", "}", ":", "<", ">", "?",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the BTW overlay focus-toggle shortcuts.
|
|
64
|
+
*
|
|
65
|
+
* Users whose window manager or terminal claims the default shortcuts can override them by
|
|
66
|
+
* setting PI_BTW_FOCUS_KEYS to a comma-separated list of pi-tui key identifiers
|
|
67
|
+
* (e.g. "ctrl+/,ctrl+alt+b"). Blank, duplicate, or unparseable entries are ignored; if no
|
|
68
|
+
* usable entries remain, the defaults are kept so focus toggling never becomes impossible.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveBtwFocusShortcuts(env: NodeJS.ProcessEnv = process.env): KeyId[] {
|
|
71
|
+
const raw = env[BTW_FOCUS_KEYS_ENV];
|
|
72
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
73
|
+
return [...BTW_DEFAULT_FOCUS_SHORTCUTS];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const seen = new Set<string>();
|
|
77
|
+
const shortcuts: KeyId[] = [];
|
|
78
|
+
for (const part of raw.split(",")) {
|
|
79
|
+
const candidate = part.trim().toLowerCase();
|
|
80
|
+
if (!candidate || seen.has(candidate) || !isValidFocusShortcut(candidate)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
seen.add(candidate);
|
|
84
|
+
shortcuts.push(candidate as KeyId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return shortcuts.length > 0 ? shortcuts : [...BTW_DEFAULT_FOCUS_SHORTCUTS];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate a candidate against the pi-tui KeyId grammar: zero or more distinct recognized
|
|
92
|
+
* modifiers followed by exactly one base key (letter, digit, symbol, or named special key).
|
|
93
|
+
* Rejects typos like "cmd+/" or "control+x" and duplicate/empty segments.
|
|
94
|
+
*/
|
|
95
|
+
export function isValidFocusShortcut(candidate: string): boolean {
|
|
96
|
+
const segments = candidate.split("+");
|
|
97
|
+
const base = segments.pop();
|
|
98
|
+
if (base === undefined || !isValidFocusBaseKey(base)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
for (const segment of segments) {
|
|
104
|
+
if (!BTW_FOCUS_MODIFIERS.has(segment) || seen.has(segment)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
seen.add(segment);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isValidFocusBaseKey(base: string): boolean {
|
|
114
|
+
if (base.length === 1) {
|
|
115
|
+
return /[a-z0-9]/.test(base) || BTW_FOCUS_SYMBOL_KEYS.has(base);
|
|
116
|
+
}
|
|
117
|
+
return BTW_FOCUS_SPECIAL_KEYS.has(base);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatFocusShortcutLabel(shortcut: KeyId): string {
|
|
121
|
+
return shortcut
|
|
122
|
+
.split("+")
|
|
123
|
+
.map((segment) => {
|
|
124
|
+
switch (segment) {
|
|
125
|
+
case "ctrl":
|
|
126
|
+
return "Ctrl";
|
|
127
|
+
case "alt":
|
|
128
|
+
return "Alt";
|
|
129
|
+
case "shift":
|
|
130
|
+
return "Shift";
|
|
131
|
+
case "super":
|
|
132
|
+
return "Super";
|
|
133
|
+
default:
|
|
134
|
+
return segment.length === 1 ? segment.toUpperCase() : segment;
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
.join("+");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function describeFocusShortcuts(shortcuts: readonly KeyId[]): string {
|
|
141
|
+
const labels = shortcuts.map(formatFocusShortcutLabel);
|
|
142
|
+
if (labels.length <= 1) {
|
|
143
|
+
return labels[0] ?? "";
|
|
144
|
+
}
|
|
145
|
+
return `${labels.slice(0, -1).join(", ")} or ${labels[labels.length - 1]}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const BTW_FOCUS_SHORTCUTS: readonly KeyId[] = resolveBtwFocusShortcuts();
|
|
149
|
+
const BTW_FOCUS_SHORTCUTS_LABEL = describeFocusShortcuts(BTW_FOCUS_SHORTCUTS);
|
|
36
150
|
|
|
37
151
|
function matchesBtwFocusShortcut(data: string): boolean {
|
|
38
152
|
return BTW_FOCUS_SHORTCUTS.some((shortcut) => matchesKey(data, shortcut));
|
|
39
153
|
}
|
|
40
154
|
|
|
155
|
+
/** Toggles the overlay between framed "window" width and edge-to-edge "full" width. */
|
|
156
|
+
const BTW_WIDTH_TOGGLE_SHORTCUT: KeyId = Key.alt("w");
|
|
157
|
+
|
|
158
|
+
function matchesBtwWidthToggle(data: string): boolean {
|
|
159
|
+
return matchesKey(data, BTW_WIDTH_TOGGLE_SHORTCUT);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type BtwOverlayWidthMode = "window" | "full";
|
|
163
|
+
|
|
41
164
|
const BTW_SYSTEM_PROMPT = [
|
|
42
165
|
"You are having an aside conversation with the user, separate from their main working session.",
|
|
43
166
|
"If main session messages are provided, they are for context only — that work is being handled by another agent.",
|
|
@@ -53,7 +176,7 @@ const BTW_CONTINUE_THREAD_USER_TEXT = "[The following is a separate side convers
|
|
|
53
176
|
const BTW_CONTINUE_THREAD_ASSISTANT_TEXT = "Understood, continuing our side conversation.";
|
|
54
177
|
|
|
55
178
|
type SessionThinkingLevel = "off" | AiThinkingLevel;
|
|
56
|
-
type BtwThreadMode = "contextual" | "tangent";
|
|
179
|
+
type BtwThreadMode = "contextual" | "tangent" | "readonly";
|
|
57
180
|
type SessionModel = NonNullable<ExtensionCommandContext["model"]>;
|
|
58
181
|
/**
|
|
59
182
|
* Loose model reference parsed from `/btw:model <provider> <id> <api>` and persisted to
|
|
@@ -109,8 +232,10 @@ type ResolvedBtwSettings = {
|
|
|
109
232
|
fallbackReason?: string;
|
|
110
233
|
};
|
|
111
234
|
|
|
235
|
+
type BtwTurnOutcome = "completed" | "aborted" | "failed";
|
|
236
|
+
|
|
112
237
|
type BtwTranscriptEntry =
|
|
113
|
-
| { id: number; turnId: number; type: "turn-boundary"; phase: "start" | "end" }
|
|
238
|
+
| { id: number; turnId: number; type: "turn-boundary"; phase: "start" | "end"; outcome?: BtwTurnOutcome }
|
|
114
239
|
| { id: number; turnId: number; type: "user-message"; text: string }
|
|
115
240
|
| { id: number; turnId: number; type: "thinking"; text: string; streaming: boolean }
|
|
116
241
|
| { id: number; turnId: number; type: "assistant-text"; text: string; streaming: boolean }
|
|
@@ -143,6 +268,8 @@ type BtwSessionRuntime = {
|
|
|
143
268
|
mode: BtwThreadMode;
|
|
144
269
|
subscriptions: Set<() => void>;
|
|
145
270
|
sideThreadStartIndex: number;
|
|
271
|
+
abortPromise?: Promise<void>;
|
|
272
|
+
promptQueue: Promise<void>;
|
|
146
273
|
};
|
|
147
274
|
|
|
148
275
|
type OverlayRuntime = {
|
|
@@ -176,17 +303,73 @@ function createBtwResourceLoader(
|
|
|
176
303
|
const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
|
|
177
304
|
const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt());
|
|
178
305
|
|
|
179
|
-
|
|
306
|
+
const resourceLoader: ResourceLoader = {
|
|
180
307
|
getExtensions: () => extensionsResult,
|
|
181
308
|
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
182
309
|
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
183
310
|
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
184
311
|
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
185
312
|
getSystemPrompt: () => systemPrompt,
|
|
313
|
+
getSystemPromptSource: () => undefined,
|
|
186
314
|
getAppendSystemPrompt: () => appendSystemPrompt,
|
|
315
|
+
getAppendSystemPromptSources: () => [],
|
|
187
316
|
extendResources: () => {},
|
|
188
|
-
reload: async () => {},
|
|
317
|
+
reload: async (_options) => {},
|
|
189
318
|
};
|
|
319
|
+
|
|
320
|
+
return resourceLoader;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function createBtwModelRuntimeOptions(
|
|
324
|
+
ctx: ExtensionCommandContext,
|
|
325
|
+
model: SessionModel,
|
|
326
|
+
): Promise<Pick<CreateAgentSessionOptions, "modelRuntime">> {
|
|
327
|
+
const nativeProvider = ctx.modelRegistry.getRegisteredNativeProvider(model.provider);
|
|
328
|
+
const providerConfig = ctx.modelRegistry.getRegisteredProviderConfig(model.provider);
|
|
329
|
+
const hasRuntimeApiKey = ctx.modelRegistry.getProviderAuthStatus(model.provider).source === "runtime";
|
|
330
|
+
|
|
331
|
+
if (!nativeProvider && !providerConfig && !hasRuntimeApiKey) {
|
|
332
|
+
return {};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false });
|
|
336
|
+
if (nativeProvider) {
|
|
337
|
+
modelRuntime.registerNativeProvider(nativeProvider);
|
|
338
|
+
} else if (providerConfig) {
|
|
339
|
+
modelRuntime.registerProvider(model.provider, providerConfig);
|
|
340
|
+
}
|
|
341
|
+
await modelRuntime.refresh({ allowNetwork: false });
|
|
342
|
+
|
|
343
|
+
// --api-key is stored only in the parent runtime.
|
|
344
|
+
if (hasRuntimeApiKey) {
|
|
345
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
346
|
+
if (auth.ok && auth.apiKey) {
|
|
347
|
+
await modelRuntime.setRuntimeApiKey(model.provider, auth.apiKey);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { modelRuntime };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function hasResolvedAuthValues(values?: Record<string, string | null | undefined>): boolean {
|
|
355
|
+
return !!values && Object.values(values).some((value) => typeof value === "string" && value.length > 0);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function hasUsableModelAuth(
|
|
359
|
+
ctx: ExtensionCommandContext,
|
|
360
|
+
model: SessionModel,
|
|
361
|
+
auth: Awaited<ReturnType<ExtensionCommandContext["modelRegistry"]["getApiKeyAndHeaders"]>>,
|
|
362
|
+
): boolean {
|
|
363
|
+
if (!auth.ok) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return (
|
|
368
|
+
!!auth.apiKey ||
|
|
369
|
+
hasResolvedAuthValues(auth.headers) ||
|
|
370
|
+
hasResolvedAuthValues(auth.env) ||
|
|
371
|
+
ctx.modelRegistry.hasConfiguredAuth(model)
|
|
372
|
+
);
|
|
190
373
|
}
|
|
191
374
|
|
|
192
375
|
function extractText(parts: AssistantMessage["content"], type: "text" | "thinking"): string {
|
|
@@ -260,6 +443,17 @@ function formatModelRef(model: Pick<SessionModel, "provider" | "id" | "api">): s
|
|
|
260
443
|
return `${model.provider}/${model.id} (${model.api})`;
|
|
261
444
|
}
|
|
262
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Tool surfaces keyed by BTW mode. Read-only mode exposes only pi's built-in
|
|
448
|
+
* read-only tools so the child session cannot mutate the workspace; every other
|
|
449
|
+
* mode matches pi's default coding-agent toolset (read/bash/edit/write).
|
|
450
|
+
*/
|
|
451
|
+
const BTW_TOOLS_BY_MODE: Record<BtwThreadMode, readonly string[]> = {
|
|
452
|
+
contextual: ["read", "bash", "edit", "write"],
|
|
453
|
+
tangent: ["read", "bash", "edit", "write"],
|
|
454
|
+
readonly: ["read", "grep", "find", "ls"],
|
|
455
|
+
};
|
|
456
|
+
|
|
263
457
|
function buildBtwSeedState(
|
|
264
458
|
ctx: ExtensionCommandContext,
|
|
265
459
|
thread: BtwDetails[],
|
|
@@ -268,7 +462,7 @@ function buildBtwSeedState(
|
|
|
268
462
|
): { messages: Message[]; sideThreadStartIndex: number } {
|
|
269
463
|
const messages: Message[] = [];
|
|
270
464
|
|
|
271
|
-
if (mode === "contextual") {
|
|
465
|
+
if (mode === "contextual" || mode === "readonly") {
|
|
272
466
|
try {
|
|
273
467
|
messages.push(
|
|
274
468
|
...(buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages as Message[]).filter(
|
|
@@ -415,17 +609,29 @@ function ensureTranscriptTurn(state: BtwTranscriptState): number {
|
|
|
415
609
|
return turnId;
|
|
416
610
|
}
|
|
417
611
|
|
|
418
|
-
function finishTranscriptTurn(
|
|
612
|
+
function finishTranscriptTurn(
|
|
613
|
+
state: BtwTranscriptState,
|
|
614
|
+
turnId?: number | null,
|
|
615
|
+
outcome: BtwTurnOutcome = "completed",
|
|
616
|
+
): void {
|
|
419
617
|
const resolvedTurnId = turnId ?? state.currentTurnId;
|
|
420
618
|
if (resolvedTurnId === null || resolvedTurnId === undefined) {
|
|
421
619
|
return;
|
|
422
620
|
}
|
|
423
621
|
|
|
424
|
-
const
|
|
425
|
-
(entry)
|
|
622
|
+
const endBoundary = state.entries.find(
|
|
623
|
+
(entry): entry is Extract<BtwTranscriptEntry, { type: "turn-boundary" }> =>
|
|
624
|
+
entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end",
|
|
426
625
|
);
|
|
427
|
-
if (
|
|
428
|
-
|
|
626
|
+
if (endBoundary) {
|
|
627
|
+
endBoundary.outcome = outcome;
|
|
628
|
+
} else {
|
|
629
|
+
appendTranscriptEntry(state, {
|
|
630
|
+
type: "turn-boundary",
|
|
631
|
+
turnId: resolvedTurnId,
|
|
632
|
+
phase: "end",
|
|
633
|
+
outcome,
|
|
634
|
+
} as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
|
|
429
635
|
}
|
|
430
636
|
|
|
431
637
|
for (const entry of state.entries) {
|
|
@@ -444,26 +650,6 @@ function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null)
|
|
|
444
650
|
}
|
|
445
651
|
}
|
|
446
652
|
|
|
447
|
-
function removeTranscriptTurn(state: BtwTranscriptState, turnId: number | null): void {
|
|
448
|
-
if (turnId === null) {
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
state.entries = state.entries.filter((entry) => entry.turnId !== turnId);
|
|
453
|
-
for (const [toolCallId, toolCall] of state.toolCalls.entries()) {
|
|
454
|
-
if (toolCall.turnId === turnId) {
|
|
455
|
-
state.toolCalls.delete(toolCallId);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (state.currentTurnId === turnId) {
|
|
460
|
-
state.currentTurnId = null;
|
|
461
|
-
}
|
|
462
|
-
if (state.lastTurnId === turnId) {
|
|
463
|
-
state.lastTurnId = null;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
653
|
function findLatestTranscriptEntry<TType extends BtwTranscriptEntry["type"]>(
|
|
468
654
|
state: BtwTranscriptState,
|
|
469
655
|
turnId: number,
|
|
@@ -746,7 +932,10 @@ function applyTranscriptEvent(state: BtwTranscriptState, event: AgentSessionEven
|
|
|
746
932
|
return;
|
|
747
933
|
}
|
|
748
934
|
case "turn_end": {
|
|
749
|
-
|
|
935
|
+
const stopReason = event.message.role === "assistant" ? event.message.stopReason : "stop";
|
|
936
|
+
const outcome: BtwTurnOutcome =
|
|
937
|
+
stopReason === "aborted" ? "aborted" : stopReason === "error" ? "failed" : "completed";
|
|
938
|
+
finishTranscriptTurn(state, undefined, outcome);
|
|
750
939
|
return;
|
|
751
940
|
}
|
|
752
941
|
default:
|
|
@@ -767,7 +956,7 @@ function appendPersistedTranscriptTurn(state: BtwTranscriptState, details: BtwDe
|
|
|
767
956
|
function setTranscriptFailure(state: BtwTranscriptState, message: string): void {
|
|
768
957
|
const turnId = state.currentTurnId ?? state.lastTurnId ?? ensureTranscriptTurn(state);
|
|
769
958
|
upsertTranscriptTextEntry(state, turnId, "assistant-text", `❌ ${message}`, false);
|
|
770
|
-
finishTranscriptTurn(state, turnId);
|
|
959
|
+
finishTranscriptTurn(state, turnId, "failed");
|
|
771
960
|
}
|
|
772
961
|
|
|
773
962
|
function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
|
|
@@ -779,10 +968,26 @@ function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
|
|
|
779
968
|
}
|
|
780
969
|
|
|
781
970
|
function getCompletedExchangeCount(entries: BtwTranscript): number {
|
|
782
|
-
|
|
971
|
+
const completedTurnIds = new Set(
|
|
972
|
+
entries.flatMap((entry) =>
|
|
973
|
+
entry.type === "turn-boundary" &&
|
|
974
|
+
entry.phase === "end" &&
|
|
975
|
+
(entry.outcome === undefined || entry.outcome === "completed")
|
|
976
|
+
? [entry.turnId]
|
|
977
|
+
: [],
|
|
978
|
+
),
|
|
979
|
+
);
|
|
980
|
+
return entries.filter(
|
|
981
|
+
(entry) => entry.type === "assistant-text" && !entry.streaming && completedTurnIds.has(entry.turnId),
|
|
982
|
+
).length;
|
|
783
983
|
}
|
|
784
984
|
|
|
785
|
-
function buildOverlayTranscript(
|
|
985
|
+
function buildOverlayTranscript(
|
|
986
|
+
entries: BtwTranscript,
|
|
987
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
988
|
+
markdownTheme: MarkdownTheme,
|
|
989
|
+
contentWidth: number,
|
|
990
|
+
): string[] {
|
|
786
991
|
if (entries.length === 0) {
|
|
787
992
|
return [theme.fg("dim", "No BTW thread yet. Ask a side question to start one.")];
|
|
788
993
|
}
|
|
@@ -793,7 +998,7 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
|
|
|
793
998
|
const toolBadge = buildTranscriptBadge(theme, "Tool", "toolPendingBg", "warning");
|
|
794
999
|
const assistantBadge = buildTranscriptBadge(theme, "Assistant", "customMessageBg", "success");
|
|
795
1000
|
const separator = theme.fg("borderMuted", "────────────────────────────────────────");
|
|
796
|
-
const blockIndent =
|
|
1001
|
+
const blockIndent = BTW_BLOCK_INDENT;
|
|
797
1002
|
const resultIndent = blockIndent;
|
|
798
1003
|
|
|
799
1004
|
const pushBlankLine = () => {
|
|
@@ -854,9 +1059,17 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
|
|
|
854
1059
|
|
|
855
1060
|
if (entry.type === "thinking") {
|
|
856
1061
|
const thinkingHeader = entry.streaming ? `${thinkingBadge} ${theme.fg("warning", "▍")}` : thinkingBadge;
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1062
|
+
const markdownLines = new Markdown(entry.text, 0, 0, markdownTheme, {
|
|
1063
|
+
color: (text: string) => theme.fg("warning", text),
|
|
1064
|
+
italic: true,
|
|
1065
|
+
})
|
|
1066
|
+
.render(Math.max(1, contentWidth))
|
|
1067
|
+
.map((line) => line.replace(/\s+$/u, ""));
|
|
1068
|
+
pushBlankLine();
|
|
1069
|
+
lines.push(thinkingHeader);
|
|
1070
|
+
for (const line of markdownLines) {
|
|
1071
|
+
lines.push(line ? `${blockIndent}${line}` : "");
|
|
1072
|
+
}
|
|
860
1073
|
continue;
|
|
861
1074
|
}
|
|
862
1075
|
|
|
@@ -884,7 +1097,14 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
|
|
|
884
1097
|
|
|
885
1098
|
if (entry.type === "assistant-text") {
|
|
886
1099
|
const assistantHeader = entry.streaming ? `${assistantBadge} ${theme.fg("warning", "▍")}` : assistantBadge;
|
|
887
|
-
|
|
1100
|
+
const markdownLines = new Markdown(entry.text, 0, 0, markdownTheme)
|
|
1101
|
+
.render(Math.max(1, contentWidth))
|
|
1102
|
+
.map((line) => line.replace(/\s+$/u, ""));
|
|
1103
|
+
pushBlankLine();
|
|
1104
|
+
lines.push(assistantHeader);
|
|
1105
|
+
for (const line of markdownLines) {
|
|
1106
|
+
lines.push(line ? `${blockIndent}${line}` : "");
|
|
1107
|
+
}
|
|
888
1108
|
}
|
|
889
1109
|
}
|
|
890
1110
|
|
|
@@ -908,7 +1128,7 @@ type BtwHandoffExchange = {
|
|
|
908
1128
|
};
|
|
909
1129
|
|
|
910
1130
|
function buildBtwMessageContent(question: string, answer: string): string {
|
|
911
|
-
return
|
|
1131
|
+
return `**Question**\n\n${question}\n\n**Answer**\n\n${answer}`;
|
|
912
1132
|
}
|
|
913
1133
|
|
|
914
1134
|
function formatThread(thread: BtwHandoffExchange[]): string {
|
|
@@ -932,18 +1152,18 @@ function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffE
|
|
|
932
1152
|
const exchanges: BtwHandoffExchange[] = [];
|
|
933
1153
|
let currentUser = "";
|
|
934
1154
|
let currentAssistant = "";
|
|
1155
|
+
let excludeCurrent = false;
|
|
935
1156
|
|
|
936
1157
|
const pushCurrent = () => {
|
|
937
|
-
if (!
|
|
938
|
-
|
|
1158
|
+
if (!excludeCurrent && (currentUser || currentAssistant)) {
|
|
1159
|
+
exchanges.push({
|
|
1160
|
+
user: currentUser.trim() || "(No user prompt)",
|
|
1161
|
+
assistant: currentAssistant.trim() || "(No assistant response)",
|
|
1162
|
+
});
|
|
939
1163
|
}
|
|
940
|
-
|
|
941
|
-
exchanges.push({
|
|
942
|
-
user: currentUser.trim() || "(No user prompt)",
|
|
943
|
-
assistant: currentAssistant.trim() || "(No assistant response)",
|
|
944
|
-
});
|
|
945
1164
|
currentUser = "";
|
|
946
1165
|
currentAssistant = "";
|
|
1166
|
+
excludeCurrent = false;
|
|
947
1167
|
};
|
|
948
1168
|
|
|
949
1169
|
for (const message of threadMessages) {
|
|
@@ -951,18 +1171,25 @@ function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffE
|
|
|
951
1171
|
continue;
|
|
952
1172
|
}
|
|
953
1173
|
|
|
954
|
-
const text = extractMessageText(message).trim();
|
|
955
|
-
if (!text) {
|
|
956
|
-
continue;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
1174
|
if (message.role === "user") {
|
|
1175
|
+
const text = extractMessageText(message).trim();
|
|
1176
|
+
if (!text) {
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
960
1179
|
pushCurrent();
|
|
961
1180
|
currentUser = text;
|
|
962
1181
|
continue;
|
|
963
1182
|
}
|
|
964
1183
|
|
|
965
|
-
|
|
1184
|
+
if (message.stopReason === "aborted" || message.stopReason === "error") {
|
|
1185
|
+
excludeCurrent = true;
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const text = extractMessageText(message).trim();
|
|
1190
|
+
if (text) {
|
|
1191
|
+
currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text;
|
|
1192
|
+
}
|
|
966
1193
|
}
|
|
967
1194
|
|
|
968
1195
|
pushCurrent();
|
|
@@ -995,6 +1222,17 @@ function saveVisibleBtwNote(
|
|
|
995
1222
|
return "saved";
|
|
996
1223
|
}
|
|
997
1224
|
|
|
1225
|
+
function canRenderBtwOverlay(ctx: ExtensionContext | ExtensionCommandContext): boolean {
|
|
1226
|
+
return ctx.hasUI && ctx.mode === "tui";
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function notifyInlineQuestionRequired(
|
|
1230
|
+
ctx: ExtensionCommandContext,
|
|
1231
|
+
command: "/btw" | "/btw:tangent" | "/btw:new" | "/btw:ask",
|
|
1232
|
+
): void {
|
|
1233
|
+
notify(ctx, `${command} cannot open its composer outside Pi's TUI. Pass the question inline instead.`, "warning");
|
|
1234
|
+
}
|
|
1235
|
+
|
|
998
1236
|
function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
|
|
999
1237
|
if (ctx.hasUI) {
|
|
1000
1238
|
ctx.ui.notify(message, level);
|
|
@@ -1003,9 +1241,17 @@ function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string
|
|
|
1003
1241
|
|
|
1004
1242
|
/** Fixed overlay rows outside the transcript viewport (must match render() structure). */
|
|
1005
1243
|
const BTW_OVERLAY_CHROME_LINES = 9;
|
|
1244
|
+
/** Indent applied to transcript block bodies. */
|
|
1245
|
+
const BTW_BLOCK_INDENT = " ";
|
|
1006
1246
|
|
|
1007
1247
|
function getOverlayTitle(mode: BtwThreadMode): string {
|
|
1008
|
-
|
|
1248
|
+
if (mode === "tangent") {
|
|
1249
|
+
return "BTW tangent";
|
|
1250
|
+
}
|
|
1251
|
+
if (mode === "readonly") {
|
|
1252
|
+
return "BTW ask · read-only";
|
|
1253
|
+
}
|
|
1254
|
+
return "BTW";
|
|
1009
1255
|
}
|
|
1010
1256
|
|
|
1011
1257
|
function buildTranscriptBadge(
|
|
@@ -1027,14 +1273,19 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1027
1273
|
private readonly readTranscriptEntries: () => BtwTranscript;
|
|
1028
1274
|
private readonly getStatus: () => string | null;
|
|
1029
1275
|
private readonly getMode: () => BtwThreadMode;
|
|
1276
|
+
private readonly getWidthMode: () => BtwOverlayWidthMode;
|
|
1030
1277
|
private readonly onSubmitCallback: (value: string) => void;
|
|
1031
1278
|
private readonly onDismissCallback: () => void;
|
|
1032
1279
|
private readonly onUnfocusCallback: () => void;
|
|
1280
|
+
private readonly onToggleWidthCallback: () => void;
|
|
1033
1281
|
private readonly tui: TUI;
|
|
1034
1282
|
private readonly theme: ExtensionContext["ui"]["theme"];
|
|
1283
|
+
private readonly markdownTheme: MarkdownTheme;
|
|
1284
|
+
private readonly managesMouseReporting: boolean;
|
|
1035
1285
|
private transcriptLines: string[] = [];
|
|
1036
1286
|
private transcriptScrollOffset = 0;
|
|
1037
1287
|
private transcriptViewportHeight = 8;
|
|
1288
|
+
private contentWidth = 66;
|
|
1038
1289
|
private followTranscript = true;
|
|
1039
1290
|
private _focused = false;
|
|
1040
1291
|
private modeTextValue = "";
|
|
@@ -1058,19 +1309,27 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1058
1309
|
readTranscriptEntries: () => BtwTranscript,
|
|
1059
1310
|
getStatus: () => string | null,
|
|
1060
1311
|
getMode: () => BtwThreadMode,
|
|
1312
|
+
getWidthMode: () => BtwOverlayWidthMode,
|
|
1061
1313
|
onSubmit: (value: string) => void,
|
|
1062
1314
|
onDismiss: () => void,
|
|
1063
1315
|
onUnfocus: () => void,
|
|
1316
|
+
onToggleWidth: () => void,
|
|
1064
1317
|
) {
|
|
1065
1318
|
super();
|
|
1066
1319
|
this.tui = tui;
|
|
1067
1320
|
this.theme = theme;
|
|
1321
|
+
this.markdownTheme = getMarkdownTheme();
|
|
1322
|
+
// Fullscreen Pi owns mouse reporting for the entire terminal session. In
|
|
1323
|
+
// regular mode BTW manages it while the overlay exists.
|
|
1324
|
+
this.managesMouseReporting = tui.mode !== "fullscreen";
|
|
1068
1325
|
this.readTranscriptEntries = readTranscriptEntries;
|
|
1069
1326
|
this.getStatus = getStatus;
|
|
1070
1327
|
this.getMode = getMode;
|
|
1328
|
+
this.getWidthMode = getWidthMode;
|
|
1071
1329
|
this.onSubmitCallback = onSubmit;
|
|
1072
1330
|
this.onDismissCallback = onDismiss;
|
|
1073
1331
|
this.onUnfocusCallback = onUnfocus;
|
|
1332
|
+
this.onToggleWidthCallback = onToggleWidth;
|
|
1074
1333
|
|
|
1075
1334
|
this.modeText = new Text("", 1, 0);
|
|
1076
1335
|
this.summaryText = new Text("", 1, 0);
|
|
@@ -1088,8 +1347,9 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1088
1347
|
|
|
1089
1348
|
this.hintsText = new Text("", 1, 0);
|
|
1090
1349
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1350
|
+
if (this.managesMouseReporting) {
|
|
1351
|
+
this.tui.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
|
|
1352
|
+
}
|
|
1093
1353
|
|
|
1094
1354
|
const originalHandleInput = this.input.handleInput.bind(this.input);
|
|
1095
1355
|
this.input.handleInput = (data: string) => {
|
|
@@ -1114,17 +1374,33 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1114
1374
|
this.refresh();
|
|
1115
1375
|
}
|
|
1116
1376
|
|
|
1377
|
+
private get borderless(): boolean {
|
|
1378
|
+
// Full-width mode drops the vertical bars and corner glyphs so a terminal
|
|
1379
|
+
// Shift+drag selection captures only the dialog's own text — with side
|
|
1380
|
+
// borders, the leftmost/rightmost columns would land inside the drag.
|
|
1381
|
+
return this.getWidthMode() === "full";
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1117
1384
|
private frameLine(content: string, innerWidth: number): string {
|
|
1118
1385
|
const truncated = truncateToWidth(content, innerWidth, "");
|
|
1119
1386
|
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
1387
|
+
if (this.borderless) {
|
|
1388
|
+
return `${truncated}${" ".repeat(padding)}`;
|
|
1389
|
+
}
|
|
1120
1390
|
return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
|
|
1121
1391
|
}
|
|
1122
1392
|
|
|
1123
1393
|
private ruleLine(innerWidth: number): string {
|
|
1394
|
+
if (this.borderless) {
|
|
1395
|
+
return this.theme.fg("border", "─".repeat(innerWidth));
|
|
1396
|
+
}
|
|
1124
1397
|
return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
|
|
1125
1398
|
}
|
|
1126
1399
|
|
|
1127
1400
|
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
1401
|
+
if (this.borderless) {
|
|
1402
|
+
return this.theme.fg("border", "─".repeat(innerWidth));
|
|
1403
|
+
}
|
|
1128
1404
|
const left = edge === "top" ? "┌" : "└";
|
|
1129
1405
|
const right = edge === "top" ? "┐" : "┘";
|
|
1130
1406
|
return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
@@ -1156,7 +1432,9 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1156
1432
|
}
|
|
1157
1433
|
|
|
1158
1434
|
dispose(): void {
|
|
1159
|
-
this.
|
|
1435
|
+
if (this.managesMouseReporting) {
|
|
1436
|
+
this.tui.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
|
|
1437
|
+
}
|
|
1160
1438
|
}
|
|
1161
1439
|
|
|
1162
1440
|
private getMouseScrollDelta(data: string): number | null {
|
|
@@ -1179,6 +1457,11 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1179
1457
|
return;
|
|
1180
1458
|
}
|
|
1181
1459
|
|
|
1460
|
+
if (matchesBtwWidthToggle(data)) {
|
|
1461
|
+
this.onToggleWidthCallback();
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1182
1465
|
const mouseScrollDelta = this.getMouseScrollDelta(data);
|
|
1183
1466
|
if (mouseScrollDelta !== null) {
|
|
1184
1467
|
this.scrollTranscript(mouseScrollDelta);
|
|
@@ -1201,7 +1484,8 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1201
1484
|
}
|
|
1202
1485
|
|
|
1203
1486
|
private inputFrameLine(dialogWidth: number): string {
|
|
1204
|
-
const
|
|
1487
|
+
const borderColumns = this.borderless ? 0 : 2;
|
|
1488
|
+
const targetWidth = Math.max(1, dialogWidth - borderColumns);
|
|
1205
1489
|
const previousFocused = this.input.focused;
|
|
1206
1490
|
// Input.render() emits CURSOR_MARKER when focused. In overlay mode that APC marker
|
|
1207
1491
|
// can skew width/composition on this one row before the TUI strips it, producing a
|
|
@@ -1212,6 +1496,9 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1212
1496
|
const renderedInputLine = this.input.render(targetWidth)[0] ?? "";
|
|
1213
1497
|
const inputLine = truncateToWidth(renderedInputLine, targetWidth, "");
|
|
1214
1498
|
const padding = Math.max(0, targetWidth - visibleWidth(inputLine));
|
|
1499
|
+
if (this.borderless) {
|
|
1500
|
+
return `${inputLine}${" ".repeat(padding)}`;
|
|
1501
|
+
}
|
|
1215
1502
|
return `${this.theme.fg("border", "│")}${inputLine}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
|
|
1216
1503
|
} finally {
|
|
1217
1504
|
this.input.focused = previousFocused;
|
|
@@ -1224,7 +1511,13 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1224
1511
|
|
|
1225
1512
|
override render(width: number): string[] {
|
|
1226
1513
|
const dialogWidth = Math.max(24, width);
|
|
1227
|
-
const
|
|
1514
|
+
const borderColumns = this.borderless ? 0 : 2;
|
|
1515
|
+
const innerWidth = Math.max(22, dialogWidth - borderColumns);
|
|
1516
|
+
const contentWidth = Math.max(1, innerWidth - BTW_BLOCK_INDENT.length);
|
|
1517
|
+
if (contentWidth !== this.contentWidth) {
|
|
1518
|
+
this.contentWidth = contentWidth;
|
|
1519
|
+
this.rebuildTranscriptLines();
|
|
1520
|
+
}
|
|
1228
1521
|
const transcriptLines = this.wrapTranscript(innerWidth);
|
|
1229
1522
|
const dialogHeight = this.getDialogHeight();
|
|
1230
1523
|
const chromeHeight = BTW_OVERLAY_CHROME_LINES;
|
|
@@ -1288,6 +1581,15 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1288
1581
|
return this.readTranscriptEntries().map((entry) => ({ ...entry }));
|
|
1289
1582
|
}
|
|
1290
1583
|
|
|
1584
|
+
private rebuildTranscriptLines(): void {
|
|
1585
|
+
this.transcriptLines = buildOverlayTranscript(
|
|
1586
|
+
this.readTranscriptEntries(),
|
|
1587
|
+
this.theme,
|
|
1588
|
+
this.markdownTheme,
|
|
1589
|
+
this.contentWidth,
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1291
1593
|
refresh(): void {
|
|
1292
1594
|
this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`;
|
|
1293
1595
|
this.modeText.setText(this.modeTextValue);
|
|
@@ -1297,7 +1599,7 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1297
1599
|
this.summaryTextValue = `${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`;
|
|
1298
1600
|
this.summaryText.setText(this.summaryTextValue);
|
|
1299
1601
|
|
|
1300
|
-
this.
|
|
1602
|
+
this.rebuildTranscriptLines();
|
|
1301
1603
|
this.transcript.clear();
|
|
1302
1604
|
for (const line of this.transcriptLines) {
|
|
1303
1605
|
this.transcript.addChild(new Text(line, 1, 0));
|
|
@@ -1306,7 +1608,7 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1306
1608
|
const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
|
|
1307
1609
|
this.statusTextValue = status;
|
|
1308
1610
|
this.statusText.setText(this.statusTextValue);
|
|
1309
|
-
this.hintsTextValue =
|
|
1611
|
+
this.hintsTextValue = `Scroll wheel ↑↓ PgUp/PgDn · Enter · ${BTW_FOCUS_SHORTCUTS_LABEL} focus · Alt+w width · Esc`;
|
|
1310
1612
|
this.hintsText.setText(this.hintsTextValue);
|
|
1311
1613
|
this.tui.requestRender();
|
|
1312
1614
|
}
|
|
@@ -1320,9 +1622,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1320
1622
|
let transcriptState = createEmptyTranscriptState();
|
|
1321
1623
|
let overlayStatus: string | null = null;
|
|
1322
1624
|
let overlayDraft = "";
|
|
1625
|
+
let overlayWidthMode: BtwOverlayWidthMode = "window";
|
|
1323
1626
|
let overlayRuntime: OverlayRuntime | null = null;
|
|
1324
1627
|
let lastUiContext: ExtensionContext | ExtensionCommandContext | null = null;
|
|
1325
1628
|
let activeBtwSession: BtwSessionRuntime | null = null;
|
|
1629
|
+
let btwLifecycleGeneration = 0;
|
|
1630
|
+
let btwSubmissionQueue = Promise.resolve();
|
|
1631
|
+
|
|
1632
|
+
function invalidateBtwLifecycle(): void {
|
|
1633
|
+
btwLifecycleGeneration += 1;
|
|
1634
|
+
}
|
|
1326
1635
|
|
|
1327
1636
|
function syncUi(ctx?: ExtensionContext | ExtensionCommandContext): void {
|
|
1328
1637
|
const activeCtx = ctx ?? lastUiContext;
|
|
@@ -1373,6 +1682,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
1373
1682
|
overlayRuntime?.refresh?.();
|
|
1374
1683
|
}
|
|
1375
1684
|
|
|
1685
|
+
function getOverlayOptions(): OverlayOptions {
|
|
1686
|
+
const base: OverlayOptions = {
|
|
1687
|
+
minWidth: 72,
|
|
1688
|
+
maxHeight: "78%",
|
|
1689
|
+
anchor: "top-center",
|
|
1690
|
+
nonCapturing: true,
|
|
1691
|
+
};
|
|
1692
|
+
if (overlayWidthMode === "full") {
|
|
1693
|
+
// Edge-to-edge so a terminal Shift+drag selection captures only the
|
|
1694
|
+
// dialog's own text — nothing from the main screen sits beside it.
|
|
1695
|
+
return { ...base, width: "100%", margin: { top: 1 } };
|
|
1696
|
+
}
|
|
1697
|
+
// Framed "window" look: narrower, inset from the terminal edges.
|
|
1698
|
+
return { ...base, width: "78%", margin: { top: 1, left: 2, right: 2 } };
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
async function toggleOverlayWidth(ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
|
|
1702
|
+
overlayWidthMode = overlayWidthMode === "window" ? "full" : "window";
|
|
1703
|
+
|
|
1704
|
+
// overlayOptions is resolved once at showOverlay time, so a width change
|
|
1705
|
+
// requires tearing down and re-opening the overlay. The close path persists
|
|
1706
|
+
// the draft into overlayDraft, and ensureOverlay restores it on reopen.
|
|
1707
|
+
const wasFocused = overlayRuntime?.handle?.isFocused() ?? true;
|
|
1708
|
+
dismissOverlay();
|
|
1709
|
+
await ensureOverlay(ctx);
|
|
1710
|
+
if (!wasFocused) {
|
|
1711
|
+
overlayRuntime?.handle?.unfocus();
|
|
1712
|
+
overlayRuntime?.refresh?.();
|
|
1713
|
+
}
|
|
1714
|
+
setOverlayStatus(
|
|
1715
|
+
overlayWidthMode === "full"
|
|
1716
|
+
? "Full-width mode. Shift+drag now selects only the dialog. Alt+w to restore the window."
|
|
1717
|
+
: "Window mode. Alt+w switches to full-width for clean copy selection.",
|
|
1718
|
+
ctx,
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1376
1722
|
function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void {
|
|
1377
1723
|
if (!sessionRuntime.subscriptions.delete(unsubscribe)) {
|
|
1378
1724
|
return;
|
|
@@ -1439,6 +1785,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1439
1785
|
sessionRuntime.subscriptions.add(unsubscribe);
|
|
1440
1786
|
}
|
|
1441
1787
|
|
|
1788
|
+
function requestBtwSessionAbort(sessionRuntime: BtwSessionRuntime): Promise<void> {
|
|
1789
|
+
sessionRuntime.abortPromise ??= Promise.resolve()
|
|
1790
|
+
.then(() => sessionRuntime.session.abort())
|
|
1791
|
+
.catch(() => {
|
|
1792
|
+
// Ignore abort errors during BTW cancellation/replacement/shutdown.
|
|
1793
|
+
});
|
|
1794
|
+
return sessionRuntime.abortPromise;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1442
1797
|
async function disposeBtwSession(): Promise<void> {
|
|
1443
1798
|
const current = activeBtwSession;
|
|
1444
1799
|
activeBtwSession = null;
|
|
@@ -1447,28 +1802,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
1447
1802
|
}
|
|
1448
1803
|
|
|
1449
1804
|
clearBtwSessionSubscriptions(current);
|
|
1450
|
-
|
|
1451
|
-
try {
|
|
1452
|
-
await current.session.abort();
|
|
1453
|
-
} catch {
|
|
1454
|
-
// Ignore abort errors during BTW session replacement/shutdown.
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1805
|
+
await requestBtwSessionAbort(current);
|
|
1457
1806
|
current.session.dispose();
|
|
1458
1807
|
}
|
|
1459
1808
|
|
|
1460
1809
|
async function dismissOverlaySession(): Promise<void> {
|
|
1810
|
+
invalidateBtwLifecycle();
|
|
1461
1811
|
dismissOverlay();
|
|
1462
1812
|
await disposeBtwSession();
|
|
1463
1813
|
}
|
|
1464
1814
|
|
|
1815
|
+
/**
|
|
1816
|
+
* Escape behaves differently depending on whether the BTW side session is
|
|
1817
|
+
* currently doing work:
|
|
1818
|
+
*
|
|
1819
|
+
* - streaming: the first Escape aborts the in-flight request but keeps the
|
|
1820
|
+
* overlay open (so the partial transcript stays readable and the thread
|
|
1821
|
+
* remains usable). A second Escape dismisses, even while cancellation settles.
|
|
1822
|
+
* - idle: Escape dismisses the overlay immediately (previous behavior).
|
|
1823
|
+
*/
|
|
1824
|
+
async function dismissOrAbortOverlaySession(): Promise<void> {
|
|
1825
|
+
const sessionRuntime = activeBtwSession;
|
|
1826
|
+
if (sessionRuntime?.session.isStreaming && !sessionRuntime.abortPromise) {
|
|
1827
|
+
setOverlayStatus("⏹ Aborting. Press Esc again to dismiss the BTW overlay.");
|
|
1828
|
+
await requestBtwSessionAbort(sessionRuntime);
|
|
1829
|
+
if (activeBtwSession === sessionRuntime && overlayRuntime) {
|
|
1830
|
+
setOverlayStatus("⏹ Aborted. Press Esc again to dismiss the BTW overlay.");
|
|
1831
|
+
}
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
await dismissOverlaySession();
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1465
1837
|
async function resolveBtwModel(
|
|
1466
1838
|
ctx: ExtensionCommandContext,
|
|
1467
1839
|
notifyOnFallback = false,
|
|
1468
1840
|
): Promise<ResolvedBtwModel> {
|
|
1469
1841
|
if (btwModelOverride) {
|
|
1470
1842
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(btwModelOverride);
|
|
1471
|
-
if (
|
|
1843
|
+
if (hasUsableModelAuth(ctx, btwModelOverride, auth)) {
|
|
1472
1844
|
return {
|
|
1473
1845
|
model: btwModelOverride,
|
|
1474
1846
|
source: "override",
|
|
@@ -1559,6 +1931,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1559
1931
|
}
|
|
1560
1932
|
|
|
1561
1933
|
async function setBtwModelOverride(ctx: ExtensionCommandContext, nextModel: SessionModel | null): Promise<void> {
|
|
1934
|
+
invalidateBtwLifecycle();
|
|
1562
1935
|
btwModelOverride = nextModel;
|
|
1563
1936
|
const details: BtwModelOverrideDetails = nextModel
|
|
1564
1937
|
? { action: "set", timestamp: Date.now(), provider: nextModel.provider, id: nextModel.id, api: nextModel.api }
|
|
@@ -1577,6 +1950,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1577
1950
|
ctx: ExtensionCommandContext,
|
|
1578
1951
|
nextThinkingLevel: SessionThinkingLevel | null,
|
|
1579
1952
|
): Promise<void> {
|
|
1953
|
+
invalidateBtwLifecycle();
|
|
1580
1954
|
btwThinkingOverride = nextThinkingLevel;
|
|
1581
1955
|
const details: BtwThinkingOverrideDetails = nextThinkingLevel
|
|
1582
1956
|
? { action: "set", timestamp: Date.now(), thinkingLevel: nextThinkingLevel }
|
|
@@ -1591,32 +1965,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
1591
1965
|
notify(ctx, `${message} ${describeResolvedThinking(settings)}`, "info");
|
|
1592
1966
|
}
|
|
1593
1967
|
|
|
1594
|
-
async function createBtwSubSession(
|
|
1595
|
-
|
|
1968
|
+
async function createBtwSubSession(
|
|
1969
|
+
ctx: ExtensionCommandContext,
|
|
1970
|
+
mode: BtwThreadMode,
|
|
1971
|
+
settings: ResolvedBtwSettings,
|
|
1972
|
+
): Promise<BtwSessionRuntime> {
|
|
1596
1973
|
if (!settings.model) {
|
|
1597
1974
|
throw new Error(settings.fallbackReason || "No active model selected.");
|
|
1598
1975
|
}
|
|
1599
1976
|
|
|
1600
|
-
const
|
|
1977
|
+
const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, settings.model);
|
|
1978
|
+
|
|
1979
|
+
const sessionOptions: CreateAgentSessionOptions = {
|
|
1601
1980
|
sessionManager: SessionManager.inMemory(),
|
|
1602
1981
|
model: settings.model,
|
|
1603
|
-
|
|
1982
|
+
...modelRuntimeOptions,
|
|
1604
1983
|
thinkingLevel: settings.thinkingLevel,
|
|
1605
|
-
//
|
|
1606
|
-
tools: [
|
|
1984
|
+
// Read-only mode narrows this to pi's built-in read-only toolset.
|
|
1985
|
+
tools: [...BTW_TOOLS_BY_MODE[mode]],
|
|
1607
1986
|
resourceLoader: createBtwResourceLoader(ctx),
|
|
1608
|
-
}
|
|
1987
|
+
};
|
|
1988
|
+
const { session } = await createAgentSession(sessionOptions);
|
|
1609
1989
|
|
|
1610
1990
|
const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode, settings.model);
|
|
1611
1991
|
if (seedMessages.length > 0) {
|
|
1612
1992
|
session.agent.state.messages = seedMessages as typeof session.state.messages;
|
|
1613
1993
|
}
|
|
1614
1994
|
|
|
1615
|
-
return { session, mode, subscriptions: new Set(), sideThreadStartIndex };
|
|
1995
|
+
return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve() };
|
|
1616
1996
|
}
|
|
1617
1997
|
|
|
1618
1998
|
async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
|
|
1619
|
-
const settings = await resolveBtwSettings(ctx);
|
|
1999
|
+
const settings = await resolveBtwSettings(ctx, true);
|
|
1620
2000
|
if (!settings.model) {
|
|
1621
2001
|
return null;
|
|
1622
2002
|
}
|
|
@@ -1626,12 +2006,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1626
2006
|
}
|
|
1627
2007
|
|
|
1628
2008
|
await disposeBtwSession();
|
|
1629
|
-
activeBtwSession = await createBtwSubSession(ctx, mode);
|
|
2009
|
+
activeBtwSession = await createBtwSubSession(ctx, mode, settings);
|
|
1630
2010
|
return activeBtwSession;
|
|
1631
2011
|
}
|
|
1632
2012
|
|
|
1633
2013
|
async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
|
|
1634
|
-
if (!ctx
|
|
2014
|
+
if (!canRenderBtwOverlay(ctx)) {
|
|
1635
2015
|
return;
|
|
1636
2016
|
}
|
|
1637
2017
|
lastUiContext = ctx;
|
|
@@ -1651,7 +2031,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1651
2031
|
if (activeBtwSession) {
|
|
1652
2032
|
clearBtwSessionSubscriptions(activeBtwSession);
|
|
1653
2033
|
}
|
|
1654
|
-
runtime.handle?.hide();
|
|
1655
2034
|
if (overlayRuntime === runtime) {
|
|
1656
2035
|
overlayRuntime = null;
|
|
1657
2036
|
}
|
|
@@ -1675,16 +2054,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
1675
2054
|
() => transcriptState.entries,
|
|
1676
2055
|
() => overlayStatus,
|
|
1677
2056
|
() => pendingMode,
|
|
2057
|
+
() => overlayWidthMode,
|
|
1678
2058
|
(value) => {
|
|
1679
2059
|
void submitFromOverlay(ctx, value);
|
|
1680
2060
|
},
|
|
1681
2061
|
() => {
|
|
1682
|
-
void
|
|
2062
|
+
void dismissOrAbortOverlaySession();
|
|
1683
2063
|
},
|
|
1684
2064
|
() => {
|
|
1685
2065
|
overlayRuntime?.handle?.unfocus();
|
|
1686
2066
|
overlayRuntime?.refresh?.();
|
|
1687
2067
|
},
|
|
2068
|
+
() => {
|
|
2069
|
+
void toggleOverlayWidth(ctx);
|
|
2070
|
+
},
|
|
1688
2071
|
);
|
|
1689
2072
|
|
|
1690
2073
|
overlay.focused = runtime.handle?.isFocused() ?? true;
|
|
@@ -1698,7 +2081,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1698
2081
|
};
|
|
1699
2082
|
runtime.close = () => {
|
|
1700
2083
|
overlayDraft = overlay.getDraft();
|
|
1701
|
-
overlay.dispose();
|
|
1702
2084
|
closeRuntime();
|
|
1703
2085
|
};
|
|
1704
2086
|
|
|
@@ -1712,14 +2094,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1712
2094
|
},
|
|
1713
2095
|
{
|
|
1714
2096
|
overlay: true,
|
|
1715
|
-
overlayOptions:
|
|
1716
|
-
width: "78%",
|
|
1717
|
-
minWidth: 72,
|
|
1718
|
-
maxHeight: "78%",
|
|
1719
|
-
anchor: "top-center",
|
|
1720
|
-
margin: { top: 1, left: 2, right: 2 },
|
|
1721
|
-
nonCapturing: true,
|
|
1722
|
-
},
|
|
2097
|
+
overlayOptions: getOverlayOptions(),
|
|
1723
2098
|
onHandle: (handle) => {
|
|
1724
2099
|
runtime.handle = handle;
|
|
1725
2100
|
handle.focus();
|
|
@@ -1743,6 +2118,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1743
2118
|
if (name === "btw") {
|
|
1744
2119
|
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
1745
2120
|
if (!question) {
|
|
2121
|
+
if (!canRenderBtwOverlay(ctx)) {
|
|
2122
|
+
notifyInlineQuestionRequired(ctx, "/btw");
|
|
2123
|
+
return true;
|
|
2124
|
+
}
|
|
1746
2125
|
await ensureBtwSession(ctx, pendingMode);
|
|
1747
2126
|
await ensureOverlay(ctx);
|
|
1748
2127
|
return true;
|
|
@@ -1758,6 +2137,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1758
2137
|
|
|
1759
2138
|
if (name === "btw:tangent") {
|
|
1760
2139
|
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
2140
|
+
if (!question && !canRenderBtwOverlay(ctx)) {
|
|
2141
|
+
notifyInlineQuestionRequired(ctx, "/btw:tangent");
|
|
2142
|
+
return true;
|
|
2143
|
+
}
|
|
1761
2144
|
if (pendingMode !== "tangent") {
|
|
1762
2145
|
await resetThread(ctx, true, "tangent");
|
|
1763
2146
|
}
|
|
@@ -1772,9 +2155,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
1772
2155
|
return true;
|
|
1773
2156
|
}
|
|
1774
2157
|
|
|
2158
|
+
if (name === "btw:ask") {
|
|
2159
|
+
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
2160
|
+
if (!question && !canRenderBtwOverlay(ctx)) {
|
|
2161
|
+
notifyInlineQuestionRequired(ctx, "/btw:ask");
|
|
2162
|
+
return true;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// Read-only mode is a distinct capability boundary, so switching into it
|
|
2166
|
+
// resets the thread and lets ensureBtwSession recreate the child session.
|
|
2167
|
+
if (pendingMode !== "readonly") {
|
|
2168
|
+
await resetThread(ctx, true, "readonly");
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
if (!question) {
|
|
2172
|
+
await ensureBtwSession(ctx, "readonly");
|
|
2173
|
+
await ensureOverlay(ctx);
|
|
2174
|
+
return true;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
await runBtw(ctx, question, save, "readonly");
|
|
2178
|
+
return true;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
1775
2181
|
if (name === "btw:new") {
|
|
1776
|
-
await resetThread(ctx, true, "contextual");
|
|
1777
2182
|
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
2183
|
+
if (!question && !canRenderBtwOverlay(ctx)) {
|
|
2184
|
+
notifyInlineQuestionRequired(ctx, "/btw:new");
|
|
2185
|
+
return true;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
await resetThread(ctx, true, "contextual");
|
|
1778
2189
|
if (question) {
|
|
1779
2190
|
await runBtw(ctx, question, save, "contextual");
|
|
1780
2191
|
} else {
|
|
@@ -1840,6 +2251,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1840
2251
|
}
|
|
1841
2252
|
|
|
1842
2253
|
if (name === "btw:inject") {
|
|
2254
|
+
await btwSubmissionQueue;
|
|
1843
2255
|
if (pendingThread.length === 0) {
|
|
1844
2256
|
notify(ctx, "No BTW thread to inject.", "warning");
|
|
1845
2257
|
return true;
|
|
@@ -1868,6 +2280,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1868
2280
|
}
|
|
1869
2281
|
|
|
1870
2282
|
if (name === "btw:summarize") {
|
|
2283
|
+
await btwSubmissionQueue;
|
|
1871
2284
|
if (pendingThread.length === 0) {
|
|
1872
2285
|
notify(ctx, "No BTW thread to summarize.", "warning");
|
|
1873
2286
|
return true;
|
|
@@ -1901,7 +2314,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1901
2314
|
|
|
1902
2315
|
function parseOverlayBtwCommand(value: string): { name: string; args: string } | null {
|
|
1903
2316
|
const trimmed = value.trim();
|
|
1904
|
-
const match = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize|model|thinking))(?:\s+(.*))?$/);
|
|
2317
|
+
const match = trimmed.match(/^\/(btw:(?:new|ask|tangent|clear|inject|summarize|model|thinking))(?:\s+(.*))?$/);
|
|
1905
2318
|
if (!match) {
|
|
1906
2319
|
return null;
|
|
1907
2320
|
}
|
|
@@ -1943,6 +2356,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1943
2356
|
persist = true,
|
|
1944
2357
|
mode: BtwThreadMode = "contextual",
|
|
1945
2358
|
): Promise<void> {
|
|
2359
|
+
invalidateBtwLifecycle();
|
|
1946
2360
|
await disposeBtwSession();
|
|
1947
2361
|
pendingThread = [];
|
|
1948
2362
|
pendingMode = mode;
|
|
@@ -1957,6 +2371,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1957
2371
|
}
|
|
1958
2372
|
|
|
1959
2373
|
async function restoreThread(ctx: ExtensionContext): Promise<void> {
|
|
2374
|
+
invalidateBtwLifecycle();
|
|
1960
2375
|
await disposeBtwSession();
|
|
1961
2376
|
pendingThread = [];
|
|
1962
2377
|
pendingMode = "contextual";
|
|
@@ -2031,8 +2446,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
2031
2446
|
saveRequested: boolean,
|
|
2032
2447
|
mode: BtwThreadMode,
|
|
2033
2448
|
): Promise<void> {
|
|
2449
|
+
const generation = btwLifecycleGeneration;
|
|
2450
|
+
const submission = btwSubmissionQueue.then(async () => {
|
|
2451
|
+
if (generation !== btwLifecycleGeneration) {
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2454
|
+
await executeBtw(ctx, question, saveRequested, mode, generation);
|
|
2455
|
+
});
|
|
2456
|
+
btwSubmissionQueue = submission.catch(() => {});
|
|
2457
|
+
await submission;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
async function executeBtw(
|
|
2461
|
+
ctx: ExtensionCommandContext,
|
|
2462
|
+
question: string,
|
|
2463
|
+
saveRequested: boolean,
|
|
2464
|
+
mode: BtwThreadMode,
|
|
2465
|
+
generation: number,
|
|
2466
|
+
): Promise<void> {
|
|
2467
|
+
const isCurrentGeneration = () => generation === btwLifecycleGeneration;
|
|
2034
2468
|
lastUiContext = ctx;
|
|
2035
2469
|
const settings = await resolveBtwSettings(ctx);
|
|
2470
|
+
if (!isCurrentGeneration()) {
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2036
2473
|
const model = settings.model;
|
|
2037
2474
|
if (!model) {
|
|
2038
2475
|
const message = settings.fallbackReason || "No active model selected.";
|
|
@@ -2042,7 +2479,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2042
2479
|
}
|
|
2043
2480
|
|
|
2044
2481
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
2045
|
-
if (!
|
|
2482
|
+
if (!isCurrentGeneration()) {
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
if (!hasUsableModelAuth(ctx, model, auth)) {
|
|
2046
2486
|
const message = auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error;
|
|
2047
2487
|
setOverlayStatus(message, ctx);
|
|
2048
2488
|
notify(ctx, message, "error");
|
|
@@ -2051,6 +2491,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
2051
2491
|
}
|
|
2052
2492
|
|
|
2053
2493
|
const sessionRuntime = await ensureBtwSession(ctx, mode);
|
|
2494
|
+
if (!isCurrentGeneration()) {
|
|
2495
|
+
if (sessionRuntime && activeBtwSession === sessionRuntime) {
|
|
2496
|
+
await disposeBtwSession();
|
|
2497
|
+
}
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2054
2500
|
if (!sessionRuntime) {
|
|
2055
2501
|
setOverlayStatus("No active model selected.", ctx);
|
|
2056
2502
|
notify(ctx, "No active model selected.", "error");
|
|
@@ -2059,22 +2505,58 @@ export default function (pi: ExtensionAPI) {
|
|
|
2059
2505
|
|
|
2060
2506
|
const session = sessionRuntime.session;
|
|
2061
2507
|
const wasBusy = !ctx.isIdle();
|
|
2508
|
+
const overlayAvailable = canRenderBtwOverlay(ctx);
|
|
2062
2509
|
pendingMode = mode;
|
|
2063
2510
|
const thinkingLevel = settings.thinkingLevel;
|
|
2064
2511
|
|
|
2512
|
+
let releasePromptTurn!: () => void;
|
|
2513
|
+
const previousPromptTurns = sessionRuntime.promptQueue;
|
|
2514
|
+
const currentPromptTurn = new Promise<void>((resolve) => {
|
|
2515
|
+
releasePromptTurn = resolve;
|
|
2516
|
+
});
|
|
2517
|
+
sessionRuntime.promptQueue = previousPromptTurns.then(() => currentPromptTurn);
|
|
2518
|
+
|
|
2519
|
+
if (session.isStreaming || sessionRuntime.abortPromise) {
|
|
2520
|
+
setOverlayStatus("⏳ waiting for the current BTW turn to finish...", ctx);
|
|
2521
|
+
}
|
|
2522
|
+
await previousPromptTurns;
|
|
2523
|
+
if (activeBtwSession !== sessionRuntime) {
|
|
2524
|
+
releasePromptTurn();
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
if (sessionRuntime.abortPromise) {
|
|
2529
|
+
setOverlayStatus("⏳ waiting for cancellation to finish...", ctx);
|
|
2530
|
+
await sessionRuntime.abortPromise;
|
|
2531
|
+
if (activeBtwSession !== sessionRuntime) {
|
|
2532
|
+
releasePromptTurn();
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
if (!isCurrentGeneration()) {
|
|
2538
|
+
releasePromptTurn();
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
sessionRuntime.abortPromise = undefined;
|
|
2065
2543
|
setOverlayStatus("⏳ streaming...", ctx);
|
|
2066
2544
|
await ensureOverlay(ctx);
|
|
2067
2545
|
|
|
2068
2546
|
try {
|
|
2069
2547
|
await session.prompt(question, { source: "extension" });
|
|
2548
|
+
if (!isCurrentGeneration()) {
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2070
2551
|
|
|
2071
2552
|
const response = getLastAssistantMessage(session);
|
|
2072
2553
|
if (!response) {
|
|
2073
2554
|
throw new Error("BTW request finished without a response.");
|
|
2074
2555
|
}
|
|
2075
2556
|
if (response.stopReason === "aborted") {
|
|
2076
|
-
|
|
2077
|
-
|
|
2557
|
+
const abortedTurnId = transcriptState.currentTurnId ?? transcriptState.lastTurnId;
|
|
2558
|
+
finishTranscriptTurn(transcriptState, abortedTurnId, "aborted");
|
|
2559
|
+
setOverlayStatus("⏹ Aborted. Press Esc again to dismiss the BTW overlay.", ctx);
|
|
2078
2560
|
return;
|
|
2079
2561
|
}
|
|
2080
2562
|
if (response.stopReason === "error") {
|
|
@@ -2102,8 +2584,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
2102
2584
|
pendingThread.push(details);
|
|
2103
2585
|
pi.appendEntry(BTW_ENTRY_TYPE, details);
|
|
2104
2586
|
|
|
2105
|
-
const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy);
|
|
2106
|
-
if (
|
|
2587
|
+
const saveState = saveVisibleBtwNote(pi, details, saveRequested || !overlayAvailable, wasBusy);
|
|
2588
|
+
if (!overlayAvailable) {
|
|
2589
|
+
const message =
|
|
2590
|
+
saveState === "queued"
|
|
2591
|
+
? "BTW response queued to display after the current turn finishes."
|
|
2592
|
+
: "Displayed BTW response in the session.";
|
|
2593
|
+
notify(ctx, message, "info");
|
|
2594
|
+
setOverlayStatus(message, ctx);
|
|
2595
|
+
} else if (saveState === "saved") {
|
|
2107
2596
|
notify(ctx, "Saved BTW note to the session.", "info");
|
|
2108
2597
|
setOverlayStatus("Saved BTW note to the session.", ctx);
|
|
2109
2598
|
} else if (saveState === "queued") {
|
|
@@ -2113,12 +2602,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
2113
2602
|
setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx);
|
|
2114
2603
|
}
|
|
2115
2604
|
} catch (error) {
|
|
2605
|
+
if (!isCurrentGeneration()) {
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2116
2608
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2117
2609
|
setTranscriptFailure(transcriptState, errorMessage);
|
|
2118
2610
|
setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx);
|
|
2119
2611
|
notify(ctx, errorMessage, "error");
|
|
2120
2612
|
await disposeBtwSession();
|
|
2121
2613
|
} finally {
|
|
2614
|
+
releasePromptTurn();
|
|
2122
2615
|
syncUi(ctx);
|
|
2123
2616
|
}
|
|
2124
2617
|
}
|
|
@@ -2130,7 +2623,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
2130
2623
|
async function getBtwHandoffThread(
|
|
2131
2624
|
ctx: ExtensionCommandContext,
|
|
2132
2625
|
): Promise<{ sessionRuntime: BtwSessionRuntime | null; thread: BtwHandoffExchange[] }> {
|
|
2626
|
+
const pendingSubmissions = btwSubmissionQueue;
|
|
2627
|
+
await pendingSubmissions;
|
|
2628
|
+
|
|
2133
2629
|
const sessionRuntime = activeBtwSession ?? (await ensureBtwSession(ctx, pendingMode));
|
|
2630
|
+
if (sessionRuntime) {
|
|
2631
|
+
const pendingPromptTurns = sessionRuntime.promptQueue;
|
|
2632
|
+
const pendingAbort = sessionRuntime.abortPromise;
|
|
2633
|
+
await pendingPromptTurns;
|
|
2634
|
+
await pendingAbort;
|
|
2635
|
+
if (activeBtwSession !== sessionRuntime) {
|
|
2636
|
+
throw new Error("BTW session closed before handoff completed.");
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2134
2640
|
const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : [];
|
|
2135
2641
|
const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff();
|
|
2136
2642
|
|
|
@@ -2149,18 +2655,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
2149
2655
|
}
|
|
2150
2656
|
|
|
2151
2657
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
2152
|
-
if (!
|
|
2658
|
+
if (!hasUsableModelAuth(ctx, model, auth)) {
|
|
2153
2659
|
throw new Error(auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error);
|
|
2154
2660
|
}
|
|
2155
2661
|
|
|
2156
|
-
const
|
|
2662
|
+
const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, model);
|
|
2663
|
+
|
|
2664
|
+
const sessionOptions: CreateAgentSessionOptions = {
|
|
2157
2665
|
sessionManager: SessionManager.inMemory(),
|
|
2158
2666
|
model,
|
|
2159
|
-
|
|
2667
|
+
...modelRuntimeOptions,
|
|
2160
2668
|
thinkingLevel: "off",
|
|
2161
2669
|
tools: [],
|
|
2162
2670
|
resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]),
|
|
2163
|
-
}
|
|
2671
|
+
};
|
|
2672
|
+
const { session } = await createAgentSession(sessionOptions);
|
|
2164
2673
|
|
|
2165
2674
|
try {
|
|
2166
2675
|
await session.prompt(formatThread(thread), { source: "extension" });
|
|
@@ -2197,29 +2706,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
2197
2706
|
|
|
2198
2707
|
pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => {
|
|
2199
2708
|
const details = message.details as BtwDetails | undefined;
|
|
2200
|
-
const content =
|
|
2201
|
-
|
|
2709
|
+
const content = details
|
|
2710
|
+
? buildBtwMessageContent(details.question, details.answer)
|
|
2711
|
+
: typeof message.content === "string"
|
|
2712
|
+
? message.content
|
|
2713
|
+
: "[non-text btw message]";
|
|
2714
|
+
|
|
2715
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
2716
|
+
box.addChild(new Text(theme.fg("accent", theme.bold("[BTW]")), 0, 0));
|
|
2717
|
+
box.addChild(
|
|
2718
|
+
new Markdown(content, 0, 0, getMarkdownTheme(), {
|
|
2719
|
+
color: (text: string) => theme.fg("customMessageText", text),
|
|
2720
|
+
}),
|
|
2721
|
+
);
|
|
2202
2722
|
|
|
2203
2723
|
if (expanded && details) {
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2724
|
+
box.addChild(
|
|
2725
|
+
new Text(
|
|
2726
|
+
theme.fg(
|
|
2727
|
+
"dim",
|
|
2728
|
+
`model: ${details.provider}/${details.model} (${details.api ?? "openai-responses"}) · thinking: ${details.thinkingLevel}`,
|
|
2729
|
+
),
|
|
2730
|
+
0,
|
|
2731
|
+
0,
|
|
2208
2732
|
),
|
|
2209
2733
|
);
|
|
2210
2734
|
|
|
2211
2735
|
if (details.usage) {
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2736
|
+
box.addChild(
|
|
2737
|
+
new Text(
|
|
2738
|
+
theme.fg(
|
|
2739
|
+
"dim",
|
|
2740
|
+
`tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`,
|
|
2741
|
+
),
|
|
2742
|
+
0,
|
|
2743
|
+
0,
|
|
2216
2744
|
),
|
|
2217
2745
|
);
|
|
2218
2746
|
}
|
|
2219
2747
|
}
|
|
2220
2748
|
|
|
2221
|
-
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
2222
|
-
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
2223
2749
|
return box;
|
|
2224
2750
|
});
|
|
2225
2751
|
|
|
@@ -2238,6 +2764,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2238
2764
|
});
|
|
2239
2765
|
|
|
2240
2766
|
pi.on("session_shutdown", async () => {
|
|
2767
|
+
invalidateBtwLifecycle();
|
|
2241
2768
|
await disposeBtwSession();
|
|
2242
2769
|
dismissOverlay();
|
|
2243
2770
|
});
|
|
@@ -2251,6 +2778,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
2251
2778
|
});
|
|
2252
2779
|
}
|
|
2253
2780
|
|
|
2781
|
+
pi.registerShortcut(BTW_WIDTH_TOGGLE_SHORTCUT, {
|
|
2782
|
+
description: "Toggle the BTW overlay between window and full-width layouts.",
|
|
2783
|
+
handler: async () => {
|
|
2784
|
+
if (!overlayRuntime || !lastUiContext) {
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
await toggleOverlayWidth(lastUiContext);
|
|
2788
|
+
},
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2254
2791
|
pi.registerCommand("btw", {
|
|
2255
2792
|
description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.",
|
|
2256
2793
|
handler: async (args, ctx) => {
|
|
@@ -2258,6 +2795,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
2258
2795
|
},
|
|
2259
2796
|
});
|
|
2260
2797
|
|
|
2798
|
+
pi.registerCommand("side", {
|
|
2799
|
+
description: "Alias for /btw: continue a side conversation in a focused BTW modal.",
|
|
2800
|
+
handler: async (args, ctx) => {
|
|
2801
|
+
await dispatchBtwCommand("btw", args, ctx);
|
|
2802
|
+
},
|
|
2803
|
+
});
|
|
2804
|
+
|
|
2261
2805
|
pi.registerCommand("btw:tangent", {
|
|
2262
2806
|
description: "Start or continue a contextless BTW tangent in the focused BTW modal.",
|
|
2263
2807
|
handler: async (args, ctx) => {
|
|
@@ -2265,6 +2809,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
2265
2809
|
},
|
|
2266
2810
|
});
|
|
2267
2811
|
|
|
2812
|
+
pi.registerCommand("btw:ask", {
|
|
2813
|
+
description: "Ask a read-only side question: inherits main-session context but exposes only read/grep/find/ls tools.",
|
|
2814
|
+
handler: async (args, ctx) => {
|
|
2815
|
+
await dispatchBtwCommand("btw:ask", args, ctx);
|
|
2816
|
+
},
|
|
2817
|
+
});
|
|
2818
|
+
|
|
2268
2819
|
pi.registerCommand("btw:new", {
|
|
2269
2820
|
description: "Start a fresh BTW thread with main-session context. Optionally ask the first question immediately.",
|
|
2270
2821
|
handler: async (args, ctx) => {
|
|
@@ -2307,3 +2858,4 @@ export default function (pi: ExtensionAPI) {
|
|
|
2307
2858
|
},
|
|
2308
2859
|
});
|
|
2309
2860
|
}
|
|
2861
|
+
|