/** * psst - Ephemeral side questions for pi * * Ask a quick question about your current work without adding to * the conversation history. Like whispering to the LLM. * * Usage: * /psst what was the name of that config file again? * /psst why did we choose that approach? * * The question and answer are ephemeral — they appear in a dismissible * overlay and never enter the conversation history. * * Features: * - Full visibility into current conversation context * - No tool access (answers from what's already in context) * - Works while the agent is processing * - Single response, no follow-ups * * Inspired by Claude Code's /btw command. */ import { complete, type Message } from "@mariozechner/pi-ai"; import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; import { BorderedLoader, convertToLlm, getMarkdownTheme, serializeConversation } from "@mariozechner/pi-coding-agent"; import { Container, Markdown, matchesKey, Text } from "@mariozechner/pi-tui"; const SYSTEM_PROMPT = `Answer the user's side question in 1-3 sentences. Be extremely brief and direct — like a whispered answer, not an explanation. No preamble, no meta-commentary about the conversation itself. Just answer the question.`; export default function (pi: ExtensionAPI) { pi.registerCommand("psst", { description: "Ask a quick side question without adding to conversation history", handler: async (args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("psst requires interactive mode", "error"); return; } if (!ctx.model) { ctx.ui.notify("No model selected", "error"); return; } const question = args.trim(); if (!question) { ctx.ui.notify("Usage: /psst ", "error"); return; } // Gather conversation context const branch = ctx.sessionManager.getBranch(); const messages = branch .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") .map((entry) => entry.message); const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); // Generate answer with loader, then show result const answer = await ctx.ui.custom((tui, theme, _kb, done) => { const loader = new BorderedLoader(tui, theme, "Thinking..."); loader.onAbort = () => done(null); const generate = async () => { const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!); const userMessage: Message = { role: "user", content: [ { type: "text", text: `## Conversation Context\n\n${conversationText}\n\n## Side Question\n\n${question}`, }, ], timestamp: Date.now(), }; const response = await complete( ctx.model!, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, { apiKey, signal: loader.signal }, ); if (response.stopReason === "aborted") { return null; } return response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("\n"); }; generate() .then(done) .catch((err) => { console.error("psst failed:", err); done(null); }); return loader; }); if (answer === null) { return; } // Show answer in a dismissible UI await ctx.ui.custom((_tui, theme, _kb, done) => { const container = new Container(); const mdTheme = getMarkdownTheme(); const separator = theme.fg("dim", "─".repeat(60)); container.addChild(new Text("", 0, 0)); container.addChild(new Text(theme.fg("accent", theme.bold(" psst ")) + theme.fg("dim", question)), 0, 0); container.addChild(new Text(separator, 0, 0)); container.addChild(new Markdown(answer, 1, 1, mdTheme)); container.addChild(new Text("", 0, 0)); container.addChild(new Text(theme.fg("dim", " Press Space, Enter, or Esc to dismiss"), 0, 0)); return { render: (width: number) => container.render(width), invalidate: () => container.invalidate(), handleInput: (data: string) => { if (matchesKey(data, "space") || matchesKey(data, "enter") || matchesKey(data, "escape")) { done(undefined); } }, }; }); }, }); }