Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**

Expand Down
47 changes: 39 additions & 8 deletions docs/prompt-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<configHome>/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

Expand Down Expand Up @@ -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:
Expand Down
28 changes: 27 additions & 1 deletion extensions/gentle-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 }));
Expand Down
68 changes: 46 additions & 22 deletions extensions/history/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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.";
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand All @@ -1105,11 +1129,8 @@ function createOpenFlow(env: NodeJS.ProcessEnv, root: string, cwd: string) {
): Promise<void> {
// 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;
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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());
Expand All @@ -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)],
Expand All @@ -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",
Expand Down
Loading
Loading