@arthony/keybook 0.1.1 → 0.3.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/dist/cli.js CHANGED
@@ -1,413 +1,1149 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/cli.ts
4
- import { spawn } from "child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { Command } from "commander";
6
- import { render } from "ink";
7
- import { createElement } from "react";
8
-
9
- // src/config.ts
10
- import { cpSync, existsSync, mkdirSync, readdirSync } from "fs";
11
- import { homedir } from "os";
12
- import { dirname, join } from "path";
13
- import { fileURLToPath } from "url";
14
- var YAML_RE = /\.ya?ml$/;
7
+ import { Box, Text, render, useApp, useInput, useStdout } from "ink";
8
+ import { createElement, useCallback, useMemo, useState } from "react";
9
+ import { homedir } from "node:os";
10
+ import { Document, parse, parseDocument } from "yaml";
11
+ import { z } from "zod";
12
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
13
+ import { Fzf, extendedMatch } from "fzf";
14
+ //#region src/config.ts
15
+ const YAML_RE$2 = /\.ya?ml$/;
15
16
  function resolveDataDir(env = process.env) {
16
- if (env.KEYBOOK_DATA_DIR) return { dir: env.KEYBOOK_DATA_DIR, source: "env" };
17
- if (env.XDG_CONFIG_HOME) return { dir: join(env.XDG_CONFIG_HOME, "keybook"), source: "xdg" };
18
- return { dir: join(homedir(), ".config", "keybook"), source: "default" };
17
+ if (env.KEYBOOK_DATA_DIR) return {
18
+ dir: env.KEYBOOK_DATA_DIR,
19
+ source: "env"
20
+ };
21
+ if (env.XDG_CONFIG_HOME) return {
22
+ dir: join(env.XDG_CONFIG_HOME, "keybook"),
23
+ source: "xdg"
24
+ };
25
+ return {
26
+ dir: join(homedir(), ".config", "keybook"),
27
+ source: "default"
28
+ };
19
29
  }
30
+ /** Locate the bundled seed/ relative to this module (works in dist and in tests). */
20
31
  function seedDir() {
21
- return join(dirname(fileURLToPath(import.meta.url)), "..", "seed");
32
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "seed");
22
33
  }
