pi-input-history 1.0.1 → 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
@@ -33,17 +33,20 @@ On session start, your last 100 prompts across all sessions are loaded into the
33
33
 
34
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
43
  | search shortcut / `↑` | Cycle to older match |
42
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
 
46
- Defaults: search = `ctrl+r`, newer = `ctrl+s`.
49
+ Defaults: search = `ctrl+r`, newer = `ctrl+s`, scroll up = `ctrl+k`, scroll down = `ctrl+j`.
47
50
 
48
51
  ## Configuration
49
52
 
@@ -52,7 +55,9 @@ Optional config at `~/.pi/agent/pi-input-history.json`:
52
55
  ```json
53
56
  {
54
57
  "searchShortcut": "ctrl+r",
55
- "newerShortcut": "ctrl+s"
58
+ "newerShortcut": "ctrl+s",
59
+ "scrollUpShortcut": "ctrl+k",
60
+ "scrollDownShortcut": "ctrl+j"
56
61
  }
57
62
  ```
58
63
 
@@ -60,6 +65,8 @@ Optional config at `~/.pi/agent/pi-input-history.json`:
60
65
  | --- | --- | --- |
61
66
  | `searchShortcut` | `ctrl+r` | Open reverse search; press again in the overlay to cycle older |
62
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 |
63
70
 
64
71
  Omit the file or any field to keep the default. After editing, run `/reload` in pi.
65
72
 
@@ -81,7 +88,11 @@ Or change `searchShortcut` in `pi-input-history.json` to another chord.
81
88
 
82
89
  - **Cross-session persistence** — history survives across sessions automatically.
83
90
  - **Fuzzy subsequence matching** — type partial characters in order, multi-token support with spaces.
84
- - **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.
85
96
  - **Deduplication** — no duplicate entries across sessions.
86
97
  - **Current session awareness** — merges live branch history with cached cross-session history.
87
98
  - **Configurable shortcuts** — override via `pi-input-history.json`.
Binary file
package/index.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  Key,
29
29
  matchesKey,
30
30
  truncateToWidth,
31
+ visibleWidth,
31
32
  type Component,
32
33
  type Focusable,
33
34
  type KeyId,
@@ -37,10 +38,16 @@ import {
37
38
  const MAX_MESSAGES = 100;
38
39
  const DEFAULT_SEARCH_SHORTCUT: KeyId = "ctrl+r";
39
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;
40
45
 
41
46
  type Config = {
42
47
  searchShortcut: KeyId;
43
48
  newerShortcut: KeyId;
49
+ scrollUpShortcut: KeyId;
50
+ scrollDownShortcut: KeyId;
44
51
  };
45
52
 
46
53
  function normalizeKey(value: unknown, fallback: KeyId): KeyId {
@@ -56,17 +63,23 @@ function loadConfig(): Config {
56
63
  return {
57
64
  searchShortcut: DEFAULT_SEARCH_SHORTCUT,
58
65
  newerShortcut: DEFAULT_NEWER_SHORTCUT,
66
+ scrollUpShortcut: DEFAULT_SCROLL_UP_SHORTCUT,
67
+ scrollDownShortcut: DEFAULT_SCROLL_DOWN_SHORTCUT,
59
68
  };
60
69
  }
61
70
  const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
62
71
  return {
63
72
  searchShortcut: normalizeKey(raw.searchShortcut, DEFAULT_SEARCH_SHORTCUT),
64
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),
65
76
  };
66
77
  } catch {
67
78
  return {
68
79
  searchShortcut: DEFAULT_SEARCH_SHORTCUT,
69
80
  newerShortcut: DEFAULT_NEWER_SHORTCUT,
81
+ scrollUpShortcut: DEFAULT_SCROLL_UP_SHORTCUT,
82
+ scrollDownShortcut: DEFAULT_SCROLL_DOWN_SHORTCUT,
70
83
  };
71
84
  }
72
85
  }
@@ -139,50 +152,205 @@ function fuzzyMatch(item: string, query: string): boolean {
139
152
  return tokens.every((t) => subsequence(lower, t));
140
153
  }
141
154
 
142
- function toSingleLinePreview(text: string): string {
143
- 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;
144
201
  }
145
202
 
