@havocrao/picktui 0.1.0 → 0.1.1

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,7 +1,8 @@
1
- # @havocrao/picktui — TS/JS 绑定
1
+ # @havocrao/picktui — TUI 候选选择器(纯 TypeScript)
2
2
 
3
- 薄绑定:只做「候选传入、结果取回」,渲染/按键/匹配全部在 Go 引擎
4
- (`../go/cmd/picktui`)内完成,效果与 Go 宿主逐像素一致。
3
+ TS 实现:过滤/匹配/交互选择/记忆全部在 JS 内完成,**零运行时依赖、
4
+ 无需任何引擎二进制**,安装即用。行为与 Go 引擎(`../go/cmd/picktui`)语义
5
+ 逐项对齐(匹配算法、高亮区间、非交互退化、状态文件格式互兼容)。
5
6
  同份源码构建 esm + cjs + d.ts,TS 与 JS 共用。
6
7
 
7
8
  ## 安装
@@ -10,10 +11,10 @@
10
11
  $ npm install @havocrao/picktui
11
12
  ```
12
13
 
13
- 引擎发现顺序:`$PICKTUI_BIN` `$PATH` 中的 `picktui`。交互 TUI 由引擎跑在
14
- `/dev/tty`,本包 stdout 只回选中值。无 TTY 时引擎自动退化
15
- (无 query 取首个、有 query 过滤取首),绑定无需分支;
16
- 取消(esc/ctrl+c)返回 `null`;协议错误抛出 `PicktuiError`(含 exitCode/stderr)。
14
+ 交互 TUI 由本包渲染:POSIX 上打开 `/dev/tty`(宿主 stdout 干净,`$(...)`
15
+ 安全);无 `/dev/tty` 的环境(如 Windows)退化为宿主 stdio。无 TTY
16
+ 自动退化(无 query 取首个、有 query 过滤取首);取消(esc/ctrl+c)
17
+ 返回 `null`;错误抛 `PicktuiError`(含 exitCode/stderr 诊断)。
17
18
 
18
19
  ## API
19
20
 
@@ -23,7 +24,7 @@ import { pick, menu, rawPick } from '@havocrao/picktui/tui'
23
24
  import { histGet, histSet } from '@havocrao/picktui/history'
24
25
  import { confirmCheck, confirmAdd } from '@havocrao/picktui/confirm'
25
26
  import type { Candidate, FilterOptions, FilteredCandidate, PickFlags } from '@havocrao/picktui/types'
26
- import { engineVersion, assertEngineVersion } from '@havocrao/picktui'
27
+ import { VERSION } from '@havocrao/picktui'
27
28
  ```
28
29
 
29
30
  | 函数 | 说明 |
@@ -32,19 +33,21 @@ import { engineVersion, assertEngineVersion } from '@havocrao/picktui'
32
33
  | `resolve(cands, query)` | 精确/唯一前缀解析;无唯一解返回 `null` |
33
34
  | `pick(cands?, flags?)` | 交互过滤选择;`flags` 透传 `-q/--label/--sep/--fuzzy/-1/--auto` |
34
35
  | `menu(label, cands)` | 多值缩写菜单(数字 1-9 直选) |
35
- | `rawPick([...args])` | 透传引擎 pick 参数(`--from`/`--map` 等) |
36
+ | `rawPick([...args])` | 透传 pick 参数(`--from`/`--map` 等) |
36
37
  | `histGet(label)` / `histSet(label, value)` | 选择记忆 |
37
38
  | `confirmCheck` / `confirmAdd` | 自动匹配首次确认(add 幂等) |
38
- | `assertEngineVersion(min?)` | 校验引擎版本不低于绑定声明的最低版本 |
39
+ | `VERSION` | 本实现语义版本 |
40
+
41
+ 注:`pick` 候选来自参数(不读宿主进程自身 stdin);需要命令/管道来源时用
42
+ `rawPick(['--from', '<cmd>', ...])`(10s 超时,失败带 stderr 诊断)。
39
43
 
40
44
  ## 开发
41
45
 