23
34
  function ensureDataDir(dir, seed = seedDir()) {
24
- const hasYaml = existsSync(dir) && readdirSync(dir).some((f) => YAML_RE.test(f));
25
- if (hasYaml) return { initialized: false, fileCount: 0 };
26
- mkdirSync(dir, { recursive: true });
27
- const seedFiles = readdirSync(seed).filter((f) => YAML_RE.test(f));
28
- for (const f of seedFiles) cpSync(join(seed, f), join(dir, f));
29
- return { initialized: true, fileCount: seedFiles.length };
30
- }
31
-
32
- // src/data/loader.ts
33
- import { readFileSync, readdirSync as readdirSync2 } from "fs";
34
- import { join as join2 } from "path";
35
- import { parse as parseYaml } from "yaml";
36
-
37
- // src/data/schema.ts
38
- import { z } from "zod";
39
- var entrySchema = z.object({
40
- action: z.string().min(1),
41
- keys: z.string().min(1).optional(),
42
- steps: z.array(z.string().min(1)).min(1).optional(),
43
- command: z.string().min(1).optional(),
44
- tags: z.array(z.string().min(1)).optional(),
45
- notes: z.string().min(1).optional(),
46
- source: z.string().min(1).optional()
47
- }).strict().refine((e) => Boolean(e.keys) || Boolean(e.steps) || Boolean(e.command), {
48
- message: "entry must have at least one of: keys, steps, command"
49
- });
50
- var fileShape = z.object({
51
- app: z.string().min(1),
52
- entries: z.array(z.unknown()).min(1)
35
+ if (existsSync(dir) && readdirSync(dir).some((f) => YAML_RE$2.test(f))) return {
36
+ initialized: false,
37
+ fileCount: 0
38
+ };
39
+ mkdirSync(dir, { recursive: true });
40
+ const seedFiles = readdirSync(seed).filter((f) => YAML_RE$2.test(f));
41
+ for (const f of seedFiles) cpSync(join(seed, f), join(dir, f));
42
+ return {
43
+ initialized: true,
44
+ fileCount: seedFiles.length
45
+ };
46
+ }
47
+ //#endregion
48
+ //#region src/data/keys.ts
49
+ const MODS = {
50
+ cmd: "⌘",
51
+ command: "⌘",
52
+ "⌘": "⌘",
53
+ opt: "⌥",
54
+ option: "⌥",
55
+ alt: "⌥",
56
+ "⌥": "⌥",
57
+ ctrl: "⌃",
58
+ control: "⌃",
59
+ "⌃": "⌃",
60
+ shift: "⇧",
61
+ "⇧": "⇧"
62
+ };
63
+ const NAMED = {
64
+ return: "⏎",
65
+ enter: "⏎",
66
+ "⏎": "⏎",
67
+ esc: "⎋",
68
+ escape: "⎋",
69
+ "⎋": "⎋",
70
+ del: "⌫",
71
+ delete: "⌫",
72
+ backspace: "⌫",
73
+ "⌫": "⌫",
74
+ tab: "⇥",
75
+ "⇥": "⇥",
76
+ space: "␣",
77
+ "␣": "␣",
78
+ up: "↑",
79
+ down: "↓",
80
+ left: "←",
81
+ right: "→",
82
+ "↑": "↑",
83
+ "↓": "↓",
84
+ "←": "←",
85
+ "→": "→"
86
+ };
87
+ const MOD_ORDER = [
88
+ "⌃",
89
+ "⌥",
90
+ "⇧",
91
+ "⌘"
92
+ ];
93
+ function normalizeSegment(seg) {
94
+ const trimmed = seg.trim();
95
+ if (!trimmed) return "";
96
+ const tokens = trimmed.split(/[\s+]+/).filter(Boolean);
97
+ const mods = /* @__PURE__ */ new Set();
98
+ const keys = [];
99
+ for (const tok of tokens) {
100
+ const low = tok.toLowerCase();
101
+ if (MODS[low]) mods.add(MODS[low]);
102
+ else if (NAMED[low]) keys.push(NAMED[low]);
103
+ else keys.push(tok.length === 1 && /[a-z]/i.test(tok) ? tok.toUpperCase() : tok);
104
+ }
105
+ return MOD_ORDER.filter((m) => mods.has(m)).join("") + keys.join("");
106
+ }
107
+ /** Normalize human key input into the canonical macOS glyph string. Never throws. */
108
+ function normalizeKeys(input) {
109
+ return input.split(/\s*,\s*|\s+then\s+/i).map(normalizeSegment).filter(Boolean).join(", ");
110
+ }
111
+ //#endregion
112
+ //#region src/data/schema.ts
113
+ const entrySchema = z.object({
114
+ action: z.string().min(1),
115
+ keys: z.string().min(1).optional(),
116
+ steps: z.array(z.string().min(1)).min(1).optional(),
117
+ command: z.string().min(1).optional(),
118
+ tags: z.array(z.string().min(1)).optional(),
119
+ notes: z.string().min(1).optional(),
120
+ source: z.string().min(1).optional()
121
+ }).strict().refine((e) => Boolean(e.keys) || Boolean(e.steps) || Boolean(e.command), { message: "entry must have at least one of: keys, steps, command" });
122
+ const fileShape = z.object({
123
+ app: z.string().min(1),
124
+ entries: z.array(z.unknown()).min(1)
53
125
  }).strict();
54
-
55
- // src/data/loader.ts
56
- var YAML_RE2 = /\.ya?ml$/;
126
+ //#endregion
127
+ //#region src/data/loader.ts
128
+ const YAML_RE$1 = /\.ya?ml$/;
57
129
  function loadEntries(dataDir) {
58
- const entries = [];
59
- const errors = [];
60
- let files;
61
- try {
62
- files = readdirSync2(dataDir).filter((f) => YAML_RE2.test(f)).sort();
63
- } catch (err) {
64
- errors.push({
65
- file: dataDir,
66
- entryIndex: null,
67
- message: `cannot read data dir: ${err.message}`
68
- });
69
- return { entries, errors };
70
- }
71
- for (const file of files) {
72
- let raw;
73
- try {
74
- raw = parseYaml(readFileSync(join2(dataDir, file), "utf8"));
75
- } catch (err) {
76
- errors.push({
77
- file,
78
- entryIndex: null,
79
- message: `YAML parse error: ${err.message}`
80
- });
81
- continue;
82
- }
83
- const shape = fileShape.safeParse(raw);
84
- if (!shape.success) {
85
- errors.push({
86
- file,
87
- entryIndex: null,
88
- message: shape.error.issues[0]?.message ?? "invalid file shape"
89
- });
90
- continue;
91
- }
92
- const { app, entries: rawEntries } = shape.data;
93
- rawEntries.forEach((rawEntry, i) => {
94
- const parsed = entrySchema.safeParse(rawEntry);
95
- if (!parsed.success) {
96
- errors.push({
97
- file,
98
- entryIndex: i,
99
- message: parsed.error.issues.map((s) => s.message).join("; ")
100
- });
101
- return;
102
- }
103
- entries.push({ app, ...parsed.data });
104
- });
105
- }
106
- return { entries, errors };
107
- }
108
-
109
- // src/commands.ts
130
+ const entries = [];
131
+ const errors = [];
132
+ let files;
133
+ try {
134
+ files = readdirSync(dataDir).filter((f) => YAML_RE$1.test(f)).sort();
135
+ } catch (err) {
136
+ errors.push({
137
+ file: dataDir,
138
+ entryIndex: null,
139
+ message: `cannot read data dir: ${err.message}`
140
+ });
141
+ return {
142
+ entries,
143
+ errors
144
+ };
145
+ }
146
+ for (const file of files) {
147
+ let raw;
148
+ try {
149
+ raw = parse(readFileSync(join(dataDir, file), "utf8"));
150
+ } catch (err) {
151
+ errors.push({
152
+ file,
153
+ entryIndex: null,
154
+ message: `YAML parse error: ${err.message}`
155
+ });
156
+ continue;
157
+ }
158
+ const shape = fileShape.safeParse(raw);
159
+ if (!shape.success) {
160
+ errors.push({
161
+ file,
162
+ entryIndex: null,
163
+ message: shape.error.issues[0]?.message ?? "invalid file shape"
164
+ });
165
+ continue;
166
+ }
167
+ const { app, entries: rawEntries } = shape.data;
168
+ rawEntries.forEach((rawEntry, i) => {
169
+ const parsed = entrySchema.safeParse(rawEntry);
170
+ if (!parsed.success) {
171
+ errors.push({
172
+ file,
173
+ entryIndex: i,
174
+ message: parsed.error.issues.map((s) => s.message).join("; ")
175
+ });
176
+ return;
177
+ }
178
+ entries.push({
179
+ app,
180
+ ...parsed.data
181
+ });
182
+ });
183
+ }
184
+ return {
185
+ entries,
186
+ errors
187
+ };
188
+ }
189
+ //#endregion
190
+ //#region src/data/writer.ts
191
+ const YAML_RE = /\.ya?ml$/;
192
+ function yamlFiles(dir) {
193
+ if (!existsSync(dir)) return [];
194
+ return readdirSync(dir).filter((f) => YAML_RE.test(f)).sort();
195
+ }
196
+ function readApp(path) {
197
+ try {
198
+ const app = parseDocument(readFileSync(path, "utf8")).get("app");
199
+ return typeof app === "string" && app.trim() ? app.trim() : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+ function listApps(dir) {
205
+ const seen = /* @__PURE__ */ new Map();
206
+ for (const f of yamlFiles(dir)) {
207
+ const app = readApp(join(dir, f));
208
+ if (app && !seen.has(app.toLowerCase())) seen.set(app.toLowerCase(), app);
209
+ }
210
+ return [...seen.values()].sort((a, b) => a.localeCompare(b));
211
+ }
212
+ function slugify(app) {
213
+ return app.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app";
214
+ }
215
+ function findFileForApp(dir, app) {
216
+ const want = app.trim().toLowerCase();
217
+ for (const f of yamlFiles(dir)) if (readApp(join(dir, f))?.toLowerCase() === want) return join(dir, f);
218
+ return null;
219
+ }
220
+ function freshPath(dir, app) {
221
+ const base = slugify(app);
222
+ let name = `${base}.yaml`;
223
+ let n = 2;
224
+ while (existsSync(join(dir, name))) {
225
+ name = `${base}-${n}.yaml`;
226
+ n += 1;
227
+ }
228
+ return join(dir, name);
229
+ }
230
+ /**
231
+ * Resolve where an entry for `app` would be written, without writing anything.
232
+ * Returns the existing file (created:false) or the fresh slug path (created:true).
233
+ */
234
+ function resolveTargetFile(dir, app) {
235
+ const existing = findFileForApp(dir, app);
236
+ if (existing) return {
237
+ file: existing,
238
+ created: false
239
+ };
240
+ return {
241
+ file: freshPath(dir, app),
242
+ created: true
243
+ };
244
+ }
245
+ function buildClean(e) {
246
+ const out = { action: e.action };
247
+ if (e.keys) out.keys = e.keys;
248
+ if (e.steps?.length) out.steps = e.steps;
249
+ if (e.command) out.command = e.command;
250
+ if (e.tags?.length) out.tags = e.tags;
251
+ if (e.notes) out.notes = e.notes;
252
+ if (e.source) out.source = e.source;
253
+ return out;
254
+ }
255
+ function err(file, lines, created = false) {
256
+ return {
257
+ ok: false,
258
+ file,
259
+ created,
260
+ lines
261
+ };
262
+ }
263
+ function addEntry(dir, app, entry) {
264
+ if ("app" in entry) return err("", ["Error: entry must not have an app field; use --app instead"]);
265
+ if (!app.trim()) return err("", ["Error: app is required"]);
266
+ const parsed = entrySchema.safeParse(entry);
267
+ if (!parsed.success) return err("", parsed.error.issues.map((i) => i.message));
268
+ const clean = buildClean(parsed.data);
269
+ try {
270
+ mkdirSync(dir, { recursive: true });
271
+ } catch (e) {
272
+ return err(dir, [e.message]);
273
+ }
274
+ const existing = findFileForApp(dir, app);
275
+ const created = !existing;
276
+ const file = existing ?? freshPath(dir, app);
277
+ let original = null;
278
+ let text;
279
+ if (created) text = new Document({
280
+ app: app.trim(),
281
+ entries: [clean]
282
+ }).toString();
283
+ else {
284
+ try {
285
+ original = readFileSync(file, "utf8");
286
+ } catch (e) {
287
+ return err(file, [e.message]);
288
+ }
289
+ const doc = parseDocument(original);
290
+ doc.addIn(["entries"], doc.createNode(clean));
291
+ text = doc.toString();
292
+ }
293
+ try {
294
+ writeFileSync(file, text);
295
+ } catch (e) {
296
+ return err(file, [e.message], created);
297
+ }
298
+ const fileErr = loadEntries(dir).errors.find((e) => e.file === basename(file));
299
+ if (fileErr) {
300
+ if (created) try {
301
+ unlinkSync(file);
302
+ } catch {}
303
+ else if (original !== null) writeFileSync(file, original);
304
+ return err(file, [`✗ ${fileErr.message}`], created);
305
+ }
306
+ return {
307
+ ok: true,
308
+ file,
309
+ created,
310
+ lines: [`✓ ${created ? "created" : "added to"} ${basename(file)}`]
311
+ };
312
+ }
313
+ //#endregion
314
+ //#region src/commands.ts
110
315
  function runPath(env = process.env) {
111
- const { dir, source } = resolveDataDir(env);
112
- return `${dir} (source: ${source})`;
316
+ const { dir, source } = resolveDataDir(env);
317
+ return `${dir} (source: ${source})`;
113
318
  }
114
319
  function runCheck(dir) {
115
- const { entries, errors } = loadEntries(dir);
116
- if (errors.length === 0) return { ok: true, lines: [`\u2713 ${entries.length} entries OK`] };
117
- return {
118
- ok: false,
119
- lines: errors.map(
120
- (e) => `\u2717 ${e.file}${e.entryIndex !== null ? ` [entry ${e.entryIndex}]` : ""}: ${e.message}`
121
- )
122
- };
123
- }
124
-
125
- // src/tui/App.tsx
126
- import { Box as Box6, useApp, useInput, useStdout } from "ink";
127
- import { useMemo, useState } from "react";
128
-
129
- // src/clipboard.ts
130
- import { spawnSync } from "child_process";
131
- function copyToClipboard(text) {
132
- try {
133
- const res = spawnSync("pbcopy", { input: text });
134
- return res.status === 0;
135
- } catch {
136
- return false;
137
- }
138
- }
139
-
140
- // src/search.ts
141
- import { Fzf, extendedMatch } from "fzf";
142
- function haystack(e) {
143
- return [e.action, (e.tags ?? []).join(" "), e.app, e.keys ?? "", e.notes ?? ""].filter(Boolean).join(" ");
320
+ const { entries, errors } = loadEntries(dir);
321
+ if (errors.length === 0) return {
322
+ ok: true,
323
+ lines: [`✓ ${entries.length} entries OK`]
324
+ };
325
+ return {
326
+ ok: false,
327
+ lines: errors.map((e) => `✗ ${e.file}${e.entryIndex !== null ? ` [entry ${e.entryIndex}]` : ""}: ${e.message}`)
328
+ };
144
329
  }
145
- function search(entries, query) {
146
- const q = query.trim();
147
- if (!q) {
148
- return [...entries].sort(
149
- (a, b) => a.app.localeCompare(b.app) || a.action.localeCompare(b.action)
150
- );
151
- }
152
- const fzf = new Fzf(entries, { selector: haystack, match: extendedMatch });
153
- return fzf.find(q).map((r) => r.item);
154
- }
155
-
156
- // src/tui/Footer.tsx
157
- import { Box, Text } from "ink";
158
- import { jsx, jsxs } from "react/jsx-runtime";
159
- function Footer({
160
- flash,
161
- errorCount,
162
- resultCount
163
- }) {
164
- return /* @__PURE__ */ jsxs(Box, { marginTop: 1, justifyContent: "space-between", children: [
165
- /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
166
- "\u2191\u2193 move \u23CE copy \u238B quit (",
167
- resultCount,
168
- ")"
169
- ] }),
170
- flash ? /* @__PURE__ */ jsx(Text, { color: "green", children: flash }) : errorCount > 0 ? /* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
171
- "\u26A0 ",
172
- errorCount,
173
- " skipped \u2014 run `keybook check`"
174
- ] }) : /* @__PURE__ */ jsx(Text, { children: " " })
175
- ] });
176
- }
177
-
178
- // src/tui/PreviewPane.tsx
179
- import { Box as Box2, Text as Text2 } from "ink";
180
-
181
- // src/tui/keycaps.ts
182
- var GLYPHS = /* @__PURE__ */ new Set(["\u2318", "\u2325", "\u2303", "\u21E7", "\u21EA", "\u23CE", "\u238B", "\u232B", "\u21E5", "\u2423", "\u2191", "\u2193", "\u2190", "\u2192"]);
330
+ function runAdd(dir, draft) {
331
+ const entry = { action: draft.action };
332
+ const keys = draft.keys ? normalizeKeys(draft.keys).trim() : "";
333
+ if (keys) entry.keys = keys;
334
+ if (draft.command?.trim()) entry.command = draft.command.trim();
335
+ if (draft.steps?.length) entry.steps = draft.steps;
336
+ if (draft.tags?.length) entry.tags = draft.tags;
337
+ if (draft.notes?.trim()) entry.notes = draft.notes.trim();
338
+ const result = addEntry(dir, draft.app, entry);
339
+ return {
340
+ ok: result.ok,
341
+ lines: result.lines
342
+ };
343
+ }
344
+ //#endregion
345
+ //#region src/tui/StepsBuilder.tsx
346
+ function StepsBuilder({ steps, line, active }) {
347
+ return /* @__PURE__ */ jsxs(Box, {
348
+ flexDirection: "column",
349
+ children: [
350
+ steps.map((s, i) => /* @__PURE__ */ jsxs(Text, { children: [
351
+ i + 1,
352
+ ". ",
353
+ s
354
+ ] }, i)),
355
+ /* @__PURE__ */ jsxs(Box, { children: [
356
+ /* @__PURE__ */ jsxs(Text, {
357
+ color: active ? "cyan" : "gray",
358
+ children: [steps.length + 1, ". "]
359
+ }),
360
+ /* @__PURE__ */ jsx(Text, { children: line }),
361
+ active ? /* @__PURE__ */ jsx(Text, {
362
+ inverse: true,
363
+ children: " "
364
+ }) : null
365
+ ] }),
366
+ steps.length === 0 ? /* @__PURE__ */ jsx(Text, {
367
+ color: "gray",
368
+ children: "⏎ adds a step · ⌫ on an empty line removes the last"
369
+ }) : null
370
+ ]
371
+ });
372
+ }
373
+ //#endregion
374
+ //#region src/tui/FormFields.tsx
375
+ const TYPES$1 = [
376
+ "shortcut",
377
+ "command",
378
+ "recipe"
379
+ ];
380
+ function Field({ label, value, focused }) {
381
+ return /* @__PURE__ */ jsxs(Box, { children: [
382
+ /* @__PURE__ */ jsx(Text, {
383
+ color: focused ? "cyan" : "gray",
384
+ children: label.padEnd(8)
385
+ }),
386
+ /* @__PURE__ */ jsx(Text, { children: value }),
387
+ focused ? /* @__PURE__ */ jsx(Text, {
388
+ inverse: true,
389
+ children: " "
390
+ }) : null
391
+ ] });
392
+ }
393
+ function FormFields({ draft, apps, appIndex, focused, existingTags }) {
394
+ const appChoices = [...apps, "Create new app…"];
395
+ return /* @__PURE__ */ jsxs(Box, {
396
+ flexDirection: "column",
397
+ children: [
398
+ /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
399
+ color: focused === 0 ? "cyan" : "gray",
400
+ children: "App".padEnd(8)
401
+ }), draft.creatingApp ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: draft.newApp }), focused === 0 ? /* @__PURE__ */ jsx(Text, {
402
+ inverse: true,
403
+ children: " "
404
+ }) : null] }) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? " (↑/↓)" : ""] })] }),
405
+ /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
406
+ color: focused === 1 ? "cyan" : "gray",
407
+ children: "Type".padEnd(8)
408
+ }), /* @__PURE__ */ jsx(Text, { children: TYPES$1.map((t) => t === draft.type ? `(•) ${t}` : `( ) ${t}`).join(" ") })] }),
409
+ /* @__PURE__ */ jsx(Field, {
410
+ label: "Action",
411
+ value: draft.action,
412
+ focused: focused === 2
413
+ }),
414
+ draft.type === "shortcut" ? /* @__PURE__ */ jsxs(Box, { children: [
415
+ /* @__PURE__ */ jsx(Text, {
416
+ color: focused === 3 ? "cyan" : "gray",
417
+ children: "Keys".padEnd(8)
418
+ }),
419
+ /* @__PURE__ */ jsx(Text, { children: draft.keys }),
420
+ focused === 3 ? /* @__PURE__ */ jsx(Text, {
421
+ inverse: true,
422
+ children: " "
423
+ }) : null,
424
+ draft.keys.trim() ? /* @__PURE__ */ jsxs(Text, {
425
+ color: "gray",
426
+ children: [" → ", normalizeKeys(draft.keys)]
427
+ }) : null
428
+ ] }) : draft.type === "command" ? /* @__PURE__ */ jsx(Field, {
429
+ label: "Command",
430
+ value: `$ ${draft.command}`,
431
+ focused: focused === 3
432
+ }) : /* @__PURE__ */ jsxs(Box, {
433
+ flexDirection: "column",
434
+ children: [/* @__PURE__ */ jsx(Text, {
435
+ color: focused === 3 ? "cyan" : "gray",
436
+ children: "Steps"
437
+ }), /* @__PURE__ */ jsx(StepsBuilder, {
438
+ steps: draft.steps,
439
+ line: draft.stepLine,
440
+ active: focused === 3
441
+ })]
442
+ }),
443
+ /* @__PURE__ */ jsx(Field, {
444
+ label: "Tags",
445
+ value: draft.tags,
446
+ focused: focused === 4
447
+ }),
448
+ focused === 4 && existingTags && existingTags.length > 0 ? /* @__PURE__ */ jsx(Text, {
449
+ color: "gray",
450
+ children: `${"".padEnd(8)}e.g. ${existingTags.slice(0, 6).join(", ")}`
451
+ }) : null,
452
+ /* @__PURE__ */ jsx(Field, {
453
+ label: "Notes",
454
+ value: draft.notes,
455
+ focused: focused === 5
456
+ })
457
+ ]
458
+ });
459
+ }
460
+ //#endregion
461
+ //#region src/tui/keycaps.ts
462
+ const GLYPHS = new Set([
463
+ "⌘",
464
+ "⌥",
465
+ "⌃",
466
+ "⇧",
467
+ "⇪",
468
+ "⏎",
469
+ "⎋",
470
+ "⌫",
471
+ "⇥",
472
+ "␣",
473
+ "↑",
474
+ "↓",
475
+ "←",
476
+ "→"
477
+ ]);
183
478
  function parseSegment(seg) {
184
- const tokens = [];
185
- let buf = "";
186
- for (const ch of seg) {
187
- if (GLYPHS.has(ch)) {
188
- if (buf) {
189
- tokens.push(buf);
190
- buf = "";
191
- }
192
- tokens.push(ch);
193
- } else if (ch === " ") {
194
- if (buf) {
195
- tokens.push(buf);
196
- buf = "";
197
- }
198
- } else {
199
- buf += ch;
200
- }
201
- }
202
- if (buf) tokens.push(buf);
203
- return tokens.length ? tokens : [seg];
479
+ const tokens = [];
480
+ let buf = "";
481
+ for (const ch of seg) if (GLYPHS.has(ch)) {
482
+ if (buf) {
483
+ tokens.push(buf);
484
+ buf = "";
485
+ }
486
+ tokens.push(ch);
487
+ } else if (ch === " ") {
488
+ if (buf) {
489
+ tokens.push(buf);
490
+ buf = "";
491
+ }
492
+ } else buf += ch;
493
+ if (buf) tokens.push(buf);
494
+ return tokens.length ? tokens : [seg];
204
495
  }
