pi-atuin 0.1.7 → 0.1.8

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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * History text normalization and preview helpers for the search TUI.
3
+ *
4
+ * normalizeHistoryText — single-line form for list rows and fuzzy search.
5
+ * buildDetailPreview — multi-line / tail preview for the selected entry.
6
+ */
7
+
8
+ export const ELLIPSIS = "…";
9
+
10
+ export type WidthFn = (text: string) => number;
11
+
12
+ /** Visible width using Unicode code points (ASCII-safe default for tests). */
13
+ export const codePointWidth: WidthFn = (s) => Array.from(s).length;
14
+
15
+ function normalizeLineSegment(line: string): string {
16
+ return line
17
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ")
18
+ .replace(/\s+/g, " ")
19
+ .trim();
20
+ }
21
+
22
+ /** Collapse multiline / control chars into one searchable display line. */
23
+ export function normalizeHistoryText(text: string): string {
24
+ return text
25
+ .replace(/\r\n?/g, "\n")
26
+ .split("\n")
27
+ .map(normalizeLineSegment)
28
+ .filter(Boolean)
29
+ .join(" ");
30
+ }
31
+
32
+ /** Per-line normalization — keeps line breaks for detail preview. */
33
+ export function normalizeHistoryLines(text: string): string[] {
34
+ return text.replace(/\r\n?/g, "\n").split("\n").map(normalizeLineSegment);
35
+ }
36
+
37
+ export function isMultilineHistory(text: string): boolean {
38
+ return /[\r\n]/.test(text);
39
+ }
40
+
41
+ /** @deprecated use normalizeHistoryText */
42
+ export const flattenHistoryLine = normalizeHistoryText;
43
+
44
+ export function truncateToWidthPure(
45
+ text: string,
46
+ maxWidth: number,
47
+ ellipsis: string = ELLIPSIS,
48
+ widthOf: WidthFn = codePointWidth,
49
+ ): string {
50
+ if (maxWidth <= 0) return "";
51
+ if (widthOf(text) <= maxWidth) return text;
52
+
53
+ const ellW = widthOf(ellipsis);
54
+ const budget = maxWidth - ellW;
55
+ if (budget <= 0) return Array.from(ellipsis).slice(0, maxWidth).join("");
56
+
57
+ const chars = Array.from(text);
58
+ let w = 0;
59
+ let i = 0;
60
+ while (i < chars.length && w + widthOf(chars[i]!) <= budget) {
61
+ w += widthOf(chars[i]!);
62
+ i++;
63
+ }
64
+ return chars.slice(0, i).join("") + ellipsis;
65
+ }
66
+
67
+ export function truncateTailPure(
68
+ text: string,
69
+ maxWidth: number,
70
+ ellipsis: string = ELLIPSIS,
71
+ widthOf: WidthFn = codePointWidth,
72
+ ): string {
73
+ if (widthOf(text) <= maxWidth) return text;
74
+
75
+ const ellW = widthOf(ellipsis);
76
+ const budget = maxWidth - ellW;
77
+ if (budget <= 0) return ellipsis;
78
+
79
+ const chars = Array.from(text);
80
+ let w = 0;
81
+ let start = chars.length;
82
+ while (start > 0) {
83
+ const cw = widthOf(chars[start - 1]!);
84
+ if (w + cw > budget) break;
85
+ w += cw;
86
+ start--;
87
+ }
88
+ return ellipsis + chars.slice(start).join("");
89
+ }
90
+
91
+ export function wrapLinePure(
92
+ line: string,
93
+ width: number,
94
+ widthOf: WidthFn = codePointWidth,
95
+ ): string[] {
96
+ if (width <= 0) return line === "" ? [""] : [line];
97
+ if (line === "") return [""];
98
+ if (widthOf(line) <= width) return [line];
99
+
100
+ const chars = Array.from(line);
101
+ const result: string[] = [];
102
+ let i = 0;
103
+ while (i < chars.length) {
104
+ let w = 0;
105
+ let j = i;
106
+ while (j < chars.length) {
107
+ const cw = widthOf(chars[j]!);
108
+ if (w + cw > width) break;
109
+ w += cw;
110
+ j++;
111
+ }
112
+ if (j === i) j = i + 1;
113
+ result.push(chars.slice(i, j).join(""));
114
+ i = j;
115
+ }
116
+ return result;
117
+ }
118
+
119
+ export function needsDetailPreview(
120
+ rawText: string,
121
+ listWidth: number,
122
+ widthOf: WidthFn = codePointWidth,
123
+ ): boolean {
124
+ if (isMultilineHistory(rawText)) return true;
125
+ return widthOf(normalizeHistoryText(rawText)) > listWidth;
126
+ }
127
+
128
+ const DETAIL_PREVIEW_MAX_LINES = 2;
129
+
130
+ /**
131
+ * Build 1–2 preview lines for the selected history entry.
132
+ * Multiline: soft-wrap each logical line. Long single line: head + tail.
133
+ */
134
+ export function buildDetailPreview(
135
+ rawText: string,
136
+ width: number,
137
+ maxLines: number = DETAIL_PREVIEW_MAX_LINES,
138
+ widthOf: WidthFn = codePointWidth,
139
+ ): string[] {
140
+ if (maxLines <= 0 || width <= 0) return [];
141
+
142
+ if (isMultilineHistory(rawText)) {
143
+ const logical = normalizeHistoryLines(rawText);
144
+ const out: string[] = [];
145
+ for (const line of logical) {
146
+ for (const wrapped of wrapLinePure(line, width, widthOf)) {
147
+ out.push(wrapped);
148
+ if (out.length >= maxLines) return out;
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ const normalized = normalizeHistoryText(rawText);
155
+ if (widthOf(normalized) <= width) return [];
156
+
157
+ if (maxLines >= 2 && widthOf(normalized) > width * 2) {
158
+ const head = truncateToWidthPure(normalized, width, ELLIPSIS, widthOf);
159
+ const tail = truncateTailPure(normalized, width, ELLIPSIS, widthOf);
160
+ if (head !== tail) return [head, tail];
161
+ }
162
+
163
+ return [truncateTailPure(normalized, width, ELLIPSIS, widthOf)];
164
+ }
package/history-store.ts CHANGED
@@ -6,6 +6,7 @@ import { readFile, writeFile, appendFile, mkdir } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { fuzzySearch } from "./fuzzy-match.js";
9
+ import { normalizeHistoryText } from "./history-preview.js";
9
10
  import { createExecFileAtuinExec, recordAtuinCommand } from "./atuin-cli.js";
10
11
  import { execFile } from "node:child_process";
11
12
  import { promisify } from "node:util";
@@ -187,7 +188,7 @@ export async function searchHistory(
187
188
  const results = fuzzySearch(
188
189
  query,
189
190
  entries,
190
- (e) => e.text,
191
+ (e) => normalizeHistoryText(e.text),
191
192
  limit,
192
193
  );
193
194
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "pi-atuin",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
+ "type": "module",
4
5
  "description": "Atuin-style fuzzy history search for pi. Press ↑ for interactive history.",
5
6
  "license": "MIT",
6
7
  "author": "RealAlexandreAI",
@@ -24,6 +25,9 @@
24
25
  "peerDependencies": {
25
26
  "@earendil-works/pi-coding-agent": "*"
26
27
  },
28
+ "scripts": {
29
+ "test": "node --test test/*.test.ts"
30
+ },
27
31
  "files": [
28
32
  "*.ts",
29
33
  "test",
package/search-ui.ts CHANGED
@@ -7,6 +7,12 @@
7
7
 
8
8
  import type { Component, Focusable } from "@earendil-works/pi-tui";
9
9
  import { CURSOR_MARKER, matchesKey, Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import {
11
+ buildDetailPreview,
12
+ needsDetailPreview,
13
+ normalizeHistoryText,
14
+ ELLIPSIS as PREVIEW_ELLIPSIS,
15
+ } from "./history-preview.js";
10
16
  import { searchHistory, listRecent, type HistoryEntry } from "./history-store.js";
11
17
 
12
18
  interface SearchResult {
@@ -26,9 +32,8 @@ function fmtTime(ts: number): string {
26
32
  return `${Math.floor(h / 24)}d`;
27
33
  }
28
34
 
29
- function clean(t: string): string {
30
- return t.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, " ").trim();
31
- }
35
+ const PREVIEW_MAX_LINES = 2;
36
+ const vw: (s: string) => number = (s) => visibleWidth(s);
32
37
 
33
38
  export class HistorySearchComponent implements Component, Focusable {
34
39
  private query = "";
@@ -36,6 +41,7 @@ export class HistorySearchComponent implements Component, Focusable {
36
41
  private sel: number;
37
42
  private scroll = 0;
38
43
  private cachedW?: number;
44
+ private cachedSel?: number;
39
45
  private cachedLines?: string[];
40
46
  private _focused = false;
41
47
  private visCount = 15;
@@ -121,12 +127,24 @@ export class HistorySearchComponent implements Component, Focusable {
121
127
  }
122
128
 
123
129
  render(width: number, height?: number): string[] {
124
- this.visCount = Math.max(3, (height ?? 20) - 5);
130
+ const W = width;
131
+ const iw = W - 2;
125
132
 
126
- if (this.cachedW === width && this.cachedLines) return this.cachedLines;
133
+ const cmdAvail = iw - 3 - this.TS_COL - 3;
134
+ const previewWidth = iw - 2;
135
+ const selected = this.results[this.sel];
136
+ const detailPreview =
137
+ selected && needsDetailPreview(selected.item.text, cmdAvail, vw)
138
+ ? buildDetailPreview(selected.item.text, previewWidth, PREVIEW_MAX_LINES, vw)
139
+ : [];
140
+ const previewOverhead = detailPreview.length > 0 ? detailPreview.length + 1 : 0;
141
+
142
+ this.visCount = Math.max(3, (height ?? 20) - 5 - previewOverhead);
143
+
144
+ if (this.cachedW === width && this.cachedSel === this.sel && this.cachedLines) {
145
+ return this.cachedLines;
146
+ }
127
147
 
128
- const W = width;
129
- const iw = W - 2; // inner width between border chars
130
148
  const lines: string[] = [];
131
149
 
132
150
  const wrap = (inner: string) => {
@@ -192,13 +210,15 @@ export class HistorySearchComponent implements Component, Focusable {
192
210
  const tsCol = this.theme.fg("dim", tsPadded);
193
211
  const sep = this.theme.fg("border", " \u2502 ");
194
212
 
195
- const cmd = clean(r.item.text);
196
- const cmdAvail = iw - 3 - this.TS_COL - 3; // pfx + ts + " | "
213
+ const cmd = normalizeHistoryText(r.item.text);
197
214
  let cmdStr: string;
198
- if (r.indices.length > 0 && this.query) {
215
+ if (this.query && r.indices.length > 0) {
199
216
  cmdStr = this.hlText(cmd, r.indices, isSel, cmdAvail);
200
217
  } else {
201
- cmdStr = this.theme.fg(isSel ? "accent" : "text", truncateToWidth(cmd, cmdAvail));
218
+ cmdStr = this.theme.fg(
219
+ isSel ? "accent" : "text",
220
+ truncateToWidth(cmd, cmdAvail, PREVIEW_ELLIPSIS),
221
+ );
202
222
  }
203
223
 
204
224
  lines.push(wrap(pfx + tsCol + sep + cmdStr));
@@ -211,6 +231,13 @@ export class HistorySearchComponent implements Component, Focusable {
211
231
  }
212
232
 
213
233
  // ── Separator ──
234
+ if (detailPreview.length > 0) {
235
+ lines.push(this.line(this.B("\u251C") + this.D("\u2500".repeat(iw)) + this.B("\u2524"), W));
236
+ for (const pl of detailPreview) {
237
+ lines.push(wrap(this.D(pl)));
238
+ }
239
+ }
240
+
214
241
  lines.push(this.line(this.B("\u251C") + this.D("\u2500".repeat(iw)) + this.B("\u2524"), W));
215
242
 
216
243
  // ── Footer ──
@@ -223,27 +250,35 @@ export class HistorySearchComponent implements Component, Focusable {
223
250
  lines.push(this.line(this.B("\u2514") + this.D("\u2500".repeat(iw)) + this.B("\u2518"), W));
224
251
 
225
252
  this.cachedW = W;
253
+ this.cachedSel = this.sel;
226
254
  this.cachedLines = lines;
227
255
  return lines;
228
256
  }
229
257
 
230
258
  private hlText(text: string, indices: number[], sel: boolean, maxW: number): string {
231
259
  const set = new Set(indices);
232
- let out = "";
260
+ let plain = "";
261
+ let styled = "";
233
262
  for (let i = 0; i < text.length; i++) {
234
- if (visibleWidth(out) >= maxW) break;
235
- const ch = text[i] === "\t" ? " " : text[i];
263
+ const ch = text[i];
264
+ const nextLen = visibleWidth(plain + ch);
265
+ if (nextLen > maxW) break;
266
+ plain += ch;
236
267
  if (set.has(i)) {
237
- out += this.theme.fg("accent", this.theme.bold(ch));
268
+ styled += this.theme.fg("accent", this.theme.bold(ch));
238
269
  } else {
239
- out += this.theme.fg(sel ? "accent" : "muted", ch);
270
+ styled += this.theme.fg(sel ? "accent" : "muted", ch);
240
271
  }
241
272
  }
242
- return out;
273
+ if (plain.length < text.length) {
274
+ return truncateToWidth(styled, maxW, PREVIEW_ELLIPSIS);
275
+ }
276
+ return styled;
243
277
  }
244
278
 
245
279
  invalidate(): void {
246
280
  this.cachedW = undefined;
281
+ this.cachedSel = undefined;
247
282
  this.cachedLines = undefined;
248
283
  }
249
284
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { fuzzyMatch, fuzzySearch } from "../fuzzy-match.js";
3
+ import { fuzzyMatch, fuzzySearch } from "../fuzzy-match.ts";
4
4
 
5
5
  describe("fuzzyMatch", () => {
6
6
  it("exact substring match", () => {
@@ -0,0 +1,136 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ buildDetailPreview,
5
+ flattenHistoryLine,
6
+ isMultilineHistory,
7
+ needsDetailPreview,
8
+ normalizeHistoryText,
9
+ normalizeHistoryLines,
10
+ truncateTailPure,
11
+ truncateToWidthPure,
12
+ wrapLinePure,
13
+ ELLIPSIS,
14
+ } from "../history-preview.ts";
15
+
16
+ describe("normalizeHistoryText", () => {
17
+ it("collapses newlines to spaces", () => {
18
+ assert.equal(normalizeHistoryText("npm install\npi /reload"), "npm install pi /reload");
19
+ });
20
+
21
+ it("collapses repeated whitespace", () => {
22
+ assert.equal(normalizeHistoryText("foo bar"), "foo bar");
23
+ });
24
+
25
+ it("strips control characters including DEL", () => {
26
+ assert.equal(normalizeHistoryText("a\u0007b"), "a b");
27
+ assert.equal(normalizeHistoryText("a\u007fb"), "a b");
28
+ });
29
+
30
+ it("normalizes CRLF", () => {
31
+ assert.equal(normalizeHistoryText("line1\r\nline2"), "line1 line2");
32
+ });
33
+
34
+ it("flattenHistoryLine alias", () => {
35
+ assert.equal(flattenHistoryLine("a\nb"), "a b");
36
+ });
37
+ });
38
+
39
+ describe("normalizeHistoryLines", () => {
40
+ it("keeps line breaks", () => {
41
+ assert.deepEqual(normalizeHistoryLines("npm install\npi /reload"), [
42
+ "npm install",
43
+ "pi /reload",
44
+ ]);
45
+ });
46
+
47
+ it("sanitizes each line independently", () => {
48
+ assert.deepEqual(normalizeHistoryLines("foo\u0007bar\nbaz"), ["foo bar", "baz"]);
49
+ });
50
+ });
51
+
52
+ describe("isMultilineHistory", () => {
53
+ it("detects newline", () => {
54
+ assert.equal(isMultilineHistory("a\nb"), true);
55
+ assert.equal(isMultilineHistory("single"), false);
56
+ });
57
+ });
58
+
59
+ describe("truncateToWidthPure", () => {
60
+ it("returns text when it fits", () => {
61
+ assert.equal(truncateToWidthPure("short", 10), "short");
62
+ });
63
+
64
+ it("truncates with ellipsis at end", () => {
65
+ assert.equal(truncateToWidthPure("hello world", 8), `hello w${ELLIPSIS}`);
66
+ });
67
+ });
68
+
69
+ describe("truncateTailPure", () => {
70
+ it("keeps the end of long text", () => {
71
+ const r = truncateTailPure("CREATE USER atuin WITH PASSWORD 'secret'", 20);
72
+ assert.ok(r.startsWith(ELLIPSIS));
73
+ assert.ok(r.includes("secret"));
74
+ assert.ok(!r.includes("CREATE"));
75
+ });
76
+ });
77
+
78
+ describe("wrapLinePure", () => {
79
+ it("splits long lines by width", () => {
80
+ assert.deepEqual(wrapLinePure("abcdefgh", 3), ["abc", "def", "gh"]);
81
+ });
82
+
83
+ it("returns single chunk when it fits", () => {
84
+ assert.deepEqual(wrapLinePure("abc", 10), ["abc"]);
85
+ });
86
+ });
87
+
88
+ describe("needsDetailPreview", () => {
89
+ it("true for multiline", () => {
90
+ assert.equal(needsDetailPreview("a\nb", 80), true);
91
+ });
92
+
93
+ it("true when normalized text exceeds list width", () => {
94
+ const long = "x".repeat(50);
95
+ assert.equal(needsDetailPreview(long, 20), true);
96
+ });
97
+
98
+ it("false for short single line", () => {
99
+ assert.equal(needsDetailPreview("npm install", 40), false);
100
+ });
101
+ });
102
+
103
+ describe("buildDetailPreview", () => {
104
+ it("returns empty for short single line", () => {
105
+ assert.deepEqual(buildDetailPreview("npm install", 40), []);
106
+ });
107
+
108
+ it("wraps multiline content", () => {
109
+ const lines = buildDetailPreview("npm install\npi /reload", 12);
110
+ assert.ok(lines.length >= 2);
111
+ assert.equal(lines[0], "npm install");
112
+ assert.equal(lines[1], "pi /reload");
113
+ });
114
+
115
+ it("shows head and tail for very long single line", () => {
116
+ const long = `git commit -m "${"a".repeat(40)}" --allow-empty`;
117
+ const lines = buildDetailPreview(long, 20, 2);
118
+ assert.equal(lines.length, 2);
119
+ assert.ok(lines[0]!.endsWith(ELLIPSIS));
120
+ assert.ok(lines[1]!.startsWith(ELLIPSIS));
121
+ });
122
+
123
+ it("shows tail for moderately long single line", () => {
124
+ const text = "npm run build --project=my-app";
125
+ const lines = buildDetailPreview(text, 15, 2);
126
+ assert.equal(lines.length, 1);
127
+ assert.ok(lines[0]!.startsWith(ELLIPSIS));
128
+ assert.ok(lines[0]!.includes("my-app"));
129
+ });
130
+
131
+ it("respects maxLines for multiline wrap", () => {
132
+ const text = "line one is long enough to wrap\nline two\nline three";
133
+ const lines = buildDetailPreview(text, 10, 2);
134
+ assert.equal(lines.length, 2);
135
+ });
136
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { fuzzySearch } from "../fuzzy-match.ts";
4
+ import { normalizeHistoryText } from "../history-preview.ts";
5
+
6
+ describe("fuzzy search with normalized history text", () => {
7
+ const items = [
8
+ { text: "npm install\npi /reload" },
9
+ { text: "git commit -m fix" },
10
+ { text: "npm test" },
11
+ ];
12
+
13
+ it("misses cross-line substring on raw text", () => {
14
+ const results = fuzzySearch("install pi", items, (i) => i.text);
15
+ assert.equal(results.length, 0);
16
+ });
17
+
18
+ it("matches cross-line substring after normalization", () => {
19
+ const results = fuzzySearch("install pi", items, (i) => normalizeHistoryText(i.text));
20
+ assert.equal(results.length, 1);
21
+ assert.equal(results[0]!.item.text, "npm install\npi /reload");
22
+ });
23
+
24
+ it("indices align with normalized text", () => {
25
+ const results = fuzzySearch("reload", items, (i) => normalizeHistoryText(i.text));
26
+ assert.equal(results.length, 1);
27
+ const normalized = normalizeHistoryText(results[0]!.item.text);
28
+ const idx = results[0]!.result.indices[0]!;
29
+ assert.equal(normalized.slice(idx, idx + 6), "reload");
30
+ });
31
+
32
+ it("still matches single-line entries", () => {
33
+ const results = fuzzySearch("git commit", items, (i) => normalizeHistoryText(i.text));
34
+ assert.equal(results.length, 1);
35
+ assert.equal(results[0]!.item.text, "git commit -m fix");
36
+ });
37
+ });