42
46
  ```console
43
- $ npm install # 安装 devDependencies(typescript
47
+ $ npm install # 安装 devDependencies(typescript,无运行时依赖)
44
48
  $ npm run build # tsc 双输出:dist/esm + dist/cjs + dist/types
45
- $ npm test # pretest 自动构建引擎二进制 → node:test 集成测试
49
+ $ npm test # node:test 全量测试(filter/history/confirm/tui/model)
46
50
  $ npm run test:pack # npm pack → 干净目录安装 → import/require 冒烟
47
51
  ```
48
52
 
49
- 测试均为对真实引擎二进制的 JSON 往返(无 TTY 依赖)。交互 TUI 路径
50
- 由引擎侧 model 级测试覆盖。
53
+ 测试均为纯 JS 断言,无需二进制或 TTY:交互 TUI 的按键行为由 model 级测试覆盖。
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serializeLines = serializeLines;
4
+ exports.structuredCandidates = structuredCandidates;
5
+ exports.toStructuredCands = toStructuredCands;
6
+ /** 将 `string | Candidate` 输入序列化为协议结构化行(key<TAB>description)。 */
7
+ function serializeLines(cands) {
8
+ return cands.map((c) => (typeof c === 'string' ? c : c.desc ? `${c.value}\t${c.desc}` : c.value));
9
+ }
10
+ /** 将原始行列表解析为结构化候选(与 Go StructuredCandidates 同语义)。 */
11
+ function structuredCandidates(lines) {
12
+ const idx = new Map();
13
+ const out = [];
14
+ for (const line of lines) {
15
+ let value = line;
16
+ let desc = '';
17
+ const tab = line.indexOf('\t');
18
+ if (tab >= 0) {
19
+ value = line.slice(0, tab);
20
+ desc = line.slice(tab + 1);
21
+ }
22
+ value = value.trim();
23
+ if (value === '') {
24
+ continue;
25
+ }
26
+ if (value.startsWith('* ')) {
27
+ value = value.slice(2);
28
+ }
29
+ else if (value.startsWith('+ ')) {
30
+ value = value.slice(2);
31
+ }
32
+ value = value.trim();
33
+ if (value === '') {
34
+ continue;
35
+ }
36
+ desc = desc.trim();
37
+ const j = idx.get(value);
38
+ if (j !== undefined) {
39
+ if (out[j].desc === '' && desc !== '') {
40
+ out[j] = { value, desc };
41
+ }
42
+ continue;
43
+ }
44
+ idx.set(value, out.length);
45
+ out.push({ value, desc });
46
+ }
47
+ return out;
48
+ }
49
+ /** 输入候选统一解析为结构化候选(filter/resolve/pick 共用入口)。 */
50
+ function toStructuredCands(cands) {
51
+ return structuredCandidates(serializeLines(cands));
52
+ }
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.dataDir = dataDir;
4
+ /**
5
+ * config — 数据目录解析(对齐协议:hist/confirm/pick --label 状态文件所在)。
6
+ *
7
+ * 优先级:$PICKTUI_CONFIG_DIR → $XDG_CONFIG_HOME/picktui → ~/.config/picktui。
8
+ */
9
+ const node_os_1 = require("node:os");
10
+ const node_path_1 = require("node:path");
11
+ /** 状态文件目录(history.toml / confirm.toml 所在)。 */
12
+ function dataDir() {
13
+ const explicit = process.env.PICKTUI_CONFIG_DIR;
14
+ if (explicit) {
15
+ return explicit;
16
+ }
17
+ const base = process.env.XDG_CONFIG_HOME || (0, node_path_1.join)((0, node_os_1.homedir)(), '.config');
18
+ return (0, node_path_1.join)(base, 'picktui');
19
+ }
@@ -1,28 +1,105 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CONFIRM_FILE = void 0;
4
+ exports.confirmPath = confirmPath;
5
+ exports.loadConfirmed = loadConfirmed;
6
+ exports.isConfirmed = isConfirmed;
7
+ exports.confirm = confirm;
8
+ exports.parseConfirmAnswer = parseConfirmAnswer;
3
9
  exports.confirmCheck = confirmCheck;
4
10
  exports.confirmAdd = confirmAdd;