146
- /** Highlight matched characters (subsequence) with underline + accent color. */
147
- function highlightMatch(text: string, query: string, theme: any, maxWidth: number): string {
148
- const truncated = truncateToWidth(text, maxWidth);
149
- const plain = truncated.replace(/\x1b\[[0-9;]*m/g, "");
150
-
151
- if (!query) return theme.fg("text", plain);
152
-
153
- 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();
154
208
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
155
- const matchPositions = new Set<number>();
156
-
157
209
  for (const token of tokens) {
158
- let hi = 0;
159
- for (let ni = 0; ni < token.length; ni++) {
160
- const idx = lower.indexOf(token[ni], hi);
161
- if (idx !== -1) {
162
- matchPositions.add(idx);
163
- hi = idx + 1;
164
- }
165
- }
210
+ const best = bestSubsequenceSpan(lower, token);
211
+ for (const idx of best) positions.add(idx);
166
212
  }
213
+ return positions;
214
+ }
167
215
 
216
+ /** Underline + accent-highlight matched characters; plain text for the rest. */
217
+ function highlightSegments(text: string, positions: Set<number>, theme: any): string {
168
218
  let result = "";
169
219
  let i = 0;
170
- while (i < plain.length) {
171
- if (matchPositions.has(i)) {
220
+ while (i < text.length) {
221
+ if (positions.has(i)) {
172
222
  let j = i;
173
- while (j < plain.length && matchPositions.has(j)) j++;
174
- 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`;
175
225
  i = j;
176
226
  } else {
177
227
  let j = i;
178
- while (j < plain.length && !matchPositions.has(j)) j++;
179
- 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));
180
230
  i = j;
181
231
  }
182
232
  }
183
233
  return result;
184
234
  }
185
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
+
186
354
  class ReverseSearchComponent implements Component, Focusable {
187
355
  private _focused = false;
188
356
  private readonly input = new Input();
@@ -190,6 +358,8 @@ class ReverseSearchComponent implements Component, Focusable {
190
358
  private query = "";
191
359
  private matchIndices: number[] = [];
192
360
  private matchPointer = 0;
361
+ private previewScroll = 0;
362
+ private previewAutoLocate = true;
193
363
 
194
364
  constructor(
195
365
  private readonly tui: TUI,
@@ -227,6 +397,7 @@ class ReverseSearchComponent implements Component, Focusable {
227
397
  if (this.matchPointer >= this.matchIndices.length) {
228
398
  this.matchPointer = Math.max(0, this.matchIndices.length - 1);
229
399
  }
400
+ this.previewAutoLocate = true;
230
401
  }
231
402
 
232
403
  private getCurrentMatch(): string | undefined {
@@ -238,11 +409,13 @@ class ReverseSearchComponent implements Component, Focusable {
238
409
  private cycleOlder(): void {
239
410
  if (this.matchIndices.length === 0) return;
240
411
  this.matchPointer = (this.matchPointer + 1) % this.matchIndices.length;
412
+ this.previewAutoLocate = true;
241
413
  }
242
414
 
243
415
  private cycleNewer(): void {
244
416
  if (this.matchIndices.length === 0) return;
245
417
  this.matchPointer = (this.matchPointer - 1 + this.matchIndices.length) % this.matchIndices.length;
418
+ this.previewAutoLocate = true;
246
419
  }
247
420
 
248
421
  handleInput(data: string): void {
@@ -263,6 +436,20 @@ class ReverseSearchComponent implements Component, Focusable {
263
436
  return;
264
437
  }
265
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
+
266
453
  const before = this.input.getValue();
267
454
  this.input.handleInput(data);
268
455
  const after = this.input.getValue();
@@ -287,27 +474,39 @@ class ReverseSearchComponent implements Component, Focusable {
287
474
  ? ` [${this.matchPointer + 1}/${this.matchIndices.length}]`
288
475
  : " [0/0]";
289
476
 
290
- const matchPreview = currentMatch
291
- ? highlightMatch(toSingleLinePreview(currentMatch), this.query, t, availableWidth)
292
- : t.fg("warning", "no match");
293
-
294
477
  const counter = t.fg("dim", counterText);
295
478
 
296
- const header =
297
- t.fg("accent", prefix) +
298
- matchPreview +
299
- 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
+ }
300
498
 
301
499
  const inputLine = truncateToWidth(this.input.render(width)[0] ?? "", width);
302
500
  const help = truncateToWidth(
303
501
  t.fg(
304
502
  "dim",
305
- `${this.config.searchShortcut}/↑ older • ${this.config.newerShortcut}/↓ newer • enter accept • esc cancel`,
503
+ `${this.config.searchShortcut}/↑ older • ${this.config.newerShortcut}/↓ newer • ${this.config.scrollUpShortcut}/${this.config.scrollDownShortcut} scroll • enter accept • esc cancel`,
306
504
  ),
307
505
  width,
308
506
  );
309
507
 
310
- return [truncateToWidth(header, width), inputLine, help];
508
+ lines.push(inputLine, help);
509
+ return lines.map((l) => truncateToWidth(l, width));
311
510
  }
312
511
 
313
512
  invalidate(): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-input-history",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Cross-session prompt history and fuzzy reverse search for pi.",
5
5
  "keywords": [
6
6
  "pi",