diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-25 00:25:04 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-25 00:25:04 +0200 |
| commit | dfa93b89eb213926bd6c8364071fbcaaac291b98 (patch) | |
| tree | a199217767b76e12b9c5f736d90ed23188926544 /pi | |
| parent | 25ebc768abbf3cefcc43e7b38dfd9e972d10336d (diff) | |
pi/agent: non-blocking /btw, live loop countdown, persistent prompt history
btw: replace blocking overlay with non-blocking widget
- Fire LLM call in background; return immediately so user can keep typing
- Show loading widget below editor while answering
- Display answer in same widget when ready; dismiss with /btw close
- Remove BtwOverlay and wrapText helpers (no longer needed)
loop-scheduler: update countdown every second
- Add startUiTick/stopUiTick (1s setInterval) so the "in Xs" countdown
in the scheduled-loops widget refreshes live instead of staying stale
- Tick starts automatically when jobs exist, stops when all are removed
or the session shuts down
prompt-history: persist editor input history across session restarts
- New extension saves every submitted prompt to ~/.pi/prompt-history.json
(up to 500 entries) via before_agent_start
- On session_start, reads the file, merges with in-memory editor history
(deduplicating), and re-seeds the editor so up-arrow history survives
process restarts and new sessions
- Requires addToHistory/getHistory on ctx.ui (patched into pi-coding-agent
dist/modes/interactive/interactive-mode.js and types.d.ts)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'pi')
| -rw-r--r-- | pi/agent/extensions/btw/index.ts | 172 | ||||
| -rw-r--r-- | pi/agent/extensions/loop-scheduler/index.ts | 16 | ||||
| -rw-r--r-- | pi/agent/extensions/prompt-history/index.ts | 80 |
3 files changed, 133 insertions, 135 deletions
diff --git a/pi/agent/extensions/btw/index.ts b/pi/agent/extensions/btw/index.ts index 286c52e..bff7a55 100644 --- a/pi/agent/extensions/btw/index.ts +++ b/pi/agent/extensions/btw/index.ts @@ -1,13 +1,10 @@ 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. @@ -34,93 +31,6 @@ function getConversationMessages(ctx: ExtensionCommandContext): 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<string> { if (!ctx.model) { throw new Error("No model selected."); @@ -151,13 +61,23 @@ async function runBtw(question: string, ctx: ExtensionCommandContext): Promise<s return extractResponseText(response) || "(no answer)"; } +const BTW_WIDGET = "btw"; + export default function btwExtension(pi: ExtensionAPI): void { pi.registerCommand("btw", { - description: "Ask a quick side question without adding it to the conversation", + description: "Ask a quick side question without blocking — answer appears in a widget", handler: async (args, ctx) => { - const question = args.trim(); + const trimmed = args.trim(); + + // /btw close — dismiss the answer widget. + if (/^close$/i.test(trimmed)) { + if (ctx.hasUI) ctx.ui.setWidget(BTW_WIDGET, undefined); + return; + } + + const question = trimmed; if (!question) { - const usage = "Usage: /btw <side question>"; + const usage = "Usage: /btw <side question> | /btw close"; if (!ctx.hasUI) process.stdout.write(`${usage}\n`); else ctx.ui.notify(usage, "warning"); return; @@ -170,6 +90,7 @@ export default function btwExtension(pi: ExtensionAPI): void { return; } + // Non-UI path: blocking is fine in a pipe/CLI context. if (!ctx.hasUI) { try { const answer = await runBtw(question, ctx); @@ -181,50 +102,31 @@ export default function btwExtension(pi: ExtensionAPI): void { return; } - const answer = await ctx.ui.custom<string | null>( - (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, - }, - }, + // Non-blocking UI path: show a loading widget immediately and return so + // the user can keep typing while the LLM answers in the background. + ctx.ui.setWidget( + BTW_WIDGET, + [ctx.ui.theme.fg("accent", "BTW"), `⟳ ${ctx.ui.theme.fg("muted", question)}`, " asking…"], + { placement: "belowEditor" }, ); - if (answer === null) { - ctx.ui.notify("BTW cancelled.", "info"); - return; - } - - await ctx.ui.custom<void>( - (_tui, theme, _kb, done) => new BtwOverlay(theme, question, answer, done), - { - overlay: true, - overlayOptions: { - width: "55%", - minWidth: 56, - maxHeight: "85%", - anchor: "right-center", - offsetX: -1, - }, - }, - ); + void runBtw(question, ctx) + .then((answer) => { + ctx.ui.setWidget( + BTW_WIDGET, + [ + ctx.ui.theme.fg("accent", "BTW") + ctx.ui.theme.fg("muted", " /btw close to dismiss"), + ` Q: ${question}`, + ...answer.split("\n").map((line) => ` ${line}`), + ], + { placement: "belowEditor" }, + ); + }) + .catch((error) => { + const text = error instanceof Error ? error.message : String(error); + ctx.ui.setWidget(BTW_WIDGET, undefined); + ctx.ui.notify(`BTW failed: ${text}`, "error"); + }); }, }); } diff --git a/pi/agent/extensions/loop-scheduler/index.ts b/pi/agent/extensions/loop-scheduler/index.ts index 837214f..05fb002 100644 --- a/pi/agent/extensions/loop-scheduler/index.ts +++ b/pi/agent/extensions/loop-scheduler/index.ts @@ -135,6 +135,7 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void { const timers = new Map<string, TimerHandle>(); let lastCtx: ExtensionContext | undefined; let agentBusy = false; + let uiTick: TimerHandle | undefined; function rememberContext(ctx: ExtensionContext): void { lastCtx = ctx; @@ -170,6 +171,7 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void { if (ordered.length === 0) { ctx.ui.setStatus("loop-scheduler", undefined); ctx.ui.setWidget("loop-scheduler", undefined); + stopUiTick(); return; } @@ -183,6 +185,19 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void { ], { placement: "belowEditor" }, ); + startUiTick(); + } + + // Tick every second so the countdown in the widget stays current. + function startUiTick(): void { + if (uiTick !== undefined) return; + uiTick = setInterval(() => updateUi(), 1000); + } + + function stopUiTick(): void { + if (uiTick === undefined) return; + clearInterval(uiTick); + uiTick = undefined; } function notify(message: string, level: "info" | "warning" | "error" | "success" = "info", ctx?: ExtensionContext): void { @@ -373,6 +388,7 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void { pi.on("session_shutdown", async (_event, ctx) => { rememberContext(ctx); clearAllTimers(); + stopUiTick(); jobs.clear(); agentBusy = false; updateUi(ctx); diff --git a/pi/agent/extensions/prompt-history/index.ts b/pi/agent/extensions/prompt-history/index.ts new file mode 100644 index 0000000..2ef7e9c --- /dev/null +++ b/pi/agent/extensions/prompt-history/index.ts @@ -0,0 +1,80 @@ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +const HISTORY_FILE = join(homedir(), ".pi", "prompt-history.json"); +const MAX_ENTRIES = 500; + +// Load persisted history from disk. Returns entries newest-first (same order as editor.history). +function loadHistory(): string[] { + try { + const raw = readFileSync(HISTORY_FILE, "utf8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +// Persist history to disk, capping at MAX_ENTRIES. +function saveHistory(entries: string[]): void { + try { + mkdirSync(join(homedir(), ".pi"), { recursive: true }); + const capped = entries.slice(0, MAX_ENTRIES); + writeFileSync(HISTORY_FILE, JSON.stringify(capped, null, 2), "utf8"); + } catch { + // Best-effort: don't crash the agent on a write failure. + } +} + +// Merge two newest-first lists, deduplicating consecutive identical entries, +// keeping at most MAX_ENTRIES total. +function mergeHistory(fresh: string[], persisted: string[]): string[] { + const merged: string[] = []; + const seen = new Set<string>(); + for (const entry of [...fresh, ...persisted]) { + if (!seen.has(entry)) { + seen.add(entry); + merged.push(entry); + } + if (merged.length >= MAX_ENTRIES) break; + } + return merged; +} + +export default function promptHistoryExtension(pi: ExtensionAPI): void { + // Restore persisted history into the editor on every session start (including + // after /reload-runtime). Entries are merged with whatever the editor already + // has so in-session history is never lost. + pi.on("session_start", async (_event, ctx) => { + if (!ctx.hasUI) return; + const persisted = loadHistory(); + if (persisted.length === 0) return; + + // getHistory returns the editor's current in-memory history (newest-first). + const current = ctx.ui.getHistory(); + const merged = mergeHistory(current, persisted); + + // Re-seed the editor: add entries oldest-first so the final array order + // (newest-first inside the editor) matches the merged list. + for (const entry of [...merged].reverse()) { + ctx.ui.addToHistory(entry); + } + + // Persist the merged result so nothing accumulated in previous runs is lost. + saveHistory(merged); + }); + + // Capture every submitted prompt and append it to the persistent history file. + pi.on("before_agent_start", async (event, _ctx) => { + const text = event.prompt.trim(); + if (!text) return; + + const current = loadHistory(); + // Avoid duplicating the most recent entry. + if (current[0] === text) return; + + saveHistory([text, ...current]); + }); +} |
