pi-btw 0.4.0 → 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 +662 -120
- 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,12 +1211,28 @@ 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);
|
|
1001
1228
|
}
|
|
1002
1229
|
}
|
|
1003
1230
|
|
|
1231
|
+
/** Fixed overlay rows outside the transcript viewport (must match render() structure). */
|
|
1232
|
+
const BTW_OVERLAY_CHROME_LINES = 9;
|
|
1233
|
+
/** Indent applied to transcript block bodies. */
|
|
1234
|
+
const BTW_BLOCK_INDENT = " ";
|
|
1235
|
+
|
|
1004
1236
|
function getOverlayTitle(mode: BtwThreadMode): string {
|
|
1005
1237
|
return mode === "tangent" ? "BTW tangent" : "BTW";
|
|
1006
1238
|
}
|
|
@@ -1024,14 +1256,19 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1024
1256
|
private readonly readTranscriptEntries: () => BtwTranscript;
|
|
1025
1257
|
private readonly getStatus: () => string | null;
|
|
1026
1258
|
private readonly getMode: () => BtwThreadMode;
|
|
1259
|
+
private readonly getWidthMode: () => BtwOverlayWidthMode;
|
|
1027
1260
|
private readonly onSubmitCallback: (value: string) => void;
|
|
1028
1261
|
private readonly onDismissCallback: () => void;
|
|
1029
1262
|
private readonly onUnfocusCallback: () => void;
|
|
1263
|
+
private readonly onToggleWidthCallback: () => void;
|
|
1030
1264
|
private readonly tui: TUI;
|
|
1031
1265
|
private readonly theme: ExtensionContext["ui"]["theme"];
|
|
1266
|
+
private readonly markdownTheme: MarkdownTheme;
|
|
1267
|
+
private readonly managesMouseReporting: boolean;
|
|
1032
1268
|
private transcriptLines: string[] = [];
|
|
1033
1269
|
private transcriptScrollOffset = 0;
|
|
1034
1270
|
private transcriptViewportHeight = 8;
|
|
1271
|
+
private contentWidth = 66;
|
|
1035
1272
|
private followTranscript = true;
|
|
1036
1273
|
private _focused = false;
|
|
1037
1274
|
private modeTextValue = "";
|
|
@@ -1055,19 +1292,27 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1055
1292
|
readTranscriptEntries: () => BtwTranscript,
|
|
1056
1293
|
getStatus: () => string | null,
|
|
1057
1294
|
getMode: () => BtwThreadMode,
|
|
1295
|
+
getWidthMode: () => BtwOverlayWidthMode,
|
|
1058
1296
|
onSubmit: (value: string) => void,
|
|
1059
1297
|
onDismiss: () => void,
|
|
1060
1298
|
onUnfocus: () => void,
|
|
1299
|
+
onToggleWidth: () => void,
|
|
1061
1300
|
) {
|
|
1062
1301
|
super();
|
|
1063
1302
|
this.tui = tui;
|
|
1064
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";
|
|
1065
1308
|
this.readTranscriptEntries = readTranscriptEntries;
|
|
1066
1309
|
this.getStatus = getStatus;
|
|
1067
1310
|
this.getMode = getMode;
|
|
1311
|
+
this.getWidthMode = getWidthMode;
|
|
1068
1312
|
this.onSubmitCallback = onSubmit;
|
|
1069
1313
|
this.onDismissCallback = onDismiss;
|
|
1070
1314
|
this.onUnfocusCallback = onUnfocus;
|
|
1315
|
+
this.onToggleWidthCallback = onToggleWidth;
|
|
1071
1316
|
|
|
1072
1317
|
this.modeText = new Text("", 1, 0);
|
|
1073
1318
|
this.summaryText = new Text("", 1, 0);
|
|
@@ -1085,6 +1330,10 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1085
1330
|
|
|
1086
1331
|
this.hintsText = new Text("", 1, 0);
|
|
1087
1332
|
|
|
1333
|
+
if (this.managesMouseReporting) {
|
|
1334
|
+
this.tui.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1088
1337
|
const originalHandleInput = this.input.handleInput.bind(this.input);
|
|
1089
1338
|
this.input.handleInput = (data: string) => {
|
|
1090
1339
|
if (keybindings.matches(data, "app.clear")) {
|
|
@@ -1108,20 +1357,36 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1108
1357
|
this.refresh();
|
|
1109
1358
|
}
|
|
1110
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
|
+
|
|
1111
1367
|
private frameLine(content: string, innerWidth: number): string {
|
|
1112
1368
|
const truncated = truncateToWidth(content, innerWidth, "");
|
|
1113
1369
|
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
1114
|
-
|
|
1370
|
+
if (this.borderless) {
|
|
1371
|
+
return `${truncated}${" ".repeat(padding)}`;
|
|
1372
|
+
}
|
|
1373
|
+
return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
|
|
1115
1374
|
}
|
|
1116
1375
|
|
|
1117
1376
|
private ruleLine(innerWidth: number): string {
|
|
1118
|
-
|
|
1377
|
+
if (this.borderless) {
|
|
1378
|
+
return this.theme.fg("border", "─".repeat(innerWidth));
|
|
1379
|
+
}
|
|
1380
|
+
return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
|
|
1119
1381
|
}
|
|
1120
1382
|
|
|
1121
1383
|
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
1384
|
+
if (this.borderless) {
|
|
1385
|
+
return this.theme.fg("border", "─".repeat(innerWidth));
|
|
1386
|
+
}
|
|
1122
1387
|
const left = edge === "top" ? "┌" : "└";
|
|
1123
1388
|
const right = edge === "top" ? "┐" : "┘";
|
|
1124
|
-
return this.theme.fg("
|
|
1389
|
+
return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
1125
1390
|
}
|
|
1126
1391
|
|
|
1127
1392
|
private wrapTranscript(innerWidth: number): string[] {
|
|
@@ -1141,22 +1406,60 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1141
1406
|
return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78)));
|
|
1142
1407
|
}
|
|
1143
1408
|
|
|
1409
|
+
private scrollTranscript(delta: number): void {
|
|
1410
|
+
if (delta < 0) {
|
|
1411
|
+
this.followTranscript = false;
|
|
1412
|
+
}
|
|
1413
|
+
this.transcriptScrollOffset = Math.max(0, this.transcriptScrollOffset + delta);
|
|
1414
|
+
this.tui.requestRender();
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
dispose(): void {
|
|
1418
|
+
if (this.managesMouseReporting) {
|
|
1419
|
+
this.tui.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
private getMouseScrollDelta(data: string): number | null {
|
|
1424
|
+
const match = data.match(/^\x1b\[<(\d+);\d+;\d+[Mm]$/);
|
|
1425
|
+
if (!match) {
|
|
1426
|
+
return null;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const button = Number(match[1]);
|
|
1430
|
+
if ((button & 64) !== 64) {
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
return (button & 1) === 0 ? -3 : 3;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1144
1437
|
handleInput(data: string): void {
|
|
1145
1438
|
if (matchesBtwFocusShortcut(data)) {
|
|
1146
1439
|
this.onUnfocusCallback();
|
|
1147
1440
|
return;
|
|
1148
1441
|
}
|
|
1149
1442
|
|
|
1150
|
-
if (
|
|
1151
|
-
this.
|
|
1152
|
-
|
|
1153
|
-
|
|
1443
|
+
if (matchesBtwWidthToggle(data)) {
|
|
1444
|
+
this.onToggleWidthCallback();
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const mouseScrollDelta = this.getMouseScrollDelta(data);
|
|
1449
|
+
if (mouseScrollDelta !== null) {
|
|
1450
|
+
this.scrollTranscript(mouseScrollDelta);
|
|
1154
1451
|
return;
|
|
1155
1452
|
}
|
|
1156
1453
|
|
|
1157
|
-
if (matchesKey(data, Key.
|
|
1158
|
-
|
|
1159
|
-
this.
|
|
1454
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.up)) {
|
|
1455
|
+
const step = matchesKey(data, Key.pageUp) ? Math.max(1, this.transcriptViewportHeight - 1) : 1;
|
|
1456
|
+
this.scrollTranscript(-step);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.down)) {
|
|
1461
|
+
const step = matchesKey(data, Key.pageDown) ? Math.max(1, this.transcriptViewportHeight - 1) : 1;
|
|
1462
|
+
this.scrollTranscript(step);
|
|
1160
1463
|
return;
|
|
1161
1464
|
}
|
|
1162
1465
|
|
|
@@ -1164,7 +1467,8 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1164
1467
|
}
|
|
1165
1468
|
|
|
1166
1469
|
private inputFrameLine(dialogWidth: number): string {
|
|
1167
|
-
const
|
|
1470
|
+
const borderColumns = this.borderless ? 0 : 2;
|
|
1471
|
+
const targetWidth = Math.max(1, dialogWidth - borderColumns);
|
|
1168
1472
|
const previousFocused = this.input.focused;
|
|
1169
1473
|
// Input.render() emits CURSOR_MARKER when focused. In overlay mode that APC marker
|
|
1170
1474
|
// can skew width/composition on this one row before the TUI strips it, producing a
|
|
@@ -1172,19 +1476,34 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1172
1476
|
// the row stays geometrically stable while the overlay still owns keyboard input.
|
|
1173
1477
|
this.input.focused = false;
|
|
1174
1478
|
try {
|
|
1175
|
-
const
|
|
1176
|
-
|
|
1479
|
+
const renderedInputLine = this.input.render(targetWidth)[0] ?? "";
|
|
1480
|
+
const inputLine = truncateToWidth(renderedInputLine, targetWidth, "");
|
|
1481
|
+
const padding = Math.max(0, targetWidth - visibleWidth(inputLine));
|
|
1482
|
+
if (this.borderless) {
|
|
1483
|
+
return `${inputLine}${" ".repeat(padding)}`;
|
|
1484
|
+
}
|
|
1485
|
+
return `${this.theme.fg("border", "│")}${inputLine}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
|
|
1177
1486
|
} finally {
|
|
1178
1487
|
this.input.focused = previousFocused;
|
|
1179
1488
|
}
|
|
1180
1489
|
}
|
|
1181
1490
|
|
|
1491
|
+
private fitRenderedLine(line: string, width: number): string {
|
|
1492
|
+
return visibleWidth(line) > width ? truncateToWidth(line, width, "") : line;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1182
1495
|
override render(width: number): string[] {
|
|
1183
1496
|
const dialogWidth = Math.max(24, width);
|
|
1184
|
-
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
|
+
}
|
|
1185
1504
|
const transcriptLines = this.wrapTranscript(innerWidth);
|
|
1186
1505
|
const dialogHeight = this.getDialogHeight();
|
|
1187
|
-
const chromeHeight =
|
|
1506
|
+
const chromeHeight = BTW_OVERLAY_CHROME_LINES;
|
|
1188
1507
|
const transcriptHeight = Math.max(6, dialogHeight - chromeHeight);
|
|
1189
1508
|
this.transcriptViewportHeight = transcriptHeight;
|
|
1190
1509
|
|
|
@@ -1229,7 +1548,7 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1229
1548
|
lines.push(this.frameLine(this.theme.fg("dim", this.hintsTextValue.trim()), innerWidth));
|
|
1230
1549
|
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
1231
1550
|
|
|
1232
|
-
return lines;
|
|
1551
|
+
return lines.map((line) => this.fitRenderedLine(line, width));
|
|
1233
1552
|
}
|
|
1234
1553
|
|
|
1235
1554
|
setDraft(value: string): void {
|
|
@@ -1245,6 +1564,15 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1245
1564
|
return this.readTranscriptEntries().map((entry) => ({ ...entry }));
|
|
1246
1565
|
}
|
|
1247
1566
|
|
|
1567
|
+
private rebuildTranscriptLines(): void {
|
|
1568
|
+
this.transcriptLines = buildOverlayTranscript(
|
|
1569
|
+
this.readTranscriptEntries(),
|
|
1570
|
+
this.theme,
|
|
1571
|
+
this.markdownTheme,
|
|
1572
|
+
this.contentWidth,
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1248
1576
|
refresh(): void {
|
|
1249
1577
|
this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`;
|
|
1250
1578
|
this.modeText.setText(this.modeTextValue);
|
|
@@ -1254,7 +1582,7 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1254
1582
|
this.summaryTextValue = `${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`;
|
|
1255
1583
|
this.summaryText.setText(this.summaryTextValue);
|
|
1256
1584
|
|
|
1257
|
-
this.
|
|
1585
|
+
this.rebuildTranscriptLines();
|
|
1258
1586
|
this.transcript.clear();
|
|
1259
1587
|
for (const line of this.transcriptLines) {
|
|
1260
1588
|
this.transcript.addChild(new Text(line, 1, 0));
|
|
@@ -1263,7 +1591,7 @@ class BtwOverlayComponent extends Container implements Focusable {
|
|
|
1263
1591
|
const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
|
|
1264
1592
|
this.statusTextValue = status;
|
|
1265
1593
|
this.statusText.setText(this.statusTextValue);
|
|
1266
|
-
this.hintsTextValue =
|
|
1594
|
+
this.hintsTextValue = `Scroll wheel ↑↓ PgUp/PgDn · Enter · ${BTW_FOCUS_SHORTCUTS_LABEL} focus · Alt+w width · Esc`;
|
|
1267
1595
|
this.hintsText.setText(this.hintsTextValue);
|
|
1268
1596
|
this.tui.requestRender();
|
|
1269
1597
|
}
|
|
@@ -1277,9 +1605,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1277
1605
|
let transcriptState = createEmptyTranscriptState();
|
|
1278
1606
|
let overlayStatus: string | null = null;
|
|
1279
1607
|
let overlayDraft = "";
|
|
1608
|
+
let overlayWidthMode: BtwOverlayWidthMode = "window";
|
|
1280
1609
|
let overlayRuntime: OverlayRuntime | null = null;
|
|
1281
1610
|
let lastUiContext: ExtensionContext | ExtensionCommandContext | null = null;
|
|
1282
1611
|
let activeBtwSession: BtwSessionRuntime | null = null;
|
|
1612
|
+
let btwLifecycleGeneration = 0;
|
|
1613
|
+
let btwSubmissionQueue = Promise.resolve();
|
|
1614
|
+
|
|
1615
|
+
function invalidateBtwLifecycle(): void {
|
|
1616
|
+
btwLifecycleGeneration += 1;
|
|
1617
|
+
}
|
|
1283
1618
|
|
|
1284
1619
|
function syncUi(ctx?: ExtensionContext | ExtensionCommandContext): void {
|
|
1285
1620
|
const activeCtx = ctx ?? lastUiContext;
|
|
@@ -1330,6 +1665,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
1330
1665
|
overlayRuntime?.refresh?.();
|
|
1331
1666
|
}
|
|
1332
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
|
+
|
|
1333
1705
|
function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void {
|
|
1334
1706
|
if (!sessionRuntime.subscriptions.delete(unsubscribe)) {
|
|
1335
1707
|
return;
|
|
@@ -1396,6 +1768,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1396
1768
|
sessionRuntime.subscriptions.add(unsubscribe);
|
|
1397
1769
|
}
|
|
1398
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
|
+
|
|
1399
1780
|
async function disposeBtwSession(): Promise<void> {
|
|
1400
1781
|
const current = activeBtwSession;
|
|
1401
1782
|
activeBtwSession = null;
|
|
@@ -1404,28 +1785,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
1404
1785
|
}
|
|
1405
1786
|
|
|
1406
1787
|
clearBtwSessionSubscriptions(current);
|
|
1407
|
-
|
|
1408
|
-
try {
|
|
1409
|
-
await current.session.abort();
|
|
1410
|
-
} catch {
|
|
1411
|
-
// Ignore abort errors during BTW session replacement/shutdown.
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1788
|
+
await requestBtwSessionAbort(current);
|
|
1414
1789
|
current.session.dispose();
|
|
1415
1790
|
}
|
|
1416
1791
|
|
|
1417
1792
|
async function dismissOverlaySession(): Promise<void> {
|
|
1793
|
+
invalidateBtwLifecycle();
|
|
1418
1794
|
dismissOverlay();
|
|
1419
1795
|
await disposeBtwSession();
|
|
1420
1796
|
}
|
|
1421
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
|
+
|
|
1422
1820
|
async function resolveBtwModel(
|
|
1423
1821
|
ctx: ExtensionCommandContext,
|
|
1424
1822
|
notifyOnFallback = false,
|
|
1425
1823
|
): Promise<ResolvedBtwModel> {
|
|
1426
1824
|
if (btwModelOverride) {
|
|
1427
1825
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(btwModelOverride);
|
|
1428
|
-
if (
|
|
1826
|
+
if (hasUsableModelAuth(ctx, btwModelOverride, auth)) {
|
|
1429
1827
|
return {
|
|
1430
1828
|
model: btwModelOverride,
|
|
1431
1829
|
source: "override",
|
|
@@ -1516,6 +1914,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1516
1914
|
}
|
|
1517
1915
|
|
|
1518
1916
|
async function setBtwModelOverride(ctx: ExtensionCommandContext, nextModel: SessionModel | null): Promise<void> {
|
|
1917
|
+
invalidateBtwLifecycle();
|
|
1519
1918
|
btwModelOverride = nextModel;
|
|
1520
1919
|
const details: BtwModelOverrideDetails = nextModel
|
|
1521
1920
|
? { action: "set", timestamp: Date.now(), provider: nextModel.provider, id: nextModel.id, api: nextModel.api }
|
|
@@ -1534,6 +1933,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1534
1933
|
ctx: ExtensionCommandContext,
|
|
1535
1934
|
nextThinkingLevel: SessionThinkingLevel | null,
|
|
1536
1935
|
): Promise<void> {
|
|
1936
|
+
invalidateBtwLifecycle();
|
|
1537
1937
|
btwThinkingOverride = nextThinkingLevel;
|
|
1538
1938
|
const details: BtwThinkingOverrideDetails = nextThinkingLevel
|
|
1539
1939
|
? { action: "set", timestamp: Date.now(), thinkingLevel: nextThinkingLevel }
|
|
@@ -1548,32 +1948,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
1548
1948
|
notify(ctx, `${message} ${describeResolvedThinking(settings)}`, "info");
|
|
1549
1949
|
}
|
|
1550
1950
|
|
|
1551
|
-
async function createBtwSubSession(
|
|
1552
|
-
|
|
1951
|
+
async function createBtwSubSession(
|
|
1952
|
+
ctx: ExtensionCommandContext,
|
|
1953
|
+
mode: BtwThreadMode,
|
|
1954
|
+
settings: ResolvedBtwSettings,
|
|
1955
|
+
): Promise<BtwSessionRuntime> {
|
|
1553
1956
|
if (!settings.model) {
|
|
1554
1957
|
throw new Error(settings.fallbackReason || "No active model selected.");
|
|
1555
1958
|
}
|
|
1556
1959
|
|
|
1557
|
-
const
|
|
1960
|
+
const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, settings.model);
|
|
1961
|
+
|
|
1962
|
+
const sessionOptions: CreateAgentSessionOptions = {
|
|
1558
1963
|
sessionManager: SessionManager.inMemory(),
|
|
1559
1964
|
model: settings.model,
|
|
1560
|
-
|
|
1965
|
+
...modelRuntimeOptions,
|
|
1561
1966
|
thinkingLevel: settings.thinkingLevel,
|
|
1562
1967
|
// Match pi's default coding-agent toolset (read/bash/edit/write).
|
|
1563
1968
|
tools: ["read", "bash", "edit", "write"],
|
|
1564
1969
|
resourceLoader: createBtwResourceLoader(ctx),
|
|
1565
|
-
}
|
|
1970
|
+
};
|
|
1971
|
+
const { session } = await createAgentSession(sessionOptions);
|
|
1566
1972
|
|
|
1567
1973
|
const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode, settings.model);
|
|
1568
1974
|
if (seedMessages.length > 0) {
|
|
1569
1975
|
session.agent.state.messages = seedMessages as typeof session.state.messages;
|
|
1570
1976
|
}
|
|
1571
1977
|
|
|
1572
|
-
return { session, mode, subscriptions: new Set(), sideThreadStartIndex };
|
|
1978
|
+
return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve() };
|
|
1573
1979
|
}
|
|
1574
1980
|
|
|
1575
1981
|
async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
|
|
1576
|
-
const settings = await resolveBtwSettings(ctx);
|
|
1982
|
+
const settings = await resolveBtwSettings(ctx, true);
|
|
1577
1983
|
if (!settings.model) {
|
|
1578
1984
|
return null;
|
|
1579
1985
|
}
|
|
@@ -1583,12 +1989,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1583
1989
|
}
|
|
1584
1990
|
|
|
1585
1991
|
await disposeBtwSession();
|
|
1586
|
-
activeBtwSession = await createBtwSubSession(ctx, mode);
|
|
1992
|
+
activeBtwSession = await createBtwSubSession(ctx, mode, settings);
|
|
1587
1993
|
return activeBtwSession;
|
|
1588
1994
|
}
|
|
1589
1995
|
|
|
1590
1996
|
async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
|
|
1591
|
-
if (!ctx
|
|
1997
|
+
if (!canRenderBtwOverlay(ctx)) {
|
|
1592
1998
|
return;
|
|
1593
1999
|
}
|
|
1594
2000
|
lastUiContext = ctx;
|
|
@@ -1608,7 +2014,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1608
2014
|
if (activeBtwSession) {
|
|
1609
2015
|
clearBtwSessionSubscriptions(activeBtwSession);
|
|
1610
2016
|
}
|
|
1611
|
-
runtime.handle?.hide();
|
|
1612
2017
|
if (overlayRuntime === runtime) {
|
|
1613
2018
|
overlayRuntime = null;
|
|
1614
2019
|
}
|
|
@@ -1632,16 +2037,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
1632
2037
|
() => transcriptState.entries,
|
|
1633
2038
|
() => overlayStatus,
|
|
1634
2039
|
() => pendingMode,
|
|
2040
|
+
() => overlayWidthMode,
|
|
1635
2041
|
(value) => {
|
|
1636
2042
|
void submitFromOverlay(ctx, value);
|
|
1637
2043
|
},
|
|
1638
2044
|
() => {
|
|
1639
|
-
void
|
|
2045
|
+
void dismissOrAbortOverlaySession();
|
|
1640
2046
|
},
|
|
1641
2047
|
() => {
|
|
1642
2048
|
overlayRuntime?.handle?.unfocus();
|
|
1643
2049
|
overlayRuntime?.refresh?.();
|
|
1644
2050
|
},
|
|
2051
|
+
() => {
|
|
2052
|
+
void toggleOverlayWidth(ctx);
|
|
2053
|
+
},
|
|
1645
2054
|
);
|
|
1646
2055
|
|
|
1647
2056
|
overlay.focused = runtime.handle?.isFocused() ?? true;
|
|
@@ -1668,14 +2077,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1668
2077
|
},
|
|
1669
2078
|
{
|
|
1670
2079
|
overlay: true,
|
|
1671
|
-
overlayOptions:
|
|
1672
|
-
width: "78%",
|
|
1673
|
-
minWidth: 72,
|
|
1674
|
-
maxHeight: "78%",
|
|
1675
|
-
anchor: "top-center",
|
|
1676
|
-
margin: { top: 1, left: 2, right: 2 },
|
|
1677
|
-
nonCapturing: true,
|
|
1678
|
-
},
|
|
2080
|
+
overlayOptions: getOverlayOptions(),
|
|
1679
2081
|
onHandle: (handle) => {
|
|
1680
2082
|
runtime.handle = handle;
|
|
1681
2083
|
handle.focus();
|
|
@@ -1699,6 +2101,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1699
2101
|
if (name === "btw") {
|
|
1700
2102
|
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
1701
2103
|
if (!question) {
|
|
2104
|
+
if (!canRenderBtwOverlay(ctx)) {
|
|
2105
|
+
notifyInlineQuestionRequired(ctx, "/btw");
|
|
2106
|
+
return true;
|
|
2107
|
+
}
|
|
1702
2108
|
await ensureBtwSession(ctx, pendingMode);
|
|
1703
2109
|
await ensureOverlay(ctx);
|
|
1704
2110
|
return true;
|
|
@@ -1714,6 +2120,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1714
2120
|
|
|
1715
2121
|
if (name === "btw:tangent") {
|
|
1716
2122
|
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
2123
|
+
if (!question && !canRenderBtwOverlay(ctx)) {
|
|
2124
|
+
notifyInlineQuestionRequired(ctx, "/btw:tangent");
|
|
2125
|
+
return true;
|
|
2126
|
+
}
|
|
1717
2127
|
if (pendingMode !== "tangent") {
|
|
1718
2128
|
await resetThread(ctx, true, "tangent");
|
|
1719
2129
|
}
|
|
@@ -1729,8 +2139,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1729
2139
|
}
|
|
1730
2140
|
|
|
1731
2141
|
if (name === "btw:new") {
|
|
1732
|
-
await resetThread(ctx, true, "contextual");
|
|
1733
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");
|
|
1734
2149
|
if (question) {
|
|
1735
2150
|
await runBtw(ctx, question, save, "contextual");
|
|
1736
2151
|
} else {
|
|
@@ -1796,6 +2211,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1796
2211
|
}
|
|
1797
2212
|
|
|
1798
2213
|
if (name === "btw:inject") {
|
|
2214
|
+
await btwSubmissionQueue;
|
|
1799
2215
|
if (pendingThread.length === 0) {
|
|
1800
2216
|
notify(ctx, "No BTW thread to inject.", "warning");
|
|
1801
2217
|
return true;
|
|
@@ -1824,6 +2240,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1824
2240
|
}
|
|
1825
2241
|
|
|
1826
2242
|
if (name === "btw:summarize") {
|
|
2243
|
+
await btwSubmissionQueue;
|
|
1827
2244
|
if (pendingThread.length === 0) {
|
|
1828
2245
|
notify(ctx, "No BTW thread to summarize.", "warning");
|
|
1829
2246
|
return true;
|
|
@@ -1899,6 +2316,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1899
2316
|
persist = true,
|
|
1900
2317
|
mode: BtwThreadMode = "contextual",
|
|
1901
2318
|
): Promise<void> {
|
|
2319
|
+
invalidateBtwLifecycle();
|
|
1902
2320
|
await disposeBtwSession();
|
|
1903
2321
|
pendingThread = [];
|
|
1904
2322
|
pendingMode = mode;
|
|
@@ -1913,6 +2331,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1913
2331
|
}
|
|
1914
2332
|
|
|
1915
2333
|
async function restoreThread(ctx: ExtensionContext): Promise<void> {
|
|
2334
|
+
invalidateBtwLifecycle();
|
|
1916
2335
|
await disposeBtwSession();
|
|
1917
2336
|
pendingThread = [];
|
|
1918
2337
|
pendingMode = "contextual";
|
|
@@ -1987,8 +2406,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
1987
2406
|
saveRequested: boolean,
|
|
1988
2407
|
mode: BtwThreadMode,
|
|
1989
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;
|
|
1990
2428
|
lastUiContext = ctx;
|
|
1991
2429
|
const settings = await resolveBtwSettings(ctx);
|
|
2430
|
+
if (!isCurrentGeneration()) {
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
1992
2433
|
const model = settings.model;
|
|
1993
2434
|
if (!model) {
|
|
1994
2435
|
const message = settings.fallbackReason || "No active model selected.";
|
|
@@ -1998,7 +2439,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1998
2439
|
}
|
|
1999
2440
|
|
|
2000
2441
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
2001
|
-
if (!
|
|
2442
|
+
if (!isCurrentGeneration()) {
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
if (!hasUsableModelAuth(ctx, model, auth)) {
|
|
2002
2446
|
const message = auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error;
|
|
2003
2447
|
setOverlayStatus(message, ctx);
|
|
2004
2448
|
notify(ctx, message, "error");
|
|
@@ -2007,6 +2451,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
2007
2451
|
}
|
|
2008
2452
|
|
|
2009
2453
|
const sessionRuntime = await ensureBtwSession(ctx, mode);
|
|
2454
|
+
if (!isCurrentGeneration()) {
|
|
2455
|
+
if (sessionRuntime && activeBtwSession === sessionRuntime) {
|
|
2456
|
+
await disposeBtwSession();
|
|
2457
|
+
}
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2010
2460
|
if (!sessionRuntime) {
|
|
2011
2461
|
setOverlayStatus("No active model selected.", ctx);
|
|
2012
2462
|
notify(ctx, "No active model selected.", "error");
|
|
@@ -2015,22 +2465,58 @@ export default function (pi: ExtensionAPI) {
|
|
|
2015
2465
|
|
|
2016
2466
|
const session = sessionRuntime.session;
|
|
2017
2467
|
const wasBusy = !ctx.isIdle();
|
|
2468
|
+
const overlayAvailable = canRenderBtwOverlay(ctx);
|
|
2018
2469
|
pendingMode = mode;
|
|
2019
2470
|
const thinkingLevel = settings.thinkingLevel;
|
|
2020
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;
|
|
2021
2503
|
setOverlayStatus("⏳ streaming...", ctx);
|
|
2022
2504
|
await ensureOverlay(ctx);
|
|
2023
2505
|
|
|
2024
2506
|
try {
|
|
2025
2507
|
await session.prompt(question, { source: "extension" });
|
|
2508
|
+
if (!isCurrentGeneration()) {
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2026
2511
|
|
|
2027
2512
|
const response = getLastAssistantMessage(session);
|
|
2028
2513
|
if (!response) {
|
|
2029
2514
|
throw new Error("BTW request finished without a response.");
|
|
2030
2515
|
}
|
|
2031
2516
|
if (response.stopReason === "aborted") {
|
|
2032
|
-
|
|
2033
|
-
|
|
2517
|
+
const abortedTurnId = transcriptState.currentTurnId ?? transcriptState.lastTurnId;
|
|
2518
|
+
finishTranscriptTurn(transcriptState, abortedTurnId, "aborted");
|
|
2519
|
+
setOverlayStatus("⏹ Aborted. Press Esc again to dismiss the BTW overlay.", ctx);
|
|
2034
2520
|
return;
|
|
2035
2521
|
}
|
|
2036
2522
|
if (response.stopReason === "error") {
|
|
@@ -2058,8 +2544,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
2058
2544
|
pendingThread.push(details);
|
|
2059
2545
|
pi.appendEntry(BTW_ENTRY_TYPE, details);
|
|
2060
2546
|
|
|
2061
|
-
const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy);
|
|
2062
|
-
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") {
|
|
2063
2556
|
notify(ctx, "Saved BTW note to the session.", "info");
|
|
2064
2557
|
setOverlayStatus("Saved BTW note to the session.", ctx);
|
|
2065
2558
|
} else if (saveState === "queued") {
|
|
@@ -2069,12 +2562,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
2069
2562
|
setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx);
|
|
2070
2563
|
}
|
|
2071
2564
|
} catch (error) {
|
|
2565
|
+
if (!isCurrentGeneration()) {
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2072
2568
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2073
2569
|
setTranscriptFailure(transcriptState, errorMessage);
|
|
2074
2570
|
setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx);
|
|
2075
2571
|
notify(ctx, errorMessage, "error");
|
|
2076
2572
|
await disposeBtwSession();
|
|
2077
2573
|
} finally {
|
|
2574
|
+
releasePromptTurn();
|
|
2078
2575
|
syncUi(ctx);
|
|
2079
2576
|
}
|
|
2080
2577
|
}
|
|
@@ -2086,7 +2583,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
2086
2583
|
async function getBtwHandoffThread(
|
|
2087
2584
|
ctx: ExtensionCommandContext,
|
|
2088
2585
|
): Promise<{ sessionRuntime: BtwSessionRuntime | null; thread: BtwHandoffExchange[] }> {
|
|
2586
|
+
const pendingSubmissions = btwSubmissionQueue;
|
|
2587
|
+
await pendingSubmissions;
|
|
2588
|
+
|
|
2089
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
|
+
|
|
2090
2600
|
const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : [];
|
|
2091
2601
|
const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff();
|
|
2092
2602
|
|
|
@@ -2105,18 +2615,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
2105
2615
|
}
|
|
2106
2616
|
|
|
2107
2617
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
2108
|
-
if (!
|
|
2618
|
+
if (!hasUsableModelAuth(ctx, model, auth)) {
|
|
2109
2619
|
throw new Error(auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error);
|
|
2110
2620
|
}
|
|
2111
2621
|
|
|
2112
|
-
const
|
|
2622
|
+
const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, model);
|
|
2623
|
+
|
|
2624
|
+
const sessionOptions: CreateAgentSessionOptions = {
|
|
2113
2625
|
sessionManager: SessionManager.inMemory(),
|
|
2114
2626
|
model,
|
|
2115
|
-
|
|
2627
|
+
...modelRuntimeOptions,
|
|
2116
2628
|
thinkingLevel: "off",
|
|
2117
2629
|
tools: [],
|
|
2118
2630
|
resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]),
|
|
2119
|
-
}
|
|
2631
|
+
};
|
|
2632
|
+
const { session } = await createAgentSession(sessionOptions);
|
|
2120
2633
|
|
|
2121
2634
|
try {
|
|
2122
2635
|
await session.prompt(formatThread(thread), { source: "extension" });
|
|
@@ -2153,29 +2666,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
2153
2666
|
|
|
2154
2667
|
pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => {
|
|
2155
2668
|
const details = message.details as BtwDetails | undefined;
|
|
2156
|
-
const content =
|
|
2157
|
-
|
|
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
|
+
);
|
|
2158
2682
|
|
|
2159
2683
|
if (expanded && details) {
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
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,
|
|
2164
2692
|
),
|
|
2165
2693
|
);
|
|
2166
2694
|
|
|
2167
2695
|
if (details.usage) {
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
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,
|
|
2172
2704
|
),
|
|
2173
2705
|
);
|
|
2174
2706
|
}
|
|
2175
2707
|
}
|
|
2176
2708
|
|
|
2177
|
-
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
2178
|
-
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
2179
2709
|
return box;
|
|
2180
2710
|
});
|
|
2181
2711
|
|
|
@@ -2194,6 +2724,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2194
2724
|
});
|
|
2195
2725
|
|
|
2196
2726
|
pi.on("session_shutdown", async () => {
|
|
2727
|
+
invalidateBtwLifecycle();
|
|
2197
2728
|
await disposeBtwSession();
|
|
2198
2729
|
dismissOverlay();
|
|
2199
2730
|
});
|
|
@@ -2207,6 +2738,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
2207
2738
|
});
|
|
2208
2739
|
}
|
|
2209
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
|
+
|
|
2210
2751
|
pi.registerCommand("btw", {
|
|
2211
2752
|
description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.",
|
|
2212
2753
|
handler: async (args, ctx) => {
|
|
@@ -2263,3 +2804,4 @@ export default function (pi: ExtensionAPI) {
|
|
|
2263
2804
|
},
|
|
2264
2805
|
});
|
|
2265
2806
|
}
|
|
2807
|
+
|