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.
- package/README.md +42 -7
- package/index.ts +63 -24
- 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
|
|
3
|
+
**Cross-session prompt history and fuzzy reverse search for pi.**
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/pi-input-history)
|
|
6
6
|
[](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**
|
|
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
|

|
|
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
|
|
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
|
-
|
|
|
42
|
-
|
|
|
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
|
|
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 +
|
|
2
|
+
* Persistent History + Reverse Search
|
|
3
3
|
*
|
|
4
4
|
* - Loads recent prompts from previous sessions into up/down history on startup.
|
|
5
|
-
* -
|
|
5
|
+
* - Configurable reverse search overlay (fuzzy subsequence matching).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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;
|
|
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(
|
|
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.
|
|
4
|
-
"description": "Cross-session prompt history and fuzzy
|
|
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",
|