pi-input-history 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +42 -7
  2. package/index.ts +63 -24
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # pi-input-history
2
2
 
3
- **Cross-session prompt history and fuzzy Ctrl+R search for pi.**
3
+ **Cross-session prompt history and fuzzy reverse search for pi.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/pi-input-history?style=for-the-badge)](https://www.npmjs.com/package/pi-input-history)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
7
7
 
8
8
  ## Why
9
9
 
10
- Pi's built-in ↑/↓ history only covers the current session and is lost on reload. This extension persists your last 100 prompts across sessions and adds fuzzy **Ctrl+R** search to find any past prompt instantly.
10
+ Pi's built-in ↑/↓ history only covers the current session and is lost on reload. This extension persists your last 100 prompts across sessions and adds fuzzy reverse search (default **Ctrl+R**) to find any past prompt instantly.
11
11
 
12
12
  ![Ctrl+R reverse search](assets/screenshot.png)
13
13
 
@@ -29,20 +29,54 @@ pi install git:github.com/ouzhenkun/pi-input-history
29
29
 
30
30
  On session start, your last 100 prompts across all sessions are loaded into the editor. Use **↑/↓** arrows to browse them as usual.
31
31
 
32
- ### Reverse Search (Ctrl+R)
32
+ ### Reverse Search
33
33
 
34
- 1. Press **Ctrl+R** to open the search overlay.
34
+ 1. Press the search shortcut (default **Ctrl+R**) to open the search overlay.
35
35
  2. Type to fuzzy-filter history (subsequence matching, space-separated multi-token).
36
36
  3. Matched characters are highlighted with your theme's accent color.
37
37
  4. Navigate and accept:
38
38
 
39
39
  | Key | Action |
40
40
  | --- | --- |
41
- | `Ctrl+R` / `↑` | Cycle to older match |
42
- | `Ctrl+S` / `↓` | Cycle to newer match |
41
+ | search shortcut / `↑` | Cycle to older match |
42
+ | newer shortcut / `↓` | Cycle to newer match |
43
43
  | `Enter` | Accept match into editor |
44
44
  | `Esc` / `Ctrl+G` | Cancel |
45
45
 
46
+ Defaults: search = `ctrl+r`, newer = `ctrl+s`.
47
+
48
+ ## Configuration
49
+
50
+ Optional config at `~/.pi/agent/pi-input-history.json`:
51
+
52
+ ```json
53
+ {
54
+ "searchShortcut": "ctrl+r",
55
+ "newerShortcut": "ctrl+s"
56
+ }
57
+ ```
58
+
59
+ | Field | Default | Description |
60
+ | --- | --- | --- |
61
+ | `searchShortcut` | `ctrl+r` | Open reverse search; press again in the overlay to cycle older |
62
+ | `newerShortcut` | `ctrl+s` | In the overlay, cycle to a newer match |
63
+
64
+ Omit the file or any field to keep the default. After editing, run `/reload` in pi.
65
+
66
+ ### Shortcut conflict with `app.session.rename`
67
+
68
+ Pi binds `app.session.rename` to `ctrl+r` by default (session picker). This extension can still use `ctrl+r`; pi logs a non-fatal conflict warning and prefers the extension shortcut at the editor level.
69
+
70
+ To silence the warning while keeping reverse search on Ctrl+R, rebind rename in `~/.pi/agent/keybindings.json`:
71
+
72
+ ```json
73
+ {
74
+ "app.session.rename": "ctrl+shift+r"
75
+ }
76
+ ```
77
+
78
+ Or change `searchShortcut` in `pi-input-history.json` to another chord.
79
+
46
80
  ## Features
47
81
 
48
82
  - **Cross-session persistence** — history survives across sessions automatically.
@@ -50,10 +84,11 @@ On session start, your last 100 prompts across all sessions are loaded into the
50
84
  - **Character-level highlighting** — matched positions shown with accent color underline.
51
85
  - **Deduplication** — no duplicate entries across sessions.
52
86
  - **Current session awareness** — merges live branch history with cached cross-session history.
87
+ - **Configurable shortcuts** — override via `pi-input-history.json`.
53
88
 
54
89
  ## Acknowledgments
55
90
 
56
- The Ctrl+R reverse search component is inspired by [pi-readline-search](https://github.com/mrshu/pi-readline-search) by [@mrshu](https://github.com/mrshu).
91
+ The reverse search component is inspired by [pi-readline-search](https://github.com/mrshu/pi-readline-search) by [@mrshu](https://github.com/mrshu).
57
92
 
58
93
  ## License
59
94
 
package/index.ts CHANGED
@@ -1,19 +1,25 @@
1
1
  /**
2
- * Persistent History + Ctrl+R Reverse Search
2
+ * Persistent History + Reverse Search
3
3
  *
4
4
  * - Loads recent prompts from previous sessions into up/down history on startup.
5
- * - Ctrl+R opens a reverse search overlay (fuzzy subsequence matching).
5
+ * - Configurable reverse search overlay (fuzzy subsequence matching).
6
6
  *
7
- * Hotkeys while searching:
8
- * - Ctrl+R / ↑ : older match
9
- * - Ctrl+S / ↓ : newer match
10
- * - Enter : accept match (fills editor)
11
- * - Esc/Ctrl+G : cancel
7
+ * Config: ~/.pi/agent/pi-input-history.json
8
+ * { "searchShortcut": "ctrl+r", "newerShortcut": "ctrl+s" }
9
+ *
10
+ * Hotkeys while searching (defaults):
11
+ * - searchShortcut / : older match
12
+ * - newerShortcut / ↓ : newer match
13
+ * - Enter : accept match (fills editor)
14
+ * - Esc/Ctrl+G : cancel
12
15
  */
13
16
 
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
14
19
  import {
15
20
  CustomEditor,
16
21
  SessionManager,
22
+ getAgentDir,
17
23
  type ExtensionAPI,
18
24
  } from "@earendil-works/pi-coding-agent";
19
25
  import type { UserMessage } from "@earendil-works/pi-ai";
@@ -24,14 +30,51 @@ import {
24
30
  truncateToWidth,
25
31
  type Component,
26
32
  type Focusable,
33
+ type KeyId,
27
34
  type TUI,
28
35
  } from "@earendil-works/pi-tui";
29
36
 
30
37
  const MAX_MESSAGES = 100;
38
+ const DEFAULT_SEARCH_SHORTCUT: KeyId = "ctrl+r";
39
+ const DEFAULT_NEWER_SHORTCUT: KeyId = "ctrl+s";
40
+
41
+ type Config = {
42
+ searchShortcut: KeyId;
43
+ newerShortcut: KeyId;
44
+ };
45
+
46
+ function normalizeKey(value: unknown, fallback: KeyId): KeyId {
47
+ if (typeof value !== "string") return fallback;
48
+ const s = value.trim().toLowerCase();
49
+ return s.length > 0 ? (s as KeyId) : fallback;
50
+ }
51
+
52
+ function loadConfig(): Config {
53
+ try {
54
+ const path = join(getAgentDir(), "pi-input-history.json");
55
+ if (!existsSync(path)) {
56
+ return {
57
+ searchShortcut: DEFAULT_SEARCH_SHORTCUT,
58
+ newerShortcut: DEFAULT_NEWER_SHORTCUT,
59
+ };
60
+ }
61
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
62
+ return {
63
+ searchShortcut: normalizeKey(raw.searchShortcut, DEFAULT_SEARCH_SHORTCUT),
64
+ newerShortcut: normalizeKey(raw.newerShortcut, DEFAULT_NEWER_SHORTCUT),
65
+ };
66
+ } catch {
67
+ return {
68
+ searchShortcut: DEFAULT_SEARCH_SHORTCUT,
69
+ newerShortcut: DEFAULT_NEWER_SHORTCUT,
70
+ };
71
+ }
72
+ }
31
73
 
32
74
  // ─── Extension Entry ───────────────────────────────────────────────────────────
33
75
 
34
76
  export default function (pi: ExtensionAPI) {
77
+ const config = loadConfig();
35
78
  let historyCache: string[] = [];
36
79
 
37
80
  pi.on("session_start", async (_event, ctx) => {
@@ -53,11 +96,9 @@ export default function (pi: ExtensionAPI) {
53
96
  });
54
97
  });
55
98
 
56
- // Ctrl+R: reverse search
57
- pi.registerShortcut("ctrl+r", {
99
+ pi.registerShortcut(config.searchShortcut, {
58
100
  description: "Reverse search through prompt history",
59
101
  handler: async (ctx) => {
60
- // Merge cached history with current session's branch history
61
102
  const branchHistory = collectBranchHistory(ctx);
62
103
  const merged = mergeHistory(branchHistory, historyCache);
63
104
 
@@ -67,7 +108,7 @@ export default function (pi: ExtensionAPI) {
67
108
  }
68
109
 
69
110
  const selected = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
70
- return new ReverseSearchComponent(tui, theme, merged, done);
111
+ return new ReverseSearchComponent(tui, theme, merged, done, config);
71
112
  }, { overlay: true, overlayOptions: { anchor: "bottom-center", width: "100%" } });
72
113
 
73
114
  if (selected === null) return;
@@ -104,14 +145,11 @@ function toSingleLinePreview(text: string): string {
104
145
 
105
146
  /** Highlight matched characters (subsequence) with underline + accent color. */
106
147
  function highlightMatch(text: string, query: string, theme: any, maxWidth: number): string {
107
- // Truncate plain text first to ensure it fits
108
148
  const truncated = truncateToWidth(text, maxWidth);
109
- // Strip any ANSI that truncateToWidth might have added for ellipsis
110
149
  const plain = truncated.replace(/\x1b\[[0-9;]*m/g, "");
111
150
 
112
151
  if (!query) return theme.fg("text", plain);
113
152
 
114
- // Find positions of subsequence-matched characters
115
153
  const lower = plain.toLowerCase();
116
154
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
117
155
  const matchPositions = new Set<number>();
@@ -127,19 +165,15 @@ function highlightMatch(text: string, query: string, theme: any, maxWidth: numbe
127
165
  }
128
166
  }
129
167
 
130
- // Build styled string — group consecutive chars to reduce ANSI overhead
131
168
  let result = "";
132
169
  let i = 0;
133
170
  while (i < plain.length) {
134
171
  if (matchPositions.has(i)) {
135
- // Collect consecutive matched chars
136
172
  let j = i;
137
173
  while (j < plain.length && matchPositions.has(j)) j++;
138
- // Accent color + underline
139
174
  result += `\x1b[4m${theme.fg("accent", plain.slice(i, j))}\x1b[24m`;
140
175
  i = j;
141
176
  } else {
142
- // Collect consecutive non-matched chars
143
177
  let j = i;
144
178
  while (j < plain.length && !matchPositions.has(j)) j++;
145
179
  result += theme.fg("text", plain.slice(i, j));
@@ -162,6 +196,7 @@ class ReverseSearchComponent implements Component, Focusable {
162
196
  private readonly theme: any,
163
197
  private readonly history: string[],
164
198
  private readonly done: Done,
199
+ private readonly config: Config,
165
200
  ) {
166
201
  this.input.onEscape = () => this.done(null);
167
202
  this.input.onSubmit = () => {
@@ -211,13 +246,13 @@ class ReverseSearchComponent implements Component, Focusable {
211
246
  }
212
247
 
213
248
  handleInput(data: string): void {
214
- if (matchesKey(data, Key.ctrl("r")) || matchesKey(data, Key.up)) {
249
+ if (matchesKey(data, this.config.searchShortcut) || matchesKey(data, Key.up)) {
215
250
  this.cycleOlder();
216
251
  this.tui.requestRender();
217
252
  return;
218
253
  }
219
254
 
220
- if (matchesKey(data, Key.ctrl("s")) || matchesKey(data, Key.down)) {
255
+ if (matchesKey(data, this.config.newerShortcut) || matchesKey(data, Key.down)) {
221
256
  this.cycleNewer();
222
257
  this.tui.requestRender();
223
258
  return;
@@ -244,9 +279,8 @@ class ReverseSearchComponent implements Component, Focusable {
244
279
  const t = this.theme;
245
280
  const currentMatch = this.getCurrentMatch();
246
281
 
247
- // Reserve space for prefix and counter (use fixed max counter width)
248
282
  const prefix = "(reverse-search) ";
249
- const maxCounterWidth = 10; // " [xx/xx]" is enough
283
+ const maxCounterWidth = 10;
250
284
  const availableWidth = Math.max(10, width - prefix.length - maxCounterWidth);
251
285
 
252
286
  const counterText = this.matchIndices.length > 0
@@ -265,7 +299,13 @@ class ReverseSearchComponent implements Component, Focusable {
265
299
  counter;
266
300
 
267
301
  const inputLine = truncateToWidth(this.input.render(width)[0] ?? "", width);
268
- const help = truncateToWidth(t.fg("dim", "ctrl+r/↑ older • ctrl+s/↓ newer • enter accept • esc cancel"), width);
302
+ const help = truncateToWidth(
303
+ t.fg(
304
+ "dim",
305
+ `${this.config.searchShortcut}/↑ older • ${this.config.newerShortcut}/↓ newer • enter accept • esc cancel`,
306
+ ),
307
+ width,
308
+ );
269
309
 
270
310
  return [truncateToWidth(header, width), inputLine, help];
271
311
  }
@@ -350,7 +390,6 @@ function extractUserMessages(sessionPath: string): string[] {
350
390
  const text = extractText(entry.message.content);
351
391
  if (text) messages.push(text);
352
392
  }
353
- // Reverse so newest messages come first within each session
354
393
  return messages.reverse();
355
394
  } catch {
356
395
  return [];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-input-history",
3
- "version": "1.0.0",
4
- "description": "Cross-session prompt history and fuzzy Ctrl+R search for pi.",
3
+ "version": "1.0.1",
4
+ "description": "Cross-session prompt history and fuzzy reverse search for pi.",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",