Commit fc1ad7ffb7b6
Changed files (1)
dots
pi
agent
extensions
ai-storage
dots/pi/agent/extensions/ai-storage/index.ts
@@ -26,10 +26,10 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
-import { Box, Markdown } from "@mariozechner/pi-tui";
+import { Box, Markdown, matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import { writeFile, mkdir, appendFile, readFile, readdir, unlink } from "node:fs/promises";
-import { existsSync, openSync } from "node:fs";
+import { existsSync, openSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir, hostname } from "node:os";
import { spawn } from "node:child_process";
@@ -1141,9 +1141,289 @@ After generating the summary, use the save_session_to_history tool to save it${c
},
});
- // Register /export-session command
- pi.registerCommand("export-session", {
- description: "Export a saved session to HTML. Usage: /export-session [path-to-summary.md] or interactive picker",
+ // ========================================================================
+ // Session picker overlay component
+ // ========================================================================
+
+ function collectSessionFiles(months: number = 3): SessionFile[] {
+ const allFiles: SessionFile[] = [];
+ const now = new Date();
+
+ for (let i = 0; i < months; i++) {
+ const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
+ const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
+ const sessionDir = join(SESSIONS_DIR, ym);
+
+ if (!existsSync(sessionDir)) continue;
+
+ const dirFiles = readdirSync(sessionDir);
+ for (const f of dirFiles) {
+ if (!f.endsWith(".md") || f.includes("session-log")) continue;
+ const fullPath = join(sessionDir, f);
+ const date = f.slice(0, 10);
+ const desc = f.slice(11).replace(".md", "").replace(/-/g, " ");
+ allFiles.push({ date, file: f, path: fullPath, desc });
+ }
+ }
+
+ // Newest first
+ allFiles.sort((a, b) => b.file.localeCompare(a.file));
+ return allFiles;
+ }
+
+ function fuzzyMatch(query: string, text: string): number {
+ const lq = query.toLowerCase();
+ const lt = text.toLowerCase();
+ if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50;
+
+ let score = 0, qi = 0, bonus = 0;
+ for (let i = 0; i < lt.length && qi < lq.length; i++) {
+ if (lt[i] === lq[qi]) { score += 10 + bonus; bonus += 5; qi++; }
+ else { bonus = 0; }
+ }
+ return qi === lq.length ? score : 0;
+ }
+
+ class SessionPickerComponent {
+ private readonly maxVisible = 20;
+ private allFiles: SessionFile[];
+ private filtered: SessionFile[];
+ private selected = 0;
+ private query = "";
+ private done: (result: string | null) => void;
+ private previewCache = new Map<string, string[]>();
+ private previewScroll = 0;
+
+ constructor(files: SessionFile[], done: (result: string | null) => void) {
+ this.allFiles = files;
+ this.filtered = files;
+ this.done = done;
+ }
+
+ handleInput(data: string): void {
+ if (matchesKey(data, "escape")) {
+ this.done(null);
+ return;
+ }
+
+ if (matchesKey(data, "return")) {
+ const entry = this.filtered[this.selected];
+ this.done(entry ? entry.path : null);
+ return;
+ }
+
+ if (matchesKey(data, "up")) {
+ if (this.filtered.length > 0) {
+ this.selected = this.selected === 0 ? this.filtered.length - 1 : this.selected - 1;
+ this.previewScroll = 0;
+ }
+ return;
+ }
+
+ if (matchesKey(data, "down")) {
+ if (this.filtered.length > 0) {
+ this.selected = this.selected === this.filtered.length - 1 ? 0 : this.selected + 1;
+ this.previewScroll = 0;
+ }
+ return;
+ }
+
+ if (matchesKey(data, "pageUp")) {
+ this.previewScroll = Math.max(0, this.previewScroll - this.maxVisible);
+ return;
+ }
+
+ if (matchesKey(data, "pageDown")) {
+ const entry = this.filtered[this.selected];
+ if (entry) {
+ const lines = this.getPreviewLines(entry.path);
+ const maxScroll = Math.max(0, lines.length - this.maxVisible);
+ this.previewScroll = Math.min(this.previewScroll + this.maxVisible, maxScroll);
+ }
+ return;
+ }
+
+ if (matchesKey(data, "backspace")) {
+ if (this.query.length > 0) {
+ this.query = this.query.slice(0, -1);
+ this.updateFilter();
+ }
+ return;
+ }
+
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
+ this.query += data;
+ this.updateFilter();
+ }
+ }
+
+ private updateFilter(): void {
+ if (!this.query.trim()) {
+ this.filtered = this.allFiles;
+ } else {
+ const scored = this.allFiles
+ .map((f) => ({ f, score: Math.max(fuzzyMatch(this.query, f.desc), fuzzyMatch(this.query, f.date)) }))
+ .filter((x) => x.score > 0)
+ .sort((a, b) => b.score - a.score);
+ this.filtered = scored.map((x) => x.f);
+ }
+ this.selected = 0;
+ this.previewScroll = 0;
+ }
+
+ private getPreviewLines(filePath: string): string[] {
+ if (this.previewCache.has(filePath)) {
+ return this.previewCache.get(filePath)!;
+ }
+ try {
+ const { readFileSync } = require("node:fs") as typeof import("node:fs");
+ let content = readFileSync(filePath, "utf-8");
+ // Strip ANSI escape sequences, OSC sequences, and other terminal controls
+ // that may be embedded in recovered session transcripts
+ content = content.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, ""); // OSC sequences
+ content = content.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); // CSI sequences
+ content = content.replace(/\x1b[^[\]]/g, ""); // Other ESC sequences
+ const lines = content.split("\n");
+ this.previewCache.set(filePath, lines);
+ return lines;
+ } catch {
+ const fallback = ["(unable to read file)"];
+ this.previewCache.set(filePath, fallback);
+ return fallback;
+ }
+ }
+
+ render(width: number): string[] {
+ // Layout: totalW includes outer borders
+ // │<leftW>│<rightW>│ = 3 border chars + leftW + rightW = totalW
+ const maxW = width; // hard limit — no line may exceed this
+ const totalW = Math.min(width - 2, 140);
+ const innerW = totalW - 2; // for full-width rows (outer borders only)
+ const leftW = Math.min(42, Math.floor(totalW * 0.35));
+ const rightW = totalW - 3 - leftW; // 3 = left border + middle border + right border
+ const lines: string[] = [];
+
+ const c = (code: string, text: string) => code ? `\x1b[${code}m${text}\x1b[0m` : text;
+ const border = (s: string) => c("2", s);
+ const accent = (s: string) => c("36", s);
+ const dim = (s: string) => c("2", s);
+ const muted = (s: string) => c("90", s);
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
+
+ // Truncate with ANSI awareness, then pad to exact width
+ const trunc = (s: string, w: number) => truncateToWidth(s, w, "…");
+ const fit = (s: string, w: number) => truncateToWidth(s, w, "…", true);
+
+ const splitRow = (left: string, right: string) =>
+ border("│") + fit(left, leftW) + border("│") + fit(right, rightW) + border("│");
+
+ const fullRow = (content: string) =>
+ border("│") + fit(content, innerW) + border("│");
+
+ // ── Top border ──
+ const titleText = " Export Session ";
+ const bLen = Math.max(0, innerW - visibleWidth(titleText));
+ const lB = Math.floor(bLen / 2);
+ lines.push(border("╭" + "─".repeat(lB)) + accent(titleText) + border("─".repeat(bLen - lB) + "╮"));
+
+ // ── Search bar (full width) ──
+ const prompt = accent("❯ ");
+ const queryDisplay = this.query || dim("Type to filter...");
+ lines.push(fullRow(` ${prompt}${queryDisplay}`));
+
+ // ── Divider with T-junction for middle column ──
+ lines.push(border("├" + "─".repeat(leftW) + "┬" + "─".repeat(rightW) + "┤"));
+
+ // ── Get preview content for selected entry ──
+ const selectedEntry = this.filtered[this.selected];
+ let previewLines: string[] = [];
+ if (selectedEntry) {
+ previewLines = this.getPreviewLines(selectedEntry.path);
+ }
+
+ // ── Body rows (list + preview side by side) ──
+ const startIndex = Math.max(
+ 0,
+ Math.min(this.selected - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible)
+ );
+
+ for (let i = 0; i < this.maxVisible; i++) {
+ // Left pane: session list
+ let leftContent = "";
+ const idx = startIndex + i;
+ if (idx < this.filtered.length) {
+ const entry = this.filtered[idx];
+ const isSel = idx === this.selected;
+ const prefix = isSel ? accent("▶ ") : " ";
+ const dateStr = dim(entry.date.slice(5)); // MM-DD to save space
+ const descStr = trunc(entry.desc, leftW - 10);
+ const name = isSel ? bold(accent(descStr)) : descStr;
+ leftContent = `${prefix}${dateStr} ${name}`;
+ } else if (i === 0 && this.filtered.length === 0) {
+ leftContent = dim(" No matches");
+ }
+
+ // Right pane: preview (with scroll offset)
+ let rightContent = "";
+ const previewIdx = i + this.previewScroll;
+ if (previewIdx < previewLines.length) {
+ const rawLine = previewLines[previewIdx].replace(/\t/g, " ");
+ const maxTxt = rightW - 2;
+ if (rawLine.startsWith("# ")) {
+ rightContent = " " + bold(accent(trunc(rawLine, maxTxt)));
+ } else if (rawLine.startsWith("## ")) {
+ rightContent = " " + accent(trunc(rawLine, maxTxt));
+ } else if (rawLine.match(/^\*\*\w/)) {
+ rightContent = " " + muted(trunc(rawLine, maxTxt));
+ } else if (rawLine.startsWith("- ")) {
+ rightContent = " " + trunc(rawLine, maxTxt);
+ } else {
+ rightContent = " " + dim(trunc(rawLine, maxTxt));
+ }
+ } else if (i === 0 && !selectedEntry) {
+ rightContent = dim(" No session selected");
+ }
+
+ lines.push(splitRow(leftContent, rightContent));
+ }
+
+ // ── Footer ──
+ lines.push(border("├" + "─".repeat(leftW) + "┴" + "─".repeat(rightW) + "┤"));
+ let countStr = "";
+ if (this.filtered.length > this.maxVisible) {
+ const shown = `${startIndex + 1}-${Math.min(startIndex + this.maxVisible, this.filtered.length)}`;
+ countStr = ` ${shown} of ${this.filtered.length}`;
+ } else if (this.filtered.length > 0) {
+ countStr = ` ${this.filtered.length} session${this.filtered.length === 1 ? "" : "s"}`;
+ }
+ const hints = "↑↓ navigate pgup/dn preview enter select esc cancel";
+ lines.push(fullRow(dim(countStr + " ".repeat(Math.max(2, innerW - visibleWidth(countStr) - visibleWidth(hints))) + hints)));
+
+ // ── Bottom border ──
+ lines.push(border(`╰${"─".repeat(innerW)}╯`));
+
+ return this.clampLines(lines, maxW);
+ }
+
+ // Safety: clamp every line to terminal width
+ private clampLines(lines: string[], maxW: number): string[] {
+ return lines.map((line) => {
+ if (visibleWidth(line) > maxW) {
+ return truncateToWidth(line, maxW, "…");
+ }
+ return line;
+ });
+ }
+
+ invalidate(): void {}
+ dispose(): void {}
+ }
+
+ // Register /session-export command
+ // NOTE: Cannot use "export-session" because the built-in /export handler
+ // catches anything starting with "/export" before extension commands run.
+ pi.registerCommand("session-export", {
+ description: "Export a saved session to HTML. Usage: /session-export [path] or interactive picker",
handler: async (args, ctx) => {
const argStr = (args || "").trim();
@@ -1153,20 +1433,19 @@ After generating the summary, use the save_session_to_history tool to save it${c
// Direct path provided
summaryPath = argStr;
} else {
- // Interactive: list recent sessions and let user pick
- const result = await listSessionsByDateRange("last 7 days", 30);
- if (!result || result.total === 0) {
- ctx.ui.notify("No recent sessions found", "info");
+ // Interactive overlay picker
+ const files = collectSessionFiles(3);
+ if (files.length === 0) {
+ ctx.ui.notify("No sessions found", "info");
return;
}
- const choices = result.files.map((f) => `${f.date} - ${f.desc}`);
- const selected = await ctx.ui.select("Select session to export:", choices);
- if (selected === undefined) return;
+ summaryPath = await ctx.ui.custom<string | null>(
+ (_tui, _theme, _kb, done) => new SessionPickerComponent(files, done),
+ { overlay: true }
+ );
- const idx = choices.indexOf(selected);
- if (idx < 0) return;
- summaryPath = result.files[idx].path;
+ if (!summaryPath) return;
}
if (!summaryPath || !existsSync(summaryPath)) {