@pi-unipi/btw 3.0.0-alpha.2 → 3.0.0-alpha.21

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/extensions/btw.ts CHANGED
@@ -1,160 +1,68 @@
1
1
  /**
2
- * @pi-unipi/btw — Side Conversation Extension
2
+ * @pi-unipi/btw — /unipi:btw inline side-question panel.
3
3
  *
4
- * A /unipi:btw side conversation channel that opens a real pi sub-session
5
- * with read-only coding-tool access, running immediately even while the main
6
- * agent is still busy.
4
+ * `/unipi:btw [question]` swaps the main input area for an inline panel
5
+ * (ctx.ui.custom without `overlay` — pi restores the editor and the draft
6
+ * when it closes). Every question runs in a NEW read-only in-memory session
7
+ * seeded from the main session's current branch via SessionManager.inMemory
8
+ * — the pi 0.87-supported seeding path (the old code assigned
9
+ * session.agent.state.messages, which 0.87 ignores). Nothing reaches the
10
+ * main session: no entries, no messages, and earlier btw answers are never
11
+ * fed back. Page history lives in process memory for this pi session only.
7
12
  *
8
- * Based on pi-btw by Dan Bachelder, adapted for the Unipi suite.
9
- *
10
- * Commands:
11
- * /unipi:btw [--save] <question> - Side conversation (contextual)
12
- * /unipi:btw-tangent [--save] <q> - Contextless tangent thread
13
- * /unipi:btw-new [question] - Fresh thread with main-session context
14
- * /unipi:btw-clear - Dismiss and clear thread
15
- * /unipi:btw-inject [instructions] - Send full thread to main agent
16
- * /unipi:btw-summarize [instructions] - Summarize and inject into main agent
13
+ * The btw-note renderer + context filter stay so old sessions keep
14
+ * displaying (and hiding from the model) legacy `btw-note` notes.
17
15
  */
18
16
 
19
17
  import {
20
- buildSessionContext,
21
18
  createAgentSession,
22
19
  createExtensionRuntime,
20
+ getMarkdownTheme,
23
21
  SessionManager,
24
22
  type AgentSession,
25
23
  type AgentSessionEvent,
26
24
  type ExtensionAPI,
27
25
  type ExtensionCommandContext,
28
- type ExtensionContext,
26
+ type FileEntry,
29
27
  type ModelRuntime,
30
28
  type ResourceLoader,
31
29
  } from "@earendil-works/pi-coding-agent";
32
- import { type AssistantMessage, type Message, type ThinkingLevel as AiThinkingLevel, type UserMessage } from "@earendil-works/pi-ai";
30
+ import { type AssistantMessage, type ThinkingLevel as AiThinkingLevel } from "@earendil-works/pi-ai";
33
31
  import {
34
32
  Box,
35
- Container,
36
33
  Input,
37
34
  Key,
35
+ Markdown,
38
36
  Text,
39
37
  matchesKey,
40
38
  truncateToWidth,
41
39
  visibleWidth,
42
40
  wrapTextWithAnsi,
43
- type Focusable,
41
+ type Component,
44
42
  type KeybindingsManager,
45
- type OverlayHandle,
46
43
  type TUI,
47
44
  } from "@earendil-works/pi-tui";
48
- import { BTW_COMMANDS, UNIPI_PREFIX } from "@pi-unipi/core";
45
+ import type { Theme } from "@earendil-works/pi-coding-agent";
46
+
49
47
 
50
48
  // ─── Constants ──────────────────────────────────────────────────────────────
51
49
 
52
50
  const BTW_MESSAGE_TYPE = "btw-note";
53
- const BTW_ENTRY_TYPE = "btw-thread-entry";
54
- const BTW_RESET_TYPE = "btw-thread-reset";
55
- const BTW_FOCUS_SHORTCUTS = [Key.alt("/"), Key.ctrlAlt("w")] as const;
56
-
57
- function matchesBtwFocusShortcut(data: string): boolean {
58
- return BTW_FOCUS_SHORTCUTS.some((shortcut) => matchesKey(data, shortcut));
59
- }
60
51
 
61
52
  const BTW_SYSTEM_PROMPT = [
62
- "You are having an aside conversation with the user, separate from their main working session.",
63
- "If main session messages are provided, they are for context only — that work is being handled by another agent.",
64
- "If no main session messages are provided, treat this as a fully contextless tangent thread and rely only on the user's words plus your general instructions.",
65
- "Focus on answering the user's side questions, helping them think through ideas, or planning next steps.",
66
- "Do not act as if you need to continue unfinished work from the main session unless the user explicitly asks you to prepare something for injection back to it.",
67
- "This aside is read-only: you can read and search files but cannot run commands or modify anything. Never claim to have edited, written, or executed something. If the user wants changes made, tell them to hand this thread off to the main session instead. Use /unipi:btw-inject or /unipi:btw-summarize to hand it off.",
53
+ "You are having a one-shot aside conversation with the user, separate from their main working session.",
54
+ "Main session messages are provided for context only — that work is being handled by another agent.",
55
+ "Answer the question directly; there is no follow-up thread and nothing you say reaches the main session.",
56
+ "This aside is read-only: you can read and search files but cannot run commands or modify anything. Never claim to have edited, written, or executed something. If the user wants changes or commands run, tell them to ask in the main conversation.",
68
57
  ].join(" ");
69
58
 
70
- const BTW_SUMMARIZE_SYSTEM_PROMPT =
71
- "Summarize the side conversation concisely. Preserve key decisions, plans, insights, risks, and action items. Output only the summary.";
72
-
73
- const BTW_CONTINUE_THREAD_USER_TEXT = "[The following is a separate side conversation. Continue this thread.]";
74
- const BTW_CONTINUE_THREAD_ASSISTANT_TEXT = "Understood, continuing our side conversation.";
75
-
76
- // ─── Types ──────────────────────────────────────────────────────────────────
77
-
78
- type SessionThinkingLevel = "off" | AiThinkingLevel;
79
- type BtwThreadMode = "contextual" | "tangent";
80
-
81
- type BtwDetails = {
82
- question: string;
83
- thinking: string;
84
- answer: string;
85
- provider: string;
86
- model: string;
87
- thinkingLevel: SessionThinkingLevel;
88
- timestamp: number;
89
- usage?: AssistantMessage["usage"];
90
- };
91
-
92
- type ParsedBtwArgs = {
93
- question: string;
94
- save: boolean;
95
- };
96
-
97
- type SaveState = "not-saved" | "saved" | "queued";
98
-
99
- type BtwResetDetails = {
100
- timestamp: number;
101
- mode?: BtwThreadMode;
102
- };
103
-
104
- type BtwTranscriptEntry =
105
- | { id: number; turnId: number; type: "turn-boundary"; phase: "start" | "end" }
106
- | { id: number; turnId: number; type: "user-message"; text: string }
107
- | { id: number; turnId: number; type: "thinking"; text: string; streaming: boolean }
108
- | { id: number; turnId: number; type: "assistant-text"; text: string; streaming: boolean }
109
- | { id: number; turnId: number; type: "tool-call"; toolCallId: string; toolName: string; args: string }
110
- | {
111
- id: number;
112
- turnId: number;
113
- type: "tool-result";
114
- toolCallId: string;
115
- toolName: string;
116
- content: string;
117
- truncated: boolean;
118
- isError: boolean;
119
- streaming: boolean;
120
- };
121
-
122
- type BtwTranscript = BtwTranscriptEntry[];
123
-
124
- type BtwTranscriptState = {
125
- entries: BtwTranscript;
126
- nextEntryId: number;
127
- nextTurnId: number;
128
- currentTurnId: number | null;
129
- lastTurnId: number | null;
130
- toolCalls: Map<string, { turnId: number; callEntryId: number; resultEntryId?: number }>;
131
- };
132
-
133
- type BtwSessionRuntime = {
134
- session: AgentSession;
135
- mode: BtwThreadMode;
136
- subscriptions: Set<() => void>;
137
- sideThreadStartIndex: number;
138
- };
139
-
140
- type OverlayRuntime = {
141
- handle?: OverlayHandle;
142
- refresh?: () => void;
143
- close?: () => void;
144
- finish?: () => void;
145
- setDraft?: (value: string) => void;
146
- closed?: boolean;
147
- };
148
-
149
- // ─── Helpers ────────────────────────────────────────────────────────────────
59
+ // ─── Legacy btw-note compatibility (renderer + context filter only) ─────────
150
60
 
151
61
  function isVisibleBtwMessage(message: { role: string; customType?: string }): boolean {
152
62
  return message.role === "custom" && message.customType === BTW_MESSAGE_TYPE;
153
63
  }
154
64
 
155
- function isCustomEntry(entry: unknown, customType: string): entry is { type: "custom"; customType: string; data?: unknown } {
156
- return !!entry && typeof entry === "object" && (entry as { type?: string }).type === "custom" && (entry as { customType?: string }).customType === customType;
157
- }
65
+ // ─── Shared helpers (unchanged from the previous implementation) ────────────
158
66
 
