pi-atuin 0.1.7 → 0.1.9
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/history-preview.ts +164 -0
- package/history-store.ts +2 -1
- package/package.json +6 -2
- package/search-ui.ts +52 -17
- package/test/fuzzy-match.test.ts +1 -1
- package/test/history-preview.test.ts +136 -0
- package/test/history-search-normalize.test.ts +37 -0
|
@@ -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.
|
|
3
|
+
"version": "0.1.9",
|
|
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",
|
|
@@ -22,7 +23,10 @@
|
|
|
22
23
|
"image": "https://raw.githubusercontent.com/RealAlexandreAI/pi-atuin/master/screenshot.png"
|
|
23
24
|
},
|
|
24
25
|
"peerDependencies": {
|
|
25
|
-
"@earendil-works/pi-coding-agent": "
|
|
26
|
+
"@earendil-works/pi-coding-agent": "^0.83.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test test/*.test.ts"
|
|
26
30
|
},
|
|
27
31
|
"files": [
|
|
28
32
|
"*.ts",
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
130
|
+
const W = width;
|
|
131
|
+
const iw = W - 2;
|
|
125
132
|
|
|
126
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
260
|
+
let plain = "";
|
|
261
|
+
let styled = "";
|
|
233
262
|
for (let i = 0; i < text.length; i++) {
|
|
234
|
-
|
|
235
|
-
const
|
|
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
|
-
|
|
268
|
+
styled += this.theme.fg("accent", this.theme.bold(ch));
|
|
238
269
|
} else {
|
|
239
|
-
|
|
270
|
+
styled += this.theme.fg(sel ? "accent" : "muted", ch);
|
|
240
271
|
}
|
|
241
272
|
}
|
|
242
|
-
|
|
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
|
|
package/test/fuzzy-match.test.ts
CHANGED
|
@@ -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.
|
|
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
|
+
});
|