From 9b56e0b3c575d1c8a766a152c0f2fcf4d0b12132 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:24:40 -0300 Subject: [PATCH 01/49] feat(history): per-instance JSONL store, project identity, storage tests Slice 1/6 of the PR #819 split (maintainer-requested review slices). - atomic-write: same-dir tmp+rename JSON writer, concurrent-instance-safe staging names, never throws - store: project identity (realpath+sha256[:16], raw-path fallback, 24-char collision re-key), project/seed/global/registry path derivations, advisory registry with fail-open reads and atomic writes, tolerant JSONL line parser, lazy per-instance session writer with command filtering - extension entry: identity constants and capture-only wiring (before_agent_start -> appendSessionCapture); migration, seeding, selector, deletion, and GC join in later slices - tests: 32 node:test cases covering storage concurrency and recovery (parallel writers, interleaved captures, burst order integrity, torn-line matrix + crash-tail recovery window, rapid same-target atomic writes with zero staging residue, two-instance registry interleaving, collision re-key, corrupt/wrong-shape fail-open) - test vectors are machine-independent: literal cwds exercise the documented raw-string fallback identically on every platform Gates: scoped history tests 32/32 green. verify-package-files and package-manifest failures are pre-existing environmental (gitignored contracts/.DS_Store; missing node_modules) and reproduce on vanilla origin/main. --- extensions/history/atomic-write.ts | 38 +++++ extensions/history/index.ts | 60 +++++++ extensions/history/store.ts | 240 +++++++++++++++++++++++++++ tests/history-atomic-write.test.ts | 57 +++++++ tests/history-multi-reader.test.ts | 203 ++++++++++++++++++++++ tests/history-registry.test.ts | 109 ++++++++++++ tests/history-session-writer.test.ts | 109 ++++++++++++ tests/history-store-paths.test.ts | 79 +++++++++ 8 files changed, 895 insertions(+) create mode 100644 extensions/history/atomic-write.ts create mode 100644 extensions/history/index.ts create mode 100644 extensions/history/store.ts create mode 100644 tests/history-atomic-write.test.ts create mode 100644 tests/history-multi-reader.test.ts create mode 100644 tests/history-registry.test.ts create mode 100644 tests/history-session-writer.test.ts create mode 100644 tests/history-store-paths.test.ts 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/index.ts b/extensions/history/index.ts new file mode 100644 index 000000000..26616733f --- /dev/null +++ b/extensions/history/index.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +// Prompt-history extension entry (slice 1): identity constants, the +// per-instance writer lifecycle, and the before_agent_start capture +// handler. Selector UI, shortcut/command, scope drains, legacy migration +// and seed bootstrap, and GC arrive in later slices. + +import { randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { + appendSessionCapture, + ensureRegistryEntry, + openSessionWriter, + type SessionWriterState, +} from "./store.ts"; + +// v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). +const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); +const AGENT_DIR = join(homedir(), ".pi", "agent"); +const CURRENT_CWD = process.cwd(); +// Instance identity: one exclusive capture file per pi process. +const INSTANCE_ID = randomUUID(); + +let writerState: SessionWriterState | null = null; + +/** + * One-time init per extension load: register the project in the advisory + * registry, then open this instance's exclusive capture file. Legacy + * migration and seed bootstrap join this init order in a later slice. + */ +function getWriter(): SessionWriterState { + if (!writerState) { + try { + ensureRegistryEntry(PI_HISTORY_ROOT, CURRENT_CWD); + } catch { + // registry is advisory + } + writerState = openSessionWriter(PI_HISTORY_ROOT, CURRENT_CWD, INSTANCE_ID); + } + return writerState; +} + +export default function promptHistoryExtension(pi: ExtensionAPI) { + // One writer per extension load; see getWriter() for the init order. + + // Persist every delivered user prompt (write-through, append-only JSONL). + // The local ExtensionAPI stub types handler args as unknown; narrow here. + pi.on("before_agent_start", (...args: unknown[]) => { + try { + const event = args[0] as { prompt?: string } | undefined; + appendSessionCapture(getWriter(), event?.prompt ?? "", Date.now()); + } catch { + // A capture failure must never break the agent loop or unregister + // the handler - swallow and keep the next prompt capturable. + } + }); +} diff --git a/extensions/history/store.ts b/extensions/history/store.ts new file mode 100644 index 000000000..8631707b9 --- /dev/null +++ b/extensions/history/store.ts @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +// Consolidated multi-concurrency store (v2), slice 1: project paths and +// identity, the advisory registry, entry primitives, and the per-instance +// session writer. Scope drains/deletes, legacy migration and seed +// bootstrap, and GC/compaction arrive in later slices. +// 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"; + +// =========================================================================== +// 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 }; + 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 }; +} + +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`. */ +export 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. + */ +export 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; + } +} + +// =========================================================================== +// Instance writer (formerly multi-store.ts; scope drains/deletes and GC +// arrive in later slices) +// =========================================================================== + +/** 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; +} 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-multi-reader.test.ts b/tests/history-multi-reader.test.ts new file mode 100644 index 000000000..c82ebcbc7 --- /dev/null +++ b/tests/history-multi-reader.test.ts @@ -0,0 +1,203 @@ +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"; +import { + appendSessionCapture, + ensureRegistryEntry, + openSessionWriter, + parseStoreLine, + projectDir, + projectHash, + registryPath, + sessionFilePath, +} from "../extensions/history/store.ts"; + +// Slice-1 concurrency/recovery coverage. The dev-suite multi-reader drain +// scenarios are re-expressed against the slice-1 surface (per-instance +// writers, parseStoreLine, atomic writes): parallel writers on one project +// dir, torn-line tolerance, and same-target atomic-write collisions. The +// drain/read ordering scenarios themselves arrive with the slice-2 reader. + +const PROJECT_A = "/pi-history-test/project-a"; +const PROJECT_B = "/pi-history-test/project-b"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-multi-reader-")); +} + +function storedTexts(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); +} + +// --- parallel writers --- + +test("growth from a concurrent instance is visible on the next read", () => { + const root = makeRoot(); + const a = openSessionWriter(root, PROJECT_A, "inst-a"); + appendSessionCapture(a, "first"); + const dirA = projectDir(root, PROJECT_A); + assert.deepEqual(fs.readdirSync(dirA), ["inst-a.jsonl"]); + + // A second pi instance grows the SAME project dir through its OWN file; + // neither writer reads or rewrites the other's bytes. + const b = openSessionWriter(root, PROJECT_A, "inst-b"); + appendSessionCapture(b, "from-other-instance"); + + assert.deepEqual(fs.readdirSync(dirA).sort(), [ + "inst-a.jsonl", + "inst-b.jsonl", + ]); + assert.deepEqual(storedTexts(sessionFilePath(root, PROJECT_A, "inst-a")), [ + "first", + ]); + assert.deepEqual(storedTexts(sessionFilePath(root, PROJECT_A, "inst-b")), [ + "from-other-instance", + ]); + assert.equal(a.lineCount, 1); + assert.equal(b.lineCount, 1); +}); + +test("interleaved captures from multiple writers never clobber each other", () => { + const root = makeRoot(); + const writers = [ + openSessionWriter(root, PROJECT_A, "w0"), + openSessionWriter(root, PROJECT_A, "w1"), + openSessionWriter(root, PROJECT_A, "w2"), + ]; + for (let i = 0; i < 10; i++) { + for (let w = 0; w < writers.length; w++) { + appendSessionCapture(writers[w], `w${w}-line-${i}`); + } + } + const dir = projectDir(root, PROJECT_A); + assert.deepEqual(fs.readdirSync(dir).sort(), [ + "w0.jsonl", + "w1.jsonl", + "w2.jsonl", + ]); + for (let w = 0; w < writers.length; w++) { + assert.equal(writers[w].lineCount, 10); + assert.deepEqual( + storedTexts(writers[w].filePath), + Array.from({ length: 10 }, (_, i) => `w${w}-line-${i}`), + ); + } +}); + +test("a high-volume burst on one writer keeps every line, in order", () => { + const root = makeRoot(); + const writer = openSessionWriter(root, PROJECT_A, "burst"); + const expected: string[] = []; + for (let i = 0; i < 100; i++) { + const text = `burst-${i}`; + expected.push(text); + appendSessionCapture(writer, text, 1000 + i); + } + assert.equal(writer.lineCount, 100); + const lines = fs.readFileSync(writer.filePath, "utf8").trim().split("\n"); + assert.equal(lines.length, 100); + const parsed = lines.map((l) => JSON.parse(l) as { text: string; ts: number }); + assert.deepEqual( + parsed.map((e) => e.text), + expected, + ); + assert.equal(parsed[42].ts, 1042); +}); + +// --- torn-line / crash recovery --- + +test("torn and malformed lines parse to null (crash garbage never resurfaces)", () => { + // A torn final line (process died mid-write) is a truncated JSON doc. + const torn = JSON.stringify({ v: 1, text: "survivor" }).slice(0, 12); + const malformed: string[] = [ + "", + "{torn", + torn, + "not json at all", + JSON.stringify([]), + JSON.stringify("scalar"), + JSON.stringify(null), + JSON.stringify({ v: 1 }), + JSON.stringify({ text: 42 }), + JSON.stringify({ text: " " }), + ]; + for (const line of malformed) { + assert.equal(parseStoreLine(line), null, JSON.stringify(line)); + } + // Valid lines keep parsing: absent v defaults to 1, ts/v flow through. + assert.deepEqual(parseStoreLine(JSON.stringify({ v: 1, text: "survivor" })), { + v: 1, + text: "survivor", + }); + assert.deepEqual(parseStoreLine(JSON.stringify({ text: "y" })), { + v: 1, + text: "y", + }); + assert.deepEqual(parseStoreLine(JSON.stringify({ v: 2, text: "x", ts: 7 })), { + v: 2, + text: "x", + ts: 7, + }); +}); + +test("a torn final line is tolerated: skipped by readers, later appends continue", () => { + const root = makeRoot(); + const writer = openSessionWriter(root, PROJECT_A, "torn"); + appendSessionCapture(writer, "before-crash"); + // Crash mid-write: a partial line lands WITHOUT its trailing newline. + fs.appendFileSync(writer.filePath, `{"v":1,"text":"tor`, "utf8"); + // parseStoreLine skips the torn tail instead of throwing... + assert.equal(parseStoreLine('{"v":1,"text":"tor'), null); + // ...and the instance keeps capturing. The first append after a + // newline-less torn tail merges with the fragment (one accepted lost + // entry — the same crash window the design documents for lost writes); + // the next full line parses cleanly again. + appendSessionCapture(writer, "after-crash"); + appendSessionCapture(writer, "after-crash-2"); + assert.equal(writer.lineCount, 3); + const lines = fs.readFileSync(writer.filePath, "utf8").trim().split("\n"); + assert.equal(lines.length, 3); + assert.equal((JSON.parse(lines[0]) as { text: string }).text, "before-crash"); + assert.equal(parseStoreLine(lines[1]), null); // torn fragment + merged entry + assert.equal( + (JSON.parse(lines[2]) as { text: string }).text, + "after-crash-2", + ); +}); + +// --- atomic-write collisions --- + +test("rapid same-target atomic writes leave one valid document and no staging files", () => { + const root = makeRoot(); + const target = path.join(root, "shared-state.json"); + for (let i = 0; i < 25; i++) { + assert.equal(writeJsonAtomic(target, { writer: i }), true); + } + const final = JSON.parse(fs.readFileSync(target, "utf8")) as { + writer: number; + }; + assert.ok(final.writer >= 0 && final.writer <= 24); + const leftovers = fs.readdirSync(root).filter((f) => f.includes(".tmp-")); + assert.deepEqual(leftovers, []); +}); + +test("interleaved registry updates from two instances keep both entries", () => { + const root = makeRoot(); + for (let i = 0; i < 3; i++) { + ensureRegistryEntry(root, PROJECT_A); + ensureRegistryEntry(root, PROJECT_B); + } + const raw = JSON.parse( + fs.readFileSync(registryPath(root), "utf8"), + ) as Record; + assert.equal(Object.keys(raw).length, 2); + assert.equal(raw[projectHash(PROJECT_A)], PROJECT_A); + assert.equal(raw[projectHash(PROJECT_B)], PROJECT_B); +}); diff --git a/tests/history-registry.test.ts b/tests/history-registry.test.ts new file mode 100644 index 000000000..c985972a1 --- /dev/null +++ b/tests/history-registry.test.ts @@ -0,0 +1,109 @@ +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 { + ensureRegistryEntry, + projectHash, + registryPath, +} from "../extensions/history/store.ts"; + +// Slice-1 port note: the dev suite asserted lookups through the dead +// `lookupCwd` export, which slice 1 drops. Every lookup assertion is +// re-expressed against the persisted registry.json content. + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-registry-")); +} + +function readRegistryFile(root: string): Record { + return JSON.parse( + fs.readFileSync(registryPath(root), "utf8"), + ) as Record; +} + +test("creates the registry with the first entry (idempotent)", () => { + const root = makeRoot(); + const result = ensureRegistryEntry(root, "/pi-history-test/project-a"); + assert.deepEqual(result, { hash: "4be15ec687e9df85", created: true }); + ensureRegistryEntry(root, "/pi-history-test/project-a"); + assert.deepEqual(readRegistryFile(root), { + "4be15ec687e9df85": "/pi-history-test/project-a", + }); +}); + +test("second project appends without touching the first", () => { + const root = makeRoot(); + ensureRegistryEntry(root, "/pi-history-test/project-a"); + const b = ensureRegistryEntry(root, "/pi-history-test/project-b"); + assert.equal(b.created, true); + const raw = readRegistryFile(root); + assert.equal(Object.keys(raw).length, 2); + assert.equal(raw[b.hash], "/pi-history-test/project-b"); +}); + +test("registry.json maps known hashes and omits unknown ones", () => { + const root = makeRoot(); + const { hash } = ensureRegistryEntry(root, "/pi-history-test/project-a"); + const raw = readRegistryFile(root); + assert.equal(raw[hash], "/pi-history-test/project-a"); + assert.equal(raw["0000000000000000"], undefined); + // A fresh root has no registry file until its first entry lands. + assert.equal(fs.existsSync(registryPath(makeRoot())), false); +}); + +test("corrupt registry json is treated as empty and rebuilt on next entry", () => { + const root = makeRoot(); + fs.writeFileSync(registryPath(root), "{not-json", "utf8"); + const result = ensureRegistryEntry(root, "/pi-history-test/project-a"); + assert.equal(result.created, true); + // The corrupt content was discarded (fail-open to empty), so the rebuilt + // registry contains exactly the new entry and nothing else. + assert.deepEqual(readRegistryFile(root), { + "4be15ec687e9df85": "/pi-history-test/project-a", + }); +}); + +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 = "/pi-history-test/project-a"; + 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 = readRegistryFile(root); + 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"); +}); + +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, and the next entry rebuilds a valid object-mapped + // registry around itself. + const shapes: unknown[] = [["an", "array"], "scalar-string", null]; + const cwd = "/pi-history-test/project-a"; + for (const shape of shapes) { + const root = makeRoot(); + fs.writeFileSync(registryPath(root), JSON.stringify(shape), "utf8"); + const result = ensureRegistryEntry(root, cwd); + assert.deepEqual(result, { hash: projectHash(cwd), created: true }); + assert.deepEqual(readRegistryFile(root), { [projectHash(cwd)]: cwd }); + } +}); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts new file mode 100644 index 000000000..8601cea14 --- /dev/null +++ b/tests/history-session-writer.test.ts @@ -0,0 +1,109 @@ +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 from "../extensions/history/index.ts"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); +} + +const CWD = "/pi-history-test/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); +} + +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 slice-1 extension entry registers only the capture handler", () => { + // Module load must stay side-effect free (importing index.ts parses the + // whole slice-1 graph without touching the real ~/.pi store root), and + // slice 1 wires exactly one handler: before_agent_start. + const registered: Array<[string, unknown]> = []; + const pi = { + on: (event: string, handler: unknown) => { + registered.push([event, handler]); + }, + }; + promptHistoryExtension(pi as never); + assert.deepEqual( + registered.map(([event]) => event), + ["before_agent_start"], + ); + // The handler is callable but is NEVER invoked here: a real invocation + // would run getWriter() against the user's real ~/.pi/agent/history. + assert.equal(typeof registered[0][1], "function"); +}); diff --git a/tests/history-store-paths.test.ts b/tests/history-store-paths.test.ts new file mode 100644 index 000000000..a6e5be45a --- /dev/null +++ b/tests/history-store-paths.test.ts @@ -0,0 +1,79 @@ +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"); + +test("projectHash returns 16 lowercase hex chars", () => { + const hash = projectHash("/pi-history-test/project-a"); + assert.match(hash, /^[0-9a-f]{16}$/); +}); + +test("known vector: stable hash for a fixed path", () => { + // The literal exists on no machine, so every platform exercises the + // documented raw-string fallback: sha256(literal), first 16 hex chars. + assert.equal( + projectHash("/pi-history-test/project-a"), + "4be15ec687e9df85", + ); +}); + +test("distinct paths produce distinct hashes", () => { + assert.notEqual( + projectHash("/pi-history-test/project-a"), + projectHash("/pi-history-test/project-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 = "/pi-history-test/project-a"; + 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, "/pi-history-test/project-a"); + const b = projectDir(ROOT, "/pi-history-test/project-b"); + assert.notEqual(a, b); + assert.equal(path.dirname(a), path.dirname(b)); +}); From 2b90751e8ff7cb97a8c0e4eba2348a95cbbaab33 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:37:15 -0300 Subject: [PATCH 02/49] fix(history): stable registry collision mappings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix (CodeRabbit #5160388228): ensureRegistryEntry now searches the registry for an existing mapping of the incoming cwd before the collision branch, returning the existing short or long key unchanged. Previously, re-entering a cwd that an earlier collision had re-keyed to 24 chars re-triggered the collision and flipped the other occupant's key every time — collision assignments were not stable. Adds a stability test: the re-keyed cwd keeps its long key, the short-hash holder keeps its key, and the registry bytes do not change across re-entries. Note: the atomic-write staging-name race CodeRabbit reported in the original commit was already hardened on this branch (unique .tmp-- staging + unlink-on-failure); no further change. --- extensions/history/store.ts | 5 +++++ tests/history-registry.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 8631707b9..f3723f6ad 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -113,6 +113,11 @@ export function ensureRegistryEntry( 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 diff --git a/tests/history-registry.test.ts b/tests/history-registry.test.ts index c985972a1..23235f8cd 100644 --- a/tests/history-registry.test.ts +++ b/tests/history-registry.test.ts @@ -1,5 +1,6 @@ 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"; @@ -93,6 +94,39 @@ test("hash collision re-keys the existing occupant; the new cwd keeps the short assert.equal(raw[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 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, and the next entry rebuilds a valid object-mapped From a5ff13d64dae7181ea7ce68ec578d322ef40a4ae Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:46:43 -0300 Subject: [PATCH 03/49] feat(history): read, ordering, deduplication, and project/global query APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2/6 of the PR #819 split (maintainer-requested review slices). - store: reader/query section — file listing with mtime resolution, drain ordering (newest entry ts, mtime fallback; stable under atomic rewrites), dedup + tombstone filter + cap drain, project scope drain (hash dir) and global scope drain (all project dirs, legacy global seed last); dead generator fileEntriesBackward (zero callers) dropped - hide-prompts: tombstone file contract (fail-open reader, atomic sorted writer, shared dedup key) — lands here because the drain APIs filter hidden prompts via the optional stateDir parameter; slice 5 delivers deletion semantics on top - selector-helpers (new): entry/dedup-key normalization, keep-first read-time dedup, records shaping with provenance, result filter with MAX_RESULTS cap; windowing/nav helpers follow in slice 3 - tests: 21 new node:test cases (cumulative 53/53): drain ordering across mixed mtimes, hidden-prompt filtering incl. corrupt hidden.json fail-open, dedup key normalization, cap at exactly 10000, hide/write contract incl. ENOTDIR failure; portable CWD literals throughout (no machine-specific paths) Gates: cumulative scoped history tests 53/53 green (slice-1 set unchanged). Known pre-existing environmental gate failures unchanged (contracts/.DS_Store; missing node_modules for package-manifest). --- extensions/history/hide-prompts.ts | 74 +++++++++++ extensions/history/selector-helpers.ts | 105 +++++++++++++++ extensions/history/store.ts | 170 ++++++++++++++++++++++++- tests/history-dedupe-entries.test.ts | 123 ++++++++++++++++++ tests/history-drain-hidden.test.ts | 54 ++++++++ tests/history-drain-order.test.ts | 86 +++++++++++++ tests/history-hide-prompts.test.ts | 99 ++++++++++++++ tests/history-max-results-cap.test.ts | 76 +++++++++++ 8 files changed, 781 insertions(+), 6 deletions(-) create mode 100644 extensions/history/hide-prompts.ts create mode 100644 extensions/history/selector-helpers.ts create mode 100644 tests/history-dedupe-entries.test.ts create mode 100644 tests/history-drain-hidden.test.ts create mode 100644 tests/history-drain-order.test.ts create mode 100644 tests/history-hide-prompts.test.ts create mode 100644 tests/history-max-results-cap.test.ts diff --git a/extensions/history/hide-prompts.ts b/extensions/history/hide-prompts.ts new file mode 100644 index 000000000..9cffd6954 --- /dev/null +++ b/extensions/history/hide-prompts.ts @@ -0,0 +1,74 @@ +// 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"; + +/** + * 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 }; + +/** + * Load the tombstone key set from `stateDir/hidden.json` — the READ half of + * the hide-file contract (spec C4). Fail-open: a missing, unreadable, + * corrupt, or wrong-shaped file is an EMPTY set and the call never throws; + * a corrupt file is rewritten clean by the next hide (the WRITE half, + * `hidePrompt`, lands in WU4). Keys are `promptDedupKey` strings written by + * `hidePrompt`; foreign values are ignored, never trusted. + */ +export function loadHiddenPrompts(stateDir: string): Set { + let raw: string; + try { + raw = fs.readFileSync(path.join(stateDir, HIDE_FILE_NAME), "utf8"); + } catch { + return new Set(); // missing or unreadable → empty tombstones + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return new Set(); // corrupt bytes → fail-open empty + } + const keys = new Set(); + if (!Array.isArray(parsed)) return keys; // wrong shape → fail-open empty + for (const item of parsed) { + if (typeof item === "string" && item !== "") keys.add(item); + } + return 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 set compacts on write and persists as a SORTED + * array via the shared atomic tmp+rename writer. Fail-open both ways: a + * corrupt or missing file reads as empty (this clean rewrite IS the + * recovery — the corrupt contents are untrustworthy by definition) and 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 keys = loadHiddenPrompts(stateDir); + keys.add(promptDedupKey(text)); + const written = writeJsonAtomic( + path.join(stateDir, HIDE_FILE_NAME), + [...keys].sort(), + ); + return written + ? { status: "written" } + : { + status: "error", + message: "Could not write the hide file; the prompt may reappear.", + }; +} diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts new file mode 100644 index 000000000..c5257bafe --- /dev/null +++ b/extensions/history/selector-helpers.ts @@ -0,0 +1,105 @@ +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 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; + }); +} + +/** + * 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 + * merge-history session-half 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; +} + +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/store.ts b/extensions/history/store.ts index f3723f6ad..bf2a41c16 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -1,15 +1,17 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Consolidated multi-concurrency store (v2), slice 1: project paths and -// identity, the advisory registry, entry primitives, and the per-instance -// session writer. Scope drains/deletes, legacy migration and seed -// bootstrap, and GC/compaction arrive in later slices. +// Consolidated multi-concurrency store (v2), slices 1+2: project paths and +// identity, the advisory registry, entry primitives, the per-instance +// session writer, and the scope drain/reader/query section (ordering, +// dedup, tombstone filter, project/global drains). Legacy migration and +// seed bootstrap, scope deletes, and GC/compaction arrive in later slices. // 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 { loadHiddenPrompts } from "./hide-prompts.ts"; // =========================================================================== // Paths (formerly store-paths.ts) @@ -184,8 +186,8 @@ export function parseStoreLine(raw: string): StoreEntry | null { } // =========================================================================== -// Instance writer (formerly multi-store.ts; scope drains/deletes and GC -// arrive in later slices) +// Instance writer (formerly multi-store.ts; scope deletes and GC arrive +// in later slices) // =========================================================================== /** Mutable state of ONE pi instance's exclusive capture file. */ @@ -243,3 +245,159 @@ export function appendSessionCapture( fs.appendFileSync(state.filePath, serializeEntry(entry) + "\n", "utf8"); state.lineCount += 1; } + + +// --------------------------------------------------------------------------- +// Multi-file reader (design v2: k-way backward merge) +// --------------------------------------------------------------------------- + +/** 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). */ +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); +} + +/** Tombstone key - byte-compatible with hide-prompts' promptDedupKey. */ +function promptDedupKeyOf(text: string): string { + return text.replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(); +} + +/** + * 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); +} + +/** + * Drain the PROJECT scope: all .jsonl files in the project dir (seed.jsonl + * included), mtime-newest-first, deduped, capped at `limit` (default 1000). + */ +export function drainProject( + root: string, + cwd: string, + limit: number = 1000, + stateDir?: string, +): string[] { + return drainFiles( + sortFilesForDrain(listProjectFiles(path.join(root, "projects", projectHash(cwd)))), + limit, + stateDir ? loadHiddenPrompts(stateDir) : new Set(), + ); +} + +/** + * Drain the GLOBAL scope: the legacy global seed (newest single source) + * plus every project dir's files, mtime-newest-first, deduped, capped. + */ +export function drainGlobal( + root: string, + limit: number = 1000, + stateDir?: string, +): string[] { + const files: string[] = []; + const globalSeed = globalSeedPath(root); + + 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)), + ); + } + const sorted = sortFilesForDrain(files); + if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last + return drainFiles( + sorted, + limit, + stateDir ? loadHiddenPrompts(stateDir) : new Set(), + ); +} diff --git a/tests/history-dedupe-entries.test.ts b/tests/history-dedupe-entries.test.ts new file mode 100644 index 000000000..f929880e9 --- /dev/null +++ b/tests/history-dedupe-entries.test.ts @@ -0,0 +1,123 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +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). +// +// The dev suite's T3 source-parse pins (dedupePromptEntries wired between +// drainForScope and buildPromptRecords inside openHistorySelector) cover the +// slice-3 selector wiring in extensions/history/index.ts and port with that +// slice — index.ts stays at its slice-1 surface here. + +// 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]); +}); diff --git a/tests/history-drain-hidden.test.ts b/tests/history-drain-hidden.test.ts new file mode 100644 index 000000000..90c479758 --- /dev/null +++ b/tests/history-drain-hidden.test.ts @@ -0,0 +1,54 @@ +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, +} 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-hidden-project"; + +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", + ); +} + +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(drainProject(root, CWD, 1000, stateDir), [ + "also keep", + "keep", + ]); + assert.deepEqual(drainGlobal(root, 1000, stateDir), [ + "also keep", + "keep", + "legacy keep", + ]); + // Without a stateDir the filter is off (raw drain semantics). + assert.equal(drainProject(root, CWD).includes("deleted from seed"), true); +}); diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts new file mode 100644 index 000000000..86cfddca9 --- /dev/null +++ b/tests/history-drain-order.test.ts @@ -0,0 +1,86 @@ +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, +} 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", + ); +} + +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(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(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(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(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(drainProject(root, CWD), ["z-new", "a-old"]); + } finally { + fs.chmodSync(sealed, 0o644); // restore before cleanup + } + }, +); diff --git a/tests/history-hide-prompts.test.ts b/tests/history-hide-prompts.test.ts new file mode 100644 index 000000000..03c054863 --- /dev/null +++ b/tests/history-hide-prompts.test.ts @@ -0,0 +1,99 @@ +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, loadHiddenPrompts } 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). fs-only +// coverage. 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"), + ); +} + +// 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, sorted. + assert.deepEqual(stored, texts.map((text) => promptDedupKey(text)).sort()); + // The loaded set agrees. + const loaded = loadHiddenPrompts(stateDir); + for (const key of stored) { + assert.ok(loaded.has(key)); + } +}); + +// T25 — AC-S4-2: hide persistence and tolerance. Two deletes of the same +// text compact to ONE key; a missing hide file reads as an empty set; reads +// never throw. +test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads as empty; reads never throw", () => { + const stateDir = makeStateDir("t25"); + // Missing file: empty set, no throw (before any write exists). + assert.equal(loadHiddenPrompts(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 loaded = loadHiddenPrompts(stateDir); + assert.equal(loaded.size, 1); + assert.ok(loaded.has(promptDedupKey("same text"))); +}); + +// T26 — AC-S4-5: corrupt hidden.json is fail-open (READ half) AND the next +// hide rewrites the file clean as a sorted compact array — the rewrite half +// is the recovery path. +test("T26 (AC-S4-5): corrupt hidden.json loads as empty and the next hide rewrites it clean", () => { + const stateDir = makeStateDir("t26"); + fs.writeFileSync( + path.join(stateDir, "hidden.json"), + "{corrupt bytes", + "utf8", + ); + assert.equal(loadHiddenPrompts(stateDir).size, 0); + assert.deepEqual(hidePrompt(stateDir, "beta prompt"), { status: "written" }); + // The rewrite landed: clean JSON holding exactly the new key. + assert.deepEqual(readHideFile(stateDir), [promptDedupKey("beta prompt")]); + assert.equal(loadHiddenPrompts(stateDir).size, 1); +}); + +// WU4c — write-failure path (AC-S4-2 triangulation): a state dir that cannot +// be created (its parent is a regular file) makes the atomic write return +// false, and hidePrompt maps that to the toast-suitable error object — +// never a throw. +test("hide write failure returns the exact error shape for the delete-flow toast", () => { + const base = makeStateDir("fail"); + const blocker = path.join(base, "blocker"); + fs.writeFileSync(blocker, "regular file", "utf8"); + const stateDir = path.join(blocker, "sealed"); // parent is a file → ENOTDIR + assert.deepEqual(hidePrompt(stateDir, "kept prompt"), { + status: "error", + message: "Could not write the hide file; the prompt may reappear.", + }); +}); diff --git a/tests/history-max-results-cap.test.ts b/tests/history-max-results-cap.test.ts new file mode 100644 index 000000000..fb319c8ec --- /dev/null +++ b/tests/history-max-results-cap.test.ts @@ -0,0 +1,76 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +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 import (no pi-tui graph) plus a source-parse + * pin on the selector-helpers slice sites. + * + * The dev suite's openHistorySelector body pin (no slice() on the load + * path) covers the slice-3 selector wiring in extensions/history/index.ts + * and ports with that slice — index.ts stays at its slice-1 surface here. + */ + +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). Selector-helpers +// reads the constant at exactly the two sanctioned filterPrompts slice +// sites — no other cap exists in the helper module. + +const helperSource = fs.readFileSync( + fileURLToPath( + new URL("../extensions/history/selector-helpers.ts", import.meta.url), + ), + "utf8", +); + +test("T30 (AC-S5-2): filterPrompts hosts exactly the two sanctioned cap slice sites", () => { + const sliceSites = helperSource.split("slice(0, MAX_RESULTS)").length - 1; + assert.equal( + sliceSites, + 2, + "filterPrompts hosts exactly the two sanctioned cap slice sites", + ); +}); From 73c55ff99193804815c80058b77b5c3e57195c55 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:38:16 -0300 Subject: [PATCH 04/49] docs(history): correct drainGlobal seed-ordering docblock Review fix (Copilot suppressed comment, store.ts): the docblock claimed the legacy global seed is the "newest single source", but the code deliberately appends it after sorting (`// legacy last`) so per-project entries win recency and keep-first dedup. Document the actual, intended behavior instead of changing it: migrated legacy history is the least specific source. --- extensions/history/store.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/history/store.ts b/extensions/history/store.ts index bf2a41c16..fb470515c 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -368,8 +368,10 @@ export function drainProject( } /** - * Drain the GLOBAL scope: the legacy global seed (newest single source) - * plus every project dir's files, mtime-newest-first, deduped, capped. + * 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). */ export function drainGlobal( root: string, From 366b17945cc752ebb6b858fe367b74b3502a043f Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:11:48 -0300 Subject: [PATCH 05/49] feat(history): history selector TUI and command/shortcut wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3/6 of the PR #819 split (maintainer-requested review slices). - selector-helpers: windowing/navigation subset — clamp/visible-range math, move/page selection, lazy-window growth (initial batch, grow triggers, target loading, query full-snapshot), visible-record projection, expanded-history globals hook - index.ts: PromptHistorySelector TUI (fixed-row layout, centered preview pane, search filter, Tab project/global scope toggle, grow-before-move navigation, PgDn catch-up, End full jump, wheel handling over fixed 30-row geometry, width-change pre-clamp), overlay glue (bottom-center anchored ctx.ui.custom factory), drainForScope + recordsFromEntries, wiring for ctrl+shift+r shortcut, history command, and tool_call overlay dismissal - upstream dead code dropped: notifyIndexProgress/activeIndexProgress sink pair (never fired) and unused fs import - deletion is slice 5: no deleteCurrent, no delete dispatch entry, no delete affordance in the footer hint yet - getWriter still performs no migration/seed bootstrap (slice 4); the selector drains live stores only - tests: 57 new node:test cases (cumulative 110/110): windowing math, lazy window growth contracts, preview layout, 11-entry dispatch table, wheel routing, expanded globals, shortcut/command registration surface, open-close flow with fake ctx; superseded slice-1 registration pin updated to the slice-3 wiring surface Gates: cumulative scoped history tests 110/110 green. esbuild bundle parse of the full extension graph clean. Known pre-existing environmental gate failures unchanged. --- extensions/history/index.ts | 889 ++++++++++++++++++++- extensions/history/selector-helpers.ts | 172 ++++ tests/history-command-registration.test.ts | 84 ++ tests/history-dispatch.test.ts | 179 +++++ tests/history-expanded-globals.test.ts | 62 ++ tests/history-lazy-windowing.test.ts | 508 ++++++++++++ tests/history-openflow-integration.test.ts | 143 ++++ tests/history-preview-layout.test.ts | 56 ++ tests/history-selector-windowing.test.ts | 94 +++ tests/history-session-writer.test.ts | 26 +- tests/history-wheel-mouse.test.ts | 242 ++++++ 11 files changed, 2442 insertions(+), 13 deletions(-) create mode 100644 tests/history-command-registration.test.ts create mode 100644 tests/history-dispatch.test.ts create mode 100644 tests/history-expanded-globals.test.ts create mode 100644 tests/history-lazy-windowing.test.ts create mode 100644 tests/history-openflow-integration.test.ts create mode 100644 tests/history-preview-layout.test.ts create mode 100644 tests/history-selector-windowing.test.ts create mode 100644 tests/history-wheel-mouse.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 26616733f..9fe2d7893 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1,21 +1,77 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Prompt-history extension entry (slice 1): identity constants, the -// per-instance writer lifecycle, and the before_agent_start capture -// handler. Selector UI, shortcut/command, scope drains, legacy migration -// and seed bootstrap, and GC arrive in later slices. +// Prompt-history extension entry (slice 3): the selector TUI, overlay glue, +// and the shortcut/command wiring over the slice-1 writer and slice-2 +// drains. Legacy migration and seed bootstrap (slice 4), deletion (slice 5), +// and GC/compaction (slice 6) arrive in later slices. -import { randomUUID } from "node:crypto"; -import { homedir } from "node:os"; import { join } from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { homedir } from "node:os"; +import { + DynamicBorder, + type ExtensionAPI, + type ShortcutContext, + type Theme, +} from "@earendil-works/pi-coding-agent"; import { appendSessionCapture, + drainGlobal, + drainProject, ensureRegistryEntry, openSessionWriter, type SessionWriterState, } from "./store.ts"; +import { randomUUID } from "node:crypto"; +import { + buildPromptRecords, + filterPrompts, + type PromptEntry, + clampPreviewOffset, + clampSelectedIndex, + dedupePromptEntries, + getVisiblePromptRecords, + initialLoadedCount, + loadedCountForQuery, + loadedCountForTarget, + moveSelectedIndex, + nextLoadedCount, + pageSelectedIndex, + shouldGrowWindow, + withExpandedHistoryGlobals, + type PiHistoryGlobals, + type PromptRecord, +} from "./selector-helpers.ts"; +import { + Container, + type Focusable, + getKeybindings, + Input, + matchesKey, + Text, + type TUI, + type TuiMouseEvent, + truncateToWidth, +} from "@earendil-works/pi-tui"; + +const SHORTCUT = "ctrl+shift+r"; +const MAX_VISIBLE = 10; +const PREVIEW_ROWS = 10; +// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=2 +// fires growth as the cursor enters the final 2 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; // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); @@ -24,6 +80,764 @@ const CURRENT_CWD = process.cwd(); // Instance identity: one exclusive capture file per pi process. const INSTANCE_ID = randomUUID(); +// Tombstone state dir: the store root itself (user-directed FINAL): +// ~/.pi/agent/history/hidden.json — one directory for everything. +// Derived state only — deleting the directory restores cold start and +// unhides every prompt; transcripts and the editor store are never written +// here. +const PI_HISTORY_NAV_STATE_DIR = join( + homedir(), + ".pi", + "agent", + "history", +); + +/** 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 === 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 { + out += 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 = truncated.replace(/\x1b\[[0-9;]*m/g, ""); + 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. + return [rendered + " ".repeat(Math.max(0, width - rendered.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 records: PromptRecord[]; + private readonly theme: Theme; + private readonly tui: TUI; + private readonly onSelect: (record: PromptRecord) => void; + private readonly onCancel: () => void; + /** Notification sink for selector feedback (wired by the factory). */ + private readonly onNotify?: SelectorNotify; + 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; + + /** 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+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.addChild( + new FixedRowText( + theme.fg( + "dim", + "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • esc cancel", + ), + true /* centered */, + ), + ); + 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 ? "→ " : " "; + const color = isSelected ? "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 { + this.scope = this.scope === "project" ? "global" : "project"; + const entries = drainForScope(this.scope); + this.records = recordsFromEntries(entries); + this.loadedCount = initialLoadedCount(this.records.length, INITIAL_BATCH); + this.applyFilter(this.searchInput.getValue()); + } + + // -- Navigation --------------------------------------------------------- + + private moveUp(): void { + this.selectedIndex = moveSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + -1, + ); + 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()); + } + 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. + 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()); + } + 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 { + 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; + 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); + } +} + +// --------------------------------------------------------------------------- +// Overlay glue +// --------------------------------------------------------------------------- + +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: ShortcutContext, + 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; /** @@ -43,6 +857,51 @@ function getWriter(): SessionWriterState { return writerState; } +/** + * 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. + */ +function drainForScope(scope: HistoryScope): string[] { + getWriter(); // ensure init ran + return scope === "project" + ? drainProject(PI_HISTORY_ROOT, CURRENT_CWD, 1000, PI_HISTORY_NAV_STATE_DIR) + : drainGlobal(PI_HISTORY_ROOT, 1000, PI_HISTORY_NAV_STATE_DIR); +} + +async function openHistorySelector( + ctx: Pick, +): Promise { + // 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 (entries.length === 0) { + ctx.ui.notify("No prompt history available.", "warning"); + return; + } + + 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 default function promptHistoryExtension(pi: ExtensionAPI) { // One writer per extension load; see getWriter() for the init order. @@ -57,4 +916,20 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { // the handler - swallow and keep the next prompt capturable. } }); + + // 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/selector-helpers.ts b/extensions/history/selector-helpers.ts index c5257bafe..fd02400ce 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -25,6 +25,22 @@ export interface PromptEntry { 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[] { @@ -46,6 +62,56 @@ export function buildPromptRecords( }); } +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 @@ -87,6 +153,112 @@ export function dedupePromptEntries( 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; +} + +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( diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts new file mode 100644 index 000000000..e4aa9bf6b --- /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 fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Source-parsing tests (preview-layout.test.ts pattern): never import +// extensions/history/index.ts — it pulls the pi-tui runtime graph (§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)", + ); + + // PR-branch (slice 3) behavior: the store-only drain keeps the empty + // guard — no history means a warning, not an empty overlay. (The dev + // repo's later always-open selector dropped this guard; the PR branch is + // the API truth here.) + 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("if (entries.length === 0)") && + body.includes('"No prompt history available."'), + "an empty history warns and skips the overlay (PR-branch drain guard)", + ); +}); + +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("the ctrl+shift+r shortcut is registered with the shared description", () => { + const index = source.indexOf("pi.registerShortcut(SHORTCUT"); + assert.ok(index >= 0, "pi.registerShortcut(SHORTCUT, ...) should exist"); + + const slice = source.slice(index, index + 200); + assert.ok( + slice.includes('"Search prompt history"'), + "shortcut should carry the shared description", + ); + assert.ok( + slice.includes("openHistorySelector(ctx)"), + "shortcut 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)", + ); +}); diff --git a/tests/history-dispatch.test.ts b/tests/history-dispatch.test.ts new file mode 100644 index 000000000..575a4d5a1 --- /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 fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** + * 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 extensions/history/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 11 explicit entries in a fixed order (the ctrl+shift+backspace + * delete entry joins with deletion in slice 5), 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+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.previewPageUp()", + "this.previewPageDown()", +]; + +function dispatchTable(): string { + const start = source.indexOf(DISPATCH_DECL); + assert.notStrictEqual( + start, + -1, + "dispatch table declaration should exist in extensions/history/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 11 explicit match: entries (AC-P2-4.1)", () => { + const table = dispatchTable(); + const matchCount = table.split("match:").length - 1; + assert.strictEqual( + matchCount, + 11, + `expected 11 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-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-lazy-windowing.test.ts b/tests/history-lazy-windowing.test.ts new file mode 100644 index 000000000..5ee40838a --- /dev/null +++ b/tests/history-lazy-windowing.test.ts @@ -0,0 +1,508 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +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 extensions/history/index.ts: +// +// 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 does in the selector — 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 extensions/history/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). +// +// Slice-3 adaptation note: upstream wires the growth trigger INLINE in +// moveUp/moveDown (no shared growLoadedWindowIfNeeded helper — that shape is +// dev-repo drift). The pins below assert the same AC contracts against the +// inline form. + +function methodBodyOf(name: string): string { + const decl = selectorSource.indexOf(`private ${name}(`); + assert.ok(decl >= 0, `private ${name}() should exist in extensions/history/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 (moveUp carries the older-direction growth check); every other +// 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("shouldGrowWindow("), + "moveDown must evaluate the C2 trigger", + ); + assert.ok( + down.includes("nextLoadedCount("), + "moveDown must grow via nextLoadedCount", + ); + const up = methodBodyOf("moveUp"); + assert.ok( + up.includes("shouldGrowWindow(") && up.includes("nextLoadedCount("), + "moveUp must carry the older-direction growth check", + ); + 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("shouldGrowWindow("); + assert.notEqual(growAt, -1, "moveDown must evaluate the C2 trigger"); + 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("shouldGrowWindow(") && + downGrow.includes("nextLoadedCount("), + "moveDown's growth region must run the trigger + one batch before the modulo", + ); + assert.ok( + !downGrow.includes("filteredRecords"), + "moveDown's pre-modulo region must read UNFILTERED counts only", + ); + const page = methodBodyOf("pageListDown"); + const pageGrow = page.slice(0, page.indexOf("pageSelectedIndex(")); + assert.ok( + pageGrow.includes("loadedCountForTarget(") && + 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-openflow-integration.test.ts b/tests/history-openflow-integration.test.ts new file mode 100644 index 000000000..1737f887a --- /dev/null +++ b/tests/history-openflow-integration.test.ts @@ -0,0 +1,143 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** + * WU5 tests (AC-S6-1..3): the open-flow wiring in extensions/history/index.ts. + * NEVER import it — it pulls the pi-tui runtime graph (design §D3). The + * wiring is pinned by source-parse (command-registration pattern); loader + * behavior uses fs-only fixtures under the OS temp dir — NEVER the user's + * real ~/.pi/agent/history. + */ + +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 extensions/history/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: store-only drain 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)") >= 0, + "the PR-branch empty guard stands: no history warns instead of opening an empty overlay", + ); +}); + +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", + ); +}); + +// --------------------------------------------------------------------------- +// 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..782da55ce --- /dev/null +++ b/tests/history-preview-layout.test.ts @@ -0,0 +1,56 @@ +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", + ); +}); 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-writer.test.ts b/tests/history-session-writer.test.ts index 8601cea14..61a2689b3 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -88,22 +88,36 @@ test("two writers own separate files in the same project dir", () => { assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); }); -test("the slice-1 extension entry registers only the capture handler", () => { +test("the extension entry registers exactly the slice-3 wiring surface", () => { // Module load must stay side-effect free (importing index.ts parses the - // whole slice-1 graph without touching the real ~/.pi store root), and - // slice 1 wires exactly one handler: before_agent_start. + // whole graph without touching the real ~/.pi store root). Wiring as of + // slice 3: before_agent_start capture + tool_call overlay dismiss, the + // ctrl+shift+r shortcut, and the history command. session_shutdown is + // slice 6 and must not appear yet. const registered: Array<[string, unknown]> = []; + const shortcuts: Array<[string, unknown]> = []; + const commands: Array<[string, unknown]> = []; const pi = { on: (event: string, handler: unknown) => { registered.push([event, handler]); }, + registerShortcut: (key: string, def: unknown) => { + shortcuts.push([key, def]); + }, + registerCommand: (name: string, def: unknown) => { + commands.push([name, def]); + }, }; promptHistoryExtension(pi as never); assert.deepEqual( registered.map(([event]) => event), - ["before_agent_start"], + ["before_agent_start", "tool_call"], ); - // The handler is callable but is NEVER invoked here: a real invocation + assert.deepEqual(shortcuts.map(([key]) => key), ["ctrl+shift+r"]); + assert.deepEqual(commands.map(([name]) => name), ["history"]); + // Handlers are callable but are NEVER invoked here: a real invocation // would run getWriter() against the user's real ~/.pi/agent/history. - assert.equal(typeof registered[0][1], "function"); + for (const [, handler] of registered) { + assert.equal(typeof handler, "function"); + } }); diff --git a/tests/history-wheel-mouse.test.ts b/tests/history-wheel-mouse.test.ts new file mode 100644 index 000000000..37ddcd6b6 --- /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 11 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, + 11, + "wheel is not a keybinding: exactly the 11 §B2 dispatch entries, no extra", + ); +}); + +// 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", + ); +}); From d354c8a450b0a4cab042d0f7a24de2f5421b46d6 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:41:43 -0300 Subject: [PATCH 06/49] fix(history): intact astral characters, correct row padding, honest comments Review fixes (Copilot + CodeRabbit on PR #819): - sanitizeForDisplay: astral code points (> 0xFFFF) are re-appended via String.fromCodePoint instead of only the high surrogate at text[i]; emoji and other non-BMP characters no longer lose half their code point in list rows and previews. The low-surrogate skip is retained. - FixedRowText.render: the full-width pad now measures the VISIBLE width (SGR escape sequences stripped), matching the centered branch's measurement; colored list rows previously padded short and could leave ghost characters on overlay dismiss. - Lazy-windowing comment corrected: PRELOAD_BUFFER is 3 (fired in the final 3 loaded rows), not 2 as the stale comment claimed. - Regression pins added for both behavior fixes. --- extensions/history/index.ts | 14 ++++++++--- tests/history-preview-layout.test.ts | 37 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 9fe2d7893..f9559b9d6 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -57,8 +57,8 @@ import { const SHORTCUT = "ctrl+shift+r"; const MAX_VISIBLE = 10; const PREVIEW_ROWS = 10; -// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=2 -// fires growth as the cursor enters the final 2 loaded rows; BATCH_SIZE=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. @@ -117,7 +117,10 @@ function sanitizeForDisplay(text: string): string { } else if (cp >= 0x80 && cp < 0xa0) { out += "\\x" + cp.toString(16).padStart(2, "0"); } else { - out += text[i]; + // 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 } @@ -180,7 +183,10 @@ class FixedRowText { : 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. - return [rendered + " ".repeat(Math.max(0, width - rendered.length))]; + // 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))]; } } diff --git a/tests/history-preview-layout.test.ts b/tests/history-preview-layout.test.ts index 782da55ce..02509725c 100644 --- a/tests/history-preview-layout.test.ts +++ b/tests/history-preview-layout.test.ts @@ -54,3 +54,40 @@ test("preview rows are bottom-padded so the panel shrinks from the bottom", () = "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", + ); +}); From c63caa5ba3897b745d06abc5e13563fc963ad69b Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:26:32 -0300 Subject: [PATCH 07/49] feat(history): transcript migration and seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4/6 of the PR #819 split (maintainer-requested review slices). - load-shared-history (new): v1 shared-history reader, fail-open entry normalization - session-scan (new): transcript JSONL extraction — line-1 admission gate (type/version), text-block extraction, timestamp fallback chain (message-ms -> entry-ISO -> header-ISO -> mtime), prompt length and whitespace rules, one-level encoded-cwd sessions-root scan - store: legacy migration (v1 editor-history.jsonl + pre-v1 editor-history.json -> history-global.jsonl, one-time gate, .imported renames, corrupt-source skip) and project seed bootstrap (transcript scan -> seed.jsonl written ONCE so deletion cannot resurrect, tombstone suppression, cwd matching via encoded sessions dirs) - index.ts: getWriter now runs the upstream init sequence — migrate -> registry -> seed bootstrap -> open instance writer - indexing scope note: upstream's session indexing (session-index.ts + merge-history.ts, ~400 lines) is dead code on the PR branch — zero importers after the store-only drain pivot — and is intentionally absent from this chain (preserved out-of-tree for reference) - tests: 38 new node:test cases (cumulative 148/148): migration one-time gate + .imported renames + corrupt-source skip, seed written-once anti-resurrection + tombstone suppression + regen idempotence, transcript extraction matrix (583-line dev suite preserved), directory scan; tmpdir + fake-cwd fixtures throughout Gates: cumulative scoped history tests 148/148 green. Known pre-existing environmental gate failures unchanged. --- extensions/history/index.ts | 35 +- extensions/history/load-shared-history.ts | 39 ++ extensions/history/session-scan.ts | 233 ++++++++ extensions/history/store.ts | 200 ++++++- tests/history-legacy-migrate-v2.test.ts | 151 +++++ tests/history-load-shared-history.test.ts | 53 ++ tests/history-seed-bootstrap.test.ts | 170 ++++++ tests/history-seed-regen.test.ts | 86 +++ tests/history-session-scan-directory.test.ts | 87 +++ tests/history-session-scan-extract.test.ts | 583 +++++++++++++++++++ 10 files changed, 1625 insertions(+), 12 deletions(-) create mode 100644 extensions/history/load-shared-history.ts create mode 100644 extensions/history/session-scan.ts create mode 100644 tests/history-legacy-migrate-v2.test.ts create mode 100644 tests/history-load-shared-history.test.ts create mode 100644 tests/history-seed-bootstrap.test.ts create mode 100644 tests/history-seed-regen.test.ts create mode 100644 tests/history-session-scan-directory.test.ts create mode 100644 tests/history-session-scan-extract.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index f9559b9d6..c36fc7f0f 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -2,9 +2,10 @@ // 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 and slice-2 -// drains. Legacy migration and seed bootstrap (slice 4), deletion (slice 5), -// and GC/compaction (slice 6) arrive in later slices. +// 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) arrive +// in later slices. import { join } from "node:path"; import { homedir } from "node:os"; @@ -16,9 +17,11 @@ import { } from "@earendil-works/pi-coding-agent"; import { appendSessionCapture, + bootstrapProjectSeed, drainGlobal, drainProject, ensureRegistryEntry, + migrateLegacyStores, openSessionWriter, type SessionWriterState, } from "./store.ts"; @@ -92,6 +95,11 @@ const PI_HISTORY_NAV_STATE_DIR = join( "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; @@ -847,17 +855,32 @@ let selectorTui: { requestRender(): void } | null = null; let writerState: SessionWriterState | null = null; /** - * One-time init per extension load: register the project in the advisory - * registry, then open this instance's exclusive capture file. Legacy - * migration and seed bootstrap join this init order in a later slice. + * 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; diff --git a/extensions/history/load-shared-history.ts b/extensions/history/load-shared-history.ts new file mode 100644 index 000000000..79ef12f7d --- /dev/null +++ b/extensions/history/load-shared-history.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +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/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 index fb470515c..7c8b2df57 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -1,17 +1,24 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Consolidated multi-concurrency store (v2), slices 1+2: project paths and -// identity, the advisory registry, entry primitives, the per-instance -// session writer, and the scope drain/reader/query section (ordering, -// dedup, tombstone filter, project/global drains). Legacy migration and -// seed bootstrap, scope deletes, and GC/compaction arrive in later slices. -// Formerly store-paths.ts + registry.ts + multi-store.ts (+ v1 primitives). +// Consolidated multi-concurrency store (v2), slices 1+2+4: project paths +// and identity, the advisory registry, entry primitives, the per-instance +// session writer, the scope drain/reader/query section (ordering, dedup, +// tombstone filter, project/global drains), legacy migration, and the +// project seed bootstrap. Scope deletes and GC/compaction arrive in later +// slices. 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 { loadHiddenPrompts } from "./hide-prompts.ts"; +import { loadSharedHistory } from "./load-shared-history.ts"; +import { + extractPromptsFromFile, + listSessionFiles, + type ExtractedPrompt, +} from "./session-scan.ts"; // =========================================================================== // Paths (formerly store-paths.ts) @@ -403,3 +410,184 @@ export function drainGlobal( stateDir ? loadHiddenPrompts(stateDir) : new Set(), ); } + +// --------------------------------------------------------------------------- +// 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 []; + } +} + +/** + * 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; each + * source is renamed `.imported`, never deleted. 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); + if (texts.length > 0) { + for (let i = texts.length - 1; i >= 0; i--) { + collected.push({ v: 1, text: texts[i] }); + } + } + try { + fs.renameSync(legacyArray, `${legacyArray}.imported`); + } catch { + // The seed write below is the source of truth; rename failure is benign. + } + } + + // v1 single-file store — already chronological. + const v1File = path.join(agentDir, "editor-history.jsonl"); + if (fs.existsSync(v1File)) { + collected.push(...readValidLines(v1File)); + try { + fs.renameSync(v1File, `${v1File}.imported`); + } catch { + // benign + } + } + + if (collected.length === 0) return { migrated: 0, ran: false }; + + 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 { migrated: collected.length, 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. + */ +export function bootstrapProjectSeed( + root: string, + cwd: string, + sessionsRoot: string, + target: number, + stateDir?: string, +): SeedResult { + const dir = path.join(root, "projects", projectHash(cwd)); + + // Count existing entries and collect their identities. + const existingKeys = new Set(); + let existingCount = 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) { + existingCount += 1; + existingKeys.add(promptKey(parsed.text)); + } + } + } + if (existingCount >= 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. + const hidden = stateDir ? loadHiddenPrompts(stateDir) : new Set(); + + // Scan transcripts: session files of THIS project's dir, newest first. + let files: string[] = []; + try { + const dirName = cwd + .replace(/^[/\\]/, "") + .replace(/[/\\:]/g, "-"); + files = listSessionFiles(sessionsRoot).filter((file) => + file.includes(`${path.sep}--${dirName}--${path.sep}`), + ); + } catch { + return { seeded: 0, ran: false }; + } + files.sort((a, b) => fileMtimeMs(b) - fileMtimeMs(a)); + + 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 text = prompts[i].text; + if (/^\/[A-Za-z]/.test(text.trim())) continue; + if (hidden.size > 0 && hidden.has(promptDedupKeyOf(text))) continue; + const key = promptKey(text); + if (existingKeys.has(key)) continue; + existingKeys.add(key); + const entry: StoreEntry = { v: 1, text }; + if (Number.isFinite(prompts[i].ts)) entry.ts = prompts[i].ts; + collected.push(entry); + if (collected.length >= target - existingCount) break outer; + } + } + if (collected.length === 0) return { seeded: 0, ran: false }; + + collected.reverse(); // chronological (oldest first) + const seed = seedFilePath(root, cwd); + 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 { seeded: collected.length, ran: true }; +} diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts new file mode 100644 index 000000000..08c41a471 --- /dev/null +++ b/tests/history-legacy-migrate-v2.test.ts @@ -0,0 +1,151 @@ +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"; + +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"]); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options +// object — chmod 000 is invisible to the superuser. +const sealedLegacyTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); +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-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts new file mode 100644 index 000000000..d8126f254 --- /dev/null +++ b/tests/history-seed-bootstrap.test.ts @@ -0,0 +1,170 @@ +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"; + +// Fake project cwd (never created on disk): projectHash falls back to +// raw-string hashing for nonexistent paths, and the transcript dirName +// encoding derives from the same string. +const CWD = "/pi-history-test/seed-project"; +const DIR = "--pi-history-test-seed-project--"; + +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, DIR, "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, DIR, "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, DIR, "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, DIR, "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); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options +// object — chmod 000 is invisible to the superuser. +const sealedStoreTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + 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, DIR, "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..e4e16539e --- /dev/null +++ b/tests/history-seed-regen.test.ts @@ -0,0 +1,86 @@ +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"; + +// Fake project cwd (never created on disk): projectHash falls back to +// raw-string hashing for nonexistent paths, and the transcript dirName +// encoding derives from the same string. +const CWD = "/pi-history-test/seed-regen-project"; +const DIR = "--pi-history-test-seed-regen-project--"; + +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-session-scan-directory.test.ts b/tests/history-session-scan-directory.test.ts new file mode 100644 index 000000000..d868b5073 --- /dev/null +++ b/tests/history-session-scan-directory.test.ts @@ -0,0 +1,87 @@ +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"; + +/** + * 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); + } +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options +// object — chmod 000 is invisible to the superuser. +const sealedDirTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); +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); +}); From 6b61c980f6d370175e7ad650b2be9e128dbf5b84 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:46:12 -0300 Subject: [PATCH 08/49] fix(history): migration renames only after the seed write; init off the first-prompt path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (CodeRabbit on PR #819): - migrateLegacyStores: legacy sources are renamed .imported only AFTER the global seed write succeeds. Previously each source was renamed immediately after reading, so a seed-write failure stranded the collected entries in .imported files with the one-shot seed gate blocking retry — silent data loss. Failure-path test added: a read-only store root makes the seed write throw, sources stay in place, and the retried migration completes and archives them. - promptHistoryExtension: writer init (migrate + registry + seed bootstrap) is scheduled once via setImmediate so the transcript scan never runs on the first-prompt path; prompts arriving before the scheduled init fall back to getWriter()'s synchronous lazy init, whose writerState guard keeps the work single-shot. - Source pin added for the setImmediate scheduling and the retained synchronous fallback. --- extensions/history/index.ts | 12 +++++++ extensions/history/store.ts | 33 +++++++++--------- tests/history-command-registration.test.ts | 21 ++++++++++++ tests/history-legacy-migrate-v2.test.ts | 40 ++++++++++++++++++++++ 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index c36fc7f0f..b62963d39 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -933,6 +933,18 @@ function recordsFromEntries( export default function promptHistoryExtension(pi: ExtensionAPI) { // One writer per extension load; see getWriter() for the init order. + // Warm migrate/registry/seed OFF the first-prompt path: the scheduled + // init runs once, immediately after load. A prompt arriving earlier + // falls back to the synchronous lazy init in getWriter(), whose + // writerState guard makes whichever runs second a no-op — bootstrap + // work is never duplicated. + setImmediate(() => { + 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). // The local ExtensionAPI stub types handler args as unknown; narrow here. diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 7c8b2df57..32fb48295 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -438,9 +438,10 @@ function readValidLines(file: string): StoreEntry[] { * 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; each - * source is renamed `.imported`, never deleted. Gated: an existing global - * seed means migration already ran. + * 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, @@ -455,15 +456,8 @@ export function migrateLegacyStores( const legacyArray = path.join(agentDir, "editor-history.json"); if (fs.existsSync(legacyArray)) { const texts = loadSharedHistory(legacyArray); - if (texts.length > 0) { - for (let i = texts.length - 1; i >= 0; i--) { - collected.push({ v: 1, text: texts[i] }); - } - } - try { - fs.renameSync(legacyArray, `${legacyArray}.imported`); - } catch { - // The seed write below is the source of truth; rename failure is benign. + for (let i = texts.length - 1; i >= 0; i--) { + collected.push({ v: 1, text: texts[i] }); } } @@ -471,11 +465,6 @@ export function migrateLegacyStores( const v1File = path.join(agentDir, "editor-history.jsonl"); if (fs.existsSync(v1File)) { collected.push(...readValidLines(v1File)); - try { - fs.renameSync(v1File, `${v1File}.imported`); - } catch { - // benign - } } if (collected.length === 0) return { migrated: 0, ran: false }; @@ -488,6 +477,16 @@ export function migrateLegacyStores( "utf8", ); fs.renameSync(tmp, seed); + + // 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: collected.length, ran: true }; } diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts index e4aa9bf6b..4e44c3859 100644 --- a/tests/history-command-registration.test.ts +++ b/tests/history-command-registration.test.ts @@ -82,3 +82,24 @@ test("in-UI hint describes multi-word AND substring matching, not fuzzy", () => "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-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts index 08c41a471..77d43586a 100644 --- a/tests/history-legacy-migrate-v2.test.ts +++ b/tests/history-legacy-migrate-v2.test.ts @@ -118,6 +118,46 @@ const sealedLegacyTest = (name: string, fn: () => void) => { skip: process.getuid?.() === 0 ? "requires non-root" : false }, fn, ); + +// chmod-based failure injection is also invisible to the superuser. +const seedFailureTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); + +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"]); + }, +); + sealedLegacyTest( "an unreadable legacy file is skipped; the readable file still migrates", () => { From 96443ee6886ace0116666a3ecac8fe087c4e07ca Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:03:45 -0300 Subject: [PATCH 09/49] feat(history): GC/compaction with active-writer and failure-path tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 6/6 of the PR #819 split (maintainer-requested review slices). - store: GC/compaction section — thresholds (50 files / 5000 lines / keep-newest-10), gcProjectDir entry point, compactFiles (merge all but newest 10 into compact-.jsonl, atomic write BEFORE originals removed, rm failure tolerated); dead compactProjectDir export (zero callers) dropped — store.ts now carries upstream content minus the documented dead exports - index.ts: session_shutdown handler wired to gcProjectDir (final registration surface: before_agent_start, session_shutdown, tool_call, ctrl+shift+r shortcut, history command) - tests: 9 new node:test cases (cumulative 174/174): threshold no-op below limits, keep-newest-10 untouched, line-threshold trigger, missing-dir and unreadable-file skips, atomic-before-rm ordering, rm-failure tolerance, concurrent append during compaction never loses post-compaction writes; tmpdir fixtures with machine-independent literals throughout Gates: cumulative scoped history tests 174/174 green — the complete six-slice chain. Known pre-existing environmental gate failures unchanged (gitignored contracts/.DS_Store; package-manifest needs node_modules, now installed). --- extensions/history/index.ts | 14 +- extensions/history/store.ts | 109 +++++++- tests/history-gc.test.ts | 360 +++++++++++++++++++++++++++ tests/history-session-writer.test.ts | 10 +- 4 files changed, 479 insertions(+), 14 deletions(-) create mode 100644 tests/history-gc.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 424008b68..dd96c3157 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -4,8 +4,8 @@ // 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) is wired here; GC/compaction -// (slice 6) arrives in a later slice. +// inside getWriter). Deletion (slice 5) and GC/compaction (slice 6) are +// wired here. import { join } from "node:path"; import { homedir } from "node:os"; @@ -22,6 +22,7 @@ import { deleteFromProject, drainGlobal, drainProject, + gcProjectDir, ensureRegistryEntry, migrateLegacyStores, openSessionWriter, @@ -1015,6 +1016,15 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); + // Backup pass: enforce the 1000-line limit on graceful shutdown. + pi.on("session_shutdown", () => { + try { + gcProjectDir(PI_HISTORY_ROOT, CURRENT_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", () => { diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 04626f48e..7ad170774 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -1,13 +1,12 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Consolidated multi-concurrency store (v2), slices 1+2+4: project paths +// Consolidated multi-concurrency store (v2), slices 1-6: project paths // and identity, the advisory registry, entry primitives, the per-instance // session writer, the scope drain/reader/query section (ordering, dedup, -// tombstone filter, project/global drains), legacy migration, and the -// project seed bootstrap. Scope deletes and GC/compaction arrive in later -// slices. Formerly store-paths.ts + registry.ts + multi-store.ts (+ v1 -// primitives). +// tombstone filter, project/global drains), scope deletes, legacy +// migration, the project seed bootstrap, and GC/compaction. Formerly +// store-paths.ts + registry.ts + multi-store.ts (+ v1 primitives). import { createHash } from "node:crypto"; import fs from "node:fs"; @@ -193,8 +192,7 @@ export function parseStoreLine(raw: string): StoreEntry | null { } // =========================================================================== -// Instance writer (formerly multi-store.ts; GC/compaction arrives in a -// later slice) +// Instance writer (formerly multi-store.ts) // =========================================================================== /** Mutable state of ONE pi instance's exclusive capture file. */ @@ -671,3 +669,100 @@ export function bootstrapProjectSeed( fs.renameSync(tmp, seed); return { seeded: collected.length, 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 `keepNewest` 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. (Upstream exposed this as + * compactProjectDir; dropped here — zero callers, gcProjectDir is the + * single entry point.) + */ +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 { + // unreadable file: skip its content, still remove nothing + continue; + } + } + if (mergedLines.length === 0) return { compacted: false, merged: 0 }; + + const dir = path.dirname(toMerge[0]); + const compact = path.join(dir, `compact-${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/tests/history-gc.test.ts b/tests/history-gc.test.ts new file mode 100644 index 000000000..adfe2a7ca --- /dev/null +++ b/tests/history-gc.test.ts @@ -0,0 +1,360 @@ +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; the +// dev-only compactProjectDir shortcut is gone — gcProjectDir with explicit +// thresholds is the single PR-branch entry point.) + +const CWD = "/pi-history-test/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+\.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-session-writer.test.ts b/tests/history-session-writer.test.ts index 61a2689b3..e384379d6 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -88,12 +88,12 @@ test("two writers own separate files in the same project dir", () => { assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); }); -test("the extension entry registers exactly the slice-3 wiring surface", () => { +test("the extension entry registers exactly the final wiring surface", () => { // Module load must stay side-effect free (importing index.ts parses the // whole graph without touching the real ~/.pi store root). Wiring as of - // slice 3: before_agent_start capture + tool_call overlay dismiss, the - // ctrl+shift+r shortcut, and the history command. session_shutdown is - // slice 6 and must not appear yet. + // slice 6 (final): before_agent_start capture, session_shutdown GC, + // tool_call overlay dismiss, the ctrl+shift+r shortcut, and the + // history command. const registered: Array<[string, unknown]> = []; const shortcuts: Array<[string, unknown]> = []; const commands: Array<[string, unknown]> = []; @@ -111,7 +111,7 @@ test("the extension entry registers exactly the slice-3 wiring surface", () => { promptHistoryExtension(pi as never); assert.deepEqual( registered.map(([event]) => event), - ["before_agent_start", "tool_call"], + ["before_agent_start", "session_shutdown", "tool_call"], ); assert.deepEqual(shortcuts.map(([key]) => key), ["ctrl+shift+r"]); assert.deepEqual(commands.map(([name]) => name), ["history"]); From 7cc79b2844729a49b662366a73c672d800e17766 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:44:27 -0300 Subject: [PATCH 10/49] feat(history): tombstones, deletion, and privacy semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5/6 of the PR #819 split (maintainer-requested review slices). - store: scope-delete section — sweepFiles (atomic rewrite per affected file, emptied session files kept so live writers stay functional, never fatal), deleteFromProject, deleteFromGlobal - selector-helpers: deletionActionsFor (pure provenance->actions planner) and loadedCountAfterDelete (backfill window math), completing the helper surface - index.ts: deleteCurrent on the selector exactly as upstream — provenance- planned sweep + tombstone ALWAYS written (seed/transcript-sourced entries cannot resurface; the seed is write-once) + splice + backfill + failure toast; ctrl+shift+backspace dispatch entry and footer affordance restored (dispatch table back to 12 entries) - privacy semantics (enforced by tests): hidden.json fail-open read, tombstone precedes any visibility change, deletion from the global seed and project stores is permanent because the seed is written once - tests: 17 new/updated node:test cases (cumulative 165/165): sweep with a concurrent live writer, emptied-file-kept, chmod-000 partial failure toast path, tombstone-always planner pin, unknown-prompt no-op, backfill bounds, dispatch/wheel table pins restored to 12 Gates: cumulative scoped history tests 165/165 green. Known pre-existing environmental gate failures unchanged. --- extensions/history/index.ts | 63 ++++++++- extensions/history/selector-helpers.ts | 40 ++++++ extensions/history/store.ts | 85 ++++++++++- tests/history-delete-backfill.test.ts | 186 +++++++++++++++++++++++++ tests/history-dispatch.test.ts | 11 +- tests/history-scope-delete.test.ts | 163 ++++++++++++++++++++++ tests/history-wheel-mouse.test.ts | 6 +- 7 files changed, 541 insertions(+), 13 deletions(-) create mode 100644 tests/history-delete-backfill.test.ts create mode 100644 tests/history-scope-delete.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index b62963d39..424008b68 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -4,8 +4,8 @@ // 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) arrive -// in later slices. +// inside getWriter). Deletion (slice 5) is wired here; GC/compaction +// (slice 6) arrives in a later slice. import { join } from "node:path"; import { homedir } from "node:os"; @@ -18,6 +18,8 @@ import { import { appendSessionCapture, bootstrapProjectSeed, + deleteFromGlobal, + deleteFromProject, drainGlobal, drainProject, ensureRegistryEntry, @@ -26,15 +28,18 @@ import { type SessionWriterState, } from "./store.ts"; import { randomUUID } from "node:crypto"; +import { hidePrompt } from "./hide-prompts.ts"; import { buildPromptRecords, filterPrompts, type PromptEntry, clampPreviewOffset, clampSelectedIndex, + deletionActionsFor, dedupePromptEntries, getVisiblePromptRecords, initialLoadedCount, + loadedCountAfterDelete, loadedCountForQuery, loadedCountForTarget, moveSelectedIndex, @@ -292,6 +297,10 @@ class PromptHistorySelector extends Container implements Focusable { 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(), @@ -364,7 +373,7 @@ class PromptHistorySelector extends Container implements Focusable { new FixedRowText( theme.fg( "dim", - "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • esc cancel", + "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • ctrl+shift+backspace delete • esc cancel", ), true /* centered */, ), @@ -547,6 +556,54 @@ class PromptHistorySelector extends Container implements Focusable { this.applyFilter(this.searchInput.getValue()); } + /** Delete the currently selected prompt from disk and refresh the list. */ + private deleteCurrent(): void { + 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). + const { removed } = + this.scope === "global" + ? deleteFromGlobal(PI_HISTORY_ROOT, selected.text) + : deleteFromProject(PI_HISTORY_ROOT, CURRENT_CWD, selected.text); + 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. + const hide = hidePrompt(PI_HISTORY_NAV_STATE_DIR, selected.text); + if (hide.status === "error") { + this.onNotify?.(hide.message, "error"); + if (!actions.deleteFromEditorStore) { + return; + } + } + // 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()); + } + // -- Navigation --------------------------------------------------------- private moveUp(): void { diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index fd02400ce..292c05907 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -215,6 +215,46 @@ export function loadedCountForTarget( 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" 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 history/index.ts. + */ +export function deletionActionsFor( + source: PromptSource, +): { deleteFromEditorStore: boolean; writeTombstone: boolean } { + if (source === "editor") { + return { deleteFromEditorStore: true, writeTombstone: true }; + } + return { deleteFromEditorStore: false, writeTombstone: true }; +} + export function getVisiblePromptRecords( records: PromptRecord[], selectedIndex: number, diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 32fb48295..04626f48e 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -193,8 +193,8 @@ export function parseStoreLine(raw: string): StoreEntry | null { } // =========================================================================== -// Instance writer (formerly multi-store.ts; scope deletes and GC arrive -// in later slices) +// Instance writer (formerly multi-store.ts; GC/compaction arrives in a +// later slice) // =========================================================================== /** Mutable state of ONE pi instance's exclusive capture file. */ @@ -411,6 +411,87 @@ export function drainGlobal( ); } +// --------------------------------------------------------------------------- +// 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: string[] = []; + const globalSeed = globalSeedPath(root); + if (fs.existsSync(globalSeed)) files.push(globalSeed); + 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 sweepFiles(files, text); +} + // --------------------------------------------------------------------------- // Legacy migration (design v2: one-time, gated) // --------------------------------------------------------------------------- diff --git a/tests/history-delete-backfill.test.ts b/tests/history-delete-backfill.test.ts new file mode 100644 index 000000000..e5fcd3bd8 --- /dev/null +++ b/tests/history-delete-backfill.test.ts @@ -0,0 +1,186 @@ +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 deleteCurrent (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 deleteCurrent +// — 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("deleteCurrent splices, backfills, then re-filters — inside the guarded block (AC-L4-1, AC-L4-4)", () => { + const decl = selectorSource.indexOf("private deleteCurrent("); + assert.ok(decl >= 0, "deleteCurrent should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent'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 tombstone-always rule and +// the partial-failure toast path. The dev suite pins the planner + these +// deleteCurrent branch shapes in hide-prompts.test.ts (T27/T28); this file +// carries the delete-flow source-parse half so the slice-5 branch stays +// pinned inside the delete slice's own tests. + +test("deletionActionsFor always plans a tombstone — session provenance deletes nothing from disk", () => { + // Session/seed-born records: tombstone ONLY (transcripts and the seed are + // never rewritten by a delete) — the tombstone is what keeps the deleted + // prompt from resurfacing on the next drain. + assert.deepEqual(deletionActionsFor("session"), { + deleteFromEditorStore: false, + writeTombstone: true, + }); + // Editor records: disk delete AND tombstone (twin suppression). + assert.deepEqual(deletionActionsFor("editor"), { + deleteFromEditorStore: true, + writeTombstone: true, + }); + + const decl = selectorSource.indexOf("private deleteCurrent("); + assert.ok(decl >= 0, "deleteCurrent should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent's body should close"); + const body = selectorSource.slice(decl, end); + + // Branch shape: the tombstone write sits OUTSIDE the editor-store guard — + // every provenance lands a tombstone, so an entry that came from the + // seed or a transcript cannot resurface after its delete. + 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 deleteCurrent("); + assert.ok(decl >= 0, "deleteCurrent should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent'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-dispatch.test.ts b/tests/history-dispatch.test.ts index 575a4d5a1..cdda32505 100644 --- a/tests/history-dispatch.test.ts +++ b/tests/history-dispatch.test.ts @@ -10,8 +10,7 @@ import { fileURLToPath } from "node:url"; * PromptHistorySelector is private to extensions/history/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 11 explicit entries in a fixed order (the ctrl+shift+backspace - * delete entry joins with deletion in slice 5), then the implicit + * exactly 12 explicit entries in a fixed order, then the implicit * forwardToSearch fallthrough inside handleInput. */ @@ -34,6 +33,7 @@ const EXPECTED_MATCHERS = [ '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")', ]; @@ -49,6 +49,7 @@ const EXPECTED_HANDLERS = [ "this.onCancel()", "this.jumpToFirst()", "this.jumpToLast()", + "this.deleteCurrent()", "this.previewPageUp()", "this.previewPageDown()", ]; @@ -84,13 +85,13 @@ function methodBody(name: string): string { } describe("dispatch table (source-parsed, §B2)", () => { - it("has exactly 11 explicit match: entries (AC-P2-4.1)", () => { + it("has exactly 12 explicit match: entries (AC-P2-4.1)", () => { const table = dispatchTable(); const matchCount = table.split("match:").length - 1; assert.strictEqual( matchCount, - 11, - `expected 11 explicit entries, found ${matchCount}`, + 12, + `expected 12 explicit entries, found ${matchCount}`, ); }); diff --git a/tests/history-scope-delete.test.ts b/tests/history-scope-delete.test.ts new file mode 100644 index 000000000..11c50f2da --- /dev/null +++ b/tests/history-scope-delete.test.ts @@ -0,0 +1,163 @@ +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, + deleteFromGlobal, + deleteFromProject, + globalSeedPath, + openSessionWriter, + projectHash, +} from "../extensions/history/store.ts"; + +// Scope delete (design v2): sweepFiles' atomic per-file rewrite semantics +// plus the project/global delete entry points. Synthetic project cwds — +// never real directories on any machine (identity only feeds projectHash; +// the fixtures live in tmpdirs and never touch the user's real ~/.pi). +const PROJECT_A = "/fixtures/pi-history/project-a"; +const PROJECT_B = "/fixtures/pi-history/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, []); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options object. +test( + "an unreadable store file (chmod 000) is skipped; readable copies still swept", + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + () => { + 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"), ""); +}); + +// Active-writer safety (design v2): the sweep rewrites the writer's own +// file IN PLACE (tmp + rename, never a removal — emptied files are kept), +// so a concurrently live writer keeps working by path: its next capture +// appends into the swept file, and the surviving + new lines parse fine. +test("a sweep with a concurrent live writer keeps the writer's file functional", () => { + const root = makeRoot(); + const state = openSessionWriter(root, PROJECT_A, "instance-1"); + appendSessionCapture(state, "victim"); + appendSessionCapture(state, "keeper"); + + const result = deleteFromProject(root, PROJECT_A, "victim"); + assert.deepEqual(result, { filesAffected: 1, removed: 1 }); + + // The same writer state keeps appending after the sweep — the file was + // rewritten under the writer's feet, not removed. + appendSessionCapture(state, "after-delete"); + assert.equal(state.lineCount, 3); + assert.equal(fs.existsSync(state.filePath), true); + assert.deepEqual(fileTexts(state.filePath), ["keeper", "after-delete"]); +}); diff --git a/tests/history-wheel-mouse.test.ts b/tests/history-wheel-mouse.test.ts index 37ddcd6b6..fbb4a33b7 100644 --- a/tests/history-wheel-mouse.test.ts +++ b/tests/history-wheel-mouse.test.ts @@ -34,7 +34,7 @@ const selectorSource = fs.readFileSync( // T13 — AC-L6-1: wheel-only override + no extra dispatch entry. -test("handleMouse override is wheel-only and the dispatch table keeps 11 entries (AC-L6-1)", () => { +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); @@ -60,8 +60,8 @@ test("handleMouse override is wheel-only and the dispatch table keeps 11 entries const entries = table.split("match:").length - 1; assert.equal( entries, - 11, - "wheel is not a keybinding: exactly the 11 §B2 dispatch entries, no extra", + 12, + "wheel is not a keybinding: exactly 12 dispatch entries, no 13th", ); }); From c9c1c51a1a7a49fcdffb7b357d5733d4a2905cea Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:48:04 -0300 Subject: [PATCH 11/49] fix(history): pid-scoped compact filename; accurate GC threshold comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (CodeRabbit + Copilot on PR #819): - compactFiles: the compact artifact name now carries process.pid (compact--.jsonl), matching the uniqueness convention of the staging name — two concurrent instances can never target the same compact filename. Filename pin updated accordingly. - session_shutdown comment corrected: compaction runs at the GC thresholds (50 files / 5000 lines / keep-newest-10), not a "1000-line limit" as the stale comment claimed. --- extensions/history/index.ts | 3 ++- extensions/history/store.ts | 5 +++-- tests/history-gc.test.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index dd96c3157..02a07906d 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1016,7 +1016,8 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); - // Backup pass: enforce the 1000-line limit on graceful shutdown. + // Maintenance pass on graceful shutdown: compaction runs at the GC + // thresholds (50 files / 5000 lines / keep-newest-10). pi.on("session_shutdown", () => { try { gcProjectDir(PI_HISTORY_ROOT, CURRENT_CWD); diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 7ad170774..fbf0be6b8 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -722,7 +722,8 @@ export function gcProjectDir( } /** - * Merge all but the newest `keepNewest` files into one `compact-.jsonl` + * Merge all but the newest `keepNewest` 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. (Upstream exposed this as @@ -753,7 +754,7 @@ function compactFiles( if (mergedLines.length === 0) return { compacted: false, merged: 0 }; const dir = path.dirname(toMerge[0]); - const compact = path.join(dir, `compact-${Date.now()}.jsonl`); + 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); diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index adfe2a7ca..f7de7b40e 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -123,7 +123,7 @@ test("file-count threshold merges the oldest files into one compact file", () => // The compact file is the renamed final artifact, not a staging leftover. assert.match( fs.readdirSync(dir).find((f) => f.startsWith("compact-")) ?? "", - /^compact-\d+\.jsonl$/, + /^compact-\d+-\d+\.jsonl$/, ); assert.deepEqual( fs.readdirSync(dir).filter((f) => f.includes(".tmp-")), From 72b5adfe9834df35f4c3c17c21d424c3bbec5289 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:36:51 -0300 Subject: [PATCH 12/49] feat(history): sync extension with pi-history latest - overlay confines to the editor column while the gentle-shell fullscreen sidebar paints (pi-tui margin resolved live via the visible() hook; rail 50 + gap 3 + 1 padding) - responsive picker header: inline / stacked (tablet) / compact (mobile) modes with fit-driven thresholds and an abbreviated scope radio; overlay stays a fixed 30-row grid in every mode - selector always opens on empty stores; registry collision re-key guard; biome-clean formatting across the module - tests: +overlay-margin, +header-layout; history suite green under the node runner (188 pass) --- extensions/history/index.ts | 379 +++++++++++-------- extensions/history/load-shared-history.ts | 3 - extensions/history/selector-helpers.ts | 108 +++++- extensions/history/store.ts | 350 +++++++++-------- tests/history-command-registration.test.ts | 54 +-- tests/history-dedupe-entries.test.ts | 66 +++- tests/history-delete-backfill.test.ts | 79 +--- tests/history-dispatch.test.ts | 13 +- tests/history-drain-hidden.test.ts | 6 +- tests/history-drain-order.test.ts | 22 +- tests/history-gc.test.ts | 235 ++---------- tests/history-header-layout.test.ts | 52 +++ tests/history-hide-prompts.test.ts | 174 ++++++++- tests/history-lazy-windowing.test.ts | 71 ++-- tests/history-legacy-migrate-v2.test.ts | 55 +-- tests/history-max-results-cap.test.ts | 45 ++- tests/history-multi-reader.test.ts | 297 +++++++-------- tests/history-openflow-integration.test.ts | 28 +- tests/history-overlay-margin.test.ts | 95 +++++ tests/history-preview-layout.test.ts | 44 +-- tests/history-registry.test.ts | 112 ++---- tests/history-scope-delete.test.ts | 43 +-- tests/history-seed-bootstrap.test.ts | 48 ++- tests/history-seed-regen.test.ts | 7 +- tests/history-session-scan-directory.test.ts | 15 +- tests/history-session-writer.test.ts | 47 +-- tests/history-store-paths.test.ts | 18 +- tests/history-wheel-mouse.test.ts | 23 +- 28 files changed, 1302 insertions(+), 1187 deletions(-) create mode 100644 tests/history-header-layout.test.ts create mode 100644 tests/history-overlay-margin.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 02a07906d..83fa91ab8 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1,14 +1,9 @@ // 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. - -import { join } from "node:path"; +import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; +import { join } from "node:path"; import { DynamicBorder, type ExtensionAPI, @@ -16,58 +11,61 @@ import { type Theme, } from "@earendil-works/pi-coding-agent"; import { - appendSessionCapture, - bootstrapProjectSeed, - deleteFromGlobal, - deleteFromProject, - drainGlobal, - drainProject, - gcProjectDir, - ensureRegistryEntry, - migrateLegacyStores, - openSessionWriter, - type SessionWriterState, -} from "./store.ts"; -import { randomUUID } from "node:crypto"; + Container, + type Focusable, + getKeybindings, + Input, + matchesKey, + stripTerminalSequences, + type TUI, + type TuiMouseEvent, + truncateToWidth, +} from "@earendil-works/pi-tui"; import { hidePrompt } from "./hide-prompts.ts"; import { buildPromptRecords, - filterPrompts, - type PromptEntry, clampPreviewOffset, clampSelectedIndex, - deletionActionsFor, dedupePromptEntries, + deletionActionsFor, + editorOverlayMargin, + filterPrompts, getVisiblePromptRecords, + type HeaderLayoutMode, initialLoadedCount, loadedCountAfterDelete, loadedCountForQuery, loadedCountForTarget, moveSelectedIndex, nextLoadedCount, + type PiHistoryGlobals, + type PromptEntry, + type PromptRecord, pageSelectedIndex, + planHeaderLayout, + scopeRadioText, shouldGrowWindow, withExpandedHistoryGlobals, - type PiHistoryGlobals, - type PromptRecord, } from "./selector-helpers.ts"; import { - Container, - type Focusable, - getKeybindings, - Input, - matchesKey, - Text, - type TUI, - type TuiMouseEvent, - truncateToWidth, -} from "@earendil-works/pi-tui"; + appendSessionCapture, + bootstrapProjectSeed, + deleteFromGlobal, + deleteFromProject, + drainGlobal, + drainProject, + ensureRegistryEntry, + gcProjectDir, + migrateLegacyStores, + openSessionWriter, + type SessionWriterState, +} from "./store.ts"; 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 +// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=2 +// fires growth as the cursor enters the final 2 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. @@ -75,12 +73,16 @@ 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. +// preview container always renders at rows 17-26. The list region is +// mode-dependent (see listWheelFirstRow): the responsive header reclaims +// rows without changing the 30-row total, and only the compact mode both +// shifts the list start (border at row 5) and paints one list row fewer. const LIST_WHEEL_Y_FIRST = 5; const LIST_WHEEL_Y_LAST = 14; const PREVIEW_WHEEL_Y_FIRST = 17; const PREVIEW_WHEEL_Y_LAST = 26; +/** Minimum columns between the counts text and a right-flushed radio before shrinking deletes the spacer and stacks the header (user-directed). */ +const HEADER_INLINE_MIN_GAP = 4; // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); @@ -89,17 +91,13 @@ 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. -// Derived state only — deleting the directory restores cold start and -// unhides every prompt; transcripts and the editor store are never written -// here. -const PI_HISTORY_NAV_STATE_DIR = join( - homedir(), - ".pi", - "agent", - "history", -); +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 @@ -121,20 +119,18 @@ const ENTRY_PREFIX_WIDTH = 2; function sanitizeForDisplay(text: string): string { let out = ""; for (let i = 0; i < text.length; i++) { - const cp = text.codePointAt(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"); + out += `\\x${cp.toString(16).padStart(2, "0")}`; } else if (cp >= 0x80 && cp < 0xa0) { - out += "\\x" + cp.toString(16).padStart(2, "0"); + 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]; + out += text[i]; } if (cp > 0xffff) i++; // skip low surrogate of astral pair } @@ -159,17 +155,17 @@ interface DispatchEntry { } /** Notification sink for selector feedback; an absent callback drops notifications. */ -type SelectorNotify = (message: string, level: "error" | "warning" | "info") => void; +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; - } + constructor( + private text: string = "", + private readonly centered = false, + ) {} /** Replace the row content in place; padding contract comes from render(). */ setText(next: string): void { @@ -190,17 +186,30 @@ class FixedRowText { // 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 = truncated.replace(/\x1b\[[0-9;]*m/g, ""); + 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))]; + return [rendered + " ".repeat(Math.max(0, width - rendered.length))]; + } +} + +/** A row that renders as ZERO lines when its text is empty, letting the fixed 30-row overlay reclaim the row instead of pushing content out the bottom. */ +class OptionalRow { + private text = ""; + + setText(next: string): void { + this.text = next; + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.text.length === 0) return []; + return [truncateToWidth(this.text, width, "…")]; } } @@ -242,14 +251,18 @@ class PromptHistorySelector extends Container implements Focusable { private readonly previewContainer: Container; private readonly listContainer: Container; private readonly headerRow: FixedRowText; + private readonly headerLine2: OptionalRow; + private readonly headerLine3: OptionalRow; + private readonly hintRow: OptionalRow; + private readonly hintText: string; + /** Current responsive header mode; drives the list wheel region. */ + private headerMode: HeaderLayoutMode = "inline"; private readonly previewLabelRow: FixedRowText; private records: PromptRecord[]; private readonly theme: Theme; private readonly tui: TUI; private readonly onSelect: (record: PromptRecord) => void; private readonly onCancel: () => void; - /** Notification sink for selector feedback (wired by the factory). */ - private readonly onNotify?: SelectorNotify; private filteredRecords: PromptRecord[] = []; private selectedIndex = 0; /** Number of records loaded (newest-first) from the top of `records`. */ @@ -327,16 +340,16 @@ class PromptHistorySelector extends Container implements Focusable { records: PromptRecord[], onSelect: (record: PromptRecord) => void, onCancel: () => void, - onNotify?: SelectorNotify, + private readonly 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))); @@ -344,13 +357,15 @@ class PromptHistorySelector extends Container implements Focusable { 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.headerLine2 = new OptionalRow(); + this.headerLine3 = new OptionalRow(); + this.addChild(this.headerLine2); + this.addChild(this.headerLine3); + this.hintText = + "Type to filter (multi-word AND substring, case-insensitive)"; + this.hintRow = new OptionalRow(); + this.hintRow.setText(this.theme.fg("dim", this.hintText)); + this.addChild(this.hintRow); this.searchInput = new Input(); this.searchInput.onSubmit = () => this.selectCurrent(); this.searchInput.onEscape = () => this.onCancel(); @@ -412,33 +427,69 @@ class PromptHistorySelector extends Container implements Focusable { this.rebuildListWithWidth(this.lastWidth); } - /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows. */ + /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows (MAX_VISIBLE - 1 in compact mode). */ 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} `) + + const titleText = " History Search "; + const positionText = ` · ${position} of ${count} `; + const loadedText = ` · loaded ${this.loadedCount} of ${this.records.length} `; + const leftWidth = + titleText.length + positionText.length + loadedText.length; + const radioFull = scopeRadioText(this.scope, false); + // Radio label compaction is fit-driven too: abbreviate only when the + // full radio cannot fit the row it would occupy (user-directed paste). + const radioText = + width >= radioFull.length ? radioFull : scopeRadioText(this.scope, true); + const mode = planHeaderLayout( + width, + leftWidth, + radioFull.length, + HEADER_INLINE_MIN_GAP, + ); + this.headerMode = mode; + if (mode === "inline") { + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(titleText)) + + this.theme.fg("dim", positionText) + + this.theme.fg("dim", loadedText) + + // Right-aligned scope radio: pad from plain-text lengths so the + // radio ends flush at the header's last column at any width. + " ".repeat(Math.max(1, width - leftWidth - radioText.length)) + + this.theme.fg("dim", radioText), + ); + this.headerLine2.setText(""); + this.headerLine3.setText(""); + } else if (mode === "stacked") { + // Tablet: the spacer is deleted — the radio wraps to its own row + // under the full counts line (user-directed paste, leading space). + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(titleText)) + + this.theme.fg("dim", positionText) + + this.theme.fg("dim", loadedText), + ); + this.headerLine2.setText(` ${this.theme.fg("dim", radioText)}`); + this.headerLine3.setText(""); + } else { + // Compact (mobile): three rows — counts split off, radio abbreviated + // (user-directed paste). + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(titleText)) + + this.theme.fg("dim", ` · ${position} of ${count}`), + ); + // Leading space aligns both rows with the title's own left padding + // space (user-directed compact paste). + this.headerLine2.setText( 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) - ); - })(), + ` loaded ${this.loadedCount} of ${this.records.length}`, + ), + ); + this.headerLine3.setText(` ${this.theme.fg("dim", radioText)}`); + } + // Stacked modes reclaim the hint row so the overlay stays 30 rows. + this.hintRow.setText( + mode === "inline" ? this.theme.fg("dim", this.hintText) : "", ); this.listContainer.clear(); @@ -446,18 +497,24 @@ class PromptHistorySelector extends Container implements Focusable { this.listContainer.addChild( new FixedRowText(this.theme.fg("warning", "No matching prompts")), ); - for (let i = 1; i < MAX_VISIBLE; i++) { + // Compact still paints one list row fewer in the empty state, or the + // 3-row header would push the fixed 30-row overlay to 31 rows. + const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE; + for (let i = 1; i < listRows; i++) { this.listContainer.addChild(new FixedRowText()); } return; } + // Compact paints one list row fewer (reclaimed by the 3-row header); + // the preview block keeps PREVIEW_ROWS so the 30-row total holds. + const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE; const entryMax = Math.floor(width * 0.95) - ENTRY_PREFIX_WIDTH; const visible = getVisiblePromptRecords( this.filteredRecords, this.selectedIndex, - MAX_VISIBLE, + listRows, ); for (const { record, isSelected } of visible) { @@ -471,11 +528,18 @@ class PromptHistorySelector extends Container implements Focusable { this.listContainer.addChild(new FixedRowText(line)); } - for (let i = visible.length; i < MAX_VISIBLE; i++) { + for (let i = visible.length; i < listRows; i++) { this.listContainer.addChild(new FixedRowText()); } } + /** List wheel region start: compact shifts the list down one row. */ + private get listWheelFirstRow(): number { + return this.headerMode === "compact" + ? LIST_WHEEL_Y_FIRST + 1 + : LIST_WHEEL_Y_FIRST; + } + /** * Rebuild preview: word-wrap the full selected prompt text and show * a PREVIEW_ROWS-tall viewport starting at previewScrollOffset. @@ -605,14 +669,12 @@ class PromptHistorySelector extends Container implements Focusable { this.applyFilter(this.searchInput.getValue()); } - // -- Navigation --------------------------------------------------------- - - private moveUp(): void { - this.selectedIndex = moveSelectedIndex( - this.selectedIndex, - this.filteredRecords.length, - -1, - ); + /** + * 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, @@ -628,6 +690,17 @@ class PromptHistorySelector extends Container implements Focusable { ); 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(); @@ -638,21 +711,7 @@ class PromptHistorySelector extends Container implements Focusable { // 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. - 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()); - } + this.growLoadedWindowIfNeeded(); this.selectedIndex = moveSelectedIndex( this.selectedIndex, this.filteredRecords.length, @@ -774,7 +833,7 @@ class PromptHistorySelector extends Container implements Focusable { ): ReturnType { if (event.type !== "wheel") return undefined; const delta = event.wheelDelta ?? 0; - if (event.y >= LIST_WHEEL_Y_FIRST && event.y <= LIST_WHEEL_Y_LAST) { + if (event.y >= this.listWheelFirstRow && 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(); @@ -836,7 +895,7 @@ class PromptHistorySelector extends Container implements Focusable { } // --------------------------------------------------------------------------- -// Overlay glue +// Extension entry point // --------------------------------------------------------------------------- type SelectorDone = (result: PromptRecord | null) => void; @@ -860,7 +919,7 @@ function createPromptHistorySelectorFactory( onNotify?: SelectorNotify, ): SelectorFactory { return (tui, theme, _keybindings, done) => { - selectorTui = tui as { requestRender(): void }; + selectorTui = tui as { requestRender(): void; terminal?: unknown }; const finish = (result: PromptRecord | null) => { activeOverlayClose = null; done(result); @@ -895,20 +954,44 @@ async function runPromptHistorySelection( ), { overlay: true, - overlayOptions: { anchor: "bottom-center", width: "100%", offsetY: 5 }, + // pi-tui freezes the options object at showOverlay time, but calls + // visible() on EVERY render pass before resolving the overlay layout + // (compositeOverlays filters visible entries first), and re-reads + // margin per layout resolution — the getter below therefore stays + // live: resizing across the sidebar breakpoint re-seats the picker + // while it stays open. While the gentle-shell fullscreen sidebar + // paints, the margin confines width "100%" (and the bottom-center + // anchor) to the editor column plus 3 columns of padding; 0 keeps + // the native full-window behavior. + overlayOptions: () => { + let rightMargin = editorOverlayMargin(selectorTui?.terminal); + return { + anchor: "bottom-center" as const, + width: "100%" as const, + offsetY: 5, + get margin() { + return rightMargin > 0 ? { right: rightMargin } : undefined; + }, + visible: () => { + rightMargin = editorOverlayMargin(selectorTui?.terminal); + return true; + }, + }; + }, }, ), ); } +/** Shared entry point for the ctrl+shift+r shortcut and the /history command. */ // --------------------------------------------------------------------------- // 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; +/** TUI handle captured when the selector overlay mounts. `terminal` feeds the sidebar overlay margin. */ +let selectorTui: { requestRender(): void; terminal?: unknown } | null = null; let writerState: SessionWriterState | null = null; @@ -945,9 +1028,10 @@ function getWriter(): SessionWriterState { } /** - * 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. + * Scope drain for the selector: project scope drains this project's store + * files (transcript prompts enter once via bootstrapProjectSeed); global + * scope is the cross-project view (all project dirs + the legacy global + * seed). */ function drainForScope(scope: HistoryScope): string[] { getWriter(); // ensure init ran @@ -963,11 +1047,9 @@ async function openHistorySelector( // symmetrically — no live transcript merge (the one-time seed bootstrap // covers pre-store history). const entries = drainForScope("project"); - if (entries.length === 0) { - ctx.ui.notify("No prompt history available.", "warning"); - 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) { @@ -991,18 +1073,6 @@ function recordsFromEntries( export default function promptHistoryExtension(pi: ExtensionAPI) { // One writer per extension load; see getWriter() for the init order. - // Warm migrate/registry/seed OFF the first-prompt path: the scheduled - // init runs once, immediately after load. A prompt arriving earlier - // falls back to the synchronous lazy init in getWriter(), whose - // writerState guard makes whichever runs second a no-op — bootstrap - // work is never duplicated. - setImmediate(() => { - 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). // The local ExtensionAPI stub types handler args as unknown; narrow here. @@ -1016,8 +1086,7 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); - // Maintenance pass on graceful shutdown: compaction runs at the GC - // thresholds (50 files / 5000 lines / keep-newest-10). + // Backup pass: enforce the 1000-line limit on graceful shutdown. pi.on("session_shutdown", () => { try { gcProjectDir(PI_HISTORY_ROOT, CURRENT_CWD); diff --git a/extensions/history/load-shared-history.ts b/extensions/history/load-shared-history.ts index 79ef12f7d..ea03d0122 100644 --- a/extensions/history/load-shared-history.ts +++ b/extensions/history/load-shared-history.ts @@ -1,6 +1,3 @@ -// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history -// SPDX-License-Identifier: MIT - import fs from "node:fs"; interface SharedHistoryEntry { diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index 292c05907..9f8b3a09c 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -119,7 +119,7 @@ export function pageSelectedIndex( * 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 - * merge-history session-half tombstone filter MUST byte-match this key. + * 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(); @@ -244,11 +244,12 @@ export function loadedCountAfterDelete( * 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 history/index.ts. + * deleteCurrent in src/index.ts. */ -export function deletionActionsFor( - source: PromptSource, -): { deleteFromEditorStore: boolean; writeTombstone: boolean } { +export function deletionActionsFor(source: PromptSource): { + deleteFromEditorStore: boolean; + writeTombstone: boolean; +} { if (source === "editor") { return { deleteFromEditorStore: true, writeTombstone: true }; } @@ -315,3 +316,100 @@ export function filterPrompts( return filtered.slice(0, MAX_RESULTS); } + +/** + * Cross-extension fullscreen-sidebar state contract (gentle-shell): stored on + * the shared ProcessTerminal under a global-registry symbol so any extension + * can read it without importing gentle-shell. Shape per its lib/shell-sidebar.ts: + * `{ active: boolean; ownsHost?: () => boolean; parts: Map }`. + */ +const SIDEBAR_STATE_SYMBOL = Symbol.for("gentle-pi.experimental-sidebar.state"); + +/** + * Geometry overlay right margin that confines a full-width overlay to the + * editor column while the gentle-shell fullscreen sidebar paints: its layout + * hstack reserves 50 columns (RAIL_WIDTH) for the rail plus a 3-column gap + * (GAP) before it, and it only activates at >= 140 columns. pi-tui resolves + * overlay width "100%" and the bottom-center anchor inside + * `[0, columns - margin)`, which is then exactly the editor column. + */ +export const SIDEBAR_RAIL_OVERLAY_MARGIN = 53; + +/** + * Visual breathing room between the picker and the sidebar rail, added on top + * of the geometry margin (user-directed: 1 column, 2026-09-21). + */ +export const SIDEBAR_OVERLAY_PADDING = 1; + +interface SidebarStateShape { + active?: unknown; + ownsHost?: () => unknown; +} + +/** + * Overlay right margin for the current terminal: the geometry margin plus + * padding while the gentle-shell sidebar rail is painting, else 0 (native + * full-window overlay). Reads the terminal-owned state contract defensively — + * any absent, malformed, or non-owning state degrades to 0 so the picker + * keeps opening. Purity note: this returns the CURRENT margin per call; live + * refresh while an overlay stays open is the caller's job (the picker wires + * visible() plus a getter margin — pi-tui re-reads both every render). + */ +export function editorOverlayMargin(terminal: unknown): number { + if (typeof terminal !== "object" || terminal === null) return 0; + const state = (terminal as Record)[SIDEBAR_STATE_SYMBOL] as + | SidebarStateShape + | undefined; + if (typeof state !== "object" || state === null) return 0; + if (state.active !== true || typeof state.ownsHost !== "function") return 0; + try { + return state.ownsHost() === true + ? SIDEBAR_RAIL_OVERLAY_MARGIN + SIDEBAR_OVERLAY_PADDING + : 0; + } catch { + return 0; + } +} + +/** Responsive picker-header mode at the current render width. */ +export type HeaderLayoutMode = "inline" | "stacked" | "compact"; + +/** + * Fit-driven header plan (user-directed responsive header): "inline" keeps + * title + counts + right-flushed radio on one row; "stacked" (tablet) deletes + * the spacer — the radio wraps to its own row under the full counts line; + * "compact" (mobile) further splits the counts off and abbreviates the radio. + * Thresholds derive from the ACTUAL text widths, so any count size flips the + * mode at the exact column where the previous layout stops fitting. + */ +export function planHeaderLayout( + width: number, + leftWidth: number, + radioWidth: number, + minGap: number, +): HeaderLayoutMode { + if (width >= leftWidth + minGap + radioWidth) return "inline"; + if (width >= leftWidth) return "stacked"; + return "compact"; +} + +/** Full scope radio: both scope labels spelled out. */ +export const SCOPE_RADIO_FULL_PROJECT = "◉ Current project | ○ All projects"; +export const SCOPE_RADIO_FULL_GLOBAL = "○ Current project | ◉ All projects"; +/** Abbreviated radio: the ACTIVE scope keeps its full label, the other shortens. */ +export const SCOPE_RADIO_COMPACT_PROJECT = "◉ Current project | ○ All"; +export const SCOPE_RADIO_COMPACT_GLOBAL = "○ Current | ◉ All projects"; + +/** + * Scope radio text for the current width: abbreviated only when the full + * radio cannot fit the row it would occupy (compact widths). + */ +export function scopeRadioText( + scope: "project" | "global", + compact: boolean, +): string { + if (scope === "project") { + return compact ? SCOPE_RADIO_COMPACT_PROJECT : SCOPE_RADIO_FULL_PROJECT; + } + return compact ? SCOPE_RADIO_COMPACT_GLOBAL : SCOPE_RADIO_FULL_GLOBAL; +} diff --git a/extensions/history/store.ts b/extensions/history/store.ts index fbf0be6b8..5ccba43cb 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -1,12 +1,6 @@ -// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history -// SPDX-License-Identifier: MIT - -// Consolidated multi-concurrency store (v2), slices 1-6: project paths -// and identity, the advisory registry, entry primitives, the per-instance -// session writer, the scope drain/reader/query section (ordering, dedup, -// tombstone filter, project/global drains), scope deletes, legacy -// migration, the project seed bootstrap, and GC/compaction. Formerly -// store-paths.ts + registry.ts + multi-store.ts (+ v1 primitives). +// 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"; @@ -14,9 +8,9 @@ import path from "node:path"; import { loadHiddenPrompts } from "./hide-prompts.ts"; import { loadSharedHistory } from "./load-shared-history.ts"; import { + type ExtractedPrompt, extractPromptsFromFile, listSessionFiles, - type ExtractedPrompt, } from "./session-scan.ts"; // =========================================================================== @@ -104,7 +98,7 @@ 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.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8"); fs.renameSync(tmp, target); } @@ -121,11 +115,6 @@ export function ensureRegistryEntry( 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 @@ -141,6 +130,11 @@ export function ensureRegistryEntry( 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; @@ -157,7 +151,7 @@ function projectHashLong(cwd: string): string { // =========================================================================== /** One line of `editor-history.jsonl`. */ -export interface StoreEntry { +interface StoreEntry { /** Schema version; 1 when absent in the source line. */ v: number; text: string; @@ -170,7 +164,7 @@ export interface StoreEntry { * non-string or whitespace-only text) so callers can skip them; a torn * last line from a crash is handled the same way. */ -export function parseStoreLine(raw: string): StoreEntry | null { +function parseStoreLine(raw: string): StoreEntry | null { if (raw.length === 0) return null; try { const value: unknown = JSON.parse(raw); @@ -192,7 +186,7 @@ export function parseStoreLine(raw: string): StoreEntry | null { } // =========================================================================== -// Instance writer (formerly multi-store.ts) +// Multi-store (formerly multi-store.ts) // =========================================================================== /** Mutable state of ONE pi instance's exclusive capture file. */ @@ -247,13 +241,12 @@ export function appendSessionCapture( 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"); + fs.appendFileSync(state.filePath, `${serializeEntry(entry)}\n`, "utf8"); state.lineCount += 1; } - // --------------------------------------------------------------------------- -// Multi-file reader (design v2: k-way backward merge) +// Multi-file reader (design v2: sequential backward drain over sorted files) // --------------------------------------------------------------------------- /** UI-level prompt identity: whitespace-collapsed, case-insensitive. */ @@ -282,7 +275,10 @@ function listProjectFiles(dir: string): string[] { .sort((a, b) => fileMtimeMs(b) - fileMtimeMs(a)); } -/** Read one file's valid entries (chronological). */ +/** + * Read one file's valid entries (chronological). Malformed lines are + * skipped. + */ function readFileEntries(file: string): StoreEntry[] { let raw = ""; try { @@ -311,11 +307,6 @@ function fileSortKey(file: string, entries: StoreEntry[]): number { return maxTs > 0 ? maxTs : fileMtimeMs(file); } -/** Tombstone key - byte-compatible with hide-prompts' promptDedupKey. */ -function promptDedupKeyOf(text: string): string { - return text.replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(); -} - /** * Sequential backward drain over PRE-SORTED files: each file fully, * newest-line-first, deduped by UI-level identity, capped at `limit`. @@ -349,8 +340,7 @@ function sortFilesForDrain(files: string[]): string[] { .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), + (a, b) => fileSortKey(b.file, b.entries) - fileSortKey(a.file, a.entries), ) .map((f) => f.file); } @@ -366,26 +356,40 @@ export function drainProject( stateDir?: string, ): string[] { return drainFiles( - sortFilesForDrain(listProjectFiles(path.join(root, "projects", projectHash(cwd)))), + sortFilesForDrain( + listProjectFiles(path.join(root, "projects", projectHash(cwd))), + ), limit, stateDir ? loadHiddenPrompts(stateDir) : new Set(), ); } /** - * 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). + * Drain the GLOBAL scope: the legacy global seed (newest single source) + * plus every project dir's files, mtime-newest-first, deduped, capped. */ export function drainGlobal( root: string, limit: number = 1000, stateDir?: string, ): string[] { - const files: string[] = []; const globalSeed = globalSeedPath(root); + const sorted = sortFilesForDrain(listAllProjectFiles(root)); + if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last + return drainFiles( + sorted, + limit, + stateDir ? loadHiddenPrompts(stateDir) : new Set(), + ); +} +/** + * 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"), { @@ -396,17 +400,9 @@ export function drainGlobal( } for (const dirEntry of projectDirs) { if (!dirEntry.isDirectory()) continue; - files.push( - ...listProjectFiles(path.join(root, "projects", dirEntry.name)), - ); + files.push(...listProjectFiles(path.join(root, "projects", dirEntry.name))); } - const sorted = sortFilesForDrain(files); - if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last - return drainFiles( - sorted, - limit, - stateDir ? loadHiddenPrompts(stateDir) : new Set(), - ); + return files; } // --------------------------------------------------------------------------- @@ -448,7 +444,11 @@ function sweepFiles(files: string[], text: string): SweepResult { } if (fileRemoved === 0) continue; const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; - fs.writeFileSync(tmp, kept.length > 0 ? kept.join("\n") + "\n" : "", "utf8"); + fs.writeFileSync( + tmp, + kept.length > 0 ? `${kept.join("\n")}\n` : "", + "utf8", + ); fs.renameSync(tmp, file); filesAffected += 1; removed += fileRemoved; @@ -470,23 +470,9 @@ export function deleteFromProject( /** Delete every copy of a prompt from the GLOBAL scope (all projects + seed). */ export function deleteFromGlobal(root: string, text: string): SweepResult { - const files: string[] = []; + const files = listAllProjectFiles(root); const globalSeed = globalSeedPath(root); - if (fs.existsSync(globalSeed)) files.push(globalSeed); - 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)), - ); - } + if (fs.existsSync(globalSeed)) files.unshift(globalSeed); return sweepFiles(files, text); } @@ -513,14 +499,30 @@ function readValidLines(file: string): StoreEntry[] { } } +/** + * 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. + * Content lands in `pi-history/history-global.jsonl` chronologically; each + * source is renamed `.imported`, never deleted. Gated: an existing global + * seed means migration already ran. */ export function migrateLegacyStores( root: string, @@ -535,8 +537,15 @@ export function migrateLegacyStores( 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] }); + if (texts.length > 0) { + for (let i = texts.length - 1; i >= 0; i--) { + collected.push({ v: 1, text: texts[i] }); + } + } + try { + fs.renameSync(legacyArray, `${legacyArray}.imported`); + } catch { + // The seed write below is the source of truth; rename failure is benign. } } @@ -544,29 +553,17 @@ export function migrateLegacyStores( 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 }; - - 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); - - // 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`); + fs.renameSync(v1File, `${v1File}.imported`); } catch { - // benign: the seed gate prevents duplicate import on the next run + // benign } } - return { migrated: collected.length, ran: true }; + + if (collected.length === 0) return { migrated: 0, ran: false }; + + const migrated = writeSeedFileAtomic(seed, collected); + return { migrated, ran: true }; } // --------------------------------------------------------------------------- @@ -584,18 +581,22 @@ export interface SeedResult { * 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. */ -export function bootstrapProjectSeed( - root: string, - cwd: string, - sessionsRoot: string, - target: number, - stateDir?: string, -): SeedResult { - const dir = path.join(root, "projects", projectHash(cwd)); +/** Tombstone key - byte-compatible with hide-prompts' promptDedupKey. */ +function promptDedupKeyOf(text: string): string { + return text.replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(); +} - // Count existing entries and collect their identities. - const existingKeys = new Set(); - let existingCount = 0; +/** + * 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 { @@ -606,34 +607,61 @@ export function bootstrapProjectSeed( for (const lineText of raw.split("\n")) { const parsed = parseStoreLine(lineText); if (parsed) { - existingCount += 1; - existingKeys.add(promptKey(parsed.text)); + count += 1; + keys.add(promptKey(parsed.text)); } } } - if (existingCount >= 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. - const hidden = stateDir ? loadHiddenPrompts(stateDir) : new Set(); + return { count, keys }; +} - // Scan transcripts: session files of THIS project's dir, newest first. - let files: string[] = []; +/** + * 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, "-"); - files = listSessionFiles(sessionsRoot).filter((file) => + 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 { seeded: 0, ran: false }; + return []; } - files.sort((a, b) => fileMtimeMs(b) - fileMtimeMs(a)); +} + +/** + * 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[] = []; @@ -643,31 +671,50 @@ export function bootstrapProjectSeed( continue; } for (let i = prompts.length - 1; i >= 0; i--) { - const text = prompts[i].text; - if (/^\/[A-Za-z]/.test(text.trim())) continue; - if (hidden.size > 0 && hidden.has(promptDedupKeyOf(text))) continue; - const key = promptKey(text); - if (existingKeys.has(key)) continue; - existingKeys.add(key); - const entry: StoreEntry = { v: 1, text }; + 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 >= target - existingCount) break outer; + 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. + const hidden = stateDir ? loadHiddenPrompts(stateDir) : new Set(); + 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 seed = seedFilePath(root, cwd); - 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 { seeded: collected.length, ran: true }; + const seeded = writeSeedFileAtomic(seedFilePath(root, cwd), collected); + return { seeded, ran: true }; } // --------------------------------------------------------------------------- @@ -682,7 +729,6 @@ 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 @@ -722,18 +768,21 @@ export function gcProjectDir( } /** - * Merge all but the newest `keepNewest` 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. (Upstream exposed this as - * compactProjectDir; dropped here — zero callers, gcProjectDir is the - * single entry point.) + * 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. */ -function compactFiles( - filesMtimeDesc: string[], - keepNewest: number, +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 }; } @@ -746,17 +795,14 @@ function compactFiles( const parsed = parseStoreLine(lineText); if (parsed) mergedLines.push(JSON.stringify(parsed)); } - } catch { - // unreadable file: skip its content, still remove nothing - continue; - } + } 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 compact = path.join(dir, `compact-${Date.now()}.jsonl`); const tmp = `${compact}.tmp-${process.pid}-${Date.now()}`; - fs.writeFileSync(tmp, mergedLines.join("\n") + "\n", "utf8"); + fs.writeFileSync(tmp, `${mergedLines.join("\n")}\n`, "utf8"); fs.renameSync(tmp, compact); for (const file of toMerge) { try { diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts index 4e44c3859..1bd615c9c 100644 --- a/tests/history-command-registration.test.ts +++ b/tests/history-command-registration.test.ts @@ -1,14 +1,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; 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 -// extensions/history/index.ts — it pulls the pi-tui runtime graph (§D3). +// 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 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", () => { @@ -27,18 +26,13 @@ test("openHistorySelector is extracted once and shared by both entry points", () "registerShortcut and registerCommand handlers should both call openHistorySelector(ctx)", ); - // PR-branch (slice 3) behavior: the store-only drain keeps the empty - // guard — no history means a warning, not an empty overlay. (The dev - // repo's later always-open selector dropped this guard; the PR branch is - // the API truth here.) 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("if (entries.length === 0)") && - body.includes('"No prompt history available."'), - "an empty history warns and skips the overlay (PR-branch drain guard)", + !body.includes('"No prompt history available."'), + "the warning is removed; the selector always opens (AC-P1-5.2)", ); }); @@ -57,21 +51,6 @@ test("the /history command is registered beside the shortcut", () => { ); }); -test("the ctrl+shift+r shortcut is registered with the shared description", () => { - const index = source.indexOf("pi.registerShortcut(SHORTCUT"); - assert.ok(index >= 0, "pi.registerShortcut(SHORTCUT, ...) should exist"); - - const slice = source.slice(index, index + 200); - assert.ok( - slice.includes('"Search prompt history"'), - "shortcut should carry the shared description", - ); - assert.ok( - slice.includes("openHistorySelector(ctx)"), - "shortcut 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"), @@ -82,24 +61,3 @@ test("in-UI hint describes multi-word AND substring matching, not fuzzy", () => "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 index f929880e9..331720599 100644 --- a/tests/history-dedupe-entries.test.ts +++ b/tests/history-dedupe-entries.test.ts @@ -1,5 +1,8 @@ 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). @@ -14,11 +17,6 @@ import { dedupePromptEntries } from "../extensions/history/selector-helpers.ts"; // `/\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). -// -// The dev suite's T3 source-parse pins (dedupePromptEntries wired between -// drainForScope and buildPromptRecords inside openHistorySelector) cover the -// slice-3 selector wiring in extensions/history/index.ts and port with that -// slice — index.ts stays at its slice-1 surface here. // T1 — AC-L5-1: keep-first over newest-first input order (file order). @@ -121,3 +119,61 @@ test("no snapshot cap: every unique entry is kept past MAX_RESULTS (AC-L5-5)", ( 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 index e5fcd3bd8..1d095ae2c 100644 --- a/tests/history-delete-backfill.test.ts +++ b/tests/history-delete-backfill.test.ts @@ -1,11 +1,9 @@ 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 { - deletionActionsFor, - loadedCountAfterDelete, -} from "../extensions/history/selector-helpers.ts"; +import { loadedCountAfterDelete } from "../extensions/history/selector-helpers.ts"; // Unit 3 — L4 delete backfill (spec C4, design §B3). // @@ -61,7 +59,7 @@ test("loadedCountAfterDelete is defensive for an empty window (AC-L4-1)", () => // (Change 1 C1 interplay unchanged). const selectorSource = fs.readFileSync( - path.join(process.cwd(), "extensions", "history", "index.ts"), + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), "utf8", ); @@ -113,74 +111,3 @@ test("deleteCurrent splices, backfills, then re-filters — inside the guarded b "the bookkeeping must read the unfiltered window and the shrunk snapshot", ); }); - -// Slice 5 scenario pins (porting contract): the tombstone-always rule and -// the partial-failure toast path. The dev suite pins the planner + these -// deleteCurrent branch shapes in hide-prompts.test.ts (T27/T28); this file -// carries the delete-flow source-parse half so the slice-5 branch stays -// pinned inside the delete slice's own tests. - -test("deletionActionsFor always plans a tombstone — session provenance deletes nothing from disk", () => { - // Session/seed-born records: tombstone ONLY (transcripts and the seed are - // never rewritten by a delete) — the tombstone is what keeps the deleted - // prompt from resurfacing on the next drain. - assert.deepEqual(deletionActionsFor("session"), { - deleteFromEditorStore: false, - writeTombstone: true, - }); - // Editor records: disk delete AND tombstone (twin suppression). - assert.deepEqual(deletionActionsFor("editor"), { - deleteFromEditorStore: true, - writeTombstone: true, - }); - - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); - const end = selectorSource.indexOf("\n }", decl); - assert.ok(end > decl, "deleteCurrent's body should close"); - const body = selectorSource.slice(decl, end); - - // Branch shape: the tombstone write sits OUTSIDE the editor-store guard — - // every provenance lands a tombstone, so an entry that came from the - // seed or a transcript cannot resurface after its delete. - 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 deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); - const end = selectorSource.indexOf("\n }", decl); - assert.ok(end > decl, "deleteCurrent'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-dispatch.test.ts b/tests/history-dispatch.test.ts index cdda32505..78a32c5bb 100644 --- a/tests/history-dispatch.test.ts +++ b/tests/history-dispatch.test.ts @@ -1,22 +1,21 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; 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 extensions/history/index.ts and needs - * the pi-tui runtime (Container, Input, TUI, Theme), so these tests read the + * 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 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[] = ["; @@ -59,7 +58,7 @@ function dispatchTable(): string { assert.notStrictEqual( start, -1, - "dispatch table declaration should exist in extensions/history/index.ts", + "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"); diff --git a/tests/history-drain-hidden.test.ts b/tests/history-drain-hidden.test.ts index 90c479758..58ac72064 100644 --- a/tests/history-drain-hidden.test.ts +++ b/tests/history-drain-hidden.test.ts @@ -10,11 +10,7 @@ import { projectHash, } 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-hidden-project"; +const CWD = "/Users/admin/Dev/pi/pi-history"; function write(file: string, texts: string[], ts = 100): void { fs.mkdirSync(path.dirname(file), { recursive: true }); diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts index 86cfddca9..0047dc4ed 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -4,17 +4,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { + deleteFromProject, drainGlobal, drainProject, globalSeedPath, projectHash, } 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); -// 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"; +const CWD = "/Users/admin/Dev/pi/pi-history"; function writeTs(file: string, texts: string[], ts: number): void { fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -31,11 +34,8 @@ test("atomic rewrite (delete) does not reshuffle the drain order", () => { writeTs(path.join(dir, "old.jsonl"), ["a-old"], 100); writeTs(path.join(dir, "new.jsonl"), ["z-new"], 200); assert.deepEqual(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()); + // Deleting from the old file rewrites it — mtime jumps to NOW. + deleteFromProject(root, CWD, "a-old"); assert.deepEqual(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); @@ -57,9 +57,9 @@ test("global drain puts the legacy seed last regardless of its fresh mtime", () assert.deepEqual(drainGlobal(root), ["fresh", "legacy-2", "legacy-1"]); }); -test( +const sealedDrainTest = skipIf(process.getuid?.() === 0); +sealedDrainTest( "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)); diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index f7de7b40e..ebd04c1e0 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -3,18 +3,19 @@ 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"; +import { + compactProjectDir, + gcProjectDir, + projectHash, +} 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); -// 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; the -// dev-only compactProjectDir shortcut is gone — gcProjectDir with explicit -// thresholds is the single PR-branch entry point.) -const CWD = "/pi-history-test/project-gc"; +const CWD = "/Users/admin/Dev/pi/pi-history"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-gc-")); @@ -54,40 +55,6 @@ function totalLines(dir: string): number { 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); @@ -120,15 +87,8 @@ test("file-count threshold merges the oldest files into one compact file", () => // 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-")), - [], - ); + const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); + assert.ok(compact); // The newest original file survives untouched by name. assert.equal(fs.readdirSync(dir).includes("f12.jsonl"), true); }); @@ -151,9 +111,9 @@ test("line-count threshold triggers compaction too", () => { assert.equal(fs.readdirSync(dir).includes("g3.jsonl"), true); }); -test("GC on a missing project dir is a no-op", () => { +test("compactProjectDir on a missing dir is a no-op", () => { const root = makeRoot(); - const result = gcProjectDir(root, "/does/not/exist"); + const result = compactProjectDir(root, "/does/not/exist"); assert.deepEqual(result, { compacted: false, merged: 0 }); }); @@ -179,41 +139,44 @@ test("compaction keeps the newest 10 files, merges the rest", () => { 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 sealedGcTest = skipIf(process.getuid?.() === 0); +sealedGcTest( + "compactProjectDir skips an unreadable file's content and compacts the readable entries", () => { 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). + // 3 files, keepNewest 1 → the two oldest merge; the sealed one sits in + // the merged tail so its content hits the unreadable-skip branch. 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, - }); + const result = compactProjectDir(root, CWD, { keepNewest: 1 }); // The merged count covers the whole tail, sealed file included. assert.deepEqual(result, { compacted: true, merged: 2 }); + const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); + if (compact === undefined) { + throw new Error("the compact file must exist"); + } + const compactTexts = fs + .readFileSync(path.join(dir, compact), "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); // Only the readable tail file's entries compacted; the sealed bytes // were skipped, never fatal. (writeFile names entries `${name}-${i}`.) - assert.deepEqual(compactTexts(dir), [ + assert.deepEqual(compactTexts, [ "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 + // GC cache 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("sealed-old.jsonl"), false); assert.equal(fs.readdirSync(dir).includes("newest.jsonl"), true); } finally { // The compaction removes the sealed original; restore only if it @@ -226,135 +189,3 @@ test( } }, ); - -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-header-layout.test.ts b/tests/history-header-layout.test.ts new file mode 100644 index 000000000..4734967b4 --- /dev/null +++ b/tests/history-header-layout.test.ts @@ -0,0 +1,52 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + planHeaderLayout, + SCOPE_RADIO_COMPACT_GLOBAL, + SCOPE_RADIO_COMPACT_PROJECT, + SCOPE_RADIO_FULL_GLOBAL, + SCOPE_RADIO_FULL_PROJECT, + scopeRadioText, +} from "../extensions/history/selector-helpers.ts"; + +const LEFT = + " History Search ".length + + " · 1 of 10 ".length + + " · loaded 10 of 27 ".length; +const RADIO = SCOPE_RADIO_FULL_PROJECT.length; +const GAP = 4; + +test("inline while counts plus radio plus minimum gap fit the width", () => { + assert.equal( + planHeaderLayout(LEFT + GAP + RADIO, LEFT, RADIO, GAP), + "inline", + ); + assert.equal(planHeaderLayout(200, LEFT, RADIO, GAP), "inline"); +}); + +test("stacked (tablet) once the spacer would drop below the minimum gap", () => { + assert.equal( + planHeaderLayout(LEFT + GAP + RADIO - 1, LEFT, RADIO, GAP), + "stacked", + ); + assert.equal(planHeaderLayout(LEFT, LEFT, RADIO, GAP), "stacked"); +}); + +test("compact (mobile) when even the counts line no longer fits", () => { + assert.equal(planHeaderLayout(LEFT - 1, LEFT, RADIO, GAP), "compact"); + assert.equal(planHeaderLayout(30, LEFT, RADIO, GAP), "compact"); +}); + +test("radio pins the user-directed labels", () => { + assert.equal(SCOPE_RADIO_FULL_PROJECT, "◉ Current project | ○ All projects"); + assert.equal(SCOPE_RADIO_FULL_GLOBAL, "○ Current project | ◉ All projects"); + assert.equal(SCOPE_RADIO_COMPACT_PROJECT, "◉ Current project | ○ All"); + assert.equal(SCOPE_RADIO_COMPACT_GLOBAL, "○ Current | ◉ All projects"); +}); + +test("scopeRadioText abbreviates only in compact mode", () => { + assert.equal(scopeRadioText("project", false), SCOPE_RADIO_FULL_PROJECT); + assert.equal(scopeRadioText("global", false), SCOPE_RADIO_FULL_GLOBAL); + assert.equal(scopeRadioText("project", true), SCOPE_RADIO_COMPACT_PROJECT); + assert.equal(scopeRadioText("global", true), SCOPE_RADIO_COMPACT_GLOBAL); +}); diff --git a/tests/history-hide-prompts.test.ts b/tests/history-hide-prompts.test.ts index 03c054863..011b4d042 100644 --- a/tests/history-hide-prompts.test.ts +++ b/tests/history-hide-prompts.test.ts @@ -1,15 +1,19 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { hidePrompt, loadHiddenPrompts } from "../extensions/history/hide-prompts.ts"; -import { promptDedupKey } from "../extensions/history/selector-helpers.ts"; +import { + deletionActionsFor, + promptDedupKey, +} from "../extensions/history/selector-helpers.ts"; -// Unit WU4 — tombstone write half + read half (spec C4, design §D6). fs-only -// coverage. 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. +// Unit WU4 — S4 tombstone write half + deletion planner + deleteCurrent +// branch (spec C4, design §D6/§F). fs-only: the overlay file is read as +// TEXT for the delete-flow pins (command-registration pattern — never +// imported; it pulls the pi-tui runtime graph). function makeStateDir(name: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `hide-prompts-${name}-`)); @@ -66,9 +70,9 @@ test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads as assert.ok(loaded.has(promptDedupKey("same text"))); }); -// T26 — AC-S4-5: corrupt hidden.json is fail-open (READ half) AND the next -// hide rewrites the file clean as a sorted compact array — the rewrite half -// is the recovery path. +// T26 — AC-S4-5: corrupt hidden.json is fail-open (READ half, green since +// WU3) AND the next hide rewrites the file clean as a sorted compact array — +// the rewrite half is the RED seam here. test("T26 (AC-S4-5): corrupt hidden.json loads as empty and the next hide rewrites it clean", () => { const stateDir = makeStateDir("t26"); fs.writeFileSync( @@ -83,6 +87,160 @@ test("T26 (AC-S4-5): corrupt hidden.json loads as empty and the next hide rewrit assert.equal(loadHiddenPrompts(stateDir).size, 1); }); +// T27 — AC-S4-3: session delete flow. Planner: tombstone only. The hide +// lands atomically in the state dir (only hidden.json remains — the .tmp +// staging file was renamed into place). Source-parse pins on the +// deleteCurrent branch: the hide call present, the disk delete UNREACHABLE +// from the session path (it sits inside the editor-store guard), splice + +// bookkeeping AFTER the hide, a failed session hide aborts WITHOUT +// splicing, and no raw fs write ever appears in the branch (transcripts are +// never written). +test("T27 (AC-S4-3): session delete — tombstone-only plan, atomic hide, branch hides then splices after", () => { + assert.deepEqual(deletionActionsFor("session"), { + deleteFromEditorStore: false, + writeTombstone: true, + }); + + // fs behavior: the hide writes the shared key; the state dir holds only + // hidden.json afterwards (no orphaned .tmp staging file). + const stateDir = makeStateDir("t27"); + assert.deepEqual(hidePrompt(stateDir, "session row"), { + status: "written", + }); + assert.deepEqual(readHideFile(stateDir), [promptDedupKey("session row")]); + assert.deepEqual(fs.readdirSync(stateDir).sort(), ["hidden.json"]); + + // Source-parse the deleteCurrent branch in src/index.ts. + const overlaySource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", + ); + const decl = overlaySource.indexOf("private deleteCurrent("); + assert.ok(decl !== -1, "deleteCurrent must exist"); + const end = overlaySource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent's body must close"); + const body = overlaySource.slice(decl, end); + + const hideAt = body.indexOf("hidePrompt("); + assert.ok(hideAt !== -1, "the branch must write the tombstone hide"); + const diskDeleteAt = body.indexOf( + "deleteFromProject(PI_HISTORY_ROOT, CURRENT_CWD, selected.text)", + ); + assert.ok( + diskDeleteAt !== -1, + "the editor branch keeps its exact call shape", + ); + // The disk delete is unreachable from the session path: it sits INSIDE + // the editor-store guard (no block closer between guard and call). + const editorGuardAt = body.indexOf("if (actions.deleteFromEditorStore)"); + assert.ok( + editorGuardAt !== -1 && editorGuardAt < diskDeleteAt, + "the disk delete must be guarded by the editor provenance", + ); + assert.ok( + !body.slice(editorGuardAt, diskDeleteAt).includes("\n }"), + "the disk delete call must sit inside the editor-store guard block", + ); + + // Session path: hide first, then splice + bookkeeping. + const spliceAt = body.indexOf("this.records.splice("); + const backfillAt = body.indexOf("loadedCountAfterDelete("); + assert.ok( + hideAt < spliceAt && spliceAt < backfillAt, + "session path: hide, then splice, then the Change 2 bookkeeping", + ); + + // A failed session hide aborts WITHOUT splicing: the hide-error gate + // precedes the splice and its early return is exclusive to the + // non-editor path. + const gateAt = body.indexOf('if (hide.status === "error")'); + assert.ok( + gateAt !== -1 && gateAt < spliceAt, + "the hide-error gate must precede the splice", + ); + const gate = body.slice(gateAt, spliceAt); + assert.ok( + gate.includes("if (!actions.deleteFromEditorStore)"), + "the abort must be conditional on the non-editor path", + ); + assert.ok( + gate.includes("return;"), + "a failed session hide aborts without splicing", + ); + + // Transcript invariant: the branch never writes files directly. + assert.ok( + !body.includes("writeFileSync"), + "no raw writes in the delete branch", + ); + assert.ok( + !body.includes("appendFileSync"), + "no raw appends in the delete branch", + ); +}); + +// T28 — AC-S4-4: editor delete flow. Planner: disk delete AND tombstone. +// Source-parse pins: the Change 1 call shape stays exact and its status +// gate unchanged; the twin-suppression hide follows the deleted status; in +// the hide-error gate the toast fires and the splice proceeds on the editor +// path (the row is legitimately gone) — only the session path returns. +test("T28 (AC-S4-4): editor delete — exact disk-delete shape, twin suppression after deleted, splice proceeds through hide errors", () => { + assert.deepEqual(deletionActionsFor("editor"), { + deleteFromEditorStore: true, + writeTombstone: true, + }); + + const overlaySource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", + ); + const decl = overlaySource.indexOf("private deleteCurrent("); + assert.ok(decl !== -1, "deleteCurrent must exist"); + const end = overlaySource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent's body must close"); + const body = overlaySource.slice(decl, end); + + // Change 1 shape unchanged: exact call, followed by the existing gate. + const diskDeleteAt = body.indexOf( + "deleteFromProject(PI_HISTORY_ROOT, CURRENT_CWD, selected.text)", + ); + assert.ok(diskDeleteAt !== -1, "the Change 1 call shape must be exact"); + const earlyReturnAt = body.indexOf("if (removed === 0) return;"); + assert.ok( + earlyReturnAt > diskDeleteAt, + "the existing status gate must follow the disk delete", + ); + + // Twin suppression: the tombstone write follows the deleted status so the + // session twin of the same text cannot resurface (R7). + const hideAt = body.indexOf("hidePrompt("); + assert.ok( + hideAt > earlyReturnAt, + "the twin-suppression hide must follow the deleted status", + ); + + // Error-semantics shape: the hide-error gate toasts, and the ONLY early + // return inside it sits behind the non-editor guard — the editor row is + // legitimately gone and splices even when the twin suppression fails. + const spliceAt = body.indexOf("this.records.splice("); + const gateAt = body.indexOf('if (hide.status === "error")'); + assert.ok(gateAt !== -1, "hide errors must be gated"); + 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 !== -1, + "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", + ); +}); + // WU4c — write-failure path (AC-S4-2 triangulation): a state dir that cannot // be created (its parent is a regular file) makes the atomic write return // false, and hidePrompt maps that to the toast-suitable error object — diff --git a/tests/history-lazy-windowing.test.ts b/tests/history-lazy-windowing.test.ts index 5ee40838a..f804da454 100644 --- a/tests/history-lazy-windowing.test.ts +++ b/tests/history-lazy-windowing.test.ts @@ -1,7 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; import { buildPromptRecords, filterPrompts, @@ -16,14 +17,14 @@ import { // 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 extensions/history/index.ts: +// 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 does in the selector — grow-before-move, one batch per +// 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). @@ -142,9 +143,7 @@ test("the selector stores no exhausted/isLoaded flag — exhaustion is derivatio 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), - ), + fileURLToPath(new URL("../extensions/history/selector-helpers.ts", import.meta.url)), "utf8", ); const bodyOf = (name: string): string => { @@ -296,43 +295,42 @@ test("nextLoadedCount steps min(L + max(1, batchSize), R) including the degenera // --------------------------------------------------------------------------- // Unit 2b — §B2 wiring pins (T9) + headerRow-only constraint (T10). // -// Source-parse tests over extensions/history/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 +// 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). -// -// Slice-3 adaptation note: upstream wires the growth trigger INLINE in -// moveUp/moveDown (no shared growLoadedWindowIfNeeded helper — that shape is -// dev-repo drift). The pins below assert the same AC contracts against the -// inline form. function methodBodyOf(name: string): string { const decl = selectorSource.indexOf(`private ${name}(`); - assert.ok(decl >= 0, `private ${name}() should exist in extensions/history/index.ts`); + 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 (moveUp carries the older-direction growth check); every other -// upward site and applyFilter stay pure. +// 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("shouldGrowWindow("), - "moveDown must evaluate the C2 trigger", + down.includes("this.growLoadedWindowIfNeeded()"), + "moveDown must route through the shared growth helper", ); + const up = methodBodyOf("moveUp"); assert.ok( - down.includes("nextLoadedCount("), - "moveDown must grow via nextLoadedCount", + up.includes("this.growLoadedWindowIfNeeded()"), + "moveUp must route through the shared growth helper (older-direction growth)", ); - const up = methodBodyOf("moveUp"); + const grow = methodBodyOf("growLoadedWindowIfNeeded"); assert.ok( - up.includes("shouldGrowWindow(") && up.includes("nextLoadedCount("), - "moveUp must carry the older-direction growth check", + 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( @@ -364,8 +362,8 @@ test("growth wiring appears in moveDown, moveUp, pageListDown, jumpToLast (AC-L1 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("shouldGrowWindow("); - assert.notEqual(growAt, -1, "moveDown must evaluate the C2 trigger"); + 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", @@ -394,19 +392,26 @@ test("growth arithmetic names only this.loadedCount and this.records.length (AC- const down = methodBodyOf("moveDown"); const downGrow = down.slice(0, down.indexOf("moveSelectedIndex(")); assert.ok( - downGrow.includes("shouldGrowWindow(") && - downGrow.includes("nextLoadedCount("), - "moveDown's growth region must run the trigger + one batch before the modulo", + 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("loadedCountForTarget(") && - pageGrow.includes("this.loadedCount") && + pageGrow.includes("this.loadedCount") && pageGrow.includes("this.records.length"), "pageListDown's catch-up must pass the unfiltered counts", ); @@ -491,7 +496,7 @@ test("the header keeps the position segment plus the loaded suffix on the existi 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"); + assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged"); }); // T14 — AC-L2-3 revision (user-directed 2026-09-08): a non-empty query diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts index 77d43586a..38e4ba139 100644 --- a/tests/history-legacy-migrate-v2.test.ts +++ b/tests/history-legacy-migrate-v2.test.ts @@ -4,6 +4,12 @@ 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); + function makeDirs(): { root: string; agentDir: string } { const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-mig-")); @@ -110,54 +116,7 @@ test("malformed v1 jsonl lines are skipped, not fatal", () => { assert.deepEqual(fileTexts(globalSeedPath(root)), ["good"]); }); -// node:test has no test.skipIf (Bun-ism): root skips via the options -// object — chmod 000 is invisible to the superuser. -const sealedLegacyTest = (name: string, fn: () => void) => - test( - name, - { skip: process.getuid?.() === 0 ? "requires non-root" : false }, - fn, - ); - -// chmod-based failure injection is also invisible to the superuser. -const seedFailureTest = (name: string, fn: () => void) => - test( - name, - { skip: process.getuid?.() === 0 ? "requires non-root" : false }, - fn, - ); - -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", () => { diff --git a/tests/history-max-results-cap.test.ts b/tests/history-max-results-cap.test.ts index fb319c8ec..938087c0e 100644 --- a/tests/history-max-results-cap.test.ts +++ b/tests/history-max-results-cap.test.ts @@ -1,19 +1,15 @@ 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 { fileURLToPath } from "node:url"; 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 import (no pi-tui graph) plus a source-parse - * pin on the selector-helpers slice sites. - * - * The dev suite's openHistorySelector body pin (no slice() on the load - * path) covers the slice-3 selector wiring in extensions/history/index.ts - * and ports with that slice — index.ts stays at its slice-1 surface here. + * path never snapshots. Pure .mjs import (no pi-tui graph) plus a + * source-parse pin on the load path (command-registration pattern). */ interface CapRecord { @@ -55,19 +51,36 @@ test("T29 (AC-S5-1): filtered-query slice caps at the raised 10000", () => { assert.ok(result.every((r) => r.searchText.includes("match"))); }); -// T30 — AC-S5-2: output-cap-only semantics (source-parse). Selector-helpers -// reads the constant at exactly the two sanctioned filterPrompts slice -// sites — no other cap exists in the helper module. +// 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 helperSource = fs.readFileSync( - fileURLToPath( - new URL("../extensions/history/selector-helpers.ts", import.meta.url), - ), +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", ); -test("T30 (AC-S5-2): filterPrompts hosts exactly the two sanctioned cap slice sites", () => { - const sliceSites = helperSource.split("slice(0, MAX_RESULTS)").length - 1; +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, diff --git a/tests/history-multi-reader.test.ts b/tests/history-multi-reader.test.ts index c82ebcbc7..0c9434ab8 100644 --- a/tests/history-multi-reader.test.ts +++ b/tests/history-multi-reader.test.ts @@ -3,201 +3,170 @@ 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"; import { - appendSessionCapture, - ensureRegistryEntry, - openSessionWriter, - parseStoreLine, - projectDir, + drainGlobal, + drainProject, + globalSeedPath, projectHash, - registryPath, - sessionFilePath, + seedFilePath, } from "../extensions/history/store.ts"; -// Slice-1 concurrency/recovery coverage. The dev-suite multi-reader drain -// scenarios are re-expressed against the slice-1 surface (per-instance -// writers, parseStoreLine, atomic writes): parallel writers on one project -// dir, torn-line tolerance, and same-target atomic-write collisions. The -// drain/read ordering scenarios themselves arrive with the slice-2 reader. - -const PROJECT_A = "/pi-history-test/project-a"; -const PROJECT_B = "/pi-history-test/project-b"; +const PROJECT_A = "/Users/admin/Dev/pi/pi-history"; +const PROJECT_B = "/Users/admin/Dev/github/pi"; function makeRoot(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-multi-reader-")); + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-reader-")); } -function storedTexts(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 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", + ); } -// --- parallel writers --- +function setMtime(file: string, ms: number): void { + fs.utimesSync(file, new Date(ms), new Date(ms)); +} -test("growth from a concurrent instance is visible on the next read", () => { +test("empty project dir drains nothing", () => { const root = makeRoot(); - const a = openSessionWriter(root, PROJECT_A, "inst-a"); - appendSessionCapture(a, "first"); - const dirA = projectDir(root, PROJECT_A); - assert.deepEqual(fs.readdirSync(dirA), ["inst-a.jsonl"]); - - // A second pi instance grows the SAME project dir through its OWN file; - // neither writer reads or rewrites the other's bytes. - const b = openSessionWriter(root, PROJECT_A, "inst-b"); - appendSessionCapture(b, "from-other-instance"); - - assert.deepEqual(fs.readdirSync(dirA).sort(), [ - "inst-a.jsonl", - "inst-b.jsonl", - ]); - assert.deepEqual(storedTexts(sessionFilePath(root, PROJECT_A, "inst-a")), [ - "first", - ]); - assert.deepEqual(storedTexts(sessionFilePath(root, PROJECT_A, "inst-b")), [ - "from-other-instance", + assert.deepEqual(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.equal(a.lineCount, 1); - assert.equal(b.lineCount, 1); + assert.deepEqual(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(drainProject(root, PROJECT_A), ["b2", "b1", "a2", "a1"]); }); -test("interleaved captures from multiple writers never clobber each other", () => { +test("duplicates across files keep only the newest occurrence", () => { const root = makeRoot(); - const writers = [ - openSessionWriter(root, PROJECT_A, "w0"), - openSessionWriter(root, PROJECT_A, "w1"), - openSessionWriter(root, PROJECT_A, "w2"), - ]; - for (let i = 0; i < 10; i++) { - for (let w = 0; w < writers.length; w++) { - appendSessionCapture(writers[w], `w${w}-line-${i}`); - } - } - const dir = projectDir(root, PROJECT_A); - assert.deepEqual(fs.readdirSync(dir).sort(), [ - "w0.jsonl", - "w1.jsonl", - "w2.jsonl", + 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(drainProject(root, PROJECT_A), [ + "only-new", + "shared", + "only-old", ]); - for (let w = 0; w < writers.length; w++) { - assert.equal(writers[w].lineCount, 10); - assert.deepEqual( - storedTexts(writers[w].filePath), - Array.from({ length: 10 }, (_, i) => `w${w}-line-${i}`), - ); - } }); -test("a high-volume burst on one writer keeps every line, in order", () => { +test("case-insensitive identity: DUPLICATE matches duplicate", () => { const root = makeRoot(); - const writer = openSessionWriter(root, PROJECT_A, "burst"); - const expected: string[] = []; - for (let i = 0; i < 100; i++) { - const text = `burst-${i}`; - expected.push(text); - appendSessionCapture(writer, text, 1000 + i); - } - assert.equal(writer.lineCount, 100); - const lines = fs.readFileSync(writer.filePath, "utf8").trim().split("\n"); - assert.equal(lines.length, 100); - const parsed = lines.map((l) => JSON.parse(l) as { text: string; ts: number }); - assert.deepEqual( - parsed.map((e) => e.text), - expected, - ); - assert.equal(parsed[42].ts, 1042); + 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 = drainProject(root, PROJECT_A); + assert.equal(drained.length, 1); + assert.equal(drained[0], "DUPLICATE"); }); -// --- torn-line / crash recovery --- - -test("torn and malformed lines parse to null (crash garbage never resurfaces)", () => { - // A torn final line (process died mid-write) is a truncated JSON doc. - const torn = JSON.stringify({ v: 1, text: "survivor" }).slice(0, 12); - const malformed: string[] = [ - "", - "{torn", - torn, - "not json at all", - JSON.stringify([]), - JSON.stringify("scalar"), - JSON.stringify(null), - JSON.stringify({ v: 1 }), - JSON.stringify({ text: 42 }), - JSON.stringify({ text: " " }), - ]; - for (const line of malformed) { - assert.equal(parseStoreLine(line), null, JSON.stringify(line)); - } - // Valid lines keep parsing: absent v defaults to 1, ts/v flow through. - assert.deepEqual(parseStoreLine(JSON.stringify({ v: 1, text: "survivor" })), { - v: 1, - text: "survivor", - }); - assert.deepEqual(parseStoreLine(JSON.stringify({ text: "y" })), { - v: 1, - text: "y", - }); - assert.deepEqual(parseStoreLine(JSON.stringify({ v: 2, text: "x", ts: 7 })), { - v: 2, - text: "x", - ts: 7, - }); +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 = 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 = drainProject(root, PROJECT_A); + assert.deepEqual(drained, ["seeded-new", "seeded-old"]); }); -test("a torn final line is tolerated: skipped by readers, later appends continue", () => { +test("malformed lines are skipped", () => { const root = makeRoot(); - const writer = openSessionWriter(root, PROJECT_A, "torn"); - appendSessionCapture(writer, "before-crash"); - // Crash mid-write: a partial line lands WITHOUT its trailing newline. - fs.appendFileSync(writer.filePath, `{"v":1,"text":"tor`, "utf8"); - // parseStoreLine skips the torn tail instead of throwing... - assert.equal(parseStoreLine('{"v":1,"text":"tor'), null); - // ...and the instance keeps capturing. The first append after a - // newline-less torn tail merges with the fragment (one accepted lost - // entry — the same crash window the design documents for lost writes); - // the next full line parses cleanly again. - appendSessionCapture(writer, "after-crash"); - appendSessionCapture(writer, "after-crash-2"); - assert.equal(writer.lineCount, 3); - const lines = fs.readFileSync(writer.filePath, "utf8").trim().split("\n"); - assert.equal(lines.length, 3); - assert.equal((JSON.parse(lines[0]) as { text: string }).text, "before-crash"); - assert.equal(parseStoreLine(lines[1]), null); // torn fragment + merged entry - assert.equal( - (JSON.parse(lines[2]) as { text: string }).text, - "after-crash-2", + 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(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 = drainGlobal(root); + assert.deepEqual(drained, ["a-newest", "a-oldest", "b-mid", "legacy-oldest"]); }); -// --- atomic-write collisions --- +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(drainGlobal(root), ["shared-prompt", "b-only"]); +}); -test("rapid same-target atomic writes leave one valid document and no staging files", () => { +test("growth from a concurrent instance is visible on the next drain", () => { const root = makeRoot(); - const target = path.join(root, "shared-state.json"); - for (let i = 0; i < 25; i++) { - assert.equal(writeJsonAtomic(target, { writer: i }), true); - } - const final = JSON.parse(fs.readFileSync(target, "utf8")) as { - writer: number; - }; - assert.ok(final.writer >= 0 && final.writer <= 24); - const leftovers = fs.readdirSync(root).filter((f) => f.includes(".tmp-")); - assert.deepEqual(leftovers, []); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "s1.jsonl"), ["first"]); + assert.deepEqual(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(drainProject(root, PROJECT_A), [ + "from-other-instance", + "first", + ]); }); -test("interleaved registry updates from two instances keep both entries", () => { +test("global drain on a fresh root without a projects dir is empty", () => { const root = makeRoot(); - for (let i = 0; i < 3; i++) { - ensureRegistryEntry(root, PROJECT_A); - ensureRegistryEntry(root, PROJECT_B); - } - const raw = JSON.parse( - fs.readFileSync(registryPath(root), "utf8"), - ) as Record; - assert.equal(Object.keys(raw).length, 2); - assert.equal(raw[projectHash(PROJECT_A)], PROJECT_A); - assert.equal(raw[projectHash(PROJECT_B)], PROJECT_B); + assert.deepEqual(drainGlobal(root), []); }); diff --git a/tests/history-openflow-integration.test.ts b/tests/history-openflow-integration.test.ts index 1737f887a..37c654672 100644 --- a/tests/history-openflow-integration.test.ts +++ b/tests/history-openflow-integration.test.ts @@ -1,14 +1,14 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; 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 extensions/history/index.ts. - * NEVER import it — it pulls the pi-tui runtime graph (design §D3). The - * wiring is pinned by source-parse (command-registration pattern); loader - * behavior uses fs-only fixtures under the OS temp dir — NEVER the user's - * real ~/.pi/agent/history. + * 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( @@ -27,14 +27,14 @@ function openHistorySelectorBody(): string { /** 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 extensions/history/index.ts`); + 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: store-only drain wiring (source-parse, §I load-bearing shape). +// 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)", () => { @@ -49,8 +49,8 @@ test("T31 (AC-S6-1): the store drain is the entries source — no live transcrip "the live transcript merge is GONE from the open flow (user-directed store-only scopes)", ); assert.ok( - body.indexOf("if (entries.length === 0)") >= 0, - "the PR-branch empty guard stands: no history warns instead of opening an empty overlay", + body.indexOf("if (entries.length === 0)") === -1, + "the empty guard is gone — the selector always opens", ); }); @@ -73,6 +73,11 @@ test("T31 (AC-S6-1): the three command-registration pins hold beside the swap", 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", + ); }); // --------------------------------------------------------------------------- @@ -125,7 +130,6 @@ test("T33 (AC-S6-3): loaded segment present, indexing segment removed", () => { "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;"), @@ -139,5 +143,5 @@ test("T33 (AC-S6-3): Change 2 structural pins still hold beside the third segmen 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"); + assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged"); }); diff --git a/tests/history-overlay-margin.test.ts b/tests/history-overlay-margin.test.ts new file mode 100644 index 000000000..816229f32 --- /dev/null +++ b/tests/history-overlay-margin.test.ts @@ -0,0 +1,95 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + editorOverlayMargin, + SIDEBAR_OVERLAY_PADDING, + SIDEBAR_RAIL_OVERLAY_MARGIN, +} from "../extensions/history/selector-helpers.ts"; + +function terminalWithState(state: unknown): object { + return { + [Symbol.for("gentle-pi.experimental-sidebar.state")]: state, + } as object; +} + +test("margin constant pins the gentle-shell rail geometry (RAIL_WIDTH 50 + GAP 3)", () => { + assert.equal(SIDEBAR_RAIL_OVERLAY_MARGIN, 53); +}); + +test("padding constant pins the user-directed 1-column breathing room", () => { + assert.equal(SIDEBAR_OVERLAY_PADDING, 1); +}); + +test("returns 0 for absent, primitive, or null terminals", () => { + assert.equal(editorOverlayMargin(undefined), 0); + assert.equal(editorOverlayMargin(null), 0); + assert.equal(editorOverlayMargin(42), 0); + assert.equal(editorOverlayMargin("terminal"), 0); +}); + +test("returns 0 when no sidebar state is stored on the terminal", () => { + assert.equal(editorOverlayMargin({}), 0); +}); + +test("returns 0 for malformed state shapes", () => { + assert.equal(editorOverlayMargin(terminalWithState(undefined)), 0); + assert.equal(editorOverlayMargin(terminalWithState(null)), 0); + assert.equal(editorOverlayMargin(terminalWithState("active")), 0); +}); + +test("returns 0 unless active is exactly true AND ownsHost is a function", () => { + assert.equal( + editorOverlayMargin(terminalWithState({ active: true })), + 0, + "active without ownsHost", + ); + assert.equal( + editorOverlayMargin( + terminalWithState({ active: false, ownsHost: () => true }), + ), + 0, + "inactive", + ); + assert.equal( + editorOverlayMargin(terminalWithState({ active: 1, ownsHost: () => true })), + 0, + "non-boolean truthy active", + ); + assert.equal( + editorOverlayMargin( + terminalWithState({ active: true, ownsHost: "not-a-function" }), + ), + 0, + "non-function ownsHost", + ); +}); + +test("returns the geometry margin plus padding only while the sidebar owns the host", () => { + assert.equal( + editorOverlayMargin( + terminalWithState({ active: true, ownsHost: () => true }), + ), + 54, + ); + assert.equal( + editorOverlayMargin( + terminalWithState({ active: true, ownsHost: () => false }), + ), + 0, + "state present but host not owned (regular mode / unpatched root)", + ); +}); + +test("a throwing ownsHost degrades to 0 instead of breaking the picker", () => { + assert.equal( + editorOverlayMargin( + terminalWithState({ + active: true, + ownsHost: () => { + throw new Error("boom"); + }, + }), + ), + 0, + ); +}); diff --git a/tests/history-preview-layout.test.ts b/tests/history-preview-layout.test.ts index 02509725c..c0b4191aa 100644 --- a/tests/history-preview-layout.test.ts +++ b/tests/history-preview-layout.test.ts @@ -1,11 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; -const sourcePath = fileURLToPath( - new URL("../extensions/history/index.ts", import.meta.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", () => { @@ -54,40 +53,3 @@ test("preview rows are bottom-padded so the panel shrinks from the bottom", () = "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 index 23235f8cd..7dc6aa0b3 100644 --- a/tests/history-registry.test.ts +++ b/tests/history-registry.test.ts @@ -1,68 +1,63 @@ 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"; -// Slice-1 port note: the dev suite asserted lookups through the dead -// `lookupCwd` export, which slice 1 drops. Every lookup assertion is -// re-expressed against the persisted registry.json content. - function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-registry-")); } -function readRegistryFile(root: string): Record { - return JSON.parse( - fs.readFileSync(registryPath(root), "utf8"), - ) as Record; -} - test("creates the registry with the first entry (idempotent)", () => { const root = makeRoot(); - const result = ensureRegistryEntry(root, "/pi-history-test/project-a"); - assert.deepEqual(result, { hash: "4be15ec687e9df85", created: true }); - ensureRegistryEntry(root, "/pi-history-test/project-a"); - assert.deepEqual(readRegistryFile(root), { - "4be15ec687e9df85": "/pi-history-test/project-a", + const result = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + assert.deepEqual(result, { hash: "28e0f06819c468cb", created: true }); + ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); + assert.deepEqual(raw, { + "28e0f06819c468cb": "/Users/admin/Dev/pi/pi-history", }); }); test("second project appends without touching the first", () => { const root = makeRoot(); - ensureRegistryEntry(root, "/pi-history-test/project-a"); - const b = ensureRegistryEntry(root, "/pi-history-test/project-b"); + ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + const b = ensureRegistryEntry(root, "/Users/admin/Dev/github/pi"); assert.equal(b.created, true); - const raw = readRegistryFile(root); + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); assert.equal(Object.keys(raw).length, 2); - assert.equal(raw[b.hash], "/pi-history-test/project-b"); + assert.equal(raw[b.hash], "/Users/admin/Dev/github/pi"); }); -test("registry.json maps known hashes and omits unknown ones", () => { +test("lookupCwd resolves known hashes and null for unknown", () => { const root = makeRoot(); - const { hash } = ensureRegistryEntry(root, "/pi-history-test/project-a"); - const raw = readRegistryFile(root); - assert.equal(raw[hash], "/pi-history-test/project-a"); - assert.equal(raw["0000000000000000"], undefined); - // A fresh root has no registry file until its first entry lands. - assert.equal(fs.existsSync(registryPath(makeRoot())), false); + const { hash } = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + assert.equal(lookupCwd(root, hash), "/Users/admin/Dev/pi/pi-history"); + 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(registryPath(root), "{not-json", "utf8"); - const result = ensureRegistryEntry(root, "/pi-history-test/project-a"); + fs.writeFileSync(path.join(root, "registry.json"), "{not-json", "utf8"); + assert.equal(lookupCwd(root, "28e0f06819c468cb"), null); + const result = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); assert.equal(result.created, true); - // The corrupt content was discarded (fail-open to empty), so the rebuilt - // registry contains exactly the new entry and nothing else. - assert.deepEqual(readRegistryFile(root), { - "4be15ec687e9df85": "/pi-history-test/project-a", + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); + assert.deepEqual(raw, { + "28e0f06819c468cb": "/Users/admin/Dev/pi/pi-history", }); }); @@ -76,7 +71,7 @@ test("no leftover tmp files after writes", () => { test("hash collision re-keys the existing occupant; the new cwd keeps the short hash", () => { const root = makeRoot(); - const cwd = "/pi-history-test/project-a"; + const cwd = "/Users/admin/Dev/pi/pi-history"; const hash = projectHash(cwd); // Simulate a collision: the short hash is pre-mapped to a different cwd. fs.mkdirSync(root, { recursive: true }); @@ -87,57 +82,30 @@ test("hash collision re-keys the existing occupant; the new cwd keeps the short ); const result = ensureRegistryEntry(root, cwd); assert.deepEqual(result, { hash, created: true }); - const raw = readRegistryFile(root); + 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"); -}); - -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 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); + assert.equal(lookupCwd(root, hash), cwd); + assert.equal(lookupCwd(root, longKeys[0]), "/some/other/project"); }); 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, and the next entry rebuilds a valid object-mapped - // registry around itself. + // 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 = "/pi-history-test/project-a"; + const cwd = "/Users/admin/Dev/pi/pi-history"; 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 }); - assert.deepEqual(readRegistryFile(root), { [projectHash(cwd)]: cwd }); + 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 index 11c50f2da..66e5c072b 100644 --- a/tests/history-scope-delete.test.ts +++ b/tests/history-scope-delete.test.ts @@ -4,20 +4,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { - appendSessionCapture, deleteFromGlobal, deleteFromProject, globalSeedPath, - openSessionWriter, projectHash, } 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); -// Scope delete (design v2): sweepFiles' atomic per-file rewrite semantics -// plus the project/global delete entry points. Synthetic project cwds — -// never real directories on any machine (identity only feeds projectHash; -// the fixtures live in tmpdirs and never touch the user's real ~/.pi). -const PROJECT_A = "/fixtures/pi-history/project-a"; -const PROJECT_B = "/fixtures/pi-history/project-b"; + +const PROJECT_A = "/Users/admin/Dev/pi/pi-history"; +const PROJECT_B = "/Users/admin/Dev/github/pi"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-del-")); @@ -106,10 +106,10 @@ test("delete leaves no tmp files behind", () => { assert.deepEqual(leftovers, []); }); -// node:test has no test.skipIf (Bun-ism): root skips via the options object. -test( +const sealedFileTest = skipIf(process.getuid?.() === 0); + +sealedFileTest( "an unreadable store file (chmod 000) is skipped; readable copies still swept", - { skip: process.getuid?.() === 0 ? "requires non-root" : false }, () => { const root = makeRoot(); const dir = path.join(root, "projects", projectHash(PROJECT_A)); @@ -140,24 +140,3 @@ test("a file whose every line is deleted becomes empty (kept, not removed)", () assert.equal(fs.existsSync(file), true); assert.equal(fs.readFileSync(file, "utf8"), ""); }); - -// Active-writer safety (design v2): the sweep rewrites the writer's own -// file IN PLACE (tmp + rename, never a removal — emptied files are kept), -// so a concurrently live writer keeps working by path: its next capture -// appends into the swept file, and the surviving + new lines parse fine. -test("a sweep with a concurrent live writer keeps the writer's file functional", () => { - const root = makeRoot(); - const state = openSessionWriter(root, PROJECT_A, "instance-1"); - appendSessionCapture(state, "victim"); - appendSessionCapture(state, "keeper"); - - const result = deleteFromProject(root, PROJECT_A, "victim"); - assert.deepEqual(result, { filesAffected: 1, removed: 1 }); - - // The same writer state keeps appending after the sweep — the file was - // rewritten under the writer's feet, not removed. - appendSessionCapture(state, "after-delete"); - assert.equal(state.lineCount, 3); - assert.equal(fs.existsSync(state.filePath), true); - assert.deepEqual(fileTexts(state.filePath), ["keeper", "after-delete"]); -}); diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts index d8126f254..24ca210cf 100644 --- a/tests/history-seed-bootstrap.test.ts +++ b/tests/history-seed-bootstrap.test.ts @@ -8,12 +8,14 @@ import { projectHash, seedFilePath, } 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); -// Fake project cwd (never created on disk): projectHash falls back to -// raw-string hashing for nonexistent paths, and the transcript dirName -// encoding derives from the same string. -const CWD = "/pi-history-test/seed-project"; -const DIR = "--pi-history-test-seed-project--"; + +const CWD = "/Users/admin/Dev/pi/pi-history"; function makeDirs(): { root: string; sessionsRoot: string } { const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-seed-")); @@ -78,7 +80,7 @@ test("no sessions and no project dir: bootstrap seeds nothing", () => { test("empty project dir bootstraps from the project's transcripts", () => { const { root, sessionsRoot } = makeDirs(); - writeSession(sessionsRoot, DIR, "s1.jsonl", [ + writeSession(sessionsRoot, `--Users-admin-Dev-pi-pi-history--`, "s1.jsonl", [ "real prompt", "/compact", " ", @@ -92,7 +94,9 @@ test("empty project dir bootstraps from the project's transcripts", () => { test("only the project's own session dir is scanned", () => { const { root, sessionsRoot } = makeDirs(); - writeSession(sessionsRoot, DIR, "s1.jsonl", ["mine"]); + writeSession(sessionsRoot, `--Users-admin-Dev-pi-pi-history--`, "s1.jsonl", [ + "mine", + ]); writeSession(sessionsRoot, "--Other--", "s2.jsonl", ["not mine"]); bootstrapProjectSeed(root, CWD, sessionsRoot, 500); assert.deepEqual(seedTexts(root), ["mine"]); @@ -102,7 +106,12 @@ 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, DIR, "big.jsonl", texts); + writeSession( + sessionsRoot, + `--Users-admin-Dev-pi-pi-history--`, + "big.jsonl", + texts, + ); const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); assert.deepEqual(result, { seeded: 500, ran: true }); const all = seedTexts(root); @@ -123,7 +132,12 @@ test("project dir already populated above target: no scan, seed untouched", () = ).join("\n")}\n`, "utf8", ); - const marker = writeSession(sessionsRoot, DIR, "s.jsonl", ["marker"]); + const marker = writeSession( + sessionsRoot, + `--Users-admin-Dev-pi-pi-history--`, + "s.jsonl", + ["marker"], + ); fs.utimesSync( marker, new Date(Date.now() + 5000), @@ -135,14 +149,7 @@ test("project dir already populated above target: no scan, seed untouched", () = assert.equal(fs.readFileSync(existing, "utf8").includes("marker"), false); }); -// node:test has no test.skipIf (Bun-ism): root skips via the options -// object — chmod 000 is invisible to the superuser. -const sealedStoreTest = (name: string, fn: () => void) => - test( - name, - { skip: process.getuid?.() === 0 ? "requires non-root" : false }, - fn, - ); +const sealedStoreTest = skipIf(process.getuid?.() === 0); sealedStoreTest( "an unreadable existing store file is skipped during counting; seeding still runs from transcripts", () => { @@ -156,7 +163,12 @@ sealedStoreTest( "utf8", ); fs.chmodSync(sealed, 0o000); - writeSession(sessionsRoot, DIR, "s1.jsonl", ["from transcript"]); + writeSession( + sessionsRoot, + `--Users-admin-Dev-pi-pi-history--`, + "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. diff --git a/tests/history-seed-regen.test.ts b/tests/history-seed-regen.test.ts index e4e16539e..289884f4c 100644 --- a/tests/history-seed-regen.test.ts +++ b/tests/history-seed-regen.test.ts @@ -5,11 +5,8 @@ import os from "node:os"; import path from "node:path"; import { bootstrapProjectSeed, seedFilePath } from "../extensions/history/store.ts"; -// Fake project cwd (never created on disk): projectHash falls back to -// raw-string hashing for nonexistent paths, and the transcript dirName -// encoding derives from the same string. -const CWD = "/pi-history-test/seed-regen-project"; -const DIR = "--pi-history-test-seed-regen-project--"; +const CWD = "/Users/admin/Dev/pi/pi-history"; +const DIR = `--Users-admin-Dev-pi-pi-history--`; function setup() { const base = fs.mkdtempSync(path.join(os.tmpdir(), "seed2-")); diff --git a/tests/history-session-scan-directory.test.ts b/tests/history-session-scan-directory.test.ts index d868b5073..8f72698b4 100644 --- a/tests/history-session-scan-directory.test.ts +++ b/tests/history-session-scan-directory.test.ts @@ -4,6 +4,12 @@ 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); + /** * WU1b-carried T7 (AC-S1-7): the one-level directory exclusion matrix. The @@ -57,14 +63,7 @@ test("one-level scan rule: only top-level jsonl of cwd dirs; nested payloads, su } }); -// node:test has no test.skipIf (Bun-ism): root skips via the options -// object — chmod 000 is invisible to the superuser. -const sealedDirTest = (name: string, fn: () => void) => - test( - name, - { skip: process.getuid?.() === 0 ? "requires non-root" : false }, - fn, - ); +const sealedDirTest = skipIf(process.getuid?.() === 0); sealedDirTest( "an unreadable child dir (chmod 000) is skipped; sibling dirs still list", () => { diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index e384379d6..e0fbda75b 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -5,17 +5,15 @@ import os from "node:os"; import path from "node:path"; import { appendSessionCapture, - openSessionWriter, projectHash, sessionFilePath, } from "../extensions/history/store.ts"; -import promptHistoryExtension from "../extensions/history/index.ts"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); } -const CWD = "/pi-history-test/project-a"; +const CWD = "/Users/admin/Dev/pi/pi-history"; function fileTexts(file: string): string[] { return fs @@ -25,10 +23,6 @@ function fileTexts(file: string): string[] { .map((l) => (JSON.parse(l) as { text: string }).text); } -function openWriterForTest(root: string, instanceId: string) { - return openSessionWriter(root, CWD, instanceId); -} - test("no file is created until the first capture", () => { const root = makeRoot(); const state = openWriterForTest(root, "sess-1"); @@ -88,36 +82,9 @@ test("two writers own separate files in the same project dir", () => { assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); }); -test("the extension entry registers exactly the final wiring surface", () => { - // Module load must stay side-effect free (importing index.ts parses the - // whole graph without touching the real ~/.pi store root). Wiring as of - // slice 6 (final): before_agent_start capture, session_shutdown GC, - // tool_call overlay dismiss, the ctrl+shift+r shortcut, and the - // history command. - const registered: Array<[string, unknown]> = []; - const shortcuts: Array<[string, unknown]> = []; - const commands: Array<[string, unknown]> = []; - const pi = { - on: (event: string, handler: unknown) => { - registered.push([event, handler]); - }, - registerShortcut: (key: string, def: unknown) => { - shortcuts.push([key, def]); - }, - registerCommand: (name: string, def: unknown) => { - commands.push([name, def]); - }, - }; - promptHistoryExtension(pi as never); - assert.deepEqual( - registered.map(([event]) => event), - ["before_agent_start", "session_shutdown", "tool_call"], - ); - assert.deepEqual(shortcuts.map(([key]) => key), ["ctrl+shift+r"]); - assert.deepEqual(commands.map(([name]) => name), ["history"]); - // Handlers are callable but are NEVER invoked here: a real invocation - // would run getWriter() against the user's real ~/.pi/agent/history. - for (const [, handler] of registered) { - assert.equal(typeof handler, "function"); - } -}); +// Helper kept local: openWriter is the U3 surface under test. +import { openSessionWriter } from "../extensions/history/store.ts"; + +function openWriterForTest(root: string, instanceId: string) { + return openSessionWriter(root, CWD, instanceId); +} diff --git a/tests/history-store-paths.test.ts b/tests/history-store-paths.test.ts index a6e5be45a..431fe43b8 100644 --- a/tests/history-store-paths.test.ts +++ b/tests/history-store-paths.test.ts @@ -15,23 +15,21 @@ import { const ROOT = path.join(os.tmpdir(), "pi-history-test-root"); test("projectHash returns 16 lowercase hex chars", () => { - const hash = projectHash("/pi-history-test/project-a"); + const hash = projectHash("/Users/admin/Dev/pi/pi-history"); assert.match(hash, /^[0-9a-f]{16}$/); }); test("known vector: stable hash for a fixed path", () => { - // The literal exists on no machine, so every platform exercises the - // documented raw-string fallback: sha256(literal), first 16 hex chars. assert.equal( - projectHash("/pi-history-test/project-a"), - "4be15ec687e9df85", + projectHash("/Users/admin/Dev/pi/pi-history"), + "28e0f06819c468cb", ); }); test("distinct paths produce distinct hashes", () => { assert.notEqual( - projectHash("/pi-history-test/project-a"), - projectHash("/pi-history-test/project-b"), + projectHash("/Users/admin/Dev/pi/pi-history"), + projectHash("/Users/admin/Dev/github/pi"), ); }); @@ -56,7 +54,7 @@ test("nonexistent path falls back to hashing the raw string (no throw)", () => { }); test("path derivations compose under the root", () => { - const cwd = "/pi-history-test/project-a"; + const cwd = "/Users/admin/Dev/pi/pi-history"; const hash = projectHash(cwd); assert.equal(projectDir(ROOT, cwd), path.join(ROOT, "projects", hash)); assert.equal( @@ -72,8 +70,8 @@ test("path derivations compose under the root", () => { }); test("two cwds map to sibling project dirs", () => { - const a = projectDir(ROOT, "/pi-history-test/project-a"); - const b = projectDir(ROOT, "/pi-history-test/project-b"); + const a = projectDir(ROOT, "/Users/admin/Dev/pi/pi-history"); + const b = projectDir(ROOT, "/Users/admin/Dev/github/pi"); 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 index fbb4a33b7..5bae67d79 100644 --- a/tests/history-wheel-mouse.test.ts +++ b/tests/history-wheel-mouse.test.ts @@ -1,23 +1,24 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; // 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: +// Source-parse structural pins on src/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 +// drag stay host-owned) and the 12-entry dispatch table gains no 13th 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; +// construction (see tmp/c2u4-qa-prechange-record.md); // - 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 @@ -32,7 +33,7 @@ const selectorSource = fs.readFileSync( "utf8", ); -// T13 — AC-L6-1: wheel-only override + no extra dispatch entry. +// T13 — AC-L6-1: wheel-only override + no 13th dispatch entry. test("handleMouse override is wheel-only and the dispatch table keeps 12 entries (AC-L6-1)", () => { const decl = selectorSource.indexOf("override handleMouse("); @@ -142,7 +143,7 @@ test("region constants 5-14 / 17-26 route the y comparisons (AC-L6-3)", () => { const body = selectorSource.slice(decl, end); assert.ok( - body.includes("event.y >= LIST_WHEEL_Y_FIRST") && + body.includes("event.y >= this.listWheelFirstRow") && body.includes("event.y <= LIST_WHEEL_Y_LAST"), "the list branch must compare y against the list band", ); @@ -169,7 +170,7 @@ test("list wheel routes sign-clamped steps through moveDown/moveUp (AC-L6-4)", ( "delta must default an absent wheelDelta to 0", ); - const listStart = body.indexOf("if (event.y >= LIST_WHEEL_Y_FIRST"); + const listStart = body.indexOf("if (event.y >= this.listWheelFirstRow"); const listEnd = body.indexOf("} else if (", listStart); assert.ok( listStart >= 0 && listEnd > listStart, From e5746ec9bf8dd84d215781e4acdded24d8af86fa Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:50:54 -0300 Subject: [PATCH 13/49] refactor(history): extract shared header counts helper Sync of pi-history a1c13b9: headerCountsText() now serves the inline and stacked header branches; no behavior change. --- extensions/history/index.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 83fa91ab8..15e4f7181 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -427,6 +427,19 @@ class PromptHistorySelector extends Container implements Focusable { this.rebuildListWithWidth(this.lastWidth); } + /** Styled title + position + loaded-counts prefix shared by the inline and stacked header layouts. */ + private headerCountsText( + titleText: string, + positionText: string, + loadedText: string, + ): string { + return ( + this.theme.fg("accent", this.theme.bold(titleText)) + + this.theme.fg("dim", positionText) + + this.theme.fg("dim", loadedText) + ); + } + /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows (MAX_VISIBLE - 1 in compact mode). */ private rebuildListWithWidth(width: number): void { const count = this.filteredRecords.length; @@ -450,9 +463,7 @@ class PromptHistorySelector extends Container implements Focusable { this.headerMode = mode; if (mode === "inline") { this.headerRow.setText( - this.theme.fg("accent", this.theme.bold(titleText)) + - this.theme.fg("dim", positionText) + - this.theme.fg("dim", loadedText) + + this.headerCountsText(titleText, positionText, loadedText) + // Right-aligned scope radio: pad from plain-text lengths so the // radio ends flush at the header's last column at any width. " ".repeat(Math.max(1, width - leftWidth - radioText.length)) + @@ -464,9 +475,7 @@ class PromptHistorySelector extends Container implements Focusable { // Tablet: the spacer is deleted — the radio wraps to its own row // under the full counts line (user-directed paste, leading space). this.headerRow.setText( - this.theme.fg("accent", this.theme.bold(titleText)) + - this.theme.fg("dim", positionText) + - this.theme.fg("dim", loadedText), + this.headerCountsText(titleText, positionText, loadedText), ); this.headerLine2.setText(` ${this.theme.fg("dim", radioText)}`); this.headerLine3.setText(""); From fdef2fc2cf3c4a6766395ca821754b41bc172bf8 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:06:18 -0300 Subject: [PATCH 14/49] fix(history): satisfy upstream typecheck gate - import ExtensionCommandContext (the real pi-coding-agent export) instead of the shim-only ShortcutContext name; ctx params take Pick - skipIf shim casts its callback to TestFn's return type so node's test options overload typechecks (6 sealed-file test files) --- extensions/history/index.ts | 6 +++--- tests/history-drain-order.test.ts | 6 +++++- tests/history-gc.test.ts | 6 +++++- tests/history-legacy-migrate-v2.test.ts | 6 +++++- tests/history-scope-delete.test.ts | 6 +++++- tests/history-seed-bootstrap.test.ts | 6 +++++- tests/history-session-scan-directory.test.ts | 6 +++++- 7 files changed, 33 insertions(+), 9 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 15e4f7181..cf1ff0036 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; import { DynamicBorder, type ExtensionAPI, - type ShortcutContext, + type ExtensionCommandContext, type Theme, } from "@earendil-works/pi-coding-agent"; import { @@ -949,7 +949,7 @@ function createPromptHistorySelectorFactory( } async function runPromptHistorySelection( - ctx: ShortcutContext, + ctx: Pick, records: PromptRecord[], ): Promise { const historyGlobals: PiHistoryGlobals = globalThis as Record< @@ -1050,7 +1050,7 @@ function drainForScope(scope: HistoryScope): string[] { } async function openHistorySelector( - ctx: Pick, + ctx: Pick, ): Promise { // Store-only drain (user-directed): both scopes read the store files // symmetrically — no live transcript merge (the one-time seed bootstrap diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts index 0047dc4ed..e6d877066 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -14,7 +14,11 @@ import { const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); const CWD = "/Users/admin/Dev/pi/pi-history"; diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index ebd04c1e0..50064dbd9 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -12,7 +12,11 @@ import { const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); const CWD = "/Users/admin/Dev/pi/pi-history"; diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts index 38e4ba139..12b8a6d91 100644 --- a/tests/history-legacy-migrate-v2.test.ts +++ b/tests/history-legacy-migrate-v2.test.ts @@ -8,7 +8,11 @@ import { globalSeedPath, migrateLegacyStores } from "../extensions/history/store const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); function makeDirs(): { root: string; agentDir: string } { diff --git a/tests/history-scope-delete.test.ts b/tests/history-scope-delete.test.ts index 66e5c072b..1f9546568 100644 --- a/tests/history-scope-delete.test.ts +++ b/tests/history-scope-delete.test.ts @@ -13,7 +13,11 @@ import { const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); const PROJECT_A = "/Users/admin/Dev/pi/pi-history"; diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts index 24ca210cf..f8e42d197 100644 --- a/tests/history-seed-bootstrap.test.ts +++ b/tests/history-seed-bootstrap.test.ts @@ -12,7 +12,11 @@ import { const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); const CWD = "/Users/admin/Dev/pi/pi-history"; diff --git a/tests/history-session-scan-directory.test.ts b/tests/history-session-scan-directory.test.ts index 8f72698b4..01db61258 100644 --- a/tests/history-session-scan-directory.test.ts +++ b/tests/history-session-scan-directory.test.ts @@ -8,7 +8,11 @@ import { listSessionFiles } from "../extensions/history/session-scan.ts"; const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); /** From 26bd9b536a9472537e70982d93229826ec7e2d90 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:17 -0300 Subject: [PATCH 15/49] test(history): remove machine-specific fixed paths from test fixtures - replace hardcoded /Users/admin/Dev/pi/pi-history and /Users/admin/Dev/github/pi constants with synthetic /pi-history-fixtures/project-a|project-b literals - pin projectHash with a portable known vector (nonexistent path falls back to raw-string hashing), replacing the layout-dependent 28e0f06819c468cb digest - registry tests use mkdtemp project fixtures with relative assertions - update the 6 session-slug literals coupled to the old cwd constant - replace bun:test-only test.skipIf wrappers with node:test-compatible skip-option wrappers in gc/scope-delete/drain-order/seed-bootstrap - bring gc/scope-delete/drain-order/seed-bootstrap in line with the newer reviewed pi-history test versions (drift since the slices were cut) Verified: node --test tests/history-*.test.ts 188 pass / 0 fail Review: review-134310abd04cf6c9 (reliability lens, approved) --- tests/history-drain-hidden.test.ts | 2 +- tests/history-drain-order.test.ts | 16 +++-------- tests/history-gc.test.ts | 16 +++-------- tests/history-multi-reader.test.ts | 4 +-- tests/history-registry.test.ts | 41 ++++++++++++++++------------ tests/history-scope-delete.test.ts | 18 ++++-------- tests/history-seed-bootstrap.test.ts | 26 ++++++------------ tests/history-seed-regen.test.ts | 4 +-- tests/history-session-writer.test.ts | 2 +- tests/history-store-paths.test.ts | 28 +++++++++++-------- 10 files changed, 68 insertions(+), 89 deletions(-) diff --git a/tests/history-drain-hidden.test.ts b/tests/history-drain-hidden.test.ts index 58ac72064..6c37149a1 100644 --- a/tests/history-drain-hidden.test.ts +++ b/tests/history-drain-hidden.test.ts @@ -10,7 +10,7 @@ import { projectHash, } from "../extensions/history/store.ts"; -const CWD = "/Users/admin/Dev/pi/pi-history"; +const CWD = "/pi-history-fixtures/project-a"; function write(file: string, texts: string[], ts = 100): void { fs.mkdirSync(path.dirname(file), { recursive: true }); diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts index e6d877066..f5430dc80 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -10,18 +10,8 @@ import { globalSeedPath, projectHash, } 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, - ); - -const CWD = "/Users/admin/Dev/pi/pi-history"; +const CWD = "/pi-history-fixtures/project-a"; function writeTs(file: string, texts: string[], ts: number): void { fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -61,7 +51,9 @@ test("global drain puts the legacy seed last regardless of its fresh mtime", () assert.deepEqual(drainGlobal(root), ["fresh", "legacy-2", "legacy-1"]); }); -const sealedDrainTest = skipIf(process.getuid?.() === 0); +const isRoot = process.getuid?.() === 0; +const sealedDrainTest = (name: string, fn: () => unknown) => + test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedDrainTest( "an unreadable store file is skipped; the rest drain in the expected order", () => { diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index 50064dbd9..2256977f2 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -8,18 +8,8 @@ import { gcProjectDir, projectHash, } 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, - ); - -const CWD = "/Users/admin/Dev/pi/pi-history"; +const CWD = "/pi-history-fixtures/project-a"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-gc-")); @@ -143,7 +133,9 @@ test("compaction keeps the newest 10 files, merges the rest", () => { assert.equal(names.includes("h06.jsonl"), true); }); -const sealedGcTest = skipIf(process.getuid?.() === 0); +const isRoot = process.getuid?.() === 0; +const sealedGcTest = (name: string, fn: () => unknown) => + test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedGcTest( "compactProjectDir skips an unreadable file's content and compacts the readable entries", () => { diff --git a/tests/history-multi-reader.test.ts b/tests/history-multi-reader.test.ts index 0c9434ab8..64b4fc4c2 100644 --- a/tests/history-multi-reader.test.ts +++ b/tests/history-multi-reader.test.ts @@ -11,8 +11,8 @@ import { seedFilePath, } from "../extensions/history/store.ts"; -const PROJECT_A = "/Users/admin/Dev/pi/pi-history"; -const PROJECT_B = "/Users/admin/Dev/github/pi"; +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-")); diff --git a/tests/history-registry.test.ts b/tests/history-registry.test.ts index 7dc6aa0b3..7ca59d94f 100644 --- a/tests/history-registry.test.ts +++ b/tests/history-registry.test.ts @@ -14,35 +14,41 @@ 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 result = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); - assert.deepEqual(result, { hash: "28e0f06819c468cb", created: true }); - ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + 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, { - "28e0f06819c468cb": "/Users/admin/Dev/pi/pi-history", - }); + assert.deepEqual(raw, { [projectHash(cwd)]: cwd }); }); test("second project appends without touching the first", () => { const root = makeRoot(); - ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); - const b = ensureRegistryEntry(root, "/Users/admin/Dev/github/pi"); + 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], "/Users/admin/Dev/github/pi"); + assert.equal(raw[b.hash], cwdB); }); test("lookupCwd resolves known hashes and null for unknown", () => { const root = makeRoot(); - const { hash } = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); - assert.equal(lookupCwd(root, hash), "/Users/admin/Dev/pi/pi-history"); + 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); }); @@ -50,15 +56,14 @@ test("lookupCwd resolves known hashes and null for unknown", () => { 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, "28e0f06819c468cb"), null); - const result = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + 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, { - "28e0f06819c468cb": "/Users/admin/Dev/pi/pi-history", - }); + assert.deepEqual(raw, { [projectHash(cwd)]: cwd }); }); test("no leftover tmp files after writes", () => { @@ -71,7 +76,7 @@ test("no leftover tmp files after writes", () => { test("hash collision re-keys the existing occupant; the new cwd keeps the short hash", () => { const root = makeRoot(); - const cwd = "/Users/admin/Dev/pi/pi-history"; + 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 }); @@ -96,7 +101,7 @@ test("wrong-shaped registry (array / scalar / null) fails open and is rebuilt on // 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 = "/Users/admin/Dev/pi/pi-history"; + const cwd = makeProject(); for (const shape of shapes) { const root = makeRoot(); fs.writeFileSync(registryPath(root), JSON.stringify(shape), "utf8"); diff --git a/tests/history-scope-delete.test.ts b/tests/history-scope-delete.test.ts index 1f9546568..9480efd8f 100644 --- a/tests/history-scope-delete.test.ts +++ b/tests/history-scope-delete.test.ts @@ -9,19 +9,9 @@ import { globalSeedPath, projectHash, } 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, - ); - -const PROJECT_A = "/Users/admin/Dev/pi/pi-history"; -const PROJECT_B = "/Users/admin/Dev/github/pi"; +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-")); @@ -110,7 +100,9 @@ test("delete leaves no tmp files behind", () => { assert.deepEqual(leftovers, []); }); -const sealedFileTest = skipIf(process.getuid?.() === 0); +const isRoot = process.getuid?.() === 0; +const sealedFileTest = (name: string, fn: () => unknown) => + test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedFileTest( "an unreadable store file (chmod 000) is skipped; readable copies still swept", diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts index f8e42d197..fcbfe64c8 100644 --- a/tests/history-seed-bootstrap.test.ts +++ b/tests/history-seed-bootstrap.test.ts @@ -8,18 +8,8 @@ import { projectHash, seedFilePath, } 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, - ); - -const CWD = "/Users/admin/Dev/pi/pi-history"; +const CWD = "/pi-history-fixtures/project-a"; function makeDirs(): { root: string; sessionsRoot: string } { const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-seed-")); @@ -84,7 +74,7 @@ test("no sessions and no project dir: bootstrap seeds nothing", () => { test("empty project dir bootstraps from the project's transcripts", () => { const { root, sessionsRoot } = makeDirs(); - writeSession(sessionsRoot, `--Users-admin-Dev-pi-pi-history--`, "s1.jsonl", [ + writeSession(sessionsRoot, `--pi-history-fixtures-project-a--`, "s1.jsonl", [ "real prompt", "/compact", " ", @@ -98,7 +88,7 @@ test("empty project dir bootstraps from the project's transcripts", () => { test("only the project's own session dir is scanned", () => { const { root, sessionsRoot } = makeDirs(); - writeSession(sessionsRoot, `--Users-admin-Dev-pi-pi-history--`, "s1.jsonl", [ + writeSession(sessionsRoot, `--pi-history-fixtures-project-a--`, "s1.jsonl", [ "mine", ]); writeSession(sessionsRoot, "--Other--", "s2.jsonl", ["not mine"]); @@ -112,7 +102,7 @@ test("caps at the target keeping the newest", () => { for (let i = 1; i <= 600; i++) texts.push(`p${i}`); writeSession( sessionsRoot, - `--Users-admin-Dev-pi-pi-history--`, + `--pi-history-fixtures-project-a--`, "big.jsonl", texts, ); @@ -138,7 +128,7 @@ test("project dir already populated above target: no scan, seed untouched", () = ); const marker = writeSession( sessionsRoot, - `--Users-admin-Dev-pi-pi-history--`, + `--pi-history-fixtures-project-a--`, "s.jsonl", ["marker"], ); @@ -153,7 +143,9 @@ test("project dir already populated above target: no scan, seed untouched", () = assert.equal(fs.readFileSync(existing, "utf8").includes("marker"), false); }); -const sealedStoreTest = skipIf(process.getuid?.() === 0); +const isRoot = process.getuid?.() === 0; +const sealedStoreTest = (name: string, fn: () => unknown) => + 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", () => { @@ -169,7 +161,7 @@ sealedStoreTest( fs.chmodSync(sealed, 0o000); writeSession( sessionsRoot, - `--Users-admin-Dev-pi-pi-history--`, + `--pi-history-fixtures-project-a--`, "s1.jsonl", ["from transcript"], ); diff --git a/tests/history-seed-regen.test.ts b/tests/history-seed-regen.test.ts index 289884f4c..cf8e7265e 100644 --- a/tests/history-seed-regen.test.ts +++ b/tests/history-seed-regen.test.ts @@ -5,8 +5,8 @@ import os from "node:os"; import path from "node:path"; import { bootstrapProjectSeed, seedFilePath } from "../extensions/history/store.ts"; -const CWD = "/Users/admin/Dev/pi/pi-history"; -const DIR = `--Users-admin-Dev-pi-pi-history--`; +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-")); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index e0fbda75b..e0c7ab819 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -13,7 +13,7 @@ function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); } -const CWD = "/Users/admin/Dev/pi/pi-history"; +const CWD = "/pi-history-fixtures/project-a"; function fileTexts(file: string): string[] { return fs diff --git a/tests/history-store-paths.test.ts b/tests/history-store-paths.test.ts index 431fe43b8..10dec14a0 100644 --- a/tests/history-store-paths.test.ts +++ b/tests/history-store-paths.test.ts @@ -14,22 +14,28 @@ import { 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", () => { - const hash = projectHash("/Users/admin/Dev/pi/pi-history"); - assert.match(hash, /^[0-9a-f]{16}$/); + assert.match(projectHash(makeProject("paths-shape-")), /^[0-9a-f]{16}$/); }); test("known vector: stable hash for a fixed path", () => { - assert.equal( - projectHash("/Users/admin/Dev/pi/pi-history"), - "28e0f06819c468cb", - ); + assert.equal(projectHash(KNOWN_VECTOR_INPUT), KNOWN_VECTOR_EXPECTED); }); test("distinct paths produce distinct hashes", () => { assert.notEqual( - projectHash("/Users/admin/Dev/pi/pi-history"), - projectHash("/Users/admin/Dev/github/pi"), + projectHash(makeProject("paths-distinct-a-")), + projectHash(makeProject("paths-distinct-b-")), ); }); @@ -54,7 +60,7 @@ test("nonexistent path falls back to hashing the raw string (no throw)", () => { }); test("path derivations compose under the root", () => { - const cwd = "/Users/admin/Dev/pi/pi-history"; + const cwd = makeProject("paths-compose-"); const hash = projectHash(cwd); assert.equal(projectDir(ROOT, cwd), path.join(ROOT, "projects", hash)); assert.equal( @@ -70,8 +76,8 @@ test("path derivations compose under the root", () => { }); test("two cwds map to sibling project dirs", () => { - const a = projectDir(ROOT, "/Users/admin/Dev/pi/pi-history"); - const b = projectDir(ROOT, "/Users/admin/Dev/github/pi"); + 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)); }); From c18271f15a3a66e77a725a24cd8879124d43a888 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:54:52 -0300 Subject: [PATCH 16/49] fix(test): match node:test TestFn callback type in skip wrappers The test.skipIf-style wrappers typed their callback as () => unknown, which is not assignable to node:test's TestFn (return void | Promise). Type the callback accordingly to clear the 4 new TS2345 diagnostics reported by the type gate. --- tests/history-drain-order.test.ts | 2 +- tests/history-gc.test.ts | 2 +- tests/history-scope-delete.test.ts | 2 +- tests/history-seed-bootstrap.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts index f5430dc80..aefa9743d 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -52,7 +52,7 @@ test("global drain puts the legacy seed last regardless of its fresh mtime", () }); const isRoot = process.getuid?.() === 0; -const sealedDrainTest = (name: string, fn: () => unknown) => +const sealedDrainTest = (name: string, fn: () => void | Promise) => test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedDrainTest( "an unreadable store file is skipped; the rest drain in the expected order", diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index 2256977f2..c1772e1e2 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -134,7 +134,7 @@ test("compaction keeps the newest 10 files, merges the rest", () => { }); const isRoot = process.getuid?.() === 0; -const sealedGcTest = (name: string, fn: () => unknown) => +const sealedGcTest = (name: string, fn: () => void | Promise) => test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedGcTest( "compactProjectDir skips an unreadable file's content and compacts the readable entries", diff --git a/tests/history-scope-delete.test.ts b/tests/history-scope-delete.test.ts index 9480efd8f..776667d3f 100644 --- a/tests/history-scope-delete.test.ts +++ b/tests/history-scope-delete.test.ts @@ -101,7 +101,7 @@ test("delete leaves no tmp files behind", () => { }); const isRoot = process.getuid?.() === 0; -const sealedFileTest = (name: string, fn: () => unknown) => +const sealedFileTest = (name: string, fn: () => void | Promise) => test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedFileTest( diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts index fcbfe64c8..d3e7d86d7 100644 --- a/tests/history-seed-bootstrap.test.ts +++ b/tests/history-seed-bootstrap.test.ts @@ -144,7 +144,7 @@ test("project dir already populated above target: no scan, seed untouched", () = }); const isRoot = process.getuid?.() === 0; -const sealedStoreTest = (name: string, fn: () => unknown) => +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", From 1c59b1103596c36219fd865f84cb122b4c0fb1cd Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:03:57 -0300 Subject: [PATCH 17/49] fix(history): restore review fixes clobbered by the upstream sync Sync 72b5adfe faithfully mirrored pi-history main, which never received the review fixes from the slice reviews (PR #819); the sync silently reverted them along with their test pins. Restore everything on the slice-06 PR branch, adapted to the current upstream-parity sources, with the test-hygiene commits (portable fixtures, skip wrappers) already cherry-picked in: - store: compact artifact name is pid-scoped (compact--.jsonl) so two concurrent instances can never rename onto the same file (silent compact loss) - store: migrateLegacyStores renames legacy sources to .imported only AFTER the global seed write succeeds - a failed write no longer strands entries with the one-shot gate blocking retry - store: ensureRegistryEntry returns an existing long-key mapping unchanged so collision assignments stay stable across calls - index: sanitizeForDisplay re-appends astral code points whole (String.fromCodePoint) - emoji no longer lose half their code point - index: FixedRowText.render pads by the SGR-stripped visible width - colored rows no longer fall short and leave ghost characters - index: writer init (migrate/registry/seed) is scheduled via setImmediate so bootstrap never runs on the first-prompt path - index: PRELOAD_BUFFER comment corrected to the constant's real value - tests: restore the GC crash-safety trio (atomic ordering, rm-failure tolerance, active-writer mid-compaction) plus the pid filename pin, the migration retry test, the registry stability test, the setImmediate scheduling pin, and the padding + astral pins - compactProjectDir stays exported for upstream parity (no knip gate configured); gcProjectDir remains the wired and tested entry point Gates: full history suite 196 pass / 0 fail under bun (node:test sources). No tsc/typecheck script exists in this repo; the suite run parses every changed file via node's type stripping. --- extensions/history/index.ts | 26 ++- extensions/history/store.ts | 42 ++-- tests/history-command-registration.test.ts | 21 ++ tests/history-gc.test.ts | 234 ++++++++++++++++++--- tests/history-legacy-migrate-v2.test.ts | 33 +++ tests/history-preview-layout.test.ts | 37 ++++ tests/history-registry.test.ts | 36 ++++ 7 files changed, 376 insertions(+), 53 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index cf1ff0036..f9f379a7c 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -64,8 +64,8 @@ import { const SHORTCUT = "ctrl+shift+r"; const MAX_VISIBLE = 10; const PREVIEW_ROWS = 10; -// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=2 -// fires growth as the cursor enters the final 2 loaded rows; BATCH_SIZE=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. @@ -130,7 +130,10 @@ function sanitizeForDisplay(text: string): string { } else if (cp >= 0x80 && cp < 0xa0) { out += `\\x${cp.toString(16).padStart(2, "0")}`; } else { - out += text[i]; + // 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 } @@ -193,7 +196,10 @@ class FixedRowText { : 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. - return [rendered + " ".repeat(Math.max(0, width - rendered.length))]; + // 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))]; } } @@ -1082,6 +1088,18 @@ function recordsFromEntries( export default function promptHistoryExtension(pi: ExtensionAPI) { // One writer per extension load; see getWriter() for the init order. + // Warm migrate/registry/seed OFF the first-prompt path: the scheduled + // init runs once, immediately after load. A prompt arriving earlier + // falls back to the synchronous lazy init in getWriter(), whose + // writerState guard makes whichever runs second a no-op — bootstrap + // work is never duplicated. + setImmediate(() => { + 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). // The local ExtensionAPI stub types handler args as unknown; narrow here. diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 5ccba43cb..e59825950 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -115,6 +115,11 @@ export function ensureRegistryEntry( 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 @@ -520,9 +525,10 @@ function writeSeedFileAtomic(seed: string, collected: StoreEntry[]): number { * 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; each - * source is renamed `.imported`, never deleted. Gated: an existing global - * seed means migration already ran. + * 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, @@ -537,15 +543,8 @@ export function migrateLegacyStores( const legacyArray = path.join(agentDir, "editor-history.json"); if (fs.existsSync(legacyArray)) { const texts = loadSharedHistory(legacyArray); - if (texts.length > 0) { - for (let i = texts.length - 1; i >= 0; i--) { - collected.push({ v: 1, text: texts[i] }); - } - } - try { - fs.renameSync(legacyArray, `${legacyArray}.imported`); - } catch { - // The seed write below is the source of truth; rename failure is benign. + for (let i = texts.length - 1; i >= 0; i--) { + collected.push({ v: 1, text: texts[i] }); } } @@ -553,16 +552,21 @@ export function migrateLegacyStores( const v1File = path.join(agentDir, "editor-history.jsonl"); if (fs.existsSync(v1File)) { collected.push(...readValidLines(v1File)); - try { - fs.renameSync(v1File, `${v1File}.imported`); - } catch { - // benign - } } 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 }; } @@ -769,7 +773,7 @@ export function gcProjectDir( /** * Merge all but the newest GC_KEEP_NEWEST files into one - * `compact-.jsonl` (chronological within the merged content). 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. */ @@ -800,7 +804,7 @@ function compactFiles(filesMtimeDesc: string[], keepNewest: number): GcResult { if (mergedLines.length === 0) return { compacted: false, merged: 0 }; const dir = path.dirname(toMerge[0]); - const compact = path.join(dir, `compact-${Date.now()}.jsonl`); + 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); diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts index 1bd615c9c..f37df90b7 100644 --- a/tests/history-command-registration.test.ts +++ b/tests/history-command-registration.test.ts @@ -61,3 +61,24 @@ test("in-UI hint describes multi-word AND substring matching, not fuzzy", () => "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-gc.test.ts b/tests/history-gc.test.ts index c1772e1e2..c7a9440b4 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -3,13 +3,19 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { - compactProjectDir, - gcProjectDir, - projectHash, -} from "../extensions/history/store.ts"; +import { gcProjectDir, projectHash } from "../extensions/history/store.ts"; -const CWD = "/pi-history-fixtures/project-a"; +// 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-")); @@ -49,6 +55,40 @@ function totalLines(dir: string): number { 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); @@ -81,8 +121,15 @@ test("file-count threshold merges the oldest files into one compact file", () => // 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); - const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); - assert.ok(compact); + // 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); }); @@ -105,9 +152,9 @@ test("line-count threshold triggers compaction too", () => { assert.equal(fs.readdirSync(dir).includes("g3.jsonl"), true); }); -test("compactProjectDir on a missing dir is a no-op", () => { +test("GC on a missing project dir is a no-op", () => { const root = makeRoot(); - const result = compactProjectDir(root, "/does/not/exist"); + const result = gcProjectDir(root, "/does/not/exist"); assert.deepEqual(result, { compacted: false, merged: 0 }); }); @@ -133,46 +180,41 @@ test("compaction keeps the newest 10 files, merges the rest", () => { assert.equal(names.includes("h06.jsonl"), true); }); -const isRoot = process.getuid?.() === 0; -const sealedGcTest = (name: string, fn: () => void | Promise) => - test(name, { skip: isRoot && "requires a non-root user" }, fn); -sealedGcTest( - "compactProjectDir skips an unreadable file's content and compacts the readable entries", +// 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 content hits the unreadable-skip branch. + // 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 = compactProjectDir(root, CWD, { keepNewest: 1 }); + 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 }); - const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); - if (compact === undefined) { - throw new Error("the compact file must exist"); - } - const compactTexts = fs - .readFileSync(path.join(dir, compact), "utf8") - .trim() - .split("\n") - .map((l) => (JSON.parse(l) as { text: string }).text); // Only the readable tail file's entries compacted; the sealed bytes // were skipped, never fatal. (writeFile names entries `${name}-${i}`.) - assert.deepEqual(compactTexts, [ + assert.deepEqual(compactTexts(dir), [ "readable-old.jsonl-0", "readable-old.jsonl-1", "readable-old.jsonl-2", "readable-old.jsonl-3", "readable-old.jsonl-4", ]); - // GC cache semantics: the tail originals (sealed one included) are + // Cleanup semantics: the tail originals (sealed one included) are // removed after the compact file lands — unlink needs no read access. - assert.equal(fs.readdirSync(dir).includes("sealed-old.jsonl"), false); + 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 @@ -185,3 +227,135 @@ sealedGcTest( } }, ); + +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-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts index 12b8a6d91..c9d665b46 100644 --- a/tests/history-legacy-migrate-v2.test.ts +++ b/tests/history-legacy-migrate-v2.test.ts @@ -120,6 +120,39 @@ test("malformed v1 jsonl lines are skipped, not fatal", () => { 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", diff --git a/tests/history-preview-layout.test.ts b/tests/history-preview-layout.test.ts index c0b4191aa..56abd381f 100644 --- a/tests/history-preview-layout.test.ts +++ b/tests/history-preview-layout.test.ts @@ -53,3 +53,40 @@ test("preview rows are bottom-padded so the panel shrinks from the bottom", () = "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 index 7ca59d94f..726ba0de4 100644 --- a/tests/history-registry.test.ts +++ b/tests/history-registry.test.ts @@ -1,5 +1,6 @@ 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"; @@ -96,6 +97,41 @@ test("hash collision re-keys the existing occupant; the new cwd keeps the short 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 From a22588fcf81747c4cf63b23739a979dbf04f8e4f Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:15:26 -0300 Subject: [PATCH 18/49] fix(history): satisfy upstream typecheck gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extensions/history/index.ts imported `ShortcutContext`, a type that only exists in the dev repo's @types shim — the real @earendil-works/pi-coding-agent exports `ExtensionCommandContext`, so the type gate added to main reports TS2305 on this branch's CI merge. Import `ExtensionCommandContext` and narrow both handler contexts to `Pick` (the only member they use), mirroring the fix already carried on the slice-6 branch. --- extensions/history/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index f9559b9d6..d82b413d9 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -11,7 +11,7 @@ import { homedir } from "node:os"; import { DynamicBorder, type ExtensionAPI, - type ShortcutContext, + type ExtensionCommandContext, type Theme, } from "@earendil-works/pi-coding-agent"; import { @@ -815,7 +815,7 @@ function createPromptHistorySelectorFactory( } async function runPromptHistorySelection( - ctx: ShortcutContext, + ctx: Pick, records: PromptRecord[], ): Promise { const historyGlobals: PiHistoryGlobals = globalThis as Record< @@ -876,7 +876,7 @@ function drainForScope(scope: HistoryScope): string[] { } async function openHistorySelector( - ctx: Pick, + ctx: Pick, ): Promise { // Store-only drain (user-directed): both scopes read the store files // symmetrically — no live transcript merge (the one-time seed bootstrap From 353a99f80e4ac2171f4bfc146484e55a3e05ce0c Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:18:17 -0300 Subject: [PATCH 19/49] fix(history): satisfy upstream typecheck gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extensions/history/index.ts imported `ShortcutContext`, a type that only exists in the dev repo's @types shim — the real @earendil-works/pi-coding-agent exports `ExtensionCommandContext`, so the type gate added to main reports TS2305 on this branch's CI merge. Import `ExtensionCommandContext` and narrow both handler contexts to `Pick` (the only member they use), mirroring the fix already carried on the slice-6 branch. --- extensions/history/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index b62963d39..4f5d08195 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -12,7 +12,7 @@ import { homedir } from "node:os"; import { DynamicBorder, type ExtensionAPI, - type ShortcutContext, + type ExtensionCommandContext, type Theme, } from "@earendil-works/pi-coding-agent"; import { @@ -823,7 +823,7 @@ function createPromptHistorySelectorFactory( } async function runPromptHistorySelection( - ctx: ShortcutContext, + ctx: Pick, records: PromptRecord[], ): Promise { const historyGlobals: PiHistoryGlobals = globalThis as Record< @@ -899,7 +899,7 @@ function drainForScope(scope: HistoryScope): string[] { } async function openHistorySelector( - ctx: Pick, + ctx: Pick, ): Promise { // Store-only drain (user-directed): both scopes read the store files // symmetrically — no live transcript merge (the one-time seed bootstrap From ea7e33b5f34fbe25cbbe0f6ee128f32d3b7beb62 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:20:33 -0300 Subject: [PATCH 20/49] fix(history): satisfy upstream typecheck gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extensions/history/index.ts imported `ShortcutContext`, a type that only exists in the dev repo's @types shim — the real @earendil-works/pi-coding-agent exports `ExtensionCommandContext`, so the type gate added to main reports TS2305 on this branch's CI merge. Import `ExtensionCommandContext` and narrow both handler contexts to `Pick` (the only member they use), mirroring the fix already carried on the slice-6 branch. --- extensions/history/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 424008b68..c63993cd1 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -12,7 +12,7 @@ import { homedir } from "node:os"; import { DynamicBorder, type ExtensionAPI, - type ShortcutContext, + type ExtensionCommandContext, type Theme, } from "@earendil-works/pi-coding-agent"; import { @@ -880,7 +880,7 @@ function createPromptHistorySelectorFactory( } async function runPromptHistorySelection( - ctx: ShortcutContext, + ctx: Pick, records: PromptRecord[], ): Promise { const historyGlobals: PiHistoryGlobals = globalThis as Record< @@ -956,7 +956,7 @@ function drainForScope(scope: HistoryScope): string[] { } async function openHistorySelector( - ctx: Pick, + ctx: Pick, ): Promise { // Store-only drain (user-directed): both scopes read the store files // symmetrically — no live transcript merge (the one-time seed bootstrap From 3f182b86e918238ca834b098885a3633e51e22a5 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:31:49 -0300 Subject: [PATCH 21/49] =?UTF-8?q?GentlePromptEditor=20now=20owns=20text=20?= =?UTF-8?q?selection=20natively:=20shift+home=20/=20shift+end=20anchored?= =?UTF-8?q?=20selection,=20alt+a=20select-all,=20and=20replace-on-key=20(b?= =?UTF-8?q?ackspace=20/=20delete=20/=20printable)=20with=20reverse-video?= =?UTF-8?q?=20highlight=20on=20the=20content=20rows=20and=20the=20"N=20cha?= =?UTF-8?q?rs=20selected"=20hint=20on=20the=20petal's=20bottom=20rule.=20T?= =?UTF-8?q?he=20engine=20(lib/selection-engine.ts)=20drives=20the=20editor?= =?UTF-8?q?'s=20own=20internals=20through=20the=20EditorInternals=20surfac?= =?UTF-8?q?e=20and=20degrades=20to=20pure=20passthrough=20if=20that=20surf?= =?UTF-8?q?ace=20drifts=20on=20a=20pi=20upgrade=20=E2=80=94=20selection=20?= =?UTF-8?q?features=20off,=20never=20a=20crash.=20handleInput=20dispatches?= =?UTF-8?q?=20selection=20keys=20in=20front=20of=20the=20petal's=20chain?= =?UTF-8?q?=20(Esc=20gates,=20idle-clear,=20autocomplete=20all=20preserved?= =?UTF-8?q?=20via=20handleInputNative).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the feature native to the petal, the pi-select-del factory composition (and the cross-extension focus handoff it required) becomes unnecessary for gentle setups. Tests: 6 new cases driving a real CustomEditor and the framed petal prompt end to end (selection, replace, collapse, zero-width no-op, highlight + hint, degraded passthrough). Gates: typecheck clean (no regressions), targeted suite 296 pass / 0 fail. --- extensions/gentle-shell.ts | 24 +- lib/selection-engine.ts | 420 +++++++++++++++++++++++++++++++++ tests/selection-engine.test.ts | 126 ++++++++++ 3 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 lib/selection-engine.ts create mode 100644 tests/selection-engine.test.ts 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/lib/selection-engine.ts b/lib/selection-engine.ts new file mode 100644 index 000000000..fc9d3cc73 --- /dev/null +++ b/lib/selection-engine.ts @@ -0,0 +1,420 @@ +import { decodePrintableKey } from "@earendil-works/pi-tui/dist/keys.js"; +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/selection-engine.test.ts b/tests/selection-engine.test.ts new file mode 100644 index 000000000..9b85bfa6b --- /dev/null +++ b/tests/selection-engine.test.ts @@ -0,0 +1,126 @@ +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"; + +// 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); +}); From 863a57c615e011d8ef2c6260f26becc338c6d234 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:51:54 -0300 Subject: [PATCH 22/49] fix: vendor decodePrintableKey so the packed runtime loads selection-engine The packed runtime resolves deep pi-tui subpath imports by joining onto the resolved entry file, so `@earendil-works/pi-tui/dist/keys.js` loaded as `dist/index.js/dist/keys.js` and broke every extension load that reaches lib/selection-engine.ts (CI: asset-installation-runtime.test.ts expected zero extension errors). Root package imports are proven safe in that loader. decodeKittyPrintable is exported from the pi-tui package root, so only the modifyOtherKeys half of decodePrintableKey is vendored verbatim into lib/pi-tui-keys.ts (parse + decode, same regexes and modifier masks), with decodePrintableKey composing root decodeKittyPrintable over the vendored half. Behavior is identical to pi-tui dist/keys.js. Tests: asset-installation-runtime.test.ts 1 pass / 0 fail under node --experimental-strip-types --test; selection-engine suite 6 pass / 0 fail; npm run typecheck clean with no regressions. --- lib/pi-tui-keys.ts | 53 +++++++++++++++++++++++++++++++++++++++++ lib/selection-engine.ts | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 lib/pi-tui-keys.ts 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 index fc9d3cc73..24a13a80e 100644 --- a/lib/selection-engine.ts +++ b/lib/selection-engine.ts @@ -1,4 +1,4 @@ -import { decodePrintableKey } from "@earendil-works/pi-tui/dist/keys.js"; +import { decodePrintableKey } from "./pi-tui-keys.ts"; import { isKeyRelease, matchesKey, truncateToWidth, visibleWidth, type EditorComponent } from "@earendil-works/pi-tui"; /** From cd945771d4e21bc8eb7c1bd44340dbd877778582 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:53:43 -0300 Subject: [PATCH 23/49] test(selection): pin terminal-key decode, release filtering and one-undo replacement The extension-era suites were replaced by the native engine port, which left the review-requested terminal-key and undo contracts untested. Add focused node:test cases alongside the existing six: - kitty CSI-u press replaces the selection; a flag-2 release is dropped before any key matching and keeps the selection active - repeated shift+home at the line edge keeps the selection (held-key auto-repeat must not collapse the span) - modifyOtherKeys printable decodes; ctrl-modified and control codepoints are rejected by the vendored decoder - CSI-u-encoded DEL (127) and C1 (0x9b) codepoints replace the selection with a pure delete: the splice guard strips the control character so it is never inserted - printable replacement pushes exactly one undo snapshot before the splice, so replacement reverts in a single undo step Gates: node --test selection suite 11 pass / 0 fail; check-types baseline gate pass (no regressions). --- tests/selection-engine.test.ts | 74 ++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/selection-engine.test.ts b/tests/selection-engine.test.ts index 9b85bfa6b..ecf9530af 100644 --- a/tests/selection-engine.test.ts +++ b/tests/selection-engine.test.ts @@ -3,6 +3,7 @@ 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 @@ -124,3 +125,76 @@ test("degraded host: pure passthrough, no selection behavior", () => { 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); +}); From d881e6a5834cf20669c1478485af88e626beefeb Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:49:07 -0300 Subject: [PATCH 24/49] Closing the /gentle:agents overlay restored the editor with an incremental re-render, and the fullscreen interaction's stale cell state survived it: keys kept working (selection, delete, everything dispatched and matched at the byte level) while the screen no longer updated. Field-reported as "selection keys stop working after gentle commands", with /reload as the only recovery; PID-stamped stdin taps proved the keypresses were delivered and matched while the screen stayed stale. Add lib/overlay-repaint.ts withOverlayRepaint: wraps an overlay's done callback so the close path runs done() first, then tui.invalidate() + tui.requestRender() to repaint every cell. Paint failures are swallowed; done failures propagate. Wired into the four gentle overlays: agents view, command palette, usage view, changes file chooser. Tests: 4 new cases (order done->invalidate->requestRender, null close, swallowed paint failure, propagating done failure). Gates: typecheck clean (no regressions), targeted suite 290 pass / 0 fail run with EDITOR/VISUAL neutralized per AGENTS.md after editor-launch leaks. --- extensions/gentle-agents.ts | 6 ++- extensions/gentle-shell.ts | 10 +++-- lib/overlay-repaint.ts | 23 ++++++++++++ tests/overlay-repaint.test.ts | 70 +++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 lib/overlay-repaint.ts create mode 100644 tests/overlay-repaint.test.ts diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 736a19cd2..45e280f02 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -31,6 +31,7 @@ import { inheritedUnsafeGitEnvironmentKeys } from "../lib/review-repository.ts"; import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts"; import { sessionToMarkdown } from "../lib/agents-transcript.ts"; import { AgentsView } from "../lib/agents-view.ts"; +import { withOverlayRepaint } from "../lib/overlay-repaint.ts"; import { PresencePublisher } from "../lib/orchestrator-presence.ts"; import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts"; import { isInteractiveRpcHost } from "../lib/rpc-host.ts"; @@ -992,6 +993,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = let overlayHost: { requestRender(force?: boolean): void; stop(): void; start(): void } | undefined; const chosen = await ctx.ui.custom( (tui, theme, _keybindings, done) => { + const close = withOverlayRepaint(tui, done); overlayHost = tui; view = new AgentsView({ theme, @@ -1006,8 +1008,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = onCancel: (task) => void stopSelected(task, ctx), canCancel: isOwnedActive, isLocalTask: (task) => !restoredTaskIds.has(task.id), - onOpen: (task) => done(task), - onClose: () => done(null), + onOpen: (task) => close(task), + onClose: () => close(null), requestRender: () => tui.requestRender(), }); overlays.add(view); diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 15822398f..41c3e408e 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 { withOverlayRepaint } from "../lib/overlay-repaint.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, @@ -608,14 +609,15 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro try { const chosen = await ctx.ui.custom<{ root: string; file: ChangedFile } | null>( (tui, theme, _keybindings, done) => { + const close = withOverlayRepaint(tui, done); host = tui; view = new WorktreeChangesView(worktrees(), { theme, rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)), loadDiff: (root, file) => Promise.resolve(deps.loadDiff(root, file)), - onOpen: (root, file) => done({ root, file }), + onOpen: (root, file) => close({ root, file }), onRefresh: () => void refresh(), - onClose: () => done(null), + onClose: () => close(null), requestRender: () => tui.requestRender(), }); return view; @@ -648,7 +650,7 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext, env: return; } const result = await ctx.ui.custom( - (tui, theme, _keybindings, done) => new CommandPalette(groups, done, theme, () => Math.max(0, tui.terminal.rows)), + (tui, theme, _keybindings, done) => new CommandPalette(groups, withOverlayRepaint(tui, done), theme, () => Math.max(0, tui.terminal.rows)), { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 60, maxHeight: "85%" } }, ); if (result?.type === "run") pi.sendUserMessage(`/${result.name}`, { expandPromptTemplates: true }); @@ -841,7 +843,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p active: () => (ctx.model ? { provider: ctx.model.provider } : undefined), registry: () => usageSources, onRefresh: () => refreshUsage(ctx, true), - onClose: () => done(null), + onClose: () => withOverlayRepaint(tui, done)(null), requestRender: () => tui.requestRender(), }), { overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } }, diff --git a/lib/overlay-repaint.ts b/lib/overlay-repaint.ts new file mode 100644 index 000000000..0c6e001c7 --- /dev/null +++ b/lib/overlay-repaint.ts @@ -0,0 +1,23 @@ +import type { TUI } from "@earendil-works/pi-tui"; + +/** + * Wrap an overlay's `done` callback so closing the overlay forces a FULL terminal + * repaint. Pi restores the editor behind a closed overlay with an incremental + * re-render; a fullscreen overlay's stale cell state can survive that pass — keys + * keep working while the screen stops updating (field-reported as "selection keys + * are dead" after /gentle:agents + Esc). `tui.invalidate()` drops the renderer's + * cached frame; the follow-up requestRender repaints every cell. `done(result)` + * runs first, so the overlay is fully torn down before the repaint is scheduled. + * Paint failures are swallowed: a dead terminal must never break the close path. + */ +export function withOverlayRepaint(tui: TUI, done: (result: T) => void): (result: T) => void { + return (result: T): void => { + done(result); + try { + tui.invalidate(); + tui.requestRender(); + } catch { + /* best-effort repaint: never break the overlay close path */ + } + }; +} diff --git a/tests/overlay-repaint.test.ts b/tests/overlay-repaint.test.ts new file mode 100644 index 000000000..7a28c8799 --- /dev/null +++ b/tests/overlay-repaint.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { withOverlayRepaint } from "../lib/overlay-repaint.ts"; +import type { TUI } from "@earendil-works/pi-tui"; + +// Overlay close repaint: wrapping an overlay's done callback must tear the +// overlay down first (done), then force a FULL terminal repaint +// (invalidate + requestRender) so a fullscreen overlay's stale cell state +// cannot survive the close. Paint failures are swallowed; done failures are not. + +function fakeTui() { + const calls: string[] = []; + const tui = { + invalidate: () => { + calls.push("invalidate"); + }, + requestRender: () => { + calls.push("requestRender"); + }, + } as unknown as TUI; + return { tui, calls }; +} + +test("withOverlayRepaint: done runs first, then full repaint (invalidate + requestRender)", () => { + const { tui, calls } = fakeTui(); + const seen: Array = []; + const close = withOverlayRepaint(tui, (result) => { + seen.push(result); + calls.push("done"); + }); + close("task-1"); + assert.deepEqual(seen, ["task-1"]); + assert.deepEqual(calls, ["done", "invalidate", "requestRender"]); +}); + +test("withOverlayRepaint: forwards null results (plain close)", () => { + const { tui, calls } = fakeTui(); + const seen: Array = []; + const close = withOverlayRepaint(tui, (result) => { + seen.push(result); + }); + close(null); + assert.deepEqual(seen, [null]); + assert.deepEqual(calls, ["invalidate", "requestRender"]); +}); + +test("withOverlayRepaint: paint failure is swallowed, done result still delivered", () => { + const seen: Array = []; + const tui = { + invalidate: () => { + throw new Error("EIO"); + }, + requestRender: () => { + throw new Error("EIO"); + }, + } as unknown as TUI; + const close = withOverlayRepaint(tui, (result) => { + seen.push(result); + }); + assert.doesNotThrow(() => close(null)); + assert.deepEqual(seen, [null]); +}); + +test("withOverlayRepaint: done failure propagates (overlay contract, not paint)", () => { + const { tui } = fakeTui(); + const close = withOverlayRepaint(tui, () => { + throw new Error("overlay teardown failed"); + }); + assert.throws(() => close(null), /overlay teardown failed/); +}); From 8cc401e8c625a4314e569142ee6420bc84ea40a0 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:41:11 -0300 Subject: [PATCH 25/49] tui.invalidate() propagated to every component, so closing an overlay on a long session rebuilt the whole transcript synchronously - a ~5s freeze that exactly matched the reported overlay close delay. Use tui.requestRender(true) instead: resetRenderState clears the written-frame buffer so the next paint rewrites every cell (clearing the stale overlay ghost) while component row caches stay warm. --- lib/overlay-repaint.ts | 23 +++++++++++++---------- tests/overlay-repaint.test.ts | 30 +++++++++++++++++------------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/lib/overlay-repaint.ts b/lib/overlay-repaint.ts index 0c6e001c7..664a556dd 100644 --- a/lib/overlay-repaint.ts +++ b/lib/overlay-repaint.ts @@ -1,21 +1,24 @@ import type { TUI } from "@earendil-works/pi-tui"; /** - * Wrap an overlay's `done` callback so closing the overlay forces a FULL terminal - * repaint. Pi restores the editor behind a closed overlay with an incremental - * re-render; a fullscreen overlay's stale cell state can survive that pass — keys - * keep working while the screen stops updating (field-reported as "selection keys - * are dead" after /gentle:agents + Esc). `tui.invalidate()` drops the renderer's - * cached frame; the follow-up requestRender repaints every cell. `done(result)` - * runs first, so the overlay is fully torn down before the repaint is scheduled. - * Paint failures are swallowed: a dead terminal must never break the close path. + * Wrap an overlay's `done` callback so closing the overlay forces the terminal + * to repaint the full cell buffer. Pi restores the editor behind a closed + * overlay with an incremental diff render; a fullscreen overlay's stale cell + * state can survive that pass — keys keep working while the screen stops + * updating (field-reported as "selection keys are dead" after /gentle:agents + * + Esc). `tui.requestRender(true)` resets the renderer's written-frame state + * so the next paint rewrites EVERY cell to the terminal, clearing the ghost — + * without rebuilding component rows, which on long sessions costs seconds of + * full-transcript re-render (the reason plain `invalidate()` is avoided + * here). `done(result)` runs first, so the overlay is fully torn down before + * the forced paint is scheduled. Failures are swallowed: a dead terminal must + * never break the close path. */ export function withOverlayRepaint(tui: TUI, done: (result: T) => void): (result: T) => void { return (result: T): void => { done(result); try { - tui.invalidate(); - tui.requestRender(); + tui.requestRender(true); } catch { /* best-effort repaint: never break the overlay close path */ } diff --git a/tests/overlay-repaint.test.ts b/tests/overlay-repaint.test.ts index 7a28c8799..9d4e35695 100644 --- a/tests/overlay-repaint.test.ts +++ b/tests/overlay-repaint.test.ts @@ -21,35 +21,39 @@ function fakeTui() { return { tui, calls }; } -test("withOverlayRepaint: done runs first, then full repaint (invalidate + requestRender)", () => { - const { tui, calls } = fakeTui(); - const seen: Array = []; +test("withOverlayRepaint: forces a full render (requestRender(true)) after done", () => { + const calls: string[] = []; + const tui = { + requestRender: (force?: boolean) => { + calls.push(`requestRender:${force === true}`); + }, + } as unknown as TUI; const close = withOverlayRepaint(tui, (result) => { - seen.push(result); - calls.push("done"); + calls.push(`done:${String(result)}`); }); - close("task-1"); - assert.deepEqual(seen, ["task-1"]); - assert.deepEqual(calls, ["done", "invalidate", "requestRender"]); + close(null); + assert.deepEqual(calls, ["done:null", "requestRender:true"]); }); test("withOverlayRepaint: forwards null results (plain close)", () => { - const { tui, calls } = fakeTui(); + const calls: string[] = []; + const tui = { + requestRender: (force?: boolean) => { + calls.push(`requestRender:${force === true}`); + }, + } as unknown as TUI; const seen: Array = []; const close = withOverlayRepaint(tui, (result) => { seen.push(result); }); close(null); assert.deepEqual(seen, [null]); - assert.deepEqual(calls, ["invalidate", "requestRender"]); + assert.deepEqual(calls, ["requestRender:true"]); }); test("withOverlayRepaint: paint failure is swallowed, done result still delivered", () => { const seen: Array = []; const tui = { - invalidate: () => { - throw new Error("EIO"); - }, requestRender: () => { throw new Error("EIO"); }, From a584d8b4eac54186b6409f8c0b2d3abe7699ee07 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:23:55 -0300 Subject: [PATCH 26/49] revert: drop overlay repaint from the selection PR Undo the merge of fix/overlay-close-repaint so PR #1402 stays selection-focused as requested in review: the overlay-close repaint fix moves to its own small PR. The tree after this revert is byte-identical to cd945771 (4 files, +695/-2 vs main: selection engine, vendored decoder, petal wiring, tests). The overlay fix itself is unchanged and lands separately from fix/overlay-close-repaint, split out of #806. --- extensions/gentle-agents.ts | 6 +-- extensions/gentle-shell.ts | 10 ++--- lib/overlay-repaint.ts | 26 ------------ tests/overlay-repaint.test.ts | 74 ----------------------------------- 4 files changed, 6 insertions(+), 110 deletions(-) delete mode 100644 lib/overlay-repaint.ts delete mode 100644 tests/overlay-repaint.test.ts diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 45e280f02..736a19cd2 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -31,7 +31,6 @@ import { inheritedUnsafeGitEnvironmentKeys } from "../lib/review-repository.ts"; import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts"; import { sessionToMarkdown } from "../lib/agents-transcript.ts"; import { AgentsView } from "../lib/agents-view.ts"; -import { withOverlayRepaint } from "../lib/overlay-repaint.ts"; import { PresencePublisher } from "../lib/orchestrator-presence.ts"; import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts"; import { isInteractiveRpcHost } from "../lib/rpc-host.ts"; @@ -993,7 +992,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = let overlayHost: { requestRender(force?: boolean): void; stop(): void; start(): void } | undefined; const chosen = await ctx.ui.custom( (tui, theme, _keybindings, done) => { - const close = withOverlayRepaint(tui, done); overlayHost = tui; view = new AgentsView({ theme, @@ -1008,8 +1006,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = onCancel: (task) => void stopSelected(task, ctx), canCancel: isOwnedActive, isLocalTask: (task) => !restoredTaskIds.has(task.id), - onOpen: (task) => close(task), - onClose: () => close(null), + onOpen: (task) => done(task), + onClose: () => done(null), requestRender: () => tui.requestRender(), }); overlays.add(view); diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 4f2388380..6513e4f99 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -31,7 +31,6 @@ import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.t 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"; -import { withOverlayRepaint } from "../lib/overlay-repaint.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, @@ -629,15 +628,14 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro try { const chosen = await ctx.ui.custom<{ root: string; file: ChangedFile } | null>( (tui, theme, _keybindings, done) => { - const close = withOverlayRepaint(tui, done); host = tui; view = new WorktreeChangesView(worktrees(), { theme, rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)), loadDiff: (root, file) => Promise.resolve(deps.loadDiff(root, file)), - onOpen: (root, file) => close({ root, file }), + onOpen: (root, file) => done({ root, file }), onRefresh: () => void refresh(), - onClose: () => close(null), + onClose: () => done(null), requestRender: () => tui.requestRender(), }); return view; @@ -670,7 +668,7 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext, env: return; } const result = await ctx.ui.custom( - (tui, theme, _keybindings, done) => new CommandPalette(groups, withOverlayRepaint(tui, done), theme, () => Math.max(0, tui.terminal.rows)), + (tui, theme, _keybindings, done) => new CommandPalette(groups, done, theme, () => Math.max(0, tui.terminal.rows)), { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 60, maxHeight: "85%" } }, ); if (result?.type === "run") pi.sendUserMessage(`/${result.name}`, { expandPromptTemplates: true }); @@ -863,7 +861,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p active: () => (ctx.model ? { provider: ctx.model.provider } : undefined), registry: () => usageSources, onRefresh: () => refreshUsage(ctx, true), - onClose: () => withOverlayRepaint(tui, done)(null), + onClose: () => done(null), requestRender: () => tui.requestRender(), }), { overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } }, diff --git a/lib/overlay-repaint.ts b/lib/overlay-repaint.ts deleted file mode 100644 index 664a556dd..000000000 --- a/lib/overlay-repaint.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { TUI } from "@earendil-works/pi-tui"; - -/** - * Wrap an overlay's `done` callback so closing the overlay forces the terminal - * to repaint the full cell buffer. Pi restores the editor behind a closed - * overlay with an incremental diff render; a fullscreen overlay's stale cell - * state can survive that pass — keys keep working while the screen stops - * updating (field-reported as "selection keys are dead" after /gentle:agents - * + Esc). `tui.requestRender(true)` resets the renderer's written-frame state - * so the next paint rewrites EVERY cell to the terminal, clearing the ghost — - * without rebuilding component rows, which on long sessions costs seconds of - * full-transcript re-render (the reason plain `invalidate()` is avoided - * here). `done(result)` runs first, so the overlay is fully torn down before - * the forced paint is scheduled. Failures are swallowed: a dead terminal must - * never break the close path. - */ -export function withOverlayRepaint(tui: TUI, done: (result: T) => void): (result: T) => void { - return (result: T): void => { - done(result); - try { - tui.requestRender(true); - } catch { - /* best-effort repaint: never break the overlay close path */ - } - }; -} diff --git a/tests/overlay-repaint.test.ts b/tests/overlay-repaint.test.ts deleted file mode 100644 index 9d4e35695..000000000 --- a/tests/overlay-repaint.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { withOverlayRepaint } from "../lib/overlay-repaint.ts"; -import type { TUI } from "@earendil-works/pi-tui"; - -// Overlay close repaint: wrapping an overlay's done callback must tear the -// overlay down first (done), then force a FULL terminal repaint -// (invalidate + requestRender) so a fullscreen overlay's stale cell state -// cannot survive the close. Paint failures are swallowed; done failures are not. - -function fakeTui() { - const calls: string[] = []; - const tui = { - invalidate: () => { - calls.push("invalidate"); - }, - requestRender: () => { - calls.push("requestRender"); - }, - } as unknown as TUI; - return { tui, calls }; -} - -test("withOverlayRepaint: forces a full render (requestRender(true)) after done", () => { - const calls: string[] = []; - const tui = { - requestRender: (force?: boolean) => { - calls.push(`requestRender:${force === true}`); - }, - } as unknown as TUI; - const close = withOverlayRepaint(tui, (result) => { - calls.push(`done:${String(result)}`); - }); - close(null); - assert.deepEqual(calls, ["done:null", "requestRender:true"]); -}); - -test("withOverlayRepaint: forwards null results (plain close)", () => { - const calls: string[] = []; - const tui = { - requestRender: (force?: boolean) => { - calls.push(`requestRender:${force === true}`); - }, - } as unknown as TUI; - const seen: Array = []; - const close = withOverlayRepaint(tui, (result) => { - seen.push(result); - }); - close(null); - assert.deepEqual(seen, [null]); - assert.deepEqual(calls, ["requestRender:true"]); -}); - -test("withOverlayRepaint: paint failure is swallowed, done result still delivered", () => { - const seen: Array = []; - const tui = { - requestRender: () => { - throw new Error("EIO"); - }, - } as unknown as TUI; - const close = withOverlayRepaint(tui, (result) => { - seen.push(result); - }); - assert.doesNotThrow(() => close(null)); - assert.deepEqual(seen, [null]); -}); - -test("withOverlayRepaint: done failure propagates (overlay contract, not paint)", () => { - const { tui } = fakeTui(); - const close = withOverlayRepaint(tui, () => { - throw new Error("overlay teardown failed"); - }); - assert.throws(() => close(null), /overlay teardown failed/); -}); From 84c1232361ca221e4b77b7aa8d2d0ebb6ddf3097 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:37 -0300 Subject: [PATCH 27/49] fix(history): make prompt capture opt-in and document the store Review follow-up on the slice-01 PR: the before_agent_start handler recorded delivered prompts by default while the deletion UI is still unshipped, so an intermediate release could accumulate sensitive prompts with no removal path. - Capture is now strictly opt-in via GENTLE_PI_HISTORY_CAPTURE=1|true|on (default off); the switch doubles as the disable path, is checked per prompt, and a disabled session writes nothing - no registry entry, no files. - promptHistoryExtension takes injectable deps (env/root/cwd/ instanceId/now) with one writer closure per extension load. - New tests: strict opt-in matrix, default-off inertness, opted-in capture, disable-leaves-existing-files. - docs/prompt-history.md documents the switch, storage locations, permissions/readers, and disable/removal semantics; the README docs table gains a pointer. --- README.md | 1 + docs/prompt-history.md | 61 ++++++++++++++++++++++ extensions/history/index.ts | 75 +++++++++++++++++++--------- tests/history-session-writer.test.ts | 61 +++++++++++++++++++++- 4 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 docs/prompt-history.md diff --git a/README.md b/README.md index 9e20d72f4..0207c389c 100644 --- a/README.md +++ b/README.md @@ -880,6 +880,7 @@ To opt out: | `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | | `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | | `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | +| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | ## Development diff --git a/docs/prompt-history.md b/docs/prompt-history.md new file mode 100644 index 000000000..1fa8eba4f --- /dev/null +++ b/docs/prompt-history.md @@ -0,0 +1,61 @@ +# Prompt history + +Slice 1 of the prompt-history extension (#819 split) ships the storage layer only: +a per-instance JSONL capture store, project identity, and the read/write +primitives later slices build on. The selector UI, deletion/scope drains, and GC +arrive in later slices of the chain. + +## Capture is opt-in + +Recording is **off by default**. Delivered prompts can contain secrets, and the +deletion UI is not shipped yet, so nothing is stored unless you explicitly opt in: + +```bash +GENTLE_PI_HISTORY_CAPTURE=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. + +## 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 add the rebuildable `seed.jsonl`, scope drains/deletes, and GC. + +## 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 or +the deletion UI ships. To erase the store manually while capture is off (or pi +is not running): + +```bash +rm -rf ~/.pi/agent/history # whole store +rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) +``` diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 26616733f..74dab5942 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -5,6 +5,12 @@ // per-instance writer lifecycle, and the before_agent_start capture // handler. Selector UI, shortcut/command, scope drains, legacy migration // and seed bootstrap, and GC arrive in later slices. +// +// Capture is OPT-IN while the deletion/privacy behavior is unshipped: +// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=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 { randomUUID } from "node:crypto"; import { homedir } from "node:os"; @@ -19,39 +25,62 @@ import { // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); -const AGENT_DIR = join(homedir(), ".pi", "agent"); -const CURRENT_CWD = process.cwd(); -// Instance identity: one exclusive capture file per pi process. -const INSTANCE_ID = randomUUID(); -let writerState: SessionWriterState | null = null; +export interface HistoryDeps { + env?: NodeJS.ProcessEnv; + root?: string; + cwd?: string; + instanceId?: string; + now?: () => number; +} /** - * One-time init per extension load: register the project in the advisory - * registry, then open this instance's exclusive capture file. Legacy - * migration and seed bootstrap join this init order in a later slice. + * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_CAPTURE 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 until the deletion tooling lands. */ -function getWriter(): SessionWriterState { - if (!writerState) { - try { - ensureRegistryEntry(PI_HISTORY_ROOT, CURRENT_CWD); - } catch { - // registry is advisory - } - writerState = openSessionWriter(PI_HISTORY_ROOT, CURRENT_CWD, INSTANCE_ID); - } - return writerState; +export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.GENTLE_PI_HISTORY_CAPTURE?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "on"; } -export default function promptHistoryExtension(pi: ExtensionAPI) { - // One writer per extension load; see getWriter() for the init order. +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 ?? process.cwd(); + const instanceId = deps.instanceId ?? randomUUID(); + const now = deps.now ?? Date.now; + let writerState: SessionWriterState | null = null; + + /** + * One-time init per extension load: register the project in the advisory + * registry, then open this instance's exclusive capture file. Legacy + * migration and seed bootstrap join this init order in a later slice. + */ + const getWriter = (): SessionWriterState => { + if (!writerState) { + try { + ensureRegistryEntry(root, cwd); + } catch { + // registry is advisory + } + writerState = openSessionWriter(root, cwd, instanceId); + } + return writerState; + }; - // Persist every delivered user prompt (write-through, append-only JSONL). - // The local ExtensionAPI stub types handler args as unknown; narrow here. + // 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 ?? "", Date.now()); + 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. diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index 8601cea14..4128b7eef 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -9,7 +9,7 @@ import { projectHash, sessionFilePath, } from "../extensions/history/store.ts"; -import promptHistoryExtension from "../extensions/history/index.ts"; +import promptHistoryExtension, { captureEnabled } from "../extensions/history/index.ts"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); @@ -29,6 +29,24 @@ 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]); + }, + }; + 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"); @@ -107,3 +125,44 @@ test("the slice-1 extension entry registers only the capture handler", () => { // would run getWriter() against the user's real ~/.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_CAPTURE: "0" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "false" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "off" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "yes" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: " 1 " }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "TRUE" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "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_CAPTURE: "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_CAPTURE: "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_CAPTURE; + handler({ prompt: "never written" }); + assert.deepEqual(fileTexts(file), ["kept"]); +}); From 5501d12d1ddc39e7efed2c6f5908b23364d6fcef Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:34:51 -0300 Subject: [PATCH 28/49] fix(history): fail closed on untrustworthy hidden.json tombstones A corrupt or unreadable hide file previously loaded as an empty hidden set (fail open), resurfacing prompts the user may have hidden because they contain secrets. The next hide also rewrote the file clean, silently clearing the incident. - readHiddenPrompts replaces loadHiddenPrompts: ENOENT stays trusted-empty (nothing ever hidden); any other read error, JSON parse failure, or non-array shape is untrusted (unreadable/corrupt/ malformed) and carries a recovery message naming hidden.json - hidePrompt refuses to write over an untrusted file: recovery is the explicit delete-or-restore of hidden.json, never a silent rewrite - drainProject/drainGlobal return DrainResult: untrusted tombstones block the drain (status "blocked", no prompts field) so the future selector UI must surface the warning; no stateDir keeps raw drain semantics Tests: rewrite T26 to pin the refusal + byte-unchanged file + manual unlink recovery; add malformed-shape, junk-item tolerance, and chmod 000 unreadable cases; drains pin the blocked shape (no prompts field) and the missing-file-stays-ok case. --- extensions/history/hide-prompts.ts | 83 ++++++++++--- extensions/history/store.ts | 53 ++++++-- tests/history-drain-hidden.test.ts | 62 +++++++++- tests/history-drain-order.test.ts | 22 +++- tests/history-hide-prompts.test.ts | 187 +++++++++++++++++++++++------ 5 files changed, 330 insertions(+), 77 deletions(-) diff --git a/extensions/history/hide-prompts.ts b/extensions/history/hide-prompts.ts index 9cffd6954..6d91a57d6 100644 --- a/extensions/history/hide-prompts.ts +++ b/extensions/history/hide-prompts.ts @@ -9,6 +9,14 @@ import { promptDedupKey } from "./selector-helpers.ts"; /** Name of the tombstone file inside the injected state dir (spec C4). */ const HIDE_FILE_NAME = "hidden.json"; +/** + * 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 @@ -19,32 +27,64 @@ export type HideResult = | { status: "error"; message: string }; /** - * Load the tombstone key set from `stateDir/hidden.json` — the READ half of - * the hide-file contract (spec C4). Fail-open: a missing, unreadable, - * corrupt, or wrong-shaped file is an EMPTY set and the call never throws; - * a corrupt file is rewritten clean by the next hide (the WRITE half, - * `hidePrompt`, lands in WU4). Keys are `promptDedupKey` strings written by - * `hidePrompt`; foreign values are ignored, never trusted. + * 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 function loadHiddenPrompts(stateDir: string): Set { +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. + */ +export function readHiddenPrompts(stateDir: string): HiddenRead { let raw: string; try { raw = fs.readFileSync(path.join(stateDir, HIDE_FILE_NAME), "utf8"); - } catch { - return new Set(); // missing or unreadable → empty tombstones + } 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 new Set(); // corrupt bytes → fail-open empty + return { status: "untrusted", reason: "corrupt", message: RECOVERY_MESSAGE }; } const keys = new Set(); - if (!Array.isArray(parsed)) return keys; // wrong shape → fail-open empty + 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 keys; + return { status: "trusted", keys }; } /** @@ -52,18 +92,23 @@ export function loadHiddenPrompts(stateDir: string): Set { * 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 set compacts on write and persists as a SORTED - * array via the shared atomic tmp+rename writer. Fail-open both ways: a - * corrupt or missing file reads as empty (this clean rewrite IS the - * recovery — the corrupt contents are untrustworthy by definition) and any - * write failure returns an error object for the delete-flow toast; the + * array 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 keys = loadHiddenPrompts(stateDir); - keys.add(promptDedupKey(text)); + const read = readHiddenPrompts(stateDir); + if (read.status === "untrusted") { + // Refuse without writing: never reset the untrusted state silently. + return { status: "error", message: read.message }; + } + read.keys.add(promptDedupKey(text)); const written = writeJsonAtomic( path.join(stateDir, HIDE_FILE_NAME), - [...keys].sort(), + [...read.keys].sort(), ); return written ? { status: "written" } diff --git a/extensions/history/store.ts b/extensions/history/store.ts index fb470515c..b63b07719 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -11,7 +11,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { loadHiddenPrompts } from "./hide-prompts.ts"; +import { readHiddenPrompts } from "./hide-prompts.ts"; // =========================================================================== // Paths (formerly store-paths.ts) @@ -350,20 +350,53 @@ function sortFilesForDrain(files: string[]): string[] { .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, -): string[] { - return drainFiles( +): DrainResult { + return drainWithHidden( sortFilesForDrain(listProjectFiles(path.join(root, "projects", projectHash(cwd)))), limit, - stateDir ? loadHiddenPrompts(stateDir) : new Set(), + stateDir, ); } @@ -371,13 +404,15 @@ export function drainProject( * 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). + * 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, -): string[] { +): DrainResult { const files: string[] = []; const globalSeed = globalSeedPath(root); @@ -397,9 +432,5 @@ export function drainGlobal( } const sorted = sortFilesForDrain(files); if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last - return drainFiles( - sorted, - limit, - stateDir ? loadHiddenPrompts(stateDir) : new Set(), - ); + return drainWithHidden(sorted, limit, stateDir); } diff --git a/tests/history-drain-hidden.test.ts b/tests/history-drain-hidden.test.ts index 90c479758..73a686b86 100644 --- a/tests/history-drain-hidden.test.ts +++ b/tests/history-drain-hidden.test.ts @@ -8,6 +8,7 @@ import { drainProject, globalSeedPath, projectHash, + type DrainResult, } from "../extensions/history/store.ts"; // Portable project identity: a never-existing literal. projectHash falls @@ -25,6 +26,14 @@ function write(file: string, texts: string[], ts = 100): void { ); } +// 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"); @@ -40,15 +49,62 @@ test("drains skip tombstoned prompts in seeds and session files", () => { write(path.join(dir, "s1.jsonl"), ["also keep", "deleted from session"], 200); write(globalSeedPath(root), ["deleted from seed", "legacy keep"], 50); - assert.deepEqual(drainProject(root, CWD, 1000, stateDir), [ + assert.deepEqual(okPrompts(drainProject(root, CWD, 1000, stateDir)), [ "also keep", "keep", ]); - assert.deepEqual(drainGlobal(root, 1000, stateDir), [ + assert.deepEqual(okPrompts(drainGlobal(root, 1000, stateDir)), [ "also keep", "keep", "legacy keep", ]); // Without a stateDir the filter is off (raw drain semantics). - assert.equal(drainProject(root, CWD).includes("deleted from seed"), true); + 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 index 86cfddca9..be2f8fdfc 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -8,6 +8,7 @@ import { drainProject, globalSeedPath, projectHash, + type DrainResult, } from "../extensions/history/store.ts"; // Portable project identity: a never-existing literal. projectHash falls @@ -25,18 +26,25 @@ function writeTs(file: string, texts: string[], ts: number): void { ); } +// 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(drainProject(root, CWD), ["z-new", "a-old"]); + 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(drainProject(root, CWD), ["z-new"]); + 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( @@ -44,7 +52,7 @@ test("atomic rewrite (delete) does not reshuffle the drain order", () => { new Date(Date.now() + 99999), new Date(Date.now() + 99999), ); - assert.deepEqual(drainProject(root, CWD), ["z-new", "b-old"]); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "b-old"]); }); test("global drain puts the legacy seed last regardless of its fresh mtime", () => { @@ -54,7 +62,11 @@ test("global drain puts the legacy seed last regardless of its fresh mtime", () 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(drainGlobal(root), ["fresh", "legacy-2", "legacy-1"]); + assert.deepEqual(okPrompts(drainGlobal(root)), [ + "fresh", + "legacy-2", + "legacy-1", + ]); }); test( @@ -78,7 +90,7 @@ test( 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(drainProject(root, CWD), ["z-new", "a-old"]); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "a-old"]); } finally { fs.chmodSync(sealed, 0o644); // restore before cleanup } diff --git a/tests/history-hide-prompts.test.ts b/tests/history-hide-prompts.test.ts index 03c054863..ccd1f8ea8 100644 --- a/tests/history-hide-prompts.test.ts +++ b/tests/history-hide-prompts.test.ts @@ -3,13 +3,20 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { hidePrompt, loadHiddenPrompts } from "../extensions/history/hide-prompts.ts"; +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). fs-only -// coverage. 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. +// 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 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}-`)); @@ -21,6 +28,29 @@ function readHideFile(stateDir: string) { ); } +/** 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 @@ -41,59 +71,138 @@ test("T24 (AC-S4-1): hide keys byte-match promptDedupKey across whitespace, case assert.ok(Array.isArray(stored), "hidden.json must hold a JSON array"); // Byte-match: the file holds EXACTLY the shared helper's output, sorted. assert.deepEqual(stored, texts.map((text) => promptDedupKey(text)).sort()); - // The loaded set agrees. - const loaded = loadHiddenPrompts(stateDir); + // The read half agrees. + const keys = trustedKeys(stateDir); + assert.equal(keys.size, stored.length); for (const key of stored) { - assert.ok(loaded.has(key)); + 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 reads as an empty set; reads -// never throw. -test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads as empty; reads never throw", () => { +// 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: empty set, no throw (before any write exists). - assert.equal(loadHiddenPrompts(stateDir).size, 0); + // 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 loaded = loadHiddenPrompts(stateDir); - assert.equal(loaded.size, 1); - assert.ok(loaded.has(promptDedupKey("same text"))); + const keys = trustedKeys(stateDir); + assert.equal(keys.size, 1); + assert.ok(keys.has(promptDedupKey("same text"))); }); -// T26 — AC-S4-5: corrupt hidden.json is fail-open (READ half) AND the next -// hide rewrites the file clean as a sorted compact array — the rewrite half -// is the recovery path. -test("T26 (AC-S4-5): corrupt hidden.json loads as empty and the next hide rewrites it clean", () => { +// 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"); - fs.writeFileSync( - path.join(stateDir, "hidden.json"), - "{corrupt bytes", - "utf8", - ); - assert.equal(loadHiddenPrompts(stateDir).size, 0); + 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" }); - // The rewrite landed: clean JSON holding exactly the new key. - assert.deepEqual(readHideFile(stateDir), [promptDedupKey("beta prompt")]); - assert.equal(loadHiddenPrompts(stateDir).size, 1); + const keys = trustedKeys(stateDir); + assert.equal(keys.size, 1); + assert.ok(keys.has(promptDedupKey("beta prompt"))); }); -// WU4c — write-failure path (AC-S4-2 triangulation): a state dir that cannot -// be created (its parent is a regular file) makes the atomic write return -// false, and hidePrompt maps that to the toast-suitable error object — -// never a throw. -test("hide write failure returns the exact error shape for the delete-flow toast", () => { - const base = makeStateDir("fail"); - const blocker = path.join(base, "blocker"); - fs.writeFileSync(blocker, "regular file", "utf8"); - const stateDir = path.join(blocker, "sealed"); // parent is a file → ENOTDIR - assert.deepEqual(hidePrompt(stateDir, "kept 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: "Could not write the hide file; the prompt may reappear.", + 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 + } + }, +); From 25a1dd12b1813f3d129dc77e6a6a528873310364 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:37 -0300 Subject: [PATCH 29/49] fix(history): make prompt capture opt-in and document the store Review follow-up on the slice-01 PR: the before_agent_start handler recorded delivered prompts by default while the deletion UI is still unshipped, so an intermediate release could accumulate sensitive prompts with no removal path. - Capture is now strictly opt-in via GENTLE_PI_HISTORY_CAPTURE=1|true|on (default off); the switch doubles as the disable path, is checked per prompt, and a disabled session writes nothing - no registry entry, no files. - promptHistoryExtension takes injectable deps (env/root/cwd/ instanceId/now) with one writer closure per extension load. - New tests: strict opt-in matrix, default-off inertness, opted-in capture, disable-leaves-existing-files. - docs/prompt-history.md documents the switch, storage locations, permissions/readers, and disable/removal semantics; the README docs table gains a pointer. --- README.md | 1 + docs/prompt-history.md | 61 +++++++++++++++++++ extensions/history/index.ts | 88 ++++++++++++++++++++++++---- tests/history-session-writer.test.ts | 66 ++++++++++++++++++++- 4 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 docs/prompt-history.md diff --git a/README.md b/README.md index 9e20d72f4..0207c389c 100644 --- a/README.md +++ b/README.md @@ -880,6 +880,7 @@ To opt out: | `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | | `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | | `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | +| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | ## Development diff --git a/docs/prompt-history.md b/docs/prompt-history.md new file mode 100644 index 000000000..1fa8eba4f --- /dev/null +++ b/docs/prompt-history.md @@ -0,0 +1,61 @@ +# Prompt history + +Slice 1 of the prompt-history extension (#819 split) ships the storage layer only: +a per-instance JSONL capture store, project identity, and the read/write +primitives later slices build on. The selector UI, deletion/scope drains, and GC +arrive in later slices of the chain. + +## Capture is opt-in + +Recording is **off by default**. Delivered prompts can contain secrets, and the +deletion UI is not shipped yet, so nothing is stored unless you explicitly opt in: + +```bash +GENTLE_PI_HISTORY_CAPTURE=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. + +## 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 add the rebuildable `seed.jsonl`, scope drains/deletes, and GC. + +## 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 or +the deletion UI ships. To erase the store manually while capture is off (or pi +is not running): + +```bash +rm -rf ~/.pi/agent/history # whole store +rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) +``` diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 4f5d08195..53b470532 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -6,6 +6,12 @@ // and slice-4 init sequence (legacy migration + seed bootstrap run once // inside getWriter). Deletion (slice 5) and GC/compaction (slice 6) arrive // in later slices. +// +// Capture is OPT-IN while the deletion/privacy behavior is unshipped: +// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=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 { join } from "node:path"; import { homedir } from "node:os"; @@ -931,14 +937,74 @@ function recordsFromEntries( return buildPromptRecords(dedupePromptEntries(entries)); } -export default function promptHistoryExtension(pi: ExtensionAPI) { - // One writer per extension load; see getWriter() for the init order. - // Warm migrate/registry/seed OFF the first-prompt path: the scheduled - // init runs once, immediately after load. A prompt arriving earlier - // falls back to the synchronous lazy init in getWriter(), whose - // writerState guard makes whichever runs second a no-op — bootstrap - // work is never duplicated. + +export interface HistoryDeps { + env?: NodeJS.ProcessEnv; + root?: string; + cwd?: string; + instanceId?: string; + now?: () => number; +} + +/** + * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_CAPTURE 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 until the deletion tooling lands. + */ +export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.GENTLE_PI_HISTORY_CAPTURE?.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 { @@ -946,12 +1012,14 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); - // Persist every delivered user prompt (write-through, append-only JSONL). - // The local ExtensionAPI stub types handler args as unknown; narrow here. + // 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 ?? "", Date.now()); + 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. diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index 61a2689b3..bb135582a 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -9,7 +9,7 @@ import { projectHash, sessionFilePath, } from "../extensions/history/store.ts"; -import promptHistoryExtension from "../extensions/history/index.ts"; +import promptHistoryExtension, { captureEnabled } from "../extensions/history/index.ts"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); @@ -29,6 +29,29 @@ 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"); @@ -121,3 +144,44 @@ test("the extension entry registers exactly the slice-3 wiring surface", () => { assert.equal(typeof handler, "function"); } }); + +test("captureEnabled is a strict opt-in", () => { + assert.equal(captureEnabled({}), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "0" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "false" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "off" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "yes" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: " 1 " }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "TRUE" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "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_CAPTURE: "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_CAPTURE: "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_CAPTURE; + handler({ prompt: "never written" }); + assert.deepEqual(fileTexts(file), ["kept"]); +}); From 041973d4cce2aa2a418fc06ccba1bb699c15af2d Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:37 -0300 Subject: [PATCH 30/49] fix(history): make prompt capture opt-in and document the store Review follow-up on the slice-01 PR: the before_agent_start handler recorded delivered prompts by default while the deletion UI is still unshipped, so an intermediate release could accumulate sensitive prompts with no removal path. - Capture is now strictly opt-in via GENTLE_PI_HISTORY_CAPTURE=1|true|on (default off); the switch doubles as the disable path, is checked per prompt, and a disabled session writes nothing - no registry entry, no files. - promptHistoryExtension takes injectable deps (env/root/cwd/ instanceId/now) with one writer closure per extension load. - New tests: strict opt-in matrix, default-off inertness, opted-in capture, disable-leaves-existing-files. - docs/prompt-history.md documents the switch, storage locations, permissions/readers, and disable/removal semantics; the README docs table gains a pointer. --- README.md | 1 + docs/prompt-history.md | 61 +++++++++++++++++++ extensions/history/index.ts | 88 ++++++++++++++++++++++++---- tests/history-session-writer.test.ts | 66 ++++++++++++++++++++- 4 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 docs/prompt-history.md diff --git a/README.md b/README.md index 9e20d72f4..0207c389c 100644 --- a/README.md +++ b/README.md @@ -880,6 +880,7 @@ To opt out: | `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | | `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | | `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | +| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | ## Development diff --git a/docs/prompt-history.md b/docs/prompt-history.md new file mode 100644 index 000000000..1fa8eba4f --- /dev/null +++ b/docs/prompt-history.md @@ -0,0 +1,61 @@ +# Prompt history + +Slice 1 of the prompt-history extension (#819 split) ships the storage layer only: +a per-instance JSONL capture store, project identity, and the read/write +primitives later slices build on. The selector UI, deletion/scope drains, and GC +arrive in later slices of the chain. + +## Capture is opt-in + +Recording is **off by default**. Delivered prompts can contain secrets, and the +deletion UI is not shipped yet, so nothing is stored unless you explicitly opt in: + +```bash +GENTLE_PI_HISTORY_CAPTURE=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. + +## 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 add the rebuildable `seed.jsonl`, scope drains/deletes, and GC. + +## 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 or +the deletion UI ships. To erase the store manually while capture is off (or pi +is not running): + +```bash +rm -rf ~/.pi/agent/history # whole store +rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) +``` diff --git a/extensions/history/index.ts b/extensions/history/index.ts index c63993cd1..97ec8f4a4 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -6,6 +6,12 @@ // and slice-4 init sequence (legacy migration + seed bootstrap run once // inside getWriter). Deletion (slice 5) is wired here; GC/compaction // (slice 6) arrives in a later slice. +// +// Capture is OPT-IN while the deletion/privacy behavior is unshipped: +// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=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 { join } from "node:path"; import { homedir } from "node:os"; @@ -988,14 +994,74 @@ function recordsFromEntries( return buildPromptRecords(dedupePromptEntries(entries)); } -export default function promptHistoryExtension(pi: ExtensionAPI) { - // One writer per extension load; see getWriter() for the init order. - // Warm migrate/registry/seed OFF the first-prompt path: the scheduled - // init runs once, immediately after load. A prompt arriving earlier - // falls back to the synchronous lazy init in getWriter(), whose - // writerState guard makes whichever runs second a no-op — bootstrap - // work is never duplicated. + +export interface HistoryDeps { + env?: NodeJS.ProcessEnv; + root?: string; + cwd?: string; + instanceId?: string; + now?: () => number; +} + +/** + * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_CAPTURE 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 until the deletion tooling lands. + */ +export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.GENTLE_PI_HISTORY_CAPTURE?.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 { @@ -1003,12 +1069,14 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); - // Persist every delivered user prompt (write-through, append-only JSONL). - // The local ExtensionAPI stub types handler args as unknown; narrow here. + // 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 ?? "", Date.now()); + 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. diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index 61a2689b3..bb135582a 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -9,7 +9,7 @@ import { projectHash, sessionFilePath, } from "../extensions/history/store.ts"; -import promptHistoryExtension from "../extensions/history/index.ts"; +import promptHistoryExtension, { captureEnabled } from "../extensions/history/index.ts"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); @@ -29,6 +29,29 @@ 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"); @@ -121,3 +144,44 @@ test("the extension entry registers exactly the slice-3 wiring surface", () => { assert.equal(typeof handler, "function"); } }); + +test("captureEnabled is a strict opt-in", () => { + assert.equal(captureEnabled({}), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "0" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "false" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "off" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "yes" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: " 1 " }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "TRUE" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "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_CAPTURE: "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_CAPTURE: "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_CAPTURE; + handler({ prompt: "never written" }); + assert.deepEqual(fileTexts(file), ["kept"]); +}); From 5d85a8fd65854a08fa0308d10ade7942521237ac Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:37 -0300 Subject: [PATCH 31/49] fix(history): make prompt capture opt-in and document the store Review follow-up on the slice-01 PR: the before_agent_start handler recorded delivered prompts by default while the deletion UI is still unshipped, so an intermediate release could accumulate sensitive prompts with no removal path. - Capture is now strictly opt-in via GENTLE_PI_HISTORY_CAPTURE=1|true|on (default off); the switch doubles as the disable path, is checked per prompt, and a disabled session writes nothing - no registry entry, no files. - promptHistoryExtension takes injectable deps (env/root/cwd/ instanceId/now) with one writer closure per extension load. - New tests: strict opt-in matrix, default-off inertness, opted-in capture, disable-leaves-existing-files. - docs/prompt-history.md documents the switch, storage locations, permissions/readers, and disable/removal semantics; the README docs table gains a pointer. --- README.md | 1 + docs/prompt-history.md | 61 +++++++++++++++++ extensions/history/index.ts | 89 +++++++++++++++++++++---- tests/history-session-writer.test.ts | 97 ++++++++++++++++++++++++++-- 4 files changed, 232 insertions(+), 16 deletions(-) create mode 100644 docs/prompt-history.md diff --git a/README.md b/README.md index 9e20d72f4..0207c389c 100644 --- a/README.md +++ b/README.md @@ -880,6 +880,7 @@ To opt out: | `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | | `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | | `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | +| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | ## Development diff --git a/docs/prompt-history.md b/docs/prompt-history.md new file mode 100644 index 000000000..1fa8eba4f --- /dev/null +++ b/docs/prompt-history.md @@ -0,0 +1,61 @@ +# Prompt history + +Slice 1 of the prompt-history extension (#819 split) ships the storage layer only: +a per-instance JSONL capture store, project identity, and the read/write +primitives later slices build on. The selector UI, deletion/scope drains, and GC +arrive in later slices of the chain. + +## Capture is opt-in + +Recording is **off by default**. Delivered prompts can contain secrets, and the +deletion UI is not shipped yet, so nothing is stored unless you explicitly opt in: + +```bash +GENTLE_PI_HISTORY_CAPTURE=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. + +## 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 add the rebuildable `seed.jsonl`, scope drains/deletes, and GC. + +## 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 or +the deletion UI ships. To erase the store manually while capture is off (or pi +is not running): + +```bash +rm -rf ~/.pi/agent/history # whole store +rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) +``` diff --git a/extensions/history/index.ts b/extensions/history/index.ts index f9f379a7c..d6dd97ea6 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT +// Capture is OPT-IN while the deletion/privacy behavior is unshipped: +// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=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 { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -1086,14 +1091,74 @@ function recordsFromEntries( return buildPromptRecords(dedupePromptEntries(entries)); } -export default function promptHistoryExtension(pi: ExtensionAPI) { - // One writer per extension load; see getWriter() for the init order. - // Warm migrate/registry/seed OFF the first-prompt path: the scheduled - // init runs once, immediately after load. A prompt arriving earlier - // falls back to the synchronous lazy init in getWriter(), whose - // writerState guard makes whichever runs second a no-op — bootstrap - // work is never duplicated. + +export interface HistoryDeps { + env?: NodeJS.ProcessEnv; + root?: string; + cwd?: string; + instanceId?: string; + now?: () => number; +} + +/** + * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_CAPTURE 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 until the deletion tooling lands. + */ +export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.GENTLE_PI_HISTORY_CAPTURE?.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 { @@ -1101,12 +1166,14 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); - // Persist every delivered user prompt (write-through, append-only JSONL). - // The local ExtensionAPI stub types handler args as unknown; narrow here. + // 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 ?? "", Date.now()); + 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. @@ -1116,7 +1183,7 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { // Backup pass: enforce the 1000-line limit on graceful shutdown. pi.on("session_shutdown", () => { try { - gcProjectDir(PI_HISTORY_ROOT, CURRENT_CWD); + gcProjectDir(root, cwd); } catch { // GC is best-effort } diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index e0c7ab819..81058d075 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -5,9 +5,13 @@ 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-")); @@ -23,6 +27,32 @@ function fileTexts(file: string): string[] { .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"); @@ -82,9 +112,66 @@ test("two writers own separate files in the same project dir", () => { assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); }); -// Helper kept local: openWriter is the U3 surface under test. -import { openSessionWriter } from "../extensions/history/store.ts"; +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"); +}); -function openWriterForTest(root: string, instanceId: string) { - return openSessionWriter(root, CWD, instanceId); -} +test("captureEnabled is a strict opt-in", () => { + assert.equal(captureEnabled({}), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "0" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "false" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "off" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "yes" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: " 1 " }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "TRUE" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "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_CAPTURE: "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_CAPTURE: "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_CAPTURE; + handler({ prompt: "never written" }); + assert.deepEqual(fileTexts(file), ["kept"]); +}); From 831bbe9689f0d2cec8b4e39ff671b28531834af5 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:37 -0300 Subject: [PATCH 32/49] fix(history): make prompt capture opt-in and document the store Review follow-up on the slice-01 PR: the before_agent_start handler recorded delivered prompts by default while the deletion UI is still unshipped, so an intermediate release could accumulate sensitive prompts with no removal path. - Capture is now strictly opt-in via GENTLE_PI_HISTORY_CAPTURE=1|true|on (default off); the switch doubles as the disable path, is checked per prompt, and a disabled session writes nothing - no registry entry, no files. - promptHistoryExtension takes injectable deps (env/root/cwd/ instanceId/now) with one writer closure per extension load. - New tests: strict opt-in matrix, default-off inertness, opted-in capture, disable-leaves-existing-files. - docs/prompt-history.md documents the switch, storage locations, permissions/readers, and disable/removal semantics; the README docs table gains a pointer. --- README.md | 1 + docs/prompt-history.md | 61 +++++++++++++++++++++++++ extensions/history/index.ts | 64 ++++++++++++++++++++++++--- tests/history-session-writer.test.ts | 66 +++++++++++++++++++++++++++- 4 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 docs/prompt-history.md diff --git a/README.md b/README.md index 9e20d72f4..0207c389c 100644 --- a/README.md +++ b/README.md @@ -880,6 +880,7 @@ To opt out: | `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | | `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | | `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | +| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | ## Development diff --git a/docs/prompt-history.md b/docs/prompt-history.md new file mode 100644 index 000000000..1fa8eba4f --- /dev/null +++ b/docs/prompt-history.md @@ -0,0 +1,61 @@ +# Prompt history + +Slice 1 of the prompt-history extension (#819 split) ships the storage layer only: +a per-instance JSONL capture store, project identity, and the read/write +primitives later slices build on. The selector UI, deletion/scope drains, and GC +arrive in later slices of the chain. + +## Capture is opt-in + +Recording is **off by default**. Delivered prompts can contain secrets, and the +deletion UI is not shipped yet, so nothing is stored unless you explicitly opt in: + +```bash +GENTLE_PI_HISTORY_CAPTURE=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. + +## 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 add the rebuildable `seed.jsonl`, scope drains/deletes, and GC. + +## 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 or +the deletion UI ships. To erase the store manually while capture is off (or pi +is not running): + +```bash +rm -rf ~/.pi/agent/history # whole store +rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) +``` diff --git a/extensions/history/index.ts b/extensions/history/index.ts index d82b413d9..31e5c5755 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -5,6 +5,12 @@ // and the shortcut/command wiring over the slice-1 writer and slice-2 // drains. Legacy migration and seed bootstrap (slice 4), deletion (slice 5), // and GC/compaction (slice 6) arrive in later slices. +// +// Capture is OPT-IN while the deletion/privacy behavior is unshipped: +// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=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 { join } from "node:path"; import { homedir } from "node:os"; @@ -75,7 +81,6 @@ const PREVIEW_WHEEL_Y_LAST = 26; // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); -const AGENT_DIR = join(homedir(), ".pi", "agent"); const CURRENT_CWD = process.cwd(); // Instance identity: one exclusive capture file per pi process. const INSTANCE_ID = randomUUID(); @@ -908,15 +913,62 @@ function recordsFromEntries( return buildPromptRecords(dedupePromptEntries(entries)); } -export default function promptHistoryExtension(pi: ExtensionAPI) { - // One writer per extension load; see getWriter() for the init order. - // Persist every delivered user prompt (write-through, append-only JSONL). - // The local ExtensionAPI stub types handler args as unknown; narrow here. +export interface HistoryDeps { + env?: NodeJS.ProcessEnv; + root?: string; + cwd?: string; + instanceId?: string; + now?: () => number; +} + +/** + * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_CAPTURE 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 until the deletion tooling lands. + */ +export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.GENTLE_PI_HISTORY_CAPTURE?.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: register the project in the advisory + * registry, then open this instance's exclusive capture file. Legacy + * migration and seed bootstrap join this init order in a later slice. + */ + const getWriter = (): SessionWriterState => { + if (!writerState) { + try { + ensureRegistryEntry(root, cwd); + } catch { + // registry is advisory + } + writerState = openSessionWriter(root, cwd, instanceId); + } + return writerState; + }; + + // 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 ?? "", Date.now()); + 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. diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index 61a2689b3..671ca6c50 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -9,7 +9,7 @@ import { projectHash, sessionFilePath, } from "../extensions/history/store.ts"; -import promptHistoryExtension from "../extensions/history/index.ts"; +import promptHistoryExtension, { captureEnabled } from "../extensions/history/index.ts"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); @@ -29,6 +29,29 @@ 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"); @@ -121,3 +144,44 @@ test("the extension entry registers exactly the slice-3 wiring surface", () => { assert.equal(typeof handler, "function"); } }); + +test("captureEnabled is a strict opt-in", () => { + assert.equal(captureEnabled({}), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "0" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "false" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "off" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "yes" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: " 1 " }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "TRUE" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "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_CAPTURE: "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_CAPTURE: "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_CAPTURE; + handler({ prompt: "never written" }); + assert.deepEqual(fileTexts(file), ["kept"]); +}); From e787ce464758bb9883924cf9c468ddd62dc65ff6 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:18:01 -0300 Subject: [PATCH 33/49] chore(readme): remove README delta from history slice The history slice branches must not touch README.md: the docs table lives in main and evolves independently of the extension slices. The opt-in capture documentation stays in docs/prompt-history.md; the README pointer row introduced by the capture-gate commit is dropped and README.md is restored to upstream/main verbatim. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index db19d3529..a8a5ee727 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,6 @@ Start with the product-facing destination, then move into the operational refere | [Telemetry](docs/telemetry.md) | Approved fields and source limitations. | | [Delegated verification](docs/delegated-verification.md) | Practical verification guidance. | | [Skill style guide](docs/skill-style-guide.md) | The package skill contract. | -| [Prompt history](docs/prompt-history.md) | Opt-in capture switch, storage layout, readers, and disable/removal semantics. |

Back to top ↑

From 892da55c51d7eb56d5e9186b329fe9f95de98a81 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:18:44 -0300 Subject: [PATCH 34/49] chore(readme): remove README delta from history slice The history slice branches must not touch README.md: the docs table lives in main and evolves independently of the extension slices. The opt-in capture documentation stays in docs/prompt-history.md; the README pointer row introduced by the capture-gate commit is dropped and README.md is restored to upstream/main verbatim. --- README.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index df59b272e..a8a5ee727 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,6 @@ A bare terminal answers "what is the agent doing?" only with scrollback. gentle- ### el Gentleman — Think before you build -Diagram of el Gentleman turning human intent into clarified scope, a smallest workflow choice, evidence, and a human delivery decision - Say what you need once, then keep moving. el Gentleman helps turn intent into clear scope, a sensible next step, and evidence people can review — without making every task feel like a process meeting. **[Docs →](docs/readme-reference.md#organic-driven-development)** @@ -156,9 +154,7 @@ Model, effort, and who does what should be choices, not accidents. Named profile ### Command palette — Every command, one keystroke away -Command palette with a search field and grouped entries: Configuration, Session, Diagnostics, SDD, and Skills - -Extension commands are only useful if you can find them. `alt+k` opens a curated, grouped palette — Configuration, Session, Diagnostics, SDD, and Skills — searchable by label, command name, or description, showing entries only when they are actually registered. +Extension commands are only useful if you can find them. `alt+k` opens a curated, grouped palette — Configuration, Session, Diagnostics, and Skills — searchable by label, command name, or description, showing entries only when they are actually registered. **[Docs →](docs/gentle-shell.md#command-palette)** @@ -278,13 +274,12 @@ Start with the product-facing destination, then move into the operational refere | Destination | Purpose | | --- | --- | | [gentle-shell reference](docs/gentle-shell.md) | Workspace layout, changes, usage, agents, and todo interactions. | -| [ODD workflow](docs/readme-reference.md#organic-driven-development) · [Technical reference](docs/readme-reference.md) | Everyday work and recovery, optional SDD/OpenSpec, installation, configuration, commands, and contributor detail. | +| [ODD workflow](docs/readme-reference.md#organic-driven-development) · [Technical reference](docs/readme-reference.md) | Everyday work and recovery, installation, configuration, commands, and contributor detail. | | [Review integration](docs/review-integration.md) | The provider/consumer boundary for native review. | | [Native authority architecture](docs/native-authority-architecture.md) | Ownership boundaries and review architecture. | | [Telemetry](docs/telemetry.md) | Approved fields and source limitations. | | [Delegated verification](docs/delegated-verification.md) | Practical verification guidance. | | [Skill style guide](docs/skill-style-guide.md) | The package skill contract. | -| [Prompt history](docs/prompt-history.md) | Opt-in capture switch, storage layout and readability, and disable/removal semantics. |

Back to top ↑

From aae37cb3d22e2cabe11f83719e32d8d65fb433ef Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:19:47 -0300 Subject: [PATCH 35/49] chore(readme): remove README delta from history slice The history slice branches must not touch README.md: the docs table lives in main and evolves independently of the extension slices. The opt-in capture documentation stays in docs/prompt-history.md; the README pointer row introduced by the capture-gate commit is dropped and README.md is restored to upstream/main verbatim. --- README.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index df59b272e..a8a5ee727 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,6 @@ A bare terminal answers "what is the agent doing?" only with scrollback. gentle- ### el Gentleman — Think before you build -Diagram of el Gentleman turning human intent into clarified scope, a smallest workflow choice, evidence, and a human delivery decision - Say what you need once, then keep moving. el Gentleman helps turn intent into clear scope, a sensible next step, and evidence people can review — without making every task feel like a process meeting. **[Docs →](docs/readme-reference.md#organic-driven-development)** @@ -156,9 +154,7 @@ Model, effort, and who does what should be choices, not accidents. Named profile ### Command palette — Every command, one keystroke away -Command palette with a search field and grouped entries: Configuration, Session, Diagnostics, SDD, and Skills - -Extension commands are only useful if you can find them. `alt+k` opens a curated, grouped palette — Configuration, Session, Diagnostics, SDD, and Skills — searchable by label, command name, or description, showing entries only when they are actually registered. +Extension commands are only useful if you can find them. `alt+k` opens a curated, grouped palette — Configuration, Session, Diagnostics, and Skills — searchable by label, command name, or description, showing entries only when they are actually registered. **[Docs →](docs/gentle-shell.md#command-palette)** @@ -278,13 +274,12 @@ Start with the product-facing destination, then move into the operational refere | Destination | Purpose | | --- | --- | | [gentle-shell reference](docs/gentle-shell.md) | Workspace layout, changes, usage, agents, and todo interactions. | -| [ODD workflow](docs/readme-reference.md#organic-driven-development) · [Technical reference](docs/readme-reference.md) | Everyday work and recovery, optional SDD/OpenSpec, installation, configuration, commands, and contributor detail. | +| [ODD workflow](docs/readme-reference.md#organic-driven-development) · [Technical reference](docs/readme-reference.md) | Everyday work and recovery, installation, configuration, commands, and contributor detail. | | [Review integration](docs/review-integration.md) | The provider/consumer boundary for native review. | | [Native authority architecture](docs/native-authority-architecture.md) | Ownership boundaries and review architecture. | | [Telemetry](docs/telemetry.md) | Approved fields and source limitations. | | [Delegated verification](docs/delegated-verification.md) | Practical verification guidance. | | [Skill style guide](docs/skill-style-guide.md) | The package skill contract. | -| [Prompt history](docs/prompt-history.md) | Opt-in capture switch, storage layout and readability, and disable/removal semantics. |

Back to top ↑

From e81e194fb3bc5d26bd0ea7a5f784c5bfb9e1fad5 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:20:48 -0300 Subject: [PATCH 36/49] chore(readme): remove README delta from history slice The history slice branches must not touch README.md: the docs table lives in main and evolves independently of the extension slices. The opt-in capture documentation stays in docs/prompt-history.md; the README pointer row introduced by the capture-gate commit is dropped and README.md is restored to upstream/main verbatim. --- README.md | 1036 +++++++++++------------------------------------------ 1 file changed, 207 insertions(+), 829 deletions(-) diff --git a/README.md b/README.md index 0207c389c..a8a5ee727 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,44 @@ -# gentle-pi + -[![npm](https://img.shields.io/npm/v/gentle-pi?color=blue)](https://www.npmjs.com/package/gentle-pi) -[![pi package](https://img.shields.io/badge/Pi-package-6f42c1)](https://pi.dev/packages/gentle-pi) -[![license](https://img.shields.io/npm/l/gentle-pi?color=blue)](LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/Gentleman-Programming/gentle-pi?style=flat&color=yellow)](https://github.com/Gentleman-Programming/gentle-pi/stargazers) -[![Gentle-AI](https://img.shields.io/badge/Gentle--AI-ecosystem-ff69b4)](https://github.com/Gentleman-Programming/gentle-ai) -[![Gentleman Programming](https://img.shields.io/badge/by-Gentleman%20Programming-black)](https://github.com/Gentleman-Programming) -[![YouTube](https://img.shields.io/badge/YouTube-Gentleman%20Programming-red?logo=youtube&logoColor=white)](https://www.youtube.com/c/GentlemanProgramming) -[![Discord](https://img.shields.io/badge/Discord-community-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/gentleman-programming-769863833996754944) -[![SDD/OpenSpec](https://img.shields.io/badge/SDD-OpenSpec-00ADD8)](#sddopenspec-flow) -[![Subagents](https://img.shields.io/badge/Pi-subagents-brightgreen)](#what-it-adds) +
+ gentle-shell — Ecosystem, Agent, One shell +
+ +

gentle-shell™

+ +

Your coding agent for controlled development in the workspace you lead.

+ +

+ npm + Pi-native package + MIT license + GitHub stars + Last commit +

+ +

+ + Website +  ·  + Quickstart +  ·  + Docs +  ·  + Wiki + +

+ +
+ +

Your terminal can run an agent. Your workspace should help you lead it.
gentle-shell is your coding agent, bringing your changes, tasks, and engineering workflow together—built for Pi.

-**[Gentle-AI website](https://gentle-ai.gentlemanprogramming.com/)** • **[Gentle-AI wiki](https://gentle-ai-wiki.gentlemanprogramming.com/)** • **[Engram](https://engram.gentlemanprogramming.com/)** +

One workspace. A coding agent you direct. A workflow you can inspect.

+ +

BUILT FOR PI  ·  Coding-agent workspace  ·  Focused agents  ·  ODD

+ +

+ ★ Star gentle-shell on GitHub +

@@ -27,934 +54,285 @@ - Star History Chart + Star History Chart -
- -**Turn Pi from a powerful coding agent into a controlled development harness.** - -`gentle-pi` installs **el Gentleman** in Pi: a senior-architect operating layer for Spec-Driven Development, focused subagents, strict TDD evidence, reviewable work units, safety guards, project/user skill discovery, and bounded native review. - -Pi already has strong tools. `gentle-pi` adds the discipline for using them well, keeps review evidence Git-derived instead of agent narration, and leaves delivery decisions to ordinary repository policy. - -`gentle-pi` is the Pi-native package from the [Gentle-AI ecosystem](https://github.com/Gentleman-Programming/gentle-ai), built by [Gentleman Programming](https://github.com/Gentleman-Programming): the broader open-source project for turning AI coding agents into disciplined engineering environments with SDD workflows, skills, memory integrations, model routing, and review guardrails across multiple agents. - -> **Trademark notice:** The gentle-pi name and logo are trademarks of Alan Buscaglia. The MIT License applies to the code; it does not permit implying endorsement or official affiliation. See [TRADEMARKS.md](TRADEMARKS.md). - -Follow the project and the community around it: - -- GitHub: [Gentleman-Programming](https://github.com/Gentleman-Programming) -- YouTube: [Gentleman Programming](https://www.youtube.com/c/GentlemanProgramming) -- Community Discord: [Gentleman Programming](https://discord.com/invite/gentleman-programming-769863833996754944) - -Startup intro collaboration: thanks to [@aporcelli](https://github.com/aporcelli) for [`pi-gentle-startup`](https://github.com/aporcelli/pi-gentle-startup), which inspired the clean-screen startup animation, compact runtime panel, and pink visual treatment. - -## The problem - -Most coding-agent sessions fail for operational reasons, not model reasons: - -- the agent jumps into code before requirements are clear; -- architectural decisions disappear into chat history; -- one request quietly becomes a huge multi-area diff; -- tests run late, or not at all; -- reviewers get handed a wall of changes; -- subagents are available, but the parent session has no orchestration discipline; -- project skills exist, but the model forgets to load them. - -`gentle-pi` fixes the workflow around the agent. - -## What it adds - -| Capability | What it does | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -| **el Gentleman persona** | Makes Pi behave like a senior architect and teacher, not a generic chatbot. Spanish responses use Rioplatense voseo by default; neutral mode is saved globally with project overrides. | -| **Configurable startup intro** | Adds a rose/text-logo startup intro, compact runtime panel, color presets, and commands to hide or show the decorative parts. | -| **Work routing discipline** | Small tasks stay inline. Context-heavy exploration can be delegated. Large or risky changes go through SDD/OpenSpec. | -| **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `onboard`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, `sync`, and `archive`. | -| **Lazy SDD preflight** | Resolves SDD mode, artifact store, delivery strategy, and review budget once per session; prompts only when a choice is genuinely unresolved. | -| **Subagent orchestration** | Keeps one parent session responsible while child agents explore, implement, test, or review with focused context. | -| **Strict TDD support** | When project config declares a test command, apply/verify phases must record RED → GREEN → TRIANGULATE → REFACTOR evidence. | -| **Closed choice prompts** | Per-option hover/click/wheel in fullscreen; keyboard selection in either TUI mode. | -| **Native pointer regions** | Compose hover, press, click, and wheel behavior around public TUI components. | -| **Agent overlay close control** | Adds a header close button that adapts to available width. | -| **Reviewer protection** | Surfaces review workload risk before a task turns into an oversized PR. | -| **Per-agent model assignment** | Pi-native modal for assigning stronger or cheaper models to specific SDD/custom agents. | -| **Skill discovery registry** | Maintains `.atl/skill-registry.md` from project and user skills so review/comment/PR workflows do not silently miss the right skill. | -| **Skill creation workflow** | Provides the `gentle-ai-skill-creator`/`gentle-ai-skill-improver` skills, `/skill-creation` prompt, and packaged style guide for LLM-first skills. | -| **Delivery skills** | Includes issue-first PRs, chained PRs, work-unit commits, cognitive docs, comment writing, and Judgment Day review. | -| **Bounded native review** | Freezes one candidate, dispatches only controller-selected lenses, and records native authority. Review outcomes are informational; delivery follows ordinary repository policy. | -| **Verified native runtime** | Provisions the exact package-local Gentle AI v2.7.0 runtime: signed, SHA-256-pinned release archives on Darwin/Linux and a Go SumDB-verified source build on Windows x64/arm64. It validates package-local integrity and rejects PATH, global, sibling, symlink, and mode fallbacks. | -| **Runtime safety** | Blocks destructive shell commands, asks for confirmation for sensitive operations, and blocks direct read/write/edit access to sensitive paths. | - -## Native pointer regions - -Compose pointer behavior around public `Text`, `Box`, or custom content without making it a keyboard target: - -```ts -const scope = createNativePointerScope(); -const openInput = scope.wrap(new Text("Open input", 0, 0), { - onClick: () => { - openInputEditor(); - return { handled: true }; - }, -}); -const panel = new Container(); -panel.addChild(openInput); -const observer = scope.createMouseObserver(() => tui.requestRender()); -``` - -Pass `observer` around the root's native mouse dispatch; reuse `panel` as custom or overlay content. -Pointer input is fullscreen-only. Regions preserve a consuming child's native result and do not focus -`Text`, activate on press or wheel, synthesize outside leave events, or alter terminal tracking. -Callers own keyboard policy, theme state, and business actions. - -**Migration note:** Do not enable `pi-tool-cards` and `quiet-tools` together: Pi rejects duplicate `bash`, `read`, `edit`, and `write` registrations. Disable or remove the standalone package during migration; gentle-pi does not change those package registrations or delete that repository. The global fullscreen setting described below is a separate install-time change. - -## Install - -```bash -pi install npm:gentle-pi@0.14.0 -``` - -### Install-time fullscreen - -For this release, a successful postinstall in Pi's **global npm-managed** `agent-home/npm/node_modules/gentle-pi` installation persists `"tuiMode": "fullscreen"` in `agent-home/settings.json`, preserving other settings. Agent home resolves through `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent`. Use `/settings` to switch back to regular; rerunning this recognized postinstall resets it to fullscreen. Existing project overrides still take precedence. - -Project-local installs (`pi install -l`), Git/local-path installs, temporary packages, development checkouts, ordinary npm consumers, and pnpm symlink-store packages do **not** receive this change. Updates or installs that do not execute postinstall cannot reassert it; this is not a universal install/update guarantee or a change to historical releases. - -Malformed/nonobject JSON, symlink/nonregular settings, unsafe paths, or a busy settings lock fail without replacing settings. The installer coordinates with Pi's cooperative settings lock and uses atomic replacement; it does not guarantee safety against noncooperating writers or malicious concurrent directory replacement. Already-fullscreen settings remain byte-identical. Native installation failure leaves settings untouched; `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1` skips only native provisioning, not the recognized global fullscreen setting. - -### RDD version policy - -Native RDD started in `gentle-pi` `v0.15.0` on 2026-07-10 with bounded review transactions. Every release from `v0.15.0` onward is part of the unstable RDD development line. New releases will continue improving RDD until the project declares the line stable. The stable version for normal use without native RDD is the last preceding release, `v0.14.0`. - -```bash -# Stable version without native RDD -pi install npm:gentle-pi@0.14.0 - -# Latest released RDD build (unstable) -pi install npm:gentle-pi@latest -``` - -The latest RDD package installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for stable pins such as the current v2.7.0; raw prerelease binaries only under a prerelease pin). Windows x64 and arm64 build the exact `v2.7.0` source tag with a local Go 1.25.10+ toolchain, a sealed Go environment, `GOTOOLCHAIN=local`, and `GOSUMDB=sum.golang.org`; it does not download Go automatically. Windows provenance is Go-toolchain plus SumDB evidence and postinstall tamper detection, **not** Authenticode or protection against a malicious joint binary-and-manifest replacement. Package-private locks coordinate cooperative concurrent or crashed installers; their tombstones fail closed. A malicious same-user process with write access to package-private `node_modules` is outside that protocol because it can already replace package code, binary, or manifest, and portable Node has no pathname-delete CAS. It never uses `PATH` or a global `gentle-ai` installation. For development or offline installs only, set `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1`; native review operations then fail closed with an actionable `package-local-binary-missing` error until the package is reinstalled normally. - -Recommended companion packages: - -```bash -pi install npm:pi-intercom -pi install npm:gentle-engram -pi install npm:pi-web-access -pi install npm:pi-lens -pi install npm:@juicesharp/rpiv-ask-user-question -``` - -Then start Pi in a project: - -```bash -pi -``` - -`gentle-pi` provides SDD agents as global Pi runtime assets, not per-project setup. The first SDD flow in a session still runs a one-time SDD preflight for preferences; for natural-language requests, el Gentleman decides when SDD is needed and runs the explicit preflight first. - -## Quick start - -```text -/gentle:status Check package, SDD assets, OpenSpec, and global model config. -/gentle:doctor Run read-only diagnostics for SDD assets, config, tools, and guards. -/gentle:sdd-preflight Run or reuse the session SDD preflight explicitly. -/gentle-sdd-init Create or refresh openspec/config.yaml (openspec/both stores only). -/gentle:models Assign global model/effort routing to SDD/custom agents. -/gentle:persona Switch between gentleman and neutral persona modes. -/gentle:background-subagents Show or set the managed background-subagents policy, with its deciding source. -/gentle:banner Configure startup rose, text logo, and color preset. -``` - -Typical flow: - -1. Open Pi in your repo. -2. Run `/gentle:status`. -3. Run `/gentle-sdd-init` once per project, or when test/project capabilities change. This also runs the session SDD preflight. -4. For a substantial change, ask Pi to use SDD. Natural-language requests are classified by the parent agent, not by brittle runtime regexes. -5. Review the phase artifacts instead of trusting floating chat context. - -## Core workflow - -1. **Install and inspect.** Install `gentle-pi`, open Pi in the target repository, then run `/gentle:status` or `/gentle:doctor`. -2. **Plan when risk justifies it.** Small work stays direct; substantial work uses SDD with Engram, OpenSpec, or both so requirements and decisions survive compaction. -3. **Build with evidence.** One focused writer implements the approved scope. When Strict TDD is available, apply and verify preserve RED → GREEN → TRIANGULATE → REFACTOR evidence. -4. **Use runtime-owned RDD when available.** Gentle AI supplies any runtime-specific review instructions; this package does not recreate a lifecycle in documentation or prompts. -5. **Deliver through ordinary repository policy.** Review and Judgment Day evidence is informational only; Pi never creates a delivery route, authorization, target rederivation, or receipt gate. - -> **Trust what the system can derive, not what an agent claims.** Agents analyze the candidate. The package-local Gentle AI runtime owns scope, risk, findings, and review authority. Review outcomes inform delivery; ordinary repository policy decides delivery commands. Dangerous-command safety and destructive-review consent remain independent. See Gentle AI's [review authority threat model](https://github.com/Gentleman-Programming/gentle-ai/blob/main/docs/review-authority-threat-model.md) and [Chapter 21 — Verifiable Trust](https://the-amazing-gentleman-programming-book.vercel.app/en/book/Chapter21_Verifiable-Trust). - -## How the harness decides what to do - -`gentle-pi` routes through the smallest safe workflow: - -| Request shape | Harness | -| --------------------------------------------------------------------------- | ---------------------------- | -| Small, clear, local edit | Inline direct work. | -| Unknown codebase area or context-heavy investigation | Focused subagent delegation. | -| Large, ambiguous, architectural, product-facing, or high-review-risk change | SDD/OpenSpec flow. | - -The goal is not ceremony. The goal is to avoid accidental chaos. Once a task stops being small, delegation is mandatory. - -### Delegation triggers - -`gentle-pi` keeps the parent session thin and delegates at the narrowest useful point. When the Pi Subagents extension is installed, the preferred runtime is the `subagent_*` tool family because it runs the user's configured project/global subagent definitions and preserves history/background behavior. With the background policy on, delegations default to background mode: the terminal stays free and each result comes back as a message that starts a new turn; task mode is reserved for delegations that must ask the user something mid-flight. If those tools are unavailable, the parent should fall back to Pi's native `Agent` tool or another available delegation mechanism. The requirement is delegation; the runtime is capability-dependent. - -| Trigger | Required behavior | -| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| Reading 4+ files to understand a flow | Launch `scout`, `context-builder`, or the closest read-only mapping subagent. | -| Touching 2+ non-trivial code files | Delegate one writer; do not continue inline unless delegation is unavailable. | -| Commit, push, or PR after code changes | Follow the loaded native instruction, or ordinary repository policy when none is supplied. | -| Wrong cwd, worktree/git accident, merge recovery, confusing test/env issue | Stop, preserve the affected scope, and investigate separately before resuming. | -| Long monolithic session with accumulating complexity, roughly 20 tool calls, 5 exploratory reads, or 2 non-mechanical edits | Pause and delegate the remaining work, or stop and explain the exact blocker. | - -The intended balanced loop for a bounded bugfix is: - -```text -parent git/status + clarify → one worker writes authorized fixes → focused verification → parent reports -``` - -`scout`/`context-builder` save parent context by compressing broad exploration. `worker` preserves a single writer thread. Any RDD-specific actor behavior belongs to the runtime instruction supplied by Gentle AI, not to this README. - -### Review authority recovery and reset safety - -Legacy pre-graph authority is never migrated. `gentle_review inspect` reports an exact repository-bound destructive reset challenge for legacy corruption; after that fresh interactive authorization, RESET and RECOVER_LOCK route to the audited native `gentle-ai review reclaim` operation and RECOVER routes to native `gentle-ai review recover`, so every destructive transition is executed and audited by the native authority store. Native inputs the request did not carry return a `native-input-required` envelope instead of being invented. Existing graph-v1 ordinary lineages remain readable and gate-validatable but are read-only; Judgment Day remains mutable on graph-v1. - -`gentle_review abandon`, `quarantine-legacy`, and `reconcile-authority` remain explicit v2.1.11 maintenance routes. Pi derives and displays the published nine-line `gentle-ai.review-abandon-authorization/v2` binding only for a caller-specified compact lineage, revision, snapshot identity, and discarded-work summary (captured lens results, findings presence, evidence-record presence); the native CLI re-derives non-terminal compact-v2 eligibility and the exact discarded work before accepting it. Legacy quarantine accepts only `historical findings freeze changed unrelated transaction state` with disposition `quarantine-malformed-freeze-event` and uses its exact eight-line binding. Both require fresh interactive approval and fail closed headlessly. - -`gentle_review reconcile-authority` accepts one predecessor lineage and revision, one successor lineage and revision, an actor, and a reason. Pi derives the exact seven-line `gentle-ai.review-reconcile-authorization/v1` binding, or appends exactly `anomalies=unchanged_target,malformed_recovery_authorization` for the published dual anomaly in that order. Native code re-derives every anomaly; malformed bindings, changed revisions, unavailable native support, cancellation, and native refusal fail closed through typed envelopes. - -Reconciliation is intentionally narrow: native code may quarantine only the bound invalid compact-v2 recovery successor and persists the returned audit record; the predecessor stays untouched. Pi never recreates the retired `prepare-supersession`/`supersede` authority writer and never falls back to RESET or RECOVER. - -`gentle_review repair-legacy-alias` is the sole v2.1.11 route for `unsupported historical v1 operation alias`. The model supplies only lineage, actor, and reason. Pi freshly reads the native inventory, derives the canonical repository, exact legacy revision, fixed diagnostic, and fixed `quarantine-approved-historical-alias` disposition, displays the LF-only eight-line binding, and requires a new interactive approval. Native re-derives eligibility and quarantines rather than rewriting or validating the historical chain. - -`review dispose-result` is deliberately unsupported by Pi pending a separate design; it has no controller operation or fallback. All maintenance routes fail closed headlessly and never auto-run against legacy history. - -Native lifecycle status remains informational. VALIDATE does not authorize delivery; commit, push, PR, and release commands follow ordinary repository policy. Recovery grants no new budget, and legacy graph bundle export/import is retired. - -This is the post-U8 boundary, not the final architecture. [Issue #191](https://github.com/Gentleman-Programming/gentle-pi/issues/191) is the immediate final unit in this same delivery: extract the remaining Pi command-projection and lifecycle-gate surface from `review-transaction.ts`, repoint runtime enforcement, then delete only dependencies proven unreachable without weakening graph-v1 Judgment Day. The branch-wide High-tier 4R runs after that extraction, before the single size-exception PR. - -### Review Lens Selection (architecture reference) - -`reviewer` is not an installed subagent name. It is historical routing vocabulary, not a static instruction. When a runtime-specific Gentle AI instruction applies, it alone determines whether any concrete lens is used: - -| Context | Review lens | -| --- | --- | -| Clear naming, structure, maintainability, small refactors | `review-readability` | -| Behavior, state, tests, determinism, regressions | `review-reliability` | -| Shell/process integration, partial failures, recovery, degraded dependencies | `review-resilience` | -| Security, permissions, data exposure/loss, architecture, dependencies | `review-risk` | -| Large PR, hot path, or >400 changed lines | Full 4R: `review-risk`, `review-resilience`, `review-readability`, `review-reliability` | - -The former compact controller classified documentation/comment/formatting-only changes as zero-lens, standard changes as one dominant lens, and higher-risk paths as full 4R. This describes compatibility architecture only; never derive or run those choices from this README. - -### Review authority architecture (reference only) - -Gentle AI dynamically supplies runtime-specific RDD instructions. `gentle-pi` does not define an RDD lifecycle, command route, approval path, recovery sequence, or fallback. The historical compact-controller material below documents architecture and compatibility boundaries only; it is not an operator instruction. - -Concretely: `gentle-pi` mirrors the Gentle AI provider contract bundle's `orchestration/pi.md` locally (`contracts/review-provider-contract-mirror/`, verified against the mirror lock's recorded SHA-256 before injection) and injects that mirrored text into the primary session's system prompt at session start. Gentle AI does not write anything into Pi's system prompt; when the mirrored contract is absent, unreadable, or fails digest verification, `gentle-pi` invents no fallback lifecycle. - -```mermaid -flowchart TD - A["Clarify scope and acceptance criteria"] --> B{"Choose the smallest safe workflow"} - B -->|Small and local| C["Inline implementation"] - B -->|Context-heavy or multi-file| D["Focused subagent"] - B -->|Large or architectural| E["SDD phase artifacts"] - C --> F["Implement with test evidence"] - D --> F - E --> F - F --> G["Independent verification"] - G --> H["Target-scoped native status"] - H -->|Ambiguous or corrupted| X["Blocked: native maintainer action"] - H -->|Unrelated| I["START freezes candidate, scope, tier, lenses, and budget"] - - subgraph Ordinary_review["Ordinary bounded review"] - I --> R["reviewing"] - R --> J["Run each selected lens once"] - J --> K{"Severe candidate-caused blocker?"} - K -->|No| A1["approved"] - K -->|Yes| C1["correction_required"] - C1 --> C2["Forecast bounded correction"] - C2 --> C3["Apply scoped fix"] - C3 --> V["validating"] - V -->|Validator passes| A1 - V -->|Fails, malformed, or out of scope| E1["escalated"] - end - - A1 --> O["Review outcome is informational"] - E1 --> O -``` - -VALIDATE is informational. Commit, push, PR, and release commands follow ordinary repository policy; RDD never authorizes, rewrites, consumes review state for, or blocks them. Dangerous-command safety and destructive-review consent remain independent. - -Native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.7.0 executable, independently hashes it, then negotiates `gentle-ai.review-integration/v2` outside the repository. Capabilities are cached by that executable digest. Every START, target status, FINALIZE, validate, and BIND-SDD request passes the same contract identifier. Negotiated envelopes decode exactly against the vendored schemas; `recover` routes only the provider-selected `action_disposition`, and optional additions require a future compatible schema/minor that the provider explicitly advertises and the consumer negotiates. - -Contract `/v2` replaces the Base64 `candidate_diff` reviewer transport of `/v1` with immutable `base_tree`/`candidate_tree` plus an ordered `changed_path_manifest` and never an inline patch. `gentle-pi` negotiates `/v2` only, with no dual-lane fallback; the cutover landed as one atomic commit against gentle-ai v2.2.2 (tracked by the `migrate-review-integration-v2` change), and the `/v1` schemas stay packaged because the `/v2` schemas `$ref` into their fragments. This provider contract version is unrelated to Pi's own internal "compact-v2" review-authority naming used below — the shared digit is coincidental, not a version pairing. - -Target status owns `current_target`, `unrelated`, `ambiguous`, and `corrupted` applicability and returns one native action. Pi does not reconstruct ordinary authority from provider-private files or choose a lineage from repository-wide history. Restart recovery rebuilds only the derived candidate view from the native Git/content projection, including intended-untracked paths, symlinks, and immutable gitlink identities. Native failure envelopes retain their exact mutation outcome, replayability, required inputs, request digest, and next action. After an unknown or lost mutating result, Pi calls target status before any replay decision and returns only the provider-declared action. - -Once the pinned gentle-ai runtime (currently v2.7.0) has written review authority, rollback MUST preserve every native store and receipt and MUST NOT run a downgraded binary against that repository. Disable the Pi route or roll forward to a compatible authority-aware release instead; deleting authority data or reinstalling an older binary is not a rollback path. - -### FINALIZE wrapper input - -`gentle_review` accepts `input` as a JSON-serialized object string. For initial results, provide `review_result.lens_results[]`; each selected lens appears exactly once with `lens`, `findings`, and non-empty `evidence`. A clean lens uses `findings: []`. Pair `final_evidence` with exactly one of `final_verification_passed` or `final_verification_outcome`. - -```json -{ - "review_result": { - "lens_results": [ - { - "lens": "review-reliability", - "findings": [], - "evidence": ["complete candidate reviewed"] - } - ] - } -} -``` - -This is the Pi wrapper contract, not the native CLI file contract. The native command receives separate `--result`, `--refuter`, `--validation`, and `--evidence` files from the wrapper. - -START derives the complete Git/untracked snapshot, lineage, persisted `low | medium | high` tier, zero/one/four lenses, authored changed lines, and correction budget `min(200, ceil(original_changed_lines / 2))`. Generated `testdata/golden/**` stays in snapshot identity but does not count as authored risk lines. - -Every finding requires `evidence_class`, `causal_disposition`, and concrete changed-hunk, candidate-created-path, differential-test, or before/after proof. Missing IDs are assigned natively and selected-lens results are canonicalized deterministically. - -Actor output is untrusted data and cannot authorize transitions, fixes, receipts, gates, or delivery. - -Only severe `introduced`, `behavior-activated`, or `worsened` findings with valid proof enter correction IDs. `pre-existing` and `base-only` become follow-ups; `unknown`, insufficient, malformed, or inconclusive severe claims escalate. WARNING and SUGGESTION are informational. - -Deterministic blockers need no refuter. Inferential blockers use exactly one complete read-only refuter batch. - -Refuter proof may be independent concrete reproduction evidence; it does not need to duplicate reviewer `proof_refs`. Invalid, empty, malformed, missing, duplicate, unknown, or inconclusive refuter output escalates without a replacement refuter. - -When native IDs are assigned to inferential findings, the first FINALIZE returns their canonical rows and a content-derived request hash without mutation; the second replays identical lens input with that hash and one complete refuter batch. - -Ordinary permits one correction transaction within the original budget. FINALIZE requires a positive forecast before editing and derives actual correction lines from Git; one targeted validator and final verification close that transaction. Initial lenses are never rerun, while frozen findings and genesis scope remain unchanged. - -The validator checks original criteria and correction regression only and cannot add scope or findings. Final evidence is hashed during FINALIZE, never at START. - -Compact ordinary has five states: `reviewing`, `correction_required`, `validating`, `approved`, and `escalated`. - -The validator cannot change claims, add findings, request fixes, launch actors, or request another attempt. A failed correction escalates instead of opening another review budget. - -Compact authority uses content-derived CAS under the Git common directory. Exact retries are idempotent; stale/semantic retries, terminal mutation, and same-lineage graph-v1/compact-v2 ambiguity fail closed. - -Trust boundary: The local orchestrator and same-user process are trusted to execute selected actors and submit their exact outputs. Native code owns scope, risk, IDs, canonicalization, state, receipts, and gates, and rejects malformed or inconsistent results structurally and causally. Malicious same-user host/process authenticity is a non-goal because that actor can replace the extension or mutate local authority; externally trusted attestation would require a separately privileged signer/service and is not claimed. - -Ordinary ends only as `approved` or `escalated`. +
-Judgment Day starts only when explicitly requested and replaces ordinary review for that lineage. +Built for Pi. Shaped by Gentle-AI. -Judgment Day starts with exactly two blind judges and zero refuters. - -Judgment Day alone may iterate discovery and scoped re-judgment, for at most two rounds. - -Findings surviving round two escalate; no third-round transition exists. - -Native review mode and the two candidate choices remain provider-owned lifecycle semantics. For a validated `consent/v3` envelope in the interactive parent TUI, Pi displays those two choices unchanged and adds a clearly separate host-owned action: **Run this review and allow reviews for this Pi session**. Only direct human selection creates this process-memory grant. Its scope is the coordinating live SessionManager session and the canonical Git common-directory identity of the selected repository: it runs the current envelope's exact provider `granted` invocation through the existing one-shot `answer-consent` path, then does the same for later fresh validated envelopes in sibling worktrees of that same clone, including package-owned children. An unrelated repository requires a separate explicit human grant. Reload preserves it; `/tree` retains it; revoke removes the current repository grant; quit, new, resume, fork, or process restart removes all session grants. The command's `status` action reports the in-memory state without changing provider mode or authority. - -The host grant is held only in a schema-checked `globalThis[Symbol.for(...)]` WeakMap registry keyed by session and canonical Git common-directory digest. It is never written through session entries, settings, environment variables, or the old asked latch. A package-owned Gentle Agents child can request one bounded parent-owned stdio authorization for its own validated pending ordinary START; it sends only that target's canonical repository digest, and the parent rechecks the live task, digest, and current parent session grant before the child replays its exact provider grant locally. No candidate bytes, provider vectors, paths, local child grant, or delivery authority crosses that channel. External or legacy `pi-subagents` launchers do not receive this channel and remain unsupported. Headless/RPC/unsupported UI, external processes, model prose, tool arguments, cancellation, identity drift, malformed identity, and uncertain native results cannot create or consume the grant. Native workspace binding remains canonical and target-specific; session-wide consent never authorizes an unselected target or an unrelated repository. The grant conveys no review verdict, forecast/cost approval, acknowledgement, maintenance, delivery, or cross-repository authority. When the host cannot resolve the choice, `gentle_review` returns the original unresolved two-choice provider envelope unchanged for the normal lossless relay. SessionManager binding isolates simultaneous SDK sessions; Pi does not claim universal same-process agent-principal isolation because the SDK exposes no principal identity. - -When RDD is on and an agent loop ends with an unreviewed candidate, `gentle-pi` sends one read-only reminder pointing the agent back to `gentle_review {"operation":"inspect"}` before it reports completion. This nudge is idempotent (at most once per target identity per session), never fires for a headless session or a subagent's own loop, and never runs START or answers consent itself. Pi treats a child `agent_end` as a latest-answer update, not completion: queued retry, compaction, follow-up, required verification, and legitimate post-correction verification remain live until `agent_settled`. It does not claim ready or RDD-ready first, but this ordering rule does not impose a universal full-suite requirement or turn a receipt into a delivery gate. At session start, `gentle-pi` records the current target identity as a baseline, so a candidate that already existed before the session began (the user's own prior work, not this session's output) never draws the reminder. - -Review outcomes and receipt state are informational; commit, push, pull-request, and release delivery follow ordinary repository policy. No one-shot command authorization, publication-target revalidation, or receipt gate is required for delivery, and Pi does not inspect RDD mode or native authority to decide a Bash delivery command. - -Dangerous-command safety remains independent and authoritative. Destructive-review-maintenance consent remains separate from delivery. Review operations, informational VALIDATE, and SDD perform no commit, push, pull-request, release, or publication operation. - -The Pi host relay bounds each locked-down reviewer subprocess by materialized prompt size rather than by one fixed number: a 15-minute floor plus 15 minutes per mebibyte of prompt, clamped to a 2-hour ceiling. Set `GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS` to a positive decimal to replace that derived bound with your own; malformed values are ignored and the same 2-hour ceiling still applies, so no configuration turns a foreground finalize into an unbounded child process. A reviewer killed by the bound reports `pi-host-relay-timeout` with the elapsed time and the limit it was measured against, and it explicitly does not ask you to relaunch the identical slot — that would re-spend the model tokens to reach the same wall. Reviewer results admitted earlier in the same finalize stay admitted and are not re-run. - -Adversarial review roles (the refuter and the targeted validator) are never Pi-authored: the provider renders self-contained `review.capture-refuter` / `review.capture-validation` vectors and Go runs its own locked-down `pi` process on them. Package agent assets remain a package-managed isolated installation. Project and user overrides may shadow a package asset; `gentle-pi` preserves those definitions and does not claim their effective permissions are package-compliant. - -## SDD/OpenSpec flow - -```text -init - ↓ -explore → research (optional) → proposal → spec ─┬→ design ─┐ - └─────────┴→ tasks → apply → verify → sync → archive -``` - -The main loop is intentionally file-backed when you choose `openspec` or `both`: - -```text -planning artifacts implementation evidence canonical update -────────────────── ─────────────────────── ──────────────── -proposal/spec/design/tasks → apply-progress/verify-report → sync-report → archive-report -``` - -For substantial work, the parent session coordinates the flow and each phase writes artifacts. That gives you: - -- explicit requirements and non-goals; -- design decisions that survive compaction; -- task plans reviewers can reason about; -- implementation evidence; -- verification reports; -- sync reports that update canonical specs while keeping the change active; -- archive notes for future agents. - -### OpenSpec artifact model - -`gentle-pi` treats OpenSpec-compatible behavior as part of the harness. You do not need to install the external OpenSpec CLI/package for SDD. - -In file-backed modes, canonical accepted behavior lives in `openspec/specs/`, while active changes carry deltas under `openspec/changes/`: - -```text -openspec/ -├── specs/ # accepted source of truth -│ └── {domain}/spec.md -└── changes/ - ├── {change}/ # active work - │ ├── proposal.md - │ ├── specs/{domain}/spec.md # full spec or delta spec - │ ├── design.md - │ ├── tasks.md - │ ├── apply-progress.md - │ ├── verify-report.md - │ └── sync-report.md - └── archive/YYYY-MM-DD-{change}/ # immutable audit trail -``` - -Delta flow: - -```text -openspec/changes/{change}/specs/{domain}/spec.md - │ - │ sdd-sync applies ADDED / MODIFIED / REMOVED - ▼ -openspec/specs/{domain}/spec.md - │ - │ sdd-archive moves the completed change folder - ▼ -openspec/changes/archive/YYYY-MM-DD-{change}/ -``` - -When a canonical spec already exists, change specs use requirement operation sections: - -```markdown -## ADDED Requirements - -## MODIFIED Requirements - -## REMOVED Requirements -``` - -`MODIFIED` requirements must include the full requirement block, including still-valid scenarios, because sync replaces the canonical block by requirement name. `sdd-sync` syncs file-backed deltas into `openspec/specs/{domain}/spec.md` while keeping the change active; `sdd-archive` then moves the synced change to `openspec/changes/archive/YYYY-MM-DD-{change}/`. - -Engram-only mode is different by design: Engram is working memory and does not maintain a canonical spec merge layer. Use `openspec` or `both` (hybrid file + memory persistence) when you need canonical spec evolution. + -## SDD preflight and project files +

+ +

-`gentle-pi` does not require SDD agents to be copied into every project. The package ensures global Pi SDD assets exist under the Pi agent home and treats project-local files only as overrides/debug copies. Slash SDD flows such as `/sdd-*`, `/gentle-sdd-init`, and the explicit `/gentle:sdd-preflight` command run a lazy preflight and resolve session-scoped SDD preferences. For natural-language requests, the parent agent decides whether the work should use SDD and must run/reuse `/gentle:sdd-preflight` before continuing. +## Features -```text -~/.pi/agent/agents/sdd-*.md -~/.pi/agent/chains/sdd-*.chain.md -~/.pi/agent/gentle-ai/support/strict-tdd*.md -``` +--- -Preflight values resolve in this order: explicit current user/session choice, valid persisted preference, capability or already-selected strategy constraint, canonical default, then a prompt only when genuinely unresolved. Resolved values are reused for later SDD flows in the session. +### gentle-shell — Your coding agent, in the workspace you lead -Canonical values are `auto` execution mode, `openspec` artifact store, `ask-on-risk` delivery strategy, and a `400` changed-line review threshold. The delivery strategy domain is `ask-on-risk`, `auto-chain`, `single-pr`, or `exception-ok`; `chain_strategy` remains deferred until chaining is selected. `exception-ok` requires explicit `size:exception` acceptance and is never inferred. Consent, authorization, security, destructive/publishing, interactive phase approval, and ambiguous-scope gates remain human-controlled. +gentle-shell running a live agent session: a header row with branch, model, and context gauge above the transcript, with status, changes, and todo cards in the right rail -It does **not** overwrite existing global assets unless you explicitly run: +A bare terminal answers "what is the agent doing?" only with scrollback. gentle-shell turns your Pi session into a workspace: agent orchestration, live changes and runtime status, usage monitoring for supported provider accounts, and built-in diff views — so you lead the work instead of chasing it. -```text -/gentle:install-sdd --force -``` +

gentle-shell in action. Screenshot from Gentle-AI.

-Manual preflight command: +**[Docs →](docs/gentle-shell.md)** -```text -/gentle:sdd-preflight -``` +--- -## Skill registry +### el Gentleman — Think before you build -`gentle-pi` keeps a local registry at: +Say what you need once, then keep moving. el Gentleman helps turn intent into clear scope, a sensible next step, and evidence people can review — without making every task feel like a process meeting. -```text -.atl/skill-registry.md -``` +**[Docs →](docs/readme-reference.md#organic-driven-development)** -The registry scans project and user skill roots, not package-owned skills. It exists to catch workflow skills that are present on disk but not visible in Pi's injected skill list. +--- -It scans common roots such as: +### Focused agents — Context with a return path -```text -./skills -.opencode/skills -.claude/skills -.gemini/skills -.cursor/skills -.github/skills -.codex/skills -.qwen/skills -.kiro/skills -.openclaw/skills -.pi/skills -.agent/skills -.agents/skills -.atl/skills -~/.pi/agent/skills -~/.config/agents/skills -~/.agents/skills -~/.kimi/skills -~/.config/opencode/skills -~/.config/kilo/skills -~/.claude/skills -~/.gemini/skills -~/.gemini/antigravity/skills -~/.cursor/skills -~/.copilot/skills -~/.codex/skills -~/.codeium/windsurf/skills -~/.qwen/skills -~/.kiro/skills -~/.openclaw/skills -``` +Diagram of one parent session directing bounded map, implementation, and verification work and receiving evidence back -Behavior: +Bring in help without losing the thread. Focused package-owned Pi agents can map a codebase, implement a bounded change, or verify it, while one parent stays accountable for the scope, the decisions, and the final summary. -- `.atl/` is added to `.gitignore` when needed; -- the registry refreshes on session start; -- startup refresh is skipped when Pi starts with `--no-skills` / `-ns`, `--no-skill-registry`, or `GENTLE_PI_NO_SKILL_REGISTRY=1`; -- `/skill-registry:refresh` forces regeneration; -- a best-effort watcher refreshes when skill files change; -- the registry indexes skill names, full descriptions, scope, and exact `SKILL.md` paths without copying skill body rules. +**[Docs →](docs/readme-reference.md#how-the-harness-decides-what-to-do)** -Skill discovery is a guardrail, not a workflow router: it helps Pi load the right skill without forcing extra ceremony. +--- -`gentle-pi` also ships package-owned `gentle-ai-skill-creator` and `gentle-ai-skill-improver` skills plus the `/skill-creation` prompt for creating or updating project skills. Both skills use `docs/skill-style-guide.md` as their normative style contract. The workflow checks for duplicates, keeps `SKILL.md` concise, uses one-line trigger-rich frontmatter, and reminds maintainers to refresh the registry after skill changes. +### ODD — The everyday workflow -Packaged skills include `cognitive-doc-design`, `comment-writer`, `gentle-ai-judgment-day`, `gentle-ai-skill-creator`, `gentle-ai-skill-improver`, and the other delivery/review skills under `skills/`. SDD init is installed as the packaged `sdd-init` runtime agent under `assets/agents/` and refreshed with the SDD assets. +Organic Driven Development as seven numbered steps: Authorize, Explore, Resolve uncertainty, and Classify across the top row; Classify forks, so small understood work stays light while substantial work gets step five, Track, with one feature document; both paths converge on Implement task by task and then Close, above a dashed band marking that one feature document mirrored in Engram lets work resume across sessions -Compatibility: the package keeps the existing skill folders (`skills/branch-pr`, `skills/cognitive-doc-design`, `skills/comment-writer`, `skills/judgment-day`, `skills/skill-creator`, `skills/skill-registry`, and `skills/work-unit-commits`) but their exported frontmatter names are prefixed to avoid collisions with user/global skills. Treat former package names such as `branch-pr`, `cognitive-doc-design`, `comment-writer`, `judgment-day`, `skill-creator`, `skill-registry`, and `work-unit-commits` as legacy aliases in prose; runtime skill selection should use `gentle-ai-branch-pr`, `gentle-ai-cognitive-doc-design`, `gentle-ai-comment-writer`, `gentle-ai-judgment-day`, `gentle-ai-skill-creator`, `gentle-ai-skill-registry`, and `gentle-ai-work-unit-commits`. +**Organic Driven Development (ODD)** is the everyday path: the agent explores before changing anything, clarifies only real decisions, and keeps small understood work small. Substantial, authorized work gets one recoverable feature document — mirrored in memory when available — so progress, evidence, and the next step survive an interruption; checks follow the configured TDD mode. -Delegation contract: +**[Docs →](docs/readme-reference.md#organic-driven-development)** -- parent/orchestrator resolves project/user skills from the registry and passes matching paths under `## Skills to load before work`; -- SDD subagents still use their assigned executor/phase skill; -- during normal runtime, subagents should not independently discover additional project/user `SKILL.md` files or the registry; -- fallback loading is degraded self-healing and must be reported via `skill_resolution` as `fallback-registry`, `fallback-path`, or `none`. +--- -## Persona modes +### Native review — Review the exact change -```text -/gentle:persona -``` +Diagram showing one frozen candidate passing through risk-scoped native review to an outcome, while human delivery choices stay separate -| Persona | Behavior | -| ----------- | ------------------------------------------------------------------------------------------------------------- | -| `gentleman` | Senior architect, teacher, direct technical feedback, Rioplatense Spanish/voseo when the user writes Spanish. | -| `neutral` | Same discipline, warmer professional language, no regional expression. | +Review the exact change, not a moving target. Native review keeps one candidate in view, returns risk-scoped evidence, and can surface a bounded correction path. You still decide what happens next in your repository. -Saved globally at: +**[Docs →](docs/review-integration.md)** -```text -~/.pi/gentle-ai/persona.json -``` +--- -A project can still override the global default with: +### Gentle Changes — Every edit, attributed and reviewable -```text -.pi/gentle-ai/persona.json -``` +Gentle Changes viewer: worktree accordion with per-file status on the left, the captured diff with line counts on the right, and a keyboard hint row -`/gentle:persona` writes the global config and updates an existing project override when one is present, so the current project does not stay stale. Run `/reload` or start a new Pi session after switching persona. +You should not have to run `git status` to find out what your agent did. Gentle Changes captures the successful write and edit tool calls from the current session and its owned subagents — no repository scans, no background polling — and shows them in a two-pane viewer with per-file line counts and an honest **diff unavailable** when an external edit breaks continuity. Coverage stops at those tools, so shell commands and failed runs leave no row, and a missing entry never proves a clean tree. `alt+g` opens it; `o` drops the real file into your editor. -## Model and effort assignment +**[Docs →](docs/gentle-shell.md#browse-captured-diffs)** -```text -/gentle:models -``` +--- -The modal discovers: +### Gentle Agents — Parallel work with a live view -- project agents in `.pi/subagents/`, `.pi/agents/`, and `.agents/`; -- user agents in `~/.pi/agent/subagents/`, `~/.pi/agent/agents/`, and `~/.agents/`. +Gentle Agents overlay showing a completed subagent thread with model, tokens, and elapsed columns, and the structured handoff it returned -When applying routing, project agents write runtime profiles to `.pi/subagents.json`; global and built-in agents write profiles to `~/.pi/agent/subagents.json`. +Delegating work should not mean losing it. Every subagent runs as its own process with a live card above the editor — model, tokens, cost, elapsed — and `alt+a` opens the full view with retained threads, stop controls, and history restored on resume. A child can ask you a question as an ordinary dialog, and background results come back as cards that start a new turn — nothing polls. -Recommended model/effort shape: +**[Docs →](docs/gentle-shell.md#gentle-agents)** -| Agent kind | Recommended model | Recommended effort (`thinking`) | -| -------------------------- | ---------------------------------------------------- | ------------------------------- | -| Explore, proposal, archive | Fast and cheap is usually enough. | `off` to `low` | -| Spec, design, tasks | Strong reasoning model. | `medium` to `high` | -| Apply | Strong coding and tool-use model. | `medium` to `high` | -| Verify / review | Strong fresh-context model. | `high` | -| Tiny utilities | Inherit active/default model unless they bottleneck. | `inherit` | +--- -Saved globally at: +### Profiles and model routing — One deliberate decision per knob -```text -~/.pi/gentle-ai/models.json -``` +Profiles view: profile list on the left, orchestrator model and effort on the right, with per-role profile routing and effective current routing -Existing project-local `.pi/gentle-ai/models.json` files are still read as a legacy fallback when no global model config exists, but `/gentle:models` writes the shared global config. +Model, effort, and who does what should be choices, not accidents. Named profiles route the orchestrator atomically and independently from packaged and review roles; a repository can pin its profile so its subagents stop following the globally active one, and the panel always shows the routing the runtime actually uses. -Inside `/gentle:models`, press `x` to export the saved routing to `~/.pi/gentle-ai/models.export.json`, or `r` to restore from that file after confirmation. Export uses a versioned envelope and restore writes the normal `models.json` shape before applying routing to agents. +**[Docs →](docs/readme-reference.md#agent-model-profiles)** -Config shape (per agent): +--- -```json -{ - "sdd-design": { - "model": "anthropic/claude-sonnet-4", - "thinking": "high" - }, - "sdd-archive": { - "model": "openai/gpt-5-mini" - } -} -``` +### Command palette — Every command, one keystroke away -Legacy string entries are still accepted and treated as `model`-only config. +Extension commands are only useful if you can find them. `alt+k` opens a curated, grouped palette — Configuration, Session, Diagnostics, and Skills — searchable by label, command name, or description, showing entries only when they are actually registered. -## Gentle Shell +**[Docs →](docs/gentle-shell.md#command-palette)** -Gentle Shell is the visual layer gentle-pi puts on top of pi. It follows the Gentle themes: one border language, champagne titles, rose for whatever is alive. +--- -In fullscreen at 140 columns or wider, the right sidebar scrolls **✿ Gentle-Pi ✿ → Status → Changes → Agents → TODO** together. The one-line heading is horizontally centered within the usable rail width, with pink flowers and normal white text in the Gentleman themes. Colors follow the active theme; no artwork scaling or custom fonts are used. Narrow/mobile terminals and regular mode retain bottom widgets without the sidebar heading. The original rose and text logo remain in the main chat startup intro. +### Also in the box -The status bar replaces pi's three-line footer with a single line of segments: +| Component | What it does | +| :--- | :--- | +| Startup and runtime panel | A configurable gentle-shell entry point and visible runtime state for Pi. | +| Skills and delivery guidance | Package skills for documentation, issue work, PRs, reviews, and reviewable work units. | +| Model, effort, persona, and profile controls | Explicit knobs for how Pi routes and presents work. | +| Safety boundaries | Guards around destructive operations and sensitive-path handling. | +| Optional companion packages | Extra capabilities you may choose to add; persistent memory is **not** bundled with `gentle-pi`. | +| Fullscreen workspace layout | Header row plus a scrolling Status → Changes → TODO rail on wide terminals. | +| Live status bar and prompt petal | One-line gauge, cost, and statuses; the petal shows `working` and `queued`. | +| Parent ↔ subagent communication | Delegate, steer, reply, and cross-session notification within your local profile. | +| Native interactive tools | Built-in questions, choices, and review captures — no third-party dependency. | +| Gentle Todo | A plan card that turns amber when the model lets it go stale. | +| Subscription usage | Per-window meters and resets for supported provider accounts. | +| Gentle notices | Gentle AI calls and review reminders as cards in the transcript. | -```text -✿ gentle-pi ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium ⟡ ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ⟡ MCP: 3 servers enabled Release notes -``` +> **Every component, skill and preset: [Full breakdown →](docs/gentle-shell.md)** -- Context is a gauge, not a number. It turns amber at 80% and red at 95%; after compaction it shows `?%` until the next response. -- Cost carries `sub` when the active model runs on a subscription login. -- Statuses other extensions publish through `setStatus` are appended as trailing segments; the session name sits at the right edge. -- On narrow terminals the session name is dropped first, then trailing segments, before the line is truncated. +--- -The prompt wraps pi's editor in a rounded frame with a petal that shows what the agent is doing: +### What's new in v3.5 -```text -╭─ ✿ working ──────────────────────────────────────────╮ -│ type, or / for commands │ -╰──────────────────────────────────────────────────────╯ -``` +The [v3.5.1 release](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) makes Gentle Shell runnable on its own: -- The petal is still while pi waits, spins with a `working` label while the agent works, and turns amber with a `queued` label when messages are waiting behind the current turn. pi's own "Working" row above the editor is hidden, since the frame already says it. -- The frame uses the theme's border color over the panel background, so the prompt reads as one panel with the cards around it; the editor's scroll indicators stay inside the frame. -- The hint appears only while the editor is empty. -- If another extension already installed a custom editor, Gentle Shell leaves it alone. +- **Standalone launcher:** `npm i -g gentle-pi` installs `gentle-shell`, which opens Pi with the Gentle Shell package loaded from its own home (`~/.gentle-shell/agent`) or, with `--link`, from your existing `~/.pi/agent`; `gentle-shell install npm:` and the other pi subcommands run against the selected home. A bundled or `PATH` pi is used, never a modified one. +- **Link mode take-over:** when `~/.pi/agent` already declares gentle-pi as a path package, the launcher takes over extension loading (`--no-extensions` plus explicit `-e` for every other declared package and loose extension) so tools never register twice. +- **Interactive RPC hosts:** with `GENTLE_SHELL_INTERACTIVE_HOST=1` and `--mode rpc`, ask-user tools use pi's RPC dialogs and gentle-agents publishes live subagent activity for the desktop app. See the [reference](docs/readme-reference.md#interactive-rpc-hosts). -Changes across this session's registered worktrees show up below the editor and as an aggregate `±N` next to the session branch in the bar: +--- -```text -✎ 3 files · +42 −7 · extensions/gentle-shell.ts, lib/shell-bar.ts, tests/x.test.ts · /gentle:changes -``` +

Back to top ↑

-- Each registered root shows **all** dirty files: plain `git diff` against HEAD plus untracked files, including edits that predate this session. There are no baselines or file-level attribution filters. -- The canonical session cwd root is included automatically. Successful standard `read`, `write`, `edit`, `grep`, `find`, and `ls` calls register their target worktree after completion. Failed calls, shell command text, and prose never register roots. Only roots sharing the session's Git common directory are accepted. -- For opaque shell use or worktrees used earlier, call `session_worktree_register` with `{"path":"/path/to/worktree"}`. Registration is explicit, canonicalized, and deduplicated; unrelated dirty siblings remain invisible without an ignored-roots list. -- The root registry persists in Pi custom entries (`gentle-pi.session-worktree/v1`). Exit/resume and `/reload` restore the same session UUID; `/tree` keeps roots session-wide. New sessions, `/fork`, and `/clone` ignore inherited registrations with another UUID. Clean roots stay registered but hidden until dirty; missing/prunable roots are skipped safely. Ephemeral `--no-session` runs cannot persist across exit. -- Counts refresh after every tool call, at the end of each turn, and every 5 seconds in the background, so edits made from nvim or another agent show up without touching pi. `GENTLE_PI_SHELL_CHANGES_WATCH_MS` changes the interval; `off` leaves only the tool-driven refresh. Outside a git repository the widget stays hidden. -- On narrow terminals the file list is dropped before the summary is truncated. +

+ +

-`/gentle:changes` or `alt+g` opens the framed two-pane viewer. Dirty worktrees are accordion groups in the left pane, labeled with branch and directory basename (`detached` when there is no branch). Expand groups to reveal indented changed files; multiple groups can stay expanded. The right pane previews the selected file's lazy-loaded diff, or shows the selected group's full directory and summary. Clean, bare, missing, and prunable roots remain hidden; untracked-only roots are included. +## Get started -- `j`/`k` or up/down traverse visible groups and files, keeping the selection in view. On a group, `enter`, space, or right arrow toggles expansion. Left arrow or backspace moves a file selection to its parent, or collapses the selected group. `ctrl+j`/`ctrl+k` or `pgdn`/`pgup` scroll the diff; `esc` or `q` closes the overlay. -- Opening, pressing `r`, and the background/overlay refresh cadence scan only registered roots. Worktree discovery supplies branch labels, never registration. No changes in registered roots means no widget and an informational notice instead of an overlay. -- While the overlay is open, git is polled every 2 seconds, so edits made from nvim, another agent, or a checkout show up in place. Expansion and selection stick to the raw worktree root and file path across refreshes; a diff reloads when its counts move. -- `GENTLE_PI_SHELL_CHANGES_KEY` rebinds the shortcut (pi key syntax, for example `ctrl+shift+g`); `off` disables it. On macOS, `alt+g` needs the terminal to send Option as Meta. -- On a file row, `o` (or `enter`) opens the selected file in `$VISUAL` or `$EDITOR`, with the selected worktree as the editor's working directory, and returns to pi when the editor exits. Diff lookup and caches are also scoped to that root; identical relative filenames in other worktrees cannot share a diff. -- Untracked files are diffed against an empty file so new files show their full content. +> **Naming transition:** The product is called `gentle-shell`; the current npm package and repository remain `gentle-pi` until migration. -Subscription usage shows in the bar after the cost, and `/gentle:usage` opens a panel with every window per provider: +### Path A: standalone `gentle-shell` (recommended, no pi changes) -```text -✿ gentle-pi ⟡ … ⟡ $9.49 sub ⟡ codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31% -``` +`gentle-shell` opens Pi with the Gentle Shell package loaded, without installing it into your pi agent or editing its `settings.json`. -- For Codex, usage comes from the same account usage endpoint the Codex CLI reads, using the OAuth token pi already holds. It is fetched at session start, at most every 5 minutes after a turn, and on `r` in the panel. Rate-limit headers on SSE responses are picked up too. -- For Claude Pro/Max, usage arrives in the rate-limit headers of every response, so the 5h and weekly windows appear after the first turn. -- The bar names the subscription it shows (`codex`, `claude`) and always follows the active model. The panel puts the active provider first, marked with the petal, and says why it has no data when it does not: API-key providers have no subscription windows, Claude reports after the first response, Codex waits for a fetch. -- Only the plan name and the windows are kept; account details in the payload are discarded. -- Gauges turn amber at 80% and red at 95%, like the context gauge. +```bash +npm i -g gentle-pi -Gentle notices are drawn as cards: the same rounded frame as the prompt, with the left rail and the title in the tone of the notice and the rest of the frame in the theme's border color. +# Own home, never touches your pi install +gentle-shell -```text -╭─ ✿ Gentle AI · review preflight ─────────────────────────────────────╮ -│ Receipt-driven development is enabled, and this worktree holds an… │ -╰──────────────────────────────────────────────────────────────────────╯ +# Reuse your pi sign-ins, models and chats instead +gentle-shell --link ``` -- Every call into the gentle-ai binary and every `gentle_review` tool renders as a card under the rose, `🌹︎ Gentle AI`: the rail is amber while it runs, green when it finished, red when it failed; the expand key sits in the top rule once the tool finished, and the collapsed result shows only its line count. Reviewer captures name their lens (`review capture · risk`; the group lists all four). -- The review preflight reminder renders as a card in the transcript with the expand key in its top rule. -- An active dev-binary override shows above the editor at startup, in amber, naming the binary and its digest, and leaves with the first prompt; an invalid override shows in red with the reason. -- Subagents draw their own card; see Gentle Agents below. - -### Gentle Agents - -The current package requires Pi 0.85.1 or newer (development tests pin 0.85.1). Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. - -The `subagent_*` tools and the agents card replace the third-party subagents package (remove `npm:pi-subagents-j0k3r` from your pi packages; while it is still installed the tools stay unregistered and a warning says so at startup). Agent definitions and settings are the ones you already have: markdown agents in `~/.pi/agent/agents/`, `~/.pi/agent/subagents/`, `/.pi/agents/`, `/.pi/subagents/` (project beats global, `subagents/` beats `agents/`), and `subagents.json` at the global and project level (`default_model`, `default_effort`, `default_mode`, `model_profiles`, `stall_timeout_ms`, `max_concurrency`, `history_max_tasks`). - -Agent paths follow `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent` for definitions, config, history, child sessions, and transcripts. These overrides select the agent profile; they do not sandbox project or shared global resources. +`gentle-shell` alone starts in its own home, `~/.gentle-shell/agent`, and sets that home up on first run — no separate step. Gentle Shell keeps its own home with the Gentle AI companion packages and no conflicting plugins; gentle-pi itself always stays this launcher's own copy, never one installed into the home; your pi install is untouched. That home also defaults to the Gentleman-Cute theme unless you set your own. `gentle-shell --link` reuses `~/.pi/agent` as-is, is never auto-provisioned, and never has its theme touched. -```text -╭─ ❀ Agents · 1 active · 1 done ─────────────────────────────── 1m24s ╮ -│ ✓ sdd-explore map footer data sources gpt-5.6-terra · 34k · $0.27 · 25s │ -│ ◐ sdd-apply write gentle-shell footer gpt-5.6-terra · 12k · $0.09 · 41s │ -╰──────────────────────────────────────────────────────────────────────────────╯ +```bash +# Re-run provisioning by hand, e.g. to see the full install output +gentle-shell setup ``` -Every subagent is its own `pi --mode rpc` child process, so the terminal never runs subagent work: the host reads JSON lines, applies each one as a small delta to a bounded per-task thread, and notifies only the listeners of that task. A task-mode child's question (`ctx.ui.select`, `confirm`, `input`, `editor`) reaches you as an ordinary pi dialog; a background child's question is dismissed. Subagents have no automatic total execution timeout: a long-running child remains live while it continues emitting RPC events. A silent child still times out through the configurable `stall_timeout_ms` watchdog (default four minutes). Closing pi stops the children that are still running. - -- `subagent_list_agents`, `subagent_run` (`agent`, `task`, `label?`, `context?`, `workspace_root?`, `mode?` task or background), `subagent_status`, `subagent_result`, `subagent_list_tasks`, `subagent_reply` (one current-session reply to a live child query), `subagent_cancel`, `subagent_send_message` (steer a running child), `subagent_continue` (resume a finished task in its own session). -- `subagent_run.workspace_root` selects an existing worktree in the session's Git clone. Validation happens before queueing; the child runs at that canonical root. Successful OS spawn registers the root in the originating parent session, including delayed queued launches, even without an active shell listener. Failed spawns do not register. `subagent_continue` retains the previous task's cwd; status and task details expose it. -- A background task's result comes back to the model as a `gentle-agents.result` message, drawn as a rose card, and starts a new turn when the agent is idle; the model never polls. -- A configured child can call `subagent_parent_message` with bounded, well-formed Unicode text. Notifications retain their existing admission semantics. A `kind: "query"` waits for one strictly correlated `subagent_reply` for at most 30 seconds; each child has at most four pending queries, and disconnect, timeout, stop, and send failure settle each request once. The current parent session alone can reply. The first admitted task-mode query ends the original tool response while its child keeps running; its eventual non-cancelled completion returns once as a follow-up only if that same session is still active. Channel closure prevents later sends and automatic retry is not provided. Peer transport, offline delivery, retries, and broadcasts are unsupported. -- The card shows the active session's tasks only: after `/new` or `/resume` the earlier session's tasks leave it and come back with their session. Finished rows stay for one minute (three at most), and the card spends at most a quarter of the terminal (three to eight rows) on tasks; beyond that the rest fold into one `… N more · alt+a to view` line so the editor never leaves the screen. Questions and running work keep their rows first. -- `/gentle:agents` or `alt+a` opens a full-terminal overlay. At 60+ columns, the split view shows groups/tasks beside the retained semantic thread; uppercase `F` or **Fullscreen** expands that thread. At 12–59 columns, click a current subagent directly to inspect its thread; in All sessions, first select its orchestrator. `Enter`/`Tab` also enter a narrow selection. **Back** or `Escape` returns one level, closing only at the root; **Close** or `q` closes globally without cancelling children. Selection and manual thread scrolling survive Back and resize. -- Mouse controls take priority over keyboard hints: **Follow** (`f`), **Open session** (`o`), **Stop** (`s`, legacy `c`, owned active tasks only), and **Scope** (`a`). A compact footer's `>` cycles through actions. Scope switches between this session's direct active children and all open orchestrators, including idle ones. Open writes a markdown transcript for `$EDITOR`, not a resumed child session. `j`/`k` move through lists or scroll an expanded thread; `ctrl+j`/`ctrl+k` and Page Down/Up page the thread. In Pi fullscreen mode, the wheel scrolls the viewport under the pointer; regular terminal mode does not capture mouse input. Below 12 columns or three rows, only a bounded Close cell remains; zero-sized terminals render nothing. -- The thread displays all retained Text, Thinking, Note, and Tool content without an additional presentation cap; existing store limits and truncation markers still apply. Only the selected task is subscribed while the overlay is open. -- Thread entries are presented as labeled Text, Thinking, Note, or Tool blocks; tool blocks show their status and nonempty output. -- Current scope has no orchestrator wrapper and excludes every terminal task. All sessions discovers open Pi instances sharing the same agent profile, even across repositories; it does not infer open sessions from retained tasks. Directory headings support left/right and mouse expansion, and cannot stop or open a task. Peer children and their retained threads are read-only: no local stop, editor-open, or continuation routing, and no import into the local task store. -- Presence refresh is paged while the overlay is open. Graceful shutdown withdraws an instance; after abrupt closure its last heartbeat may remain visible for up to 15 seconds plus the time to complete the next directory refresh. A recent heartbeat is a heuristic, not proof that a process is alive. Same-profile, same-user processes share retained activity text; this is not an authorization channel. -- `alt+s` confirms stopping the current active or queued subagents owned by the current process. `GENTLE_PI_AGENTS_STOP_KEY` rebinds it; `off` disables it. -- Finished tasks are written to `~/.pi/agent/gentle-agents/tasks/` (one JSON per task, newest `history_max_tasks` kept, default 200) and come back on demand for `subagent_result` and `subagent_continue`, never as overlay history. Child sessions live under `~/.pi/agent/gentle-agents/sessions/`. -- `ctrl+shift+a` collapses the card to its first row (`GENTLE_PI_AGENTS_KEY`), `GENTLE_PI_AGENTS_VIEW_KEY` rebinds the overlay, `GENTLE_PI_AGENTS_PI` overrides the pi command used for children, and `GENTLE_PI_AGENTS=0` disables the tools and the card. - -### Gentle Todo +`gentle-shell setup` installs the same companion packages gentle-ai provisions into a regular Pi, into this home only, then removes the one package that conflicts with gentle-pi's own `ask_user_question` tool (gentle-ai #4820). The first `gentle-shell` launch in a home already runs this automatically; `setup` is for re-running it by hand. See **[First run](docs/readme-reference.md#first-run-in-an-isolated-or-custom-home)** for the opt-out (`GENTLE_SHELL_NO_AUTO_SETUP=1`) and failure behavior. -The `todo` tool and its card replace the third-party todo extension (remove `npm:@juicesharp/rpiv-todo` from your pi packages; sessions written by it replay into the new card). - -```text -╭─ ❀ Todos · 1 of 3 ──────────────────────────────────────╮ -│ ✓ Add quiet tool rendering │ -│ ◐ Fix quiet tools conflict · fixing conflict │ -│ ○ Show git bash tails │ -╰─────────────────────────────────────────────────────────╯ +```bash +# Make --link the default +gentle-shell home link ``` -Three things keep the list current, which a static tool description cannot: +Every other argument is forwarded to pi unchanged, for example `gentle-shell --mode rpc` or `gentle-shell -p "..."`. Full flags, env vars, and modes: **[launcher reference](docs/readme-reference.md#gentle-shell-launcher)**. -- `write` replaces the whole list in one call, so the model rewrites the plan instead of patching it; `add`, `update`, `clear`, and `list` remain for single moves. -- Every turn's system prompt carries the open tasks and the rules: in_progress before starting, done right after finishing, update before ending the turn. -- A list that goes two turns untouched while tasks stay open turns amber with `stale · N turns`, and the prompt says so, so the model brings it up to date. +### Path B: inside an existing pi -A finished list stays on screen for the turn it finished in and clears at the next. `ctrl+shift+t` collapses the card to the task in progress (`GENTLE_PI_TODO_KEY` rebinds it, `off` disables it); `GENTLE_PI_TODO=0` disables the tool and the card. +Install the stable release into an existing pi agent, restart Pi, then synchronize the installed assets. -Set `GENTLE_PI_SHELL=0` to keep pi's built-in footer and editor. - -## Commands - -| Command | What it does | -| -------------------------------- | ------------------------------------------------------------------- | -| `/gentle:status` | Shows package, SDD asset, OpenSpec, and global model config status. | -| `/gentle:doctor` | Runs read-only diagnostics for SDD assets, model/persona config, memory tools, and safety guards. | -| `/gentle:models` | Opens global model + effort assignment UI. Press `x` to export and `r` to restore saved routing. | -| `/gentle:persona` | Switches global persona mode, with project override support. | -| `/gentle:background-subagents` | Shows or sets the managed background-subagents policy (`status\|enable\|disable`), naming the source that decided it. | -| `/gentle:telemetry` | Shows or changes the local Gentle AI telemetry trigger (`status\|enable\|disable\|preview`). | -| `/gentle:banner` | Configures startup banner rose, text logo, and color preset. | -| `/gentle:toggle-rose` | Toggles the startup rose. | -| `/gentle:toggle-text-logo` | Toggles the startup text logo. | -| `/gentle:banner-color` | Selects a startup banner color preset. | -| `/gentle-sdd-init` | Initializes or refreshes `openspec/config.yaml` (openspec/both stores only). | -| `/gentle:install-sdd` | Repairs missing global SDD runtime assets without overwriting files. | -| `/gentle:install-sdd --force` | Force-refreshes installed global SDD assets. | -| `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. | -| `/skill-creation` | Creates or updates an LLM-first skill using the packaged `gentle-ai-skill-creator` contract and style guide. | - -Package-owned global SDD runtime assets are also refreshed automatically on session start when `gentle-pi` changes. Project-local `.pi/agents` and `.pi/chains` remain manual overrides and are never overwritten by startup refresh. - -### Background subagents policy +```bash +# Published stable release: v3.5.1 +pi install npm:gentle-pi@3.5.1 -Background delegation is off unless you turn it on. The policy is user-owned: only an explicit `/gentle:background-subagents enable` or `disable` writes it, and Pi automation never toggles it. +# Restart Pi, then run: +gentle-ai sync -```text -/gentle:background-subagents Report the effective policy, the deciding source, and the resolved capability. -/gentle:background-subagents enable Write "on" to the global file. -/gentle:background-subagents disable Write "off" to the global file. +# Start Pi in your project +pi ``` -Four sources can decide the policy, and the first hit wins: - -| Priority | Source | Notes | -| -------- | ------------------------------------------------- | ------------------------------------------------------------ | -| 1 | `/.pi/gentle-ai/background-subagents.json` | Project file. Outranks everything, including a global write. | -| 2 | `/background-subagents.json` | Global file, written by `enable`/`disable`. `configHome` honors `GENTLE_PI_CONFIG_HOME` and defaults to `~/.pi/gentle-ai`. | -| 3 | `GENTLE_PI_BACKGROUND_SUBAGENTS` | Exactly `on` or `off`. Any other value is ignored. | -| 4 | Built-in default | `off`. | - -Both files use the strict shape `{"schema":"gentle-pi.background-subagents/v1","policy":"on"}`. A file that is present but malformed fails closed to `off` and is **not** skipped in favor of a lower-priority source, so a typo in the project file disables background subagents rather than silently handing the decision to the global file. The command reports that case as a warning instead of an ordinary `off`. - -Because the project file outranks the global one, `enable` still writes the global file but reports plainly when a project file keeps the effective policy unchanged. The resolved capability (`ready` or `absent`) reports whether `subagent_run` is actually callable in this session; a policy of `on` with capability `absent` means Gentle Agents is disabled or the retired subagents package is still installed. - -Startup banner settings remain global in `banner.json` under `GENTLE_PI_CONFIG_HOME` (default `~/.pi/gentle-ai`). Existing `showRose` and `showTextLogo` opt-outs independently control the main startup artwork; both default to enabled. Changes apply on the next session or `/reload`. Color presets are `pink` (default), `cyan`, `yellow`, and `green`. The static sidebar heading is independent of these preferences and follows the active theme. - -Startup flag: +See the [v3.5.1 release notes](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) for version-specific changes. ```text -pi --no-skill-registry -``` - -Use it when you want skills available normally but do not want Gentle AI to refresh/watch `.atl/skill-registry.md` on startup. `pi -ns` / `pi --no-skills` also skip the registry startup work because Pi is already disabling skill loading. - -## Included skills - -- `gentle-ai` — harness discipline for controlled Pi work. -- `gentle-ai-branch-pr` — issue-first PR preparation. -- `gentle-ai-chained-pr` — split oversized changes into reviewable PR chains. -- `work-unit-commits` — commits as reviewable work units. -- `gentle-ai-judgment-day` — blind dual review, fixes, and re-judgment. -- `cognitive-doc-design` — documentation that reduces cognitive load. -- `comment-writer` — concise, warm, postable collaboration comments. -- `gentle-ai-issue-creation` — issue workflow with checks before creation. -- `gentle-ai-skill-creator` — create LLM-first skills with valid frontmatter. -- `gentle-ai-skill-improver` — audit and upgrade existing LLM-first skills. - -## Memory - -`gentle-pi` does **not** provide persistent memory by itself. - -For memory, install the companion package: - -```bash -pi install npm:gentle-engram +/gentle:status +/gentle:doctor ``` -When memory tools are actually active, el Gentleman can save decisions, bug fixes, discoveries, user prompts, and session summaries across Pi sessions. +> **RDD is opt-in:** enable native receipt-driven development only through an explicit `/gentle:review-mode enable` decision. -Memory contract for SDD delegation: +> **Fullscreen installation note:** a recognized global installation persists Pi’s `"tuiMode": "fullscreen"` setting. Project-local and other install paths do not receive that change. -- parent/orchestrator owns memory retrieval and passes selected context into subagent prompts; -- subagents should not independently search memory during normal runtime unless explicitly instructed to retrieve a specific artifact or observation; -- subagents should save significant discoveries, decisions, bug fixes, and completed SDD phase artifacts before returning when memory tools are available; -- in memory/hybrid mode, SDD artifacts use stable topic keys such as `sdd//proposal`, `sdd//spec`, `sdd//design`, `sdd//tasks`, `sdd//apply-progress`, and `sdd//verify-report`. +> **Interactive RPC hosts:** the desktop app sets `GENTLE_SHELL_INTERACTIVE_HOST=1` automatically, without touching your Pi config — see the [installation reference](docs/readme-reference.md#interactive-rpc-hosts). -## Telemetry +For prerequisites, source-checkout instructions, full install behavior, and release policy, use the **[installation reference](docs/readme-reference.md#install)**. For everyday work, describe the outcome and follow [ODD](#odd--the-everyday-workflow). -`gentle-pi` does not collect anything itself. [gentle-ai](https://github.com/Gentleman-Programming/gentle-ai) owns anonymous usage telemetry end to end — install and heartbeat events, what fields are sent, rate limiting, and every opt-out. See its README/docs for the exact contract. +

Back to top ↑

-At session start, for a primary session only (never for a named or SDD sub-agent), Gentle Pi asks the local `gentle-ai` binary to send its own telemetry: it spawns `gentle-ai telemetry trigger --json` detached, with a 3 s deadline, discards its output, and never blocks session start or surfaces an error — an older binary without the verb is silently treated as nothing to do. This runs at most once per process. +

+ +

-Install counts for `gentle-pi` and `gentle-engram` come from npm download statistics; the package itself never emits an install event. +## Documentation -To opt out: +Start with the product-facing destination, then move into the operational reference only when you need the details. -- `/gentle:telemetry disable` — asks the local `gentle-ai` binary to disable telemetry (also `status` and `preview` to inspect it without leaving Pi). -- `DO_NOT_TRACK=1` — Gentle Pi itself will not spawn the trigger, and `gentle-ai` also honors this standard on its own. -- `GENTLE_AI_TELEMETRY=0` — same effect, `gentle-ai`'s own environment switch. - -`CI=true` also suppresses the trigger, since automated runs are not a real usage signal. - -## Package contents - -| Path | Purpose | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| `extensions/gentle-ai.ts` | Injects identity, orchestrates native review authority, refreshes global SDD assets, registers commands, applies model/persona config, and enforces runtime safety. | -| `lib/native-review-cli.ts` | Strict package-local adapter for Gentle AI START, FINALIZE, VALIDATE, SDD binding, and status contracts. | -| `lib/review-integration-v2.ts` | Strict consumer decoder for negotiated capabilities, operations, target status, projections, repair, and failures against contract `review-integration/v2` (active today). | -| `lib/review-candidate-view.ts` | Builds immutable changed-scope actor views while preserving full-tree, path, mode, symlink, and index integrity. | -| `lib/review-canonical.ts` | Permanent Pi-owned canonical JSON and domain-hash primitives for consumer-side identities. | -| `lib/review-repository.ts` | Permanent Pi-owned Git common-directory identity, safe Git environment, and authority-root binding. | -| `lib/gentle-ai-binary.ts` | Resolves and verifies the confined package-local Gentle AI runtime without global or PATH fallback. | -| `scripts/gentle-ai-installer.mjs` | Installs signed Darwin/Linux archives or exact Go SumDB-verified Windows source builds into the package-local runtime. | -| `contracts/review-integration/v1/` | Byte-identical provider schemas and conformance fixtures for contract `review-integration/v1`, hash-checked before packaging; retained on disk permanently because `/v2`'s schemas `$ref` into these fragments. | -| `contracts/review-integration/v2/` | Byte-identical provider schemas and conformance fixtures for contract `review-integration/v2` (immutable `base_tree`/`candidate_tree`, ordered `changed_path_manifest`, no inline candidate diff), hash-checked before packaging. | -| `extensions/startup-banner.ts` | Shows and configures the startup intro, color presets, and compact runtime panel. | -| `extensions/sdd-init.ts` | Registers `/gentle-sdd-init` for OpenSpec initialization. | -| `extensions/skill-registry.ts` | Maintains `.atl/skill-registry.md` from project/user skills and closes file watchers on shutdown. | -| `assets/orchestrator.md` | Parent-session orchestration contract (always-on core). | -| `assets/orchestrator-delegation.md` | Lazy-loaded delegation/routing/review detail, including the mirrored gentle-ai canon. | -| `assets/orchestrator-memory.md` | Lazy-loaded SDD memory phase table, artifact keys, and lifecycle rule. | -| `assets/orchestrator-skills.md` | Lazy-loaded skill registry fallback semantics and intent-driven skill discovery. | -| `assets/sdd-orchestrator-workflow.md` | Lazy-loaded SDD workflow surface for the parent orchestrator. | -| `assets/agents/` | SDD agents installed as global Pi runtime assets. | -| `assets/chains/` | SDD chains installed as global Pi runtime assets. | -| `assets/support/` | Strict TDD support docs for apply/verify phases. | -| `skills/` | Gentle AI delivery and collaboration skills. | -| `prompts/` | The `/skill-creation` prompt template. | -| `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | -| `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | -| `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | -| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | - -## Development - -Install from this repo: +| Destination | Purpose | +| --- | --- | +| [gentle-shell reference](docs/gentle-shell.md) | Workspace layout, changes, usage, agents, and todo interactions. | +| [ODD workflow](docs/readme-reference.md#organic-driven-development) · [Technical reference](docs/readme-reference.md) | Everyday work and recovery, installation, configuration, commands, and contributor detail. | +| [Review integration](docs/review-integration.md) | The provider/consumer boundary for native review. | +| [Native authority architecture](docs/native-authority-architecture.md) | Ownership boundaries and review architecture. | +| [Telemetry](docs/telemetry.md) | Approved fields and source limitations. | +| [Delegated verification](docs/delegated-verification.md) | Practical verification guidance. | +| [Skill style guide](docs/skill-style-guide.md) | The package skill contract. | -```bash -pi install . -``` +

Back to top ↑

-Validate before publishing: +

+ +

-```bash -pnpm test -bun build extensions/skill-registry.ts --target=node --format=esm --outfile=/tmp/skill-registry.js -node --experimental-strip-types --check extensions/gentle-ai.ts -node --experimental-strip-types --check extensions/sdd-init.ts -node --experimental-strip-types --check extensions/startup-banner.ts -npm pack --dry-run -``` +## Community -### Running the cross-lane battery +This project is built in public. Bring a real workflow, a sharp question, a bug report, or a small improvement that makes the next person’s work clearer. -The cross-lane battery (`tests/crosslane/cross-lane.mjs`) validates the adapter against a real `gentle-ai` binary, end to end and out of CI on purpose. The pinned decoder lane only ever sees vendored fixtures, so new envelope schemas and full controller sequencing are never driven through a live lifecycle before merge; the battery closes that gap. +

+ GitHub issues + Contributors + Gentleman Programming Discord +

-```bash -pnpm test:cross-lane # requires the dev-binary override -pnpm test:cross-lane --with-model # adds the real Go-owned pi reviewer run (model spend) -``` +

+ gentle-shell contributors +

-What it checks, against live scratch repositories: +- Open an [issue](https://github.com/Gentleman-Programming/gentle-shell/issues) with the context needed to reproduce or understand the idea. +- See the people shaping the project in the [contributors graph](https://github.com/Gentleman-Programming/gentle-shell/graphs/contributors). +- Follow [Gentleman Programming](https://github.com/Gentleman-Programming) for the wider ecosystem. -- a low-risk lifecycle: START → native-approved FINALIZE → terminal burn; the `pre-commit` gate is informational and unmanaged, not an allow decision or retained receipt; -- the medium-risk `consent/v3` granted round-trip through the direct decoder lane; -- controller sequencing: each decoded offered next step equals the native transition; correction evidence precedes Go-owned targeted validation, then native approval and terminal burn leave no retained receipt; -- the active audited abandon end to end, asserting the adapter builds the exact nine-line `gentle-ai.review-abandon-authorization/v2` discarded-work binding and the native gate commits the quarantine record; -- after a scope change, a burned approved predecessor exposes no recoverable authority; recovered-successor hydration remains covered at unit level; -- forward-decoder freshness: every live envelope captured from the binary must decode without unknown-key rejection, the early warning that gentle-ai main grew a field gentle-pi lacks; -- the default no-model lane: 13 of 14 checks pass while the real-model check is intentionally skipped; Go-owned validation uses a deterministic scratch fake `pi`, and only `--with-model` runs the real locked-down reviewer with model spend. +

Back to top ↑

-Prerequisites: +

+ +

-- A real `gentle-ai` binary selected through the dev-binary override; there is no PATH or pinned-binary fallback, and the battery refuses to run without one. Either export `GENTLE_PI_GENTLE_AI_DEV_BINARY=` for the session, or register a persistent override with `/gentle:dev-binary ` (stored at `~/.pi/gentle-ai/dev-binary.json` with schema `gentle-pi.dev-binary/v1`; the environment variable takes precedence over the registration, and the binary is re-validated and re-hashed on every resolution). Any real build works: an installed release binary or a locally built gentle-ai main. -- A Git checkout or worktree of this repository. The battery is a contributor tool wired to the repository layout and is excluded from `pnpm test` and CI by construction; run it from the repo, not from an installed Pi package. +## About the author -The battery owns one throwaway scratch root under the OS temp directory and never touches the enclosing repository. Before any review lifecycle it creates private `HOME`, XDG config/cache/data/state, temporary, and RDD state directories inside that root; it proves RDD starts `off/default`, explicitly opts in with sandbox-global RDD, and removes the complete root after the run. It never requires or changes the user's ambient RDD mode. The default run spends no model tokens; `--with-model` launches one real reviewer model run and costs model spend. +`gentle-shell` is built by [Alan Buscaglia](https://github.com/Gentleman-Programming), the maker behind Gentleman Programming. It grew from a practical belief: capable agents are more useful when the human’s intent, review load, and delivery judgment stay visible all the way through the work. -It prints one PASS/FAIL/SKIP row per check plus a note, and exits non-zero when any check fails. A check blocked by a known upstream class is reported with a `known-red` prefix instead of being hidden; it remains a failure, not a success. +Startup intro collaboration: thanks to [@aporcelli](https://github.com/aporcelli) and [`pi-gentle-startup`](https://github.com/aporcelli/pi-gentle-startup), which inspired the clean-screen startup animation, compact runtime panel, and pink visual treatment. -Running this battery against new gentle-ai builds (release candidates or main) and reporting red checks is a valuable contribution. The sibling provider-side battery lives at `scripts/cross-lane-battery.sh` in [Gentleman-Programming/gentle-ai](https://github.com/Gentleman-Programming/gentle-ai). +

+ Gentleman Programming website + Gentleman Programming YouTube + Gentleman Programming GitHub +

-Publish npm through GitHub Actions only: +

Back to top ↑

-```bash -version="$(node -p "require('./package.json').version")" -tag="v${version}" -git fetch --no-tags origin "refs/tags/${tag}" -test "$(git rev-parse 'FETCH_HEAD^{commit}')" = "$(git rev-parse "${tag}^{commit}")" -gh workflow run publish.yml \ - --repo Gentleman-Programming/gentle-pi \ - --ref main \ - -f tag="${tag}" -gh run watch --repo Gentleman-Programming/gentle-pi --exit-status -npm view gentle-pi@ version --registry=https://registry.npmjs.org/ -npm dist-tag ls gentle-pi --registry=https://registry.npmjs.org/ -``` +

+ +

-Do not run `npm publish` locally for `gentle-pi`. Dispatch the trusted workflow definition only from protected default `main` and provide its sole `tag` input. The workflow requires an exact annotated `vSemVer` tag whose peeled commit, current remote `main`, dispatch/main workflow commit, checkout, and `package.json` version are identical. It rechecks remote tag and `main` immediately before publishing through OIDC with provenance and environment protection; an advanced `main` requires a new release version, never a moved tag. +

Built with the workflow it brings to Pi.

-## Principles +

+ MIT License +

-- Human control over agent momentum. -- Concepts before code. -- Artifacts over floating chat context. -- SDD when risk justifies it. -- Strict TDD when tests exist. -- One parent orchestrator, focused subagents. -- Reviewable changes over giant diffs. +> **Trademark notice:** The gentle-shell™ and gentle-pi™ names and associated logos are trademarks of Alan Buscaglia. The MIT License applies to the code; it does not permit implying endorsement or official affiliation. See [TRADEMARKS.md](TRADEMARKS.md). From 3cc57c7ed05d977bfcac7509a344e310e40eb69a Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:21:16 -0300 Subject: [PATCH 37/49] chore(readme): remove README delta from history slice The history slice branches must not touch README.md: the docs table lives in main and evolves independently of the extension slices. The opt-in capture documentation stays in docs/prompt-history.md; the README pointer row introduced by the capture-gate commit is dropped and README.md is restored to upstream/main verbatim. --- README.md | 1036 +++++++++++------------------------------------------ 1 file changed, 207 insertions(+), 829 deletions(-) diff --git a/README.md b/README.md index 0207c389c..a8a5ee727 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,44 @@ -# gentle-pi + -[![npm](https://img.shields.io/npm/v/gentle-pi?color=blue)](https://www.npmjs.com/package/gentle-pi) -[![pi package](https://img.shields.io/badge/Pi-package-6f42c1)](https://pi.dev/packages/gentle-pi) -[![license](https://img.shields.io/npm/l/gentle-pi?color=blue)](LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/Gentleman-Programming/gentle-pi?style=flat&color=yellow)](https://github.com/Gentleman-Programming/gentle-pi/stargazers) -[![Gentle-AI](https://img.shields.io/badge/Gentle--AI-ecosystem-ff69b4)](https://github.com/Gentleman-Programming/gentle-ai) -[![Gentleman Programming](https://img.shields.io/badge/by-Gentleman%20Programming-black)](https://github.com/Gentleman-Programming) -[![YouTube](https://img.shields.io/badge/YouTube-Gentleman%20Programming-red?logo=youtube&logoColor=white)](https://www.youtube.com/c/GentlemanProgramming) -[![Discord](https://img.shields.io/badge/Discord-community-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/gentleman-programming-769863833996754944) -[![SDD/OpenSpec](https://img.shields.io/badge/SDD-OpenSpec-00ADD8)](#sddopenspec-flow) -[![Subagents](https://img.shields.io/badge/Pi-subagents-brightgreen)](#what-it-adds) +
+ gentle-shell — Ecosystem, Agent, One shell +
+ +

gentle-shell™

+ +

Your coding agent for controlled development in the workspace you lead.

+ +

+ npm + Pi-native package + MIT license + GitHub stars + Last commit +

+ +

+ + Website +  ·  + Quickstart +  ·  + Docs +  ·  + Wiki + +

+ +
+ +

Your terminal can run an agent. Your workspace should help you lead it.
gentle-shell is your coding agent, bringing your changes, tasks, and engineering workflow together—built for Pi.

-**[Gentle-AI website](https://gentle-ai.gentlemanprogramming.com/)** • **[Gentle-AI wiki](https://gentle-ai-wiki.gentlemanprogramming.com/)** • **[Engram](https://engram.gentlemanprogramming.com/)** +

One workspace. A coding agent you direct. A workflow you can inspect.

+ +

BUILT FOR PI  ·  Coding-agent workspace  ·  Focused agents  ·  ODD

+ +

+ ★ Star gentle-shell on GitHub +

@@ -27,934 +54,285 @@ - Star History Chart + Star History Chart -
- -**Turn Pi from a powerful coding agent into a controlled development harness.** - -`gentle-pi` installs **el Gentleman** in Pi: a senior-architect operating layer for Spec-Driven Development, focused subagents, strict TDD evidence, reviewable work units, safety guards, project/user skill discovery, and bounded native review. - -Pi already has strong tools. `gentle-pi` adds the discipline for using them well, keeps review evidence Git-derived instead of agent narration, and leaves delivery decisions to ordinary repository policy. - -`gentle-pi` is the Pi-native package from the [Gentle-AI ecosystem](https://github.com/Gentleman-Programming/gentle-ai), built by [Gentleman Programming](https://github.com/Gentleman-Programming): the broader open-source project for turning AI coding agents into disciplined engineering environments with SDD workflows, skills, memory integrations, model routing, and review guardrails across multiple agents. - -> **Trademark notice:** The gentle-pi name and logo are trademarks of Alan Buscaglia. The MIT License applies to the code; it does not permit implying endorsement or official affiliation. See [TRADEMARKS.md](TRADEMARKS.md). - -Follow the project and the community around it: - -- GitHub: [Gentleman-Programming](https://github.com/Gentleman-Programming) -- YouTube: [Gentleman Programming](https://www.youtube.com/c/GentlemanProgramming) -- Community Discord: [Gentleman Programming](https://discord.com/invite/gentleman-programming-769863833996754944) - -Startup intro collaboration: thanks to [@aporcelli](https://github.com/aporcelli) for [`pi-gentle-startup`](https://github.com/aporcelli/pi-gentle-startup), which inspired the clean-screen startup animation, compact runtime panel, and pink visual treatment. - -## The problem - -Most coding-agent sessions fail for operational reasons, not model reasons: - -- the agent jumps into code before requirements are clear; -- architectural decisions disappear into chat history; -- one request quietly becomes a huge multi-area diff; -- tests run late, or not at all; -- reviewers get handed a wall of changes; -- subagents are available, but the parent session has no orchestration discipline; -- project skills exist, but the model forgets to load them. - -`gentle-pi` fixes the workflow around the agent. - -## What it adds - -| Capability | What it does | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -| **el Gentleman persona** | Makes Pi behave like a senior architect and teacher, not a generic chatbot. Spanish responses use Rioplatense voseo by default; neutral mode is saved globally with project overrides. | -| **Configurable startup intro** | Adds a rose/text-logo startup intro, compact runtime panel, color presets, and commands to hide or show the decorative parts. | -| **Work routing discipline** | Small tasks stay inline. Context-heavy exploration can be delegated. Large or risky changes go through SDD/OpenSpec. | -| **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `onboard`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, `sync`, and `archive`. | -| **Lazy SDD preflight** | Resolves SDD mode, artifact store, delivery strategy, and review budget once per session; prompts only when a choice is genuinely unresolved. | -| **Subagent orchestration** | Keeps one parent session responsible while child agents explore, implement, test, or review with focused context. | -| **Strict TDD support** | When project config declares a test command, apply/verify phases must record RED → GREEN → TRIANGULATE → REFACTOR evidence. | -| **Closed choice prompts** | Per-option hover/click/wheel in fullscreen; keyboard selection in either TUI mode. | -| **Native pointer regions** | Compose hover, press, click, and wheel behavior around public TUI components. | -| **Agent overlay close control** | Adds a header close button that adapts to available width. | -| **Reviewer protection** | Surfaces review workload risk before a task turns into an oversized PR. | -| **Per-agent model assignment** | Pi-native modal for assigning stronger or cheaper models to specific SDD/custom agents. | -| **Skill discovery registry** | Maintains `.atl/skill-registry.md` from project and user skills so review/comment/PR workflows do not silently miss the right skill. | -| **Skill creation workflow** | Provides the `gentle-ai-skill-creator`/`gentle-ai-skill-improver` skills, `/skill-creation` prompt, and packaged style guide for LLM-first skills. | -| **Delivery skills** | Includes issue-first PRs, chained PRs, work-unit commits, cognitive docs, comment writing, and Judgment Day review. | -| **Bounded native review** | Freezes one candidate, dispatches only controller-selected lenses, and records native authority. Review outcomes are informational; delivery follows ordinary repository policy. | -| **Verified native runtime** | Provisions the exact package-local Gentle AI v2.7.0 runtime: signed, SHA-256-pinned release archives on Darwin/Linux and a Go SumDB-verified source build on Windows x64/arm64. It validates package-local integrity and rejects PATH, global, sibling, symlink, and mode fallbacks. | -| **Runtime safety** | Blocks destructive shell commands, asks for confirmation for sensitive operations, and blocks direct read/write/edit access to sensitive paths. | - -## Native pointer regions - -Compose pointer behavior around public `Text`, `Box`, or custom content without making it a keyboard target: - -```ts -const scope = createNativePointerScope(); -const openInput = scope.wrap(new Text("Open input", 0, 0), { - onClick: () => { - openInputEditor(); - return { handled: true }; - }, -}); -const panel = new Container(); -panel.addChild(openInput); -const observer = scope.createMouseObserver(() => tui.requestRender()); -``` - -Pass `observer` around the root's native mouse dispatch; reuse `panel` as custom or overlay content. -Pointer input is fullscreen-only. Regions preserve a consuming child's native result and do not focus -`Text`, activate on press or wheel, synthesize outside leave events, or alter terminal tracking. -Callers own keyboard policy, theme state, and business actions. - -**Migration note:** Do not enable `pi-tool-cards` and `quiet-tools` together: Pi rejects duplicate `bash`, `read`, `edit`, and `write` registrations. Disable or remove the standalone package during migration; gentle-pi does not change those package registrations or delete that repository. The global fullscreen setting described below is a separate install-time change. - -## Install - -```bash -pi install npm:gentle-pi@0.14.0 -``` - -### Install-time fullscreen - -For this release, a successful postinstall in Pi's **global npm-managed** `agent-home/npm/node_modules/gentle-pi` installation persists `"tuiMode": "fullscreen"` in `agent-home/settings.json`, preserving other settings. Agent home resolves through `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent`. Use `/settings` to switch back to regular; rerunning this recognized postinstall resets it to fullscreen. Existing project overrides still take precedence. - -Project-local installs (`pi install -l`), Git/local-path installs, temporary packages, development checkouts, ordinary npm consumers, and pnpm symlink-store packages do **not** receive this change. Updates or installs that do not execute postinstall cannot reassert it; this is not a universal install/update guarantee or a change to historical releases. - -Malformed/nonobject JSON, symlink/nonregular settings, unsafe paths, or a busy settings lock fail without replacing settings. The installer coordinates with Pi's cooperative settings lock and uses atomic replacement; it does not guarantee safety against noncooperating writers or malicious concurrent directory replacement. Already-fullscreen settings remain byte-identical. Native installation failure leaves settings untouched; `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1` skips only native provisioning, not the recognized global fullscreen setting. - -### RDD version policy - -Native RDD started in `gentle-pi` `v0.15.0` on 2026-07-10 with bounded review transactions. Every release from `v0.15.0` onward is part of the unstable RDD development line. New releases will continue improving RDD until the project declares the line stable. The stable version for normal use without native RDD is the last preceding release, `v0.14.0`. - -```bash -# Stable version without native RDD -pi install npm:gentle-pi@0.14.0 - -# Latest released RDD build (unstable) -pi install npm:gentle-pi@latest -``` - -The latest RDD package installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for stable pins such as the current v2.7.0; raw prerelease binaries only under a prerelease pin). Windows x64 and arm64 build the exact `v2.7.0` source tag with a local Go 1.25.10+ toolchain, a sealed Go environment, `GOTOOLCHAIN=local`, and `GOSUMDB=sum.golang.org`; it does not download Go automatically. Windows provenance is Go-toolchain plus SumDB evidence and postinstall tamper detection, **not** Authenticode or protection against a malicious joint binary-and-manifest replacement. Package-private locks coordinate cooperative concurrent or crashed installers; their tombstones fail closed. A malicious same-user process with write access to package-private `node_modules` is outside that protocol because it can already replace package code, binary, or manifest, and portable Node has no pathname-delete CAS. It never uses `PATH` or a global `gentle-ai` installation. For development or offline installs only, set `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1`; native review operations then fail closed with an actionable `package-local-binary-missing` error until the package is reinstalled normally. - -Recommended companion packages: - -```bash -pi install npm:pi-intercom -pi install npm:gentle-engram -pi install npm:pi-web-access -pi install npm:pi-lens -pi install npm:@juicesharp/rpiv-ask-user-question -``` - -Then start Pi in a project: - -```bash -pi -``` - -`gentle-pi` provides SDD agents as global Pi runtime assets, not per-project setup. The first SDD flow in a session still runs a one-time SDD preflight for preferences; for natural-language requests, el Gentleman decides when SDD is needed and runs the explicit preflight first. - -## Quick start - -```text -/gentle:status Check package, SDD assets, OpenSpec, and global model config. -/gentle:doctor Run read-only diagnostics for SDD assets, config, tools, and guards. -/gentle:sdd-preflight Run or reuse the session SDD preflight explicitly. -/gentle-sdd-init Create or refresh openspec/config.yaml (openspec/both stores only). -/gentle:models Assign global model/effort routing to SDD/custom agents. -/gentle:persona Switch between gentleman and neutral persona modes. -/gentle:background-subagents Show or set the managed background-subagents policy, with its deciding source. -/gentle:banner Configure startup rose, text logo, and color preset. -``` - -Typical flow: - -1. Open Pi in your repo. -2. Run `/gentle:status`. -3. Run `/gentle-sdd-init` once per project, or when test/project capabilities change. This also runs the session SDD preflight. -4. For a substantial change, ask Pi to use SDD. Natural-language requests are classified by the parent agent, not by brittle runtime regexes. -5. Review the phase artifacts instead of trusting floating chat context. - -## Core workflow - -1. **Install and inspect.** Install `gentle-pi`, open Pi in the target repository, then run `/gentle:status` or `/gentle:doctor`. -2. **Plan when risk justifies it.** Small work stays direct; substantial work uses SDD with Engram, OpenSpec, or both so requirements and decisions survive compaction. -3. **Build with evidence.** One focused writer implements the approved scope. When Strict TDD is available, apply and verify preserve RED → GREEN → TRIANGULATE → REFACTOR evidence. -4. **Use runtime-owned RDD when available.** Gentle AI supplies any runtime-specific review instructions; this package does not recreate a lifecycle in documentation or prompts. -5. **Deliver through ordinary repository policy.** Review and Judgment Day evidence is informational only; Pi never creates a delivery route, authorization, target rederivation, or receipt gate. - -> **Trust what the system can derive, not what an agent claims.** Agents analyze the candidate. The package-local Gentle AI runtime owns scope, risk, findings, and review authority. Review outcomes inform delivery; ordinary repository policy decides delivery commands. Dangerous-command safety and destructive-review consent remain independent. See Gentle AI's [review authority threat model](https://github.com/Gentleman-Programming/gentle-ai/blob/main/docs/review-authority-threat-model.md) and [Chapter 21 — Verifiable Trust](https://the-amazing-gentleman-programming-book.vercel.app/en/book/Chapter21_Verifiable-Trust). - -## How the harness decides what to do - -`gentle-pi` routes through the smallest safe workflow: - -| Request shape | Harness | -| --------------------------------------------------------------------------- | ---------------------------- | -| Small, clear, local edit | Inline direct work. | -| Unknown codebase area or context-heavy investigation | Focused subagent delegation. | -| Large, ambiguous, architectural, product-facing, or high-review-risk change | SDD/OpenSpec flow. | - -The goal is not ceremony. The goal is to avoid accidental chaos. Once a task stops being small, delegation is mandatory. - -### Delegation triggers - -`gentle-pi` keeps the parent session thin and delegates at the narrowest useful point. When the Pi Subagents extension is installed, the preferred runtime is the `subagent_*` tool family because it runs the user's configured project/global subagent definitions and preserves history/background behavior. With the background policy on, delegations default to background mode: the terminal stays free and each result comes back as a message that starts a new turn; task mode is reserved for delegations that must ask the user something mid-flight. If those tools are unavailable, the parent should fall back to Pi's native `Agent` tool or another available delegation mechanism. The requirement is delegation; the runtime is capability-dependent. - -| Trigger | Required behavior | -| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| Reading 4+ files to understand a flow | Launch `scout`, `context-builder`, or the closest read-only mapping subagent. | -| Touching 2+ non-trivial code files | Delegate one writer; do not continue inline unless delegation is unavailable. | -| Commit, push, or PR after code changes | Follow the loaded native instruction, or ordinary repository policy when none is supplied. | -| Wrong cwd, worktree/git accident, merge recovery, confusing test/env issue | Stop, preserve the affected scope, and investigate separately before resuming. | -| Long monolithic session with accumulating complexity, roughly 20 tool calls, 5 exploratory reads, or 2 non-mechanical edits | Pause and delegate the remaining work, or stop and explain the exact blocker. | - -The intended balanced loop for a bounded bugfix is: - -```text -parent git/status + clarify → one worker writes authorized fixes → focused verification → parent reports -``` - -`scout`/`context-builder` save parent context by compressing broad exploration. `worker` preserves a single writer thread. Any RDD-specific actor behavior belongs to the runtime instruction supplied by Gentle AI, not to this README. - -### Review authority recovery and reset safety - -Legacy pre-graph authority is never migrated. `gentle_review inspect` reports an exact repository-bound destructive reset challenge for legacy corruption; after that fresh interactive authorization, RESET and RECOVER_LOCK route to the audited native `gentle-ai review reclaim` operation and RECOVER routes to native `gentle-ai review recover`, so every destructive transition is executed and audited by the native authority store. Native inputs the request did not carry return a `native-input-required` envelope instead of being invented. Existing graph-v1 ordinary lineages remain readable and gate-validatable but are read-only; Judgment Day remains mutable on graph-v1. - -`gentle_review abandon`, `quarantine-legacy`, and `reconcile-authority` remain explicit v2.1.11 maintenance routes. Pi derives and displays the published nine-line `gentle-ai.review-abandon-authorization/v2` binding only for a caller-specified compact lineage, revision, snapshot identity, and discarded-work summary (captured lens results, findings presence, evidence-record presence); the native CLI re-derives non-terminal compact-v2 eligibility and the exact discarded work before accepting it. Legacy quarantine accepts only `historical findings freeze changed unrelated transaction state` with disposition `quarantine-malformed-freeze-event` and uses its exact eight-line binding. Both require fresh interactive approval and fail closed headlessly. - -`gentle_review reconcile-authority` accepts one predecessor lineage and revision, one successor lineage and revision, an actor, and a reason. Pi derives the exact seven-line `gentle-ai.review-reconcile-authorization/v1` binding, or appends exactly `anomalies=unchanged_target,malformed_recovery_authorization` for the published dual anomaly in that order. Native code re-derives every anomaly; malformed bindings, changed revisions, unavailable native support, cancellation, and native refusal fail closed through typed envelopes. - -Reconciliation is intentionally narrow: native code may quarantine only the bound invalid compact-v2 recovery successor and persists the returned audit record; the predecessor stays untouched. Pi never recreates the retired `prepare-supersession`/`supersede` authority writer and never falls back to RESET or RECOVER. - -`gentle_review repair-legacy-alias` is the sole v2.1.11 route for `unsupported historical v1 operation alias`. The model supplies only lineage, actor, and reason. Pi freshly reads the native inventory, derives the canonical repository, exact legacy revision, fixed diagnostic, and fixed `quarantine-approved-historical-alias` disposition, displays the LF-only eight-line binding, and requires a new interactive approval. Native re-derives eligibility and quarantines rather than rewriting or validating the historical chain. - -`review dispose-result` is deliberately unsupported by Pi pending a separate design; it has no controller operation or fallback. All maintenance routes fail closed headlessly and never auto-run against legacy history. - -Native lifecycle status remains informational. VALIDATE does not authorize delivery; commit, push, PR, and release commands follow ordinary repository policy. Recovery grants no new budget, and legacy graph bundle export/import is retired. - -This is the post-U8 boundary, not the final architecture. [Issue #191](https://github.com/Gentleman-Programming/gentle-pi/issues/191) is the immediate final unit in this same delivery: extract the remaining Pi command-projection and lifecycle-gate surface from `review-transaction.ts`, repoint runtime enforcement, then delete only dependencies proven unreachable without weakening graph-v1 Judgment Day. The branch-wide High-tier 4R runs after that extraction, before the single size-exception PR. - -### Review Lens Selection (architecture reference) - -`reviewer` is not an installed subagent name. It is historical routing vocabulary, not a static instruction. When a runtime-specific Gentle AI instruction applies, it alone determines whether any concrete lens is used: - -| Context | Review lens | -| --- | --- | -| Clear naming, structure, maintainability, small refactors | `review-readability` | -| Behavior, state, tests, determinism, regressions | `review-reliability` | -| Shell/process integration, partial failures, recovery, degraded dependencies | `review-resilience` | -| Security, permissions, data exposure/loss, architecture, dependencies | `review-risk` | -| Large PR, hot path, or >400 changed lines | Full 4R: `review-risk`, `review-resilience`, `review-readability`, `review-reliability` | - -The former compact controller classified documentation/comment/formatting-only changes as zero-lens, standard changes as one dominant lens, and higher-risk paths as full 4R. This describes compatibility architecture only; never derive or run those choices from this README. - -### Review authority architecture (reference only) - -Gentle AI dynamically supplies runtime-specific RDD instructions. `gentle-pi` does not define an RDD lifecycle, command route, approval path, recovery sequence, or fallback. The historical compact-controller material below documents architecture and compatibility boundaries only; it is not an operator instruction. - -Concretely: `gentle-pi` mirrors the Gentle AI provider contract bundle's `orchestration/pi.md` locally (`contracts/review-provider-contract-mirror/`, verified against the mirror lock's recorded SHA-256 before injection) and injects that mirrored text into the primary session's system prompt at session start. Gentle AI does not write anything into Pi's system prompt; when the mirrored contract is absent, unreadable, or fails digest verification, `gentle-pi` invents no fallback lifecycle. - -```mermaid -flowchart TD - A["Clarify scope and acceptance criteria"] --> B{"Choose the smallest safe workflow"} - B -->|Small and local| C["Inline implementation"] - B -->|Context-heavy or multi-file| D["Focused subagent"] - B -->|Large or architectural| E["SDD phase artifacts"] - C --> F["Implement with test evidence"] - D --> F - E --> F - F --> G["Independent verification"] - G --> H["Target-scoped native status"] - H -->|Ambiguous or corrupted| X["Blocked: native maintainer action"] - H -->|Unrelated| I["START freezes candidate, scope, tier, lenses, and budget"] - - subgraph Ordinary_review["Ordinary bounded review"] - I --> R["reviewing"] - R --> J["Run each selected lens once"] - J --> K{"Severe candidate-caused blocker?"} - K -->|No| A1["approved"] - K -->|Yes| C1["correction_required"] - C1 --> C2["Forecast bounded correction"] - C2 --> C3["Apply scoped fix"] - C3 --> V["validating"] - V -->|Validator passes| A1 - V -->|Fails, malformed, or out of scope| E1["escalated"] - end - - A1 --> O["Review outcome is informational"] - E1 --> O -``` - -VALIDATE is informational. Commit, push, PR, and release commands follow ordinary repository policy; RDD never authorizes, rewrites, consumes review state for, or blocks them. Dangerous-command safety and destructive-review consent remain independent. - -Native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.7.0 executable, independently hashes it, then negotiates `gentle-ai.review-integration/v2` outside the repository. Capabilities are cached by that executable digest. Every START, target status, FINALIZE, validate, and BIND-SDD request passes the same contract identifier. Negotiated envelopes decode exactly against the vendored schemas; `recover` routes only the provider-selected `action_disposition`, and optional additions require a future compatible schema/minor that the provider explicitly advertises and the consumer negotiates. - -Contract `/v2` replaces the Base64 `candidate_diff` reviewer transport of `/v1` with immutable `base_tree`/`candidate_tree` plus an ordered `changed_path_manifest` and never an inline patch. `gentle-pi` negotiates `/v2` only, with no dual-lane fallback; the cutover landed as one atomic commit against gentle-ai v2.2.2 (tracked by the `migrate-review-integration-v2` change), and the `/v1` schemas stay packaged because the `/v2` schemas `$ref` into their fragments. This provider contract version is unrelated to Pi's own internal "compact-v2" review-authority naming used below — the shared digit is coincidental, not a version pairing. - -Target status owns `current_target`, `unrelated`, `ambiguous`, and `corrupted` applicability and returns one native action. Pi does not reconstruct ordinary authority from provider-private files or choose a lineage from repository-wide history. Restart recovery rebuilds only the derived candidate view from the native Git/content projection, including intended-untracked paths, symlinks, and immutable gitlink identities. Native failure envelopes retain their exact mutation outcome, replayability, required inputs, request digest, and next action. After an unknown or lost mutating result, Pi calls target status before any replay decision and returns only the provider-declared action. - -Once the pinned gentle-ai runtime (currently v2.7.0) has written review authority, rollback MUST preserve every native store and receipt and MUST NOT run a downgraded binary against that repository. Disable the Pi route or roll forward to a compatible authority-aware release instead; deleting authority data or reinstalling an older binary is not a rollback path. - -### FINALIZE wrapper input - -`gentle_review` accepts `input` as a JSON-serialized object string. For initial results, provide `review_result.lens_results[]`; each selected lens appears exactly once with `lens`, `findings`, and non-empty `evidence`. A clean lens uses `findings: []`. Pair `final_evidence` with exactly one of `final_verification_passed` or `final_verification_outcome`. - -```json -{ - "review_result": { - "lens_results": [ - { - "lens": "review-reliability", - "findings": [], - "evidence": ["complete candidate reviewed"] - } - ] - } -} -``` - -This is the Pi wrapper contract, not the native CLI file contract. The native command receives separate `--result`, `--refuter`, `--validation`, and `--evidence` files from the wrapper. - -START derives the complete Git/untracked snapshot, lineage, persisted `low | medium | high` tier, zero/one/four lenses, authored changed lines, and correction budget `min(200, ceil(original_changed_lines / 2))`. Generated `testdata/golden/**` stays in snapshot identity but does not count as authored risk lines. - -Every finding requires `evidence_class`, `causal_disposition`, and concrete changed-hunk, candidate-created-path, differential-test, or before/after proof. Missing IDs are assigned natively and selected-lens results are canonicalized deterministically. - -Actor output is untrusted data and cannot authorize transitions, fixes, receipts, gates, or delivery. - -Only severe `introduced`, `behavior-activated`, or `worsened` findings with valid proof enter correction IDs. `pre-existing` and `base-only` become follow-ups; `unknown`, insufficient, malformed, or inconclusive severe claims escalate. WARNING and SUGGESTION are informational. - -Deterministic blockers need no refuter. Inferential blockers use exactly one complete read-only refuter batch. - -Refuter proof may be independent concrete reproduction evidence; it does not need to duplicate reviewer `proof_refs`. Invalid, empty, malformed, missing, duplicate, unknown, or inconclusive refuter output escalates without a replacement refuter. - -When native IDs are assigned to inferential findings, the first FINALIZE returns their canonical rows and a content-derived request hash without mutation; the second replays identical lens input with that hash and one complete refuter batch. - -Ordinary permits one correction transaction within the original budget. FINALIZE requires a positive forecast before editing and derives actual correction lines from Git; one targeted validator and final verification close that transaction. Initial lenses are never rerun, while frozen findings and genesis scope remain unchanged. - -The validator checks original criteria and correction regression only and cannot add scope or findings. Final evidence is hashed during FINALIZE, never at START. - -Compact ordinary has five states: `reviewing`, `correction_required`, `validating`, `approved`, and `escalated`. - -The validator cannot change claims, add findings, request fixes, launch actors, or request another attempt. A failed correction escalates instead of opening another review budget. - -Compact authority uses content-derived CAS under the Git common directory. Exact retries are idempotent; stale/semantic retries, terminal mutation, and same-lineage graph-v1/compact-v2 ambiguity fail closed. - -Trust boundary: The local orchestrator and same-user process are trusted to execute selected actors and submit their exact outputs. Native code owns scope, risk, IDs, canonicalization, state, receipts, and gates, and rejects malformed or inconsistent results structurally and causally. Malicious same-user host/process authenticity is a non-goal because that actor can replace the extension or mutate local authority; externally trusted attestation would require a separately privileged signer/service and is not claimed. - -Ordinary ends only as `approved` or `escalated`. +
-Judgment Day starts only when explicitly requested and replaces ordinary review for that lineage. +Built for Pi. Shaped by Gentle-AI. -Judgment Day starts with exactly two blind judges and zero refuters. - -Judgment Day alone may iterate discovery and scoped re-judgment, for at most two rounds. - -Findings surviving round two escalate; no third-round transition exists. - -Native review mode and the two candidate choices remain provider-owned lifecycle semantics. For a validated `consent/v3` envelope in the interactive parent TUI, Pi displays those two choices unchanged and adds a clearly separate host-owned action: **Run this review and allow reviews for this Pi session**. Only direct human selection creates this process-memory grant. Its scope is the coordinating live SessionManager session and the canonical Git common-directory identity of the selected repository: it runs the current envelope's exact provider `granted` invocation through the existing one-shot `answer-consent` path, then does the same for later fresh validated envelopes in sibling worktrees of that same clone, including package-owned children. An unrelated repository requires a separate explicit human grant. Reload preserves it; `/tree` retains it; revoke removes the current repository grant; quit, new, resume, fork, or process restart removes all session grants. The command's `status` action reports the in-memory state without changing provider mode or authority. - -The host grant is held only in a schema-checked `globalThis[Symbol.for(...)]` WeakMap registry keyed by session and canonical Git common-directory digest. It is never written through session entries, settings, environment variables, or the old asked latch. A package-owned Gentle Agents child can request one bounded parent-owned stdio authorization for its own validated pending ordinary START; it sends only that target's canonical repository digest, and the parent rechecks the live task, digest, and current parent session grant before the child replays its exact provider grant locally. No candidate bytes, provider vectors, paths, local child grant, or delivery authority crosses that channel. External or legacy `pi-subagents` launchers do not receive this channel and remain unsupported. Headless/RPC/unsupported UI, external processes, model prose, tool arguments, cancellation, identity drift, malformed identity, and uncertain native results cannot create or consume the grant. Native workspace binding remains canonical and target-specific; session-wide consent never authorizes an unselected target or an unrelated repository. The grant conveys no review verdict, forecast/cost approval, acknowledgement, maintenance, delivery, or cross-repository authority. When the host cannot resolve the choice, `gentle_review` returns the original unresolved two-choice provider envelope unchanged for the normal lossless relay. SessionManager binding isolates simultaneous SDK sessions; Pi does not claim universal same-process agent-principal isolation because the SDK exposes no principal identity. - -When RDD is on and an agent loop ends with an unreviewed candidate, `gentle-pi` sends one read-only reminder pointing the agent back to `gentle_review {"operation":"inspect"}` before it reports completion. This nudge is idempotent (at most once per target identity per session), never fires for a headless session or a subagent's own loop, and never runs START or answers consent itself. Pi treats a child `agent_end` as a latest-answer update, not completion: queued retry, compaction, follow-up, required verification, and legitimate post-correction verification remain live until `agent_settled`. It does not claim ready or RDD-ready first, but this ordering rule does not impose a universal full-suite requirement or turn a receipt into a delivery gate. At session start, `gentle-pi` records the current target identity as a baseline, so a candidate that already existed before the session began (the user's own prior work, not this session's output) never draws the reminder. - -Review outcomes and receipt state are informational; commit, push, pull-request, and release delivery follow ordinary repository policy. No one-shot command authorization, publication-target revalidation, or receipt gate is required for delivery, and Pi does not inspect RDD mode or native authority to decide a Bash delivery command. - -Dangerous-command safety remains independent and authoritative. Destructive-review-maintenance consent remains separate from delivery. Review operations, informational VALIDATE, and SDD perform no commit, push, pull-request, release, or publication operation. - -The Pi host relay bounds each locked-down reviewer subprocess by materialized prompt size rather than by one fixed number: a 15-minute floor plus 15 minutes per mebibyte of prompt, clamped to a 2-hour ceiling. Set `GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS` to a positive decimal to replace that derived bound with your own; malformed values are ignored and the same 2-hour ceiling still applies, so no configuration turns a foreground finalize into an unbounded child process. A reviewer killed by the bound reports `pi-host-relay-timeout` with the elapsed time and the limit it was measured against, and it explicitly does not ask you to relaunch the identical slot — that would re-spend the model tokens to reach the same wall. Reviewer results admitted earlier in the same finalize stay admitted and are not re-run. - -Adversarial review roles (the refuter and the targeted validator) are never Pi-authored: the provider renders self-contained `review.capture-refuter` / `review.capture-validation` vectors and Go runs its own locked-down `pi` process on them. Package agent assets remain a package-managed isolated installation. Project and user overrides may shadow a package asset; `gentle-pi` preserves those definitions and does not claim their effective permissions are package-compliant. - -## SDD/OpenSpec flow - -```text -init - ↓ -explore → research (optional) → proposal → spec ─┬→ design ─┐ - └─────────┴→ tasks → apply → verify → sync → archive -``` - -The main loop is intentionally file-backed when you choose `openspec` or `both`: - -```text -planning artifacts implementation evidence canonical update -────────────────── ─────────────────────── ──────────────── -proposal/spec/design/tasks → apply-progress/verify-report → sync-report → archive-report -``` - -For substantial work, the parent session coordinates the flow and each phase writes artifacts. That gives you: - -- explicit requirements and non-goals; -- design decisions that survive compaction; -- task plans reviewers can reason about; -- implementation evidence; -- verification reports; -- sync reports that update canonical specs while keeping the change active; -- archive notes for future agents. - -### OpenSpec artifact model - -`gentle-pi` treats OpenSpec-compatible behavior as part of the harness. You do not need to install the external OpenSpec CLI/package for SDD. - -In file-backed modes, canonical accepted behavior lives in `openspec/specs/`, while active changes carry deltas under `openspec/changes/`: - -```text -openspec/ -├── specs/ # accepted source of truth -│ └── {domain}/spec.md -└── changes/ - ├── {change}/ # active work - │ ├── proposal.md - │ ├── specs/{domain}/spec.md # full spec or delta spec - │ ├── design.md - │ ├── tasks.md - │ ├── apply-progress.md - │ ├── verify-report.md - │ └── sync-report.md - └── archive/YYYY-MM-DD-{change}/ # immutable audit trail -``` - -Delta flow: - -```text -openspec/changes/{change}/specs/{domain}/spec.md - │ - │ sdd-sync applies ADDED / MODIFIED / REMOVED - ▼ -openspec/specs/{domain}/spec.md - │ - │ sdd-archive moves the completed change folder - ▼ -openspec/changes/archive/YYYY-MM-DD-{change}/ -``` - -When a canonical spec already exists, change specs use requirement operation sections: - -```markdown -## ADDED Requirements - -## MODIFIED Requirements - -## REMOVED Requirements -``` - -`MODIFIED` requirements must include the full requirement block, including still-valid scenarios, because sync replaces the canonical block by requirement name. `sdd-sync` syncs file-backed deltas into `openspec/specs/{domain}/spec.md` while keeping the change active; `sdd-archive` then moves the synced change to `openspec/changes/archive/YYYY-MM-DD-{change}/`. - -Engram-only mode is different by design: Engram is working memory and does not maintain a canonical spec merge layer. Use `openspec` or `both` (hybrid file + memory persistence) when you need canonical spec evolution. + -## SDD preflight and project files +

+ +

-`gentle-pi` does not require SDD agents to be copied into every project. The package ensures global Pi SDD assets exist under the Pi agent home and treats project-local files only as overrides/debug copies. Slash SDD flows such as `/sdd-*`, `/gentle-sdd-init`, and the explicit `/gentle:sdd-preflight` command run a lazy preflight and resolve session-scoped SDD preferences. For natural-language requests, the parent agent decides whether the work should use SDD and must run/reuse `/gentle:sdd-preflight` before continuing. +## Features -```text -~/.pi/agent/agents/sdd-*.md -~/.pi/agent/chains/sdd-*.chain.md -~/.pi/agent/gentle-ai/support/strict-tdd*.md -``` +--- -Preflight values resolve in this order: explicit current user/session choice, valid persisted preference, capability or already-selected strategy constraint, canonical default, then a prompt only when genuinely unresolved. Resolved values are reused for later SDD flows in the session. +### gentle-shell — Your coding agent, in the workspace you lead -Canonical values are `auto` execution mode, `openspec` artifact store, `ask-on-risk` delivery strategy, and a `400` changed-line review threshold. The delivery strategy domain is `ask-on-risk`, `auto-chain`, `single-pr`, or `exception-ok`; `chain_strategy` remains deferred until chaining is selected. `exception-ok` requires explicit `size:exception` acceptance and is never inferred. Consent, authorization, security, destructive/publishing, interactive phase approval, and ambiguous-scope gates remain human-controlled. +gentle-shell running a live agent session: a header row with branch, model, and context gauge above the transcript, with status, changes, and todo cards in the right rail -It does **not** overwrite existing global assets unless you explicitly run: +A bare terminal answers "what is the agent doing?" only with scrollback. gentle-shell turns your Pi session into a workspace: agent orchestration, live changes and runtime status, usage monitoring for supported provider accounts, and built-in diff views — so you lead the work instead of chasing it. -```text -/gentle:install-sdd --force -``` +

gentle-shell in action. Screenshot from Gentle-AI.

-Manual preflight command: +**[Docs →](docs/gentle-shell.md)** -```text -/gentle:sdd-preflight -``` +--- -## Skill registry +### el Gentleman — Think before you build -`gentle-pi` keeps a local registry at: +Say what you need once, then keep moving. el Gentleman helps turn intent into clear scope, a sensible next step, and evidence people can review — without making every task feel like a process meeting. -```text -.atl/skill-registry.md -``` +**[Docs →](docs/readme-reference.md#organic-driven-development)** -The registry scans project and user skill roots, not package-owned skills. It exists to catch workflow skills that are present on disk but not visible in Pi's injected skill list. +--- -It scans common roots such as: +### Focused agents — Context with a return path -```text -./skills -.opencode/skills -.claude/skills -.gemini/skills -.cursor/skills -.github/skills -.codex/skills -.qwen/skills -.kiro/skills -.openclaw/skills -.pi/skills -.agent/skills -.agents/skills -.atl/skills -~/.pi/agent/skills -~/.config/agents/skills -~/.agents/skills -~/.kimi/skills -~/.config/opencode/skills -~/.config/kilo/skills -~/.claude/skills -~/.gemini/skills -~/.gemini/antigravity/skills -~/.cursor/skills -~/.copilot/skills -~/.codex/skills -~/.codeium/windsurf/skills -~/.qwen/skills -~/.kiro/skills -~/.openclaw/skills -``` +Diagram of one parent session directing bounded map, implementation, and verification work and receiving evidence back -Behavior: +Bring in help without losing the thread. Focused package-owned Pi agents can map a codebase, implement a bounded change, or verify it, while one parent stays accountable for the scope, the decisions, and the final summary. -- `.atl/` is added to `.gitignore` when needed; -- the registry refreshes on session start; -- startup refresh is skipped when Pi starts with `--no-skills` / `-ns`, `--no-skill-registry`, or `GENTLE_PI_NO_SKILL_REGISTRY=1`; -- `/skill-registry:refresh` forces regeneration; -- a best-effort watcher refreshes when skill files change; -- the registry indexes skill names, full descriptions, scope, and exact `SKILL.md` paths without copying skill body rules. +**[Docs →](docs/readme-reference.md#how-the-harness-decides-what-to-do)** -Skill discovery is a guardrail, not a workflow router: it helps Pi load the right skill without forcing extra ceremony. +--- -`gentle-pi` also ships package-owned `gentle-ai-skill-creator` and `gentle-ai-skill-improver` skills plus the `/skill-creation` prompt for creating or updating project skills. Both skills use `docs/skill-style-guide.md` as their normative style contract. The workflow checks for duplicates, keeps `SKILL.md` concise, uses one-line trigger-rich frontmatter, and reminds maintainers to refresh the registry after skill changes. +### ODD — The everyday workflow -Packaged skills include `cognitive-doc-design`, `comment-writer`, `gentle-ai-judgment-day`, `gentle-ai-skill-creator`, `gentle-ai-skill-improver`, and the other delivery/review skills under `skills/`. SDD init is installed as the packaged `sdd-init` runtime agent under `assets/agents/` and refreshed with the SDD assets. +Organic Driven Development as seven numbered steps: Authorize, Explore, Resolve uncertainty, and Classify across the top row; Classify forks, so small understood work stays light while substantial work gets step five, Track, with one feature document; both paths converge on Implement task by task and then Close, above a dashed band marking that one feature document mirrored in Engram lets work resume across sessions -Compatibility: the package keeps the existing skill folders (`skills/branch-pr`, `skills/cognitive-doc-design`, `skills/comment-writer`, `skills/judgment-day`, `skills/skill-creator`, `skills/skill-registry`, and `skills/work-unit-commits`) but their exported frontmatter names are prefixed to avoid collisions with user/global skills. Treat former package names such as `branch-pr`, `cognitive-doc-design`, `comment-writer`, `judgment-day`, `skill-creator`, `skill-registry`, and `work-unit-commits` as legacy aliases in prose; runtime skill selection should use `gentle-ai-branch-pr`, `gentle-ai-cognitive-doc-design`, `gentle-ai-comment-writer`, `gentle-ai-judgment-day`, `gentle-ai-skill-creator`, `gentle-ai-skill-registry`, and `gentle-ai-work-unit-commits`. +**Organic Driven Development (ODD)** is the everyday path: the agent explores before changing anything, clarifies only real decisions, and keeps small understood work small. Substantial, authorized work gets one recoverable feature document — mirrored in memory when available — so progress, evidence, and the next step survive an interruption; checks follow the configured TDD mode. -Delegation contract: +**[Docs →](docs/readme-reference.md#organic-driven-development)** -- parent/orchestrator resolves project/user skills from the registry and passes matching paths under `## Skills to load before work`; -- SDD subagents still use their assigned executor/phase skill; -- during normal runtime, subagents should not independently discover additional project/user `SKILL.md` files or the registry; -- fallback loading is degraded self-healing and must be reported via `skill_resolution` as `fallback-registry`, `fallback-path`, or `none`. +--- -## Persona modes +### Native review — Review the exact change -```text -/gentle:persona -``` +Diagram showing one frozen candidate passing through risk-scoped native review to an outcome, while human delivery choices stay separate -| Persona | Behavior | -| ----------- | ------------------------------------------------------------------------------------------------------------- | -| `gentleman` | Senior architect, teacher, direct technical feedback, Rioplatense Spanish/voseo when the user writes Spanish. | -| `neutral` | Same discipline, warmer professional language, no regional expression. | +Review the exact change, not a moving target. Native review keeps one candidate in view, returns risk-scoped evidence, and can surface a bounded correction path. You still decide what happens next in your repository. -Saved globally at: +**[Docs →](docs/review-integration.md)** -```text -~/.pi/gentle-ai/persona.json -``` +--- -A project can still override the global default with: +### Gentle Changes — Every edit, attributed and reviewable -```text -.pi/gentle-ai/persona.json -``` +Gentle Changes viewer: worktree accordion with per-file status on the left, the captured diff with line counts on the right, and a keyboard hint row -`/gentle:persona` writes the global config and updates an existing project override when one is present, so the current project does not stay stale. Run `/reload` or start a new Pi session after switching persona. +You should not have to run `git status` to find out what your agent did. Gentle Changes captures the successful write and edit tool calls from the current session and its owned subagents — no repository scans, no background polling — and shows them in a two-pane viewer with per-file line counts and an honest **diff unavailable** when an external edit breaks continuity. Coverage stops at those tools, so shell commands and failed runs leave no row, and a missing entry never proves a clean tree. `alt+g` opens it; `o` drops the real file into your editor. -## Model and effort assignment +**[Docs →](docs/gentle-shell.md#browse-captured-diffs)** -```text -/gentle:models -``` +--- -The modal discovers: +### Gentle Agents — Parallel work with a live view -- project agents in `.pi/subagents/`, `.pi/agents/`, and `.agents/`; -- user agents in `~/.pi/agent/subagents/`, `~/.pi/agent/agents/`, and `~/.agents/`. +Gentle Agents overlay showing a completed subagent thread with model, tokens, and elapsed columns, and the structured handoff it returned -When applying routing, project agents write runtime profiles to `.pi/subagents.json`; global and built-in agents write profiles to `~/.pi/agent/subagents.json`. +Delegating work should not mean losing it. Every subagent runs as its own process with a live card above the editor — model, tokens, cost, elapsed — and `alt+a` opens the full view with retained threads, stop controls, and history restored on resume. A child can ask you a question as an ordinary dialog, and background results come back as cards that start a new turn — nothing polls. -Recommended model/effort shape: +**[Docs →](docs/gentle-shell.md#gentle-agents)** -| Agent kind | Recommended model | Recommended effort (`thinking`) | -| -------------------------- | ---------------------------------------------------- | ------------------------------- | -| Explore, proposal, archive | Fast and cheap is usually enough. | `off` to `low` | -| Spec, design, tasks | Strong reasoning model. | `medium` to `high` | -| Apply | Strong coding and tool-use model. | `medium` to `high` | -| Verify / review | Strong fresh-context model. | `high` | -| Tiny utilities | Inherit active/default model unless they bottleneck. | `inherit` | +--- -Saved globally at: +### Profiles and model routing — One deliberate decision per knob -```text -~/.pi/gentle-ai/models.json -``` +Profiles view: profile list on the left, orchestrator model and effort on the right, with per-role profile routing and effective current routing -Existing project-local `.pi/gentle-ai/models.json` files are still read as a legacy fallback when no global model config exists, but `/gentle:models` writes the shared global config. +Model, effort, and who does what should be choices, not accidents. Named profiles route the orchestrator atomically and independently from packaged and review roles; a repository can pin its profile so its subagents stop following the globally active one, and the panel always shows the routing the runtime actually uses. -Inside `/gentle:models`, press `x` to export the saved routing to `~/.pi/gentle-ai/models.export.json`, or `r` to restore from that file after confirmation. Export uses a versioned envelope and restore writes the normal `models.json` shape before applying routing to agents. +**[Docs →](docs/readme-reference.md#agent-model-profiles)** -Config shape (per agent): +--- -```json -{ - "sdd-design": { - "model": "anthropic/claude-sonnet-4", - "thinking": "high" - }, - "sdd-archive": { - "model": "openai/gpt-5-mini" - } -} -``` +### Command palette — Every command, one keystroke away -Legacy string entries are still accepted and treated as `model`-only config. +Extension commands are only useful if you can find them. `alt+k` opens a curated, grouped palette — Configuration, Session, Diagnostics, and Skills — searchable by label, command name, or description, showing entries only when they are actually registered. -## Gentle Shell +**[Docs →](docs/gentle-shell.md#command-palette)** -Gentle Shell is the visual layer gentle-pi puts on top of pi. It follows the Gentle themes: one border language, champagne titles, rose for whatever is alive. +--- -In fullscreen at 140 columns or wider, the right sidebar scrolls **✿ Gentle-Pi ✿ → Status → Changes → Agents → TODO** together. The one-line heading is horizontally centered within the usable rail width, with pink flowers and normal white text in the Gentleman themes. Colors follow the active theme; no artwork scaling or custom fonts are used. Narrow/mobile terminals and regular mode retain bottom widgets without the sidebar heading. The original rose and text logo remain in the main chat startup intro. +### Also in the box -The status bar replaces pi's three-line footer with a single line of segments: +| Component | What it does | +| :--- | :--- | +| Startup and runtime panel | A configurable gentle-shell entry point and visible runtime state for Pi. | +| Skills and delivery guidance | Package skills for documentation, issue work, PRs, reviews, and reviewable work units. | +| Model, effort, persona, and profile controls | Explicit knobs for how Pi routes and presents work. | +| Safety boundaries | Guards around destructive operations and sensitive-path handling. | +| Optional companion packages | Extra capabilities you may choose to add; persistent memory is **not** bundled with `gentle-pi`. | +| Fullscreen workspace layout | Header row plus a scrolling Status → Changes → TODO rail on wide terminals. | +| Live status bar and prompt petal | One-line gauge, cost, and statuses; the petal shows `working` and `queued`. | +| Parent ↔ subagent communication | Delegate, steer, reply, and cross-session notification within your local profile. | +| Native interactive tools | Built-in questions, choices, and review captures — no third-party dependency. | +| Gentle Todo | A plan card that turns amber when the model lets it go stale. | +| Subscription usage | Per-window meters and resets for supported provider accounts. | +| Gentle notices | Gentle AI calls and review reminders as cards in the transcript. | -```text -✿ gentle-pi ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium ⟡ ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ⟡ MCP: 3 servers enabled Release notes -``` +> **Every component, skill and preset: [Full breakdown →](docs/gentle-shell.md)** -- Context is a gauge, not a number. It turns amber at 80% and red at 95%; after compaction it shows `?%` until the next response. -- Cost carries `sub` when the active model runs on a subscription login. -- Statuses other extensions publish through `setStatus` are appended as trailing segments; the session name sits at the right edge. -- On narrow terminals the session name is dropped first, then trailing segments, before the line is truncated. +--- -The prompt wraps pi's editor in a rounded frame with a petal that shows what the agent is doing: +### What's new in v3.5 -```text -╭─ ✿ working ──────────────────────────────────────────╮ -│ type, or / for commands │ -╰──────────────────────────────────────────────────────╯ -``` +The [v3.5.1 release](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) makes Gentle Shell runnable on its own: -- The petal is still while pi waits, spins with a `working` label while the agent works, and turns amber with a `queued` label when messages are waiting behind the current turn. pi's own "Working" row above the editor is hidden, since the frame already says it. -- The frame uses the theme's border color over the panel background, so the prompt reads as one panel with the cards around it; the editor's scroll indicators stay inside the frame. -- The hint appears only while the editor is empty. -- If another extension already installed a custom editor, Gentle Shell leaves it alone. +- **Standalone launcher:** `npm i -g gentle-pi` installs `gentle-shell`, which opens Pi with the Gentle Shell package loaded from its own home (`~/.gentle-shell/agent`) or, with `--link`, from your existing `~/.pi/agent`; `gentle-shell install npm:` and the other pi subcommands run against the selected home. A bundled or `PATH` pi is used, never a modified one. +- **Link mode take-over:** when `~/.pi/agent` already declares gentle-pi as a path package, the launcher takes over extension loading (`--no-extensions` plus explicit `-e` for every other declared package and loose extension) so tools never register twice. +- **Interactive RPC hosts:** with `GENTLE_SHELL_INTERACTIVE_HOST=1` and `--mode rpc`, ask-user tools use pi's RPC dialogs and gentle-agents publishes live subagent activity for the desktop app. See the [reference](docs/readme-reference.md#interactive-rpc-hosts). -Changes across this session's registered worktrees show up below the editor and as an aggregate `±N` next to the session branch in the bar: +--- -```text -✎ 3 files · +42 −7 · extensions/gentle-shell.ts, lib/shell-bar.ts, tests/x.test.ts · /gentle:changes -``` +

Back to top ↑

-- Each registered root shows **all** dirty files: plain `git diff` against HEAD plus untracked files, including edits that predate this session. There are no baselines or file-level attribution filters. -- The canonical session cwd root is included automatically. Successful standard `read`, `write`, `edit`, `grep`, `find`, and `ls` calls register their target worktree after completion. Failed calls, shell command text, and prose never register roots. Only roots sharing the session's Git common directory are accepted. -- For opaque shell use or worktrees used earlier, call `session_worktree_register` with `{"path":"/path/to/worktree"}`. Registration is explicit, canonicalized, and deduplicated; unrelated dirty siblings remain invisible without an ignored-roots list. -- The root registry persists in Pi custom entries (`gentle-pi.session-worktree/v1`). Exit/resume and `/reload` restore the same session UUID; `/tree` keeps roots session-wide. New sessions, `/fork`, and `/clone` ignore inherited registrations with another UUID. Clean roots stay registered but hidden until dirty; missing/prunable roots are skipped safely. Ephemeral `--no-session` runs cannot persist across exit. -- Counts refresh after every tool call, at the end of each turn, and every 5 seconds in the background, so edits made from nvim or another agent show up without touching pi. `GENTLE_PI_SHELL_CHANGES_WATCH_MS` changes the interval; `off` leaves only the tool-driven refresh. Outside a git repository the widget stays hidden. -- On narrow terminals the file list is dropped before the summary is truncated. +

+ +

-`/gentle:changes` or `alt+g` opens the framed two-pane viewer. Dirty worktrees are accordion groups in the left pane, labeled with branch and directory basename (`detached` when there is no branch). Expand groups to reveal indented changed files; multiple groups can stay expanded. The right pane previews the selected file's lazy-loaded diff, or shows the selected group's full directory and summary. Clean, bare, missing, and prunable roots remain hidden; untracked-only roots are included. +## Get started -- `j`/`k` or up/down traverse visible groups and files, keeping the selection in view. On a group, `enter`, space, or right arrow toggles expansion. Left arrow or backspace moves a file selection to its parent, or collapses the selected group. `ctrl+j`/`ctrl+k` or `pgdn`/`pgup` scroll the diff; `esc` or `q` closes the overlay. -- Opening, pressing `r`, and the background/overlay refresh cadence scan only registered roots. Worktree discovery supplies branch labels, never registration. No changes in registered roots means no widget and an informational notice instead of an overlay. -- While the overlay is open, git is polled every 2 seconds, so edits made from nvim, another agent, or a checkout show up in place. Expansion and selection stick to the raw worktree root and file path across refreshes; a diff reloads when its counts move. -- `GENTLE_PI_SHELL_CHANGES_KEY` rebinds the shortcut (pi key syntax, for example `ctrl+shift+g`); `off` disables it. On macOS, `alt+g` needs the terminal to send Option as Meta. -- On a file row, `o` (or `enter`) opens the selected file in `$VISUAL` or `$EDITOR`, with the selected worktree as the editor's working directory, and returns to pi when the editor exits. Diff lookup and caches are also scoped to that root; identical relative filenames in other worktrees cannot share a diff. -- Untracked files are diffed against an empty file so new files show their full content. +> **Naming transition:** The product is called `gentle-shell`; the current npm package and repository remain `gentle-pi` until migration. -Subscription usage shows in the bar after the cost, and `/gentle:usage` opens a panel with every window per provider: +### Path A: standalone `gentle-shell` (recommended, no pi changes) -```text -✿ gentle-pi ⟡ … ⟡ $9.49 sub ⟡ codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31% -``` +`gentle-shell` opens Pi with the Gentle Shell package loaded, without installing it into your pi agent or editing its `settings.json`. -- For Codex, usage comes from the same account usage endpoint the Codex CLI reads, using the OAuth token pi already holds. It is fetched at session start, at most every 5 minutes after a turn, and on `r` in the panel. Rate-limit headers on SSE responses are picked up too. -- For Claude Pro/Max, usage arrives in the rate-limit headers of every response, so the 5h and weekly windows appear after the first turn. -- The bar names the subscription it shows (`codex`, `claude`) and always follows the active model. The panel puts the active provider first, marked with the petal, and says why it has no data when it does not: API-key providers have no subscription windows, Claude reports after the first response, Codex waits for a fetch. -- Only the plan name and the windows are kept; account details in the payload are discarded. -- Gauges turn amber at 80% and red at 95%, like the context gauge. +```bash +npm i -g gentle-pi -Gentle notices are drawn as cards: the same rounded frame as the prompt, with the left rail and the title in the tone of the notice and the rest of the frame in the theme's border color. +# Own home, never touches your pi install +gentle-shell -```text -╭─ ✿ Gentle AI · review preflight ─────────────────────────────────────╮ -│ Receipt-driven development is enabled, and this worktree holds an… │ -╰──────────────────────────────────────────────────────────────────────╯ +# Reuse your pi sign-ins, models and chats instead +gentle-shell --link ``` -- Every call into the gentle-ai binary and every `gentle_review` tool renders as a card under the rose, `🌹︎ Gentle AI`: the rail is amber while it runs, green when it finished, red when it failed; the expand key sits in the top rule once the tool finished, and the collapsed result shows only its line count. Reviewer captures name their lens (`review capture · risk`; the group lists all four). -- The review preflight reminder renders as a card in the transcript with the expand key in its top rule. -- An active dev-binary override shows above the editor at startup, in amber, naming the binary and its digest, and leaves with the first prompt; an invalid override shows in red with the reason. -- Subagents draw their own card; see Gentle Agents below. - -### Gentle Agents - -The current package requires Pi 0.85.1 or newer (development tests pin 0.85.1). Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. - -The `subagent_*` tools and the agents card replace the third-party subagents package (remove `npm:pi-subagents-j0k3r` from your pi packages; while it is still installed the tools stay unregistered and a warning says so at startup). Agent definitions and settings are the ones you already have: markdown agents in `~/.pi/agent/agents/`, `~/.pi/agent/subagents/`, `/.pi/agents/`, `/.pi/subagents/` (project beats global, `subagents/` beats `agents/`), and `subagents.json` at the global and project level (`default_model`, `default_effort`, `default_mode`, `model_profiles`, `stall_timeout_ms`, `max_concurrency`, `history_max_tasks`). - -Agent paths follow `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent` for definitions, config, history, child sessions, and transcripts. These overrides select the agent profile; they do not sandbox project or shared global resources. +`gentle-shell` alone starts in its own home, `~/.gentle-shell/agent`, and sets that home up on first run — no separate step. Gentle Shell keeps its own home with the Gentle AI companion packages and no conflicting plugins; gentle-pi itself always stays this launcher's own copy, never one installed into the home; your pi install is untouched. That home also defaults to the Gentleman-Cute theme unless you set your own. `gentle-shell --link` reuses `~/.pi/agent` as-is, is never auto-provisioned, and never has its theme touched. -```text -╭─ ❀ Agents · 1 active · 1 done ─────────────────────────────── 1m24s ╮ -│ ✓ sdd-explore map footer data sources gpt-5.6-terra · 34k · $0.27 · 25s │ -│ ◐ sdd-apply write gentle-shell footer gpt-5.6-terra · 12k · $0.09 · 41s │ -╰──────────────────────────────────────────────────────────────────────────────╯ +```bash +# Re-run provisioning by hand, e.g. to see the full install output +gentle-shell setup ``` -Every subagent is its own `pi --mode rpc` child process, so the terminal never runs subagent work: the host reads JSON lines, applies each one as a small delta to a bounded per-task thread, and notifies only the listeners of that task. A task-mode child's question (`ctx.ui.select`, `confirm`, `input`, `editor`) reaches you as an ordinary pi dialog; a background child's question is dismissed. Subagents have no automatic total execution timeout: a long-running child remains live while it continues emitting RPC events. A silent child still times out through the configurable `stall_timeout_ms` watchdog (default four minutes). Closing pi stops the children that are still running. - -- `subagent_list_agents`, `subagent_run` (`agent`, `task`, `label?`, `context?`, `workspace_root?`, `mode?` task or background), `subagent_status`, `subagent_result`, `subagent_list_tasks`, `subagent_reply` (one current-session reply to a live child query), `subagent_cancel`, `subagent_send_message` (steer a running child), `subagent_continue` (resume a finished task in its own session). -- `subagent_run.workspace_root` selects an existing worktree in the session's Git clone. Validation happens before queueing; the child runs at that canonical root. Successful OS spawn registers the root in the originating parent session, including delayed queued launches, even without an active shell listener. Failed spawns do not register. `subagent_continue` retains the previous task's cwd; status and task details expose it. -- A background task's result comes back to the model as a `gentle-agents.result` message, drawn as a rose card, and starts a new turn when the agent is idle; the model never polls. -- A configured child can call `subagent_parent_message` with bounded, well-formed Unicode text. Notifications retain their existing admission semantics. A `kind: "query"` waits for one strictly correlated `subagent_reply` for at most 30 seconds; each child has at most four pending queries, and disconnect, timeout, stop, and send failure settle each request once. The current parent session alone can reply. The first admitted task-mode query ends the original tool response while its child keeps running; its eventual non-cancelled completion returns once as a follow-up only if that same session is still active. Channel closure prevents later sends and automatic retry is not provided. Peer transport, offline delivery, retries, and broadcasts are unsupported. -- The card shows the active session's tasks only: after `/new` or `/resume` the earlier session's tasks leave it and come back with their session. Finished rows stay for one minute (three at most), and the card spends at most a quarter of the terminal (three to eight rows) on tasks; beyond that the rest fold into one `… N more · alt+a to view` line so the editor never leaves the screen. Questions and running work keep their rows first. -- `/gentle:agents` or `alt+a` opens a full-terminal overlay. At 60+ columns, the split view shows groups/tasks beside the retained semantic thread; uppercase `F` or **Fullscreen** expands that thread. At 12–59 columns, click a current subagent directly to inspect its thread; in All sessions, first select its orchestrator. `Enter`/`Tab` also enter a narrow selection. **Back** or `Escape` returns one level, closing only at the root; **Close** or `q` closes globally without cancelling children. Selection and manual thread scrolling survive Back and resize. -- Mouse controls take priority over keyboard hints: **Follow** (`f`), **Open session** (`o`), **Stop** (`s`, legacy `c`, owned active tasks only), and **Scope** (`a`). A compact footer's `>` cycles through actions. Scope switches between this session's direct active children and all open orchestrators, including idle ones. Open writes a markdown transcript for `$EDITOR`, not a resumed child session. `j`/`k` move through lists or scroll an expanded thread; `ctrl+j`/`ctrl+k` and Page Down/Up page the thread. In Pi fullscreen mode, the wheel scrolls the viewport under the pointer; regular terminal mode does not capture mouse input. Below 12 columns or three rows, only a bounded Close cell remains; zero-sized terminals render nothing. -- The thread displays all retained Text, Thinking, Note, and Tool content without an additional presentation cap; existing store limits and truncation markers still apply. Only the selected task is subscribed while the overlay is open. -- Thread entries are presented as labeled Text, Thinking, Note, or Tool blocks; tool blocks show their status and nonempty output. -- Current scope has no orchestrator wrapper and excludes every terminal task. All sessions discovers open Pi instances sharing the same agent profile, even across repositories; it does not infer open sessions from retained tasks. Directory headings support left/right and mouse expansion, and cannot stop or open a task. Peer children and their retained threads are read-only: no local stop, editor-open, or continuation routing, and no import into the local task store. -- Presence refresh is paged while the overlay is open. Graceful shutdown withdraws an instance; after abrupt closure its last heartbeat may remain visible for up to 15 seconds plus the time to complete the next directory refresh. A recent heartbeat is a heuristic, not proof that a process is alive. Same-profile, same-user processes share retained activity text; this is not an authorization channel. -- `alt+s` confirms stopping the current active or queued subagents owned by the current process. `GENTLE_PI_AGENTS_STOP_KEY` rebinds it; `off` disables it. -- Finished tasks are written to `~/.pi/agent/gentle-agents/tasks/` (one JSON per task, newest `history_max_tasks` kept, default 200) and come back on demand for `subagent_result` and `subagent_continue`, never as overlay history. Child sessions live under `~/.pi/agent/gentle-agents/sessions/`. -- `ctrl+shift+a` collapses the card to its first row (`GENTLE_PI_AGENTS_KEY`), `GENTLE_PI_AGENTS_VIEW_KEY` rebinds the overlay, `GENTLE_PI_AGENTS_PI` overrides the pi command used for children, and `GENTLE_PI_AGENTS=0` disables the tools and the card. - -### Gentle Todo +`gentle-shell setup` installs the same companion packages gentle-ai provisions into a regular Pi, into this home only, then removes the one package that conflicts with gentle-pi's own `ask_user_question` tool (gentle-ai #4820). The first `gentle-shell` launch in a home already runs this automatically; `setup` is for re-running it by hand. See **[First run](docs/readme-reference.md#first-run-in-an-isolated-or-custom-home)** for the opt-out (`GENTLE_SHELL_NO_AUTO_SETUP=1`) and failure behavior. -The `todo` tool and its card replace the third-party todo extension (remove `npm:@juicesharp/rpiv-todo` from your pi packages; sessions written by it replay into the new card). - -```text -╭─ ❀ Todos · 1 of 3 ──────────────────────────────────────╮ -│ ✓ Add quiet tool rendering │ -│ ◐ Fix quiet tools conflict · fixing conflict │ -│ ○ Show git bash tails │ -╰─────────────────────────────────────────────────────────╯ +```bash +# Make --link the default +gentle-shell home link ``` -Three things keep the list current, which a static tool description cannot: +Every other argument is forwarded to pi unchanged, for example `gentle-shell --mode rpc` or `gentle-shell -p "..."`. Full flags, env vars, and modes: **[launcher reference](docs/readme-reference.md#gentle-shell-launcher)**. -- `write` replaces the whole list in one call, so the model rewrites the plan instead of patching it; `add`, `update`, `clear`, and `list` remain for single moves. -- Every turn's system prompt carries the open tasks and the rules: in_progress before starting, done right after finishing, update before ending the turn. -- A list that goes two turns untouched while tasks stay open turns amber with `stale · N turns`, and the prompt says so, so the model brings it up to date. +### Path B: inside an existing pi -A finished list stays on screen for the turn it finished in and clears at the next. `ctrl+shift+t` collapses the card to the task in progress (`GENTLE_PI_TODO_KEY` rebinds it, `off` disables it); `GENTLE_PI_TODO=0` disables the tool and the card. +Install the stable release into an existing pi agent, restart Pi, then synchronize the installed assets. -Set `GENTLE_PI_SHELL=0` to keep pi's built-in footer and editor. - -## Commands - -| Command | What it does | -| -------------------------------- | ------------------------------------------------------------------- | -| `/gentle:status` | Shows package, SDD asset, OpenSpec, and global model config status. | -| `/gentle:doctor` | Runs read-only diagnostics for SDD assets, model/persona config, memory tools, and safety guards. | -| `/gentle:models` | Opens global model + effort assignment UI. Press `x` to export and `r` to restore saved routing. | -| `/gentle:persona` | Switches global persona mode, with project override support. | -| `/gentle:background-subagents` | Shows or sets the managed background-subagents policy (`status\|enable\|disable`), naming the source that decided it. | -| `/gentle:telemetry` | Shows or changes the local Gentle AI telemetry trigger (`status\|enable\|disable\|preview`). | -| `/gentle:banner` | Configures startup banner rose, text logo, and color preset. | -| `/gentle:toggle-rose` | Toggles the startup rose. | -| `/gentle:toggle-text-logo` | Toggles the startup text logo. | -| `/gentle:banner-color` | Selects a startup banner color preset. | -| `/gentle-sdd-init` | Initializes or refreshes `openspec/config.yaml` (openspec/both stores only). | -| `/gentle:install-sdd` | Repairs missing global SDD runtime assets without overwriting files. | -| `/gentle:install-sdd --force` | Force-refreshes installed global SDD assets. | -| `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. | -| `/skill-creation` | Creates or updates an LLM-first skill using the packaged `gentle-ai-skill-creator` contract and style guide. | - -Package-owned global SDD runtime assets are also refreshed automatically on session start when `gentle-pi` changes. Project-local `.pi/agents` and `.pi/chains` remain manual overrides and are never overwritten by startup refresh. - -### Background subagents policy +```bash +# Published stable release: v3.5.1 +pi install npm:gentle-pi@3.5.1 -Background delegation is off unless you turn it on. The policy is user-owned: only an explicit `/gentle:background-subagents enable` or `disable` writes it, and Pi automation never toggles it. +# Restart Pi, then run: +gentle-ai sync -```text -/gentle:background-subagents Report the effective policy, the deciding source, and the resolved capability. -/gentle:background-subagents enable Write "on" to the global file. -/gentle:background-subagents disable Write "off" to the global file. +# Start Pi in your project +pi ``` -Four sources can decide the policy, and the first hit wins: - -| Priority | Source | Notes | -| -------- | ------------------------------------------------- | ------------------------------------------------------------ | -| 1 | `/.pi/gentle-ai/background-subagents.json` | Project file. Outranks everything, including a global write. | -| 2 | `/background-subagents.json` | Global file, written by `enable`/`disable`. `configHome` honors `GENTLE_PI_CONFIG_HOME` and defaults to `~/.pi/gentle-ai`. | -| 3 | `GENTLE_PI_BACKGROUND_SUBAGENTS` | Exactly `on` or `off`. Any other value is ignored. | -| 4 | Built-in default | `off`. | - -Both files use the strict shape `{"schema":"gentle-pi.background-subagents/v1","policy":"on"}`. A file that is present but malformed fails closed to `off` and is **not** skipped in favor of a lower-priority source, so a typo in the project file disables background subagents rather than silently handing the decision to the global file. The command reports that case as a warning instead of an ordinary `off`. - -Because the project file outranks the global one, `enable` still writes the global file but reports plainly when a project file keeps the effective policy unchanged. The resolved capability (`ready` or `absent`) reports whether `subagent_run` is actually callable in this session; a policy of `on` with capability `absent` means Gentle Agents is disabled or the retired subagents package is still installed. - -Startup banner settings remain global in `banner.json` under `GENTLE_PI_CONFIG_HOME` (default `~/.pi/gentle-ai`). Existing `showRose` and `showTextLogo` opt-outs independently control the main startup artwork; both default to enabled. Changes apply on the next session or `/reload`. Color presets are `pink` (default), `cyan`, `yellow`, and `green`. The static sidebar heading is independent of these preferences and follows the active theme. - -Startup flag: +See the [v3.5.1 release notes](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) for version-specific changes. ```text -pi --no-skill-registry -``` - -Use it when you want skills available normally but do not want Gentle AI to refresh/watch `.atl/skill-registry.md` on startup. `pi -ns` / `pi --no-skills` also skip the registry startup work because Pi is already disabling skill loading. - -## Included skills - -- `gentle-ai` — harness discipline for controlled Pi work. -- `gentle-ai-branch-pr` — issue-first PR preparation. -- `gentle-ai-chained-pr` — split oversized changes into reviewable PR chains. -- `work-unit-commits` — commits as reviewable work units. -- `gentle-ai-judgment-day` — blind dual review, fixes, and re-judgment. -- `cognitive-doc-design` — documentation that reduces cognitive load. -- `comment-writer` — concise, warm, postable collaboration comments. -- `gentle-ai-issue-creation` — issue workflow with checks before creation. -- `gentle-ai-skill-creator` — create LLM-first skills with valid frontmatter. -- `gentle-ai-skill-improver` — audit and upgrade existing LLM-first skills. - -## Memory - -`gentle-pi` does **not** provide persistent memory by itself. - -For memory, install the companion package: - -```bash -pi install npm:gentle-engram +/gentle:status +/gentle:doctor ``` -When memory tools are actually active, el Gentleman can save decisions, bug fixes, discoveries, user prompts, and session summaries across Pi sessions. +> **RDD is opt-in:** enable native receipt-driven development only through an explicit `/gentle:review-mode enable` decision. -Memory contract for SDD delegation: +> **Fullscreen installation note:** a recognized global installation persists Pi’s `"tuiMode": "fullscreen"` setting. Project-local and other install paths do not receive that change. -- parent/orchestrator owns memory retrieval and passes selected context into subagent prompts; -- subagents should not independently search memory during normal runtime unless explicitly instructed to retrieve a specific artifact or observation; -- subagents should save significant discoveries, decisions, bug fixes, and completed SDD phase artifacts before returning when memory tools are available; -- in memory/hybrid mode, SDD artifacts use stable topic keys such as `sdd//proposal`, `sdd//spec`, `sdd//design`, `sdd//tasks`, `sdd//apply-progress`, and `sdd//verify-report`. +> **Interactive RPC hosts:** the desktop app sets `GENTLE_SHELL_INTERACTIVE_HOST=1` automatically, without touching your Pi config — see the [installation reference](docs/readme-reference.md#interactive-rpc-hosts). -## Telemetry +For prerequisites, source-checkout instructions, full install behavior, and release policy, use the **[installation reference](docs/readme-reference.md#install)**. For everyday work, describe the outcome and follow [ODD](#odd--the-everyday-workflow). -`gentle-pi` does not collect anything itself. [gentle-ai](https://github.com/Gentleman-Programming/gentle-ai) owns anonymous usage telemetry end to end — install and heartbeat events, what fields are sent, rate limiting, and every opt-out. See its README/docs for the exact contract. +

Back to top ↑

-At session start, for a primary session only (never for a named or SDD sub-agent), Gentle Pi asks the local `gentle-ai` binary to send its own telemetry: it spawns `gentle-ai telemetry trigger --json` detached, with a 3 s deadline, discards its output, and never blocks session start or surfaces an error — an older binary without the verb is silently treated as nothing to do. This runs at most once per process. +

+ +

-Install counts for `gentle-pi` and `gentle-engram` come from npm download statistics; the package itself never emits an install event. +## Documentation -To opt out: +Start with the product-facing destination, then move into the operational reference only when you need the details. -- `/gentle:telemetry disable` — asks the local `gentle-ai` binary to disable telemetry (also `status` and `preview` to inspect it without leaving Pi). -- `DO_NOT_TRACK=1` — Gentle Pi itself will not spawn the trigger, and `gentle-ai` also honors this standard on its own. -- `GENTLE_AI_TELEMETRY=0` — same effect, `gentle-ai`'s own environment switch. - -`CI=true` also suppresses the trigger, since automated runs are not a real usage signal. - -## Package contents - -| Path | Purpose | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| `extensions/gentle-ai.ts` | Injects identity, orchestrates native review authority, refreshes global SDD assets, registers commands, applies model/persona config, and enforces runtime safety. | -| `lib/native-review-cli.ts` | Strict package-local adapter for Gentle AI START, FINALIZE, VALIDATE, SDD binding, and status contracts. | -| `lib/review-integration-v2.ts` | Strict consumer decoder for negotiated capabilities, operations, target status, projections, repair, and failures against contract `review-integration/v2` (active today). | -| `lib/review-candidate-view.ts` | Builds immutable changed-scope actor views while preserving full-tree, path, mode, symlink, and index integrity. | -| `lib/review-canonical.ts` | Permanent Pi-owned canonical JSON and domain-hash primitives for consumer-side identities. | -| `lib/review-repository.ts` | Permanent Pi-owned Git common-directory identity, safe Git environment, and authority-root binding. | -| `lib/gentle-ai-binary.ts` | Resolves and verifies the confined package-local Gentle AI runtime without global or PATH fallback. | -| `scripts/gentle-ai-installer.mjs` | Installs signed Darwin/Linux archives or exact Go SumDB-verified Windows source builds into the package-local runtime. | -| `contracts/review-integration/v1/` | Byte-identical provider schemas and conformance fixtures for contract `review-integration/v1`, hash-checked before packaging; retained on disk permanently because `/v2`'s schemas `$ref` into these fragments. | -| `contracts/review-integration/v2/` | Byte-identical provider schemas and conformance fixtures for contract `review-integration/v2` (immutable `base_tree`/`candidate_tree`, ordered `changed_path_manifest`, no inline candidate diff), hash-checked before packaging. | -| `extensions/startup-banner.ts` | Shows and configures the startup intro, color presets, and compact runtime panel. | -| `extensions/sdd-init.ts` | Registers `/gentle-sdd-init` for OpenSpec initialization. | -| `extensions/skill-registry.ts` | Maintains `.atl/skill-registry.md` from project/user skills and closes file watchers on shutdown. | -| `assets/orchestrator.md` | Parent-session orchestration contract (always-on core). | -| `assets/orchestrator-delegation.md` | Lazy-loaded delegation/routing/review detail, including the mirrored gentle-ai canon. | -| `assets/orchestrator-memory.md` | Lazy-loaded SDD memory phase table, artifact keys, and lifecycle rule. | -| `assets/orchestrator-skills.md` | Lazy-loaded skill registry fallback semantics and intent-driven skill discovery. | -| `assets/sdd-orchestrator-workflow.md` | Lazy-loaded SDD workflow surface for the parent orchestrator. | -| `assets/agents/` | SDD agents installed as global Pi runtime assets. | -| `assets/chains/` | SDD chains installed as global Pi runtime assets. | -| `assets/support/` | Strict TDD support docs for apply/verify phases. | -| `skills/` | Gentle AI delivery and collaboration skills. | -| `prompts/` | The `/skill-creation` prompt template. | -| `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | -| `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | -| `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | -| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | - -## Development - -Install from this repo: +| Destination | Purpose | +| --- | --- | +| [gentle-shell reference](docs/gentle-shell.md) | Workspace layout, changes, usage, agents, and todo interactions. | +| [ODD workflow](docs/readme-reference.md#organic-driven-development) · [Technical reference](docs/readme-reference.md) | Everyday work and recovery, installation, configuration, commands, and contributor detail. | +| [Review integration](docs/review-integration.md) | The provider/consumer boundary for native review. | +| [Native authority architecture](docs/native-authority-architecture.md) | Ownership boundaries and review architecture. | +| [Telemetry](docs/telemetry.md) | Approved fields and source limitations. | +| [Delegated verification](docs/delegated-verification.md) | Practical verification guidance. | +| [Skill style guide](docs/skill-style-guide.md) | The package skill contract. | -```bash -pi install . -``` +

Back to top ↑

-Validate before publishing: +

+ +

-```bash -pnpm test -bun build extensions/skill-registry.ts --target=node --format=esm --outfile=/tmp/skill-registry.js -node --experimental-strip-types --check extensions/gentle-ai.ts -node --experimental-strip-types --check extensions/sdd-init.ts -node --experimental-strip-types --check extensions/startup-banner.ts -npm pack --dry-run -``` +## Community -### Running the cross-lane battery +This project is built in public. Bring a real workflow, a sharp question, a bug report, or a small improvement that makes the next person’s work clearer. -The cross-lane battery (`tests/crosslane/cross-lane.mjs`) validates the adapter against a real `gentle-ai` binary, end to end and out of CI on purpose. The pinned decoder lane only ever sees vendored fixtures, so new envelope schemas and full controller sequencing are never driven through a live lifecycle before merge; the battery closes that gap. +

+ GitHub issues + Contributors + Gentleman Programming Discord +

-```bash -pnpm test:cross-lane # requires the dev-binary override -pnpm test:cross-lane --with-model # adds the real Go-owned pi reviewer run (model spend) -``` +

+ gentle-shell contributors +

-What it checks, against live scratch repositories: +- Open an [issue](https://github.com/Gentleman-Programming/gentle-shell/issues) with the context needed to reproduce or understand the idea. +- See the people shaping the project in the [contributors graph](https://github.com/Gentleman-Programming/gentle-shell/graphs/contributors). +- Follow [Gentleman Programming](https://github.com/Gentleman-Programming) for the wider ecosystem. -- a low-risk lifecycle: START → native-approved FINALIZE → terminal burn; the `pre-commit` gate is informational and unmanaged, not an allow decision or retained receipt; -- the medium-risk `consent/v3` granted round-trip through the direct decoder lane; -- controller sequencing: each decoded offered next step equals the native transition; correction evidence precedes Go-owned targeted validation, then native approval and terminal burn leave no retained receipt; -- the active audited abandon end to end, asserting the adapter builds the exact nine-line `gentle-ai.review-abandon-authorization/v2` discarded-work binding and the native gate commits the quarantine record; -- after a scope change, a burned approved predecessor exposes no recoverable authority; recovered-successor hydration remains covered at unit level; -- forward-decoder freshness: every live envelope captured from the binary must decode without unknown-key rejection, the early warning that gentle-ai main grew a field gentle-pi lacks; -- the default no-model lane: 13 of 14 checks pass while the real-model check is intentionally skipped; Go-owned validation uses a deterministic scratch fake `pi`, and only `--with-model` runs the real locked-down reviewer with model spend. +

Back to top ↑

-Prerequisites: +

+ +

-- A real `gentle-ai` binary selected through the dev-binary override; there is no PATH or pinned-binary fallback, and the battery refuses to run without one. Either export `GENTLE_PI_GENTLE_AI_DEV_BINARY=` for the session, or register a persistent override with `/gentle:dev-binary ` (stored at `~/.pi/gentle-ai/dev-binary.json` with schema `gentle-pi.dev-binary/v1`; the environment variable takes precedence over the registration, and the binary is re-validated and re-hashed on every resolution). Any real build works: an installed release binary or a locally built gentle-ai main. -- A Git checkout or worktree of this repository. The battery is a contributor tool wired to the repository layout and is excluded from `pnpm test` and CI by construction; run it from the repo, not from an installed Pi package. +## About the author -The battery owns one throwaway scratch root under the OS temp directory and never touches the enclosing repository. Before any review lifecycle it creates private `HOME`, XDG config/cache/data/state, temporary, and RDD state directories inside that root; it proves RDD starts `off/default`, explicitly opts in with sandbox-global RDD, and removes the complete root after the run. It never requires or changes the user's ambient RDD mode. The default run spends no model tokens; `--with-model` launches one real reviewer model run and costs model spend. +`gentle-shell` is built by [Alan Buscaglia](https://github.com/Gentleman-Programming), the maker behind Gentleman Programming. It grew from a practical belief: capable agents are more useful when the human’s intent, review load, and delivery judgment stay visible all the way through the work. -It prints one PASS/FAIL/SKIP row per check plus a note, and exits non-zero when any check fails. A check blocked by a known upstream class is reported with a `known-red` prefix instead of being hidden; it remains a failure, not a success. +Startup intro collaboration: thanks to [@aporcelli](https://github.com/aporcelli) and [`pi-gentle-startup`](https://github.com/aporcelli/pi-gentle-startup), which inspired the clean-screen startup animation, compact runtime panel, and pink visual treatment. -Running this battery against new gentle-ai builds (release candidates or main) and reporting red checks is a valuable contribution. The sibling provider-side battery lives at `scripts/cross-lane-battery.sh` in [Gentleman-Programming/gentle-ai](https://github.com/Gentleman-Programming/gentle-ai). +

+ Gentleman Programming website + Gentleman Programming YouTube + Gentleman Programming GitHub +

-Publish npm through GitHub Actions only: +

Back to top ↑

-```bash -version="$(node -p "require('./package.json').version")" -tag="v${version}" -git fetch --no-tags origin "refs/tags/${tag}" -test "$(git rev-parse 'FETCH_HEAD^{commit}')" = "$(git rev-parse "${tag}^{commit}")" -gh workflow run publish.yml \ - --repo Gentleman-Programming/gentle-pi \ - --ref main \ - -f tag="${tag}" -gh run watch --repo Gentleman-Programming/gentle-pi --exit-status -npm view gentle-pi@ version --registry=https://registry.npmjs.org/ -npm dist-tag ls gentle-pi --registry=https://registry.npmjs.org/ -``` +

+ +

-Do not run `npm publish` locally for `gentle-pi`. Dispatch the trusted workflow definition only from protected default `main` and provide its sole `tag` input. The workflow requires an exact annotated `vSemVer` tag whose peeled commit, current remote `main`, dispatch/main workflow commit, checkout, and `package.json` version are identical. It rechecks remote tag and `main` immediately before publishing through OIDC with provenance and environment protection; an advanced `main` requires a new release version, never a moved tag. +

Built with the workflow it brings to Pi.

-## Principles +

+ MIT License +

-- Human control over agent momentum. -- Concepts before code. -- Artifacts over floating chat context. -- SDD when risk justifies it. -- Strict TDD when tests exist. -- One parent orchestrator, focused subagents. -- Reviewable changes over giant diffs. +> **Trademark notice:** The gentle-shell™ and gentle-pi™ names and associated logos are trademarks of Alan Buscaglia. The MIT License applies to the code; it does not permit implying endorsement or official affiliation. See [TRADEMARKS.md](TRADEMARKS.md). From a663195c6871647ac22869c04ae8cb4a37df2aff Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:21:45 -0300 Subject: [PATCH 38/49] chore(readme): remove README delta from history slice The history slice branches must not touch README.md: the docs table lives in main and evolves independently of the extension slices. The opt-in capture documentation stays in docs/prompt-history.md; the README pointer row introduced by the capture-gate commit is dropped and README.md is restored to upstream/main verbatim. --- README.md | 1036 +++++++++++------------------------------------------ 1 file changed, 207 insertions(+), 829 deletions(-) diff --git a/README.md b/README.md index 0207c389c..a8a5ee727 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,44 @@ -# gentle-pi + -[![npm](https://img.shields.io/npm/v/gentle-pi?color=blue)](https://www.npmjs.com/package/gentle-pi) -[![pi package](https://img.shields.io/badge/Pi-package-6f42c1)](https://pi.dev/packages/gentle-pi) -[![license](https://img.shields.io/npm/l/gentle-pi?color=blue)](LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/Gentleman-Programming/gentle-pi?style=flat&color=yellow)](https://github.com/Gentleman-Programming/gentle-pi/stargazers) -[![Gentle-AI](https://img.shields.io/badge/Gentle--AI-ecosystem-ff69b4)](https://github.com/Gentleman-Programming/gentle-ai) -[![Gentleman Programming](https://img.shields.io/badge/by-Gentleman%20Programming-black)](https://github.com/Gentleman-Programming) -[![YouTube](https://img.shields.io/badge/YouTube-Gentleman%20Programming-red?logo=youtube&logoColor=white)](https://www.youtube.com/c/GentlemanProgramming) -[![Discord](https://img.shields.io/badge/Discord-community-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/gentleman-programming-769863833996754944) -[![SDD/OpenSpec](https://img.shields.io/badge/SDD-OpenSpec-00ADD8)](#sddopenspec-flow) -[![Subagents](https://img.shields.io/badge/Pi-subagents-brightgreen)](#what-it-adds) +
+ gentle-shell — Ecosystem, Agent, One shell +
+ +

gentle-shell™

+ +

Your coding agent for controlled development in the workspace you lead.

+ +

+ npm + Pi-native package + MIT license + GitHub stars + Last commit +

+ +

+ + Website +  ·  + Quickstart +  ·  + Docs +  ·  + Wiki + +

+ +
+ +

Your terminal can run an agent. Your workspace should help you lead it.
gentle-shell is your coding agent, bringing your changes, tasks, and engineering workflow together—built for Pi.

-**[Gentle-AI website](https://gentle-ai.gentlemanprogramming.com/)** • **[Gentle-AI wiki](https://gentle-ai-wiki.gentlemanprogramming.com/)** • **[Engram](https://engram.gentlemanprogramming.com/)** +

One workspace. A coding agent you direct. A workflow you can inspect.

+ +

BUILT FOR PI  ·  Coding-agent workspace  ·  Focused agents  ·  ODD

+ +

+ ★ Star gentle-shell on GitHub +

@@ -27,934 +54,285 @@ - Star History Chart + Star History Chart -
- -**Turn Pi from a powerful coding agent into a controlled development harness.** - -`gentle-pi` installs **el Gentleman** in Pi: a senior-architect operating layer for Spec-Driven Development, focused subagents, strict TDD evidence, reviewable work units, safety guards, project/user skill discovery, and bounded native review. - -Pi already has strong tools. `gentle-pi` adds the discipline for using them well, keeps review evidence Git-derived instead of agent narration, and leaves delivery decisions to ordinary repository policy. - -`gentle-pi` is the Pi-native package from the [Gentle-AI ecosystem](https://github.com/Gentleman-Programming/gentle-ai), built by [Gentleman Programming](https://github.com/Gentleman-Programming): the broader open-source project for turning AI coding agents into disciplined engineering environments with SDD workflows, skills, memory integrations, model routing, and review guardrails across multiple agents. - -> **Trademark notice:** The gentle-pi name and logo are trademarks of Alan Buscaglia. The MIT License applies to the code; it does not permit implying endorsement or official affiliation. See [TRADEMARKS.md](TRADEMARKS.md). - -Follow the project and the community around it: - -- GitHub: [Gentleman-Programming](https://github.com/Gentleman-Programming) -- YouTube: [Gentleman Programming](https://www.youtube.com/c/GentlemanProgramming) -- Community Discord: [Gentleman Programming](https://discord.com/invite/gentleman-programming-769863833996754944) - -Startup intro collaboration: thanks to [@aporcelli](https://github.com/aporcelli) for [`pi-gentle-startup`](https://github.com/aporcelli/pi-gentle-startup), which inspired the clean-screen startup animation, compact runtime panel, and pink visual treatment. - -## The problem - -Most coding-agent sessions fail for operational reasons, not model reasons: - -- the agent jumps into code before requirements are clear; -- architectural decisions disappear into chat history; -- one request quietly becomes a huge multi-area diff; -- tests run late, or not at all; -- reviewers get handed a wall of changes; -- subagents are available, but the parent session has no orchestration discipline; -- project skills exist, but the model forgets to load them. - -`gentle-pi` fixes the workflow around the agent. - -## What it adds - -| Capability | What it does | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -| **el Gentleman persona** | Makes Pi behave like a senior architect and teacher, not a generic chatbot. Spanish responses use Rioplatense voseo by default; neutral mode is saved globally with project overrides. | -| **Configurable startup intro** | Adds a rose/text-logo startup intro, compact runtime panel, color presets, and commands to hide or show the decorative parts. | -| **Work routing discipline** | Small tasks stay inline. Context-heavy exploration can be delegated. Large or risky changes go through SDD/OpenSpec. | -| **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `onboard`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, `sync`, and `archive`. | -| **Lazy SDD preflight** | Resolves SDD mode, artifact store, delivery strategy, and review budget once per session; prompts only when a choice is genuinely unresolved. | -| **Subagent orchestration** | Keeps one parent session responsible while child agents explore, implement, test, or review with focused context. | -| **Strict TDD support** | When project config declares a test command, apply/verify phases must record RED → GREEN → TRIANGULATE → REFACTOR evidence. | -| **Closed choice prompts** | Per-option hover/click/wheel in fullscreen; keyboard selection in either TUI mode. | -| **Native pointer regions** | Compose hover, press, click, and wheel behavior around public TUI components. | -| **Agent overlay close control** | Adds a header close button that adapts to available width. | -| **Reviewer protection** | Surfaces review workload risk before a task turns into an oversized PR. | -| **Per-agent model assignment** | Pi-native modal for assigning stronger or cheaper models to specific SDD/custom agents. | -| **Skill discovery registry** | Maintains `.atl/skill-registry.md` from project and user skills so review/comment/PR workflows do not silently miss the right skill. | -| **Skill creation workflow** | Provides the `gentle-ai-skill-creator`/`gentle-ai-skill-improver` skills, `/skill-creation` prompt, and packaged style guide for LLM-first skills. | -| **Delivery skills** | Includes issue-first PRs, chained PRs, work-unit commits, cognitive docs, comment writing, and Judgment Day review. | -| **Bounded native review** | Freezes one candidate, dispatches only controller-selected lenses, and records native authority. Review outcomes are informational; delivery follows ordinary repository policy. | -| **Verified native runtime** | Provisions the exact package-local Gentle AI v2.7.0 runtime: signed, SHA-256-pinned release archives on Darwin/Linux and a Go SumDB-verified source build on Windows x64/arm64. It validates package-local integrity and rejects PATH, global, sibling, symlink, and mode fallbacks. | -| **Runtime safety** | Blocks destructive shell commands, asks for confirmation for sensitive operations, and blocks direct read/write/edit access to sensitive paths. | - -## Native pointer regions - -Compose pointer behavior around public `Text`, `Box`, or custom content without making it a keyboard target: - -```ts -const scope = createNativePointerScope(); -const openInput = scope.wrap(new Text("Open input", 0, 0), { - onClick: () => { - openInputEditor(); - return { handled: true }; - }, -}); -const panel = new Container(); -panel.addChild(openInput); -const observer = scope.createMouseObserver(() => tui.requestRender()); -``` - -Pass `observer` around the root's native mouse dispatch; reuse `panel` as custom or overlay content. -Pointer input is fullscreen-only. Regions preserve a consuming child's native result and do not focus -`Text`, activate on press or wheel, synthesize outside leave events, or alter terminal tracking. -Callers own keyboard policy, theme state, and business actions. - -**Migration note:** Do not enable `pi-tool-cards` and `quiet-tools` together: Pi rejects duplicate `bash`, `read`, `edit`, and `write` registrations. Disable or remove the standalone package during migration; gentle-pi does not change those package registrations or delete that repository. The global fullscreen setting described below is a separate install-time change. - -## Install - -```bash -pi install npm:gentle-pi@0.14.0 -``` - -### Install-time fullscreen - -For this release, a successful postinstall in Pi's **global npm-managed** `agent-home/npm/node_modules/gentle-pi` installation persists `"tuiMode": "fullscreen"` in `agent-home/settings.json`, preserving other settings. Agent home resolves through `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent`. Use `/settings` to switch back to regular; rerunning this recognized postinstall resets it to fullscreen. Existing project overrides still take precedence. - -Project-local installs (`pi install -l`), Git/local-path installs, temporary packages, development checkouts, ordinary npm consumers, and pnpm symlink-store packages do **not** receive this change. Updates or installs that do not execute postinstall cannot reassert it; this is not a universal install/update guarantee or a change to historical releases. - -Malformed/nonobject JSON, symlink/nonregular settings, unsafe paths, or a busy settings lock fail without replacing settings. The installer coordinates with Pi's cooperative settings lock and uses atomic replacement; it does not guarantee safety against noncooperating writers or malicious concurrent directory replacement. Already-fullscreen settings remain byte-identical. Native installation failure leaves settings untouched; `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1` skips only native provisioning, not the recognized global fullscreen setting. - -### RDD version policy - -Native RDD started in `gentle-pi` `v0.15.0` on 2026-07-10 with bounded review transactions. Every release from `v0.15.0` onward is part of the unstable RDD development line. New releases will continue improving RDD until the project declares the line stable. The stable version for normal use without native RDD is the last preceding release, `v0.14.0`. - -```bash -# Stable version without native RDD -pi install npm:gentle-pi@0.14.0 - -# Latest released RDD build (unstable) -pi install npm:gentle-pi@latest -``` - -The latest RDD package installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for stable pins such as the current v2.7.0; raw prerelease binaries only under a prerelease pin). Windows x64 and arm64 build the exact `v2.7.0` source tag with a local Go 1.25.10+ toolchain, a sealed Go environment, `GOTOOLCHAIN=local`, and `GOSUMDB=sum.golang.org`; it does not download Go automatically. Windows provenance is Go-toolchain plus SumDB evidence and postinstall tamper detection, **not** Authenticode or protection against a malicious joint binary-and-manifest replacement. Package-private locks coordinate cooperative concurrent or crashed installers; their tombstones fail closed. A malicious same-user process with write access to package-private `node_modules` is outside that protocol because it can already replace package code, binary, or manifest, and portable Node has no pathname-delete CAS. It never uses `PATH` or a global `gentle-ai` installation. For development or offline installs only, set `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1`; native review operations then fail closed with an actionable `package-local-binary-missing` error until the package is reinstalled normally. - -Recommended companion packages: - -```bash -pi install npm:pi-intercom -pi install npm:gentle-engram -pi install npm:pi-web-access -pi install npm:pi-lens -pi install npm:@juicesharp/rpiv-ask-user-question -``` - -Then start Pi in a project: - -```bash -pi -``` - -`gentle-pi` provides SDD agents as global Pi runtime assets, not per-project setup. The first SDD flow in a session still runs a one-time SDD preflight for preferences; for natural-language requests, el Gentleman decides when SDD is needed and runs the explicit preflight first. - -## Quick start - -```text -/gentle:status Check package, SDD assets, OpenSpec, and global model config. -/gentle:doctor Run read-only diagnostics for SDD assets, config, tools, and guards. -/gentle:sdd-preflight Run or reuse the session SDD preflight explicitly. -/gentle-sdd-init Create or refresh openspec/config.yaml (openspec/both stores only). -/gentle:models Assign global model/effort routing to SDD/custom agents. -/gentle:persona Switch between gentleman and neutral persona modes. -/gentle:background-subagents Show or set the managed background-subagents policy, with its deciding source. -/gentle:banner Configure startup rose, text logo, and color preset. -``` - -Typical flow: - -1. Open Pi in your repo. -2. Run `/gentle:status`. -3. Run `/gentle-sdd-init` once per project, or when test/project capabilities change. This also runs the session SDD preflight. -4. For a substantial change, ask Pi to use SDD. Natural-language requests are classified by the parent agent, not by brittle runtime regexes. -5. Review the phase artifacts instead of trusting floating chat context. - -## Core workflow - -1. **Install and inspect.** Install `gentle-pi`, open Pi in the target repository, then run `/gentle:status` or `/gentle:doctor`. -2. **Plan when risk justifies it.** Small work stays direct; substantial work uses SDD with Engram, OpenSpec, or both so requirements and decisions survive compaction. -3. **Build with evidence.** One focused writer implements the approved scope. When Strict TDD is available, apply and verify preserve RED → GREEN → TRIANGULATE → REFACTOR evidence. -4. **Use runtime-owned RDD when available.** Gentle AI supplies any runtime-specific review instructions; this package does not recreate a lifecycle in documentation or prompts. -5. **Deliver through ordinary repository policy.** Review and Judgment Day evidence is informational only; Pi never creates a delivery route, authorization, target rederivation, or receipt gate. - -> **Trust what the system can derive, not what an agent claims.** Agents analyze the candidate. The package-local Gentle AI runtime owns scope, risk, findings, and review authority. Review outcomes inform delivery; ordinary repository policy decides delivery commands. Dangerous-command safety and destructive-review consent remain independent. See Gentle AI's [review authority threat model](https://github.com/Gentleman-Programming/gentle-ai/blob/main/docs/review-authority-threat-model.md) and [Chapter 21 — Verifiable Trust](https://the-amazing-gentleman-programming-book.vercel.app/en/book/Chapter21_Verifiable-Trust). - -## How the harness decides what to do - -`gentle-pi` routes through the smallest safe workflow: - -| Request shape | Harness | -| --------------------------------------------------------------------------- | ---------------------------- | -| Small, clear, local edit | Inline direct work. | -| Unknown codebase area or context-heavy investigation | Focused subagent delegation. | -| Large, ambiguous, architectural, product-facing, or high-review-risk change | SDD/OpenSpec flow. | - -The goal is not ceremony. The goal is to avoid accidental chaos. Once a task stops being small, delegation is mandatory. - -### Delegation triggers - -`gentle-pi` keeps the parent session thin and delegates at the narrowest useful point. When the Pi Subagents extension is installed, the preferred runtime is the `subagent_*` tool family because it runs the user's configured project/global subagent definitions and preserves history/background behavior. With the background policy on, delegations default to background mode: the terminal stays free and each result comes back as a message that starts a new turn; task mode is reserved for delegations that must ask the user something mid-flight. If those tools are unavailable, the parent should fall back to Pi's native `Agent` tool or another available delegation mechanism. The requirement is delegation; the runtime is capability-dependent. - -| Trigger | Required behavior | -| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| Reading 4+ files to understand a flow | Launch `scout`, `context-builder`, or the closest read-only mapping subagent. | -| Touching 2+ non-trivial code files | Delegate one writer; do not continue inline unless delegation is unavailable. | -| Commit, push, or PR after code changes | Follow the loaded native instruction, or ordinary repository policy when none is supplied. | -| Wrong cwd, worktree/git accident, merge recovery, confusing test/env issue | Stop, preserve the affected scope, and investigate separately before resuming. | -| Long monolithic session with accumulating complexity, roughly 20 tool calls, 5 exploratory reads, or 2 non-mechanical edits | Pause and delegate the remaining work, or stop and explain the exact blocker. | - -The intended balanced loop for a bounded bugfix is: - -```text -parent git/status + clarify → one worker writes authorized fixes → focused verification → parent reports -``` - -`scout`/`context-builder` save parent context by compressing broad exploration. `worker` preserves a single writer thread. Any RDD-specific actor behavior belongs to the runtime instruction supplied by Gentle AI, not to this README. - -### Review authority recovery and reset safety - -Legacy pre-graph authority is never migrated. `gentle_review inspect` reports an exact repository-bound destructive reset challenge for legacy corruption; after that fresh interactive authorization, RESET and RECOVER_LOCK route to the audited native `gentle-ai review reclaim` operation and RECOVER routes to native `gentle-ai review recover`, so every destructive transition is executed and audited by the native authority store. Native inputs the request did not carry return a `native-input-required` envelope instead of being invented. Existing graph-v1 ordinary lineages remain readable and gate-validatable but are read-only; Judgment Day remains mutable on graph-v1. - -`gentle_review abandon`, `quarantine-legacy`, and `reconcile-authority` remain explicit v2.1.11 maintenance routes. Pi derives and displays the published nine-line `gentle-ai.review-abandon-authorization/v2` binding only for a caller-specified compact lineage, revision, snapshot identity, and discarded-work summary (captured lens results, findings presence, evidence-record presence); the native CLI re-derives non-terminal compact-v2 eligibility and the exact discarded work before accepting it. Legacy quarantine accepts only `historical findings freeze changed unrelated transaction state` with disposition `quarantine-malformed-freeze-event` and uses its exact eight-line binding. Both require fresh interactive approval and fail closed headlessly. - -`gentle_review reconcile-authority` accepts one predecessor lineage and revision, one successor lineage and revision, an actor, and a reason. Pi derives the exact seven-line `gentle-ai.review-reconcile-authorization/v1` binding, or appends exactly `anomalies=unchanged_target,malformed_recovery_authorization` for the published dual anomaly in that order. Native code re-derives every anomaly; malformed bindings, changed revisions, unavailable native support, cancellation, and native refusal fail closed through typed envelopes. - -Reconciliation is intentionally narrow: native code may quarantine only the bound invalid compact-v2 recovery successor and persists the returned audit record; the predecessor stays untouched. Pi never recreates the retired `prepare-supersession`/`supersede` authority writer and never falls back to RESET or RECOVER. - -`gentle_review repair-legacy-alias` is the sole v2.1.11 route for `unsupported historical v1 operation alias`. The model supplies only lineage, actor, and reason. Pi freshly reads the native inventory, derives the canonical repository, exact legacy revision, fixed diagnostic, and fixed `quarantine-approved-historical-alias` disposition, displays the LF-only eight-line binding, and requires a new interactive approval. Native re-derives eligibility and quarantines rather than rewriting or validating the historical chain. - -`review dispose-result` is deliberately unsupported by Pi pending a separate design; it has no controller operation or fallback. All maintenance routes fail closed headlessly and never auto-run against legacy history. - -Native lifecycle status remains informational. VALIDATE does not authorize delivery; commit, push, PR, and release commands follow ordinary repository policy. Recovery grants no new budget, and legacy graph bundle export/import is retired. - -This is the post-U8 boundary, not the final architecture. [Issue #191](https://github.com/Gentleman-Programming/gentle-pi/issues/191) is the immediate final unit in this same delivery: extract the remaining Pi command-projection and lifecycle-gate surface from `review-transaction.ts`, repoint runtime enforcement, then delete only dependencies proven unreachable without weakening graph-v1 Judgment Day. The branch-wide High-tier 4R runs after that extraction, before the single size-exception PR. - -### Review Lens Selection (architecture reference) - -`reviewer` is not an installed subagent name. It is historical routing vocabulary, not a static instruction. When a runtime-specific Gentle AI instruction applies, it alone determines whether any concrete lens is used: - -| Context | Review lens | -| --- | --- | -| Clear naming, structure, maintainability, small refactors | `review-readability` | -| Behavior, state, tests, determinism, regressions | `review-reliability` | -| Shell/process integration, partial failures, recovery, degraded dependencies | `review-resilience` | -| Security, permissions, data exposure/loss, architecture, dependencies | `review-risk` | -| Large PR, hot path, or >400 changed lines | Full 4R: `review-risk`, `review-resilience`, `review-readability`, `review-reliability` | - -The former compact controller classified documentation/comment/formatting-only changes as zero-lens, standard changes as one dominant lens, and higher-risk paths as full 4R. This describes compatibility architecture only; never derive or run those choices from this README. - -### Review authority architecture (reference only) - -Gentle AI dynamically supplies runtime-specific RDD instructions. `gentle-pi` does not define an RDD lifecycle, command route, approval path, recovery sequence, or fallback. The historical compact-controller material below documents architecture and compatibility boundaries only; it is not an operator instruction. - -Concretely: `gentle-pi` mirrors the Gentle AI provider contract bundle's `orchestration/pi.md` locally (`contracts/review-provider-contract-mirror/`, verified against the mirror lock's recorded SHA-256 before injection) and injects that mirrored text into the primary session's system prompt at session start. Gentle AI does not write anything into Pi's system prompt; when the mirrored contract is absent, unreadable, or fails digest verification, `gentle-pi` invents no fallback lifecycle. - -```mermaid -flowchart TD - A["Clarify scope and acceptance criteria"] --> B{"Choose the smallest safe workflow"} - B -->|Small and local| C["Inline implementation"] - B -->|Context-heavy or multi-file| D["Focused subagent"] - B -->|Large or architectural| E["SDD phase artifacts"] - C --> F["Implement with test evidence"] - D --> F - E --> F - F --> G["Independent verification"] - G --> H["Target-scoped native status"] - H -->|Ambiguous or corrupted| X["Blocked: native maintainer action"] - H -->|Unrelated| I["START freezes candidate, scope, tier, lenses, and budget"] - - subgraph Ordinary_review["Ordinary bounded review"] - I --> R["reviewing"] - R --> J["Run each selected lens once"] - J --> K{"Severe candidate-caused blocker?"} - K -->|No| A1["approved"] - K -->|Yes| C1["correction_required"] - C1 --> C2["Forecast bounded correction"] - C2 --> C3["Apply scoped fix"] - C3 --> V["validating"] - V -->|Validator passes| A1 - V -->|Fails, malformed, or out of scope| E1["escalated"] - end - - A1 --> O["Review outcome is informational"] - E1 --> O -``` - -VALIDATE is informational. Commit, push, PR, and release commands follow ordinary repository policy; RDD never authorizes, rewrites, consumes review state for, or blocks them. Dangerous-command safety and destructive-review consent remain independent. - -Native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.7.0 executable, independently hashes it, then negotiates `gentle-ai.review-integration/v2` outside the repository. Capabilities are cached by that executable digest. Every START, target status, FINALIZE, validate, and BIND-SDD request passes the same contract identifier. Negotiated envelopes decode exactly against the vendored schemas; `recover` routes only the provider-selected `action_disposition`, and optional additions require a future compatible schema/minor that the provider explicitly advertises and the consumer negotiates. - -Contract `/v2` replaces the Base64 `candidate_diff` reviewer transport of `/v1` with immutable `base_tree`/`candidate_tree` plus an ordered `changed_path_manifest` and never an inline patch. `gentle-pi` negotiates `/v2` only, with no dual-lane fallback; the cutover landed as one atomic commit against gentle-ai v2.2.2 (tracked by the `migrate-review-integration-v2` change), and the `/v1` schemas stay packaged because the `/v2` schemas `$ref` into their fragments. This provider contract version is unrelated to Pi's own internal "compact-v2" review-authority naming used below — the shared digit is coincidental, not a version pairing. - -Target status owns `current_target`, `unrelated`, `ambiguous`, and `corrupted` applicability and returns one native action. Pi does not reconstruct ordinary authority from provider-private files or choose a lineage from repository-wide history. Restart recovery rebuilds only the derived candidate view from the native Git/content projection, including intended-untracked paths, symlinks, and immutable gitlink identities. Native failure envelopes retain their exact mutation outcome, replayability, required inputs, request digest, and next action. After an unknown or lost mutating result, Pi calls target status before any replay decision and returns only the provider-declared action. - -Once the pinned gentle-ai runtime (currently v2.7.0) has written review authority, rollback MUST preserve every native store and receipt and MUST NOT run a downgraded binary against that repository. Disable the Pi route or roll forward to a compatible authority-aware release instead; deleting authority data or reinstalling an older binary is not a rollback path. - -### FINALIZE wrapper input - -`gentle_review` accepts `input` as a JSON-serialized object string. For initial results, provide `review_result.lens_results[]`; each selected lens appears exactly once with `lens`, `findings`, and non-empty `evidence`. A clean lens uses `findings: []`. Pair `final_evidence` with exactly one of `final_verification_passed` or `final_verification_outcome`. - -```json -{ - "review_result": { - "lens_results": [ - { - "lens": "review-reliability", - "findings": [], - "evidence": ["complete candidate reviewed"] - } - ] - } -} -``` - -This is the Pi wrapper contract, not the native CLI file contract. The native command receives separate `--result`, `--refuter`, `--validation`, and `--evidence` files from the wrapper. - -START derives the complete Git/untracked snapshot, lineage, persisted `low | medium | high` tier, zero/one/four lenses, authored changed lines, and correction budget `min(200, ceil(original_changed_lines / 2))`. Generated `testdata/golden/**` stays in snapshot identity but does not count as authored risk lines. - -Every finding requires `evidence_class`, `causal_disposition`, and concrete changed-hunk, candidate-created-path, differential-test, or before/after proof. Missing IDs are assigned natively and selected-lens results are canonicalized deterministically. - -Actor output is untrusted data and cannot authorize transitions, fixes, receipts, gates, or delivery. - -Only severe `introduced`, `behavior-activated`, or `worsened` findings with valid proof enter correction IDs. `pre-existing` and `base-only` become follow-ups; `unknown`, insufficient, malformed, or inconclusive severe claims escalate. WARNING and SUGGESTION are informational. - -Deterministic blockers need no refuter. Inferential blockers use exactly one complete read-only refuter batch. - -Refuter proof may be independent concrete reproduction evidence; it does not need to duplicate reviewer `proof_refs`. Invalid, empty, malformed, missing, duplicate, unknown, or inconclusive refuter output escalates without a replacement refuter. - -When native IDs are assigned to inferential findings, the first FINALIZE returns their canonical rows and a content-derived request hash without mutation; the second replays identical lens input with that hash and one complete refuter batch. - -Ordinary permits one correction transaction within the original budget. FINALIZE requires a positive forecast before editing and derives actual correction lines from Git; one targeted validator and final verification close that transaction. Initial lenses are never rerun, while frozen findings and genesis scope remain unchanged. - -The validator checks original criteria and correction regression only and cannot add scope or findings. Final evidence is hashed during FINALIZE, never at START. - -Compact ordinary has five states: `reviewing`, `correction_required`, `validating`, `approved`, and `escalated`. - -The validator cannot change claims, add findings, request fixes, launch actors, or request another attempt. A failed correction escalates instead of opening another review budget. - -Compact authority uses content-derived CAS under the Git common directory. Exact retries are idempotent; stale/semantic retries, terminal mutation, and same-lineage graph-v1/compact-v2 ambiguity fail closed. - -Trust boundary: The local orchestrator and same-user process are trusted to execute selected actors and submit their exact outputs. Native code owns scope, risk, IDs, canonicalization, state, receipts, and gates, and rejects malformed or inconsistent results structurally and causally. Malicious same-user host/process authenticity is a non-goal because that actor can replace the extension or mutate local authority; externally trusted attestation would require a separately privileged signer/service and is not claimed. - -Ordinary ends only as `approved` or `escalated`. +
-Judgment Day starts only when explicitly requested and replaces ordinary review for that lineage. +Built for Pi. Shaped by Gentle-AI. -Judgment Day starts with exactly two blind judges and zero refuters. - -Judgment Day alone may iterate discovery and scoped re-judgment, for at most two rounds. - -Findings surviving round two escalate; no third-round transition exists. - -Native review mode and the two candidate choices remain provider-owned lifecycle semantics. For a validated `consent/v3` envelope in the interactive parent TUI, Pi displays those two choices unchanged and adds a clearly separate host-owned action: **Run this review and allow reviews for this Pi session**. Only direct human selection creates this process-memory grant. Its scope is the coordinating live SessionManager session and the canonical Git common-directory identity of the selected repository: it runs the current envelope's exact provider `granted` invocation through the existing one-shot `answer-consent` path, then does the same for later fresh validated envelopes in sibling worktrees of that same clone, including package-owned children. An unrelated repository requires a separate explicit human grant. Reload preserves it; `/tree` retains it; revoke removes the current repository grant; quit, new, resume, fork, or process restart removes all session grants. The command's `status` action reports the in-memory state without changing provider mode or authority. - -The host grant is held only in a schema-checked `globalThis[Symbol.for(...)]` WeakMap registry keyed by session and canonical Git common-directory digest. It is never written through session entries, settings, environment variables, or the old asked latch. A package-owned Gentle Agents child can request one bounded parent-owned stdio authorization for its own validated pending ordinary START; it sends only that target's canonical repository digest, and the parent rechecks the live task, digest, and current parent session grant before the child replays its exact provider grant locally. No candidate bytes, provider vectors, paths, local child grant, or delivery authority crosses that channel. External or legacy `pi-subagents` launchers do not receive this channel and remain unsupported. Headless/RPC/unsupported UI, external processes, model prose, tool arguments, cancellation, identity drift, malformed identity, and uncertain native results cannot create or consume the grant. Native workspace binding remains canonical and target-specific; session-wide consent never authorizes an unselected target or an unrelated repository. The grant conveys no review verdict, forecast/cost approval, acknowledgement, maintenance, delivery, or cross-repository authority. When the host cannot resolve the choice, `gentle_review` returns the original unresolved two-choice provider envelope unchanged for the normal lossless relay. SessionManager binding isolates simultaneous SDK sessions; Pi does not claim universal same-process agent-principal isolation because the SDK exposes no principal identity. - -When RDD is on and an agent loop ends with an unreviewed candidate, `gentle-pi` sends one read-only reminder pointing the agent back to `gentle_review {"operation":"inspect"}` before it reports completion. This nudge is idempotent (at most once per target identity per session), never fires for a headless session or a subagent's own loop, and never runs START or answers consent itself. Pi treats a child `agent_end` as a latest-answer update, not completion: queued retry, compaction, follow-up, required verification, and legitimate post-correction verification remain live until `agent_settled`. It does not claim ready or RDD-ready first, but this ordering rule does not impose a universal full-suite requirement or turn a receipt into a delivery gate. At session start, `gentle-pi` records the current target identity as a baseline, so a candidate that already existed before the session began (the user's own prior work, not this session's output) never draws the reminder. - -Review outcomes and receipt state are informational; commit, push, pull-request, and release delivery follow ordinary repository policy. No one-shot command authorization, publication-target revalidation, or receipt gate is required for delivery, and Pi does not inspect RDD mode or native authority to decide a Bash delivery command. - -Dangerous-command safety remains independent and authoritative. Destructive-review-maintenance consent remains separate from delivery. Review operations, informational VALIDATE, and SDD perform no commit, push, pull-request, release, or publication operation. - -The Pi host relay bounds each locked-down reviewer subprocess by materialized prompt size rather than by one fixed number: a 15-minute floor plus 15 minutes per mebibyte of prompt, clamped to a 2-hour ceiling. Set `GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS` to a positive decimal to replace that derived bound with your own; malformed values are ignored and the same 2-hour ceiling still applies, so no configuration turns a foreground finalize into an unbounded child process. A reviewer killed by the bound reports `pi-host-relay-timeout` with the elapsed time and the limit it was measured against, and it explicitly does not ask you to relaunch the identical slot — that would re-spend the model tokens to reach the same wall. Reviewer results admitted earlier in the same finalize stay admitted and are not re-run. - -Adversarial review roles (the refuter and the targeted validator) are never Pi-authored: the provider renders self-contained `review.capture-refuter` / `review.capture-validation` vectors and Go runs its own locked-down `pi` process on them. Package agent assets remain a package-managed isolated installation. Project and user overrides may shadow a package asset; `gentle-pi` preserves those definitions and does not claim their effective permissions are package-compliant. - -## SDD/OpenSpec flow - -```text -init - ↓ -explore → research (optional) → proposal → spec ─┬→ design ─┐ - └─────────┴→ tasks → apply → verify → sync → archive -``` - -The main loop is intentionally file-backed when you choose `openspec` or `both`: - -```text -planning artifacts implementation evidence canonical update -────────────────── ─────────────────────── ──────────────── -proposal/spec/design/tasks → apply-progress/verify-report → sync-report → archive-report -``` - -For substantial work, the parent session coordinates the flow and each phase writes artifacts. That gives you: - -- explicit requirements and non-goals; -- design decisions that survive compaction; -- task plans reviewers can reason about; -- implementation evidence; -- verification reports; -- sync reports that update canonical specs while keeping the change active; -- archive notes for future agents. - -### OpenSpec artifact model - -`gentle-pi` treats OpenSpec-compatible behavior as part of the harness. You do not need to install the external OpenSpec CLI/package for SDD. - -In file-backed modes, canonical accepted behavior lives in `openspec/specs/`, while active changes carry deltas under `openspec/changes/`: - -```text -openspec/ -├── specs/ # accepted source of truth -│ └── {domain}/spec.md -└── changes/ - ├── {change}/ # active work - │ ├── proposal.md - │ ├── specs/{domain}/spec.md # full spec or delta spec - │ ├── design.md - │ ├── tasks.md - │ ├── apply-progress.md - │ ├── verify-report.md - │ └── sync-report.md - └── archive/YYYY-MM-DD-{change}/ # immutable audit trail -``` - -Delta flow: - -```text -openspec/changes/{change}/specs/{domain}/spec.md - │ - │ sdd-sync applies ADDED / MODIFIED / REMOVED - ▼ -openspec/specs/{domain}/spec.md - │ - │ sdd-archive moves the completed change folder - ▼ -openspec/changes/archive/YYYY-MM-DD-{change}/ -``` - -When a canonical spec already exists, change specs use requirement operation sections: - -```markdown -## ADDED Requirements - -## MODIFIED Requirements - -## REMOVED Requirements -``` - -`MODIFIED` requirements must include the full requirement block, including still-valid scenarios, because sync replaces the canonical block by requirement name. `sdd-sync` syncs file-backed deltas into `openspec/specs/{domain}/spec.md` while keeping the change active; `sdd-archive` then moves the synced change to `openspec/changes/archive/YYYY-MM-DD-{change}/`. - -Engram-only mode is different by design: Engram is working memory and does not maintain a canonical spec merge layer. Use `openspec` or `both` (hybrid file + memory persistence) when you need canonical spec evolution. + -## SDD preflight and project files +

+ +

-`gentle-pi` does not require SDD agents to be copied into every project. The package ensures global Pi SDD assets exist under the Pi agent home and treats project-local files only as overrides/debug copies. Slash SDD flows such as `/sdd-*`, `/gentle-sdd-init`, and the explicit `/gentle:sdd-preflight` command run a lazy preflight and resolve session-scoped SDD preferences. For natural-language requests, the parent agent decides whether the work should use SDD and must run/reuse `/gentle:sdd-preflight` before continuing. +## Features -```text -~/.pi/agent/agents/sdd-*.md -~/.pi/agent/chains/sdd-*.chain.md -~/.pi/agent/gentle-ai/support/strict-tdd*.md -``` +--- -Preflight values resolve in this order: explicit current user/session choice, valid persisted preference, capability or already-selected strategy constraint, canonical default, then a prompt only when genuinely unresolved. Resolved values are reused for later SDD flows in the session. +### gentle-shell — Your coding agent, in the workspace you lead -Canonical values are `auto` execution mode, `openspec` artifact store, `ask-on-risk` delivery strategy, and a `400` changed-line review threshold. The delivery strategy domain is `ask-on-risk`, `auto-chain`, `single-pr`, or `exception-ok`; `chain_strategy` remains deferred until chaining is selected. `exception-ok` requires explicit `size:exception` acceptance and is never inferred. Consent, authorization, security, destructive/publishing, interactive phase approval, and ambiguous-scope gates remain human-controlled. +gentle-shell running a live agent session: a header row with branch, model, and context gauge above the transcript, with status, changes, and todo cards in the right rail -It does **not** overwrite existing global assets unless you explicitly run: +A bare terminal answers "what is the agent doing?" only with scrollback. gentle-shell turns your Pi session into a workspace: agent orchestration, live changes and runtime status, usage monitoring for supported provider accounts, and built-in diff views — so you lead the work instead of chasing it. -```text -/gentle:install-sdd --force -``` +

gentle-shell in action. Screenshot from Gentle-AI.

-Manual preflight command: +**[Docs →](docs/gentle-shell.md)** -```text -/gentle:sdd-preflight -``` +--- -## Skill registry +### el Gentleman — Think before you build -`gentle-pi` keeps a local registry at: +Say what you need once, then keep moving. el Gentleman helps turn intent into clear scope, a sensible next step, and evidence people can review — without making every task feel like a process meeting. -```text -.atl/skill-registry.md -``` +**[Docs →](docs/readme-reference.md#organic-driven-development)** -The registry scans project and user skill roots, not package-owned skills. It exists to catch workflow skills that are present on disk but not visible in Pi's injected skill list. +--- -It scans common roots such as: +### Focused agents — Context with a return path -```text -./skills -.opencode/skills -.claude/skills -.gemini/skills -.cursor/skills -.github/skills -.codex/skills -.qwen/skills -.kiro/skills -.openclaw/skills -.pi/skills -.agent/skills -.agents/skills -.atl/skills -~/.pi/agent/skills -~/.config/agents/skills -~/.agents/skills -~/.kimi/skills -~/.config/opencode/skills -~/.config/kilo/skills -~/.claude/skills -~/.gemini/skills -~/.gemini/antigravity/skills -~/.cursor/skills -~/.copilot/skills -~/.codex/skills -~/.codeium/windsurf/skills -~/.qwen/skills -~/.kiro/skills -~/.openclaw/skills -``` +Diagram of one parent session directing bounded map, implementation, and verification work and receiving evidence back -Behavior: +Bring in help without losing the thread. Focused package-owned Pi agents can map a codebase, implement a bounded change, or verify it, while one parent stays accountable for the scope, the decisions, and the final summary. -- `.atl/` is added to `.gitignore` when needed; -- the registry refreshes on session start; -- startup refresh is skipped when Pi starts with `--no-skills` / `-ns`, `--no-skill-registry`, or `GENTLE_PI_NO_SKILL_REGISTRY=1`; -- `/skill-registry:refresh` forces regeneration; -- a best-effort watcher refreshes when skill files change; -- the registry indexes skill names, full descriptions, scope, and exact `SKILL.md` paths without copying skill body rules. +**[Docs →](docs/readme-reference.md#how-the-harness-decides-what-to-do)** -Skill discovery is a guardrail, not a workflow router: it helps Pi load the right skill without forcing extra ceremony. +--- -`gentle-pi` also ships package-owned `gentle-ai-skill-creator` and `gentle-ai-skill-improver` skills plus the `/skill-creation` prompt for creating or updating project skills. Both skills use `docs/skill-style-guide.md` as their normative style contract. The workflow checks for duplicates, keeps `SKILL.md` concise, uses one-line trigger-rich frontmatter, and reminds maintainers to refresh the registry after skill changes. +### ODD — The everyday workflow -Packaged skills include `cognitive-doc-design`, `comment-writer`, `gentle-ai-judgment-day`, `gentle-ai-skill-creator`, `gentle-ai-skill-improver`, and the other delivery/review skills under `skills/`. SDD init is installed as the packaged `sdd-init` runtime agent under `assets/agents/` and refreshed with the SDD assets. +Organic Driven Development as seven numbered steps: Authorize, Explore, Resolve uncertainty, and Classify across the top row; Classify forks, so small understood work stays light while substantial work gets step five, Track, with one feature document; both paths converge on Implement task by task and then Close, above a dashed band marking that one feature document mirrored in Engram lets work resume across sessions -Compatibility: the package keeps the existing skill folders (`skills/branch-pr`, `skills/cognitive-doc-design`, `skills/comment-writer`, `skills/judgment-day`, `skills/skill-creator`, `skills/skill-registry`, and `skills/work-unit-commits`) but their exported frontmatter names are prefixed to avoid collisions with user/global skills. Treat former package names such as `branch-pr`, `cognitive-doc-design`, `comment-writer`, `judgment-day`, `skill-creator`, `skill-registry`, and `work-unit-commits` as legacy aliases in prose; runtime skill selection should use `gentle-ai-branch-pr`, `gentle-ai-cognitive-doc-design`, `gentle-ai-comment-writer`, `gentle-ai-judgment-day`, `gentle-ai-skill-creator`, `gentle-ai-skill-registry`, and `gentle-ai-work-unit-commits`. +**Organic Driven Development (ODD)** is the everyday path: the agent explores before changing anything, clarifies only real decisions, and keeps small understood work small. Substantial, authorized work gets one recoverable feature document — mirrored in memory when available — so progress, evidence, and the next step survive an interruption; checks follow the configured TDD mode. -Delegation contract: +**[Docs →](docs/readme-reference.md#organic-driven-development)** -- parent/orchestrator resolves project/user skills from the registry and passes matching paths under `## Skills to load before work`; -- SDD subagents still use their assigned executor/phase skill; -- during normal runtime, subagents should not independently discover additional project/user `SKILL.md` files or the registry; -- fallback loading is degraded self-healing and must be reported via `skill_resolution` as `fallback-registry`, `fallback-path`, or `none`. +--- -## Persona modes +### Native review — Review the exact change -```text -/gentle:persona -``` +Diagram showing one frozen candidate passing through risk-scoped native review to an outcome, while human delivery choices stay separate -| Persona | Behavior | -| ----------- | ------------------------------------------------------------------------------------------------------------- | -| `gentleman` | Senior architect, teacher, direct technical feedback, Rioplatense Spanish/voseo when the user writes Spanish. | -| `neutral` | Same discipline, warmer professional language, no regional expression. | +Review the exact change, not a moving target. Native review keeps one candidate in view, returns risk-scoped evidence, and can surface a bounded correction path. You still decide what happens next in your repository. -Saved globally at: +**[Docs →](docs/review-integration.md)** -```text -~/.pi/gentle-ai/persona.json -``` +--- -A project can still override the global default with: +### Gentle Changes — Every edit, attributed and reviewable -```text -.pi/gentle-ai/persona.json -``` +Gentle Changes viewer: worktree accordion with per-file status on the left, the captured diff with line counts on the right, and a keyboard hint row -`/gentle:persona` writes the global config and updates an existing project override when one is present, so the current project does not stay stale. Run `/reload` or start a new Pi session after switching persona. +You should not have to run `git status` to find out what your agent did. Gentle Changes captures the successful write and edit tool calls from the current session and its owned subagents — no repository scans, no background polling — and shows them in a two-pane viewer with per-file line counts and an honest **diff unavailable** when an external edit breaks continuity. Coverage stops at those tools, so shell commands and failed runs leave no row, and a missing entry never proves a clean tree. `alt+g` opens it; `o` drops the real file into your editor. -## Model and effort assignment +**[Docs →](docs/gentle-shell.md#browse-captured-diffs)** -```text -/gentle:models -``` +--- -The modal discovers: +### Gentle Agents — Parallel work with a live view -- project agents in `.pi/subagents/`, `.pi/agents/`, and `.agents/`; -- user agents in `~/.pi/agent/subagents/`, `~/.pi/agent/agents/`, and `~/.agents/`. +Gentle Agents overlay showing a completed subagent thread with model, tokens, and elapsed columns, and the structured handoff it returned -When applying routing, project agents write runtime profiles to `.pi/subagents.json`; global and built-in agents write profiles to `~/.pi/agent/subagents.json`. +Delegating work should not mean losing it. Every subagent runs as its own process with a live card above the editor — model, tokens, cost, elapsed — and `alt+a` opens the full view with retained threads, stop controls, and history restored on resume. A child can ask you a question as an ordinary dialog, and background results come back as cards that start a new turn — nothing polls. -Recommended model/effort shape: +**[Docs →](docs/gentle-shell.md#gentle-agents)** -| Agent kind | Recommended model | Recommended effort (`thinking`) | -| -------------------------- | ---------------------------------------------------- | ------------------------------- | -| Explore, proposal, archive | Fast and cheap is usually enough. | `off` to `low` | -| Spec, design, tasks | Strong reasoning model. | `medium` to `high` | -| Apply | Strong coding and tool-use model. | `medium` to `high` | -| Verify / review | Strong fresh-context model. | `high` | -| Tiny utilities | Inherit active/default model unless they bottleneck. | `inherit` | +--- -Saved globally at: +### Profiles and model routing — One deliberate decision per knob -```text -~/.pi/gentle-ai/models.json -``` +Profiles view: profile list on the left, orchestrator model and effort on the right, with per-role profile routing and effective current routing -Existing project-local `.pi/gentle-ai/models.json` files are still read as a legacy fallback when no global model config exists, but `/gentle:models` writes the shared global config. +Model, effort, and who does what should be choices, not accidents. Named profiles route the orchestrator atomically and independently from packaged and review roles; a repository can pin its profile so its subagents stop following the globally active one, and the panel always shows the routing the runtime actually uses. -Inside `/gentle:models`, press `x` to export the saved routing to `~/.pi/gentle-ai/models.export.json`, or `r` to restore from that file after confirmation. Export uses a versioned envelope and restore writes the normal `models.json` shape before applying routing to agents. +**[Docs →](docs/readme-reference.md#agent-model-profiles)** -Config shape (per agent): +--- -```json -{ - "sdd-design": { - "model": "anthropic/claude-sonnet-4", - "thinking": "high" - }, - "sdd-archive": { - "model": "openai/gpt-5-mini" - } -} -``` +### Command palette — Every command, one keystroke away -Legacy string entries are still accepted and treated as `model`-only config. +Extension commands are only useful if you can find them. `alt+k` opens a curated, grouped palette — Configuration, Session, Diagnostics, and Skills — searchable by label, command name, or description, showing entries only when they are actually registered. -## Gentle Shell +**[Docs →](docs/gentle-shell.md#command-palette)** -Gentle Shell is the visual layer gentle-pi puts on top of pi. It follows the Gentle themes: one border language, champagne titles, rose for whatever is alive. +--- -In fullscreen at 140 columns or wider, the right sidebar scrolls **✿ Gentle-Pi ✿ → Status → Changes → Agents → TODO** together. The one-line heading is horizontally centered within the usable rail width, with pink flowers and normal white text in the Gentleman themes. Colors follow the active theme; no artwork scaling or custom fonts are used. Narrow/mobile terminals and regular mode retain bottom widgets without the sidebar heading. The original rose and text logo remain in the main chat startup intro. +### Also in the box -The status bar replaces pi's three-line footer with a single line of segments: +| Component | What it does | +| :--- | :--- | +| Startup and runtime panel | A configurable gentle-shell entry point and visible runtime state for Pi. | +| Skills and delivery guidance | Package skills for documentation, issue work, PRs, reviews, and reviewable work units. | +| Model, effort, persona, and profile controls | Explicit knobs for how Pi routes and presents work. | +| Safety boundaries | Guards around destructive operations and sensitive-path handling. | +| Optional companion packages | Extra capabilities you may choose to add; persistent memory is **not** bundled with `gentle-pi`. | +| Fullscreen workspace layout | Header row plus a scrolling Status → Changes → TODO rail on wide terminals. | +| Live status bar and prompt petal | One-line gauge, cost, and statuses; the petal shows `working` and `queued`. | +| Parent ↔ subagent communication | Delegate, steer, reply, and cross-session notification within your local profile. | +| Native interactive tools | Built-in questions, choices, and review captures — no third-party dependency. | +| Gentle Todo | A plan card that turns amber when the model lets it go stale. | +| Subscription usage | Per-window meters and resets for supported provider accounts. | +| Gentle notices | Gentle AI calls and review reminders as cards in the transcript. | -```text -✿ gentle-pi ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium ⟡ ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ⟡ MCP: 3 servers enabled Release notes -``` +> **Every component, skill and preset: [Full breakdown →](docs/gentle-shell.md)** -- Context is a gauge, not a number. It turns amber at 80% and red at 95%; after compaction it shows `?%` until the next response. -- Cost carries `sub` when the active model runs on a subscription login. -- Statuses other extensions publish through `setStatus` are appended as trailing segments; the session name sits at the right edge. -- On narrow terminals the session name is dropped first, then trailing segments, before the line is truncated. +--- -The prompt wraps pi's editor in a rounded frame with a petal that shows what the agent is doing: +### What's new in v3.5 -```text -╭─ ✿ working ──────────────────────────────────────────╮ -│ type, or / for commands │ -╰──────────────────────────────────────────────────────╯ -``` +The [v3.5.1 release](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) makes Gentle Shell runnable on its own: -- The petal is still while pi waits, spins with a `working` label while the agent works, and turns amber with a `queued` label when messages are waiting behind the current turn. pi's own "Working" row above the editor is hidden, since the frame already says it. -- The frame uses the theme's border color over the panel background, so the prompt reads as one panel with the cards around it; the editor's scroll indicators stay inside the frame. -- The hint appears only while the editor is empty. -- If another extension already installed a custom editor, Gentle Shell leaves it alone. +- **Standalone launcher:** `npm i -g gentle-pi` installs `gentle-shell`, which opens Pi with the Gentle Shell package loaded from its own home (`~/.gentle-shell/agent`) or, with `--link`, from your existing `~/.pi/agent`; `gentle-shell install npm:` and the other pi subcommands run against the selected home. A bundled or `PATH` pi is used, never a modified one. +- **Link mode take-over:** when `~/.pi/agent` already declares gentle-pi as a path package, the launcher takes over extension loading (`--no-extensions` plus explicit `-e` for every other declared package and loose extension) so tools never register twice. +- **Interactive RPC hosts:** with `GENTLE_SHELL_INTERACTIVE_HOST=1` and `--mode rpc`, ask-user tools use pi's RPC dialogs and gentle-agents publishes live subagent activity for the desktop app. See the [reference](docs/readme-reference.md#interactive-rpc-hosts). -Changes across this session's registered worktrees show up below the editor and as an aggregate `±N` next to the session branch in the bar: +--- -```text -✎ 3 files · +42 −7 · extensions/gentle-shell.ts, lib/shell-bar.ts, tests/x.test.ts · /gentle:changes -``` +

Back to top ↑

-- Each registered root shows **all** dirty files: plain `git diff` against HEAD plus untracked files, including edits that predate this session. There are no baselines or file-level attribution filters. -- The canonical session cwd root is included automatically. Successful standard `read`, `write`, `edit`, `grep`, `find`, and `ls` calls register their target worktree after completion. Failed calls, shell command text, and prose never register roots. Only roots sharing the session's Git common directory are accepted. -- For opaque shell use or worktrees used earlier, call `session_worktree_register` with `{"path":"/path/to/worktree"}`. Registration is explicit, canonicalized, and deduplicated; unrelated dirty siblings remain invisible without an ignored-roots list. -- The root registry persists in Pi custom entries (`gentle-pi.session-worktree/v1`). Exit/resume and `/reload` restore the same session UUID; `/tree` keeps roots session-wide. New sessions, `/fork`, and `/clone` ignore inherited registrations with another UUID. Clean roots stay registered but hidden until dirty; missing/prunable roots are skipped safely. Ephemeral `--no-session` runs cannot persist across exit. -- Counts refresh after every tool call, at the end of each turn, and every 5 seconds in the background, so edits made from nvim or another agent show up without touching pi. `GENTLE_PI_SHELL_CHANGES_WATCH_MS` changes the interval; `off` leaves only the tool-driven refresh. Outside a git repository the widget stays hidden. -- On narrow terminals the file list is dropped before the summary is truncated. +

+ +

-`/gentle:changes` or `alt+g` opens the framed two-pane viewer. Dirty worktrees are accordion groups in the left pane, labeled with branch and directory basename (`detached` when there is no branch). Expand groups to reveal indented changed files; multiple groups can stay expanded. The right pane previews the selected file's lazy-loaded diff, or shows the selected group's full directory and summary. Clean, bare, missing, and prunable roots remain hidden; untracked-only roots are included. +## Get started -- `j`/`k` or up/down traverse visible groups and files, keeping the selection in view. On a group, `enter`, space, or right arrow toggles expansion. Left arrow or backspace moves a file selection to its parent, or collapses the selected group. `ctrl+j`/`ctrl+k` or `pgdn`/`pgup` scroll the diff; `esc` or `q` closes the overlay. -- Opening, pressing `r`, and the background/overlay refresh cadence scan only registered roots. Worktree discovery supplies branch labels, never registration. No changes in registered roots means no widget and an informational notice instead of an overlay. -- While the overlay is open, git is polled every 2 seconds, so edits made from nvim, another agent, or a checkout show up in place. Expansion and selection stick to the raw worktree root and file path across refreshes; a diff reloads when its counts move. -- `GENTLE_PI_SHELL_CHANGES_KEY` rebinds the shortcut (pi key syntax, for example `ctrl+shift+g`); `off` disables it. On macOS, `alt+g` needs the terminal to send Option as Meta. -- On a file row, `o` (or `enter`) opens the selected file in `$VISUAL` or `$EDITOR`, with the selected worktree as the editor's working directory, and returns to pi when the editor exits. Diff lookup and caches are also scoped to that root; identical relative filenames in other worktrees cannot share a diff. -- Untracked files are diffed against an empty file so new files show their full content. +> **Naming transition:** The product is called `gentle-shell`; the current npm package and repository remain `gentle-pi` until migration. -Subscription usage shows in the bar after the cost, and `/gentle:usage` opens a panel with every window per provider: +### Path A: standalone `gentle-shell` (recommended, no pi changes) -```text -✿ gentle-pi ⟡ … ⟡ $9.49 sub ⟡ codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31% -``` +`gentle-shell` opens Pi with the Gentle Shell package loaded, without installing it into your pi agent or editing its `settings.json`. -- For Codex, usage comes from the same account usage endpoint the Codex CLI reads, using the OAuth token pi already holds. It is fetched at session start, at most every 5 minutes after a turn, and on `r` in the panel. Rate-limit headers on SSE responses are picked up too. -- For Claude Pro/Max, usage arrives in the rate-limit headers of every response, so the 5h and weekly windows appear after the first turn. -- The bar names the subscription it shows (`codex`, `claude`) and always follows the active model. The panel puts the active provider first, marked with the petal, and says why it has no data when it does not: API-key providers have no subscription windows, Claude reports after the first response, Codex waits for a fetch. -- Only the plan name and the windows are kept; account details in the payload are discarded. -- Gauges turn amber at 80% and red at 95%, like the context gauge. +```bash +npm i -g gentle-pi -Gentle notices are drawn as cards: the same rounded frame as the prompt, with the left rail and the title in the tone of the notice and the rest of the frame in the theme's border color. +# Own home, never touches your pi install +gentle-shell -```text -╭─ ✿ Gentle AI · review preflight ─────────────────────────────────────╮ -│ Receipt-driven development is enabled, and this worktree holds an… │ -╰──────────────────────────────────────────────────────────────────────╯ +# Reuse your pi sign-ins, models and chats instead +gentle-shell --link ``` -- Every call into the gentle-ai binary and every `gentle_review` tool renders as a card under the rose, `🌹︎ Gentle AI`: the rail is amber while it runs, green when it finished, red when it failed; the expand key sits in the top rule once the tool finished, and the collapsed result shows only its line count. Reviewer captures name their lens (`review capture · risk`; the group lists all four). -- The review preflight reminder renders as a card in the transcript with the expand key in its top rule. -- An active dev-binary override shows above the editor at startup, in amber, naming the binary and its digest, and leaves with the first prompt; an invalid override shows in red with the reason. -- Subagents draw their own card; see Gentle Agents below. - -### Gentle Agents - -The current package requires Pi 0.85.1 or newer (development tests pin 0.85.1). Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. - -The `subagent_*` tools and the agents card replace the third-party subagents package (remove `npm:pi-subagents-j0k3r` from your pi packages; while it is still installed the tools stay unregistered and a warning says so at startup). Agent definitions and settings are the ones you already have: markdown agents in `~/.pi/agent/agents/`, `~/.pi/agent/subagents/`, `/.pi/agents/`, `/.pi/subagents/` (project beats global, `subagents/` beats `agents/`), and `subagents.json` at the global and project level (`default_model`, `default_effort`, `default_mode`, `model_profiles`, `stall_timeout_ms`, `max_concurrency`, `history_max_tasks`). - -Agent paths follow `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent` for definitions, config, history, child sessions, and transcripts. These overrides select the agent profile; they do not sandbox project or shared global resources. +`gentle-shell` alone starts in its own home, `~/.gentle-shell/agent`, and sets that home up on first run — no separate step. Gentle Shell keeps its own home with the Gentle AI companion packages and no conflicting plugins; gentle-pi itself always stays this launcher's own copy, never one installed into the home; your pi install is untouched. That home also defaults to the Gentleman-Cute theme unless you set your own. `gentle-shell --link` reuses `~/.pi/agent` as-is, is never auto-provisioned, and never has its theme touched. -```text -╭─ ❀ Agents · 1 active · 1 done ─────────────────────────────── 1m24s ╮ -│ ✓ sdd-explore map footer data sources gpt-5.6-terra · 34k · $0.27 · 25s │ -│ ◐ sdd-apply write gentle-shell footer gpt-5.6-terra · 12k · $0.09 · 41s │ -╰──────────────────────────────────────────────────────────────────────────────╯ +```bash +# Re-run provisioning by hand, e.g. to see the full install output +gentle-shell setup ``` -Every subagent is its own `pi --mode rpc` child process, so the terminal never runs subagent work: the host reads JSON lines, applies each one as a small delta to a bounded per-task thread, and notifies only the listeners of that task. A task-mode child's question (`ctx.ui.select`, `confirm`, `input`, `editor`) reaches you as an ordinary pi dialog; a background child's question is dismissed. Subagents have no automatic total execution timeout: a long-running child remains live while it continues emitting RPC events. A silent child still times out through the configurable `stall_timeout_ms` watchdog (default four minutes). Closing pi stops the children that are still running. - -- `subagent_list_agents`, `subagent_run` (`agent`, `task`, `label?`, `context?`, `workspace_root?`, `mode?` task or background), `subagent_status`, `subagent_result`, `subagent_list_tasks`, `subagent_reply` (one current-session reply to a live child query), `subagent_cancel`, `subagent_send_message` (steer a running child), `subagent_continue` (resume a finished task in its own session). -- `subagent_run.workspace_root` selects an existing worktree in the session's Git clone. Validation happens before queueing; the child runs at that canonical root. Successful OS spawn registers the root in the originating parent session, including delayed queued launches, even without an active shell listener. Failed spawns do not register. `subagent_continue` retains the previous task's cwd; status and task details expose it. -- A background task's result comes back to the model as a `gentle-agents.result` message, drawn as a rose card, and starts a new turn when the agent is idle; the model never polls. -- A configured child can call `subagent_parent_message` with bounded, well-formed Unicode text. Notifications retain their existing admission semantics. A `kind: "query"` waits for one strictly correlated `subagent_reply` for at most 30 seconds; each child has at most four pending queries, and disconnect, timeout, stop, and send failure settle each request once. The current parent session alone can reply. The first admitted task-mode query ends the original tool response while its child keeps running; its eventual non-cancelled completion returns once as a follow-up only if that same session is still active. Channel closure prevents later sends and automatic retry is not provided. Peer transport, offline delivery, retries, and broadcasts are unsupported. -- The card shows the active session's tasks only: after `/new` or `/resume` the earlier session's tasks leave it and come back with their session. Finished rows stay for one minute (three at most), and the card spends at most a quarter of the terminal (three to eight rows) on tasks; beyond that the rest fold into one `… N more · alt+a to view` line so the editor never leaves the screen. Questions and running work keep their rows first. -- `/gentle:agents` or `alt+a` opens a full-terminal overlay. At 60+ columns, the split view shows groups/tasks beside the retained semantic thread; uppercase `F` or **Fullscreen** expands that thread. At 12–59 columns, click a current subagent directly to inspect its thread; in All sessions, first select its orchestrator. `Enter`/`Tab` also enter a narrow selection. **Back** or `Escape` returns one level, closing only at the root; **Close** or `q` closes globally without cancelling children. Selection and manual thread scrolling survive Back and resize. -- Mouse controls take priority over keyboard hints: **Follow** (`f`), **Open session** (`o`), **Stop** (`s`, legacy `c`, owned active tasks only), and **Scope** (`a`). A compact footer's `>` cycles through actions. Scope switches between this session's direct active children and all open orchestrators, including idle ones. Open writes a markdown transcript for `$EDITOR`, not a resumed child session. `j`/`k` move through lists or scroll an expanded thread; `ctrl+j`/`ctrl+k` and Page Down/Up page the thread. In Pi fullscreen mode, the wheel scrolls the viewport under the pointer; regular terminal mode does not capture mouse input. Below 12 columns or three rows, only a bounded Close cell remains; zero-sized terminals render nothing. -- The thread displays all retained Text, Thinking, Note, and Tool content without an additional presentation cap; existing store limits and truncation markers still apply. Only the selected task is subscribed while the overlay is open. -- Thread entries are presented as labeled Text, Thinking, Note, or Tool blocks; tool blocks show their status and nonempty output. -- Current scope has no orchestrator wrapper and excludes every terminal task. All sessions discovers open Pi instances sharing the same agent profile, even across repositories; it does not infer open sessions from retained tasks. Directory headings support left/right and mouse expansion, and cannot stop or open a task. Peer children and their retained threads are read-only: no local stop, editor-open, or continuation routing, and no import into the local task store. -- Presence refresh is paged while the overlay is open. Graceful shutdown withdraws an instance; after abrupt closure its last heartbeat may remain visible for up to 15 seconds plus the time to complete the next directory refresh. A recent heartbeat is a heuristic, not proof that a process is alive. Same-profile, same-user processes share retained activity text; this is not an authorization channel. -- `alt+s` confirms stopping the current active or queued subagents owned by the current process. `GENTLE_PI_AGENTS_STOP_KEY` rebinds it; `off` disables it. -- Finished tasks are written to `~/.pi/agent/gentle-agents/tasks/` (one JSON per task, newest `history_max_tasks` kept, default 200) and come back on demand for `subagent_result` and `subagent_continue`, never as overlay history. Child sessions live under `~/.pi/agent/gentle-agents/sessions/`. -- `ctrl+shift+a` collapses the card to its first row (`GENTLE_PI_AGENTS_KEY`), `GENTLE_PI_AGENTS_VIEW_KEY` rebinds the overlay, `GENTLE_PI_AGENTS_PI` overrides the pi command used for children, and `GENTLE_PI_AGENTS=0` disables the tools and the card. - -### Gentle Todo +`gentle-shell setup` installs the same companion packages gentle-ai provisions into a regular Pi, into this home only, then removes the one package that conflicts with gentle-pi's own `ask_user_question` tool (gentle-ai #4820). The first `gentle-shell` launch in a home already runs this automatically; `setup` is for re-running it by hand. See **[First run](docs/readme-reference.md#first-run-in-an-isolated-or-custom-home)** for the opt-out (`GENTLE_SHELL_NO_AUTO_SETUP=1`) and failure behavior. -The `todo` tool and its card replace the third-party todo extension (remove `npm:@juicesharp/rpiv-todo` from your pi packages; sessions written by it replay into the new card). - -```text -╭─ ❀ Todos · 1 of 3 ──────────────────────────────────────╮ -│ ✓ Add quiet tool rendering │ -│ ◐ Fix quiet tools conflict · fixing conflict │ -│ ○ Show git bash tails │ -╰─────────────────────────────────────────────────────────╯ +```bash +# Make --link the default +gentle-shell home link ``` -Three things keep the list current, which a static tool description cannot: +Every other argument is forwarded to pi unchanged, for example `gentle-shell --mode rpc` or `gentle-shell -p "..."`. Full flags, env vars, and modes: **[launcher reference](docs/readme-reference.md#gentle-shell-launcher)**. -- `write` replaces the whole list in one call, so the model rewrites the plan instead of patching it; `add`, `update`, `clear`, and `list` remain for single moves. -- Every turn's system prompt carries the open tasks and the rules: in_progress before starting, done right after finishing, update before ending the turn. -- A list that goes two turns untouched while tasks stay open turns amber with `stale · N turns`, and the prompt says so, so the model brings it up to date. +### Path B: inside an existing pi -A finished list stays on screen for the turn it finished in and clears at the next. `ctrl+shift+t` collapses the card to the task in progress (`GENTLE_PI_TODO_KEY` rebinds it, `off` disables it); `GENTLE_PI_TODO=0` disables the tool and the card. +Install the stable release into an existing pi agent, restart Pi, then synchronize the installed assets. -Set `GENTLE_PI_SHELL=0` to keep pi's built-in footer and editor. - -## Commands - -| Command | What it does | -| -------------------------------- | ------------------------------------------------------------------- | -| `/gentle:status` | Shows package, SDD asset, OpenSpec, and global model config status. | -| `/gentle:doctor` | Runs read-only diagnostics for SDD assets, model/persona config, memory tools, and safety guards. | -| `/gentle:models` | Opens global model + effort assignment UI. Press `x` to export and `r` to restore saved routing. | -| `/gentle:persona` | Switches global persona mode, with project override support. | -| `/gentle:background-subagents` | Shows or sets the managed background-subagents policy (`status\|enable\|disable`), naming the source that decided it. | -| `/gentle:telemetry` | Shows or changes the local Gentle AI telemetry trigger (`status\|enable\|disable\|preview`). | -| `/gentle:banner` | Configures startup banner rose, text logo, and color preset. | -| `/gentle:toggle-rose` | Toggles the startup rose. | -| `/gentle:toggle-text-logo` | Toggles the startup text logo. | -| `/gentle:banner-color` | Selects a startup banner color preset. | -| `/gentle-sdd-init` | Initializes or refreshes `openspec/config.yaml` (openspec/both stores only). | -| `/gentle:install-sdd` | Repairs missing global SDD runtime assets without overwriting files. | -| `/gentle:install-sdd --force` | Force-refreshes installed global SDD assets. | -| `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. | -| `/skill-creation` | Creates or updates an LLM-first skill using the packaged `gentle-ai-skill-creator` contract and style guide. | - -Package-owned global SDD runtime assets are also refreshed automatically on session start when `gentle-pi` changes. Project-local `.pi/agents` and `.pi/chains` remain manual overrides and are never overwritten by startup refresh. - -### Background subagents policy +```bash +# Published stable release: v3.5.1 +pi install npm:gentle-pi@3.5.1 -Background delegation is off unless you turn it on. The policy is user-owned: only an explicit `/gentle:background-subagents enable` or `disable` writes it, and Pi automation never toggles it. +# Restart Pi, then run: +gentle-ai sync -```text -/gentle:background-subagents Report the effective policy, the deciding source, and the resolved capability. -/gentle:background-subagents enable Write "on" to the global file. -/gentle:background-subagents disable Write "off" to the global file. +# Start Pi in your project +pi ``` -Four sources can decide the policy, and the first hit wins: - -| Priority | Source | Notes | -| -------- | ------------------------------------------------- | ------------------------------------------------------------ | -| 1 | `/.pi/gentle-ai/background-subagents.json` | Project file. Outranks everything, including a global write. | -| 2 | `/background-subagents.json` | Global file, written by `enable`/`disable`. `configHome` honors `GENTLE_PI_CONFIG_HOME` and defaults to `~/.pi/gentle-ai`. | -| 3 | `GENTLE_PI_BACKGROUND_SUBAGENTS` | Exactly `on` or `off`. Any other value is ignored. | -| 4 | Built-in default | `off`. | - -Both files use the strict shape `{"schema":"gentle-pi.background-subagents/v1","policy":"on"}`. A file that is present but malformed fails closed to `off` and is **not** skipped in favor of a lower-priority source, so a typo in the project file disables background subagents rather than silently handing the decision to the global file. The command reports that case as a warning instead of an ordinary `off`. - -Because the project file outranks the global one, `enable` still writes the global file but reports plainly when a project file keeps the effective policy unchanged. The resolved capability (`ready` or `absent`) reports whether `subagent_run` is actually callable in this session; a policy of `on` with capability `absent` means Gentle Agents is disabled or the retired subagents package is still installed. - -Startup banner settings remain global in `banner.json` under `GENTLE_PI_CONFIG_HOME` (default `~/.pi/gentle-ai`). Existing `showRose` and `showTextLogo` opt-outs independently control the main startup artwork; both default to enabled. Changes apply on the next session or `/reload`. Color presets are `pink` (default), `cyan`, `yellow`, and `green`. The static sidebar heading is independent of these preferences and follows the active theme. - -Startup flag: +See the [v3.5.1 release notes](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) for version-specific changes. ```text -pi --no-skill-registry -``` - -Use it when you want skills available normally but do not want Gentle AI to refresh/watch `.atl/skill-registry.md` on startup. `pi -ns` / `pi --no-skills` also skip the registry startup work because Pi is already disabling skill loading. - -## Included skills - -- `gentle-ai` — harness discipline for controlled Pi work. -- `gentle-ai-branch-pr` — issue-first PR preparation. -- `gentle-ai-chained-pr` — split oversized changes into reviewable PR chains. -- `work-unit-commits` — commits as reviewable work units. -- `gentle-ai-judgment-day` — blind dual review, fixes, and re-judgment. -- `cognitive-doc-design` — documentation that reduces cognitive load. -- `comment-writer` — concise, warm, postable collaboration comments. -- `gentle-ai-issue-creation` — issue workflow with checks before creation. -- `gentle-ai-skill-creator` — create LLM-first skills with valid frontmatter. -- `gentle-ai-skill-improver` — audit and upgrade existing LLM-first skills. - -## Memory - -`gentle-pi` does **not** provide persistent memory by itself. - -For memory, install the companion package: - -```bash -pi install npm:gentle-engram +/gentle:status +/gentle:doctor ``` -When memory tools are actually active, el Gentleman can save decisions, bug fixes, discoveries, user prompts, and session summaries across Pi sessions. +> **RDD is opt-in:** enable native receipt-driven development only through an explicit `/gentle:review-mode enable` decision. -Memory contract for SDD delegation: +> **Fullscreen installation note:** a recognized global installation persists Pi’s `"tuiMode": "fullscreen"` setting. Project-local and other install paths do not receive that change. -- parent/orchestrator owns memory retrieval and passes selected context into subagent prompts; -- subagents should not independently search memory during normal runtime unless explicitly instructed to retrieve a specific artifact or observation; -- subagents should save significant discoveries, decisions, bug fixes, and completed SDD phase artifacts before returning when memory tools are available; -- in memory/hybrid mode, SDD artifacts use stable topic keys such as `sdd//proposal`, `sdd//spec`, `sdd//design`, `sdd//tasks`, `sdd//apply-progress`, and `sdd//verify-report`. +> **Interactive RPC hosts:** the desktop app sets `GENTLE_SHELL_INTERACTIVE_HOST=1` automatically, without touching your Pi config — see the [installation reference](docs/readme-reference.md#interactive-rpc-hosts). -## Telemetry +For prerequisites, source-checkout instructions, full install behavior, and release policy, use the **[installation reference](docs/readme-reference.md#install)**. For everyday work, describe the outcome and follow [ODD](#odd--the-everyday-workflow). -`gentle-pi` does not collect anything itself. [gentle-ai](https://github.com/Gentleman-Programming/gentle-ai) owns anonymous usage telemetry end to end — install and heartbeat events, what fields are sent, rate limiting, and every opt-out. See its README/docs for the exact contract. +

Back to top ↑

-At session start, for a primary session only (never for a named or SDD sub-agent), Gentle Pi asks the local `gentle-ai` binary to send its own telemetry: it spawns `gentle-ai telemetry trigger --json` detached, with a 3 s deadline, discards its output, and never blocks session start or surfaces an error — an older binary without the verb is silently treated as nothing to do. This runs at most once per process. +

+ +

-Install counts for `gentle-pi` and `gentle-engram` come from npm download statistics; the package itself never emits an install event. +## Documentation -To opt out: +Start with the product-facing destination, then move into the operational reference only when you need the details. -- `/gentle:telemetry disable` — asks the local `gentle-ai` binary to disable telemetry (also `status` and `preview` to inspect it without leaving Pi). -- `DO_NOT_TRACK=1` — Gentle Pi itself will not spawn the trigger, and `gentle-ai` also honors this standard on its own. -- `GENTLE_AI_TELEMETRY=0` — same effect, `gentle-ai`'s own environment switch. - -`CI=true` also suppresses the trigger, since automated runs are not a real usage signal. - -## Package contents - -| Path | Purpose | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| `extensions/gentle-ai.ts` | Injects identity, orchestrates native review authority, refreshes global SDD assets, registers commands, applies model/persona config, and enforces runtime safety. | -| `lib/native-review-cli.ts` | Strict package-local adapter for Gentle AI START, FINALIZE, VALIDATE, SDD binding, and status contracts. | -| `lib/review-integration-v2.ts` | Strict consumer decoder for negotiated capabilities, operations, target status, projections, repair, and failures against contract `review-integration/v2` (active today). | -| `lib/review-candidate-view.ts` | Builds immutable changed-scope actor views while preserving full-tree, path, mode, symlink, and index integrity. | -| `lib/review-canonical.ts` | Permanent Pi-owned canonical JSON and domain-hash primitives for consumer-side identities. | -| `lib/review-repository.ts` | Permanent Pi-owned Git common-directory identity, safe Git environment, and authority-root binding. | -| `lib/gentle-ai-binary.ts` | Resolves and verifies the confined package-local Gentle AI runtime without global or PATH fallback. | -| `scripts/gentle-ai-installer.mjs` | Installs signed Darwin/Linux archives or exact Go SumDB-verified Windows source builds into the package-local runtime. | -| `contracts/review-integration/v1/` | Byte-identical provider schemas and conformance fixtures for contract `review-integration/v1`, hash-checked before packaging; retained on disk permanently because `/v2`'s schemas `$ref` into these fragments. | -| `contracts/review-integration/v2/` | Byte-identical provider schemas and conformance fixtures for contract `review-integration/v2` (immutable `base_tree`/`candidate_tree`, ordered `changed_path_manifest`, no inline candidate diff), hash-checked before packaging. | -| `extensions/startup-banner.ts` | Shows and configures the startup intro, color presets, and compact runtime panel. | -| `extensions/sdd-init.ts` | Registers `/gentle-sdd-init` for OpenSpec initialization. | -| `extensions/skill-registry.ts` | Maintains `.atl/skill-registry.md` from project/user skills and closes file watchers on shutdown. | -| `assets/orchestrator.md` | Parent-session orchestration contract (always-on core). | -| `assets/orchestrator-delegation.md` | Lazy-loaded delegation/routing/review detail, including the mirrored gentle-ai canon. | -| `assets/orchestrator-memory.md` | Lazy-loaded SDD memory phase table, artifact keys, and lifecycle rule. | -| `assets/orchestrator-skills.md` | Lazy-loaded skill registry fallback semantics and intent-driven skill discovery. | -| `assets/sdd-orchestrator-workflow.md` | Lazy-loaded SDD workflow surface for the parent orchestrator. | -| `assets/agents/` | SDD agents installed as global Pi runtime assets. | -| `assets/chains/` | SDD chains installed as global Pi runtime assets. | -| `assets/support/` | Strict TDD support docs for apply/verify phases. | -| `skills/` | Gentle AI delivery and collaboration skills. | -| `prompts/` | The `/skill-creation` prompt template. | -| `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | -| `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | -| `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | -| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | - -## Development - -Install from this repo: +| Destination | Purpose | +| --- | --- | +| [gentle-shell reference](docs/gentle-shell.md) | Workspace layout, changes, usage, agents, and todo interactions. | +| [ODD workflow](docs/readme-reference.md#organic-driven-development) · [Technical reference](docs/readme-reference.md) | Everyday work and recovery, installation, configuration, commands, and contributor detail. | +| [Review integration](docs/review-integration.md) | The provider/consumer boundary for native review. | +| [Native authority architecture](docs/native-authority-architecture.md) | Ownership boundaries and review architecture. | +| [Telemetry](docs/telemetry.md) | Approved fields and source limitations. | +| [Delegated verification](docs/delegated-verification.md) | Practical verification guidance. | +| [Skill style guide](docs/skill-style-guide.md) | The package skill contract. | -```bash -pi install . -``` +

Back to top ↑

-Validate before publishing: +

+ +

-```bash -pnpm test -bun build extensions/skill-registry.ts --target=node --format=esm --outfile=/tmp/skill-registry.js -node --experimental-strip-types --check extensions/gentle-ai.ts -node --experimental-strip-types --check extensions/sdd-init.ts -node --experimental-strip-types --check extensions/startup-banner.ts -npm pack --dry-run -``` +## Community -### Running the cross-lane battery +This project is built in public. Bring a real workflow, a sharp question, a bug report, or a small improvement that makes the next person’s work clearer. -The cross-lane battery (`tests/crosslane/cross-lane.mjs`) validates the adapter against a real `gentle-ai` binary, end to end and out of CI on purpose. The pinned decoder lane only ever sees vendored fixtures, so new envelope schemas and full controller sequencing are never driven through a live lifecycle before merge; the battery closes that gap. +

+ GitHub issues + Contributors + Gentleman Programming Discord +

-```bash -pnpm test:cross-lane # requires the dev-binary override -pnpm test:cross-lane --with-model # adds the real Go-owned pi reviewer run (model spend) -``` +

+ gentle-shell contributors +

-What it checks, against live scratch repositories: +- Open an [issue](https://github.com/Gentleman-Programming/gentle-shell/issues) with the context needed to reproduce or understand the idea. +- See the people shaping the project in the [contributors graph](https://github.com/Gentleman-Programming/gentle-shell/graphs/contributors). +- Follow [Gentleman Programming](https://github.com/Gentleman-Programming) for the wider ecosystem. -- a low-risk lifecycle: START → native-approved FINALIZE → terminal burn; the `pre-commit` gate is informational and unmanaged, not an allow decision or retained receipt; -- the medium-risk `consent/v3` granted round-trip through the direct decoder lane; -- controller sequencing: each decoded offered next step equals the native transition; correction evidence precedes Go-owned targeted validation, then native approval and terminal burn leave no retained receipt; -- the active audited abandon end to end, asserting the adapter builds the exact nine-line `gentle-ai.review-abandon-authorization/v2` discarded-work binding and the native gate commits the quarantine record; -- after a scope change, a burned approved predecessor exposes no recoverable authority; recovered-successor hydration remains covered at unit level; -- forward-decoder freshness: every live envelope captured from the binary must decode without unknown-key rejection, the early warning that gentle-ai main grew a field gentle-pi lacks; -- the default no-model lane: 13 of 14 checks pass while the real-model check is intentionally skipped; Go-owned validation uses a deterministic scratch fake `pi`, and only `--with-model` runs the real locked-down reviewer with model spend. +

Back to top ↑

-Prerequisites: +

+ +

-- A real `gentle-ai` binary selected through the dev-binary override; there is no PATH or pinned-binary fallback, and the battery refuses to run without one. Either export `GENTLE_PI_GENTLE_AI_DEV_BINARY=` for the session, or register a persistent override with `/gentle:dev-binary ` (stored at `~/.pi/gentle-ai/dev-binary.json` with schema `gentle-pi.dev-binary/v1`; the environment variable takes precedence over the registration, and the binary is re-validated and re-hashed on every resolution). Any real build works: an installed release binary or a locally built gentle-ai main. -- A Git checkout or worktree of this repository. The battery is a contributor tool wired to the repository layout and is excluded from `pnpm test` and CI by construction; run it from the repo, not from an installed Pi package. +## About the author -The battery owns one throwaway scratch root under the OS temp directory and never touches the enclosing repository. Before any review lifecycle it creates private `HOME`, XDG config/cache/data/state, temporary, and RDD state directories inside that root; it proves RDD starts `off/default`, explicitly opts in with sandbox-global RDD, and removes the complete root after the run. It never requires or changes the user's ambient RDD mode. The default run spends no model tokens; `--with-model` launches one real reviewer model run and costs model spend. +`gentle-shell` is built by [Alan Buscaglia](https://github.com/Gentleman-Programming), the maker behind Gentleman Programming. It grew from a practical belief: capable agents are more useful when the human’s intent, review load, and delivery judgment stay visible all the way through the work. -It prints one PASS/FAIL/SKIP row per check plus a note, and exits non-zero when any check fails. A check blocked by a known upstream class is reported with a `known-red` prefix instead of being hidden; it remains a failure, not a success. +Startup intro collaboration: thanks to [@aporcelli](https://github.com/aporcelli) and [`pi-gentle-startup`](https://github.com/aporcelli/pi-gentle-startup), which inspired the clean-screen startup animation, compact runtime panel, and pink visual treatment. -Running this battery against new gentle-ai builds (release candidates or main) and reporting red checks is a valuable contribution. The sibling provider-side battery lives at `scripts/cross-lane-battery.sh` in [Gentleman-Programming/gentle-ai](https://github.com/Gentleman-Programming/gentle-ai). +

+ Gentleman Programming website + Gentleman Programming YouTube + Gentleman Programming GitHub +

-Publish npm through GitHub Actions only: +

Back to top ↑

-```bash -version="$(node -p "require('./package.json').version")" -tag="v${version}" -git fetch --no-tags origin "refs/tags/${tag}" -test "$(git rev-parse 'FETCH_HEAD^{commit}')" = "$(git rev-parse "${tag}^{commit}")" -gh workflow run publish.yml \ - --repo Gentleman-Programming/gentle-pi \ - --ref main \ - -f tag="${tag}" -gh run watch --repo Gentleman-Programming/gentle-pi --exit-status -npm view gentle-pi@ version --registry=https://registry.npmjs.org/ -npm dist-tag ls gentle-pi --registry=https://registry.npmjs.org/ -``` +

+ +

-Do not run `npm publish` locally for `gentle-pi`. Dispatch the trusted workflow definition only from protected default `main` and provide its sole `tag` input. The workflow requires an exact annotated `vSemVer` tag whose peeled commit, current remote `main`, dispatch/main workflow commit, checkout, and `package.json` version are identical. It rechecks remote tag and `main` immediately before publishing through OIDC with provenance and environment protection; an advanced `main` requires a new release version, never a moved tag. +

Built with the workflow it brings to Pi.

-## Principles +

+ MIT License +

-- Human control over agent momentum. -- Concepts before code. -- Artifacts over floating chat context. -- SDD when risk justifies it. -- Strict TDD when tests exist. -- One parent orchestrator, focused subagents. -- Reviewable changes over giant diffs. +> **Trademark notice:** The gentle-shell™ and gentle-pi™ names and associated logos are trademarks of Alan Buscaglia. The MIT License applies to the code; it does not permit implying endorsement or official affiliation. See [TRADEMARKS.md](TRADEMARKS.md). From 6786efdb8fd99e66dd88e3f196178f9d06ec1e73 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:28:12 -0300 Subject: [PATCH 39/49] chore(ci): re-trigger checks review-repository-windows failed with CandidateViewError "candidate view owner preparation failed (ETIMEDOUT)" during worktree preparation, while test/verify/session-transport all passed. No code change; re-running the checks via an empty commit because workflow rerun requires upstream admin rights. From f4b7a33b6456d5e8112a12a3894034d8c79dddfc Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:07:03 -0300 Subject: [PATCH 40/49] fix(history): gate legacy seeding/migration behind capture opt-in Review follow-up on the slice-04 PR: the selector read path (openHistorySelector -> drainForScope -> getWriter) ran legacy migration and seed bootstrap without checking the capture preference, so opening /history with capture disabled silently imported past prompts into searchable store files. - openHistorySelector warns and returns before any drain unless GENTLE_PI_HISTORY_CAPTURE=1|true|on; drainForScope adds a defense-in-depth early return. The warm-up and capture handler were already gated; the read path now matches. - Define the previously-undefined AGENT_DIR constant: migrateLegacyStores had been dead code (swallowed ReferenceError) since the deps refactor. - docs/prompt-history.md: "Legacy migration and seeding are opt-in" - imports create new searchable copies under ~/.pi/agent/history, source transcripts stay untouched, disabling does not remove imported copies. - tests/history-off-path.test.ts: with capture off, extension load writes nothing and the history command imports nothing and warns. --- docs/prompt-history.md | 13 +++++ extensions/history/index.ts | 18 ++++++- tests/history-off-path.test.ts | 94 ++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/history-off-path.test.ts diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 1fa8eba4f..fce302705 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -21,6 +21,19 @@ GENTLE_PI_HISTORY_CAPTURE=1 pi - 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/`: diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 610bcf76e..0cb0785a2 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -82,8 +82,11 @@ const LIST_WHEEL_Y_LAST = 14; const PREVIEW_WHEEL_Y_FIRST = 17; const PREVIEW_WHEEL_Y_LAST = 26; +// 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(homedir(), ".pi", "agent", "history"); +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(); @@ -897,6 +900,9 @@ function getWriter(): SessionWriterState { * dirs + the legacy global seed). Both filter tombstoned prompts. */ function drainForScope(scope: HistoryScope): string[] { + // 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 return scope === "project" ? drainProject(PI_HISTORY_ROOT, CURRENT_CWD, 1000, PI_HISTORY_NAV_STATE_DIR) @@ -906,6 +912,16 @@ function drainForScope(scope: HistoryScope): string[] { 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_CAPTURE=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). diff --git a/tests/history-off-path.test.ts b/tests/history-off-path.test.ts new file mode 100644 index 000000000..ef0eb0701 --- /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_CAPTURE; + +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_CAPTURE"), + `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), []); +}); From 89ac34883767a7fac330e4f6afd3abe8017ff2a3 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:29:12 -0300 Subject: [PATCH 41/49] fix(history): restore fail-closed tombstones and honest delete UX Review follow-up on the slice-05 PR (plus restoration of a fix clobbered by the Sept-24 merge train): - Restore the fail-closed tombstone contract from slice-2: hidden.json reads return trusted (missing/valid array) or untrusted (unreadable/ corrupt/malformed) with a recovery message naming the file; hidePrompt refuses to silently rewrite an untrusted file; drains return a blocked DrainResult with no prompts field; seed bootstrap fails closed on untrusted tombstones. - Two-step delete confirmation: the first ctrl+shift+backspace arms the selected row with scope-aware copy, the second executes, any other key disarms. The copy distinguishes deleting a stored prompt (physical store removal + tombstone) from hiding a session-derived prompt (tombstone only; transcripts are immutable). - Failure semantics made explicit: store-delete failures toast and abort before any tombstone write; hide failures toast distinctly (session path aborts; editor path reports the store row was removed while the hide failed). - docs/prompt-history.md: "Delete vs hide" section covering provenance semantics, failure behavior, and corrupt-hidden.json recovery. - Tests: restore the fail-closed hide/drain suites, adapt drain-order to the DrainResult contract, and add history-delete-confirm covering arming, provenance, and failure paths. --- docs/prompt-history.md | 30 +++ extensions/history/hide-prompts.ts | 83 +++++-- extensions/history/index.ts | 137 +++++++++-- extensions/history/selector-helpers.ts | 54 +++++ extensions/history/store.ts | 64 +++++- tests/history-delete-confirm.test.ts | 300 +++++++++++++++++++++++++ tests/history-drain-hidden.test.ts | 62 ++++- tests/history-drain-order.test.ts | 22 +- tests/history-hide-prompts.test.ts | 187 +++++++++++---- 9 files changed, 840 insertions(+), 99 deletions(-) create mode 100644 tests/history-delete-confirm.test.ts diff --git a/docs/prompt-history.md b/docs/prompt-history.md index fce302705..6a62d81e8 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -72,3 +72,33 @@ is not running): rm -rf ~/.pi/agent/history # whole store rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) ``` + +## Delete vs hide + +The selector's delete key (`ctrl+shift+backspace`) is a two-step +confirmation: the first press **arms** the delete for the selected row and +shows what it will do in the footer (the row highlights); the second press +executes it. Any other key or cancel disarms without deleting. + +What a delete does depends on where the prompt came from: + +- **Editor-stored prompts** (captured into the store's `.jsonl` files) are + deleted physically: every copy is removed from the store in one atomic + rewrite per affected file. +- **Session-derived prompts** (seeded from past transcripts) can only be + hidden: session transcripts are immutable, so the delete writes a + **tombstone** (`hidden.json`) that keeps the prompt out of the list. The + original stays in the transcript file. + +Both flows therefore end with a tombstone — otherwise the next merge would +re-supply the prompt from transcripts. Write 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. + +The tombstone file 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). diff --git a/extensions/history/hide-prompts.ts b/extensions/history/hide-prompts.ts index 9cffd6954..6d91a57d6 100644 --- a/extensions/history/hide-prompts.ts +++ b/extensions/history/hide-prompts.ts @@ -9,6 +9,14 @@ import { promptDedupKey } from "./selector-helpers.ts"; /** Name of the tombstone file inside the injected state dir (spec C4). */ const HIDE_FILE_NAME = "hidden.json"; +/** + * 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 @@ -19,32 +27,64 @@ export type HideResult = | { status: "error"; message: string }; /** - * Load the tombstone key set from `stateDir/hidden.json` — the READ half of - * the hide-file contract (spec C4). Fail-open: a missing, unreadable, - * corrupt, or wrong-shaped file is an EMPTY set and the call never throws; - * a corrupt file is rewritten clean by the next hide (the WRITE half, - * `hidePrompt`, lands in WU4). Keys are `promptDedupKey` strings written by - * `hidePrompt`; foreign values are ignored, never trusted. + * 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 function loadHiddenPrompts(stateDir: string): Set { +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. + */ +export function readHiddenPrompts(stateDir: string): HiddenRead { let raw: string; try { raw = fs.readFileSync(path.join(stateDir, HIDE_FILE_NAME), "utf8"); - } catch { - return new Set(); // missing or unreadable → empty tombstones + } 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 new Set(); // corrupt bytes → fail-open empty + return { status: "untrusted", reason: "corrupt", message: RECOVERY_MESSAGE }; } const keys = new Set(); - if (!Array.isArray(parsed)) return keys; // wrong shape → fail-open empty + 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 keys; + return { status: "trusted", keys }; } /** @@ -52,18 +92,23 @@ export function loadHiddenPrompts(stateDir: string): Set { * 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 set compacts on write and persists as a SORTED - * array via the shared atomic tmp+rename writer. Fail-open both ways: a - * corrupt or missing file reads as empty (this clean rewrite IS the - * recovery — the corrupt contents are untrustworthy by definition) and any - * write failure returns an error object for the delete-flow toast; the + * array 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 keys = loadHiddenPrompts(stateDir); - keys.add(promptDedupKey(text)); + const read = readHiddenPrompts(stateDir); + if (read.status === "untrusted") { + // Refuse without writing: never reset the untrusted state silently. + return { status: "error", message: read.message }; + } + read.keys.add(promptDedupKey(text)); const written = writeJsonAtomic( path.join(stateDir, HIDE_FILE_NAME), - [...keys].sort(), + [...read.keys].sort(), ); return written ? { status: "written" } diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 16bef52a3..240a2ead7 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -31,6 +31,7 @@ import { ensureRegistryEntry, migrateLegacyStores, openSessionWriter, + type DrainResult, type SessionWriterState, } from "./store.ts"; import { randomUUID } from "node:crypto"; @@ -41,8 +42,11 @@ import { type PromptEntry, clampPreviewOffset, clampSelectedIndex, + deleteConfirmFooterText, + deleteConfirmNext, deletionActionsFor, dedupePromptEntries, + EDITOR_HIDE_FAILED_TEXT, getVisiblePromptRecords, initialLoadedCount, loadedCountAfterDelete, @@ -52,6 +56,7 @@ import { nextLoadedCount, pageSelectedIndex, shouldGrowWindow, + STORE_DELETE_FAILED_TEXT, withExpandedHistoryGlobals, type PiHistoryGlobals, type PromptRecord, @@ -87,6 +92,11 @@ 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 scope-aware 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"); @@ -250,6 +260,7 @@ class PromptHistorySelector extends Container implements Focusable { 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; @@ -269,6 +280,12 @@ class PromptHistorySelector extends Container implements Focusable { private wrappedPreviewLines: string[] = []; /** Scroll offset into wrappedPreviewLines for the preview viewport. */ private previewScrollOffset = 0; + /** + * Two-step delete confirmation (PR #1393): armed by the first + * ctrl+shift+backspace press; the second press executes, and any other + * key or cancel disarms. Nothing is deleted on the arming press. + */ + private confirmArmed = false; /** Dispatch table: first match wins, fallthrough last. */ private readonly dispatch: readonly DispatchEntry[] = [ @@ -377,15 +394,11 @@ class PromptHistorySelector extends Container implements Focusable { this.addChild(this.previewContainer); this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); - this.addChild( - new FixedRowText( - theme.fg( - "dim", - "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • ctrl+shift+backspace delete • esc cancel", - ), - true /* centered */, - ), + 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(""); @@ -469,7 +482,13 @@ class PromptHistorySelector extends Container implements Focusable { for (const { record, isSelected } of visible) { const prefix = isSelected ? "→ " : " "; - const color = isSelected ? "accent" : "text"; + // 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(); @@ -557,8 +576,16 @@ class PromptHistorySelector extends Container implements Focusable { * 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()); @@ -569,30 +596,54 @@ class PromptHistorySelector extends Container implements Focusable { const selected = this.filteredRecords[this.selectedIndex]; if (!selected) return; + // Two-step confirm (PR #1393 review): the first ctrl+shift+backspace + // press ARMS the delete for the selected row — a scope-aware + // confirmation line in the footer plus an error-colored highlight — + // and executes NOTHING; the SECOND press runs the flow below. Any + // other key or cancel disarms (handleInput / handleMouse). + const step = deleteConfirmNext(this.confirmArmed, true); + this.confirmArmed = step.armed; + this.refreshDeleteFooter(); + if (!step.execute) { + this.rebuildList(); // repaint the armed-row highlight + return; + } + this.rebuildList(); // drop the highlight before the flow mutates rows + // 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). - const { removed } = - this.scope === "global" - ? deleteFromGlobal(PI_HISTORY_ROOT, selected.text) - : deleteFromProject(PI_HISTORY_ROOT, CURRENT_CWD, selected.text); + // (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. + // 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") { - this.onNotify?.(hide.message, "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. @@ -612,6 +663,26 @@ class PromptHistorySelector extends Container implements Focusable { this.applyFilter(this.searchInput.getValue()); } + /** Footer line: scope-aware confirm copy while armed, help otherwise. */ + private refreshDeleteFooter(): void { + if (!this.confirmArmed) { + this.footerRow.setText(this.theme.fg("dim", SELECTOR_FOOTER_HELP)); + return; + } + const selected = this.filteredRecords[this.selectedIndex]; + const source = selected?.source ?? "editor"; + this.footerRow.setText( + this.theme.fg("warning", deleteConfirmFooterText(source)), + ); + } + + /** Leave the armed state: restore the help footer and the plain row. */ + private disarmDeleteConfirm(): void { + this.confirmArmed = false; + this.refreshDeleteFooter(); + this.rebuildList(); + } + // -- Navigation --------------------------------------------------------- private moveUp(): void { @@ -755,6 +826,11 @@ class PromptHistorySelector extends Container implements Focusable { handleInput(data: string): void { const kb = getKeybindings(); + // Any key other than the delete combo disarms a pending confirmation + // (PR #1393) BEFORE its own action runs — esc, arrows, typing, tab. + if (this.confirmArmed && !matchesKey(data, "ctrl+shift+backspace")) { + this.disarmDeleteConfirm(); + } let handled = false; for (const { match, handler } of this.dispatch) { if (match(data, kb)) { @@ -780,6 +856,9 @@ class PromptHistorySelector extends Container implements Focusable { 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); @@ -951,19 +1030,29 @@ function getWriter(): SessionWriterState { 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. + * 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): string[] { +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 - return scope === "project" - ? drainProject(PI_HISTORY_ROOT, CURRENT_CWD, 1000, PI_HISTORY_NAV_STATE_DIR) - : drainGlobal(PI_HISTORY_ROOT, 1000, PI_HISTORY_NAV_STATE_DIR); + 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; } async function openHistorySelector( @@ -983,6 +1072,12 @@ async function openHistorySelector( // 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; + } if (entries.length === 0) { ctx.ui.notify("No prompt history available.", "warning"); return; diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index 292c05907..54364bd86 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -255,6 +255,60 @@ export function deletionActionsFor( return { deleteFromEditorStore: false, writeTombstone: true }; } +/** + * One transition of the two-step delete confirmation (PR #1393 review): + * the first delete-key press ARMS the delete for the selected row and + * executes nothing; the SECOND press executes; any other key disarms. The + * selector's deleteCurrent and handleInput both route through this pure + * step so the arm/execute/disarm machine has exactly one definition. + */ +export interface DeleteConfirmStep { + /** The armed state AFTER this transition. */ + armed: boolean; + /** True only on the second delete-key press — the executing press. */ + execute: boolean; +} + +export function deleteConfirmNext( + armed: boolean, + isDeleteKey: boolean, +): DeleteConfirmStep { + if (!isDeleteKey) return { armed: false, execute: false }; + if (armed) return { armed: false, execute: true }; + return { armed: true, execute: false }; +} + +/** + * Scope-aware confirmation copy shown in the footer while a delete is + * armed (PR #1393): the two provenances have different semantics and the + * copy must say which one is about to run, in one line. Editor-stored + * prompts are removed from the store physically AND hidden from history; + * session-derived prompts can only be hidden (transcripts are immutable), + * so the original stays in the session transcript. + */ +export function deleteConfirmFooterText(source: PromptSource): string { + if (source === "editor") { + return "Delete stored prompt? Removes every copy from the store and hides it from history. Session transcripts keep the original."; + } + return "Hide from history? The original stays in the session transcript; a tombstone keeps it out of this list."; +} + +/** + * 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, diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 04626f48e..a2c192dc5 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -12,7 +12,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { loadHiddenPrompts } from "./hide-prompts.ts"; +import { readHiddenPrompts } from "./hide-prompts.ts"; import { loadSharedHistory } from "./load-shared-history.ts"; import { extractPromptsFromFile, @@ -357,20 +357,53 @@ function sortFilesForDrain(files: string[]): string[] { .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, -): string[] { - return drainFiles( +): DrainResult { + return drainWithHidden( sortFilesForDrain(listProjectFiles(path.join(root, "projects", projectHash(cwd)))), limit, - stateDir ? loadHiddenPrompts(stateDir) : new Set(), + stateDir, ); } @@ -378,13 +411,15 @@ export function drainProject( * 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). + * 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, -): string[] { +): DrainResult { const files: string[] = []; const globalSeed = globalSeedPath(root); @@ -404,11 +439,7 @@ export function drainGlobal( } const sorted = sortFilesForDrain(files); if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last - return drainFiles( - sorted, - limit, - stateDir ? loadHiddenPrompts(stateDir) : new Set(), - ); + return drainWithHidden(sorted, limit, stateDir); } // --------------------------------------------------------------------------- @@ -620,7 +651,16 @@ export function bootstrapProjectSeed( return { seeded: 0, ran: false }; } // Tombstones (user deletions) suppress transcript prompts from seeding. - const hidden = stateDir ? loadHiddenPrompts(stateDir) : new Set(); + // 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; + } // Scan transcripts: session files of THIS project's dir, newest first. let files: string[] = []; diff --git a/tests/history-delete-confirm.test.ts b/tests/history-delete-confirm.test.ts new file mode 100644 index 000000000..fe828f256 --- /dev/null +++ b/tests/history-delete-confirm.test.ts @@ -0,0 +1,300 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + deleteConfirmFooterText, + deleteConfirmNext, + deletionActionsFor, + EDITOR_HIDE_FAILED_TEXT, + STORE_DELETE_FAILED_TEXT, +} from "../extensions/history/selector-helpers.ts"; + +// PR #1393 review-fix tests: the two-step delete confirmation in the +// history selector. The first ctrl+shift+backspace press ARMS the delete +// for the selected row (scope-aware confirmation footer + highlighted +// record) and executes NOTHING; the second press executes the +// deletionActionsFor-driven flow; any other key or cancel disarms. +// +// 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 confirmation +// DECISION is factored into pure helpers tested here directly, and the +// execution semantics are pinned by source-parse on deleteCurrent +// (delete-backfill.test.ts discipline). No test in this file touches the +// user's real store. + +// --------------------------------------------------------------------------- +// Pure decision machine: arm → execute, disarm on anything else. +// --------------------------------------------------------------------------- + +test("the first delete press arms only — nothing executes (PR #1393)", () => { + assert.deepEqual(deleteConfirmNext(false, true), { + armed: true, + execute: false, + }); +}); + +test("the second delete press executes and rearms-to-idle (PR #1393)", () => { + assert.deepEqual(deleteConfirmNext(true, true), { + armed: false, + execute: true, + }); +}); + +test("any other key disarms without executing; an idle stay stays idle", () => { + assert.deepEqual(deleteConfirmNext(true, false), { + armed: false, + execute: false, + }); + assert.deepEqual(deleteConfirmNext(false, false), { + armed: false, + execute: false, + }); +}); + +test("after an executed delete the machine is idle again — a fresh confirm per row", () => { + const first = deleteConfirmNext(false, true); + assert.equal(first.execute, false); + const second = deleteConfirmNext(first.armed, true); + assert.equal(second.execute, true); + // A THIRD press starts a NEW confirmation instead of executing blindly. + assert.deepEqual(deleteConfirmNext(second.armed, true), { + armed: true, + execute: false, + }); +}); + +// (b) + (c): the executing press composes with the pure planner — an +// editor-source record deletes from the store AND tombstones; a +// session-source record NEVER plans a store delete (tombstone only). + +test("second press executes the editor-source plan: store delete + tombstone", () => { + const armed = deleteConfirmNext(false, true); + const step = deleteConfirmNext(armed.armed, true); + assert.equal(step.execute, true); + assert.deepEqual(deletionActionsFor("editor"), { + deleteFromEditorStore: true, + writeTombstone: true, + }); +}); + +test("a session-source record never plans a store delete — tombstone only", () => { + const armed = deleteConfirmNext(false, true); + const step = deleteConfirmNext(armed.armed, true); + assert.equal(step.execute, true); + const actions = deletionActionsFor("session"); + assert.equal(actions.deleteFromEditorStore, false); + assert.equal(actions.writeTombstone, true); +}); + +// --------------------------------------------------------------------------- +// Copy: the armed footer distinguishes the two semantics in one line; the +// failure toasts state exactly what state remains. +// --------------------------------------------------------------------------- + +test("the editor confirmation names the physical delete AND the hide", () => { + const text = deleteConfirmFooterText("editor"); + assert.ok(!text.includes("\n"), "the confirmation stays on one line"); + assert.ok(text.includes("Delete stored prompt?")); + assert.ok(text.includes("Removes every copy from the store")); + assert.ok(text.includes("hides it from history")); + assert.ok( + text.includes("Session transcripts keep the original"), + "the immutability caveat must be stated", + ); +}); + +test("the session confirmation names the hide-only semantics", () => { + const text = deleteConfirmFooterText("session"); + assert.ok(!text.includes("\n"), "the confirmation stays on one line"); + assert.ok(text.includes("Hide from history?")); + assert.ok(text.includes("The original stays in the session transcript")); + assert.ok(text.includes("tombstone keeps it out of this list")); +}); + +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 execution semantics inside deleteCurrent (the selector +// 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", +); + +function deleteCurrentBody(): string { + const decl = selectorSource.indexOf("private deleteCurrent("); + assert.ok(decl >= 0, "deleteCurrent should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent's body should close"); + return selectorSource.slice(decl, end); +} + +test("(b) the arming press returns before ANY mutation of rows or disk", () => { + const body = deleteCurrentBody(); + const stepAt = body.indexOf("const step = deleteConfirmNext(this.confirmArmed, true);"); + assert.ok(stepAt >= 0, "the transition must route through the pure helper"); + const armReturnAt = body.indexOf("if (!step.execute)"); + assert.ok(armReturnAt > stepAt, "the execute gate must follow the step"); + const editorGuardAt = body.indexOf("if (actions.deleteFromEditorStore)"); + const spliceAt = body.indexOf("this.records.splice("); + const hideAt = body.indexOf("hidePrompt("); + assert.ok( + armReturnAt < editorGuardAt && + armReturnAt < spliceAt && + armReturnAt < hideAt, + "arming must precede the store flow, the splice, and the tombstone", + ); +}); + +test("(c) the store deletes live only inside the editor-source guard", () => { + const body = deleteCurrentBody(); + const guardAt = body.indexOf("if (actions.deleteFromEditorStore)"); + assert.ok(guardAt >= 0, "the editor-store guard must exist"); + const guardCloseAt = body.indexOf("\n }", guardAt); + assert.ok(guardCloseAt > guardAt, "the editor-store guard must close"); + + for (const call of ["deleteFromGlobal(", "deleteFromProject("]) { + const at = body.indexOf(call); + assert.ok(at >= 0, `${call} must exist`); + assert.ok( + at > guardAt && at < guardCloseAt, + `${call} must sit inside the editor guard — a session record never reaches it`, + ); + } + // The tombstone write follows the guard: EVERY provenance lands one. + const hideAt = body.indexOf("hidePrompt("); + assert.ok( + hideAt > guardCloseAt, + "the tombstone must follow (not sit inside) the editor-store guard", + ); +}); + +test("(d) a thrown store delete toasts the failure copy and aborts", () => { + const body = deleteCurrentBody(); + const tryAt = body.indexOf("try {"); + const catchAt = body.indexOf("} catch {", tryAt); + assert.ok(tryAt >= 0 && catchAt > tryAt, "the store calls must be wrapped"); + const catchEnd = body.indexOf("\n }", catchAt); + const catchBody = body.slice(catchAt, catchEnd); + assert.ok( + catchBody.includes(`this.onNotify?.(STORE_DELETE_FAILED_TEXT, "error")`), + "the catch must toast the store-failure copy", + ); + assert.ok( + catchBody.includes("return;"), + "the catch must abort the flow", + ); + // The abort precedes the tombstone write: a failed store delete leaves + // NO tombstone behind. + const hideAt = body.indexOf("hidePrompt("); + assert.ok(catchAt < hideAt, "the catch must precede the hide write"); +}); + +test("(e) a hide error toasts the session message and aborts — the editor path proceeds to the splice", () => { + const body = deleteCurrentBody(); + 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 gate must precede the splice"); + const gate = body.slice(gateAt, spliceAt); + + // Session path: toast the recovery message and abort. + const abortGuardAt = gate.indexOf("if (!actions.deleteFromEditorStore)"); + assert.ok( + abortGuardAt >= 0, + "the session-path early return must be exclusive", + ); + const abortBody = gate.slice(abortGuardAt, gate.indexOf("}", abortGuardAt)); + assert.ok( + abortBody.includes('this.onNotify?.(hide.message, "error")'), + "the session path must toast the hide error itself", + ); + assert.ok(abortBody.includes("return;"), "the session path must abort"); + assert.ok( + !gate.slice(0, abortGuardAt).includes("return;"), + "no unconditional abort before the provenance split", + ); + + // Editor path: the store row is already gone — the toast says so, and + // control FALLS THROUGH to the splice (no return between the toast and + // the splice). + const editorToastAt = gate.indexOf(`this.onNotify?.(EDITOR_HIDE_FAILED_TEXT, "error")`); + assert.ok(editorToastAt >= 0, "the editor path must toast the hide failure"); + const gateToSplice = gate.slice(editorToastAt); + assert.ok( + !gateToSplice.includes("return;"), + "the editor path must NOT abort — the splice still runs", + ); +}); + +test("any other key disarms before its own action; a wheel scroll disarms too", () => { + const handleInputAt = selectorSource.indexOf("handleInput(data: string): void {"); + assert.ok(handleInputAt >= 0, "handleInput should exist"); + const inputEnd = selectorSource.indexOf("\n }", handleInputAt); + const inputBody = selectorSource.slice(handleInputAt, inputEnd); + const disarmAt = inputBody.indexOf("this.disarmDeleteConfirm()"); + assert.ok(disarmAt >= 0, "handleInput must disarm a pending confirmation"); + assert.ok( + inputBody.includes('!matchesKey(data, "ctrl+shift+backspace")'), + "the delete combo itself must NOT route through the disarm pre-pass", + ); + // The disarm must happen before the dispatch loop consumes the key. + const loopAt = inputBody.indexOf("for (const { match, handler } of this.dispatch) {"); + assert.ok(disarmAt < loopAt, "the disarm pre-pass must precede dispatch"); + + 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", + ); +}); + +test("the armed state drives the footer copy and the error-colored highlight", () => { + const body = deleteCurrentBody(); + assert.ok( + body.includes("this.refreshDeleteFooter()"), + "every delete press refreshes the footer", + ); + + const footerAt = selectorSource.indexOf("private refreshDeleteFooter(): void {"); + assert.ok(footerAt >= 0, "refreshDeleteFooter should exist"); + const footerEnd = selectorSource.indexOf("\n }", footerAt); + const footerBody = selectorSource.slice(footerAt, footerEnd); + assert.ok( + footerBody.includes("deleteConfirmFooterText(source)"), + "the armed footer uses the scope-aware pure copy", + ); + assert.ok( + footerBody.includes("SELECTOR_FOOTER_HELP"), + "disarming restores the help line", + ); + + const rebuildAt = selectorSource.indexOf("private rebuildListWithWidth(width: number): void {"); + assert.ok(rebuildAt >= 0, "rebuildListWithWidth should exist"); + const rebuildEnd = selectorSource.indexOf("\n }", rebuildAt); + const rebuildBody = selectorSource.slice(rebuildAt, rebuildEnd); + assert.ok( + rebuildBody.includes("this.confirmArmed"), + "the armed state repaints the selected row", + ); +}); diff --git a/tests/history-drain-hidden.test.ts b/tests/history-drain-hidden.test.ts index 90c479758..73a686b86 100644 --- a/tests/history-drain-hidden.test.ts +++ b/tests/history-drain-hidden.test.ts @@ -8,6 +8,7 @@ import { drainProject, globalSeedPath, projectHash, + type DrainResult, } from "../extensions/history/store.ts"; // Portable project identity: a never-existing literal. projectHash falls @@ -25,6 +26,14 @@ function write(file: string, texts: string[], ts = 100): void { ); } +// 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"); @@ -40,15 +49,62 @@ test("drains skip tombstoned prompts in seeds and session files", () => { write(path.join(dir, "s1.jsonl"), ["also keep", "deleted from session"], 200); write(globalSeedPath(root), ["deleted from seed", "legacy keep"], 50); - assert.deepEqual(drainProject(root, CWD, 1000, stateDir), [ + assert.deepEqual(okPrompts(drainProject(root, CWD, 1000, stateDir)), [ "also keep", "keep", ]); - assert.deepEqual(drainGlobal(root, 1000, stateDir), [ + assert.deepEqual(okPrompts(drainGlobal(root, 1000, stateDir)), [ "also keep", "keep", "legacy keep", ]); // Without a stateDir the filter is off (raw drain semantics). - assert.equal(drainProject(root, CWD).includes("deleted from seed"), true); + 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 index 86cfddca9..be2f8fdfc 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -8,6 +8,7 @@ import { drainProject, globalSeedPath, projectHash, + type DrainResult, } from "../extensions/history/store.ts"; // Portable project identity: a never-existing literal. projectHash falls @@ -25,18 +26,25 @@ function writeTs(file: string, texts: string[], ts: number): void { ); } +// 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(drainProject(root, CWD), ["z-new", "a-old"]); + 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(drainProject(root, CWD), ["z-new"]); + 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( @@ -44,7 +52,7 @@ test("atomic rewrite (delete) does not reshuffle the drain order", () => { new Date(Date.now() + 99999), new Date(Date.now() + 99999), ); - assert.deepEqual(drainProject(root, CWD), ["z-new", "b-old"]); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "b-old"]); }); test("global drain puts the legacy seed last regardless of its fresh mtime", () => { @@ -54,7 +62,11 @@ test("global drain puts the legacy seed last regardless of its fresh mtime", () 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(drainGlobal(root), ["fresh", "legacy-2", "legacy-1"]); + assert.deepEqual(okPrompts(drainGlobal(root)), [ + "fresh", + "legacy-2", + "legacy-1", + ]); }); test( @@ -78,7 +90,7 @@ test( 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(drainProject(root, CWD), ["z-new", "a-old"]); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "a-old"]); } finally { fs.chmodSync(sealed, 0o644); // restore before cleanup } diff --git a/tests/history-hide-prompts.test.ts b/tests/history-hide-prompts.test.ts index 03c054863..ccd1f8ea8 100644 --- a/tests/history-hide-prompts.test.ts +++ b/tests/history-hide-prompts.test.ts @@ -3,13 +3,20 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { hidePrompt, loadHiddenPrompts } from "../extensions/history/hide-prompts.ts"; +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). fs-only -// coverage. 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. +// 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 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}-`)); @@ -21,6 +28,29 @@ function readHideFile(stateDir: string) { ); } +/** 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 @@ -41,59 +71,138 @@ test("T24 (AC-S4-1): hide keys byte-match promptDedupKey across whitespace, case assert.ok(Array.isArray(stored), "hidden.json must hold a JSON array"); // Byte-match: the file holds EXACTLY the shared helper's output, sorted. assert.deepEqual(stored, texts.map((text) => promptDedupKey(text)).sort()); - // The loaded set agrees. - const loaded = loadHiddenPrompts(stateDir); + // The read half agrees. + const keys = trustedKeys(stateDir); + assert.equal(keys.size, stored.length); for (const key of stored) { - assert.ok(loaded.has(key)); + 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 reads as an empty set; reads -// never throw. -test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads as empty; reads never throw", () => { +// 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: empty set, no throw (before any write exists). - assert.equal(loadHiddenPrompts(stateDir).size, 0); + // 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 loaded = loadHiddenPrompts(stateDir); - assert.equal(loaded.size, 1); - assert.ok(loaded.has(promptDedupKey("same text"))); + const keys = trustedKeys(stateDir); + assert.equal(keys.size, 1); + assert.ok(keys.has(promptDedupKey("same text"))); }); -// T26 — AC-S4-5: corrupt hidden.json is fail-open (READ half) AND the next -// hide rewrites the file clean as a sorted compact array — the rewrite half -// is the recovery path. -test("T26 (AC-S4-5): corrupt hidden.json loads as empty and the next hide rewrites it clean", () => { +// 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"); - fs.writeFileSync( - path.join(stateDir, "hidden.json"), - "{corrupt bytes", - "utf8", - ); - assert.equal(loadHiddenPrompts(stateDir).size, 0); + 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" }); - // The rewrite landed: clean JSON holding exactly the new key. - assert.deepEqual(readHideFile(stateDir), [promptDedupKey("beta prompt")]); - assert.equal(loadHiddenPrompts(stateDir).size, 1); + const keys = trustedKeys(stateDir); + assert.equal(keys.size, 1); + assert.ok(keys.has(promptDedupKey("beta prompt"))); }); -// WU4c — write-failure path (AC-S4-2 triangulation): a state dir that cannot -// be created (its parent is a regular file) makes the atomic write return -// false, and hidePrompt maps that to the toast-suitable error object — -// never a throw. -test("hide write failure returns the exact error shape for the delete-flow toast", () => { - const base = makeStateDir("fail"); - const blocker = path.join(base, "blocker"); - fs.writeFileSync(blocker, "regular file", "utf8"); - const stateDir = path.join(blocker, "sealed"); // parent is a file → ENOTDIR - assert.deepEqual(hidePrompt(stateDir, "kept 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: "Could not write the hide file; the prompt may reappear.", + 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 + } + }, +); From 417b9fdba8572aa0dede1df05d519ede2e7809ac Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:57:35 -0300 Subject: [PATCH 42/49] fix(history): port fail-closed tombstones to the seed slice Port slice-5's restoration (89ac348) of the fail-closed tombstone contract onto slice-4 so PR #1391 does not reintroduce the fail-open hidden.json behavior after #1392 merges: - hide-prompts.ts byte-identical to the restored version: readHiddenPrompts returns trusted (missing/valid array) or untrusted (unreadable/corrupt/malformed) with a recovery message naming hidden.json; hidePrompt refuses to rewrite an untrusted file. - store.ts: drains return DrainResult (blocked status carries no prompts field) and bootstrapProjectSeed skips seeding on untrusted tombstones. - index.ts: drainForScope unwraps DrainResult; the selector surfaces the blocked recovery message instead of silently showing entries. - Tests: hide-prompts, drain-hidden, and drain-order suites ported byte-identically from the restored versions. Delete-flow code remains slice-5 scope; nothing delete-related entered this port. --- extensions/history/hide-prompts.ts | 83 ++++++++++--- extensions/history/index.ts | 35 +++++- extensions/history/store.ts | 64 ++++++++-- tests/history-drain-hidden.test.ts | 62 +++++++++- tests/history-drain-order.test.ts | 22 +++- tests/history-hide-prompts.test.ts | 187 +++++++++++++++++++++++------ 6 files changed, 370 insertions(+), 83 deletions(-) diff --git a/extensions/history/hide-prompts.ts b/extensions/history/hide-prompts.ts index 9cffd6954..6d91a57d6 100644 --- a/extensions/history/hide-prompts.ts +++ b/extensions/history/hide-prompts.ts @@ -9,6 +9,14 @@ import { promptDedupKey } from "./selector-helpers.ts"; /** Name of the tombstone file inside the injected state dir (spec C4). */ const HIDE_FILE_NAME = "hidden.json"; +/** + * 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 @@ -19,32 +27,64 @@ export type HideResult = | { status: "error"; message: string }; /** - * Load the tombstone key set from `stateDir/hidden.json` — the READ half of - * the hide-file contract (spec C4). Fail-open: a missing, unreadable, - * corrupt, or wrong-shaped file is an EMPTY set and the call never throws; - * a corrupt file is rewritten clean by the next hide (the WRITE half, - * `hidePrompt`, lands in WU4). Keys are `promptDedupKey` strings written by - * `hidePrompt`; foreign values are ignored, never trusted. + * 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 function loadHiddenPrompts(stateDir: string): Set { +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. + */ +export function readHiddenPrompts(stateDir: string): HiddenRead { let raw: string; try { raw = fs.readFileSync(path.join(stateDir, HIDE_FILE_NAME), "utf8"); - } catch { - return new Set(); // missing or unreadable → empty tombstones + } 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 new Set(); // corrupt bytes → fail-open empty + return { status: "untrusted", reason: "corrupt", message: RECOVERY_MESSAGE }; } const keys = new Set(); - if (!Array.isArray(parsed)) return keys; // wrong shape → fail-open empty + 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 keys; + return { status: "trusted", keys }; } /** @@ -52,18 +92,23 @@ export function loadHiddenPrompts(stateDir: string): Set { * 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 set compacts on write and persists as a SORTED - * array via the shared atomic tmp+rename writer. Fail-open both ways: a - * corrupt or missing file reads as empty (this clean rewrite IS the - * recovery — the corrupt contents are untrustworthy by definition) and any - * write failure returns an error object for the delete-flow toast; the + * array 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 keys = loadHiddenPrompts(stateDir); - keys.add(promptDedupKey(text)); + const read = readHiddenPrompts(stateDir); + if (read.status === "untrusted") { + // Refuse without writing: never reset the untrusted state silently. + return { status: "error", message: read.message }; + } + read.keys.add(promptDedupKey(text)); const written = writeJsonAtomic( path.join(stateDir, HIDE_FILE_NAME), - [...keys].sort(), + [...read.keys].sort(), ); return written ? { status: "written" } diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 0cb0785a2..648138eda 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -29,6 +29,7 @@ import { ensureRegistryEntry, migrateLegacyStores, openSessionWriter, + type DrainResult, type SessionWriterState, } from "./store.ts"; import { randomUUID } from "node:crypto"; @@ -548,8 +549,16 @@ class PromptHistorySelector extends Container implements Focusable { * 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()); @@ -894,19 +903,29 @@ function getWriter(): SessionWriterState { 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. + * 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): string[] { +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 - return scope === "project" - ? drainProject(PI_HISTORY_ROOT, CURRENT_CWD, 1000, PI_HISTORY_NAV_STATE_DIR) - : drainGlobal(PI_HISTORY_ROOT, 1000, PI_HISTORY_NAV_STATE_DIR); + 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; } async function openHistorySelector( @@ -926,6 +945,12 @@ async function openHistorySelector( // 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; + } if (entries.length === 0) { ctx.ui.notify("No prompt history available.", "warning"); return; diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 32fb48295..feb8d05c3 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -12,7 +12,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { loadHiddenPrompts } from "./hide-prompts.ts"; +import { readHiddenPrompts } from "./hide-prompts.ts"; import { loadSharedHistory } from "./load-shared-history.ts"; import { extractPromptsFromFile, @@ -357,20 +357,53 @@ function sortFilesForDrain(files: string[]): string[] { .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, -): string[] { - return drainFiles( +): DrainResult { + return drainWithHidden( sortFilesForDrain(listProjectFiles(path.join(root, "projects", projectHash(cwd)))), limit, - stateDir ? loadHiddenPrompts(stateDir) : new Set(), + stateDir, ); } @@ -378,13 +411,15 @@ export function drainProject( * 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). + * 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, -): string[] { +): DrainResult { const files: string[] = []; const globalSeed = globalSeedPath(root); @@ -404,11 +439,7 @@ export function drainGlobal( } const sorted = sortFilesForDrain(files); if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last - return drainFiles( - sorted, - limit, - stateDir ? loadHiddenPrompts(stateDir) : new Set(), - ); + return drainWithHidden(sorted, limit, stateDir); } // --------------------------------------------------------------------------- @@ -539,7 +570,16 @@ export function bootstrapProjectSeed( return { seeded: 0, ran: false }; } // Tombstones (user deletions) suppress transcript prompts from seeding. - const hidden = stateDir ? loadHiddenPrompts(stateDir) : new Set(); + // 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; + } // Scan transcripts: session files of THIS project's dir, newest first. let files: string[] = []; diff --git a/tests/history-drain-hidden.test.ts b/tests/history-drain-hidden.test.ts index 90c479758..73a686b86 100644 --- a/tests/history-drain-hidden.test.ts +++ b/tests/history-drain-hidden.test.ts @@ -8,6 +8,7 @@ import { drainProject, globalSeedPath, projectHash, + type DrainResult, } from "../extensions/history/store.ts"; // Portable project identity: a never-existing literal. projectHash falls @@ -25,6 +26,14 @@ function write(file: string, texts: string[], ts = 100): void { ); } +// 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"); @@ -40,15 +49,62 @@ test("drains skip tombstoned prompts in seeds and session files", () => { write(path.join(dir, "s1.jsonl"), ["also keep", "deleted from session"], 200); write(globalSeedPath(root), ["deleted from seed", "legacy keep"], 50); - assert.deepEqual(drainProject(root, CWD, 1000, stateDir), [ + assert.deepEqual(okPrompts(drainProject(root, CWD, 1000, stateDir)), [ "also keep", "keep", ]); - assert.deepEqual(drainGlobal(root, 1000, stateDir), [ + assert.deepEqual(okPrompts(drainGlobal(root, 1000, stateDir)), [ "also keep", "keep", "legacy keep", ]); // Without a stateDir the filter is off (raw drain semantics). - assert.equal(drainProject(root, CWD).includes("deleted from seed"), true); + 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 index 86cfddca9..be2f8fdfc 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -8,6 +8,7 @@ import { drainProject, globalSeedPath, projectHash, + type DrainResult, } from "../extensions/history/store.ts"; // Portable project identity: a never-existing literal. projectHash falls @@ -25,18 +26,25 @@ function writeTs(file: string, texts: string[], ts: number): void { ); } +// 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(drainProject(root, CWD), ["z-new", "a-old"]); + 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(drainProject(root, CWD), ["z-new"]); + 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( @@ -44,7 +52,7 @@ test("atomic rewrite (delete) does not reshuffle the drain order", () => { new Date(Date.now() + 99999), new Date(Date.now() + 99999), ); - assert.deepEqual(drainProject(root, CWD), ["z-new", "b-old"]); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "b-old"]); }); test("global drain puts the legacy seed last regardless of its fresh mtime", () => { @@ -54,7 +62,11 @@ test("global drain puts the legacy seed last regardless of its fresh mtime", () 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(drainGlobal(root), ["fresh", "legacy-2", "legacy-1"]); + assert.deepEqual(okPrompts(drainGlobal(root)), [ + "fresh", + "legacy-2", + "legacy-1", + ]); }); test( @@ -78,7 +90,7 @@ test( 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(drainProject(root, CWD), ["z-new", "a-old"]); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "a-old"]); } finally { fs.chmodSync(sealed, 0o644); // restore before cleanup } diff --git a/tests/history-hide-prompts.test.ts b/tests/history-hide-prompts.test.ts index 03c054863..ccd1f8ea8 100644 --- a/tests/history-hide-prompts.test.ts +++ b/tests/history-hide-prompts.test.ts @@ -3,13 +3,20 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { hidePrompt, loadHiddenPrompts } from "../extensions/history/hide-prompts.ts"; +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). fs-only -// coverage. 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. +// 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 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}-`)); @@ -21,6 +28,29 @@ function readHideFile(stateDir: string) { ); } +/** 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 @@ -41,59 +71,138 @@ test("T24 (AC-S4-1): hide keys byte-match promptDedupKey across whitespace, case assert.ok(Array.isArray(stored), "hidden.json must hold a JSON array"); // Byte-match: the file holds EXACTLY the shared helper's output, sorted. assert.deepEqual(stored, texts.map((text) => promptDedupKey(text)).sort()); - // The loaded set agrees. - const loaded = loadHiddenPrompts(stateDir); + // The read half agrees. + const keys = trustedKeys(stateDir); + assert.equal(keys.size, stored.length); for (const key of stored) { - assert.ok(loaded.has(key)); + 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 reads as an empty set; reads -// never throw. -test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads as empty; reads never throw", () => { +// 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: empty set, no throw (before any write exists). - assert.equal(loadHiddenPrompts(stateDir).size, 0); + // 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 loaded = loadHiddenPrompts(stateDir); - assert.equal(loaded.size, 1); - assert.ok(loaded.has(promptDedupKey("same text"))); + const keys = trustedKeys(stateDir); + assert.equal(keys.size, 1); + assert.ok(keys.has(promptDedupKey("same text"))); }); -// T26 — AC-S4-5: corrupt hidden.json is fail-open (READ half) AND the next -// hide rewrites the file clean as a sorted compact array — the rewrite half -// is the recovery path. -test("T26 (AC-S4-5): corrupt hidden.json loads as empty and the next hide rewrites it clean", () => { +// 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"); - fs.writeFileSync( - path.join(stateDir, "hidden.json"), - "{corrupt bytes", - "utf8", - ); - assert.equal(loadHiddenPrompts(stateDir).size, 0); + 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" }); - // The rewrite landed: clean JSON holding exactly the new key. - assert.deepEqual(readHideFile(stateDir), [promptDedupKey("beta prompt")]); - assert.equal(loadHiddenPrompts(stateDir).size, 1); + const keys = trustedKeys(stateDir); + assert.equal(keys.size, 1); + assert.ok(keys.has(promptDedupKey("beta prompt"))); }); -// WU4c — write-failure path (AC-S4-2 triangulation): a state dir that cannot -// be created (its parent is a regular file) makes the atomic write return -// false, and hidePrompt maps that to the toast-suitable error object — -// never a throw. -test("hide write failure returns the exact error shape for the delete-flow toast", () => { - const base = makeStateDir("fail"); - const blocker = path.join(base, "blocker"); - fs.writeFileSync(blocker, "regular file", "utf8"); - const stateDir = path.join(blocker, "sealed"); // parent is a file → ENOTDIR - assert.deepEqual(hidePrompt(stateDir, "kept 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: "Could not write the hide file; the prompt may reappear.", + 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 + } + }, +); From e2cca1f9bd328695b3deb7ec6c216446b585ddbb Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:44:50 -0300 Subject: [PATCH 43/49] fix(history): scope the gc slice to lifecycle work Review follow-up on the slice-06 PR: the branch mixed the GC/lifecycle rewrite with unrelated selector header-layout, overlay-margin, and scope-radio changes, so reviewers could not assess the destructive store rewrite as one bounded unit. - Remove the layout batch: planHeaderLayout/HeaderLayoutMode, editorOverlayMargin, SIDEBAR_RAIL_OVERLAY_MARGIN/SIDEBAR_OVERLAY_PADDING, SCOPE_RADIO_* constants, and scopeRadioText from selector-helpers; drop their index.ts usage (header mode fields, OptionalRow, headerCountsText, listWheelFirstRow) and delete the header-layout and overlay-margin test files. - Restore preview-layout and wheel-mouse suites byte-exact to slice-5 and revert the constructor child-count pins the layout batch introduced. - Keep the GC/lifecycle work intact: gcProjectDir/compactProjectFile(s), thresholds, the session_shutdown GC hook, and the gc test suite; keep all slice-5 delete-confirm behavior and the fail-closed tombstone contract. - Relocate a merge-misplaced doc comment above openHistorySelector. The layout/formatting work moves to a follow-up PR so the destructive GC rewrite and its failure tests are reviewed as one unit. --- extensions/history/index.ts | 194 +++++---------------- extensions/history/selector-helpers.ts | 97 ----------- tests/history-header-layout.test.ts | 52 ------ tests/history-lazy-windowing.test.ts | 2 +- tests/history-openflow-integration.test.ts | 2 +- tests/history-overlay-margin.test.ts | 95 ---------- tests/history-preview-layout.test.ts | 7 +- tests/history-wheel-mouse.test.ts | 23 ++- 8 files changed, 58 insertions(+), 414 deletions(-) delete mode 100644 tests/history-header-layout.test.ts delete mode 100644 tests/history-overlay-margin.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index c81e93cf9..a0fbb750b 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -22,6 +22,7 @@ import { Input, matchesKey, stripTerminalSequences, + Text, type TUI, type TuiMouseEvent, truncateToWidth, @@ -36,10 +37,8 @@ import { dedupePromptEntries, deletionActionsFor, EDITOR_HIDE_FAILED_TEXT, - editorOverlayMargin, filterPrompts, getVisiblePromptRecords, - type HeaderLayoutMode, initialLoadedCount, loadedCountAfterDelete, loadedCountForQuery, @@ -50,8 +49,6 @@ import { type PromptEntry, type PromptRecord, pageSelectedIndex, - planHeaderLayout, - scopeRadioText, shouldGrowWindow, STORE_DELETE_FAILED_TEXT, withExpandedHistoryGlobals, @@ -83,16 +80,12 @@ const INITIAL_BATCH = 10; const BATCH_SIZE = 10; const PRELOAD_BUFFER = 3; // Wheel regions over the fixed 30-row overlay geometry (design §D6): the -// preview container always renders at rows 17-26. The list region is -// mode-dependent (see listWheelFirstRow): the responsive header reclaims -// rows without changing the 30-row total, and only the compact mode both -// shifts the list start (border at row 5) and paints one list row fewer. +// 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; -/** Minimum columns between the counts text and a right-flushed radio before shrinking deletes the spacer and stacks the header (user-directed). */ -const HEADER_INLINE_MIN_GAP = 4; // Default selector footer line (PR #1393): shown whenever a delete is not // armed; the armed state swaps it for the scope-aware confirmation copy. @@ -220,22 +213,6 @@ class FixedRowText { } } -/** A row that renders as ZERO lines when its text is empty, letting the fixed 30-row overlay reclaim the row instead of pushing content out the bottom. */ -class OptionalRow { - private text = ""; - - setText(next: string): void { - this.text = next; - } - - invalidate(): void {} - - render(width: number): string[] { - if (this.text.length === 0) return []; - return [truncateToWidth(this.text, width, "…")]; - } -} - /** Word-wrap plain text so each line fits within maxWidth characters. */ function wordWrapText(text: string, maxWidth: number): string[] { if (maxWidth <= 0) return [text || " "]; @@ -274,12 +251,6 @@ class PromptHistorySelector extends Container implements Focusable { private readonly previewContainer: Container; private readonly listContainer: Container; private readonly headerRow: FixedRowText; - private readonly headerLine2: OptionalRow; - private readonly headerLine3: OptionalRow; - private readonly hintRow: OptionalRow; - private readonly hintText: string; - /** Current responsive header mode; drives the list wheel region. */ - private headerMode: HeaderLayoutMode = "inline"; private readonly previewLabelRow: FixedRowText; private readonly footerRow: FixedRowText; private records: PromptRecord[]; @@ -387,15 +358,13 @@ class PromptHistorySelector extends Container implements Focusable { theme.fg("accent", theme.bold(" History Search ")), ); this.addChild(this.headerRow); - this.headerLine2 = new OptionalRow(); - this.headerLine3 = new OptionalRow(); - this.addChild(this.headerLine2); - this.addChild(this.headerLine3); - this.hintText = - "Type to filter (multi-word AND substring, case-insensitive)"; - this.hintRow = new OptionalRow(); - this.hintRow.setText(this.theme.fg("dim", this.hintText)); - this.addChild(this.hintRow); + 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(); @@ -453,78 +422,33 @@ class PromptHistorySelector extends Container implements Focusable { this.rebuildListWithWidth(this.lastWidth); } - /** Styled title + position + loaded-counts prefix shared by the inline and stacked header layouts. */ - private headerCountsText( - titleText: string, - positionText: string, - loadedText: string, - ): string { - return ( - this.theme.fg("accent", this.theme.bold(titleText)) + - this.theme.fg("dim", positionText) + - this.theme.fg("dim", loadedText) - ); - } - - /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows (MAX_VISIBLE - 1 in compact mode). */ + /** 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; - const titleText = " History Search "; - const positionText = ` · ${position} of ${count} `; - const loadedText = ` · loaded ${this.loadedCount} of ${this.records.length} `; - const leftWidth = - titleText.length + positionText.length + loadedText.length; - const radioFull = scopeRadioText(this.scope, false); - // Radio label compaction is fit-driven too: abbreviate only when the - // full radio cannot fit the row it would occupy (user-directed paste). - const radioText = - width >= radioFull.length ? radioFull : scopeRadioText(this.scope, true); - const mode = planHeaderLayout( - width, - leftWidth, - radioFull.length, - HEADER_INLINE_MIN_GAP, - ); - this.headerMode = mode; - if (mode === "inline") { - this.headerRow.setText( - this.headerCountsText(titleText, positionText, loadedText) + - // Right-aligned scope radio: pad from plain-text lengths so the - // radio ends flush at the header's last column at any width. - " ".repeat(Math.max(1, width - leftWidth - radioText.length)) + - this.theme.fg("dim", radioText), - ); - this.headerLine2.setText(""); - this.headerLine3.setText(""); - } else if (mode === "stacked") { - // Tablet: the spacer is deleted — the radio wraps to its own row - // under the full counts line (user-directed paste, leading space). - this.headerRow.setText( - this.headerCountsText(titleText, positionText, loadedText), - ); - this.headerLine2.setText(` ${this.theme.fg("dim", radioText)}`); - this.headerLine3.setText(""); - } else { - // Compact (mobile): three rows — counts split off, radio abbreviated - // (user-directed paste). - this.headerRow.setText( - this.theme.fg("accent", this.theme.bold(titleText)) + - this.theme.fg("dim", ` · ${position} of ${count}`), - ); - // Leading space aligns both rows with the title's own left padding - // space (user-directed compact paste). - this.headerLine2.setText( + 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}`, - ), - ); - this.headerLine3.setText(` ${this.theme.fg("dim", radioText)}`); - } - // Stacked modes reclaim the hint row so the overlay stays 30 rows. - this.hintRow.setText( - mode === "inline" ? this.theme.fg("dim", this.hintText) : "", + ` · 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(); @@ -532,24 +456,18 @@ class PromptHistorySelector extends Container implements Focusable { this.listContainer.addChild( new FixedRowText(this.theme.fg("warning", "No matching prompts")), ); - // Compact still paints one list row fewer in the empty state, or the - // 3-row header would push the fixed 30-row overlay to 31 rows. - const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE; - for (let i = 1; i < listRows; i++) { + for (let i = 1; i < MAX_VISIBLE; i++) { this.listContainer.addChild(new FixedRowText()); } return; } - // Compact paints one list row fewer (reclaimed by the 3-row header); - // the preview block keeps PREVIEW_ROWS so the 30-row total holds. - const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE; const entryMax = Math.floor(width * 0.95) - ENTRY_PREFIX_WIDTH; const visible = getVisiblePromptRecords( this.filteredRecords, this.selectedIndex, - listRows, + MAX_VISIBLE, ); for (const { record, isSelected } of visible) { @@ -569,18 +487,11 @@ class PromptHistorySelector extends Container implements Focusable { this.listContainer.addChild(new FixedRowText(line)); } - for (let i = visible.length; i < listRows; i++) { + for (let i = visible.length; i < MAX_VISIBLE; i++) { this.listContainer.addChild(new FixedRowText()); } } - /** List wheel region start: compact shifts the list down one row. */ - private get listWheelFirstRow(): number { - return this.headerMode === "compact" - ? LIST_WHEEL_Y_FIRST + 1 - : LIST_WHEEL_Y_FIRST; - } - /** * Rebuild preview: word-wrap the full selected prompt text and show * a PREVIEW_ROWS-tall viewport starting at previewScrollOffset. @@ -934,7 +845,7 @@ class PromptHistorySelector extends Container implements Focusable { // 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 >= this.listWheelFirstRow && event.y <= LIST_WHEEL_Y_LAST) { + 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(); @@ -1020,7 +931,7 @@ function createPromptHistorySelectorFactory( onNotify?: SelectorNotify, ): SelectorFactory { return (tui, theme, _keybindings, done) => { - selectorTui = tui as { requestRender(): void; terminal?: unknown }; + selectorTui = tui as { requestRender(): void }; const finish = (result: PromptRecord | null) => { activeOverlayClose = null; done(result); @@ -1055,44 +966,20 @@ async function runPromptHistorySelection( ), { overlay: true, - // pi-tui freezes the options object at showOverlay time, but calls - // visible() on EVERY render pass before resolving the overlay layout - // (compositeOverlays filters visible entries first), and re-reads - // margin per layout resolution — the getter below therefore stays - // live: resizing across the sidebar breakpoint re-seats the picker - // while it stays open. While the gentle-shell fullscreen sidebar - // paints, the margin confines width "100%" (and the bottom-center - // anchor) to the editor column plus 3 columns of padding; 0 keeps - // the native full-window behavior. - overlayOptions: () => { - let rightMargin = editorOverlayMargin(selectorTui?.terminal); - return { - anchor: "bottom-center" as const, - width: "100%" as const, - offsetY: 5, - get margin() { - return rightMargin > 0 ? { right: rightMargin } : undefined; - }, - visible: () => { - rightMargin = editorOverlayMargin(selectorTui?.terminal); - return true; - }, - }; - }, + overlayOptions: { anchor: "bottom-center", width: "100%", offsetY: 5 }, }, ), ); } -/** Shared entry point for the ctrl+shift+r shortcut and the /history command. */ // --------------------------------------------------------------------------- // Multi-concurrency store (v2): per-session writes, scope drains // --------------------------------------------------------------------------- type HistoryScope = "project" | "global"; -/** TUI handle captured when the selector overlay mounts. `terminal` feeds the sidebar overlay margin. */ -let selectorTui: { requestRender(): void; terminal?: unknown } | null = null; +/** TUI handle captured when the selector overlay mounts. */ +let selectorTui: { requestRender(): void } | null = null; let writerState: SessionWriterState | null = null; @@ -1153,6 +1040,7 @@ function drainForScope(scope: HistoryScope): ScopeDrain { 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 { diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index 7a68bb325..29500d90c 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -370,100 +370,3 @@ export function filterPrompts( return filtered.slice(0, MAX_RESULTS); } - -/** - * Cross-extension fullscreen-sidebar state contract (gentle-shell): stored on - * the shared ProcessTerminal under a global-registry symbol so any extension - * can read it without importing gentle-shell. Shape per its lib/shell-sidebar.ts: - * `{ active: boolean; ownsHost?: () => boolean; parts: Map }`. - */ -const SIDEBAR_STATE_SYMBOL = Symbol.for("gentle-pi.experimental-sidebar.state"); - -/** - * Geometry overlay right margin that confines a full-width overlay to the - * editor column while the gentle-shell fullscreen sidebar paints: its layout - * hstack reserves 50 columns (RAIL_WIDTH) for the rail plus a 3-column gap - * (GAP) before it, and it only activates at >= 140 columns. pi-tui resolves - * overlay width "100%" and the bottom-center anchor inside - * `[0, columns - margin)`, which is then exactly the editor column. - */ -export const SIDEBAR_RAIL_OVERLAY_MARGIN = 53; - -/** - * Visual breathing room between the picker and the sidebar rail, added on top - * of the geometry margin (user-directed: 1 column, 2026-09-21). - */ -export const SIDEBAR_OVERLAY_PADDING = 1; - -interface SidebarStateShape { - active?: unknown; - ownsHost?: () => unknown; -} - -/** - * Overlay right margin for the current terminal: the geometry margin plus - * padding while the gentle-shell sidebar rail is painting, else 0 (native - * full-window overlay). Reads the terminal-owned state contract defensively — - * any absent, malformed, or non-owning state degrades to 0 so the picker - * keeps opening. Purity note: this returns the CURRENT margin per call; live - * refresh while an overlay stays open is the caller's job (the picker wires - * visible() plus a getter margin — pi-tui re-reads both every render). - */ -export function editorOverlayMargin(terminal: unknown): number { - if (typeof terminal !== "object" || terminal === null) return 0; - const state = (terminal as Record)[SIDEBAR_STATE_SYMBOL] as - | SidebarStateShape - | undefined; - if (typeof state !== "object" || state === null) return 0; - if (state.active !== true || typeof state.ownsHost !== "function") return 0; - try { - return state.ownsHost() === true - ? SIDEBAR_RAIL_OVERLAY_MARGIN + SIDEBAR_OVERLAY_PADDING - : 0; - } catch { - return 0; - } -} - -/** Responsive picker-header mode at the current render width. */ -export type HeaderLayoutMode = "inline" | "stacked" | "compact"; - -/** - * Fit-driven header plan (user-directed responsive header): "inline" keeps - * title + counts + right-flushed radio on one row; "stacked" (tablet) deletes - * the spacer — the radio wraps to its own row under the full counts line; - * "compact" (mobile) further splits the counts off and abbreviates the radio. - * Thresholds derive from the ACTUAL text widths, so any count size flips the - * mode at the exact column where the previous layout stops fitting. - */ -export function planHeaderLayout( - width: number, - leftWidth: number, - radioWidth: number, - minGap: number, -): HeaderLayoutMode { - if (width >= leftWidth + minGap + radioWidth) return "inline"; - if (width >= leftWidth) return "stacked"; - return "compact"; -} - -/** Full scope radio: both scope labels spelled out. */ -export const SCOPE_RADIO_FULL_PROJECT = "◉ Current project | ○ All projects"; -export const SCOPE_RADIO_FULL_GLOBAL = "○ Current project | ◉ All projects"; -/** Abbreviated radio: the ACTIVE scope keeps its full label, the other shortens. */ -export const SCOPE_RADIO_COMPACT_PROJECT = "◉ Current project | ○ All"; -export const SCOPE_RADIO_COMPACT_GLOBAL = "○ Current | ◉ All projects"; - -/** - * Scope radio text for the current width: abbreviated only when the full - * radio cannot fit the row it would occupy (compact widths). - */ -export function scopeRadioText( - scope: "project" | "global", - compact: boolean, -): string { - if (scope === "project") { - return compact ? SCOPE_RADIO_COMPACT_PROJECT : SCOPE_RADIO_FULL_PROJECT; - } - return compact ? SCOPE_RADIO_COMPACT_GLOBAL : SCOPE_RADIO_FULL_GLOBAL; -} diff --git a/tests/history-header-layout.test.ts b/tests/history-header-layout.test.ts deleted file mode 100644 index 4734967b4..000000000 --- a/tests/history-header-layout.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - planHeaderLayout, - SCOPE_RADIO_COMPACT_GLOBAL, - SCOPE_RADIO_COMPACT_PROJECT, - SCOPE_RADIO_FULL_GLOBAL, - SCOPE_RADIO_FULL_PROJECT, - scopeRadioText, -} from "../extensions/history/selector-helpers.ts"; - -const LEFT = - " History Search ".length + - " · 1 of 10 ".length + - " · loaded 10 of 27 ".length; -const RADIO = SCOPE_RADIO_FULL_PROJECT.length; -const GAP = 4; - -test("inline while counts plus radio plus minimum gap fit the width", () => { - assert.equal( - planHeaderLayout(LEFT + GAP + RADIO, LEFT, RADIO, GAP), - "inline", - ); - assert.equal(planHeaderLayout(200, LEFT, RADIO, GAP), "inline"); -}); - -test("stacked (tablet) once the spacer would drop below the minimum gap", () => { - assert.equal( - planHeaderLayout(LEFT + GAP + RADIO - 1, LEFT, RADIO, GAP), - "stacked", - ); - assert.equal(planHeaderLayout(LEFT, LEFT, RADIO, GAP), "stacked"); -}); - -test("compact (mobile) when even the counts line no longer fits", () => { - assert.equal(planHeaderLayout(LEFT - 1, LEFT, RADIO, GAP), "compact"); - assert.equal(planHeaderLayout(30, LEFT, RADIO, GAP), "compact"); -}); - -test("radio pins the user-directed labels", () => { - assert.equal(SCOPE_RADIO_FULL_PROJECT, "◉ Current project | ○ All projects"); - assert.equal(SCOPE_RADIO_FULL_GLOBAL, "○ Current project | ◉ All projects"); - assert.equal(SCOPE_RADIO_COMPACT_PROJECT, "◉ Current project | ○ All"); - assert.equal(SCOPE_RADIO_COMPACT_GLOBAL, "○ Current | ◉ All projects"); -}); - -test("scopeRadioText abbreviates only in compact mode", () => { - assert.equal(scopeRadioText("project", false), SCOPE_RADIO_FULL_PROJECT); - assert.equal(scopeRadioText("global", false), SCOPE_RADIO_FULL_GLOBAL); - assert.equal(scopeRadioText("project", true), SCOPE_RADIO_COMPACT_PROJECT); - assert.equal(scopeRadioText("global", true), SCOPE_RADIO_COMPACT_GLOBAL); -}); diff --git a/tests/history-lazy-windowing.test.ts b/tests/history-lazy-windowing.test.ts index f804da454..d53c8ef81 100644 --- a/tests/history-lazy-windowing.test.ts +++ b/tests/history-lazy-windowing.test.ts @@ -496,7 +496,7 @@ test("the header keeps the position segment plus the loaded suffix on the existi const ctorEnd = selectorSource.indexOf('this.applyFilter("")', ctorAt); const ctorAddChild = selectorSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; - assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged"); + assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); }); // T14 — AC-L2-3 revision (user-directed 2026-09-08): a non-empty query diff --git a/tests/history-openflow-integration.test.ts b/tests/history-openflow-integration.test.ts index 37c654672..cb1895fad 100644 --- a/tests/history-openflow-integration.test.ts +++ b/tests/history-openflow-integration.test.ts @@ -143,5 +143,5 @@ test("T33 (AC-S6-3): Change 2 structural pins still hold beside the third segmen const ctorEnd = indexSource.indexOf('this.applyFilter("")', ctorAt); const ctorAddChild = indexSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; - assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged"); + assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); }); diff --git a/tests/history-overlay-margin.test.ts b/tests/history-overlay-margin.test.ts deleted file mode 100644 index 816229f32..000000000 --- a/tests/history-overlay-margin.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - editorOverlayMargin, - SIDEBAR_OVERLAY_PADDING, - SIDEBAR_RAIL_OVERLAY_MARGIN, -} from "../extensions/history/selector-helpers.ts"; - -function terminalWithState(state: unknown): object { - return { - [Symbol.for("gentle-pi.experimental-sidebar.state")]: state, - } as object; -} - -test("margin constant pins the gentle-shell rail geometry (RAIL_WIDTH 50 + GAP 3)", () => { - assert.equal(SIDEBAR_RAIL_OVERLAY_MARGIN, 53); -}); - -test("padding constant pins the user-directed 1-column breathing room", () => { - assert.equal(SIDEBAR_OVERLAY_PADDING, 1); -}); - -test("returns 0 for absent, primitive, or null terminals", () => { - assert.equal(editorOverlayMargin(undefined), 0); - assert.equal(editorOverlayMargin(null), 0); - assert.equal(editorOverlayMargin(42), 0); - assert.equal(editorOverlayMargin("terminal"), 0); -}); - -test("returns 0 when no sidebar state is stored on the terminal", () => { - assert.equal(editorOverlayMargin({}), 0); -}); - -test("returns 0 for malformed state shapes", () => { - assert.equal(editorOverlayMargin(terminalWithState(undefined)), 0); - assert.equal(editorOverlayMargin(terminalWithState(null)), 0); - assert.equal(editorOverlayMargin(terminalWithState("active")), 0); -}); - -test("returns 0 unless active is exactly true AND ownsHost is a function", () => { - assert.equal( - editorOverlayMargin(terminalWithState({ active: true })), - 0, - "active without ownsHost", - ); - assert.equal( - editorOverlayMargin( - terminalWithState({ active: false, ownsHost: () => true }), - ), - 0, - "inactive", - ); - assert.equal( - editorOverlayMargin(terminalWithState({ active: 1, ownsHost: () => true })), - 0, - "non-boolean truthy active", - ); - assert.equal( - editorOverlayMargin( - terminalWithState({ active: true, ownsHost: "not-a-function" }), - ), - 0, - "non-function ownsHost", - ); -}); - -test("returns the geometry margin plus padding only while the sidebar owns the host", () => { - assert.equal( - editorOverlayMargin( - terminalWithState({ active: true, ownsHost: () => true }), - ), - 54, - ); - assert.equal( - editorOverlayMargin( - terminalWithState({ active: true, ownsHost: () => false }), - ), - 0, - "state present but host not owned (regular mode / unpatched root)", - ); -}); - -test("a throwing ownsHost degrades to 0 instead of breaking the picker", () => { - assert.equal( - editorOverlayMargin( - terminalWithState({ - active: true, - ownsHost: () => { - throw new Error("boom"); - }, - }), - ), - 0, - ); -}); diff --git a/tests/history-preview-layout.test.ts b/tests/history-preview-layout.test.ts index 56abd381f..02509725c 100644 --- a/tests/history-preview-layout.test.ts +++ b/tests/history-preview-layout.test.ts @@ -1,10 +1,11 @@ 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 { fileURLToPath } from "node:url"; -const sourcePath = fileURLToPath(new URL("../extensions/history/index.ts", import.meta.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", () => { diff --git a/tests/history-wheel-mouse.test.ts b/tests/history-wheel-mouse.test.ts index 5bae67d79..fbb4a33b7 100644 --- a/tests/history-wheel-mouse.test.ts +++ b/tests/history-wheel-mouse.test.ts @@ -1,24 +1,23 @@ 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 { fileURLToPath } from "node:url"; // Unit 4 — L6 wheel slice (spec C5, design §D6). // -// Source-parse structural pins on src/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: +// 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 12-entry dispatch table gains no 13th entry -// (wheel is not a keybinding — dispatch.test.ts remains the authoritative +// 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 (see tmp/c2u4-qa-prechange-record.md); +// 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 @@ -33,7 +32,7 @@ const selectorSource = fs.readFileSync( "utf8", ); -// T13 — AC-L6-1: wheel-only override + no 13th dispatch entry. +// 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("); @@ -143,7 +142,7 @@ test("region constants 5-14 / 17-26 route the y comparisons (AC-L6-3)", () => { const body = selectorSource.slice(decl, end); assert.ok( - body.includes("event.y >= this.listWheelFirstRow") && + 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", ); @@ -170,7 +169,7 @@ test("list wheel routes sign-clamped steps through moveDown/moveUp (AC-L6-4)", ( "delta must default an absent wheelDelta to 0", ); - const listStart = body.indexOf("if (event.y >= this.listWheelFirstRow"); + const listStart = body.indexOf("if (event.y >= LIST_WHEEL_Y_FIRST"); const listEnd = body.indexOf("} else if (", listStart); assert.ok( listStart >= 0 && listEnd > listStart, From a225102f219d8c7d12864502fdfe80c302182579 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:45:06 -0300 Subject: [PATCH 44/49] docs(history): compaction is not a retention limit Clarify in the prompt-history docs that compaction is housekeeping for performance: it consolidates capture files and drops the oldest entries to bound file/line counts, but it does not remove prompts from the resulting store and is not a data-retention or automatic-deletion policy. Prompts leave the store only through the delete flow (or manual removal), and compaction honors tombstones so deleted prompts stay deleted. --- docs/prompt-history.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 6a62d81e8..e8b630d34 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -102,3 +102,15 @@ 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. From 7f3aed854b031e4f755152e4a591256c54558135 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:12:52 -0300 Subject: [PATCH 45/49] feat(history): add the selector open flow First of three review units for the selector slice (PR #1395 review asked to split it into command/open flow, search/list, and preview/mouse): - /history command and ctrl+shift+r shortcut share one gated entry point: with capture disabled it warns naming GENTLE_PI_HISTORY_CAPTURE and never touches migration, seed, registry, or store files; DrainResult drains unwrap with the fail-closed blocked path surfacing the recovery message instead of any prompts. - Minimal PromptHistorySelector overlay: frame, wrapped-cursor list, esc/enter lifecycle, and the original empty-store policy (notify and return). - selector-helpers: visible-range helpers (moveSelectedIndex, computeVisibleRange, getVisiblePromptRecords, VisibleRange types). - Tests: command-registration (shared entry point, empty-store guard, capture-gate ordering, blocked-drain handling) and openflow-integration adapted to the DrainResult contract. --- extensions/history/index.ts | 436 ++++++++++++++++++++- extensions/history/selector-helpers.ts | 67 ++++ tests/history-command-registration.test.ts | 123 ++++++ tests/history-openflow-integration.test.ts | 117 ++++++ tests/history-session-writer.test.ts | 11 +- 5 files changed, 743 insertions(+), 11 deletions(-) create mode 100644 tests/history-command-registration.test.ts create mode 100644 tests/history-openflow-integration.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 74dab5942..2df8a7b10 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1,27 +1,58 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Prompt-history extension entry (slice 1): identity constants, the -// per-instance writer lifecycle, and the before_agent_start capture -// handler. Selector UI, shortcut/command, scope drains, legacy migration -// and seed bootstrap, and GC arrive in later slices. +// Prompt-history extension entry (slice 3, stage 1): the minimal selector +// open flow over the slice-1 writer and slice-2 drains — /history command, +// ctrl+shift+r shortcut, the capture gate, the fail-closed project drain, +// and a non-search list overlay. Search, paging/preview, mouse, and the +// scope toggle arrive in later stages; deletion (slice 5) and GC +// (slice 6) later still. // // Capture is OPT-IN while the deletion/privacy behavior is unshipped: -// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=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). +// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=1|true|on. The +// selector honors the same gate: with the switch off, opening the selector +// is a no-op — no registry entry, no writer init, no store reads. +// Unsetting the switch only stops NEW captures; files already written stay +// on disk (docs/prompt-history.md). import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { + DynamicBorder, + type ExtensionAPI, + type ExtensionCommandContext, + type Theme, +} from "@earendil-works/pi-coding-agent"; +import { + Container, + type Focusable, + getKeybindings, + type TUI, + truncateToWidth, +} from "@earendil-works/pi-tui"; import { appendSessionCapture, + type DrainResult, + drainGlobal, + drainProject, ensureRegistryEntry, openSessionWriter, type SessionWriterState, } from "./store.ts"; +import { + buildPromptRecords, + dedupePromptEntries, + getVisiblePromptRecords, + moveSelectedIndex, + type PromptEntry, + type PromptRecord, +} from "./selector-helpers.ts"; + +const SHORTCUT = "ctrl+shift+r"; +const MAX_VISIBLE = 10; +/** Width of the "→ " / " " prefix on each entry line. */ +const ENTRY_PREFIX_WIDTH = 2; // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); @@ -45,6 +76,380 @@ export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return value === "1" || value === "true" || value === "on"; } +// --------------------------------------------------------------------------- +// Sanitization (a22588fc) +// --------------------------------------------------------------------------- + +/** + * 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 === 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; +} + +// --------------------------------------------------------------------------- +// TUI Selector (stage 1: frame + non-search list) +// --------------------------------------------------------------------------- + +/** 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 = truncated.replace(/\x1b\[[0-9;]*m/g, ""); + 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) + // occupy no terminal cells. + const visible = rendered.replace(/\x1b\[[0-9;]*m/g, ""); + return [rendered + " ".repeat(Math.max(0, width - visible.length))]; + } +} + +class PromptHistorySelector extends Container implements Focusable { + private readonly headerRow: FixedRowText; + private readonly listContainer: Container; + private records: PromptRecord[]; + private readonly theme: Theme; + private readonly tui: TUI; + private readonly onSelect: (record: PromptRecord) => void; + private readonly onCancel: () => void; + /** Notification sink for selector feedback (wired by the open flow). */ + private readonly onNotify?: SelectorNotify; + private selectedIndex = 0; + + /** Dispatch table: first match wins. Unhandled input is ignored — the + * search box joins in a later stage and owns the fallthrough. */ + 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) => d === "\r" || kb.matches(d, "tui.select.confirm"), + handler: () => this.selectCurrent(), + }, + { + match: (_d, kb) => kb.matches(_d, "tui.select.cancel"), + handler: () => this.onCancel(), + }, + ]; + + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._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.onSelect = onSelect; + this.onCancel = onCancel; + this.onNotify = onNotify; + + // ── Frame ── + this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + this.headerRow = new FixedRowText( + theme.fg("accent", theme.bold(" Prompt History ")), + ); + this.addChild(this.headerRow); + this.listContainer = new Container(); + this.addChild(this.listContainer); + this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); + this.addChild( + new FixedRowText( + theme.fg("dim", "↑↓ move • enter select and quit • esc cancel"), + true /* centered */, + ), + ); + this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + this.rebuildList(); + } + + // -- List building ------------------------------------------------------- + + /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows. */ + private rebuildListWithWidth(width: number): void { + const count = this.records.length; + const position = count === 0 ? 0 : this.selectedIndex + 1; + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(" Prompt History ")) + + this.theme.fg("dim", ` · ${position} of ${count} `), + ); + this.listContainer.clear(); + + if (count === 0) { + // Defensive: the open-flow guard never opens an empty selector. + 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.records, + this.selectedIndex, + MAX_VISIBLE, + ); + + for (const { record, isSelected } of visible) { + const prefix = isSelected ? "→ " : " "; + const color = isSelected ? "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()); + } + } + + private rebuildList(): void { + this.rebuildListWithWidth(800); + } + + // -- Navigation & selection ---------------------------------------------- + + private moveUp(): void { + this.selectedIndex = moveSelectedIndex( + this.selectedIndex, + this.records.length, + -1, + ); + this.rebuildList(); + } + + private moveDown(): void { + this.selectedIndex = moveSelectedIndex( + this.selectedIndex, + this.records.length, + 1, + ); + this.rebuildList(); + } + + private selectCurrent(): void { + const selected = this.records[this.selectedIndex]; + if (selected) this.onSelect(selected); + } + + // -- Input handling -------------------------------------------------------- + + handleInput(data: string): void { + const kb = getKeybindings(); + for (const { match, handler } of this.dispatch) { + if (match(data, kb)) { + handler(); + this.tui.requestRender(); + return; + } + } + } + + // -- Render --------------------------------------------------------------- + + override render(width: number): string[] { + this.rebuildListWithWidth(width); + return super.render(width); + } +} + +// --------------------------------------------------------------------------- +// Overlay glue +// --------------------------------------------------------------------------- + +async function runPromptHistorySelection( + ctx: Pick, + records: PromptRecord[], +): Promise { + return ctx.ui.custom( + (tui, theme, _keybindings, done) => { + const finish = (result: PromptRecord | null) => done(result); + return new PromptHistorySelector( + tui, + theme, + records, + (record) => finish(record), + () => finish(null), + ); + }, + { + overlay: true, + overlayOptions: { anchor: "bottom-center", width: "100%", offsetY: 5 }, + }, + ); +} + +/** Build selector records from drain entries (shared by both scopes). */ +function recordsFromEntries( + entries: Array, +): PromptRecord[] { + return buildPromptRecords(dedupePromptEntries(entries)); +} + +// --------------------------------------------------------------------------- +// Open flow: capture gate → fail-closed drain → records → overlay +// --------------------------------------------------------------------------- + +type HistoryScope = "project" | "global"; + +/** + * Per-load open flow over the slice-1 deps (env/root/cwd): the selector is + * a pure store reader, so it never initializes the capture writer — the + * capture gate in openHistorySelector runs before any store access and a + * capture-off session performs no registry/writer side effects on the + * open path. + */ +function createOpenFlow(env: NodeJS.ProcessEnv, root: string, cwd: string) { + /** + * 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 apply the fail-closed tombstone + * filter — the state dir is the store root itself (hidden.json contract). + * The DrainResult is returned verbatim: `blocked` must stop the open flow + * before any records build. + */ + function drainForScope(scope: HistoryScope): DrainResult { + return scope === "project" + ? drainProject(root, cwd, 1000, root) + : drainGlobal(root, 1000, root); + } + + async function openHistorySelector( + ctx: Pick, + ): Promise { + // Capture gate (#1390) FIRST: with capture off the selector is a no-op — + // no registry writes, no writer init, no store reads, no overlay. + if (!captureEnabled(env)) { + ctx.ui.notify( + "Prompt history is disabled (GENTLE_PI_HISTORY_CAPTURE is not set).", + "warning", + ); + return; + } + + // Store-only drain (user-directed): the selector reads the store files — + // no live transcript merge. `blocked` (untrusted hidden.json) fails + // CLOSED: surface the recovery message and stop before building records. + const drained = drainForScope("project"); + if (drained.status === "blocked") { + ctx.ui.notify(drained.message, "error"); + return; + } + const entries = drained.prompts; + if (entries.length === 0) { + // a22588fc empty-store policy: no history warns and skips the overlay. + // A later slice changes this, not this one. + ctx.ui.notify("No prompt history available.", "warning"); + return; + } + + 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 (a22588fc). + ctx.ui.pasteToEditor(selected.text); + } + } + + return { openHistorySelector }; +} + export default function promptHistoryExtension( pi: ExtensionAPI, deps: HistoryDeps = {}, @@ -86,4 +491,17 @@ export default function promptHistoryExtension( // the handler - swallow and keep the next prompt capturable. } }); + + // Selector open flow (slice 3, stage 1): both entry points share it. + const { openHistorySelector } = createOpenFlow(env, root, cwd); + + 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/selector-helpers.ts b/extensions/history/selector-helpers.ts index c5257bafe..e0884fcde 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -25,6 +25,19 @@ export interface PromptEntry { ts?: number; } +/** Half-open window of list rows currently rendered (spec: centered cursor). */ +export interface VisibleRange { + start: number; + end: number; +} + +/** One rendered list row: the record, its master index, and cursor state. */ +export interface VisiblePromptRecord { + index: number; + record: PromptRecord; + isSelected: boolean; +} + export function buildPromptRecords( entries: ReadonlyArray, ): PromptRecord[] { @@ -87,6 +100,60 @@ export function dedupePromptEntries( return deduped; } +// --------------------------------------------------------------------------- +// Selector navigation & windowing (open-flow surface; search/paging helpers +// join in later stages) +// --------------------------------------------------------------------------- + +/** Wrapped cursor move: (+/-delta) with modulo wrap over the total. */ +export function moveSelectedIndex( + selectedIndex: number, + total: number, + delta: number, +): number { + if (total === 0) return 0; + return (selectedIndex + delta + total) % total; +} + +/** + * Centered visible window (a22588fc shape): keep the cursor near the middle + * once the list outgrows maxVisible; small lists render in full. + */ +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), + }; +} + +/** The rows to render for the current cursor: sliced, indexed, cursor-flagged. */ +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, + })); +} + const MAX_RESULTS = 10000; export function filterPrompts( diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts new file mode 100644 index 000000000..70a84bc25 --- /dev/null +++ b/tests/history-command-registration.test.ts @@ -0,0 +1,123 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Source-parsing tests (a22588fc port, stage-1 surface): never import +// extensions/history/index.ts — it pulls the pi-tui runtime graph (§D3). + +const sourcePath = fileURLToPath( + new URL("../extensions/history/index.ts", import.meta.url), +); +const source = fs.readFileSync(sourcePath, "utf8"); + +/** The open-flow body: openHistorySelector up to the extension entry point. */ +function openFlowBody(): string { + const start = source.indexOf("async function openHistorySelector("); + assert.ok(start >= 0, "openHistorySelector should exist"); + const end = source.indexOf("export default function", start); + assert.notStrictEqual(end, -1, "extension entry point should follow"); + return source.slice(start, end); +} + +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)", + ); +}); + +test("an empty history warns and skips the overlay (a22588fc empty-store policy)", () => { + const body = openFlowBody(); + assert.ok( + body.includes("entries.length === 0") && + body.includes('"No prompt history available."'), + "an empty history warns and skips the overlay (slice-03 policy; a later slice changes it)", + ); +}); + +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("the ctrl+shift+r shortcut is registered with the shared description", () => { + const index = source.indexOf("pi.registerShortcut(SHORTCUT"); + assert.ok(index >= 0, "pi.registerShortcut(SHORTCUT, ...) should exist"); + + const slice = source.slice(index, index + 200); + assert.ok( + slice.includes('"Search prompt history"'), + "shortcut should carry the shared description", + ); + assert.ok( + slice.includes("openHistorySelector(ctx)"), + "shortcut handler should route through the shared entry point", + ); +}); + +test("the SHORTCUT constant pins ctrl+shift+r", () => { + assert.ok( + source.includes('const SHORTCUT = "ctrl+shift+r";'), + "the shortcut key must stay ctrl+shift+r", + ); +}); + +test("the capture gate precedes every store touch in the open flow (#1390)", () => { + const body = openFlowBody(); + const gateAt = body.indexOf("if (!captureEnabled(env))"); + const drainAt = body.indexOf('drainForScope("project")'); + assert.ok(gateAt >= 0, "the open flow must check captureEnabled first"); + assert.ok( + drainAt > gateAt, + "the drain must run only after the capture gate passes", + ); + assert.ok( + body.includes("GENTLE_PI_HISTORY_CAPTURE"), + "the disabled warning names the capture switch", + ); + // No writer init on the open path: the selector never touches the + // registry or the capture writer — getWriter stays capture-side. + assert.ok( + !body.includes("getWriter()"), + "the open flow must not initialize the capture writer", + ); +}); + +test("a blocked drain stops the open flow with an error and no records", () => { + const body = openFlowBody(); + const blockedAt = body.indexOf('drained.status === "blocked"'); + assert.ok( + blockedAt >= 0, + "the slice-02 DrainResult blocked variant must be handled", + ); + const recordsAt = body.indexOf("recordsFromEntries(entries)"); + assert.ok( + recordsAt > blockedAt, + "records build only after the blocked check (fail-closed)", + ); + assert.ok( + body.includes('ctx.ui.notify(drained.message, "error")'), + "the blocked recovery message surfaces as an error notification", + ); +}); diff --git a/tests/history-openflow-integration.test.ts b/tests/history-openflow-integration.test.ts new file mode 100644 index 000000000..287f35e0e --- /dev/null +++ b/tests/history-openflow-integration.test.ts @@ -0,0 +1,117 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** + * Stage-1 open-flow wiring tests (a22588fc T31/T32 port, adapted to the + * slice-02 DrainResult drain contract). NEVER import + * extensions/history/index.ts — it pulls the pi-tui runtime graph (§D3). + * The wiring is pinned by source-parse (command-registration pattern); + * loader behavior uses fs-only fixtures under the OS temp dir — NEVER the + * user's real ~/.pi/agent/history. + */ + +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); +} + +// --------------------------------------------------------------------------- +// T31 (adapted) — store-only drain wiring over the DrainResult contract. +// --------------------------------------------------------------------------- + +test("T31 (adapted): the store drain is the entries source — DrainResult unwrapped", () => { + const body = openHistorySelectorBody(); + const drainIdx = body.indexOf('const drained = drainForScope("project")'); + assert.ok( + drainIdx >= 0, + "the load step must drain the store through drainForScope", + ); + assert.ok( + !body.includes("mergeHistoryEntries("), + "no live transcript merge in the open flow (store-only scopes)", + ); + assert.ok( + body.includes('drained.status === "blocked"'), + "the DrainResult blocked variant must be unwrapped before any records build", + ); + assert.ok( + body.includes("const entries = drained.prompts"), + "the ok variant feeds the entries list", + ); +}); + +test("T31 (adapted): records are built via recordsFromEntries over the drained entries", () => { + const body = openHistorySelectorBody(); + const recIdx = body.indexOf("recordsFromEntries(entries)"); + assert.ok( + recIdx >= 0, + "records build through the shared recordsFromEntries helper", + ); +}); + +test("T31: the command-registration pins hold beside the drain contract", () => { + 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 drain contract adds no occurrence", + ); +}); + +// --------------------------------------------------------------------------- +// T32 (adapted) — synchronous open-path wiring (no-await source-parse). +// --------------------------------------------------------------------------- + +test("T32 (adapted): the open path never awaits a records build (source-parse)", () => { + const body = openHistorySelectorBody(); + assert.ok( + !body.includes("startBackgroundIndexBuild"), + "no background build kick lives in the selector", + ); + assert.ok( + !/await\s+recordsFromEntries/.test(body), + "the open path never awaits the records build (sync const declaration)", + ); + const drainIdx = body.indexOf('drainForScope("project")'); + const emptyIdx = body.indexOf("entries.length === 0"); + assert.ok( + drainIdx >= 0 && emptyIdx > drainIdx, + "the empty guard follows the drain", + ); +}); + +// --------------------------------------------------------------------------- +// Stage-1 skeleton pins: the minimal openable unit and its policy seams. +// --------------------------------------------------------------------------- + +test("the stage-1 selector skeleton wires cancel through the overlay close", () => { + assert.ok( + indexSource.includes('kb.matches(_d, "tui.select.cancel")'), + "the dispatch table handles cancel", + ); + assert.ok( + indexSource.includes("this.onCancel()"), + "cancel routes to the overlay close callback", + ); +}); + +test("the selection result pastes into the editor via pasteToEditor", () => { + const body = openHistorySelectorBody(); + assert.ok( + body.includes("ctx.ui.pasteToEditor(selected.text)"), + "the selected prompt enters the editor through the paste pipeline (a22588fc)", + ); +}); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index 4128b7eef..b095c18bc 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -36,6 +36,9 @@ function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { on: (event: string, handler: unknown) => { registered.push([event, handler]); }, + // Slice-3 stage-1 surface: registration-time no-ops for this harness. + registerShortcut: () => {}, + registerCommand: () => {}, }; promptHistoryExtension(pi as never, { env, @@ -108,13 +111,17 @@ test("two writers own separate files in the same project dir", () => { test("the slice-1 extension entry registers only the capture handler", () => { // Module load must stay side-effect free (importing index.ts parses the - // whole slice-1 graph without touching the real ~/.pi store root), and - // slice 1 wires exactly one handler: before_agent_start. + // whole slice-1 graph without touching the real ~/.pi store root). The + // slice-1 contract on pi.on events holds: exactly one handler, + // before_agent_start. (Shortcut/command registration is slice-3 wiring + // and is not a pi.on event; the fake below stubs it as no-ops.) const registered: Array<[string, unknown]> = []; const pi = { on: (event: string, handler: unknown) => { registered.push([event, handler]); }, + registerShortcut: () => {}, + registerCommand: () => {}, }; promptHistoryExtension(pi as never); assert.deepEqual( From 87454149d70cdf5b996d4d4bb4387fbcf2c5c633 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:24:42 -0300 Subject: [PATCH 46/49] feat(history): add selector search and list windowing Second of three review units for the selector slice (PR #1395 review): - Search panel: header, hint row, Input with onSubmit/onEscape, forwardToSearch key fallthrough, and filterPrompts applied over the loaded snapshot via loadedCountForQuery. - Lazy windowing: initial batch, grow-before-move prefetch, PgUp/PgDn catch-up, and Home/End jumps (initialLoadedCount, shouldGrowWindow, nextLoadedCount, loadedCountForTarget, clampSelectedIndex, pageSelectedIndex). - Scope toggle between project and global drains over the fail-closed DrainResult contract: blocked drains revert the scope flip and surface the recovery message. Header gains the loaded segment and the scope radio; the overlay runs under withExpandedHistoryGlobals. - Full dispatch table: up/down/pageUp/pageDown/confirm/tab/cancel/ home/end. - Tests: dispatch, lazy-windowing, selector-windowing, and expanded-globals suites ported/adapted to the current contracts. --- extensions/history/index.ts | 395 ++++++++++++++--- extensions/history/selector-helpers.ts | 122 ++++++ tests/history-dispatch.test.ts | 168 ++++++++ tests/history-expanded-globals.test.ts | 62 +++ tests/history-lazy-windowing.test.ts | 515 +++++++++++++++++++++++ tests/history-selector-windowing.test.ts | 94 +++++ 6 files changed, 1305 insertions(+), 51 deletions(-) create mode 100644 tests/history-dispatch.test.ts create mode 100644 tests/history-expanded-globals.test.ts create mode 100644 tests/history-lazy-windowing.test.ts create mode 100644 tests/history-selector-windowing.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 2df8a7b10..b84ed83e0 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1,12 +1,14 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Prompt-history extension entry (slice 3, stage 1): the minimal selector -// open flow over the slice-1 writer and slice-2 drains — /history command, -// ctrl+shift+r shortcut, the capture gate, the fail-closed project drain, -// and a non-search list overlay. Search, paging/preview, mouse, and the -// scope toggle arrive in later stages; deletion (slice 5) and GC -// (slice 6) later still. +// Prompt-history extension entry (slice 3, stage 2): the selector open flow +// over the slice-1 writer and slice-2 drains, now with the search input +// (filterPrompts + forwardToSearch fallthrough), the lazy loaded window +// (initial batch, prefetch growth, PgUp/PgDn, Home/End), the header loaded +// segment, and the project<->global scope toggle under the expanded-globals +// contract. The preview panel, wheel/mouse handling, and the fixed 30-row +// overlay geometry arrive in stage 3; deletion (slice 5) and GC (slice 6) +// later still. // // Capture is OPT-IN while the deletion/privacy behavior is unshipped: // nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=1|true|on. The @@ -28,6 +30,9 @@ import { Container, type Focusable, getKeybindings, + Input, + matchesKey, + Text, type TUI, truncateToWidth, } from "@earendil-works/pi-tui"; @@ -42,15 +47,33 @@ import { } from "./store.ts"; import { buildPromptRecords, + clampSelectedIndex, dedupePromptEntries, + filterPrompts, getVisiblePromptRecords, + initialLoadedCount, + loadedCountForQuery, + loadedCountForTarget, moveSelectedIndex, + nextLoadedCount, + pageSelectedIndex, + shouldGrowWindow, + withExpandedHistoryGlobals, + type PiHistoryGlobals, type PromptEntry, type PromptRecord, } from "./selector-helpers.ts"; const SHORTCUT = "ctrl+shift+r"; const MAX_VISIBLE = 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; /** Width of the "→ " / " " prefix on each entry line. */ const ENTRY_PREFIX_WIDTH = 2; @@ -109,7 +132,7 @@ function sanitizeForDisplay(text: string): string { } // --------------------------------------------------------------------------- -// TUI Selector (stage 1: frame + non-search list) +// TUI Selector (stage 2: search + lazy list + scope toggle) // --------------------------------------------------------------------------- /** Keybinding lookup returned by getKeybindings(). */ @@ -175,19 +198,34 @@ class FixedRowText { } class PromptHistorySelector extends Container implements Focusable { - private readonly headerRow: FixedRowText; + private readonly searchInput: Input; private readonly listContainer: Container; + private readonly headerRow: FixedRowText; private records: PromptRecord[]; private readonly theme: Theme; private readonly tui: TUI; private readonly onSelect: (record: PromptRecord) => void; private readonly onCancel: () => void; - /** Notification sink for selector feedback (wired by the open flow). */ + /** Notification sink for selector feedback (wired by the factory). */ private readonly onNotify?: SelectorNotify; + /** + * Scope drain injectable (slice-02 DrainResult contract): the open flow + * hands the selector its drainForScope so tab can re-drain the other + * scope without the selector touching store paths itself. + */ + private readonly drainScope: (scope: HistoryScope) => DrainResult; + private filteredRecords: PromptRecord[] = []; private selectedIndex = 0; - - /** Dispatch table: first match wins. Unhandled input is ignored — the - * search box joins in a later stage and owns the fallthrough. */ + /** Number of records loaded (newest-first) from the top of `records`. */ + private loadedCount = 0; + /** Active scope (design v2): project (default) or global. */ + private scope: HistoryScope = "project"; + /** Last render width, used for entry truncation. */ + private lastWidth = 800; + + /** Dispatch table: first match wins, fallthrough last. The preview + * ctrl+shift combos join with the preview panel (stage 3) and the + * ctrl+shift+backspace delete entry joins with deletion (slice 5). */ private readonly dispatch: readonly DispatchEntry[] = [ { match: (_d, kb) => kb.matches(_d, "tui.select.up"), @@ -197,14 +235,31 @@ class PromptHistorySelector extends Container implements Focusable { 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(), + }, ]; private _focused = false; @@ -213,6 +268,7 @@ class PromptHistorySelector extends Container implements Focusable { } set focused(value: boolean) { this._focused = value; + this.searchInput.focused = value; } constructor( @@ -222,49 +278,118 @@ class PromptHistorySelector extends Container implements Focusable { onSelect: (record: PromptRecord) => void, onCancel: () => void, onNotify?: SelectorNotify, + drainScope?: (scope: HistoryScope) => DrainResult, ) { 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; + // Default injectable: an empty drain so a bare constructor (tests, + // tooling) never touches the store; the open flow always passes the + // real fail-closed drainForScope. + this.drainScope = drainScope ?? (() => ({ status: "ok", prompts: [] })); - // ── Frame ── + // ── Search panel (top) ── this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); this.headerRow = new FixedRowText( - theme.fg("accent", theme.bold(" Prompt History ")), + 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); + this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); this.addChild( new FixedRowText( - theme.fg("dim", "↑↓ move • enter select and quit • esc cancel"), + theme.fg( + "dim", + "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • esc cancel", + ), true /* centered */, ), ); 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.rebuildList(); } - // -- List building ------------------------------------------------------- + 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.records.length; + const count = this.filteredRecords.length; const position = count === 0 ? 0 : this.selectedIndex + 1; this.headerRow.setText( - this.theme.fg("accent", this.theme.bold(" Prompt History ")) + - this.theme.fg("dim", ` · ${position} of ${count} `), + 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) { - // Defensive: the open-flow guard never opens an empty selector. this.listContainer.addChild( new FixedRowText(this.theme.fg("warning", "No matching prompts")), ); @@ -277,7 +402,7 @@ class PromptHistorySelector extends Container implements Focusable { const entryMax = Math.floor(width * 0.95) - ENTRY_PREFIX_WIDTH; const visible = getVisiblePromptRecords( - this.records, + this.filteredRecords, this.selectedIndex, MAX_VISIBLE, ); @@ -298,53 +423,182 @@ class PromptHistorySelector extends Container implements Focusable { } } - private rebuildList(): void { - this.rebuildListWithWidth(800); + // -- Selection actions -------------------------------------------------- + + private selectCurrent(): void { + const selected = this.filteredRecords[this.selectedIndex]; + if (selected) this.onSelect(selected); } - // -- Navigation & selection ---------------------------------------------- + /** + * Toggle project <-> global (design v2): re-drain the other scope, + * rebuild the records, reset the window. Tab's only role. Fail-closed + * (slice-02 contract): a blocked drain keeps the working scope and + * surfaces the recovery warning instead of a blocked (entry-less) list. + */ + private toggleScope(): void { + const previous = this.scope; + this.scope = this.scope === "project" ? "global" : "project"; + const drained = this.drainScope(this.scope); + if (drained.status === "blocked") { + this.scope = previous; + this.onNotify?.(drained.message, "error"); + return; + } + this.records = recordsFromEntries(drained.prompts); + this.loadedCount = initialLoadedCount(this.records.length, INITIAL_BATCH); + this.applyFilter(this.searchInput.getValue()); + } + + // -- Navigation --------------------------------------------------------- private moveUp(): void { this.selectedIndex = moveSelectedIndex( this.selectedIndex, - this.records.length, + this.filteredRecords.length, -1, ); + 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()); + } this.rebuildList(); } 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. + 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()); + } this.selectedIndex = moveSelectedIndex( this.selectedIndex, - this.records.length, + this.filteredRecords.length, 1, ); this.rebuildList(); } - private selectCurrent(): void { - const selected = this.records[this.selectedIndex]; - if (selected) this.onSelect(selected); + /** 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.rebuildList(); + } + + /** 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.rebuildList(); } - // -- Input handling -------------------------------------------------------- + private jumpToFirst(): void { + if (this.filteredRecords.length === 0) return; + this.selectedIndex = 0; + this.rebuildList(); + } + + 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.rebuildList(); + } + + // -- Input handling ----------------------------------------------------- + + private forwardToSearch(data: string): void { + this.searchInput.handleInput(data); + this.selectedIndex = 0; + this.applyFilter(this.searchInput.getValue()); + } handleInput(data: string): void { const kb = getKeybindings(); + let handled = false; for (const { match, handler } of this.dispatch) { if (match(data, kb)) { handler(); - this.tui.requestRender(); - return; + handled = true; + break; } } + if (!handled) this.forwardToSearch(data); + this.tui.requestRender(); } - // -- Render --------------------------------------------------------------- + // -- Render override for dynamic entry width --------------------------- + + /** Fixed overlay height so the TUI never repositions the panel. Stage-2 + * geometry (search + list, no preview panel); grows to 30 rows when the + * preview panel joins in stage 3. */ + private static readonly OVERLAY_LINES = 18; override render(width: number): string[] { + if (width !== this.lastWidth) { + // Pre-clamp against the previous filter so a width change can never + // drive the rebuild with a stale selection (AC-P1-4.1/4.2). + this.selectedIndex = clampSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + ); + } + this.lastWidth = width; this.rebuildListWithWidth(width); - return super.render(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); } } @@ -352,25 +606,60 @@ class PromptHistorySelector extends Container implements Focusable { // Overlay glue // --------------------------------------------------------------------------- +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]; +} + +function createPromptHistorySelectorFactory( + records: PromptRecord[], + onNotify?: SelectorNotify, + drainScope?: (scope: HistoryScope) => DrainResult, +): SelectorFactory { + return (tui, theme, _keybindings, done) => { + const finish = (result: PromptRecord | null) => done(result); + const [typedTui, typedTheme] = castSelectorArgs(tui, theme); + return new PromptHistorySelector( + typedTui, + typedTheme, + records, + (record) => finish(record), + () => finish(null), + onNotify, + drainScope, + ); + }; +} + async function runPromptHistorySelection( ctx: Pick, records: PromptRecord[], + drainScope?: (scope: HistoryScope) => DrainResult, ): Promise { - return ctx.ui.custom( - (tui, theme, _keybindings, done) => { - const finish = (result: PromptRecord | null) => done(result); - return new PromptHistorySelector( - tui, - theme, + const historyGlobals: PiHistoryGlobals = globalThis as Record< + string, + unknown + >; + return withExpandedHistoryGlobals(historyGlobals, async () => + ctx.ui.custom( + createPromptHistorySelectorFactory( records, - (record) => finish(record), - () => finish(null), - ); - }, - { - overlay: true, - overlayOptions: { anchor: "bottom-center", width: "100%", offsetY: 5 }, - }, + (message, level) => ctx.ui.notify(message, level), + drainScope, + ), + { + overlay: true, + overlayOptions: { anchor: "bottom-center", width: "100%", offsetY: 5 }, + }, + ), ); } @@ -401,7 +690,7 @@ function createOpenFlow(env: NodeJS.ProcessEnv, root: string, cwd: string) { * dirs + the legacy global seed). Both apply the fail-closed tombstone * filter — the state dir is the store root itself (hidden.json contract). * The DrainResult is returned verbatim: `blocked` must stop the open flow - * before any records build. + * before any records build, and the selector's scope toggle surfaces it. */ function drainForScope(scope: HistoryScope): DrainResult { return scope === "project" @@ -439,7 +728,11 @@ function createOpenFlow(env: NodeJS.ProcessEnv, root: string, cwd: string) { } const records = recordsFromEntries(entries); - const selected = await runPromptHistorySelection(ctx, records); + const selected = await runPromptHistorySelection( + ctx, + records, + drainForScope, + ); if (selected) { // pasteToEditor routes through the editor's input pipeline (bracketed // paste), so the text renders immediately (a22588fc). @@ -492,7 +785,7 @@ export default function promptHistoryExtension( } }); - // Selector open flow (slice 3, stage 1): both entry points share it. + // Selector open flow (slice 3, stage 2): both entry points share it. const { openHistorySelector } = createOpenFlow(env, root, cwd); pi.registerShortcut(SHORTCUT, { diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index e0884fcde..a50796cf2 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -38,6 +38,11 @@ export interface VisiblePromptRecord { isSelected: boolean; } +export interface PiHistoryGlobals { + __piHistoryExpand?: () => void; + __piHistoryTrim?: () => void; +} + export function buildPromptRecords( entries: ReadonlyArray, ): PromptRecord[] { @@ -59,6 +64,21 @@ export function buildPromptRecords( }); } +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))); +} + /** * Normalization key for read-time dedup (spec C3): byte-matches the * APPLIED patch key in nav/patches/editor.cjs (:480-:586) — whitespace @@ -115,6 +135,15 @@ export function moveSelectedIndex( 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); +} + /** * Centered visible window (a22588fc shape): keep the cursor near the middle * once the list outgrows maxVisible; small lists render in full. @@ -154,6 +183,87 @@ export function getVisiblePromptRecords( })); } +// --------------------------------------------------------------------------- +// Lazy windowing (spec C1/C2, design §D3/§D4) and search visibility +// --------------------------------------------------------------------------- + +/** + * 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; +} + +/** + * 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( @@ -170,3 +280,15 @@ export function filterPrompts( return filtered.slice(0, MAX_RESULTS); } + +export async function withExpandedHistoryGlobals( + globals: PiHistoryGlobals, + run: () => Promise, +): Promise { + globals.__piHistoryExpand?.(); + try { + return await run(); + } finally { + globals.__piHistoryTrim?.(); + } +} diff --git a/tests/history-dispatch.test.ts b/tests/history-dispatch.test.ts new file mode 100644 index 000000000..1c9e47b43 --- /dev/null +++ b/tests/history-dispatch.test.ts @@ -0,0 +1,168 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** + * 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 extensions/history/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 9 explicit entries in a fixed order (the ctrl+shift+up/down + * preview entries join with the preview panel in stage 3 and the + * ctrl+shift+backspace delete entry joins with deletion in slice 5), 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")', +]; + +/** 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()", +]; + +function dispatchTable(): string { + const start = source.indexOf(DISPATCH_DECL); + assert.notStrictEqual( + start, + -1, + "dispatch table declaration should exist in extensions/history/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 9 explicit match: entries (AC-P2-4.1)", () => { + const table = dispatchTable(); + const matchCount = table.split("match:").length - 1; + assert.strictEqual( + matchCount, + 9, + `expected 9 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 with clamping (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", + ); + 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", + ); + }); + + it("runs the 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, "end")'), + "the final table entry must be the end key (preview combos join in stage 3)", + ); + 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-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-lazy-windowing.test.ts b/tests/history-lazy-windowing.test.ts new file mode 100644 index 000000000..429ee3acd --- /dev/null +++ b/tests/history-lazy-windowing.test.ts @@ -0,0 +1,515 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +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 extensions/history/index.ts: +// +// 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 does in the selector — 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 extensions/history/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). +// +// Slice-3 adaptation note: upstream wires the growth trigger INLINE in +// moveUp/moveDown (no shared growLoadedWindowIfNeeded helper — that shape is +// dev-repo drift). The pins below assert the same AC contracts against the +// inline form. + +function methodBodyOf(name: string): string { + const decl = selectorSource.indexOf(`private ${name}(`); + assert.ok(decl >= 0, `private ${name}() should exist in extensions/history/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 (moveUp carries the older-direction growth check); every other +// 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("shouldGrowWindow("), + "moveDown must evaluate the C2 trigger", + ); + assert.ok( + down.includes("nextLoadedCount("), + "moveDown must grow via nextLoadedCount", + ); + const up = methodBodyOf("moveUp"); + assert.ok( + up.includes("shouldGrowWindow(") && up.includes("nextLoadedCount("), + "moveUp must carry the older-direction growth check", + ); + 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("shouldGrowWindow("); + assert.notEqual(growAt, -1, "moveDown must evaluate the C2 trigger"); + 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("shouldGrowWindow(") && + downGrow.includes("nextLoadedCount("), + "moveDown's growth region must run the trigger + one batch before the modulo", + ); + assert.ok( + !downGrow.includes("filteredRecords"), + "moveDown's pre-modulo region must read UNFILTERED counts only", + ); + const page = methodBodyOf("pageListDown"); + const pageGrow = page.slice(0, page.indexOf("pageSelectedIndex(")); + assert.ok( + pageGrow.includes("loadedCountForTarget(") && + 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 +// the fixed overlay height stays intact. Stage-2 adaptation: the overlay is +// 18 rows (search + list; the preview panel's 12 more rows join in stage 3, +// where the ratified 30-row geometry completes), and the constructor holds +// 9 children instead of the final 12. + +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 = 18;"), + "the stage-2 fixed overlay height (18 rows) 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, + 9, + "the stage-2 constructor child sequence is unchanged (search + list, no preview yet)", + ); +}); + +// 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-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, + }, + ], + ); +}); From 6ad619165f7a934234dbd7b7eec5c19542bf540e Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:33:48 -0300 Subject: [PATCH 47/49] feat(history): add selector preview and mouse handling Third and final review unit for the selector slice (PR #1395 review): - Preview panel: PREVIEW_ROWS viewport, word-wrapped prompt text with SGR-safe padding, range label, preview scroll with ctrl+shift+up/down completing the 11-entry dispatch table, and offset resets on every list navigation. - Mouse: wheel-only handling with consumed-event routing and region constants (list 5-14, preview 17-26), sign-clamped list wheel through moveDown/moveUp and one-clamped-line preview wheel. - Completed 30-row overlay geometry; overlay glue: selectorTui capture, activeOverlayClose on tool_call, and the post-paste render flush. - Tests: preview-layout and wheel-mouse suites ported byte-exact; dispatch suite restored to the full 11-entry original; lazy-windowing geometry pins updated to the ratified overlay shape. --- extensions/history/index.ts | 257 +++++++++++++++++++++++++-- tests/history-dispatch.test.ts | 33 ++-- tests/history-lazy-windowing.test.ts | 15 +- tests/history-preview-layout.test.ts | 93 ++++++++++ tests/history-session-writer.test.ts | 16 +- tests/history-wheel-mouse.test.ts | 242 +++++++++++++++++++++++++ 6 files changed, 610 insertions(+), 46 deletions(-) create mode 100644 tests/history-preview-layout.test.ts create mode 100644 tests/history-wheel-mouse.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index b84ed83e0..50304bcda 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1,14 +1,13 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Prompt-history extension entry (slice 3, stage 2): the selector open flow -// over the slice-1 writer and slice-2 drains, now with the search input +// Prompt-history extension entry (slice 3, stage 3): the selector open flow +// over the slice-1 writer and slice-2 drains, with the search input // (filterPrompts + forwardToSearch fallthrough), the lazy loaded window // (initial batch, prefetch growth, PgUp/PgDn, Home/End), the header loaded -// segment, and the project<->global scope toggle under the expanded-globals -// contract. The preview panel, wheel/mouse handling, and the fixed 30-row -// overlay geometry arrive in stage 3; deletion (slice 5) and GC (slice 6) -// later still. +// segment, the project<->global scope toggle under the expanded-globals +// contract, and the preview panel + wheel handling over the fixed 30-row +// overlay geometry. Deletion (slice 5) and GC (slice 6) arrive later. // // Capture is OPT-IN while the deletion/privacy behavior is unshipped: // nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=1|true|on. The @@ -34,6 +33,7 @@ import { matchesKey, Text, type TUI, + type TuiMouseEvent, truncateToWidth, } from "@earendil-works/pi-tui"; import { @@ -47,6 +47,7 @@ import { } from "./store.ts"; import { buildPromptRecords, + clampPreviewOffset, clampSelectedIndex, dedupePromptEntries, filterPrompts, @@ -66,6 +67,7 @@ import { 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 @@ -74,6 +76,13 @@ const MAX_VISIBLE = 10; 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; /** Width of the "→ " / " " prefix on each entry line. */ const ENTRY_PREFIX_WIDTH = 2; @@ -132,7 +141,7 @@ function sanitizeForDisplay(text: string): string { } // --------------------------------------------------------------------------- -// TUI Selector (stage 2: search + lazy list + scope toggle) +// TUI Selector (stage 3: search + lazy list + scope toggle + preview + wheel) // --------------------------------------------------------------------------- /** Keybinding lookup returned by getKeybindings(). */ @@ -197,10 +206,41 @@ class FixedRowText { } } +/** 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 : [""]; +} + 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 records: PromptRecord[]; private readonly theme: Theme; private readonly tui: TUI; @@ -222,9 +262,12 @@ class PromptHistorySelector extends Container implements Focusable { private scope: HistoryScope = "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; - /** Dispatch table: first match wins, fallthrough last. The preview - * ctrl+shift combos join with the preview panel (stage 3) and the + /** Dispatch table: first match wins, fallthrough last. The * ctrl+shift+backspace delete entry joins with deletion (slice 5). */ private readonly dispatch: readonly DispatchEntry[] = [ { @@ -260,6 +303,14 @@ class PromptHistorySelector extends Container implements Focusable { match: (d, _kb) => matchesKey(d, "end"), handler: () => this.jumpToLast(), }, + { + 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; @@ -318,6 +369,15 @@ class PromptHistorySelector extends Container implements Focusable { 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.addChild( new FixedRowText( @@ -352,7 +412,9 @@ class PromptHistorySelector extends Container implements Focusable { this.selectedIndex, this.filteredRecords.length, ); + this.previewScrollOffset = 0; this.rebuildList(); + this.rebuildPreview(); } private rebuildList(): void { @@ -423,6 +485,68 @@ class PromptHistorySelector extends Container implements Focusable { } } + /** + * 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 { @@ -473,7 +597,9 @@ class PromptHistorySelector extends Container implements Focusable { ); this.applyFilter(this.searchInput.getValue()); } + this.previewScrollOffset = 0; this.rebuildList(); + this.rebuildPreview(); } private moveDown(): void { @@ -501,7 +627,9 @@ class PromptHistorySelector extends Container implements Focusable { this.filteredRecords.length, 1, ); + this.previewScrollOffset = 0; this.rebuildList(); + this.rebuildPreview(); } /** Page the LIST up by MAX_VISIBLE with clamping (no wrap). */ @@ -511,7 +639,9 @@ class PromptHistorySelector extends Container implements Focusable { this.filteredRecords.length, -MAX_VISIBLE, ); + this.previewScrollOffset = 0; this.rebuildList(); + this.rebuildPreview(); } /** Page the LIST down by MAX_VISIBLE with clamping (no wrap). */ @@ -533,13 +663,34 @@ class PromptHistorySelector extends Container implements Focusable { 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 { @@ -551,7 +702,9 @@ class PromptHistorySelector extends Container implements Focusable { } if (this.filteredRecords.length === 0) return; this.selectedIndex = this.filteredRecords.length - 1; + this.previewScrollOffset = 0; this.rebuildList(); + this.rebuildPreview(); } // -- Input handling ----------------------------------------------------- @@ -576,24 +729,73 @@ class PromptHistorySelector extends Container implements Focusable { 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; + 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. Stage-2 - * geometry (search + list, no preview panel); grows to 30 rows when the - * preview panel joins in stage 3. */ - private static readonly OVERLAY_LINES = 18; + /** 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 filter so a width change can never - // drive the rebuild with a stale selection (AC-P1-4.1/4.2). + // 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)); @@ -619,13 +821,25 @@ function castSelectorArgs(tui: unknown, theme: unknown): [TUI, Theme] { return [tui as TUI, theme as Theme]; } +/** TUI handle captured when the selector overlay mounts. */ +let selectorTui: { requestRender(): void } | null = null; + +/** Stored close callback for the currently-open overlay. Null when closed. */ +let activeOverlayClose: (() => void) | null = null; + function createPromptHistorySelectorFactory( records: PromptRecord[], onNotify?: SelectorNotify, drainScope?: (scope: HistoryScope) => DrainResult, ): SelectorFactory { return (tui, theme, _keybindings, done) => { - const finish = (result: PromptRecord | null) => done(result); + 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); return new PromptHistorySelector( typedTui, @@ -737,6 +951,9 @@ function createOpenFlow(env: NodeJS.ProcessEnv, root: string, cwd: string) { // pasteToEditor routes through the editor's input pipeline (bracketed // paste), so the text renders immediately (a22588fc). 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); } } @@ -785,7 +1002,13 @@ export default function promptHistoryExtension( } }); - // Selector open flow (slice 3, stage 2): both entry points share it. + // 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?.(); + }); + + // Selector open flow (slice 3, stage 3): both entry points share it. const { openHistorySelector } = createOpenFlow(env, root, cwd); pi.registerShortcut(SHORTCUT, { diff --git a/tests/history-dispatch.test.ts b/tests/history-dispatch.test.ts index 1c9e47b43..575a4d5a1 100644 --- a/tests/history-dispatch.test.ts +++ b/tests/history-dispatch.test.ts @@ -10,10 +10,9 @@ import { fileURLToPath } from "node:url"; * PromptHistorySelector is private to extensions/history/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 9 explicit entries in a fixed order (the ctrl+shift+up/down - * preview entries join with the preview panel in stage 3 and the - * ctrl+shift+backspace delete entry joins with deletion in slice 5), then - * the implicit forwardToSearch fallthrough inside handleInput. + * exactly 11 explicit entries in a fixed order (the ctrl+shift+backspace + * delete entry joins with deletion in slice 5), then the implicit + * forwardToSearch fallthrough inside handleInput. */ const sourcePath = fileURLToPath( @@ -35,6 +34,8 @@ const EXPECTED_MATCHERS = [ 'kb.matches(_d, "tui.select.cancel")', 'matchesKey(d, "home")', 'matchesKey(d, "end")', + 'matchesKey(d, "ctrl+shift+up")', + 'matchesKey(d, "ctrl+shift+down")', ]; /** Handler each entry must invoke (searched within the entry's body). */ @@ -48,6 +49,8 @@ const EXPECTED_HANDLERS = [ "this.onCancel()", "this.jumpToFirst()", "this.jumpToLast()", + "this.previewPageUp()", + "this.previewPageDown()", ]; function dispatchTable(): string { @@ -81,13 +84,13 @@ function methodBody(name: string): string { } describe("dispatch table (source-parsed, §B2)", () => { - it("has exactly 9 explicit match: entries (AC-P2-4.1)", () => { + it("has exactly 11 explicit match: entries (AC-P2-4.1)", () => { const table = dispatchTable(); const matchCount = table.split("match:").length - 1; assert.strictEqual( matchCount, - 9, - `expected 9 explicit entries, found ${matchCount}`, + 11, + `expected 11 explicit entries, found ${matchCount}`, ); }); @@ -120,7 +123,7 @@ describe("dispatch table (source-parsed, §B2)", () => { }); }); - it("pages the LIST via pageSelectedIndex with clamping (AC-P2-1.3)", () => { + it("pages the LIST via pageSelectedIndex and resets the preview offset (AC-P2-1.3)", () => { const up = methodBody("pageListUp"); assert.ok( up.includes("pageSelectedIndex("), @@ -130,6 +133,10 @@ describe("dispatch table (source-parsed, §B2)", () => { 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("), @@ -139,14 +146,18 @@ describe("dispatch table (source-parsed, §B2)", () => { 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 combos before the implicit fallthrough (AC-P2-2.1)", () => { + 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, "end")'), - "the final table entry must be the end key (preview combos join in stage 3)", + 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) {", diff --git a/tests/history-lazy-windowing.test.ts b/tests/history-lazy-windowing.test.ts index 429ee3acd..5ee40838a 100644 --- a/tests/history-lazy-windowing.test.ts +++ b/tests/history-lazy-windowing.test.ts @@ -453,10 +453,7 @@ test("applyFilter windows the loaded prefix and grows only via loadedCountForQue // 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 -// the fixed overlay height stays intact. Stage-2 adaptation: the overlay is -// 18 rows (search + list; the preview panel's 12 more rows join in stage 3, -// where the ratified 30-row geometry completes), and the constructor holds -// 9 children instead of the final 12. +// 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"); @@ -486,19 +483,15 @@ test("the header keeps the position segment plus the loaded suffix on the existi "the suffix adds no addChild call — today's 4 list-row sites unchanged", ); assert.ok( - selectorSource.includes("private static readonly OVERLAY_LINES = 18;"), - "the stage-2 fixed overlay height (18 rows) must stay intact", + 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, - 9, - "the stage-2 constructor child sequence is unchanged (search + list, no preview yet)", - ); + assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); }); // T14 — AC-L2-3 revision (user-directed 2026-09-08): a non-empty query 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-session-writer.test.ts b/tests/history-session-writer.test.ts index b095c18bc..de220b781 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -109,12 +109,13 @@ test("two writers own separate files in the same project dir", () => { assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); }); -test("the slice-1 extension entry registers only the capture handler", () => { +test("the extension entry registers the capture handler and the overlay dismiss", () => { // Module load must stay side-effect free (importing index.ts parses the // whole slice-1 graph without touching the real ~/.pi store root). The - // slice-1 contract on pi.on events holds: exactly one handler, - // before_agent_start. (Shortcut/command registration is slice-3 wiring - // and is not a pi.on event; the fake below stubs it as no-ops.) + // pi.on surface is exactly two handlers: before_agent_start (slice-1 + // capture) and tool_call (slice-3 stage-3 overlay dismissal). + // (Shortcut/command registration is slice-3 wiring and is not a pi.on + // event; the fake below stubs it as no-ops.) const registered: Array<[string, unknown]> = []; const pi = { on: (event: string, handler: unknown) => { @@ -126,10 +127,11 @@ test("the slice-1 extension entry registers only the capture handler", () => { promptHistoryExtension(pi as never); assert.deepEqual( registered.map(([event]) => event), - ["before_agent_start"], + ["before_agent_start", "tool_call"], ); - // The handler is callable but is NEVER invoked here: a real invocation - // would run getWriter() against the user's real ~/.pi/agent/history. + // The capture handler is callable but is NEVER invoked here: a real + // invocation would run getWriter() against the user's real + // ~/.pi/agent/history. assert.equal(typeof registered[0][1], "function"); }); diff --git a/tests/history-wheel-mouse.test.ts b/tests/history-wheel-mouse.test.ts new file mode 100644 index 000000000..37ddcd6b6 --- /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 11 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, + 11, + "wheel is not a keybinding: exactly the 11 §B2 dispatch entries, no extra", + ); +}); + +// 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", + ); +}); From e30233751f94c6a9e07045d65a2f8d20e2878d64 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:22:04 -0300 Subject: [PATCH 48/49] fix(history): modal delete confirm, read-only session rows, GENTLE_PI_HISTORY_ENABLE, hidden.json cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework of the delete affordance per the slice-5 review decisions: - Two-step modal: ctrl+shift+backspace arms the selected row with the footer copy; while armed ONLY y/Y (execute), n/N and Esc (cancel) are honored — every other key is swallowed and stays armed, so nothing is ever typed into the search input. Esc while armed cancels the confirmation without closing the overlay. Wheel still disarms then scrolls. - Session-derived rows are read-only: the delete key on a session row is a silent no-op — session transcripts are immutable input owned by Pi core, so no hide, no tombstone, no store write comes from them. - Confirm copy: "Delete this prompt from history (y/n)? Prompt stays in session log" (single variant; the per-provenance distinction lives in docs/prompt-history.md). - Env rename: GENTLE_PI_HISTORY_CAPTURE → GENTLE_PI_HISTORY_ENABLE (captureEnabled, open-flow warning, docs, tests). - hidden.json retention: capped at HIDE_FILE_MAX_ENTRIES = 1000 in insertion order (newest last) — re-hiding refreshes recency, past the cap the oldest entries drop; .sort() removed; the fail-closed reader is unchanged, so the tombstone still applies to seeded copies and no prompt is permanently fixed. - docs/prompt-history.md: §Delete rewritten (modal, read-only session rows, verbatim failure toasts, cap); env renamed across all sections; stale "deletion UI is not shipped yet" sentences fixed. --- docs/prompt-history.md | 79 ++--- extensions/history/hide-prompts.ts | 44 ++- extensions/history/index.ts | 99 ++++-- extensions/history/selector-helpers.ts | 69 +++-- tests/history-delete-backfill.test.ts | 52 ++-- tests/history-delete-confirm.test.ts | 414 ++++++++++++++----------- tests/history-hide-prompts.test.ts | 87 +++++- tests/history-off-path.test.ts | 4 +- tests/history-session-writer.test.ts | 20 +- 9 files changed, 536 insertions(+), 332 deletions(-) diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 6a62d81e8..d5ea5734b 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -1,17 +1,17 @@ # Prompt history -Slice 1 of the prompt-history extension (#819 split) ships the storage layer only: -a per-instance JSONL capture store, project identity, and the read/write -primitives later slices build on. The selector UI, deletion/scope drains, and GC -arrive in later slices of the chain. +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, and the -deletion UI is not shipped yet, so nothing is stored unless you explicitly 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_CAPTURE=1 pi +GENTLE_PI_HISTORY_ENABLE=1 pi ``` - Enabled by `1`, `true`, or `on` (case-insensitive). Unset, empty, or any other @@ -51,7 +51,8 @@ cwd; `` is a per-process UUID. Each line is one delivered prompt: ``` UI command-like prompts (`/name ...`) and empty lines are never stored. Later -slices add the rebuildable `seed.jsonl`, scope drains/deletes, and GC. +slices added the rebuildable `seed.jsonl` and the scope drains/deletes behind +the selector; GC is still to come. ## Who can read them @@ -64,41 +65,49 @@ 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 or -the deletion UI ships. To erase the store manually while capture is off (or pi -is not running): +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 vs hide +## Delete -The selector's delete key (`ctrl+shift+backspace`) is a two-step -confirmation: the first press **arms** the delete for the selected row and -shows what it will do in the footer (the row highlights); the second press -executes it. Any other key or cancel disarms without deleting. +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 physically: every copy is removed from the store in one atomic - rewrite per affected file. -- **Session-derived prompts** (seeded from past transcripts) can only be - hidden: session transcripts are immutable, so the delete writes a - **tombstone** (`hidden.json`) that keeps the prompt out of the list. The - original stays in the transcript file. - -Both flows therefore end with a tombstone — otherwise the next merge would -re-supply the prompt from transcripts. Write 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. - -The tombstone file 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). + 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). diff --git a/extensions/history/hide-prompts.ts b/extensions/history/hide-prompts.ts index 6d91a57d6..08d39eb7e 100644 --- a/extensions/history/hide-prompts.ts +++ b/extensions/history/hide-prompts.ts @@ -9,6 +9,14 @@ 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 @@ -50,6 +58,8 @@ export type HiddenRead = * 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; @@ -91,13 +101,20 @@ export function readHiddenPrompts(stateDir: string): HiddenRead { * 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 set compacts on write and persists as a SORTED - * array 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. + * 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); @@ -105,10 +122,19 @@ export function hidePrompt(stateDir: string, text: string): HideResult { // Refuse without writing: never reset the untrusted state silently. return { status: "error", message: read.message }; } - read.keys.add(promptDedupKey(text)); + // 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), - [...read.keys].sort(), + ordered, ); return written ? { status: "written" } diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 240a2ead7..4f91729a7 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -7,11 +7,11 @@ // inside getWriter). Deletion (slice 5) is wired here; GC/compaction // (slice 6) arrives in a later slice. // -// Capture is OPT-IN while the deletion/privacy behavior is unshipped: -// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=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). +// 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 { join } from "node:path"; import { homedir } from "node:os"; @@ -43,7 +43,7 @@ import { clampPreviewOffset, clampSelectedIndex, deleteConfirmFooterText, - deleteConfirmNext, + deleteConfirmStep, deletionActionsFor, dedupePromptEntries, EDITOR_HIDE_FAILED_TEXT, @@ -93,7 +93,7 @@ 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 scope-aware confirmation copy. +// 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"; @@ -281,9 +281,10 @@ class PromptHistorySelector extends Container implements Focusable { /** Scroll offset into wrappedPreviewLines for the preview viewport. */ private previewScrollOffset = 0; /** - * Two-step delete confirmation (PR #1393): armed by the first - * ctrl+shift+backspace press; the second press executes, and any other - * key or cancel disarms. Nothing is deleted on the arming press. + * 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 confirmArmed = false; @@ -591,25 +592,45 @@ class PromptHistorySelector extends Container implements Focusable { this.applyFilter(this.searchInput.getValue()); } - /** Delete the currently selected prompt from disk and refresh the list. */ + /** + * 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; - // Two-step confirm (PR #1393 review): the first ctrl+shift+backspace - // press ARMS the delete for the selected row — a scope-aware - // confirmation line in the footer plus an error-colored highlight — - // and executes NOTHING; the SECOND press runs the flow below. Any - // other key or cancel disarms (handleInput / handleMouse). - const step = deleteConfirmNext(this.confirmArmed, true); - this.confirmArmed = step.armed; - this.refreshDeleteFooter(); - if (!step.execute) { - this.rebuildList(); // repaint the armed-row highlight + // 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"); @@ -663,16 +684,14 @@ class PromptHistorySelector extends Container implements Focusable { this.applyFilter(this.searchInput.getValue()); } - /** Footer line: scope-aware confirm copy while armed, help otherwise. */ + /** 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; } - const selected = this.filteredRecords[this.selectedIndex]; - const source = selected?.source ?? "editor"; this.footerRow.setText( - this.theme.fg("warning", deleteConfirmFooterText(source)), + this.theme.fg("warning", deleteConfirmFooterText()), ); } @@ -825,12 +844,24 @@ class PromptHistorySelector extends Container implements Focusable { } handleInput(data: string): void { - const kb = getKeybindings(); - // Any key other than the delete combo disarms a pending confirmation - // (PR #1393) BEFORE its own action runs — esc, arrows, typing, tab. - if (this.confirmArmed && !matchesKey(data, "ctrl+shift+backspace")) { - this.disarmDeleteConfirm(); + // 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)) { @@ -1062,7 +1093,7 @@ async function openHistorySelector( // 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_CAPTURE=1 to enable it.", + "Prompt history capture is off — set GENTLE_PI_HISTORY_ENABLE=1 to enable it.", "warning", ); return; @@ -1114,13 +1145,13 @@ export interface HistoryDeps { } /** - * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_CAPTURE is + * 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 until the deletion tooling lands. + * 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_CAPTURE?.trim().toLowerCase(); + const value = env.GENTLE_PI_HISTORY_ENABLE?.trim().toLowerCase(); return value === "1" || value === "true" || value === "on"; } diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index 54364bd86..e26b0fa48 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -240,11 +240,13 @@ export function loadedCountAfterDelete( * 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" 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 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. */ export function deletionActionsFor( source: PromptSource, @@ -252,45 +254,58 @@ export function deletionActionsFor( if (source === "editor") { return { deleteFromEditorStore: true, writeTombstone: true }; } - return { deleteFromEditorStore: false, writeTombstone: true }; + return { deleteFromEditorStore: false, writeTombstone: false }; } /** - * One transition of the two-step delete confirmation (PR #1393 review): - * the first delete-key press ARMS the delete for the selected row and - * executes nothing; the SECOND press executes; any other key disarms. The - * selector's deleteCurrent and handleInput both route through this pure - * step so the arm/execute/disarm machine has exactly one definition. + * 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 on the second delete-key press — the executing press. */ + /** 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 deleteConfirmNext( +export function deleteConfirmStep( armed: boolean, isDeleteKey: boolean, + isEscapeKey: boolean, + data: string, ): DeleteConfirmStep { - if (!isDeleteKey) return { armed: false, execute: false }; - if (armed) return { armed: false, execute: true }; - return { armed: true, execute: false }; + 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 }; } /** - * Scope-aware confirmation copy shown in the footer while a delete is - * armed (PR #1393): the two provenances have different semantics and the - * copy must say which one is about to run, in one line. Editor-stored - * prompts are removed from the store physically AND hidden from history; - * session-derived prompts can only be hidden (transcripts are immutable), - * so the original stays in the session transcript. + * 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(source: PromptSource): string { - if (source === "editor") { - return "Delete stored prompt? Removes every copy from the store and hides it from history. Session transcripts keep the original."; - } - return "Hide from history? The original stays in the session transcript; a tombstone keeps it out of this list."; +export function deleteConfirmFooterText(): string { + return "Delete this prompt from history (y/n)? Prompt stays in session log"; } /** diff --git a/tests/history-delete-backfill.test.ts b/tests/history-delete-backfill.test.ts index e5fcd3bd8..5abd6c1ad 100644 --- a/tests/history-delete-backfill.test.ts +++ b/tests/history-delete-backfill.test.ts @@ -47,14 +47,14 @@ test("loadedCountAfterDelete bottoms out at 0 on the terminal delete (AC-L4-3)", // 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 deleteCurrent (a delete implies a selected row inside the +// 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 deleteCurrent +// 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 @@ -65,11 +65,11 @@ const selectorSource = fs.readFileSync( "utf8", ); -test("deleteCurrent splices, backfills, then re-filters — inside the guarded block (AC-L4-1, AC-L4-4)", () => { - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); +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, "deleteCurrent's body should close"); + assert.ok(end > decl, "executeDelete's body should close"); const body = selectorSource.slice(decl, end); const earlyReturnAt = body.indexOf("if (removed === 0) return;"); @@ -114,19 +114,21 @@ test("deleteCurrent splices, backfills, then re-filters — inside the guarded b ); }); -// Slice 5 scenario pins (porting contract): the tombstone-always rule and -// the partial-failure toast path. The dev suite pins the planner + these -// deleteCurrent branch shapes in hide-prompts.test.ts (T27/T28); this file +// 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. - -test("deletionActionsFor always plans a tombstone — session provenance deletes nothing from disk", () => { - // Session/seed-born records: tombstone ONLY (transcripts and the seed are - // never rewritten by a delete) — the tombstone is what keeps the deleted - // prompt from resurfacing on the next drain. +// 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: true, + writeTombstone: false, }); // Editor records: disk delete AND tombstone (twin suppression). assert.deepEqual(deletionActionsFor("editor"), { @@ -134,15 +136,15 @@ test("deletionActionsFor always plans a tombstone — session provenance deletes writeTombstone: true, }); - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); + const decl = selectorSource.indexOf("private executeDelete(): void {"); + assert.ok(decl >= 0, "executeDelete should exist"); const end = selectorSource.indexOf("\n }", decl); - assert.ok(end > decl, "deleteCurrent's body should close"); + assert.ok(end > decl, "executeDelete's body should close"); const body = selectorSource.slice(decl, end); - // Branch shape: the tombstone write sits OUTSIDE the editor-store guard — - // every provenance lands a tombstone, so an entry that came from the - // seed or a transcript cannot resurface after its delete. + // 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); @@ -156,10 +158,10 @@ test("deletionActionsFor always plans a tombstone — session provenance deletes }); test("a failed hide toasts and only the session path aborts — the editor path still splices", () => { - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); + const decl = selectorSource.indexOf("private executeDelete(): void {"); + assert.ok(decl >= 0, "executeDelete should exist"); const end = selectorSource.indexOf("\n }", decl); - assert.ok(end > decl, "deleteCurrent's body should close"); + assert.ok(end > decl, "executeDelete's body should close"); const body = selectorSource.slice(decl, end); const gateAt = body.indexOf('if (hide.status === "error")'); diff --git a/tests/history-delete-confirm.test.ts b/tests/history-delete-confirm.test.ts index fe828f256..c755364d7 100644 --- a/tests/history-delete-confirm.test.ts +++ b/tests/history-delete-confirm.test.ts @@ -4,75 +4,93 @@ import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { deleteConfirmFooterText, - deleteConfirmNext, + deleteConfirmStep, deletionActionsFor, EDITOR_HIDE_FAILED_TEXT, STORE_DELETE_FAILED_TEXT, } from "../extensions/history/selector-helpers.ts"; -// PR #1393 review-fix tests: the two-step delete confirmation in the -// history selector. The first ctrl+shift+backspace press ARMS the delete -// for the selected row (scope-aware confirmation footer + highlighted -// record) and executes NOTHING; the second press executes the -// deletionActionsFor-driven flow; any other key or cancel disarms. +// 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 confirmation -// DECISION is factored into pure helpers tested here directly, and the -// execution semantics are pinned by source-parse on deleteCurrent -// (delete-backfill.test.ts discipline). No test in this file touches the -// user's real store. +// 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 decision machine: arm → execute, disarm on anything else. +// Pure modal router: arm → y executes / n·Esc cancels / rest swallowed. // --------------------------------------------------------------------------- -test("the first delete press arms only — nothing executes (PR #1393)", () => { - assert.deepEqual(deleteConfirmNext(false, true), { - armed: true, - execute: false, - }); +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("the second delete press executes and rearms-to-idle (PR #1393)", () => { - assert.deepEqual(deleteConfirmNext(true, true), { - armed: false, - execute: true, - }); +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("any other key disarms without executing; an idle stay stays idle", () => { - assert.deepEqual(deleteConfirmNext(true, false), { - armed: false, - execute: false, - }); - assert.deepEqual(deleteConfirmNext(false, false), { - armed: false, - execute: false, - }); +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("after an executed delete the machine is idle again — a fresh confirm per row", () => { - const first = deleteConfirmNext(false, true); - assert.equal(first.execute, false); - const second = deleteConfirmNext(first.armed, true); - assert.equal(second.execute, true); - // A THIRD press starts a NEW confirmation instead of executing blindly. - assert.deepEqual(deleteConfirmNext(second.armed, true), { - armed: true, - execute: false, - }); +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); }); -// (b) + (c): the executing press composes with the pure planner — an -// editor-source record deletes from the store AND tombstones; a -// session-source record NEVER plans a store delete (tombstone only). +// 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("second press executes the editor-source plan: store delete + tombstone", () => { - const armed = deleteConfirmNext(false, true); - const step = deleteConfirmNext(armed.armed, true); +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, @@ -80,38 +98,23 @@ test("second press executes the editor-source plan: store delete + tombstone", ( }); }); -test("a session-source record never plans a store delete — tombstone only", () => { - const armed = deleteConfirmNext(false, true); - const step = deleteConfirmNext(armed.armed, true); - assert.equal(step.execute, true); - const actions = deletionActionsFor("session"); - assert.equal(actions.deleteFromEditorStore, false); - assert.equal(actions.writeTombstone, true); +test("session rows are read-only: the planner plans nothing for them", () => { + assert.deepEqual(deletionActionsFor("session"), { + deleteFromEditorStore: false, + writeTombstone: false, + }); }); // --------------------------------------------------------------------------- -// Copy: the armed footer distinguishes the two semantics in one line; the -// failure toasts state exactly what state remains. +// Copy: one confirmation line for every row; the failure toasts state +// exactly what state remains. // --------------------------------------------------------------------------- -test("the editor confirmation names the physical delete AND the hide", () => { - const text = deleteConfirmFooterText("editor"); - assert.ok(!text.includes("\n"), "the confirmation stays on one line"); - assert.ok(text.includes("Delete stored prompt?")); - assert.ok(text.includes("Removes every copy from the store")); - assert.ok(text.includes("hides it from history")); - assert.ok( - text.includes("Session transcripts keep the original"), - "the immutability caveat must be stated", - ); -}); - -test("the session confirmation names the hide-only semantics", () => { - const text = deleteConfirmFooterText("session"); +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("Hide from history?")); - assert.ok(text.includes("The original stays in the session transcript")); - assert.ok(text.includes("tombstone keeps it out of this list")); + 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)", () => { @@ -129,8 +132,8 @@ test("failure toasts state the remaining state exactly (PR #1393)", () => { }); // --------------------------------------------------------------------------- -// Source-parse: the execution semantics inside deleteCurrent (the selector -// class itself is not instantiable under node:test — see the header note). +// Source-parse: the wiring inside the selector (the class itself is not +// instantiable under node:test — see the header note). // --------------------------------------------------------------------------- const selectorSource = fs.readFileSync( @@ -138,127 +141,132 @@ const selectorSource = fs.readFileSync( "utf8", ); -function deleteCurrentBody(): string { - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); +/** 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, "deleteCurrent's body should close"); + assert.ok(end > decl, `${signature}'s body should close`); return selectorSource.slice(decl, end); } -test("(b) the arming press returns before ANY mutation of rows or disk", () => { +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 stepAt = body.indexOf("const step = deleteConfirmNext(this.confirmArmed, true);"); - assert.ok(stepAt >= 0, "the transition must route through the pure helper"); - const armReturnAt = body.indexOf("if (!step.execute)"); - assert.ok(armReturnAt > stepAt, "the execute gate must follow the step"); - const editorGuardAt = body.indexOf("if (actions.deleteFromEditorStore)"); - const spliceAt = body.indexOf("this.records.splice("); - const hideAt = body.indexOf("hidePrompt("); + 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( - armReturnAt < editorGuardAt && - armReturnAt < spliceAt && - armReturnAt < hideAt, - "arming must precede the store flow, the splice, and the tombstone", + body.includes("if (!this.confirmArmed)"), + "the unarmed press must arm", ); -}); - -test("(c) the store deletes live only inside the editor-source guard", () => { - const body = deleteCurrentBody(); - const guardAt = body.indexOf("if (actions.deleteFromEditorStore)"); - assert.ok(guardAt >= 0, "the editor-store guard must exist"); - const guardCloseAt = body.indexOf("\n }", guardAt); - assert.ok(guardCloseAt > guardAt, "the editor-store guard must close"); - - for (const call of ["deleteFromGlobal(", "deleteFromProject("]) { - const at = body.indexOf(call); - assert.ok(at >= 0, `${call} must exist`); - assert.ok( - at > guardAt && at < guardCloseAt, - `${call} must sit inside the editor guard — a session record never reaches it`, - ); - } - // The tombstone write follows the guard: EVERY provenance lands one. - const hideAt = body.indexOf("hidePrompt("); assert.ok( - hideAt > guardCloseAt, - "the tombstone must follow (not sit inside) the editor-store guard", + armAt < executeAt, + "armDelete is the unarmed branch, executeDelete the armed one", ); }); -test("(d) a thrown store delete toasts the failure copy and aborts", () => { - const body = deleteCurrentBody(); - const tryAt = body.indexOf("try {"); - const catchAt = body.indexOf("} catch {", tryAt); - assert.ok(tryAt >= 0 && catchAt > tryAt, "the store calls must be wrapped"); - const catchEnd = body.indexOf("\n }", catchAt); - const catchBody = body.slice(catchAt, catchEnd); - assert.ok( - catchBody.includes(`this.onNotify?.(STORE_DELETE_FAILED_TEXT, "error")`), - "the catch must toast the store-failure copy", - ); +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( - catchBody.includes("return;"), - "the catch must abort the flow", + !body.includes("this.records.splice("), + "arming never mutates rows", ); - // The abort precedes the tombstone write: a failed store delete leaves - // NO tombstone behind. - const hideAt = body.indexOf("hidePrompt("); - assert.ok(catchAt < hideAt, "the catch must precede the hide write"); }); -test("(e) a hide error toasts the session message and aborts — the editor path proceeds to the splice", () => { - const body = deleteCurrentBody(); - const gateAt = body.indexOf('if (hide.status === "error")'); - assert.ok(gateAt >= 0, "hide errors must be gated"); +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(gateAt < spliceAt, "the hide gate must precede the splice"); - const gate = body.slice(gateAt, spliceAt); - - // Session path: toast the recovery message and abort. - const abortGuardAt = gate.indexOf("if (!actions.deleteFromEditorStore)"); assert.ok( - abortGuardAt >= 0, - "the session-path early return must be exclusive", + disarmAt < actionsAt && actionsAt < hideAt && hideAt < spliceAt, + "disarm → plan → tombstone → splice ordering", ); - const abortBody = gate.slice(abortGuardAt, gate.indexOf("}", abortGuardAt)); +}); + +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( - abortBody.includes('this.onNotify?.(hide.message, "error")'), - "the session path must toast the hide error itself", + body.includes("deleteConfirmStep("), + "the armed branch routes through the pure router", ); - assert.ok(abortBody.includes("return;"), "the session path must abort"); assert.ok( - !gate.slice(0, abortGuardAt).includes("return;"), - "no unconditional abort before the provenance split", + 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"); +}); - // Editor path: the store row is already gone — the toast says so, and - // control FALLS THROUGH to the splice (no return between the toast and - // the splice). - const editorToastAt = gate.indexOf(`this.onNotify?.(EDITOR_HIDE_FAILED_TEXT, "error")`); - assert.ok(editorToastAt >= 0, "the editor path must toast the hide failure"); - const gateToSplice = gate.slice(editorToastAt); +test("the old disarm pre-pass is superseded — disarm only on the modal cancel path", () => { + const body = handleInputBody(); assert.ok( - !gateToSplice.includes("return;"), - "the editor path must NOT abort — the splice still runs", + !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("any other key disarms before its own action; a wheel scroll disarms too", () => { - const handleInputAt = selectorSource.indexOf("handleInput(data: string): void {"); - assert.ok(handleInputAt >= 0, "handleInput should exist"); - const inputEnd = selectorSource.indexOf("\n }", handleInputAt); - const inputBody = selectorSource.slice(handleInputAt, inputEnd); - const disarmAt = inputBody.indexOf("this.disarmDeleteConfirm()"); - assert.ok(disarmAt >= 0, "handleInput must disarm a pending confirmation"); +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( - inputBody.includes('!matchesKey(data, "ctrl+shift+backspace")'), - "the delete combo itself must NOT route through the disarm pre-pass", + entry.includes("this.onCancel()"), + "disarmed esc must still close the overlay", ); - // The disarm must happen before the dispatch loop consumes the key. - const loopAt = inputBody.indexOf("for (const { match, handler } of this.dispatch) {"); - assert.ok(disarmAt < loopAt, "the disarm pre-pass must precede dispatch"); +}); +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); @@ -267,34 +275,80 @@ test("any other key disarms before its own action; a wheel scroll disarms too", 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 body = deleteCurrentBody(); + const footerBody = methodBodyOf("private refreshDeleteFooter(): void {"); assert.ok( - body.includes("this.refreshDeleteFooter()"), - "every delete press refreshes the footer", + footerBody.includes("deleteConfirmFooterText()"), + "the armed footer uses the single pure copy (no source argument)", ); - - const footerAt = selectorSource.indexOf("private refreshDeleteFooter(): void {"); - assert.ok(footerAt >= 0, "refreshDeleteFooter should exist"); - const footerEnd = selectorSource.indexOf("\n }", footerAt); - const footerBody = selectorSource.slice(footerAt, footerEnd); assert.ok( - footerBody.includes("deleteConfirmFooterText(source)"), - "the armed footer uses the scope-aware pure copy", + !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 rebuildAt = selectorSource.indexOf("private rebuildListWithWidth(width: number): void {"); - assert.ok(rebuildAt >= 0, "rebuildListWithWidth should exist"); - const rebuildEnd = selectorSource.indexOf("\n }", rebuildAt); - const rebuildBody = selectorSource.slice(rebuildAt, rebuildEnd); + 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-hide-prompts.test.ts b/tests/history-hide-prompts.test.ts index ccd1f8ea8..88db3e005 100644 --- a/tests/history-hide-prompts.test.ts +++ b/tests/history-hide-prompts.test.ts @@ -9,14 +9,16 @@ import { } 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). 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 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. +// 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}-`)); @@ -69,8 +71,9 @@ test("T24 (AC-S4-1): hide keys byte-match promptDedupKey across whitespace, case } 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, sorted. - assert.deepEqual(stored, texts.map((text) => promptDedupKey(text)).sort()); + // 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); @@ -97,6 +100,70 @@ test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads tr 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 diff --git a/tests/history-off-path.test.ts b/tests/history-off-path.test.ts index ef0eb0701..a9d4f8884 100644 --- a/tests/history-off-path.test.ts +++ b/tests/history-off-path.test.ts @@ -8,7 +8,7 @@ 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_CAPTURE; +delete process.env.GENTLE_PI_HISTORY_ENABLE; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-")); @@ -86,7 +86,7 @@ test("with capture disabled, the history command imports nothing and warns", asy assert.equal(notifyCalls.length, 1); assert.equal(notifyCalls[0][1], "warning"); assert.ok( - notifyCalls[0][0].includes("GENTLE_PI_HISTORY_CAPTURE"), + 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. diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index bb135582a..2d44a1389 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -147,13 +147,13 @@ test("the extension entry registers exactly the slice-3 wiring surface", () => { test("captureEnabled is a strict opt-in", () => { assert.equal(captureEnabled({}), false); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "0" }), false); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "false" }), false); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "off" }), false); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "yes" }), false); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: " 1 " }), true); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "TRUE" }), true); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "On" }), true); + 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", () => { @@ -167,7 +167,7 @@ test("the capture handler is a no-op unless the user opts in", () => { test("an opted-in session captures delivered prompts", () => { const root = makeRoot(); - const handler = captureHandlerWith({ GENTLE_PI_HISTORY_CAPTURE: "1" }, root); + const handler = captureHandlerWith({ GENTLE_PI_HISTORY_ENABLE: "1" }, root); handler({ prompt: "hello store" }); assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "inst-entry")), [ "hello store", @@ -176,12 +176,12 @@ test("an opted-in session captures delivered prompts", () => { test("disabling capture stops new lines and leaves existing files alone", () => { const root = makeRoot(); - const env: NodeJS.ProcessEnv = { GENTLE_PI_HISTORY_CAPTURE: "true" }; + 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_CAPTURE; + delete env.GENTLE_PI_HISTORY_ENABLE; handler({ prompt: "never written" }); assert.deepEqual(fileTexts(file), ["kept"]); }); From 59811531f56e7cab97e8426a7c41390556991300 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:11:02 -0300 Subject: [PATCH 49/49] fix(history): strip-types-safe constructors for the node test runner --- extensions/history/index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index c07b7130b..5a79c4483 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -182,10 +182,12 @@ type SelectorNotify = ( /** Single rendered row; always occupies exactly one terminal row. */ class FixedRowText { - constructor( - private text: string = "", - private readonly centered = false, - ) {} + 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 { @@ -283,6 +285,7 @@ class PromptHistorySelector extends Container implements Focusable { * 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. */ @@ -349,7 +352,7 @@ class PromptHistorySelector extends Container implements Focusable { records: PromptRecord[], onSelect: (record: PromptRecord) => void, onCancel: () => void, - private readonly onNotify?: SelectorNotify, + onNotify?: SelectorNotify, ) { super(); @@ -359,6 +362,7 @@ class PromptHistorySelector extends Container implements Focusable { 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)));