496
+ /** Parse a keys string into a chord sequence of token groups. Never throws. */
205
497
  function parseKeys(input) {
206
- return input.split(",").map((s) => s.trim()).filter(Boolean).map(parseSegment);
498
+ return input.split(",").map((s) => s.trim()).filter(Boolean).map(parseSegment);
207
499
  }
208
-
209
- // src/tui/PreviewPane.tsx
210
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
500
+ //#endregion
501
+ //#region src/tui/key-caps.tsx
211
502
  function KeyCaps({ value }) {
212
- const chords = parseKeys(value);
213
- return /* @__PURE__ */ jsx2(Box2, { children: chords.map((seg, si) => (
214
- // biome-ignore lint/suspicious/noArrayIndexKey: token order is stable; tokens can repeat (e.g. ⌃X⌃E) so the value is not a unique key
215
- /* @__PURE__ */ jsxs2(Box2, { children: [
216
- si > 0 ? /* @__PURE__ */ jsx2(Text2, { children: " , " }) : null,
217
- seg.map((tok, ti) => (
218
- // biome-ignore lint/suspicious/noArrayIndexKey: see above — repeated tokens make the value non-unique
219
- /* @__PURE__ */ jsx2(Box2, { marginRight: 1, borderStyle: "round", paddingX: 1, children: /* @__PURE__ */ jsx2(Text2, { bold: true, children: tok }) }, ti)
220
- ))
221
- ] }, si)
222
- )) });
503
+ return /* @__PURE__ */ jsx(Box, { children: parseKeys(value).map((seg, si) => /* @__PURE__ */ jsxs(Box, { children: [si > 0 ? /* @__PURE__ */ jsx(Text, { children: " , " }) : null, seg.map((tok, ti) => /* @__PURE__ */ jsx(Box, {
504
+ marginRight: 1,
505
+ borderStyle: "round",
506
+ paddingX: 1,
507
+ children: /* @__PURE__ */ jsx(Text, {
508
+ bold: true,
509
+ children: tok
510
+ })
511
+ }, ti))] }, si)) });
512
+ }
513
+ //#endregion
514
+ //#region src/tui/ReviewScreen.tsx
515
+ function ReviewScreen({ app, entry, targetPath, error }) {
516
+ return /* @__PURE__ */ jsxs(Box, {
517
+ flexDirection: "column",
518
+ children: [
519
+ /* @__PURE__ */ jsxs(Text, {
520
+ color: "cyan",
521
+ children: ["Review — ", app]
522
+ }),
523
+ /* @__PURE__ */ jsx(Text, {
524
+ bold: true,
525
+ children: entry.action
526
+ }),
527
+ /* @__PURE__ */ jsxs(Box, {
528
+ marginTop: 1,
529
+ flexDirection: "column",
530
+ children: [
531
+ entry.keys ? /* @__PURE__ */ jsx(KeyCaps, { value: entry.keys }) : null,
532
+ entry.steps?.map((s, i) => /* @__PURE__ */ jsxs(Text, { children: [
533
+ i + 1,
534
+ ". ",
535
+ s
536
+ ] }, i)),
537
+ entry.command ? /* @__PURE__ */ jsxs(Text, {
538
+ color: "green",
539
+ children: ["$ ", entry.command]
540
+ }) : null
541
+ ]
542
+ }),
543
+ entry.tags?.length ? /* @__PURE__ */ jsxs(Text, {
544
+ color: "gray",
545
+ children: ["tags: ", entry.tags.join(", ")]
546
+ }) : null,
547
+ entry.notes ? /* @__PURE__ */ jsx(Text, {
548
+ color: "gray",
549
+ children: entry.notes
550
+ }) : null,
551
+ /* @__PURE__ */ jsxs(Text, {
552
+ color: "gray",
553
+ children: ["→ ", targetPath]
554
+ }),
555
+ error ? /* @__PURE__ */ jsx(Text, {
556
+ color: "red",
557
+ children: error
558
+ }) : null,
559
+ /* @__PURE__ */ jsx(Box, {
560
+ marginTop: 1,
561
+ children: /* @__PURE__ */ jsx(Text, {
562
+ color: "gray",
563
+ children: "⏎ save · e edit · esc cancel"
564
+ })
565
+ })
566
+ ]
567
+ });
568
+ }
569
+ //#endregion
570
+ //#region src/tui/useAddForm.ts
571
+ const emptyDraft = {
572
+ app: "",
573
+ newApp: "",
574
+ creatingApp: false,
575
+ type: "shortcut",
576
+ action: "",
577
+ keys: "",
578
+ command: "",
579
+ steps: [],
580
+ stepLine: "",
581
+ tags: "",
582
+ notes: ""
583
+ };
584
+ function parseTags(raw) {
585
+ return raw.split(",").map((t) => t.trim()).filter(Boolean);
586
+ }
587
+ function resolvedApp(d) {
588
+ return (d.creatingApp ? d.newApp : d.app).trim();
589
+ }
590
+ /**
591
+ * Flush a typed-but-not-yet-appended recipe step (`stepLine`) into `steps`.
592
+ * Returns `d` unchanged when `stepLine` is empty after trimming.
593
+ */
594
+ function flushStep(d) {
595
+ const line = d.stepLine.trim();
596
+ if (!line) return d;
597
+ return {
598
+ ...d,
599
+ steps: [...d.steps, line],
600
+ stepLine: ""
601
+ };
602
+ }
603
+ function validateDraft(d) {
604
+ if (!resolvedApp(d)) return "App is required";
605
+ if (!d.action.trim()) return "Action is required";
606
+ if (!(Boolean(normalizeKeys(d.keys).trim()) || Boolean(d.command.trim()) || d.steps.length > 0)) return "Add keys, a command, or at least one step";
607
+ return null;
608
+ }
609
+ function draftToEntryInput(d) {
610
+ const e = { action: d.action.trim() };
611
+ const keys = normalizeKeys(d.keys).trim();
612
+ if (keys) e.keys = keys;
613
+ if (d.command.trim()) e.command = d.command.trim();
614
+ if (d.steps.length) e.steps = d.steps;
615
+ const tags = parseTags(d.tags);
616
+ if (tags.length) e.tags = tags;
617
+ if (d.notes.trim()) e.notes = d.notes.trim();
618
+ return e;
619
+ }
620
+ function useAddForm(initialApp = "") {
621
+ const [draft, setDraft] = useState(() => ({
622
+ ...emptyDraft,
623
+ app: initialApp
624
+ }));
625
+ const update = (patch) => setDraft((d) => ({
626
+ ...d,
627
+ ...patch
628
+ }));
629
+ return {
630
+ draft,
631
+ update,
632
+ setDraft
633
+ };
634
+ }
635
+ //#endregion
636
+ //#region src/tui/AddEntryForm.tsx
637
+ const TYPES = [
638
+ "shortcut",
639
+ "command",
640
+ "recipe"
641
+ ];
642
+ const LAST_FIELD = 5;
643
+ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget }) {
644
+ const { draft, update, setDraft } = useAddForm(apps[0] ?? "");
645
+ const [focused, setFocused] = useState(0);
646
+ const [appIndex, setAppIndex] = useState(0);
647
+ const [screen, setScreen] = useState("form");
648
+ const [hint, setHint] = useState("");
649
+ const [writeError, setWriteError] = useState("");
650
+ const appChoices = [...apps, "Create new app…"];
651
+ function commitAppSelection(index) {
652
+ if (index === apps.length) update({ creatingApp: true });
653
+ else update({
654
+ creatingApp: false,
655
+ app: apps[index] ?? ""
656
+ });
657
+ }
658
+ function goReview() {
659
+ const d = flushStep(draft);
660
+ const v = validateDraft(d);
661
+ if (v) {
662
+ setHint(v);
663
+ return;
664
+ }
665
+ setHint("");
666
+ setDraft(d);
667
+ setScreen("review");
668
+ }
669
+ const review = flushStep(draft);
670
+ const target = resolveTarget?.(resolvedApp(review));
671
+ const targetPath = target ? `${basename(target.file)}${target.created ? " (new)" : ""}` : `${resolvedApp(review)} file`;
672
+ useInput((input, key) => {
673
+ if (screen === "review") {
674
+ if (key.escape) return onCancel();
675
+ if (input === "e") return setScreen("form");
676
+ if (key.return) {
677
+ const result = onSubmit(resolvedApp(review), draftToEntryInput(review));
678
+ if (result.ok) onComplete?.(result);
679
+ else setWriteError(result.lines.join("; "));
680
+ }
681
+ return;
682
+ }
683
+ if (key.escape) return onCancel();
684
+ if (key.ctrl && input === "n") return setFocused((f) => Math.min(f + 1, LAST_FIELD));
685
+ if (key.ctrl && input === "p") return setFocused((f) => Math.max(f - 1, 0));
686
+ if (focused === 0) {
687
+ if (key.upArrow) {
688
+ const next = Math.max(appIndex - 1, 0);
689
+ setAppIndex(next);
690
+ return commitAppSelection(next);
691
+ }
692
+ if (key.downArrow) {
693
+ const next = Math.min(appIndex + 1, appChoices.length - 1);
694
+ setAppIndex(next);
695
+ return commitAppSelection(next);
696
+ }
697
+ if (draft.creatingApp) {
698
+ if (key.return) return setFocused(1);
699
+ if (key.backspace || key.delete) return update({ newApp: draft.newApp.slice(0, -1) });
700
+ if (input && !key.ctrl && !key.meta) return update({ newApp: draft.newApp + input });
701
+ return;
702
+ }
703
+ if (key.return) return setFocused(1);
704
+ return;
705
+ }
706
+ if (focused === 1) {
707
+ if (key.leftArrow || key.rightArrow || input === " ") {
708
+ const idx = TYPES.indexOf(draft.type);
709
+ return update({ type: TYPES[key.leftArrow ? (idx + TYPES.length - 1) % TYPES.length : (idx + 1) % TYPES.length] });
710
+ }
711
+ if (key.return) return goReview();
712
+ return;
713
+ }
714
+ if (focused === 3 && draft.type === "recipe") {
715
+ if (key.return) {
716
+ if (draft.stepLine.trim()) update({
717
+ steps: [...draft.steps, draft.stepLine.trim()],
718
+ stepLine: ""
719
+ });
720
+ return;
721
+ }
722
+ if (key.backspace || key.delete) {
723
+ if (draft.stepLine) return update({ stepLine: draft.stepLine.slice(0, -1) });
724
+ return update({ steps: draft.steps.slice(0, -1) });
725
+ }
726
+ if (input && !key.ctrl && !key.meta) return update({ stepLine: draft.stepLine + input });
727
+ return;
728
+ }
729
+ if (key.return) return goReview();
730
+ const fieldKey = focused === 2 ? "action" : focused === 3 ? draft.type === "command" ? "command" : "keys" : focused === 4 ? "tags" : "notes";
731
+ if (key.backspace || key.delete) return update({ [fieldKey]: draft[fieldKey].slice(0, -1) });
732
+ if (input && !key.ctrl && !key.meta) return update({ [fieldKey]: draft[fieldKey] + input });
733
+ });
734
+ if (screen === "review") return /* @__PURE__ */ jsx(ReviewScreen, {
735
+ app: resolvedApp(review),
736
+ entry: draftToEntryInput(review),
737
+ targetPath,
738
+ error: writeError
739
+ });
740
+ return /* @__PURE__ */ jsxs(Box, {
741
+ flexDirection: "column",
742
+ children: [
743
+ /* @__PURE__ */ jsx(Text, {
744
+ color: "cyan",
745
+ children: "keybook add"
746
+ }),
747
+ /* @__PURE__ */ jsx(Box, {
748
+ marginTop: 1,
749
+ children: /* @__PURE__ */ jsx(FormFields, {
750
+ draft,
751
+ apps,
752
+ appIndex,
753
+ focused,
754
+ existingTags
755
+ })
756
+ }),
757
+ /* @__PURE__ */ jsx(Box, {
758
+ marginTop: 1,
759
+ children: /* @__PURE__ */ jsx(Text, {
760
+ color: hint ? "red" : "gray",
761
+ children: hint || "⌃N next · ⌃P prev · ⏎ review · esc cancel"
762
+ })
763
+ })
764
+ ]
765
+ });
766
+ }
767
+ //#endregion
768
+ //#region src/clipboard.ts
769
+ /** Copy text to the macOS clipboard via pbcopy. Returns false if it fails. */
770
+ function copyToClipboard(text) {
771
+ try {
772
+ return spawnSync("pbcopy", { input: text }).status === 0;
773
+ } catch {
774
+ return false;
775
+ }
776
+ }
777
+ //#endregion
778
+ //#region src/search.ts
779
+ function haystack(e) {
780
+ return [
781
+ e.action,
782
+ (e.tags ?? []).join(" "),
783
+ e.app,
784
+ e.keys ?? "",
785
+ e.notes ?? ""
786
+ ].filter(Boolean).join(" ");
787
+ }
788
+ /** Pure fuzzy search. Empty query → all entries in stable browse order. */
789
+ function search(entries, query) {
790
+ const q = query.trim();
791
+ if (!q) return [...entries].sort((a, b) => a.app.localeCompare(b.app) || a.action.localeCompare(b.action));
792
+ return new Fzf(entries, {
793
+ selector: haystack,
794
+ match: extendedMatch
795
+ }).find(q).map((r) => r.item);
223
796
  }