5
11
  /**
6
- * confirm — 自动匹配首次确认(文件状态,直调引擎 confirm 子命令)。
12
+ * confirm — 自动匹配的首次用户确认记录(confirm.toml)。
7
13
  *
8
- * 状态文件位于引擎数据目录,与 pick --auto 的确认记录同一份:
9
- * 首次自动匹配某 (label, value) 需确认,确认后不再询问。
14
+ * 文件格式与 Go 引擎 BurntSushi/toml 输出完全兼容:
15
+ *
16
+ * # picktui confirm — user-confirmed auto resolutions (auto-managed)
17
+ * [confirmed]
18
+ * "npm run" = ["release", "dev"]
19
+ *
20
+ * label 为空时不做任何落盘(返回 false)。文件不存在或损坏 → 空记录
21
+ * (确认记录只是安全辅助,缺失最多导致首次匹配再次询问)。
10
22
  */
11
- const engine_js_1 = require("./engine.js");
23
+ const node_fs_1 = require("node:fs");
24
+ const node_path_1 = require("node:path");
25
+ const config_js_1 = require("./config.js");
26
+ const toml_js_1 = require("./toml.js");
27
+ const types_js_1 = require("./types.js");
28
+ /** 自动匹配确认记录文件名。 */
29
+ exports.CONFIRM_FILE = 'confirm.toml';
30
+ /** 注释头(对齐引擎输出)。 */
31
+ const HEADER = '# picktui confirm — user-confirmed auto resolutions (auto-managed)';
32
+ /** 返回确认记录文件路径。 */
33
+ function confirmPath() {
34
+ return (0, node_path_1.join)((0, config_js_1.dataDir)(), exports.CONFIRM_FILE);
35
+ }
36
+ /** 解码 confirm.toml 为 label → 已确认值列表;缺失/损坏时返回空 Map。 */
37
+ function loadConfirmed() {
38
+ const p = confirmPath();
39
+ if (!(0, node_fs_1.existsSync)(p)) {
40
+ return new Map();
41
+ }
42
+ const doc = (0, toml_js_1.parseToml)((0, node_fs_1.readFileSync)(p, 'utf8'));
43
+ if (doc === null) {
44
+ return new Map();
45
+ }
46
+ const confirmed = doc.get('confirmed');
47
+ if (!confirmed) {
48
+ return new Map();
49
+ }
50
+ const out = new Map();
51
+ for (const [label, values] of confirmed) {
52
+ if (Array.isArray(values)) {
53
+ out.set(label, values.map(String));
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ /** 报告 (label, value) 是否已被确认过;label 为空时恒为 false。 */
59
+ function isConfirmed(label, value) {
60
+ if (label === '') {
61
+ return false;
62
+ }
63
+ return (loadConfirmed().get(label) ?? []).includes(value);
64
+ }
65
+ /** 记录 (label, value) 为已确认(幂等;label 为空不落盘)。 */
66
+ function confirm(label, value) {
67
+ if (label === '') {
68
+ return;
69
+ }
70
+ const m = loadConfirmed();
71
+ const list = m.get(label) ?? [];
72
+ if (list.includes(value)) {
73
+ return;
74
+ }
75
+ list.push(value);
76
+ m.set(label, list);
77
+ const lines = [HEADER, '[confirmed]'];
78
+ const labels = [...m.keys()].sort();
79
+ for (const l of labels) {
80
+ const values = (m.get(l) ?? []).map(toml_js_1.escapeTomlString).map((v) => `"${v}"`);
81
+ lines.push(`${(0, toml_js_1.keyLiteral)(l)} = [${values.join(', ')}]`);
82
+ }
83
+ const p = confirmPath();
84
+ try {
85
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
86
+ (0, node_fs_1.writeFileSync)(p, lines.join('\n') + '\n', { mode: 0o644 });
87
+ }
88
+ catch (err) {
89
+ throw new types_js_1.PicktuiError(`写入确认记录失败:${err instanceof Error ? err.message : String(err)}`, 1, '');
90
+ }
91
+ }
92
+ /** 解析确认回答:y/yes(大小写不敏感,容忍首尾空白)为确认。 */
93
+ function parseConfirmAnswer(s) {
94
+ const t = s.trim().toLowerCase();
95
+ return t === 'y' || t === 'yes';
96
+ }
12
97
  /** 报告 (label, value) 是否已被确认过。 */
13
98
  async function confirmCheck(label, value) {
14
- const inv = await (0, engine_js_1.invokeEngine)(['confirm', 'check', label, value]);
15
- (0, engine_js_1.assertSuccess)(inv, 'picktui confirm check');
16
- const parsed = (0, engine_js_1.parseJSON)(inv.stdout, 'picktui confirm check');
17
- return parsed.confirmed;
99
+ return isConfirmed(label, value);
18
100
  }
19
- /**
20
- * 记录 (label, value) 为已确认(幂等)。
21
- * @returns 确认后的实际状态(label 为空时不落盘 → false)
22
- */
101
+ /** 记录 (label, value) 为已确认(幂等);返回确认后的实际状态。 */
23
102
  async function confirmAdd(label, value) {
24
- const inv = await (0, engine_js_1.invokeEngine)(['confirm', 'add', label, value]);
25
- (0, engine_js_1.assertSuccess)(inv, 'picktui confirm add');
26
- const parsed = (0, engine_js_1.parseJSON)(inv.stdout, 'picktui confirm add');
27
- return parsed.confirmed;
103
+ confirm(label, value);
104
+ return isConfirmed(label, value);
28
105
  }
@@ -1,49 +1,312 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.splitKeywords = splitKeywords;
4
+ exports.matchSubstringAll = matchSubstringAll;
5
+ exports.matchTokenPrefixAll = matchTokenPrefixAll;
6
+ exports.matchFuzzyAll = matchFuzzyAll;
7
+ exports.isSubsequence = isSubsequence;
8
+ exports.mergeRanges = mergeRanges;
9
+ exports.highlightRanges = highlightRanges;
10
+ exports.matchCandidate = matchCandidate;
11
+ exports.normalizeOpts = normalizeOpts;
12
+ exports.filterCands = filterCands;
13
+ exports.autoResolve = autoResolve;
3
14
  exports.filter = filter;
4
15
  exports.resolve = resolve;
5
16
  /**
6
- * filter / resolve — 纯函数数据面(直调引擎 filter/resolve --json)。
17
+ * filter / resolve — TS 过滤匹配引擎(对齐 Go filter.go,零依赖)。
7
18
  *
8
- * 过滤与高亮区间由引擎权威计算;本模块只做「候选传入、JSON 取回」,
9
- * 禁止重实现匹配(详见 docs/integration/protocol.md 兼容性承诺)。
19
+ * 三种匹配模式(与协议一致):
20
+ * - 子串 AND(默认):空格分关键字,全部命中才保留,大小写不敏感
21
+ * - token 前缀(--sep <chars>):按分隔符集合切 token(默认 _,可多字符如 ":_"),
22
+ * 每关键字匹配某 token 前缀
23
+ * - 子序列模糊(--fuzzy):关键字为子序列
24
+ *
25
+ * 高亮区间为 rune(code point)索引 [start, end),排序合并;空查询恒 []。
10
26
  */
11
- const engine_js_1 = require("./engine.js");
12
- /** FilterOptions 转为引擎参数。 */
13
- function modeArgs(opts) {
14
- const args = [];
15
- if (opts.mode && opts.mode !== 'substring') {
16
- args.push('--mode', opts.mode);
27
+ const cand_js_1 = require("./cand.js");
28
+ /** 默认分隔符集合(token 模式)。 */
29
+ const DEFAULT_SEP = '_';
30
+ /** 将查询字符串拆分为小写关键字列表(空格分词、小写化、过滤空串)。 */
31
+ function splitKeywords(query) {
32
+ const fields = query.trim().split(/\s+/).filter(Boolean);
33
+ return fields.map((f) => f.toLowerCase());
34
+ }
35
+ /** 按 rune 切分字符串为 code point 数组(与 Go []rune 对齐)。 */
36
+ function toRunes(s) {
37
+ return [...s];
38
+ }
39
+ /** 某 rune 是否在分隔符集合中。 */
40
+ function sepSetOf(sep) {
41
+ return new Set(toRunes(sep));
42
+ }
43
+ /** 按分隔符集合切分 runes,返回每个非空 token 的 [start, end) rune 索引区间。 */
44
+ function splitRuneRanges(runes, seps) {
45
+ if (seps.size === 0) {
46
+ return runes.length > 0 ? [[0, runes.length]] : [];
47
+ }
48
+ const ranges = [];
49
+ let start = 0;
50
+ for (let i = 0; i < runes.length; i++) {
51
+ if (seps.has(runes[i])) {
52
+ if (i > start) {
53
+ ranges.push([start, i]);
54
+ }
55
+ start = i + 1;
56
+ }
57
+ }
58
+ if (start < runes.length) {
59
+ ranges.push([start, runes.length]);
60
+ }
61
+ return ranges;
62
+ }
63
+ /** 按分隔符集合切分字符串为 token 列表(供匹配用)。 */
64
+ function splitTokens(s, seps) {
65
+ if (seps.size === 0) {
66
+ return [s];
67
+ }
68
+ const tokens = [];
69
+ let cur = '';
70
+ for (const r of s) {
71
+ if (seps.has(r)) {
72
+ if (cur !== '') {
73
+ tokens.push(cur);
74
+ cur = '';
75
+ }
76
+ }
77
+ else {
78
+ cur += r;
79
+ }
80
+ }
81
+ if (cur !== '') {
82
+ tokens.push(cur);
83
+ }
84
+ return tokens;
85
+ }
86
+ /** 子串 AND:所有关键字都需作为子串出现(大小写不敏感)。 */
87
+ function matchSubstringAll(s, kws) {
88
+ const lower = s.toLowerCase();
89
+ return kws.every((kw) => lower.includes(kw));
90
+ }
91
+ /** token 前缀:按分隔符集合切 token,每关键字须匹配某 token 的前缀。 */
92
+ function matchTokenPrefixAll(s, kws, sep) {
93
+ const seps = sepSetOf(sep === '' ? DEFAULT_SEP : sep);
94
+ const tokens = splitTokens(s.toLowerCase(), seps);
95
+ return kws.every((kw) => tokens.some((tok) => tok.startsWith(kw)));
96
+ }
97
+ /** 子序列模糊:所有关键字都需作为子序列出现(大小写不敏感)。 */
98
+ function matchFuzzyAll(s, kws) {
99
+ const lower = s.toLowerCase();
100
+ return kws.every((kw) => isSubsequence(kw, lower));
101
+ }
102
+ /** pat 是否为 s 的子序列(不要求连续)。 */
103
+ function isSubsequence(pat, s) {
104
+ if (pat === '') {
105
+ return true;
17
106
  }
18
- if (opts.mode === 'token' && opts.sep) {
19
- args.push('--sep', opts.sep);
107
+ let pi = 0;
108
+ for (let si = 0; si < s.length && pi < pat.length; si++) {
109
+ if (s[si] === pat[pi]) {
110
+ pi++;
111
+ }
20
112
  }
21
- return args;
113
+ return pi === pat.length;
114
+ }
115
+ /** rune 索引:在 s 中查找 substr 的首个出现位置,未找到返回 -1。 */
116
+ function runeIndex(s, substr) {
117
+ if (substr.length === 0 || substr.length > s.length) {
118
+ return -1;
119
+ }
120
+ for (let i = 0; i <= s.length - substr.length; i++) {
121
+ let match = true;
122
+ for (let j = 0; j < substr.length; j++) {
123
+ if (s[i + j] !== substr[j]) {
124
+ match = false;
125
+ break;
126
+ }
127
+ }
128
+ if (match) {
129
+ return i;
130
+ }
131
+ }
132
+ return -1;
133
+ }
134
+ /** 排序并合并重叠的 [start, end) 区间(与 Go mergeRanges 一致)。 */
135
+ function mergeRanges(ranges) {
136
+ if (ranges.length === 0) {
137
+ return [];
138
+ }
139
+ const sorted = [...ranges].sort((a, b) => (a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1]));
140
+ const merged = [sorted[0]];
141
+ for (let i = 1; i < sorted.length; i++) {
142
+ const last = merged[merged.length - 1];
143
+ const r = sorted[i];
144
+ if (r[0] <= last[1]) {
145
+ if (r[1] > last[1]) {
146
+ last[1] = r[1];
147
+ }
148
+ }
149
+ else {
150
+ merged.push(r);
151
+ }
152
+ }
153
+ return merged;
154
+ }
155
+ /**
156
+ * highlightRanges 返回 s 中匹配关键字的 [start, end) rune 索引区间(已排序合并)。
157
+ * 空查询返回 []。
158
+ */
159
+ function highlightRanges(s, query, opts = {}) {
160
+ const kws = splitKeywords(query);
161
+ if (kws.length === 0) {
162
+ return [];
163
+ }
164
+ const lowerRunes = toRunes(s.toLowerCase());
165
+ const ranges = [];
166
+ const mode = opts.mode ?? 'substring';
167
+ if (mode === 'token') {
168
+ const seps = sepSetOf(opts.sep === undefined || opts.sep === '' ? DEFAULT_SEP : opts.sep);
169
+ const tokRanges = splitRuneRanges(lowerRunes, seps);
170
+ for (const kw of kws) {
171
+ const kwRunes = toRunes(kw);
172
+ if (kwRunes.length === 0) {
173
+ continue;
174
+ }
175
+ for (const tr of tokRanges) {
176
+ const tok = lowerRunes.slice(tr[0], tr[1]);
177
+ if (hasRunePrefix(tok, kwRunes)) {
178
+ ranges.push([tr[0], tr[0] + kwRunes.length]);
179
+ break;
180
+ }
181
+ }
182
+ }
183
+ }
184
+ else if (mode === 'fuzzy') {
185
+ for (const kw of kws) {
186
+ const positions = subsequenceRunePositions(toRunes(kw), lowerRunes);
187
+ for (const p of positions) {
188
+ ranges.push([p, p + 1]);
189
+ }
190
+ }
191
+ }
192
+ else {
193
+ for (const kw of kws) {
194
+ const kwRunes = toRunes(kw);
195
+ if (kwRunes.length === 0) {
196
+ continue;
197
+ }
198
+ const idx = runeIndex(lowerRunes, kwRunes);
199
+ if (idx >= 0) {
200
+ ranges.push([idx, idx + kwRunes.length]);
201
+ }
202
+ }
203
+ }
204
+ return mergeRanges(ranges);
205
+ }
206
+ function hasRunePrefix(s, prefix) {
207
+ if (prefix.length > s.length) {
208
+ return false;
209
+ }
210
+ for (let i = 0; i < prefix.length; i++) {
211
+ if (s[i] !== prefix[i]) {
212
+ return false;
213
+ }
214
+ }
215
+ return true;
216
+ }
217
+ /** 返回 pat 作为 s 子序列匹配时的各字符 rune 位置;不完整匹配返回 []。 */
218
+ function subsequenceRunePositions(pat, s) {
219
+ if (pat.length === 0) {
220
+ return [];
221
+ }
222
+ const positions = [];
223
+ let pi = 0;
224
+ for (let si = 0; si < s.length && pi < pat.length; si++) {
225
+ if (s[si] === pat[pi]) {
226
+ positions.push(si);
227
+ pi++;
228
+ }
229
+ }
230
+ if (pi < pat.length) {
231
+ return [];
232
+ }
233
+ return positions;
234
+ }
235
+ /** 匹配模式分派:单候选是否命中全部关键字。 */
236
+ function matchCandidate(s, kws, opts = {}) {
237
+ switch (opts.mode ?? 'substring') {
238
+ case 'token':
239
+ return matchTokenPrefixAll(s, kws, opts.sep ?? '');
240
+ case 'fuzzy':
241
+ return matchFuzzyAll(s, kws);
242
+ default:
243
+ return matchSubstringAll(s, kws);
244
+ }
245
+ }
246
+ /** 归一化 FilterOptions(对齐协议 --mode/--sep 语义)。 */
247
+ function normalizeOpts(opts = {}) {
248
+ return { mode: opts.mode ?? 'substring', sep: opts.sep ?? '' };
249
+ }
250
+ /**
251
+ * filterCands — 过滤 + 高亮区间(纯同步,保留输入顺序)。
252
+ * 描述不参与过滤——过滤永远针对"选中什么"而非"展示什么"。空查询返回全部。
253
+ */
254
+ function filterCands(cands, query, opts = {}) {
255
+ const kws = splitKeywords(query);
256
+ if (kws.length === 0) {
257
+ return cands.map((c) => ({ value: c.value, desc: c.desc ?? '', ranges: [] }));
258
+ }
259
+ const norm = normalizeOpts(opts);
260
+ const out = [];
261
+ for (const c of cands) {
262
+ if (matchCandidate(c.value, kws, norm)) {
263
+ out.push({
264
+ value: c.value,
265
+ desc: c.desc ?? '',
266
+ ranges: highlightRanges(c.value, query, norm),
267
+ });
268
+ }
269
+ }
270
+ return out;
271
+ }
272
+ /**
273
+ * autoResolve — 把查询字符串解析为唯一候选(供 --auto 自动选中):
274
+ * 1. 精确匹配(大小写敏感)直接命中;
275
+ * 2. 否则大小写不敏感的唯一前缀匹配;
276
+ * 无匹配或多个前缀匹配时返回 null。
277
+ */
278
+ function autoResolve(cands, query) {
279
+ if (query === '') {
280
+ return null;
281
+ }
282
+ for (const c of cands) {
283
+ if (c.value === query) {
284
+ return c.value;
285
+ }
286
+ }
287
+ const lq = query.toLowerCase();
288
+ let only = null;
289
+ for (const c of cands) {
290
+ if (c.value.toLowerCase().startsWith(lq)) {
291
+ if (only !== null && only !== c.value) {
292
+ return null; // 多个前缀匹配,无法唯一确定
293
+ }
294
+ only = c.value;
295
+ }
296
+ }
297
+ return only;
22
298
  }
23
299
  /**
24
300
  * filter — 过滤候选并返回命中项(保留输入顺序)+ 高亮区间。
25
- *
26
- * @param cands 候选(字符串或结构化 Candidate)
27
- * @param query 过滤关键字(空格分词,空串返回全部)
28
- * @param opts 匹配模式(默认 substring)
29
- * @returns 命中候选,`ranges` 为命中的 rune 区间(空查询恒为 [])
301
+ * 与协议 filter --json 等价;空查询返回全部(ranges 恒为 [])。
30
302
  */
31
- async function filter(cands, query = '', opts = {}) {
32
- const inv = await (0, engine_js_1.invokeEngine)(['filter', '--json', '--query', query, ...modeArgs(opts)], { input: (0, engine_js_1.serializeCandidates)(cands) });
33
- (0, engine_js_1.assertSuccess)(inv, 'picktui filter');
34
- return (0, engine_js_1.parseJSON)(inv.stdout, 'picktui filter');
303
+ function filter(cands, query = '', opts = {}) {
304
+ return Promise.resolve(filterCands((0, cand_js_1.toStructuredCands)(cands), query, opts));
35
305
  }
36
306
  /**
37
307
  * resolve — 把 query 解析为唯一候选(精确 → 唯一前缀,大小写不敏感前缀)。
38
- *
39
- * @returns 解析出的选中值;无匹配/多个前缀匹配返回 null(引擎退出码 1)
308
+ * @returns 解析出的选中值;无匹配/多个前缀匹配/空 query 返回 null
40
309
  */
41
- async function resolve(cands, query, opts = {}) {
42
- const inv = await (0, engine_js_1.invokeEngine)(['resolve', '--json', '--query', query, ...modeArgs(opts)], { input: (0, engine_js_1.serializeCandidates)(cands) });
43
- if (inv.exitCode === 1) {
44
- return null; // 协议:无唯一解 → stdout 空 + 退出码 1
45
- }
46
- (0, engine_js_1.assertSuccess)(inv, 'picktui resolve');
47
- const parsed = (0, engine_js_1.parseJSON)(inv.stdout, 'picktui resolve');
48
- return parsed.value;
310
+ function resolve(cands, query, _opts = {}) {
311
+ return Promise.resolve(autoResolve((0, cand_js_1.toStructuredCands)(cands), query));
49
312
  }