diff --git a/src/resources/extensions/sf-tui/stash.ts b/src/resources/extensions/sf-tui/stash.ts new file mode 100644 index 000000000..4018f584d --- /dev/null +++ b/src/resources/extensions/sf-tui/stash.ts @@ -0,0 +1,183 @@ +import type { ExtensionContext, Theme } from "@sf-run/pi-coding-agent"; +import { truncateToWidth, visibleWidth, matchesKey, Key } from "@sf-run/pi-tui"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; + +const LIMIT = 20; + +interface Data { + version: number; + history: string[]; +} + +function stashPath(): string { + return join(homedir(), ".sf", "agent", "prompt-history.json"); +} + +export function readStash(): string[] { + try { + const path = stashPath(); + if (!existsSync(path)) return []; + const d = JSON.parse(readFileSync(path, "utf-8")) as Data; + return d.history.filter((h): h is string => typeof h === "string" && h.trim().length > 0); + } catch { + return []; + } +} + +export function writeStash(history: string[]): void { + try { + const path = stashPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + JSON.stringify({ version: 1, history: history.slice(0, LIMIT) }, null, 2) + "\n", + "utf-8", + ); + } catch { + /* non-fatal */ + } +} + +export function pushStash(history: string[], text: string): void { + const t = text.trim(); + if (!t || history[0] === t) return; + history.unshift(t); + if (history.length > LIMIT) { + history.length = LIMIT; + } +} + +function preview(text: string, maxWidth: number): string { + const c = text.replace(/\s+/g, " ").trim(); + return c ? truncateToWidth(c, maxWidth, "…") : "(empty)"; +} + +class StashOverlay { + private tui: { requestRender: () => void }; + private theme: Theme; + private done: (s: string | null) => void; + private items: string[]; + private sel = 0; + private cacheW = 0; + private cacheL: string[] = []; + + constructor( + tui: { requestRender: () => void }, + theme: Theme, + items: string[], + done: (s: string | null) => void, + ) { + this.tui = tui; + this.theme = theme; + this.items = items; + this.done = done; + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + this.done(null); + return; + } + if (matchesKey(data, Key.return) || matchesKey(data, Key.enter)) { + this.done(this.items[this.sel] ?? null); + return; + } + if (matchesKey(data, Key.down) || data === "j") { + this.sel = Math.min(this.items.length - 1, this.sel + 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.up) || data === "k") { + this.sel = Math.max(0, this.sel - 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + if (data >= "1" && data <= "9") { + const idx = parseInt(data, 10) - 1; + if (idx >= 0 && idx < this.items.length) { + this.done(this.items[idx] ?? null); + } + } + } + + invalidate(): void { + this.cacheW = 0; + } + + render(width: number): string[] { + if (this.cacheW === width) return this.cacheL; + + const th = this.theme; + const bw = Math.min(84, width - 4); + const iw = bw - 4; + + const pad = (s: string) => s + " ".repeat(Math.max(0, width - visibleWidth(s))); + const box = (s: string) => { + const len = visibleWidth(s); + return th.fg("dim", "│ ") + s + " ".repeat(Math.max(0, bw - 2 - len)) + th.fg("dim", " │"); + }; + + const lines: string[] = []; + lines.push(pad(th.fg("dim", "╭" + "─".repeat(bw) + "╮"))); + lines.push(pad(box(th.bold(th.fg("accent", "📜 Prompt History"))))); + lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); + lines.push(pad(box(th.fg("dim", "↑/jk navigate • 1-9 quick pick • Enter insert • Esc cancel")))); + lines.push(pad(box(""))); + + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]!; + const p = preview(item, iw - 8); + const ptr = i === this.sel ? th.fg("accent", "❯ ") : " "; + const num = i < 9 ? th.fg("dim", `${i + 1}`) : " "; + const label = i === this.sel ? th.fg("accent", p) : p; + lines.push(pad(box(`${ptr}${num}. ${label}`))); + } + + lines.push(pad(box(""))); + lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); + lines.push(pad(box(th.fg("dim", `${this.items.length} stashed prompts`)))); + lines.push(pad(th.fg("dim", "╰" + "─".repeat(bw) + "╯"))); + lines.push(""); + + this.cacheL = lines; + this.cacheW = width; + return lines; + } +} + +export async function openStashOverlay(ctx: ExtensionContext): Promise { + if (!ctx.hasUI) { + ctx.ui.notify("Prompt history requires interactive mode", "error"); + return; + } + const items = readStash(); + if (!items.length) { + ctx.ui.notify("No stashed prompts yet. Send a message to build history.", "info"); + return; + } + const selected = await ctx.ui.custom((tui, theme, _kb, done) => { + const o = new StashOverlay(tui, theme, items, done); + return { + render: (w) => o.render(w), + invalidate: () => o.invalidate(), + handleInput: (d) => o.handleInput(d), + }; + }, { + overlay: true, + overlayOptions: { + width: "90%", + minWidth: 60, + maxHeight: "85%", + anchor: "center", + backdrop: true, + }, + }); + if (selected) { + ctx.ui.setEditorText(selected); + ctx.ui.notify("Inserted prompt from history", "info"); + } +}