@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/dist/cjs/tui.js CHANGED
@@ -1,75 +1,493 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OFF_ENV = void 0;
3
4
  exports.pick = pick;
4
5
  exports.menu = menu;
5
6
  exports.rawPick = rawPick;
7
+ exports.runTui = runTui;
8
+ exports.mapperScriptMissing = mapperScriptMissing;
6
9
  /**
7
- * tui — 交互选择 / 菜单(引擎跑 TUI,宿主零渲染)。
10
+ * tui — pick / menu / rawPick(纯 TS 实现,零引擎依赖)。
8
11
  *
9
- * 交互渲染与按键输入走引擎的 /dev/tty;本模块的 stdout 仅收选中值——
10
- * `$(picktui pick ...)` 同样的安全语义。无 TTY 时引擎自动退化
11
- * (无 query 取首个、有 query 过滤取首),绑定无需分支。
12
+ * 交互渲染与按键输入走 /dev/tty(posix)或宿主 stdio(Windows fallback),
13
+ * 宿主 stdout 只承载选中值——与引擎协议相同的安全语义。无 TTY 时自动退化
14
+ * (无 query 取首个、有 query 过滤取首),调用方无需分支;取消返回 null。
15
+ *
16
+ * 流程对齐 Go 引擎 pickWith/menuWith:--auto 确认链、-1 自动选中、
17
+ * 非交互开关($PICKTUI_PICK=off)、选择记忆(--label)、候选来源
18
+ * (位置参数/stdin/--from/--map)。
12
19
  */
13
- const engine_js_1 = require("./engine.js");
20
+ const node_child_process_1 = require("node:child_process");
21
+ const node_fs_1 = require("node:fs");
22
+ const node_os_1 = require("node:os");
23
+ const node_path_1 = require("node:path");
24
+ const cand_js_1 = require("./cand.js");
25
+ const confirm_js_1 = require("./confirm.js");
26
+ const filter_js_1 = require("./filter.js");
27
+ const history_js_1 = require("./history.js");
28
+ const model_js_1 = require("./model.js");
29
+ const tty_js_1 = require("./tty.js");
30
+ const types_js_1 = require("./types.js");
31
+ /** 命令名前缀(对齐引擎默认名,宿主品牌对齐不在本包范围)。 */
32
+ const NAME = 'picktui';
33
+ /** 非交互开关环境变量(值为 "off" 时 pick 跳过 TUI)。 */
34
+ exports.OFF_ENV = 'PICKTUI_PICK';
35
+ /** shell 候选来源/转换器超时(对齐引擎 10s)。 */
36
+ const CMD_TIMEOUT_MS = 10_000;
37
+ /** 光标闪烁周期(对齐引擎 500ms)。 */
38
+ const TICK_MS = 500;
14
39
  /**
15
40
  * pick — 交互过滤选择(fzf 风格)。
16
41
  *
17
- * @param cands 候选(经 stdin 传入,与位置参数等价且无 argv 长度限制)
18
- * @param flags 透传引擎 flags(query/label/sep/fuzzy/select1/auto
19
- * @returns 选中值;用户取消(esc/ctrl+c,退出码 130)返回 null
42
+ * @param cands 候选(不传时从宿主 stdin 管道读取)
43
+ * @param flags query/label/sep/fuzzy/select1/auto
44
+ * @returns 选中值;用户取消(esc/ctrl+c)返回 null
20
45
  */
