pi-btw 0.4.1 → 0.5.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 +16 -4
- package/extensions/btw.ts +608 -110
- package/package.json +10 -5
- package/skills/btw/SKILL.md +170 -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.",
|
|
@@ -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 {
|
|
@@ -415,17 +598,29 @@ function ensureTranscriptTurn(state: BtwTranscriptState): number {
|
|
|
415
598
|
return turnId;
|
|
416
599
|
}
|
|
417
600
|
|
|
418
|
-
function finishTranscriptTurn(
|
|
601
|
+
function finishTranscriptTurn(
|
|
602
|
+
state: BtwTranscriptState,
|
|
603
|
+
turnId?: number | null,
|
|
604
|
+
outcome: BtwTurnOutcome = "completed",
|
|
605
|
+
): void {
|
|
419
606
|
const resolvedTurnId = turnId ?? state.currentTurnId;
|
|
420
607
|
if (resolvedTurnId === null || resolvedTurnId === undefined) {
|
|
421
608
|
return;
|
|
422
609
|
}
|
|
423
610
|
|
|
424
|
-
const
|
|
425
|
-
(entry)
|
|
611
|
+
const endBoundary = state.entries.find(
|
|
612
|
+
(entry): entry is Extract<BtwTranscriptEntry, { type: "turn-boundary" }> =>
|
|
613
|
+
entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end",
|
|
426
614
|
);
|
|
427
|
-
if (
|
|
428
|
-
|
|
615
|
+
if (endBoundary) {
|
|
616
|
+
endBoundary.outcome = outcome;
|
|
617
|
+
} else {
|
|
618
|
+
appendTranscriptEntry(state, {
|
|
619
|
+
type: "turn-boundary",
|
|
620
|
+
turnId: resolvedTurnId,
|
|
621
|
+
phase: "end",
|
|
622
|
+
outcome,
|
|
623
|
+
} as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
|
|
429
624
|
}
|
|
430
625
|
|
|
431
626
|
for (const entry of state.entries) {
|
|
@@ -444,26 +639,6 @@ function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null)
|
|
|
444
639
|
}
|
|
445
640
|
}
|
|
446
641
|
|
|
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
642
|
function findLatestTranscriptEntry<TType extends BtwTranscriptEntry["type"]>(
|
|
468
643
|
state: BtwTranscriptState,
|
|
469
644
|
turnId: number,
|
|
@@ -746,7 +921,10 @@ function applyTranscriptEvent(state: BtwTranscriptState, event: AgentSessionEven
|
|
|
746
921
|
return;
|
|
747
922
|
}
|
|
748
923
|
case "turn_end": {
|
|
749
|
-
|
|
924
|
+
const stopReason = event.message.role === "assistant" ? event.message.stopReason : "stop";
|
|
925
|
+
const outcome: BtwTurnOutcome =
|
|
926
|
+
stopReason === "aborted" ? "aborted" : stopReason === "error" ? "failed" : "completed";
|
|
927
|
+
finishTranscriptTurn(state, undefined, outcome);
|
|
750
928
|
return;
|
|
751
929
|
}
|
|
752
930
|
default:
|
|
@@ -767,7 +945,7 @@ function appendPersistedTranscriptTurn(state: BtwTranscriptState, details: BtwDe
|
|
|
767
945
|
function setTranscriptFailure(state: BtwTranscriptState, message: string): void {
|
|
768
946
|
const turnId = state.currentTurnId ?? state.lastTurnId ?? ensureTranscriptTurn(state);
|
|
769
947
|
upsertTranscriptTextEntry(state, turnId, "assistant-text", `❌ ${message}`, false);
|
|
770
|
-
finishTranscriptTurn(state, turnId);
|
|
948
|
+
finishTranscriptTurn(state, turnId, "failed");
|
|
771
949
|
}
|
|
772
950
|
|
|
773
951
|
function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
|
|
@@ -779,10 +957,26 @@ function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
|
|
|
779
957
|
}
|
|
780
958
|
|
|
781
959
|
function getCompletedExchangeCount(entries: BtwTranscript): number {
|
|
782
|
-
|
|
960
|
+
const completedTurnIds = new Set(
|
|
961
|
+
entries.flatMap((entry) =>
|
|
962
|
+
entry.type === "turn-boundary" &&
|
|
963
|
+
entry.phase === "end" &&
|
|
964
|
+
(entry.outcome === undefined || entry.outcome === "completed")
|
|
965
|
+
? [entry.turnId]
|
|
966
|
+
: [],
|
|
967
|
+
),
|
|
968
|
+
);
|
|
969
|
+
return entries.filter(
|
|
970
|
+
(entry) => entry.type === "assistant-text" && !entry.streaming && completedTurnIds.has(entry.turnId),
|
|
971
|
+
).length;
|
|
783
972
|
}
|
|
784
973
|
|
|
785
|
-
function buildOverlayTranscript(
|
|
974
|
+
function buildOverlayTranscript(
|
|
975
|
+
entries: BtwTranscript,
|
|
976
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
977
|
+
markdownTheme: MarkdownTheme,
|
|
978
|
+
contentWidth: number,
|
|
979
|
+
): string[] {
|
|
786
980
|
if (entries.length === 0) {
|
|
787
981
|
return [theme.fg("dim", "No BTW thread yet. Ask a side question to start one.")];
|
|
788
982
|
}
|
|
@@ -793,7 +987,7 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
|
|
|
793
987
|
const toolBadge = buildTranscriptBadge(theme, "Tool", "toolPendingBg", "warning");
|
|
794
988
|
const assistantBadge = buildTranscriptBadge(theme, "Assistant", "customMessageBg", "success");
|
|
795
989
|
const separator = theme.fg("borderMuted", "────────────────────────────────────────");
|
|
796
|
-
const blockIndent =
|
|
990
|
+
const blockIndent = BTW_BLOCK_INDENT;
|
|
797
991
|
const resultIndent = blockIndent;
|
|
798
992
|
|
|
799
993
|
const pushBlankLine = () => {
|
|
@@ -854,9 +1048,17 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
|
|
|
854
1048
|
|
|
855
1049
|
if (entry.type === "thinking") {
|
|
856
1050
|
const thinkingHeader = entry.streaming ? `${thinkingBadge} ${theme.fg("warning", "▍")}` : thinkingBadge;
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1051
|
+
const markdownLines = new Markdown(entry.text, 0, 0, markdownTheme, {
|
|
1052
|
+
color: (text: string) => theme.fg("warning", text),
|
|
1053
|
+
italic: true,
|
|
1054
|
+
})
|
|
1055
|
+
.render(Math.max(1, contentWidth))
|
|
1056
|
+
.map((line) => line.replace(/\s+$/u, ""));
|
|
1057
|
+
pushBlankLine();
|
|
1058
|
+
lines.push(thinkingHeader);
|
|
1059
|
+
for (const line of markdownLines) {
|
|
1060
|
+
lines.push(line ? `${blockIndent}${line}` : "");
|
|
1061
|
+
}
|
|
860
1062
|
continue;
|
|
861
1063
|
}
|
|
862
1064
|
|
|
@@ -884,7 +1086,14 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
|
|
|
884
1086
|
|
|
885
1087
|
if (entry.type === "assistant-text") {
|
|
886
1088
|
const assistantHeader = entry.streaming ? `${assistantBadge} ${theme.fg("warning", "▍")}` : assistantBadge;
|
|
887
|
-
|
|
1089
|
+
const markdownLines = new Markdown(entry.text, 0, 0, markdownTheme)
|
|
1090
|
+
.render(Math.max(1, contentWidth))
|
|
1091
|
+
.map((line) => line.replace(/\s+$/u, ""));
|
|
1092
|
+
pushBlankLine();
|
|
1093
|
+
lines.push(assistantHeader);
|
|
1094
|
+
for (const line of markdownLines) {
|
|
1095
|
+
lines.push(line ? `${blockIndent}${line}` : "");
|
|
1096
|
+
}
|
|
888
1097
|
}
|
|
889
1098
|
}
|
|
890
1099
|
|
|
@@ -908,7 +1117,7 @@ type BtwHandoffExchange = {
|
|
|
908
1117
|
};
|
|
909
1118
|
|
|
910
1119
|
function buildBtwMessageContent(question: string, answer: string): string {
|
|
911
|
-
return
|
|
1120
|
+
return `**Question**\n\n${question}\n\n**Answer**\n\n${answer}`;
|
|
912
1121
|
}
|
|
913
1122
|
|
|
914
1123
|
function formatThread(thread: BtwHandoffExchange[]): string {
|
|
@@ -932,18 +1141,18 @@ function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffE
|
|
|
932
1141
|
const exchanges: BtwHandoffExchange[] = [];
|
|
933
1142
|
let currentUser = "";
|
|
934
1143
|
let currentAssistant = "";
|
|
1144
|
+
let excludeCurrent = false;
|
|
935
1145
|
|
|
936
1146
|
const pushCurrent = () => {
|
|
937
|
-
if (!
|
|
938
|
-
|
|
1147
|
+
if (!excludeCurrent && (currentUser || currentAssistant)) {
|
|
1148
|
+
exchanges.push({
|
|
1149
|
+
user: currentUser.trim() || "(No user prompt)",
|
|
1150
|
+
assistant: currentAssistant.trim() || "(No assistant response)",
|
|
1151
|
+
});
|
|
939
1152
|
}
|
|
940
|
-
|
|
941
|
-
exchanges.push({
|
|
942
|
-
user: currentUser.trim() || "(No user prompt)",
|
|
943
|
-
assistant: currentAssistant.trim() || "(No assistant response)",
|
|
944
|
-
});
|
|
945
1153
|
currentUser = "";
|
|
946
1154
|
currentAssistant = "";
|
|
1155
|
+
excludeCurrent = false;
|
|
947
1156
|
};
|
|
948
1157
|
|
|
949
1158
|
for (const message of threadMessages) {
|
|
@@ -951,18 +1160,25 @@ function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffE
|
|
|
951
1160
|
continue;
|
|
952
1161
|
}
|
|
953
1162
|
|
|
954
|
-
const text = extractMessageText(message).trim();
|
|
955
|
-
if (!text) {
|
|
956
|
-
continue;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
1163
|
if (message.role === "user") {
|
|
1164
|
+
const text = extractMessageText(message).trim();
|
|
1165
|
+
if (!text) {
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
960
1168
|
pushCurrent();
|
|
961
1169
|
currentUser = text;
|
|
962
1170
|
continue;
|
|
963
1171
|
}
|
|
964
1172
|
|
|
965
|
-
|
|
1173
|
+
if (message.stopReason === "aborted" || message.stopReason === "error") {
|
|
1174
|
+
excludeCurrent = true;
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const text = extractMessageText(message).trim();
|
|
1179
|
+
if (text) {
|
|
1180
|
+
currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text;
|
|
1181
|
+
}
|
|
966
1182
|
}
|
|
967
1183
|
|
|
968
1184
|
pushCurrent();
|
|
@@ -995,6 +1211,17 @@ function saveVisibleBtwNote(
|
|
|
995
1211
|
return "saved";
|
|
996
1212
|
}
|
|
997
1213
|
|
|
1214
|
+
function canRenderBtwOverlay(ctx: ExtensionContext | ExtensionCommandContext): boolean {
|
|
1215
|
+
return ctx.hasUI && ctx.mode === "tui";
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function notifyInlineQuestionRequired(
|
|
1219
|
+
ctx: ExtensionCommandContext,
|
|
1220
|
+
command: "/btw" | "/btw:tangent" | "/btw:new",
|
|
1221
|
+
): void {
|
|
1222
|
+
notify(ctx, `${command} cannot open its composer outside Pi's TUI. Pass the question inline instead.`, "warning");
|
|
1223
|
+
}
|
|
1224
|
+
|
|
998
1225
|
function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
|
|
999
1226
|
if (ctx.hasUI) {
|
|
1000
1227
|
ctx.ui.notify(message, level);
|
|
@@ -1003,6 +1230,8 @@ function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string
|
|
|
1003
1230
|
|
|
1004
1231
|
/** Fixed overlay rows outside the transcript viewport (must match render() structure). */
|
|
1005
1232
|
const BTW_OVERLAY_CHROME_LINES = 9;
|
|
1233
|
+
/** Indent applied to transcript block bodies. */
|
|
1234
|
+
const BTW_BLOCK_INDENT = " ";
|
|
1006
1235
|
|
|
1007
1236
|
function getOverlayTitle(mode: BtwThreadMode): string {
|
|
1008
1237
|
return mode === "tangent" ? "BTW tangent" : "BTW";
|
|
@@ -1027,14 +1256,19 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1027
1256
|
private readonly readTranscriptEntries: () => BtwTranscript;
|
|
1028
1257
|
private readonly getStatus: () => string | null;
|
|
1029
1258
|
private readonly getMode: () => BtwThreadMode;
|
|
1259
|
+
private readonly getWidthMode: () => BtwOverlayWidthMode;
|
|
1030
1260
|
private readonly onSubmitCallback: (value: string) => void;
|
|
1031
1261
|
private readonly onDismissCallback: () => void;
|
|
1032
1262
|
private readonly onUnfocusCallback: () => void;
|
|
1263
|
+
private readonly onToggleWidthCallback: () => void;
|
|
1033
1264
|
private readonly tui: TUI;
|
|
1034
1265
|
private readonly theme: ExtensionContext["ui"]["theme"];
|
|
1266
|
+
private readonly markdownTheme: MarkdownTheme;
|
|
1267
|
+
private readonly managesMouseReporting: boolean;
|
|
1035
1268
|
private transcriptLines: string[] = [];
|
|
1036
1269
|
private transcriptScrollOffset = 0;
|
|
1037
1270
|
private transcriptViewportHeight = 8;
|
|
1271
|
+
private contentWidth = 66;
|
|
1038
1272
|
private followTranscript = true;
|
|
1039
1273
|
private _focused = false;
|
|
1040
1274
|
private modeTextValue = "";
|
|
@@ -1058,19 +1292,27 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1058
1292
|
readTranscriptEntries: () => BtwTranscript,
|
|
1059
1293
|
getStatus: () => string | null,
|
|
1060
1294
|
getMode: () => BtwThreadMode,
|
|
1295
|
+
getWidthMode: () => BtwOverlayWidthMode,
|
|
1061
1296
|
onSubmit: (value: string) => void,
|
|
1062
1297
|
onDismiss: () => void,
|
|
1063
1298
|
onUnfocus: () => void,
|
|
1299
|
+
onToggleWidth: () => void,
|
|
1064
1300
|
) {
|
|
1065
1301
|
super();
|
|
1066
1302
|
this.tui = tui;
|
|
1067
1303
|
this.theme = theme;
|
|
1304
|
+
this.markdownTheme = getMarkdownTheme();
|
|
1305
|
+
// Fullscreen Pi owns mouse reporting for the entire terminal session. In
|
|
1306
|
+
// regular mode BTW manages it while the overlay exists.
|
|
1307
|
+
this.managesMouseReporting = tui.mode !== "fullscreen";
|
|
1068
1308
|
this.readTranscriptEntries = readTranscriptEntries;
|
|
1069
1309
|
this.getStatus = getStatus;
|
|
1070
1310
|
this.getMode = getMode;
|
|
1311
|
+
this.getWidthMode = getWidthMode;
|
|
1071
1312
|
this.onSubmitCallback = onSubmit;
|
|
1072
1313
|
this.onDismissCallback = onDismiss;
|
|
1073
1314
|
this.onUnfocusCallback = onUnfocus;
|
|
1315
|
+
this.onToggleWidthCallback = onToggleWidth;
|
|
1074
1316
|
|
|
1075
1317
|
this.modeText = new Text("", 1, 0);
|
|
1076
1318
|
this.summaryText = new Text("", 1, 0);
|
|
@@ -1088,8 +1330,9 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1088
1330
|
|
|
1089
1331
|
this.hintsText = new Text("", 1, 0);
|
|
1090
1332
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1333
|
+
if (this.managesMouseReporting) {
|
|
1334
|
+
this.tui.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
|
|
1335
|
+
}
|
|
1093
1336
|
|
|
1094
1337
|
const originalHandleInput = this.input.handleInput.bind(this.input);
|
|
1095
1338
|
this.input.handleInput = (data: string) => {
|
|
@@ -1114,17 +1357,33 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1114
1357
|
this.refresh();
|
|
1115
1358
|
}
|
|
1116
1359
|
|
|
1360
|
+
private get borderless(): boolean {
|
|
1361
|
+
// Full-width mode drops the vertical bars and corner glyphs so a terminal
|
|
1362
|
+
// Shift+drag selection captures only the dialog's own text — with side
|
|
1363
|
+
// borders, the leftmost/rightmost columns would land inside the drag.
|
|
1364
|
+
return this.getWidthMode() === "full";
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1117
1367
|
private frameLine(content: string, innerWidth: number): string {
|
|
1118
1368
|
const truncated = truncateToWidth(content, innerWidth, "");
|
|
1119
1369
|
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
1370
|
+
if (this.borderless) {
|
|
1371
|
+
return `${truncated}${" ".repeat(padding)}`;
|
|
1372
|
+
}
|
|
1120
1373
|
return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
|
|
1121
1374
|
}
|
|
1122
1375
|
|
|
1123
1376
|
private ruleLine(innerWidth: number): string {
|
|
1377
|
+
if (this.borderless) {
|
|
1378
|
+
return this.theme.fg("border", "─".repeat(innerWidth));
|
|
1379
|
+
}
|
|
1124
1380
|
return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
|
|
1125
1381
|
}
|
|
1126
1382
|
|
|
1127
1383
|
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
1384
|
+
if (this.borderless) {
|
|
1385
|
+
return this.theme.fg("border", "─".repeat(innerWidth));
|
|
1386
|
+
}
|
|
1128
1387
|
const left = edge === "top" ? "┌" : "└";
|
|
1129
1388
|
const right = edge === "top" ? "┐" : "┘";
|
|
1130
1389
|
return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
@@ -1156,7 +1415,9 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1156
1415
|
}
|
|
1157
1416
|
|
|
1158
1417
|
dispose(): void {
|
|
1159
|
-
this.
|
|
1418
|
+
if (this.managesMouseReporting) {
|
|
1419
|
+
this.tui.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
|
|
1420
|
+
}
|
|
1160
1421
|
}
|
|
1161
1422
|
|
|
1162
1423
|
private getMouseScrollDelta(data: string): number | null {
|
|
@@ -1179,6 +1440,11 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1179
1440
|
return;
|
|
1180
1441
|
}
|
|
1181
1442
|
|
|
1443
|
+
if (matchesBtwWidthToggle(data)) {
|
|
1444
|
+
this.onToggleWidthCallback();
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1182
1448
|
const mouseScrollDelta = this.getMouseScrollDelta(data);
|
|
1183
1449
|
if (mouseScrollDelta !== null) {
|
|
1184
1450
|
this.scrollTranscript(mouseScrollDelta);
|
|
@@ -1201,7 +1467,8 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1201
1467
|
}
|
|
1202
1468
|
|
|
1203
1469
|
private inputFrameLine(dialogWidth: number): string {
|
|
1204
|
-
const
|
|
1470
|
+
const borderColumns = this.borderless ? 0 : 2;
|
|
1471
|
+
const targetWidth = Math.max(1, dialogWidth - borderColumns);
|
|
1205
1472
|
const previousFocused = this.input.focused;
|
|
1206
1473
|
// Input.render() emits CURSOR_MARKER when focused. In overlay mode that APC marker
|
|
1207
1474
|
// can skew width/composition on this one row before the TUI strips it, producing a
|
|
@@ -1212,6 +1479,9 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1212
1479
|
const renderedInputLine = this.input.render(targetWidth)[0] ?? "";
|
|
1213
1480
|
const inputLine = truncateToWidth(renderedInputLine, targetWidth, "");
|
|
1214
1481
|
const padding = Math.max(0, targetWidth - visibleWidth(inputLine));
|
|
1482
|
+
if (this.borderless) {
|
|
1483
|
+
return `${inputLine}${" ".repeat(padding)}`;
|
|
1484
|
+
}
|
|
1215
1485
|
return `${this.theme.fg("border", "│")}${inputLine}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
|
|
1216
1486
|
} finally {
|
|
1217
1487
|
this.input.focused = previousFocused;
|
|
@@ -1224,7 +1494,13 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1224
1494
|
|
|
1225
1495
|
override render(width: number): string[] {
|
|
1226
1496
|
const dialogWidth = Math.max(24, width);
|
|
1227
|
-
const
|
|
1497
|
+
const borderColumns = this.borderless ? 0 : 2;
|
|
1498
|
+
const innerWidth = Math.max(22, dialogWidth - borderColumns);
|
|
1499
|
+
const contentWidth = Math.max(1, innerWidth - BTW_BLOCK_INDENT.length);
|
|
1500
|
+
if (contentWidth !== this.contentWidth) {
|
|
1501
|
+
this.contentWidth = contentWidth;
|
|
1502
|
+
this.rebuildTranscriptLines();
|
|
1503
|
+
}
|
|
1228
1504
|
const transcriptLines = this.wrapTranscript(innerWidth);
|
|
1229
1505
|
const dialogHeight = this.getDialogHeight();
|
|
1230
1506
|
const chromeHeight = BTW_OVERLAY_CHROME_LINES;
|
|
@@ -1288,6 +1564,15 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1288
1564
|
return this.readTranscriptEntries().map((entry) => ({ ...entry }));
|
|
1289
1565
|
}
|
|
1290
1566
|
|
|
1567
|
+
private rebuildTranscriptLines(): void {
|
|
1568
|
+
this.transcriptLines = buildOverlayTranscript(
|
|
1569
|
+
this.readTranscriptEntries(),
|
|
1570
|
+
this.theme,
|
|
1571
|
+
this.markdownTheme,
|
|
1572
|
+
this.contentWidth,
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1291
1576
|
refresh(): void {
|
|
1292
1577
|
this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`;
|
|
1293
1578
|
this.modeText.setText(this.modeTextValue);
|
|
@@ -1297,7 +1582,7 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1297
1582
|
this.summaryTextValue = `${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`;
|
|
1298
1583
|
this.summaryText.setText(this.summaryTextValue);
|
|
1299
1584
|
|
|
1300
|
-
this.
|
|
1585
|
+
this.rebuildTranscriptLines();
|
|
1301
1586
|
this.transcript.clear();
|
|
1302
1587
|
for (const line of this.transcriptLines) {
|
|
1303
1588
|
this.transcript.addChild(new Text(line, 1, 0));
|
|
@@ -1306,7 +1591,7 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1306
1591
|
const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
|
|
1307
1592
|
this.statusTextValue = status;
|
|
1308
1593
|
this.statusText.setText(this.statusTextValue);
|
|
1309
|
-
this.hintsTextValue =
|
|
1594
|
+
this.hintsTextValue = `Scroll wheel ↑↓ PgUp/PgDn · Enter · ${BTW_FOCUS_SHORTCUTS_LABEL} focus · Alt+w width · Esc`;
|
|
1310
1595
|
this.hintsText.setText(this.hintsTextValue);
|
|
1311
1596
|
this.tui.requestRender();
|
|
1312
1597
|
}
|
|
@@ -1320,9 +1605,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1320
1605
|
let transcriptState = createEmptyTranscriptState();
|
|
1321
1606
|
let overlayStatus: string | null = null;
|
|
1322
1607
|
let overlayDraft = "";
|
|
1608
|
+
let overlayWidthMode: BtwOverlayWidthMode = "window";
|
|
1323
1609
|
let overlayRuntime: OverlayRuntime | null = null;
|
|
1324
1610
|
let lastUiContext: ExtensionContext | ExtensionCommandContext | null = null;
|
|
1325
1611
|
let activeBtwSession: BtwSessionRuntime | null = null;
|
|
1612
|
+
let btwLifecycleGeneration = 0;
|
|
1613
|
+
let btwSubmissionQueue = Promise.resolve();
|
|
1614
|
+
|
|
1615
|
+
function invalidateBtwLifecycle(): void {
|
|
1616
|
+
btwLifecycleGeneration += 1;
|
|
1617
|
+
}
|
|
1326
1618
|
|
|
1327
1619
|
function syncUi(ctx?: ExtensionContext | ExtensionCommandContext): void {
|
|
1328
1620
|
const activeCtx = ctx ?? lastUiContext;
|
|
@@ -1373,6 +1665,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
1373
1665
|
overlayRuntime?.refresh?.();
|
|
1374
1666
|
}
|
|
1375
1667
|
|
|
1668
|
+
function getOverlayOptions(): OverlayOptions {
|
|
1669
|
+
const base: OverlayOptions = {
|
|
1670
|
+
minWidth: 72,
|
|
1671
|
+
maxHeight: "78%",
|
|
1672
|
+
anchor: "top-center",
|
|
1673
|
+
nonCapturing: true,
|
|
1674
|
+
};
|
|
1675
|
+
if (overlayWidthMode === "full") {
|
|
1676
|
+
// Edge-to-edge so a terminal Shift+drag selection captures only the
|
|
1677
|
+
// dialog's own text — nothing from the main screen sits beside it.
|
|
1678
|
+
return { ...base, width: "100%", margin: { top: 1 } };
|
|
1679
|
+
}
|
|
1680
|
+
// Framed "window" look: narrower, inset from the terminal edges.
|
|
1681
|
+
return { ...base, width: "78%", margin: { top: 1, left: 2, right: 2 } };
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
async function toggleOverlayWidth(ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
|
|
1685
|
+
overlayWidthMode = overlayWidthMode === "window" ? "full" : "window";
|
|
1686
|
+
|
|
1687
|
+
// overlayOptions is resolved once at showOverlay time, so a width change
|
|
1688
|
+
// requires tearing down and re-opening the overlay. The close path persists
|
|
1689
|
+
// the draft into overlayDraft, and ensureOverlay restores it on reopen.
|
|
1690
|
+
const wasFocused = overlayRuntime?.handle?.isFocused() ?? true;
|
|
1691
|
+
dismissOverlay();
|
|
1692
|
+
await ensureOverlay(ctx);
|
|
1693
|
+
if (!wasFocused) {
|
|
1694
|
+
overlayRuntime?.handle?.unfocus();
|
|
1695
|
+
overlayRuntime?.refresh?.();
|
|
1696
|
+
}
|
|
1697
|
+
setOverlayStatus(
|
|
1698
|
+
overlayWidthMode === "full"
|
|
1699
|
+
? "Full-width mode. Shift+drag now selects only the dialog. Alt+w to restore the window."
|
|
1700
|
+
: "Window mode. Alt+w switches to full-width for clean copy selection.",
|
|
1701
|
+
ctx,
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1376
1705
|
function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void {
|
|
1377
1706
|
if (!sessionRuntime.subscriptions.delete(unsubscribe)) {
|
|
1378
1707
|
return;
|
|
@@ -1439,6 +1768,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1439
1768
|
sessionRuntime.subscriptions.add(unsubscribe);
|
|
1440
1769
|
}
|
|
1441
1770
|
|
|
1771
|
+
function requestBtwSessionAbort(sessionRuntime: BtwSessionRuntime): Promise<void> {
|
|
1772
|
+
sessionRuntime.abortPromise ??= Promise.resolve()
|
|
1773
|
+
.then(() => sessionRuntime.session.abort())
|
|
1774
|
+
.catch(() => {
|
|
1775
|
+
// Ignore abort errors during BTW cancellation/replacement/shutdown.
|
|
1776
|
+
});
|
|
1777
|
+
return sessionRuntime.abortPromise;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1442
1780
|
async function disposeBtwSession(): Promise<void> {
|
|
1443
1781
|
const current = activeBtwSession;
|
|
1444
1782
|
activeBtwSession = null;
|
|
@@ -1447,28 +1785,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
1447
1785
|
}
|
|
1448
1786
|
|
|
1449
1787
|
clearBtwSessionSubscriptions(current);
|
|
1450
|
-
|
|
1451
|
-
try {
|
|
1452
|
-
await current.session.abort();
|
|
1453
|
-
} catch {
|
|
1454
|
-
// Ignore abort errors during BTW session replacement/shutdown.
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1788
|
+
await requestBtwSessionAbort(current);
|
|
1457
1789
|
current.session.dispose();
|
|
1458
1790
|
}
|
|
1459
1791
|
|
|
1460
1792
|
async function dismissOverlaySession(): Promise<void> {
|
|
1793
|
+
invalidateBtwLifecycle();
|
|
1461
1794
|
dismissOverlay();
|
|
1462
1795
|
await disposeBtwSession();
|
|
1463
1796
|
}
|
|
1464
1797
|
|
|
1798
|
+
/**
|
|
1799
|
+
* Escape behaves differently depending on whether the BTW side session is
|
|
1800
|
+
* currently doing work:
|
|
1801
|
+
*
|
|
1802
|
+
* - streaming: the first Escape aborts the in-flight request but keeps the
|
|
1803
|
+
* overlay open (so the partial transcript stays readable and the thread
|
|
1804
|
+
* remains usable). A second Escape dismisses, even while cancellation settles.
|
|
1805
|
+
* - idle: Escape dismisses the overlay immediately (previous behavior).
|
|
1806
|
+
*/
|
|
1807
|
+
async function dismissOrAbortOverlaySession(): Promise<void> {
|
|
1808
|
+
const sessionRuntime = activeBtwSession;
|
|
1809
|
+
if (sessionRuntime?.session.isStreaming && !sessionRuntime.abortPromise) {
|
|
1810
|
+
setOverlayStatus("⏹ Aborting. Press Esc again to dismiss the BTW overlay.");
|
|
1811
|
+
await requestBtwSessionAbort(sessionRuntime);
|
|
1812
|
+
if (activeBtwSession === sessionRuntime && overlayRuntime) {
|
|
1813
|
+
setOverlayStatus("⏹ Aborted. Press Esc again to dismiss the BTW overlay.");
|
|
1814
|
+
}
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
await dismissOverlaySession();
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1465
1820
|
async function resolveBtwModel(
|
|
1466
1821
|
ctx: ExtensionCommandContext,
|
|
1467
1822
|
notifyOnFallback = false,
|
|
1468
1823
|
): Promise<ResolvedBtwModel> {
|
|
1469
1824
|
if (btwModelOverride) {
|
|
1470
1825
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(btwModelOverride);
|
|
1471
|
-
if (
|
|
1826
|
+
if (hasUsableModelAuth(ctx, btwModelOverride, auth)) {
|
|
1472
1827
|
return {
|
|
1473
1828
|
model: btwModelOverride,
|
|
1474
1829
|
source: "override",
|
|
@@ -1559,6 +1914,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1559
1914
|
}
|
|
1560
1915
|
|
|
1561
1916
|
async function setBtwModelOverride(ctx: ExtensionCommandContext, nextModel: SessionModel | null): Promise<void> {
|
|
1917
|
+
invalidateBtwLifecycle();
|
|
1562
1918
|
btwModelOverride = nextModel;
|
|
1563
1919
|
const details: BtwModelOverrideDetails = nextModel
|
|
1564
1920
|
? { action: "set", timestamp: Date.now(), provider: nextModel.provider, id: nextModel.id, api: nextModel.api }
|
|
@@ -1577,6 +1933,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1577
1933
|
ctx: ExtensionCommandContext,
|
|
1578
1934
|
nextThinkingLevel: SessionThinkingLevel | null,
|
|
1579
1935
|
): Promise<void> {
|
|
1936
|
+
invalidateBtwLifecycle();
|
|
1580
1937
|
btwThinkingOverride = nextThinkingLevel;
|
|
1581
1938
|
const details: BtwThinkingOverrideDetails = nextThinkingLevel
|
|
1582
1939
|
? { action: "set", timestamp: Date.now(), thinkingLevel: nextThinkingLevel }
|
|
@@ -1591,32 +1948,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
1591
1948
|
notify(ctx, `${message} ${describeResolvedThinking(settings)}`, "info");
|
|
1592
1949
|
}
|
|
1593
1950
|
|
|
1594
|
-
async function createBtwSubSession(
|
|
1595
|
-
|
|
1951
|
+
async function createBtwSubSession(
|
|
1952
|
+
ctx: ExtensionCommandContext,
|
|
1953
|
+
mode: BtwThreadMode,
|
|
1954
|
+
settings: ResolvedBtwSettings,
|
|
1955
|
+
): Promise<BtwSessionRuntime> {
|
|
1596
1956
|
if (!settings.model) {
|
|
1597
1957
|
throw new Error(settings.fallbackReason || "No active model selected.");
|
|
1598
1958
|
}
|
|
1599
1959
|
|
|
1600
|
-
const
|
|
1960
|
+
const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, settings.model);
|
|
1961
|
+
|
|
1962
|
+
const sessionOptions: CreateAgentSessionOptions = {
|
|
1601
1963
|
sessionManager: SessionManager.inMemory(),
|
|
1602
1964
|
model: settings.model,
|
|
1603
|
-
|
|
1965
|
+
...modelRuntimeOptions,
|
|
1604
1966
|
thinkingLevel: settings.thinkingLevel,
|
|
1605
1967
|
// Match pi's default coding-agent toolset (read/bash/edit/write).
|
|
1606
1968
|
tools: ["read", "bash", "edit", "write"],
|
|
1607
1969
|
resourceLoader: createBtwResourceLoader(ctx),
|
|
1608
|
-
}
|
|
1970
|
+
};
|
|
1971
|
+
const { session } = await createAgentSession(sessionOptions);
|
|
1609
1972
|
|
|
1610
1973
|
const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode, settings.model);
|
|
1611
1974
|
if (seedMessages.length > 0) {
|
|
1612
1975
|
session.agent.state.messages = seedMessages as typeof session.state.messages;
|
|
1613
1976
|
}
|
|
1614
1977
|
|
|
1615
|
-
return { session, mode, subscriptions: new Set(), sideThreadStartIndex };
|
|
1978
|
+
return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve() };
|
|
1616
1979
|
}
|
|
1617
1980
|
|
|
1618
1981
|
async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
|
|
1619
|
-
const settings = await resolveBtwSettings(ctx);
|
|
1982
|
+
const settings = await resolveBtwSettings(ctx, true);
|
|
1620
1983
|
if (!settings.model) {
|
|
1621
1984
|
return null;
|
|
1622
1985
|
}
|
|
@@ -1626,12 +1989,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1626
1989
|
}
|
|
1627
1990
|
|
|
1628
1991
|
await disposeBtwSession();
|
|
1629
|
-
activeBtwSession = await createBtwSubSession(ctx, mode);
|
|
1992
|
+
activeBtwSession = await createBtwSubSession(ctx, mode, settings);
|
|
1630
1993
|
return activeBtwSession;
|
|
1631
1994
|
}
|
|
1632
1995
|
|
|
1633
1996
|
async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
|
|
1634
|
-
if (!ctx
|
|
1997
|
+
if (!canRenderBtwOverlay(ctx)) {
|
|
1635
1998
|
return;
|
|
1636
1999
|
}
|
|
1637
2000
|
lastUiContext = ctx;
|
|
@@ -1651,7 +2014,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1651
2014
|
if (activeBtwSession) {
|
|
1652
2015
|
clearBtwSessionSubscriptions(activeBtwSession);
|
|
1653
2016
|
}
|
|
1654
|
-
runtime.handle?.hide();
|
|
1655
2017
|
if (overlayRuntime === runtime) {
|
|
1656
2018
|
overlayRuntime = null;
|
|
1657
2019
|
}
|
|
@@ -1675,16 +2037,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
1675
2037
|
() => transcriptState.entries,
|
|
1676
2038
|
() => overlayStatus,
|
|
1677
2039
|
() => pendingMode,
|
|
2040
|
+
() => overlayWidthMode,
|
|
1678
2041
|
(value) => {
|
|
1679
2042
|
void submitFromOverlay(ctx, value);
|
|
1680
2043
|
},
|
|
1681
2044
|
() => {
|
|
1682
|
-
void
|
|
2045
|
+
void dismissOrAbortOverlaySession();
|
|
1683
2046
|
},
|
|
1684
2047
|
() => {
|
|
1685
2048
|
overlayRuntime?.handle?.unfocus();
|
|
1686
2049
|
overlayRuntime?.refresh?.();
|
|
1687
2050
|
},
|
|
2051
|
+
() => {
|
|
2052
|
+
void toggleOverlayWidth(ctx);
|
|
2053
|
+
},
|
|
1688
2054
|
);
|
|
1689
2055
|
|
|
1690
2056
|
overlay.focused = runtime.handle?.isFocused() ?? true;
|
|
@@ -1698,7 +2064,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1698
2064
|
};
|
|
1699
2065
|
runtime.close = () => {
|
|
1700
2066
|
overlayDraft = overlay.getDraft();
|
|
1701
|
-
overlay.dispose();
|
|
1702
2067
|
closeRuntime();
|
|
1703
2068
|
};
|
|
1704
2069
|
|
|
@@ -1712,14 +2077,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1712
2077
|
},
|
|
1713
2078
|
{
|
|
1714
2079
|
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
|
-
},
|
|
2080
|
+
overlayOptions: getOverlayOptions(),
|
|
1723
2081
|
onHandle: (handle) => {
|
|
1724
2082
|
runtime.handle = handle;
|
|
1725
2083
|
handle.focus();
|
|
@@ -1743,6 +2101,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1743
2101
|
if (name === "btw") {
|
|
1744
2102
|
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
1745
2103
|
if (!question) {
|
|
2104
|
+
if (!canRenderBtwOverlay(ctx)) {
|
|
2105
|
+
notifyInlineQuestionRequired(ctx, "/btw");
|
|
2106
|
+
return true;
|
|
2107
|
+
}
|
|
1746
2108
|
await ensureBtwSession(ctx, pendingMode);
|
|
1747
2109
|
await ensureOverlay(ctx);
|
|
1748
2110
|
return true;
|
|
@@ -1758,6 +2120,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1758
2120
|
|
|
1759
2121
|
if (name === "btw:tangent") {
|
|
1760
2122
|
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
2123
|
+
if (!question && !canRenderBtwOverlay(ctx)) {
|
|
2124
|
+
notifyInlineQuestionRequired(ctx, "/btw:tangent");
|
|
2125
|
+
return true;
|
|
2126
|
+
}
|
|
1761
2127
|
if (pendingMode !== "tangent") {
|
|
1762
2128
|
await resetThread(ctx, true, "tangent");
|
|
1763
2129
|
}
|
|
@@ -1773,8 +2139,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1773
2139
|
}
|
|
1774
2140
|
|
|
1775
2141
|
if (name === "btw:new") {
|
|
1776
|
-
await resetThread(ctx, true, "contextual");
|
|
1777
2142
|
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
2143
|
+
if (!question && !canRenderBtwOverlay(ctx)) {
|
|
2144
|
+
notifyInlineQuestionRequired(ctx, "/btw:new");
|
|
2145
|
+
return true;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
await resetThread(ctx, true, "contextual");
|
|
1778
2149
|
if (question) {
|
|
1779
2150
|
await runBtw(ctx, question, save, "contextual");
|
|
1780
2151
|
} else {
|
|
@@ -1840,6 +2211,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1840
2211
|
}
|
|
1841
2212
|
|
|
1842
2213
|
if (name === "btw:inject") {
|
|
2214
|
+
await btwSubmissionQueue;
|
|
1843
2215
|
if (pendingThread.length === 0) {
|
|
1844
2216
|
notify(ctx, "No BTW thread to inject.", "warning");
|
|
1845
2217
|
return true;
|
|
@@ -1868,6 +2240,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1868
2240
|
}
|
|
1869
2241
|
|
|
1870
2242
|
if (name === "btw:summarize") {
|
|
2243
|
+
await btwSubmissionQueue;
|
|
1871
2244
|
if (pendingThread.length === 0) {
|
|
1872
2245
|
notify(ctx, "No BTW thread to summarize.", "warning");
|
|
1873
2246
|
return true;
|
|
@@ -1943,6 +2316,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1943
2316
|
persist = true,
|
|
1944
2317
|
mode: BtwThreadMode = "contextual",
|
|
1945
2318
|
): Promise<void> {
|
|
2319
|
+
invalidateBtwLifecycle();
|
|
1946
2320
|
await disposeBtwSession();
|
|
1947
2321
|
pendingThread = [];
|
|
1948
2322
|
pendingMode = mode;
|
|
@@ -1957,6 +2331,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1957
2331
|
}
|
|
1958
2332
|
|
|
1959
2333
|
async function restoreThread(ctx: ExtensionContext): Promise<void> {
|
|
2334
|
+
invalidateBtwLifecycle();
|
|
1960
2335
|
await disposeBtwSession();
|
|
1961
2336
|
pendingThread = [];
|
|
1962
2337
|
pendingMode = "contextual";
|
|
@@ -2031,8 +2406,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
2031
2406
|
saveRequested: boolean,
|
|
2032
2407
|
mode: BtwThreadMode,
|
|
2033
2408
|
): Promise<void> {
|
|
2409
|
+
const generation = btwLifecycleGeneration;
|
|
2410
|
+
const submission = btwSubmissionQueue.then(async () => {
|
|
2411
|
+
if (generation !== btwLifecycleGeneration) {
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
await executeBtw(ctx, question, saveRequested, mode, generation);
|
|
2415
|
+
});
|
|
2416
|
+
btwSubmissionQueue = submission.catch(() => {});
|
|
2417
|
+
await submission;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
async function executeBtw(
|
|
2421
|
+
ctx: ExtensionCommandContext,
|
|
2422
|
+
question: string,
|
|
2423
|
+
saveRequested: boolean,
|
|
2424
|
+
mode: BtwThreadMode,
|
|
2425
|
+
generation: number,
|
|
2426
|
+
): Promise<void> {
|
|
2427
|
+
const isCurrentGeneration = () => generation === btwLifecycleGeneration;
|
|
2034
2428
|
lastUiContext = ctx;
|
|
2035
2429
|
const settings = await resolveBtwSettings(ctx);
|
|
2430
|
+
if (!isCurrentGeneration()) {
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2036
2433
|
const model = settings.model;
|
|
2037
2434
|
if (!model) {
|
|
2038
2435
|
const message = settings.fallbackReason || "No active model selected.";
|
|
@@ -2042,7 +2439,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2042
2439
|
}
|
|
2043
2440
|
|
|
2044
2441
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
2045
|
-
if (!
|
|
2442
|
+
if (!isCurrentGeneration()) {
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
if (!hasUsableModelAuth(ctx, model, auth)) {
|
|
2046
2446
|
const message = auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error;
|
|
2047
2447
|
setOverlayStatus(message, ctx);
|
|
2048
2448
|
notify(ctx, message, "error");
|
|
@@ -2051,6 +2451,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
2051
2451
|
}
|
|
2052
2452
|
|
|
2053
2453
|
const sessionRuntime = await ensureBtwSession(ctx, mode);
|
|
2454
|
+
if (!isCurrentGeneration()) {
|
|
2455
|
+
if (sessionRuntime && activeBtwSession === sessionRuntime) {
|
|
2456
|
+
await disposeBtwSession();
|
|
2457
|
+
}
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2054
2460
|
if (!sessionRuntime) {
|
|
2055
2461
|
setOverlayStatus("No active model selected.", ctx);
|
|
2056
2462
|
notify(ctx, "No active model selected.", "error");
|
|
@@ -2059,22 +2465,58 @@ export default function (pi: ExtensionAPI) {
|
|
|
2059
2465
|
|
|
2060
2466
|
const session = sessionRuntime.session;
|
|
2061
2467
|
const wasBusy = !ctx.isIdle();
|
|
2468
|
+
const overlayAvailable = canRenderBtwOverlay(ctx);
|
|
2062
2469
|
pendingMode = mode;
|
|
2063
2470
|
const thinkingLevel = settings.thinkingLevel;
|
|
2064
2471
|
|
|
2472
|
+
let releasePromptTurn!: () => void;
|
|
2473
|
+
const previousPromptTurns = sessionRuntime.promptQueue;
|
|
2474
|
+
const currentPromptTurn = new Promise<void>((resolve) => {
|
|
2475
|
+
releasePromptTurn = resolve;
|
|
2476
|
+
});
|
|
2477
|
+
sessionRuntime.promptQueue = previousPromptTurns.then(() => currentPromptTurn);
|
|
2478
|
+
|
|
2479
|
+
if (session.isStreaming || sessionRuntime.abortPromise) {
|
|
2480
|
+
setOverlayStatus("⏳ waiting for the current BTW turn to finish...", ctx);
|
|
2481
|
+
}
|
|
2482
|
+
await previousPromptTurns;
|
|
2483
|
+
if (activeBtwSession !== sessionRuntime) {
|
|
2484
|
+
releasePromptTurn();
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
if (sessionRuntime.abortPromise) {
|
|
2489
|
+
setOverlayStatus("⏳ waiting for cancellation to finish...", ctx);
|
|
2490
|
+
await sessionRuntime.abortPromise;
|
|
2491
|
+
if (activeBtwSession !== sessionRuntime) {
|
|
2492
|
+
releasePromptTurn();
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
if (!isCurrentGeneration()) {
|
|
2498
|
+
releasePromptTurn();
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
sessionRuntime.abortPromise = undefined;
|
|
2065
2503
|
setOverlayStatus("⏳ streaming...", ctx);
|
|
2066
2504
|
await ensureOverlay(ctx);
|
|
2067
2505
|
|
|
2068
2506
|
try {
|
|
2069
2507
|
await session.prompt(question, { source: "extension" });
|
|
2508
|
+
if (!isCurrentGeneration()) {
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2070
2511
|
|
|
2071
2512
|
const response = getLastAssistantMessage(session);
|
|
2072
2513
|
if (!response) {
|
|
2073
2514
|
throw new Error("BTW request finished without a response.");
|
|
2074
2515
|
}
|
|
2075
2516
|
if (response.stopReason === "aborted") {
|
|
2076
|
-
|
|
2077
|
-
|
|
2517
|
+
const abortedTurnId = transcriptState.currentTurnId ?? transcriptState.lastTurnId;
|
|
2518
|
+
finishTranscriptTurn(transcriptState, abortedTurnId, "aborted");
|
|
2519
|
+
setOverlayStatus("⏹ Aborted. Press Esc again to dismiss the BTW overlay.", ctx);
|
|
2078
2520
|
return;
|
|
2079
2521
|
}
|
|
2080
2522
|
if (response.stopReason === "error") {
|
|
@@ -2102,8 +2544,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
2102
2544
|
pendingThread.push(details);
|
|
2103
2545
|
pi.appendEntry(BTW_ENTRY_TYPE, details);
|
|
2104
2546
|
|
|
2105
|
-
const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy);
|
|
2106
|
-
if (
|
|
2547
|
+
const saveState = saveVisibleBtwNote(pi, details, saveRequested || !overlayAvailable, wasBusy);
|
|
2548
|
+
if (!overlayAvailable) {
|
|
2549
|
+
const message =
|
|
2550
|
+
saveState === "queued"
|
|
2551
|
+
? "BTW response queued to display after the current turn finishes."
|
|
2552
|
+
: "Displayed BTW response in the session.";
|
|
2553
|
+
notify(ctx, message, "info");
|
|
2554
|
+
setOverlayStatus(message, ctx);
|
|
2555
|
+
} else if (saveState === "saved") {
|
|
2107
2556
|
notify(ctx, "Saved BTW note to the session.", "info");
|
|
2108
2557
|
setOverlayStatus("Saved BTW note to the session.", ctx);
|
|
2109
2558
|
} else if (saveState === "queued") {
|
|
@@ -2113,12 +2562,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
2113
2562
|
setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx);
|
|
2114
2563
|
}
|
|
2115
2564
|
} catch (error) {
|
|
2565
|
+
if (!isCurrentGeneration()) {
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2116
2568
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2117
2569
|
setTranscriptFailure(transcriptState, errorMessage);
|
|
2118
2570
|
setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx);
|
|
2119
2571
|
notify(ctx, errorMessage, "error");
|
|
2120
2572
|
await disposeBtwSession();
|
|
2121
2573
|
} finally {
|
|
2574
|
+
releasePromptTurn();
|
|
2122
2575
|
syncUi(ctx);
|
|
2123
2576
|
}
|
|
2124
2577
|
}
|
|
@@ -2130,7 +2583,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
2130
2583
|
async function getBtwHandoffThread(
|
|
2131
2584
|
ctx: ExtensionCommandContext,
|
|
2132
2585
|
): Promise<{ sessionRuntime: BtwSessionRuntime | null; thread: BtwHandoffExchange[] }> {
|
|
2586
|
+
const pendingSubmissions = btwSubmissionQueue;
|
|
2587
|
+
await pendingSubmissions;
|
|
2588
|
+
|
|
2133
2589
|
const sessionRuntime = activeBtwSession ?? (await ensureBtwSession(ctx, pendingMode));
|
|
2590
|
+
if (sessionRuntime) {
|
|
2591
|
+
const pendingPromptTurns = sessionRuntime.promptQueue;
|
|
2592
|
+
const pendingAbort = sessionRuntime.abortPromise;
|
|
2593
|
+
await pendingPromptTurns;
|
|
2594
|
+
await pendingAbort;
|
|
2595
|
+
if (activeBtwSession !== sessionRuntime) {
|
|
2596
|
+
throw new Error("BTW session closed before handoff completed.");
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2134
2600
|
const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : [];
|
|
2135
2601
|
const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff();
|
|
2136
2602
|
|
|
@@ -2149,18 +2615,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
2149
2615
|
}
|
|
2150
2616
|
|
|
2151
2617
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
2152
|
-
if (!
|
|
2618
|
+
if (!hasUsableModelAuth(ctx, model, auth)) {
|
|
2153
2619
|
throw new Error(auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error);
|
|
2154
2620
|
}
|
|
2155
2621
|
|
|
2156
|
-
const
|
|
2622
|
+
const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, model);
|
|
2623
|
+
|
|
2624
|
+
const sessionOptions: CreateAgentSessionOptions = {
|
|
2157
2625
|
sessionManager: SessionManager.inMemory(),
|
|
2158
2626
|
model,
|
|
2159
|
-
|
|
2627
|
+
...modelRuntimeOptions,
|
|
2160
2628
|
thinkingLevel: "off",
|
|
2161
2629
|
tools: [],
|
|
2162
2630
|
resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]),
|
|
2163
|
-
}
|
|
2631
|
+
};
|
|
2632
|
+
const { session } = await createAgentSession(sessionOptions);
|
|
2164
2633
|
|
|
2165
2634
|
try {
|
|
2166
2635
|
await session.prompt(formatThread(thread), { source: "extension" });
|
|
@@ -2197,29 +2666,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
2197
2666
|
|
|
2198
2667
|
pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => {
|
|
2199
2668
|
const details = message.details as BtwDetails | undefined;
|
|
2200
|
-
const content =
|
|
2201
|
-
|
|
2669
|
+
const content = details
|
|
2670
|
+
? buildBtwMessageContent(details.question, details.answer)
|
|
2671
|
+
: typeof message.content === "string"
|
|
2672
|
+
? message.content
|
|
2673
|
+
: "[non-text btw message]";
|
|
2674
|
+
|
|
2675
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
2676
|
+
box.addChild(new Text(theme.fg("accent", theme.bold("[BTW]")), 0, 0));
|
|
2677
|
+
box.addChild(
|
|
2678
|
+
new Markdown(content, 0, 0, getMarkdownTheme(), {
|
|
2679
|
+
color: (text: string) => theme.fg("customMessageText", text),
|
|
2680
|
+
}),
|
|
2681
|
+
);
|
|
2202
2682
|
|
|
2203
2683
|
if (expanded && details) {
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2684
|
+
box.addChild(
|
|
2685
|
+
new Text(
|
|
2686
|
+
theme.fg(
|
|
2687
|
+
"dim",
|
|
2688
|
+
`model: ${details.provider}/${details.model} (${details.api ?? "openai-responses"}) · thinking: ${details.thinkingLevel}`,
|
|
2689
|
+
),
|
|
2690
|
+
0,
|
|
2691
|
+
0,
|
|
2208
2692
|
),
|
|
2209
2693
|
);
|
|
2210
2694
|
|
|
2211
2695
|
if (details.usage) {
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2696
|
+
box.addChild(
|
|
2697
|
+
new Text(
|
|
2698
|
+
theme.fg(
|
|
2699
|
+
"dim",
|
|
2700
|
+
`tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`,
|
|
2701
|
+
),
|
|
2702
|
+
0,
|
|
2703
|
+
0,
|
|
2216
2704
|
),
|
|
2217
2705
|
);
|
|
2218
2706
|
}
|
|
2219
2707
|
}
|
|
2220
2708
|
|
|
2221
|
-
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
2222
|
-
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
2223
2709
|
return box;
|
|
2224
2710
|
});
|
|
2225
2711
|
|
|
@@ -2238,6 +2724,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2238
2724
|
});
|
|
2239
2725
|
|
|
2240
2726
|
pi.on("session_shutdown", async () => {
|
|
2727
|
+
invalidateBtwLifecycle();
|
|
2241
2728
|
await disposeBtwSession();
|
|
2242
2729
|
dismissOverlay();
|
|
2243
2730
|
});
|
|
@@ -2251,6 +2738,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
2251
2738
|
});
|
|
2252
2739
|
}
|
|
2253
2740
|
|
|
2741
|
+
pi.registerShortcut(BTW_WIDTH_TOGGLE_SHORTCUT, {
|
|
2742
|
+
description: "Toggle the BTW overlay between window and full-width layouts.",
|
|
2743
|
+
handler: async () => {
|
|
2744
|
+
if (!overlayRuntime || !lastUiContext) {
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
await toggleOverlayWidth(lastUiContext);
|
|
2748
|
+
},
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2254
2751
|
pi.registerCommand("btw", {
|
|
2255
2752
|
description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.",
|
|
2256
2753
|
handler: async (args, ctx) => {
|
|
@@ -2307,3 +2804,4 @@ export default function (pi: ExtensionAPI) {
|
|
|
2307
2804
|
},
|
|
2308
2805
|
});
|
|
2309
2806
|
}
|
|
2807
|
+
|