159
67
  function stripDynamicSystemPromptFooter(systemPrompt: string): string {
160
68
  return systemPrompt
@@ -163,13 +71,8 @@ function stripDynamicSystemPromptFooter(systemPrompt: string): string {
163
71
  .trim();
164
72
  }
165
73
 
166
- /**
167
- * Extract the canonical ModelRuntime from the extension-facing ModelRegistry
168
- * facade. The SDK (>=0.84) exposes `modelRuntime` on AgentSession and expects
169
- * it in CreateAgentSessionOptions; the extension context only hands us the
170
- * ModelRegistry wrapper, whose `runtime` field is the same instance the
171
- * parent session uses (agent-session wires `new ModelRegistry(this._modelRuntime)`).
172
- */
74
+ /** The extension-facing ModelRegistry wrapper's `runtime` is the same
75
+ * ModelRuntime the parent session uses. */
173
76
  function sessionModelRuntime(ctx: ExtensionCommandContext): ModelRuntime {
174
77
  return (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime;
175
78
  }
@@ -198,1772 +101,476 @@ function createBtwResourceLoader(
198
101
 
199
102
  function extractText(parts: AssistantMessage["content"], type: "text" | "thinking"): string {
200
103
  const chunks: string[] = [];
201
-
202
104
  for (const part of parts) {
203
- if (type === "text" && part.type === "text") {
204
- chunks.push(part.text);
205
- } else if (type === "thinking" && part.type === "thinking") {
206
- chunks.push(part.thinking);
207
- }
105
+ if (type === "text" && part.type === "text") chunks.push(part.text);
106
+ else if (type === "thinking" && part.type === "thinking") chunks.push(part.thinking);
208
107
  }
209
-
210
108
  return chunks.join("\n").trim();
211
109
  }
212
110
 
213
- function extractAnswer(message: AssistantMessage): string {
214
- return extractText(message.content, "text") || "(No text response)";
215
- }
216
-
217
- function extractThinking(message: AssistantMessage): string {
218
- return extractText(message.content, "thinking");
111
+ function getLastAssistantMessage(session: AgentSession): AssistantMessage | null {
112
+ for (let i = session.state.messages.length - 1; i >= 0; i--) {
113
+ const message = session.state.messages[i];
114
+ if (message.role === "assistant") return message as AssistantMessage;
115
+ }
116
+ return null;
219
117
  }
220
118
 
221
- function parseBtwArgs(args: string): ParsedBtwArgs {
222
- const save = /(?:^|\s)(?:--save|-s)(?=\s|$)/.test(args);
223
- const question = args.replace(/(?:^|\s)(?:--save|-s)(?=\s|$)/g, " ").trim();
224
- return { question, save };
119
+ function notify(ctx: { ui: { notify: (m: string, t: "info" | "warning" | "error") => void } }, message: string, type: "info" | "warning" | "error" = "info"): void {
120
+ try { ctx.ui.notify(message, type); } catch { /* no UI */ }
225
121
  }
226
122
 
227
- function buildBtwSeedState(
228
- ctx: ExtensionCommandContext,
229
- thread: BtwDetails[],
230
- mode: BtwThreadMode,
231
- ): { messages: Message[]; sideThreadStartIndex: number } {
232
- const messages: Message[] = [];
233
-
234
- if (mode === "contextual") {
235
- try {
236
- messages.push(
237
- ...(buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages as Message[]).filter(
238
- (message) => !isVisibleBtwMessage(message),
239
- ),
240
- );
241
- } catch {
242
- messages.push(
243
- ...ctx.sessionManager.getEntries().flatMap((entry) => {
244
- if (!entry || typeof entry !== "object") {
245
- return [];
246
- }
247
-
248
- const message = entry as unknown as Partial<Message> & { role?: string; customType?: string; content?: unknown };
249
- if (typeof message.role !== "string" || !Array.isArray(message.content)) {
250
- return [];
251
- }
252
-
253
- return isVisibleBtwMessage({ role: message.role, customType: message.customType }) ? [] : [message as Message];
254
- }),
255
- );
256
- }
257
- }
258
-
259
- const sideThreadStartIndex = messages.length;
260
-
261
- if (thread.length > 0) {
262
- messages.push(
263
- {
264
- role: "user",
265
- content: [{ type: "text", text: BTW_CONTINUE_THREAD_USER_TEXT }],
266
- timestamp: Date.now(),
267
- },
268
- {
269
- role: "assistant",
270
- content: [{ type: "text", text: BTW_CONTINUE_THREAD_ASSISTANT_TEXT }],
271
- provider: ctx.model?.provider ?? "unknown",
272
- model: ctx.model?.id ?? "unknown",
273
- api: ctx.model?.api ?? "openai-responses",
274
- usage: {
275
- input: 0,
276
- output: 0,
277
- cacheRead: 0,
278
- cacheWrite: 0,
279
- totalTokens: 0,
280
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
281
- },
282
- stopReason: "stop",
283
- timestamp: Date.now(),
284
- },
285
- );
286
-
287
- for (const entry of thread) {
288
- messages.push(
289
- {
290
- role: "user",
291
- content: [{ type: "text", text: entry.question }],
292
- timestamp: entry.timestamp,
293
- },
294
- {
295
- role: "assistant",
296
- content: [{ type: "text", text: entry.answer }],
297
- provider: entry.provider,
298
- model: entry.model,
299
- api: ctx.model?.api ?? "openai-responses",
300
- usage:
301
- entry.usage ?? {
302
- input: 0,
303
- output: 0,
304
- cacheRead: 0,
305
- cacheWrite: 0,
306
- totalTokens: 0,
307
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
308
- },
309
- stopReason: "stop",
310
- timestamp: entry.timestamp,
311
- },
312
- );
313
- }
314
- }
123
+ // ─── Seeding (the 0.87-supported path) ─────────────────────────────────────
315
124
 
316
- return {
317
- messages,
318
- sideThreadStartIndex,
125
+ /**
126
+ * Build FileEntry[] for a fresh in-memory btw session: the main session's
127
+ * header + its CURRENT branch, minus legacy `btw-note` custom_message
128
+ * entries. Tool results, compaction entries and in-progress tool calls all
129
+ * come along because they are branch entries — mid-run questions see the
130
+ * main agent's latest state.
131
+ */
132
+ export function buildSeedEntries(ctx: {
133
+ sessionManager: {
134
+ getHeader(): { type: string } | null;
135
+ getBranch(): Array<{ type: string; customType?: string }>;
319
136
  };
137
+ }): FileEntry[] {
138
+ const header = ctx.sessionManager.getHeader();
139
+ const branch = ctx.sessionManager.getBranch().filter(
140
+ (entry) => !(entry.type === "custom_message" && entry.customType === BTW_MESSAGE_TYPE),
141
+ );
142
+ return header
143
+ ? [header as FileEntry, ...(branch as FileEntry[])]
144
+ : (branch as FileEntry[]);
320
145
  }
321
146
 
322
- function formatToolPreview(value: unknown): string {
323
- if (value === undefined) {
324
- return "";
325
- }
326
-
327
- if (typeof value === "string") {
328
- return value;
329
- }
330
-
331
- if (value && typeof value === "object") {
332
- const path = (value as { path?: unknown }).path;
333
- if (typeof path === "string") {
334
- return path;
335
- }
336
- }
147
+ // ─── Page history (session-memory only, never persisted) ───────────────────
337
148
 
338
- try {
339
- const preview = JSON.stringify(value);
340
- if (!preview || preview === "{}") {
341
- return "";
342
- }
343
- return preview.length > 120 ? `${preview.slice(0, 117)}...` : preview;
344
- } catch {
345
- return "";
346
- }
149
+ export interface BtwPage {
150
+ question: string;
151
+ /** One line per tool call: "… Reading <p>" while running → "✓ <desc>" */
152
+ toolLines: string[];
153
+ answer: string;
154
+ error?: string;
155
+ aborted?: boolean;
156
+ done: boolean;
347
157
  }
348
158
 
349
- // ─── Transcript State ───────────────────────────────────────────────────────
159
+ const pages: BtwPage[] = [];
350
160
 
351
- function createEmptyTranscriptState(): BtwTranscriptState {
352
- return {
353
- entries: [],
354
- nextEntryId: 1,
355
- nextTurnId: 1,
356
- currentTurnId: null,
357
- lastTurnId: null,
358
- toolCalls: new Map(),
359
- };
161
+ /** ↑/↓ paging reducer — clamp the target index into [0, count-1]. */
162
+ export function pageIndexAfter(current: number, delta: number, count: number): number {
163
+ if (count <= 0) return 0;
164
+ return Math.max(0, Math.min(count - 1, current + delta));
360
165
  }
361
166
 
362
- function appendTranscriptEntry<T extends BtwTranscriptEntry>(
363
- state: BtwTranscriptState,
364
- entry: Omit<T, "id">,
365
- ): T {
366
- const nextEntry = { ...entry, id: state.nextEntryId++ } as T;
367
- state.entries.push(nextEntry);
368
- return nextEntry;
167
+ function clearPages(): void {
168
+ pages.length = 0;
369
169
  }
370
170
 
371
- function ensureTranscriptTurn(state: BtwTranscriptState): number {
372
- if (state.currentTurnId !== null) {
373
- return state.currentTurnId;
374
- }
171
+ // ─── The one-shot ask ───────────────────────────────────────────────────────
375
172
 
376
- const turnId = state.nextTurnId++;
377
- state.currentTurnId = turnId;
378
- state.lastTurnId = turnId;
379
- appendTranscriptEntry(state, { type: "turn-boundary", turnId, phase: "start" } as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
380
- return turnId;
173
+ export interface BtwRun {
174
+ /** Abort the in-flight answer (Ctrl+C path). */
175
+ abort(): void;
176
+ /** Promise resolves when the page is finalized (answer, error, or abort). */
177
+ finished: Promise<void>;
381
178
  }
382
179
 
383
- function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null): void {
384
- const resolvedTurnId = turnId ?? state.currentTurnId;
385
- if (resolvedTurnId === null || resolvedTurnId === undefined) {
386
- return;
387
- }
180
+ const TOOL_VERBS: Record<string, { run: string; done: string; arg: string }> = {
181
+ read: { run: "Reading", done: "Read", arg: "path" },
182
+ grep: { run: "Searching", done: "Searched", arg: "pattern" },
183
+ find: { run: "Finding", done: "Found", arg: "pattern" },
184
+ ls: { run: "Listing", done: "Listed", arg: "path" },
185
+ };
388
186
 
389
- const hasEndBoundary = state.entries.some(
390
- (entry) => entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end",
391
- );
392
- if (!hasEndBoundary) {
393
- appendTranscriptEntry(state, { type: "turn-boundary", turnId: resolvedTurnId, phase: "end" } as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
394
- }
187
+ function relPath(p: string, cwd: string): string {
188
+ if (p === cwd) return ".";
189
+ return p.startsWith(cwd + "/") ? p.slice(cwd.length + 1) : p;
190
+ }
395
191
 
396
- for (const entry of state.entries) {
397
- if (entry.turnId !== resolvedTurnId) {
398
- continue;
192
+ /** Devin-style tool line: `… Reading <p>` running, `✓ Read <p>` done,
193
+ * `✗ …` on error. Paths are rendered relative to cwd. */
194
+ export function formatToolLine(
195
+ toolName: string,
196
+ args: Record<string, unknown> | undefined,
197
+ cwd: string,
198
+ state: "running" | "done" | "error",
199
+ ): string {
200
+ const verbs = TOOL_VERBS[toolName];
201
+ const verb = state === "running" ? (verbs?.run ?? "Running") : (verbs?.done ?? "Done");
202
+ const argKey = verbs?.arg;
203
+ let arg = args && argKey ? args[argKey] : undefined;
204
+ arg ??= args?.path ?? args?.pattern ?? args?.command ?? args?.query;
205
+ const detail = typeof arg === "string" && arg
206
+ ? ` ${arg === args?.path ? relPath(arg, cwd) : arg}`
207
+ : "";
208
+ const mark = state === "running" ? "…" : state === "error" ? "✗" : "✓";
209
+ const label = verbs ? verb : `${verb} ${toolName}`;
210
+ return `${mark} ${label}${detail}`;
211
+ }
212
+
213
+ function runQuestion(
214
+ ctx: ExtensionCommandContext,
215
+ question: string,
216
+ page: BtwPage,
217
+ thinkingLevel: AiThinkingLevel,
218
+ onUpdate: () => void,
219
+ ): BtwRun {
220
+ let session: AgentSession | null = null;
221
+ let unsubscribe: (() => void) | null = null;
222
+
223
+ const finished = (async () => {
224
+ const model = ctx.model;
225
+ if (!model) {
226
+ page.error = "No active model selected.";
227
+ page.done = true;
228
+ onUpdate();
229
+ return;
399
230
  }
400
-
401
- if (entry.type === "thinking" || entry.type === "assistant-text" || entry.type === "tool-result") {
402
- entry.streaming = false;
231
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
232
+ if (!auth.ok || !auth.apiKey) {
233
+ page.error = auth.ok ? `No credentials for ${model.provider}/${model.id}.` : (auth.error ?? "No credentials.");
234
+ page.done = true;
235
+ onUpdate();
236
+ return;
403
237
  }
404
- }
405
-
406
- state.lastTurnId = resolvedTurnId;
407
- if (state.currentTurnId === resolvedTurnId) {
408
- state.currentTurnId = null;
409
- }
410
- }
411
-
412
- function removeTranscriptTurn(state: BtwTranscriptState, turnId: number | null): void {
413
- if (turnId === null) {
414
- return;
415
- }
416
238
 
417
- state.entries = state.entries.filter((entry) => entry.turnId !== turnId);
418
- for (const [toolCallId, toolCall] of state.toolCalls.entries()) {
419
- if (toolCall.turnId === turnId) {
420
- state.toolCalls.delete(toolCallId);
421
- }
422
- }
239
+ try {
240
+ const seeded = await createAgentSession({
241
+ sessionManager: SessionManager.inMemory(ctx.cwd, undefined, buildSeedEntries(ctx)),
242
+ model,
243
+ modelRuntime: sessionModelRuntime(ctx),
244
+ thinkingLevel,
245
+ tools: ["read", "grep", "find", "ls"],
246
+ resourceLoader: createBtwResourceLoader(ctx),
247
+ });
248
+ session = seeded.session;
249
+ const lineIndex = new Map<string, number>(); // toolCallId → toolLines index
250
+ const pendingStarts = new Map<string, { args?: Record<string, unknown> }>();
251
+ unsubscribe = session.subscribe((event: AgentSessionEvent) => {
252
+ if (event.type === "tool_execution_start") {
253
+ const e = event as { toolCallId: string; toolName: string; args?: Record<string, unknown> };
254
+ pendingStarts.set(e.toolCallId, { args: e.args });
255
+ lineIndex.set(e.toolCallId, page.toolLines.length);
256
+ page.toolLines.push(formatToolLine(e.toolName, e.args, ctx.cwd, "running"));
257
+ onUpdate();
258
+ } else if (event.type === "tool_execution_end") {
259
+ const e = event as { toolCallId: string; toolName: string; isError: boolean };
260
+ const start = pendingStarts.get(e.toolCallId);
261
+ const idx = lineIndex.get(e.toolCallId);
262
+ const line = formatToolLine(e.toolName, start?.args, ctx.cwd, e.isError ? "error" : "done");
263
+ if (idx !== undefined) page.toolLines[idx] = line;
264
+ else page.toolLines.push(line);
265
+ pendingStarts.delete(e.toolCallId);
266
+ onUpdate();
267
+ }
268
+ });
423
269
 
424
- if (state.currentTurnId === turnId) {
425
- state.currentTurnId = null;
426
- }
427
- if (state.lastTurnId === turnId) {
428
- state.lastTurnId = null;
429
- }
430
- }
270
+ await session.prompt(question, { source: "extension" });
431
271
 
432
- function findLatestTranscriptEntry<TType extends BtwTranscriptEntry["type"]>(
433
- state: BtwTranscriptState,
434
- turnId: number,
435
- type: TType,
436
- ): Extract<BtwTranscriptEntry, { type: TType }> | undefined {
437
- for (let i = state.entries.length - 1; i >= 0; i--) {
438
- const entry = state.entries[i];
439
- if (entry.turnId === turnId && entry.type === type) {
440
- return entry as Extract<BtwTranscriptEntry, { type: TType }>;
272
+ const response = getLastAssistantMessage(session);
273
+ if (!response) {
274
+ page.error = "No response.";
275
+ } else if (response.stopReason === "aborted") {
276
+ page.aborted = true;
277
+ page.error = "cancelled";
278
+ } else if (response.stopReason === "error") {
279
+ page.error = response.errorMessage || "request failed";
280
+ } else {
281
+ page.answer = extractText(response.content, "text") || "(no text)";
282
+ }
283
+ } catch (err) {
284
+ page.error = err instanceof Error ? err.message : String(err);
285
+ } finally {
286
+ page.done = true;
287
+ try { unsubscribe?.(); } catch { /* */ }
288
+ try { session?.dispose(); } catch { /* */ }
289
+ session = null;
290
+ unsubscribe = null;
291
+ onUpdate();
441
292
  }
442
- }
293
+ })();
443
294
 
444
- return undefined;
295
+ return {
296
+ abort() {
297
+ void session?.abort().catch(() => {});
298
+ },
299
+ finished,
300
+ };
445
301
  }
446
302
 
447
- function ensureTranscriptTurnForUserMessage(state: BtwTranscriptState): number {
448
- if (state.currentTurnId !== null) {
449
- const currentAssistant = findLatestTranscriptEntry(state, state.currentTurnId, "assistant-text");
450
- if (currentAssistant && !currentAssistant.streaming) {
451
- finishTranscriptTurn(state, state.currentTurnId);
452
- }
453
- }
303
+ // ─── Panel component ────────────────────────────────────────────────────────
454
304
 
455
- return ensureTranscriptTurn(state);
456
- }
305
+ const HINT_IDLE = "↵ ask · ↑↓ scroll/history · esc back to chat";
306
+ const HINT_STREAMING = "ctrl+c cancel answer · ↑↓ scroll/history · esc back to chat";
457
307
 
458
- function extractMessageText(message: { content?: string | AssistantMessage["content"] | UserMessage["content"] }): string {
459
- if (typeof message.content === "string") {
460
- return message.content;
461
- }
462
- if (!Array.isArray(message.content)) {
463
- return "";
308
+ class BtwPanel implements Component {
309
+ invalidate(): void {
310
+ this.tui.requestRender();
464
311
  }
465
- return message.content
466
- .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
467
- .map((part) => part.text)
468
- .join("\n")
469
- .trim();
470
- }
312
+ private input: Input;
313
+ private scrollOffset = 0;
314
+ private follow = true;
471
315
 
472
- function upsertUserMessageEntry(state: BtwTranscriptState, turnId: number, text: string): void {
473
- if (!text) {
474
- return;
475
- }
476
316
 
477
- const existing = findLatestTranscriptEntry(state, turnId, "user-message");
478
- if (existing) {
479
- existing.text = text;
480
- return;
317
+ constructor(
318
+ private tui: TUI,
319
+ private theme: Theme,
320
+ _keybindings: KeybindingsManager,
321
+ private done: () => void,
322
+ private onSubmit: (question: string) => void,
323
+ private initialIndex: number,
324
+ /** Extension-level "a run is in flight" check — pages can be viewed
325
+ * while another page's run streams. */
326
+ private isRunActive: () => boolean,
327
+ ) {
328
+ this.input = new Input({ prompt: "❭ ", placeholder: "Ask a /btw…", placeholderStyle: (t) => this.theme.fg("dim", t) });
481
329
  }
482
330
 
483
- appendTranscriptEntry(state, { type: "user-message", turnId, text } as Omit<Extract<BtwTranscriptEntry, { type: "user-message" }>, "id">);
484
- }
485
-
486
- function upsertTranscriptTextEntry(
487
- state: BtwTranscriptState,
488
- turnId: number,
489
- type: "thinking" | "assistant-text",
490
- text: string,
491
- streaming: boolean,
492
- ): void {
493
- if (!text) {
494
- return;
331
+ get isStreaming(): boolean {
332
+ return this.isRunActive();
495
333
  }
496
334
 
497
- const existing = findLatestTranscriptEntry(state, turnId, type);
498
- if (existing) {
499
- existing.text = text;
500
- existing.streaming = streaming;
501
- return;
335
+ private rule(width: number): string {
336
+ const label = ` /btw ${Math.min(this.initialIndex + 1, pages.length)}/${pages.length} ──`;
337
+ const rule = "─".repeat(Math.max(0, width - visibleWidth(label)));
338
+ return this.theme.fg("borderMuted", rule) + this.theme.fg("dim", label);
502
339
  }
503
340
 
504
- appendTranscriptEntry(state, { type, turnId, text, streaming } as Omit<Extract<BtwTranscriptEntry, { type: "thinking" | "assistant-text" }>, "id">);
505
- }
506
-
507
- function summarizeToolResult(value: unknown, maxLength = 400): { content: string; truncated: boolean } {
508
- let content = "";
509
-
510
- if (value && typeof value === "object") {
511
- const toolValue = value as {
512
- content?: Array<{ type?: string; text?: string }>;
513
- error?: unknown;
514
- message?: unknown;
515
- };
516
-
517
- if (Array.isArray(toolValue.content)) {
518
- content = toolValue.content
519
- .filter((part) => part.type === "text" && typeof part.text === "string")
520
- .map((part) => part.text ?? "")
521
- .join("\n")
522
- .trim();
523
- }
524
-
525
- if (!content && typeof toolValue.error === "string") {
526
- content = toolValue.error;
341
+ private pageLines(width: number): string[] {
342
+ const page = pages[this.initialIndex];
343
+ if (!page) return [];
344
+ const out: string[] = [this.theme.fg("accent", `❭ ${page.question}`)];
345
+ for (const line of page.toolLines) {
346
+ out.push(this.theme.fg("dim", line.startsWith("…") ? line : ` ${line}`));
527
347
  }
528
-
529
- if (!content && typeof toolValue.message === "string") {
530
- content = toolValue.message;
348
+ if (!page.done) {
349
+ out.push(this.theme.fg("dim", "Thinking.."));
350
+ } else if (page.error) {
351
+ out.push(this.theme.fg("error", `✗ ${page.error}`));
531
352
  }
532
- }
533
-
534
- if (!content) {
535
- if (typeof value === "string") {
536
- content = value;
537
- } else if (value !== undefined) {
538
- try {
539
- content = JSON.stringify(value, null, 2);
540
- } catch {
541
- content = String(value);
542
- }
353
+ if (page.answer) {
354
+ const md = new Markdown(page.answer, 0, 0, getMarkdownTheme());
355
+ out.push(...md.render(width));
543
356
  }
357
+ return out;
544
358
  }
545
359
 
546
- if (!content) {
547
- content = "(no tool output)";
548
- }
549
-
550
- const truncated = content.length > maxLength;
551
- return {
552
- content: truncated ? `${content.slice(0, maxLength - 3)}...` : content,
553
- truncated,
554
- };
555
- }
556
-
557
- function ensureToolCallEntry(
558
- state: BtwTranscriptState,
559
- turnId: number,
560
- toolCallId: string,
561
- toolName: string,
562
- args: string,
563
- ): { turnId: number; callEntryId: number; resultEntryId?: number } {
564
- const existing = state.toolCalls.get(toolCallId);
565
- if (existing) {
566
- return existing;
567
- }
568
-
569
- const callEntry = appendTranscriptEntry(state, {
570
- type: "tool-call",
571
- turnId,
572
- toolCallId,
573
- toolName,
574
- args,
575
- } as Omit<Extract<BtwTranscriptEntry, { type: "tool-call" }>, "id">);
576
- const record = { turnId, callEntryId: callEntry.id };
577
- state.toolCalls.set(toolCallId, record);
578
- return record;
579
- }
580
-
581
- function upsertToolResultEntry(
582
- state: BtwTranscriptState,
583
- turnId: number,
584
- toolCallId: string,
585
- toolName: string,
586
- content: string,
587
- truncated: boolean,
588
- isError: boolean,
589
- streaming: boolean,
590
- ): void {
591
- const toolCall = ensureToolCallEntry(state, turnId, toolCallId, toolName, "");
592
- const existing =
593
- toolCall.resultEntryId !== undefined
594
- ? state.entries.find((entry) => entry.id === toolCall.resultEntryId && entry.type === "tool-result")
595
- : undefined;
596
-
597
- if (existing && existing.type === "tool-result") {
598
- existing.content = content;
599
- existing.truncated = truncated;
600
- existing.isError = isError;
601
- existing.streaming = streaming;
602
- return;
603
- }
360
+ render(width: number): string[] {
361
+ const w = Math.max(20, width);
362
+ const rows = process.stdout.rows ?? 30;
363
+ const viewport = Math.max(4, rows - 10);
604
364
 
605
- const resultEntry = appendTranscriptEntry(state, {
606
- type: "tool-result",
607
- turnId,
608
- toolCallId,
609
- toolName,
610
- content,
611
- truncated,
612
- isError,
613
- streaming,
614
- } as Omit<Extract<BtwTranscriptEntry, { type: "tool-result" }>, "id">);
615
- toolCall.resultEntryId = resultEntry.id;
616
- }
365
+ const body = this.pageLines(w).flatMap((l) => wrapTextWithAnsi(l, Math.max(1, w - 1)));
366
+ const maxScroll = Math.max(0, body.length - viewport);
367
+ if (this.follow) this.scrollOffset = maxScroll;
368
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
369
+ if (this.scrollOffset >= maxScroll) this.follow = true;
370
+ const visible = body.slice(this.scrollOffset, this.scrollOffset + viewport);
617
371
 
618
- function applyAssistantMessageToTranscript(
619
- state: BtwTranscriptState,
620
- turnId: number,
621
- message: AssistantMessage,
622
- streaming: boolean,
623
- ): void {
624
- const thinking = extractThinking(message);
625
- const answer = extractMessageText(message);
372
+ const inputLine = this.isStreaming
373
+ ? this.theme.fg("dim", "❭ Waiting for the answer…")
374
+ : this.input.render(w - 2)[0] ?? "❭ ";
626
375
 
627
- if (thinking) {
628
- upsertTranscriptTextEntry(state, turnId, "thinking", thinking, streaming);
376
+ // Devin layout: top rule · body · input · bottom rule · hint.
377
+ return [
378
+ this.rule(w),
379
+ ...visible.map((l) => truncateToWidth(l, w, "")),
380
+ inputLine,
381
+ this.theme.fg("borderMuted", "─".repeat(w)),
382
+ this.theme.fg("dim", this.isStreaming ? HINT_STREAMING : HINT_IDLE),
383
+ ];
629
384
  }
630
385
 
631
- if (answer) {
632
- upsertTranscriptTextEntry(state, turnId, "assistant-text", answer, streaming);
386
+ /** Re-render while an answer streams (called from the run's onUpdate). */
387
+ tick(): void {
388
+ this.tui.requestRender();
633
389
  }
634
- }
635
-
636
- function applyTranscriptEvent(state: BtwTranscriptState, event: AgentSessionEvent): void {
637
- switch (event.type) {
638
- case "turn_start": {
639
- ensureTranscriptTurn(state);
640
- return;
641
- }
642
- case "message_start": {
643
- if (event.message.role === "user") {
644
- const turnId = ensureTranscriptTurnForUserMessage(state);
645
- upsertUserMessageEntry(state, turnId, extractMessageText(event.message));
646
- return;
647
- }
648
-
649
- if (event.message.role === "assistant") {
650
- const turnId = ensureTranscriptTurn(state);
651
- applyAssistantMessageToTranscript(state, turnId, event.message, true);
652
- }
653
- return;
654
- }
655
- case "message_update": {
656
- if (event.message.role !== "assistant") {
657
- return;
658
- }
659
390
 
660
- const turnId = ensureTranscriptTurn(state);
661
- applyAssistantMessageToTranscript(state, turnId, event.message, true);
391
+ handleInput(data: string): void {
392
+ // Esc — always closable. A streaming answer keeps running into history.
393
+ if (data === "\x1b" || matchesKey(data, Key.escape)) {
394
+ // Esc while streaming: close, the answer keeps going into history.
395
+ this.done();
662
396
  return;
663
397
  }
664
- case "message_end": {
665
- if (event.message.role === "user") {
666
- const turnId = ensureTranscriptTurnForUserMessage(state);
667
- upsertUserMessageEntry(state, turnId, extractMessageText(event.message));
668
- return;
669
- }
670
-
671
- if (event.message.role === "assistant") {
672
- const turnId = ensureTranscriptTurn(state);
673
- applyAssistantMessageToTranscript(state, turnId, event.message, false);
674
- }
398
+ // Ctrl+C cancels the in-flight answer (idle panel: close like Esc).
399
+ if (data === "\x03") {
400
+ if (this.isStreaming) this.abortRun();
401
+ else this.done();
675
402
  return;
676
403
  }
677
- case "tool_execution_start": {
678
- const turnId = ensureTranscriptTurn(state);
679
- ensureToolCallEntry(state, turnId, event.toolCallId, event.toolName, formatToolPreview(event.args));
404
+ // PgUp/PgDn scroll the answer viewport.
405
+ if (matchesKey(data, Key.pageUp)) {
406
+ this.follow = false;
407
+ this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(1, (process.stdout.rows ?? 30) - 11));
408
+ this.tui.requestRender();
680
409
  return;
681
410
  }
682
- case "tool_execution_update": {
683
- const turnId = state.toolCalls.get(event.toolCallId)?.turnId ?? ensureTranscriptTurn(state);
684
- const result = summarizeToolResult(event.partialResult);
685
- upsertToolResultEntry(
686
- state,
687
- turnId,
688
- event.toolCallId,
689
- event.toolName,
690
- result.content,
691
- result.truncated,
692
- false,
693
- true,
694
- );
411
+ if (matchesKey(data, Key.pageDown)) {
412
+ this.scrollOffset += Math.max(1, (process.stdout.rows ?? 30) - 11);
413
+ this.follow = false;
414
+ this.tui.requestRender();
695
415
  return;
696
416
  }
697
- case "tool_execution_end": {
698
- const turnId = state.toolCalls.get(event.toolCallId)?.turnId ?? ensureTranscriptTurn(state);
699
- const result = summarizeToolResult(event.result);
700
- upsertToolResultEntry(
701
- state,
702
- turnId,
703
- event.toolCallId,
704
- event.toolName,
705
- result.content,
706
- result.truncated,
707
- event.isError,
708
- false,
709
- );
417
+ // ↑/↓ on an EMPTY input pages through earlier Q&As.
418
+ if ((matchesKey(data, Key.up) || matchesKey(data, Key.down)) && this.input.getValue() === "") {
419
+ const delta = matchesKey(data, Key.up) ? -1 : 1;
420
+ const next = pageIndexAfter(this.initialIndex, delta, pages.length);
421
+ if (next !== this.initialIndex) {
422
+ this.initialIndex = next;
423
+ this.follow = true;
424
+ this.scrollOffset = 0;
425
+ }
426
+ this.tui.requestRender();
710
427
  return;
711
428
  }
712
- case "turn_end": {
713
- finishTranscriptTurn(state);
429
+ // Enter submits — but never while an answer is streaming.
430
+ if (data === "\r" || matchesKey(data, Key.enter)) {
431
+ if (this.isStreaming) return;
432
+ const value = this.input.getValue().trim();
433
+ if (!value) return;
434
+ this.input.setValue("");
435
+ this.onSubmit(value);
714
436
  return;
715
437
  }
716
- default:
717
- return;
438
+ if (this.isStreaming) return; // input locked while waiting
439
+ this.input.handleInput(data);
718
440
  }
719
- }
720
441
 
721
- function appendPersistedTranscriptTurn(state: BtwTranscriptState, details: BtwDetails): void {
722
- const turnId = ensureTranscriptTurn(state);
723
- upsertUserMessageEntry(state, turnId, details.question);
724
- if (details.thinking) {
725
- upsertTranscriptTextEntry(state, turnId, "thinking", details.thinking, false);
442
+ /** Wire Ctrl+C to the extension-level active run (attached on reopen). */
443
+ bindAbort(fn: () => void): void {
444
+ this.abortRun = fn;
726
445
  }
727
- upsertTranscriptTextEntry(state, turnId, "assistant-text", details.answer, false);
728
- finishTranscriptTurn(state, turnId);
729
- }
730
-
731
- function setTranscriptFailure(state: BtwTranscriptState, message: string): void {
732
- const turnId = state.currentTurnId ?? state.lastTurnId ?? ensureTranscriptTurn(state);
733
- upsertTranscriptTextEntry(state, turnId, "assistant-text", `❌ ${message}`, false);
734
- finishTranscriptTurn(state, turnId);
735
- }
446
+ private abortRun: () => void = () => {};
736
447
 
737
- function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
738
- return entries.some(
739
- (entry) =>
740
- (entry.type === "thinking" || entry.type === "assistant-text" || entry.type === "tool-result") &&
741
- entry.streaming,
742
- );
743
- }
448
+ goToPage(index: number): void {
449
+ this.initialIndex = Math.max(0, Math.min(pages.length - 1, index));
450
+ this.follow = true;
451
+ this.scrollOffset = 0;
452
+ this.tui.requestRender();
453
+ }
744
454
 
745
- function getCompletedExchangeCount(entries: BtwTranscript): number {
746
- return entries.filter((entry) => entry.type === "assistant-text" && !entry.streaming).length;
455
+ dispose(): void {}
747
456
  }
748
457
 
749
- // ─── Overlay Transcript Rendering ───────────────────────────────────────────
458
+ // ─── Extension ──────────────────────────────────────────────────────────────
750
459
 
751
- function buildTranscriptBadge(
752
- theme: ExtensionContext["ui"]["theme"],
753
- label: string,
754
- background: "userMessageBg" | "toolPendingBg" | "customMessageBg",
755
- foreground: "accent" | "warning" | "success",
756
- ): string {
757
- return theme.bg(background, theme.fg(foreground, theme.bold(` ${label} `)));
758
- }
460
+ export default function btwExtension(pi: ExtensionAPI): void {
461
+ let panelRef: BtwPanel | null = null;
462
+ /** One in-flight answer at a time, tracked here (not per page or panel):
463
+ * Enter is blocked while it streams, Esc leaves it running into history,
464
+ * reopening re-attaches abort. */
465
+ let activeRun: BtwRun | null = null;
759
466
 
760
- function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext["ui"]["theme"]): string[] {
761
- if (entries.length === 0) {
762
- return [theme.fg("dim", "No BTW thread yet. Ask a side question to start one.")];
467
+ function startRun(ctx: ExtensionCommandContext, question: string): void {
468
+ if (activeRun) return; // never two answers at once
469
+ const page: BtwPage = { question, toolLines: [], answer: "", done: false };
470
+ const index = pages.length;
471
+ pages.push(page);
472
+ const run = runQuestion(ctx, question, page, pi.getThinkingLevel() as AiThinkingLevel, () => panelRef?.tick());
473
+ activeRun = run;
474
+ panelRef?.bindAbort(() => activeRun?.abort());
475
+ void run.finished.finally(() => {
476
+ if (activeRun === run) activeRun = null;
477
+ panelRef?.tick();
478
+ });
479
+ panelRef?.goToPage(index);
763
480
  }
764
481
 
765
- const lines: string[] = [];
766
- const userBadge = buildTranscriptBadge(theme, "You", "userMessageBg", "accent");
767
- const thinkingBadge = buildTranscriptBadge(theme, "Thinking", "toolPendingBg", "warning");
768
- const toolBadge = buildTranscriptBadge(theme, "Tool", "toolPendingBg", "warning");
769
- const assistantBadge = buildTranscriptBadge(theme, "Assistant", "customMessageBg", "success");
770
- const separator = theme.fg("borderMuted", "────────────────────────────────────────");
771
- const blockIndent = " ";
772
- const resultIndent = blockIndent;
773
-
774
- const pushBlankLine = () => {
775
- if (lines.length > 0 && lines[lines.length - 1] !== "") {
776
- lines.push("");
777
- }
778
- };
779
-
780
- const pushInlineBlock = (
781
- header: string,
782
- text: string,
783
- options: { blankBefore?: boolean; style?: (value: string) => string } = {},
784
- ) => {
785
- const bodyLines = text.split("\n");
786
- const style = options.style ?? ((value: string) => value);
787
- if (options.blankBefore !== false) {
788
- pushBlankLine();
789
- }
790
-
791
- const firstLine = bodyLines.shift() ?? "";
792
- lines.push(`${header}${firstLine ? ` ${style(firstLine)}` : ""}`);
793
- for (const line of bodyLines) {
794
- lines.push(`${blockIndent}${style(line)}`);
795
- }
796
- };
797
-
798
- const pushStackedBlock = (
799
- header: string,
800
- text: string,
801
- options: { blankBefore?: boolean; indent?: string; style?: (value: string) => string } = {},
802
- ) => {
803
- const bodyLines = text.split("\n");
804
- const indent = options.indent ?? blockIndent;
805
- const style = options.style ?? ((value: string) => value);
806
- if (options.blankBefore !== false) {
807
- pushBlankLine();
808
- }
809
-
810
- lines.push(header);
811
- for (const line of bodyLines) {
812
- lines.push(`${indent}${style(line)}`);
813
- }
814
- };
815
-
816
- for (const entry of entries) {
817
- if (entry.type === "turn-boundary") {
818
- if (entry.phase === "start" && lines.length > 0) {
819
- pushBlankLine();
820
- lines.push(separator);
821
- }
822
- continue;
823
- }
824
-
825
- if (entry.type === "user-message") {
826
- pushInlineBlock(userBadge, entry.text, { blankBefore: false });
827
- continue;
828
- }
829
-
830
- if (entry.type === "thinking") {
831
- const thinkingHeader = entry.streaming ? `${thinkingBadge} ${theme.fg("warning", "▍")}` : thinkingBadge;
832
- pushStackedBlock(thinkingHeader, entry.text, {
833
- style: (line) => theme.fg("warning", theme.italic(line)),
834
- });
835
- continue;
836
- }
837
-
838
- if (entry.type === "tool-call") {
839
- const toolLabel = theme.fg("warning", theme.bold(entry.toolName));
840
- const argsLabel = entry.args ? theme.fg("dim", ` · ${entry.args}`) : "";
841
- pushInlineBlock(toolBadge, `${toolLabel}${argsLabel}`);
842
- continue;
843
- }
844
-
845
- if (entry.type === "tool-result") {
846
- const resultHeaderLabel = entry.isError
847
- ? theme.fg("error", "↳ error")
848
- : entry.streaming
849
- ? theme.fg("warning", "↳ streaming result")
850
- : theme.fg("dim", "↳ result");
851
- const truncationLabel = entry.truncated ? theme.fg("dim", " (truncated)") : "";
852
- pushStackedBlock(`${resultHeaderLabel}${truncationLabel}`, entry.content, {
853
- blankBefore: false,
854
- indent: resultIndent,
855
- style: (line) => (entry.isError ? theme.fg("error", line) : theme.fg("dim", line)),
856
- });
857
- continue;
858
- }
482
+ function abortActiveRun(): void {
483
+ activeRun?.abort();
484
+ }
859
485
 
860
- if (entry.type === "assistant-text") {
861
- const assistantHeader = entry.streaming ? `${assistantBadge} ${theme.fg("warning", "▍")}` : assistantBadge;
862
- pushStackedBlock(assistantHeader, entry.text);
486
+ async function openPanel(ctx: ExtensionCommandContext, question?: string): Promise<void> {
487
+ try {
488
+ await ctx.ui.custom<void>(
489
+ async (tui, theme, keybindings, done) => {
490
+ const panel = new BtwPanel(
491
+ tui,
492
+ theme,
493
+ keybindings,
494
+ () => { panelRef = null; done(); },
495
+ (value) => startRun(ctx, value),
496
+ Math.max(0, pages.length - 1),
497
+ () => activeRun !== null,
498
+ );
499
+ panelRef = panel;
500
+ if (activeRun) panel.bindAbort(() => activeRun?.abort());
501
+ if (question) startRun(ctx, question);
502
+ return panel;
503
+ },
504
+ // No `overlay`: pi swaps the editor container for our component and
505
+ // restores the editor + draft when done() is called.
506
+ );
507
+ } catch (err) {
508
+ notify(ctx, err instanceof Error ? err.message : String(err), "error");
863
509
  }
864
510
  }
865
511
 
866
- return lines;
867
- }
512
+ async function headlessAsk(ctx: ExtensionCommandContext, question: string): Promise<void> {
513
+ const page: BtwPage = { question, toolLines: [], answer: "", done: false };
514
+ pages.push(page);
515
+ notify(ctx, `btw: ${question}`, "info");
516
+ const run = runQuestion(ctx, question, page, pi.getThinkingLevel() as AiThinkingLevel, () => {});
517
+ activeRun = run;
518
+ await run.finished;
519
+ activeRun = null;
520
+ notify(ctx, page.error ? `btw failed: ${page.error}` : `btw: ${page.answer}`, page.error ? "error" : "info");
521
+ }
868
522
 
869
- // ─── Session Helpers ────────────────────────────────────────────────────────
523
+ pi.on("session_start", () => {
524
+ abortActiveRun();
525
+ clearPages();
526
+ });
527
+ pi.on("session_tree", () => {
528
+ abortActiveRun();
529
+ clearPages();
530
+ });
531
+ pi.on("session_shutdown", async () => {
532
+ abortActiveRun();
533
+ clearPages();
534
+ panelRef = null;
535
+ });
870
536
 
871
- function getLastAssistantMessage(session: AgentSession): AssistantMessage | null {
872
- for (let i = session.state.messages.length - 1; i >= 0; i--) {
873
- const message = session.state.messages[i];
874
- if (message.role === "assistant") {
875
- return message as AssistantMessage;
537
+ // Legacy compatibility: keep hiding btw-note messages from the model and
538
+ // keep rendering them for old sessions.
539
+ pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => {
540
+ const details = message.details as { provider?: string; model?: string; thinkingLevel?: string; usage?: { input: number; output: number; totalTokens: number } } | undefined;
541
+ const content = typeof message.content === "string" ? message.content : "[non-text btw message]";
542
+ const lines = [theme.fg("accent", theme.bold("[BTW]")), content];
543
+ if (expanded && details) {
544
+ lines.push(theme.fg("dim", `model: ${details.provider}/${details.model} · thinking: ${details.thinkingLevel}`));
545
+ if (details.usage) {
546
+ lines.push(theme.fg("dim", `tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`));
547
+ }
876
548
  }
877
- }
878
-
879
- return null;
880
- }
549
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
550
+ box.addChild(new Text(lines.join("\n"), 0, 0));
551
+ return box;
552
+ });
881
553
 
882
- type BtwHandoffExchange = {
883
- user: string;
884
- assistant: string;
885
- };
554
+ pi.on("context", async (event) => {
555
+ return {
556
+ messages: event.messages.filter((message) => !isVisibleBtwMessage(message)),
557
+ };
558
+ });
886
559
 
887
- function buildBtwMessageContent(question: string, answer: string): string {
888
- return `Q: ${question}\n\nA: ${answer}`;
889
- }
890
-
891
- function formatThread(thread: BtwHandoffExchange[]): string {
892
- return thread.map((entry) => `User: ${entry.user.trim()}\nAssistant: ${entry.assistant.trim()}`).join("\n\n---\n\n");
893
- }
894
-
895
- function isThreadContinuationMarker(messages: Message[], index: number): boolean {
896
- const userMessage = messages[index];
897
- const assistantMessage = messages[index + 1];
898
- return (
899
- userMessage?.role === "user" &&
900
- extractMessageText(userMessage) === BTW_CONTINUE_THREAD_USER_TEXT &&
901
- assistantMessage?.role === "assistant" &&
902
- extractMessageText(assistantMessage) === BTW_CONTINUE_THREAD_ASSISTANT_TEXT
903
- );
904
- }
905
-
906
- function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffExchange[] {
907
- const handoffMessages = sessionRuntime.session.state.messages.slice(sessionRuntime.sideThreadStartIndex);
908
- const threadMessages = isThreadContinuationMarker(handoffMessages as Message[], 0) ? handoffMessages.slice(2) : handoffMessages;
909
- const exchanges: BtwHandoffExchange[] = [];
910
- let currentUser = "";
911
- let currentAssistant = "";
912
-
913
- const pushCurrent = () => {
914
- if (!currentUser && !currentAssistant) {
915
- return;
916
- }
917
-
918
- exchanges.push({
919
- user: currentUser.trim() || "(No user prompt)",
920
- assistant: currentAssistant.trim() || "(No assistant response)",
921
- });
922
- currentUser = "";
923
- currentAssistant = "";
924
- };
925
-
926
- for (const message of threadMessages) {
927
- if (message.role !== "user" && message.role !== "assistant") {
928
- continue;
929
- }
930
-
931
- const text = extractMessageText(message).trim();
932
- if (!text) {
933
- continue;
934
- }
935
-
936
- if (message.role === "user") {
937
- pushCurrent();
938
- currentUser = text;
939
- continue;
940
- }
941
-
942
- currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text;
943
- }
944
-
945
- pushCurrent();
946
- return exchanges;
947
- }
948
-
949
- function saveVisibleBtwNote(
950
- pi: ExtensionAPI,
951
- details: BtwDetails,
952
- saveRequested: boolean,
953
- wasBusy: boolean,
954
- ): SaveState {
955
- if (!saveRequested) {
956
- return "not-saved";
957
- }
958
-
959
- const message = {
960
- customType: BTW_MESSAGE_TYPE,
961
- content: buildBtwMessageContent(details.question, details.answer),
962
- display: true,
963
- details,
964
- };
965
-
966
- if (wasBusy) {
967
- pi.sendMessage(message, { deliverAs: "followUp" });
968
- return "queued";
969
- }
970
-
971
- pi.sendMessage(message);
972
- return "saved";
973
- }
974
-
975
- function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
976
- if (ctx.hasUI) {
977
- ctx.ui.notify(message, level);
978
- }
979
- }
980
-
981
- function getOverlayTitle(mode: BtwThreadMode): string {
982
- return mode === "tangent" ? "BTW tangent" : "BTW";
983
- }
984
-
985
- // ─── BtwOverlayComponent ────────────────────────────────────────────────────
986
-
987
- class BtwOverlayComponent extends Container implements Focusable {
988
- private readonly input: Input;
989
- private readonly readTranscriptEntries: () => BtwTranscript;
990
- private readonly getStatus: () => string | null;
991
- private readonly getMode: () => BtwThreadMode;
992
- private readonly onSubmitCallback: (value: string) => void;
993
- private readonly onDismissCallback: () => void;
994
- private readonly onUnfocusCallback: () => void;
995
- private readonly tui: TUI;
996
- private readonly theme: ExtensionContext["ui"]["theme"];
997
- private transcriptLines: string[] = [];
998
- private transcriptScrollOffset = 0;
999
- private transcriptViewportHeight = 8;
1000
- private followTranscript = true;
1001
- private _focused = false;
1002
- private modeTextValue = "";
1003
- private summaryTextValue = "";
1004
- private statusTextValue = "";
1005
- private hintsTextValue = "";
1006
-
1007
- get focused(): boolean {
1008
- return this._focused;
1009
- }
1010
-
1011
- set focused(value: boolean) {
1012
- this._focused = value;
1013
- this.input.focused = value;
1014
- }
1015
-
1016
- constructor(
1017
- tui: TUI,
1018
- theme: ExtensionContext["ui"]["theme"],
1019
- keybindings: KeybindingsManager,
1020
- readTranscriptEntries: () => BtwTranscript,
1021
- getStatus: () => string | null,
1022
- getMode: () => BtwThreadMode,
1023
- onSubmit: (value: string) => void,
1024
- onDismiss: () => void,
1025
- onUnfocus: () => void,
1026
- ) {
1027
- super();
1028
- this.tui = tui;
1029
- this.theme = theme;
1030
- this.readTranscriptEntries = readTranscriptEntries;
1031
- this.getStatus = getStatus;
1032
- this.getMode = getMode;
1033
- this.onSubmitCallback = onSubmit;
1034
- this.onDismissCallback = onDismiss;
1035
- this.onUnfocusCallback = onUnfocus;
1036
-
1037
- this.input = new Input();
1038
- this.input.onSubmit = (value) => {
1039
- this.followTranscript = true;
1040
- this.onSubmitCallback(value);
1041
- };
1042
- this.input.onEscape = () => {
1043
- this.onDismissCallback();
1044
- };
1045
-
1046
- const originalHandleInput = this.input.handleInput.bind(this.input);
1047
- this.input.handleInput = (data: string) => {
1048
- if (keybindings.matches(data, "tui.select.cancel")) {
1049
- this.onDismissCallback();
1050
- return;
1051
- }
1052
- originalHandleInput(data);
1053
- };
1054
-
1055
- this.refresh();
1056
- }
1057
-
1058
- private frameLine(content: string, innerWidth: number): string {
1059
- const truncated = truncateToWidth(content, innerWidth, "");
1060
- const padding = Math.max(0, innerWidth - visibleWidth(truncated));
1061
- return `${this.theme.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("borderMuted", "│")}`;
1062
- }
1063
-
1064
- private ruleLine(innerWidth: number): string {
1065
- return this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
1066
- }
1067
-
1068
- private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
1069
- const left = edge === "top" ? "┌" : "└";
1070
- const right = edge === "top" ? "┐" : "┘";
1071
- return this.theme.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
1072
- }
1073
-
1074
- private wrapTranscript(innerWidth: number): string[] {
1075
- const wrapped: string[] = [];
1076
- for (const line of this.transcriptLines) {
1077
- if (!line) {
1078
- wrapped.push("");
1079
- continue;
1080
- }
1081
- wrapped.push(...wrapTextWithAnsi(line, Math.max(1, innerWidth)));
1082
- }
1083
- return wrapped;
1084
- }
1085
-
1086
- private getDialogHeight(): number {
1087
- const terminalRows = process.stdout.rows ?? 30;
1088
- return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78)));
1089
- }
1090
-
1091
- handleInput(data: string): void {
1092
- if (matchesBtwFocusShortcut(data)) {
1093
- this.onUnfocusCallback();
1094
- return;
1095
- }
1096
-
1097
- if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("pageUp")) || matchesKey(data, Key.alt("pageUp"))) {
1098
- this.followTranscript = false;
1099
- this.transcriptScrollOffset = Math.max(0, this.transcriptScrollOffset - Math.max(1, this.transcriptViewportHeight - 1));
1100
- this.tui.requestRender();
1101
- return;
1102
- }
1103
-
1104
- if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("pageDown")) || matchesKey(data, Key.alt("pageDown"))) {
1105
- this.transcriptScrollOffset += Math.max(1, this.transcriptViewportHeight - 1);
1106
- this.tui.requestRender();
1107
- return;
1108
- }
1109
-
1110
- this.input.handleInput(data);
1111
- }
1112
-
1113
- private inputFrameLine(dialogWidth: number): string {
1114
- const targetWidth = Math.max(1, dialogWidth - 2);
1115
- const previousFocused = this.input.focused;
1116
- this.input.focused = false;
1117
- try {
1118
- const inputLine = this.input.render(targetWidth)[0] ?? "";
1119
- return `${this.theme.fg("borderMuted", "│")}${inputLine}${this.theme.fg("borderMuted", "│")}`;
1120
- } finally {
1121
- this.input.focused = previousFocused;
1122
- }
1123
- }
1124
-
1125
- override render(width: number): string[] {
1126
- const dialogWidth = Math.max(24, width);
1127
- const innerWidth = Math.max(22, dialogWidth - 2);
1128
- const transcriptLines = this.wrapTranscript(innerWidth);
1129
- const dialogHeight = this.getDialogHeight();
1130
- const chromeHeight = 8;
1131
- const transcriptHeight = Math.max(6, dialogHeight - chromeHeight);
1132
- this.transcriptViewportHeight = transcriptHeight;
1133
-
1134
- const maxScroll = Math.max(0, transcriptLines.length - transcriptHeight);
1135
- if (this.followTranscript) {
1136
- this.transcriptScrollOffset = maxScroll;
1137
- } else {
1138
- this.transcriptScrollOffset = Math.max(0, Math.min(this.transcriptScrollOffset, maxScroll));
1139
- if (this.transcriptScrollOffset >= maxScroll) {
1140
- this.followTranscript = true;
1141
- }
1142
- }
1143
-
1144
- const visibleTranscript = transcriptLines.slice(
1145
- this.transcriptScrollOffset,
1146
- this.transcriptScrollOffset + transcriptHeight,
1147
- );
1148
- const transcriptPadCount = Math.max(0, transcriptHeight - visibleTranscript.length);
1149
- const hiddenAbove = this.transcriptScrollOffset;
1150
- const hiddenBelow = Math.max(0, maxScroll - this.transcriptScrollOffset);
1151
- const summary =
1152
- hiddenAbove || hiddenBelow
1153
- ? `${this.summaryTextValue.trim()} · ↑${hiddenAbove} ↓${hiddenBelow}`
1154
- : this.summaryTextValue.trim();
1155
-
1156
- const lines = [this.borderLine(innerWidth, "top")];
1157
-
1158
- lines.push(this.frameLine(this.theme.fg("accent", this.theme.bold(this.modeTextValue.trim())), innerWidth));
1159
- lines.push(this.frameLine(this.theme.fg("dim", summary), innerWidth));
1160
- lines.push(this.ruleLine(innerWidth));
1161
-
1162
- for (const line of visibleTranscript) {
1163
- lines.push(this.frameLine(line, innerWidth));
1164
- }
1165
- for (let i = 0; i < transcriptPadCount; i++) {
1166
- lines.push(this.frameLine("", innerWidth));
1167
- }
1168
-
1169
- lines.push(this.ruleLine(innerWidth));
1170
- lines.push(this.frameLine(this.theme.fg("warning", this.statusTextValue.trim()), innerWidth));
1171
- lines.push(this.inputFrameLine(dialogWidth));
1172
- lines.push(this.frameLine(this.theme.fg("dim", this.hintsTextValue.trim()), innerWidth));
1173
- lines.push(this.borderLine(innerWidth, "bottom"));
1174
-
1175
- return lines;
1176
- }
1177
-
1178
- setDraft(value: string): void {
1179
- this.input.setValue(value);
1180
- this.tui.requestRender();
1181
- }
1182
-
1183
- getDraft(): string {
1184
- return this.input.getValue();
1185
- }
1186
-
1187
- refresh(): void {
1188
- this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`;
1189
- const entries = this.readTranscriptEntries();
1190
- const exchanges = getCompletedExchangeCount(entries);
1191
- const active = hasStreamingTranscriptEntry(entries) ? " · streaming" : " · idle";
1192
- this.summaryTextValue = `${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`;
1193
-
1194
- this.transcriptLines = buildOverlayTranscript(entries, this.theme);
1195
-
1196
- const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
1197
- this.statusTextValue = status;
1198
- this.hintsTextValue = "Enter submit · Alt+/ toggle focus · Escape dismiss · PgUp/Ctrl+PgUp scroll up · PgDn/Ctrl+PgDn scroll down";
1199
- this.tui.requestRender();
1200
- }
1201
- }
1202
-
1203
- // ─── Extension Entry ────────────────────────────────────────────────────────
1204
-
1205
- export default function (pi: ExtensionAPI) {
1206
- let pendingThread: BtwDetails[] = [];
1207
- let pendingMode: BtwThreadMode = "contextual";
1208
- let transcriptState = createEmptyTranscriptState();
1209
- let overlayStatus: string | null = null;
1210
- let overlayDraft = "";
1211
- let overlayRuntime: OverlayRuntime | null = null;
1212
- let lastUiContext: ExtensionContext | ExtensionCommandContext | null = null;
1213
- let activeBtwSession: BtwSessionRuntime | null = null;
1214
-
1215
- function syncUi(ctx?: ExtensionContext | ExtensionCommandContext): void {
1216
- const activeCtx = ctx ?? lastUiContext;
1217
- if (activeCtx?.hasUI) {
1218
- activeCtx.ui.setWidget("btw", undefined);
1219
- overlayRuntime?.refresh?.();
1220
- }
1221
- }
1222
-
1223
- function setOverlayStatus(status: string | null, ctx?: ExtensionContext | ExtensionCommandContext): void {
1224
- overlayStatus = status;
1225
- syncUi(ctx);
1226
- }
1227
-
1228
- function setOverlayDraft(value: string): void {
1229
- overlayDraft = value;
1230
- overlayRuntime?.setDraft?.(value);
1231
- }
1232
-
1233
- function dismissOverlay(): void {
1234
- overlayRuntime?.close?.();
1235
- overlayRuntime = null;
1236
- }
1237
-
1238
- function toggleOverlayFocus(): void {
1239
- const handle = overlayRuntime?.handle;
1240
- if (!handle) {
1241
- return;
1242
- }
1243
-
1244
- handle.setHidden(false);
1245
- if (handle.isFocused()) {
1246
- handle.unfocus();
1247
- } else {
1248
- handle.focus();
1249
- }
1250
- overlayRuntime?.refresh?.();
1251
- }
1252
-
1253
- function focusOverlay(): void {
1254
- const handle = overlayRuntime?.handle;
1255
- if (!handle) {
1256
- return;
1257
- }
1258
-
1259
- handle.setHidden(false);
1260
- handle.focus();
1261
- overlayRuntime?.refresh?.();
1262
- }
1263
-
1264
- function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void {
1265
- if (!sessionRuntime.subscriptions.delete(unsubscribe)) {
1266
- return;
1267
- }
1268
-
1269
- try {
1270
- unsubscribe();
1271
- } catch {
1272
- // Ignore unsubscribe errors during BTW session replacement/shutdown.
1273
- }
1274
- }
1275
-
1276
- function clearBtwSessionSubscriptions(sessionRuntime: BtwSessionRuntime): void {
1277
- for (const unsubscribe of [...sessionRuntime.subscriptions]) {
1278
- removeBtwSessionSubscription(sessionRuntime, unsubscribe);
1279
- }
1280
- }
1281
-
1282
- function handleBtwSessionEvent(
1283
- sessionRuntime: BtwSessionRuntime,
1284
- event: AgentSessionEvent,
1285
- ctx?: ExtensionContext | ExtensionCommandContext,
1286
- ): void {
1287
- if (activeBtwSession?.session !== sessionRuntime.session || !overlayRuntime) {
1288
- return;
1289
- }
1290
-
1291
- applyTranscriptEvent(transcriptState, event);
1292
-
1293
- if (event.type === "tool_execution_start") {
1294
- setOverlayStatus(`⏳ running tool: ${event.toolName}`, ctx);
1295
- return;
1296
- }
1297
-
1298
- if (event.type === "tool_execution_end") {
1299
- setOverlayStatus(sessionRuntime.session.isStreaming ? `⏳ running tool: ${event.toolName}` : "⏳ streaming...", ctx);
1300
- return;
1301
- }
1302
-
1303
- if (event.type === "turn_end") {
1304
- setOverlayStatus("⏳ streaming...", ctx);
1305
- return;
1306
- }
1307
-
1308
- if (
1309
- event.type === "message_start" ||
1310
- event.type === "message_update" ||
1311
- event.type === "message_end" ||
1312
- event.type === "turn_start"
1313
- ) {
1314
- syncUi(ctx);
1315
- }
1316
- }
1317
-
1318
- function subscribeOverlayToActiveBtwSession(ctx?: ExtensionContext | ExtensionCommandContext): void {
1319
- const sessionRuntime = activeBtwSession;
1320
- if (!sessionRuntime || sessionRuntime.subscriptions.size > 0) {
1321
- return;
1322
- }
1323
-
1324
- const unsubscribe = sessionRuntime.session.subscribe((event: AgentSessionEvent) => {
1325
- handleBtwSessionEvent(sessionRuntime, event, ctx);
1326
- });
1327
- sessionRuntime.subscriptions.add(unsubscribe);
1328
- }
1329
-
1330
- async function disposeBtwSession(): Promise<void> {
1331
- const current = activeBtwSession;
1332
- activeBtwSession = null;
1333
- if (!current) {
1334
- return;
1335
- }
1336
-
1337
- clearBtwSessionSubscriptions(current);
1338
-
1339
- try {
1340
- await current.session.abort();
1341
- } catch {
1342
- // Ignore abort errors during BTW session replacement/shutdown.
1343
- }
1344
-
1345
- current.session.dispose();
1346
- }
1347
-
1348
- async function dismissOverlaySession(): Promise<void> {
1349
- dismissOverlay();
1350
- await disposeBtwSession();
1351
- }
1352
-
1353
- async function createBtwSubSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime> {
1354
- const { session } = await createAgentSession({
1355
- sessionManager: SessionManager.inMemory(),
1356
- model: ctx.model,
1357
- modelRuntime: sessionModelRuntime(ctx),
1358
- thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel,
1359
- tools: ["read", "grep", "find", "ls"],
1360
- resourceLoader: createBtwResourceLoader(ctx),
1361
- });
1362
-
1363
- const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode);
1364
- if (seedMessages.length > 0) {
1365
- session.agent.state.messages = seedMessages as typeof session.state.messages;
1366
- }
1367
-
1368
- return { session, mode, subscriptions: new Set(), sideThreadStartIndex };
1369
- }
1370
-
1371
- async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
1372
- if (!ctx.model) {
1373
- return null;
1374
- }
1375
-
1376
- if (activeBtwSession?.mode === mode) {
1377
- return activeBtwSession;
1378
- }
1379
-
1380
- await disposeBtwSession();
1381
- activeBtwSession = await createBtwSubSession(ctx, mode);
1382
- return activeBtwSession;
1383
- }
1384
-
1385
- async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
1386
- if (!ctx.hasUI) {
1387
- return;
1388
- }
1389
- lastUiContext = ctx;
1390
-
1391
- if (overlayRuntime?.handle) {
1392
- subscribeOverlayToActiveBtwSession(ctx);
1393
- focusOverlay();
1394
- return;
1395
- }
1396
-
1397
- const runtime: OverlayRuntime = {};
1398
- const closeRuntime = () => {
1399
- if (runtime.closed) {
1400
- return;
1401
- }
1402
- runtime.closed = true;
1403
- if (activeBtwSession) {
1404
- clearBtwSessionSubscriptions(activeBtwSession);
1405
- }
1406
- runtime.handle?.hide();
1407
- if (overlayRuntime === runtime) {
1408
- overlayRuntime = null;
1409
- }
1410
- runtime.finish?.();
1411
- };
1412
-
1413
- runtime.close = closeRuntime;
1414
- overlayRuntime = runtime;
1415
-
1416
- void ctx.ui
1417
- .custom<void>(
1418
- async (tui, theme, keybindings, done) => {
1419
- runtime.finish = () => {
1420
- done();
1421
- };
1422
-
1423
- const overlay = new BtwOverlayComponent(
1424
- tui,
1425
- theme,
1426
- keybindings,
1427
- () => transcriptState.entries,
1428
- () => overlayStatus,
1429
- () => pendingMode,
1430
- (value) => {
1431
- void submitFromOverlay(ctx, value);
1432
- },
1433
- () => {
1434
- void dismissOverlaySession();
1435
- },
1436
- () => {
1437
- overlayRuntime?.handle?.unfocus();
1438
- overlayRuntime?.refresh?.();
1439
- },
1440
- );
1441
-
1442
- overlay.focused = runtime.handle?.isFocused() ?? true;
1443
- overlay.setDraft(overlayDraft);
1444
- runtime.setDraft = (value) => {
1445
- overlay.setDraft(value);
1446
- };
1447
- runtime.refresh = () => {
1448
- overlay.focused = runtime.handle?.isFocused() ?? false;
1449
- overlay.refresh();
1450
- };
1451
- runtime.close = () => {
1452
- overlayDraft = overlay.getDraft();
1453
- closeRuntime();
1454
- };
1455
-
1456
- subscribeOverlayToActiveBtwSession(ctx);
1457
-
1458
- if (runtime.closed) {
1459
- done();
1460
- }
1461
-
1462
- return overlay;
1463
- },
1464
- {
1465
- overlay: true,
1466
- overlayOptions: {
1467
- width: "78%",
1468
- minWidth: 72,
1469
- maxHeight: "78%",
1470
- anchor: "top-center",
1471
- margin: { top: 1, left: 2, right: 2 },
1472
- nonCapturing: true,
1473
- },
1474
- onHandle: (handle) => {
1475
- runtime.handle = handle;
1476
- handle.focus();
1477
- if (runtime.closed) {
1478
- closeRuntime();
1479
- }
1480
- },
1481
- },
1482
- )
1483
- .catch((error) => {
1484
- if (overlayRuntime === runtime) {
1485
- overlayRuntime = null;
560
+ pi.registerCommand("unipi:btw", {
561
+ description: "Ask a side question in an inline panel — read-only, never reaches the main agent.",
562
+ getArgumentCompletions: () => [],
563
+ handler: async (args, ctx) => {
564
+ const question = (args ?? "").trim();
565
+ if (!ctx.hasUI) {
566
+ if (!question) {
567
+ notify(ctx, "usage: /unipi:btw <question>", "warning");
568
+ return;
1486
569
  }
1487
- notify(ctx, error instanceof Error ? error.message : String(error), "error");
1488
- });
1489
- }
1490
-
1491
- async function dispatchBtwCommand(name: string, args: string, ctx: ExtensionCommandContext): Promise<void> {
1492
- const trimmedArgs = args.trim();
1493
-
1494
- if (name === "btw") {
1495
- const { question, save } = parseBtwArgs(trimmedArgs);
1496
- if (!question) {
1497
- await ensureBtwSession(ctx, pendingMode);
1498
- await ensureOverlay(ctx);
1499
- return;
1500
- }
1501
-
1502
- if (pendingMode !== "contextual") {
1503
- await resetThread(ctx, true, "contextual");
1504
- }
1505
-
1506
- await runBtw(ctx, question, save, "contextual");
1507
- return;
1508
- }
1509
-
1510
- if (name === "btw:tangent") {
1511
- const { question, save } = parseBtwArgs(trimmedArgs);
1512
- if (pendingMode !== "tangent") {
1513
- await resetThread(ctx, true, "tangent");
1514
- }
1515
-
1516
- if (!question) {
1517
- await ensureBtwSession(ctx, "tangent");
1518
- await ensureOverlay(ctx);
1519
- return;
1520
- }
1521
-
1522
- await runBtw(ctx, question, save, "tangent");
1523
- return;
1524
- }
1525
-
1526
- if (name === "btw:new") {
1527
- await resetThread(ctx, true, "contextual");
1528
- const { question, save } = parseBtwArgs(trimmedArgs);
1529
- if (question) {
1530
- await runBtw(ctx, question, save, "contextual");
1531
- } else {
1532
- await ensureBtwSession(ctx, "contextual");
1533
- setOverlayStatus("Started a fresh BTW thread.", ctx);
1534
- await ensureOverlay(ctx);
1535
- notify(ctx, "Started a fresh BTW thread.", "info");
1536
- }
1537
- return;
1538
- }
1539
-
1540
- if (name === "btw:clear") {
1541
- await resetThread(ctx);
1542
- dismissOverlay();
1543
- notify(ctx, "Cleared BTW thread.", "info");
1544
- return;
1545
- }
1546
-
1547
- if (name === "btw:inject") {
1548
- if (pendingThread.length === 0) {
1549
- notify(ctx, "No BTW thread to inject.", "warning");
1550
- return;
1551
- }
1552
-
1553
- setOverlayStatus("⏳ injecting into the main session...", ctx);
1554
- await ensureOverlay(ctx);
1555
-
1556
- try {
1557
- const { thread } = await getBtwHandoffThread(ctx);
1558
- const instructions = trimmedArgs;
1559
- const content = instructions
1560
- ? `Here is a side conversation I had. ${instructions}\n\n${formatThread(thread)}`
1561
- : `Here is a side conversation I had for additional context:\n\n${formatThread(thread)}`;
1562
-
1563
- sendThreadToMain(ctx, content);
1564
- const count = thread.length;
1565
- await resetThread(ctx);
1566
- dismissOverlay();
1567
- notify(ctx, `Injected BTW thread (${count} exchange${count === 1 ? "" : "s"}).`, "info");
1568
- } catch (error) {
1569
- setOverlayStatus("Inject failed. Thread preserved for retry or summarize.", ctx);
1570
- notify(ctx, error instanceof Error ? error.message : String(error), "error");
1571
- }
1572
- return;
1573
- }
1574
-
1575
- if (name === "btw:summarize") {
1576
- if (pendingThread.length === 0) {
1577
- notify(ctx, "No BTW thread to summarize.", "warning");
1578
- return;
1579
- }
1580
-
1581
- setOverlayStatus("⏳ summarizing...", ctx);
1582
- await ensureOverlay(ctx);
1583
-
1584
- try {
1585
- const { thread } = await getBtwHandoffThread(ctx);
1586
- const summary = await summarizeThread(ctx, thread);
1587
- const instructions = trimmedArgs;
1588
- const content = instructions
1589
- ? `Here is a summary of a side conversation I had. ${instructions}\n\n${summary}`
1590
- : `Here is a summary of a side conversation I had:\n\n${summary}`;
1591
-
1592
- sendThreadToMain(ctx, content);
1593
- const count = thread.length;
1594
- await resetThread(ctx);
1595
- dismissOverlay();
1596
- notify(ctx, `Injected BTW summary (${count} exchange${count === 1 ? "" : "s"}).`, "info");
1597
- } catch (error) {
1598
- setOverlayStatus("Summarize failed. Thread preserved for retry or injection.", ctx);
1599
- notify(ctx, error instanceof Error ? error.message : String(error), "error");
1600
- }
1601
- return;
1602
- }
1603
- }
1604
-
1605
- function parseOverlayBtwCommand(value: string): { name: string; args: string } | null {
1606
- const trimmed = value.trim();
1607
- const legacy = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize))(?:\s+(.*))?$/);
1608
- if (legacy) {
1609
- return {
1610
- name: legacy[1],
1611
- args: legacy[2]?.trim() ?? "",
1612
- };
1613
- }
1614
-
1615
- const unipi = trimmed.match(/^\/unipi:btw-(new|tangent|clear|inject|summarize)(?:\s+(.*))?$/);
1616
- if (unipi) {
1617
- return {
1618
- name: `btw:${unipi[1]}`,
1619
- args: unipi[2]?.trim() ?? "",
1620
- };
1621
- }
1622
-
1623
- return null;
1624
- }
1625
-
1626
- async function submitFromOverlay(ctx: ExtensionCommandContext | ExtensionContext, value: string): Promise<void> {
1627
- const question = value.trim();
1628
- if (!question) {
1629
- setOverlayStatus("Enter a BTW prompt before submitting.", ctx);
1630
- return;
1631
- }
1632
-
1633
- if (!("getSystemPrompt" in ctx)) {
1634
- setOverlayStatus("BTW overlay submit requires a command context. Reopen BTW from a command.", ctx);
1635
- return;
1636
- }
1637
-
1638
- const cmdCtx = ctx as ExtensionCommandContext;
1639
- const btwCommand = parseOverlayBtwCommand(question);
1640
- if (btwCommand) {
1641
- setOverlayDraft("");
1642
- await dispatchBtwCommand(btwCommand.name, btwCommand.args, cmdCtx);
1643
- return;
1644
- }
1645
-
1646
- setOverlayDraft("");
1647
- setOverlayStatus("⏳ streaming...", ctx);
1648
- syncUi(ctx);
1649
- await runBtw(cmdCtx, question, false, pendingMode);
1650
- }
1651
-
1652
- async function resetThread(
1653
- ctx: ExtensionContext | ExtensionCommandContext,
1654
- persist = true,
1655
- mode: BtwThreadMode = "contextual",
1656
- ): Promise<void> {
1657
- await disposeBtwSession();
1658
- pendingThread = [];
1659
- pendingMode = mode;
1660
- transcriptState = createEmptyTranscriptState();
1661
- setOverlayDraft("");
1662
- setOverlayStatus(null, ctx);
1663
- if (persist) {
1664
- const details: BtwResetDetails = { timestamp: Date.now(), mode };
1665
- pi.appendEntry(BTW_RESET_TYPE, details);
1666
- }
1667
- syncUi(ctx);
1668
- }
1669
-
1670
- async function restoreThread(ctx: ExtensionContext): Promise<void> {
1671
- await disposeBtwSession();
1672
- pendingThread = [];
1673
- pendingMode = "contextual";
1674
- transcriptState = createEmptyTranscriptState();
1675
- overlayDraft = "";
1676
- lastUiContext = ctx;
1677
- overlayStatus = null;
1678
-
1679
- const branch = ctx.sessionManager.getBranch();
1680
- let lastResetIndex = -1;
1681
-
1682
- for (let i = 0; i < branch.length; i++) {
1683
- if (isCustomEntry(branch[i], BTW_RESET_TYPE)) {
1684
- lastResetIndex = i;
1685
- const details = (branch[i] as unknown as { data?: BtwResetDetails }).data;
1686
- pendingMode = details?.mode ?? "contextual";
1687
- }
1688
- }
1689
-
1690
- for (const entry of branch.slice(lastResetIndex + 1)) {
1691
- if (!isCustomEntry(entry, BTW_ENTRY_TYPE)) {
1692
- continue;
1693
- }
1694
-
1695
- const details = (entry as unknown as { data?: BtwDetails }).data;
1696
- if (!details?.question || !details.answer) {
1697
- continue;
1698
- }
1699
-
1700
- pendingThread.push(details);
1701
- appendPersistedTranscriptTurn(transcriptState, details);
1702
- }
1703
-
1704
- syncUi(ctx);
1705
- }
1706
-
1707
- async function runBtw(
1708
- ctx: ExtensionCommandContext,
1709
- question: string,
1710
- saveRequested: boolean,
1711
- mode: BtwThreadMode,
1712
- ): Promise<void> {
1713
- lastUiContext = ctx;
1714
- const model = ctx.model;
1715
- if (!model) {
1716
- setOverlayStatus("No active model selected.", ctx);
1717
- notify(ctx, "No active model selected.", "error");
1718
- return;
1719
- }
1720
-
1721
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
1722
- if (!auth.ok || !auth.apiKey) {
1723
- const message = auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error;
1724
- setOverlayStatus(message, ctx);
1725
- notify(ctx, message, "error");
1726
- await ensureOverlay(ctx);
1727
- return;
1728
- }
1729
-
1730
- const sessionRuntime = await ensureBtwSession(ctx, mode);
1731
- if (!sessionRuntime) {
1732
- setOverlayStatus("No active model selected.", ctx);
1733
- notify(ctx, "No active model selected.", "error");
1734
- return;
1735
- }
1736
-
1737
- const session = sessionRuntime.session;
1738
- const wasBusy = !ctx.isIdle();
1739
- pendingMode = mode;
1740
- const thinkingLevel = pi.getThinkingLevel() as SessionThinkingLevel;
1741
-
1742
- setOverlayStatus("⏳ streaming...", ctx);
1743
- await ensureOverlay(ctx);
1744
-
1745
- try {
1746
- await session.prompt(question, { source: "extension" });
1747
-
1748
- const response = getLastAssistantMessage(session);
1749
- if (!response) {
1750
- throw new Error("BTW request finished without a response.");
1751
- }
1752
- if (response.stopReason === "aborted") {
1753
- removeTranscriptTurn(transcriptState, transcriptState.lastTurnId ?? transcriptState.currentTurnId);
1754
- setOverlayStatus("Request aborted.", ctx);
570
+ await headlessAsk(ctx, question);
1755
571
  return;
1756
572
  }
1757
- if (response.stopReason === "error") {
1758
- throw new Error(response.errorMessage || "BTW request failed.");
1759
- }
1760
-
1761
- const completedTurnId = transcriptState.lastTurnId ?? transcriptState.currentTurnId;
1762
- const streamedThinking =
1763
- completedTurnId !== null ? findLatestTranscriptEntry(transcriptState, completedTurnId, "thinking")?.text : "";
1764
- const answer = extractAnswer(response);
1765
- const thinking = extractThinking(response) || streamedThinking || "";
1766
-
1767
- const details: BtwDetails = {
1768
- question,
1769
- thinking,
1770
- answer,
1771
- provider: model.provider,
1772
- model: model.id,
1773
- thinkingLevel,
1774
- timestamp: Date.now(),
1775
- usage: response.usage,
1776
- };
1777
-
1778
- pendingThread.push(details);
1779
- pi.appendEntry(BTW_ENTRY_TYPE, details);
1780
-
1781
- const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy);
1782
- if (saveState === "saved") {
1783
- notify(ctx, "Saved BTW note to the session.", "info");
1784
- setOverlayStatus("Saved BTW note to the session.", ctx);
1785
- } else if (saveState === "queued") {
1786
- notify(ctx, "BTW note queued to save after the current turn finishes.", "info");
1787
- setOverlayStatus("BTW note queued to save after the current turn finishes.", ctx);
1788
- } else {
1789
- setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx);
1790
- }
1791
- } catch (error) {
1792
- const errorMessage = error instanceof Error ? error.message : String(error);
1793
- setTranscriptFailure(transcriptState, errorMessage);
1794
- setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx);
1795
- notify(ctx, errorMessage, "error");
1796
- await disposeBtwSession();
1797
- } finally {
1798
- syncUi(ctx);
1799
- }
1800
- }
1801
-
1802
- function getPendingThreadForHandoff(): BtwHandoffExchange[] {
1803
- return pendingThread.map((entry) => ({ user: entry.question, assistant: entry.answer }));
1804
- }
1805
-
1806
- async function getBtwHandoffThread(
1807
- ctx: ExtensionCommandContext,
1808
- ): Promise<{ thread: BtwHandoffExchange[] }> {
1809
- const sessionRuntime = activeBtwSession ?? (await ensureBtwSession(ctx, pendingMode));
1810
- const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : [];
1811
- const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff();
1812
-
1813
- if (resolvedThread.length === 0) {
1814
- throw new Error("No BTW thread available for handoff.");
1815
- }
1816
-
1817
- return { thread: resolvedThread };
1818
- }
1819
-
1820
- async function summarizeThread(ctx: ExtensionCommandContext, thread: BtwHandoffExchange[]): Promise<string> {
1821
- const model = ctx.model;
1822
- if (!model) {
1823
- throw new Error("No active model selected.");
1824
- }
1825
-
1826
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
1827
- if (!auth.ok || !auth.apiKey) {
1828
- throw new Error(auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error);
1829
- }
1830
-
1831
- const { session } = await createAgentSession({
1832
- sessionManager: SessionManager.inMemory(),
1833
- model,
1834
- modelRuntime: sessionModelRuntime(ctx),
1835
- thinkingLevel: "off",
1836
- tools: [],
1837
- resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]),
1838
- });
1839
-
1840
- try {
1841
- await session.prompt(formatThread(thread), { source: "extension" });
1842
-
1843
- const response = getLastAssistantMessage(session);
1844
- if (!response) {
1845
- throw new Error("BTW summarize finished without a response.");
1846
- }
1847
- if (response.stopReason === "error") {
1848
- throw new Error(response.errorMessage || "Failed to summarize BTW thread.");
1849
- }
1850
- if (response.stopReason === "aborted") {
1851
- throw new Error("BTW summarize aborted.");
1852
- }
1853
-
1854
- return extractAnswer(response);
1855
- } finally {
1856
- try {
1857
- await session.abort();
1858
- } catch {
1859
- // Ignore abort errors during summarize session shutdown.
1860
- }
1861
- session.dispose();
1862
- }
1863
- }
1864
-
1865
- function sendThreadToMain(ctx: ExtensionCommandContext, content: string): void {
1866
- if (ctx.isIdle()) {
1867
- pi.sendUserMessage(content);
1868
- } else {
1869
- pi.sendUserMessage(content, { deliverAs: "followUp" });
1870
- }
1871
- }
1872
-
1873
- // ─── Register ───────────────────────────────────────────────────────────
1874
-
1875
- pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => {
1876
- const details = message.details as BtwDetails | undefined;
1877
- const content = typeof message.content === "string" ? message.content : "[non-text btw message]";
1878
- const lines = [theme.fg("accent", theme.bold("[BTW]")), content];
1879
-
1880
- if (expanded && details) {
1881
- lines.push(
1882
- theme.fg("dim", `model: ${details.provider}/${details.model} · thinking: ${details.thinkingLevel}`),
1883
- );
1884
-
1885
- if (details.usage) {
1886
- lines.push(
1887
- theme.fg(
1888
- "dim",
1889
- `tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`,
1890
- ),
1891
- );
1892
- }
1893
- }
1894
-
1895
- const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
1896
- box.addChild(new Text(lines.join("\n"), 0, 0));
1897
- return box;
1898
- });
1899
-
1900
- pi.on("context", async (event) => {
1901
- return {
1902
- messages: event.messages.filter((message) => !isVisibleBtwMessage(message)),
1903
- };
1904
- });
1905
-
1906
- pi.on("session_start", async (_event, ctx) => {
1907
- await restoreThread(ctx);
1908
- });
1909
-
1910
- pi.on("session_tree", async (_event, ctx) => {
1911
- await restoreThread(ctx);
1912
- });
1913
-
1914
- pi.on("session_shutdown", async () => {
1915
- await disposeBtwSession();
1916
- dismissOverlay();
1917
- });
1918
-
1919
- for (const shortcut of BTW_FOCUS_SHORTCUTS) {
1920
- pi.registerShortcut(shortcut, {
1921
- description: "Toggle BTW overlay focus while leaving it open.",
1922
- handler: async (_ctx) => {
1923
- toggleOverlayFocus();
1924
- },
1925
- });
1926
- }
1927
-
1928
- pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.BTW}`, {
1929
- description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.",
1930
- handler: async (args, ctx) => {
1931
- await dispatchBtwCommand("btw", args, ctx);
1932
- },
1933
- });
1934
-
1935
- pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.TANGENT}`, {
1936
- description: "Start or continue a contextless BTW tangent in the focused BTW modal.",
1937
- handler: async (args, ctx) => {
1938
- await dispatchBtwCommand("btw:tangent", args, ctx);
1939
- },
1940
- });
1941
-
1942
- pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.NEW}`, {
1943
- description: "Start a fresh BTW thread with main-session context. Optionally ask the first question immediately.",
1944
- handler: async (args, ctx) => {
1945
- await dispatchBtwCommand("btw:new", args, ctx);
1946
- },
1947
- });
1948
-
1949
- pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.CLEAR}`, {
1950
- description: "Dismiss the BTW modal/widget and clear the current thread.",
1951
- handler: async (args, ctx) => {
1952
- await dispatchBtwCommand("btw:clear", args, ctx);
1953
- },
1954
- });
1955
-
1956
- pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.INJECT}`, {
1957
- description: "Inject the full BTW thread into the main agent as a user message.",
1958
- handler: async (args, ctx) => {
1959
- await dispatchBtwCommand("btw:inject", args, ctx);
1960
- },
1961
- });
1962
-
1963
- pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.SUMMARIZE}`, {
1964
- description: "Summarize the BTW thread, then inject the summary into the main agent.",
1965
- handler: async (args, ctx) => {
1966
- await dispatchBtwCommand("btw:summarize", args, ctx);
573
+ await openPanel(ctx, question || undefined);
1967
574
  },
1968
575
  });
1969
576
  }