@mgiles/perk 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -0
- package/extension/adapters/planAdapterPlannotator.ts +269 -0
- package/extension/adapters/planAdapterTombell.ts +147 -0
- package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
- package/extension/checkpoints/checkpoints.ts +542 -0
- package/extension/checkpoints/planSteps.ts +108 -0
- package/extension/doors/address.ts +360 -0
- package/extension/doors/askUser.ts +194 -0
- package/extension/doors/ciExecutor.ts +583 -0
- package/extension/doors/land.ts +222 -0
- package/extension/doors/learn.ts +235 -0
- package/extension/doors/learnDocs.ts +99 -0
- package/extension/doors/lifecycleGates.ts +171 -0
- package/extension/doors/prReview.ts +339 -0
- package/extension/doors/ready.ts +86 -0
- package/extension/doors/selfcheck.ts +155 -0
- package/extension/doors/submit.ts +253 -0
- package/extension/factories/objective.ts +240 -0
- package/extension/factories/objectiveAuthor.ts +114 -0
- package/extension/factories/objectiveDraft.ts +343 -0
- package/extension/factories/objectivePlan.ts +838 -0
- package/extension/factories/objectiveSave.ts +285 -0
- package/extension/factories/planDraft.ts +140 -0
- package/extension/factories/planMode.ts +214 -0
- package/extension/factories/planReview.ts +644 -0
- package/extension/factories/planSave.ts +589 -0
- package/extension/factories/planTitle.ts +123 -0
- package/extension/index.ts +459 -0
- package/extension/substrate/bindingDelivery.ts +199 -0
- package/extension/substrate/bindings.ts +180 -0
- package/extension/substrate/cache.ts +163 -0
- package/extension/substrate/coldDoor.ts +226 -0
- package/extension/substrate/config.ts +339 -0
- package/extension/substrate/miniYaml.ts +262 -0
- package/extension/substrate/prompts.ts +35 -0
- package/extension/substrate/providers.ts +177 -0
- package/extension/substrate/registry.ts +62 -0
- package/extension/substrate/resources.ts +41 -0
- package/extension/substrate/result.ts +72 -0
- package/extension/substrate/runId.ts +49 -0
- package/extension/substrate/sessionData.ts +229 -0
- package/extension/substrate/structuredOutput.ts +141 -0
- package/extension/substrate/toolGating.ts +400 -0
- package/extension/substrate/toolParams.ts +106 -0
- package/extension/substrate/workflowState.ts +233 -0
- package/extension/surfaces/footerProvider.ts +43 -0
- package/extension/surfaces/report.ts +34 -0
- package/extension/surfaces/surfaces.ts +460 -0
- package/extension/vendor/btw/btw.ts +964 -0
- package/extension/vendor/btw/core.ts +153 -0
- package/extension/vendor/whimsical/whimsical.ts +485 -0
- package/extension/worker/readOnlySession.ts +282 -0
- package/extension/worker/worker.ts +765 -0
- package/extension/workerMain.ts +150 -0
- package/package.json +55 -0
- package/prompts/README.md +15 -0
- package/prompts/_fixtures/cases.yaml +140 -0
- package/prompts/_fixtures/golden/address-action-model.txt +10 -0
- package/prompts/_fixtures/golden/address-action.txt +10 -0
- package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
- package/prompts/_fixtures/golden/address-preview.txt +6 -0
- package/prompts/_fixtures/golden/hello.txt +1 -0
- package/prompts/_fixtures/golden/implement-github.txt +8 -0
- package/prompts/_fixtures/golden/learn-docs.txt +8 -0
- package/prompts/_fixtures/golden/learn-github.txt +11 -0
- package/prompts/_fixtures/golden/learn-linear.txt +11 -0
- package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
- package/prompts/_fixtures/golden/learn-other.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
- package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
- package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
- package/prompts/_fixtures/golden/with_include.txt +4 -0
- package/prompts/_fixtures/templates/_greeting.md +1 -0
- package/prompts/_fixtures/templates/hello.md +1 -0
- package/prompts/_fixtures/templates/with_include.md +4 -0
- package/prompts/common/objective-read/linear.md +1 -0
- package/prompts/common/plan-read/github.md +1 -0
- package/prompts/common/plan-read/linear.md +1 -0
- package/prompts/common/plan-read/other.md +1 -0
- package/prompts/stages/address/action.md +10 -0
- package/prompts/stages/address/preview.md +6 -0
- package/prompts/stages/implement.md +8 -0
- package/prompts/stages/learn-docs.md +8 -0
- package/prompts/stages/learn.md +21 -0
- package/prompts/stages/objective-plan/guidance.md +12 -0
- package/prompts/stages/objective-plan/seed.md +20 -0
- package/shared/README.md +29 -0
- package/shared/bindings.yaml +64 -0
- package/shared/contracts-history.md +403 -0
- package/shared/contracts.md +4172 -0
- package/shared/providers.yaml +221 -0
- package/shared/registry.yaml +199 -0
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
// btw — a `/btw` side-chat popover backed by an isolated in-memory AgentSession seeded with the
|
|
2
|
+
// main conversation context, with optional summary injection back into the main chat.
|
|
3
|
+
//
|
|
4
|
+
// Vendored from `mitsuhiko/agent-stuff` `extensions/btw.ts` (MIT). Adapted for perk:
|
|
5
|
+
// - `registerBtw(pi, gating)` wrapper (the perk registerX pattern) over the original default export;
|
|
6
|
+
// - the local `notify` helper routes through the headless-safe `report()` surfaces seam
|
|
7
|
+
// (`perk: btw — <message>` grammar, D7);
|
|
8
|
+
// - the side session's toolset mirrors perk's read-only gate (`sideSessionTools(gating.isActive())`)
|
|
9
|
+
// and the session cache key carries the gate state so a gate flip recreates the session — perk's
|
|
10
|
+
// structural read-only guarantee is never bypassed by the isolated side session;
|
|
11
|
+
// - §5 themed-glyph conformance (`❌`→`✗`, running `⚙`→`▸`) via the extracted core;
|
|
12
|
+
// - the extended `stripDynamicSystemPromptFooter` regex (also strips `Current date:`).
|
|
13
|
+
//
|
|
14
|
+
// Charter note: `/btw`'s UI is a `ctx.ui.custom` overlay — the ONE sanctioned exception to the §6 D6
|
|
15
|
+
// decline (docs/design/tui-charter.md). It is human-invoked only (no model tool, not a stage/door),
|
|
16
|
+
// `ctx.hasUI`-gated, and never machine-reachable (cold/headless/RPC), so it cannot threaten the
|
|
17
|
+
// machine-executability the decline protects. `ctx.ui.custom` stays declined for all workflow surfaces.
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
ThinkingLevel as AiThinkingLevel,
|
|
21
|
+
AssistantMessage,
|
|
22
|
+
Message,
|
|
23
|
+
} from "@earendil-works/pi-ai";
|
|
24
|
+
import {
|
|
25
|
+
type AgentSession,
|
|
26
|
+
type AgentSessionEvent,
|
|
27
|
+
buildSessionContext,
|
|
28
|
+
createAgentSession,
|
|
29
|
+
createExtensionRuntime,
|
|
30
|
+
type ExtensionAPI,
|
|
31
|
+
type ExtensionCommandContext,
|
|
32
|
+
type ExtensionContext,
|
|
33
|
+
getMarkdownTheme,
|
|
34
|
+
type KeybindingsManager,
|
|
35
|
+
type ResourceLoader,
|
|
36
|
+
SessionManager,
|
|
37
|
+
} from "@earendil-works/pi-coding-agent";
|
|
38
|
+
import {
|
|
39
|
+
Container,
|
|
40
|
+
type Focusable,
|
|
41
|
+
Input,
|
|
42
|
+
Markdown,
|
|
43
|
+
truncateToWidth,
|
|
44
|
+
visibleWidth,
|
|
45
|
+
} from "@earendil-works/pi-tui";
|
|
46
|
+
|
|
47
|
+
// Structural slices of pi-tui's `TUI` / `OverlayHandle` — used to dodge the dual-copy class clash
|
|
48
|
+
// (pi-coding-agent bundles its own pi-tui copy; importing the `TUI` class type directly clashes on
|
|
49
|
+
// its private fields). The overlay only needs these methods.
|
|
50
|
+
type TuiLike = { requestRender(): void };
|
|
51
|
+
type OverlayHandleLike = {
|
|
52
|
+
setHidden(hidden: boolean): void;
|
|
53
|
+
hide(): void;
|
|
54
|
+
focus(): void;
|
|
55
|
+
isFocused(): boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
import type { ToolGating } from "../../substrate/toolGating.ts";
|
|
59
|
+
import { report } from "../../surfaces/report.ts";
|
|
60
|
+
import {
|
|
61
|
+
extractEventAssistantText,
|
|
62
|
+
extractText,
|
|
63
|
+
formatThread,
|
|
64
|
+
formatToolArgs,
|
|
65
|
+
lastAssistantMessage,
|
|
66
|
+
renderErrorLine,
|
|
67
|
+
renderToolCallLines,
|
|
68
|
+
sideSessionTools,
|
|
69
|
+
stripDynamicSystemPromptFooter,
|
|
70
|
+
type ToolCallInfo,
|
|
71
|
+
} from "./core.ts";
|
|
72
|
+
|
|
73
|
+
const BTW_ENTRY_TYPE = "btw-thread-entry";
|
|
74
|
+
const BTW_RESET_TYPE = "btw-thread-reset";
|
|
75
|
+
|
|
76
|
+
const BTW_SYSTEM_PROMPT = [
|
|
77
|
+
"You are BTW, a side-channel assistant embedded in the user's coding agent.",
|
|
78
|
+
"You have access to the main conversation context — use it to give informed answers.",
|
|
79
|
+
"Help with focused questions, planning, and quick explorations.",
|
|
80
|
+
"Be direct and practical.",
|
|
81
|
+
].join(" ");
|
|
82
|
+
|
|
83
|
+
const BTW_SUMMARY_PROMPT =
|
|
84
|
+
"Summarize this side conversation for handoff into the main conversation. Keep key decisions, findings, risks, and next actions. Output only the summary.";
|
|
85
|
+
|
|
86
|
+
type SessionThinkingLevel = "off" | AiThinkingLevel;
|
|
87
|
+
|
|
88
|
+
type BtwDetails = {
|
|
89
|
+
question: string;
|
|
90
|
+
answer: string;
|
|
91
|
+
timestamp: number;
|
|
92
|
+
provider: string;
|
|
93
|
+
model: string;
|
|
94
|
+
thinkingLevel: SessionThinkingLevel;
|
|
95
|
+
usage?: AssistantMessage["usage"];
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type BtwResetDetails = {
|
|
99
|
+
timestamp: number;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
type OverlayRuntime = {
|
|
103
|
+
handle?: OverlayHandleLike;
|
|
104
|
+
refresh?: () => void;
|
|
105
|
+
close?: () => void;
|
|
106
|
+
finish?: () => void;
|
|
107
|
+
setDraft?: (value: string) => void;
|
|
108
|
+
closed?: boolean;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type SideSessionRuntime = {
|
|
112
|
+
session: AgentSession;
|
|
113
|
+
modelKey: string;
|
|
114
|
+
unsubscribe: () => void;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function createBtwResourceLoader(
|
|
118
|
+
ctx: ExtensionContext,
|
|
119
|
+
appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT],
|
|
120
|
+
): ResourceLoader {
|
|
121
|
+
const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
|
|
122
|
+
const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt());
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
getExtensions: () => extensionsResult,
|
|
126
|
+
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
127
|
+
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
128
|
+
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
129
|
+
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
130
|
+
getSystemPrompt: () => systemPrompt,
|
|
131
|
+
getAppendSystemPrompt: () => appendSystemPrompt,
|
|
132
|
+
extendResources: () => {},
|
|
133
|
+
reload: async () => {},
|
|
134
|
+
} as unknown as ResourceLoader;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildSeedMessages(ctx: ExtensionContext, thread: BtwDetails[]): Message[] {
|
|
138
|
+
const seed: Message[] = [];
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const contextMessages = buildSessionContext(
|
|
142
|
+
ctx.sessionManager.getEntries(),
|
|
143
|
+
ctx.sessionManager.getLeafId(),
|
|
144
|
+
).messages;
|
|
145
|
+
seed.push(...(contextMessages.filter((message) => "role" in message) as Message[]));
|
|
146
|
+
} catch {
|
|
147
|
+
// Ignore context seed failures and continue with an empty side thread.
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const item of thread) {
|
|
151
|
+
seed.push(
|
|
152
|
+
{
|
|
153
|
+
role: "user",
|
|
154
|
+
content: [{ type: "text", text: item.question }],
|
|
155
|
+
timestamp: item.timestamp,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
role: "assistant",
|
|
159
|
+
content: [{ type: "text", text: item.answer }],
|
|
160
|
+
provider: item.provider,
|
|
161
|
+
model: item.model,
|
|
162
|
+
api: ctx.model?.api ?? "openai-responses",
|
|
163
|
+
usage: item.usage ?? {
|
|
164
|
+
input: 0,
|
|
165
|
+
output: 0,
|
|
166
|
+
cacheRead: 0,
|
|
167
|
+
cacheWrite: 0,
|
|
168
|
+
totalTokens: 0,
|
|
169
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
170
|
+
},
|
|
171
|
+
stopReason: "stop",
|
|
172
|
+
timestamp: item.timestamp,
|
|
173
|
+
} as AssistantMessage,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return seed;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
class BtwOverlay extends Container implements Focusable {
|
|
181
|
+
private readonly input: Input;
|
|
182
|
+
private readonly tui: TuiLike;
|
|
183
|
+
private readonly theme: ExtensionContext["ui"]["theme"];
|
|
184
|
+
private readonly keybindings: KeybindingsManager;
|
|
185
|
+
private readonly getTranscript: (
|
|
186
|
+
width: number,
|
|
187
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
188
|
+
) => string[];
|
|
189
|
+
private readonly getStatus: () => string;
|
|
190
|
+
private readonly onSubmitCallback: (value: string) => void;
|
|
191
|
+
private readonly onDismissCallback: () => void;
|
|
192
|
+
private _focused = false;
|
|
193
|
+
|
|
194
|
+
get focused(): boolean {
|
|
195
|
+
return this._focused;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
set focused(value: boolean) {
|
|
199
|
+
this._focused = value;
|
|
200
|
+
this.input.focused = value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
constructor(
|
|
204
|
+
tui: TuiLike,
|
|
205
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
206
|
+
keybindings: KeybindingsManager,
|
|
207
|
+
getTranscript: (width: number, theme: ExtensionContext["ui"]["theme"]) => string[],
|
|
208
|
+
getStatus: () => string,
|
|
209
|
+
onSubmit: (value: string) => void,
|
|
210
|
+
onDismiss: () => void,
|
|
211
|
+
) {
|
|
212
|
+
super();
|
|
213
|
+
this.tui = tui;
|
|
214
|
+
this.theme = theme;
|
|
215
|
+
this.keybindings = keybindings;
|
|
216
|
+
this.getTranscript = getTranscript;
|
|
217
|
+
this.getStatus = getStatus;
|
|
218
|
+
this.onSubmitCallback = onSubmit;
|
|
219
|
+
this.onDismissCallback = onDismiss;
|
|
220
|
+
|
|
221
|
+
this.input = new Input();
|
|
222
|
+
this.input.onSubmit = (value) => {
|
|
223
|
+
this.onSubmitCallback(value);
|
|
224
|
+
};
|
|
225
|
+
this.input.onEscape = () => {
|
|
226
|
+
this.onDismissCallback();
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
handleInput(data: string): void {
|
|
231
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
232
|
+
this.onDismissCallback();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.input.handleInput(data);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
setDraft(value: string): void {
|
|
240
|
+
this.input.setValue(value);
|
|
241
|
+
this.tui.requestRender();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
getDraft(): string {
|
|
245
|
+
return this.input.getValue();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private frameLine(content: string, innerWidth: number): string {
|
|
249
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
250
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
251
|
+
return `${this.theme.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("borderMuted", "│")}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
255
|
+
const left = edge === "top" ? "┌" : "└";
|
|
256
|
+
const right = edge === "top" ? "┐" : "┘";
|
|
257
|
+
return this.theme.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
override render(width: number): string[] {
|
|
261
|
+
const dialogWidth = Math.max(56, Math.min(width, Math.floor(width * 0.9)));
|
|
262
|
+
const innerWidth = Math.max(40, dialogWidth - 2);
|
|
263
|
+
const terminalRows = process.stdout.rows ?? 30;
|
|
264
|
+
const dialogHeight = Math.max(16, Math.min(30, Math.floor(terminalRows * 0.75)));
|
|
265
|
+
const chromeHeight = 7;
|
|
266
|
+
const transcriptHeight = Math.max(6, dialogHeight - chromeHeight);
|
|
267
|
+
|
|
268
|
+
// Markdown renders to innerWidth already — no manual wrapping needed
|
|
269
|
+
const transcript = this.getTranscript(innerWidth, this.theme);
|
|
270
|
+
const visibleTranscript = transcript.slice(-transcriptHeight);
|
|
271
|
+
const transcriptPadding = Math.max(0, transcriptHeight - visibleTranscript.length);
|
|
272
|
+
|
|
273
|
+
const status = this.getStatus();
|
|
274
|
+
|
|
275
|
+
const previousFocused = this.input.focused;
|
|
276
|
+
this.input.focused = false;
|
|
277
|
+
const inputLine = this.input.render(innerWidth)[0] ?? "";
|
|
278
|
+
this.input.focused = previousFocused;
|
|
279
|
+
|
|
280
|
+
const lines = [
|
|
281
|
+
this.borderLine(innerWidth, "top"),
|
|
282
|
+
this.frameLine(this.theme.fg("accent", this.theme.bold(" BTW side chat ")), innerWidth),
|
|
283
|
+
this.frameLine(this.theme.fg("dim", "Separate side conversation. Esc closes."), innerWidth),
|
|
284
|
+
this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`),
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
for (const line of visibleTranscript) {
|
|
288
|
+
lines.push(this.frameLine(line, innerWidth));
|
|
289
|
+
}
|
|
290
|
+
for (let i = 0; i < transcriptPadding; i++) {
|
|
291
|
+
lines.push(this.frameLine("", innerWidth));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
lines.push(this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`));
|
|
295
|
+
lines.push(this.frameLine(this.theme.fg("warning", status), innerWidth));
|
|
296
|
+
lines.push(
|
|
297
|
+
`${this.theme.fg("borderMuted", "│")}${inputLine}${this.theme.fg("borderMuted", "│")}`,
|
|
298
|
+
);
|
|
299
|
+
lines.push(this.frameLine(this.theme.fg("dim", "Enter submit · Esc close"), innerWidth));
|
|
300
|
+
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
301
|
+
|
|
302
|
+
return lines;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function registerBtw(pi: ExtensionAPI, gating: ToolGating): void {
|
|
307
|
+
let thread: BtwDetails[] = [];
|
|
308
|
+
let pendingQuestion: string | null = null;
|
|
309
|
+
let pendingAnswer = "";
|
|
310
|
+
let pendingError: string | null = null;
|
|
311
|
+
let pendingToolCalls: ToolCallInfo[] = [];
|
|
312
|
+
let sideBusy = false;
|
|
313
|
+
let overlayStatus = "Ready";
|
|
314
|
+
let overlayDraft = "";
|
|
315
|
+
let overlayRuntime: OverlayRuntime | null = null;
|
|
316
|
+
let activeSideSession: SideSessionRuntime | null = null;
|
|
317
|
+
let overlayRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
318
|
+
|
|
319
|
+
const mdTheme = getMarkdownTheme();
|
|
320
|
+
|
|
321
|
+
// perk gate-mirror: the cache key carries the read-only/read-write gate state, so a gate flip
|
|
322
|
+
// (read-only ↔ read-write) recreates the side session with the correct `sideSessionTools` set
|
|
323
|
+
// — never reusing a stale toolset that could bypass perk's read-only guarantee.
|
|
324
|
+
function getModelKey(ctx: ExtensionContext): string {
|
|
325
|
+
const model = ctx.model;
|
|
326
|
+
return model ? `${model.provider}/${model.id}#${gating.isActive() ? "ro" : "rw"}` : "none";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// perk: route the local notify through the headless-safe report() seam (D7 grammar).
|
|
330
|
+
function notify(
|
|
331
|
+
ctx: ExtensionContext | ExtensionCommandContext,
|
|
332
|
+
message: string,
|
|
333
|
+
level: "info" | "warning" | "error",
|
|
334
|
+
): void {
|
|
335
|
+
report(ctx, "btw", level, message);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderMarkdownLines(text: string, width: number): string[] {
|
|
339
|
+
if (!text) return [];
|
|
340
|
+
try {
|
|
341
|
+
const md = new Markdown(text, 0, 0, mdTheme);
|
|
342
|
+
return md.render(width);
|
|
343
|
+
} catch {
|
|
344
|
+
// Fall back to plain text wrapping if Markdown rendering fails
|
|
345
|
+
return text.split("\n").flatMap((line) => {
|
|
346
|
+
if (!line) return [""];
|
|
347
|
+
const wrapped: string[] = [];
|
|
348
|
+
for (let i = 0; i < line.length; i += width) {
|
|
349
|
+
wrapped.push(line.slice(i, i + width));
|
|
350
|
+
}
|
|
351
|
+
return wrapped.length > 0 ? wrapped : [""];
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function getTranscriptLines(width: number, theme: ExtensionContext["ui"]["theme"]): string[] {
|
|
357
|
+
try {
|
|
358
|
+
return getTranscriptLinesInner(width, theme);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
return [
|
|
361
|
+
theme.fg(
|
|
362
|
+
"error",
|
|
363
|
+
`Render error: ${error instanceof Error ? error.message : String(error)}`,
|
|
364
|
+
),
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function getTranscriptLinesInner(
|
|
370
|
+
width: number,
|
|
371
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
372
|
+
): string[] {
|
|
373
|
+
if (thread.length === 0 && !pendingQuestion && !pendingAnswer && !pendingError) {
|
|
374
|
+
return [theme.fg("dim", "No BTW messages yet. Type a question below.")];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const lines: string[] = [];
|
|
378
|
+
for (const item of thread.slice(-6)) {
|
|
379
|
+
// User message
|
|
380
|
+
const userText = item.question.trim().split("\n")[0] ?? "";
|
|
381
|
+
lines.push(
|
|
382
|
+
theme.fg("accent", theme.bold("You: ")) + truncateToWidth(userText, width - 5, "…"),
|
|
383
|
+
);
|
|
384
|
+
lines.push("");
|
|
385
|
+
|
|
386
|
+
// Assistant message rendered as markdown
|
|
387
|
+
const mdLines = renderMarkdownLines(item.answer, width);
|
|
388
|
+
lines.push(...mdLines);
|
|
389
|
+
lines.push("");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (pendingQuestion) {
|
|
393
|
+
const userText = pendingQuestion.trim().split("\n")[0] ?? "";
|
|
394
|
+
lines.push(
|
|
395
|
+
theme.fg("accent", theme.bold("You: ")) + truncateToWidth(userText, width - 5, "…"),
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// Show tool calls inline (§5-conformed glyphs via the extracted core)
|
|
399
|
+
if (pendingToolCalls.length > 0) {
|
|
400
|
+
lines.push(...renderToolCallLines(pendingToolCalls, theme, width));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (pendingError) {
|
|
404
|
+
lines.push(renderErrorLine(theme, pendingError));
|
|
405
|
+
} else if (pendingAnswer) {
|
|
406
|
+
lines.push("");
|
|
407
|
+
const mdLines = renderMarkdownLines(pendingAnswer, width);
|
|
408
|
+
lines.push(...mdLines);
|
|
409
|
+
} else if (pendingToolCalls.length === 0) {
|
|
410
|
+
lines.push(theme.fg("dim", "…"));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Trim trailing empty line
|
|
415
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
416
|
+
lines.pop();
|
|
417
|
+
}
|
|
418
|
+
return lines;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function syncOverlay(): void {
|
|
422
|
+
overlayRuntime?.refresh?.();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function scheduleOverlayRefresh(): void {
|
|
426
|
+
if (overlayRefreshTimer) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
overlayRefreshTimer = setTimeout(() => {
|
|
431
|
+
overlayRefreshTimer = null;
|
|
432
|
+
syncOverlay();
|
|
433
|
+
}, 16);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function setOverlayStatus(status: string, throttled = false): void {
|
|
437
|
+
overlayStatus = status;
|
|
438
|
+
if (throttled) {
|
|
439
|
+
scheduleOverlayRefresh();
|
|
440
|
+
} else {
|
|
441
|
+
syncOverlay();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function dismissOverlay(): void {
|
|
446
|
+
overlayRuntime?.close?.();
|
|
447
|
+
overlayRuntime = null;
|
|
448
|
+
if (overlayRefreshTimer) {
|
|
449
|
+
clearTimeout(overlayRefreshTimer);
|
|
450
|
+
overlayRefreshTimer = null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function setOverlayDraft(value: string): void {
|
|
455
|
+
overlayDraft = value;
|
|
456
|
+
overlayRuntime?.setDraft?.(value);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function disposeSideSession(): Promise<void> {
|
|
460
|
+
const current = activeSideSession;
|
|
461
|
+
activeSideSession = null;
|
|
462
|
+
if (!current) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
current.unsubscribe();
|
|
468
|
+
} catch {
|
|
469
|
+
// Ignore unsubscribe errors during cleanup.
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
await current.session.abort();
|
|
474
|
+
} catch {
|
|
475
|
+
// Ignore abort errors during cleanup.
|
|
476
|
+
}
|
|
477
|
+
current.session.dispose();
|
|
478
|
+
|
|
479
|
+
if (overlayRefreshTimer) {
|
|
480
|
+
clearTimeout(overlayRefreshTimer);
|
|
481
|
+
overlayRefreshTimer = null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function resetThread(
|
|
486
|
+
_ctx: ExtensionContext | ExtensionCommandContext,
|
|
487
|
+
persist = true,
|
|
488
|
+
): Promise<void> {
|
|
489
|
+
thread = [];
|
|
490
|
+
pendingQuestion = null;
|
|
491
|
+
pendingAnswer = "";
|
|
492
|
+
pendingError = null;
|
|
493
|
+
pendingToolCalls = [];
|
|
494
|
+
sideBusy = false;
|
|
495
|
+
setOverlayDraft("");
|
|
496
|
+
setOverlayStatus("Ready");
|
|
497
|
+
await disposeSideSession();
|
|
498
|
+
if (persist) {
|
|
499
|
+
const details: BtwResetDetails = { timestamp: Date.now() };
|
|
500
|
+
pi.appendEntry(BTW_RESET_TYPE, details);
|
|
501
|
+
}
|
|
502
|
+
syncOverlay();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function restoreThread(ctx: ExtensionContext): Promise<void> {
|
|
506
|
+
await disposeSideSession();
|
|
507
|
+
thread = [];
|
|
508
|
+
pendingQuestion = null;
|
|
509
|
+
pendingAnswer = "";
|
|
510
|
+
pendingError = null;
|
|
511
|
+
pendingToolCalls = [];
|
|
512
|
+
sideBusy = false;
|
|
513
|
+
overlayStatus = "Ready";
|
|
514
|
+
overlayDraft = "";
|
|
515
|
+
const branch = ctx.sessionManager.getBranch();
|
|
516
|
+
let lastResetIndex = -1;
|
|
517
|
+
for (let i = 0; i < branch.length; i++) {
|
|
518
|
+
const entry = branch[i];
|
|
519
|
+
if (entry && entry.type === "custom" && entry.customType === BTW_RESET_TYPE) {
|
|
520
|
+
lastResetIndex = i;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const entry of branch.slice(lastResetIndex + 1)) {
|
|
525
|
+
if (entry.type !== "custom" || entry.customType !== BTW_ENTRY_TYPE) {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const details = entry.data as BtwDetails | undefined;
|
|
529
|
+
if (!details?.question || !details.answer) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
thread.push(details);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
syncOverlay();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function createSideSession(
|
|
539
|
+
ctx: ExtensionCommandContext,
|
|
540
|
+
): Promise<SideSessionRuntime | null> {
|
|
541
|
+
if (!ctx.model) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const { session } = await createAgentSession({
|
|
546
|
+
sessionManager: SessionManager.inMemory(),
|
|
547
|
+
model: ctx.model,
|
|
548
|
+
modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
|
|
549
|
+
thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel,
|
|
550
|
+
// perk gate-mirror: read-only ⇒ ["read"] only (a foreign session's bash can't be sandboxed
|
|
551
|
+
// by perk's isReadOnlyBashCommand); read-write ⇒ the full set.
|
|
552
|
+
tools: sideSessionTools(gating.isActive()),
|
|
553
|
+
resourceLoader: createBtwResourceLoader(ctx),
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const seedMessages = buildSeedMessages(ctx, thread);
|
|
557
|
+
if (seedMessages.length > 0) {
|
|
558
|
+
session.agent.state.messages = seedMessages as typeof session.agent.state.messages;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
562
|
+
if (!sideBusy || !pendingQuestion) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
switch (event.type) {
|
|
567
|
+
case "message_start":
|
|
568
|
+
case "message_update":
|
|
569
|
+
case "message_end": {
|
|
570
|
+
const streamed = extractEventAssistantText(event.message);
|
|
571
|
+
if (streamed) {
|
|
572
|
+
pendingAnswer = streamed;
|
|
573
|
+
pendingError = null;
|
|
574
|
+
}
|
|
575
|
+
setOverlayStatus(
|
|
576
|
+
event.type === "message_end"
|
|
577
|
+
? "Finalizing side response..."
|
|
578
|
+
: "Streaming side response...",
|
|
579
|
+
true,
|
|
580
|
+
);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
case "tool_execution_start": {
|
|
584
|
+
const toolName = (event as { toolName?: string }).toolName ?? "unknown";
|
|
585
|
+
try {
|
|
586
|
+
pendingToolCalls.push({
|
|
587
|
+
toolCallId: (event as { toolCallId?: string }).toolCallId ?? "",
|
|
588
|
+
toolName,
|
|
589
|
+
args: formatToolArgs(toolName, (event as { args?: unknown }).args),
|
|
590
|
+
status: "running",
|
|
591
|
+
});
|
|
592
|
+
} catch {
|
|
593
|
+
// Ignore tool tracking failures
|
|
594
|
+
}
|
|
595
|
+
setOverlayStatus(`Running tool: ${toolName}...`, true);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
case "tool_execution_end": {
|
|
599
|
+
const endToolName = (event as { toolName?: string }).toolName ?? "unknown";
|
|
600
|
+
const tc = pendingToolCalls.find(
|
|
601
|
+
(t) => t.toolName === endToolName && t.status === "running",
|
|
602
|
+
);
|
|
603
|
+
if (tc) {
|
|
604
|
+
tc.status = (event as { isError?: boolean }).isError ? "error" : "done";
|
|
605
|
+
}
|
|
606
|
+
setOverlayStatus("Streaming side response...", true);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
case "turn_end": {
|
|
610
|
+
setOverlayStatus("Finalizing side response...", true);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
default:
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
session,
|
|
620
|
+
modelKey: getModelKey(ctx),
|
|
621
|
+
unsubscribe,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function ensureSideSession(
|
|
626
|
+
ctx: ExtensionCommandContext,
|
|
627
|
+
): Promise<SideSessionRuntime | null> {
|
|
628
|
+
if (!ctx.model) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const expectedModelKey = getModelKey(ctx);
|
|
633
|
+
if (activeSideSession && activeSideSession.modelKey === expectedModelKey) {
|
|
634
|
+
return activeSideSession;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
await disposeSideSession();
|
|
638
|
+
activeSideSession = await createSideSession(ctx);
|
|
639
|
+
return activeSideSession;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
|
|
643
|
+
if (!ctx.hasUI) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (overlayRuntime?.handle) {
|
|
648
|
+
overlayRuntime.handle.setHidden(false);
|
|
649
|
+
overlayRuntime.handle.focus();
|
|
650
|
+
overlayRuntime.refresh?.();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const runtime: OverlayRuntime = {};
|
|
655
|
+
const closeRuntime = () => {
|
|
656
|
+
if (runtime.closed) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
runtime.closed = true;
|
|
660
|
+
runtime.handle?.hide();
|
|
661
|
+
if (overlayRuntime === runtime) {
|
|
662
|
+
overlayRuntime = null;
|
|
663
|
+
}
|
|
664
|
+
runtime.finish?.();
|
|
665
|
+
};
|
|
666
|
+
runtime.close = closeRuntime;
|
|
667
|
+
overlayRuntime = runtime;
|
|
668
|
+
|
|
669
|
+
void ctx.ui
|
|
670
|
+
.custom<void>(
|
|
671
|
+
async (tui, theme, keybindings, done) => {
|
|
672
|
+
runtime.finish = () => done();
|
|
673
|
+
|
|
674
|
+
const overlay = new BtwOverlay(
|
|
675
|
+
tui,
|
|
676
|
+
theme,
|
|
677
|
+
keybindings,
|
|
678
|
+
(width, t) => getTranscriptLines(width, t),
|
|
679
|
+
() => overlayStatus,
|
|
680
|
+
(value) => {
|
|
681
|
+
void submitFromOverlay(ctx, value);
|
|
682
|
+
},
|
|
683
|
+
() => {
|
|
684
|
+
void closeOverlayFlow(ctx);
|
|
685
|
+
},
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
overlay.focused = true;
|
|
689
|
+
overlay.setDraft(overlayDraft);
|
|
690
|
+
runtime.setDraft = (value) => overlay.setDraft(value);
|
|
691
|
+
runtime.refresh = () => {
|
|
692
|
+
overlay.focused = runtime.handle?.isFocused() ?? false;
|
|
693
|
+
tui.requestRender();
|
|
694
|
+
};
|
|
695
|
+
runtime.close = () => {
|
|
696
|
+
overlayDraft = overlay.getDraft();
|
|
697
|
+
closeRuntime();
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
if (runtime.closed) {
|
|
701
|
+
done();
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return overlay;
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
overlay: true,
|
|
708
|
+
overlayOptions: {
|
|
709
|
+
width: "80%",
|
|
710
|
+
minWidth: 72,
|
|
711
|
+
maxHeight: "78%",
|
|
712
|
+
anchor: "top-center",
|
|
713
|
+
margin: { top: 1, left: 2, right: 2 },
|
|
714
|
+
},
|
|
715
|
+
onHandle: (handle) => {
|
|
716
|
+
runtime.handle = handle;
|
|
717
|
+
handle.focus();
|
|
718
|
+
if (runtime.closed) {
|
|
719
|
+
closeRuntime();
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
)
|
|
724
|
+
.catch((error) => {
|
|
725
|
+
if (overlayRuntime === runtime) {
|
|
726
|
+
overlayRuntime = null;
|
|
727
|
+
}
|
|
728
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function summarizeThread(ctx: ExtensionContext, items: BtwDetails[]): Promise<string> {
|
|
733
|
+
const model = ctx.model;
|
|
734
|
+
if (!model) {
|
|
735
|
+
throw new Error("No active model selected.");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
739
|
+
if (auth.ok === false) {
|
|
740
|
+
throw new Error(auth.error);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const { session } = await createAgentSession({
|
|
744
|
+
sessionManager: SessionManager.inMemory(),
|
|
745
|
+
model,
|
|
746
|
+
modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
|
|
747
|
+
thinkingLevel: "off",
|
|
748
|
+
tools: [],
|
|
749
|
+
resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARY_PROMPT]),
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
try {
|
|
753
|
+
await session.prompt(formatThread(items), { source: "extension" });
|
|
754
|
+
const response = lastAssistantMessage(session.state.messages) as AssistantMessage | null;
|
|
755
|
+
if (!response) {
|
|
756
|
+
throw new Error("Summary finished without a response.");
|
|
757
|
+
}
|
|
758
|
+
if (response.stopReason === "aborted") {
|
|
759
|
+
throw new Error("Summary request was aborted.");
|
|
760
|
+
}
|
|
761
|
+
if (response.stopReason === "error") {
|
|
762
|
+
throw new Error(response.errorMessage || "Summary request failed.");
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return extractText(response.content) || "(No summary generated)";
|
|
766
|
+
} finally {
|
|
767
|
+
try {
|
|
768
|
+
await session.abort();
|
|
769
|
+
} catch {
|
|
770
|
+
// Ignore abort errors during temporary session teardown.
|
|
771
|
+
}
|
|
772
|
+
session.dispose();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function injectSummaryIntoMain(
|
|
777
|
+
ctx: ExtensionContext | ExtensionCommandContext,
|
|
778
|
+
): Promise<void> {
|
|
779
|
+
if (thread.length === 0) {
|
|
780
|
+
notify(ctx, "No BTW thread to summarize.", "warning");
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
setOverlayStatus("Summarizing BTW thread for injection...");
|
|
785
|
+
try {
|
|
786
|
+
const summary = await summarizeThread(ctx, thread);
|
|
787
|
+
const message = `Summary of my BTW side conversation:\n\n${summary}`;
|
|
788
|
+
if (ctx.isIdle()) {
|
|
789
|
+
pi.sendUserMessage(message);
|
|
790
|
+
} else {
|
|
791
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
await resetThread(ctx);
|
|
795
|
+
notify(ctx, "Injected BTW summary into main chat.", "info");
|
|
796
|
+
} catch (error) {
|
|
797
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function closeOverlayFlow(ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
|
|
802
|
+
dismissOverlay();
|
|
803
|
+
if (!ctx.hasUI) {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (thread.length === 0) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const choice = await ctx.ui.select("Close BTW:", [
|
|
812
|
+
"Keep side thread",
|
|
813
|
+
"Inject summary into main chat",
|
|
814
|
+
]);
|
|
815
|
+
if (choice === "Inject summary into main chat") {
|
|
816
|
+
await injectSummaryIntoMain(ctx);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function runBtwPrompt(ctx: ExtensionCommandContext, question: string): Promise<void> {
|
|
821
|
+
const model = ctx.model;
|
|
822
|
+
if (!model) {
|
|
823
|
+
setOverlayStatus("No active model selected.");
|
|
824
|
+
notify(ctx, "No active model selected.", "error");
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
829
|
+
if (auth.ok === false) {
|
|
830
|
+
const message = auth.error;
|
|
831
|
+
setOverlayStatus(message);
|
|
832
|
+
notify(ctx, message, "error");
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (sideBusy) {
|
|
837
|
+
notify(ctx, "BTW is still processing the previous message.", "warning");
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const side = await ensureSideSession(ctx);
|
|
842
|
+
if (!side) {
|
|
843
|
+
notify(ctx, "Unable to create BTW side session.", "error");
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
sideBusy = true;
|
|
848
|
+
pendingQuestion = question;
|
|
849
|
+
pendingAnswer = "";
|
|
850
|
+
pendingError = null;
|
|
851
|
+
pendingToolCalls = [];
|
|
852
|
+
setOverlayStatus("Streaming side response...");
|
|
853
|
+
syncOverlay();
|
|
854
|
+
|
|
855
|
+
try {
|
|
856
|
+
await side.session.prompt(question, { source: "extension" });
|
|
857
|
+
const response = lastAssistantMessage(side.session.state.messages) as AssistantMessage | null;
|
|
858
|
+
if (!response) {
|
|
859
|
+
throw new Error("BTW request finished without a response.");
|
|
860
|
+
}
|
|
861
|
+
if (response.stopReason === "aborted") {
|
|
862
|
+
throw new Error("BTW request aborted.");
|
|
863
|
+
}
|
|
864
|
+
if (response.stopReason === "error") {
|
|
865
|
+
throw new Error(response.errorMessage || "BTW request failed.");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const answer = extractText(response.content) || "(No text response)";
|
|
869
|
+
pendingAnswer = answer;
|
|
870
|
+
const details: BtwDetails = {
|
|
871
|
+
question,
|
|
872
|
+
answer,
|
|
873
|
+
timestamp: Date.now(),
|
|
874
|
+
provider: model.provider,
|
|
875
|
+
model: model.id,
|
|
876
|
+
thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel,
|
|
877
|
+
usage: response.usage,
|
|
878
|
+
};
|
|
879
|
+
thread.push(details);
|
|
880
|
+
pi.appendEntry(BTW_ENTRY_TYPE, details);
|
|
881
|
+
|
|
882
|
+
pendingQuestion = null;
|
|
883
|
+
pendingAnswer = "";
|
|
884
|
+
pendingToolCalls = [];
|
|
885
|
+
setOverlayStatus("Ready for the next side question.");
|
|
886
|
+
} catch (error) {
|
|
887
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
888
|
+
pendingError = message;
|
|
889
|
+
setOverlayStatus("BTW request failed.");
|
|
890
|
+
notify(ctx, message, "error");
|
|
891
|
+
} finally {
|
|
892
|
+
sideBusy = false;
|
|
893
|
+
syncOverlay();
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function submitFromOverlay(
|
|
898
|
+
ctx: ExtensionContext | ExtensionCommandContext,
|
|
899
|
+
rawValue: string,
|
|
900
|
+
): Promise<void> {
|
|
901
|
+
const question = rawValue.trim();
|
|
902
|
+
if (!question) {
|
|
903
|
+
setOverlayStatus("Enter a question first.");
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
setOverlayDraft("");
|
|
908
|
+
if (!("waitForIdle" in ctx)) {
|
|
909
|
+
setOverlayStatus("BTW submit requires command context. Re-open with /btw.");
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
await runBtwPrompt(ctx as ExtensionCommandContext, question);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
pi.registerCommand("btw", {
|
|
917
|
+
description:
|
|
918
|
+
"Open a simple BTW side-chat popover. `/btw <text>` asks immediately, `/btw` opens the side thread.",
|
|
919
|
+
handler: async (args, ctx) => {
|
|
920
|
+
const question = args.trim();
|
|
921
|
+
|
|
922
|
+
if (!question) {
|
|
923
|
+
if (thread.length > 0 && ctx.hasUI) {
|
|
924
|
+
const choice = await ctx.ui.select("BTW side chat:", [
|
|
925
|
+
"Continue previous conversation",
|
|
926
|
+
"Start fresh",
|
|
927
|
+
]);
|
|
928
|
+
if (choice === "Continue previous conversation") {
|
|
929
|
+
// Dispose session so it's recreated with fresh main context on next submit
|
|
930
|
+
await disposeSideSession();
|
|
931
|
+
setOverlayStatus("Continuing BTW thread.");
|
|
932
|
+
await ensureOverlay(ctx);
|
|
933
|
+
} else if (choice === "Start fresh") {
|
|
934
|
+
await resetThread(ctx, true);
|
|
935
|
+
setOverlayStatus("Ready");
|
|
936
|
+
await ensureOverlay(ctx);
|
|
937
|
+
}
|
|
938
|
+
// null = user cancelled (Esc), do nothing
|
|
939
|
+
} else {
|
|
940
|
+
await resetThread(ctx, true);
|
|
941
|
+
setOverlayStatus("Ready");
|
|
942
|
+
await ensureOverlay(ctx);
|
|
943
|
+
}
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
await ensureOverlay(ctx);
|
|
948
|
+
await runBtwPrompt(ctx, question);
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
953
|
+
await restoreThread(ctx);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
957
|
+
await restoreThread(ctx);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
pi.on("session_shutdown", async () => {
|
|
961
|
+
await disposeSideSession();
|
|
962
|
+
dismissOverlay();
|
|
963
|
+
});
|
|
964
|
+
}
|