797
+ //#endregion
798
+ //#region src/tui/Footer.tsx
799
+ function Footer({ flash, errorCount, resultCount }) {
800
+ return /* @__PURE__ */ jsxs(Box, {
801
+ marginTop: 1,
802
+ justifyContent: "space-between",
803
+ children: [/* @__PURE__ */ jsxs(Text, {
804
+ color: "gray",
805
+ children: [
806
+ "↑↓ move ⏎ copy ⌃O add ⎋ quit ⌥⌫/⌃W del ⌃U clear (",
807
+ resultCount,
808
+ ")"
809
+ ]
810
+ }), flash ? /* @__PURE__ */ jsx(Text, {
811
+ color: "green",
812
+ children: flash
813
+ }) : errorCount > 0 ? /* @__PURE__ */ jsxs(Text, {
814
+ color: "yellow",
815
+ children: [
816
+ "⚠ ",
817
+ errorCount,
818
+ " skipped — run `keybook check`"
819
+ ]
820
+ }) : /* @__PURE__ */ jsx(Text, { children: " " })]
821
+ });
822
+ }
823
+ //#endregion
824
+ //#region src/tui/PreviewPane.tsx
224
825
  function PreviewPane({ entry, width = "50%" }) {
225
- if (!entry) return null;
226
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width, paddingLeft: 2, children: [
227
- /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: entry.app }),
228
- /* @__PURE__ */ jsx2(Text2, { bold: true, children: entry.action }),
229
- /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", children: [
230
- entry.keys ? /* @__PURE__ */ jsx2(KeyCaps, { value: entry.keys }) : null,
231
- entry.steps?.map((s, i) => /* @__PURE__ */ jsxs2(Text2, { children: [
232
- i + 1,
233
- ". ",
234
- s
235
- ] }, s)),
236
- entry.command ? /* @__PURE__ */ jsxs2(Text2, { color: "green", children: [
237
- "$ ",
238
- entry.command
239
- ] }) : null
240
- ] }),
241
- entry.tags?.length ? /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
242
- "tags: ",
243
- entry.tags.join(", ")
244
- ] }) : null,
245
- entry.notes ? /* @__PURE__ */ jsx2(Text2, { color: "gray", children: entry.notes }) : null
246
- ] });
247
- }
248
-
249
- // src/tui/ResultList.tsx
250
- import { Box as Box4, Text as Text4 } from "ink";
251
-
252
- // src/tui/ResultRow.tsx
253
- import { Box as Box3, Text as Text3 } from "ink";
254
- import { jsxs as jsxs3 } from "react/jsx-runtime";
826
+ if (!entry) return null;
827
+ return /* @__PURE__ */ jsxs(Box, {
828
+ flexDirection: "column",
829
+ width,
830
+ paddingLeft: 2,
831
+ children: [
832
+ /* @__PURE__ */ jsx(Text, {
833
+ color: "cyan",
834
+ children: entry.app
835
+ }),
836
+ /* @__PURE__ */ jsx(Text, {
837
+ bold: true,
838
+ children: entry.action
839
+ }),
840
+ /* @__PURE__ */ jsxs(Box, {
841
+ marginTop: 1,
842
+ flexDirection: "column",
843
+ children: [
844
+ entry.keys ? /* @__PURE__ */ jsx(KeyCaps, { value: entry.keys }) : null,
845
+ entry.steps?.map((s, i) => /* @__PURE__ */ jsxs(Text, { children: [
846
+ i + 1,
847
+ ". ",
848
+ s
849
+ ] }, s)),
850
+ entry.command ? /* @__PURE__ */ jsxs(Text, {
851
+ color: "green",
852
+ children: ["$ ", entry.command]
853
+ }) : null
854
+ ]
855
+ }),
856
+ entry.tags?.length ? /* @__PURE__ */ jsxs(Text, {
857
+ color: "gray",
858
+ children: ["tags: ", entry.tags.join(", ")]
859
+ }) : null,
860
+ entry.notes ? /* @__PURE__ */ jsx(Text, {
861
+ color: "gray",
862
+ children: entry.notes
863
+ }) : null
864
+ ]
865
+ });
866
+ }
867
+ //#endregion
868
+ //#region src/tui/ResultRow.tsx
255
869
  function ResultRow({ entry, selected }) {
256
- const right = entry.keys ?? "recipe";
257
- return /* @__PURE__ */ jsxs3(Box3, { children: [
258
- /* @__PURE__ */ jsxs3(Text3, { color: selected ? "cyan" : void 0, inverse: selected, children: [
259
- selected ? "\u25B8 " : " ",
260
- entry.app,
261
- " \xB7 ",
262
- entry.action
263
- ] }),
264
- /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
265
- " ",
266
- right
267
- ] })
268
- ] });
269
- }
270
-
271
- // src/tui/ResultList.tsx
272
- import { jsx as jsx3, jsxs as jsxs4 } from "react/jsx-runtime";
273
- function ResultList({
274
- results,
275
- selected,
276
- query,
277
- height = 12,
278
- width = "50%"
279
- }) {
280
- if (results.length === 0) {
281
- return /* @__PURE__ */ jsx3(Box4, { width, justifyContent: "center", children: /* @__PURE__ */ jsxs4(Text4, { color: "gray", children: [
282
- 'No matches for "',
283
- query,
284
- '"'
285
- ] }) });
286
- }
287
- const start = Math.max(0, Math.min(selected - Math.floor(height / 2), results.length - height));
288
- const visible = results.slice(Math.max(0, start), Math.max(0, start) + height);
289
- return /* @__PURE__ */ jsx3(Box4, { flexDirection: "column", width, children: visible.map((e, i) => /* @__PURE__ */ jsx3(
290
- ResultRow,
291
- {
292
- entry: e,
293
- selected: Math.max(0, start) + i === selected
294
- },
295
- `${e.app}:${e.action}`
296
- )) });
297
- }
298
-
299
- // src/tui/SearchInput.tsx
300
- import { Box as Box5, Text as Text5 } from "ink";
301
- import { jsx as jsx4, jsxs as jsxs5 } from "react/jsx-runtime";
870
+ const right = entry.keys ?? "recipe";
871
+ return /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, {
872
+ color: selected ? "cyan" : void 0,
873
+ inverse: selected,
874
+ children: [
875
+ selected ? " " : " ",
876
+ entry.app,
877
+ " · ",
878
+ entry.action
879
+ ]
880
+ }), /* @__PURE__ */ jsxs(Text, {
881
+ color: "gray",
882
+ children: [" ", right]
883
+ })] });
884
+ }
885
+ //#endregion
886
+ //#region src/tui/ResultList.tsx
887
+ function ResultList({ results, selected, query, height = 12, width = "50%" }) {
888
+ if (results.length === 0) return /* @__PURE__ */ jsx(Box, {
889
+ width,
890
+ justifyContent: "center",
891
+ children: /* @__PURE__ */ jsxs(Text, {
892
+ color: "gray",
893
+ children: [
894
+ "No matches for \"",
895
+ query,
896
+ "\""
897
+ ]
898
+ })
899
+ });
900
+ const start = Math.max(0, Math.min(selected - Math.floor(height / 2), results.length - height));
901
+ return /* @__PURE__ */ jsx(Box, {
902
+ flexDirection: "column",
903
+ width,
904
+ children: results.slice(Math.max(0, start), Math.max(0, start) + height).map((e, i) => /* @__PURE__ */ jsx(ResultRow, {
905
+ entry: e,
906
+ selected: Math.max(0, start) + i === selected
907
+ }, `${e.app}:${e.action}`))
908
+ });
909
+ }
910
+ //#endregion
911
+ //#region src/tui/SearchInput.tsx
302
912
  function SearchInput({ query }) {
303
- return /* @__PURE__ */ jsxs5(Box5, { children: [
304
- /* @__PURE__ */ jsx4(Text5, { color: "cyan", children: "search: " }),
305
- /* @__PURE__ */ jsx4(Text5, { children: query }),
306
- /* @__PURE__ */ jsx4(Text5, { inverse: true, children: " " })
307
- ] });
308
- }
309
-
310
- // src/tui/layout.ts
311
- var RESERVED_ROWS = 4;
913
+ return /* @__PURE__ */ jsxs(Box, { children: [
914
+ /* @__PURE__ */ jsx(Text, {
915
+ color: "cyan",
916
+ children: "search: "
917
+ }),
918
+ /* @__PURE__ */ jsx(Text, { children: query }),
919
+ /* @__PURE__ */ jsx(Text, {
920
+ inverse: true,
921
+ children: " "
922
+ })
923
+ ] });
924
+ }
925
+ //#endregion
926
+ //#region src/tui/input.ts
927
+ /**
928
+ * Drop the run of trailing whitespace, then the run of trailing non-whitespace.
929
+ * Mirrors readline's `unix-word-rubout` / `backward-kill-word` behavior so a
930
+ * single press eats both the spaces and the word immediately before them.
931
+ */
932
+ function deleteWordBack(query) {
933
+ const trimmed = query.replace(/\s+$/, "");
934
+ const lastSpace = trimmed.lastIndexOf(" ");
935
+ if (lastSpace === -1) return "";
936
+ return trimmed.slice(0, lastSpace + 1);
937
+ }
938
+ /**
939
+ * Rows to give the windowed result list, sized to the terminal height so the
940
+ * whole frame fits the viewport. Ink can only redraw in place while the frame
941
+ * stays within the terminal; an oversized frame duplicates on every keypress.
942
+ */
312
943
  function visibleListHeight(terminalRows) {
313
- const rows = terminalRows && terminalRows > 0 ? terminalRows : 24;
314
- return Math.max(1, rows - RESERVED_ROWS);
944
+ return Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 4);
315
945
  }
