From 8fdba30d44037a91623c7cf05da7f1e2a298c47e Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 21 Mar 2026 09:56:45 +0200 Subject: import pi.dev stuff --- pi/agent/extensions/btw/README.md | 48 ++++++++ pi/agent/extensions/btw/index.ts | 230 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 pi/agent/extensions/btw/README.md create mode 100644 pi/agent/extensions/btw/index.ts (limited to 'pi/agent/extensions/btw') diff --git a/pi/agent/extensions/btw/README.md b/pi/agent/extensions/btw/README.md new file mode 100644 index 0000000..cf39e1c --- /dev/null +++ b/pi/agent/extensions/btw/README.md @@ -0,0 +1,48 @@ +# BTW + +Ephemeral side questions for Pi. + +This extension adds `/btw`, modeled after Claude Code's side-question flow: + +- it uses the current branch conversation as context +- it asks a separate one-shot question with the current model +- it does not add the side question or answer to session history +- it does not expose tools to that side question + +## Command + +- `/btw ` + Ask a quick side question without changing the main thread history. + +## Usage Flow + +### Flow 1: Ask a quick side question + +```text +/btw Why did the current taskwarrior loop happen? +``` + +Pi will answer in a temporary overlay. Close it with `Esc`, `Enter`, or `Space`. + +### Flow 2: Use it while you are in the middle of another task + +```text +/btw Remind me which file currently owns the SSH host key bootstrap logic. +``` + +This is meant for detours and clarifications. The main conversation stays clean. + +### Flow 3: Use it in non-interactive mode + +```bash +pi --model openai/gpt-4.1 --no-session -p '/btw Reply with exactly BTW_OK' +``` + +In non-interactive mode, the answer is printed directly to stdout. + +## Notes And Limits + +- `/btw` uses the currently selected model. +- The side question gets current branch context, not a fresh context. +- It has no tools. If the answer is not derivable from the supplied context, it should say so. +- It is best for short clarifications, not long implementation work. diff --git a/pi/agent/extensions/btw/index.ts b/pi/agent/extensions/btw/index.ts new file mode 100644 index 0000000..286c52e --- /dev/null +++ b/pi/agent/extensions/btw/index.ts @@ -0,0 +1,230 @@ +import { complete, type Message, type TextContent, type UserMessage } from "@mariozechner/pi-ai"; +import { + BorderedLoader, + convertToLlm, + type ExtensionAPI, + type ExtensionCommandContext, + type SessionEntry, + type Theme, +} from "@mariozechner/pi-coding-agent"; +import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; + +const SYSTEM_PROMPT = `You are answering a side question for the user. + +Rules: +- Use the supplied conversation context if it is relevant. +- Answer the side question directly and concisely. +- Do not use tools. +- Do not invent facts that are not supported by the supplied context. +- If the answer is not available from the supplied conversation context, say so plainly. +- Keep the answer short by default unless the user explicitly asks for depth.`; + +function extractResponseText(message: Message): string { + return message.content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n") + .trim(); +} + +function getConversationMessages(ctx: ExtensionCommandContext): Message[] { + const branch = ctx.sessionManager.getBranch(); + return branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message); +} + +function wrapParagraph(text: string, width: number): string[] { + if (width <= 1) return [text]; + if (!text.trim()) return [""]; + + const words = text.split(/\s+/).filter(Boolean); + if (words.length === 0) return [""]; + + const lines: string[] = []; + let current = ""; + + for (const word of words) { + const next = current ? `${current} ${word}` : word; + if (visibleWidth(next) <= width) { + current = next; + continue; + } + + if (current) lines.push(current); + + if (visibleWidth(word) <= width) { + current = word; + continue; + } + + let remainder = word; + while (visibleWidth(remainder) > width) { + lines.push(truncateToWidth(remainder, width, "")); + remainder = remainder.slice(lines[lines.length - 1]!.length); + } + current = remainder; + } + + if (current) lines.push(current); + return lines.length > 0 ? lines : [""]; +} + +function wrapText(text: string, width: number): string[] { + return text.split(/\r?\n/).flatMap((line) => wrapParagraph(line, width)); +} + +class BtwOverlay { + constructor( + private readonly theme: Theme, + private readonly question: string, + private readonly answer: string, + private readonly done: () => void, + ) {} + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, "return") || data === " " || data === "\r") { + this.done(); + } + } + + render(width: number): string[] { + const innerWidth = Math.max(20, width - 2); + const contentWidth = Math.max(10, innerWidth - 2); + const lines: string[] = []; + + const pad = (text: string) => { + const visible = visibleWidth(text); + return text + " ".repeat(Math.max(0, innerWidth - visible)); + }; + + const row = (text = "") => `${this.theme.fg("border", "│")}${pad(text)}${this.theme.fg("border", "│")}`; + const addWrappedSection = (label: string, value: string) => { + lines.push(row(` ${this.theme.fg("accent", label)}`)); + for (const wrapped of wrapText(value || "(no answer)", contentWidth)) { + lines.push(row(` ${wrapped}`)); + } + lines.push(row()); + }; + + lines.push(this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`)); + lines.push(row(` ${this.theme.fg("accent", "BTW")}${this.theme.fg("muted", " Side question")}`)); + lines.push(row()); + addWrappedSection("Question", this.question); + addWrappedSection("Answer", this.answer || "(no answer)"); + lines.push(row(this.theme.fg("dim", " Esc, Enter, or Space to close"))); + lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`)); + + return lines; + } + + invalidate(): void {} +} + +async function runBtw(question: string, ctx: ExtensionCommandContext): Promise { + if (!ctx.model) { + throw new Error("No model selected."); + } + + const branchMessages = getConversationMessages(ctx); + const llmMessages = convertToLlm(branchMessages); + const apiKey = await ctx.modelRegistry.getApiKey(ctx.model); + const userMessage: UserMessage = { + role: "user", + content: [{ type: "text", text: question }], + timestamp: Date.now(), + }; + + const response = await complete( + ctx.model, + { + systemPrompt: SYSTEM_PROMPT, + messages: [...llmMessages, userMessage], + }, + { apiKey }, + ); + + if (response.stopReason === "aborted") { + throw new Error("Cancelled."); + } + + return extractResponseText(response) || "(no answer)"; +} + +export default function btwExtension(pi: ExtensionAPI): void { + pi.registerCommand("btw", { + description: "Ask a quick side question without adding it to the conversation", + handler: async (args, ctx) => { + const question = args.trim(); + if (!question) { + const usage = "Usage: /btw "; + if (!ctx.hasUI) process.stdout.write(`${usage}\n`); + else ctx.ui.notify(usage, "warning"); + return; + } + + if (!ctx.model) { + const error = "No model selected."; + if (!ctx.hasUI) process.stdout.write(`${error}\n`); + else ctx.ui.notify(error, "error"); + return; + } + + if (!ctx.hasUI) { + try { + const answer = await runBtw(question, ctx); + process.stdout.write(`${answer}\n`); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + process.stdout.write(`${text}\n`); + } + return; + } + + const answer = await ctx.ui.custom( + (tui, theme, _kb, done) => { + const loader = new BorderedLoader(tui, theme, `Asking BTW using ${ctx.model!.id}...`); + loader.onAbort = () => done(null); + + runBtw(question, ctx) + .then(done) + .catch((error) => { + const text = error instanceof Error ? error.message : String(error); + done(`BTW failed: ${text}`); + }); + + return loader; + }, + { + overlay: true, + overlayOptions: { + width: "50%", + minWidth: 50, + maxHeight: "80%", + anchor: "right-center", + offsetX: -1, + }, + }, + ); + + if (answer === null) { + ctx.ui.notify("BTW cancelled.", "info"); + return; + } + + await ctx.ui.custom( + (_tui, theme, _kb, done) => new BtwOverlay(theme, question, answer, done), + { + overlay: true, + overlayOptions: { + width: "55%", + minWidth: 56, + maxHeight: "85%", + anchor: "right-center", + offsetX: -1, + }, + }, + ); + }, + }); +} -- cgit v1.2.3