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 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,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 (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
- 3. Matched characters are highlighted with your theme's accent color.
37
- 4. Navigate and accept:
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
- | `Ctrl+R` / `↑` | Cycle to older match |
42
- | `Ctrl+S` / `↓` | Cycle to newer match |
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
- - **Character-level highlighting** — matched positions shown with accent color underline.
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 Ctrl+R reverse search component is inspired by [pi-readline-search](https://github.com/mrshu/pi-readline-search) by [@mrshu](https://github.com/mrshu).
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
 
Binary file
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";
@@ -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
- // Ctrl+R: reverse search
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
- function toSingleLinePreview(text: string): string {
102
- return text.replace(/\s+/g, " ").trim();
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
- /** Highlight matched characters (subsequence) with underline + accent color. */
106
- function highlightMatch(text: string, query: string, theme: any, maxWidth: number): string {
107
- // Truncate plain text first to ensure it fits
108
- const truncated = truncateToWidth(text, maxWidth);
109
- // Strip any ANSI that truncateToWidth might have added for ellipsis
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
- let hi = 0;
121
- for (let ni = 0; ni < token.length; ni++) {
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
- // Build styled string group consecutive chars to reduce ANSI overhead
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 < plain.length) {
134
- if (matchPositions.has(i)) {
135
- // Collect consecutive matched chars
220
+ while (i < text.length) {
221
+ if (positions.has(i)) {
136
222
  let j = i;
137
- while (j < plain.length && matchPositions.has(j)) j++;
138
- // Accent color + underline
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 < plain.length && !matchPositions.has(j)) j++;
145
- result += theme.fg("text", plain.slice(i, j));
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, Key.ctrl("r")) || matchesKey(data, Key.up)) {
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, Key.ctrl("s")) || matchesKey(data, Key.down)) {
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; // " [xx/xx]" is enough
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 header =
263
- t.fg("accent", prefix) +
264
- matchPreview +
265
- counter;
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(t.fg("dim", "ctrl+r/↑ older • ctrl+s/↓ newer • enter accept • esc cancel"), width);
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
- return [truncateToWidth(header, width), inputLine, help];
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.0.0",
4
- "description": "Cross-session prompt history and fuzzy Ctrl+R search for pi.",
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",