@havocrao/picktui 0.1.0

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 ADDED
@@ -0,0 +1,50 @@
1
+ # @havocrao/picktui — TS/JS 绑定
2
+
3
+ 薄绑定:只做「候选传入、结果取回」,渲染/按键/匹配全部在 Go 引擎
4
+ (`../go/cmd/picktui`)内完成,效果与 Go 宿主逐像素一致。
5
+ 同份源码构建 esm + cjs + d.ts,TS 与 JS 共用。
6
+
7
+ ## 安装
8
+
9
+ ```console
10
+ $ npm install @havocrao/picktui
11
+ ```
12
+
13
+ 引擎发现顺序:`$PICKTUI_BIN` → `$PATH` 中的 `picktui`。交互 TUI 由引擎跑在
14
+ `/dev/tty`,本包 stdout 只回选中值。无 TTY 时引擎自动退化
15
+ (无 query 取首个、有 query 过滤取首),绑定无需分支;
16
+ 取消(esc/ctrl+c)返回 `null`;协议错误抛出 `PicktuiError`(含 exitCode/stderr)。
17
+
18
+ ## API
19
+
20
+ ```ts
21
+ import { filter, resolve } from '@havocrao/picktui/filter'
22
+ import { pick, menu, rawPick } from '@havocrao/picktui/tui'
23
+ import { histGet, histSet } from '@havocrao/picktui/history'
24
+ import { confirmCheck, confirmAdd } from '@havocrao/picktui/confirm'
25
+ import type { Candidate, FilterOptions, FilteredCandidate, PickFlags } from '@havocrao/picktui/types'
26
+ import { engineVersion, assertEngineVersion } from '@havocrao/picktui'
27
+ ```
28
+
29
+ | 函数 | 说明 |
30
+ |---|---|
31
+ | `filter(cands, query?, opts?)` | 过滤 + 高亮区间(ranges 为 rune 区间,空查询恒 `[]`) |
32
+ | `resolve(cands, query)` | 精确/唯一前缀解析;无唯一解返回 `null` |
33
+ | `pick(cands?, flags?)` | 交互过滤选择;`flags` 透传 `-q/--label/--sep/--fuzzy/-1/--auto` |
34
+ | `menu(label, cands)` | 多值缩写菜单(数字 1-9 直选) |
35
+ | `rawPick([...args])` | 透传引擎 pick 参数(`--from`/`--map` 等) |
36
+ | `histGet(label)` / `histSet(label, value)` | 选择记忆 |
37
+ | `confirmCheck` / `confirmAdd` | 自动匹配首次确认(add 幂等) |
38
+ | `assertEngineVersion(min?)` | 校验引擎版本不低于绑定声明的最低版本 |
39
+
40
+ ## 开发
41
+
42
+ ```console
43
+ $ npm install # 安装 devDependencies(typescript)
44
+ $ npm run build # tsc 双输出:dist/esm + dist/cjs + dist/types
45
+ $ npm test # pretest 自动构建引擎二进制 → node:test 集成测试
46
+ $ npm run test:pack # npm pack → 干净目录安装 → import/require 冒烟
47
+ ```
48
+
49
+ 测试均为对真实引擎二进制的 JSON 往返(无 TTY 依赖)。交互 TUI 路径
50
+ 由引擎侧 model 级测试覆盖。
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.confirmCheck = confirmCheck;
4
+ exports.confirmAdd = confirmAdd;
5
+ /**
6
+ * confirm — 自动匹配首次确认(文件状态,直调引擎 confirm 子命令)。
7
+ *
8
+ * 状态文件位于引擎数据目录,与 pick --auto 的确认记录同一份:
9
+ * 首次自动匹配某 (label, value) 需确认,确认后不再询问。
10
+ */
11
+ const engine_js_1 = require("./engine.js");
12
+ /** 报告 (label, value) 是否已被确认过。 */
13
+ 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;
18
+ }
19
+ /**
20
+ * 记录 (label, value) 为已确认(幂等)。
21
+ * @returns 确认后的实际状态(label 为空时不落盘 → false)
22
+ */
23
+ 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;
28
+ }
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MIN_ENGINE_VERSION = void 0;
4
+ exports.engineBin = engineBin;
5
+ exports.invokeEngine = invokeEngine;
6
+ exports.assertSuccess = assertSuccess;
7
+ exports.parseJSON = parseJSON;
8
+ exports.engineVersion = engineVersion;
9
+ exports.versionAtLeast = versionAtLeast;
10
+ exports.assertEngineVersion = assertEngineVersion;
11
+ exports.serializeCandidates = serializeCandidates;
12
+ /**
13
+ * engine — 引擎发现与进程调用(薄绑定核心,零渲染逻辑)。
14
+ *
15
+ * 引擎发现顺序(对齐 docs/integration/protocol.md):
16
+ * 1. $PICKTUI_BIN
17
+ * 2. $PATH 中的 picktui / picktui.exe
18
+ *
19
+ * 渲染/按键/匹配全部在引擎内完成;本模块只负责 spawn + stdout/stderr 收集,
20
+ * 保证宿主 stdout 不被污染(交互 TUI 走引擎的 /dev/tty)。
21
+ */
22
+ const node_child_process_1 = require("node:child_process");
23
+ const node_fs_1 = require("node:fs");
24
+ const node_path_1 = require("node:path");
25
+ const types_js_1 = require("./types.js");
26
+ /** 绑定声明的最低引擎版本(协议 v1)。 */
27
+ exports.MIN_ENGINE_VERSION = '0.1.0';
28
+ /** 引擎二进制名(win32 带 .exe 后缀)。 */
29
+ function binName() {
30
+ return process.platform === 'win32' ? 'picktui.exe' : 'picktui';
31
+ }
32
+ /** 解析引擎二进制绝对路径;找不到时抛出带指引的错误。 */
33
+ async function engineBin() {
34
+ const fromEnv = process.env.PICKTUI_BIN;
35
+ if (fromEnv) {
36
+ return fromEnv;
37
+ }
38
+ const name = binName();
39
+ const dirs = (process.env.PATH ?? '').split(node_path_1.delimiter).filter(Boolean);
40
+ for (const dir of dirs) {
41
+ const candidate = (0, node_path_1.join)(dir, name);
42
+ if ((0, node_fs_1.existsSync)(candidate)) {
43
+ return candidate;
44
+ }
45
+ }
46
+ throw new Error(`picktui 引擎未找到:请设置 PICKTUI_BIN 指向引擎二进制,或将 ${name} 加入 PATH。` +
47
+ `(引擎即本仓库 Go 模块的 cmd/picktui,` +
48
+ `见 README「引擎二进制」一节;` +
49
+ `纯数据场景可设 PICKTUI_NO_BIN=1 使用内嵌 JS 过滤实现——该实现尚未提供,请先安装引擎。)`);
50
+ }
51
+ /** 调用引擎子命令:数据走 stdin,结果回 stdout/stderr(无 TTY 依赖)。 */
52
+ async function invokeEngine(args, opts = {}) {
53
+ const bin = await engineBin();
54
+ return new Promise((resolve, reject) => {
55
+ let settled = false;
56
+ const fail = (err) => {
57
+ if (!settled) {
58
+ settled = true;
59
+ reject(err);
60
+ }
61
+ };
62
+ const done = (inv) => {
63
+ if (!settled) {
64
+ settled = true;
65
+ resolve(inv);
66
+ }
67
+ };
68
+ const child = (0, node_child_process_1.spawn)(bin, args, {
69
+ stdio: ['pipe', 'pipe', 'pipe'],
70
+ env: { ...process.env, ...opts.env },
71
+ // 引擎自身通过 /dev/tty 完成交互渲染与输入;这里不需要终端
72
+ windowsHide: true,
73
+ });
74
+ let stdout = '';
75
+ let stderr = '';
76
+ child.stdout.setEncoding('utf8');
77
+ child.stderr.setEncoding('utf8');
78
+ child.stdout.on('data', (chunk) => (stdout += chunk));
79
+ child.stderr.on('data', (chunk) => (stderr += chunk));
80
+ child.on('error', fail);
81
+ child.on('close', (code) => done({ exitCode: code ?? -1, stdout, stderr }));
82
+ if (opts.input !== undefined) {
83
+ child.stdin.end(opts.input);
84
+ }
85
+ else {
86
+ child.stdin.end();
87
+ }
88
+ });
89
+ }
90
+ /** 引擎调用约定:退出码 0 成功 / 1 运行错误 / 2 用法错误 / 130 取消。 */
91
+ function assertSuccess(inv, what) {
92
+ if (inv.exitCode === 0) {
93
+ return;
94
+ }
95
+ const detail = inv.stderr.trim() || `exit code ${inv.exitCode}`;
96
+ throw new types_js_1.PicktuiError(`${what}失败:${detail}`, inv.exitCode, inv.stderr);
97
+ }
98
+ /** 解析 stdout 为 JSON(协议输出恒为 JSON);失败抛 PicktuiError。 */
99
+ function parseJSON(stdout, what) {
100
+ try {
101
+ return JSON.parse(stdout);
102
+ }
103
+ catch {
104
+ throw new types_js_1.PicktuiError(`${what}输出不是合法 JSON:${stdout.slice(0, 200)}`, -1, stdout);
105
+ }
106
+ }
107
+ /** 读取引擎版本("picktui <semver>")。 */
108
+ async function engineVersion() {
109
+ const bin = await engineBin();
110
+ const inv = await invokeEngine(['version']);
111
+ assertSuccess(inv, 'picktui version');
112
+ const match = /^picktui\s+(\d+\.\d+\.\d+)/.exec(inv.stdout.trim());
113
+ if (!match) {
114
+ throw new types_js_1.PicktuiError(`无法解析引擎版本:${inv.stdout.trim()}`, inv.exitCode, inv.stdout);
115
+ }
116
+ return match[1];
117
+ }
118
+ /** 比较两个 x.y.z 版本号:a >= b。 */
119
+ function versionAtLeast(a, b) {
120
+ const pa = a.split('.').map(Number);
121
+ const pb = b.split('.').map(Number);
122
+ for (let i = 0; i < 3; i++) {
123
+ const x = pa[i] ?? 0;
124
+ const y = pb[i] ?? 0;
125
+ if (x !== y) {
126
+ return x > y;
127
+ }
128
+ }
129
+ return true;
130
+ }
131
+ /** 校验引擎版本不低于绑定声明的最低版本;不满足时抛错。 */
132
+ async function assertEngineVersion(min = exports.MIN_ENGINE_VERSION) {
133
+ const v = await engineVersion();
134
+ if (!versionAtLeast(v, min)) {
135
+ throw new types_js_1.PicktuiError(`picktui 引擎版本 ${v} 低于绑定要求的最低版本 ${min}(请升级引擎或设置 PICKTUI_BIN)`, -1, '');
136
+ }
137
+ return v;
138
+ }
139
+ /** 将候选列表序列化为协议结构化行(key<TAB>description)。 */
140
+ function serializeCandidates(cands) {
141
+ return cands
142
+ .map((c) => (typeof c === 'string' ? c : c.desc ? `${c.value}\t${c.desc}` : c.value))
143
+ .join('\n') + '\n';
144
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.filter = filter;
4
+ exports.resolve = resolve;
5
+ /**
6
+ * filter / resolve — 纯函数数据面(直调引擎 filter/resolve --json)。
7
+ *
8
+ * 过滤与高亮区间由引擎权威计算;本模块只做「候选传入、JSON 取回」,
9
+ * 禁止重实现匹配(详见 docs/integration/protocol.md 兼容性承诺)。
10
+ */
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);
17
+ }
18
+ if (opts.mode === 'token' && opts.sep) {
19
+ args.push('--sep', opts.sep);
20
+ }
21
+ return args;
22
+ }
23
+ /**
24
+ * filter — 过滤候选并返回命中项(保留输入顺序)+ 高亮区间。
25
+ *
26
+ * @param cands 候选(字符串或结构化 Candidate)
27
+ * @param query 过滤关键字(空格分词,空串返回全部)
28
+ * @param opts 匹配模式(默认 substring)
29
+ * @returns 命中候选,`ranges` 为命中的 rune 区间(空查询恒为 [])
30
+ */
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');
35
+ }
36
+ /**
37
+ * resolve — 把 query 解析为唯一候选(精确 → 唯一前缀,大小写不敏感前缀)。
38
+ *
39
+ * @returns 解析出的选中值;无匹配/多个前缀匹配返回 null(引擎退出码 1)
40
+ */
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;
49
+ }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.histGet = histGet;
4
+ exports.histSet = histSet;
5
+ /**
6
+ * history — 选择记忆(文件状态,直调引擎 hist 子命令)。
7
+ *
8
+ * 状态文件位于引擎数据目录($PICKTUI_CONFIG_DIR → $XDG_CONFIG_HOME/picktui →
9
+ * ~/.config/picktui),与 pick --label 的 TUI 记忆同一份。
10
+ */
11
+ const engine_js_1 = require("./engine.js");
12
+ /** 读取 label 上次选中的值;无记录返回 ""。 */
13
+ async function histGet(label) {
14
+ const inv = await (0, engine_js_1.invokeEngine)(['hist', 'get', label]);
15
+ (0, engine_js_1.assertSuccess)(inv, 'picktui hist get');
16
+ const parsed = (0, engine_js_1.parseJSON)(inv.stdout, 'picktui hist get');
17
+ return parsed.last ?? '';
18
+ }
19
+ /** 记录 label 的上次选中值(成功无输出;失败抛 PicktuiError)。 */
20
+ async function histSet(label, value) {
21
+ const inv = await (0, engine_js_1.invokeEngine)(['hist', 'set', label, value]);
22
+ (0, engine_js_1.assertSuccess)(inv, 'picktui hist set');
23
+ }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ /**
18
+ * index — 聚合导出;亦支持按模块 import(tree-shakable):
19
+ * import { filter } from '@havocrao/picktui/filter'
20
+ * import { pick } from '@havocrao/picktui/tui'
21
+ * import { histGet } from '@havocrao/picktui/history'
22
+ */
23
+ __exportStar(require("./types.js"), exports);
24
+ __exportStar(require("./engine.js"), exports);
25
+ __exportStar(require("./filter.js"), exports);
26
+ __exportStar(require("./tui.js"), exports);
27
+ __exportStar(require("./history.js"), exports);
28
+ __exportStar(require("./confirm.js"), exports);
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pick = pick;
4
+ exports.menu = menu;
5
+ exports.rawPick = rawPick;
6
+ /**
7
+ * tui — 交互选择 / 菜单(引擎跑 TUI,宿主零渲染)。
8
+ *
9
+ * 交互渲染与按键输入走引擎的 /dev/tty;本模块的 stdout 仅收选中值——
10
+ * 与 `$(picktui pick ...)` 同样的安全语义。无 TTY 时引擎自动退化
11
+ * (无 query 取首个、有 query 过滤取首),绑定无需分支。
12
+ */
13
+ const engine_js_1 = require("./engine.js");
14
+ /**
15
+ * pick — 交互过滤选择(fzf 风格)。
16
+ *
17
+ * @param cands 候选(经 stdin 传入,与位置参数等价且无 argv 长度限制)
18
+ * @param flags 透传引擎 flags(query/label/sep/fuzzy/select1/auto)
19
+ * @returns 选中值;用户取消(esc/ctrl+c,退出码 130)返回 null
20
+ */
21
+ async function pick(cands = [], flags = {}) {
22
+ const args = ['pick'];
23
+ if (flags.query !== undefined) {
24
+ args.push('-q', flags.query);
25
+ }
26
+ if (flags.label !== undefined) {
27
+ args.push('--label', flags.label);
28
+ }
29
+ if (flags.sep !== undefined) {
30
+ args.push('--sep', flags.sep);
31
+ }
32
+ if (flags.fuzzy) {
33
+ args.push('--fuzzy');
34
+ }
35
+ if (flags.select1) {
36
+ args.push('-1');
37
+ }
38
+ if (flags.auto) {
39
+ args.push('--auto');
40
+ }
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; // 取消
44
+ }
45
+ (0, engine_js_1.assertSuccess)(inv, 'picktui pick');
46
+ return inv.stdout.replace(/\r?\n$/, '');
47
+ }
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;
59
+ }
60
+ (0, engine_js_1.assertSuccess)(inv, 'picktui _menu');
61
+ return inv.stdout.replace(/\r?\n$/, '');
62
+ }
63
+ /**
64
+ * rawPick — 透传任意引擎 pick 参数(--from/--map 等绑定未包装的 flags)。
65
+ *
66
+ * @example rawPick(['--from', 'git branch', '--label', 'git co', '-1'])
67
+ */
68
+ async function rawPick(args) {
69
+ const inv = await (0, engine_js_1.invokeEngine)(['pick', ...args]);
70
+ if (inv.exitCode === 130) {
71
+ return null;
72
+ }
73
+ (0, engine_js_1.assertSuccess)(inv, 'picktui pick');
74
+ return inv.stdout.replace(/\r?\n$/, '');
75
+ }
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ /**
3
+ * types — 与 docs/integration/protocol.md 对齐的纯类型(零依赖)。
4
+ *
5
+ * 字段名/形状是协议 v1 的一部分,任何改动需同步 protocol.md 与 Go 引擎。
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.PicktuiError = void 0;
9
+ /** 引擎调用失败(退出码非预期)时抛出的错误。 */
10
+ class PicktuiError extends Error {
11
+ exitCode;
12
+ stderr;
13
+ constructor(message, exitCode, stderr) {
14
+ super(message);
15
+ this.name = 'PicktuiError';
16
+ this.exitCode = exitCode;
17
+ this.stderr = stderr;
18
+ }
19
+ }
20
+ exports.PicktuiError = PicktuiError;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * confirm — 自动匹配首次确认(文件状态,直调引擎 confirm 子命令)。
3
+ *
4
+ * 状态文件位于引擎数据目录,与 pick --auto 的确认记录同一份:
5
+ * 首次自动匹配某 (label, value) 需确认,确认后不再询问。
6
+ */
7
+ import { assertSuccess, invokeEngine, parseJSON } from './engine.js';
8
+ /** 报告 (label, value) 是否已被确认过。 */
9
+ export async function confirmCheck(label, value) {
10
+ const inv = await invokeEngine(['confirm', 'check', label, value]);
11
+ assertSuccess(inv, 'picktui confirm check');
12
+ const parsed = parseJSON(inv.stdout, 'picktui confirm check');
13
+ return parsed.confirmed;
14
+ }
15
+ /**
16
+ * 记录 (label, value) 为已确认(幂等)。
17
+ * @returns 确认后的实际状态(label 为空时不落盘 → false)
18
+ */
19
+ export async function confirmAdd(label, value) {
20
+ const inv = await invokeEngine(['confirm', 'add', label, value]);
21
+ assertSuccess(inv, 'picktui confirm add');
22
+ const parsed = parseJSON(inv.stdout, 'picktui confirm add');
23
+ return parsed.confirmed;
24
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * engine — 引擎发现与进程调用(薄绑定核心,零渲染逻辑)。
3
+ *
4
+ * 引擎发现顺序(对齐 docs/integration/protocol.md):
5
+ * 1. $PICKTUI_BIN
6
+ * 2. $PATH 中的 picktui / picktui.exe
7
+ *
8
+ * 渲染/按键/匹配全部在引擎内完成;本模块只负责 spawn + stdout/stderr 收集,
9
+ * 保证宿主 stdout 不被污染(交互 TUI 走引擎的 /dev/tty)。
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import { existsSync } from 'node:fs';
13
+ import { delimiter, join } from 'node:path';
14
+ import { PicktuiError } from './types.js';
15
+ /** 绑定声明的最低引擎版本(协议 v1)。 */
16
+ export const MIN_ENGINE_VERSION = '0.1.0';
17
+ /** 引擎二进制名(win32 带 .exe 后缀)。 */
18
+ function binName() {
19
+ return process.platform === 'win32' ? 'picktui.exe' : 'picktui';
20
+ }
21
+ /** 解析引擎二进制绝对路径;找不到时抛出带指引的错误。 */
22
+ export async function engineBin() {
23
+ const fromEnv = process.env.PICKTUI_BIN;
24
+ if (fromEnv) {
25
+ return fromEnv;
26
+ }
27
+ const name = binName();
28
+ const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
29
+ for (const dir of dirs) {
30
+ const candidate = join(dir, name);
31
+ if (existsSync(candidate)) {
32
+ return candidate;
33
+ }
34
+ }
35
+ throw new Error(`picktui 引擎未找到:请设置 PICKTUI_BIN 指向引擎二进制,或将 ${name} 加入 PATH。` +
36
+ `(引擎即本仓库 Go 模块的 cmd/picktui,` +
37
+ `见 README「引擎二进制」一节;` +
38
+ `纯数据场景可设 PICKTUI_NO_BIN=1 使用内嵌 JS 过滤实现——该实现尚未提供,请先安装引擎。)`);
39
+ }
40
+ /** 调用引擎子命令:数据走 stdin,结果回 stdout/stderr(无 TTY 依赖)。 */
41
+ export async function invokeEngine(args, opts = {}) {
42
+ const bin = await engineBin();
43
+ return new Promise((resolve, reject) => {
44
+ let settled = false;
45
+ const fail = (err) => {
46
+ if (!settled) {
47
+ settled = true;
48
+ reject(err);
49
+ }
50
+ };
51
+ const done = (inv) => {
52
+ if (!settled) {
53
+ settled = true;
54
+ resolve(inv);
55
+ }
56
+ };
57
+ const child = spawn(bin, args, {
58
+ stdio: ['pipe', 'pipe', 'pipe'],
59
+ env: { ...process.env, ...opts.env },
60
+ // 引擎自身通过 /dev/tty 完成交互渲染与输入;这里不需要终端
61
+ windowsHide: true,
62
+ });
63
+ let stdout = '';
64
+ let stderr = '';
65
+ child.stdout.setEncoding('utf8');
66
+ child.stderr.setEncoding('utf8');
67
+ child.stdout.on('data', (chunk) => (stdout += chunk));
68
+ child.stderr.on('data', (chunk) => (stderr += chunk));
69
+ child.on('error', fail);
70
+ child.on('close', (code) => done({ exitCode: code ?? -1, stdout, stderr }));
71
+ if (opts.input !== undefined) {
72
+ child.stdin.end(opts.input);
73
+ }
74
+ else {
75
+ child.stdin.end();
76
+ }
77
+ });
78
+ }
79
+ /** 引擎调用约定:退出码 0 成功 / 1 运行错误 / 2 用法错误 / 130 取消。 */
80
+ export function assertSuccess(inv, what) {
81
+ if (inv.exitCode === 0) {
82
+ return;
83
+ }
84
+ const detail = inv.stderr.trim() || `exit code ${inv.exitCode}`;
85
+ throw new PicktuiError(`${what}失败:${detail}`, inv.exitCode, inv.stderr);
86
+ }
87
+ /** 解析 stdout 为 JSON(协议输出恒为 JSON);失败抛 PicktuiError。 */
88
+ export function parseJSON(stdout, what) {
89
+ try {
90
+ return JSON.parse(stdout);
91
+ }
92
+ catch {
93
+ throw new PicktuiError(`${what}输出不是合法 JSON:${stdout.slice(0, 200)}`, -1, stdout);
94
+ }
95
+ }
96
+ /** 读取引擎版本("picktui <semver>")。 */
97
+ export async function engineVersion() {
98
+ const bin = await engineBin();
99
+ const inv = await invokeEngine(['version']);
100
+ assertSuccess(inv, 'picktui version');
101
+ const match = /^picktui\s+(\d+\.\d+\.\d+)/.exec(inv.stdout.trim());
102
+ if (!match) {
103
+ throw new PicktuiError(`无法解析引擎版本:${inv.stdout.trim()}`, inv.exitCode, inv.stdout);
104
+ }
105
+ return match[1];
106
+ }
107
+ /** 比较两个 x.y.z 版本号:a >= b。 */
108
+ export function versionAtLeast(a, b) {
109
+ const pa = a.split('.').map(Number);
110
+ const pb = b.split('.').map(Number);
111
+ for (let i = 0; i < 3; i++) {
112
+ const x = pa[i] ?? 0;
113
+ const y = pb[i] ?? 0;
114
+ if (x !== y) {
115
+ return x > y;
116
+ }
117
+ }
118
+ return true;
119
+ }
120
+ /** 校验引擎版本不低于绑定声明的最低版本;不满足时抛错。 */
121
+ export async function assertEngineVersion(min = MIN_ENGINE_VERSION) {
122
+ const v = await engineVersion();
123
+ if (!versionAtLeast(v, min)) {
124
+ throw new PicktuiError(`picktui 引擎版本 ${v} 低于绑定要求的最低版本 ${min}(请升级引擎或设置 PICKTUI_BIN)`, -1, '');
125
+ }
126
+ return v;
127
+ }
128
+ /** 将候选列表序列化为协议结构化行(key<TAB>description)。 */
129
+ export function serializeCandidates(cands) {
130
+ return cands
131
+ .map((c) => (typeof c === 'string' ? c : c.desc ? `${c.value}\t${c.desc}` : c.value))
132
+ .join('\n') + '\n';
133
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * filter / resolve — 纯函数数据面(直调引擎 filter/resolve --json)。
3
+ *
4
+ * 过滤与高亮区间由引擎权威计算;本模块只做「候选传入、JSON 取回」,
5
+ * 禁止重实现匹配(详见 docs/integration/protocol.md 兼容性承诺)。
6
+ */
7
+ import { assertSuccess, invokeEngine, parseJSON, serializeCandidates, } from './engine.js';
8
+ /** 将 FilterOptions 转为引擎参数。 */
9
+ function modeArgs(opts) {
10
+ const args = [];
11
+ if (opts.mode && opts.mode !== 'substring') {
12
+ args.push('--mode', opts.mode);
13
+ }
14
+ if (opts.mode === 'token' && opts.sep) {
15
+ args.push('--sep', opts.sep);
16
+ }
17
+ return args;
18
+ }
19
+ /**
20
+ * filter — 过滤候选并返回命中项(保留输入顺序)+ 高亮区间。
21
+ *
22
+ * @param cands 候选(字符串或结构化 Candidate)
23
+ * @param query 过滤关键字(空格分词,空串返回全部)
24
+ * @param opts 匹配模式(默认 substring)
25
+ * @returns 命中候选,`ranges` 为命中的 rune 区间(空查询恒为 [])
26
+ */
27
+ export async function filter(cands, query = '', opts = {}) {
28
+ const inv = await invokeEngine(['filter', '--json', '--query', query, ...modeArgs(opts)], { input: serializeCandidates(cands) });
29
+ assertSuccess(inv, 'picktui filter');
30
+ return parseJSON(inv.stdout, 'picktui filter');
31
+ }
32
+ /**
33
+ * resolve — 把 query 解析为唯一候选(精确 → 唯一前缀,大小写不敏感前缀)。
34
+ *
35
+ * @returns 解析出的选中值;无匹配/多个前缀匹配返回 null(引擎退出码 1)
36
+ */
37
+ export async function resolve(cands, query, opts = {}) {
38
+ const inv = await invokeEngine(['resolve', '--json', '--query', query, ...modeArgs(opts)], { input: serializeCandidates(cands) });
39
+ if (inv.exitCode === 1) {
40
+ return null; // 协议:无唯一解 → stdout 空 + 退出码 1
41
+ }
42
+ assertSuccess(inv, 'picktui resolve');
43
+ const parsed = parseJSON(inv.stdout, 'picktui resolve');
44
+ return parsed.value;
45
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * history — 选择记忆(文件状态,直调引擎 hist 子命令)。
3
+ *
4
+ * 状态文件位于引擎数据目录($PICKTUI_CONFIG_DIR → $XDG_CONFIG_HOME/picktui →
5
+ * ~/.config/picktui),与 pick --label 的 TUI 记忆同一份。
6
+ */
7
+ import { assertSuccess, invokeEngine, parseJSON } from './engine.js';
8
+ /** 读取 label 上次选中的值;无记录返回 ""。 */
9
+ export async function histGet(label) {
10
+ const inv = await invokeEngine(['hist', 'get', label]);
11
+ assertSuccess(inv, 'picktui hist get');
12
+ const parsed = parseJSON(inv.stdout, 'picktui hist get');
13
+ return parsed.last ?? '';
14
+ }
15
+ /** 记录 label 的上次选中值(成功无输出;失败抛 PicktuiError)。 */
16
+ export async function histSet(label, value) {
17
+ const inv = await invokeEngine(['hist', 'set', label, value]);
18
+ assertSuccess(inv, 'picktui hist set');
19
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * index — 聚合导出;亦支持按模块 import(tree-shakable):
3
+ * import { filter } from '@havocrao/picktui/filter'
4
+ * import { pick } from '@havocrao/picktui/tui'
5
+ * import { histGet } from '@havocrao/picktui/history'
6
+ */
7
+ export * from './types.js';
8
+ export * from './engine.js';
9
+ export * from './filter.js';
10
+ export * from './tui.js';
11
+ export * from './history.js';
12
+ export * from './confirm.js';
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * tui — 交互选择 / 菜单(引擎跑 TUI,宿主零渲染)。
3
+ *
4
+ * 交互渲染与按键输入走引擎的 /dev/tty;本模块的 stdout 仅收选中值——
5
+ * 与 `$(picktui pick ...)` 同样的安全语义。无 TTY 时引擎自动退化
6
+ * (无 query 取首个、有 query 过滤取首),绑定无需分支。
7
+ */
8
+ import { assertSuccess, invokeEngine, serializeCandidates } from './engine.js';
9
+ /**
10
+ * pick — 交互过滤选择(fzf 风格)。
11
+ *
12
+ * @param cands 候选(经 stdin 传入,与位置参数等价且无 argv 长度限制)
13
+ * @param flags 透传引擎 flags(query/label/sep/fuzzy/select1/auto)
14
+ * @returns 选中值;用户取消(esc/ctrl+c,退出码 130)返回 null
15
+ */
16
+ export async function pick(cands = [], flags = {}) {
17
+ const args = ['pick'];
18
+ if (flags.query !== undefined) {
19
+ args.push('-q', flags.query);
20
+ }
21
+ if (flags.label !== undefined) {
22
+ args.push('--label', flags.label);
23
+ }
24
+ if (flags.sep !== undefined) {
25
+ args.push('--sep', flags.sep);
26
+ }
27
+ if (flags.fuzzy) {
28
+ args.push('--fuzzy');
29
+ }
30
+ if (flags.select1) {
31
+ args.push('-1');
32
+ }
33
+ if (flags.auto) {
34
+ args.push('--auto');
35
+ }
36
+ const inv = await invokeEngine(args, { input: serializeCandidates(cands) });
37
+ if (inv.exitCode === 130) {
38
+ return null; // 取消
39
+ }
40
+ assertSuccess(inv, 'picktui pick');
41
+ return inv.stdout.replace(/\r?\n$/, '');
42
+ }
43
+ /**
44
+ * menu — 多值缩写 TUI 菜单(数字 1-9 直选)。
45
+ *
46
+ * @param label 菜单标题与选择记忆键
47
+ * @param cands 候选(经位置参数传入,注意 argv 长度限制;超长场景用 pick)
48
+ * @returns 选中值;取消返回 null
49
+ */
50
+ export async function menu(label, cands) {
51
+ const inv = await invokeEngine(['_menu', label, ...cands]);
52
+ if (inv.exitCode === 130) {
53
+ return null;
54
+ }
55
+ assertSuccess(inv, 'picktui _menu');
56
+ return inv.stdout.replace(/\r?\n$/, '');
57
+ }
58
+ /**
59
+ * rawPick — 透传任意引擎 pick 参数(--from/--map 等绑定未包装的 flags)。
60
+ *
61
+ * @example rawPick(['--from', 'git branch', '--label', 'git co', '-1'])
62
+ */
63
+ export async function rawPick(args) {
64
+ const inv = await invokeEngine(['pick', ...args]);
65
+ if (inv.exitCode === 130) {
66
+ return null;
67
+ }
68
+ assertSuccess(inv, 'picktui pick');
69
+ return inv.stdout.replace(/\r?\n$/, '');
70
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * types — 与 docs/integration/protocol.md 对齐的纯类型(零依赖)。
3
+ *
4
+ * 字段名/形状是协议 v1 的一部分,任何改动需同步 protocol.md 与 Go 引擎。
5
+ */
6
+ /** 引擎调用失败(退出码非预期)时抛出的错误。 */
7
+ export class PicktuiError extends Error {
8
+ exitCode;
9
+ stderr;
10
+ constructor(message, exitCode, stderr) {
11
+ super(message);
12
+ this.name = 'PicktuiError';
13
+ this.exitCode = exitCode;
14
+ this.stderr = stderr;
15
+ }
16
+ }
@@ -0,0 +1,7 @@
1
+ /** 报告 (label, value) 是否已被确认过。 */
2
+ export declare function confirmCheck(label: string, value: string): Promise<boolean>;
3
+ /**
4
+ * 记录 (label, value) 为已确认(幂等)。
5
+ * @returns 确认后的实际状态(label 为空时不落盘 → false)
6
+ */
7
+ export declare function confirmAdd(label: string, value: string): Promise<boolean>;
@@ -0,0 +1,27 @@
1
+ /** 绑定声明的最低引擎版本(协议 v1)。 */
2
+ export declare const MIN_ENGINE_VERSION = "0.1.0";
3
+ /** 解析引擎二进制绝对路径;找不到时抛出带指引的错误。 */
4
+ export declare function engineBin(): Promise<string>;
5
+ /** 引擎调用的原始结果。 */
6
+ export interface EngineInvocation {
7
+ exitCode: number;
8
+ stdout: string;
9
+ stderr: string;
10
+ }
11
+ /** 调用引擎子命令:数据走 stdin,结果回 stdout/stderr(无 TTY 依赖)。 */
12
+ export declare function invokeEngine(args: string[], opts?: {
13
+ input?: string;
14
+ env?: NodeJS.ProcessEnv;
15
+ }): Promise<EngineInvocation>;
16
+ /** 引擎调用约定:退出码 0 成功 / 1 运行错误 / 2 用法错误 / 130 取消。 */
17
+ export declare function assertSuccess(inv: EngineInvocation, what: string): void;
18
+ /** 解析 stdout 为 JSON(协议输出恒为 JSON);失败抛 PicktuiError。 */
19
+ export declare function parseJSON<T>(stdout: string, what: string): T;
20
+ /** 读取引擎版本("picktui <semver>")。 */
21
+ export declare function engineVersion(): Promise<string>;
22
+ /** 比较两个 x.y.z 版本号:a >= b。 */
23
+ export declare function versionAtLeast(a: string, b: string): boolean;
24
+ /** 校验引擎版本不低于绑定声明的最低版本;不满足时抛错。 */
25
+ export declare function assertEngineVersion(min?: string): Promise<string>;
26
+ /** 将候选列表序列化为协议结构化行(key<TAB>description)。 */
27
+ export declare function serializeCandidates(cands: (string | import('./types.js').Candidate)[]): string;
@@ -0,0 +1,16 @@
1
+ import type { Candidate, FilterOptions, FilteredCandidate } from './types.js';
2
+ /**
3
+ * filter — 过滤候选并返回命中项(保留输入顺序)+ 高亮区间。
4
+ *
5
+ * @param cands 候选(字符串或结构化 Candidate)
6
+ * @param query 过滤关键字(空格分词,空串返回全部)
7
+ * @param opts 匹配模式(默认 substring)
8
+ * @returns 命中候选,`ranges` 为命中的 rune 区间(空查询恒为 [])
9
+ */
10
+ export declare function filter(cands: (string | Candidate)[], query?: string, opts?: FilterOptions): Promise<FilteredCandidate[]>;
11
+ /**
12
+ * resolve — 把 query 解析为唯一候选(精确 → 唯一前缀,大小写不敏感前缀)。
13
+ *
14
+ * @returns 解析出的选中值;无匹配/多个前缀匹配返回 null(引擎退出码 1)
15
+ */
16
+ export declare function resolve(cands: (string | Candidate)[], query: string, opts?: FilterOptions): Promise<string | null>;
@@ -0,0 +1,4 @@
1
+ /** 读取 label 上次选中的值;无记录返回 ""。 */
2
+ export declare function histGet(label: string): Promise<string>;
3
+ /** 记录 label 的上次选中值(成功无输出;失败抛 PicktuiError)。 */
4
+ export declare function histSet(label: string, value: string): Promise<void>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * index — 聚合导出;亦支持按模块 import(tree-shakable):
3
+ * import { filter } from '@havocrao/picktui/filter'
4
+ * import { pick } from '@havocrao/picktui/tui'
5
+ * import { histGet } from '@havocrao/picktui/history'
6
+ */
7
+ export * from './types.js';
8
+ export * from './engine.js';
9
+ export * from './filter.js';
10
+ export * from './tui.js';
11
+ export * from './history.js';
12
+ export * from './confirm.js';
@@ -0,0 +1,23 @@
1
+ import type { Candidate, PickFlags } from './types.js';
2
+ /**
3
+ * pick — 交互过滤选择(fzf 风格)。
4
+ *
5
+ * @param cands 候选(经 stdin 传入,与位置参数等价且无 argv 长度限制)
6
+ * @param flags 透传引擎 flags(query/label/sep/fuzzy/select1/auto)
7
+ * @returns 选中值;用户取消(esc/ctrl+c,退出码 130)返回 null
8
+ */
9
+ export declare function pick(cands?: (string | Candidate)[], flags?: PickFlags): Promise<string | null>;
10
+ /**
11
+ * menu — 多值缩写 TUI 菜单(数字 1-9 直选)。
12
+ *
13
+ * @param label 菜单标题与选择记忆键
14
+ * @param cands 候选(经位置参数传入,注意 argv 长度限制;超长场景用 pick)
15
+ * @returns 选中值;取消返回 null
16
+ */
17
+ export declare function menu(label: string, cands: string[]): Promise<string | null>;
18
+ /**
19
+ * rawPick — 透传任意引擎 pick 参数(--from/--map 等绑定未包装的 flags)。
20
+ *
21
+ * @example rawPick(['--from', 'git branch', '--label', 'git co', '-1'])
22
+ */
23
+ export declare function rawPick(args: string[]): Promise<string | null>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * types — 与 docs/integration/protocol.md 对齐的纯类型(零依赖)。
3
+ *
4
+ * 字段名/形状是协议 v1 的一部分,任何改动需同步 protocol.md 与 Go 引擎。
5
+ */
6
+ /** 结构化候选:value 为选中值,desc 仅供展示(来自 key<TAB>description 拆分)。 */
7
+ export interface Candidate {
8
+ value: string;
9
+ desc?: string;
10
+ }
11
+ /** 过滤模式(对齐引擎 --mode)。 */
12
+ export type FilterMode = 'substring' | 'token' | 'fuzzy';
13
+ /** 过滤选项(对齐引擎 filter --json 的 --mode/--sep)。 */
14
+ export interface FilterOptions {
15
+ mode?: FilterMode;
16
+ /** 仅 token 模式:分隔符集合,默认 "_",可多字符如 ":_"。 */
17
+ sep?: string;
18
+ }
19
+ /** filter 协议输出:命中候选 + 高亮 rune 区间 [start, end)。 */
20
+ export interface FilteredCandidate {
21
+ value: string;
22
+ desc: string;
23
+ /** 无 query 时恒为 [](非 null)。 */
24
+ ranges: [number, number][];
25
+ }
26
+ /**
27
+ * pick 命令透传 flags(对齐协议 pick 子命令)。
28
+ * 注意:--from/--map 属于引擎侧候选来源,绑定层不额外包装——需要时请用
29
+ * rawPick([...args]) 或直接调用引擎二进制。
30
+ */
31
+ export interface PickFlags {
32
+ /** 预填查询;非交互时过滤后取首个匹配。 */
33
+ query?: string;
34
+ /** token 前缀模式分隔符集合(默认 "_")。 */
35
+ sep?: string;
36
+ /** 子序列模糊匹配。 */
37
+ fuzzy?: boolean;
38
+ /** 选择记忆键(history.toml)。 */
39
+ label?: string;
40
+ /** 仅一个匹配时自动选中。 */
41
+ select1?: boolean;
42
+ /** 配合 query:精确或唯一前缀自动选中(首次需引擎侧确认)。 */
43
+ auto?: boolean;
44
+ }
45
+ /** 引擎调用失败(退出码非预期)时抛出的错误。 */
46
+ export declare class PicktuiError extends Error {
47
+ readonly exitCode: number;
48
+ readonly stderr: string;
49
+ constructor(message: string, exitCode: number, stderr: string);
50
+ }
51
+ /** resolve 协议输出。 */
52
+ export interface ResolveResult {
53
+ value: string;
54
+ }
55
+ /** 引擎 version 输出("picktui <semver>")。 */
56
+ export interface EngineVersion {
57
+ /** 完整输出行,如 "picktui 0.1.0"。 */
58
+ raw: string;
59
+ /** 语义化版本号,如 "0.1.0"。 */
60
+ version: string;
61
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@havocrao/picktui",
3
+ "version": "0.1.0",
4
+ "description": "TUI candidate picker — TS/JS bindings for the picktui engine (protocol v1). Filter / pick / history / confirm, tree-shakable, same behavior as the Go engine.",
5
+ "license": "MIT",
6
+ "author": "havoc-rao",
7
+ "keywords": [
8
+ "tui",
9
+ "picker",
10
+ "fzf",
11
+ "filter",
12
+ "select",
13
+ "menu"
14
+ ],
15
+ "main": "./dist/cjs/index.js",
16
+ "module": "./dist/esm/index.js",
17
+ "types": "./dist/types/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/types/index.d.ts",
21
+ "import": "./dist/esm/index.js",
22
+ "require": "./dist/cjs/index.js"
23
+ },
24
+ "./filter": {
25
+ "types": "./dist/types/filter.d.ts",
26
+ "import": "./dist/esm/filter.js",
27
+ "require": "./dist/cjs/filter.js"
28
+ },
29
+ "./tui": {
30
+ "types": "./dist/types/tui.d.ts",
31
+ "import": "./dist/esm/tui.js",
32
+ "require": "./dist/cjs/tui.js"
33
+ },
34
+ "./history": {
35
+ "types": "./dist/types/history.d.ts",
36
+ "import": "./dist/esm/history.js",
37
+ "require": "./dist/cjs/history.js"
38
+ },
39
+ "./confirm": {
40
+ "types": "./dist/types/confirm.d.ts",
41
+ "import": "./dist/esm/confirm.js",
42
+ "require": "./dist/cjs/confirm.js"
43
+ },
44
+ "./types": {
45
+ "types": "./dist/types/types.d.ts",
46
+ "import": "./dist/esm/types.js",
47
+ "require": "./dist/cjs/types.js"
48
+ },
49
+ "./package.json": "./package.json"
50
+ },
51
+ "files": [
52
+ "dist"
53
+ ],
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ },
60
+ "scripts": {
61
+ "build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && node scripts/fixup.mjs",
62
+ "pretest": "node scripts/build-engine.mjs",
63
+ "test": "node --test \"test/*.test.mjs\"",
64
+ "test:pack": "npm run build && node scripts/test-pack.mjs",
65
+ "prepack": "npm run build"
66
+ },
67
+ "devDependencies": {
68
+ "@types/node": "^26.4.1",
69
+ "typescript": "^5.5.0"
70
+ }
71
+ }