pi-input-history 1.0.0 → 1.1.0
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 +56 -10
- package/assets/screenshot.png +0 -0
- package/index.ts +297 -59
- 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,31 +29,77 @@ 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
|
-
3. Matched characters are highlighted with your theme's accent color.
|
|
37
|
-
4.
|
|
36
|
+
3. Matched characters are highlighted with your theme's accent color, using **minimal-span** matching so the highlight stays on the closest contiguous group (e.g. `in` highlights `in` in `input`, not `pi`'s `i` + `input`'s `n`).
|
|
37
|
+
4. The preview viewport shows the matched record; long lines are **soft-wrapped** (never truncated) and the viewport grows with content up to your terminal's height, then scrolls.
|
|
38
|
+
5. The first matched line is marked with `▸`; the viewport is separated from the input line by a dim divider.
|
|
39
|
+
6. Navigate and accept:
|
|
38
40
|
|
|
39
41
|
| Key | Action |
|
|
40
42
|
| --- | --- |
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
+
| search shortcut / `↑` | Cycle to older match |
|
|
44
|
+
| newer shortcut / `↓` | Cycle to newer match |
|
|
45
|
+
| `ctrl+k` / `ctrl+j` | Scroll the preview viewport (when it exceeds the terminal height) |
|
|
43
46
|
| `Enter` | Accept match into editor |
|
|
44
47
|
| `Esc` / `Ctrl+G` | Cancel |
|
|
45
48
|
|
|
49
|
+
Defaults: search = `ctrl+r`, newer = `ctrl+s`, scroll up = `ctrl+k`, scroll down = `ctrl+j`.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
Optional config at `~/.pi/agent/pi-input-history.json`:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"searchShortcut": "ctrl+r",
|
|
58
|
+
"newerShortcut": "ctrl+s",
|
|
59
|
+
"scrollUpShortcut": "ctrl+k",
|
|
60
|
+
"scrollDownShortcut": "ctrl+j"
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Field | Default | Description |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `searchShortcut` | `ctrl+r` | Open reverse search; press again in the overlay to cycle older |
|
|
67
|
+
| `newerShortcut` | `ctrl+s` | In the overlay, cycle to a newer match |
|
|
68
|
+
| `scrollUpShortcut` | `ctrl+k` | In the overlay, scroll the preview viewport up |
|
|
69
|
+
| `scrollDownShortcut` | `ctrl+j` | In the overlay, scroll the preview viewport down |
|
|
70
|
+
|
|
71
|
+
Omit the file or any field to keep the default. After editing, run `/reload` in pi.
|
|
72
|
+
|
|
73
|
+
### Shortcut conflict with `app.session.rename`
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
To silence the warning while keeping reverse search on Ctrl+R, rebind rename in `~/.pi/agent/keybindings.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"app.session.rename": "ctrl+shift+r"
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or change `searchShortcut` in `pi-input-history.json` to another chord.
|
|
86
|
+
|
|
46
87
|
## Features
|
|
47
88
|
|
|
48
89
|
- **Cross-session persistence** — history survives across sessions automatically.
|
|
49
90
|
- **Fuzzy subsequence matching** — type partial characters in order, multi-token support with spaces.
|
|
50
|
-
- **
|
|
91
|
+
- **Minimal-span highlighting** — matched characters form the closest contiguous group, so `in` highlights `input`, not scattered chars.
|
|
92
|
+
- **Soft-wrapped preview** — long lines wrap to multiple lines instead of being truncated with `...`.
|
|
93
|
+
- **Adaptive viewport height** — the preview grows with content up to the terminal height, then scrolls.
|
|
94
|
+
- **Scrollable viewport** — `ctrl+k` / `ctrl+j` to browse the whole record.
|
|
95
|
+
- **Match markers** — the first matched line is marked with `▸`, separated by a dim divider.
|
|
51
96
|
- **Deduplication** — no duplicate entries across sessions.
|
|
52
97
|
- **Current session awareness** — merges live branch history with cached cross-session history.
|
|
98
|
+
- **Configurable shortcuts** — override via `pi-input-history.json`.
|
|
53
99
|
|
|
54
100
|
## Acknowledgments
|
|
55
101
|
|
|
56
|
-
The
|
|
102
|
+
The reverse search component is inspired by [pi-readline-search](https://github.com/mrshu/pi-readline-search) by [@mrshu](https://github.com/mrshu).
|
|
57
103
|
|
|
58
104
|
## License
|
|
59
105
|
|
package/assets/screenshot.png
CHANGED
|
Binary file
|
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";
|
|
@@ -22,16 +28,66 @@ import {
|
|
|
22
28
|
Key,
|
|
23
29
|
matchesKey,
|
|
24
30
|
truncateToWidth,
|
|
31
|
+
visibleWidth,
|
|
25
32
|
type Component,
|
|
26
33
|
type Focusable,
|
|
34
|
+
type KeyId,
|
|
27
35
|
type TUI,
|
|
28
36
|
} from "@earendil-works/pi-tui";
|
|
29
37
|
|
|
30
38
|
const MAX_MESSAGES = 100;
|
|
39
|
+
const DEFAULT_SEARCH_SHORTCUT: KeyId = "ctrl+r";
|
|
40
|
+
const DEFAULT_NEWER_SHORTCUT: KeyId = "ctrl+s";
|
|
41
|
+
const DEFAULT_SCROLL_UP_SHORTCUT: KeyId = "ctrl+k";
|
|
42
|
+
const DEFAULT_SCROLL_DOWN_SHORTCUT: KeyId = "ctrl+j";
|
|
43
|
+
/** Visible preview lines in the reverse-search viewport. */
|
|
44
|
+
const PREVIEW_LINES = 3;
|
|
45
|
+
|
|
46
|
+
type Config = {
|
|
47
|
+
searchShortcut: KeyId;
|
|
48
|
+
newerShortcut: KeyId;
|
|
49
|
+
scrollUpShortcut: KeyId;
|
|
50
|
+
scrollDownShortcut: KeyId;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function normalizeKey(value: unknown, fallback: KeyId): KeyId {
|
|
54
|
+
if (typeof value !== "string") return fallback;
|
|
55
|
+
const s = value.trim().toLowerCase();
|
|
56
|
+
return s.length > 0 ? (s as KeyId) : fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadConfig(): Config {
|
|
60
|
+
try {
|
|
61
|
+
const path = join(getAgentDir(), "pi-input-history.json");
|
|
62
|
+
if (!existsSync(path)) {
|
|
63
|
+
return {
|
|
64
|
+
searchShortcut: DEFAULT_SEARCH_SHORTCUT,
|
|
65
|
+
newerShortcut: DEFAULT_NEWER_SHORTCUT,
|
|
66
|
+
scrollUpShortcut: DEFAULT_SCROLL_UP_SHORTCUT,
|
|
67
|
+
scrollDownShortcut: DEFAULT_SCROLL_DOWN_SHORTCUT,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
|
71
|
+
return {
|
|
72
|
+
searchShortcut: normalizeKey(raw.searchShortcut, DEFAULT_SEARCH_SHORTCUT),
|
|
73
|
+
newerShortcut: normalizeKey(raw.newerShortcut, DEFAULT_NEWER_SHORTCUT),
|
|
74
|
+
scrollUpShortcut: normalizeKey(raw.scrollUpShortcut, DEFAULT_SCROLL_UP_SHORTCUT),
|
|
75
|
+
scrollDownShortcut: normalizeKey(raw.scrollDownShortcut, DEFAULT_SCROLL_DOWN_SHORTCUT),
|
|
76
|
+
};
|
|
77
|
+
} catch {
|
|
78
|
+
return {
|
|
79
|
+
searchShortcut: DEFAULT_SEARCH_SHORTCUT,
|
|
80
|
+
newerShortcut: DEFAULT_NEWER_SHORTCUT,
|
|
81
|
+
scrollUpShortcut: DEFAULT_SCROLL_UP_SHORTCUT,
|
|
82
|
+
scrollDownShortcut: DEFAULT_SCROLL_DOWN_SHORTCUT,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
31
86
|
|
|
32
87
|
// ─── Extension Entry ───────────────────────────────────────────────────────────
|
|
33
88
|
|
|
34
89
|
export default function (pi: ExtensionAPI) {
|
|
90
|
+
const config = loadConfig();
|
|
35
91
|
let historyCache: string[] = [];
|
|
36
92
|
|
|
37
93
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -53,11 +109,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
53
109
|
});
|
|
54
110
|
});
|
|
55
111
|
|
|
56
|
-
|
|
57
|
-
pi.registerShortcut("ctrl+r", {
|
|
112
|
+
pi.registerShortcut(config.searchShortcut, {
|
|
58
113
|
description: "Reverse search through prompt history",
|
|
59
114
|
handler: async (ctx) => {
|
|
60
|
-
// Merge cached history with current session's branch history
|
|
61
115
|
const branchHistory = collectBranchHistory(ctx);
|
|
62
116
|
const merged = mergeHistory(branchHistory, historyCache);
|
|
63
117
|
|
|
@@ -67,7 +121,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
67
121
|
}
|
|
68
122
|
|
|
69
123
|
const selected = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
70
|
-
return new ReverseSearchComponent(tui, theme, merged, done);
|
|
124
|
+
return new ReverseSearchComponent(tui, theme, merged, done, config);
|
|
71
125
|
}, { overlay: true, overlayOptions: { anchor: "bottom-center", width: "100%" } });
|
|
72
126
|
|
|
73
127
|
if (selected === null) return;
|
|
@@ -98,57 +152,205 @@ function fuzzyMatch(item: string, query: string): boolean {
|
|
|
98
152
|
return tokens.every((t) => subsequence(lower, t));
|
|
99
153
|
}
|
|
100
154
|
|
|
101
|
-
|
|
102
|
-
|
|
155
|
+
/** Find the indices matching `token` as a subsequence with the smallest spread. */
|
|
156
|
+
function bestSubsequenceSpan(text: string, token: string): number[] {
|
|
157
|
+
const positions: number[][] = [];
|
|
158
|
+
for (const ch of token) {
|
|
159
|
+
const idxs: number[] = [];
|
|
160
|
+
for (let i = 0; i < text.length; i++) if (text[i] === ch) idxs.push(i);
|
|
161
|
+
positions.push(idxs);
|
|
162
|
+
}
|
|
163
|
+
if (positions.some((arr) => arr.length === 0)) return [];
|
|
164
|
+
|
|
165
|
+
const lowerBound = (arr: number[], min: number): number => {
|
|
166
|
+
let lo = 0;
|
|
167
|
+
let hi = arr.length;
|
|
168
|
+
while (lo < hi) {
|
|
169
|
+
const mid = (lo + hi) >> 1;
|
|
170
|
+
if (arr[mid]! < min) lo = mid + 1;
|
|
171
|
+
else hi = mid;
|
|
172
|
+
}
|
|
173
|
+
return lo;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
let bestSpan = Infinity;
|
|
177
|
+
let bestIdx: number[] = [];
|
|
178
|
+
for (const c0 of positions[0]!) {
|
|
179
|
+
const cur = [c0];
|
|
180
|
+
let prev = c0;
|
|
181
|
+
let ok = true;
|
|
182
|
+
for (let t = 1; t < token.length; t++) {
|
|
183
|
+
const arr = positions[t]!;
|
|
184
|
+
const p = lowerBound(arr, prev + 1);
|
|
185
|
+
if (p >= arr.length) {
|
|
186
|
+
ok = false;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
const nxt = arr[p]!;
|
|
190
|
+
cur.push(nxt);
|
|
191
|
+
prev = nxt;
|
|
192
|
+
}
|
|
193
|
+
if (!ok) continue;
|
|
194
|
+
const span = cur[cur.length - 1]! - c0;
|
|
195
|
+
if (span < bestSpan) {
|
|
196
|
+
bestSpan = span;
|
|
197
|
+
bestIdx = cur;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return bestIdx;
|
|
103
201
|
}
|
|
104
202
|
|
|
105
|
-
/**
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const plain = truncated.replace(/\x1b\[[0-9;]*m/g, "");
|
|
111
|
-
|
|
112
|
-
if (!query) return theme.fg("text", plain);
|
|
113
|
-
|
|
114
|
-
// Find positions of subsequence-matched characters
|
|
115
|
-
const lower = plain.toLowerCase();
|
|
203
|
+
/** Collect character indices (in `text`) matched by each query token (smallest-spread subsequence). */
|
|
204
|
+
function collectMatchPositions(text: string, query: string): Set<number> {
|
|
205
|
+
const positions = new Set<number>();
|
|
206
|
+
if (!query) return positions;
|
|
207
|
+
const lower = text.toLowerCase();
|
|
116
208
|
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
117
|
-
const matchPositions = new Set<number>();
|
|
118
|
-
|
|
119
209
|
for (const token of tokens) {
|
|
120
|
-
|
|
121
|
-
for (
|
|
122
|
-
const idx = lower.indexOf(token[ni], hi);
|
|
123
|
-
if (idx !== -1) {
|
|
124
|
-
matchPositions.add(idx);
|
|
125
|
-
hi = idx + 1;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
210
|
+
const best = bestSubsequenceSpan(lower, token);
|
|
211
|
+
for (const idx of best) positions.add(idx);
|
|
128
212
|
}
|
|
213
|
+
return positions;
|
|
214
|
+
}
|
|
129
215
|
|
|
130
|
-
|
|
216
|
+
/** Underline + accent-highlight matched characters; plain text for the rest. */
|
|
217
|
+
function highlightSegments(text: string, positions: Set<number>, theme: any): string {
|
|
131
218
|
let result = "";
|
|
132
219
|
let i = 0;
|
|
133
|
-
while (i <
|
|
134
|
-
if (
|
|
135
|
-
// Collect consecutive matched chars
|
|
220
|
+
while (i < text.length) {
|
|
221
|
+
if (positions.has(i)) {
|
|
136
222
|
let j = i;
|
|
137
|
-
while (j <
|
|
138
|
-
|
|
139
|
-
result += `\x1b[4m${theme.fg("accent", plain.slice(i, j))}\x1b[24m`;
|
|
223
|
+
while (j < text.length && positions.has(j)) j++;
|
|
224
|
+
result += `\x1b[4m${theme.fg("accent", text.slice(i, j))}\x1b[24m`;
|
|
140
225
|
i = j;
|
|
141
226
|
} else {
|
|
142
|
-
// Collect consecutive non-matched chars
|
|
143
227
|
let j = i;
|
|
144
|
-
while (j <
|
|
145
|
-
result += theme.fg("text",
|
|
228
|
+
while (j < text.length && !positions.has(j)) j++;
|
|
229
|
+
result += theme.fg("text", text.slice(i, j));
|
|
146
230
|
i = j;
|
|
147
231
|
}
|
|
148
232
|
}
|
|
149
233
|
return result;
|
|
150
234
|
}
|
|
151
235
|
|
|
236
|
+
const GRAPHEME_SEGMENTER =
|
|
237
|
+
typeof Intl !== "undefined" && typeof (Intl as any).Segmenter === "function"
|
|
238
|
+
? new (Intl as any).Segmenter(undefined, { granularity: "grapheme" })
|
|
239
|
+
: null;
|
|
240
|
+
|
|
241
|
+
type WrappedLine = { text: string; start: number; end: number };
|
|
242
|
+
|
|
243
|
+
/** Soft-wrap `text` to `maxWidth` columns; `\n` forces a hard break; content is never truncated. */
|
|
244
|
+
function wrapText(text: string, maxWidth: number): WrappedLine[] {
|
|
245
|
+
if (maxWidth <= 0) return [{ text: "", start: 0, end: 0 }];
|
|
246
|
+
const lines: WrappedLine[] = [];
|
|
247
|
+
let cur = "";
|
|
248
|
+
let curW = 0;
|
|
249
|
+
let curStart = 0;
|
|
250
|
+
const push = (wLine: WrappedLine) => lines.push(wLine);
|
|
251
|
+
if (GRAPHEME_SEGMENTER) {
|
|
252
|
+
for (const { segment, index } of GRAPHEME_SEGMENTER.segment(text)) {
|
|
253
|
+
if (segment === "\n") {
|
|
254
|
+
push({ text: cur, start: curStart, end: index });
|
|
255
|
+
cur = "";
|
|
256
|
+
curW = 0;
|
|
257
|
+
curStart = index + 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const w = visibleWidth(segment);
|
|
261
|
+
if (curW + w > maxWidth && curW > 0) {
|
|
262
|
+
push({ text: cur, start: curStart, end: index });
|
|
263
|
+
cur = segment;
|
|
264
|
+
curW = w;
|
|
265
|
+
curStart = index;
|
|
266
|
+
} else {
|
|
267
|
+
cur += segment;
|
|
268
|
+
curW += w;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
for (let idx = 0; idx < text.length; ) {
|
|
273
|
+
const cp = text.codePointAt(idx)!;
|
|
274
|
+
const ch = String.fromCodePoint(cp);
|
|
275
|
+
if (ch === "\n") {
|
|
276
|
+
push({ text: cur, start: curStart, end: idx });
|
|
277
|
+
cur = "";
|
|
278
|
+
curW = 0;
|
|
279
|
+
curStart = idx + 1;
|
|
280
|
+
idx += 1;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const w = visibleWidth(ch);
|
|
284
|
+
if (curW + w > maxWidth && curW > 0) {
|
|
285
|
+
push({ text: cur, start: curStart, end: idx });
|
|
286
|
+
cur = ch;
|
|
287
|
+
curW = w;
|
|
288
|
+
curStart = idx;
|
|
289
|
+
} else {
|
|
290
|
+
cur += ch;
|
|
291
|
+
curW += w;
|
|
292
|
+
}
|
|
293
|
+
idx += ch.length;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (curW > 0 || text.length === 0) {
|
|
297
|
+
push({ text: cur, start: curStart, end: text.length });
|
|
298
|
+
} else if (text.endsWith("\n") && lines.length > 0) {
|
|
299
|
+
push({ text: "", start: text.length, end: text.length });
|
|
300
|
+
}
|
|
301
|
+
return lines;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Highlight the matched positions within one wrapped line. */
|
|
305
|
+
function renderWrappedLine(line: WrappedLine, matchPositions: Set<number>, theme: any): string {
|
|
306
|
+
const local = new Set<number>();
|
|
307
|
+
for (const p of matchPositions) {
|
|
308
|
+
if (p >= line.start && p < line.end) local.add(p - line.start);
|
|
309
|
+
}
|
|
310
|
+
return local.size === 0 ? theme.fg("text", line.text) : highlightSegments(line.text, local, theme);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Render the viewport over wrapped lines; mark the first matched line with `▸`. */
|
|
314
|
+
function renderWrappedLines(
|
|
315
|
+
lines: WrappedLine[],
|
|
316
|
+
scroll: number,
|
|
317
|
+
viewportLines: number,
|
|
318
|
+
markIndex: number,
|
|
319
|
+
matchPositions: Set<number>,
|
|
320
|
+
theme: any,
|
|
321
|
+
): string[] {
|
|
322
|
+
const out: string[] = [];
|
|
323
|
+
for (let k = 0; k < viewportLines; k++) {
|
|
324
|
+
const ln = lines[scroll + k];
|
|
325
|
+
const text = ln ? renderWrappedLine(ln, matchPositions, theme) : "";
|
|
326
|
+
const arrow = ln && scroll + k === markIndex ? "▸ " : "";
|
|
327
|
+
out.push(arrow + text);
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Wrap a (CRLF-normalized) record, locate the first matched line, and collect match positions. */
|
|
333
|
+
function buildWrappedMatch(
|
|
334
|
+
record: string,
|
|
335
|
+
query: string,
|
|
336
|
+
maxWidth: number,
|
|
337
|
+
): { lines: WrappedLine[]; anchor: number; positions: Set<number> } {
|
|
338
|
+
const normalized = record.replace(/\r\n/g, "\n");
|
|
339
|
+
const lines = wrapText(normalized, maxWidth);
|
|
340
|
+
const positions = collectMatchPositions(normalized, query);
|
|
341
|
+
let anchor = 0;
|
|
342
|
+
if (positions.size > 0) {
|
|
343
|
+
const first = Math.min(...positions);
|
|
344
|
+
for (let i = 0; i < lines.length; i++) {
|
|
345
|
+
if (first >= lines[i]!.start && first < lines[i]!.end) {
|
|
346
|
+
anchor = i;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { lines, anchor, positions };
|
|
352
|
+
}
|
|
353
|
+
|
|
152
354
|
class ReverseSearchComponent implements Component, Focusable {
|
|
153
355
|
private _focused = false;
|
|
154
356
|
private readonly input = new Input();
|
|
@@ -156,12 +358,15 @@ class ReverseSearchComponent implements Component, Focusable {
|
|
|
156
358
|
private query = "";
|
|
157
359
|
private matchIndices: number[] = [];
|
|
158
360
|
private matchPointer = 0;
|
|
361
|
+
private previewScroll = 0;
|
|
362
|
+
private previewAutoLocate = true;
|
|
159
363
|
|
|
160
364
|
constructor(
|
|
161
365
|
private readonly tui: TUI,
|
|
162
366
|
private readonly theme: any,
|
|
163
367
|
private readonly history: string[],
|
|
164
368
|
private readonly done: Done,
|
|
369
|
+
private readonly config: Config,
|
|
165
370
|
) {
|
|
166
371
|
this.input.onEscape = () => this.done(null);
|
|
167
372
|
this.input.onSubmit = () => {
|
|
@@ -192,6 +397,7 @@ class ReverseSearchComponent implements Component, Focusable {
|
|
|
192
397
|
if (this.matchPointer >= this.matchIndices.length) {
|
|
193
398
|
this.matchPointer = Math.max(0, this.matchIndices.length - 1);
|
|
194
399
|
}
|
|
400
|
+
this.previewAutoLocate = true;
|
|
195
401
|
}
|
|
196
402
|
|
|
197
403
|
private getCurrentMatch(): string | undefined {
|
|
@@ -203,21 +409,23 @@ class ReverseSearchComponent implements Component, Focusable {
|
|
|
203
409
|
private cycleOlder(): void {
|
|
204
410
|
if (this.matchIndices.length === 0) return;
|
|
205
411
|
this.matchPointer = (this.matchPointer + 1) % this.matchIndices.length;
|
|
412
|
+
this.previewAutoLocate = true;
|
|
206
413
|
}
|
|
207
414
|
|
|
208
415
|
private cycleNewer(): void {
|
|
209
416
|
if (this.matchIndices.length === 0) return;
|
|
210
417
|
this.matchPointer = (this.matchPointer - 1 + this.matchIndices.length) % this.matchIndices.length;
|
|
418
|
+
this.previewAutoLocate = true;
|
|
211
419
|
}
|
|
212
420
|
|
|
213
421
|
handleInput(data: string): void {
|
|
214
|
-
if (matchesKey(data,
|
|
422
|
+
if (matchesKey(data, this.config.searchShortcut) || matchesKey(data, Key.up)) {
|
|
215
423
|
this.cycleOlder();
|
|
216
424
|
this.tui.requestRender();
|
|
217
425
|
return;
|
|
218
426
|
}
|
|
219
427
|
|
|
220
|
-
if (matchesKey(data,
|
|
428
|
+
if (matchesKey(data, this.config.newerShortcut) || matchesKey(data, Key.down)) {
|
|
221
429
|
this.cycleNewer();
|
|
222
430
|
this.tui.requestRender();
|
|
223
431
|
return;
|
|
@@ -228,6 +436,20 @@ class ReverseSearchComponent implements Component, Focusable {
|
|
|
228
436
|
return;
|
|
229
437
|
}
|
|
230
438
|
|
|
439
|
+
if (matchesKey(data, this.config.scrollUpShortcut)) {
|
|
440
|
+
this.previewAutoLocate = false;
|
|
441
|
+
this.previewScroll = Math.max(0, this.previewScroll - 1);
|
|
442
|
+
this.tui.requestRender();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (matchesKey(data, this.config.scrollDownShortcut)) {
|
|
447
|
+
this.previewAutoLocate = false;
|
|
448
|
+
this.previewScroll += 1;
|
|
449
|
+
this.tui.requestRender();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
231
453
|
const before = this.input.getValue();
|
|
232
454
|
this.input.handleInput(data);
|
|
233
455
|
const after = this.input.getValue();
|
|
@@ -244,30 +466,47 @@ class ReverseSearchComponent implements Component, Focusable {
|
|
|
244
466
|
const t = this.theme;
|
|
245
467
|
const currentMatch = this.getCurrentMatch();
|
|
246
468
|
|
|
247
|
-
// Reserve space for prefix and counter (use fixed max counter width)
|
|
248
469
|
const prefix = "(reverse-search) ";
|
|
249
|
-
const maxCounterWidth = 10;
|
|
470
|
+
const maxCounterWidth = 10;
|
|
250
471
|
const availableWidth = Math.max(10, width - prefix.length - maxCounterWidth);
|
|
251
472
|
|
|
252
473
|
const counterText = this.matchIndices.length > 0
|
|
253
474
|
? ` [${this.matchPointer + 1}/${this.matchIndices.length}]`
|
|
254
475
|
: " [0/0]";
|
|
255
476
|
|
|
256
|
-
const matchPreview = currentMatch
|
|
257
|
-
? highlightMatch(toSingleLinePreview(currentMatch), this.query, t, availableWidth)
|
|
258
|
-
: t.fg("warning", "no match");
|
|
259
|
-
|
|
260
477
|
const counter = t.fg("dim", counterText);
|
|
261
478
|
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
479
|
+
const lines: string[] = [];
|
|
480
|
+
if (currentMatch) {
|
|
481
|
+
lines.push(t.fg("accent", prefix) + counter);
|
|
482
|
+
const sep = t.fg("dim", "─".repeat(Math.max(1, width)));
|
|
483
|
+
lines.push(sep);
|
|
484
|
+
const { lines: wl, anchor, positions } = buildWrappedMatch(currentMatch, this.query, availableWidth);
|
|
485
|
+
const terminalRows = this.tui.terminal.rows;
|
|
486
|
+
const maxVp = Math.max(PREVIEW_LINES, terminalRows - 6);
|
|
487
|
+
const viewportLines = Math.max(PREVIEW_LINES, Math.min(wl.length, maxVp));
|
|
488
|
+
const maxScroll = Math.max(0, wl.length - viewportLines);
|
|
489
|
+
const scroll = this.previewAutoLocate
|
|
490
|
+
? Math.min(maxScroll, Math.max(0, anchor - Math.floor(viewportLines / 2)))
|
|
491
|
+
: Math.min(maxScroll, Math.max(0, this.previewScroll));
|
|
492
|
+
this.previewScroll = scroll;
|
|
493
|
+
lines.push(...renderWrappedLines(wl, scroll, viewportLines, anchor, positions, t));
|
|
494
|
+
lines.push(sep);
|
|
495
|
+
} else {
|
|
496
|
+
lines.push(t.fg("accent", prefix) + t.fg("warning", "no match") + counter);
|
|
497
|
+
}
|
|
266
498
|
|
|
267
499
|
const inputLine = truncateToWidth(this.input.render(width)[0] ?? "", width);
|
|
268
|
-
const help = truncateToWidth(
|
|
500
|
+
const help = truncateToWidth(
|
|
501
|
+
t.fg(
|
|
502
|
+
"dim",
|
|
503
|
+
`${this.config.searchShortcut}/↑ older • ${this.config.newerShortcut}/↓ newer • ${this.config.scrollUpShortcut}/${this.config.scrollDownShortcut} scroll • enter accept • esc cancel`,
|
|
504
|
+
),
|
|
505
|
+
width,
|
|
506
|
+
);
|
|
269
507
|
|
|
270
|
-
|
|
508
|
+
lines.push(inputLine, help);
|
|
509
|
+
return lines.map((l) => truncateToWidth(l, width));
|
|
271
510
|
}
|
|
272
511
|
|
|
273
512
|
invalidate(): void {
|
|
@@ -350,7 +589,6 @@ function extractUserMessages(sessionPath: string): string[] {
|
|
|
350
589
|
const text = extractText(entry.message.content);
|
|
351
590
|
if (text) messages.push(text);
|
|
352
591
|
}
|
|
353
|
-
// Reverse so newest messages come first within each session
|
|
354
592
|
return messages.reverse();
|
|
355
593
|
} catch {
|
|
356
594
|
return [];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-input-history",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Cross-session prompt history and fuzzy
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Cross-session prompt history and fuzzy reverse search for pi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|