@pugi/cli 0.1.0-alpha.6 → 0.1.0-alpha.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.
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +194 -1
- package/dist/core/repl/slash-commands.js +133 -22
- package/dist/core/settings.js +13 -0
- package/dist/runtime/cli.js +392 -66
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/registry.js +1 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +410 -27
- package/dist/tui/render.js +57 -0
- package/dist/tui/repl-render.js +1 -1
- package/dist/tui/repl.js +39 -3
- package/dist/tui/slash-palette.js +69 -0
- package/dist/tui/update-banner.js +8 -0
- package/package.json +7 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Open `url` in the default browser. Returns `{ opened: true }` on a
|
|
4
|
+
* successful spawn, `{ opened: false }` on any failure. The url is not
|
|
5
|
+
* shell-escaped here; we always pass it as a single argv element to a
|
|
6
|
+
* direct spawn (no shell), so quoting is irrelevant.
|
|
7
|
+
*/
|
|
8
|
+
export async function autoOpenBrowser(url, deps = {}) {
|
|
9
|
+
const platform = deps.platform ?? process.platform;
|
|
10
|
+
const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
|
|
11
|
+
// Refuse anything that does not parse as http(s). The device-flow URL
|
|
12
|
+
// is always https — guarding here keeps a hostile server response
|
|
13
|
+
// from convincing us to spawn `open file:///etc/passwd` or worse.
|
|
14
|
+
if (!isSafeHttpUrl(url)) {
|
|
15
|
+
return { opened: false };
|
|
16
|
+
}
|
|
17
|
+
if (platform === 'darwin') {
|
|
18
|
+
return { opened: spawnDetached('open', [url]) };
|
|
19
|
+
}
|
|
20
|
+
if (platform === 'win32') {
|
|
21
|
+
// P1-3 (triple-review 2026-05-24): cmd.exe parses `&` as a command
|
|
22
|
+
// separator BEFORE Node hands argv to the child, regardless of
|
|
23
|
+
// `shell: false`. A device-flow URL like
|
|
24
|
+
// `https://app.pugi.io/devices/authorize?user_code=ABC&trace=xyz`
|
|
25
|
+
// would be truncated at the `&`, opening only the first half. We
|
|
26
|
+
// prefer PowerShell (its argv parser is sane: a single quoted URL
|
|
27
|
+
// round-trips verbatim) and fall back to a double-quoted cmd
|
|
28
|
+
// invocation when PowerShell is missing from PATH.
|
|
29
|
+
if (spawnDetached('powershell', [
|
|
30
|
+
'-NoProfile',
|
|
31
|
+
'-NonInteractive',
|
|
32
|
+
'-Command',
|
|
33
|
+
'Start-Process',
|
|
34
|
+
quoteForPowerShell(url),
|
|
35
|
+
])) {
|
|
36
|
+
return { opened: true };
|
|
37
|
+
}
|
|
38
|
+
// Fallback: `cmd /c start "" "<url>"`. The URL itself is wrapped
|
|
39
|
+
// in double quotes so cmd does not split on `&`; any embedded `"`
|
|
40
|
+
// in the URL is escaped using cmd's caret-quote convention.
|
|
41
|
+
return {
|
|
42
|
+
opened: spawnDetached('cmd', ['/c', 'start', '""', quoteForCmd(url)]),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') {
|
|
46
|
+
// Try xdg-open first (the freedesktop standard), then gio (GNOME),
|
|
47
|
+
// then a couple of well-known browsers as a last resort. Each
|
|
48
|
+
// attempt is a fresh spawn — if the binary is missing the helper
|
|
49
|
+
// returns false and we fall through.
|
|
50
|
+
for (const cmd of LINUX_OPENERS) {
|
|
51
|
+
if (spawnDetached(cmd, [url]))
|
|
52
|
+
return { opened: true };
|
|
53
|
+
}
|
|
54
|
+
return { opened: false };
|
|
55
|
+
}
|
|
56
|
+
// Unknown platform (android, aix, sunos…) — degrade gracefully.
|
|
57
|
+
return { opened: false };
|
|
58
|
+
}
|
|
59
|
+
const LINUX_OPENERS = ['xdg-open', 'gio', 'gnome-open', 'kde-open'];
|
|
60
|
+
function isSafeHttpUrl(candidate) {
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(candidate);
|
|
63
|
+
return url.protocol === 'https:' || url.protocol === 'http:';
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* P1-3 (triple-review 2026-05-24): wrap a URL for PowerShell's
|
|
71
|
+
* `Start-Process` invocation. PowerShell's single-quote string literal
|
|
72
|
+
* does NOT process escape sequences; the only metachar inside is the
|
|
73
|
+
* single quote itself (escaped by doubling). URLs do not contain
|
|
74
|
+
* single quotes in practice, but the helper handles them defensively
|
|
75
|
+
* so future input shapes do not break.
|
|
76
|
+
*/
|
|
77
|
+
function quoteForPowerShell(url) {
|
|
78
|
+
return `'${url.replace(/'/g, "''")}'`;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* P1-3 (triple-review 2026-05-24): wrap a URL for cmd.exe's
|
|
82
|
+
* `start ""` invocation. Double quotes pin the URL as a single token
|
|
83
|
+
* so cmd does NOT split on `&`, `|`, `^`, `<`, `>`. Embedded `"` (rare
|
|
84
|
+
* in URLs but defensible) is escaped via cmd's caret-quote convention:
|
|
85
|
+
* `^"`.
|
|
86
|
+
*/
|
|
87
|
+
function quoteForCmd(url) {
|
|
88
|
+
return `"${url.replace(/"/g, '^"')}"`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Real spawn implementation. Detaches the child so the Pugi CLI can
|
|
92
|
+
* exit (or keep polling) without inheriting the browser's lifecycle.
|
|
93
|
+
* Returns `true` only when the spawn produced a pid; any error event
|
|
94
|
+
* (ENOENT for a missing binary, EACCES for a sandboxed permission
|
|
95
|
+
* denial) flips the result to `false`.
|
|
96
|
+
*/
|
|
97
|
+
function defaultSpawnDetached(cmd, args) {
|
|
98
|
+
try {
|
|
99
|
+
const options = {
|
|
100
|
+
detached: true,
|
|
101
|
+
stdio: 'ignore',
|
|
102
|
+
shell: false,
|
|
103
|
+
};
|
|
104
|
+
const child = spawn(cmd, args.slice(), options);
|
|
105
|
+
// P3 polish (triple-review 2026-05-24): swallow the async `error`
|
|
106
|
+
// event (ENOENT for a missing binary, EACCES for a sandboxed
|
|
107
|
+
// permission denial). The old `errored` flag was always false at
|
|
108
|
+
// the synchronous `return !errored` point (the `error` event is
|
|
109
|
+
// delivered later in the next tick), so the flag was dead. The
|
|
110
|
+
// listener is still required: without it, Node escalates the
|
|
111
|
+
// event to an unhandled-error and crashes the host process when
|
|
112
|
+
// the binary is missing.
|
|
113
|
+
child.on('error', () => undefined);
|
|
114
|
+
if (typeof child.pid !== 'number')
|
|
115
|
+
return false;
|
|
116
|
+
// unref so the parent event loop is not held open by the browser
|
|
117
|
+
// handle. The browser process continues independently.
|
|
118
|
+
child.unref();
|
|
119
|
+
// Callers treat `true` as best-effort: the fallback "Browser
|
|
120
|
+
// didn't open?" hint is always rendered, so a silent spawn
|
|
121
|
+
// failure still leaves the user a usable path.
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=auto-open-browser.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Write `text` to the platform clipboard. Best-effort, async.
|
|
4
|
+
*/
|
|
5
|
+
export async function writeClipboard(text, deps = {}) {
|
|
6
|
+
const platform = deps.platform ?? process.platform;
|
|
7
|
+
const spawnWrite = deps.spawnWrite ?? defaultSpawnWrite;
|
|
8
|
+
if (platform === 'darwin') {
|
|
9
|
+
const ok = await spawnWrite('pbcopy', [], text);
|
|
10
|
+
return { copied: ok };
|
|
11
|
+
}
|
|
12
|
+
if (platform === 'win32') {
|
|
13
|
+
// clip.exe is shipped with every Windows >= XP. UTF-16LE would be
|
|
14
|
+
// ideal, but a UTF-8 BOM-less write covers the ASCII subset that
|
|
15
|
+
// device-flow URLs use (no non-ASCII characters reach this path).
|
|
16
|
+
const ok = await spawnWrite('clip', [], text);
|
|
17
|
+
return { copied: ok };
|
|
18
|
+
}
|
|
19
|
+
if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') {
|
|
20
|
+
// Wayland-native sessions usually have wl-copy and not xclip. Try
|
|
21
|
+
// wl-copy first if WAYLAND_DISPLAY is set, otherwise prefer xclip.
|
|
22
|
+
const order = process.env.WAYLAND_DISPLAY
|
|
23
|
+
? ['wl-copy', 'xclip', 'xsel']
|
|
24
|
+
: ['xclip', 'wl-copy', 'xsel'];
|
|
25
|
+
for (const tool of order) {
|
|
26
|
+
const args = tool === 'xclip' ? ['-selection', 'clipboard'] : tool === 'xsel' ? ['--clipboard', '--input'] : [];
|
|
27
|
+
const ok = await spawnWrite(tool, args, text);
|
|
28
|
+
if (ok)
|
|
29
|
+
return { copied: true };
|
|
30
|
+
}
|
|
31
|
+
return { copied: false };
|
|
32
|
+
}
|
|
33
|
+
return { copied: false };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Real spawn. Writes `text` to the child's stdin and resolves with
|
|
37
|
+
* `true` only when the child exits 0. Errors (missing binary,
|
|
38
|
+
* permission denied, non-zero exit) flip the result to `false`.
|
|
39
|
+
*/
|
|
40
|
+
function defaultSpawnWrite(cmd, args, text) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
let settled = false;
|
|
43
|
+
const settle = (value) => {
|
|
44
|
+
if (settled)
|
|
45
|
+
return;
|
|
46
|
+
settled = true;
|
|
47
|
+
resolve(value);
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
const options = {
|
|
51
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
52
|
+
shell: false,
|
|
53
|
+
};
|
|
54
|
+
const child = spawn(cmd, args.slice(), options);
|
|
55
|
+
child.on('error', () => settle(false));
|
|
56
|
+
child.on('close', (code) => settle(code === 0));
|
|
57
|
+
const stdin = child.stdin;
|
|
58
|
+
if (!stdin) {
|
|
59
|
+
settle(false);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
stdin.on('error', () => settle(false));
|
|
63
|
+
stdin.end(text, 'utf8');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
settle(false);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=clipboard.js.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort clipboard READ helper - Sprint α6.14.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of the clipboard WRITE helper but for the opposite
|
|
5
|
+
* direction. Powers Ctrl+V paste in the REPL input box: when the
|
|
6
|
+
* operator presses Ctrl+V we spawn the platform's paste binary
|
|
7
|
+
* (pbpaste / wl-paste / xclip -o / PowerShell Get-Clipboard) and
|
|
8
|
+
* insert the result at the cursor.
|
|
9
|
+
*
|
|
10
|
+
* Contract:
|
|
11
|
+
* - Returns `{ text }` when the helper exited 0 with non-empty
|
|
12
|
+
* stdout. Trailing single newline is stripped (clipboard
|
|
13
|
+
* contents authored by the operator rarely include the trailing
|
|
14
|
+
* LF the paste helpers append).
|
|
15
|
+
* - Returns `{ text: null }` on any failure: missing binary,
|
|
16
|
+
* non-zero exit, no $DISPLAY on headless Linux, EAGAIN.
|
|
17
|
+
* - Never throws. Caller falls back to "Ctrl+V not available -
|
|
18
|
+
* try right-click paste" hint.
|
|
19
|
+
* - Targets node >= 20.
|
|
20
|
+
*
|
|
21
|
+
* Implementation note: we do NOT optimistically run all three Linux
|
|
22
|
+
* helpers in parallel - the cost of spawning xclip when wl-copy
|
|
23
|
+
* already produced text is small but visible (50ms+ each on cold
|
|
24
|
+
* cache). Ordering: WAYLAND_DISPLAY → wl-paste; otherwise xclip first.
|
|
25
|
+
*/
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
export async function readClipboard(deps = {}) {
|
|
28
|
+
const platform = deps.platform ?? process.platform;
|
|
29
|
+
const spawnRead = deps.spawnRead ?? defaultSpawnRead;
|
|
30
|
+
const env = deps.env ?? process.env;
|
|
31
|
+
if (platform === 'darwin') {
|
|
32
|
+
const text = await spawnRead('pbpaste', []);
|
|
33
|
+
return { text: normalise(text) };
|
|
34
|
+
}
|
|
35
|
+
if (platform === 'win32') {
|
|
36
|
+
// PowerShell is the universally available path. Get-Clipboard
|
|
37
|
+
// emits UTF-16 by default; the -Raw flag preserves newlines and
|
|
38
|
+
// returns one string.
|
|
39
|
+
const text = await spawnRead('powershell', ['-NoProfile', '-Command', 'Get-Clipboard -Raw']);
|
|
40
|
+
return { text: normalise(text) };
|
|
41
|
+
}
|
|
42
|
+
if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') {
|
|
43
|
+
const order = env.WAYLAND_DISPLAY
|
|
44
|
+
? ['wl-paste', 'xclip', 'xsel']
|
|
45
|
+
: ['xclip', 'wl-paste', 'xsel'];
|
|
46
|
+
for (const tool of order) {
|
|
47
|
+
const args = tool === 'xclip'
|
|
48
|
+
? ['-selection', 'clipboard', '-o']
|
|
49
|
+
: tool === 'xsel'
|
|
50
|
+
? ['--clipboard', '--output']
|
|
51
|
+
: ['--no-newline'];
|
|
52
|
+
const text = await spawnRead(tool, args);
|
|
53
|
+
if (text !== null && text.length > 0) {
|
|
54
|
+
return { text: normalise(text) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { text: null };
|
|
58
|
+
}
|
|
59
|
+
return { text: null };
|
|
60
|
+
}
|
|
61
|
+
function normalise(raw) {
|
|
62
|
+
if (raw === null)
|
|
63
|
+
return null;
|
|
64
|
+
if (raw.length === 0)
|
|
65
|
+
return null;
|
|
66
|
+
// Strip a single trailing LF the helpers often append. Multi-line
|
|
67
|
+
// pastes preserve interior newlines.
|
|
68
|
+
return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Hard cap on clipboard payload size. The paste helper streams stdout
|
|
72
|
+
* with no upper bound by default, so a pathologically large clipboard
|
|
73
|
+
* (e.g. a hostile actor's 500 MiB file URL list, or an accidental copy
|
|
74
|
+
* of a large binary) would OOM the CLI process if we kept appending.
|
|
75
|
+
* 1 MiB is plenty for any realistic prompt + code snippet paste and
|
|
76
|
+
* still leaves room in V8's default young-generation heap.
|
|
77
|
+
*/
|
|
78
|
+
const MAX_CLIPBOARD_BYTES = 1024 * 1024; // 1 MiB
|
|
79
|
+
function defaultSpawnRead(cmd, args) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
let settled = false;
|
|
82
|
+
let overflow = false;
|
|
83
|
+
let totalBytes = 0;
|
|
84
|
+
const chunks = [];
|
|
85
|
+
const settle = (value) => {
|
|
86
|
+
if (settled)
|
|
87
|
+
return;
|
|
88
|
+
settled = true;
|
|
89
|
+
resolve(value);
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
const options = {
|
|
93
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
94
|
+
shell: false,
|
|
95
|
+
};
|
|
96
|
+
const child = spawn(cmd, args.slice(), options);
|
|
97
|
+
child.on('error', () => settle(null));
|
|
98
|
+
child.stdout?.on('data', (b) => {
|
|
99
|
+
if (overflow)
|
|
100
|
+
return;
|
|
101
|
+
totalBytes += b.length;
|
|
102
|
+
if (totalBytes > MAX_CLIPBOARD_BYTES) {
|
|
103
|
+
overflow = true;
|
|
104
|
+
try {
|
|
105
|
+
child.kill('SIGTERM');
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Best effort; close handler still settles below.
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
chunks.push(b);
|
|
113
|
+
});
|
|
114
|
+
child.on('close', (code) => {
|
|
115
|
+
if (overflow)
|
|
116
|
+
return settle(null);
|
|
117
|
+
if (code !== 0)
|
|
118
|
+
return settle(null);
|
|
119
|
+
const buf = Buffer.concat(chunks);
|
|
120
|
+
settle(buf.toString('utf8'));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
settle(null);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/* ------------------------------------------------------------------ */
|
|
129
|
+
/* Bracketed-paste mode helpers */
|
|
130
|
+
/* ------------------------------------------------------------------ */
|
|
131
|
+
/**
|
|
132
|
+
* Modern terminals (iTerm2, Alacritty, GNOME Terminal, kitty,
|
|
133
|
+
* Windows Terminal) bracket pasted text with `ESC[200~ ... ESC[201~`
|
|
134
|
+
* when bracketed-paste mode is enabled. The input box can detect
|
|
135
|
+
* these markers and disable newline-as-submit during the paste burst
|
|
136
|
+
* so a multi-line paste does not fire `onSubmit` mid-paste.
|
|
137
|
+
*
|
|
138
|
+
* The constants here are exported so the input box and the unit
|
|
139
|
+
* test can share the byte sequences without re-implementing them.
|
|
140
|
+
*/
|
|
141
|
+
export const PASTE_START = '\x1b[200~';
|
|
142
|
+
export const PASTE_END = '\x1b[201~';
|
|
143
|
+
export const CTRL_V = '\x16';
|
|
144
|
+
export function classifyChunk(chunk, inPaste) {
|
|
145
|
+
if (inPaste) {
|
|
146
|
+
const endIdx = chunk.indexOf(PASTE_END);
|
|
147
|
+
if (endIdx === -1) {
|
|
148
|
+
return { kind: 'paste-cont', textInPaste: chunk };
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
kind: 'paste-end',
|
|
152
|
+
textInPaste: chunk.slice(0, endIdx),
|
|
153
|
+
textAfter: chunk.slice(endIdx + PASTE_END.length),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const startIdx = chunk.indexOf(PASTE_START);
|
|
157
|
+
if (startIdx === -1) {
|
|
158
|
+
return { kind: 'plain', text: chunk };
|
|
159
|
+
}
|
|
160
|
+
const afterStart = chunk.slice(startIdx + PASTE_START.length);
|
|
161
|
+
const endIdx = afterStart.indexOf(PASTE_END);
|
|
162
|
+
if (endIdx === -1) {
|
|
163
|
+
return {
|
|
164
|
+
kind: 'paste-start',
|
|
165
|
+
textBefore: chunk.slice(0, startIdx),
|
|
166
|
+
textInPaste: afterStart,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
kind: 'paste-only',
|
|
171
|
+
text: afterStart.slice(0, endIdx),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=clipboard-read.js.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FZF-style history search - Sprint α6.14.
|
|
3
|
+
*
|
|
4
|
+
* Powers the Ctrl+R (reverse) / Ctrl+S (forward) interactive search
|
|
5
|
+
* mode in the REPL input box. Hand-rolled scorer (no dep) tuned for
|
|
6
|
+
* short query strings (operator typing a few chars) over up to
|
|
7
|
+
* MAX_HISTORY_ENTRIES candidates.
|
|
8
|
+
*
|
|
9
|
+
* Scoring model (mirrors fzf v2 mid-priorities, simplified):
|
|
10
|
+
*
|
|
11
|
+
* - Substring match required. Non-matching candidates score 0.
|
|
12
|
+
* - Prefix match: +50
|
|
13
|
+
* - Word-boundary start (' ', '/', '-', '_'): +20
|
|
14
|
+
* - Camel boundary (`abcD` → 'D'): +10
|
|
15
|
+
* - Contiguous run bonus: +5 per consecutive char after the first
|
|
16
|
+
* - Distance penalty: -1 per gap char between query characters
|
|
17
|
+
* - Recency bonus: +0.5 × (recencyRank / total) so newer entries
|
|
18
|
+
* break ties in the operator's favour
|
|
19
|
+
*
|
|
20
|
+
* The result is a list of matches ordered by descending score with
|
|
21
|
+
* the matched character positions preserved so the UI can highlight
|
|
22
|
+
* them. `cycle` steps the focused match index forward (Ctrl+R again)
|
|
23
|
+
* or backward (Ctrl+S) modulo the result length.
|
|
24
|
+
*
|
|
25
|
+
* Contract:
|
|
26
|
+
* - `searchHistory(query, entries, options)` is pure. No I/O.
|
|
27
|
+
* - Empty query returns ALL entries newest-first so the operator
|
|
28
|
+
* can browse without typing.
|
|
29
|
+
* - Matching is case-insensitive on ASCII; non-ASCII characters
|
|
30
|
+
* compare as-is.
|
|
31
|
+
* - `cycle(state, direction)` is a no-op when there are no matches.
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_LIMIT = 50;
|
|
34
|
+
const WORD_BOUNDARIES = new Set([' ', '/', '-', '_', '.', ',', ':', ';', '\t']);
|
|
35
|
+
/**
|
|
36
|
+
* Score one candidate against the query. Returns `null` when the
|
|
37
|
+
* query cannot be matched as an in-order substring (not necessarily
|
|
38
|
+
* contiguous). The matched positions are returned so the UI can
|
|
39
|
+
* underline them.
|
|
40
|
+
*/
|
|
41
|
+
export function scoreCandidate(query, candidate) {
|
|
42
|
+
if (query.length === 0) {
|
|
43
|
+
return { score: 0, positions: [] };
|
|
44
|
+
}
|
|
45
|
+
const lowerQuery = query.toLowerCase();
|
|
46
|
+
const lowerCandidate = candidate.toLowerCase();
|
|
47
|
+
// Walk the candidate, greedily matching each query char in order.
|
|
48
|
+
const positions = [];
|
|
49
|
+
let qIdx = 0;
|
|
50
|
+
for (let i = 0; i < lowerCandidate.length && qIdx < lowerQuery.length; i += 1) {
|
|
51
|
+
if (lowerCandidate[i] === lowerQuery[qIdx]) {
|
|
52
|
+
positions.push(i);
|
|
53
|
+
qIdx += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (qIdx < lowerQuery.length)
|
|
57
|
+
return null;
|
|
58
|
+
// Score the match.
|
|
59
|
+
let score = 0;
|
|
60
|
+
// Prefix bonus.
|
|
61
|
+
if (positions[0] === 0)
|
|
62
|
+
score += 50;
|
|
63
|
+
// Substring contiguity + boundary bonuses.
|
|
64
|
+
let lastPos = -2;
|
|
65
|
+
for (let p = 0; p < positions.length; p += 1) {
|
|
66
|
+
const pos = positions[p];
|
|
67
|
+
// Contiguous run.
|
|
68
|
+
if (pos === lastPos + 1) {
|
|
69
|
+
score += 5;
|
|
70
|
+
}
|
|
71
|
+
else if (lastPos >= 0) {
|
|
72
|
+
// Distance penalty for non-contiguous matches.
|
|
73
|
+
score -= pos - lastPos - 1;
|
|
74
|
+
}
|
|
75
|
+
// Word-boundary start.
|
|
76
|
+
if (pos === 0) {
|
|
77
|
+
// Already counted as prefix.
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const prev = candidate[pos - 1] ?? '';
|
|
81
|
+
if (WORD_BOUNDARIES.has(prev)) {
|
|
82
|
+
score += 20;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Camel boundary: prev is lowercase, current is uppercase.
|
|
86
|
+
const cur = candidate[pos] ?? '';
|
|
87
|
+
if (prev === prev.toLowerCase() && cur !== cur.toLowerCase()) {
|
|
88
|
+
score += 10;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
lastPos = pos;
|
|
93
|
+
}
|
|
94
|
+
return { score, positions };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Search a history list for matches. Entries are passed oldest-first
|
|
98
|
+
* (the format `history.read` returns); the recency bonus uses the
|
|
99
|
+
* original index so newer entries win ties.
|
|
100
|
+
*/
|
|
101
|
+
export function searchHistory(query, entries, options = {}) {
|
|
102
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
103
|
+
const total = entries.length;
|
|
104
|
+
if (query.length === 0) {
|
|
105
|
+
// Empty query - browse mode. Return newest-first up to limit, no
|
|
106
|
+
// scoring needed.
|
|
107
|
+
const out = [];
|
|
108
|
+
for (let i = total - 1; i >= 0 && out.length < limit; i -= 1) {
|
|
109
|
+
const brief = entries[i];
|
|
110
|
+
out.push({ brief, positions: [], score: 0, originalIndex: i });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
const candidates = [];
|
|
115
|
+
for (let i = 0; i < total; i += 1) {
|
|
116
|
+
const brief = entries[i];
|
|
117
|
+
const scored = scoreCandidate(query, brief);
|
|
118
|
+
if (!scored)
|
|
119
|
+
continue;
|
|
120
|
+
// Recency bonus: 0..0.5 weight scaled by index/total.
|
|
121
|
+
const recency = total === 0 ? 0 : (i / total) * 0.5;
|
|
122
|
+
candidates.push({
|
|
123
|
+
brief,
|
|
124
|
+
positions: scored.positions,
|
|
125
|
+
score: scored.score + recency,
|
|
126
|
+
originalIndex: i,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
candidates.sort((a, b) => {
|
|
130
|
+
if (b.score !== a.score)
|
|
131
|
+
return b.score - a.score;
|
|
132
|
+
// Tie-breaker: newer first.
|
|
133
|
+
return b.originalIndex - a.originalIndex;
|
|
134
|
+
});
|
|
135
|
+
return candidates.slice(0, limit);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Initial search state for a fresh Ctrl+R press. Empty query +
|
|
139
|
+
* focusedIndex 0 over the browse list.
|
|
140
|
+
*/
|
|
141
|
+
export function initialSearchState(entries) {
|
|
142
|
+
const matches = searchHistory('', entries);
|
|
143
|
+
return { query: '', matches, focusedIndex: 0 };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Update the search state when the operator types a new query char or
|
|
147
|
+
* removes one. The focused index is clamped to the new match list so
|
|
148
|
+
* the UI never points off-the-end.
|
|
149
|
+
*/
|
|
150
|
+
export function applyQuery(state, query, entries) {
|
|
151
|
+
const matches = searchHistory(query, entries);
|
|
152
|
+
const focusedIndex = matches.length === 0 ? 0 : Math.min(state.focusedIndex, matches.length - 1);
|
|
153
|
+
return { query, matches, focusedIndex };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Move the focused match. `+1` = next (Ctrl+R repeat), `-1` = previous
|
|
157
|
+
* (Ctrl+S). Wraps around modulo result length so the operator never
|
|
158
|
+
* has to chase the end.
|
|
159
|
+
*/
|
|
160
|
+
export function cycle(state, direction) {
|
|
161
|
+
if (state.matches.length === 0)
|
|
162
|
+
return state;
|
|
163
|
+
const len = state.matches.length;
|
|
164
|
+
const next = (state.focusedIndex + direction + len) % len;
|
|
165
|
+
return { ...state, focusedIndex: next };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Pull the brief from the currently focused match, or `null` when
|
|
169
|
+
* the result list is empty (Enter accepts nothing).
|
|
170
|
+
*/
|
|
171
|
+
export function currentBrief(state) {
|
|
172
|
+
const match = state.matches[state.focusedIndex];
|
|
173
|
+
return match ? match.brief : null;
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=history-search.js.map
|