946
+ /**
947
+ * Integer widths for the two side-by-side panes. They MUST sum to the exact
948
+ * terminal width: two `width="50%"` siblings each round up on an odd-width
949
+ * terminal (58 + 58 = 116 at 115 cols), overflowing by a column. The terminal
950
+ * then soft-wraps the full-width rows, Ink miscounts the frame height, and its
951
+ * redraw leaves stale lines stacked on every keystroke.
952
+ */
316
953
  function columnWidths(terminalCols) {
317
- const cols = terminalCols && terminalCols > 0 ? terminalCols : 80;
318
- const left = Math.floor(cols / 2);
319
- return { left, right: cols - left };
320
- }
321
-
322
- // src/tui/App.tsx
323
- import { jsx as jsx5, jsxs as jsxs6 } from "react/jsx-runtime";
324
- function App({ entries, errorCount = 0, onCopy = copyToClipboard }) {
325
- const { exit } = useApp();
326
- const { stdout } = useStdout();
327
- const [query, setQuery] = useState("");
328
- const [selected, setSelected] = useState(0);
329
- const [flash, setFlash] = useState("");
330
- const results = useMemo(() => search(entries, query), [entries, query]);
331
- const sel = results.length ? Math.min(selected, results.length - 1) : 0;
332
- const current = results[sel];
333
- const listHeight = visibleListHeight(stdout?.rows);
334
- const { left, right } = columnWidths(stdout?.columns);
335
- useInput((input, key) => {
336
- if (!key.return && flash) setFlash("");
337
- if (key.escape || key.ctrl && input === "c") {
338
- exit();
339
- return;
340
- }
341
- if (key.downArrow || key.ctrl && input === "n") {
342
- setSelected((s) => Math.min(s + 1, results.length - 1));
343
- return;
344
- }
345
- if (key.upArrow || key.ctrl && input === "p") {
346
- setSelected((s) => Math.max(s - 1, 0));
347
- return;
348
- }
349
- if (key.return) {
350
- if (current) {
351
- const text = current.keys ?? current.command ?? `${current.app}: ${current.action}`;
352
- setFlash(onCopy(text) ? "\u2713 copied!" : "\u2717 copy failed");
353
- }
354
- return;
355
- }
356
- if (key.backspace || key.delete) {
357
- setQuery((q) => q.slice(0, -1));
358
- setSelected(0);
359
- return;
360
- }
361
- if (input && !key.ctrl && !key.meta) {
362
- setQuery((q) => q + input);
363
- setSelected(0);
364
- }
365
- });
366
- return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
367
- /* @__PURE__ */ jsx5(SearchInput, { query }),
368
- /* @__PURE__ */ jsxs6(Box6, { children: [
369
- /* @__PURE__ */ jsx5(
370
- ResultList,
371
- {
372
- results,
373
- selected: sel,
374
- query,
375
- height: listHeight,
376
- width: left
377
- }
378
- ),
379
- /* @__PURE__ */ jsx5(PreviewPane, { entry: current, width: right })
380
- ] }),
381
- /* @__PURE__ */ jsx5(Footer, { flash, errorCount, resultCount: results.length })
382
- ] });
383
- }
384
-
385
- // src/cli.ts
386
- var program = new Command();
387
- program.name("keybook").description("Search keyboard shortcuts & recipes for your apps").version("0.1.0");
954
+ const cols = terminalCols && terminalCols > 0 ? terminalCols : 80;
955
+ const left = Math.floor(cols / 2);
956
+ return {
957
+ left,
958
+ right: cols - left
959
+ };
960
+ }
961
+ //#endregion
962
+ //#region src/tui/App.tsx
963
+ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboard }) {
964
+ const { exit } = useApp();
965
+ const { stdout } = useStdout();
966
+ const [entries, setEntries] = useState(initial);
967
+ const [mode, setMode] = useState("search");
968
+ const [query, setQuery] = useState("");
969
+ const [selected, setSelected] = useState(0);
970
+ const [flash, setFlash] = useState("");
971
+ const reload = useCallback(() => {
972
+ if (dataDir) setEntries(loadEntries(dataDir).entries);
973
+ }, [dataDir]);
974
+ const results = useMemo(() => search(entries, query), [entries, query]);
975
+ const sel = results.length ? Math.min(selected, results.length - 1) : 0;
976
+ const current = results[sel];
977
+ const listHeight = visibleListHeight(stdout?.rows);
978
+ const { left, right } = columnWidths(stdout?.columns);
979
+ useInput((input, key) => {
980
+ if (!key.return && flash) setFlash("");
981
+ if (key.escape || key.ctrl && input === "c") {
982
+ exit();
983
+ return;
984
+ }
985
+ if (dataDir && key.ctrl && input === "o") {
986
+ setMode("add");
987
+ return;
988
+ }
989
+ if (key.downArrow || key.ctrl && input === "n") {
990
+ setSelected((s) => Math.min(s + 1, results.length - 1));
991
+ return;
992
+ }
993
+ if (key.upArrow || key.ctrl && input === "p") {
994
+ setSelected((s) => Math.max(s - 1, 0));
995
+ return;
996
+ }
997
+ if (key.return) {
998
+ if (current) setFlash(onCopy(current.keys ?? current.command ?? `${current.app}: ${current.action}`) ? "✓ copied!" : "✗ copy failed");
999
+ return;
1000
+ }
1001
+ if (key.meta && (key.backspace || key.delete) || key.ctrl && input === "w") {
1002
+ setQuery(deleteWordBack);
1003
+ setSelected(0);
1004
+ return;
1005
+ }
1006
+ if (key.ctrl && input === "u") {
1007
+ setQuery("");
1008
+ setSelected(0);
1009
+ return;
1010
+ }
1011
+ if (key.backspace || key.delete) {
1012
+ setQuery((q) => q.slice(0, -1));
1013
+ setSelected(0);
1014
+ return;
1015
+ }
1016
+ if (input && !key.ctrl && !key.meta) {
1017
+ setQuery((q) => q + input);
1018
+ setSelected(0);
1019
+ }
1020
+ }, { isActive: mode === "search" });
1021
+ if (mode === "add" && dataDir) {
1022
+ const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1023
+ return /* @__PURE__ */ jsx(AddEntryForm, {
1024
+ apps: listApps(dataDir),
1025
+ existingTags,
1026
+ resolveTarget: (a) => resolveTargetFile(dataDir, a),
1027
+ onSubmit: (app, entry) => addEntry(dataDir, app, entry),
1028
+ onComplete: (result) => {
1029
+ if (result.ok) {
1030
+ reload();
1031
+ setSelected(0);
1032
+ setFlash(result.lines[0] ?? "✓ added");
1033
+ }
1034
+ setMode("search");
1035
+ },
1036
+ onCancel: () => setMode("search")
1037
+ });
1038
+ }
1039
+ return /* @__PURE__ */ jsxs(Box, {
1040
+ flexDirection: "column",
1041
+ children: [
1042
+ /* @__PURE__ */ jsx(SearchInput, { query }),
1043
+ /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(ResultList, {
1044
+ results,
1045
+ selected: sel,
1046
+ query,
1047
+ height: listHeight,
1048
+ width: left
1049
+ }), /* @__PURE__ */ jsx(PreviewPane, {
1050
+ entry: current,
1051
+ width: right
1052
+ })] }),
1053
+ /* @__PURE__ */ jsx(Footer, {
1054
+ flash,
1055
+ errorCount,
1056
+ resultCount: results.length
1057
+ })
1058
+ ]
1059
+ });
1060
+ }
1061
+ //#endregion
1062
+ //#region src/cli.ts
1063
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1064
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1065
+ const program = new Command();
1066
+ program.name("keybook").description("Search keyboard shortcuts & recipes for your apps").version(pkg.version);
388
1067
  program.command("path").description("Print the data directory").action(() => {
389
- console.log(runPath());
1068
+ console.log(runPath());
390
1069
  });
