From 9eaa2ea3ade3bd11a84fcb518fe8a597fe9cb30f Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sat, 26 Sep 2026 21:21:40 +0200 Subject: [PATCH] feat(history): add persisted capture toggle to Customize --- README.md | 2 +- docs/prompt-history.md | 47 ++++++++-- extensions/gentle-shell.ts | 28 +++++- extensions/history/index.ts | 68 +++++++++----- lib/history-capture-policy.ts | 95 +++++++++++++++++++ lib/visual-customize-view.ts | 2 +- odd/tasks/history-followups.md | 44 +++++++++ tests/gentle-shell.test.ts | 65 ++++++++++++- tests/history-capture-policy.test.ts | 102 +++++++++++++++++++++ tests/history-command-registration.test.ts | 15 ++- tests/history-delete-confirm.test.ts | 17 ++-- tests/history-off-path.test.ts | 39 +++++++- tests/history-session-writer.test.ts | 81 +++++++++++++++- 13 files changed, 552 insertions(+), 53 deletions(-) create mode 100644 lib/history-capture-policy.ts create mode 100644 odd/tasks/history-followups.md create mode 100644 tests/history-capture-policy.test.ts diff --git a/README.md b/README.md index 4dfefd8ab..6b5cfe6a1 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ Model, effort, and who does what should be choices, not accidents. Named profile 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:customize` opens an interactive panel to set animation quality, startup banner rose, text logo and color, or choose an installed Pi theme. Highlighting a theme previews its source palette without changing the active theme; press Enter or Space to apply it through Pi. If its source is unreadable, the preview is unavailable. Status defaults to a right rail in fullscreen terminals at least 140 columns wide, and to a bottom bar otherwise. Choose right, bottom, or hidden (which removes both the rail and the bottom status bar at every width), move the fullscreen header below the input (below the 140-column breakpoint only one status row paints: a top header replaces the bottom bar, while with the header below the input the bottom bar alone carries the header's context, cost, and usage plus extension statuses), select comfortable/compact/minimal density, and toggle Changes, Agents, TODO, usage/cost, and model details independently. Layout changes and reset take effect immediately; banner changes appear on the next startup. The Editor category offers explicit Vim enable/disable controls for the global prompt preference; highlighting shows the persisted preference and effective prompt state without changing either. Enter or Space saves it and updates the live prompt; unsupported editors keep ordinary editing even when the saved preference is on. Vim is not included in visual profiles or visual reset. The panel can reset visual, banner, and animation settings to defaults. Press `p` for named visual profiles: `s` saves the current installed theme, banner, animation and layout; select a profile with ↑/↓, then use `r` to replace, `a` to apply, or `d` to delete. `z` clears only the profile catalog. Confirm destructive/apply actions with `y`, or cancel with any other key; Esc returns without applying a preview. Applying independent stores is not atomic: partial failures identify what changed. +`/gentle:customize` opens an interactive panel to set animation quality, startup banner rose, text logo and color, or choose an installed Pi theme. Highlighting a theme previews its source palette without changing the active theme; press Enter or Space to apply it through Pi. If its source is unreadable, the preview is unavailable. Status defaults to a right rail in fullscreen terminals at least 140 columns wide, and to a bottom bar otherwise. Choose right, bottom, or hidden (which removes both the rail and the bottom status bar at every width), move the fullscreen header below the input (below the 140-column breakpoint only one status row paints: a top header replaces the bottom bar, while with the header below the input the bottom bar alone carries the header's context, cost, and usage plus extension statuses), select comfortable/compact/minimal density, and toggle Changes, Agents, TODO, usage/cost, and model details independently. Layout changes and reset take effect immediately; banner changes appear on the next startup. The Editor category offers explicit Vim enable/disable controls for the global prompt preference; highlighting shows the persisted preference and effective prompt state without changing either. Enter or Space saves it and updates the live prompt; unsupported editors keep ordinary editing even when the saved preference is on. Vim is not included in visual profiles or visual reset. The History category turns prompt-history capture on or off; it is off by default, applies to the next prompt without a restart, and never deletes stored history. An explicit `GENTLE_PI_HISTORY_CAPTURE` value overrides the saved choice, and the panel marks that override (see [Prompt history](docs/prompt-history.md)). The panel can reset visual, banner, and animation settings to defaults. Press `p` for named visual profiles: `s` saves the current installed theme, banner, animation and layout; select a profile with ↑/↓, then use `r` to replace, `a` to apply, or `d` to delete. `z` clears only the profile catalog. Confirm destructive/apply actions with `y`, or cancel with any other key; Esc returns without applying a preview. Applying independent stores is not atomic: partial failures identify what changed. **[Docs →](docs/gentle-shell.md#command-palette)** diff --git a/docs/prompt-history.md b/docs/prompt-history.md index ea3e614f5..432c4e374 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -8,19 +8,49 @@ history selector. Opted-in sessions consolidate a project's files at shutdown ## Capture is opt-in Recording is **off by default**. Delivered prompts can contain secrets, so -nothing is stored unless you explicitly opt in: +nothing is stored unless you explicitly opt in. + +### Turn capture on or off + +1. Run `/gentle:customize` and open the **History** category. +2. Select **Prompt history capture: enable** (or **disable**) and press Enter + or Space. Highlighting a row only previews the saved preference and the + effective state. +3. The change applies from the next prompt; no pi restart is needed. + +The preference is saved globally in `/history-capture.json` +(default config home `~/.pi/gentle-ai`, overridable with +`GENTLE_PI_CONFIG_HOME`) with the strict shape +`{"schema":"gentle-pi.history-capture/v1","policy":"on"}` or `off`. It is +written atomically and is not part of visual profiles or visual reset. + +For a single session or a script, the environment variable still works: ```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. +### Which setting wins + +| Situation | Capture | +|-----------|---------| +| `GENTLE_PI_HISTORY_CAPTURE` is `1`, `true`, or `on` | on, whatever Customize says | +| `GENTLE_PI_HISTORY_CAPTURE` is `0`, `false`, or `off` | off, whatever Customize says | +| Variable unset, empty, or any other value | the Customize preference | +| No preference saved | off | +| Preference file malformed or unreadable | off (fail closed) | + +Env values are trimmed and case-insensitive. While the variable forces a +value, the Customize rows show `env override` and the preview says the +variable overrides the preference; a selection is still saved and takes effect +once the variable stops forcing a value. A malformed preference file is +reported and never rewritten by Customize: fix or remove it by hand. + +- The check runs per prompt: changing the preference or the variable stops or + starts new captures immediately. - With capture off the extension is inert: no registry entry, no files, and - prompts are never written. The history selector only warns; it reads, - imports, and deletes nothing. + prompts are never written. The history selector only warns and names the + control that decides; it reads, imports, and deletes nothing. ## Legacy migration and seeding are opt-in @@ -72,7 +102,8 @@ 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 +Turning capture off — in Customize or with the variable — only stops **new** +captures. Nothing is deleted: files already written — and the registry entry — stay on disk until you remove them. Individual prompts can be deleted from the history selector while capture is on (see "Delete" below); the store directory itself is removed by hand: diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index c2dd22c9c..163303054 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -26,6 +26,7 @@ import { oddPhaseRegistry } from "../lib/odd-phase.ts"; import { gentlePiConfigHome } from "../lib/agent-home.ts"; import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } from "../lib/animation-policy.ts"; import { resolveVimPolicy, writeVimPolicy, type VimPolicy } from "../lib/vim-policy.ts"; +import { resolveHistoryCapture, writeHistoryCapturePolicy } from "../lib/history-capture-policy.ts"; import { createRequire } from "node:module"; import { createVimEditorAdapter } from "../lib/vim-editor-adapter.ts"; import { VimNormalEngine } from "../lib/vim-normal-engine.ts"; @@ -1713,7 +1714,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p }); } pi.registerCommand("gentle:customize", { - description: "Configure animations, banner, themes, layout and global Vim prompt editing.", + description: "Configure animations, banner, themes, layout, global Vim prompt editing and prompt history capture.", handler: async (_args, ctx) => { if (ctx.mode !== "tui" || !ctx.hasUI) { if (ctx.hasUI) ctx.ui.notify("Visual customization requires an interactive terminal.", "warning"); @@ -1823,6 +1824,31 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p reportVim(ctx, result); }, }); + category = "History"; + // The prompt-history extension re-reads this preference per prompt, so a + // change applies without restart. An explicit GENTLE_PI_HISTORY_CAPTURE + // value wins; the rows say so instead of silently ignoring the choice. + const historyCapture = () => resolveHistoryCapture({ env, gentlePiConfigHome: doubleEscCancelConfigHome }); + for (const [label, policy] of [["enable", "on"], ["disable", "off"]] as const) rows.push({ + category, + label: () => { + const result = historyCapture(); + return `Prompt history capture: ${label}${result.preference === policy && !result.malformed ? " (current)" : ""}${result.envOverride ? " · env override" : ""}`; + }, + preview: () => { + const result = historyCapture(); + const effective = result.enabled ? "on" : "off"; + return { title: "Prompt history capture · Customize preference", sample: `preference: ${result.preference} · effective: ${effective}${result.malformed ? " · malformed or unreadable file" : ""}${result.envOverride ? " · GENTLE_PI_HISTORY_CAPTURE overrides this preference" : ""}` }; + }, + action: () => { + const current = historyCapture(); + if (current.malformed) throw new Error(`Cannot update malformed or unreadable history capture preference: ${current.globalFile}`); + writeHistoryCapturePolicy(policy, { gentlePiConfigHome: doubleEscCancelConfigHome }); + const result = historyCapture(); + if (result.envOverride) ctx.ui.notify(`Prompt history capture preference saved: ${result.preference}. GENTLE_PI_HISTORY_CAPTURE=${result.envOverride} overrides it; capture stays ${result.envOverride}.`, "warning"); + else ctx.ui.notify(result.enabled ? "Prompt history capture: on. Applies from the next prompt; stored history is kept." : "Prompt history capture: off. New prompts are not recorded; stored history is kept.", "info"); + }, + }); category = "Layout"; for (const value of Object.values(STATUS_PLACEMENT)) add(() => `Status placement: ${value}${visual().statusPlacement === value ? " (current)" : ""}`, pending, () => updateVisual((settings) => ({ ...settings, statusPlacement: value })), () => layoutPreview({ ...visual(), statusPlacement: value })); for (const value of Object.values(HEADER_PLACEMENT)) add(() => `Header placement: ${value}${visual().headerPlacement === value ? " (current)" : ""}`, pending, () => updateVisual((settings) => ({ ...settings, headerPlacement: value })), () => layoutPreview({ ...visual(), headerPlacement: value })); diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 0f950e4a7..839de9918 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -9,11 +9,12 @@ // modal delete (store sweep + exact tombstone), and the slice-6 // session_shutdown GC/compaction. // -// Capture is OPT-IN: 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, no deletes. Unsetting the switch only -// stops NEW captures; files already written stay on disk +// Capture is OPT-IN: nothing is recorded unless an explicit +// GENTLE_PI_HISTORY_CAPTURE=1|true|on, or (with no explicit env value) the +// persisted Gentle → Customize preference, turns it on. The selector honors +// the same gate: with capture off, opening the selector is a no-op — no +// registry entry, no writer init, no store reads, no deletes. Turning +// capture off only stops NEW captures; files already written stay on disk // (docs/prompt-history.md). import { randomUUID } from "node:crypto"; @@ -37,6 +38,11 @@ import { type TuiMouseEvent, truncateToWidth, } from "@earendil-works/pi-tui"; +import { gentlePiConfigHome } from "../../lib/agent-home.ts"; +import { + historyCaptureEnabled, + historyCaptureEnvOverride, +} from "../../lib/history-capture-policy.ts"; import { hidePrompt } from "./hide-prompts.ts"; import { appendSessionCapture, @@ -120,6 +126,8 @@ const SESSIONS_ROOT = join(AGENT_DIR, "sessions"); export interface HistoryDeps { env?: NodeJS.ProcessEnv; + /** Gentle config home holding the Customize preference (default: from env). */ + gentlePiConfigHome?: string; root?: string; cwd?: string; instanceId?: string; @@ -129,14 +137,25 @@ export interface HistoryDeps { } /** - * 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 (deletes run from the selector while capture is on). + * Strict opt-in: an explicit GENTLE_PI_HISTORY_CAPTURE value (1|true|on or + * 0|false|off, case-insensitive) wins; otherwise the persisted Customize + * preference under the Gentle config home decides; a missing, malformed or + * unreadable preference is off. Read per call so a Customize toggle applies + * without restart. Turning capture off stops new captures; files already on + * disk are left untouched (deletes run from the selector while capture is on). */ -export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { - const value = env.GENTLE_PI_HISTORY_CAPTURE?.trim().toLowerCase(); - return value === "1" || value === "true" || value === "on"; +export function captureEnabled( + env: NodeJS.ProcessEnv = process.env, + configHome: string = gentlePiConfigHome(env), +): boolean { + return historyCaptureEnabled({ env, gentlePiConfigHome: configHome }); +} + +/** Why capture is off, naming the control that actually decides it. */ +function captureDisabledMessage(env: NodeJS.ProcessEnv): string { + return historyCaptureEnvOverride(env) === "off" + ? "Prompt history is disabled by GENTLE_PI_HISTORY_CAPTURE, which overrides the Gentle → Customize → History preference." + : "Prompt history is disabled. Turn on \"Prompt history capture\" in Gentle → Customize → History, or set GENTLE_PI_HISTORY_CAPTURE=1."; } // --------------------------------------------------------------------------- @@ -1083,7 +1102,12 @@ type HistoryScope = "project" | "global"; * runs before any store access and a capture-off session performs no * registry/writer/delete side effects on the open path. */ -function createOpenFlow(env: NodeJS.ProcessEnv, root: string, cwd: string) { +function createOpenFlow( + env: NodeJS.ProcessEnv, + configHome: string, + 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 @@ -1105,11 +1129,8 @@ function createOpenFlow(env: NodeJS.ProcessEnv, root: string, cwd: string) { ): 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", - ); + if (!captureEnabled(env, configHome)) { + ctx.ui.notify(captureDisabledMessage(env), "warning"); return; } @@ -1149,6 +1170,9 @@ export default function promptHistoryExtension( deps: HistoryDeps = {}, ): void { const env = deps.env ?? process.env; + const configHome = deps.gentlePiConfigHome ?? gentlePiConfigHome(env); + // Per-prompt gate: re-read so a Customize toggle applies live. + const capturing = () => captureEnabled(env, configHome); const root = deps.root ?? PI_HISTORY_ROOT; const cwd = deps.cwd ?? process.cwd(); const instanceId = deps.instanceId ?? randomUUID(); @@ -1193,7 +1217,7 @@ export default function promptHistoryExtension( // 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; + if (!capturing()) return; try { getWriter(); } catch { @@ -1205,7 +1229,7 @@ export default function promptHistoryExtension( // 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; + if (!capturing()) return; try { const event = args[0] as { prompt?: string } | undefined; appendSessionCapture(getWriter(), event?.prompt ?? "", now()); @@ -1219,7 +1243,7 @@ export default function promptHistoryExtension( // only for opted-in sessions: with capture off the store is never // rewritten. This instance's own capture file is never a merge candidate. pi.on("session_shutdown", () => { - if (!captureEnabled(env)) return; + if (!capturing()) return; try { gcProjectDir(root, cwd, { keepFiles: [sessionFilePath(root, cwd, instanceId)], @@ -1236,7 +1260,7 @@ export default function promptHistoryExtension( }); // Selector open flow (slice 3, stage 3): both entry points share it. - const { openHistorySelector } = createOpenFlow(env, root, cwd); + const { openHistorySelector } = createOpenFlow(env, configHome, root, cwd); pi.registerShortcut(SHORTCUT, { description: "Search prompt history", diff --git a/lib/history-capture-policy.ts b/lib/history-capture-policy.ts new file mode 100644 index 000000000..3aa09bb83 --- /dev/null +++ b/lib/history-capture-policy.ts @@ -0,0 +1,95 @@ +import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { gentlePiConfigHome } from "./agent-home.ts"; + +// Prompt history capture is default-off. An explicit GENTLE_PI_HISTORY_CAPTURE +// value wins, then the Gentle → Customize preference, then off. Any missing, +// malformed or unreadable preference fails closed. Turning capture off only +// stops new captures; stored history is never deleted here. +export const HISTORY_CAPTURE_POLICY = { ON: "on", OFF: "off" } as const; +export type HistoryCapturePolicy = (typeof HISTORY_CAPTURE_POLICY)[keyof typeof HISTORY_CAPTURE_POLICY]; +export const HISTORY_CAPTURE_SCHEMA = "gentle-pi.history-capture/v1"; +export const HISTORY_CAPTURE_ENV = "GENTLE_PI_HISTORY_CAPTURE"; +interface HistoryCaptureOptions { gentlePiConfigHome?: string } +export interface HistoryCapturePolicyResolution { + policy: HistoryCapturePolicy; + source: "global_file" | "default"; + malformed: boolean; + globalFile: string; +} +export interface HistoryCaptureResolution { + enabled: boolean; + source: "env" | "global_file" | "default"; + /** The persisted Customize preference, reported even while the env overrides it. */ + preference: HistoryCapturePolicy; + envOverride: HistoryCapturePolicy | undefined; + malformed: boolean; + globalFile: string; +} + +/** Only explicit on/off values override; anything else defers to the preference. */ +export function historyCaptureEnvOverride(env: NodeJS.ProcessEnv = process.env): HistoryCapturePolicy | undefined { + const value = env[HISTORY_CAPTURE_ENV]?.trim().toLowerCase(); + if (value === "1" || value === "true" || value === "on") return "on"; + if (value === "0" || value === "false" || value === "off") return "off"; + return undefined; +} + +export function parseHistoryCaptureFile(raw: string): HistoryCapturePolicy | undefined { + try { + const value: unknown = JSON.parse(raw); + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + if (Object.keys(value).length !== 2 || !("schema" in value) || value.schema !== HISTORY_CAPTURE_SCHEMA || !("policy" in value)) return undefined; + return value.policy === "on" || value.policy === "off" ? value.policy : undefined; + } catch { return undefined; } +} + +export function resolveHistoryCapturePolicy(options: HistoryCaptureOptions = {}): HistoryCapturePolicyResolution { + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "history-capture.json"); + try { + const policy = parseHistoryCaptureFile(readFileSync(globalFile, "utf8")); + return { policy: policy ?? "off", source: "global_file", malformed: policy === undefined, globalFile }; + } catch (error) { + const missing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; + return { policy: "off", source: missing ? "default" : "global_file", malformed: !missing, globalFile }; + } +} + +/** Full resolution for status surfaces; always reads the preference. */ +export function resolveHistoryCapture(options: { env?: NodeJS.ProcessEnv; gentlePiConfigHome?: string } = {}): HistoryCaptureResolution { + const env = options.env ?? process.env; + const saved = resolveHistoryCapturePolicy({ gentlePiConfigHome: options.gentlePiConfigHome ?? gentlePiConfigHome(env) }); + const envOverride = historyCaptureEnvOverride(env); + return { + enabled: (envOverride ?? saved.policy) === "on", + source: envOverride ? "env" : saved.source, + preference: saved.policy, + envOverride, + malformed: saved.malformed, + globalFile: saved.globalFile, + }; +} + +/** Per-prompt gate: an explicit env value never reads the preference file. */ +export function historyCaptureEnabled(options: { env?: NodeJS.ProcessEnv; gentlePiConfigHome?: string } = {}): boolean { + const env = options.env ?? process.env; + const override = historyCaptureEnvOverride(env); + if (override) return override === "on"; + return resolveHistoryCapturePolicy({ gentlePiConfigHome: options.gentlePiConfigHome ?? gentlePiConfigHome(env) }).policy === "on"; +} + +export function writeHistoryCapturePolicy(policy: HistoryCapturePolicy, options: HistoryCaptureOptions = {}): string { + if (policy !== "on" && policy !== "off") throw new TypeError("Invalid history capture policy"); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const path = join(home, "history-capture.json"); + const temporary = `${path}.${randomUUID()}.tmp`; + mkdirSync(home, { recursive: true }); + try { + writeFileSync(temporary, `${JSON.stringify({ schema: HISTORY_CAPTURE_SCHEMA, policy })}\n`, { flag: "wx", mode: 0o600 }); + renameSync(temporary, path); + } finally { + try { unlinkSync(temporary); } catch { /* Rename consumed the temporary file. */ } + } + return path; +} diff --git a/lib/visual-customize-view.ts b/lib/visual-customize-view.ts index e96fa4763..d275bf23f 100644 --- a/lib/visual-customize-view.ts +++ b/lib/visual-customize-view.ts @@ -29,7 +29,7 @@ function dropLastGrapheme(text: string): string { return segments.length ? text.slice(0, segments[segments.length - 1]!.index) : text; } -export type CustomizeCategory = "Animations" | "Banner" | "Themes" | "Editor" | "Layout" | "Sections" | "Profiles" | "Reset"; +export type CustomizeCategory = "Animations" | "Banner" | "Themes" | "Editor" | "History" | "Layout" | "Sections" | "Profiles" | "Reset"; export interface CustomizeRow { category?: CustomizeCategory; diff --git a/odd/tasks/history-followups.md b/odd/tasks/history-followups.md new file mode 100644 index 000000000..aded676c9 --- /dev/null +++ b/odd/tasks/history-followups.md @@ -0,0 +1,44 @@ +# History follow-ups + +## Objective / authorization +Deliver the pending prompt-history follow-ups after the contributor chain (#1390–#1394) merged and tracking issue #818 closed. User authorized: "haz los pendientes" (implement, review, merge). + +## Problem +- Capture can only be enabled through `GENTLE_PI_HISTORY_CAPTURE`; a running Pi process cannot persist environment changes, so users need a persisted control in Gentle → Customize. +- Native review advisories on the merged history slices were deferred: GC carry-over after unlink, perpetual line-threshold compaction, and other non-blocking reliability notes. Selector search: `Home`/`End` move list selection instead of the search caret, and `End` loads every record. +- The contributor removed the responsive header and sidebar-aware overlay margin from #1394 (commit `e2cca1f9b`) for a later PR. + +## Constraints / decisions +- Capture stays default-off. Precedence: an explicit `GENTLE_PI_HISTORY_CAPTURE` value (on: `1|true|on`, off: `0|false|off`) wins; otherwise the persisted Customize setting; otherwise off. Turning capture off never deletes stored history. +- Follow existing Customize persistence (`lib/visual-customization-policy.ts`, `lib/visual-customize-view.ts`, `extensions/gentle-shell.ts`) and existing policy-file conventions; live change without restart where the existing pattern supports it. +- `hidden.json` keeps sha256 tombstones with legacy plaintext compatibility (decided). +- Preserve contributor attribution when restoring `e2cca1f9b^` work. +- Strict TDD active by configuration; runner `node --experimental-strip-types --test`. + +## Route and delivery +Delegated direct: each task touches 2+ non-trivial files; one writer at a time. Strategy: one PR per task (independent rollback), each with an approved issue, native review on the work-unit commit, required CI, then merge. ~400 authored-line heuristic is advisory only. + +## Tasks +- [x] F1: Persisted History capture toggle in Customize with env precedence, docs and tests. Route: delegated (Customize + history + docs). +- [ ] F2: History reliability follow-ups: GC carry-over after unlink, perpetual line-threshold compaction, still-valid review advisories, `Home`/`End` search caret. Route: delegated. +- [ ] F3: Restore responsive header and sidebar-aware overlay margin from `e2cca1f9b^` onto current main with tests. Route: delegated. +- [ ] F4: Remove temporary worktrees from the chain work. Route: inline. + +## Acceptance and checks +`node --experimental-strip-types --test tests/*.test.ts`, `node scripts/check-types.mjs` (no regressions vs baseline), `git diff --check`, native review approved per PR, five required CI checks green. + +## Progress +2026-09-26: Worktree `history-followups` on branch `feat/history-customize-toggle` from main `45240bcc2`. Engram mirror stored in the gentle-ai project (session binding); gentle-pi mirror pending. + +2026-09-26 F1 (delegated writer): implemented, uncommitted; awaiting parent review and work-unit commit. +- New `lib/history-capture-policy.ts` (sibling of `vim-policy.ts`): `/history-capture.json`, schema `gentle-pi.history-capture/v1`, strict validation, atomic write; `resolveHistoryCapture` (status) and `historyCaptureEnabled` (per-prompt gate; an explicit env value short-circuits without reading the file). +- `extensions/history/index.ts`: `HistoryDeps.gentlePiConfigHome` seam; `captureEnabled(env, configHome)` re-read per prompt/open/shutdown; disabled warning names Customize → History, or the env override when env forces off. +- Customize: new `History` category (after Editor) with `Prompt history capture: enable|disable` rows, `(current)` and `· env override` markers, preview `preference · effective`; saving under an env override warns; malformed file is refused, never rewritten (Vim convention). +- Tests: new `tests/history-capture-policy.test.ts`; History-row tests in `tests/gentle-shell.test.ts` (`findCustomizeRow` now scans 9 categories); live toggle, env override, fail-closed in history suites; existing history harnesses now inject an empty config home so a developer's real preference cannot leak in. Two source-shape tests re-pinned to the delegated gate. +- RED observed: policy module missing; 3 extension tests (preference enable, live toggle, Customize message); 3 UI tests (rows missing). GREEN: all pass. +- Checks: `node --experimental-strip-types --test tests/*.test.ts` 3857 tests, 3814 pass, 0 fail, 43 skipped (baseline 3798/0/43); `node scripts/check-types.mjs` exit 0, no regressions; `git diff --check` clean. +- Docs: `docs/prompt-history.md` quick path + precedence table. `docs/readme-reference.md` has no Customize option list, so unchanged; the Customize paragraph lives in `README.md` (outside F1 edit surface) and does not yet mention the History category — proposed follow-up. +- Size: ~310 insertions/52 deletions tracked plus ~200 lines in two new files (above the advisory ~400 heuristic because of tests; not split). + +## Next step +Parent: review F1, decide on the README.md Customize mention, commit F1 as a work-unit commit. diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index f93394693..b6b37082c 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -17,6 +17,7 @@ import { stripAnsi } from "../lib/terminal-theme.ts"; import { resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts"; import { resolveAnimationPolicy } from "../lib/animation-policy.ts"; import { resolveVimPolicy, writeVimPolicy } from "../lib/vim-policy.ts"; +import { resolveHistoryCapturePolicy, writeHistoryCapturePolicy } from "../lib/history-capture-policy.ts"; import { readBannerConfig } from "../extensions/startup-banner.ts"; import { listVisualProfiles, saveVisualProfile } from "../lib/visual-profiles.ts"; import { oddPhaseRegistry } from "../lib/odd-phase.ts"; @@ -2533,7 +2534,7 @@ function scopedDoubleEscCancelConfigHome(t: { after(callback: () => void): void function findCustomizeRow(ui: FakeUi, label: string, width = 90): boolean { const view = ui.overlayView!; view.handleInput("\x1b[D"); - for (let category = 0; category < 8; category++) { + for (let category = 0; category < 9; category++) { view.handleInput("\x1b[C"); for (let index = 0; index < 35; index++) { if (view.render(width).some((line) => line.includes(`▸ ${label}`))) return true; @@ -2574,6 +2575,68 @@ test("customize Editor rows preview global preference without applying until Ent ui.overlayView!.handleInput("\x1b"); await pending; }); +test("customize History rows persist prompt history capture and keep stored history", async (t) => { + const home = scopedDoubleEscCancelConfigHome(t); + const { pi, commands } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home }); + const { ctx, ui, overlayReady } = fakeContext(); + const pending = commands.get("gentle:customize")!.handler("", ctx); + await overlayReady; + assert.ok(findCustomizeRow(ui, "Prompt history capture: enable")); + assert.match(ui.overlayView!.render(90).join("\n"), /History · 1\/2/); + assert.match(ui.overlayView!.render(90).join("\n"), /Preview · Prompt history capture[\s\S]*preference: off · effective: off/i); + assert.equal(existsSync(join(home, "history-capture.json")), false, "highlighting never applies"); + await customizeAction(ui, "Prompt history capture: enable"); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(resolveHistoryCapturePolicy({ gentlePiConfigHome: home }).policy, "on"); + assert.match(ui.notices.at(-1)!, /Prompt history capture: on\. Applies from the next prompt/i); + assert.ok(findCustomizeRow(ui, "Prompt history capture: enable (current)")); + assert.match(ui.overlayView!.render(90).join("\n"), /preference: on · effective: on/i); + await customizeAction(ui, "Prompt history capture: disable"); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(resolveHistoryCapturePolicy({ gentlePiConfigHome: home }).policy, "off"); + assert.match(ui.notices.at(-1)!, /stored history is kept/i); + ui.overlayView!.handleInput("\x1b"); await pending; +}); + +test("customize History rows show when GENTLE_PI_HISTORY_CAPTURE overrides the saved preference", async (t) => { + const home = scopedDoubleEscCancelConfigHome(t); + writeHistoryCapturePolicy("on", { gentlePiConfigHome: home }); + const { pi, commands } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_HISTORY_CAPTURE: " Off " }); + const { ctx, ui, overlayReady } = fakeContext(); + const pending = commands.get("gentle:customize")!.handler("", ctx); + await overlayReady; + assert.ok(findCustomizeRow(ui, "Prompt history capture: enable (current) · env override")); + assert.match(ui.overlayView!.render(90).join("\n"), /preference: on · effective: off · GENTLE_PI_HISTORY_CAPTURE overrides/i); + await customizeAction(ui, "Prompt history capture: disable"); + await new Promise(resolve => setImmediate(resolve)); + // The choice is still saved for when the env stops forcing a value. + assert.equal(resolveHistoryCapturePolicy({ gentlePiConfigHome: home }).policy, "off"); + await customizeAction(ui, "Prompt history capture: enable"); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(resolveHistoryCapturePolicy({ gentlePiConfigHome: home }).policy, "on"); + assert.match(ui.notices.at(-1)!, /GENTLE_PI_HISTORY_CAPTURE=off overrides it; capture stays off/i); + ui.overlayView!.handleInput("\x1b"); await pending; +}); + +test("customize History rows refuse to overwrite a malformed preference and report it", async (t) => { + const home = scopedDoubleEscCancelConfigHome(t); + writeFileSync(join(home, "history-capture.json"), "{"); + const { pi, commands } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home }); + const { ctx, ui, overlayReady } = fakeContext(); + const pending = commands.get("gentle:customize")!.handler("", ctx); + await overlayReady; + assert.ok(findCustomizeRow(ui, "Prompt history capture: enable")); + assert.match(ui.overlayView!.render(90).join("\n"), /preference: off · effective: off · malformed or unreadable file/i); + ui.overlayView!.handleInput("\r"); + for (let attempt = 0; attempt < 100 && !ui.notices.some(n => /malformed or unreadable history capture/i.test(n)); attempt++) await new Promise((resolve) => setTimeout(resolve, 5)); + assert.ok(ui.notices.some(n => /Cannot update malformed or unreadable history capture preference/i.test(n)), ui.notices.join("\n")); + assert.equal(readFileSync(join(home, "history-capture.json"), "utf8"), "{"); + ui.overlayView!.handleInput("\x1b"); await pending; +}); + test("external Vim preference change while customize is open never implies a compatibility failure", async (t) => { const home = scopedDoubleEscCancelConfigHome(t); const { pi, handlers, commands } = fakePi(); diff --git a/tests/history-capture-policy.test.ts b/tests/history-capture-policy.test.ts new file mode 100644 index 000000000..1cb5b14bf --- /dev/null +++ b/tests/history-capture-policy.test.ts @@ -0,0 +1,102 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + HISTORY_CAPTURE_SCHEMA, + historyCaptureEnabled, + historyCaptureEnvOverride, + parseHistoryCaptureFile, + resolveHistoryCapture, + resolveHistoryCapturePolicy, + writeHistoryCapturePolicy, +} from "../lib/history-capture-policy.ts"; + +const home = () => mkdtempSync(join(tmpdir(), "gentle-history-capture-")); + +test("env override accepts only explicit on/off values, trimmed and case-insensitive", () => { + for (const value of ["1", "true", "on", " TRUE ", "On"]) assert.equal(historyCaptureEnvOverride({ GENTLE_PI_HISTORY_CAPTURE: value }), "on", value); + for (const value of ["0", "false", "off", " FALSE ", "Off"]) assert.equal(historyCaptureEnvOverride({ GENTLE_PI_HISTORY_CAPTURE: value }), "off", value); + for (const value of [undefined, "", "yes", "no", "enabled", "2"]) assert.equal(historyCaptureEnvOverride({ GENTLE_PI_HISTORY_CAPTURE: value }), undefined, String(value)); +}); + +test("persisted preference round-trips through the atomic writer", () => { + const dir = home(); + assert.equal(resolveHistoryCapturePolicy({ gentlePiConfigHome: dir }).source, "default"); + const path = writeHistoryCapturePolicy("on", { gentlePiConfigHome: dir }); + assert.equal(path, join(dir, "history-capture.json")); + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { schema: HISTORY_CAPTURE_SCHEMA, policy: "on" }); + assert.deepEqual(resolveHistoryCapturePolicy({ gentlePiConfigHome: dir }), { policy: "on", source: "global_file", malformed: false, globalFile: path }); + writeHistoryCapturePolicy("off", { gentlePiConfigHome: dir }); + assert.equal(resolveHistoryCapturePolicy({ gentlePiConfigHome: dir }).policy, "off"); + assert.deepEqual(readdirSync(dir), ["history-capture.json"], "no temporary file survives the rename"); +}); + +test("writer rejects values outside the on/off domain", () => { + const dir = home(); + assert.throws(() => writeHistoryCapturePolicy("yes" as never, { gentlePiConfigHome: dir }), TypeError); + assert.equal(existsSync(join(dir, "history-capture.json")), false); +}); + +test("invalid or unreadable preference files fail closed to off", () => { + for (const raw of ["", "{", "[]", "null", `{"schema":"${HISTORY_CAPTURE_SCHEMA}","policy":"yes"}`, `{"schema":"other/v1","policy":"on"}`, `{"schema":"${HISTORY_CAPTURE_SCHEMA}","policy":"on","extra":1}`]) { + assert.equal(parseHistoryCaptureFile(raw), undefined, raw); + const dir = home(); + writeFileSync(join(dir, "history-capture.json"), raw); + const resolved = resolveHistoryCapturePolicy({ gentlePiConfigHome: dir }); + assert.equal(resolved.policy, "off", raw); + assert.equal(resolved.malformed, true, raw); + assert.equal(resolveHistoryCapture({ env: {}, gentlePiConfigHome: dir }).enabled, false, raw); + } + // A directory where the file should be is unreadable, not missing. + const dir = home(); + mkdirSync(join(dir, "history-capture.json")); + assert.deepEqual({ ...resolveHistoryCapturePolicy({ gentlePiConfigHome: dir }), globalFile: "" }, { policy: "off", source: "global_file", malformed: true, globalFile: "" }); + if (process.getuid?.() !== 0) { + const locked = home(); + writeHistoryCapturePolicy("on", { gentlePiConfigHome: locked }); + chmodSync(join(locked, "history-capture.json"), 0o000); + try { assert.equal(resolveHistoryCapture({ env: {}, gentlePiConfigHome: locked }).enabled, false); } + finally { chmodSync(join(locked, "history-capture.json"), 0o600); } + } +}); + +test("precedence: explicit env wins, then the persisted preference, then off", () => { + const dir = home(); + assert.deepEqual(resolveHistoryCapture({ env: {}, gentlePiConfigHome: dir }), { enabled: false, source: "default", preference: "off", envOverride: undefined, malformed: false, globalFile: join(dir, "history-capture.json") }); + writeHistoryCapturePolicy("on", { gentlePiConfigHome: dir }); + assert.equal(resolveHistoryCapture({ env: {}, gentlePiConfigHome: dir }).enabled, true); + assert.equal(resolveHistoryCapture({ env: {}, gentlePiConfigHome: dir }).source, "global_file"); + assert.equal(resolveHistoryCapture({ env: { GENTLE_PI_HISTORY_CAPTURE: "yes" }, gentlePiConfigHome: dir }).enabled, true, "an unrecognized env value defers to the preference"); + const forcedOff = resolveHistoryCapture({ env: { GENTLE_PI_HISTORY_CAPTURE: " OFF " }, gentlePiConfigHome: dir }); + assert.equal(forcedOff.enabled, false); + assert.equal(forcedOff.source, "env"); + assert.equal(forcedOff.envOverride, "off"); + assert.equal(forcedOff.preference, "on", "the saved preference is still reported under an override"); + writeHistoryCapturePolicy("off", { gentlePiConfigHome: dir }); + const forcedOn = resolveHistoryCapture({ env: { GENTLE_PI_HISTORY_CAPTURE: "1" }, gentlePiConfigHome: dir }); + assert.equal(forcedOn.enabled, true); + assert.equal(forcedOn.source, "env"); + assert.equal(forcedOn.preference, "off"); +}); + +test("per-prompt gate follows the same precedence and fails closed", () => { + const dir = home(); + assert.equal(historyCaptureEnabled({ env: {}, gentlePiConfigHome: dir }), false); + writeHistoryCapturePolicy("on", { gentlePiConfigHome: dir }); + assert.equal(historyCaptureEnabled({ env: {}, gentlePiConfigHome: dir }), true); + assert.equal(historyCaptureEnabled({ env: { GENTLE_PI_HISTORY_CAPTURE: "off" }, gentlePiConfigHome: dir }), false); + assert.equal(historyCaptureEnabled({ env: { GENTLE_PI_HISTORY_CAPTURE: "maybe" }, gentlePiConfigHome: dir }), true); + writeFileSync(join(dir, "history-capture.json"), "{"); + assert.equal(historyCaptureEnabled({ env: {}, gentlePiConfigHome: dir }), false); + // An explicit env value decides without consulting the (malformed) file. + assert.equal(historyCaptureEnabled({ env: { GENTLE_PI_HISTORY_CAPTURE: "TRUE" }, gentlePiConfigHome: dir }), true); +}); + +test("config home defaults to GENTLE_PI_CONFIG_HOME from the supplied env", () => { + const dir = home(); + writeHistoryCapturePolicy("on", { gentlePiConfigHome: dir }); + assert.equal(resolveHistoryCapture({ env: { GENTLE_PI_CONFIG_HOME: dir } }).enabled, true); + assert.equal(resolveHistoryCapture({ env: { GENTLE_PI_CONFIG_HOME: dir } }).globalFile, join(dir, "history-capture.json")); +}); diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts index c78f457b4..867ab9b74 100644 --- a/tests/history-command-registration.test.ts +++ b/tests/history-command-registration.test.ts @@ -85,7 +85,7 @@ test("the SHORTCUT constant pins 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 gateAt = body.indexOf("if (!captureEnabled(env, configHome))"); const drainAt = body.indexOf('drainForScope("project")'); assert.ok(gateAt >= 0, "the open flow must check captureEnabled first"); assert.ok( @@ -93,8 +93,17 @@ test("the capture gate precedes every store touch in the open flow (#1390)", () "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", + body.includes("captureDisabledMessage(env)"), + "the disabled warning explains which control decides", + ); + const message = source.slice( + source.indexOf("function captureDisabledMessage("), + source.indexOf("\n}", source.indexOf("function captureDisabledMessage(")), + ); + assert.ok( + message.includes("GENTLE_PI_HISTORY_CAPTURE") && + message.includes("Gentle → Customize → History"), + "the disabled warning names both the env switch and the Customize control", ); // No writer init on the open path: the selector never touches the // registry or the capture writer — getWriter stays capture-side. diff --git a/tests/history-delete-confirm.test.ts b/tests/history-delete-confirm.test.ts index 115b1ef30..758b52987 100644 --- a/tests/history-delete-confirm.test.ts +++ b/tests/history-delete-confirm.test.ts @@ -314,25 +314,20 @@ test("the armed state drives the footer copy and the error-colored highlight", ( // grep-clean for it. const renamedSwitch = `GENTLE_PI_HISTORY_${"ENABLE"}`; -test("captureEnabled reads GENTLE_PI_HISTORY_CAPTURE (strict 1/true/on unchanged)", () => { +test("captureEnabled delegates to the shared env-then-Customize gate", () => { 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); + // The strict 1/true/on (and explicit 0/false/off) parsing lives in + // lib/history-capture-policy.ts; tests/history-capture-policy.test.ts and + // the session-writer suite pin the behavior. assert.ok( - body.includes("env.GENTLE_PI_HISTORY_CAPTURE"), - "the shipped switch must be read", + body.includes("historyCaptureEnabled({ env, gentlePiConfigHome: configHome })"), + "the extension must use the shared gate with its injected config home", ); assert.ok(!body.includes(renamedSwitch), "the rename must not ship"); - 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 history extension never mentions the renamed switch", () => { diff --git a/tests/history-off-path.test.ts b/tests/history-off-path.test.ts index 8503dd973..2ff92a14e 100644 --- a/tests/history-off-path.test.ts +++ b/tests/history-off-path.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import promptHistoryExtension from "../extensions/history/index.ts"; +import { writeHistoryCapturePolicy } from "../lib/history-capture-policy.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 @@ -24,7 +25,11 @@ interface Harness { * 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 { +function loadWithCommand( + env: NodeJS.ProcessEnv, + root: string, + configHome: string = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-config-")), +): Harness { const shortcuts: Array<[string, { handler: unknown }]> = []; const commands: Array<[string, { handler: unknown }]> = []; const pi = { @@ -45,6 +50,8 @@ function loadWithCommand(env: NodeJS.ProcessEnv, root: string): Harness { // Keep any opted-in warm-up away from the real ~/.pi/agent. agentDir: path.join(root, "agent"), sessionsRoot: path.join(root, "sessions"), + // An empty config home by default: the Customize preference is unset (off). + gentlePiConfigHome: configHome, }); const command = commands.find(([name]) => name === "history"); assert.ok(command, "the history command must be registered"); @@ -88,6 +95,10 @@ test("with capture disabled, the history command imports nothing and warns", asy notifyCalls[0][0].includes("GENTLE_PI_HISTORY_CAPTURE"), `the warning must name the switch, got: ${notifyCalls[0][0]}`, ); + assert.ok( + notifyCalls[0][0].includes("Gentle → Customize"), + `the warning must name the Customize control, got: ${notifyCalls[0][0]}`, + ); // The gate must fire before the drain: no migration, no seed, no store. assert.deepEqual(fs.readdirSync(root), []); }); @@ -102,3 +113,29 @@ test("with capture enabled, opening the selector reads without initializing the assert.deepEqual(notifyCalls, [["No prompt history available.", "warning"]]); assert.deepEqual(fs.readdirSync(root), []); }); + +test("the Customize preference opens the selector without the env switch", async () => { + const root = makeRoot(); + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-config-")); + const { commandHandler } = loadWithCommand({}, root, configHome); + const notifyCalls: Array<[string, string]> = []; + await commandHandler([], fakeCtx(notifyCalls)); + assert.equal(notifyCalls[0][1], "warning"); + assert.match(notifyCalls[0][0], /disabled/); + // The same loaded extension honors a later toggle without restart. + writeHistoryCapturePolicy("on", { gentlePiConfigHome: configHome }); + await commandHandler([], fakeCtx(notifyCalls)); + assert.deepEqual(notifyCalls[1], ["No prompt history available.", "warning"]); +}); + +test("an explicit env off names the override instead of the Customize fix", async () => { + const root = makeRoot(); + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-config-")); + writeHistoryCapturePolicy("on", { gentlePiConfigHome: configHome }); + const { commandHandler } = loadWithCommand({ GENTLE_PI_HISTORY_CAPTURE: "false" }, root, configHome); + const notifyCalls: Array<[string, string]> = []; + await commandHandler([], fakeCtx(notifyCalls)); + assert.equal(notifyCalls.length, 1); + assert.match(notifyCalls[0][0], /disabled by GENTLE_PI_HISTORY_CAPTURE, which overrides the Gentle → Customize → History preference/); + assert.deepEqual(fs.readdirSync(root), []); +}); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index 5f3288dfb..735c62764 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -11,6 +11,7 @@ import { sessionFilePath, } from "../extensions/history/store.ts"; import promptHistoryExtension, { captureEnabled } from "../extensions/history/index.ts"; +import { writeHistoryCapturePolicy } from "../lib/history-capture-policy.ts"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); @@ -18,6 +19,11 @@ function makeRoot(): string { const CWD = "/pi-history-test/project-a"; +/** An empty Gentle config home: the Customize preference is unset. */ +function makeConfigHome(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-config-")); +} + function fileTexts(file: string): string[] { return fs .readFileSync(file, "utf8") @@ -34,6 +40,7 @@ function openWriterForTest(root: string, instanceId: string) { function handlersWith( env: NodeJS.ProcessEnv, root: string, + configHome: string = makeConfigHome(), ): Map void> { const registered: Array<[string, unknown]> = []; const pi = { @@ -54,6 +61,8 @@ function handlersWith( now: () => 1700000000000, agentDir: path.join(root, "agent"), sessionsRoot: path.join(root, "sessions"), + // Never read a developer's real Customize preference. + gentlePiConfigHome: configHome, }); return new Map( registered.map(([event, handler]) => [ @@ -64,8 +73,12 @@ function handlersWith( } /** Load the extension against a temp root and return the capture handler. */ -function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { - const handler = handlersWith(env, root).get("before_agent_start"); +function captureHandlerWith( + env: NodeJS.ProcessEnv, + root: string, + configHome?: string, +) { + const handler = handlersWith(env, root, configHome).get("before_agent_start"); assert.ok(handler, "the capture handler is registered"); return handler; } @@ -160,7 +173,12 @@ test("the extension entry registers exactly the slice-6 wiring surface", () => { commands.push([name, def]); }, }; - promptHistoryExtension(pi as never); + // Capture off (explicit env, empty config home) keeps the warm-up from + // touching the real store root while the defaults are exercised. + promptHistoryExtension(pi as never, { + env: { GENTLE_PI_HISTORY_CAPTURE: "off" }, + gentlePiConfigHome: makeConfigHome(), + }); assert.deepEqual( registered.map(([event]) => event), ["before_agent_start", "session_shutdown", "tool_call"], @@ -175,7 +193,7 @@ test("the extension entry registers exactly the slice-6 wiring surface", () => { }); test("captureEnabled is a strict opt-in", () => { - assert.equal(captureEnabled({}), false); + assert.equal(captureEnabled({}, makeConfigHome()), 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); @@ -241,6 +259,61 @@ test("disabling capture stops new lines and leaves existing files alone", () => assert.deepEqual(fileTexts(file), ["kept"]); }); +test("the Customize preference enables capture without the env switch and toggles live", () => { + const root = makeRoot(); + const configHome = makeConfigHome(); + const handler = captureHandlerWith({}, root, configHome); + handler({ prompt: "before opt-in" }); + assert.deepEqual(fs.readdirSync(root), []); + writeHistoryCapturePolicy("on", { gentlePiConfigHome: configHome }); + handler({ prompt: "captured" }); + const file = sessionFilePath(root, CWD, "inst-entry"); + assert.deepEqual(fileTexts(file), ["captured"]); + // Turning the preference off stops new lines and keeps stored history. + writeHistoryCapturePolicy("off", { gentlePiConfigHome: configHome }); + handler({ prompt: "never written" }); + assert.deepEqual(fileTexts(file), ["captured"]); +}); + +test("an explicit env value overrides the Customize preference", () => { + const root = makeRoot(); + const configHome = makeConfigHome(); + writeHistoryCapturePolicy("on", { gentlePiConfigHome: configHome }); + captureHandlerWith({ GENTLE_PI_HISTORY_CAPTURE: "0" }, root, configHome)({ prompt: "forced off" }); + assert.deepEqual(fs.readdirSync(root), []); + writeHistoryCapturePolicy("off", { gentlePiConfigHome: configHome }); + captureHandlerWith({ GENTLE_PI_HISTORY_CAPTURE: "On" }, root, configHome)({ prompt: "forced on" }); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "inst-entry")), ["forced on"]); +}); + +test("a malformed Customize preference fails closed", () => { + const root = makeRoot(); + const configHome = makeConfigHome(); + fs.writeFileSync(path.join(configHome, "history-capture.json"), '{"policy":"on"}'); + captureHandlerWith({}, root, configHome)({ prompt: "not captured" }); + assert.deepEqual(fs.readdirSync(root), []); + assert.equal(captureEnabled({}, configHome), false); +}); + +test("the extension resolves the preference under GENTLE_PI_CONFIG_HOME by default", () => { + const root = makeRoot(); + const configHome = makeConfigHome(); + writeHistoryCapturePolicy("on", { gentlePiConfigHome: configHome }); + const registered: Array<[string, unknown]> = []; + promptHistoryExtension({ on: (event: string, handler: unknown) => registered.push([event, handler]), registerShortcut: () => {}, registerCommand: () => {} } as never, { + env: { GENTLE_PI_CONFIG_HOME: configHome }, + root, + cwd: CWD, + instanceId: "inst-entry", + now: () => 1700000000000, + agentDir: path.join(root, "agent"), + sessionsRoot: path.join(root, "sessions"), + }); + const capture = registered.find(([event]) => event === "before_agent_start")![1] as (event: unknown) => void; + capture({ prompt: "from config home" }); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "inst-entry")), ["from config home"]); +}); + test("session_shutdown GC is a no-op while capture is off", () => { const root = makeRoot(); const dir = fillProjectDir(root, 60);