diff --git a/docs/prompt-history.md b/docs/prompt-history.md new file mode 100644 index 000000000..f3866224d --- /dev/null +++ b/docs/prompt-history.md @@ -0,0 +1,125 @@ +# Prompt history + +Slice 1 of the prompt-history extension (#819 split) shipped the storage +layer: a per-instance JSONL capture store, project identity, and the +read/write primitives later slices build on. The selector UI and deletion +shipped in later slices; GC is the one part that still arrives later. + +## Capture is opt-in + +Recording is **off by default**. Delivered prompts can contain secrets, so +nothing is stored unless you explicitly opt in: + +```bash +GENTLE_PI_HISTORY_ENABLE=1 pi +``` + +- Enabled by `1`, `true`, or `on` (case-insensitive). Unset, empty, or any other + value means **off** — the same switch is the disable path. +- The check runs per prompt: unsetting the switch (or setting it to `0`) stops + new captures immediately, no pi restart needed. +- With capture off the extension is inert: no registry entry, no files, and + prompts are never written. + +## Legacy migration and seeding are opt-in + +Importing past prompts is part of capture: opening the history selector while +capture is enabled also migrates legacy editor-history stores and runs the +one-time seed bootstrap from past session transcripts. With capture off, the +selector warns and returns before any of that — no migration, no seed, no +store files. + +An import creates **new searchable copies** under `~/.pi/agent/history`. The +source transcripts stay untouched and read-only. Turning capture off again +does not remove copies that were already imported: delete them manually as +described in "What disabling capture does" below. + +## Where the files live + +Everything sits under `~/.pi/agent/history/`: + +- `registry.json` — advisory map of project hash → cwd, used for display + labels. +- `projects//.jsonl` — one append-only capture file per pi + process. + +`` is the first 16 hex chars of the SHA-256 of the canonicalized project +cwd; `` is a per-process UUID. Each line is one delivered prompt: + +```json +{"v":1,"text":"the prompt as delivered","ts":1700000000000} +``` + +UI command-like prompts (`/name ...`) and empty lines are never stored. Later +slices added the rebuildable `seed.jsonl` and the scope drains/deletes behind +the selector; GC is still to come. + +## Who can read them + +The store is plain JSONL on your local disk, not encrypted. Files are created by +the pi process with default umask permissions (typically `0644` files inside +`0755` directories), so any process running as your OS user can read them, and +other local accounts can too wherever they can traverse your home directory. +Treat the store as sensitive: it holds your prompts verbatim. + +## What disabling capture does + +Turning the switch off only stops **new** captures. Nothing is deleted: files +already written — and the registry entry — stay on disk until you remove them. +Individual prompts can be deleted from the history selector while capture is +on (see "Delete" below); the store directory itself is removed by hand: + +```bash +rm -rf ~/.pi/agent/history # whole store +rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) +``` + +## Delete + +The selector's delete key (`ctrl+shift+backspace`) is a two-step y/n +confirmation: + +1. The first press **arms** the delete for the selected row: the footer + shows "Delete this prompt from history (y/n)? Prompt stays in session + log" and the row highlights in red. +2. While armed, the next key decides: `y` executes the delete, `n` or + `Esc` cancels, and any other key is ignored — nothing is typed into the + search box and the overlay stays open. + +What a delete does depends on where the prompt came from: + +- **Editor-stored prompts** (captured into the store's `.jsonl` files) are + deleted: every stored copy is removed from the store in one atomic + rewrite per affected file. The session transcript keeps the original. +- **Session-derived prompts** (seeded from past transcripts) are + read-only: a delete press on them does nothing. Session transcripts are + immutable and owned by Pi core — the extension never writes them. + +Failures surface an error toast and never lie about state: a failed store +delete removes nothing and aborts ("Store delete failed; nothing was +removed."), while a failed tombstone write after a store delete leaves the +store row removed but the prompt may reappear from session transcripts +("Deleted from the store, but hiding failed — the prompt may reappear +from session transcripts."). + +The tombstone file (`hidden.json`) is a bounded cache, not a retention +guarantee: it holds at most **1000 keys** in recency order (oldest first, +newest last); hiding a 1001st prompt drops the oldest key, and that prompt +may reappear in the list and can be deleted again. The file still fails +closed: if `hidden.json` exists but cannot be trusted (unreadable, corrupt, +wrong shape), history is blocked with a recovery warning instead of +resurfacing hidden prompts, and deletes refuse to silently rewrite it. +Recovery is explicit — restore the file or delete it yourself (hidden +prompts may then reappear). + +## Compaction is not a retention limit + +When a project's store grows past the GC thresholds, compaction merges the +small capture files into fewer, larger ones and drops the oldest entries to +bound the file count and line count. This is housekeeping for performance: +it consolidates history but does not remove prompts from the resulting +store, and it is not a data-retention or automatic-deletion policy. + +Prompts leave the store only through the delete flow above (or by removing +the files manually). Compaction honors tombstones and never resurrects a +deleted prompt: deleted content stays deleted across compactions. diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 15822398f..6513e4f99 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -30,6 +30,7 @@ import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts"; import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts"; import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts"; import { installSessionChangeCapture } from "../lib/session-change-capture.ts"; +import { SelectionEngine } from "../lib/selection-engine.ts"; // Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the // status bar, the petal prompt, the working-tree changes widget and overlay, @@ -233,6 +234,11 @@ export class GentlePromptEditor extends CustomEditor { private animationPolicy: AnimationPolicy = "quality"; private pulse: NodeJS.Timeout | undefined; private readonly deps: PromptEditorDeps; + // Native selection engine (shift+home/end, alt+a, replace-on-key): ported + // from pi-select-del so the petal prompt owns the feature without factory + // composition. Constructed with `this`; the internals probe degrades to + // passthrough on pi drift, costing only the selection features. + private readonly selectionEngine: SelectionEngine; // CustomEditor keeps its own `keybindings` private, so this class holds // its own reference to run the same app.interrupt match before deciding // whether to swallow the keystroke. @@ -246,6 +252,7 @@ export class GentlePromptEditor extends CustomEditor { constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) { super(tui, theme, keybindings); + this.selectionEngine = new SelectionEngine(this); this.deps = deps; this.keybindingsManager = keybindings; } @@ -294,6 +301,13 @@ export class GentlePromptEditor extends CustomEditor { * and never reach this branch. */ override handleInput(data: string): void { + // Selection keys (shift+home/end, alt+a, replace-on-selection) are + // dispatched here, in front of the petal's own chain; everything else + // collapses any active selection and flows into handleInputNative. + this.selectionEngine.handleInput(data, (d) => this.handleInputNative(d)); + } + + private handleInputNative(data: string): void { // Any keystroke that is not the confirming Esc ends the pending idle // clear, even one that leaves the text identical (type, then delete). if (this.pendingIdleClearDeadline !== undefined && !this.keybindingsManager.matches(data, "app.interrupt")) { @@ -387,12 +401,16 @@ export class GentlePromptEditor extends CustomEditor { } render(width: number): string[] { - const lines = super.render(Math.max(1, width - 2)); + const inner = Math.max(1, width - 2); + const lines = super.render(inner); if (this.getText() === "" && lines.length === 3) lines[1] = withPromptHint(lines[1], PROMPT_HINT, this.deps.fg); + // Native selection highlight on the content rows (before the frame walls + // are added); the selection hint rides the petal's bottom rule below. + const decorated = this.selectionEngine.decorateRows(lines, inner, 0); const state = this.promptState === PROMPT_STATE.WORKING && this.deps.pending() ? PROMPT_STATE.QUEUED : this.promptState; // The frame keeps the theme's border color rather than pi's thinking-level // color, so the prompt reads as one panel with the cards around it. - return framePromptLines(lines, width, { + const rows = framePromptLines(decorated, width, { state, tick: this.tick, borderColor: (text) => this.deps.fg(PROMPT_FRAME_ROLE, text), @@ -404,6 +422,8 @@ export class GentlePromptEditor extends CustomEditor { ? IDLE_ESC_CLEAR_HINT : undefined, }); + rows[rows.length - 1] = this.selectionEngine.decorateBottomRule(rows[rows.length - 1] ?? "", width, "╯"); + return rows; } dispose(): void { diff --git a/extensions/history/atomic-write.ts b/extensions/history/atomic-write.ts new file mode 100644 index 000000000..cc6ae8147 --- /dev/null +++ b/extensions/history/atomic-write.ts @@ -0,0 +1,38 @@ +import path from "node:path"; +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +import fs from "node:fs"; + +/** + * Shared atomic JSON writer (design §D3): serialize to a `.tmp` file in the + * SAME directory as the target, then renameSync it into place — a same-dir + * rename is atomic on POSIX/APFS, so readers see the old or the new file, + * never a partial write. Any error returns false and never throws. + * + * No fsync: both consumers treat lost writes as derived cache (a lost index + * rebuilds on the next open; a lost tombstone resurfaces a prompt the user + * can re-delete), so the per-write fsync cost is not justified — the crash + * window is documented, not fixed. The staging name is unique per write + * (`.tmp--`, the same convention as the store.ts writers): the + * state dir is shared across concurrent pi instances, so a fixed + * `${filePath}.tmp` would let two writers clobber the same staging file + * (torn target JSON, spurious rename failures). A failed write unlinks its + * staging file, so orphaned `.tmp` files do not accumulate. + */ +export function writeJsonAtomic(filePath: string, value: unknown): boolean { + const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(tmpPath, JSON.stringify(value), "utf8"); + fs.renameSync(tmpPath, filePath); + return true; + } catch { + try { + fs.unlinkSync(tmpPath); + } catch { + // staging file never created or already renamed + } + return false; + } +} diff --git a/extensions/history/hide-prompts.ts b/extensions/history/hide-prompts.ts new file mode 100644 index 000000000..08d39eb7e --- /dev/null +++ b/extensions/history/hide-prompts.ts @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +import fs from "node:fs"; +import path from "node:path"; +import { writeJsonAtomic } from "./atomic-write.ts"; +import { promptDedupKey } from "./selector-helpers.ts"; + +/** Name of the tombstone file inside the injected state dir (spec C4). */ +const HIDE_FILE_NAME = "hidden.json"; + +/** + * Retention cap for hidden.json (slice-05 D5): the tombstone file is a + * rebuildable derived cache, not a retention guarantee, so it holds at + * most this many keys in recency order; hiding past the cap drops the + * OLDEST keys from the front. + */ +export const HIDE_FILE_MAX_ENTRIES = 1000; + +/** + * Shared recovery warning for a file that exists but cannot be trusted + * (spec C4, fail-closed READ half): toast-suitable, names hidden.json, and + * gives the user the explicit restore-or-delete choice. + */ +const RECOVERY_MESSAGE = + "The prompt-history hide list (hidden.json) is corrupt or unreadable. History is blocked until you restore the file or delete it (hidden prompts may then reappear)."; + +/** + * Result of one tombstone write (spec C4): `written` on a successful atomic + * write, or an error object carrying a short, toast-suitable reason. Never + * throws. + */ +export type HideResult = + | { status: "written" } + | { status: "error"; message: string }; + +/** + * Result of one tombstone read (spec C4): `trusted` keys when the file is + * missing or holds a valid array, or `untrusted` when the file exists but + * cannot be trusted. History reads FAIL CLOSED on `untrusted`: callers must + * block the drain instead of emptying the tombstone set, because hidden + * prompts may contain secrets an empty set would resurface. + */ +export type HiddenRead = + | { status: "trusted"; keys: Set } + | { + status: "untrusted"; + reason: "unreadable" | "corrupt" | "malformed"; + message: string; + }; + +/** + * Read the tombstone key set from `stateDir/hidden.json` — the READ half of + * the hide-file contract (spec C4). Fail-closed for history: a file that + * exists but is unreadable, corrupt, or wrong-shaped returns `untrusted` + * with the recovery warning so callers block the drain; it never degrades + * to an empty trusted set. A MISSING file — before any deletion — is the + * safe empty case and reads `trusted` with no keys. A valid array is + * trusted; junk items inside it are ignored, never trusted. Keys are + * `promptDedupKey` strings written by `hidePrompt`; the call never throws. + * A valid array's stored order is preserved (the recency order — oldest + * first — that `hidePrompt` maintains and caps). + */ +export function readHiddenPrompts(stateDir: string): HiddenRead { + let raw: string; + try { + raw = fs.readFileSync(path.join(stateDir, HIDE_FILE_NAME), "utf8"); + } catch (error) { + const code = (error as { code?: unknown } | null | undefined)?.code; + if (code === "ENOENT") { + // Missing before any deletion: the safe empty tombstone set. + return { status: "trusted", keys: new Set() }; + } + return { + status: "untrusted", + reason: "unreadable", + message: RECOVERY_MESSAGE, + }; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { status: "untrusted", reason: "corrupt", message: RECOVERY_MESSAGE }; + } + const keys = new Set(); + if (!Array.isArray(parsed)) { + return { + status: "untrusted", + reason: "malformed", + message: RECOVERY_MESSAGE, + }; + } + for (const item of parsed) { + if (typeof item === "string" && item !== "") keys.add(item); + } + return { status: "trusted", keys }; +} + +/** + * Write the tombstone key for `text` into `stateDir/hidden.json` — the + * WRITE half of the hide-file contract (spec C4). The key is the shared + * `promptDedupKey` (byte-match normative with the merge filter — never a + * re-implementation). The file array is RECENCY-ordered — oldest key + * first, newest key appended last — and re-hiding an existing key + * refreshes it to the end (delete + add, since Set.add on a present + * member keeps its old position). The file is capped at + * `HIDE_FILE_MAX_ENTRIES` (1000): after the append, keys drop from the + * FRONT until the file fits, so hidden.json stays a bounded cache — a + * dropped (oldest) prompt may reappear in the list and can be deleted + * again. Keys persist in that insertion order — NO sort — via the shared + * atomic tmp+rename writer. An untrusted existing file is never silently + * reset (a clean rewrite would clear the blocked state one hide later): + * hidePrompt refuses with the recovery warning until the user restores or + * deletes the file. A missing file is the clean baseline; any write + * failure returns an error object for the delete-flow toast; the call + * never throws. + */ +export function hidePrompt(stateDir: string, text: string): HideResult { + const read = readHiddenPrompts(stateDir); + if (read.status === "untrusted") { + // Refuse without writing: never reset the untrusted state silently. + return { status: "error", message: read.message }; + } + // Recency order (slice-05 D5): the set iterates in stored file order + // (oldest first); delete+add refreshes a re-hidden key to the END. + const key = promptDedupKey(text); + read.keys.delete(key); + read.keys.add(key); + // Cap: drop the OLDEST keys from the front once over the limit. + const ordered = [...read.keys]; + if (ordered.length > HIDE_FILE_MAX_ENTRIES) { + ordered.splice(0, ordered.length - HIDE_FILE_MAX_ENTRIES); + } + const written = writeJsonAtomic( + path.join(stateDir, HIDE_FILE_NAME), + ordered, + ); + return written + ? { status: "written" } + : { + status: "error", + message: "Could not write the hide file; the prompt may reappear.", + }; +} diff --git a/extensions/history/index.ts b/extensions/history/index.ts new file mode 100644 index 000000000..5a79c4483 --- /dev/null +++ b/extensions/history/index.ts @@ -0,0 +1,1246 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +// Prompt-history extension entry (slice 3): the selector TUI, overlay glue, +// and the shortcut/command wiring over the slice-1 writer, slice-2 drains, +// and slice-4 init sequence (legacy migration + seed bootstrap run once +// inside getWriter). Deletion (slice 5) and GC/compaction (slice 6) are +// wired here. +// +// Capture is OPT-IN: nothing is recorded unless +// GENTLE_PI_HISTORY_ENABLE=1|true|on. With the switch off the handler is a +// no-op — no registry entry, no files, and prompts are never written. +// Unsetting the switch only stops NEW captures; files already written stay +// on disk (docs/prompt-history.md). + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + DynamicBorder, + type ExtensionAPI, + type ExtensionCommandContext, + type Theme, +} from "@earendil-works/pi-coding-agent"; +import { + Container, + type Focusable, + getKeybindings, + Input, + matchesKey, + stripTerminalSequences, + Text, + type TUI, + type TuiMouseEvent, + truncateToWidth, +} from "@earendil-works/pi-tui"; +import { hidePrompt } from "./hide-prompts.ts"; +import { + buildPromptRecords, + clampPreviewOffset, + clampSelectedIndex, + deleteConfirmFooterText, + deleteConfirmStep, + dedupePromptEntries, + deletionActionsFor, + EDITOR_HIDE_FAILED_TEXT, + filterPrompts, + getVisiblePromptRecords, + initialLoadedCount, + loadedCountAfterDelete, + loadedCountForQuery, + loadedCountForTarget, + moveSelectedIndex, + nextLoadedCount, + type PiHistoryGlobals, + type PromptEntry, + type PromptRecord, + pageSelectedIndex, + shouldGrowWindow, + STORE_DELETE_FAILED_TEXT, + withExpandedHistoryGlobals, +} from "./selector-helpers.ts"; +import { + appendSessionCapture, + bootstrapProjectSeed, + deleteFromGlobal, + deleteFromProject, + drainGlobal, + drainProject, + ensureRegistryEntry, + gcProjectDir, + migrateLegacyStores, + openSessionWriter, + type DrainResult, + type SessionWriterState, +} from "./store.ts"; +import { randomUUID } from "node:crypto"; + +const SHORTCUT = "ctrl+shift+r"; +const MAX_VISIBLE = 10; +const PREVIEW_ROWS = 10; +// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=3 +// fires growth as the cursor enters the final 3 loaded rows; BATCH_SIZE=10 +// loads exactly one viewport per growth; INITIAL_BATCH=10 paints one +// viewport at open. PRELOAD_BUFFER <= MAX_VISIBLE keeps a jump within one +// viewport covered by the catch-up loop; review all three together. +const INITIAL_BATCH = 10; +const BATCH_SIZE = 10; +const PRELOAD_BUFFER = 3; +// Wheel regions over the fixed 30-row overlay geometry (design §D6): the +// list container renders at rows 5-14 and the preview container at rows +// 17-26; every other row is a consumed no-op. +const LIST_WHEEL_Y_FIRST = 5; +const LIST_WHEEL_Y_LAST = 14; +const PREVIEW_WHEEL_Y_FIRST = 17; +const PREVIEW_WHEEL_Y_LAST = 26; + +// Default selector footer line (PR #1393): shown whenever a delete is not +// armed; the armed state swaps it for the confirmation copy. +const SELECTOR_FOOTER_HELP = + "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • ctrl+shift+backspace delete • esc cancel"; + +// Legacy agent dir: pre-v1 editor-history files live directly here and are +// migrated into the store root by migrateLegacyStores(). +const AGENT_DIR = join(homedir(), ".pi", "agent"); +// v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). +const PI_HISTORY_ROOT = join(AGENT_DIR, "history"); +const CURRENT_CWD = process.cwd(); +// Instance identity: one exclusive capture file per pi process. +const INSTANCE_ID = randomUUID(); + +// State dir for the session index and the tombstone file (spec C2/C4, +// design §D5). Derived state only — deleting the directory restores cold +// start and unhides every prompt; transcripts and the editor store are +// never written here. +// Tombstone state dir: the store root itself (user-directed FINAL): +// ~/.pi/agent/history/hidden.json — one directory for everything. +const PI_HISTORY_NAV_STATE_DIR = join(homedir(), ".pi", "agent", "history"); + +// Sessions root for the one-level transcript scan (spec C1, design §D5): +// ~/.pi/agent/sessions/. Read-only by invariant — transcripts are never +// written by this extension. +const SESSIONS_ROOT = join(homedir(), ".pi", "agent", "sessions"); + +/** Width of the "→ " / " " prefix on each entry line. */ +const ENTRY_PREFIX_WIDTH = 2; + +// --------------------------------------------------------------------------- +// Sanitization +// --------------------------------------------------------------------------- + +/** + * Replace control characters with visible escape notation so the terminal + * renders them as text instead of interpreting them as commands. + * Preserves \n (newlines) and \t (tabs). + */ +function sanitizeForDisplay(text: string): string { + let out = ""; + for (let i = 0; i < text.length; i++) { + const cp = text.codePointAt(i); + if (cp === undefined) break; + if (cp === 0x0a) { + out += "\n"; + } else if (cp === 0x09) { + out += "\t"; + } else if (cp < 0x20 || cp === 0x7f) { + out += `\\x${cp.toString(16).padStart(2, "0")}`; + } else if (cp >= 0x80 && cp < 0xa0) { + out += `\\x${cp.toString(16).padStart(2, "0")}`; + } else { + // Astral code points (> 0xFFFF) span a surrogate pair; append the + // full code point, not just the high surrogate at text[i], so emoji + // and other non-BMP characters survive sanitization intact. + out += cp > 0xffff ? String.fromCodePoint(cp) : text[i]; + } + if (cp > 0xffff) i++; // skip low surrogate of astral pair + } + return out; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Keybinding lookup returned by getKeybindings(). */ +interface Keybindings { + matches(data: string, action: string): boolean; +} + +type InputMatcher = (data: string, kb: Keybindings) => boolean; +type InputHandler = () => void; + +interface DispatchEntry { + match: InputMatcher; + handler: InputHandler; +} + +/** Notification sink for selector feedback; an absent callback drops notifications. */ +type SelectorNotify = ( + message: string, + level: "error" | "warning" | "info", +) => void; + +/** Single rendered row; always occupies exactly one terminal row. */ +class FixedRowText { + private text: string = ""; + private readonly centered: boolean; + constructor(text: string = "", centered = false) { + this.text = text; + this.centered = centered; + } + + /** Replace the row content in place; padding contract comes from render(). */ + setText(next: string): void { + this.text = next; + } + + invalidate(): void {} + + render(width: number): string[] { + if (width <= 0) return [" "] as string[]; + if (this.text.length === 0) { + // Use a space so the terminal always renders this as a visible row + // and differential rendering correctly detects it as a changed line. + return [" ".repeat(width)] as string[]; + } + const rendered = this.centered + ? (() => { + // Truncate first so an overlong help row can never exceed width, + // then center the truncated copy (design §C hardening). + const truncated = truncateToWidth(this.text, width, "…"); + const visible = stripTerminalSequences(truncated); + const pad = Math.max(0, Math.floor((width - visible.length) / 2)); + return " ".repeat(pad) + truncated; + })() + : truncateToWidth(this.text, width, "…"); + // Pad to full terminal width so the overlay fully overwrites + // whatever is beneath it and leaves no ghost characters on dismiss. + // Measure the VISIBLE width: SGR escape sequences (colored rows from + // rebuildListWithWidth) occupy no terminal cells. + const visible = rendered.replace(/\x1b\[[0-9;]*m/g, ""); + return [rendered + " ".repeat(Math.max(0, width - visible.length))]; + } +} + +/** Word-wrap plain text so each line fits within maxWidth characters. */ +function wordWrapText(text: string, maxWidth: number): string[] { + if (maxWidth <= 0) return [text || " "]; + const paragraphs = text.split("\n"); + const result: string[] = []; + for (const para of paragraphs) { + if (para.length === 0) { + result.push(""); + continue; + } + let remaining = para; + while (remaining.length > 0) { + if (remaining.length <= maxWidth) { + result.push(remaining); + break; + } + const breakAt = remaining.lastIndexOf(" ", maxWidth); + if (breakAt <= 0) { + result.push(remaining.substring(0, maxWidth)); + remaining = remaining.substring(maxWidth); + } else { + result.push(remaining.substring(0, breakAt)); + remaining = remaining.substring(breakAt + 1); + } + } + } + return result.length > 0 ? result : [""]; +} + +// --------------------------------------------------------------------------- +// TUI Selector +// --------------------------------------------------------------------------- + +class PromptHistorySelector extends Container implements Focusable { + private readonly searchInput: Input; + private readonly previewContainer: Container; + private readonly listContainer: Container; + private readonly headerRow: FixedRowText; + private readonly previewLabelRow: FixedRowText; + private readonly footerRow: FixedRowText; + private records: PromptRecord[]; + private readonly theme: Theme; + private readonly tui: TUI; + private readonly onSelect: (record: PromptRecord) => void; + private readonly onCancel: () => void; + private filteredRecords: PromptRecord[] = []; + private selectedIndex = 0; + /** Number of records loaded (newest-first) from the top of `records`. */ + private loadedCount = 0; + /** Active scope (design v2): project (default) or global. */ + private scope: "project" | "global" = "project"; + /** Last render width, used for entry truncation. */ + private lastWidth = 800; + /** Word-wrapped lines of the currently selected prompt. */ + private wrappedPreviewLines: string[] = []; + /** Scroll offset into wrappedPreviewLines for the preview viewport. */ + private previewScrollOffset = 0; + /** + * Modal delete confirmation (PR #1393 follow-up): armed by the first + * ctrl+shift+backspace press; while armed, y executes, n/Esc cancels, + * and every other key is swallowed. Nothing is deleted on the arming + * press. + */ + private readonly onNotify?: SelectorNotify; + private confirmArmed = false; + + /** Dispatch table: first match wins, fallthrough last. */ + private readonly dispatch: readonly DispatchEntry[] = [ + { + match: (_d, kb) => kb.matches(_d, "tui.select.up"), + handler: () => this.moveUp(), + }, + { + match: (_d, kb) => kb.matches(_d, "tui.select.down"), + handler: () => this.moveDown(), + }, + { + match: (_d, kb) => kb.matches(_d, "tui.select.pageUp"), + handler: () => this.pageListUp(), + }, + { + match: (_d, kb) => kb.matches(_d, "tui.select.pageDown"), + handler: () => this.pageListDown(), + }, + { + match: (d, kb) => d === "\r" || kb.matches(d, "tui.select.confirm"), + handler: () => this.selectCurrent(), + }, + { match: (d, _kb) => d === "\t", handler: () => this.toggleScope() }, + { + match: (_d, kb) => kb.matches(_d, "tui.select.cancel"), + handler: () => this.onCancel(), + }, + { + match: (d, _kb) => matchesKey(d, "home"), + handler: () => this.jumpToFirst(), + }, + { + match: (d, _kb) => matchesKey(d, "end"), + handler: () => this.jumpToLast(), + }, + { + match: (d, _kb) => matchesKey(d, "ctrl+shift+backspace"), + handler: () => this.deleteCurrent(), + }, + { + match: (d, _kb) => matchesKey(d, "ctrl+shift+up"), + handler: () => this.previewPageUp(), + }, + { + match: (d, _kb) => matchesKey(d, "ctrl+shift+down"), + handler: () => this.previewPageDown(), + }, + ]; + + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + + constructor( + tui: TUI, + theme: Theme, + records: PromptRecord[], + onSelect: (record: PromptRecord) => void, + onCancel: () => void, + onNotify?: SelectorNotify, + ) { + super(); + + this.tui = tui; + this.theme = theme; + this.records = records; + this.loadedCount = initialLoadedCount(records.length, INITIAL_BATCH); + this.onSelect = onSelect; + this.onCancel = onCancel; + this.onNotify = onNotify; + + // ── Search panel (top) ── + this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + this.headerRow = new FixedRowText( + theme.fg("accent", theme.bold(" History Search ")), + ); + this.addChild(this.headerRow); + this.addChild( + new Text( + theme.fg("dim", "Type to filter (multi-word AND substring, case-insensitive)"), + 0, + 0, + ), + ); + this.searchInput = new Input(); + this.searchInput.onSubmit = () => this.selectCurrent(); + this.searchInput.onEscape = () => this.onCancel(); + this.addChild(this.searchInput); + this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); + + this.listContainer = new Container(); + this.addChild(this.listContainer); + + // ── Preview panel (bottom) ── + this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + this.previewLabelRow = new FixedRowText( + theme.fg("accent", theme.bold(" Preview ")), + ); + this.addChild(this.previewLabelRow); + this.previewContainer = new Container(); + this.addChild(this.previewContainer); + + this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); + this.footerRow = new FixedRowText( + theme.fg("dim", SELECTOR_FOOTER_HELP), + true /* centered */, + ); + this.addChild(this.footerRow); + this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + this.applyFilter(""); + } + + // -- Filtering & list building ------------------------------------------ + + private applyFilter(query: string): void { + // AC-L2-3r (user-directed 2026-09-08): a non-empty query implies + // full-snapshot visibility — one-shot and idempotent, never a batch — + // so per-keypress incremental loads remain impossible (C2). + this.loadedCount = loadedCountForQuery( + this.loadedCount, + this.records.length, + query, + ); + this.filteredRecords = filterPrompts( + this.records.slice(0, this.loadedCount), + query, + ); + this.selectedIndex = clampSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + ); + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + private rebuildList(): void { + this.rebuildListWithWidth(this.lastWidth); + } + + /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows. */ + private rebuildListWithWidth(width: number): void { + const count = this.filteredRecords.length; + const position = count === 0 ? 0 : this.selectedIndex + 1; + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(" History Search ")) + + this.theme.fg("dim", ` · ${position} of ${count} `) + + this.theme.fg( + "dim", + ` · loaded ${this.loadedCount} of ${this.records.length} `, + ) + + // Right-aligned scope radio: pad from plain-text lengths so the + // radio ends flush at the header's last column at any width. + (() => { + const scopeRadio = + this.scope === "project" + ? "◉ Current project | ○ All projects" + : "○ Current project | ◉ All projects"; + const leftWidth = + " History Search ".length + + ` · ${position} of ${count} `.length + + ` · loaded ${this.loadedCount} of ${this.records.length} `.length; + return ( + " ".repeat(Math.max(1, width - leftWidth - scopeRadio.length)) + + this.theme.fg("dim", scopeRadio) + ); + })(), + ); + this.listContainer.clear(); + + if (count === 0) { + this.listContainer.addChild( + new FixedRowText(this.theme.fg("warning", "No matching prompts")), + ); + for (let i = 1; i < MAX_VISIBLE; i++) { + this.listContainer.addChild(new FixedRowText()); + } + return; + } + + const entryMax = Math.floor(width * 0.95) - ENTRY_PREFIX_WIDTH; + + const visible = getVisiblePromptRecords( + this.filteredRecords, + this.selectedIndex, + MAX_VISIBLE, + ); + + for (const { record, isSelected } of visible) { + const prefix = isSelected ? "→ " : " "; + // Armed delete (PR #1393): the armed row repaints in the error color + // while the confirmation is pending, then reverts on disarm. + const color = isSelected + ? this.confirmArmed + ? "error" + : "accent" + : "text"; + const compacted = sanitizeForDisplay(record.text) + .replace(/\s+/g, " ") + .trim(); + const truncated = truncateToWidth(compacted, entryMax, "…"); + const line = prefix + this.theme.fg(color, truncated); + this.listContainer.addChild(new FixedRowText(line)); + } + + for (let i = visible.length; i < MAX_VISIBLE; i++) { + this.listContainer.addChild(new FixedRowText()); + } + } + + /** + * Rebuild preview: word-wrap the full selected prompt text and show + * a PREVIEW_ROWS-tall viewport starting at previewScrollOffset. + * Content starts immediately below the "Preview" label (no top padding). + * PgUp/PgDn scroll through the wrapped lines. + */ + private rebuildPreviewWithWidth(width: number): void { + this.previewContainer.clear(); + + const wrapWidth = Math.max(1, width - 2); + const selected = this.filteredRecords[this.selectedIndex]; + if (selected) { + const safeText = sanitizeForDisplay(selected.text); + this.wrappedPreviewLines = wordWrapText(safeText, wrapWidth); + this.previewScrollOffset = clampPreviewOffset( + this.previewScrollOffset, + this.wrappedPreviewLines.length, + PREVIEW_ROWS, + ); + } else { + this.wrappedPreviewLines = []; + this.previewScrollOffset = 0; + } + + // P1-3 indicator: fresh wrap is known here — one update site covers all + // paths; the label appends the 1-based range only when content overflows. + this.previewLabelRow.setText(this.previewLabelRowText()); + + for (let i = 0; i < PREVIEW_ROWS; i++) { + const lineIdx = this.previewScrollOffset + i; + if (lineIdx < this.wrappedPreviewLines.length) { + // Pad the plain text to wrapWidth so FixedRowText.render() + // never truncates — the visible width is always ≤ width-2. + const raw = this.wrappedPreviewLines[lineIdx]; + const padded = raw + " ".repeat(Math.max(0, wrapWidth - raw.length)); + this.previewContainer.addChild( + new FixedRowText(this.theme.fg("text", padded)), + ); + } else { + this.previewContainer.addChild(new FixedRowText()); + } + } + } + + /** " Preview " label; appends the 1-based visible range only on overflow. */ + private previewLabelRowText(): string { + const total = this.wrappedPreviewLines.length; + if (total <= PREVIEW_ROWS) { + return this.theme.fg("accent", this.theme.bold(" Preview ")); + } + const start = this.previewScrollOffset + 1; + const end = Math.min(this.previewScrollOffset + PREVIEW_ROWS, total); + return this.theme.fg( + "accent", + this.theme.bold(` Preview — ${start}–${end}/${total} `), + ); + } + + private rebuildPreview(): void { + this.rebuildPreviewWithWidth(this.lastWidth); + } + + // -- Selection actions -------------------------------------------------- + + private selectCurrent(): void { + const selected = this.filteredRecords[this.selectedIndex]; + if (selected) this.onSelect(selected); + } + + /** + * Toggle project <-> global (design v2): re-drain the other scope, + * rebuild the merged records, reset the window. Tab's only role. + */ + private toggleScope(): void { + const previous = this.scope; + this.scope = this.scope === "project" ? "global" : "project"; + const entries = drainForScope(this.scope); + if (!Array.isArray(entries)) { + // Fail-closed drain (spec C4): stay on the working scope and surface + // the recovery warning instead of a blocked (entry-less) list. + this.scope = previous; + this.onNotify?.(entries.message, "error"); + return; + } + this.records = recordsFromEntries(entries); + this.loadedCount = initialLoadedCount(this.records.length, INITIAL_BATCH); + this.applyFilter(this.searchInput.getValue()); + } + + /** + * Delete-combo entry (slice-05 D3): the FIRST press arms the modal + * confirm for the selected row; while armed, the modal router in + * handleInput calls executeDelete() on `y`. Session-derived rows are + * read-only (slice-05 D1): a delete press on one is a silent no-op. + */ + private deleteCurrent(): void { + const selected = this.filteredRecords[this.selectedIndex]; + if (!selected) return; + + // Session rows are read-only: session transcripts are immutable and + // owned by Pi core — the extension never deletes from or writes to + // them. Silent no-op: no arm, no footer change, no tombstone. + if ((selected.source ?? "editor") === "session") return; + + if (!this.confirmArmed) { + this.armDelete(); + return; + } + this.executeDelete(); + } + + /** Arm the confirm: footer copy + error-colored row, nothing executes. */ + private armDelete(): void { + this.confirmArmed = true; + this.refreshDeleteFooter(); + this.rebuildList(); // repaint the armed-row highlight + } + + /** The executing half of the delete: leave the armed state, then mutate. */ + private executeDelete(): void { + // Leave the armed state first: help footer back, highlight dropped. + this.confirmArmed = false; + this.refreshDeleteFooter(); + this.rebuildList(); // drop the highlight before the flow mutates rows + + const selected = this.filteredRecords[this.selectedIndex]; + if (!selected) return; + + // C4 delete flows (design §F): the record's provenance decides the + // actions via the pure planner; module constants are used directly. + const actions = deletionActionsFor(selected.source ?? "editor"); + + if (actions.deleteFromEditorStore) { + // Store path: physically remove EVERY copy from the JSONL store + // (memory + file in one atomic rewrite). A thrown store failure is + // contained here (PR #1393): toast + abort — nothing was removed and + // no tombstone is written, so the delete never lies about state. + let removed: number; + try { + ({ removed } = + this.scope === "global" + ? deleteFromGlobal(PI_HISTORY_ROOT, selected.text) + : deleteFromProject(PI_HISTORY_ROOT, CURRENT_CWD, selected.text)); + } catch { + this.onNotify?.(STORE_DELETE_FAILED_TEXT, "error"); + return; + } + if (removed === 0) return; + } + + // Tombstone ALWAYS: the session transcripts are immutable and would + // re-supply the deleted prompt on the next merge (hide-file suppresses + // the twin). Only the session path aborts on a hide error — the store + // row is already gone on the editor path, so the splice proceeds; its + // toast says exactly that (PR #1393). + const hide = hidePrompt(PI_HISTORY_NAV_STATE_DIR, selected.text); + if (hide.status === "error") { + if (!actions.deleteFromEditorStore) { + this.onNotify?.(hide.message, "error"); + return; + } + this.onNotify?.(EDITOR_HIDE_FAILED_TEXT, "error"); + } + // Remove from the master records array so a subsequent filter doesn't + // bring it back. + const idx = this.records.indexOf(selected); + if (idx !== -1) { + this.records.splice(idx, 1); + // C4 delete backfill (design §B3): shrink the window with the splice, + // then pull the next unloaded row while any remain — genuine shrink + // only at exhaustion. + this.loadedCount = loadedCountAfterDelete( + this.loadedCount, + this.records.length, + ); + } + + // Re-apply current filter (rebuilds filteredRecords, list, preview). + this.applyFilter(this.searchInput.getValue()); + } + + /** Footer line: confirm copy while armed, help otherwise. */ + private refreshDeleteFooter(): void { + if (!this.confirmArmed) { + this.footerRow.setText(this.theme.fg("dim", SELECTOR_FOOTER_HELP)); + return; + } + this.footerRow.setText( + this.theme.fg("warning", deleteConfirmFooterText()), + ); + } + + /** Leave the armed state: restore the help footer and the plain row. */ + private disarmDeleteConfirm(): void { + this.confirmArmed = false; + this.refreshDeleteFooter(); + this.rebuildList(); + } + + /** + * Lazy-load growth shared by moveUp/moveDown (design §D1): when the cursor + * sits in the final PRELOAD_BUFFER rows of the loaded window, grow via + * nextLoadedCount and re-apply the filter so fresh rows become visible. + */ + private growLoadedWindowIfNeeded(): void { + if ( + shouldGrowWindow( + this.selectedIndex, + this.loadedCount, + this.records.length, + PRELOAD_BUFFER, + ) + ) { + this.loadedCount = nextLoadedCount( + this.loadedCount, + this.records.length, + BATCH_SIZE, + ); + this.applyFilter(this.searchInput.getValue()); + } + } + + // -- Navigation --------------------------------------------------------- + + private moveUp(): void { + this.selectedIndex = moveSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + -1, + ); + this.growLoadedWindowIfNeeded(); + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + private moveDown(): void { + // Grow-before-move (design §D1): the C2 trigger fires while the cursor + // sits in the final PRELOAD_BUFFER rows of the loaded window, so the + // modulo below moves into freshly loaded rows — a wrap to index 0 is + // reachable only on the exhausted set. + this.growLoadedWindowIfNeeded(); + this.selectedIndex = moveSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + 1, + ); + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + /** Page the LIST up by MAX_VISIBLE with clamping (no wrap). */ + private pageListUp(): void { + this.selectedIndex = pageSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + -MAX_VISIBLE, + ); + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + /** Page the LIST down by MAX_VISIBLE with clamping (no wrap). */ + private pageListDown(): void { + // PgDn catch-up (design §D7): grow in whole batches until the paged-to + // row is loaded BEFORE the selection lands on it. + const grown = loadedCountForTarget( + this.loadedCount, + this.records.length, + this.selectedIndex + MAX_VISIBLE, + BATCH_SIZE, + ); + if (grown !== this.loadedCount) { + this.loadedCount = grown; + this.applyFilter(this.searchInput.getValue()); + } + this.selectedIndex = pageSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + MAX_VISIBLE, + ); + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + private previewPageUp(): void { + this.previewScrollOffset = Math.max( + 0, + this.previewScrollOffset - PREVIEW_ROWS, + ); + this.rebuildPreview(); + } + + private previewPageDown(): void { + this.previewScrollOffset = clampPreviewOffset( + this.previewScrollOffset + PREVIEW_ROWS, + this.wrappedPreviewLines.length, + PREVIEW_ROWS, + ); + this.rebuildPreview(); + } + + private jumpToFirst(): void { + if (this.filteredRecords.length === 0) return; + this.selectedIndex = 0; + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + private jumpToLast(): void { + // End full-jump (design §D7): one-shot load of everything BEFORE the + // empty guard, so End also surfaces matches beyond the window. + if (this.loadedCount < this.records.length) { + this.loadedCount = this.records.length; + this.applyFilter(this.searchInput.getValue()); + } + if (this.filteredRecords.length === 0) return; + this.selectedIndex = this.filteredRecords.length - 1; + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + // -- Input handling ----------------------------------------------------- + + private forwardToSearch(data: string): void { + this.searchInput.handleInput(data); + this.selectedIndex = 0; + this.applyFilter(this.searchInput.getValue()); + } + + handleInput(data: string): void { + // Modal armed confirm (slice-05 D3): while a delete is armed the pure + // router consumes EVERY key — y executes, n/Esc cancels (esc must NOT + // close the overlay here), anything else stays armed and is swallowed + // — so no key reaches the dispatch table or the search input. When not + // armed, behavior is unchanged. + if (this.confirmArmed) { + const step = deleteConfirmStep( + this.confirmArmed, + matchesKey(data, "ctrl+shift+backspace"), + matchesKey(data, "escape"), + data, + ); + if (step.execute) this.executeDelete(); + else if (step.cancel) this.disarmDeleteConfirm(); + this.tui.requestRender(); + return; + } + const kb = getKeybindings(); + let handled = false; + for (const { match, handler } of this.dispatch) { + if (match(data, kb)) { + handler(); + handled = true; + break; + } + } + if (!handled) this.forwardToSearch(data); + this.tui.requestRender(); + } + + // -- Mouse (wheel-only) ------------------------------------------------- + + /** + * Wheel-only mouse handling over the fixed 30-row geometry (design + * §D6). Non-wheel events stay host-owned (undefined = Container child + * dispatch); EVERY wheel path — including the no-op regions — reaches + * the single consumed return, closing the pre-existing SGR-fallthrough + * hazard where raw wheel bytes were typed into the search box. + */ + override handleMouse( + event: TuiMouseEvent, + ): ReturnType { + if (event.type !== "wheel") return undefined; + // A wheel scroll can move the selection off the armed row — disarm so + // the next delete press re-arms for the NEW row first (PR #1393). + if (this.confirmArmed) this.disarmDeleteConfirm(); + const delta = event.wheelDelta ?? 0; + if (event.y >= LIST_WHEEL_Y_FIRST && event.y <= LIST_WHEEL_Y_LAST) { + const steps = Math.min(Math.abs(delta), this.filteredRecords.length); + for (let i = 0; i < steps; i++) { + if (delta > 0) this.moveDown(); + else this.moveUp(); + } + } else if ( + event.y >= PREVIEW_WHEEL_Y_FIRST && + event.y <= PREVIEW_WHEEL_Y_LAST + ) { + if (delta !== 0) { + this.previewScrollOffset = clampPreviewOffset( + this.previewScrollOffset + (delta > 0 ? 1 : -1), + this.wrappedPreviewLines.length, + PREVIEW_ROWS, + ); + this.rebuildPreview(); + } + } + return { + handled: true, + target: { + component: this, + originX: event.screenX - event.x, + originY: event.screenY - event.y, + width: event.width, + height: event.height, + }, + }; + } + + // -- Render override for dynamic entry width --------------------------- + + /** Fixed overlay height so the TUI never repositions the panel. */ + private static readonly OVERLAY_LINES = 30; + + override render(width: number): string[] { + if (width !== this.lastWidth) { + // Pre-clamp against the previous wrap so a width change can never + // drive the rebuilds with a stale selection/offset (AC-P1-4.1/4.2). + this.selectedIndex = clampSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + ); + this.previewScrollOffset = clampPreviewOffset( + this.previewScrollOffset, + this.wrappedPreviewLines.length, + PREVIEW_ROWS, + ); + } + this.lastWidth = width; + this.rebuildListWithWidth(width); + this.rebuildPreviewWithWidth(width); + const raw = super.render(width); + // Pad or trim to exactly OVERLAY_LINES so the overlay never shifts. + const blank = " ".repeat(Math.max(1, width)); + while (raw.length < PromptHistorySelector.OVERLAY_LINES) raw.push(blank); + return raw.slice(0, PromptHistorySelector.OVERLAY_LINES); + } +} + +// --------------------------------------------------------------------------- +// Extension entry point +// --------------------------------------------------------------------------- + +type SelectorDone = (result: PromptRecord | null) => void; + +type SelectorFactory = ( + tui: unknown, + theme: unknown, + keybindings: unknown, + done: SelectorDone, +) => PromptHistorySelector; + +function castSelectorArgs(tui: unknown, theme: unknown): [TUI, Theme] { + return [tui as TUI, theme as Theme]; +} + +/** Stored close callback for the currently-open overlay. Null when closed. */ +let activeOverlayClose: (() => void) | null = null; + +function createPromptHistorySelectorFactory( + records: PromptRecord[], + onNotify?: SelectorNotify, +): SelectorFactory { + return (tui, theme, _keybindings, done) => { + selectorTui = tui as { requestRender(): void }; + const finish = (result: PromptRecord | null) => { + activeOverlayClose = null; + done(result); + }; + // Expose close so the tool_call handler can dismiss the overlay. + activeOverlayClose = () => finish(null); + const [typedTui, typedTheme] = castSelectorArgs(tui, theme); + const selector = new PromptHistorySelector( + typedTui, + typedTheme, + records, + (record) => finish(record), + () => finish(null), + onNotify, + ); + return selector; + }; +} + +async function runPromptHistorySelection( + ctx: Pick, + records: PromptRecord[], +): Promise { + const historyGlobals: PiHistoryGlobals = globalThis as Record< + string, + unknown + >; + return withExpandedHistoryGlobals(historyGlobals, async () => + ctx.ui.custom( + createPromptHistorySelectorFactory(records, (message, level) => + ctx.ui.notify(message, level), + ), + { + overlay: true, + overlayOptions: { anchor: "bottom-center", width: "100%", offsetY: 5 }, + }, + ), + ); +} + +// --------------------------------------------------------------------------- +// Multi-concurrency store (v2): per-session writes, scope drains +// --------------------------------------------------------------------------- + +type HistoryScope = "project" | "global"; + +/** TUI handle captured when the selector overlay mounts. */ +let selectorTui: { requestRender(): void } | null = null; + +let writerState: SessionWriterState | null = null; + +/** + * One-time init per extension load: migrate legacy stores, register the + * project, bootstrap the seed, then open this instance's exclusive file. + */ +function getWriter(): SessionWriterState { + if (!writerState) { + try { + migrateLegacyStores(PI_HISTORY_ROOT, AGENT_DIR); + } catch { + // migration is best-effort; the gate keeps it one-shot + } + try { + ensureRegistryEntry(PI_HISTORY_ROOT, CURRENT_CWD); + } catch { + // registry is advisory + } + try { + bootstrapProjectSeed( + PI_HISTORY_ROOT, + CURRENT_CWD, + SESSIONS_ROOT, + 500, + PI_HISTORY_NAV_STATE_DIR, + ); + } catch { + // bootstrap is a rebuildable cache + } + writerState = openSessionWriter(PI_HISTORY_ROOT, CURRENT_CWD, INSTANCE_ID); + } + return writerState; +} + +/** + * A selector scope drain: the drained prompts, or the fail-closed blocked + * shape (spec C4) carrying the recovery message and NO prompts. + */ +type ScopeDrain = string[] | Extract; + +/** + * Scope drain for the selector: project scope drains the project's store + * files; global scope is the store-only cross-project view (all project + * dirs + the legacy global seed). Both filter tombstoned prompts and fail + * closed (spec C4): an untrusted hidden.json returns the blocked + * DrainResult with the recovery message instead of any prompts. + */ +function drainForScope(scope: HistoryScope): ScopeDrain { + // Defense in depth: a drain must never trigger init writes while the user + // has capture disabled (the selector gate below is the first line). + if (!captureEnabled()) return []; + getWriter(); // ensure init ran + const drain = + scope === "project" + ? drainProject(PI_HISTORY_ROOT, CURRENT_CWD, 1000, PI_HISTORY_NAV_STATE_DIR) + : drainGlobal(PI_HISTORY_ROOT, 1000, PI_HISTORY_NAV_STATE_DIR); + return drain.status === "ok" ? drain.prompts : drain; +} + +/** Shared entry point for the ctrl+shift+r shortcut and the /history command. */ +async function openHistorySelector( + ctx: Pick, +): Promise { + // Gate: with capture off the selector must not run legacy migration, seed + // bootstrap, or any store/registry write as a side effect of opening it. + if (!captureEnabled()) { + ctx.ui.notify( + "Prompt history capture is off — set GENTLE_PI_HISTORY_ENABLE=1 to enable it.", + "warning", + ); + return; + } + + // Store-only drain (user-directed): both scopes read the store files + // symmetrically — no live transcript merge (the one-time seed bootstrap + // covers pre-store history). + const entries = drainForScope("project"); + if (!Array.isArray(entries)) { + // Fail-closed drain (spec C4): the tombstone file is untrusted, so NO + // entries are shown — surface the recovery warning instead. + ctx.ui.notify(entries.message, "error"); + return; + } + // Always open the selector (user-directed): an empty store still shows + // the overlay with its "No matching prompts" empty state instead of a + // warning notify. + const records = recordsFromEntries(entries); + const selected = await runPromptHistorySelection(ctx, records); + if (selected) { + // pasteToEditor routes through the editor's input pipeline + // (bracketed paste), so the text renders immediately. Plain + // setText left the editor stale until the next keypress after + // overlay close. + ctx.ui.pasteToEditor(selected.text); + // The overlay teardown can race the paste render: force one more + // frame on the next tick so the editor box shows the text at once. + setTimeout(() => selectorTui?.requestRender(), 0); + } +} + +/** Build selector records from merged/drain entries (shared by both scopes). */ +function recordsFromEntries( + entries: Array, +): PromptRecord[] { + return buildPromptRecords(dedupePromptEntries(entries)); +} + + +export interface HistoryDeps { + env?: NodeJS.ProcessEnv; + root?: string; + cwd?: string; + instanceId?: string; + now?: () => number; +} + +/** + * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_ENABLE is + * explicitly 1, true, or on (case-insensitive). The same switch is the + * disable path — unsetting it stops new captures; files already on disk + * are left untouched (deletes run from the selector while capture is on). + */ +export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.GENTLE_PI_HISTORY_ENABLE?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "on"; +} + +export default function promptHistoryExtension( + pi: ExtensionAPI, + deps: HistoryDeps = {}, +): void { + const env = deps.env ?? process.env; + const root = deps.root ?? PI_HISTORY_ROOT; + const cwd = deps.cwd ?? CURRENT_CWD; + const instanceId = deps.instanceId ?? INSTANCE_ID; + const now = deps.now ?? Date.now; + let writerState: SessionWriterState | null = null; + + /** + * One-time init per extension load: migrate legacy stores, register the + * project, bootstrap the seed, then open this instance's exclusive file. + */ + const getWriter = (): SessionWriterState => { + if (!writerState) { + try { + migrateLegacyStores(root, AGENT_DIR); + } catch { + // migration is best-effort; the gate keeps it one-shot + } + try { + ensureRegistryEntry(root, cwd); + } catch { + // registry is advisory + } + try { + bootstrapProjectSeed( + root, + cwd, + SESSIONS_ROOT, + 500, + PI_HISTORY_NAV_STATE_DIR, + ); + } catch { + // bootstrap is a rebuildable cache + } + writerState = openSessionWriter(root, cwd, instanceId); + } + return writerState; + }; + + // Warm migrate/registry/seed OFF the first-prompt path, but only for + // opted-in sessions: with capture disabled nothing may be written — + // no registry entry, no seed files, no store (docs/prompt-history.md). + setImmediate(() => { + if (!captureEnabled(env)) return; + try { + getWriter(); + } catch { + // init is best-effort; the lazy path retries on the next prompt + } + }); + + // Persist every delivered user prompt (write-through, append-only JSONL), + // but only for opted-in sessions — see captureEnabled(). The local + // ExtensionAPI stub types handler args as unknown; narrow here. + pi.on("before_agent_start", (...args: unknown[]) => { + if (!captureEnabled(env)) return; + try { + const event = args[0] as { prompt?: string } | undefined; + appendSessionCapture(getWriter(), event?.prompt ?? "", now()); + } catch { + // A capture failure must never break the agent loop or unregister + // the handler - swallow and keep the next prompt capturable. + } + }); + + // Backup pass: enforce the 1000-line limit on graceful shutdown. + pi.on("session_shutdown", () => { + try { + gcProjectDir(root, cwd); + } catch { + // GC is best-effort + } + }); + + // When a tool asks for user input while the history overlay is open, + // dismiss the overlay so the tool can take over the UI. + pi.on("tool_call", () => { + activeOverlayClose?.(); + }); + + pi.registerShortcut(SHORTCUT, { + description: "Search prompt history", + handler: async (ctx) => openHistorySelector(ctx), + }); + + pi.registerCommand("history", { + description: "Search prompt history", + handler: async (_args, ctx) => openHistorySelector(ctx), + }); +} diff --git a/extensions/history/load-shared-history.ts b/extensions/history/load-shared-history.ts new file mode 100644 index 000000000..ea03d0122 --- /dev/null +++ b/extensions/history/load-shared-history.ts @@ -0,0 +1,36 @@ +import fs from "node:fs"; + +interface SharedHistoryEntry { + text: string; +} + +function isSharedHistoryEntry(value: unknown): value is SharedHistoryEntry { + if (!value || typeof value !== "object") return false; + return typeof (value as { text?: unknown }).text === "string"; +} + +function toSharedHistoryValue(value: unknown): string | null { + if (typeof value === "string") return value.length > 0 ? value : null; + if (isSharedHistoryEntry(value)) + return value.text.length > 0 ? value.text : null; + return null; +} + +function isNonEmptyString(value: string | null): value is string { + return typeof value === "string" && value.length > 0; +} + +export function loadSharedHistory(historyFile: string): string[] { + if (!fs.existsSync(historyFile)) return []; + + try { + const raw = fs.readFileSync(historyFile, "utf8"); + const parsed: unknown = JSON.parse(raw); + + if (!Array.isArray(parsed)) return []; + + return parsed.map(toSharedHistoryValue).filter(isNonEmptyString); + } catch { + return []; + } +} diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts new file mode 100644 index 000000000..7f7a1794d --- /dev/null +++ b/extensions/history/selector-helpers.ts @@ -0,0 +1,401 @@ +export interface PromptRecord { + text: string; + searchText: string; + /** + * Provenance (spec C3): set on records built from PromptEntry inputs; + * ABSENT on records built from bare strings so the pinned deepEqual + * record shape ({text, searchText}) stays byte-compatible (design §I). + */ + source?: PromptSource; + /** Session records only: the resolved ms-epoch ordering timestamp. */ + ts?: number; +} + +/** Provenance of a prompt record or merge-loader entry (spec C3). */ +export type PromptSource = "editor" | "session"; + +/** + * Merge-loader currency (pre-dedup, spec C3): one editor-store or + * session-derived prompt. Session entries carry the resolved ms-epoch `ts`; + * editor entries do not (block ordering at the seam, proposal R8). + */ +export interface PromptEntry { + text: string; + source: PromptSource; + ts?: number; +} + +export interface VisibleRange { + start: number; + end: number; +} + +export interface VisiblePromptRecord { + index: number; + record: PromptRecord; + isSelected: boolean; +} + +export interface PiHistoryGlobals { + __piHistoryExpand?: () => void; + __piHistoryTrim?: () => void; +} + +export function buildPromptRecords( + entries: ReadonlyArray, +): PromptRecord[] { + return entries.map((entry): PromptRecord => { + if (typeof entry === "string") { + // Bare string input keeps the EXACT Change 2 runtime shape — the + // pinned deepEqual records carry only {text, searchText}. + return { text: entry, searchText: entry.toLowerCase() }; + } + const record: PromptRecord = { + text: entry.text, + searchText: entry.text.toLowerCase(), + source: entry.source, + }; + if (entry.ts !== undefined) { + record.ts = entry.ts; + } + return record; + }); +} + +export function clampSelectedIndex( + selectedIndex: number, + total: number, +): number { + return Math.max(0, Math.min(selectedIndex, Math.max(0, total - 1))); +} + +export function clampPreviewOffset( + offset: number, + totalLines: number, + viewportRows: number, +): number { + return Math.max(0, Math.min(offset, Math.max(0, totalLines - viewportRows))); +} + +export function computeVisibleRange( + selectedIndex: number, + total: number, + maxVisible: number, +): VisibleRange { + if (total <= 0 || maxVisible <= 0) return { start: 0, end: 0 }; + if (total <= maxVisible) return { start: 0, end: total }; + + const half = Math.floor(maxVisible / 2); + const start = Math.max(0, Math.min(selectedIndex - half, total - maxVisible)); + + return { + start, + end: Math.min(start + maxVisible, total), + }; +} + +export function moveSelectedIndex( + selectedIndex: number, + total: number, + delta: number, +): number { + if (total === 0) return 0; + return (selectedIndex + delta + total) % total; +} + +export function pageSelectedIndex( + selectedIndex: number, + total: number, + pageSize: number, +): number { + if (total === 0) return 0; + return clampSelectedIndex(selectedIndex + pageSize, total); +} + +/** + * Normalization key for read-time dedup (spec C3): byte-matches the + * APPLIED patch key in nav/patches/editor.cjs (:480-:586) — whitespace + * runs collapse, then trim, then a 120-char prefix slice, then lowercase. + * The literal is the single-backslash applied-patch form; the raw patch + * file stores \\s+ only because its code sits inside a template literal. + * Shared by contract (spec C4): hide-prompts tombstone keys and the + * store seeding tombstone filter MUST byte-match this key. + */ +export function promptDedupKey(entry: string): string { + return entry.replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(); +} + +/** + * Read-time dedup pass (spec C3): keep-first over input order (file order + * is newest-first, mirroring the patch's keep-first dedup-on-save), and + * empty-key entries (empty or whitespace-only) are skipped — excluded from + * the output and never usable as collision keys. Removes ONLY duplicate- + * normalized entries; no snapshot cap (filterPrompts still caps output). + * Generic over string | PromptEntry (WU3): over the COMBINED merged input + * the keep-first rule makes the editor copy win at the seam — objects pass + * through with provenance intact; no new dedup logic exists anywhere. + */ +export function dedupePromptEntries( + entries: readonly T[], +): T[] { + const seen = new Set(); + const deduped: T[] = []; + for (const entry of entries) { + const key = + typeof entry === "string" + ? promptDedupKey(entry) + : promptDedupKey(entry.text); + if (key !== "" && !seen.has(key)) { + seen.add(key); + deduped.push(entry); + } + } + return deduped; +} + +/** + * First-paint window size (spec C1, AC-L1-1): min(initialBatch, total), + * floored at 0 — small stores open fully loaded (exhausted at open), + * identical to today's behavior for R <= INITIAL_BATCH. + */ +export function initialLoadedCount( + total: number, + initialBatch: number, +): number { + return Math.max(0, Math.min(initialBatch, total)); +} + +/** + * Prefetch trigger (spec C2's normative expression, AC-L2-1): growth fires + * iff rows remain unloaded AND the 0-based cursor sits within the final + * preloadBuffer rows of the loaded window. Reads UNFILTERED counts only — + * filteredRecords.length appears in no trigger arithmetic (AC-L2-2). + */ +export function shouldGrowWindow( + selectedIndex: number, + loadedCount: number, + totalCount: number, + preloadBuffer: number, +): boolean { + return ( + loadedCount < totalCount && selectedIndex + preloadBuffer >= loadedCount + ); +} + +/** + * One growth step (spec C2, AC-L2-1): min(L + max(1, batchSize), R). The + * max(1, ·) guard also keeps loadedCountForTarget's loop terminating on a + * degenerate batch size. + */ +export function nextLoadedCount( + loadedCount: number, + totalCount: number, + batchSize: number, +): number { + const step = Math.max(1, batchSize); + return Math.min(loadedCount + step, totalCount); +} + +/** + * PgDn catch-up (spec C1, AC-L1-5): the smallest whole-batch count that + * strictly covers targetIndex (a 0-based master row), clamped at totalCount. + * No-op when the target is already covered or the window is exhausted. + * Terminates by construction: each step adds ≥ 1, bounded by totalCount. + */ +export function loadedCountForTarget( + loadedCount: number, + totalCount: number, + targetIndex: number, + batchSize: number, +): number { + let next = loadedCount; + while (next <= targetIndex && next < totalCount) { + next = nextLoadedCount(next, totalCount, batchSize); + } + return next; +} + +/** + * Delete backfill (spec C4's two steps verbatim, AC-L4-1..3): decrement the + * window against the splice-shrunk snapshot; while unloaded rows remain, + * backfill one row (clamped) so the next unloaded record slides into the + * deleted slot and the visible list length stays stable; at exhaustion the + * decrement is the genuine shrink. Written stepwise — NOT the algebraic + * min(L, T') shortcut — so the unit tests pin the contract, not an + * equivalence. Callers guarantee the deleted row sits inside the loaded + * prefix (idx < loadedCount by construction). + */ +export function loadedCountAfterDelete( + loadedCount: number, + totalCountAfterSplice: number, +): number { + const decrement = loadedCount - 1; + if (decrement < totalCountAfterSplice) { + return Math.min(decrement + 1, totalCountAfterSplice); + } + return decrement; +} + +/** + * Pure delete-flow planner (spec C4, design §F): maps a record's provenance + * to the two delete actions. "editor" deletes from the editor store on disk + * AND writes the tombstone (twin suppression — the session copy of the same + * text would otherwise resurface next open); "session" plans NOTHING — + * session-derived rows are read-only (slice-05 D1): transcripts are + * immutable and owned by Pi core, so the extension never deletes from or + * writes to them, and deleteCurrent guards the source before the flow. + * Takes source as a plain parameter (no member reads — the T23 provenance + * pin keeps overlay consumers source-agnostic outside deleteCurrent); the + * only consumer is the delete flow in history/index.ts. + * text would otherwise resurface next open); "session" plans NOTHING — + * session-derived rows are read-only (slice-05 D1): transcripts are + * immutable and owned by Pi core, so the extension never deletes from or + * writes to them, and deleteCurrent guards the source before the flow. + * Takes source as a plain parameter (no member reads — the T23 provenance + * pin keeps overlay consumers source-agnostic outside deleteCurrent); the + * only consumer is the delete flow in history/index.ts. +======= + * text would otherwise resurface next open); "session" writes the tombstone + * only (session transcripts are NEVER written). Takes source as a plain + * parameter (no member reads — the T23 provenance pin keeps overlay + * consumers source-agnostic outside deleteCurrent); the only consumer is + * deleteCurrent in src/index.ts. +>>>>>>> a225102f + */ +export function deletionActionsFor(source: PromptSource): { + deleteFromEditorStore: boolean; + writeTombstone: boolean; +} { + if (source === "editor") { + return { deleteFromEditorStore: true, writeTombstone: true }; + } + return { deleteFromEditorStore: false, writeTombstone: false }; +} + +/** + * One transition of the modal delete confirmation (PR #1393 follow-up, + * slice-05 D3). Disarmed, only the delete combo matters: it ARMS the + * confirm and executes nothing. While armed the confirm is MODAL: `y`/`Y` + * executes, `n`/`N`/Esc cancels, and every other key — including a second + * press of the combo — is swallowed with the confirm still armed (nothing + * reaches the dispatch table or the search input). The TUI keybinding + * matches (ctrl+shift+backspace, escape) are computed by the caller via + * matchesKey and passed as plain booleans so this router stays pure and + * testable without the TUI; the y/n semantics read the raw data here. + * ONE definition: the selector's handleInput routes every armed-state key + * through this function. + */ +export interface DeleteConfirmStep { + /** The armed state AFTER this transition. */ + armed: boolean; + /** True only when `y`/`Y` confirms the armed delete — run the flow. */ + execute: boolean; + /** True when `n`/`N`/Esc cancels — disarm and resume normal input. */ + cancel: boolean; +} + +export function deleteConfirmStep( + armed: boolean, + isDeleteKey: boolean, + isEscapeKey: boolean, + data: string, +): DeleteConfirmStep { + if (!armed) { + return isDeleteKey + ? { armed: true, execute: false, cancel: false } + : { armed: false, execute: false, cancel: false }; + } + if (data === "y" || data === "Y") { + return { armed: false, execute: true, cancel: false }; + } + if (data === "n" || data === "N" || isEscapeKey) { + return { armed: false, execute: false, cancel: true }; + } + return { armed: true, execute: false, cancel: false }; +} + +/** + * Confirmation copy shown in the footer while a delete is armed (PR + * #1393): one line, one variant — a y/n question carrying the standing + * guarantee that the session log keeps the original either way. + */ +export function deleteConfirmFooterText(): string { + return "Delete this prompt from history (y/n)? Prompt stays in session log"; +} + +/** + * Toast copy when the store delete THROWS (PR #1393): the flow aborts + * before any tombstone write, so nothing was removed — the store keeps the + * prompt and no tombstone is written. + */ +export const STORE_DELETE_FAILED_TEXT = + "Store delete failed; nothing was removed."; + +/** + * Toast copy when the tombstone write fails on the EDITOR path (PR + * #1393): the store row was already removed, so only the hide failed — + * the prompt may reappear from session transcripts. + */ +export const EDITOR_HIDE_FAILED_TEXT = + "Deleted from the store, but hiding failed — the prompt may reappear from session transcripts."; + +export function getVisiblePromptRecords( + records: PromptRecord[], + selectedIndex: number, + maxVisible: number, +): VisiblePromptRecord[] { + const { start, end } = computeVisibleRange( + selectedIndex, + records.length, + maxVisible, + ); + return records.slice(start, end).map((record, offset) => ({ + index: start + offset, + record, + isSelected: start + offset === selectedIndex, + })); +} + +export async function withExpandedHistoryGlobals( + globals: PiHistoryGlobals, + run: () => Promise, +): Promise { + globals.__piHistoryExpand?.(); + try { + return await run(); + } finally { + globals.__piHistoryTrim?.(); + } +} + +/** + * Full-snapshot visibility for non-empty queries (AC-L2-3r, user-directed + * 2026-09-08): searching must see the whole deduped snapshot, not just the + * loaded prefix. One-shot and idempotent — returns the total, never an + * incremental batch — so per-keypress growth stays impossible. Empty or + * whitespace-only queries leave the lazy window untouched. + */ +export function loadedCountForQuery( + loadedCount: number, + totalCount: number, + query: string, +): number { + return query.trim().length > 0 ? totalCount : loadedCount; +} + +const MAX_RESULTS = 10000; + +export function filterPrompts( + records: PromptRecord[], + query: string, +): PromptRecord[] { + const trimmed = query.trim(); + if (!trimmed) return records.slice(0, MAX_RESULTS); + + const tokens = trimmed.toLowerCase().split(/\s+/).filter(Boolean); + const filtered = records.filter((record) => { + return tokens.every((token) => record.searchText.includes(token)); + }); + + return filtered.slice(0, MAX_RESULTS); +} diff --git a/extensions/history/session-scan.ts b/extensions/history/session-scan.ts new file mode 100644 index 000000000..accd302c7 --- /dev/null +++ b/extensions/history/session-scan.ts @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +import fs from "node:fs"; +import path from "node:path"; + +/** + * One user prompt extracted from a pi session transcript file. + * + * `ts` is the resolved ordering timestamp in milliseconds since the epoch. + * It follows the documented fallback chain (message ms-epoch → entry ISO → + * header ISO → file mtime) and exists for ordering only — extraction never + * fails because of it. + */ +export interface ExtractedPrompt { + text: string; + ts: number; +} + +/** + * Result of scanning one session file: the extracted user prompts (in file + * order) plus `skippedLines`, the number of gate-passing lines whose JSON + * could not be parsed. Parse failures are counted, never fatal. + */ +export interface FileScanResult { + prompts: ExtractedPrompt[]; + skippedLines: number; +} + +/** + * Maximum extracted prompt length in UTF-16 code units (`text.length`). A + * prompt strictly greater than this is skipped; at the maximum it still + * extracts. The skip is uniform and silent (design §D1). + */ +export const MAX_PROMPT_CHARS = 16384; + +/** + * Cheap per-line substring prefilter literal. PREFILTER ONLY: a miss means + * the line is never parsed; the parsed extraction rule below is the only + * membership authority. + */ +const USER_ROLE_LITERAL = '"role":"user"'; + +/** Defensive field-access shapes for session JSONL values. */ +interface SessionFields { + type?: unknown; + version?: unknown; + timestamp?: unknown; + message?: unknown; +} + +interface MessageFields { + role?: unknown; + content?: unknown; + timestamp?: unknown; +} + +/** + * Validate the line-1 session header. A file is admitted only when the + * header parses, carries type "session", and a numeric version ≤ 3 + * (format v3; legacy v1/v2 tolerated). Mirrors pi's loadEntriesFromFile + * tolerance: any miss yields an empty scan, never a throw. Returns the + * header timestamp in ms, or NaN when absent or unparseable. + */ +function parseHeader(line: string): number | null { + let header: unknown; + try { + header = JSON.parse(line); + } catch { + return null; + } + if (header == null || typeof header !== "object") return null; + const fields = header as SessionFields; + if (fields.type !== "session") return null; + if (typeof fields.version !== "number" || !(fields.version <= 3)) { + return null; + } + return typeof fields.timestamp === "string" + ? Date.parse(fields.timestamp) + : NaN; +} + +/** + * Extract the prompt text from a user message's content: plain string + * content passes through as-is; block arrays join their text blocks with a + * single space and trim the assembly (upstream extractTextContent rule, + * design §D1) — images and other block types are ignored, and zero text + * blocks yield no prompt. Returns null when no prompt text exists. + */ +function extractText(content: unknown): string | null { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return null; + const parts: string[] = []; + for (const block of content) { + if ( + block != null && + typeof block === "object" && + (block as SessionFields).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ) { + parts.push((block as { text: string }).text); + } + } + if (parts.length === 0) return null; + return parts.join(" ").trim(); +} + +/** + * Resolve a prompt's ordering timestamp: message ms-epoch, then entry ISO, + * then header ISO, then the file mtime. Every hop is NaN-tolerant; ordering + * data is never worth a throw. + */ +function resolveTimestamp( + entry: SessionFields, + message: MessageFields, + headerTs: number, + filePath: string, +): number { + if ( + typeof message.timestamp === "number" && + Number.isFinite(message.timestamp) + ) { + return message.timestamp; + } + if (typeof entry.timestamp === "string") { + const parsed = Date.parse(entry.timestamp); + if (!Number.isNaN(parsed)) return parsed; + } + if (!Number.isNaN(headerTs)) return headerTs; + try { + const mtimeMs = fs.statSync(filePath).mtimeMs; + if (!Number.isNaN(mtimeMs)) return mtimeMs; + } catch { + // The file vanished between read and stat — leave the NaN residue. + } + return NaN; +} + +/** + * Extract every user prompt from one pi session transcript file. + * + * Pipeline (design §C): line-1 header admission gate; per line the cheap + * USER_ROLE_LITERAL substring gate as a PREFILTER ONLY (gate misses are + * never parsed); JSON.parse with a counted skip on throw; then the parsed + * extraction rule (entry type "message" AND user role) as the only + * membership authority. Whitespace-only text is skipped silently, and text + * above MAX_PROMPT_CHARS skips uniformly. UI-free and fs-only: data-path + * errors degrade to empty or partial results and never throw. + */ +export function extractPromptsFromFile(filePath: string): FileScanResult { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch { + return { prompts: [], skippedLines: 0 }; + } + + const lines = raw.split("\n"); + const headerTs = parseHeader(lines[0]); + if (headerTs === null) return { prompts: [], skippedLines: 0 }; + + const prompts: ExtractedPrompt[] = []; + let skippedLines = 0; + + for (let index = 1; index < lines.length; index++) { + const line = lines[index]; + // Prefilter: a miss never reaches JSON.parse. + if (!line.includes(USER_ROLE_LITERAL)) continue; + + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + skippedLines++; + continue; + } + if (entry == null || typeof entry !== "object") continue; + + const record = entry as SessionFields; + if (record.type !== "message") continue; + if (record.message == null || typeof record.message !== "object") continue; + const message = record.message as MessageFields; + if (message.role !== "user") continue; + + const text = extractText(message.content); + if (text === null || text.trim() === "") continue; // silent, not counted + if (text.length > MAX_PROMPT_CHARS) continue; // uniform silent length skip + + prompts.push({ + text, + ts: resolveTimestamp(record, message, headerTs, filePath), + }); + } + + return { prompts, skippedLines }; +} + +/** + * One-level scan rule (C1): list the top-level `*.jsonl` session files of + * every encoded-cwd directory under the pi sessions root. Only DIRECTORIES + * at the root are entered (stray root-level files are skipped) and only + * their top-level jsonl files are candidates — nested subagent payloads + * (`run-N/session.jsonl`) and `subagent-artifacts/` subtrees are directories + * and are never descended. An unreadable root yields an empty list and a + * directory whose readdir fails is skipped — never fatal. Returns sorted + * absolute paths for deterministic scan order. Mirrors pi's non-recursive + * listSessionsFromDir (session-manager.ts:822-826, read-only). + */ +export function listSessionFiles(sessionsRoot: string): string[] { + let rootEntries: fs.Dirent[]; + try { + rootEntries = fs.readdirSync(sessionsRoot, { withFileTypes: true }); + } catch { + return []; + } + const files: string[] = []; + for (const rootEntry of rootEntries) { + if (!rootEntry.isDirectory()) continue; + const dirPath = path.join(sessionsRoot, rootEntry.name); + let children: fs.Dirent[]; + try { + children = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + continue; // one unreadable directory skips itself, never fatal + } + for (const child of children) { + if (child.isFile() && child.name.endsWith(".jsonl")) { + files.push(path.join(dirPath, child.name)); + } + } + } + return files.sort(); +} diff --git a/extensions/history/store.ts b/extensions/history/store.ts new file mode 100644 index 000000000..7185d552f --- /dev/null +++ b/extensions/history/store.ts @@ -0,0 +1,862 @@ +// Consolidated multi-concurrency store (v2): paths, registry, session +// writer, scope drains/deletes, legacy migration, bootstrap, GC. +// Formerly store-paths.ts + registry.ts + multi-store.ts (+ v1 primitives). + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { readHiddenPrompts } from "./hide-prompts.ts"; +import { loadSharedHistory } from "./load-shared-history.ts"; +import { + type ExtractedPrompt, + extractPromptsFromFile, + listSessionFiles, +} from "./session-scan.ts"; + +// =========================================================================== +// Paths (formerly store-paths.ts) +// =========================================================================== + +/** + * Project identity for the multi-concurrency store (design v2). + * + * The cwd is canonicalized through realpath — the same resolution pi's + * session-manager applies — so symlinked or differently-spelled paths to one + * project merge into a single identity. A failed resolution (deleted cwd) + * falls back to hashing the raw string: identity degrades, never throws. + */ +export function projectHash(cwd: string): string { + let canonical = cwd; + try { + canonical = fs.realpathSync(cwd); + } catch { + // fall back to the raw path + } + return createHash("sha256").update(canonical).digest("hex").slice(0, 16); +} + +/** The project's directory under the store root. */ +export function projectDir(root: string, cwd: string): string { + return path.join(root, "projects", projectHash(cwd)); +} + +/** The capture file owned by one pi instance (per session/process). */ +export function sessionFilePath( + root: string, + cwd: string, + instanceId: string, +): string { + return path.join(projectDir(root, cwd), `${instanceId}.jsonl`); +} + +/** The rebuildable bootstrap output for a project. */ +export function seedFilePath(root: string, cwd: string): string { + return path.join(projectDir(root, cwd), "seed.jsonl"); +} + +/** The one-time legacy/global seed (never GC'd). */ +export function globalSeedPath(root: string): string { + return path.join(root, "history-global.jsonl"); +} + +/** Advisory hash → cwd map for display labels. */ +export function registryPath(root: string): string { + return path.join(root, "registry.json"); +} + +// =========================================================================== +// Registry (formerly registry.ts) +// =========================================================================== + +export interface RegistryEntryResult { + hash: string; + created: boolean; +} + +type RegistryData = Record; + +function readRegistry(root: string): RegistryData { + try { + const raw = fs.readFileSync(registryPath(root), "utf8"); + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const out: RegistryData = {}; + for (const [key, value] of Object.entries( + parsed as Record, + )) { + if (typeof value === "string") out[key] = value; + } + return out; + } catch { + return {}; + } +} + +function writeRegistryAtomic(root: string, data: RegistryData): void { + const target = registryPath(root); + const tmp = `${target}.tmp-${process.pid}-${Date.now()}`; + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + fs.renameSync(tmp, target); +} + +/** + * Ensure the advisory registry maps this project's hash to its cwd. + * Idempotent: an existing identical entry writes nothing. A hash mapped to a + * DIFFERENT cwd is a (practically unreachable) collision — the entry is + * re-keyed at 24 hash chars so both identities coexist. + */ +export function ensureRegistryEntry( + root: string, + cwd: string, +): RegistryEntryResult { + const hash = projectHash(cwd); + const data = readRegistry(root); + if (data[hash] === cwd) return { hash, created: false }; + // An earlier collision may have re-keyed THIS cwd to a long key. + // Return the existing mapping unchanged so collision assignments stay + // stable across calls instead of flipping the other occupant's key. + const existingKey = Object.keys(data).find((k) => data[k] === cwd); + if (existingKey !== undefined) return { hash: existingKey, created: false }; + if (data[hash] !== undefined) { + // Collision: re-key the EXISTING occupant at 24 hash chars so both + // identities coexist; the incoming cwd keeps the short hash — the + // key shape projectDir/sessionFilePath/drains derive. + const existing = data[hash]; + data[projectHashLong(existing)] = existing; + data[hash] = cwd; + writeRegistryAtomic(root, data); + return { hash, created: true }; + } + data[hash] = cwd; + writeRegistryAtomic(root, data); + return { hash, created: true }; +} + +/** Display lookup: hash → cwd, null when unknown or the file is absent. */ +export function lookupCwd(root: string, hash: string): string | null { + return readRegistry(root)[hash] ?? null; +} + +function projectHashLong(cwd: string): string { + // Reuse the same canonicalization as projectHash but keep 24 chars. + let canonical = cwd; + try { + canonical = fs.realpathSync(cwd); + } catch { + // fall back to the raw path + } + return createHash("sha256").update(canonical).digest("hex").slice(0, 24); +} + +// =========================================================================== +// Entry primitives (from v1 history-store.ts) +// =========================================================================== + +/** One line of `editor-history.jsonl`. */ +interface StoreEntry { + /** Schema version; 1 when absent in the source line. */ + v: number; + text: string; + /** Capture epoch-ms; optional, line order is authoritative for recency. */ + ts?: number; +} + +/** + * Parse one JSONL line. Returns null for malformed lines (bad JSON, + * non-string or whitespace-only text) so callers can skip them; a torn + * last line from a crash is handled the same way. + */ +function parseStoreLine(raw: string): StoreEntry | null { + if (raw.length === 0) return null; + try { + const value: unknown = JSON.parse(raw); + if (!value || typeof value !== "object") return null; + const record = value as { v?: unknown; text?: unknown; ts?: unknown }; + if (typeof record.text !== "string") return null; + if (record.text.trim().length === 0) return null; + const entry: StoreEntry = { v: 1, text: record.text }; + if (typeof record.v === "number" && Number.isFinite(record.v)) { + entry.v = record.v; + } + if (typeof record.ts === "number" && Number.isFinite(record.ts)) { + entry.ts = record.ts; + } + return entry; + } catch { + return null; + } +} + +// =========================================================================== +// Multi-store (formerly multi-store.ts) +// =========================================================================== + +/** Mutable state of ONE pi instance's exclusive capture file. */ +export interface SessionWriterState { + filePath: string; + /** Logical line count of this instance's file. */ + lineCount: number; +} + +/** Command-like prompts (`/name ...`) are UI commands, not prompts. */ +function isLikelyCommand(text: string): boolean { + return /^\/[A-Za-z]/.test(text.trim()); +} + +function serializeEntry(entry: StoreEntry): string { + const out: { v: number; text: string; ts?: number } = { + v: entry.v, + text: entry.text, + }; + if (entry.ts !== undefined) out.ts = entry.ts; + return JSON.stringify(out); +} + +/** + * Open the writer for this pi instance. The file is created LAZILY by the + * first capture — starting pi must not litter empty files. Only this + * instance ever appends here (design v2: zero shared writes). + */ +export function openSessionWriter( + root: string, + cwd: string, + instanceId: string, +): SessionWriterState { + return { + filePath: sessionFilePath(root, cwd, instanceId), + lineCount: 0, + }; +} + +/** + * Append one prompt line to the instance's own file (write-through). + * Skips empty/whitespace-only and command-like prompts. + */ +export function appendSessionCapture( + state: SessionWriterState, + text: string, + ts?: number, +): void { + if (typeof text !== "string" || text.trim().length === 0) return; + if (isLikelyCommand(text)) return; + + const entry: StoreEntry = { v: 1, text }; + if (ts !== undefined) entry.ts = ts; + fs.mkdirSync(path.dirname(state.filePath), { recursive: true }); + fs.appendFileSync(state.filePath, `${serializeEntry(entry)}\n`, "utf8"); + state.lineCount += 1; +} + +// --------------------------------------------------------------------------- +// Multi-file reader (design v2: sequential backward drain over sorted files) +// --------------------------------------------------------------------------- + +/** UI-level prompt identity: whitespace-collapsed, case-insensitive. */ +function promptKey(text: string): string { + return text.replace(/\s+/g, " ").trim().toLowerCase(); +} + +function fileMtimeMs(file: string): number { + try { + return fs.statSync(file).mtimeMs; + } catch { + return 0; + } +} + +function listProjectFiles(dir: string): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((e) => e.isFile() && e.name.endsWith(".jsonl")) + .map((e) => path.join(dir, e.name)) + .sort((a, b) => fileMtimeMs(b) - fileMtimeMs(a)); +} + +/** + * Read one file's valid entries (chronological). Malformed lines are + * skipped. + */ +function readFileEntries(file: string): StoreEntry[] { + let raw = ""; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + return []; + } + const entries: StoreEntry[] = []; + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (parsed) entries.push(parsed); + } + return entries; +} + +/** + * Sort key = the newest entry ts in the file (fallback: file mtime). + * ts-based keys are STABLE under atomic rewrites (deletes/compaction + * bump mtime, which used to reshuffle the drain order). + */ +function fileSortKey(file: string, entries: StoreEntry[]): number { + let maxTs = 0; + for (const entry of entries) { + if (entry.ts !== undefined && entry.ts > maxTs) maxTs = entry.ts; + } + return maxTs > 0 ? maxTs : fileMtimeMs(file); +} + +/** + * Sequential backward drain over PRE-SORTED files: each file fully, + * newest-line-first, deduped by UI-level identity, capped at `limit`. + */ +function drainFiles( + files: string[], + limit: number, + hidden: Set = new Set(), +): string[] { + const seen = new Set(); + const out: string[] = []; + for (const file of files) { + const entries = readFileEntries(file); + for (let i = entries.length - 1; i >= 0; i--) { + const key = promptKey(entries[i].text); + if (seen.has(key)) continue; + if (hidden.size > 0 && hidden.has(promptDedupKeyOf(entries[i].text))) { + continue; + } + seen.add(key); + out.push(entries[i].text); + if (out.length >= limit) return out; + } + } + return out; +} + +/** Sort files for draining: ts-keyed, newest first, empty files dropped. */ +function sortFilesForDrain(files: string[]): string[] { + return files + .map((file) => ({ file, entries: readFileEntries(file) })) + .filter((f) => f.entries.length > 0) + .sort( + (a, b) => fileSortKey(b.file, b.entries) - fileSortKey(a.file, a.entries), + ) + .map((f) => f.file); +} + +/** + * Result of a scope drain: `ok` with the drained prompts, or `blocked` + * when the tombstone file is untrusted (fail-closed READ half). The + * blocked shape carries NO prompts field, so a caller cannot accidentally + * render prompts that may include hidden ones. + */ +export type DrainResult = + | { status: "ok"; prompts: string[] } + | { status: "blocked"; message: string }; + +/** + * Shared drain tail: without a `stateDir` the raw drain semantics hold (no + * filter). With one, the tombstone filter applies and fails CLOSED: an + * untrusted hidden.json (unreadable, corrupt, wrong shape) blocks the + * whole drain with the recovery message instead of resurfacing hidden + * prompts; a missing file is the safe empty tombstone set and drains + * normally. + */ +function drainWithHidden( + files: string[], + limit: number, + stateDir?: string, +): DrainResult { + if (!stateDir) return { status: "ok", prompts: drainFiles(files, limit) }; + const read = readHiddenPrompts(stateDir); + if (read.status === "untrusted") { + return { status: "blocked", message: read.message }; + } + return { status: "ok", prompts: drainFiles(files, limit, read.keys) }; +} + +/** + * Drain the PROJECT scope: all .jsonl files in the project dir (seed.jsonl + * included), mtime-newest-first, deduped, capped at `limit` (default 1000). + * With a `stateDir`, the tombstone filter applies and fails closed: an + * untrusted hidden.json blocks the drain (see DrainResult). + */ +export function drainProject( + root: string, + cwd: string, + limit: number = 1000, + stateDir?: string, +): DrainResult { + return drainWithHidden( + sortFilesForDrain( + listProjectFiles(path.join(root, "projects", projectHash(cwd))), + ), + limit, + stateDir, + ); +} + +/** + * Drain the GLOBAL scope: every project dir's files, mtime-newest-first, + * deduped, capped — with the legacy global seed appended LAST (deliberate: + * it is the least specific, migrated source, so per-project entries win + * recency and keep-first dedup favors them). With a `stateDir`, the + * tombstone filter applies and fails closed: an untrusted hidden.json + * blocks the drain (see DrainResult). + */ +export function drainGlobal( + root: string, + limit: number = 1000, + stateDir?: string, +): DrainResult { + const sorted = sortFilesForDrain(listAllProjectFiles(root)); + const globalSeed = globalSeedPath(root); + if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last + return drainWithHidden(sorted, limit, stateDir); +} + +/** + * Every project dir's store files: the projects root is skipped fail-open + * when unreadable, and non-directory entries are ignored. Shared by the + * global drain and the global delete sweep. + */ +function listAllProjectFiles(root: string): string[] { + const files: string[] = []; + let projectDirs: fs.Dirent[]; + try { + projectDirs = fs.readdirSync(path.join(root, "projects"), { + withFileTypes: true, + }); + } catch { + projectDirs = []; + } + for (const dirEntry of projectDirs) { + if (!dirEntry.isDirectory()) continue; + files.push(...listProjectFiles(path.join(root, "projects", dirEntry.name))); + } + return files; +} + +// --------------------------------------------------------------------------- +// Scope delete (design v2) +// --------------------------------------------------------------------------- + +interface SweepResult { + filesAffected: number; + removed: number; +} + +/** + * Remove every line whose prompt identity matches `text` from each file in + * `files`, one atomic rewrite (tmp + rename) per affected file. Files whose + * every line matched are kept as empty files (never removed — the instance + * owning a session file may still append to it). + */ +function sweepFiles(files: string[], text: string): SweepResult { + const key = promptKey(text); + let filesAffected = 0; + let removed = 0; + for (const file of files) { + let raw = ""; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + continue; + } + const kept: string[] = []; + let fileRemoved = 0; + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (!parsed) continue; + if (promptKey(parsed.text) === key) { + fileRemoved += 1; + } else { + kept.push(JSON.stringify(parsed)); + } + } + if (fileRemoved === 0) continue; + const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync( + tmp, + kept.length > 0 ? `${kept.join("\n")}\n` : "", + "utf8", + ); + fs.renameSync(tmp, file); + filesAffected += 1; + removed += fileRemoved; + } + return { filesAffected, removed }; +} + +/** Delete every copy of a prompt from the CURRENT project's scope. */ +export function deleteFromProject( + root: string, + cwd: string, + text: string, +): SweepResult { + return sweepFiles( + listProjectFiles(path.join(root, "projects", projectHash(cwd))), + text, + ); +} + +/** Delete every copy of a prompt from the GLOBAL scope (all projects + seed). */ +export function deleteFromGlobal(root: string, text: string): SweepResult { + const files = listAllProjectFiles(root); + const globalSeed = globalSeedPath(root); + if (fs.existsSync(globalSeed)) files.unshift(globalSeed); + return sweepFiles(files, text); +} + +// --------------------------------------------------------------------------- +// Legacy migration (design v2: one-time, gated) +// --------------------------------------------------------------------------- + +export interface MigrationResult { + migrated: number; + ran: boolean; +} + +function readValidLines(file: string): StoreEntry[] { + try { + const raw = fs.readFileSync(file, "utf8"); + const entries: StoreEntry[] = []; + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (parsed) entries.push(parsed); + } + return entries; + } catch { + return []; + } +} + +/** + * Atomically write a seed file: create the parent dir, write a tmp sibling, + * rename over the target. Returns the entry count written. Shared by the + * legacy migration and the project bootstrap. + */ +function writeSeedFileAtomic(seed: string, collected: StoreEntry[]): number { + fs.mkdirSync(path.dirname(seed), { recursive: true }); + const tmp = `${seed}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync( + tmp, + `${collected.map((e) => JSON.stringify(e)).join("\n")}\n`, + "utf8", + ); + fs.renameSync(tmp, seed); + return collected.length; +} + +/** + * One-time migration from the v1 stores into the v2 global seed: + * - `~/.pi/agent/editor-history.jsonl` (v1 single-file store) + * - `~/.pi/agent/editor-history.json` (pre-v1 array, newest-first) + * Content lands in `pi-history/history-global.jsonl` chronologically; only + * after the seed write succeeds is each source renamed `.imported`, never + * deleted — a failed write leaves sources untouched for a later retry. + * Gated: an existing global seed means migration already ran. + */ +export function migrateLegacyStores( + root: string, + agentDir: string, +): MigrationResult { + const seed = globalSeedPath(root); + if (fs.existsSync(seed)) return { migrated: 0, ran: false }; + + const collected: StoreEntry[] = []; + + // Pre-v1 array (newest-first) → reverse to chronological. + const legacyArray = path.join(agentDir, "editor-history.json"); + if (fs.existsSync(legacyArray)) { + const texts = loadSharedHistory(legacyArray); + for (let i = texts.length - 1; i >= 0; i--) { + collected.push({ v: 1, text: texts[i] }); + } + } + + // v1 single-file store — already chronological. + const v1File = path.join(agentDir, "editor-history.jsonl"); + if (fs.existsSync(v1File)) { + collected.push(...readValidLines(v1File)); + } + + if (collected.length === 0) return { migrated: 0, ran: false }; + + const migrated = writeSeedFileAtomic(seed, collected); + + // The seed write is the source of truth: rename sources only once it + // succeeded, so a failure can never strand entries in .imported files. + for (const src of [legacyArray, v1File]) { + try { + if (fs.existsSync(src)) fs.renameSync(src, `${src}.imported`); + } catch { + // benign: the seed gate prevents duplicate import on the next run + } + } + return { migrated, ran: true }; +} + +// --------------------------------------------------------------------------- +// Project bootstrap (design v2: seed.jsonl) +// --------------------------------------------------------------------------- + +export interface SeedResult { + seeded: number; + ran: boolean; +} + +/** + * Seed `projects//seed.jsonl` from the project's pi transcripts when + * the project dir holds fewer than `target` entries. Existing session files + * are counted; their prompts are NOT re-seeded (dedupe by UI-level key). + * The seed is a rebuildable cache — rewritten only when the dir is empty. + */ +/** Tombstone key - byte-compatible with hide-prompts' promptDedupKey. */ +function promptDedupKeyOf(text: string): string { + return text.replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(); +} + +/** + * Existing entries in the project dir: total count plus the UI-level dedupe + * keys of everything already stored (seed included). Unreadable files are + * skipped. + */ +function countExistingEntries(dir: string): { + count: number; + keys: Set; +} { + const keys = new Set(); + let count = 0; + for (const file of listProjectFiles(dir)) { + let raw = ""; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + continue; + } + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (parsed) { + count += 1; + keys.add(promptKey(parsed.text)); + } + } + } + return { count, keys }; +} + +/** + * This project's session transcript files (encoded-cwd dir match), newest + * mtime first. Returns [] when the sessions root is unreadable. + */ +function listProjectTranscripts(sessionsRoot: string, cwd: string): string[] { + try { + const dirName = cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-"); + const files = listSessionFiles(sessionsRoot).filter((file) => + file.includes(`${path.sep}--${dirName}--${path.sep}`), + ); + files.sort((a, b) => fileMtimeMs(b) - fileMtimeMs(a)); + return files; + } catch { + return []; + } +} + +/** + * Single-prompt acceptance test for seeding: not command-like, not + * tombstoned, not already stored. Returns null to skip; on accept the key + * is recorded in `existingKeys` and returned. + */ +function acceptTranscriptPrompt( + text: string, + hidden: ReadonlySet, + existingKeys: Set, +): string | null { + if (/^\/[A-Za-z]/.test(text.trim())) return null; + if (hidden.size > 0 && hidden.has(promptDedupKeyOf(text))) return null; + const key = promptKey(text); + if (existingKeys.has(key)) return null; + existingKeys.add(key); + return key; +} + +/** + * Newest-first transcript sweep: extract prompts, apply the acceptance + * test, cap at the remaining `budget` (target minus existing entries). + */ +function collectTranscriptPrompts( + files: readonly string[], + opts: { + hidden: ReadonlySet; + existingKeys: Set; + budget: number; + }, +): StoreEntry[] { + const collected: StoreEntry[] = []; + outer: for (const file of files) { + let prompts: ExtractedPrompt[] = []; + try { + prompts = extractPromptsFromFile(file).prompts; + } catch { + continue; + } + for (let i = prompts.length - 1; i >= 0; i--) { + const key = acceptTranscriptPrompt( + prompts[i].text, + opts.hidden, + opts.existingKeys, + ); + if (key === null) continue; + const entry: StoreEntry = { v: 1, text: prompts[i].text }; + if (Number.isFinite(prompts[i].ts)) entry.ts = prompts[i].ts; + collected.push(entry); + if (collected.length >= opts.budget) break outer; + } + } + return collected; +} + +export function bootstrapProjectSeed( + root: string, + cwd: string, + sessionsRoot: string, + target: number, + stateDir?: string, +): SeedResult { + const existing = countExistingEntries( + path.join(root, "projects", projectHash(cwd)), + ); + if (existing.count >= target) return { seeded: 0, ran: false }; + // The seed is written ONCE: an existing seed is never regenerated, so a + // deleted prompt cannot be resurrected from transcripts on a new session. + if (fs.existsSync(seedFilePath(root, cwd))) { + return { seeded: 0, ran: false }; + } + // Tombstones (user deletions) suppress transcript prompts from seeding. + // Tombstones (user deletions) suppress transcript prompts from seeding. + // Fail closed (spec C4): an untrusted hidden.json leaves the tombstone + // set unknown, and a wrongly seeded prompt would be permanent (the seed + // is written once, never regenerated) — skip the bootstrap instead; a + // later open retries once the file is trusted again or deleted. + let hidden = new Set(); + if (stateDir !== undefined) { + const read = readHiddenPrompts(stateDir); + if (read.status === "untrusted") return { seeded: 0, ran: false }; + hidden = read.keys; + } + const files = listProjectTranscripts(sessionsRoot, cwd); + const collected = collectTranscriptPrompts(files, { + hidden, + existingKeys: existing.keys, + budget: target - existing.count, + }); + if (collected.length === 0) return { seeded: 0, ran: false }; + + collected.reverse(); // chronological (oldest first) + const seeded = writeSeedFileAtomic(seedFilePath(root, cwd), collected); + return { seeded, ran: true }; +} + +// --------------------------------------------------------------------------- +// GC / compaction (design v2) +// --------------------------------------------------------------------------- + +const GC_FILE_THRESHOLD = 50; +const GC_LINE_THRESHOLD = 5000; +const GC_KEEP_NEWEST = 10; + +export interface GcResult { + compacted: boolean; + merged: number; +} +/** + * Threshold check + compaction entry point (called at shutdown and at + * selector close). Compacts when a project dir holds more than + * GC_FILE_THRESHOLD files or GC_LINE_THRESHOLD total lines. + */ +export function gcProjectDir( + root: string, + cwd: string, + opts: { + fileThreshold?: number; + lineThreshold?: number; + keepNewest?: number; + } = {}, +): GcResult { + const fileThreshold = opts.fileThreshold ?? GC_FILE_THRESHOLD; + const lineThreshold = opts.lineThreshold ?? GC_LINE_THRESHOLD; + const keepNewest = opts.keepNewest ?? GC_KEEP_NEWEST; + const dir = path.join(root, "projects", projectHash(cwd)); + const files = listProjectFiles(dir); // mtime-desc + if (files.length === 0) return { compacted: false, merged: 0 }; + + let totalLines = 0; + for (const file of files) { + try { + totalLines += fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim().length > 0).length; + } catch { + // unreadable file: skip counting + } + } + if (files.length <= fileThreshold && totalLines <= lineThreshold) { + return { compacted: false, merged: 0 }; + } + return compactFiles(files, keepNewest); +} + +/** + * Merge all but the newest GC_KEEP_NEWEST files into one + * `compact--.jsonl` (chronological within the merged content). One + * atomic write; the originals are removed only after the compact file + * lands. Readers see either the old set or the compacted set. + */ +export function compactProjectDir( + root: string, + cwd: string, + opts: { keepNewest?: number } = {}, +): GcResult { + const dir = path.join(root, "projects", projectHash(cwd)); + return compactFiles(listProjectFiles(dir), opts.keepNewest ?? GC_KEEP_NEWEST); +} + +function compactFiles(filesMtimeDesc: string[], keepNewest: number): GcResult { + if (filesMtimeDesc.length <= keepNewest) { + return { compacted: false, merged: 0 }; + } + const toMerge = filesMtimeDesc.slice(keepNewest); // oldest tail + const mergedLines: string[] = []; + for (const file of toMerge) { + try { + const raw = fs.readFileSync(file, "utf8"); + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (parsed) mergedLines.push(JSON.stringify(parsed)); + } + } catch {} + } + if (mergedLines.length === 0) return { compacted: false, merged: 0 }; + + const dir = path.dirname(toMerge[0]); + const compact = path.join(dir, `compact-${process.pid}-${Date.now()}.jsonl`); + const tmp = `${compact}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmp, `${mergedLines.join("\n")}\n`, "utf8"); + fs.renameSync(tmp, compact); + for (const file of toMerge) { + try { + fs.rmSync(file); + } catch { + // a surviving original is harmless (readers dedupe by identity) + } + } + return { compacted: true, merged: toMerge.length }; +} diff --git a/lib/pi-tui-keys.ts b/lib/pi-tui-keys.ts new file mode 100644 index 000000000..6b6883d91 --- /dev/null +++ b/lib/pi-tui-keys.ts @@ -0,0 +1,53 @@ +/** + * Vendored printable-key decoding for the packed runtime. + * + * pi-tui does not export `decodePrintableKey` from its package root, and the + * packed runtime cannot resolve deep subpath imports such as + * `@earendil-works/pi-tui/dist/keys.js` (they load as mangled + * `dist/index.js/dist/keys.js` paths inside downstream projects). The root + * does export `decodeKittyPrintable`, so only the modifyOtherKeys half is + * vendored here. + * + * Behavior is copied verbatim from @earendil-works/pi-tui dist/keys.js so + * replacement input decodes exactly like the base editor inserts. + */ +import { decodeKittyPrintable } from "@earendil-works/pi-tui"; + +const MODIFIERS = { + shift: 1, + alt: 2, + ctrl: 4, + super: 8, +}; + +const LOCK_MASK = 64 + 128; // Caps Lock + Num Lock + +interface ModifyOtherKeysSequence { + codepoint: number; + modifier: number; +} + +function parseModifyOtherKeysSequence(data: string): ModifyOtherKeysSequence | null { + const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/); + if (!match) return undefined; + const modValue = Number.parseInt(match[1], 10); + const codepoint = Number.parseInt(match[2], 10); + return { codepoint, modifier: modValue - 1 }; +} + +function decodeModifyOtherKeysPrintable(data: string): string | undefined { + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed) return undefined; + const modifier = parsed.modifier & ~LOCK_MASK; + if ((modifier & ~MODIFIERS.shift) !== 0) return undefined; + if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined; + try { + return String.fromCodePoint(parsed.codepoint); + } catch { + return undefined; + } +} + +export function decodePrintableKey(data: string): string | undefined { + return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data); +} diff --git a/lib/selection-engine.ts b/lib/selection-engine.ts new file mode 100644 index 000000000..24a13a80e --- /dev/null +++ b/lib/selection-engine.ts @@ -0,0 +1,420 @@ +import { decodePrintableKey } from "./pi-tui-keys.ts"; +import { isKeyRelease, matchesKey, truncateToWidth, visibleWidth, type EditorComponent } from "@earendil-works/pi-tui"; + +/** + * Native text-selection engine for GentlePromptEditor: shift+home / shift+end + * selection, alt+a select-all, and replace-on-key (backspace / delete / + * printable character) with reverse-video highlight and a bottom-rule hint. + * Ported from @exopro/pi-select-del so the petal prompt owns the feature + * natively — no factory composition, no cross-extension focus handoff. + * + * Behavior contract (identical to pi-select-del): + * - shift+home — anchor at the cursor, move to line start (held presses at + * the edge keep the selection; only a zero-width span collapses) + * - shift+end — anchor at the cursor, move to line end + * - alt+a — select all + * - backspace / delete / printable character over an active selection — + * replace it in one atomic edit (undo restores text AND cursor) + * - any other key — collapse the selection first, then behave natively + * + * The engine drives the host editor's own internals (the EditorInternals + * surface every CustomEditor-derived editor exposes) and degrades to pure + * passthrough if that surface drifts: a pi upgrade can cost the selection + * features, never crash editing. + */ + +/** Cursor/anchor position in logical (line, col) editor coordinates. */ +export interface Point { + line: number; + col: number; +} + +/** + * Narrow view of the private Editor internals the engine relies on. Kept in + * one place so a pi upgrade only needs re-verifying against this interface. + */ +export interface EditorInternals { + state: { lines: string[]; cursorLine: number; cursorCol: number }; + paddingX: number; + scrollOffset: number; + renderedVisibleLineCount: number; + /** Layout width the last base render wrapped at; soft-optional — falsy falls back to manual width math. */ + lastWidth: number; + tui: { requestRender(): void }; + autocompleteState: "regular" | "force" | null; + lastAction: "kill" | "yank" | "type-word" | null; + pushUndoSnapshot(): void; + setCursorCol(col: number): void; + moveToLineStart(): void; + moveToLineEnd(): void; + exitHistoryBrowsing(): void; + cancelAutocomplete(): void; + updateAutocomplete(): void; + buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }>; +} + +const INTERNAL_PROBE_MEMBERS = { + properties: [ + "state", + "paddingX", + "scrollOffset", + "renderedVisibleLineCount", + "autocompleteState", + "lastAction", + "tui", + ], + functions: [ + "pushUndoSnapshot", + "setCursorCol", + "moveToLineStart", + "moveToLineEnd", + "exitHistoryBrowsing", + "cancelAutocomplete", + "updateAutocomplete", + "buildVisualLineMap", + ], +} as const satisfies { + properties: readonly (keyof EditorInternals)[]; + functions: readonly (keyof EditorInternals)[]; +}; + +/** + * Members of `target` missing or malformed against the EditorInternals + * contract. Class-field trap: the properties are ES class fields (instance + * own-properties), invisible to any prototype probe — probe a real instance, + * never Editor.prototype or a subclass prototype. + */ +export function missingEditorInternals(target: object): string[] { + const missing: string[] = []; + const view = target as Record; + for (const name of INTERNAL_PROBE_MEMBERS.properties) { + if (view[name] === undefined) missing.push(name); + } + for (const name of INTERNAL_PROBE_MEMBERS.functions) { + if (typeof view[name] !== "function") missing.push(name); + } + return missing; +} + +export function clamp(value: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, value)); +} + +/** + * Wrap the code-unit span [startCu, endCu) of a rendered editor row in reverse + * video. Positions are code-unit offsets into the row's PLAIN text (same unit + * the editor uses for cursorCol and visual-line map columns). The rendered row + * may already contain escape sequences (SGR colors, cursor markers); the walk + * passes those through untouched, keeping code-unit alignment. The span closes + * with SGR 27 (reverse off), not a full reset: row attributes set before the + * span must survive. A nested SGR reset re-arms reverse right after it. + */ +export function withReverseSpan(row: string, startCu: number, endCu: number): string { + let out = ""; + let cu = 0; + let i = 0; + let opened = false; + while (i < row.length) { + if (!opened && cu >= startCu) { + out += "\x1b[7m"; + opened = true; + } + if (opened && cu >= endCu) { + return `${out}\x1b[27m${row.slice(i)}`; + } + if (row[i] === "\x1b") { + const seq = row.slice(i, i + escapeSequenceLength(row, i)); + out += seq; + // A nested SGR reset (e.g. the cursor block's own, when the cursor + // sits inside the span) clears reverse for everything after it: + // re-arm reverse right after it. + if (opened && cu < endCu && isSgrReset(seq)) out += "\x1b[7m"; + i += seq.length; + continue; + } + out += row[i]; + i += 1; + cu += 1; + } + return opened ? `${out}\x1b[27m` : out; +} + +/** Length of the escape sequence at s[i] (s[i] === ESC). Unterminated sequences end the row. */ +export function escapeSequenceLength(s: string, i: number): number { + const next = s[i + 1]; + if (next === "[") { + // CSI: parameter/intermediate bytes 0x20-0x3F, final byte 0x40-0x7E. + for (let j = i + 2; j < s.length; j++) { + const code = s.charCodeAt(j); + if (code >= 0x40 && code <= 0x7e) return j - i + 1; + } + return s.length - i; + } + if (next === "]" || next === "_") { + // OSC / APC (cursor markers are APC): terminated by BEL or ST (ESC \). + const bel = s.indexOf("\x07", i + 2); + const st = s.indexOf("\x1b\\", i + 2); + if (bel === -1 && st === -1) return s.length - i; + if (bel === -1) return st - i + 2; + if (st === -1) return bel - i + 1; + return Math.min(bel - i + 1, st - i + 2); + } + return 2; +} + +/** True for SGR reset sequences: CSI ... m with an empty or all-zero parameter list. */ +export function isSgrReset(seq: string): boolean { + const match = /^\x1b\[([\d;]*)m$/.exec(seq); + if (!match) return false; + const params = match[1]; + if (params === "") return true; + return /^0+$/.test(params.split(";")[0]); +} + +/** + * Selection engine driving a host editor through the EditorInternals cast. + * `handleInput` takes the host's native dispatch as a `native` callback so the + * host keeps its own key chain (Esc gates, autocomplete, history) intact: + * selection keys are handled here; everything else collapses the anchor first, + * then behaves natively. + */ +export class SelectionEngine { + /** Selection anchor in logical (line, col) coordinates; adapter-exposed state. */ + anchor: Point | null = null; + + /** + * Capability probe cache for the host internals: null until first use, then + * the (possibly empty) list of missing members, computed once on the first + * handleInput/render call. Any defect permanently DEGRADES the host to + * passthrough — selection features off, never a crash. + */ + private internalDefects: string[] | null = null; + + private readonly editor: EditorComponent; + + constructor(editor: EditorComponent) { + this.editor = editor; + } + + /** True once the probe found missing internals; computes and caches the probe on first call. */ + get degraded(): boolean { + if (this.internalDefects === null) { + this.internalDefects = missingEditorInternals(this.editor); + } + return this.internalDefects.length > 0; + } + + private get internals(): EditorInternals { + return this.editor as unknown as EditorInternals; + } + + private get s(): EditorInternals["state"] { + return this.internals.state; + } + + private cursor(): Point { + return { line: this.s.cursorLine, col: this.s.cursorCol }; + } + + private setCursor(p: Point): void { + this.s.cursorLine = p.line; + this.internals.setCursorCol(p.col); + } + + /** Ordered (start, end) selection range, or null when no anchor is set. */ + range(): [Point, Point] | null { + if (!this.anchor) return null; + const a = this.anchor; + const c = this.cursor(); + const anchorFirst = a.line < c.line || (a.line === c.line && a.col < c.col); + return anchorFirst ? [a, c] : [c, a]; + } + + /** Number of characters covered by the active selection (0 when none). Counts line breaks a deletion would remove. */ + selectionLength(): number { + const range = this.range(); + if (!range) return 0; + const [start, end] = range; + const lines = this.s.lines; + if (start.line === end.line) return end.col - start.col; + let n = (lines[start.line] ?? "").length - start.col; + for (let i = start.line + 1; i < end.line; i++) n += (lines[i] ?? "").length; + return n + end.col + (end.line - start.line); + } + + /** + * Selection dispatch in front of the native chain. Selection and replace + * keys are handled here; everything else collapses the anchor first, then + * behaves natively. Degraded hosts keep pure native key handling. + */ + handleInput(data: string, native: (data: string) => void): void { + if (this.degraded) { + native(data); + return; + } + // Kitty flag 2 release byte strings still match their own key, so + // releases must be dropped before any matchesKey. + if (isKeyRelease(data)) return; + if (matchesKey(data, "shift+home")) { + this.selectToLineEdge(false); + return; + } + if (matchesKey(data, "shift+end")) { + this.selectToLineEdge(true); + return; + } + if (matchesKey(data, "alt+a")) { + this.selectAll(); + return; + } + + if (this.anchor) { + if (this.isReplaceKey(data)) { + const replaced = this.replaceSelection(data); + if (replaced) return; + // Selection collapsed to empty (anchor met cursor): native key behavior. + this.anchor = null; + native(data); + return; + } + // Movement, enter, history, kill/yank, app shortcuts: collapse first, then native behavior. + this.anchor = null; + } + + native(data); + } + + /** Keys whose native effect replaces a selection: backspace/delete (and shift variants) or a printable character. */ + private isReplaceKey(data: string): boolean { + return ( + matchesKey(data, "backspace") || + matchesKey(data, "shift+backspace") || + matchesKey(data, "delete") || + matchesKey(data, "shift+delete") || + this.insertsCharacter(data) + ); + } + + /** + * True when the input inserts a text character (Kitty/CSI-u and + * modify-other-keys aware, plus raw terminal bytes). The raw fallback + * accepts at most 4 UTF-16 code units with no control bytes; DEL and C1 + * controls must not count as printable (the editor routes them to + * delete/other actions before its printable fallback, so re-submitting + * them after a splice would double-edit). + */ + private insertsCharacter(data: string): boolean { + if (decodePrintableKey(data) !== undefined) return true; + if (data.length > 4) return false; + for (let i = 0; i < data.length; i++) { + const c = data.charCodeAt(i); + if (c < 32 || c === 127 || (c >= 0x80 && c <= 0x9f)) return false; + } + return true; + } + + private selectToLineEdge(toEnd: boolean): void { + const before = this.cursor(); + if (toEnd) this.internals.moveToLineEnd(); + else this.internals.moveToLineStart(); + this.internals.exitHistoryBrowsing(); + // Repeated or held presses at the edge KEEP the selection: legacy + // terminals repeat the press byte-identically. Only a zero-width span + // (cursor landed exactly on the anchor) collapses. + this.anchor ??= before; + if (this.anchor.line === this.s.cursorLine && this.anchor.col === this.s.cursorCol) { + this.anchor = null; + } + if (this.internals.autocompleteState) this.internals.updateAutocomplete(); + this.internals.tui.requestRender(); + } + + /** Select the entire editor text (alt+a). Cursor moves to the end of the last line. */ + private selectAll(): void { + const lines = this.s.lines; + const lastLine = Math.max(0, lines.length - 1); + this.anchor = { line: 0, col: 0 }; + this.s.cursorLine = lastLine; + this.internals.setCursorCol((lines[lastLine] ?? "").length); + this.internals.lastAction = null; + this.internals.exitHistoryBrowsing(); + if (this.internals.autocompleteState) this.internals.cancelAutocomplete(); + this.internals.tui.requestRender(); + } + + /** + * Replace the active selection in ONE atomic edit (delete, or delete + + * printable character): a single undo snapshot before any mutation, then + * one splice. Returns false when the span is empty; the caller falls back + * to native key behavior. + */ + private replaceSelection(data: string): boolean { + const range = this.range(); + if (!range) return false; + const [start, end] = range; + if (start.line === end.line && start.col === end.col) return false; + const resolved = decodePrintableKey(data) ?? (this.insertsCharacter(data) ? data : undefined); + const cp = resolved?.codePointAt(0) ?? 0; + const char = cp === 127 || (cp >= 0x80 && cp <= 0x9f) ? undefined : resolved; + const internals = this.internals; + const lines = internals.state.lines; + internals.pushUndoSnapshot(); + const merged = + (lines[start.line] ?? "").slice(0, start.col) + (char ?? "") + (lines[end.line] ?? "").slice(end.col); + lines.splice(start.line, end.line - start.line + 1, merged); + this.anchor = null; + this.setCursor({ line: start.line, col: start.col + (char?.length ?? 0) }); + internals.exitHistoryBrowsing(); + internals.lastAction = null; + if (internals.autocompleteState) internals.cancelAutocomplete(); + this.editor.onChange?.(this.editor.getText()); + internals.tui.requestRender(); + return true; + } + + /** + * Render post-pass: wrap the selected span of each visible row in reverse + * video. `inset` is the number of columns the host render adds on EACH side + * before the editor's own content (0 for plain content rows, 1 for a + * one-column frame wall); `width` is the width the content was rendered at. + */ + decorateRows(rows: string[], width: number, inset: number): string[] { + const range = this.range(); + if (!range) return rows; + const internals = this.internals; + const [start, end] = range; + const contentWidth = Math.max(1, width - inset * 2 - internals.paddingX * 2); + const layoutWidth = internals.lastWidth || Math.max(1, contentWidth - (internals.paddingX ? 0 : 1)); + const visual = internals.buildVisualLineMap(layoutWidth); + for (let r = 0; r < internals.renderedVisibleLineCount; r++) { + const vr = visual[internals.scrollOffset + r]; + if (!vr || vr.logicalLine < start.line || vr.logicalLine > end.line) continue; + const from = + clamp(vr.logicalLine === start.line ? start.col - vr.startCol : 0, 0, vr.length) + + internals.paddingX + + inset; + const to = + clamp(vr.logicalLine === end.line ? end.col - vr.startCol : vr.length, 0, vr.length) + + internals.paddingX + + inset; + if (to <= from) continue; + const index = 1 + r; // rows[0] is the top border/rule in every layout + if (index < rows.length) rows[index] = withReverseSpan(rows[index] ?? "", from, to); + } + return rows; + } + + /** + * Bottom-rule selection hint. `corner` re-attaches the host frame's right + * corner glyph after the label. Widths always add up: the rule is truncated + * to make exact room for label + corner. + */ + decorateBottomRule(baseRow: string, width: number, corner: string): string { + const n = this.selectionLength(); + if (n <= 0) return baseRow; + const label = ` ${n} char${n === 1 ? "" : "s"} selected - Del deletes - Alt+a select all `; + const labelWidth = visibleWidth(label); + if (labelWidth + visibleWidth(corner) + 1 >= width) return baseRow; + return `${truncateToWidth(baseRow, width - labelWidth - visibleWidth(corner), "")}${label}${corner}`; + } +} diff --git a/tests/history-atomic-write.test.ts b/tests/history-atomic-write.test.ts new file mode 100644 index 000000000..5448398e1 --- /dev/null +++ b/tests/history-atomic-write.test.ts @@ -0,0 +1,57 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { writeJsonAtomic } from "../extensions/history/atomic-write.ts"; + +// Shared atomic writer (design §D3): tmp+rename in the TARGET's directory. +// Fixtures live under the OS temp dir — never the workspace tmp. Failure +// paths exercise the catch branch: false return, no throw, and the staging +// file unlinked so `.tmp-*` files never accumulate beside the target. + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "atomic-write-")); +} + +function tmpLeftovers(dir: string): string[] { + return fs.readdirSync(dir).filter((f) => f.includes(".tmp-")); +} + +test("success: missing parent dirs are created recursively and the JSON parses back", () => { + const root = makeRoot(); + const target = path.join(root, "deep", "nested", "state.json"); + const value = { key: "value", nested: { n: 1 } }; + assert.equal(writeJsonAtomic(target, value), true); + assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), value); + assert.deepEqual(tmpLeftovers(path.dirname(target)), []); +}); + +test("a parent chain holding a regular file (ENOTDIR) returns false without throwing", () => { + const root = makeRoot(); + const blocker = path.join(root, "blocker"); + fs.writeFileSync(blocker, "regular file", "utf8"); + const target = path.join(blocker, "child", "state.json"); + assert.equal(writeJsonAtomic(target, { a: 1 }), false); + assert.equal(fs.existsSync(target), false); +}); + +test("an existing directory as the target fails the rename: false and no leftover staging file", () => { + const root = makeRoot(); + const target = path.join(root, "state.json"); + fs.mkdirSync(target, { recursive: true }); + assert.equal(writeJsonAtomic(target, { a: 1 }), false); + // The catch branch unlinked its staging file — no `.tmp-*` accumulation. + assert.deepEqual(tmpLeftovers(root), []); + // The directory itself is untouched. + assert.equal(fs.statSync(target).isDirectory(), true); +}); + +test("overwrite of an existing target replaces the content", () => { + const root = makeRoot(); + const target = path.join(root, "state.json"); + assert.equal(writeJsonAtomic(target, { v: 1 }), true); + assert.equal(writeJsonAtomic(target, { v: 2 }), true); + assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { v: 2 }); + assert.deepEqual(tmpLeftovers(root), []); +}); diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts new file mode 100644 index 000000000..f37df90b7 --- /dev/null +++ b/tests/history-command-registration.test.ts @@ -0,0 +1,84 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; + +// Source-parsing tests (preview-layout.test.ts pattern): never import +// src/index.ts — it pulls the pi-tui runtime graph (design §D3). + +const sourcePath = fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)); +const source = fs.readFileSync(sourcePath, "utf8"); + +test("openHistorySelector is extracted once and shared by both entry points", () => { + const definitions = + source.split("async function openHistorySelector(").length - 1; + assert.strictEqual( + definitions, + 1, + "openHistorySelector should be defined exactly once", + ); + + const calls = source.split("openHistorySelector(ctx)").length - 1; + assert.strictEqual( + calls, + 2, + "registerShortcut and registerCommand handlers should both call openHistorySelector(ctx)", + ); + + const start = source.indexOf("async function openHistorySelector("); + const end = source.indexOf("export default function", start); + assert.notStrictEqual(end, -1, "extension entry point should follow"); + const body = source.slice(start, end); + assert.ok( + !body.includes('"No prompt history available."'), + "the warning is removed; the selector always opens (AC-P1-5.2)", + ); +}); + +test("the /history command is registered beside the shortcut", () => { + const index = source.indexOf('pi.registerCommand("history"'); + assert.ok(index >= 0, 'pi.registerCommand("history", ...) should exist'); + + const slice = source.slice(index, index + 200); + assert.ok( + slice.includes('"Search prompt history"'), + "command should carry the same description as the shortcut", + ); + assert.ok( + slice.includes("openHistorySelector(ctx)"), + "command handler should route through the shared entry point", + ); +}); + +test("in-UI hint describes multi-word AND substring matching, not fuzzy", () => { + assert.ok( + !source.includes("fzf-style fuzzy match"), + "the fzf-style fuzzy match claim must be removed (AC-P1-6.1)", + ); + assert.ok( + source.includes("multi-word AND substring"), + "hint should describe multi-word AND substring filtering (AC-P1-6.1)", + ); +}); + +test("writer init is scheduled off the first-prompt path via setImmediate", () => { + const entry = source.indexOf("export default function promptHistoryExtension"); + assert.notStrictEqual(entry, -1, "extension entry point should exist"); + + const body = source.slice(entry); + assert.ok( + body.includes("setImmediate(() => {"), + "init must be scheduled with setImmediate so bootstrap never runs on\nthe first-prompt path", + ); + assert.ok( + /setImmediate\(\(\) => \{[\s\S]*?getWriter\(\);/.test(body), + "the scheduled callback should warm getWriter()", + ); + // The synchronous fallback stays: a prompt arriving before the + // scheduled call still initializes lazily inside the capture handler. + assert.ok( + /before_agent_start[\s\S]*?appendSessionCapture\(getWriter\(\)/.test(body), + "capture handler keeps the synchronous getWriter() fallback", + ); +}); diff --git a/tests/history-dedupe-entries.test.ts b/tests/history-dedupe-entries.test.ts new file mode 100644 index 000000000..331720599 --- /dev/null +++ b/tests/history-dedupe-entries.test.ts @@ -0,0 +1,179 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; +import { dedupePromptEntries } from "../extensions/history/selector-helpers.ts"; + +// AC-L5-1..AC-L5-5 — read-time dedup pass (spec C3, design §D5). +// +// The normalization key MUST byte-match the APPLIED patch form from +// nav/patches/editor.cjs :480–:586: +// +// entry.replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase() +// +// The raw patch file stores `\\s+` because the replacement code sits inside +// a template literal; the code that actually runs in the editor contains +// `/\s+/g`. An implementation copying the double-backslash form would build +// a regex matching a literal backslash: whitespace variants would stop +// collapsing (T1 fails) and empty-key entries would leak through (T2 fails). + +// T1 — AC-L5-1: keep-first over newest-first input order (file order). + +test("keep-first: [A, B, A″] where A″ normalizes equal to A yields [A, B] (AC-L5-1)", () => { + const a = "deploy the API"; + const b = "write the tests"; + const aDoublePrime = "deploy the API"; + assert.deepEqual(dedupePromptEntries([a, b, aDoublePrime]), [a, b]); +}); + +// T1 — AC-L5-2: normalization groups each collapse to their first entry. + +test("normalization group: internal whitespace runs collapse to the first entry (AC-L5-2)", () => { + const first = "run the build now"; + assert.deepEqual( + dedupePromptEntries([ + first, + "run the build now", + "run the build now ", + " run the build now", + ]), + [first], + ); +}); + +test("normalization group: tabs and newlines collapse to the first entry (AC-L5-2)", () => { + const first = "run the build now"; + assert.deepEqual( + dedupePromptEntries([ + first, + "run\tthe\tbuild\tnow", + "run\nthe\nbuild\nnow", + "run \t the \n build now", + ]), + [first], + ); +}); + +test("normalization group: letter case collapses to the first entry (AC-L5-2)", () => { + const first = "Run The Build NOW"; + assert.deepEqual( + dedupePromptEntries([first, "run the build now", "RUN THE BUILD NOW"]), + [first], + ); +}); + +// T1 — AC-L5-2: 120-char normalized prefix collisions collapse (accepted +// patch-mirror semantics, NOT the P5 dedup-key fix). + +test("entries sharing the normalized 120-char prefix but differing later collapse (AC-L5-2)", () => { + const prefix = "x".repeat(120); + const first = `${prefix} tail one`; + const second = `${prefix} tail two`; + assert.deepEqual(dedupePromptEntries([first, second]), [first]); +}); + +test("entries differing within the first 120 normalized chars stay distinct (AC-L5-2)", () => { + const a = `${"x".repeat(119)}a ${"y".repeat(10)}`; + const b = `${"x".repeat(119)}b ${"y".repeat(10)}`; + assert.deepEqual(dedupePromptEntries([a, b]), [a, b]); +}); + +// T2 — AC-L5-3: empty-key entries (empty string, whitespace-only) are +// skipped: excluded from the output, never usable as collision keys, real +// entries pass through. + +test("empty-key entries are excluded from the output while real entries pass through (AC-L5-3)", () => { + const real = "a real prompt"; + assert.deepEqual(dedupePromptEntries(["", " ", "\t\n ", real, "\t"]), [ + real, + ]); +}); + +test("whitespace-only entries never shadow real entries as collision keys (AC-L5-3)", () => { + const first = "another real prompt"; + assert.deepEqual(dedupePromptEntries(["", " ", first]), [first]); +}); + +// T2 — AC-L5-5: no truncation beyond duplicate removal (no snapshot cap). + +test("output length equals input length minus duplicate-normalized entries (AC-L5-5)", () => { + const entries = [ + "alpha", + "alpha", // duplicate of 0 + "beta", + " ALPHA ", // duplicate of 0 (case + whitespace) + "beta\t", // duplicate of 2 + "gamma", + ]; + assert.equal(dedupePromptEntries(entries).length, 3); +}); + +test("no snapshot cap: every unique entry is kept past MAX_RESULTS (AC-L5-5)", () => { + const entries: string[] = []; + for (let i = 0; i < 1200; i++) { + entries.push(`unique prompt number ${i}`); + } + const deduped = dedupePromptEntries(entries); + assert.equal(deduped.length, 1200); + assert.equal(deduped[0], entries[0]); + assert.equal(deduped[1199], entries[1199]); +}); + +// T3 — AC-L5-4 (source-parse, command-registration.test.ts pattern): never +// import src/index.ts — it pulls the pi-tui runtime graph (design §D3). + +const sourcePath = fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)); +const source = fs.readFileSync(sourcePath, "utf8"); + +test("dedupePromptEntries is wired between the store drain and buildPromptRecords in openHistorySelector (AC-L5-4)", () => { + const loadIdx = source.indexOf('drainForScope("project")'); + assert.ok( + loadIdx >= 0, + "store drain call should exist in openHistorySelector", + ); + + const dedupeCallIdx = source.indexOf("dedupePromptEntries(", loadIdx); + assert.ok( + dedupeCallIdx > loadIdx, + "dedup invocation must come after the store drain call", + ); + + const buildIdx = source.indexOf("buildPromptRecords("); + assert.ok( + buildIdx > loadIdx, + "buildPromptRecords call should follow the loadSharedHistory call", + ); + assert.ok( + source + .slice(buildIdx, buildIdx + "buildPromptRecords(".length + 40) + .includes("dedupePromptEntries(entries)"), + "records must be built from dedupePromptEntries(entries) — the read-time dedup runs between load and build (design §B1)", + ); +}); + +test("the three command-registration pins still hold beside the dedup wiring (AC-L5-4)", () => { + const definitions = + source.split("async function openHistorySelector(").length - 1; + assert.strictEqual( + definitions, + 1, + "openHistorySelector should be defined exactly once", + ); + + const calls = source.split("openHistorySelector(ctx)").length - 1; + assert.strictEqual( + calls, + 2, + "the dedup wiring must add no openHistorySelector(ctx) occurrence", + ); + + const start = source.indexOf("async function openHistorySelector("); + const end = source.indexOf("export default function", start); + assert.notStrictEqual(end, -1, "extension entry point should follow"); + const body = source.slice(start, end); + assert.ok( + !body.includes('"No prompt history available."'), + "the warning is removed; the selector always opens", + ); +}); diff --git a/tests/history-delete-backfill.test.ts b/tests/history-delete-backfill.test.ts new file mode 100644 index 000000000..5abd6c1ad --- /dev/null +++ b/tests/history-delete-backfill.test.ts @@ -0,0 +1,188 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { + deletionActionsFor, + loadedCountAfterDelete, +} from "../extensions/history/selector-helpers.ts"; + +// Unit 3 — L4 delete backfill (spec C4, design §B3). +// +// C4's contract as a verbatim two-step: a successful delete splices the +// master snapshot AND shrinks the loaded window together; while unloaded +// rows remain, the window backfills one row (clamped) so the next unloaded +// record slides into the deleted slot and the visible list length stays +// stable; at exhaustion (loadedCount == records.length after the decrement) +// there is NO backfill — the visible set genuinely shrinks by one row, by +// design. +// +// The deleted record always comes from filteredRecords ⊆ the loaded prefix, +// so idx < loadedCount by construction (§B3). Change 1's delete contract +// (delete-prompt.ts, error-only notify) is UNTOUCHED — AC-L4-4's regression +// pin is test/history/delete-prompt.test.ts itself, green and unmodified. + +// T11 — AC-L4-1 + AC-L4-2: mid-window delete with unloaded rows remaining — +// the count is preserved by pulling the next record: (30, 99) decrements to +// 29, 29 < 99, so backfill min(29 + 1, 99) = 30 (stable window). + +test("loadedCountAfterDelete backfills while unloaded rows remain — stable window (AC-L4-1, AC-L4-2)", () => { + assert.equal(loadedCountAfterDelete(30, 99), 30); +}); + +// T11 — AC-L4-3: exhaustion shrink — the window was fully loaded (100 of 100, +// 99 after the splice), so the decrement is the genuine shrink, no backfill: +// 99 < 99 is false → 99. + +test("loadedCountAfterDelete shrinks genuinely at exhaustion (AC-L4-3)", () => { + assert.equal(loadedCountAfterDelete(100, 99), 99); +}); + +// T11 — AC-L4-3 terminal case: deleting the last loaded row on an exhausted +// window bottoms out at 0: (1, 0) decrements to 0, 0 < 0 is false → 0. + +test("loadedCountAfterDelete bottoms out at 0 on the terminal delete (AC-L4-3)", () => { + assert.equal(loadedCountAfterDelete(1, 0), 0); +}); + +// T11 — defensive degenerate row: an empty window stays 0 even when counts +// disagree: (0, 5) decrements to −1, −1 < 5, so min(−1 + 1, 5) = 0. +// Unreachable via executeDelete (a delete implies a selected row inside the +// loaded prefix) — pinned as C4's defensive bound. + +test("loadedCountAfterDelete is defensive for an empty window (AC-L4-1)", () => { + assert.equal(loadedCountAfterDelete(0, 5), 0); +}); + +// T11 — AC-L4-1 + AC-L4-4 (source-parse): ordering shape inside executeDelete +// — the bookkeeping call sits strictly between the existing splice and the +// trailing applyFilter, INSIDE the existing `if (idx !== -1)` guarded block, +// and the non-`deleted` early return still precedes every mutation +// (Change 1 C1 interplay unchanged). + +const selectorSource = fs.readFileSync( + path.join(process.cwd(), "extensions", "history", "index.ts"), + "utf8", +); + +test("executeDelete splices, backfills, then re-filters — inside the guarded block (AC-L4-1, AC-L4-4)", () => { + const decl = selectorSource.indexOf("private executeDelete(): void {"); + assert.ok(decl >= 0, "executeDelete should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "executeDelete's body should close"); + const body = selectorSource.slice(decl, end); + + const earlyReturnAt = body.indexOf("if (removed === 0) return;"); + const spliceAt = body.indexOf("this.records.splice("); + const backfillAt = body.indexOf("loadedCountAfterDelete("); + const refilterAt = body.lastIndexOf("this.applyFilter("); + assert.ok(earlyReturnAt >= 0, "the Change 1 early return must stay"); + assert.ok(spliceAt >= 0, "the existing splice must stay"); + assert.ok( + backfillAt >= 0, + "the loadedCountAfterDelete bookkeeping call must exist", + ); + assert.ok(refilterAt >= 0, "the trailing applyFilter must stay"); + assert.ok( + earlyReturnAt < spliceAt && + spliceAt < backfillAt && + backfillAt < refilterAt, + "ordering must be: early return → splice → backfill → re-filter", + ); + + // Inside the guarded block: no 4-space block closer may appear between the + // `if (idx !== -1)` guard and the bookkeeping call (the block's own close + // sits only AFTER the call). + const guardAt = body.indexOf("if (idx !== -1)"); + assert.ok(guardAt >= 0, "the `if (idx !== -1)` guard must stay"); + const guardToCall = body.slice(guardAt, backfillAt); + assert.ok( + !guardToCall.includes("\n }"), + "the bookkeeping must sit inside the `if (idx !== -1)` block", + ); + + // The call assigns this.loadedCount from the unfiltered counts only. + assert.ok( + body.includes("this.loadedCount = loadedCountAfterDelete("), + "the call must assign this.loadedCount", + ); + const callRegion = body.slice(backfillAt, refilterAt); + assert.ok( + callRegion.includes("this.loadedCount") && + callRegion.includes("this.records.length"), + "the bookkeeping must read the unfiltered window and the shrunk snapshot", + ); +}); + +// Slice 5 scenario pins (porting contract): the editor-path tombstone rule +// and the partial-failure toast path. The dev suite pins the planner + +// these delete-flow branch shapes in delete-confirm.test.ts; this file +// carries the delete-flow source-parse half so the slice-5 branch stays +// pinned inside the delete slice's own tests. The mutation flow lives in +// executeDelete() (slice-05 D3 split), so the parse targets that method. + +test("deletionActionsFor plans a store delete + tombstone for editor rows and NOTHING for session rows", () => { + // Session/seed-born records are READ-ONLY (slice-05 D1): no store delete + // and no tombstone — deleteCurrent guards the source before the flow, so + // a transcript-born prompt is never written or deleted by this + // extension. + assert.deepEqual(deletionActionsFor("session"), { + deleteFromEditorStore: false, + writeTombstone: false, + }); + // Editor records: disk delete AND tombstone (twin suppression). + assert.deepEqual(deletionActionsFor("editor"), { + deleteFromEditorStore: true, + writeTombstone: true, + }); + + const decl = selectorSource.indexOf("private executeDelete(): void {"); + assert.ok(decl >= 0, "executeDelete should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "executeDelete's body should close"); + const body = selectorSource.slice(decl, end); + + // Branch shape: the tombstone write follows (never sits inside) the + // editor-store guard — the executing path is editor-only, and its hide + // suppresses the session twin that would re-supply the prompt. + const editorGuardAt = body.indexOf("if (actions.deleteFromEditorStore)"); + assert.ok(editorGuardAt >= 0, "the editor-store guard must exist"); + const guardCloseAt = body.indexOf("\n }", editorGuardAt); + assert.ok(guardCloseAt > editorGuardAt, "the editor-store guard must close"); + const hideAt = body.indexOf("hidePrompt("); + assert.ok(hideAt >= 0, "the tombstone write must exist"); + assert.ok( + hideAt > guardCloseAt, + "the tombstone must follow (not sit inside) the editor-store guard", + ); +}); + +test("a failed hide toasts and only the session path aborts — the editor path still splices", () => { + const decl = selectorSource.indexOf("private executeDelete(): void {"); + assert.ok(decl >= 0, "executeDelete should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "executeDelete's body should close"); + const body = selectorSource.slice(decl, end); + + const gateAt = body.indexOf('if (hide.status === "error")'); + assert.ok(gateAt >= 0, "hide errors must be gated"); + const spliceAt = body.indexOf("this.records.splice("); + assert.ok( + gateAt < spliceAt, + "the hide-error gate must precede the splice", + ); + const gate = body.slice(gateAt, spliceAt); + assert.ok( + gate.includes('this.onNotify?.(hide.message, "error")'), + "a hide error must toast", + ); + const abortGuardAt = gate.indexOf("if (!actions.deleteFromEditorStore)"); + assert.ok( + abortGuardAt >= 0, + "the early return must be exclusive to the session path", + ); + assert.ok( + !gate.slice(0, abortGuardAt).includes("return;"), + "no unconditional abort before the editor/session split — the editor path splices", + ); +}); diff --git a/tests/history-delete-confirm.test.ts b/tests/history-delete-confirm.test.ts new file mode 100644 index 000000000..c755364d7 --- /dev/null +++ b/tests/history-delete-confirm.test.ts @@ -0,0 +1,354 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + deleteConfirmFooterText, + deleteConfirmStep, + deletionActionsFor, + EDITOR_HIDE_FAILED_TEXT, + STORE_DELETE_FAILED_TEXT, +} from "../extensions/history/selector-helpers.ts"; + +// Slice-05 delete-confirm tests (PR #1393 follow-up): the delete +// confirmation is a MODAL y/n step. The first ctrl+shift+backspace press +// ARMS the delete for the selected row (confirmation footer + highlighted +// record) and executes NOTHING; while armed, y executes, n/Esc cancels, +// and every other key is swallowed with the confirm still armed — nothing +// reaches the dispatch table or the search input. Session-derived rows +// are read-only: a delete press on one is a silent no-op. +// +// PromptHistorySelector is private to extensions/history/index.ts and +// needs the pi-tui runtime graph (openflow-integration.test.ts +// discipline), and an executing delete writes the module-constant REAL +// store (~/.pi/agent/history — no injection point), so the confirm +// DECISION is factored into the pure deleteConfirmStep router tested here +// directly, and the wiring semantics are pinned by source-parse on +// deleteCurrent/armDelete/executeDelete/handleInput (delete-backfill +// discipline). No test in this file touches the user's real store. + +// --------------------------------------------------------------------------- +// Pure modal router: arm → y executes / n·Esc cancels / rest swallowed. +// --------------------------------------------------------------------------- + +const ARM = { armed: true, execute: false, cancel: false }; +const IDLE = { armed: false, execute: false, cancel: false }; +const EXECUTE = { armed: false, execute: true, cancel: false }; +const CANCEL = { armed: false, execute: false, cancel: true }; + +test("the first delete press arms only — nothing executes, nothing cancels", () => { + assert.deepEqual(deleteConfirmStep(false, true, false, ""), ARM); +}); + +test("an unarmed non-delete key is a no-op — the confirm stays out of the way", () => { + assert.deepEqual(deleteConfirmStep(false, false, false, "x"), IDLE); +}); + +test("while armed, y (and Y) executes the delete", () => { + assert.deepEqual(deleteConfirmStep(true, false, false, "y"), EXECUTE); + assert.deepEqual(deleteConfirmStep(true, false, false, "Y"), EXECUTE); +}); + +test("while armed, n / N / Esc cancel — the confirm disarms without executing", () => { + assert.deepEqual(deleteConfirmStep(true, false, false, "n"), CANCEL); + assert.deepEqual(deleteConfirmStep(true, false, false, "N"), CANCEL); + assert.deepEqual(deleteConfirmStep(true, false, true, "\x1b"), CANCEL); +}); + +test("while armed, any other key is swallowed and the confirm STAYS armed", () => { + // Plain typing, digits, empty data, arrow-key bytes, and a SECOND + // delete-combo press: none of them execute or cancel. + assert.deepEqual(deleteConfirmStep(true, false, false, "x"), ARM); + assert.deepEqual(deleteConfirmStep(true, false, false, "1"), ARM); + assert.deepEqual(deleteConfirmStep(true, false, false, ""), ARM); + assert.deepEqual(deleteConfirmStep(true, false, false, "\x1b[A"), ARM); + assert.deepEqual(deleteConfirmStep(true, true, false, "\x1b[27;6~"), ARM); +}); + +test("full machine: arm → y executes; a fresh arm is needed per delete", () => { + const armed = deleteConfirmStep(false, true, false, ""); + assert.equal(armed.armed, true); + assert.equal(armed.execute, false); + const done = deleteConfirmStep(armed.armed, false, false, "y"); + assert.equal(done.execute, true); + assert.equal(done.armed, false, "executing leaves the confirm disarmed"); + // After execution the confirm is idle: typing resumes as usual. + assert.deepEqual(deleteConfirmStep(done.armed, false, false, "x"), IDLE); +}); + +test("full machine: arm → n cancels → disarmed without executing", () => { + const armed = deleteConfirmStep(false, true, false, ""); + const cancelled = deleteConfirmStep(armed.armed, false, true, "\x1b"); + assert.equal(cancelled.cancel, true); + assert.equal(cancelled.armed, false); + assert.equal(cancelled.execute, false); +}); + +// The executing press composes with the pure planner: an editor-source +// record deletes from the store AND tombstones; a session-source record is +// read-only — the planner plans NOTHING for it (slice-05 D1). + +test("y on an editor row runs the store-delete + tombstone plan", () => { + const armed = deleteConfirmStep(false, true, false, ""); + const step = deleteConfirmStep(armed.armed, false, false, "y"); + assert.equal(step.execute, true); + assert.deepEqual(deletionActionsFor("editor"), { + deleteFromEditorStore: true, + writeTombstone: true, + }); +}); + +test("session rows are read-only: the planner plans nothing for them", () => { + assert.deepEqual(deletionActionsFor("session"), { + deleteFromEditorStore: false, + writeTombstone: false, + }); +}); + +// --------------------------------------------------------------------------- +// Copy: one confirmation line for every row; the failure toasts state +// exactly what state remains. +// --------------------------------------------------------------------------- + +test("the confirmation footer is the single y/n line (PR #1393)", () => { + const text = deleteConfirmFooterText(); + assert.ok(!text.includes("\n"), "the confirmation stays on one line"); + assert.ok(text.includes("Delete this prompt from history (y/n)?")); + assert.ok(text.includes("Prompt stays in session log")); +}); + +test("failure toasts state the remaining state exactly (PR #1393)", () => { + // A thrown store delete aborts before any tombstone: nothing removed. + assert.equal( + STORE_DELETE_FAILED_TEXT, + "Store delete failed; nothing was removed.", + ); + // Editor-path hide failure: the store row is gone, the prompt may + // reappear from transcripts. + assert.equal( + EDITOR_HIDE_FAILED_TEXT, + "Deleted from the store, but hiding failed — the prompt may reappear from session transcripts.", + ); +}); + +// --------------------------------------------------------------------------- +// Source-parse: the wiring inside the selector (the class itself is not +// instantiable under node:test — see the header note). +// --------------------------------------------------------------------------- + +const selectorSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", +); + +/** Slice out a 2-space-indented method body by its exact signature. */ +function methodBodyOf(signature: string): string { + const decl = selectorSource.indexOf(signature); + assert.ok(decl >= 0, `${signature} should exist`); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, `${signature}'s body should close`); + return selectorSource.slice(decl, end); +} + +function deleteCurrentBody(): string { + return methodBodyOf("private deleteCurrent(): void {"); +} + +function executeDeleteBody(): string { + return methodBodyOf("private executeDelete(): void {"); +} + +function handleInputBody(): string { + return methodBodyOf("handleInput(data: string): void {"); +} + +test("deleteCurrent: session rows no-op FIRST — before any arm or mutation", () => { + const body = deleteCurrentBody(); + const guardAt = body.indexOf('(selected.source ?? "editor") === "session"'); + assert.ok(guardAt >= 0, "the session read-only guard must exist"); + const guardReturnAt = body.indexOf("return;", guardAt); + assert.ok(guardReturnAt > guardAt, "the session guard must return"); + // The guard precedes the arm/execute split and every mutation helper. + const armAt = body.indexOf("this.armDelete()"); + const executeAt = body.indexOf("this.executeDelete()"); + assert.ok(armAt > guardAt, "the session guard must precede arming"); + assert.ok(executeAt > guardAt, "the session guard must precede executing"); + // The combo entry stays two-step: unarmed arms, armed executes. + assert.ok( + body.includes("if (!this.confirmArmed)"), + "the unarmed press must arm", + ); + assert.ok( + armAt < executeAt, + "armDelete is the unarmed branch, executeDelete the armed one", + ); +}); + +test("armDelete only paints: armed + footer + rebuild — never mutates", () => { + const body = methodBodyOf("private armDelete(): void {"); + assert.ok(body.includes("this.confirmArmed = true;")); + assert.ok(body.includes("this.refreshDeleteFooter()")); + assert.ok(body.includes("this.rebuildList()")); + assert.ok(!body.includes("hidePrompt("), "arming never writes a tombstone"); + assert.ok( + !body.includes("this.records.splice("), + "arming never mutates rows", + ); +}); + +test("executeDelete leaves the armed state before any mutation", () => { + const body = executeDeleteBody(); + const disarmAt = body.indexOf("this.confirmArmed = false;"); + assert.ok(disarmAt >= 0, "executing must leave the armed state"); + assert.ok(body.includes("this.refreshDeleteFooter()")); + const actionsAt = body.indexOf("deletionActionsFor("); + const hideAt = body.indexOf("hidePrompt("); + const spliceAt = body.indexOf("this.records.splice("); + assert.ok( + disarmAt < actionsAt && actionsAt < hideAt && hideAt < spliceAt, + "disarm → plan → tombstone → splice ordering", + ); +}); + +test("while armed, handleInput is modal: the router runs FIRST and returns", () => { + const body = handleInputBody(); + const modalAt = body.indexOf("if (this.confirmArmed) {"); + assert.ok(modalAt >= 0, "the modal branch must exist"); + assert.ok( + body.includes("deleteConfirmStep("), + "the armed branch routes through the pure router", + ); + assert.ok( + body.includes('matchesKey(data, "ctrl+shift+backspace")') && + body.includes('matchesKey(data, "escape")'), + "the combo and escape matches come from the TUI keymap", + ); + const executeAt = body.indexOf("this.executeDelete()"); + const cancelAt = body.indexOf("this.disarmDeleteConfirm()"); + assert.ok(executeAt > modalAt, "y must execute inside the modal branch"); + assert.ok(cancelAt > modalAt, "n/Esc must disarm inside the modal branch"); + // Full swallow: the modal branch RETURNS before the dispatch loop and + // the search fallthrough can see the key — esc cannot close the overlay. + const modalReturnAt = body.indexOf("return;", modalAt); + assert.ok(modalReturnAt > modalAt, "the modal branch must return"); + const loopAt = body.indexOf( + "for (const { match, handler } of this.dispatch) {", + ); + const fallthroughAt = body.indexOf( + "if (!handled) this.forwardToSearch(data);", + ); + assert.ok(loopAt > modalReturnAt, "armed keys never reach dispatch"); + assert.ok(fallthroughAt > modalReturnAt, "armed keys never reach search"); +}); + +test("the old disarm pre-pass is superseded — disarm only on the modal cancel path", () => { + const body = handleInputBody(); + assert.ok( + !body.includes('!matchesKey(data, "ctrl+shift+backspace")'), + "the unconditional disarm pre-pass must be gone", + ); + const modalAt = body.indexOf("if (this.confirmArmed) {"); + const disarmAt = body.indexOf("this.disarmDeleteConfirm()"); + assert.ok(disarmAt > modalAt, "disarm must sit inside the modal branch"); +}); + +test("Esc while DISARMED still cancels the overlay via the dispatch entry", () => { + const table = selectorSource.slice( + selectorSource.indexOf("private readonly dispatch"), + selectorSource.indexOf("\n ];"), + ); + const cancelAt = table.indexOf('kb.matches(_d, "tui.select.cancel")'); + assert.ok(cancelAt >= 0, "the cancel dispatch entry must stay"); + const entry = table.slice(cancelAt, table.indexOf("},", cancelAt)); + assert.ok( + entry.includes("this.onCancel()"), + "disarmed esc must still close the overlay", + ); +}); + +test("a wheel scroll disarms (and never executes) the armed delete", () => { + const handleMouseAt = selectorSource.indexOf("override handleMouse("); + assert.ok(handleMouseAt >= 0, "handleMouse should exist"); + const mouseEnd = selectorSource.indexOf("\n }", handleMouseAt); + const mouseBody = selectorSource.slice(handleMouseAt, mouseEnd); + assert.ok( + mouseBody.indexOf("this.disarmDeleteConfirm()") >= 0, + "a wheel scroll can move the selection off the armed row — it must disarm", + ); + assert.ok( + !mouseBody.includes("this.executeDelete()"), + "a wheel scroll must never execute the delete", + ); +}); + +test("the armed state drives the footer copy and the error-colored highlight", () => { + const footerBody = methodBodyOf("private refreshDeleteFooter(): void {"); + assert.ok( + footerBody.includes("deleteConfirmFooterText()"), + "the armed footer uses the single pure copy (no source argument)", + ); + assert.ok( + !footerBody.includes("deleteConfirmFooterText" + "(source)"), + "the footer must not route through a source variant", + ); + assert.ok( + footerBody.includes("SELECTOR_FOOTER_HELP"), + "disarming restores the help line", + ); + + const rebuildBody = methodBodyOf( + "private rebuildListWithWidth(width: number): void {", + ); + assert.ok( + rebuildBody.includes("this.confirmArmed"), + "the armed state repaints the selected row", + ); +}); + +// --------------------------------------------------------------------------- +// Env rename (slice-05 D4): the opt-in switch is GENTLE_PI_HISTORY_ENABLE. +// --------------------------------------------------------------------------- + +// The old switch name must be gone everywhere; assemble the literal from +// parts so this file stays grep-clean for the rename proof (rg for the old +// env var must return 0 matches). +const legacySwitch = `GENTLE_PI_HISTORY_${"CAPTURE"}`; + +test("captureEnabled reads GENTLE_PI_HISTORY_ENABLE (strict 1/true/on unchanged)", () => { + const decl = selectorSource.indexOf("export function captureEnabled("); + assert.ok(decl >= 0, "captureEnabled should exist"); + const end = selectorSource.indexOf("\n}", decl); + assert.ok(end > decl, "captureEnabled's body should close"); + const body = selectorSource.slice(decl, end); + assert.ok( + body.includes("env.GENTLE_PI_HISTORY_ENABLE"), + "the renamed switch must be read", + ); + assert.ok( + !body.includes(legacySwitch), + "the old switch name must be gone", + ); + assert.ok( + body.includes('?.trim().toLowerCase()'), + "whitespace + case normalization unchanged", + ); + assert.ok( + body.includes('value === "1" || value === "true" || value === "on"'), + "strict 1/true/on opt-in unchanged", + ); +}); + +test("the open-flow warning names GENTLE_PI_HISTORY_ENABLE", () => { + const at = selectorSource.indexOf("Prompt history capture is off"); + assert.ok(at >= 0, "the off-gate warning must exist"); + const lineEnd = selectorSource.indexOf("\n", at); + const line = selectorSource.slice(at, lineEnd); + assert.ok( + line.includes("GENTLE_PI_HISTORY_ENABLE=1"), + `the warning must name the new switch, got: ${line.trim()}`, + ); + assert.ok( + !line.includes(legacySwitch), + "the warning must not name the old switch", + ); +}); diff --git a/tests/history-dispatch.test.ts b/tests/history-dispatch.test.ts new file mode 100644 index 000000000..78a32c5bb --- /dev/null +++ b/tests/history-dispatch.test.ts @@ -0,0 +1,179 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; + +/** + * Dispatch table structural tests — source-parsed (AC-P2-1.3, AC-P2-2.1, + * AC-P2-4.1), following the preview-layout.test.ts pattern. + * + * PromptHistorySelector is private to src/index.ts and needs the + * pi-tui runtime (Container, Input, TUI, Theme), so these tests read the + * source file and pin the normative §B2 shape instead of importing it: + * exactly 12 explicit entries in a fixed order, then the implicit + * forwardToSearch fallthrough inside handleInput. + */ + +const sourcePath = fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)); +const source = fs.readFileSync(sourcePath, "utf8"); + +const DISPATCH_DECL = "private readonly dispatch: readonly DispatchEntry[] = ["; +const TABLE_CLOSE = "\n ];"; + +/** §B2 normative matcher order — the exact literal as it appears per entry. */ +const EXPECTED_MATCHERS = [ + 'kb.matches(_d, "tui.select.up")', + 'kb.matches(_d, "tui.select.down")', + 'kb.matches(_d, "tui.select.pageUp")', + 'kb.matches(_d, "tui.select.pageDown")', + 'd === "\\r" || kb.matches(d, "tui.select.confirm")', + 'd === "\\t"', + 'kb.matches(_d, "tui.select.cancel")', + 'matchesKey(d, "home")', + 'matchesKey(d, "end")', + 'matchesKey(d, "ctrl+shift+backspace")', + 'matchesKey(d, "ctrl+shift+up")', + 'matchesKey(d, "ctrl+shift+down")', +]; + +/** Handler each entry must invoke (searched within the entry's body). */ +const EXPECTED_HANDLERS = [ + "this.moveUp()", + "this.moveDown()", + "this.pageListUp()", + "this.pageListDown()", + "this.selectCurrent()", + "this.toggleScope()", + "this.onCancel()", + "this.jumpToFirst()", + "this.jumpToLast()", + "this.deleteCurrent()", + "this.previewPageUp()", + "this.previewPageDown()", +]; + +function dispatchTable(): string { + const start = source.indexOf(DISPATCH_DECL); + assert.notStrictEqual( + start, + -1, + "dispatch table declaration should exist in src/index.ts", + ); + const end = source.indexOf(TABLE_CLOSE, start); + assert.notStrictEqual(end, -1, "dispatch table closing should exist"); + return source.slice(start, end); +} + +/** Entry i's body: from its matcher literal to the next matcher (or table end). */ +function entryBody(table: string, index: number): string { + const start = table.indexOf(EXPECTED_MATCHERS[index]); + const next = + index + 1 < EXPECTED_MATCHERS.length + ? table.indexOf(EXPECTED_MATCHERS[index + 1]) + : table.length; + return table.slice(start, next === -1 ? table.length : next); +} + +function methodBody(name: string): string { + const start = source.indexOf(`private ${name}(): void {`); + assert.notStrictEqual(start, -1, `private ${name}() should exist`); + const end = source.indexOf("\n }", start); + assert.notStrictEqual(end, -1, `private ${name}() body should close`); + return source.slice(start, end); +} + +describe("dispatch table (source-parsed, §B2)", () => { + it("has exactly 12 explicit match: entries (AC-P2-4.1)", () => { + const table = dispatchTable(); + const matchCount = table.split("match:").length - 1; + assert.strictEqual( + matchCount, + 12, + `expected 12 explicit entries, found ${matchCount}`, + ); + }); + + it("keeps the exact §B2 matcher order", () => { + const table = dispatchTable(); + let cursor = -1; + EXPECTED_MATCHERS.forEach((matcher, i) => { + const at = table.indexOf(matcher); + assert.notStrictEqual( + at, + -1, + `entry #${i + 1} matcher missing: ${matcher}`, + ); + assert.ok( + at > cursor, + `entry #${i + 1} matcher out of order: ${matcher}`, + ); + cursor = at; + }); + }); + + it("wires every entry handler per §B2", () => { + const table = dispatchTable(); + EXPECTED_HANDLERS.forEach((handler, i) => { + const body = entryBody(table, i); + assert.ok( + body.includes(handler), + `entry #${i + 1} should call ${handler}`, + ); + }); + }); + + it("pages the LIST via pageSelectedIndex and resets the preview offset (AC-P2-1.3)", () => { + const up = methodBody("pageListUp"); + assert.ok( + up.includes("pageSelectedIndex("), + "pageListUp must clamp via pageSelectedIndex", + ); + assert.ok( + up.includes("-MAX_VISIBLE"), + "pageListUp must page up by one page", + ); + assert.ok( + up.includes("previewScrollOffset = 0"), + "pageListUp must reset the preview offset", + ); + const down = methodBody("pageListDown"); + assert.ok( + down.includes("pageSelectedIndex("), + "pageListDown must clamp via pageSelectedIndex", + ); + assert.ok( + down.includes("MAX_VISIBLE"), + "pageListDown must page down by one page", + ); + assert.ok( + down.includes("previewScrollOffset = 0"), + "pageListDown must reset the preview offset", + ); + }); + + it("runs the ctrl+shift combos before the implicit fallthrough (AC-P2-2.1)", () => { + const table = dispatchTable(); + const lastMatch = table.lastIndexOf("match:"); + assert.ok( + table.slice(lastMatch).includes('matchesKey(d, "ctrl+shift+down")'), + "the final table entry must be the ctrl+shift+down combo", + ); + const loopAt = source.indexOf( + "for (const { match, handler } of this.dispatch) {", + ); + const fallthroughAt = source.indexOf( + "if (!handled) this.forwardToSearch(data);", + ); + assert.notStrictEqual(loopAt, -1, "dispatch loop should exist"); + assert.notStrictEqual( + fallthroughAt, + -1, + "forwardToSearch fallthrough should exist", + ); + assert.ok( + fallthroughAt > loopAt, + "fallthrough must run after the dispatch loop", + ); + }); +}); diff --git a/tests/history-drain-hidden.test.ts b/tests/history-drain-hidden.test.ts new file mode 100644 index 000000000..fb19b5eac --- /dev/null +++ b/tests/history-drain-hidden.test.ts @@ -0,0 +1,106 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + drainGlobal, + drainProject, + globalSeedPath, + projectHash, + type DrainResult, +} from "../extensions/history/store.ts"; + +const CWD = "/pi-history-fixtures/project-a"; + +function write(file: string, texts: string[], ts = 100): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${texts.map((t) => JSON.stringify({ v: 1, text: t, ts })).join("\n")}\n`, + "utf8", + ); +} + +// Unwrap the ok shape. Drains FAIL CLOSED: the blocked variant carries no +// prompts field at all (asserted in the blocked test below). +function okPrompts(result: DrainResult): string[] { + assert.equal(result.status, "ok"); + if (result.status !== "ok") throw new Error("unreachable"); + return result.prompts; +} + +test("drains skip tombstoned prompts in seeds and session files", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "hid-")); + const root = path.join(base, "h"); + const stateDir = path.join(base, "state"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "hidden.json"), + JSON.stringify(["deleted from seed", "deleted from session"]), + "utf8", + ); + const dir = path.join(root, "projects", projectHash(CWD)); + write(path.join(dir, "seed.jsonl"), ["keep", "deleted from seed"], 100); + write(path.join(dir, "s1.jsonl"), ["also keep", "deleted from session"], 200); + write(globalSeedPath(root), ["deleted from seed", "legacy keep"], 50); + + assert.deepEqual(okPrompts(drainProject(root, CWD, 1000, stateDir)), [ + "also keep", + "keep", + ]); + assert.deepEqual(okPrompts(drainGlobal(root, 1000, stateDir)), [ + "also keep", + "keep", + "legacy keep", + ]); + // Without a stateDir the filter is off (raw drain semantics). + assert.equal( + okPrompts(drainProject(root, CWD)).includes("deleted from seed"), + true, + ); +}); + +// Fail-closed seam: an untrusted hidden.json BLOCKS both drains with the +// recovery message and no prompts field; a stateDir whose hidden.json is +// MISSING stays the safe empty-tombstones case (the full expected prompts). +test("corrupt hidden.json blocks both drains with no prompts field; a missing file drains normally", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "hid-blocked-")); + const root = path.join(base, "h"); + const stateDir = path.join(base, "state"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "hidden.json"), + "{corrupt bytes", + "utf8", + ); + const dir = path.join(root, "projects", projectHash(CWD)); + write(path.join(dir, "seed.jsonl"), ["secret prompt", "keeper"], 100); + + for (const result of [ + drainProject(root, CWD, 1000, stateDir), + drainGlobal(root, 1000, stateDir), + ]) { + assert.equal(result.status, "blocked"); + if (result.status !== "blocked") throw new Error("unreachable"); + assert.ok(result.message.includes("hidden.json")); + // The blocked shape carries no prompts to render. + assert.equal("prompts" in result, false); + } + + // Missing file: safe empty tombstones — the full drain comes back. + const missingBase = fs.mkdtempSync(path.join(os.tmpdir(), "hid-missing-")); + const missingRoot = path.join(missingBase, "h"); + const missingState = path.join(missingBase, "state"); + fs.mkdirSync(missingState, { recursive: true }); + const missingDir = path.join(missingRoot, "projects", projectHash(CWD)); + write(path.join(missingDir, "seed.jsonl"), ["kept", "shown"], 100); + assert.deepEqual( + okPrompts(drainProject(missingRoot, CWD, 1000, missingState)), + ["shown", "kept"], + ); + assert.deepEqual(okPrompts(drainGlobal(missingRoot, 1000, missingState)), [ + "shown", + "kept", + ]); +}); diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts new file mode 100644 index 000000000..be2f8fdfc --- /dev/null +++ b/tests/history-drain-order.test.ts @@ -0,0 +1,98 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + drainGlobal, + drainProject, + globalSeedPath, + projectHash, + type DrainResult, +} from "../extensions/history/store.ts"; + +// Portable project identity: a never-existing literal. projectHash falls +// back to hashing the raw string when realpath fails, so the identity is +// deterministic on every machine (no machine-specific absolute paths). + +const CWD = "/pi-history-test/drain-order-project"; + +function writeTs(file: string, texts: string[], ts: number): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${texts.map((t) => JSON.stringify({ v: 1, text: t, ts })).join("\n")}\n`, + "utf8", + ); +} + +// Mechanical unwrap of the ok shape (drains can also return blocked). +function okPrompts(result: DrainResult): string[] { + assert.equal(result.status, "ok"); + if (result.status !== "ok") throw new Error("unreachable"); + return result.prompts; +} + +test("atomic rewrite (delete) does not reshuffle the drain order", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ord-")); + const dir = path.join(root, "projects", projectHash(CWD)); + writeTs(path.join(dir, "old.jsonl"), ["a-old"], 100); + writeTs(path.join(dir, "new.jsonl"), ["z-new"], 200); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "a-old"]); + // Slice 5 ports deleteFromProject; its observable effect on the drain is + // simulated directly here: an atomic rewrite of the affected file that + // empties it — the mtime jumps to NOW, and the drain order must not move. + fs.writeFileSync(path.join(dir, "old.jsonl"), "", "utf8"); + fs.utimesSync(path.join(dir, "old.jsonl"), new Date(), new Date()); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new"]); + // Re-add with an OLD ts via direct write: still ordered by ts, not mtime. + writeTs(path.join(dir, "old2.jsonl"), ["b-old"], 150); + fs.utimesSync( + path.join(dir, "old2.jsonl"), + new Date(Date.now() + 99999), + new Date(Date.now() + 99999), + ); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "b-old"]); +}); + +test("global drain puts the legacy seed last regardless of its fresh mtime", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "seed-")); + const dir = path.join(root, "projects", projectHash(CWD)); + writeTs(path.join(dir, "s.jsonl"), ["fresh"], 200); + const seed = globalSeedPath(root); + writeTs(seed, ["legacy-1", "legacy-2"], 10); + fs.utimesSync(seed, new Date(Date.now() + 5000), new Date(Date.now() + 5000)); + assert.deepEqual(okPrompts(drainGlobal(root)), [ + "fresh", + "legacy-2", + "legacy-1", + ]); +}); + +test( + "an unreadable store file is skipped; the rest drain in the expected order", + { skip: process.getuid?.() === 0 }, + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ord-sealed-")); + const dir = path.join(root, "projects", projectHash(CWD)); + writeTs(path.join(dir, "old.jsonl"), ["a-old"], 100); + const sealed = path.join(dir, "sealed.jsonl"); + writeTs(sealed, ["sealed-never"], 150); + writeTs(path.join(dir, "new.jsonl"), ["z-new"], 200); + // The sealed file's fresh mtime would sort it FIRST if it were readable — + // its absence from the drain is caused by the unreadable skip alone. + fs.utimesSync( + sealed, + new Date(Date.now() + 99999), + new Date(Date.now() + 99999), + ); + fs.chmodSync(sealed, 0o000); + try { + // An unreadable file reads as zero entries and drops out of the drain; + // the readable files keep their ts order. No throw. + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "a-old"]); + } finally { + fs.chmodSync(sealed, 0o644); // restore before cleanup + } + }, +); diff --git a/tests/history-expanded-globals.test.ts b/tests/history-expanded-globals.test.ts new file mode 100644 index 000000000..e66d92163 --- /dev/null +++ b/tests/history-expanded-globals.test.ts @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + type PiHistoryGlobals, + withExpandedHistoryGlobals, +} from "../extensions/history/selector-helpers.ts"; + +// withExpandedHistoryGlobals contract: optional hooks invoked via `?.` — +// expand exactly once BEFORE the run starts, trim exactly once in the +// finally (success and rejection alike), and the run's resolution passes +// through untouched. Fixtures track call order in one array so the +// before/after ordering and the call counts are pinned together. + +function trackingGlobals(): { globals: PiHistoryGlobals; events: string[] } { + const events: string[] = []; + return { + events, + globals: { + __piHistoryExpand: () => { + events.push("expand"); + }, + __piHistoryTrim: () => { + events.push("trim"); + }, + }, + }; +} + +test("expand runs once before the run; trim once after; the result passes through", async () => { + const { globals, events } = trackingGlobals(); + let ran = 0; + const value = await withExpandedHistoryGlobals(globals, async () => { + // By the time the body executes, expand already ran — exactly once. + ran += 1; + assert.deepEqual(events, ["expand"]); + return 42; + }); + assert.equal(value, 42); + assert.equal(ran, 1); + assert.deepEqual(events, ["expand", "trim"]); +}); + +test("a rejected run still trims (finally) and the rejection propagates unchanged", async () => { + const { globals, events } = trackingGlobals(); + const boom = new Error("boom"); + let caught: unknown; + try { + await withExpandedHistoryGlobals(globals, async () => { + throw boom; + }); + } catch (error) { + caught = error; + } + assert.equal(caught, boom); + assert.deepEqual(events, ["expand", "trim"]); +}); + +test("absent hooks are tolerated: the run executes with no throw", async () => { + const empty: PiHistoryGlobals = {}; + const value = await withExpandedHistoryGlobals(empty, async () => "ok"); + assert.equal(value, "ok"); +}); diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts new file mode 100644 index 000000000..c7a9440b4 --- /dev/null +++ b/tests/history-gc.test.ts @@ -0,0 +1,361 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { gcProjectDir, projectHash } from "../extensions/history/store.ts"; + +// GC/compaction (slice 6): threshold no-op below the limits, keep-newest +// semantics, and the failure paths — the compact file lands atomically +// before any original is removed, cleanup failures are tolerated, unreadable +// files are skipped, and an append landing mid-compaction is never lost. +// All fixtures live under os.tmpdir(): the user's real ~/.pi store root is +// never touched. (Ported from the dev repo's test/history/gc.test.ts. +// compactProjectDir stays exported for upstream parity but carries no +// callers here — gcProjectDir with explicit thresholds is the wired and +// tested entry point.) + +const CWD = "/pi-history-fixtures/project-gc"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-gc-")); +} + +function projectRoot(root: string): string { + return path.join(root, "projects", projectHash(CWD)); +} + +function writeFile( + dir: string, + name: string, + count: number, + mtimeMs: number, +): string { + const file = path.join(dir, name); + fs.writeFileSync( + file, + `${Array.from({ length: count }, (_, i) => + JSON.stringify({ v: 1, text: `${name}-${i}` }), + ).join("\n")}\n`, + "utf8", + ); + fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs)); + return file; +} + +function totalLines(dir: string): number { + let total = 0; + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith(".jsonl")) continue; + total += fs + .readFileSync(path.join(dir, f), "utf8") + .split("\n") + .filter((l) => l.trim().length > 0).length; + } + return total; +} + +/** Line texts of the single compact-*.jsonl file in dir (must exist). */ +function compactTexts(dir: string): string[] { + const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); + assert.ok(compact, "a compact-*.jsonl file must exist"); + return fs + .readFileSync(path.join(dir, compact), "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +/** + * Replace fs.rmSync (the shared CJS exports object store.ts resolves at + * call time) for the duration of fn; the original is always restored. + * `rmSync` inside the replacement is the captured original, so replacements + * can observe-or-fail and then call through. + */ +function withRmSyncPatched( + replacement: (file: string, rmSync: (file: string) => void) => void, + fn: () => void, +): void { + type RmSync = (file: string) => void; + const realRmSync = fs.rmSync.bind(fs) as RmSync; + const target = fs as unknown as { rmSync: RmSync }; + target.rmSync = (file: string) => { + replacement(file, realRmSync); + }; + try { + fn(); + } finally { + target.rmSync = realRmSync; + } +} + +test("under both thresholds: GC is a no-op", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + writeFile(dir, "a.jsonl", 10, 1000); + writeFile(dir, "b.jsonl", 10, 2000); + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: false, merged: 0 }); + assert.equal(fs.readdirSync(dir).length, 2); +}); + +test("file-count threshold merges the oldest files into one compact file", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 12 files (threshold 10) x 10 lines each. + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 11 }); + // 12 files -> newest 1 kept + 1 compact file = 2 files; all lines kept. + assert.equal(fs.readdirSync(dir).length, 2); + assert.equal(totalLines(dir), 120); + // The compact file is the renamed final artifact, not a staging leftover. + assert.match( + fs.readdirSync(dir).find((f) => f.startsWith("compact-")) ?? "", + /^compact-\d+-\d+\.jsonl$/, + ); + assert.deepEqual( + fs.readdirSync(dir).filter((f) => f.includes(".tmp-")), + [], + ); + // The newest original file survives untouched by name. + assert.equal(fs.readdirSync(dir).includes("f12.jsonl"), true); +}); + +test("line-count threshold triggers compaction too", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 3 files x 4000 lines = 12000 > 10000 threshold. + for (let i = 1; i <= 3; i++) { + writeFile(dir, `g${i}.jsonl`, 4000, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.equal(result.compacted, true); + assert.equal(totalLines(dir), 12000); + assert.equal(fs.readdirSync(dir).includes("g3.jsonl"), true); +}); + +test("GC on a missing project dir is a no-op", () => { + const root = makeRoot(); + const result = gcProjectDir(root, "/does/not/exist"); + assert.deepEqual(result, { compacted: false, merged: 0 }); +}); + +test("compaction keeps the newest 10 files, merges the rest", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 15; i++) { + writeFile(dir, `h${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 10, + }); + assert.deepEqual(result, { compacted: true, merged: 5 }); + const names = fs.readdirSync(dir).sort(); + // 10 newest originals + 1 compact file. + assert.equal(names.length, 11); + assert.equal(names[0].startsWith("compact-"), true); + assert.equal(names.includes("h15.jsonl"), true); + assert.equal(names.includes("h05.jsonl"), false); + assert.equal(names.includes("h06.jsonl"), true); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options object. +test( + "an unreadable file (chmod 000) is skipped; GC still compacts the readable tail", + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 3 files, keepNewest 1 -> the two oldest merge; the sealed one sits in + // the merged tail so its bytes hit the unreadable-skip branch (both the + // line-counting pass and the merge pass skip it). + writeFile(dir, "readable-old.jsonl", 5, 1000); + const sealed = writeFile(dir, "sealed-old.jsonl", 5, 2000); + writeFile(dir, "newest.jsonl", 5, 3000); + fs.chmodSync(sealed, 0o000); + try { + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + // The merged count covers the whole tail, sealed file included. + assert.deepEqual(result, { compacted: true, merged: 2 }); + // Only the readable tail file's entries compacted; the sealed bytes + // were skipped, never fatal. (writeFile names entries `${name}-${i}`.) + assert.deepEqual(compactTexts(dir), [ + "readable-old.jsonl-0", + "readable-old.jsonl-1", + "readable-old.jsonl-2", + "readable-old.jsonl-3", + "readable-old.jsonl-4", + ]); + // Cleanup semantics: the tail originals (sealed one included) are + // removed after the compact file lands — unlink needs no read access. + assert.equal(fs.existsSync(sealed), false); + assert.equal(fs.readdirSync(dir).includes("newest.jsonl"), true); + } finally { + // The compaction removes the sealed original; restore only if it + // survived an early failure so cleanup never leaves a 000 file. + try { + fs.chmodSync(sealed, 0o644); + } catch { + // already removed by the compaction + } + } + }, +); + +test("the compact file lands complete before any original is removed", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + // Observe, do not replace: at the FIRST cleanup unlink the compact file + // must already exist on disk with the full merged content (110 lines). + // That is the crash-safe ordering contract: readers never see the tail + // gone with no compact file in its place. + let compactCompleteAtFirstRm: boolean | null = null; + withRmSyncPatched( + (file, rmSync) => { + if (compactCompleteAtFirstRm === null) { + const parent = path.dirname(file); + const compact = fs + .readdirSync(parent) + .find((f) => f.startsWith("compact-")); + compactCompleteAtFirstRm = + compact !== undefined && + fs + .readFileSync(path.join(parent, compact), "utf8") + .trim() + .split("\n") + .filter((l) => l.trim().length > 0).length === 110; + } + rmSync(file); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 11 }); + }, + ); + assert.equal(compactCompleteAtFirstRm, true); +}); + +test("rm failure is tolerated: originals survive, GC still reports success", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + // Simulate every cleanup unlink failing (e.g. originals held by another + // process): the compact file already landed, so a surviving original is + // harmless — readers dedupe by identity. + withRmSyncPatched( + () => { + throw new Error("simulated EBUSY: original still held"); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + // The success shape is unchanged even though cleanup failed. + assert.deepEqual(result, { compacted: true, merged: 11 }); + }, + ); + // The compact file is complete on disk... + assert.equal(compactTexts(dir).length, 110); + // ...and every original survived the failed cleanup (12 + 1 compact). + assert.equal(fs.readdirSync(dir).length, 13); +}); + +test("an append landing during compaction is never lost (active writer)", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 12 old files (merge-tail candidates) + one active writer file with the + // newest mtime. The freshness rule keeps the active file out of the merge + // tail — that is what makes concurrent appends safe during GC. + for (let i = 1; i <= 12; i++) { + writeFile(dir, `t${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000); + } + const active = writeFile(dir, "active.jsonl", 5, 99_000); + // Mid-compaction (first cleanup unlink), the active writer appends a line. + let appended = false; + withRmSyncPatched( + (file, rmSync) => { + if (!appended) { + appended = true; + fs.appendFileSync( + active, + `${JSON.stringify({ v: 1, text: "during-gc" })}\n`, + "utf8", + ); + } + rmSync(file); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 100000, + keepNewest: 10, + }); + // 13 files > threshold 10; tail = 3 oldest; active writer untouched. + assert.deepEqual(result, { compacted: true, merged: 3 }); + }, + ); + // The active file survived by name with every line: the pre-GC lines and + // the line appended mid-compaction. + const activeTexts = fs + .readFileSync(active, "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); + assert.deepEqual(activeTexts, [ + "active.jsonl-0", + "active.jsonl-1", + "active.jsonl-2", + "active.jsonl-3", + "active.jsonl-4", + "during-gc", + ]); + // The tail's 15 lines all compacted; nothing from kept files was merged. + const mergedTexts = compactTexts(dir); + assert.equal(mergedTexts.length, 15); + assert.ok(mergedTexts.includes("t01.jsonl-0")); + assert.ok(mergedTexts.includes("t03.jsonl-4")); + assert.ok(!mergedTexts.some((t) => t.startsWith("active."))); + assert.ok(!mergedTexts.some((t) => t.startsWith("t04."))); + // Whole-dir accounting: 13 x 5 original lines + 1 mid-GC append. + assert.equal(totalLines(dir), 66); +}); diff --git a/tests/history-hide-prompts.test.ts b/tests/history-hide-prompts.test.ts new file mode 100644 index 000000000..88db3e005 --- /dev/null +++ b/tests/history-hide-prompts.test.ts @@ -0,0 +1,275 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + hidePrompt, + readHiddenPrompts, +} from "../extensions/history/hide-prompts.ts"; +import { promptDedupKey } from "../extensions/history/selector-helpers.ts"; + +// Unit WU4 — tombstone write half + read half (spec C4, design §D6; slice-05 +// D5 recency cap). fs-only coverage. The READ half FAILS CLOSED for history: +// a file that exists but cannot be trusted (unreadable, corrupt, wrong +// shape) reads `untrusted` with a recovery warning instead of an empty +// tombstone set, and the WRITE half refuses without a silent rewrite. The +// WRITE half persists keys in RECENCY order (oldest first, newest last, +// never sorted) capped at HIDE_FILE_MAX_ENTRIES (1000, oldest dropped). +// The dev suite's deleteCurrent source-parse pins (T27/T28) and the +// deletionActionsFor planner pins cover the slice-3 selector branch and +// the slice-5 delete flow; they port with those slices. + +function makeStateDir(name: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `hide-prompts-${name}-`)); +} + +function readHideFile(stateDir: string) { + return JSON.parse( + fs.readFileSync(path.join(stateDir, "hidden.json"), "utf8"), + ); +} + +/** Assert an untrusted read of the expected reason; returns its message. */ +function assertUntrusted( + stateDir: string, + reason: "unreadable" | "corrupt" | "malformed", +): string { + const read = readHiddenPrompts(stateDir); + assert.equal(read.status, "untrusted"); + if (read.status !== "untrusted") throw new Error("unreachable"); + assert.equal(read.reason, reason); + // The recovery warning names the file and offers restore-or-delete. + assert.ok(read.message.includes("hidden.json")); + assert.ok(/restore|delete/.test(read.message)); + return read.message; +} + +/** Assert a trusted read and return its key set. */ +function trustedKeys(stateDir: string): Set { + const read = readHiddenPrompts(stateDir); + assert.equal(read.status, "trusted"); + if (read.status !== "trusted") throw new Error("unreachable"); + return read.keys; +} + +// T24 — AC-S4-1: hide-key fidelity. Tombstone keys must byte-match the +// Change 2 dedup key for the same text — same imported helper, never a +// re-implementation: the stored file content is compared against +// promptDedupKey's own output with strict equality. +test("T24 (AC-S4-1): hide keys byte-match promptDedupKey across whitespace, case, and >120-char groups", () => { + const stateDir = makeStateDir("t24"); + // Three normalization groups: internal whitespace runs (space + tab), + // letter case, and a text longer than the 120-char key prefix. + const texts = [ + "fix\t the build", + "Deploy THE api", + `${"pad ".repeat(40)}tail beyond one hundred twenty chars`, + ]; + for (const text of texts) { + assert.deepEqual(hidePrompt(stateDir, text), { status: "written" }); + } + const stored = readHideFile(stateDir); + assert.ok(Array.isArray(stored), "hidden.json must hold a JSON array"); + // Byte-match: the file holds EXACTLY the shared helper's output, in + // insertion (recency) order — the write half never sorts. + assert.deepEqual(stored, texts.map((text) => promptDedupKey(text))); + // The read half agrees. + const keys = trustedKeys(stateDir); + assert.equal(keys.size, stored.length); + for (const key of stored) { + assert.ok(keys.has(key)); + } +}); + +// T25 — AC-S4-2: hide persistence and tolerance. Two deletes of the same +// text compact to ONE key; a MISSING hide file (before any deletion) is the +// safe empty case — trusted with no keys; reads never throw. +test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads trusted-empty; reads never throw", () => { + const stateDir = makeStateDir("t25"); + // Missing file: trusted empty tombstones (before any write exists). + assert.equal(trustedKeys(stateDir).size, 0); + // Two deletes of the same text — variants differing by case + whitespace + // runs normalize onto the same key. + assert.deepEqual(hidePrompt(stateDir, "Same Text"), { status: "written" }); + assert.deepEqual(hidePrompt(stateDir, "same text"), { status: "written" }); + const stored = readHideFile(stateDir); + assert.deepEqual(stored, [promptDedupKey("same text")]); + const keys = trustedKeys(stateDir); + assert.equal(keys.size, 1); + assert.ok(keys.has(promptDedupKey("same text"))); +}); + +// D5 — recency order: re-hiding an existing key REFRESHES it to the end +// (newest); the file array is oldest-first, newest-appended-last — the +// write half never sorts. +test("re-hiding an existing key refreshes it to the end (recency order, no sort)", () => { + const stateDir = makeStateDir("recency"); + const keysOf = (texts: string[]) => texts.map((text) => promptDedupKey(text)); + for (const text of ["alpha prompt", "beta prompt", "gamma prompt"]) { + assert.deepEqual(hidePrompt(stateDir, text), { status: "written" }); + } + assert.deepEqual(readHideFile(stateDir), keysOf([ + "alpha prompt", + "beta prompt", + "gamma prompt", + ])); + // Re-hide the oldest key: it moves to the END; the others keep order. + assert.deepEqual(hidePrompt(stateDir, "alpha prompt"), { + status: "written", + }); + assert.deepEqual(readHideFile(stateDir), keysOf([ + "beta prompt", + "gamma prompt", + "alpha prompt", + ])); + assert.equal(trustedKeys(stateDir).size, 3); +}); + +// D5 — cap: hidden.json keeps at most 1000 keys in recency order; the +// 1001st distinct prompt drops the OLDEST key from the front. The file is +// a rebuildable cache, not a retention guarantee — a dropped prompt may +// reappear and be deleted again. +test("the 1001st distinct prompt drops the oldest key — the file keeps exactly 1000", () => { + const stateDir = makeStateDir("cap"); + const oldest = "oldest prompt"; + assert.deepEqual(hidePrompt(stateDir, oldest), { status: "written" }); + for (let i = 1; i <= 999; i++) { + assert.deepEqual(hidePrompt(stateDir, `prompt number ${i}`), { + status: "written", + }); + } + // Exactly at the cap: 1000 keys, oldest first, newest last. + const atCap = readHideFile(stateDir); + assert.equal(atCap.length, 1000); + assert.equal(atCap[0], promptDedupKey(oldest)); + assert.equal(atCap[atCap.length - 1], promptDedupKey("prompt number 999")); + // The 1001st distinct prompt: the front (oldest) drops, the newest lands. + assert.deepEqual(hidePrompt(stateDir, "prompt number 1000"), { + status: "written", + }); + const after = readHideFile(stateDir); + assert.equal(after.length, 1000, "the cap holds the file at exactly 1000"); + assert.equal( + after.includes(promptDedupKey(oldest)), + false, + "the oldest key must be dropped from the front", + ); + assert.equal( + after[after.length - 1], + promptDedupKey("prompt number 1000"), + "the newest key must sit at the end", + ); + // The read half agrees with the capped file. + assert.equal(trustedKeys(stateDir).size, 1000); +}); + +// T26 — AC-S4-5: corrupt hidden.json FAILS CLOSED for history reads. The +// READ half reports untrusted (corrupt) so callers block history instead of +// resurfacing hidden prompts; the WRITE half refuses WITHOUT a silent clean +// rewrite (the old fail-open behavior cleared the blocked state one hide +// later). Recovery is manual — restore or delete the file; after deletion +// the next hide succeeds and reads are trusted again. +test("T26 (AC-S4-5): corrupt hidden.json reads untrusted; hide refuses without rewriting; deleting the file recovers", () => { + const stateDir = makeStateDir("t26"); + const hidePath = path.join(stateDir, "hidden.json"); + fs.writeFileSync(hidePath, "{corrupt bytes", "utf8"); + // READ half: untrusted/corrupt — never an empty trusted set. + const message = assertUntrusted(stateDir, "corrupt"); + // WRITE half: refused, and the corrupt bytes are UNCHANGED — the blocked + // state is never silently reset (no-silent-rewrite pin). + const before = fs.readFileSync(hidePath, "utf8"); + assert.deepEqual(hidePrompt(stateDir, "beta prompt"), { + status: "error", + message, + }); + assert.equal(fs.readFileSync(hidePath, "utf8"), before); + // Manual recovery: delete the file; the next hide succeeds and reads are + // trusted with exactly the new key. + fs.unlinkSync(hidePath); + assert.deepEqual(hidePrompt(stateDir, "beta prompt"), { status: "written" }); + const keys = trustedKeys(stateDir); + assert.equal(keys.size, 1); + assert.ok(keys.has(promptDedupKey("beta prompt"))); +}); + +// Wrong-shaped file: valid JSON that is not an array fails closed too (both +// halves), while junk items inside a VALID array are ignored and the real +// keys stay trusted. +test("malformed hidden.json fails closed for reads and writes; junk items in a valid array are ignored", () => { + const malformed = makeStateDir("malformed"); + const hidePath = path.join(malformed, "hidden.json"); + for (const shape of ["{}", JSON.stringify({ keys: [] })]) { + fs.writeFileSync(hidePath, shape, "utf8"); + assertUntrusted(malformed, "malformed"); + } + // The write half refuses the malformed file as well. + const message = assertUntrusted(malformed, "malformed"); + assert.deepEqual(hidePrompt(malformed, "kept prompt"), { + status: "error", + message, + }); + + // Junk items are ignored, never trusted; real keys survive. + const junk = makeStateDir("junk"); + fs.writeFileSync( + path.join(junk, "hidden.json"), + JSON.stringify([42, "", "real-key", null]), + "utf8", + ); + const keys = trustedKeys(junk); + assert.equal(keys.size, 1); + assert.ok(keys.has("real-key")); +}); + +// Unreadable file: a hidden.json that cannot be read at all fails closed +// for reads, and the write half refuses too. Skipped as root, where chmod +// 000 does not block reads; permissions are restored in finally. +test( + "an unreadable hidden.json fails closed for reads and refuses writes", + { skip: process.getuid?.() === 0 }, + () => { + const stateDir = makeStateDir("sealed"); + const hidePath = path.join(stateDir, "hidden.json"); + fs.writeFileSync(hidePath, '["kept-key"]', "utf8"); + fs.chmodSync(hidePath, 0o000); + try { + const message = assertUntrusted(stateDir, "unreadable"); + assert.deepEqual(hidePrompt(stateDir, "beta prompt"), { + status: "error", + message, + }); + } finally { + fs.chmodSync(hidePath, 0o644); // restore before cleanup + } + }, +); + +// WU4c — write-failure path (AC-S4-2 triangulation): a trusted read whose +// atomic write fails makes hidePrompt return the toast-suitable error +// object — never a throw. The state dir is made non-writable while the +// existing hidden.json stays readable (a state dir whose PATH is blocked +// by a regular file is now the untrusted-refusal case instead — the READ +// half fails closed before any write). Skipped as root, where chmod-based +// write blocking does not apply; permissions are restored in finally. +test( + "hide write failure returns the exact error shape for the delete-flow toast", + { skip: process.getuid?.() === 0 }, + () => { + const stateDir = makeStateDir("fail"); + fs.writeFileSync( + path.join(stateDir, "hidden.json"), + '["kept-key"]', + "utf8", + ); + fs.chmodSync(stateDir, 0o555); // read+execute, no write → EACCES on tmp + try { + assert.deepEqual(hidePrompt(stateDir, "kept prompt"), { + status: "error", + message: "Could not write the hide file; the prompt may reappear.", + }); + } finally { + fs.chmodSync(stateDir, 0o700); // restore before cleanup + } + }, +); diff --git a/tests/history-lazy-windowing.test.ts b/tests/history-lazy-windowing.test.ts new file mode 100644 index 000000000..d53c8ef81 --- /dev/null +++ b/tests/history-lazy-windowing.test.ts @@ -0,0 +1,513 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; +import { + buildPromptRecords, + filterPrompts, + initialLoadedCount, + loadedCountForQuery, + loadedCountForTarget, + moveSelectedIndex, + nextLoadedCount, + shouldGrowWindow, +} from "../extensions/history/selector-helpers.ts"; + +// Unit 2a — L1+L2 windowing helpers (spec C1/C2, design §D3/§D4). +// +// The ratified constant VALUES (design R3) are pinned here as test literals +// while the named constants themselves land in src/index.ts in Unit 2b: +// +// INITIAL_BATCH = 10 · BATCH_SIZE = 10 · PRELOAD_BUFFER = 3 (trigger at 8th; milestones 10/20/30) +// +// Every helper is a parameterized pure function over UNFILTERED counts only: +// `filteredRecords.length` appears in no trigger or growth expression (the +// §8a regression pin, AC-L2-2). All behaviors below use the helpers exactly +// as the §B2 wiring will in Unit 2b — grow-before-move, one batch per +// threshold crossing, derived exhaustion (no stored flag). + +// T4 — AC-L1-1: initial window clamp, min(INITIAL_BATCH, records.length). + +test("initialLoadedCount clamps the first-paint window to min(INITIAL_BATCH, records.length) (AC-L1-1)", () => { + const initialBatch = 30; + assert.equal(initialLoadedCount(0, initialBatch), 0); + assert.equal(initialLoadedCount(12, initialBatch), 12); + assert.equal(initialLoadedCount(30, initialBatch), 30); + assert.equal(initialLoadedCount(200, initialBatch), 30); +}); + +// T4 — AC-L1-2: filtering windows the loaded prefix — filterPrompts over +// records.slice(0, loadedCount) derives exclusively from that prefix; a +// match beyond the loaded count stays invisible until growth. filterPrompts +// itself is untouched (imported read-only from selector-helpers.ts). + +test("filterPrompts over the loaded prefix hides matches beyond L until growth (AC-L1-2)", () => { + const records: { text: string; searchText: string }[] = []; + for (let i = 0; i < 200; i++) { + const text = + i === 40 ? "needle40 special prompt" : `plain prompt number ${i}`; + const [record] = buildPromptRecords([text]); + assert.ok(record, "buildPromptRecords yields one record per entry"); + records.push(record); + } + + const loadedPrefix = records.slice(0, initialLoadedCount(200, 30)); + assert.equal(loadedPrefix.length, 30); + assert.equal( + filterPrompts(loadedPrefix, "needle40").length, + 0, + "the match at master index 40 sits beyond the loaded prefix — invisible until growth", + ); + assert.equal( + filterPrompts(records.slice(0, 60), "needle40").length, + 1, + "after growth to cover index 40, the match surfaces", + ); + assert.equal( + filterPrompts(loadedPrefix, "").length, + 30, + "the empty query derives exclusively from the loaded prefix", + ); +}); + +// T5 — AC-L2-1: trigger truth table with the off-by-one edges. The predicate +// is exactly `selectedIndex + preloadBuffer >= loadedCount` (0-based cursor +// within the final PRELOAD_BUFFER rows of the loaded window). + +test("shouldGrowWindow fires exactly when the cursor enters the final PRELOAD_BUFFER rows (AC-L2-1)", () => { + const totalCount = 200; + const preloadBuffer = 10; + + // One row early — a naive selected+1 paraphrase would already fire here + // (spec risk: trigger-expression off-by-one drift). + assert.equal(shouldGrowWindow(19, 30, totalCount, preloadBuffer), false); + // Exact boundary: 20 + 10 >= 30. + assert.equal(shouldGrowWindow(20, 30, totalCount, preloadBuffer), true); + assert.equal(shouldGrowWindow(29, 30, totalCount, preloadBuffer), true); + + // Grown window: cursor mid-window does not fire, the next final-buffer + // band does (exact boundary 50 + 10 >= 60). + assert.equal(shouldGrowWindow(0, 60, totalCount, preloadBuffer), false); + assert.equal(shouldGrowWindow(49, 60, totalCount, preloadBuffer), false); + assert.equal(shouldGrowWindow(50, 60, totalCount, preloadBuffer), true); + assert.equal(shouldGrowWindow(59, 60, totalCount, preloadBuffer), true); +}); + +// T5 — AC-L2-4 + AC-L1-3: exhaustion is derived — the predicate is false at +// EVERY cursor position once loadedCount equals totalCount, no stored latch. + +test("shouldGrowWindow is false at every cursor position once exhausted (AC-L2-4, AC-L1-3)", () => { + const totalCount = 200; + for (const cursor of [0, 1, 100, 189, 190, 191, 199, 500]) { + assert.equal( + shouldGrowWindow(cursor, 200, totalCount, 10), + false, + `cursor ${cursor} on the exhausted window`, + ); + } +}); + +// T5 — AC-L1-3/AC-L2-4 (source-parse): exhaustion is derivation-only — the +// selector's class-fields region stores no `exhausted`/`isLoaded` boolean +// that could go stale across query changes. + +const selectorSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", +); + +test("the selector stores no exhausted/isLoaded flag — exhaustion is derivation-only (AC-L1-3, AC-L2-4)", () => { + const classStart = selectorSource.indexOf("class PromptHistorySelector"); + assert.ok(classStart >= 0, "PromptHistorySelector should exist"); + const dispatchStart = selectorSource.indexOf( + "private readonly dispatch", + classStart, + ); + assert.ok(dispatchStart > classStart, "dispatch table should follow"); + const fieldsRegion = selectorSource.slice(classStart, dispatchStart); + assert.ok( + !fieldsRegion.includes("exhausted"), + "no stored `exhausted` flag may exist in the class fields", + ); + assert.ok( + !fieldsRegion.includes("isLoaded"), + "no stored `isLoaded` flag may exist in the class fields", + ); +}); + +// T6 — AC-L2-2 (§8a regression pin, helper half): the trigger/growth +// arithmetic lives in pure helpers over UNFILTERED counts only. The helpers +// file must carry no filteredRecords reference and must contain C2's exact +// normative predicate expression. + +test("trigger arithmetic is unfiltered-only: exact C2 predicate, no filteredRecords in growth helpers (AC-L2-2)", () => { + const helpersSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/selector-helpers.ts", import.meta.url)), + "utf8", + ); + const bodyOf = (name: string): string => { + const fnStart = helpersSource.indexOf(`export function ${name}`); + assert.ok(fnStart >= 0, `${name} should exist`); + const bodyStart = helpersSource.indexOf("{", fnStart); + const bodyEnd = helpersSource.indexOf("\n}", fnStart); + assert.ok(bodyStart >= 0 && bodyEnd > bodyStart); + return helpersSource.slice(bodyStart, bodyEnd); + }; + for (const name of [ + "shouldGrowWindow", + "nextLoadedCount", + "loadedCountForTarget", + ]) { + assert.ok( + !bodyOf(name).includes("filteredRecords"), + `${name} must read unfiltered counts only`, + ); + } + assert.ok( + bodyOf("shouldGrowWindow").includes( + "loadedCount < totalCount && selectedIndex + preloadBuffer >= loadedCount", + ), + "the predicate must be C2's exact normative expression", + ); +}); + +// T6 — AC-L2-1/AC-L2-2 (§8a walk): cursor 0→29 over total=200 with the +// ratified constants produces exactly ONE grow (+30 clamped) — one batch +// per threshold crossing, never per-keypress re-triggering. + +test("§8a walk: cursor 0→29 over total=200 produces exactly one grow (AC-L2-1, AC-L2-2)", () => { + const total = 200; + const batchSize = 30; + const preloadBuffer = 10; + let loadedCount = initialLoadedCount(total, 30); + let grows = 0; + for (let cursor = 0; cursor <= 29; cursor++) { + if (shouldGrowWindow(cursor, loadedCount, total, preloadBuffer)) { + loadedCount = nextLoadedCount(loadedCount, total, batchSize); + grows++; + } + } + assert.equal(grows, 1, "exactly one batch per threshold crossing"); + assert.equal(loadedCount, 60, "one +30 batch clamped by nothing here"); +}); + +// T6 — AC-L2-2: after that crossing the predicate stays quiet for at least +// 20 more presses — PRELOAD_BUFFER leaves a full-viewport margin (§D3). + +test("§8a walk: the next threshold crossing is at least 20 presses away (AC-L2-2)", () => { + const total = 200; + const loadedCount = 60; // state right after the first crossing (cursor 20) + let pressesToNextCrossing: number | null = null; + for (let cursor = 21; cursor <= total; cursor++) { + if (shouldGrowWindow(cursor, loadedCount, total, 10)) { + pressesToNextCrossing = cursor - 21; + break; + } + } + assert.ok( + pressesToNextCrossing !== null && pressesToNextCrossing >= 20, + "the next crossing fires at cursor 50 — 29 presses after the first (a crossing must exist)", + ); +}); + +// T7 — AC-L1-7: wrap reachability invariant as a pure simulation of the §B2 +// wiring: grow-before-move through the helpers, modulo over the loaded set. +// A wrap to index 0 occurs ONLY on the exhausted set; every record index is +// reached (no unloaded row skipped); the walk terminates. + +test("wrap-invariant walk: wrap to 0 only when exhausted, every index reached, walk terminates (AC-L1-7)", () => { + const total = 75; + const batchSize = 30; + const preloadBuffer = 10; + let loadedCount = initialLoadedCount(total, 30); + let cursor = 0; + const visited = new Set(); + let wraps = 0; + + for (let step = 0; step < 500; step++) { + visited.add(cursor); + // §B2 grow-before-move: fire the C2 trigger, one batch per crossing. + if (shouldGrowWindow(cursor, loadedCount, total, preloadBuffer)) { + loadedCount = nextLoadedCount(loadedCount, total, batchSize); + } + const next = moveSelectedIndex(cursor, loadedCount, 1); + if (next === 0) { + wraps++; + assert.ok( + loadedCount >= total, + "wrap to index 0 must occur only when loadedCount >= records.length", + ); + break; + } + cursor = next; + } + + assert.equal(wraps, 1, "the walk must terminate via a single full wrap"); + assert.equal(loadedCount, total, "the window must be exhausted at wrap time"); + assert.equal( + visited.size, + total, + "every record index 0..74 must be reached — no unloaded row skipped", + ); + for (let i = 0; i < total; i++) { + assert.ok(visited.has(i), `record index ${i} must be reachable`); + } +}); + +// T8 — AC-L1-5: loadedCountForTarget table (PgDn catch-up semantics). + +test("loadedCountForTarget: covered target is a no-op (AC-L1-5)", () => { + const total = 200; + assert.equal(loadedCountForTarget(60, total, 35, 30), 60); + assert.equal(loadedCountForTarget(30, total, 29, 30), 30); +}); + +test("loadedCountForTarget: uncovered target grows in whole batches strictly covering it (AC-L1-5)", () => { + const total = 200; + // Target row 30 is NOT loaded by loadedCount=30 (rows 0..29) — one batch. + assert.equal(loadedCountForTarget(30, total, 30, 30), 60); + assert.equal(loadedCountForTarget(30, total, 35, 30), 60); + // Strictly covers: row 60 needs rows 0..60, so two batches. + assert.equal(loadedCountForTarget(30, total, 60, 30), 90); + assert.equal(loadedCountForTarget(30, total, 61, 30), 90); +}); + +test("loadedCountForTarget: target past total clamps; exhausted window unchanged (AC-L1-5)", () => { + assert.equal(loadedCountForTarget(30, 75, 500, 30), 75); + assert.equal(loadedCountForTarget(75, 75, 500, 30), 75); + assert.equal(loadedCountForTarget(200, 200, 10, 30), 200); +}); + +// T8 — AC-L2-1: the growth step is min(L + max(1, batchSize), R); the +// max(1, ·) guard is what keeps loadedCountForTarget's loop terminating on +// a degenerate (or negative) batch size. + +test("nextLoadedCount steps min(L + max(1, batchSize), R) including the degenerate-batch guard (AC-L2-1)", () => { + assert.equal(nextLoadedCount(30, 200, 30), 60); + assert.equal(nextLoadedCount(90, 200, 30), 120); + assert.equal(nextLoadedCount(180, 200, 30), 200, "clamped at total"); + assert.equal(nextLoadedCount(200, 200, 30), 200, "exhausted: no-op clamp"); + assert.equal(nextLoadedCount(30, 200, 0), 31, "degenerate batch adds 1"); + assert.equal(nextLoadedCount(30, 200, -5), 31, "negative batch adds 1"); +}); + +// --------------------------------------------------------------------------- +// Unit 2b — §B2 wiring pins (T9) + headerRow-only constraint (T10). +// +// Source-parse tests over src/index.ts. The body extractor mirrors +// dispatch.test.ts's methodBody(): slice from the method declaration to the +// first "\n }" — which is exactly why every nested if added by the §B2 +// wiring must close at 4-space indent (a 4-space closer cannot match the +// first-close slice, so the method close is still found). + +function methodBodyOf(name: string): string { + const decl = selectorSource.indexOf(`private ${name}(`); + assert.ok(decl >= 0, `private ${name}() should exist in src/index.ts`); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, `private ${name}() body should close`); + return selectorSource.slice(decl, end); +} + +// T9 — AC-L1-4: batch append points — growth wiring in the three downward +// paths ONLY; every upward site and applyFilter stay pure. + +test("growth wiring appears in moveDown, moveUp, pageListDown, jumpToLast (AC-L1-4)", () => { + const down = methodBodyOf("moveDown"); + assert.ok( + down.includes("this.growLoadedWindowIfNeeded()"), + "moveDown must route through the shared growth helper", + ); + const up = methodBodyOf("moveUp"); + assert.ok( + up.includes("this.growLoadedWindowIfNeeded()"), + "moveUp must route through the shared growth helper (older-direction growth)", + ); + const grow = methodBodyOf("growLoadedWindowIfNeeded"); + assert.ok( + grow.includes("shouldGrowWindow("), + "the growth helper must evaluate the C2 trigger", + ); + assert.ok( + grow.includes("nextLoadedCount("), + "the growth helper must grow via nextLoadedCount", + ); + const pageDown = methodBodyOf("pageListDown"); + assert.ok( + pageDown.includes("loadedCountForTarget("), + "pageListDown must catch up via loadedCountForTarget", + ); + const jumpLast = methodBodyOf("jumpToLast"); + assert.ok( + jumpLast.includes("this.loadedCount = this.records.length;"), + "jumpToLast must one-shot the full load (End)", + ); + for (const name of ["pageListUp", "jumpToFirst", "applyFilter"]) { + const body = methodBodyOf(name); + for (const grow of [ + "shouldGrowWindow(", + "nextLoadedCount(", + "loadedCountForTarget(", + ]) { + assert.ok( + !body.includes(grow), + `${name} must never grow (found ${grow})`, + ); + } + } +}); + +// T9 — AC-L1-7 / AC-L1-5 / AC-L1-6 ordering: growth runs BEFORE the index +// computation in every downward path (grow-before-move, design §B2/§D1). + +test("growth runs BEFORE the index computation in every downward path (AC-L1-7, AC-L1-5, AC-L1-6)", () => { + const down = methodBodyOf("moveDown"); + const growAt = down.indexOf("this.growLoadedWindowIfNeeded()"); + assert.notEqual(growAt, -1, "moveDown must route through the growth helper"); + assert.ok( + growAt < down.indexOf("moveSelectedIndex("), + "moveDown must grow before the modulo — wrap-to-0 only on the exhausted set", + ); + const page = methodBodyOf("pageListDown"); + const catchUpAt = page.indexOf("loadedCountForTarget("); + assert.notEqual(catchUpAt, -1, "pageListDown must run the PgDn catch-up"); + assert.ok( + catchUpAt < page.indexOf("pageSelectedIndex("), + "pageListDown must load the paged-to row before the selection lands", + ); + const last = methodBodyOf("jumpToLast"); + const fullLoad = last.indexOf("this.loadedCount = this.records.length;"); + const guard = last.indexOf("if (this.filteredRecords.length === 0) return;"); + assert.ok( + fullLoad !== -1 && guard !== -1 && fullLoad < guard, + "jumpToLast must full-load before the empty guard so End surfaces unloaded matches", + ); +}); + +// T9 — AC-L2-2 (§8a regression pin, wiring half): the trigger/growth call +// arguments read ONLY the unfiltered counts — `filteredRecords` appears in +// no growth region of any downward body. + +test("growth arithmetic names only this.loadedCount and this.records.length (AC-L2-2)", () => { + const down = methodBodyOf("moveDown"); + const downGrow = down.slice(0, down.indexOf("moveSelectedIndex(")); + assert.ok( + downGrow.includes("this.growLoadedWindowIfNeeded()"), + "moveDown's growth region must run the shared helper before the modulo", + ); + assert.ok( + !downGrow.includes("filteredRecords"), + "moveDown's pre-modulo region must read UNFILTERED counts only", + ); + const grow = methodBodyOf("growLoadedWindowIfNeeded"); + assert.ok( + grow.includes("this.loadedCount") && grow.includes("this.records.length"), + "the growth helper must pass the unfiltered counts", + ); + assert.ok( + !grow.includes("filteredRecords"), + "the growth helper must read UNFILTERED counts only", + ); + const page = methodBodyOf("pageListDown"); + const pageGrow = page.slice(0, page.indexOf("pageSelectedIndex(")); + assert.ok( + pageGrow.includes("this.loadedCount") && + pageGrow.includes("this.records.length"), + "pageListDown's catch-up must pass the unfiltered counts", + ); + assert.ok( + !pageGrow.includes("filteredRecords"), + "pageListDown's growth arithmetic must read UNFILTERED counts only", + ); + const last = methodBodyOf("jumpToLast"); + const guardAt = last.indexOf( + "if (this.filteredRecords.length === 0) return;", + ); + const lastGrow = last.slice(0, guardAt); + assert.ok( + lastGrow.includes("this.loadedCount = this.records.length;") && + !lastGrow.includes("filteredRecords"), + "jumpToLast's full-load region must be unfiltered-only", + ); +}); + +// T9 — AC-L2-3 (typing never loads) + AC-L1-2: applyFilter derives matches +// from the loaded prefix and contains no grow call. + +test("applyFilter windows the loaded prefix and grows only via loadedCountForQuery (AC-L2-3r, AC-L1-2)", () => { + const body = methodBodyOf("applyFilter"); + assert.ok( + body.includes("filterPrompts(") && + body.includes(".slice(0, this.loadedCount)"), + "applyFilter must derive matches from records.slice(0, loadedCount)", + ); + assert.ok( + body.includes("loadedCountForQuery("), + "applyFilter must route visibility through loadedCountForQuery (AC-L2-3r)", + ); + assert.ok( + !body.includes("nextLoadedCount("), + "typing implies one-shot full visibility via loadedCountForQuery; incremental loads stay banned (C2)", + ); + assert.ok( + !body.includes("shouldGrowWindow("), + "the filter path must never trigger growth", + ); +}); + +// T10 — AC-L3-2: headerRow-only constraint — the suffix is produced inside +// rebuildListWithWidth's existing headerRow.setText argument, adds no row +// (no new addChild in the method, constructor child sequence unchanged) and +// OVERLAY_LINES = 30 stays intact. + +test("the header keeps the position segment plus the loaded suffix on the existing headerRow.setText path (AC-L3-2)", () => { + const body = methodBodyOf("rebuildListWithWidth"); + const setTextAt = body.indexOf("headerRow.setText("); + assert.ok( + setTextAt >= 0, + "the suffix must extend the existing headerRow.setText call", + ); + const setTextRegion = body.slice( + setTextAt, + body.indexOf("this.listContainer.clear()"), + ); + assert.ok( + setTextRegion.includes("loaded ") && + setTextRegion.includes("this.loadedCount") && + setTextRegion.includes("this.records.length"), + "the ` · loaded M of T ` suffix must be produced inside the setText argument", + ); + assert.ok( + !setTextRegion.includes("indexing "), + "no indexing segment — removed by user decision", + ); + const addChildCount = body.split("addChild(").length - 1; + assert.equal( + addChildCount, + 4, + "the suffix adds no addChild call — today's 4 list-row sites unchanged", + ); + assert.ok( + selectorSource.includes("private static readonly OVERLAY_LINES = 30;"), + "OVERLAY_LINES = 30 must stay intact", + ); + const classAt = selectorSource.indexOf("class PromptHistorySelector"); + const ctorAt = selectorSource.indexOf("constructor(", classAt); + const ctorEnd = selectorSource.indexOf('this.applyFilter("")', ctorAt); + const ctorAddChild = + selectorSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; + assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); +}); + +// T14 — AC-L2-3 revision (user-directed 2026-09-08): a non-empty query +// implies full-snapshot visibility, one-shot and idempotent; an empty or +// whitespace-only query leaves the window untouched. Per-keypress +// incremental growth remains banned (the helper returns total, never +BATCH). + +test("loadedCountForQuery: non-empty query returns total, empty keeps window (AC-L2-3r)", () => { + assert.equal(loadedCountForQuery(10, 512, "deploy"), 512); + assert.equal(loadedCountForQuery(10, 512, ""), 10); + assert.equal(loadedCountForQuery(10, 512, " "), 10); + assert.equal(loadedCountForQuery(512, 512, "deploy"), 512); + assert.equal(loadedCountForQuery(10, 10, "x"), 10); +}); diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts new file mode 100644 index 000000000..c9d665b46 --- /dev/null +++ b/tests/history-legacy-migrate-v2.test.ts @@ -0,0 +1,187 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { globalSeedPath, migrateLegacyStores } from "../extensions/history/store.ts"; +// node:test has no test.skipIf (Bun-ism): emulate via the options object. +const skipIf = + (condition: unknown) => + (name: string, fn: () => unknown) => + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); + + +function makeDirs(): { root: string; agentDir: string } { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-mig-")); + const root = path.join(base, "pi-history"); + const agentDir = path.join(base, "agent"); + fs.mkdirSync(agentDir, { recursive: true }); + return { root, agentDir }; +} + +function fileTexts(file: string): string[] { + if (!fs.existsSync(file)) return []; + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .filter((l) => l.length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +test("no legacy files: migration is a no-op, nothing created", () => { + const { root, agentDir } = makeDirs(); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 0, ran: false }); + assert.equal(fs.existsSync(globalSeedPath(root)), false); +}); + +test("v1 jsonl migrates into the global seed chronologically", () => { + const { root, agentDir } = makeDirs(); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${[ + JSON.stringify({ v: 1, text: "old" }), + JSON.stringify({ v: 1, text: "new" }), + ].join("\n")}\n`, + "utf8", + ); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 2, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["old", "new"]); + assert.equal(fs.existsSync(v1), false); + assert.equal(fs.existsSync(`${v1}.imported`), true); +}); + +test("legacy array file also migrates (newest-first reversed)", () => { + const { root, agentDir } = makeDirs(); + const legacy = path.join(agentDir, "editor-history.json"); + fs.writeFileSync(legacy, JSON.stringify(["newest", "oldest"]), "utf8"); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 2, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["oldest", "newest"]); + assert.equal(fs.existsSync(`${legacy}.imported`), true); +}); + +test("both legacy files: v1 jsonl content appends after array content", () => { + const { root, agentDir } = makeDirs(); + const legacy = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(legacy, JSON.stringify(["from-array"]), "utf8"); + fs.writeFileSync( + v1, + `${JSON.stringify({ v: 1, text: "from-jsonl" })}\n`, + "utf8", + ); + migrateLegacyStores(root, agentDir); + assert.deepEqual(fileTexts(globalSeedPath(root)), [ + "from-array", + "from-jsonl", + ]); + assert.equal(fs.existsSync(`${legacy}.imported`), true); + assert.equal(fs.existsSync(`${v1}.imported`), true); +}); + +test("existing global seed gates the migration (idempotent)", () => { + const { root, agentDir } = makeDirs(); + fs.mkdirSync(path.dirname(globalSeedPath(root)), { recursive: true }); + fs.writeFileSync( + globalSeedPath(root), + `${JSON.stringify({ v: 1, text: "already-here" })}\n`, + "utf8", + ); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${JSON.stringify({ v: 1, text: "would-migrate" })}\n`, + "utf8", + ); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 0, ran: false }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["already-here"]); + assert.equal(fs.existsSync(v1), true); +}); + +test("malformed v1 jsonl lines are skipped, not fatal", () => { + const { root, agentDir } = makeDirs(); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${["{torn", JSON.stringify({ v: 1, text: "good" })].join("\n")}\n`, + "utf8", + ); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["good"]); +}); + +// chmod-based failure injection is also invisible to the superuser. +const seedFailureTest = skipIf(process.getuid?.() === 0); +seedFailureTest( + "a failed seed write leaves legacy sources untouched for retry", + () => { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "migrate-fail-")); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${JSON.stringify({ v: 1, text: "survives-retry" })}\n`, + "utf8", + ); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "migrate-fail-root-")); + // A read-only store root makes the seed write fail AFTER the sources + // have been read but BEFORE any rename. + fs.chmodSync(root, 0o555); + try { + assert.throws(() => migrateLegacyStores(root, agentDir)); + // The source was NOT renamed: the retry path is intact. + assert.equal(fs.existsSync(v1), true); + assert.equal(fs.existsSync(`${v1}.imported`), false); + assert.equal(fs.existsSync(globalSeedPath(root)), false); + } finally { + fs.chmodSync(root, 0o755); + } + // Retry after the failure clears: full migration, then rename. + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 1, ran: true }); + assert.equal(fs.existsSync(`${v1}.imported`), true); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["survives-retry"]); + }, +); + +const sealedLegacyTest = skipIf(process.getuid?.() === 0); +sealedLegacyTest( + "an unreadable legacy file is skipped; the readable file still migrates", + () => { + const { root, agentDir } = makeDirs(); + const readable = path.join(agentDir, "editor-history.json"); + fs.writeFileSync(readable, JSON.stringify(["from-array"]), "utf8"); + const sealed = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + sealed, + `${JSON.stringify({ v: 1, text: "sealed-content" })}\n`, + "utf8", + ); + fs.chmodSync(sealed, 0o000); + try { + // The sealed file's bytes are unreadable: its prompts contribute + // nothing to the seed; the readable array still migrates. No throw. + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["from-array"]); + } finally { + // The migration archives the unreadable file as `.imported` (rename + // needs no read permission — content skipped, file still moved aside); + // restore only when the original path survived an early failure. + try { + fs.chmodSync(sealed, 0o644); + } catch { + // already renamed to `.imported` by the migration + } + } + }, +); diff --git a/tests/history-load-shared-history.test.ts b/tests/history-load-shared-history.test.ts new file mode 100644 index 000000000..235a7b1bf --- /dev/null +++ b/tests/history-load-shared-history.test.ts @@ -0,0 +1,53 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { loadSharedHistory } from "../extensions/history/load-shared-history.ts"; + +function makeTempFile(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "history-load-")); + const file = path.join(dir, "editor-history.json"); + fs.writeFileSync(file, content, "utf8"); + return file; +} + +test("returns empty array when file is missing", () => { + assert.deepEqual(loadSharedHistory("/definitely/missing.json"), []); +}); + +test("returns empty array for malformed json", () => { + const file = makeTempFile("{not-json"); + assert.deepEqual(loadSharedHistory(file), []); +}); + +test("supports string entries", () => { + const file = makeTempFile(JSON.stringify(["one", "two"])); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); + +test("supports object entries with text field", () => { + const file = makeTempFile(JSON.stringify([{ text: "one" }, { text: "two" }])); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); + +test("ignores malformed object entries", () => { + const file = makeTempFile( + JSON.stringify([{ text: "one" }, { text: 2 }, { nope: "three" }]), + ); + assert.deepEqual(loadSharedHistory(file), ["one"]); +}); + +test("ignores empty string entries", () => { + const file = makeTempFile( + JSON.stringify(["", "one", { text: "" }, { text: "two" }]), + ); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); + +test("keeps only valid strings from mixed arrays", () => { + const file = makeTempFile( + JSON.stringify(["one", null, false, 42, { text: "two" }, { text: 1 }]), + ); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); diff --git a/tests/history-max-results-cap.test.ts b/tests/history-max-results-cap.test.ts new file mode 100644 index 000000000..938087c0e --- /dev/null +++ b/tests/history-max-results-cap.test.ts @@ -0,0 +1,89 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; +import { filterPrompts } from "../extensions/history/selector-helpers.ts"; + +/** + * WU5 tests (AC-S5-1, AC-S5-2): the MAX_RESULTS raise 1000 → 10000 is an + * OUTPUT cap only — filterPrompts caps both of its slice sites; the load + * path never snapshots. Pure .mjs import (no pi-tui graph) plus a + * source-parse pin on the load path (command-registration pattern). + */ + +interface CapRecord { + text: string; + searchText: string; +} + +function record(text: string): CapRecord { + return { text, searchText: text.toLowerCase() }; +} + +// T29 — AC-S5-1: the cap value. More than 10,000 records → exactly 10,000 at +// the empty-query slice AND at a filtered-query slice. NEW pin — no existing +// test pins the old 1000 literal (design §D9; zero existing-test edits +// repo-wide, so this file ADDS the pin instead of editing one). + +test("T29 (AC-S5-1): empty-query slice caps at the raised 10000", () => { + const records: CapRecord[] = []; + for (let i = 0; i < 10500; i++) { + records.push(record(`unique prompt number ${i}`)); + } + const result = filterPrompts(records, ""); + assert.equal(result.length, 10000); +}); + +test("T29 (AC-S5-1): filtered-query slice caps at the raised 10000", () => { + const records: CapRecord[] = []; + // 10,500 matches for the query token plus non-matching padding rows: the + // FILTERED set alone is above the cap, so the filtered slice site is the + // one being exercised here. + for (let i = 0; i < 10500; i++) { + records.push(record(`match me ${i}`)); + } + records.push(record("unrelated row one")); + records.push(record("unrelated row two")); + const result = filterPrompts(records, "match"); + assert.ok(result.length > 1000, "the filtered set must exceed the old cap"); + assert.equal(result.length, 10000); + assert.ok(result.every((r) => r.searchText.includes("match"))); +}); + +// T30 — AC-S5-2: output-cap-only semantics (source-parse). The load path in +// openHistorySelector carries NO slicing call — a snapshot cap would have to +// slice there — and selector-helpers.ts reads the constant at exactly the two +// sanctioned filterPrompts slice sites. Expected GREEN already BEFORE the +// WU5 wiring (the load path carries no cap today); it must STAY green after. + +const indexSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", +); +const pureSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/selector-helpers.ts", import.meta.url)), + "utf8", +); + +function openHistorySelectorBody(): string { + const start = indexSource.indexOf("async function openHistorySelector("); + assert.ok(start >= 0, "openHistorySelector should exist"); + const end = indexSource.indexOf("export default function", start); + assert.ok(end > start, "extension entry point should follow"); + return indexSource.slice(start, end); +} + +test("T30 (AC-S5-2): the load path carries no slicing call — output cap only", () => { + const body = openHistorySelectorBody(); + assert.ok( + !body.includes("slice("), + "no snapshot cap in the load path: openHistorySelector must not slice records", + ); + const sliceSites = pureSource.split("slice(0, MAX_RESULTS)").length - 1; + assert.equal( + sliceSites, + 2, + "filterPrompts hosts exactly the two sanctioned cap slice sites", + ); +}); diff --git a/tests/history-multi-reader.test.ts b/tests/history-multi-reader.test.ts new file mode 100644 index 000000000..dbf5ce37a --- /dev/null +++ b/tests/history-multi-reader.test.ts @@ -0,0 +1,179 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + type DrainResult, + drainGlobal, + drainProject, + globalSeedPath, + projectHash, + seedFilePath, +} from "../extensions/history/store.ts"; + +/** Unwrap the ok drain shape; blocked drains are a test failure. */ +function okPrompts(result: DrainResult): string[] { + assert.equal(result.status, "ok"); + return result.prompts; +} + +const PROJECT_A = "/pi-history-fixtures/project-a"; +const PROJECT_B = "/pi-history-fixtures/project-b"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-reader-")); +} + +function writeLines( + file: string, + texts: string[], + opts?: { ts?: number }, +): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${texts + .map((t) => JSON.stringify({ v: 1, text: t, ts: opts?.ts ?? 1000 })) + .join("\n")}\n`, + "utf8", + ); +} + +function setMtime(file: string, ms: number): void { + fs.utimesSync(file, new Date(ms), new Date(ms)); +} + +test("empty project dir drains nothing", () => { + const root = makeRoot(); + assert.deepEqual(okPrompts(drainProject(root, PROJECT_A)), []); +}); + +test("single file drains newest-first (reverse of file order)", () => { + const root = makeRoot(); + writeLines(path.join(root, "projects", projectHash(PROJECT_A), "s1.jsonl"), [ + "old", + "mid", + "new", + ]); + assert.deepEqual(okPrompts(drainProject(root, PROJECT_A)), ["new", "mid", "old"]); +}); + +test("multiple files merge by file mtime, then newest-first inside", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "older-session.jsonl"), ["a1", "a2"]); + writeLines(path.join(dir, "newer-session.jsonl"), ["b1", "b2"]); + setMtime(path.join(dir, "older-session.jsonl"), 1000); + setMtime(path.join(dir, "newer-session.jsonl"), 2000); + assert.deepEqual(okPrompts(drainProject(root, PROJECT_A)), ["b2", "b1", "a2", "a1"]); +}); + +test("duplicates across files keep only the newest occurrence", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "old.jsonl"), ["shared", "only-old"]); + writeLines(path.join(dir, "new.jsonl"), ["shared", "only-new"]); + setMtime(path.join(dir, "old.jsonl"), 1000); + setMtime(path.join(dir, "new.jsonl"), 2000); + assert.deepEqual(okPrompts(drainProject(root, PROJECT_A)), [ + "only-new", + "shared", + "only-old", + ]); +}); + +test("case-insensitive identity: DUPLICATE matches duplicate", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "old.jsonl"), ["duplicate"]); + writeLines(path.join(dir, "new.jsonl"), ["DUPLICATE"]); + setMtime(path.join(dir, "old.jsonl"), 1000); + setMtime(path.join(dir, "new.jsonl"), 2000); + const drained = okPrompts(drainProject(root, PROJECT_A)); + assert.equal(drained.length, 1); + assert.equal(drained[0], "DUPLICATE"); +}); + +test("limit stops the drain early (newest kept)", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + const texts: string[] = []; + for (let i = 1; i <= 30; i++) texts.push(`p${i}`); + writeLines(path.join(dir, "s.jsonl"), texts); + const drained = okPrompts(drainProject(root, PROJECT_A, 5)); + assert.deepEqual(drained, ["p30", "p29", "p28", "p27", "p26"]); +}); + +test("seed.jsonl participates as an ordinary source file", () => { + const root = makeRoot(); + writeLines(seedFilePath(root, PROJECT_A), ["seeded-old", "seeded-new"]); + setMtime(seedFilePath(root, PROJECT_A), 500); + const drained = okPrompts(drainProject(root, PROJECT_A)); + assert.deepEqual(drained, ["seeded-new", "seeded-old"]); +}); + +test("malformed lines are skipped", () => { + const root = makeRoot(); + const file = path.join(root, "projects", projectHash(PROJECT_A), "s.jsonl"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + [ + JSON.stringify({ v: 1, text: "good" }), + "{torn", + JSON.stringify({ v: 1, text: "also-good" }), + "", + ].join("\n"), + "utf8", + ); + assert.deepEqual(okPrompts(drainProject(root, PROJECT_A)), ["also-good", "good"]); +}); + +// --- global drain --- + +test("global drain merges all projects newest-first with the legacy seed", () => { + const root = makeRoot(); + const dirA = path.join(root, "projects", projectHash(PROJECT_A)); + const dirB = path.join(root, "projects", projectHash(PROJECT_B)); + // Distinct entry ts values make the cross-project order explicit: + // fileSortKey keys on the newest entry ts, so equal-ts files would leave + // the order to directory enumeration (accidental, not asserted). + writeLines(path.join(dirA, "s1.jsonl"), ["a-oldest", "a-newest"], { + ts: 3000, + }); + writeLines(path.join(dirB, "s1.jsonl"), ["b-mid"], { ts: 2000 }); + writeLines(globalSeedPath(root), ["legacy-oldest"], { ts: 1000 }); + const drained = okPrompts(drainGlobal(root)); + assert.deepEqual(drained, ["a-newest", "a-oldest", "b-mid", "legacy-oldest"]); +}); + +test("global drain dedupes across projects", () => { + const root = makeRoot(); + const dirA = path.join(root, "projects", projectHash(PROJECT_A)); + const dirB = path.join(root, "projects", projectHash(PROJECT_B)); + // Distinct entry ts: A must drain before B (see the merge test above). + writeLines(path.join(dirA, "s.jsonl"), ["shared-prompt"], { ts: 2000 }); + writeLines(path.join(dirB, "s.jsonl"), ["shared-prompt", "b-only"], { + ts: 1000, + }); + assert.deepEqual(okPrompts(drainGlobal(root)), ["shared-prompt", "b-only"]); +}); + +test("growth from a concurrent instance is visible on the next drain", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "s1.jsonl"), ["first"]); + assert.deepEqual(okPrompts(drainProject(root, PROJECT_A)), ["first"]); + writeLines(path.join(dir, "s2.jsonl"), ["from-other-instance"]); + setMtime(path.join(dir, "s2.jsonl"), Date.now() + 5000); + assert.deepEqual(okPrompts(drainProject(root, PROJECT_A)), [ + "from-other-instance", + "first", + ]); +}); + +test("global drain on a fresh root without a projects dir is empty", () => { + const root = makeRoot(); + assert.deepEqual(okPrompts(drainGlobal(root)), []); +}); diff --git a/tests/history-off-path.test.ts b/tests/history-off-path.test.ts new file mode 100644 index 000000000..a9d4f8884 --- /dev/null +++ b/tests/history-off-path.test.ts @@ -0,0 +1,94 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import promptHistoryExtension from "../extensions/history/index.ts"; + +// The module-level selector gate reads process.env directly (that path has +// no deps.env injection); keep the suite hermetic regardless of the ambient +// shell so the off-path assertions cannot be flipped by the environment. +delete process.env.GENTLE_PI_HISTORY_ENABLE; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-")); +} + +const CWD = "/pi-history-test/project-off"; + +interface Harness { + commandHandler: (args: unknown, ctx: unknown) => Promise; +} + +/** + * Load the extension against a temp root and capture the registered + * shortcut + history command handlers from the fake pi. + */ +function loadWithCommand(env: NodeJS.ProcessEnv, root: string): Harness { + const shortcuts: Array<[string, { handler: unknown }]> = []; + const commands: Array<[string, { handler: unknown }]> = []; + const pi = { + on: () => {}, + registerShortcut: (key: string, def: { handler: unknown }) => { + shortcuts.push([key, def]); + }, + registerCommand: (name: string, def: { handler: unknown }) => { + commands.push([name, def]); + }, + }; + promptHistoryExtension(pi as never, { + env, + root, + cwd: CWD, + instanceId: "inst-off", + now: () => 1700000000000, + }); + const command = commands.find(([name]) => name === "history"); + assert.ok(command, "the history command must be registered"); + assert.equal(shortcuts.length, 1, "the shortcut must still be registered"); + return { + commandHandler: command[1].handler as Harness["commandHandler"], + }; +} + +function fakeCtx(notifyCalls: Array<[string, string]>) { + return { + ui: { + notify: (message: string, level: string) => { + notifyCalls.push([message, level]); + }, + }, + }; +} + +// NOTE: there is deliberately no enabled-path smoke test here. The selector +// drain is a module-level path hard-wired to PI_HISTORY_ROOT +// (~/.pi/agent/history) with no injection point, and bun's os.homedir() +// ignores runtime HOME overrides — invoking the command with capture on +// would migrate/seed/write the real user store. The enabled direction stays +// covered by the deps-injected tests in history-session-writer.test.ts. + +test("with capture disabled, extension load writes nothing", async () => { + const root = makeRoot(); + loadWithCommand({}, root); + // Flush the setImmediate warm-up. + await new Promise((resolve) => setImmediate(resolve)); + // Nothing at all: no registry, no seed, no store file. + assert.deepEqual(fs.readdirSync(root), []); +}); + +test("with capture disabled, the history command imports nothing and warns", async () => { + const root = makeRoot(); + const { commandHandler } = loadWithCommand({}, root); + await new Promise((resolve) => setImmediate(resolve)); + const notifyCalls: Array<[string, string]> = []; + await commandHandler([], fakeCtx(notifyCalls)); + assert.equal(notifyCalls.length, 1); + assert.equal(notifyCalls[0][1], "warning"); + assert.ok( + notifyCalls[0][0].includes("GENTLE_PI_HISTORY_ENABLE"), + `the warning must name the switch, got: ${notifyCalls[0][0]}`, + ); + // The gate must fire before the drain: no migration, no seed, no store. + assert.deepEqual(fs.readdirSync(root), []); +}); diff --git a/tests/history-openflow-integration.test.ts b/tests/history-openflow-integration.test.ts new file mode 100644 index 000000000..cb1895fad --- /dev/null +++ b/tests/history-openflow-integration.test.ts @@ -0,0 +1,147 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; + +/** + * WU5 tests (AC-S6-1..3): the open-flow wiring in src/index.ts. NEVER + * import src/index.ts — it pulls the pi-tui runtime graph (design §D3). + * The wiring is pinned by source-parse (command-registration pattern); the + * loader behavior uses fs-only fixtures under the OS temp dir — NEVER the + */ + +const indexSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", +); + +function openHistorySelectorBody(): string { + const start = indexSource.indexOf("async function openHistorySelector("); + assert.ok(start >= 0, "openHistorySelector should exist"); + const end = indexSource.indexOf("export default function", start); + assert.ok(end > start, "extension entry point should follow"); + return indexSource.slice(start, end); +} + +/** Method body slice (lazy-windowing.test.ts pattern; first "\n }" close). */ +function methodBodyOf(name: string): string { + const decl = indexSource.indexOf(`private ${name}(`); + assert.ok(decl >= 0, `private ${name}() should exist in src/index.ts`); + const end = indexSource.indexOf("\n }", decl); + assert.ok(end > decl, `private ${name}() body should close`); + return indexSource.slice(decl, end); +} + +// --------------------------------------------------------------------------- +// T31 — AC-S6-1: combined-loader wiring (source-parse, §I load-bearing shape). +// --------------------------------------------------------------------------- + +test("T31 (AC-S6-1): the store drain is the entries source — no live transcript merge (§I pin 1)", () => { + const body = openHistorySelectorBody(); + const drainIdx = body.indexOf('const entries = drainForScope("project")'); + assert.ok( + drainIdx >= 0, + "the load step must drain the store directly (wiring RED seam)", + ); + assert.ok( + !body.includes("mergeHistoryEntries("), + "the live transcript merge is GONE from the open flow (user-directed store-only scopes)", + ); + assert.ok( + body.indexOf("if (entries.length === 0)") === -1, + "the empty guard is gone — the selector always opens", + ); +}); + +test("T31 (AC-S6-1): records are built via recordsFromEntries over the drained entries (§I pins 2+3)", () => { + const body = openHistorySelectorBody(); + const recIdx = body.indexOf("recordsFromEntries(entries)"); + assert.ok( + recIdx >= 0, + "records build through the shared recordsFromEntries helper", + ); +}); + +test("T31 (AC-S6-1): the three command-registration pins hold beside the swap", () => { + const definitions = + indexSource.split("async function openHistorySelector(").length - 1; + assert.equal(definitions, 1, "openHistorySelector defined exactly once"); + const calls = indexSource.split("openHistorySelector(ctx)").length - 1; + assert.equal( + calls, + 2, + "exactly the two entry-point call sites — the swap adds no occurrence", + ); + const body = openHistorySelectorBody(); + assert.ok( + !body.includes('"No prompt history available."'), + "the warning string is removed from the shared entry point", + ); +}); + +// --------------------------------------------------------------------------- +// T32 — AC-S6-2: cold-start wiring (no-await source-parse). +// --------------------------------------------------------------------------- + +test("T32 (AC-S6-2): NO await on any records build inside openHistorySelector (source-parse)", () => { + const body = openHistorySelectorBody(); + assert.ok( + body.includes('const entries = drainForScope("project")'), + "wiring present (RED seam before GREEN)", + ); + assert.ok( + !body.includes("startBackgroundIndexBuild"), + "the build kick lives inside the loader — never in the selector", + ); + assert.ok( + !/await\s+mergeHistoryEntries/.test(body), + "the open path never awaits the loader (sync const declaration)", + ); +}); + +// --------------------------------------------------------------------------- +// T33 — AC-S6-3: merged header totals + third transient dim indexing segment. +// --------------------------------------------------------------------------- + +test("T33 (AC-S6-3): header totals derive from filteredRecords — derivation untouched", () => { + const body = methodBodyOf("rebuildListWithWidth"); + assert.ok( + body.includes("const count = this.filteredRecords.length;"), + "N derives from filteredRecords (merged by construction)", + ); +}); + +test("T33 (AC-S6-3): loaded segment present, indexing segment removed", () => { + const body = methodBodyOf("rebuildListWithWidth"); + const setTextAt = body.indexOf("headerRow.setText("); + assert.ok(setTextAt >= 0, "the header must keep the existing setText call"); + const setTextRegion = body.slice( + setTextAt, + body.indexOf("this.listContainer.clear()"), + ); + assert.ok( + setTextRegion.includes("loaded ") && + setTextRegion.includes("this.loadedCount"), + "the loaded segment stays (user-restored)", + ); + assert.ok( + !setTextRegion.includes("indexing "), + "the indexing segment stays removed", + ); +}); +test("T33 (AC-S6-3): Change 2 structural pins still hold beside the third segment", () => { + assert.ok( + indexSource.includes("private static readonly OVERLAY_LINES = 30;"), + "OVERLAY_LINES = 30 intact", + ); + const body = methodBodyOf("rebuildListWithWidth"); + const addChildCount = body.split("addChild(").length - 1; + assert.equal(addChildCount, 4, "no new addChild in rebuildListWithWidth"); + const classAt = indexSource.indexOf("class PromptHistorySelector"); + const ctorAt = indexSource.indexOf("constructor(", classAt); + const ctorEnd = indexSource.indexOf('this.applyFilter("")', ctorAt); + const ctorAddChild = + indexSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; + assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); +}); diff --git a/tests/history-preview-layout.test.ts b/tests/history-preview-layout.test.ts new file mode 100644 index 000000000..02509725c --- /dev/null +++ b/tests/history-preview-layout.test.ts @@ -0,0 +1,93 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +const sourcePath = fileURLToPath( + new URL("../extensions/history/index.ts", import.meta.url), +); +const source = fs.readFileSync(sourcePath, "utf8"); + +test("preview rows are bottom-padded so the panel shrinks from the bottom", () => { + const rebuildStart = source.indexOf( + "private rebuildPreviewWithWidth(width: number): void {", + ); + assert.notStrictEqual( + rebuildStart, + -1, + "rebuildPreviewWithWidth() should exist", + ); + + const rebuildEnd = source.indexOf( + "\n // -- Selection actions", + rebuildStart, + ); + assert.notStrictEqual( + rebuildEnd, + -1, + "rebuildPreview section boundary should exist", + ); + + const rebuildPreviewSource = source.slice(rebuildStart, rebuildEnd); + + const rowLoopIndex = rebuildPreviewSource.indexOf( + "for (let i = 0; i < PREVIEW_ROWS; i++)", + ); + assert.ok( + rowLoopIndex >= 0, + "fixed-height PREVIEW_ROWS row loop should exist", + ); + + const emptyRowPadIndex = rebuildPreviewSource.indexOf( + "this.previewContainer.addChild(new FixedRowText());", + rowLoopIndex, + ); + assert.ok( + emptyRowPadIndex >= 0, + "rows past the wrapped content should be added as empty bottom padding", + ); + + assert.ok( + !rebuildPreviewSource.includes( + "const topPadding = PREVIEW_ROWS - visible.length;", + ), + "preview should not compute top padding", + ); +}); + +test("row padding measures visible width, stripping SGR escapes", () => { + // Colored list rows carry SGR escape sequences that occupy no terminal + // cells; padding must use the VISIBLE width or the row falls short of + // the overlay width and leaves ghost characters on dismiss. + const renderStart = source.indexOf(" render(width: number): string[] {"); + assert.notStrictEqual(renderStart, -1, "FixedRowText.render should exist"); + + const renderSource = source.slice(renderStart, renderStart + 2200); + const padLine = renderSource + .split("\n") + .find((l) => l.includes('" ".repeat(Math.max(0, width -')); + assert.ok(padLine !== undefined, "final full-width pad should exist"); + assert.ok( + padLine.includes("visible"), + "pad must measure the SGR-stripped visible width, not rendered.length", + ); + assert.ok( + /visible = rendered\.replace\(/.test(renderSource), + "visible width must be derived by stripping escape sequences", + ); +}); + +test("sanitizeForDisplay appends the full astral code point, not a lone surrogate", () => { + const fnStart = source.indexOf("function sanitizeForDisplay("); + assert.notStrictEqual(fnStart, -1, "sanitizeForDisplay should exist"); + + const fnSource = source.slice(fnStart, fnStart + 1200); + assert.ok( + fnSource.includes("String.fromCodePoint(cp)"), + "astral code points must be re-appended whole (emoji survive)", + ); + assert.ok( + fnSource.includes("if (cp > 0xffff) i++"), + "the low surrogate of the pair must still be skipped", + ); +}); diff --git a/tests/history-registry.test.ts b/tests/history-registry.test.ts new file mode 100644 index 000000000..726ba0de4 --- /dev/null +++ b/tests/history-registry.test.ts @@ -0,0 +1,152 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + ensureRegistryEntry, + lookupCwd, + projectHash, + registryPath, +} from "../extensions/history/store.ts"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-registry-")); +} + +function makeProject(prefix = "pi-history-registry-proj-"): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +test("creates the registry with the first entry (idempotent)", () => { + const root = makeRoot(); + const cwd = makeProject(); + const result = ensureRegistryEntry(root, cwd); + assert.deepEqual(result, { hash: projectHash(cwd), created: true }); + ensureRegistryEntry(root, cwd); + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); + assert.deepEqual(raw, { [projectHash(cwd)]: cwd }); +}); + +test("second project appends without touching the first", () => { + const root = makeRoot(); + const cwdA = makeProject(); + const cwdB = makeProject(); + ensureRegistryEntry(root, cwdA); + const b = ensureRegistryEntry(root, cwdB); + assert.equal(b.created, true); + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); + assert.equal(Object.keys(raw).length, 2); + assert.equal(raw[b.hash], cwdB); +}); + +test("lookupCwd resolves known hashes and null for unknown", () => { + const root = makeRoot(); + const cwd = makeProject(); + const { hash } = ensureRegistryEntry(root, cwd); + assert.equal(lookupCwd(root, hash), cwd); + assert.equal(lookupCwd(root, "0000000000000000"), null); + assert.equal(lookupCwd(makeRoot(), hash), null); +}); + +test("corrupt registry json is treated as empty and rebuilt on next entry", () => { + const root = makeRoot(); + fs.writeFileSync(path.join(root, "registry.json"), "{not-json", "utf8"); + assert.equal(lookupCwd(root, "0000000000000000"), null); + const cwd = makeProject(); + const result = ensureRegistryEntry(root, cwd); + assert.equal(result.created, true); + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); + assert.deepEqual(raw, { [projectHash(cwd)]: cwd }); +}); + +test("no leftover tmp files after writes", () => { + const root = makeRoot(); + ensureRegistryEntry(root, "/a"); + ensureRegistryEntry(root, "/b"); + const leftovers = fs.readdirSync(root).filter((f) => f.includes(".tmp-")); + assert.deepEqual(leftovers, []); +}); + +test("hash collision re-keys the existing occupant; the new cwd keeps the short hash", () => { + const root = makeRoot(); + const cwd = makeProject(); + const hash = projectHash(cwd); + // Simulate a collision: the short hash is pre-mapped to a different cwd. + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + registryPath(root), + JSON.stringify({ [hash]: "/some/other/project" }), + "utf8", + ); + const result = ensureRegistryEntry(root, cwd); + assert.deepEqual(result, { hash, created: true }); + const raw = JSON.parse(fs.readFileSync(registryPath(root), "utf8")); + assert.equal(raw[hash], cwd); + const longKeys = Object.keys(raw).filter((k) => k.length === 24); + assert.equal(longKeys.length, 1); + assert.equal(raw[longKeys[0]], "/some/other/project"); + assert.equal(lookupCwd(root, hash), cwd); + assert.equal(lookupCwd(root, longKeys[0]), "/some/other/project"); +}); + +test("a re-keyed cwd keeps its long key on later calls (stable collision mappings)", () => { + // projectHashLong is private: derive the documented 24-char key here — + // the literals never exist, so canonicalization falls back to the raw + // string on every platform. + const longKey = (cwd: string) => + createHash("sha256").update(cwd).digest("hex").slice(0, 24); + const readRegistryFile = (dir: string): Record => + JSON.parse(fs.readFileSync(registryPath(dir), "utf8")); + const root = makeRoot(); + const a = "/pi-history-test/registry-collide-a"; + const b = "/pi-history-test/registry-collide-b"; + // Simulate the collision: b's short hash is pre-mapped to a different + // cwd, so entering b re-keys that occupant to a 24-char key. + const shortHash = projectHash(b); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + registryPath(root), + JSON.stringify({ [shortHash]: a }), + "utf8", + ); + ensureRegistryEntry(root, b); // collision: a re-keyed to 24 chars + const before = readRegistryFile(root); + // Re-entering the re-keyed cwd must return its EXISTING long key and + // leave the other occupant's short-hash mapping untouched. + const again = ensureRegistryEntry(root, a); + assert.equal(again.created, false); + assert.equal(again.hash, longKey(a)); + const after = readRegistryFile(root); + assert.deepEqual(after, before); + // And re-entering the short-hash holder keeps the short key. + const holder = ensureRegistryEntry(root, b); + assert.equal(holder.hash, projectHash(b)); + assert.deepEqual(readRegistryFile(root), before); +}); + +test("wrong-shaped registry (array / scalar / null) fails open and is rebuilt on the next entry", () => { + // Valid JSON, wrong shape: the readRegistry shape guard treats each as an + // empty registry — lookups fail open to null, and the next entry rebuilds + // a valid object-mapped registry around itself. + const shapes: unknown[] = [["an", "array"], "scalar-string", null]; + const cwd = makeProject(); + for (const shape of shapes) { + const root = makeRoot(); + fs.writeFileSync(registryPath(root), JSON.stringify(shape), "utf8"); + assert.equal(lookupCwd(root, projectHash(cwd)), null); + const result = ensureRegistryEntry(root, cwd); + assert.deepEqual(result, { hash: projectHash(cwd), created: true }); + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); + assert.deepEqual(raw, { [projectHash(cwd)]: cwd }); + } +}); diff --git a/tests/history-scope-delete.test.ts b/tests/history-scope-delete.test.ts new file mode 100644 index 000000000..776667d3f --- /dev/null +++ b/tests/history-scope-delete.test.ts @@ -0,0 +1,138 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + deleteFromGlobal, + deleteFromProject, + globalSeedPath, + projectHash, +} from "../extensions/history/store.ts"; + +const PROJECT_A = "/pi-history-fixtures/project-a"; +const PROJECT_B = "/pi-history-fixtures/project-b"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-del-")); +} + +function writeLines(file: string, texts: string[]): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${texts.map((t) => JSON.stringify({ v: 1, text: t })).join("\n")}\n`, + "utf8", + ); +} + +function fileTexts(file: string): string[] { + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .filter((l) => l.length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +test("project delete removes every copy across the project's files", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "s1.jsonl"), ["keep", "victim"]); + writeLines(path.join(dir, "s2.jsonl"), ["VICTIM ", "also-keep"]); + const result = deleteFromProject(root, PROJECT_A, "victim"); + assert.deepEqual(result, { filesAffected: 2, removed: 2 }); + assert.deepEqual(fileTexts(path.join(dir, "s1.jsonl")), ["keep"]); + assert.deepEqual(fileTexts(path.join(dir, "s2.jsonl")), ["also-keep"]); +}); + +test("project delete leaves other projects untouched", () => { + const root = makeRoot(); + const dirA = path.join(root, "projects", projectHash(PROJECT_A)); + const dirB = path.join(root, "projects", projectHash(PROJECT_B)); + writeLines(path.join(dirA, "s.jsonl"), ["victim"]); + writeLines(path.join(dirB, "s.jsonl"), ["victim", "b-keep"]); + deleteFromProject(root, PROJECT_A, "victim"); + assert.deepEqual(fileTexts(path.join(dirB, "s.jsonl")), ["victim", "b-keep"]); +}); + +test("project delete of unknown prompt is a no-op", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "s.jsonl"), ["a"]); + const result = deleteFromProject(root, PROJECT_A, "missing"); + assert.deepEqual(result, { filesAffected: 0, removed: 0 }); + assert.deepEqual(fileTexts(path.join(dir, "s.jsonl")), ["a"]); +}); + +test("project delete on a missing dir is a no-op", () => { + const root = makeRoot(); + const result = deleteFromProject(root, PROJECT_A, "x"); + assert.deepEqual(result, { filesAffected: 0, removed: 0 }); +}); + +test("global delete on a root without a projects dir is a zero-delete no-op", () => { + const root = makeRoot(); + const result = deleteFromGlobal(root, "x"); + assert.deepEqual(result, { filesAffected: 0, removed: 0 }); +}); + +test("global delete sweeps every project dir plus the legacy seed", () => { + const root = makeRoot(); + const dirA = path.join(root, "projects", projectHash(PROJECT_A)); + const dirB = path.join(root, "projects", projectHash(PROJECT_B)); + writeLines(path.join(dirA, "s.jsonl"), ["victim", "a-keep"]); + writeLines(path.join(dirB, "s.jsonl"), ["victim"]); + writeLines(globalSeedPath(root), ["victim", "legacy-keep"]); + const result = deleteFromGlobal(root, "victim"); + assert.deepEqual(result, { filesAffected: 3, removed: 3 }); + assert.deepEqual(fileTexts(path.join(dirA, "s.jsonl")), ["a-keep"]); + assert.deepEqual(fileTexts(path.join(dirB, "s.jsonl")), []); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["legacy-keep"]); +}); + +test("delete leaves no tmp files behind", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "s.jsonl"), ["victim"]); + deleteFromProject(root, PROJECT_A, "victim"); + const leftovers = fs.readdirSync(dir).filter((f) => f.includes(".tmp-")); + assert.deepEqual(leftovers, []); +}); + +const isRoot = process.getuid?.() === 0; +const sealedFileTest = (name: string, fn: () => void | Promise) => + test(name, { skip: isRoot && "requires a non-root user" }, fn); + +sealedFileTest( + "an unreadable store file (chmod 000) is skipped; readable copies still swept", + () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + const readable = path.join(dir, "readable.jsonl"); + const sealed = path.join(dir, "sealed.jsonl"); + writeLines(readable, ["victim", "keep"]); + writeLines(sealed, ["victim"]); + fs.chmodSync(sealed, 0o000); + try { + const result = deleteFromProject(root, PROJECT_A, "victim"); + // The unreadable file's copy is invisible to the sweep; the readable + // copy is removed and the sweep is never fatal. + assert.deepEqual(result, { filesAffected: 1, removed: 1 }); + assert.deepEqual(fileTexts(readable), ["keep"]); + assert.equal(fs.existsSync(sealed), true); + } finally { + fs.chmodSync(sealed, 0o644); // restore before cleanup + } + }, +); + +test("a file whose every line is deleted becomes empty (kept, not removed)", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + const file = path.join(dir, "s.jsonl"); + writeLines(file, ["only-victim"]); + deleteFromProject(root, PROJECT_A, "only-victim"); + assert.equal(fs.existsSync(file), true); + assert.equal(fs.readFileSync(file, "utf8"), ""); +}); diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts new file mode 100644 index 000000000..d3e7d86d7 --- /dev/null +++ b/tests/history-seed-bootstrap.test.ts @@ -0,0 +1,178 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + bootstrapProjectSeed, + projectHash, + seedFilePath, +} from "../extensions/history/store.ts"; + +const CWD = "/pi-history-fixtures/project-a"; + +function makeDirs(): { root: string; sessionsRoot: string } { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-seed-")); + return { + root: path.join(base, "pi-history"), + sessionsRoot: path.join(base, "sessions"), + }; +} + +function writeSession( + sessionsRoot: string, + dirName: string, + fileName: string, + userTexts: string[], + mtimeMs?: number, +): string { + const dir = path.join(sessionsRoot, dirName); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, fileName); + const lines: string[] = [ + JSON.stringify({ + type: "session", + version: 1, + timestamp: "2026-01-01T00:00:00.000Z", + }), + ]; + let ms = 1700000000000; + for (const text of userTexts) { + lines.push( + JSON.stringify({ + type: "message", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: text, timestamp: ms }, + }), + ); + ms += 1; + } + fs.writeFileSync(file, `${lines.join("\n")}\n`, "utf8"); + if (mtimeMs !== undefined) { + fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs)); + } + return file; +} + +function seedTexts(root: string): string[] { + const file = seedFilePath(root, CWD); + if (!fs.existsSync(file)) return []; + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .filter((l) => l.length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +test("no sessions and no project dir: bootstrap seeds nothing", () => { + const { root, sessionsRoot } = makeDirs(); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 0, ran: false }); + assert.equal(fs.existsSync(seedFilePath(root, CWD)), false); +}); + +test("empty project dir bootstraps from the project's transcripts", () => { + const { root, sessionsRoot } = makeDirs(); + writeSession(sessionsRoot, `--pi-history-fixtures-project-a--`, "s1.jsonl", [ + "real prompt", + "/compact", + " ", + "second prompt", + ]); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 2, ran: true }); + // Chronological order (oldest first) in the seed file. + assert.deepEqual(seedTexts(root), ["real prompt", "second prompt"]); +}); + +test("only the project's own session dir is scanned", () => { + const { root, sessionsRoot } = makeDirs(); + writeSession(sessionsRoot, `--pi-history-fixtures-project-a--`, "s1.jsonl", [ + "mine", + ]); + writeSession(sessionsRoot, "--Other--", "s2.jsonl", ["not mine"]); + bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(seedTexts(root), ["mine"]); +}); + +test("caps at the target keeping the newest", () => { + const { root, sessionsRoot } = makeDirs(); + const texts: string[] = []; + for (let i = 1; i <= 600; i++) texts.push(`p${i}`); + writeSession( + sessionsRoot, + `--pi-history-fixtures-project-a--`, + "big.jsonl", + texts, + ); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 500, ran: true }); + const all = seedTexts(root); + assert.equal(all.length, 500); + assert.equal(all[0], "p101"); // oldest kept + assert.equal(all[499], "p600"); // newest +}); + +test("project dir already populated above target: no scan, seed untouched", () => { + const { root, sessionsRoot } = makeDirs(); + const dir = path.join(root, "projects", projectHash(CWD)); + fs.mkdirSync(dir, { recursive: true }); + const existing = path.join(dir, "existing.jsonl"); + fs.writeFileSync( + existing, + `${Array.from({ length: 500 }, (_, i) => + JSON.stringify({ v: 1, text: `e${i}` }), + ).join("\n")}\n`, + "utf8", + ); + const marker = writeSession( + sessionsRoot, + `--pi-history-fixtures-project-a--`, + "s.jsonl", + ["marker"], + ); + fs.utimesSync( + marker, + new Date(Date.now() + 5000), + new Date(Date.now() + 5000), + ); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 0, ran: false }); + assert.deepEqual(seedTexts(root), []); + assert.equal(fs.readFileSync(existing, "utf8").includes("marker"), false); +}); + +const isRoot = process.getuid?.() === 0; +const sealedStoreTest = (name: string, fn: () => void | Promise) => + test(name, { skip: isRoot && "requires a non-root user" }, fn); +sealedStoreTest( + "an unreadable existing store file is skipped during counting; seeding still runs from transcripts", + () => { + const { root, sessionsRoot } = makeDirs(); + const dir = path.join(root, "projects", projectHash(CWD)); + fs.mkdirSync(dir, { recursive: true }); + const sealed = path.join(dir, "sealed.jsonl"); + fs.writeFileSync( + sealed, + `${JSON.stringify({ v: 1, text: "sealed-entry" })}\n`, + "utf8", + ); + fs.chmodSync(sealed, 0o000); + writeSession( + sessionsRoot, + `--pi-history-fixtures-project-a--`, + "s1.jsonl", + ["from transcript"], + ); + try { + // The unreadable file contributes zero to existingCount, so the count + // stays under target and the transcript scan still runs. No throw. + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 1, ran: true }); + assert.deepEqual(seedTexts(root), ["from transcript"]); + } finally { + fs.chmodSync(sealed, 0o644); // restore before cleanup + } + }, +); diff --git a/tests/history-seed-regen.test.ts b/tests/history-seed-regen.test.ts new file mode 100644 index 000000000..cf8e7265e --- /dev/null +++ b/tests/history-seed-regen.test.ts @@ -0,0 +1,83 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { bootstrapProjectSeed, seedFilePath } from "../extensions/history/store.ts"; + +const CWD = "/pi-history-fixtures/project-a"; +const DIR = `--pi-history-fixtures-project-a--`; + +function setup() { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "seed2-")); + return { + root: path.join(base, "h"), + sessionsRoot: path.join(base, "sessions"), + stateDir: path.join(base, "state"), + }; +} + +function writeSession(sessionsRoot: string, texts: string[]): void { + const dir = path.join(sessionsRoot, DIR); + fs.mkdirSync(dir, { recursive: true }); + const lines = [ + JSON.stringify({ + type: "session", + version: 1, + timestamp: "2026-01-01T00:00:00.000Z", + }), + ]; + let ms = 1700000000000; + for (const text of texts) { + lines.push( + JSON.stringify({ + type: "message", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: text, timestamp: ms++ }, + }), + ); + } + fs.writeFileSync(path.join(dir, "s1.jsonl"), `${lines.join("\n")}\n`, "utf8"); +} + +test("an existing seed is never regenerated (deleted prompts stay gone)", () => { + const { root, sessionsRoot } = setup(); + writeSession(sessionsRoot, ["keep", "delete-me"]); + bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + // User deletes "delete-me" from the seed file (scope delete). + const seed = seedFilePath(root, CWD); + const kept = fs + .readFileSync(seed, "utf8") + .split("\n") + .filter((l) => !l.includes("delete-me")) + .join("\n"); + fs.writeFileSync(seed, kept, "utf8"); + // A NEW session bootstraps again -> must not resurrect from transcripts. + const again = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(again, { seeded: 0, ran: false }); + const texts = fs + .readFileSync(seed, "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); + assert.deepEqual(texts, ["keep"]); +}); + +test("tombstoned prompts are not seeded from transcripts", () => { + const { root, sessionsRoot, stateDir } = setup(); + writeSession(sessionsRoot, ["visible", "hidden-prompt"]); + // Tombstone "hidden-prompt" (same key shape hide-prompts writes). + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "hidden.json"), + JSON.stringify(["hidden-prompt"]), + "utf8", + ); + bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir); + const texts = fs + .readFileSync(seedFilePath(root, CWD), "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); + assert.deepEqual(texts, ["visible"]); +}); diff --git a/tests/history-selector-windowing.test.ts b/tests/history-selector-windowing.test.ts new file mode 100644 index 000000000..9fca7e9d7 --- /dev/null +++ b/tests/history-selector-windowing.test.ts @@ -0,0 +1,94 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildPromptRecords, + clampPreviewOffset, + clampSelectedIndex, + computeVisibleRange, + getVisiblePromptRecords, + moveSelectedIndex, + pageSelectedIndex, +} from "../extensions/history/selector-helpers.ts"; + +test("buildPromptRecords lowercases search text", () => { + assert.deepEqual(buildPromptRecords(["Hello"]), [ + { text: "Hello", searchText: "hello" }, + ]); +}); + +test("computeVisibleRange centers when possible", () => { + assert.deepEqual(computeVisibleRange(8, 30, 10), { start: 3, end: 13 }); +}); + +test("computeVisibleRange pins near end", () => { + assert.deepEqual(computeVisibleRange(28, 30, 10), { start: 20, end: 30 }); +}); + +test("computeVisibleRange handles small lists", () => { + assert.deepEqual(computeVisibleRange(1, 3, 10), { start: 0, end: 3 }); +}); + +test("clampSelectedIndex stays within filtered record bounds", () => { + assert.equal(clampSelectedIndex(8, 3), 2); + assert.equal(clampSelectedIndex(1, 0), 0); +}); + +test("clampPreviewOffset clamps offsets past the last page", () => { + assert.equal(clampPreviewOffset(50, 47, 10), 37); + assert.equal(clampPreviewOffset(5, 47, 10), 5); +}); + +test("clampPreviewOffset pins to zero when content fits the viewport", () => { + assert.equal(clampPreviewOffset(3, 8, 10), 0); + assert.equal(clampPreviewOffset(7, 0, 10), 0); +}); + +test("moveSelectedIndex wraps around the list", () => { + assert.equal(moveSelectedIndex(0, 3, -1), 2); + assert.equal(moveSelectedIndex(2, 3, 1), 0); + assert.equal(moveSelectedIndex(0, 0, 1), 0); +}); + +test("pageSelectedIndex clamps within the list", () => { + assert.equal(pageSelectedIndex(8, 30, -10), 0); + assert.equal(pageSelectedIndex(2, 3, 10), 2); + assert.equal(pageSelectedIndex(0, 0, 10), 0); +}); + +test("pageSelectedIndex end-clamps a downward page at the last entry (AC-P2-1.1)", () => { + assert.equal(pageSelectedIndex(28, 30, 10), 29); + assert.equal(pageSelectedIndex(25, 30, 10), 29); +}); + +test("pageSelectedIndex clamps |pageSize| greater than total in both directions (AC-P2-1.2)", () => { + assert.equal(pageSelectedIndex(0, 3, 10), 2); + assert.equal(pageSelectedIndex(2, 3, -10), 0); + assert.equal(pageSelectedIndex(0, 30, -50), 0); +}); + +test("pageSelectedIndex never wraps past the ends (AC-P2-1.2)", () => { + assert.equal(pageSelectedIndex(29, 30, 10), 29); + assert.equal(pageSelectedIndex(0, 30, -10), 0); +}); + +test("getVisiblePromptRecords returns visible records with selection state", () => { + assert.deepEqual( + getVisiblePromptRecords( + buildPromptRecords(["one", "two", "three", "four"]), + 2, + 2, + ), + [ + { + index: 1, + record: { text: "two", searchText: "two" }, + isSelected: false, + }, + { + index: 2, + record: { text: "three", searchText: "three" }, + isSelected: true, + }, + ], + ); +}); diff --git a/tests/history-session-scan-directory.test.ts b/tests/history-session-scan-directory.test.ts new file mode 100644 index 000000000..01db61258 --- /dev/null +++ b/tests/history-session-scan-directory.test.ts @@ -0,0 +1,90 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { listSessionFiles } from "../extensions/history/session-scan.ts"; +// node:test has no test.skipIf (Bun-ism): emulate via the options object. +const skipIf = + (condition: unknown) => + (name: string, fn: () => unknown) => + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); + + +/** + * WU1b-carried T7 (AC-S1-7): the one-level directory exclusion matrix. The + * fixture tree mirrors the pi sessions root — encoded-cwd directories with + * top-level jsonl session files, nested run-N/session.jsonl subagent + * payloads, a subagent-artifacts subtree, and a stray root-level file. + * Fixture files carry garbage content: the scanner must LIST paths only, so + * exact path equality proves nested payloads are never ingested (a + * recursive scanner would emit them). + */ +function makeSessionsRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-dirs-")); +} + +function writeFileAt(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "garbage-not-json\n", "utf8"); +} + +test("one-level scan rule: only top-level jsonl of cwd dirs; nested payloads, subagent-artifacts, and stray root files never ingested (AC-S1-7)", () => { + const root = makeSessionsRoot(); + + // two cwd directories, each holding top-level jsonl session files + const cwdA = path.join(root, "--home-user-project-a--"); + const cwdB = path.join(root, "--home-user-project-b--"); + writeFileAt(path.join(cwdA, "2026-01-01t10-00-00aaa.jsonl")); + writeFileAt(path.join(cwdA, "2026-01-02t11-00-00bbb.jsonl")); + writeFileAt(path.join(cwdB, "2026-01-03t12-00-00ccc.jsonl")); + + // nested run-N/session.jsonl subagent payloads — never descended + writeFileAt(path.join(cwdA, "run-1", "session.jsonl")); + writeFileAt(path.join(cwdB, "run-2", "session.jsonl")); + + // a subagent-artifacts subtree holding a jsonl file — never descended + writeFileAt(path.join(cwdA, "subagent-artifacts", "artifact.jsonl")); + + // one stray root-level FILE (not a directory) — skipped + writeFileAt(path.join(root, "stray.jsonl")); + + const files = listSessionFiles(root); + + // exactly the three top-level jsonl paths of the cwd directories, + // sorted, absolute + assert.deepEqual(files, [ + path.join(cwdA, "2026-01-01t10-00-00aaa.jsonl"), + path.join(cwdA, "2026-01-02t11-00-00bbb.jsonl"), + path.join(cwdB, "2026-01-03t12-00-00ccc.jsonl"), + ]); + for (const file of files) { + assert.equal(path.isAbsolute(file), true); + } +}); + +const sealedDirTest = skipIf(process.getuid?.() === 0); +sealedDirTest( + "an unreadable child dir (chmod 000) is skipped; sibling dirs still list", + () => { + const root = makeSessionsRoot(); + const sealed = path.join(root, "--sealed--"); + const open = path.join(root, "--open--"); + writeFileAt(path.join(sealed, "hidden-session.jsonl")); + writeFileAt(path.join(open, "visible-session.jsonl")); + fs.chmodSync(sealed, 0o000); + try { + // One directory whose readdir fails skips itself — never fatal — and + // the sibling directories still contribute their files. + assert.deepEqual(listSessionFiles(root), [ + path.join(open, "visible-session.jsonl"), + ]); + } finally { + fs.chmodSync(sealed, 0o755); // restore before cleanup + } + }, +); diff --git a/tests/history-session-scan-extract.test.ts b/tests/history-session-scan-extract.test.ts new file mode 100644 index 000000000..7814ef8cc --- /dev/null +++ b/tests/history-session-scan-extract.test.ts @@ -0,0 +1,583 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + extractPromptsFromFile, + listSessionFiles, + MAX_PROMPT_CHARS, +} from "../extensions/history/session-scan.ts"; + +/** + * WU1a fixtures (AC-S1-1..6): synthetic v3 session JSONL written to OS temp + * dirs — the module under test is fs-only and takes the file path as a + * parameter. Object lines serialize compactly (pi's JSONL shape); raw + * strings land verbatim for corrupt-line fixtures. + */ +function writeSessionFile(lines: Array): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")); + const file = path.join(dir, "session.jsonl"); + const serialized = lines + .map((line) => (typeof line === "string" ? line : JSON.stringify(line))) + .join("\n"); + fs.writeFileSync(file, `${serialized}\n`, "utf8"); + return file; +} + +/** + * WU1b fixtures (AC-S1-8..9): a synthetic pi sessions root whose shape + * mirrors ~/.pi/agent/sessions — encoded-cwd directories holding top-level + * jsonl session files. + */ +function makeSessionsRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-root-")); +} + +function writeFileAt(filePath: string, lines: Array): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const serialized = lines + .map((line) => (typeof line === "string" ? line : JSON.stringify(line))) + .join("\n"); + fs.writeFileSync(filePath, `${serialized}\n`, "utf8"); +} + +function sessionHeader(overrides: Record = {}): object { + return { + type: "session", + version: 3, + timestamp: "2026-01-15T10:00:00.000Z", + id: "session-1", + cwd: "/tmp/project", + ...overrides, + }; +} + +function userTextEntry( + text: string, + options: { messageTimestamp?: number; entryTimestamp?: string } = {}, +): object { + const message: Record = { role: "user", content: text }; + if (options.messageTimestamp !== undefined) { + message.timestamp = options.messageTimestamp; + } + const entry: Record = { + type: "message", + id: "entry-1", + parentId: null, + message, + }; + if (options.entryTimestamp !== undefined) { + entry.timestamp = options.entryTimestamp; + } + return entry; +} + +test("extracts exactly the user text block with the message ms-epoch ts (AC-S1-1)", () => { + const user = { + type: "message", + id: "m1", + parentId: null, + timestamp: "2026-01-15T10:00:01.000Z", + message: { + role: "user", + content: [{ type: "text", text: "hello from the user" }], + timestamp: 1768468801123, + }, + }; + const assistant = { + type: "message", + id: "m2", + parentId: "m1", + timestamp: "2026-01-15T10:00:02.000Z", + message: { + role: "assistant", + content: [{ type: "text", text: "assistant reply" }], + }, + }; + const toolResult = { + type: "message", + id: "m3", + parentId: "m2", + timestamp: "2026-01-15T10:00:03.000Z", + message: { + role: "toolResult", + content: [{ type: "text", text: "tool output" }], + }, + }; + const file = writeSessionFile([sessionHeader(), user, assistant, toolResult]); + const result = extractPromptsFromFile(file); + assert.equal(result.prompts.length, 1); + assert.deepEqual(result.prompts[0], { + text: "hello from the user", + ts: 1768468801123, + }); + assert.equal(result.skippedLines, 0); +}); + +test("excludes every non-prompt entry type and role; the valid user entry still extracts (AC-S1-2)", () => { + // compaction / custom_message / custom carry a nested "role":"user" so the + // substring gate HITS and the parsed type rule must reject them — the gate + // never decides membership. The role exclusions below gate-miss instead. + const exclusions = [ + { + type: "compaction", + message: { role: "user", content: "compacted summary" }, + }, + { type: "branch_summary", summary: "branched from main" }, + { type: "custom", customType: "state_snapshot", data: { role: "user" } }, + { + type: "custom_message", + message: { role: "user", content: "custom message text" }, + }, + { type: "label", name: "checkpoint" }, + { type: "session_info", version: 3 }, + { type: "model_change", message: { role: "assistant", content: "switch" } }, + { type: "thinking_level_change", level: "high" }, + { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "reply" }], + }, + }, + { + type: "message", + message: { + role: "toolResult", + content: [{ type: "text", text: "output" }], + }, + }, + { + type: "message", + message: { + role: "bashExecution", + content: [{ type: "text", text: "ls -la" }], + }, + }, + ]; + const file = writeSessionFile([ + sessionHeader(), + userTextEntry("real prompt"), + ...exclusions, + ]); + const result = extractPromptsFromFile(file); + assert.equal(result.prompts.length, 1); + assert.equal(result.prompts[0].text, "real prompt"); + assert.equal(result.skippedLines, 0); +}); + +test("skips empty, whitespace-only, and images-only user content; neighbors still extract (AC-S1-3)", () => { + const entries = [ + userTextEntry("real text before"), + { + type: "message", + message: { + role: "user", + content: [{ type: "image", source: { type: "base64", data: "img" } }], + }, + }, + { type: "message", message: { role: "user", content: "" } }, + { type: "message", message: { role: "user", content: " \n\t " } }, + { + type: "message", + message: { role: "user", content: [{ type: "text", text: " \t " }] }, + }, + userTextEntry("real text after"), + ]; + const file = writeSessionFile([sessionHeader(), ...entries]); + const result = extractPromptsFromFile(file); + assert.deepEqual( + result.prompts.map((prompt) => prompt.text), + ["real text before", "real text after"], + ); + assert.equal(result.skippedLines, 0); +}); + +test("ts precedence: message ms beats entry ISO; ISO alone; header ts; file mtime; NaN hops tolerated (AC-S1-4)", () => { + // (a) message ms-epoch beats entry ISO + const a = writeSessionFile([ + sessionHeader(), + userTextEntry("a", { + messageTimestamp: 1700000000123, + entryTimestamp: "2023-11-14T22:13:19.000Z", + }), + ]); + assert.equal(extractPromptsFromFile(a).prompts[0].ts, 1700000000123); + + // (b) entry ISO only + const b = writeSessionFile([ + sessionHeader(), + userTextEntry("b", { entryTimestamp: "2024-03-01T09:30:00.000Z" }), + ]); + assert.equal( + extractPromptsFromFile(b).prompts[0].ts, + Date.parse("2024-03-01T09:30:00.000Z"), + ); + + // (c) neither present → header timestamp + const c = writeSessionFile([sessionHeader(), userTextEntry("c")]); + assert.equal( + extractPromptsFromFile(c).prompts[0].ts, + Date.parse("2026-01-15T10:00:00.000Z"), + ); + + // NaN tolerance at the entry-ISO hop: garbage entry timestamp falls through + const garbage = writeSessionFile([ + sessionHeader(), + userTextEntry("g", { entryTimestamp: "not-a-timestamp" }), + ]); + assert.equal( + extractPromptsFromFile(garbage).prompts[0].ts, + Date.parse("2026-01-15T10:00:00.000Z"), + ); + + // final fallback: unparseable header timestamp → the file mtime + const before = Date.now() - 5; + const mtimeFile = writeSessionFile([ + sessionHeader({ timestamp: "garbage" }), + userTextEntry("m"), + ]); + const after = Date.now() + 5000; + const ts = extractPromptsFromFile(mtimeFile).prompts[0].ts; + assert.ok(Number.isFinite(ts)); + assert.ok(ts >= before && ts <= after); +}); + +test("corrupt lines are skipped, counted, and never fatal (AC-S1-5)", () => { + const file = writeSessionFile([ + sessionHeader(), + userTextEntry("one"), + '{"type":"message","message":{"role":"user"', + userTextEntry("two"), + '{broken json with "role":"user" inside}', + userTextEntry("three"), + 'not json "role":"user" at all', + ",{oops", + ]); + const result = extractPromptsFromFile(file); + assert.deepEqual( + result.prompts.map((prompt) => prompt.text), + ["one", "two", "three"], + ); + // The three corrupt gate-hit lines count; the gate-missed corrupt line is + // skipped by the prefilter without ever being parsed or counted. + assert.equal(result.skippedLines, 3); +}); + +test("bad-header aborts yield zero entries without throwing (AC-S1-6)", () => { + const emptyResult = { prompts: [], skippedLines: 0 }; + + // first line missing: an empty file + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")); + const emptyFile = path.join(emptyDir, "session.jsonl"); + fs.writeFileSync(emptyFile, "", "utf8"); + assert.deepEqual(extractPromptsFromFile(emptyFile), emptyResult); + + // unparseable first line + const unparseable = writeSessionFile([ + "{not json at all", + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(unparseable), emptyResult); + + // first line type is not session + const wrongType = writeSessionFile([ + { type: "compaction", version: 3 }, + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(wrongType), emptyResult); + + // version >= 4 + const futureVersion = writeSessionFile([ + sessionHeader({ version: 4 }), + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(futureVersion), emptyResult); + + // non-numeric version is rejected without coercion + const stringVersion = writeSessionFile([ + sessionHeader({ version: "3" }), + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(stringVersion), emptyResult); +}); + +test("boundaries: header-only file, blank padding lines, gate hits that fail the parsed rule (triangulation)", () => { + const emptyResult = { prompts: [], skippedLines: 0 }; + + // header-only file: admitted, zero prompts, zero skips + const headerOnly = writeSessionFile([sessionHeader()]); + assert.deepEqual(extractPromptsFromFile(headerOnly), emptyResult); + + // trailing and interior blank lines never parse (gate economy) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")); + const padded = path.join(dir, "session.jsonl"); + fs.writeFileSync( + padded, + JSON.stringify(sessionHeader()) + + "\n\n" + + JSON.stringify(userTextEntry("padded")) + + "\n\n", + "utf8", + ); + const paddedResult = extractPromptsFromFile(padded); + assert.equal(paddedResult.prompts.length, 1); + assert.equal(paddedResult.prompts[0].text, "padded"); + assert.equal(paddedResult.skippedLines, 0); + + // a gate hit whose parsed shape fails the extraction rule is silently + // dropped — the gate alone never decides membership + const gateHit = writeSessionFile([ + sessionHeader(), + { + type: "custom", + payload: { role: "user", content: "nested user literal" }, + }, + ]); + assert.deepEqual(extractPromptsFromFile(gateHit), emptyResult); +}); + +test("gate safety: escaped quotes extract exactly, misses never parse, the gate never decides membership (AC-S1-8)", () => { + const root = makeSessionsRoot(); + const cwdDir = path.join(root, "--tmp-project--"); + + // Escaped quotes beside the role field and inside text values: the raw + // "role":"user" literal survives serialization, the gate hits, and + // JSON.parse decodes the escapes to the exact text. + writeFileAt(path.join(cwdDir, "escaped.jsonl"), [ + sessionHeader(), + { + type: "message", + message: { + content: '"leading quote right before the role field', + role: "user", + }, + }, + { + type: "message", + message: { + role: "user", + content: [{ type: "text", text: 'block with "quoted" words' }], + }, + }, + ]); + + // Sentinel lines WITHOUT the user-role literal: if the gate ever parsed + // them, JSON.parse would throw and skippedLines would count them — a zero + // skip count proves the miss path never parses. + writeFileAt(path.join(cwdDir, "sentinel.jsonl"), [ + sessionHeader(), + "{definitely not json and no role literal", + userTextEntry("real prompt after sentinels"), + "{another broken line, still no literal", + ]); + + // A gate hit that fails the parsed extraction rule is silently dropped: + // the parsed rule, not the substring, decides membership. + writeFileAt(path.join(cwdDir, "gate-only.jsonl"), [ + sessionHeader(), + { + type: "custom", + message: { role: "user", content: "gate hits, rule rejects" }, + }, + ]); + + const prompts: string[] = []; + let skippedLines = 0; + const files = listSessionFiles(root); + assert.equal(files.length, 3); + for (const file of files) { + const result = extractPromptsFromFile(file); + for (const prompt of result.prompts) prompts.push(prompt.text); + skippedLines += result.skippedLines; + } + assert.ok(prompts.includes('"leading quote right before the role field')); + assert.ok(prompts.includes('block with "quoted" words')); + assert.ok(prompts.includes("real prompt after sentinels")); + assert.ok(!prompts.includes("gate hits, rule rejects")); + assert.equal(skippedLines, 0); +}); + +test("v1/v2 legacy tolerance: no id/parentId, weak timestamps resolve through the fallback chain (AC-S1-9)", () => { + const root = makeSessionsRoot(); + const cwdDir = path.join(root, "--legacy-project--"); + + // version-1 header; entries carry no id and no parentId + writeFileAt(path.join(cwdDir, "legacy-v1.jsonl"), [ + { type: "session", version: 1, timestamp: "2025-06-01T08:00:00.000Z" }, + { + type: "message", + timestamp: "2025-06-01T09:00:00.000Z", + message: { role: "user", content: "legacy with entry iso" }, + }, + { + type: "message", + message: { role: "user", content: "legacy bare" }, + }, + ]); + + // neither entry nor header timestamp usable → the file mtime is the tail + const before = Date.now() - 5_000; + writeFileAt(path.join(cwdDir, "legacy-mtime.jsonl"), [ + { type: "session", version: 2, timestamp: "garbage" }, + { type: "message", message: { role: "user", content: "legacy mtime" } }, + ]); + const after = Date.now() + 5_000; + + const byText = new Map(); + for (const file of listSessionFiles(root)) { + for (const prompt of extractPromptsFromFile(file).prompts) { + byText.set(prompt.text, prompt.ts); + } + } + assert.equal(byText.size, 3); + assert.equal( + byText.get("legacy with entry iso"), + Date.parse("2025-06-01T09:00:00.000Z"), + ); + assert.equal( + byText.get("legacy bare"), + Date.parse("2025-06-01T08:00:00.000Z"), + ); + const mtimeTs = byText.get("legacy mtime"); + if (mtimeTs === undefined) { + throw new Error("legacy mtime entry did not extract"); + } + assert.ok(Number.isFinite(mtimeTs)); + assert.ok(mtimeTs >= before && mtimeTs <= after); +}); + +test("multi-block content joins text blocks with a single space, trimmed; string content passes as-is (AC-S1-10)", () => { + const multi = writeSessionFile([ + sessionHeader(), + { + type: "message", + message: { + role: "user", + content: [ + { type: "text", text: "first part" }, + { type: "image", source: { type: "base64", data: "img" } }, + { type: "text", text: "second part" }, + ], + }, + }, + ]); + const multiResult = extractPromptsFromFile(multi); + assert.equal(multiResult.prompts.length, 1); + assert.equal(multiResult.prompts[0].text, "first part second part"); + + // the assembly is trimmed at its ends; the raw join keeps inner spacing + const padded = writeSessionFile([ + sessionHeader(), + { + type: "message", + message: { + role: "user", + content: [ + { type: "text", text: " padded " }, + { type: "text", text: "tail " }, + ], + }, + }, + ]); + const paddedResult = extractPromptsFromFile(padded); + assert.equal(paddedResult.prompts[0].text, "padded tail"); + + // plain-string content extracts as-is (WU1a behavior preserved) + const plain = writeSessionFile([ + sessionHeader(), + userTextEntry("plain string content"), + ]); + assert.equal( + extractPromptsFromFile(plain).prompts[0].text, + "plain string content", + ); +}); + +test("length guard: at MAX_PROMPT_CHARS extracts, strictly above skips uniformly and silently (AC-S1-11)", () => { + const atMax = "a".repeat(MAX_PROMPT_CHARS); + const over = "b".repeat(MAX_PROMPT_CHARS + 1); + const file = writeSessionFile([ + sessionHeader(), + userTextEntry("short entry"), + { type: "message", message: { role: "user", content: atMax } }, + { type: "message", message: { role: "user", content: over } }, + ]); + const result = extractPromptsFromFile(file); + assert.deepEqual( + result.prompts.map((prompt) => prompt.text), + ["short entry", atMax], + ); + // the oversized skip is uniform and silent — not a corruption count + assert.equal(result.skippedLines, 0); +}); + +test("WU1b triangulation: exact length boundary, sorted determinism across runs, empty-root fail-open", () => { + // the boundary is exact: MAX_PROMPT_CHARS extracts, one unit more skips + const exact = "x".repeat(MAX_PROMPT_CHARS); + const boundary = writeSessionFile([ + sessionHeader(), + { type: "message", message: { role: "user", content: exact } }, + { + type: "message", + message: { role: "user", content: "y".repeat(MAX_PROMPT_CHARS + 1) }, + }, + ]); + const boundaryResult = extractPromptsFromFile(boundary); + assert.deepEqual( + boundaryResult.prompts.map((prompt) => prompt.text), + [exact], + ); + assert.equal(boundaryResult.skippedLines, 0); + + // two runs return identical sorted absolute paths (deterministic order) + const root = makeSessionsRoot(); + writeFileAt(path.join(root, "--bbb--", "b.jsonl"), [ + sessionHeader(), + userTextEntry("b"), + ]); + writeFileAt(path.join(root, "--aaa--", "a.jsonl"), [ + sessionHeader(), + userTextEntry("a"), + ]); + const first = listSessionFiles(root); + const second = listSessionFiles(root); + assert.deepEqual(first, second); + assert.deepEqual(first, [ + path.join(root, "--aaa--", "a.jsonl"), + path.join(root, "--bbb--", "b.jsonl"), + ]); + + // a sessions root with no cwd directories yields an empty list, no throw + assert.deepEqual(listSessionFiles(makeSessionsRoot()), []); +}); + +test("a nonexistent path yields the empty result without throwing (triangulation)", () => { + const missing = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")), + "does-not-exist.jsonl", + ); + assert.deepEqual(extractPromptsFromFile(missing), { + prompts: [], + skippedLines: 0, + }); +}); + +test("a header with NO timestamp field (parseHeader NaN branch) plus timestamp-less messages falls back to the file mtime", () => { + // Distinct from the garbage-header-timestamp case already covered: here + // the header carries no timestamp key at all, so parseHeader returns NaN + // and resolveTimestamp falls all the way through to the file mtime. + const before = Date.now() - 5; + const file = writeSessionFile([ + { type: "session", version: 3 }, + userTextEntry("no ts anywhere"), + ]); + const after = Date.now() + 5000; + const result = extractPromptsFromFile(file); + assert.equal(result.prompts.length, 1); + assert.equal(result.prompts[0].text, "no ts anywhere"); + const ts = result.prompts[0].ts; + assert.ok(Number.isFinite(ts)); + assert.ok(ts >= before && ts <= after); +}); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts new file mode 100644 index 000000000..81ca80a1b --- /dev/null +++ b/tests/history-session-writer.test.ts @@ -0,0 +1,219 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + appendSessionCapture, + openSessionWriter, + projectHash, + sessionFilePath, +} from "../extensions/history/store.ts"; +import promptHistoryExtension, { + captureEnabled, +} from "../extensions/history/index.ts"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); +} + +const CWD = "/pi-history-fixtures/project-a"; + +function fileTexts(file: string): string[] { + return fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +function openWriterForTest(root: string, instanceId: string) { + return openSessionWriter(root, CWD, instanceId); +} + +/** Load the extension against a temp root and return the capture handler. */ +function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { + const registered: Array<[string, unknown]> = []; + const pi = { + on: (event: string, handler: unknown) => { + registered.push([event, handler]); + }, + // Slice-3+ wiring surface: the factory also registers the shortcut, + // command, and tool_call dismissal; the capture handler stays the + // first registration, so these no-ops only absorb the extra wiring. + registerShortcut: () => {}, + registerCommand: () => {}, + }; + promptHistoryExtension(pi as never, { + env, + root, + cwd: CWD, + instanceId: "inst-entry", + now: () => 1700000000000, + }); + return registered[0][1] as (event: unknown) => void; +} + +test("no file is created until the first capture", () => { + const root = makeRoot(); + const state = openWriterForTest(root, "sess-1"); + const file = sessionFilePath(root, CWD, "sess-1"); + assert.equal(fs.existsSync(file), false); + assert.equal(state.lineCount, 0); +}); + +test("first capture lazily creates the file and appends one line", () => { + const root = makeRoot(); + const state = openWriterForTest(root, "sess-1"); + appendSessionCapture(state, "hello world", 1234); + const file = sessionFilePath(root, CWD, "sess-1"); + assert.equal(fs.existsSync(file), true); + const lines = fs.readFileSync(file, "utf8").trim().split("\n"); + assert.equal(lines.length, 1); + const parsed = JSON.parse(lines[0]); + assert.equal(parsed.text, "hello world"); + assert.equal(parsed.ts, 1234); + assert.equal(parsed.v, 1); + assert.equal(state.lineCount, 1); +}); + +test("captures append in order; count tracks", () => { + const root = makeRoot(); + const state = openWriterForTest(root, "sess-2"); + appendSessionCapture(state, "one"); + appendSessionCapture(state, "two"); + appendSessionCapture(state, "three"); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "sess-2")), [ + "one", + "two", + "three", + ]); + assert.equal(state.lineCount, 3); +}); + +test("command-like and empty captures are skipped", () => { + const root = makeRoot(); + const state = openWriterForTest(root, "sess-3"); + appendSessionCapture(state, "/compact"); + appendSessionCapture(state, " "); + appendSessionCapture(state, ""); + appendSessionCapture(state, "kept"); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "sess-3")), ["kept"]); + assert.equal(state.lineCount, 1); +}); + +test("two writers own separate files in the same project dir", () => { + const root = makeRoot(); + const a = openWriterForTest(root, "inst-a"); + const b = openWriterForTest(root, "inst-b"); + appendSessionCapture(a, "from-a"); + appendSessionCapture(b, "from-b"); + const dir = path.join(root, "projects", projectHash(CWD)); + const files = fs.readdirSync(dir).sort(); + assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); +}); + +test("the extension entry wires capture first, then the selector surface", () => { + // Module load must stay side-effect free (importing index.ts parses the + // whole extension graph without touching the real ~/.pi store root). + // Capture is registered first; the selector adds session_shutdown GC, + // tool_call dismissal, the shortcut, and the /history command beside it. + const registered: Array<[string, unknown]> = []; + const pi = { + on: (event: string, handler: unknown) => { + registered.push([event, handler]); + }, + registerShortcut: () => {}, + registerCommand: () => {}, + }; + promptHistoryExtension(pi as never); + assert.deepEqual( + registered.map(([event]) => event), + ["before_agent_start", "session_shutdown", "tool_call"], + ); + // The capture handler is callable but is NEVER invoked here: a real + // invocation would run getWriter() against ~/.pi/agent/history. + assert.equal(typeof registered[0][1], "function"); +}); + +test("captureEnabled is a strict opt-in", () => { + assert.equal(captureEnabled({}), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "0" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "false" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "off" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "yes" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: " 1 " }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "TRUE" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "On" }), true); +}); + +test("the capture handler is a no-op unless the user opts in", () => { + const root = makeRoot(); + const handler = captureHandlerWith({}, root); + handler({ prompt: "sensitive prompt" }); + handler({ prompt: "another one" }); + // Nothing at all: no capture file, no project dir, no registry entry. + assert.deepEqual(fs.readdirSync(root), []); +}); + +test("an opted-in session captures delivered prompts", () => { + const root = makeRoot(); + const handler = captureHandlerWith({ GENTLE_PI_HISTORY_ENABLE: "1" }, root); + handler({ prompt: "hello store" }); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "inst-entry")), [ + "hello store", + ]); +}); + +test("disabling capture stops new lines and leaves existing files alone", () => { + const root = makeRoot(); + const env: NodeJS.ProcessEnv = { GENTLE_PI_HISTORY_ENABLE: "true" }; + const handler = captureHandlerWith(env, root); + handler({ prompt: "kept" }); + const file = sessionFilePath(root, CWD, "inst-entry"); + assert.equal(fs.existsSync(file), true); + delete env.GENTLE_PI_HISTORY_ENABLE; + handler({ prompt: "never written" }); + assert.deepEqual(fileTexts(file), ["kept"]); +}); + +test("captureEnabled is a strict opt-in", () => { + assert.equal(captureEnabled({}), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "0" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "false" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "off" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "yes" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: " 1 " }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "TRUE" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "On" }), true); +}); + +test("the capture handler is a no-op unless the user opts in", () => { + const root = makeRoot(); + const handler = captureHandlerWith({}, root); + handler({ prompt: "sensitive prompt" }); + handler({ prompt: "another one" }); + // Nothing at all: no capture file, no project dir, no registry entry. + assert.deepEqual(fs.readdirSync(root), []); +}); + +test("an opted-in session captures delivered prompts", () => { + const root = makeRoot(); + const handler = captureHandlerWith({ GENTLE_PI_HISTORY_ENABLE: "1" }, root); + handler({ prompt: "hello store" }); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "inst-entry")), [ + "hello store", + ]); +}); + +test("disabling capture stops new lines and leaves existing files alone", () => { + const root = makeRoot(); + const env: NodeJS.ProcessEnv = { GENTLE_PI_HISTORY_ENABLE: "true" }; + const handler = captureHandlerWith(env, root); + handler({ prompt: "kept" }); + const file = sessionFilePath(root, CWD, "inst-entry"); + assert.equal(fs.existsSync(file), true); + delete env.GENTLE_PI_HISTORY_ENABLE; + handler({ prompt: "never written" }); + assert.deepEqual(fileTexts(file), ["kept"]); +}); diff --git a/tests/history-store-paths.test.ts b/tests/history-store-paths.test.ts new file mode 100644 index 000000000..10dec14a0 --- /dev/null +++ b/tests/history-store-paths.test.ts @@ -0,0 +1,83 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + globalSeedPath, + projectDir, + projectHash, + registryPath, + seedFilePath, + sessionFilePath, +} from "../extensions/history/store.ts"; + +const ROOT = path.join(os.tmpdir(), "pi-history-test-root"); + +// A path that does not exist on any machine: realpathSync fails and +// projectHash falls back to hashing the raw string, so this vector pins the +// algorithm with a digest that is identical everywhere. +const KNOWN_VECTOR_INPUT = "/pi-history-known-vector/missing-project"; +const KNOWN_VECTOR_EXPECTED = "fdcfb7426fb80158"; + +function makeProject(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +test("projectHash returns 16 lowercase hex chars", () => { + assert.match(projectHash(makeProject("paths-shape-")), /^[0-9a-f]{16}$/); +}); + +test("known vector: stable hash for a fixed path", () => { + assert.equal(projectHash(KNOWN_VECTOR_INPUT), KNOWN_VECTOR_EXPECTED); +}); + +test("distinct paths produce distinct hashes", () => { + assert.notEqual( + projectHash(makeProject("paths-distinct-a-")), + projectHash(makeProject("paths-distinct-b-")), + ); +}); + +test("symlinked cwd resolves to the same hash as its target", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paths-sym-")); + const target = path.join(dir, "real-project"); + fs.mkdirSync(target); + const link = path.join(dir, "link-project"); + fs.symlinkSync(target, link); + assert.equal(projectHash(link), projectHash(target)); +}); + +test("trailing slash does not change the identity", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paths-slash-")); + assert.equal(projectHash(dir), projectHash(`${dir}/`)); +}); + +test("nonexistent path falls back to hashing the raw string (no throw)", () => { + const missing = path.join(os.tmpdir(), "paths-missing-does-not-exist"); + const hash = projectHash(missing); + assert.match(hash, /^[0-9a-f]{16}$/); +}); + +test("path derivations compose under the root", () => { + const cwd = makeProject("paths-compose-"); + const hash = projectHash(cwd); + assert.equal(projectDir(ROOT, cwd), path.join(ROOT, "projects", hash)); + assert.equal( + sessionFilePath(ROOT, cwd, "abc-123"), + path.join(ROOT, "projects", hash, "abc-123.jsonl"), + ); + assert.equal( + seedFilePath(ROOT, cwd), + path.join(ROOT, "projects", hash, "seed.jsonl"), + ); + assert.equal(globalSeedPath(ROOT), path.join(ROOT, "history-global.jsonl")); + assert.equal(registryPath(ROOT), path.join(ROOT, "registry.json")); +}); + +test("two cwds map to sibling project dirs", () => { + const a = projectDir(ROOT, makeProject("paths-sibling-a-")); + const b = projectDir(ROOT, makeProject("paths-sibling-b-")); + assert.notEqual(a, b); + assert.equal(path.dirname(a), path.dirname(b)); +}); diff --git a/tests/history-wheel-mouse.test.ts b/tests/history-wheel-mouse.test.ts new file mode 100644 index 000000000..fbb4a33b7 --- /dev/null +++ b/tests/history-wheel-mouse.test.ts @@ -0,0 +1,242 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Unit 4 — L6 wheel slice (spec C5, design §D6). +// +// Source-parse structural pins on extensions/history/index.ts (no pi-tui +// runtime graph — the same discipline as the other source-parse suites). +// The overlay renders only through pi-tui, so the unit-level contract is the +// SHAPE of the handleMouse override: +// +// - wheel-only: every non-wheel event type returns undefined (press/click/ +// drag stay host-owned) and the dispatch table gains no extra entry (wheel +// is not a keybinding — dispatch.test.ts remains the authoritative +// untouched pin); +// - ONE consumed wheel return: `handled: true` plus the synthetic target +// enrichment, reached by every wheel path including the no-op regions — +// this closes the pre-existing fullscreen SGR-fallthrough hazard by +// construction; +// - fixed 30-row geometry routing: list region y 5–14, preview region y 17–26, +// all other rows consumed no-ops; +// - list wheel: sign × |wheelDelta| steps through moveDown (the arrow grow +// path applies per step) / moveUp, magnitude clamped to the filtered list, +// zero/absent delta a no-op move — the override itself never re-implements +// growth; +// - preview wheel: 1 line per notch toward the delta direction via the +// existing clampPreviewOffset semantics + rebuildPreview. + +const selectorSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", +); + +// T13 — AC-L6-1: wheel-only override + no extra dispatch entry. + +test("handleMouse override is wheel-only and the dispatch table keeps 12 entries (AC-L6-1)", () => { + const decl = selectorSource.indexOf("override handleMouse("); + assert.ok(decl >= 0, "PromptHistorySelector should override handleMouse"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "handleMouse's body should close"); + const body = selectorSource.slice(decl, end); + + assert.ok( + body.includes('if (event.type !== "wheel") return undefined;'), + "non-wheel event types must return undefined (press/click/drag stay host-owned)", + ); + assert.ok( + body.includes('ReturnType'), + 'the return type must name the base contract via ReturnType', + ); + + const tableAt = selectorSource.indexOf( + "private readonly dispatch: readonly DispatchEntry[] = [", + ); + assert.ok(tableAt >= 0, "the dispatch table should exist"); + const tableEnd = selectorSource.indexOf("\n ];", tableAt); + assert.ok(tableEnd > tableAt, "the dispatch table should close"); + const table = selectorSource.slice(tableAt, tableEnd); + const entries = table.split("match:").length - 1; + assert.equal( + entries, + 12, + "wheel is not a keybinding: exactly 12 dispatch entries, no 13th", + ); +}); + +// T13 — AC-L6-2: ONE consumed wheel return with the target enrichment, +// reached by every wheel path including the no-op regions. + +test("every wheel path reaches the single handled:true return with target enrichment (AC-L6-2)", () => { + const decl = selectorSource.indexOf("override handleMouse("); + assert.ok(decl >= 0, "handleMouse should exist"); + const end = selectorSource.indexOf("\n }", decl); + const body = selectorSource.slice(decl, end); + + const returns = body.split("return").length - 1; + assert.equal( + returns, + 2, + "exactly two returns: the guard's undefined and the ONE consumed wheel return", + ); + assert.equal( + body.split("handled: true").length - 1, + 1, + "exactly one handled:true — the single wheel return", + ); + assert.equal( + body.split("return {").length - 1, + 1, + "exactly one object return, so list, preview, and no-op regions all reach it", + ); + + // Synthetic target mirroring dispatchMouseEvent's enrichment math + // (pi-tui tui.js dispatchMouseEvent: originX = screenX - x, originY = + // screenY - y, bounds from the event) — the result carries `target`, so + // dispatch passes it through verbatim. + assert.ok(body.includes("component: this,"), "target.component: this"); + assert.ok( + body.includes("originX: event.screenX - event.x,"), + "target.originX mirrors the dispatch enrichment math", + ); + assert.ok( + body.includes("originY: event.screenY - event.y,"), + "target.originY mirrors the dispatch enrichment math", + ); + assert.ok(body.includes("width: event.width,"), "target bounds width"); + assert.ok(body.includes("height: event.height,"), "target bounds height"); + + // No manual render: pi-tui re-renders handled wheels by default. + assert.ok( + !body.includes("requestRender"), + "handleMouse must not call requestRender (wheel results render by default)", + ); +}); + +// T13 — AC-L6-3: region routing truth table — the fixed 30-row geometry's +// list band 5–14 and preview band 17–26 appear as the y comparisons, all +// other rows fall through to the consumed no-op return. + +test("region constants 5-14 / 17-26 route the y comparisons (AC-L6-3)", () => { + assert.ok( + selectorSource.includes("const LIST_WHEEL_Y_FIRST = 5;"), + "LIST_WHEEL_Y_FIRST = 5 (list container rows)", + ); + assert.ok( + selectorSource.includes("const LIST_WHEEL_Y_LAST = 14;"), + "LIST_WHEEL_Y_LAST = 14", + ); + assert.ok( + selectorSource.includes("const PREVIEW_WHEEL_Y_FIRST = 17;"), + "PREVIEW_WHEEL_Y_FIRST = 17 (preview container rows)", + ); + assert.ok( + selectorSource.includes("const PREVIEW_WHEEL_Y_LAST = 26;"), + "PREVIEW_WHEEL_Y_LAST = 26", + ); + + const decl = selectorSource.indexOf("override handleMouse("); + assert.ok(decl >= 0, "handleMouse should exist"); + const end = selectorSource.indexOf("\n }", decl); + const body = selectorSource.slice(decl, end); + + assert.ok( + body.includes("event.y >= LIST_WHEEL_Y_FIRST") && + body.includes("event.y <= LIST_WHEEL_Y_LAST"), + "the list branch must compare y against the list band", + ); + assert.ok( + body.includes("event.y >= PREVIEW_WHEEL_Y_FIRST") && + body.includes("event.y <= PREVIEW_WHEEL_Y_LAST"), + "the preview branch must compare y against the preview band", + ); +}); + +// T13 — AC-L6-4: list wheel semantics — sign picks the direction, magnitude +// is clamped to the filtered list, zero/absent delta is a no-op, and the +// routing goes THROUGH moveDown's own grow path (never a re-implementation). + +test("list wheel routes sign-clamped steps through moveDown/moveUp (AC-L6-4)", () => { + const decl = selectorSource.indexOf("override handleMouse("); + assert.ok(decl >= 0, "handleMouse should exist"); + const end = selectorSource.indexOf("\n }", decl); + const body = selectorSource.slice(decl, end); + + // Absent wheelDelta normalizes to 0 → zero steps → no-op move. + assert.ok( + body.includes("const delta = event.wheelDelta ?? 0;"), + "delta must default an absent wheelDelta to 0", + ); + + const listStart = body.indexOf("if (event.y >= LIST_WHEEL_Y_FIRST"); + const listEnd = body.indexOf("} else if (", listStart); + assert.ok( + listStart >= 0 && listEnd > listStart, + "the list branch should exist", + ); + const listBranch = body.slice(listStart, listEnd); + + assert.ok( + listBranch.includes( + "const steps = Math.min(Math.abs(delta), this.filteredRecords.length);", + ), + "magnitude must clamp to the filtered list length", + ); + assert.ok( + listBranch.includes("for (let i = 0; i < steps; i++) {"), + "steps must move one row at a time (0 for a zero delta — the no-op)", + ); + assert.ok( + listBranch.includes("if (delta > 0) this.moveDown();") && + listBranch.includes("else this.moveUp();"), + "sign semantics: positive delta moves down through moveDown, else up", + ); + + // Prefetch interplay intact: growth belongs to moveDown itself — the + // override must not re-implement the trigger. + assert.ok( + !body.includes("shouldGrowWindow") && !body.includes("nextLoadedCount"), + "the override must not re-implement growth (wheel-down grows via moveDown)", + ); +}); + +// T13 — AC-L6-5: preview wheel semantics — one line per notch toward the +// delta direction through the existing clamp, zero-delta no-op. + +test("preview wheel scrolls one clamped line per notch (AC-L6-5)", () => { + const decl = selectorSource.indexOf("override handleMouse("); + assert.ok(decl >= 0, "handleMouse should exist"); + const end = selectorSource.indexOf("\n }", decl); + const body = selectorSource.slice(decl, end); + + const previewStart = body.indexOf("} else if ("); + const previewEnd = body.indexOf("return {", previewStart); + assert.ok( + previewStart >= 0 && previewEnd > previewStart, + "the preview branch should exist", + ); + const previewBranch = body.slice(previewStart, previewEnd); + + assert.ok( + previewBranch.includes("if (delta !== 0) {"), + "a zero delta must be a no-op in the preview band too", + ); + assert.ok( + previewBranch.includes("this.previewScrollOffset = clampPreviewOffset("), + "the preview must scroll through the existing clamp semantics", + ); + assert.ok( + previewBranch.includes("this.previewScrollOffset + (delta > 0 ? 1 : -1)"), + "exactly one line per notch toward the delta direction", + ); + assert.ok( + previewBranch.includes("this.wrappedPreviewLines.length") && + previewBranch.includes("PREVIEW_ROWS"), + "the clamp must run against the wrapped length and the viewport rows", + ); + assert.ok( + previewBranch.includes("this.rebuildPreview()"), + "the preview must re-render after the offset change", + ); +}); diff --git a/tests/selection-engine.test.ts b/tests/selection-engine.test.ts new file mode 100644 index 000000000..ecf9530af --- /dev/null +++ b/tests/selection-engine.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { CustomEditor } from "@earendil-works/pi-coding-agent"; +import { GentlePromptEditor } from "../extensions/gentle-shell.ts"; +import { SelectionEngine } from "../lib/selection-engine.ts"; +import { decodePrintableKey } from "../lib/pi-tui-keys.ts"; + +// Native selection engine tests: drive a real CustomEditor through the +// SelectionEngine the same way GentlePromptEditor wires it — engine.handleInput +// in front, native dispatch back into the editor. Covers the ported contract: +// shift+home/end selection, replace-on-delete, alt+a select all, collapse on +// movement, zero-width no-op at the edge, highlight + hint rendering, and +// degraded passthrough. + +type CtorParams = ConstructorParameters; + +function makeEditor(): CustomEditor { + const tui = { terminal: { rows: 30, columns: 100 }, requestRender: () => {} } as unknown as CtorParams[0]; + const theme = { borderColor: (s: string) => s, selectList: {} } as unknown as CtorParams[1]; + const kb = { matches: () => false } as unknown as CtorParams[2]; + const editor = new CustomEditor(tui, theme, kb); + (editor as unknown as { focused: boolean }).focused = true; + return editor; +} + +const END = "\x1b[F"; +const HOME = "\x1b[H"; +const SHIFT_HOME = "\x1b[1;2H"; +const SHIFT_END = "\x1b[1;2F"; +const RIGHT = "\x1b[C"; +const DEL = "\x1b[3~"; +const BACKSPACE = "\x7f"; +const ALT_A = "\x1ba"; + +function cursorOf(editor: CustomEditor): { line: number; col: number } { + return (editor as unknown as { getCursor(): { line: number; col: number } }).getCursor(); +} + +test("shift+home selects to line start; delete replaces the selection atomically", () => { + const editor = makeEditor(); + const engine = new SelectionEngine(editor); + const native = (d: string) => editor.handleInput(d); + editor.setText("hello world"); + editor.handleInput(END); + engine.handleInput(SHIFT_HOME, native); + assert.equal(cursorOf(editor).col, 0); + engine.handleInput(DEL, native); + assert.equal(editor.getText(), ""); +}); + +test("alt+a selects all; backspace replaces the whole text", () => { + const editor = makeEditor(); + const engine = new SelectionEngine(editor); + const native = (d: string) => editor.handleInput(d); + editor.setText("abc\ndef"); + engine.handleInput(ALT_A, native); + engine.handleInput(BACKSPACE, native); + assert.equal(editor.getText(), ""); + assert.equal(cursorOf(editor).line, 0); +}); + +test("movement collapses the selection; later delete behaves natively", () => { + const editor = makeEditor(); + const engine = new SelectionEngine(editor); + const native = (d: string) => editor.handleInput(d); + editor.setText("hello"); + editor.handleInput(END); + engine.handleInput(SHIFT_HOME, native); + engine.handleInput(RIGHT, native); + engine.handleInput(DEL, native); + assert.equal(editor.getText(), "hllo"); +}); + +test("shift+end at line end: zero-width selection, delete is a no-op", () => { + const editor = makeEditor(); + const engine = new SelectionEngine(editor); + const native = (d: string) => editor.handleInput(d); + editor.setText("hi"); + editor.handleInput(END); + engine.handleInput(SHIFT_END, native); + engine.handleInput(DEL, native); + assert.equal(editor.getText(), "hi"); +}); + +test("render wraps the selected span in reverse video and shows the hint", () => { + const tui = { terminal: { rows: 30, columns: 100 }, requestRender: () => {} } as unknown as CtorParams[0]; + const theme = { borderColor: (s: string) => s, selectList: {} } as unknown as CtorParams[1]; + const kb = { matches: () => false } as unknown as CtorParams[2]; + const editor = new GentlePromptEditor(tui, theme, kb, { + fg: (_color, text) => text, + bold: (text) => text, + requestRender: () => {}, + pending: () => false, + now: () => Date.now(), + doubleEscCancelEnabled: () => false, + dispatchQueuedText: () => {}, + }); + (editor as unknown as { focused: boolean }).focused = true; + const engine = (editor as unknown as { selectionEngine: SelectionEngine }).selectionEngine; + const native = (d: string) => editor.handleInput(d); + editor.setText("hello world"); + editor.handleInput(END); + engine.handleInput(SHIFT_HOME, native); + const rows = editor.render(80); + const content = rows[1] ?? ""; + assert.ok(content.includes("\x1b[7m"), "reverse-video span missing"); + const last = rows[rows.length - 1] ?? ""; + assert.ok(last.includes("chars selected"), "selection hint missing on the bottom rule"); +}); + +test("degraded host: pure passthrough, no selection behavior", () => { + const minimal = { + getText: () => "", + setText: (_text: string) => {}, + handleInput: (_data: string) => {}, + render: (_width: number) => [] as string[], + invalidate: () => {}, + } as unknown as CustomEditor; + const engine = new SelectionEngine(minimal); + assert.equal(engine.degraded, true); + let nativeSeen = ""; + engine.handleInput(SHIFT_HOME, (d) => { + nativeSeen = d; + }); + assert.equal(nativeSeen, SHIFT_HOME); + assert.equal(engine.anchor, null); +}); + +// --- Focused terminal-key decode + undo-transaction coverage (review round 2): +// the extension-era suites were replaced by the native engine, so these pin the +// contracts the review explicitly named: kitty press/repeat/release filtering, +// CSI-u DEL/C1 delete-only replacement, and exactly-one-undo replacement. + +const KITTY_A_PRESS = "\x1b[97;1:1u"; // 'a', kitty flag 2, press +const KITTY_A_RELEASE = "\x1b[97;1:3u"; // 'a', kitty flag 2, release + +test("kitty CSI-u press replaces the selection; release is dropped and keeps it", () => { + const editor = makeEditor(); + const engine = new SelectionEngine(editor); + const native = (d: string) => editor.handleInput(d); + editor.setText("hello"); + editor.handleInput(END); + engine.handleInput(SHIFT_HOME, native); + engine.handleInput(KITTY_A_RELEASE, native); + assert.equal(editor.getText(), "hello"); + assert.deepEqual(engine.anchor, { line: 0, col: 5 }); + engine.handleInput(KITTY_A_PRESS, native); + assert.equal(editor.getText(), "a"); + assert.deepEqual(cursorOf(editor), { line: 0, col: 1 }); +}); + +test("repeated shift+home at the line edge keeps the selection", () => { + const editor = makeEditor(); + const engine = new SelectionEngine(editor); + const native = (d: string) => editor.handleInput(d); + editor.setText("hello"); + editor.handleInput(END); + engine.handleInput(SHIFT_HOME, native); + engine.handleInput(SHIFT_HOME, native); + assert.notEqual(engine.anchor, null); + assert.equal(engine.selectionLength(), 5); +}); + +test("modifyOtherKeys: printable decodes; ctrl-modified and control codepoints are rejected", () => { + assert.equal(decodePrintableKey("\x1b[27;1;97~"), "a"); + assert.equal(decodePrintableKey("\x1b[27;5;97~"), undefined); + assert.equal(decodePrintableKey("\x1b[27;1;27~"), undefined); +}); + +test("CSI-u DEL and C1 codepoints replace the selection with a pure delete", () => { + for (const data of ["\x1b[27;1;127~", "\x1b[27;1;155~"]) { + const editor = makeEditor(); + const engine = new SelectionEngine(editor); + const native = (d: string) => editor.handleInput(d); + editor.setText("hi"); + editor.handleInput(END); + engine.handleInput(SHIFT_HOME, native); + engine.handleInput(data, native); + assert.equal(editor.getText(), "", `control byte from ${JSON.stringify(data)} must not be inserted`); + } +}); + +test("printable replacement is exactly one undo transaction", () => { + const editor = makeEditor(); + const engine = new SelectionEngine(editor); + const native = (d: string) => editor.handleInput(d); + editor.setText("hello world"); + editor.handleInput(END); + engine.handleInput(SHIFT_HOME, native); + const internals = editor as unknown as { pushUndoSnapshot(): void }; + const original = internals.pushUndoSnapshot.bind(editor); + let snapshots = 0; + internals.pushUndoSnapshot = () => { + snapshots += 1; + original(); + }; + engine.handleInput("x", native); + assert.equal(editor.getText(), "x"); + assert.equal(snapshots, 1); +});