391
1070
  program.command("edit").description("Open the data directory in $EDITOR").action(() => {
392
- const { dir } = resolveDataDir();
393
- ensureDataDir(dir);
394
- const editor = process.env.EDITOR || "code";
395
- const child = spawn(editor, [dir], { stdio: "inherit" });
396
- child.on("error", () => spawn("open", [dir], { stdio: "inherit" }));
1071
+ const { dir } = resolveDataDir();
1072
+ ensureDataDir(dir);
1073
+ spawn(process.env.EDITOR || "code", [dir], { stdio: "inherit" }).on("error", () => spawn("open", [dir], { stdio: "inherit" }));
397
1074
  });
398
1075
  program.command("check").description("Validate all data files").action(() => {
399
- const { dir } = resolveDataDir();
400
- ensureDataDir(dir);
401
- const result = runCheck(dir);
402
- for (const line of result.lines) (result.ok ? console.log : console.error)(line);
403
- process.exit(result.ok ? 0 : 1);
1076
+ const { dir } = resolveDataDir();
1077
+ ensureDataDir(dir);
1078
+ const result = runCheck(dir);
1079
+ for (const line of result.lines) (result.ok ? console.log : console.error)(line);
1080
+ process.exit(result.ok ? 0 : 1);
1081
+ });
1082
+ program.command("add").description("Add a new entry (interactive, or non-interactive with flags)").option("--app <name>", "target app").option("--action <text>", "what the entry does").option("--keys <combo>", "key combo (glyphs or words, e.g. 'shift cmd p')").option("--command <cmd>", "terminal command").option("--step <text...>", "recipe step (repeatable)").option("--tags <list>", "comma-separated tags").option("--notes <text>", "notes").action((opts) => {
1083
+ const { dir } = resolveDataDir();
1084
+ try {
1085
+ ensureDataDir(dir);
1086
+ } catch (e) {
1087
+ console.error(`Error: could not prepare data directory ${dir}: ${e.message}`);
1088
+ process.exit(1);
1089
+ }
1090
+ const app = opts.app;
1091
+ const action = opts.action;
1092
+ const keys = opts.keys;
1093
+ const command = opts.command;
1094
+ const steps = opts.step ?? void 0;
1095
+ const tags = typeof opts.tags === "string" ? opts.tags.split(",").map((t) => t.trim()).filter(Boolean) : void 0;
1096
+ const notes = opts.notes;
1097
+ const haveBody = Boolean(keys || command || steps?.length);
1098
+ if (Boolean(app && action && haveBody)) {
1099
+ const result = runAdd(dir, {
1100
+ app,
1101
+ action,
1102
+ keys,
1103
+ command,
1104
+ steps,
1105
+ tags,
1106
+ notes
1107
+ });
1108
+ for (const line of result.lines) (result.ok ? console.log : console.error)(line);
1109
+ process.exit(result.ok ? 0 : 1);
1110
+ }
1111
+ if (!process.stdout.isTTY) {
1112
+ const missing = [
1113
+ !app && "--app",
1114
+ !action && "--action",
1115
+ !haveBody && "--keys/--command/--step"
1116
+ ].filter(Boolean).join(", ");
1117
+ console.error(`Error: missing required field(s): ${missing}`);
1118
+ process.exit(2);
1119
+ }
1120
+ const { entries } = loadEntries(dir);
1121
+ const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1122
+ const instance = render(createElement(AddEntryForm, {
1123
+ apps: listApps(dir),
1124
+ existingTags,
1125
+ resolveTarget: (a) => resolveTargetFile(dir, a),
1126
+ onSubmit: (a, entry) => addEntry(dir, a, entry),
1127
+ onComplete: (result) => {
1128
+ if (result.lines[0]) console.log(result.lines[0]);
1129
+ instance.unmount();
1130
+ },
1131
+ onCancel: () => instance.unmount()
1132
+ }));
1133
+ instance.waitUntilExit().then(() => process.exit(0));
404
1134
  });
405
1135
  program.action(() => {
406
- const { dir } = resolveDataDir();
407
- const init = ensureDataDir(dir);
408
- if (init.initialized) console.log(`Initialized ${dir} from seed (${init.fileCount} files).`);
409
- const { entries, errors } = loadEntries(dir);
410
- for (const e of errors) console.error(`\u26A0 ${e.file}: ${e.message}`);
411
- render(createElement(App, { entries, errorCount: errors.length }));
1136
+ const { dir } = resolveDataDir();
1137
+ const init = ensureDataDir(dir);
1138
+ if (init.initialized) console.log(`Initialized ${dir} from seed (${init.fileCount} files).`);
1139
+ const { entries, errors } = loadEntries(dir);
1140
+ for (const e of errors) console.error(`⚠ ${e.file}: ${e.message}`);
1141
+ render(createElement(App, {
1142
+ entries,
1143
+ errorCount: errors.length,
1144
+ dataDir: dir
1145
+ }));
412
1146
  });
413
1147
  program.parse();
1148
+ //#endregion
1149
+ export {};