/** * @pi-unipi/btw — Side Conversation Extension * * A /unipi:btw side conversation channel that opens a real pi sub-session * with coding-tool access, running immediately even while the main * agent is still busy. * * Based on pi-btw by Dan Bachelder, adapted for the Unipi suite. * * Commands: * /unipi:btw [--save] - Side conversation (contextual) * /unipi:btw-tangent [--save] - Contextless tangent thread * /unipi:btw-new [question] - Fresh thread with main-session context * /unipi:btw-clear - Dismiss and clear thread * /unipi:btw-inject [instructions] - Send full thread to main agent * /unipi:btw-summarize [instructions] - Summarize and inject into main agent */ import { buildSessionContext, createAgentSession, createExtensionRuntime, SessionManager, type AgentSession, type AgentSessionEvent, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ResourceLoader, } from "@earendil-works/pi-coding-agent"; import { type AssistantMessage, type Message, type ThinkingLevel as AiThinkingLevel, type UserMessage } from "@earendil-works/pi-ai"; import { Box, Container, Input, Key, Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Focusable, type KeybindingsManager, type OverlayHandle, type TUI, } from "@earendil-works/pi-tui"; import { BTW_COMMANDS, UNIPI_PREFIX } from "@pi-unipi/core"; // ─── Constants ────────────────────────────────────────────────────────────── const BTW_MESSAGE_TYPE = "btw-note"; const BTW_ENTRY_TYPE = "btw-thread-entry"; const BTW_RESET_TYPE = "btw-thread-reset"; const BTW_FOCUS_SHORTCUTS = [Key.alt("/"), Key.ctrlAlt("w")] as const; function matchesBtwFocusShortcut(data: string): boolean { return BTW_FOCUS_SHORTCUTS.some((shortcut) => matchesKey(data, shortcut)); } const BTW_SYSTEM_PROMPT = [ "You are having an aside conversation with the user, separate from their main working session.", "If main session messages are provided, they are for context only — that work is being handled by another agent.", "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.", "Focus on answering the user's side questions, helping them think through ideas, or planning next steps.", "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.", ].join(" "); const BTW_SUMMARIZE_SYSTEM_PROMPT = "Summarize the side conversation concisely. Preserve key decisions, plans, insights, risks, and action items. Output only the summary."; const BTW_CONTINUE_THREAD_USER_TEXT = "[The following is a separate side conversation. Continue this thread.]"; const BTW_CONTINUE_THREAD_ASSISTANT_TEXT = "Understood, continuing our side conversation."; // ─── Types ────────────────────────────────────────────────────────────────── type SessionThinkingLevel = "off" | AiThinkingLevel; type BtwThreadMode = "contextual" | "tangent"; type BtwDetails = { question: string; thinking: string; answer: string; provider: string; model: string; thinkingLevel: SessionThinkingLevel; timestamp: number; usage?: AssistantMessage["usage"]; }; type ParsedBtwArgs = { question: string; save: boolean; }; type SaveState = "not-saved" | "saved" | "queued"; type BtwResetDetails = { timestamp: number; mode?: BtwThreadMode; }; type BtwTranscriptEntry = | { id: number; turnId: number; type: "turn-boundary"; phase: "start" | "end" } | { id: number; turnId: number; type: "user-message"; text: string } | { id: number; turnId: number; type: "thinking"; text: string; streaming: boolean } | { id: number; turnId: number; type: "assistant-text"; text: string; streaming: boolean } | { id: number; turnId: number; type: "tool-call"; toolCallId: string; toolName: string; args: string } | { id: number; turnId: number; type: "tool-result"; toolCallId: string; toolName: string; content: string; truncated: boolean; isError: boolean; streaming: boolean; }; type BtwTranscript = BtwTranscriptEntry[]; type BtwTranscriptState = { entries: BtwTranscript; nextEntryId: number; nextTurnId: number; currentTurnId: number | null; lastTurnId: number | null; toolCalls: Map; }; type BtwSessionRuntime = { session: AgentSession; mode: BtwThreadMode; subscriptions: Set<() => void>; sideThreadStartIndex: number; }; type OverlayRuntime = { handle?: OverlayHandle; refresh?: () => void; close?: () => void; finish?: () => void; setDraft?: (value: string) => void; closed?: boolean; }; // ─── Helpers ──────────────────────────────────────────────────────────────── function isVisibleBtwMessage(message: { role: string; customType?: string }): boolean { return message.role === "custom" && message.customType === BTW_MESSAGE_TYPE; } function isCustomEntry(entry: unknown, customType: string): entry is { type: "custom"; customType: string; data?: unknown } { return !!entry && typeof entry === "object" && (entry as { type?: string }).type === "custom" && (entry as { customType?: string }).customType === customType; } function stripDynamicSystemPromptFooter(systemPrompt: string): string { return systemPrompt .replace(/\nCurrent date and time:[^\n]*(?:\nCurrent working directory:[^\n]*)?$/u, "") .replace(/\nCurrent working directory:[^\n]*$/u, "") .trim(); } function createBtwResourceLoader( ctx: ExtensionCommandContext, appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT], ): ResourceLoader { const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() }; const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt()); return { getExtensions: () => extensionsResult, getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }), getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => appendSystemPrompt, extendResources: () => {}, reload: async () => {}, }; } function extractText(parts: AssistantMessage["content"], type: "text" | "thinking"): string { const chunks: string[] = []; for (const part of parts) { if (type === "text" && part.type === "text") { chunks.push(part.text); } else if (type === "thinking" && part.type === "thinking") { chunks.push(part.thinking); } } return chunks.join("\n").trim(); } function extractAnswer(message: AssistantMessage): string { return extractText(message.content, "text") || "(No text response)"; } function extractThinking(message: AssistantMessage): string { return extractText(message.content, "thinking"); } function parseBtwArgs(args: string): ParsedBtwArgs { const save = /(?:^|\s)(?:--save|-s)(?=\s|$)/.test(args); const question = args.replace(/(?:^|\s)(?:--save|-s)(?=\s|$)/g, " ").trim(); return { question, save }; } function buildBtwSeedState( ctx: ExtensionCommandContext, thread: BtwDetails[], mode: BtwThreadMode, ): { messages: Message[]; sideThreadStartIndex: number } { const messages: Message[] = []; if (mode === "contextual") { try { messages.push( ...(buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages as Message[]).filter( (message) => !isVisibleBtwMessage(message), ), ); } catch { messages.push( ...ctx.sessionManager.getEntries().flatMap((entry) => { if (!entry || typeof entry !== "object") { return []; } const message = entry as unknown as Partial & { role?: string; customType?: string; content?: unknown }; if (typeof message.role !== "string" || !Array.isArray(message.content)) { return []; } return isVisibleBtwMessage({ role: message.role, customType: message.customType }) ? [] : [message as Message]; }), ); } } const sideThreadStartIndex = messages.length; if (thread.length > 0) { messages.push( { role: "user", content: [{ type: "text", text: BTW_CONTINUE_THREAD_USER_TEXT }], timestamp: Date.now(), }, { role: "assistant", content: [{ type: "text", text: BTW_CONTINUE_THREAD_ASSISTANT_TEXT }], provider: ctx.model?.provider ?? "unknown", model: ctx.model?.id ?? "unknown", api: ctx.model?.api ?? "openai-responses", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: Date.now(), }, ); for (const entry of thread) { messages.push( { role: "user", content: [{ type: "text", text: entry.question }], timestamp: entry.timestamp, }, { role: "assistant", content: [{ type: "text", text: entry.answer }], provider: entry.provider, model: entry.model, api: ctx.model?.api ?? "openai-responses", usage: entry.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: entry.timestamp, }, ); } } return { messages, sideThreadStartIndex, }; } function formatToolPreview(value: unknown): string { if (value === undefined) { return ""; } if (typeof value === "string") { return value; } if (value && typeof value === "object") { const path = (value as { path?: unknown }).path; if (typeof path === "string") { return path; } } try { const preview = JSON.stringify(value); if (!preview || preview === "{}") { return ""; } return preview.length > 120 ? `${preview.slice(0, 117)}...` : preview; } catch { return ""; } } // ─── Transcript State ─────────────────────────────────────────────────────── function createEmptyTranscriptState(): BtwTranscriptState { return { entries: [], nextEntryId: 1, nextTurnId: 1, currentTurnId: null, lastTurnId: null, toolCalls: new Map(), }; } function appendTranscriptEntry( state: BtwTranscriptState, entry: Omit, ): T { const nextEntry = { ...entry, id: state.nextEntryId++ } as T; state.entries.push(nextEntry); return nextEntry; } function ensureTranscriptTurn(state: BtwTranscriptState): number { if (state.currentTurnId !== null) { return state.currentTurnId; } const turnId = state.nextTurnId++; state.currentTurnId = turnId; state.lastTurnId = turnId; appendTranscriptEntry(state, { type: "turn-boundary", turnId, phase: "start" } as Omit, "id">); return turnId; } function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null): void { const resolvedTurnId = turnId ?? state.currentTurnId; if (resolvedTurnId === null || resolvedTurnId === undefined) { return; } const hasEndBoundary = state.entries.some( (entry) => entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end", ); if (!hasEndBoundary) { appendTranscriptEntry(state, { type: "turn-boundary", turnId: resolvedTurnId, phase: "end" } as Omit, "id">); } for (const entry of state.entries) { if (entry.turnId !== resolvedTurnId) { continue; } if (entry.type === "thinking" || entry.type === "assistant-text" || entry.type === "tool-result") { entry.streaming = false; } } state.lastTurnId = resolvedTurnId; if (state.currentTurnId === resolvedTurnId) { state.currentTurnId = null; } } function removeTranscriptTurn(state: BtwTranscriptState, turnId: number | null): void { if (turnId === null) { return; } state.entries = state.entries.filter((entry) => entry.turnId !== turnId); for (const [toolCallId, toolCall] of state.toolCalls.entries()) { if (toolCall.turnId === turnId) { state.toolCalls.delete(toolCallId); } } if (state.currentTurnId === turnId) { state.currentTurnId = null; } if (state.lastTurnId === turnId) { state.lastTurnId = null; } } function findLatestTranscriptEntry( state: BtwTranscriptState, turnId: number, type: TType, ): Extract | undefined { for (let i = state.entries.length - 1; i >= 0; i--) { const entry = state.entries[i]; if (entry.turnId === turnId && entry.type === type) { return entry as Extract; } } return undefined; } function ensureTranscriptTurnForUserMessage(state: BtwTranscriptState): number { if (state.currentTurnId !== null) { const currentAssistant = findLatestTranscriptEntry(state, state.currentTurnId, "assistant-text"); if (currentAssistant && !currentAssistant.streaming) { finishTranscriptTurn(state, state.currentTurnId); } } return ensureTranscriptTurn(state); } function extractMessageText(message: { content?: string | AssistantMessage["content"] | UserMessage["content"] }): string { if (typeof message.content === "string") { return message.content; } if (!Array.isArray(message.content)) { return ""; } return message.content .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string") .map((part) => part.text) .join("\n") .trim(); } function upsertUserMessageEntry(state: BtwTranscriptState, turnId: number, text: string): void { if (!text) { return; } const existing = findLatestTranscriptEntry(state, turnId, "user-message"); if (existing) { existing.text = text; return; } appendTranscriptEntry(state, { type: "user-message", turnId, text } as Omit, "id">); } function upsertTranscriptTextEntry( state: BtwTranscriptState, turnId: number, type: "thinking" | "assistant-text", text: string, streaming: boolean, ): void { if (!text) { return; } const existing = findLatestTranscriptEntry(state, turnId, type); if (existing) { existing.text = text; existing.streaming = streaming; return; } appendTranscriptEntry(state, { type, turnId, text, streaming } as Omit, "id">); } function summarizeToolResult(value: unknown, maxLength = 400): { content: string; truncated: boolean } { let content = ""; if (value && typeof value === "object") { const toolValue = value as { content?: Array<{ type?: string; text?: string }>; error?: unknown; message?: unknown; }; if (Array.isArray(toolValue.content)) { content = toolValue.content .filter((part) => part.type === "text" && typeof part.text === "string") .map((part) => part.text ?? "") .join("\n") .trim(); } if (!content && typeof toolValue.error === "string") { content = toolValue.error; } if (!content && typeof toolValue.message === "string") { content = toolValue.message; } } if (!content) { if (typeof value === "string") { content = value; } else if (value !== undefined) { try { content = JSON.stringify(value, null, 2); } catch { content = String(value); } } } if (!content) { content = "(no tool output)"; } const truncated = content.length > maxLength; return { content: truncated ? `${content.slice(0, maxLength - 3)}...` : content, truncated, }; } function ensureToolCallEntry( state: BtwTranscriptState, turnId: number, toolCallId: string, toolName: string, args: string, ): { turnId: number; callEntryId: number; resultEntryId?: number } { const existing = state.toolCalls.get(toolCallId); if (existing) { return existing; } const callEntry = appendTranscriptEntry(state, { type: "tool-call", turnId, toolCallId, toolName, args, } as Omit, "id">); const record = { turnId, callEntryId: callEntry.id }; state.toolCalls.set(toolCallId, record); return record; } function upsertToolResultEntry( state: BtwTranscriptState, turnId: number, toolCallId: string, toolName: string, content: string, truncated: boolean, isError: boolean, streaming: boolean, ): void { const toolCall = ensureToolCallEntry(state, turnId, toolCallId, toolName, ""); const existing = toolCall.resultEntryId !== undefined ? state.entries.find((entry) => entry.id === toolCall.resultEntryId && entry.type === "tool-result") : undefined; if (existing && existing.type === "tool-result") { existing.content = content; existing.truncated = truncated; existing.isError = isError; existing.streaming = streaming; return; } const resultEntry = appendTranscriptEntry(state, { type: "tool-result", turnId, toolCallId, toolName, content, truncated, isError, streaming, } as Omit, "id">); toolCall.resultEntryId = resultEntry.id; } function applyAssistantMessageToTranscript( state: BtwTranscriptState, turnId: number, message: AssistantMessage, streaming: boolean, ): void { const assistantMessage = message; const thinking = extractThinking(assistantMessage); const answer = extractMessageText(assistantMessage); if (thinking) { upsertTranscriptTextEntry(state, turnId, "thinking", thinking, streaming); } if (answer) { upsertTranscriptTextEntry(state, turnId, "assistant-text", answer, streaming); } } function applyTranscriptEvent(state: BtwTranscriptState, event: AgentSessionEvent): void { switch (event.type) { case "turn_start": { ensureTranscriptTurn(state); return; } case "message_start": { if (event.message.role === "user") { const turnId = ensureTranscriptTurnForUserMessage(state); upsertUserMessageEntry(state, turnId, extractMessageText(event.message)); return; } if (event.message.role === "assistant") { const turnId = ensureTranscriptTurn(state); applyAssistantMessageToTranscript(state, turnId, event.message, true); } return; } case "message_update": { if (event.message.role !== "assistant") { return; } const turnId = ensureTranscriptTurn(state); applyAssistantMessageToTranscript(state, turnId, event.message, true); return; } case "message_end": { if (event.message.role === "user") { const turnId = ensureTranscriptTurnForUserMessage(state); upsertUserMessageEntry(state, turnId, extractMessageText(event.message)); return; } if (event.message.role === "assistant") { const turnId = ensureTranscriptTurn(state); applyAssistantMessageToTranscript(state, turnId, event.message, false); } return; } case "tool_execution_start": { const turnId = ensureTranscriptTurn(state); ensureToolCallEntry(state, turnId, event.toolCallId, event.toolName, formatToolPreview(event.args)); return; } case "tool_execution_update": { const turnId = state.toolCalls.get(event.toolCallId)?.turnId ?? ensureTranscriptTurn(state); const result = summarizeToolResult(event.partialResult); upsertToolResultEntry( state, turnId, event.toolCallId, event.toolName, result.content, result.truncated, false, true, ); return; } case "tool_execution_end": { const turnId = state.toolCalls.get(event.toolCallId)?.turnId ?? ensureTranscriptTurn(state); const result = summarizeToolResult(event.result); upsertToolResultEntry( state, turnId, event.toolCallId, event.toolName, result.content, result.truncated, event.isError, false, ); return; } case "turn_end": { finishTranscriptTurn(state); return; } default: return; } } function appendPersistedTranscriptTurn(state: BtwTranscriptState, details: BtwDetails): void { const turnId = ensureTranscriptTurn(state); upsertUserMessageEntry(state, turnId, details.question); if (details.thinking) { upsertTranscriptTextEntry(state, turnId, "thinking", details.thinking, false); } upsertTranscriptTextEntry(state, turnId, "assistant-text", details.answer, false); finishTranscriptTurn(state, turnId); } function setTranscriptFailure(state: BtwTranscriptState, message: string): void { const turnId = state.currentTurnId ?? state.lastTurnId ?? ensureTranscriptTurn(state); upsertTranscriptTextEntry(state, turnId, "assistant-text", `❌ ${message}`, false); finishTranscriptTurn(state, turnId); } function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean { return entries.some( (entry) => (entry.type === "thinking" || entry.type === "assistant-text" || entry.type === "tool-result") && entry.streaming, ); } function getCompletedExchangeCount(entries: BtwTranscript): number { return entries.filter((entry) => entry.type === "assistant-text" && !entry.streaming).length; } // ─── Overlay Transcript Rendering ─────────────────────────────────────────── function buildTranscriptBadge( theme: ExtensionContext["ui"]["theme"], label: string, background: "userMessageBg" | "toolPendingBg" | "customMessageBg", foreground: "accent" | "warning" | "success", ): string { return theme.bg(background, theme.fg(foreground, theme.bold(` ${label} `))); } function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext["ui"]["theme"]): string[] { if (entries.length === 0) { return [theme.fg("dim", "No BTW thread yet. Ask a side question to start one.")]; } const lines: string[] = []; const userBadge = buildTranscriptBadge(theme, "You", "userMessageBg", "accent"); const thinkingBadge = buildTranscriptBadge(theme, "Thinking", "toolPendingBg", "warning"); const toolBadge = buildTranscriptBadge(theme, "Tool", "toolPendingBg", "warning"); const assistantBadge = buildTranscriptBadge(theme, "Assistant", "customMessageBg", "success"); const separator = theme.fg("borderMuted", "────────────────────────────────────────"); const blockIndent = " "; const resultIndent = blockIndent; const pushBlankLine = () => { if (lines.length > 0 && lines[lines.length - 1] !== "") { lines.push(""); } }; const pushInlineBlock = ( header: string, text: string, options: { blankBefore?: boolean; style?: (value: string) => string } = {}, ) => { const bodyLines = text.split("\n"); const style = options.style ?? ((value: string) => value); if (options.blankBefore !== false) { pushBlankLine(); } const firstLine = bodyLines.shift() ?? ""; lines.push(`${header}${firstLine ? ` ${style(firstLine)}` : ""}`); for (const line of bodyLines) { lines.push(`${blockIndent}${style(line)}`); } }; const pushStackedBlock = ( header: string, text: string, options: { blankBefore?: boolean; indent?: string; style?: (value: string) => string } = {}, ) => { const bodyLines = text.split("\n"); const indent = options.indent ?? blockIndent; const style = options.style ?? ((value: string) => value); if (options.blankBefore !== false) { pushBlankLine(); } lines.push(header); for (const line of bodyLines) { lines.push(`${indent}${style(line)}`); } }; for (const entry of entries) { if (entry.type === "turn-boundary") { if (entry.phase === "start" && lines.length > 0) { pushBlankLine(); lines.push(separator); } continue; } if (entry.type === "user-message") { pushInlineBlock(userBadge, entry.text, { blankBefore: false }); continue; } if (entry.type === "thinking") { const thinkingHeader = entry.streaming ? `${thinkingBadge} ${theme.fg("warning", "▍")}` : thinkingBadge; pushStackedBlock(thinkingHeader, entry.text, { style: (line) => theme.fg("warning", theme.italic(line)), }); continue; } if (entry.type === "tool-call") { const toolLabel = theme.fg("warning", theme.bold(entry.toolName)); const argsLabel = entry.args ? theme.fg("dim", ` · ${entry.args}`) : ""; pushInlineBlock(toolBadge, `${toolLabel}${argsLabel}`); continue; } if (entry.type === "tool-result") { const resultHeaderLabel = entry.isError ? theme.fg("error", "↳ error") : entry.streaming ? theme.fg("warning", "↳ streaming result") : theme.fg("dim", "↳ result"); const truncationLabel = entry.truncated ? theme.fg("dim", " (truncated)") : ""; pushStackedBlock(`${resultHeaderLabel}${truncationLabel}`, entry.content, { blankBefore: false, indent: resultIndent, style: (line) => (entry.isError ? theme.fg("error", line) : theme.fg("dim", line)), }); continue; } if (entry.type === "assistant-text") { const assistantHeader = entry.streaming ? `${assistantBadge} ${theme.fg("warning", "▍")}` : assistantBadge; pushStackedBlock(assistantHeader, entry.text); } } return lines; } // ─── Session Helpers ──────────────────────────────────────────────────────── function getLastAssistantMessage(session: AgentSession): AssistantMessage | null { for (let i = session.state.messages.length - 1; i >= 0; i--) { const message = session.state.messages[i]; if (message.role === "assistant") { return message as AssistantMessage; } } return null; } type BtwHandoffExchange = { user: string; assistant: string; }; function buildBtwMessageContent(question: string, answer: string): string { return `Q: ${question}\n\nA: ${answer}`; } function formatThread(thread: BtwHandoffExchange[]): string { return thread.map((entry) => `User: ${entry.user.trim()}\nAssistant: ${entry.assistant.trim()}`).join("\n\n---\n\n"); } function isThreadContinuationMarker(messages: Message[], index: number): boolean { const userMessage = messages[index]; const assistantMessage = messages[index + 1]; return ( userMessage?.role === "user" && extractMessageText(userMessage) === BTW_CONTINUE_THREAD_USER_TEXT && assistantMessage?.role === "assistant" && extractMessageText(assistantMessage) === BTW_CONTINUE_THREAD_ASSISTANT_TEXT ); } function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffExchange[] { const handoffMessages = sessionRuntime.session.state.messages.slice(sessionRuntime.sideThreadStartIndex); const threadMessages = isThreadContinuationMarker(handoffMessages as Message[], 0) ? handoffMessages.slice(2) : handoffMessages; const exchanges: BtwHandoffExchange[] = []; let currentUser = ""; let currentAssistant = ""; const pushCurrent = () => { if (!currentUser && !currentAssistant) { return; } exchanges.push({ user: currentUser.trim() || "(No user prompt)", assistant: currentAssistant.trim() || "(No assistant response)", }); currentUser = ""; currentAssistant = ""; }; for (const message of threadMessages) { if (message.role !== "user" && message.role !== "assistant") { continue; } const text = extractMessageText(message).trim(); if (!text) { continue; } if (message.role === "user") { pushCurrent(); currentUser = text; continue; } currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text; } pushCurrent(); return exchanges; } function saveVisibleBtwNote( pi: ExtensionAPI, details: BtwDetails, saveRequested: boolean, wasBusy: boolean, ): SaveState { if (!saveRequested) { return "not-saved"; } const message = { customType: BTW_MESSAGE_TYPE, content: buildBtwMessageContent(details.question, details.answer), display: true, details, }; if (wasBusy) { pi.sendMessage(message, { deliverAs: "followUp" }); return "queued"; } pi.sendMessage(message); return "saved"; } function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void { if (ctx.hasUI) { ctx.ui.notify(message, level); } } function getOverlayTitle(mode: BtwThreadMode): string { return mode === "tangent" ? "BTW tangent" : "BTW"; } // ─── BtwOverlayComponent ──────────────────────────────────────────────────── class BtwOverlayComponent extends Container implements Focusable { private readonly input: Input; private readonly transcript: Container; private readonly statusText: Text; private readonly modeText: Text; private readonly summaryText: Text; private readonly hintsText: Text; private readonly readTranscriptEntries: () => BtwTranscript; private readonly getStatus: () => string | null; private readonly getMode: () => BtwThreadMode; private readonly onSubmitCallback: (value: string) => void; private readonly onDismissCallback: () => void; private readonly onUnfocusCallback: () => void; private readonly tui: TUI; private readonly theme: ExtensionContext["ui"]["theme"]; private transcriptLines: string[] = []; private transcriptScrollOffset = 0; private transcriptViewportHeight = 8; private followTranscript = true; private _focused = false; private modeTextValue = ""; private summaryTextValue = ""; private statusTextValue = ""; private hintsTextValue = ""; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.input.focused = value; } constructor( tui: TUI, theme: ExtensionContext["ui"]["theme"], keybindings: KeybindingsManager, readTranscriptEntries: () => BtwTranscript, getStatus: () => string | null, getMode: () => BtwThreadMode, onSubmit: (value: string) => void, onDismiss: () => void, onUnfocus: () => void, ) { super(); this.tui = tui; this.theme = theme; this.readTranscriptEntries = readTranscriptEntries; this.getStatus = getStatus; this.getMode = getMode; this.onSubmitCallback = onSubmit; this.onDismissCallback = onDismiss; this.onUnfocusCallback = onUnfocus; this.modeText = new Text("", 1, 0); this.summaryText = new Text("", 1, 0); this.transcript = new Container(); this.statusText = new Text("", 1, 0); this.input = new Input(); this.input.onSubmit = (value) => { this.followTranscript = true; this.onSubmitCallback(value); }; this.input.onEscape = () => { this.onDismissCallback(); }; this.hintsText = new Text("", 1, 0); const originalHandleInput = this.input.handleInput.bind(this.input); this.input.handleInput = (data: string) => { if (keybindings.matches(data, "tui.select.cancel")) { this.onDismissCallback(); return; } originalHandleInput(data); }; this.refresh(); } private frameLine(content: string, innerWidth: number): string { const truncated = truncateToWidth(content, innerWidth, ""); const padding = Math.max(0, innerWidth - visibleWidth(truncated)); return `${this.theme.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("borderMuted", "│")}`; } private ruleLine(innerWidth: number): string { return this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`); } private borderLine(innerWidth: number, edge: "top" | "bottom"): string { const left = edge === "top" ? "┌" : "└"; const right = edge === "top" ? "┐" : "┘"; return this.theme.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`); } private wrapTranscript(innerWidth: number): string[] { const wrapped: string[] = []; for (const line of this.transcriptLines) { if (!line) { wrapped.push(""); continue; } wrapped.push(...wrapTextWithAnsi(line, Math.max(1, innerWidth))); } return wrapped; } private getDialogHeight(): number { const terminalRows = process.stdout.rows ?? 30; return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78))); } handleInput(data: string): void { if (matchesBtwFocusShortcut(data)) { this.onUnfocusCallback(); return; } if (matchesKey(data, Key.pageUp)) { this.followTranscript = false; this.transcriptScrollOffset = Math.max(0, this.transcriptScrollOffset - Math.max(1, this.transcriptViewportHeight - 1)); this.tui.requestRender(); return; } if (matchesKey(data, Key.pageDown)) { this.transcriptScrollOffset += Math.max(1, this.transcriptViewportHeight - 1); this.tui.requestRender(); return; } this.input.handleInput(data); } private inputFrameLine(dialogWidth: number): string { const targetWidth = Math.max(1, dialogWidth - 2); const previousFocused = this.input.focused; this.input.focused = false; try { const inputLine = this.input.render(targetWidth)[0] ?? ""; return `${this.theme.fg("borderMuted", "│")}${inputLine}${this.theme.fg("borderMuted", "│")}`; } finally { this.input.focused = previousFocused; } } override render(width: number): string[] { const dialogWidth = Math.max(24, width); const innerWidth = Math.max(22, dialogWidth - 2); const transcriptLines = this.wrapTranscript(innerWidth); const dialogHeight = this.getDialogHeight(); const chromeHeight = 8; const transcriptHeight = Math.max(6, dialogHeight - chromeHeight); this.transcriptViewportHeight = transcriptHeight; const maxScroll = Math.max(0, transcriptLines.length - transcriptHeight); if (this.followTranscript) { this.transcriptScrollOffset = maxScroll; } else { this.transcriptScrollOffset = Math.max(0, Math.min(this.transcriptScrollOffset, maxScroll)); if (this.transcriptScrollOffset >= maxScroll) { this.followTranscript = true; } } const visibleTranscript = transcriptLines.slice( this.transcriptScrollOffset, this.transcriptScrollOffset + transcriptHeight, ); const transcriptPadCount = Math.max(0, transcriptHeight - visibleTranscript.length); const hiddenAbove = this.transcriptScrollOffset; const hiddenBelow = Math.max(0, maxScroll - this.transcriptScrollOffset); const summary = hiddenAbove || hiddenBelow ? `${this.summaryTextValue.trim()} · ↑${hiddenAbove} ↓${hiddenBelow}` : this.summaryTextValue.trim(); const lines = [this.borderLine(innerWidth, "top")]; lines.push(this.frameLine(this.theme.fg("accent", this.theme.bold(this.modeTextValue.trim())), innerWidth)); lines.push(this.frameLine(this.theme.fg("dim", summary), innerWidth)); lines.push(this.ruleLine(innerWidth)); for (const line of visibleTranscript) { lines.push(this.frameLine(line, innerWidth)); } for (let i = 0; i < transcriptPadCount; i++) { lines.push(this.frameLine("", innerWidth)); } lines.push(this.ruleLine(innerWidth)); lines.push(this.frameLine(this.theme.fg("warning", this.statusTextValue.trim()), innerWidth)); lines.push(this.inputFrameLine(dialogWidth)); lines.push(this.frameLine(this.theme.fg("dim", this.hintsTextValue.trim()), innerWidth)); lines.push(this.borderLine(innerWidth, "bottom")); return lines; } setDraft(value: string): void { this.input.setValue(value); this.tui.requestRender(); } getDraft(): string { return this.input.getValue(); } getTranscriptEntries(): BtwTranscript { return this.readTranscriptEntries().map((entry) => ({ ...entry })); } refresh(): void { this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`; this.modeText.setText(this.modeTextValue); const entries = this.readTranscriptEntries(); const exchanges = getCompletedExchangeCount(entries); const active = hasStreamingTranscriptEntry(entries) ? " · streaming" : " · idle"; this.summaryTextValue = `${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`; this.summaryText.setText(this.summaryTextValue); this.transcriptLines = buildOverlayTranscript(entries, this.theme); this.transcript.clear(); for (const line of this.transcriptLines) { this.transcript.addChild(new Text(line, 1, 0)); } const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing."; this.statusTextValue = status; this.statusText.setText(this.statusTextValue); this.hintsTextValue = "Enter submit · Alt+/ toggle focus · Escape dismiss · PgUp/PgDn scroll"; this.hintsText.setText(this.hintsTextValue); this.tui.requestRender(); } } // ─── Extension Entry ──────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { let pendingThread: BtwDetails[] = []; let pendingMode: BtwThreadMode = "contextual"; let transcriptState = createEmptyTranscriptState(); let overlayStatus: string | null = null; let overlayDraft = ""; let overlayRuntime: OverlayRuntime | null = null; let lastUiContext: ExtensionContext | ExtensionCommandContext | null = null; let activeBtwSession: BtwSessionRuntime | null = null; function syncUi(ctx?: ExtensionContext | ExtensionCommandContext): void { const activeCtx = ctx ?? lastUiContext; if (activeCtx?.hasUI) { activeCtx.ui.setWidget("btw", undefined); overlayRuntime?.refresh?.(); } } function setOverlayStatus(status: string | null, ctx?: ExtensionContext | ExtensionCommandContext): void { overlayStatus = status; syncUi(ctx); } function setOverlayDraft(value: string): void { overlayDraft = value; overlayRuntime?.setDraft?.(value); } function dismissOverlay(): void { overlayRuntime?.close?.(); overlayRuntime = null; } function toggleOverlayFocus(): void { const handle = overlayRuntime?.handle; if (!handle) { return; } handle.setHidden(false); if (handle.isFocused()) { handle.unfocus(); } else { handle.focus(); } overlayRuntime?.refresh?.(); } function focusOverlay(): void { const handle = overlayRuntime?.handle; if (!handle) { return; } handle.setHidden(false); handle.focus(); overlayRuntime?.refresh?.(); } function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void { if (!sessionRuntime.subscriptions.delete(unsubscribe)) { return; } try { unsubscribe(); } catch { // Ignore unsubscribe errors during BTW session replacement/shutdown. } } function clearBtwSessionSubscriptions(sessionRuntime: BtwSessionRuntime): void { for (const unsubscribe of [...sessionRuntime.subscriptions]) { removeBtwSessionSubscription(sessionRuntime, unsubscribe); } } function handleBtwSessionEvent( sessionRuntime: BtwSessionRuntime, event: AgentSessionEvent, ctx?: ExtensionContext | ExtensionCommandContext, ): void { if (activeBtwSession?.session !== sessionRuntime.session || !overlayRuntime) { return; } applyTranscriptEvent(transcriptState, event); if (event.type === "tool_execution_start") { setOverlayStatus(`⏳ running tool: ${event.toolName}`, ctx); return; } if (event.type === "tool_execution_end") { setOverlayStatus(sessionRuntime.session.isStreaming ? `⏳ running tool: ${event.toolName}` : "⏳ streaming...", ctx); return; } if (event.type === "turn_end") { setOverlayStatus("⏳ streaming...", ctx); return; } if ( event.type === "message_start" || event.type === "message_update" || event.type === "message_end" || event.type === "turn_start" ) { syncUi(ctx); } } function subscribeOverlayToActiveBtwSession(ctx?: ExtensionContext | ExtensionCommandContext): void { const sessionRuntime = activeBtwSession; if (!sessionRuntime || sessionRuntime.subscriptions.size > 0) { return; } const unsubscribe = sessionRuntime.session.subscribe((event: AgentSessionEvent) => { handleBtwSessionEvent(sessionRuntime, event, ctx); }); sessionRuntime.subscriptions.add(unsubscribe); } async function disposeBtwSession(): Promise { const current = activeBtwSession; activeBtwSession = null; if (!current) { return; } clearBtwSessionSubscriptions(current); try { await current.session.abort(); } catch { // Ignore abort errors during BTW session replacement/shutdown. } current.session.dispose(); } async function dismissOverlaySession(): Promise { dismissOverlay(); await disposeBtwSession(); } async function createBtwSubSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise { const { session } = await createAgentSession({ sessionManager: SessionManager.inMemory(), model: ctx.model, modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"], thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel, tools: ["read", "bash", "edit", "write"], resourceLoader: createBtwResourceLoader(ctx), }); const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode); if (seedMessages.length > 0) { session.agent.state.messages = seedMessages as typeof session.state.messages; } return { session, mode, subscriptions: new Set(), sideThreadStartIndex }; } async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise { if (!ctx.model) { return null; } if (activeBtwSession?.mode === mode) { return activeBtwSession; } await disposeBtwSession(); activeBtwSession = await createBtwSubSession(ctx, mode); return activeBtwSession; } async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise { if (!ctx.hasUI) { return; } lastUiContext = ctx; if (overlayRuntime?.handle) { subscribeOverlayToActiveBtwSession(ctx); focusOverlay(); return; } const runtime: OverlayRuntime = {}; const closeRuntime = () => { if (runtime.closed) { return; } runtime.closed = true; if (activeBtwSession) { clearBtwSessionSubscriptions(activeBtwSession); } runtime.handle?.hide(); if (overlayRuntime === runtime) { overlayRuntime = null; } runtime.finish?.(); }; runtime.close = closeRuntime; overlayRuntime = runtime; void ctx.ui .custom( async (tui, theme, keybindings, done) => { runtime.finish = () => { done(); }; const overlay = new BtwOverlayComponent( tui, theme, keybindings, () => transcriptState.entries, () => overlayStatus, () => pendingMode, (value) => { void submitFromOverlay(ctx, value); }, () => { void dismissOverlaySession(); }, () => { overlayRuntime?.handle?.unfocus(); overlayRuntime?.refresh?.(); }, ); overlay.focused = runtime.handle?.isFocused() ?? true; overlay.setDraft(overlayDraft); runtime.setDraft = (value) => { overlay.setDraft(value); }; runtime.refresh = () => { overlay.focused = runtime.handle?.isFocused() ?? false; overlay.refresh(); }; runtime.close = () => { overlayDraft = overlay.getDraft(); closeRuntime(); }; subscribeOverlayToActiveBtwSession(ctx); if (runtime.closed) { done(); } return overlay; }, { overlay: true, overlayOptions: { width: "78%", minWidth: 72, maxHeight: "78%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 }, nonCapturing: true, }, onHandle: (handle) => { runtime.handle = handle; handle.focus(); if (runtime.closed) { closeRuntime(); } }, }, ) .catch((error) => { if (overlayRuntime === runtime) { overlayRuntime = null; } notify(ctx, error instanceof Error ? error.message : String(error), "error"); }); } async function dispatchBtwCommand(name: string, args: string, ctx: ExtensionCommandContext): Promise { const trimmedArgs = args.trim(); if (name === "btw") { const { question, save } = parseBtwArgs(trimmedArgs); if (!question) { await ensureBtwSession(ctx, pendingMode); await ensureOverlay(ctx); return true; } if (pendingMode !== "contextual") { await resetThread(ctx, true, "contextual"); } await runBtw(ctx, question, save, "contextual"); return true; } if (name === "btw:tangent") { const { question, save } = parseBtwArgs(trimmedArgs); if (pendingMode !== "tangent") { await resetThread(ctx, true, "tangent"); } if (!question) { await ensureBtwSession(ctx, "tangent"); await ensureOverlay(ctx); return true; } await runBtw(ctx, question, save, "tangent"); return true; } if (name === "btw:new") { await resetThread(ctx, true, "contextual"); const { question, save } = parseBtwArgs(trimmedArgs); if (question) { await runBtw(ctx, question, save, "contextual"); } else { await ensureBtwSession(ctx, "contextual"); setOverlayStatus("Started a fresh BTW thread.", ctx); await ensureOverlay(ctx); notify(ctx, "Started a fresh BTW thread.", "info"); } return true; } if (name === "btw:clear") { await resetThread(ctx); dismissOverlay(); notify(ctx, "Cleared BTW thread.", "info"); return true; } if (name === "btw:inject") { if (pendingThread.length === 0) { notify(ctx, "No BTW thread to inject.", "warning"); return true; } setOverlayStatus("⏳ injecting into the main session...", ctx); await ensureOverlay(ctx); try { const { thread } = await getBtwHandoffThread(ctx); const instructions = trimmedArgs; const content = instructions ? `Here is a side conversation I had. ${instructions}\n\n${formatThread(thread)}` : `Here is a side conversation I had for additional context:\n\n${formatThread(thread)}`; sendThreadToMain(ctx, content); const count = thread.length; await resetThread(ctx); dismissOverlay(); notify(ctx, `Injected BTW thread (${count} exchange${count === 1 ? "" : "s"}).`, "info"); } catch (error) { setOverlayStatus("Inject failed. Thread preserved for retry or summarize.", ctx); notify(ctx, error instanceof Error ? error.message : String(error), "error"); } return true; } if (name === "btw:summarize") { if (pendingThread.length === 0) { notify(ctx, "No BTW thread to summarize.", "warning"); return true; } setOverlayStatus("⏳ summarizing...", ctx); await ensureOverlay(ctx); try { const { thread } = await getBtwHandoffThread(ctx); const summary = await summarizeThread(ctx, thread); const instructions = trimmedArgs; const content = instructions ? `Here is a summary of a side conversation I had. ${instructions}\n\n${summary}` : `Here is a summary of a side conversation I had:\n\n${summary}`; sendThreadToMain(ctx, content); const count = thread.length; await resetThread(ctx); dismissOverlay(); notify(ctx, `Injected BTW summary (${count} exchange${count === 1 ? "" : "s"}).`, "info"); } catch (error) { setOverlayStatus("Summarize failed. Thread preserved for retry or injection.", ctx); notify(ctx, error instanceof Error ? error.message : String(error), "error"); } return true; } return false; } function parseOverlayBtwCommand(value: string): { name: string; args: string } | null { const trimmed = value.trim(); const legacy = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize))(?:\s+(.*))?$/); if (legacy) { return { name: legacy[1], args: legacy[2]?.trim() ?? "", }; } const unipi = trimmed.match(/^\/unipi:btw-(new|tangent|clear|inject|summarize)(?:\s+(.*))?$/); if (unipi) { return { name: `btw:${unipi[1]}`, args: unipi[2]?.trim() ?? "", }; } return null; } async function submitFromOverlay(ctx: ExtensionCommandContext | ExtensionContext, value: string): Promise { const question = value.trim(); if (!question) { setOverlayStatus("Enter a BTW prompt before submitting.", ctx); return; } if (!("getSystemPrompt" in ctx)) { setOverlayStatus("BTW overlay submit requires a command context. Reopen BTW from a command.", ctx); return; } const cmdCtx = ctx as ExtensionCommandContext; const btwCommand = parseOverlayBtwCommand(question); if (btwCommand) { setOverlayDraft(""); await dispatchBtwCommand(btwCommand.name, btwCommand.args, cmdCtx); return; } setOverlayDraft(""); setOverlayStatus("⏳ streaming...", ctx); syncUi(ctx); await runBtw(cmdCtx, question, false, pendingMode); } async function resetThread( ctx: ExtensionContext | ExtensionCommandContext, persist = true, mode: BtwThreadMode = "contextual", ): Promise { await disposeBtwSession(); pendingThread = []; pendingMode = mode; transcriptState = createEmptyTranscriptState(); setOverlayDraft(""); setOverlayStatus(null, ctx); if (persist) { const details: BtwResetDetails = { timestamp: Date.now(), mode }; pi.appendEntry(BTW_RESET_TYPE, details); } syncUi(ctx); } async function restoreThread(ctx: ExtensionContext): Promise { await disposeBtwSession(); pendingThread = []; pendingMode = "contextual"; transcriptState = createEmptyTranscriptState(); overlayDraft = ""; lastUiContext = ctx; overlayStatus = null; const branch = ctx.sessionManager.getBranch(); let lastResetIndex = -1; for (let i = 0; i < branch.length; i++) { if (isCustomEntry(branch[i], BTW_RESET_TYPE)) { lastResetIndex = i; const details = (branch[i] as unknown as { data?: BtwResetDetails }).data; pendingMode = details?.mode ?? "contextual"; } } for (const entry of branch.slice(lastResetIndex + 1)) { if (!isCustomEntry(entry, BTW_ENTRY_TYPE)) { continue; } const details = (entry as unknown as { data?: BtwDetails }).data; if (!details?.question || !details.answer) { continue; } pendingThread.push(details); appendPersistedTranscriptTurn(transcriptState, details); } syncUi(ctx); } async function runBtw( ctx: ExtensionCommandContext, question: string, saveRequested: boolean, mode: BtwThreadMode, ): Promise { lastUiContext = ctx; const model = ctx.model; if (!model) { setOverlayStatus("No active model selected.", ctx); notify(ctx, "No active model selected.", "error"); return; } const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (!auth.ok || !auth.apiKey) { const message = auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error; setOverlayStatus(message, ctx); notify(ctx, message, "error"); await ensureOverlay(ctx); return; } const sessionRuntime = await ensureBtwSession(ctx, mode); if (!sessionRuntime) { setOverlayStatus("No active model selected.", ctx); notify(ctx, "No active model selected.", "error"); return; } const session = sessionRuntime.session; const wasBusy = !ctx.isIdle(); pendingMode = mode; const thinkingLevel = pi.getThinkingLevel() as SessionThinkingLevel; setOverlayStatus("⏳ streaming...", ctx); await ensureOverlay(ctx); try { await session.prompt(question, { source: "extension" }); const response = getLastAssistantMessage(session); if (!response) { throw new Error("BTW request finished without a response."); } if (response.stopReason === "aborted") { removeTranscriptTurn(transcriptState, transcriptState.lastTurnId ?? transcriptState.currentTurnId); setOverlayStatus("Request aborted.", ctx); return; } if (response.stopReason === "error") { throw new Error(response.errorMessage || "BTW request failed."); } const completedTurnId = transcriptState.lastTurnId ?? transcriptState.currentTurnId; const streamedThinking = completedTurnId !== null ? findLatestTranscriptEntry(transcriptState, completedTurnId, "thinking")?.text : ""; const answer = extractAnswer(response); const thinking = extractThinking(response) || streamedThinking || ""; const details: BtwDetails = { question, thinking, answer, provider: model.provider, model: model.id, thinkingLevel, timestamp: Date.now(), usage: response.usage, }; pendingThread.push(details); pi.appendEntry(BTW_ENTRY_TYPE, details); const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy); if (saveState === "saved") { notify(ctx, "Saved BTW note to the session.", "info"); setOverlayStatus("Saved BTW note to the session.", ctx); } else if (saveState === "queued") { notify(ctx, "BTW note queued to save after the current turn finishes.", "info"); setOverlayStatus("BTW note queued to save after the current turn finishes.", ctx); } else { setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); setTranscriptFailure(transcriptState, errorMessage); setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx); notify(ctx, errorMessage, "error"); await disposeBtwSession(); } finally { syncUi(ctx); } } function getPendingThreadForHandoff(): BtwHandoffExchange[] { return pendingThread.map((entry) => ({ user: entry.question, assistant: entry.answer })); } async function getBtwHandoffThread( ctx: ExtensionCommandContext, ): Promise<{ sessionRuntime: BtwSessionRuntime | null; thread: BtwHandoffExchange[] }> { const sessionRuntime = activeBtwSession ?? (await ensureBtwSession(ctx, pendingMode)); const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : []; const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff(); if (resolvedThread.length === 0) { throw new Error("No BTW thread available for handoff."); } return { sessionRuntime, thread: resolvedThread }; } async function summarizeThread(ctx: ExtensionCommandContext, thread: BtwHandoffExchange[]): Promise { const model = ctx.model; if (!model) { throw new Error("No active model selected."); } const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (!auth.ok || !auth.apiKey) { throw new Error(auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error); } const { session } = await createAgentSession({ sessionManager: SessionManager.inMemory(), model, modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"], thinkingLevel: "off", tools: [], resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]), }); try { await session.prompt(formatThread(thread), { source: "extension" }); const response = getLastAssistantMessage(session); if (!response) { throw new Error("BTW summarize finished without a response."); } if (response.stopReason === "error") { throw new Error(response.errorMessage || "Failed to summarize BTW thread."); } if (response.stopReason === "aborted") { throw new Error("BTW summarize aborted."); } return extractAnswer(response); } finally { try { await session.abort(); } catch { // Ignore abort errors during summarize session shutdown. } session.dispose(); } } function sendThreadToMain(ctx: ExtensionCommandContext, content: string): void { if (ctx.isIdle()) { pi.sendUserMessage(content); } else { pi.sendUserMessage(content, { deliverAs: "followUp" }); } } // ─── Register ─────────────────────────────────────────────────────────── pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => { const details = message.details as BtwDetails | undefined; const content = typeof message.content === "string" ? message.content : "[non-text btw message]"; const lines = [theme.fg("accent", theme.bold("[BTW]")), content]; if (expanded && details) { lines.push( theme.fg("dim", `model: ${details.provider}/${details.model} · thinking: ${details.thinkingLevel}`), ); if (details.usage) { lines.push( theme.fg( "dim", `tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`, ), ); } } const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); box.addChild(new Text(lines.join("\n"), 0, 0)); return box; }); pi.on("context", async (event) => { return { messages: event.messages.filter((message) => !isVisibleBtwMessage(message)), }; }); pi.on("session_start", async (_event, ctx) => { await restoreThread(ctx); }); pi.on("session_tree", async (_event, ctx) => { await restoreThread(ctx); }); pi.on("session_shutdown", async () => { await disposeBtwSession(); dismissOverlay(); }); for (const shortcut of BTW_FOCUS_SHORTCUTS) { pi.registerShortcut(shortcut, { description: "Toggle BTW overlay focus while leaving it open.", handler: async (_ctx) => { toggleOverlayFocus(); }, }); } pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.BTW}`, { description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.", handler: async (args, ctx) => { await dispatchBtwCommand("btw", args, ctx); }, }); pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.TANGENT}`, { description: "Start or continue a contextless BTW tangent in the focused BTW modal.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:tangent", args, ctx); }, }); pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.NEW}`, { description: "Start a fresh BTW thread with main-session context. Optionally ask the first question immediately.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:new", args, ctx); }, }); pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.CLEAR}`, { description: "Dismiss the BTW modal/widget and clear the current thread.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:clear", args, ctx); }, }); pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.INJECT}`, { description: "Inject the full BTW thread into the main agent as a user message.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:inject", args, ctx); }, }); pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.SUMMARIZE}`, { description: "Summarize the BTW thread, then inject the summary into the main agent.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:summarize", args, ctx); }, }); }