21
46
  async function pick(cands = [], flags = {}) {
22
- const args = ['pick'];
23
- if (flags.query !== undefined) {
24
- args.push('-q', flags.query);
47
+ const structured = cands.length > 0 ? (0, cand_js_1.toStructuredCands)(cands) : [];
48
+ return pickFlow({
49
+ cands: structured,
50
+ fromCmd: '',
51
+ mapper: '',
52
+ flags,
53
+ });
54
+ }
55
+ /**
56
+ * menu — 多值缩写 TUI 菜单(数字 1-9 直选)。
57
+ * @returns 选中值;取消返回 null;参数不足抛 PicktuiError(退出码 2)
58
+ */
59
+ async function menu(label, cands) {
60
+ if (cands.length < 1) {
61
+ const msg = `usage: ${NAME} _menu <label> <cand...>`;
62
+ throw new types_js_1.PicktuiError(msg, 2, msg);
63
+ }
64
+ const tty = (0, tty_js_1.openTty)();
65
+ if (tty === null) {
66
+ // 非交互:取首个候选(对齐引擎退化:保证脚本不卡)
67
+ return cands[0];
68
+ }
69
+ try {
70
+ const res = await runTui((0, model_js_1.newModel)({
71
+ title: `${NAME} ${label}`,
72
+ subtitle: '多值缩写',
73
+ cands: cands.map((v) => ({ value: v, desc: '' })),
74
+ query: '',
75
+ filterOpts: { mode: 'substring' },
76
+ initial: memoryInitial(label, cands.map((v) => ({ value: v, desc: '' }))),
77
+ jumpKeys: true,
78
+ statusHint: 'type to filter · ↑↓ move · 1-9 jump · enter confirm · esc cancel',
79
+ multi: false,
80
+ selected: [],
81
+ }), tty);
82
+ if (res.cancelled) {
83
+ return null;
84
+ }
85
+ if (label !== '') {
86
+ (0, history_js_1.savePick)(label, res.value);
87
+ }
88
+ return res.value;
25
89
  }
26
- if (flags.label !== undefined) {
27
- args.push('--label', flags.label);
90
+ finally {
91
+ tty.read.destroy();
92
+ }
93
+ }
94
+ /**
95
+ * rawPick — 透传引擎 pick 参数(--from/--map/-q/--label/--sep/--fuzzy/-1/--auto)。
96
+ * @example rawPick(['--from', 'git branch', '--label', 'git co', '-1'])
97
+ */
98
+ async function rawPick(args) {
99
+ const parsed = parsePickArgs(args);
100
+ const structured = parsed.rest.length > 0 ? (0, cand_js_1.toStructuredCands)(parsed.rest) : [];
101
+ return pickFlow({
102
+ cands: structured,
103
+ fromCmd: parsed.from,
104
+ mapper: parsed.mapper,
105
+ flags: parsed.flags,
106
+ });
107
+ }
108
+ /** 解析 pick 参数(对齐引擎 flags:--query=、--from=、--map= 等长格式均支持)。 */
109
+ function parsePickArgs(args) {
110
+ const flags = {};
111
+ let from = '';
112
+ let mapper = '';
113
+ const rest = [];
114
+ for (let i = 0; i < args.length; i++) {
115
+ const a = args[i];
116
+ const take = (name) => {
117
+ if (i + 1 < args.length) {
118
+ i++;
119
+ return args[i];
120
+ }
121
+ throw new types_js_1.PicktuiError(`${NAME} pick: ${name} 缺少参数`, 2, '');
122
+ };
123
+ if (a === '-q' || a === '--query') {
124
+ flags.query = take('--query');
125
+ }
126
+ else if (a.startsWith('--query=')) {
127
+ flags.query = a.slice('--query='.length);
128
+ }
129
+ else if (a === '--from') {
130
+ from = take('--from');
131
+ }
132
+ else if (a.startsWith('--from=')) {
133
+ from = a.slice('--from='.length);
134
+ }
135
+ else if (a === '--map') {
136
+ mapper = take('--map');
137
+ }
138
+ else if (a.startsWith('--map=')) {
139
+ mapper = a.slice('--map='.length);
140
+ }
141
+ else if (a === '--label') {
142
+ flags.label = take('--label');
143
+ }
144
+ else if (a.startsWith('--label=')) {
145
+ flags.label = a.slice('--label='.length);
146
+ }
147
+ else if (a === '--sep') {
148
+ flags.sep = take('--sep');
149
+ }
150
+ else if (a.startsWith('--sep=')) {
151
+ flags.sep = a.slice('--sep='.length);
152
+ }
153
+ else if (a === '--fuzzy') {
154
+ flags.fuzzy = true;
155
+ }
156
+ else if (a === '-1' || a === '--select-1') {
157
+ flags.select1 = true;
158
+ }
159
+ else if (a === '--auto') {
160
+ flags.auto = true;
161
+ }
162
+ else if (a.startsWith('-') && a !== '-') {
163
+ const msg = `${NAME} pick: 未知参数 ${a}`;
164
+ throw new types_js_1.PicktuiError(msg, 2, msg);
165
+ }
166
+ else {
167
+ rest.push(a);
168
+ }
28
169
  }
29
- if (flags.sep !== undefined) {
30
- args.push('--sep', flags.sep);
170
+ return { flags, from, mapper, rest };
171
+ }
172
+ /** pick 全流程(对齐引擎 pickWith)。 */
173
+ async function pickFlow(params) {
174
+ const { flags, fromCmd, mapper } = params;
175
+ const query = flags.query ?? '';
176
+ if (mapper !== '' && params.cands.length > 0) {
177
+ const msg = `${NAME} pick: --map 只与 --from 配合,不能与位置参数同用`;
178
+ throw new types_js_1.PicktuiError(msg, 2, msg);
31
179
  }
180
+ // 构造 FilterOptions(对齐引擎:--fuzzy 优先,--sep 切 token 模式)
181
+ const filterOpts = { mode: 'substring' };
32
182
  if (flags.fuzzy) {
33
- args.push('--fuzzy');
183
+ filterOpts.mode = 'fuzzy';
184
+ }
185
+ else if (flags.sep) {
186
+ filterOpts.mode = 'token';
187
+ filterOpts.sep = flags.sep;
188
+ }
189
+ // 候选来源:显式候选(参数)或 --from 命令执行。
190
+ // 注意:不读宿主进程自身的 stdin——宿主 stdin 是宿主自己的输入流,
191
+ // 由库消费会造成半开 pipe 阻塞并侵入宿主输入(对齐旧版绑定行为)。
192
+ let cands = params.cands;
193
+ if (fromCmd !== '') {
194
+ const raw = await runShell(fromCmd, '');
195
+ if (!raw.ok) {
196
+ throw new types_js_1.PicktuiError(`${NAME} pick: ${fromCmd}: ${raw.stderr}`, 1, raw.stderr);
197
+ }
198
+ cands = (0, cand_js_1.toStructuredCands)(raw.stdout.split('\n'));
199
+ // --map 转换器:接收 --from 原始输出
200
+ if (mapper !== '') {
201
+ cands = await runMapper(mapper, raw.stdout);
202
+ }
203
+ }
204
+ if (cands.length === 0) {
205
+ const msg = `${NAME} pick: no candidates`;
206
+ throw new types_js_1.PicktuiError(msg, 1, msg);
34
207
  }
208
+ const label = flags.label ?? '';
209
+ const off = process.env[exports.OFF_ENV] === 'off';
210
+ // --auto 自动选中:query 精确命中或唯一前缀命中时直接输出(跳过 TUI)。
211
+ // 首次匹配该 (label, value) 需用户确认(记录 confirm 记录,之后不再询问);
212
+ // off / 无 tty / 无 label 时跳过确认;拒绝确认回退下方 TUI。
213
+ if (flags.auto && query !== '') {
214
+ const v = (0, filter_js_1.autoResolve)(cands, query);
215
+ if (v !== null) {
216
+ if (label === '' || off || (0, confirm_js_1.isConfirmed)(label, v)) {
217
+ savePickIfLabeled(label, v);
218
+ return v;
219
+ }
220
+ const tty = (0, tty_js_1.openTty)();
221
+ if (tty !== null) {
222
+ try {
223
+ const answer = await (0, tty_js_1.promptConfirm)(tty, `\r\n${NAME}: 首次自动匹配 ${label} → ${v},确认执行?[y/N] `);
224
+ if (answer !== null && /^(y|yes)$/i.test(answer.trim())) {
225
+ (0, confirm_js_1.confirm)(label, v);
226
+ savePickIfLabeled(label, v);
227
+ return v;
228
+ }
229
+ // 用户拒绝确认:回退到下方 TUI 选择(query 预填过滤)
230
+ }
231
+ finally {
232
+ tty.read.destroy();
233
+ }
234
+ }
235
+ else {
236
+ // 无 /dev/tty:非交互环境跳过确认,保持确定性执行
237
+ savePickIfLabeled(label, v);
238
+ return v;
239
+ }
240
+ }
241
+ }
242
+ // -1 自动选中(仅一个匹配时)
35
243
  if (flags.select1) {
36
- args.push('-1');
244
+ const filtered = (0, filter_js_1.filterCands)(cands, query, filterOpts);
245
+ if (filtered.length === 1) {
246
+ savePickIfLabeled(label, filtered[0].value);
247
+ return filtered[0].value;
248
+ }
249
+ }
250
+ // 非交互开关或无 tty → 退化路径
251
+ const tty = (0, tty_js_1.openTty)();
252
+ if (off || tty === null) {
253
+ return pickNonInteractive(cands, query, filterOpts, label, flags.auto === true);
37
254
  }
38
- if (flags.auto) {
39
- args.push('--auto');
255
+ try {
256
+ const res = await runTui((0, model_js_1.newModel)({
257
+ title: `${NAME} pick`,
258
+ subtitle: label,
259
+ cands,
260
+ query,
261
+ filterOpts,
262
+ initial: memoryInitial(label, cands),
263
+ jumpKeys: false,
264
+ statusHint: 'type to filter · ↑↓ move · enter select · esc cancel',
265
+ multi: false,
266
+ selected: [],
267
+ }), tty);
268
+ if (res.cancelled) {
269
+ return null;
270
+ }
271
+ // 自动解析流程中用户从 TUI 显式选中 → 视为对该解析的确认
272
+ if (flags.auto && label !== '') {
273
+ (0, confirm_js_1.confirm)(label, res.value);
274
+ }
275
+ savePickIfLabeled(label, res.value);
276
+ return res.value;
40
277
  }
41
- const inv = await (0, engine_js_1.invokeEngine)(args, { input: (0, engine_js_1.serializeCandidates)(cands) });
42
- if (inv.exitCode === 130) {
43
- return null; // 取消
278
+ finally {
279
+ tty.read.destroy();
44
280
  }
45
- (0, engine_js_1.assertSuccess)(inv, 'picktui pick');
46
- return inv.stdout.replace(/\r?\n$/, '');
47
281
  }
48
- /**
49
- * menu 多值缩写 TUI 菜单(数字 1-9 直选)。
50
- *
51
- * @param label 菜单标题与选择记忆键
52
- * @param cands 候选(经位置参数传入,注意 argv 长度限制;超长场景用 pick)
53
- * @returns 选中值;取消返回 null
54
- */
55
- async function menu(label, cands) {
56
- const inv = await (0, engine_js_1.invokeEngine)(['_menu', label, ...cands]);
57
- if (inv.exitCode === 130) {
58
- return null;
282
+ /** 非交互模式(对齐引擎 pickNonInteractive)。 */
283
+ function pickNonInteractive(cands, query, opts, label, auto) {
284
+ if (auto && query !== '') {
285
+ const v = (0, filter_js_1.autoResolve)(cands, query);
286
+ if (v !== null) {
287
+ savePickIfLabeled(label, v);
288
+ return v;
289
+ }
290
+ const msg = `${NAME} pick: no unique match for "${query}"`;
291
+ throw new types_js_1.PicktuiError(msg, 1, msg);
292
+ }
293
+ if (query === '') {
294
+ const chosen = cands[0].value;
295
+ savePickIfLabeled(label, chosen);
296
+ return chosen;
59
297
  }
60
- (0, engine_js_1.assertSuccess)(inv, 'picktui _menu');
61
- return inv.stdout.replace(/\r?\n$/, '');
298
+ const filtered = (0, filter_js_1.filterCands)(cands, query, opts);
299
+ if (filtered.length === 0) {
300
+ const msg = `${NAME} pick: no match for "${query}"`;
301
+ throw new types_js_1.PicktuiError(msg, 1, msg);
302
+ }
303
+ const chosen = filtered[0].value;
304
+ savePickIfLabeled(label, chosen);
305
+ return chosen;
306
+ }
307
+ /** label 非空时记录选择记忆(失败不中断主流程,与引擎一致)。 */
308
+ function savePickIfLabeled(label, value) {
309
+ if (label === '') {
310
+ return;
311
+ }
312
+ try {
313
+ (0, history_js_1.savePick)(label, value);
314
+ }
315
+ catch {
316
+ // 记忆只是锦上添花,记录失败不影响选中结果
317
+ }
318
+ }
319
+ /** 选择记忆:仅 query 为空时应用(有预填过滤词说明是精确搜索,不记忆)。 */
320
+ function memoryInitial(label, cands) {
321
+ if (label === '') {
322
+ return 0;
323
+ }
324
+ try {
325
+ const last = (0, history_js_1.lastPick)((0, history_js_1.loadPickHistory)(), label);
326
+ if (last !== '') {
327
+ for (let i = 0; i < cands.length; i++) {
328
+ if (cands[i].value === last) {
329
+ return i;
330
+ }
331
+ }
332
+ }
333
+ }
334
+ catch {
335
+ // 记忆损坏时静默回退首行
336
+ }
337
+ return 0;
338
+ }
339
+ /** 在 tty 上运行 TUI 选择器,返回(取消, 选中值, 多选勾选)。 */
340
+ async function runTui(model, tty) {
341
+ (0, tty_js_1.enterInteractive)(tty);
342
+ const reader = new tty_js_1.KeyReader(tty.read);
343
+ const onResize = () => {
344
+ model.width = tty.write.columns > 0 ? tty.write.columns : model.width;
345
+ model.height = tty.write.rows > 0 ? tty.write.rows : model.height;
346
+ };
347
+ tty.write.on('resize', onResize);
348
+ onResize();
349
+ let pending = null;
350
+ const nextKey = () => {
351
+ if (pending === null) {
352
+ pending = reader.next().finally(() => {
353
+ pending = null;
354
+ });
355
+ }
356
+ return pending;
357
+ };
358
+ try {
359
+ for (;;) {
360
+ const [kind, value] = await Promise.race([
361
+ nextKey().then((k) => ['key', k]),
362
+ sleep(TICK_MS).then(() => ['tick', null]),
363
+ ]);
364
+ if (kind === 'key') {
365
+ const k = value;
366
+ if (k === null) {
367
+ break; // 输入流关闭(EOF)
368
+ }
369
+ model = (0, model_js_1.update)(model, toKeyLike(k));
370
+ if (model.quit) {
371
+ break;
372
+ }
373
+ }
374
+ else {
375
+ model = { ...model, phase: model.phase ^ 1 };
376
+ }
377
+ (0, tty_js_1.renderFrame)(tty, (0, model_js_1.view)(model));
378
+ }
379
+ }
380
+ finally {
381
+ (0, tty_js_1.exitInteractive)(tty);
382
+ tty.write.removeListener('resize', onResize);
383
+ tty.write.write('\x1b[2J\x1b[H');
384
+ }
385
+ if (model.cancelled || model.filtered.length === 0) {
386
+ return { cancelled: true, value: '', selected: [] };
387
+ }
388
+ if (model.opts.multi) {
389
+ return {
390
+ cancelled: false,
391
+ value: model.filtered[model.cursor].value,
392
+ selected: (0, model_js_1.selectedValues)(model),
393
+ };
394
+ }
395
+ return { cancelled: false, value: model.filtered[model.cursor].value, selected: [] };
396
+ }
397
+ /** 归一化按键:可打印字符转字符串('k'/'j' 等移动键语义由 model 处理)。 */
398
+ function toKeyLike(k) {
399
+ if (typeof k === 'object') {
400
+ return k.value === '' ? 'x' : k.value; // 左右方向键等忽略键映射为无操作
401
+ }
402
+ return k;
403
+ }
404
+ /** 执行 shell 命令(10s 超时,input 喂 stdin);失败时附带 stderr 诊断。 */
405
+ function runShell(cmd, input) {
406
+ return new Promise((resolve) => {
407
+ const child = (0, node_child_process_1.exec)(cmd, {
408
+ timeout: CMD_TIMEOUT_MS,
409
+ maxBuffer: 64 * 1024 * 1024,
410
+ windowsHide: true,
411
+ }, (err, stdout) => {
412
+ if (err) {
413
+ const stderr = err.stderr ?? '';
414
+ resolve({ ok: false, stderr: stderr.trim() || err.message });
415
+ return;
416
+ }
417
+ resolve({ ok: true, stdout: stdout ?? '' });
418
+ });
419
+ child.stdin?.end(input);
420
+ });
421
+ }
422
+ /** 执行 --map 转换器:stdin 喂 input,输出按结构化候选解析;脚本缺失 fail-fast。 */
423
+ async function runMapper(mapper, input) {
424
+ const missing = mapperScriptMissing(mapper);
425
+ if (missing !== '') {
426
+ const msg = `map 脚本文件不存在: ${missing}\n → 脚本可能已被移动、删除或重命名`;
427
+ throw new types_js_1.PicktuiError(msg, 1, msg);
428
+ }
429
+ const res = await runShell(mapper, input);
430
+ if (!res.ok) {
431
+ throw new types_js_1.PicktuiError(`${NAME} pick: --map ${mapper}: ${res.stderr}`, 1, res.stderr);
432
+ }
433
+ return (0, cand_js_1.toStructuredCands)(res.stdout.split('\n'));
62
434
  }
63
435
  /**
64
- * rawPick 透传任意引擎 pick 参数(--from/--map 等绑定未包装的 flags)。
65
- *
66
- * @example rawPick(['--from', 'git branch', '--label', 'git co', '-1'])
436
+ * 扫描 mapper 命令,识别"解释器 + 脚本路径"形态,返回第一个不存在的脚本文件路径。
437
+ * 保守策略:跳过 flags/引号/变量/管道等 shell 语法,只检查形如路径的 token
438
+ * (/ ./ ../ ~/ 前缀、带常见脚本扩展名),避免误拦 node -e / python3 -m 等合法用法。
67
439
  */
68
- async function rawPick(args) {
69
- const inv = await (0, engine_js_1.invokeEngine)(['pick', ...args]);
70
- if (inv.exitCode === 130) {
71
- return null;
440
+ function mapperScriptMissing(mapper) {
441
+ const fields = mapper.trim().split(/\s+/).filter(Boolean);
442
+ if (fields.length < 2) {
443
+ return '';
444
+ }
445
+ for (const tok of fields.slice(1)) {
446
+ if (tok.startsWith('-')) {
447
+ continue;
448
+ }
449
+ if (/['"`${}|;&<>?*=]/.test(tok)) {
450
+ continue;
451
+ }
452
+ if (!scriptPathLike(tok)) {
453
+ continue;
454
+ }
455
+ const p = expandTilde(tok);
456
+ if ((0, node_fs_1.existsSync)(p)) {
457
+ continue;
458
+ }
459
+ return p;
460
+ }
461
+ return '';
462
+ }
463
+ /** token 是否形如脚本文件路径:/ ./ ../ ~/ 前缀,或带常见脚本扩展名。 */
464
+ function scriptPathLike(tok) {
465
+ if (tok.startsWith('/') || tok.startsWith('./') || tok.startsWith('../') || tok.startsWith('~/')) {
466
+ return true;
467
+ }
468
+ return isScriptExt((0, node_path_1.extname)(tok).toLowerCase());
469
+ }
470
+ /** 常见脚本解释器可直接执行的文件扩展名。 */
471
+ function isScriptExt(ext) {
472
+ return [
473
+ '.mjs', '.cjs', '.js', '.ts', '.py', '.sh', '.bash', '.zsh', '.fish',
474
+ '.pl', '.rb', '.php', '.lua', '.exs', '.r',
475
+ ].includes(ext);
476
+ }
477
+ /** 展开 ~ 与 ~/ 前缀为用户主目录。 */
478
+ function expandTilde(p) {
479
+ if (p === '~') {
480
+ return (0, node_os_1.homedir)();
72
481
  }
73
- (0, engine_js_1.assertSuccess)(inv, 'picktui pick');
74
- return inv.stdout.replace(/\r?\n$/, '');
482
+ if (p.startsWith('~/')) {
483
+ return (0, node_path_1.join)((0, node_os_1.homedir)(), p.slice(2));
484
+ }
485
+ return p;
486
+ }
487
+ function sleep(ms) {
488
+ return new Promise((resolve) => setTimeout(resolve, ms));
489
+ }
490
+ // 颜色开关:NO_COLOR 环境变量时降级纯文本
491
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') {
492
+ (0, model_js_1.setColorEnabled)(false);
75
493
  }
@@ -0,0 +1,47 @@
1
+ /** 将 `string | Candidate` 输入序列化为协议结构化行(key<TAB>description)。 */
2
+ export function serializeLines(cands) {
3
+ return cands.map((c) => (typeof c === 'string' ? c : c.desc ? `${c.value}\t${c.desc}` : c.value));
4
+ }
5
+ /** 将原始行列表解析为结构化候选(与 Go StructuredCandidates 同语义)。 */
6
+ export function structuredCandidates(lines) {
7
+ const idx = new Map();
8
+ const out = [];
9
+ for (const line of lines) {
10
+ let value = line;
11
+ let desc = '';
12
+ const tab = line.indexOf('\t');
13
+ if (tab >= 0) {
14
+ value = line.slice(0, tab);
15
+ desc = line.slice(tab + 1);
16
+ }
17
+ value = value.trim();
18
+ if (value === '') {
19
+ continue;
20
+ }
21
+ if (value.startsWith('* ')) {
22
+ value = value.slice(2);
23
+ }
24
+ else if (value.startsWith('+ ')) {
25
+ value = value.slice(2);
26
+ }
27
+ value = value.trim();
28
+ if (value === '') {
29
+ continue;
30
+ }
31
+ desc = desc.trim();
32
+ const j = idx.get(value);
33
+ if (j !== undefined) {
34
+ if (out[j].desc === '' && desc !== '') {
35
+ out[j] = { value, desc };
36
+ }
37
+ continue;
38
+ }
39
+ idx.set(value, out.length);
40
+ out.push({ value, desc });
41
+ }
42
+ return out;
43
+ }
44
+ /** 输入候选统一解析为结构化候选(filter/resolve/pick 共用入口)。 */
45
+ export function toStructuredCands(cands) {
46
+ return structuredCandidates(serializeLines(cands));
47
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * config — 数据目录解析(对齐协议:hist/confirm/pick --label 状态文件所在)。
3
+ *
4
+ * 优先级:$PICKTUI_CONFIG_DIR → $XDG_CONFIG_HOME/picktui → ~/.config/picktui。
5
+ */
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ /** 状态文件目录(history.toml / confirm.toml 所在)。 */
9
+ export function dataDir() {
10
+ const explicit = process.env.PICKTUI_CONFIG_DIR;
11
+ if (explicit) {
12
+ return explicit;
13
+ }
14
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
15
+ return join(base, 'picktui');
16
+ }