@arthony/keybook 0.2.0 → 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,436 +1,1149 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/cli.ts
4
- import { spawn } from "child_process";
5
- import { readFileSync as readFileSync2 } from "fs";
6
- import { dirname as dirname2, join as join3 } from "path";
7
- import { fileURLToPath as fileURLToPath2 } from "url";
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";
8
6
  import { Command } from "commander";
9
- import { render } from "ink";
10
- import { createElement } from "react";
11
-
12
- // src/config.ts
13
- import { cpSync, existsSync, mkdirSync, readdirSync } from "fs";
14
- import { homedir } from "os";
15
- import { dirname, join } from "path";
16
- import { fileURLToPath } from "url";
17
- 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$/;
18
16
  function resolveDataDir(env = process.env) {
19
- if (env.KEYBOOK_DATA_DIR) return { dir: env.KEYBOOK_DATA_DIR, source: "env" };
20
- if (env.XDG_CONFIG_HOME) return { dir: join(env.XDG_CONFIG_HOME, "keybook"), source: "xdg" };
21
- 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
+ };
22
29
  }
30
+ /** Locate the bundled seed/ relative to this module (works in dist and in tests). */
23
31
  function seedDir() {
24
- return join(dirname(fileURLToPath(import.meta.url)), "..", "seed");
32
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "seed");
25
33
  }
26
34
  function ensureDataDir(dir, seed = seedDir()) {
27
- const hasYaml = existsSync(dir) && readdirSync(dir).some((f) => YAML_RE.test(f));
28
- if (hasYaml) return { initialized: false, fileCount: 0 };
29
- mkdirSync(dir, { recursive: true });
30
- const seedFiles = readdirSync(seed).filter((f) => YAML_RE.test(f));
31
- for (const f of seedFiles) cpSync(join(seed, f), join(dir, f));
32
- return { initialized: true, fileCount: seedFiles.length };
33
- }
34
-
35
- // src/data/loader.ts
36
- import { readFileSync, readdirSync as readdirSync2 } from "fs";
37
- import { join as join2 } from "path";
38
- import { parse as parseYaml } from "yaml";
39
-
40
- // src/data/schema.ts
41
- import { z } from "zod";
42
- var entrySchema = z.object({
43
- action: z.string().min(1),
44
- keys: z.string().min(1).optional(),
45
- steps: z.array(z.string().min(1)).min(1).optional(),
46
- command: z.string().min(1).optional(),
47
- tags: z.array(z.string().min(1)).optional(),
48
- notes: z.string().min(1).optional(),
49
- source: z.string().min(1).optional()
50
- }).strict().refine((e) => Boolean(e.keys) || Boolean(e.steps) || Boolean(e.command), {
51
- message: "entry must have at least one of: keys, steps, command"
52
- });
53
- var fileShape = z.object({
54
- app: z.string().min(1),
55
- 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)
56
125
  }).strict();
57
-
58
- // src/data/loader.ts
59
- var YAML_RE2 = /\.ya?ml$/;
126
+ //#endregion
127
+ //#region src/data/loader.ts
128
+ const YAML_RE$1 = /\.ya?ml$/;
60
129
  function loadEntries(dataDir) {
61
- const entries = [];
62
- const errors = [];
63
- let files;
64
- try {
65
- files = readdirSync2(dataDir).filter((f) => YAML_RE2.test(f)).sort();
66
- } catch (err) {
67
- errors.push({
68
- file: dataDir,
69
- entryIndex: null,
70
- message: `cannot read data dir: ${err.message}`
71
- });
72
- return { entries, errors };
73
- }
74
- for (const file of files) {
75
- let raw;
76
- try {
77
- raw = parseYaml(readFileSync(join2(dataDir, file), "utf8"));
78
- } catch (err) {
79
- errors.push({
80
- file,
81
- entryIndex: null,
82
- message: `YAML parse error: ${err.message}`
83
- });
84
- continue;
85
- }
86
- const shape = fileShape.safeParse(raw);
87
- if (!shape.success) {
88
- errors.push({
89
- file,
90
- entryIndex: null,
91
- message: shape.error.issues[0]?.message ?? "invalid file shape"
92
- });
93
- continue;
94
- }
95
- const { app, entries: rawEntries } = shape.data;
96
- rawEntries.forEach((rawEntry, i) => {
97
- const parsed = entrySchema.safeParse(rawEntry);
98
- if (!parsed.success) {
99
- errors.push({
100
- file,
101
- entryIndex: i,
102
- message: parsed.error.issues.map((s) => s.message).join("; ")
103
- });
104
- return;
105
- }
106
- entries.push({ app, ...parsed.data });
107
- });
108
- }
109
- return { entries, errors };
110
- }
111
-
112
- // 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
113
315
  function runPath(env = process.env) {
114
- const { dir, source } = resolveDataDir(env);
115
- return `${dir} (source: ${source})`;
316
+ const { dir, source } = resolveDataDir(env);
317
+ return `${dir} (source: ${source})`;
116
318
  }
117
319
  function runCheck(dir) {
118
- const { entries, errors } = loadEntries(dir);
119
- if (errors.length === 0) return { ok: true, lines: [`\u2713 ${entries.length} entries OK`] };
120
- return {
121
- ok: false,
122
- lines: errors.map(
123
- (e) => `\u2717 ${e.file}${e.entryIndex !== null ? ` [entry ${e.entryIndex}]` : ""}: ${e.message}`
124
- )
125
- };
126
- }
127
-
128
- // src/tui/App.tsx
129
- import { Box as Box6, useApp, useInput, useStdout } from "ink";
130
- import { useMemo, useState } from "react";
131
-
132
- // src/clipboard.ts
133
- import { spawnSync } from "child_process";
134
- function copyToClipboard(text) {
135
- try {
136
- const res = spawnSync("pbcopy", { input: text });
137
- return res.status === 0;
138
- } catch {
139
- return false;
140
- }
141
- }
142
-
143
- // src/search.ts
144
- import { Fzf, extendedMatch } from "fzf";
145
- function haystack(e) {
146
- 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
+ };
147
329
  }
148
- function search(entries, query) {
149
- const q = query.trim();
150
- if (!q) {
151
- return [...entries].sort(
152
- (a, b) => a.app.localeCompare(b.app) || a.action.localeCompare(b.action)
153
- );
154
- }
155
- const fzf = new Fzf(entries, { selector: haystack, match: extendedMatch });
156
- return fzf.find(q).map((r) => r.item);
157
- }
158
-
159
- // src/tui/Footer.tsx
160
- import { Box, Text } from "ink";
161
- import { jsx, jsxs } from "react/jsx-runtime";
162
- function Footer({
163
- flash,
164
- errorCount,
165
- resultCount
166
- }) {
167
- return /* @__PURE__ */ jsxs(Box, { marginTop: 1, justifyContent: "space-between", children: [
168
- /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
169
- "\u2191\u2193 move \u23CE copy \u238B quit \u2325\u232B/\u2303W del word \u2303U clear (",
170
- resultCount,
171
- ")"
172
- ] }),
173
- flash ? /* @__PURE__ */ jsx(Text, { color: "green", children: flash }) : errorCount > 0 ? /* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
174
- "\u26A0 ",
175
- errorCount,
176
- " skipped \u2014 run `keybook check`"
177
- ] }) : /* @__PURE__ */ jsx(Text, { children: " " })
178
- ] });
179
- }
180
-
181
- // src/tui/PreviewPane.tsx
182
- import { Box as Box2, Text as Text2 } from "ink";
183
-
184
- // src/tui/keycaps.ts
185
- 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
+ ]);
186
478
  function parseSegment(seg) {
187
- const tokens = [];
188
- let buf = "";
189
- for (const ch of seg) {
190
- if (GLYPHS.has(ch)) {
191
- if (buf) {
192
- tokens.push(buf);
193
- buf = "";
194
- }
195
- tokens.push(ch);
196
- } else if (ch === " ") {
197
- if (buf) {
198
- tokens.push(buf);
199
- buf = "";
200
- }
201
- } else {
202
- buf += ch;
203
- }
204
- }
205
- if (buf) tokens.push(buf);
206
- 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];
207
495
  }
496
+ /** Parse a keys string into a chord sequence of token groups. Never throws. */
208
497
  function parseKeys(input) {
209
- return input.split(",").map((s) => s.trim()).filter(Boolean).map(parseSegment);
498
+ return input.split(",").map((s) => s.trim()).filter(Boolean).map(parseSegment);
210
499
  }
211
-
212
- // src/tui/PreviewPane.tsx
213
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
500
+ //#endregion
501
+ //#region src/tui/key-caps.tsx
214
502
  function KeyCaps({ value }) {
215
- const chords = parseKeys(value);
216
- return /* @__PURE__ */ jsx2(Box2, { children: chords.map((seg, si) => (
217
- // biome-ignore lint/suspicious/noArrayIndexKey: token order is stable; tokens can repeat (e.g. ⌃X⌃E) so the value is not a unique key
218
- /* @__PURE__ */ jsxs2(Box2, { children: [
219
- si > 0 ? /* @__PURE__ */ jsx2(Text2, { children: " , " }) : null,
220
- seg.map((tok, ti) => (
221
- // biome-ignore lint/suspicious/noArrayIndexKey: see above — repeated tokens make the value non-unique
222
- /* @__PURE__ */ jsx2(Box2, { marginRight: 1, borderStyle: "round", paddingX: 1, children: /* @__PURE__ */ jsx2(Text2, { bold: true, children: tok }) }, ti)
223
- ))
224
- ] }, si)
225
- )) });
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);
226
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
227
825
  function PreviewPane({ entry, width = "50%" }) {
228
- if (!entry) return null;
229
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width, paddingLeft: 2, children: [
230
- /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: entry.app }),
231
- /* @__PURE__ */ jsx2(Text2, { bold: true, children: entry.action }),
232
- /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", children: [
233
- entry.keys ? /* @__PURE__ */ jsx2(KeyCaps, { value: entry.keys }) : null,
234
- entry.steps?.map((s, i) => /* @__PURE__ */ jsxs2(Text2, { children: [
235
- i + 1,
236
- ". ",
237
- s
238
- ] }, s)),
239
- entry.command ? /* @__PURE__ */ jsxs2(Text2, { color: "green", children: [
240
- "$ ",
241
- entry.command
242
- ] }) : null
243
- ] }),
244
- entry.tags?.length ? /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
245
- "tags: ",
246
- entry.tags.join(", ")
247
- ] }) : null,
248
- entry.notes ? /* @__PURE__ */ jsx2(Text2, { color: "gray", children: entry.notes }) : null
249
- ] });
250
- }
251
-
252
- // src/tui/ResultList.tsx
253
- import { Box as Box4, Text as Text4 } from "ink";
254
-
255
- // src/tui/ResultRow.tsx
256
- import { Box as Box3, Text as Text3 } from "ink";
257
- 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
258
869
  function ResultRow({ entry, selected }) {
259
- const right = entry.keys ?? "recipe";
260
- return /* @__PURE__ */ jsxs3(Box3, { children: [
261
- /* @__PURE__ */ jsxs3(Text3, { color: selected ? "cyan" : void 0, inverse: selected, children: [
262
- selected ? "\u25B8 " : " ",
263
- entry.app,
264
- " \xB7 ",
265
- entry.action
266
- ] }),
267
- /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
268
- " ",
269
- right
270
- ] })
271
- ] });
272
- }
273
-
274
- // src/tui/ResultList.tsx
275
- import { jsx as jsx3, jsxs as jsxs4 } from "react/jsx-runtime";
276
- function ResultList({
277
- results,
278
- selected,
279
- query,
280
- height = 12,
281
- width = "50%"
282
- }) {
283
- if (results.length === 0) {
284
- return /* @__PURE__ */ jsx3(Box4, { width, justifyContent: "center", children: /* @__PURE__ */ jsxs4(Text4, { color: "gray", children: [
285
- 'No matches for "',
286
- query,
287
- '"'
288
- ] }) });
289
- }
290
- const start = Math.max(0, Math.min(selected - Math.floor(height / 2), results.length - height));
291
- const visible = results.slice(Math.max(0, start), Math.max(0, start) + height);
292
- return /* @__PURE__ */ jsx3(Box4, { flexDirection: "column", width, children: visible.map((e, i) => /* @__PURE__ */ jsx3(
293
- ResultRow,
294
- {
295
- entry: e,
296
- selected: Math.max(0, start) + i === selected
297
- },
298
- `${e.app}:${e.action}`
299
- )) });
300
- }
301
-
302
- // src/tui/SearchInput.tsx
303
- import { Box as Box5, Text as Text5 } from "ink";
304
- 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
305
912
  function SearchInput({ query }) {
306
- return /* @__PURE__ */ jsxs5(Box5, { children: [
307
- /* @__PURE__ */ jsx4(Text5, { color: "cyan", children: "search: " }),
308
- /* @__PURE__ */ jsx4(Text5, { children: query }),
309
- /* @__PURE__ */ jsx4(Text5, { inverse: true, children: " " })
310
- ] });
311
- }
312
-
313
- // src/tui/input.ts
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
+ */
314
932
  function deleteWordBack(query) {
315
- const trimmed = query.replace(/\s+$/, "");
316
- const lastSpace = trimmed.lastIndexOf(" ");
317
- if (lastSpace === -1) return "";
318
- return trimmed.slice(0, lastSpace + 1);
319
- }
320
-
321
- // src/tui/layout.ts
322
- var RESERVED_ROWS = 4;
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
+ */
323
943
  function visibleListHeight(terminalRows) {
324
- const rows = terminalRows && terminalRows > 0 ? terminalRows : 24;
325
- return Math.max(1, rows - RESERVED_ROWS);
944
+ return Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 4);
326
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
+ */
327
953
  function columnWidths(terminalCols) {
328
- const cols = terminalCols && terminalCols > 0 ? terminalCols : 80;
329
- const left = Math.floor(cols / 2);
330
- return { left, right: cols - left };
331
- }
332
-
333
- // src/tui/App.tsx
334
- import { jsx as jsx5, jsxs as jsxs6 } from "react/jsx-runtime";
335
- function App({ entries, errorCount = 0, onCopy = copyToClipboard }) {
336
- const { exit } = useApp();
337
- const { stdout } = useStdout();
338
- const [query, setQuery] = useState("");
339
- const [selected, setSelected] = useState(0);
340
- const [flash, setFlash] = useState("");
341
- const results = useMemo(() => search(entries, query), [entries, query]);
342
- const sel = results.length ? Math.min(selected, results.length - 1) : 0;
343
- const current = results[sel];
344
- const listHeight = visibleListHeight(stdout?.rows);
345
- const { left, right } = columnWidths(stdout?.columns);
346
- useInput((input, key) => {
347
- if (!key.return && flash) setFlash("");
348
- if (key.escape || key.ctrl && input === "c") {
349
- exit();
350
- return;
351
- }
352
- if (key.downArrow || key.ctrl && input === "n") {
353
- setSelected((s) => Math.min(s + 1, results.length - 1));
354
- return;
355
- }
356
- if (key.upArrow || key.ctrl && input === "p") {
357
- setSelected((s) => Math.max(s - 1, 0));
358
- return;
359
- }
360
- if (key.return) {
361
- if (current) {
362
- const text = current.keys ?? current.command ?? `${current.app}: ${current.action}`;
363
- setFlash(onCopy(text) ? "\u2713 copied!" : "\u2717 copy failed");
364
- }
365
- return;
366
- }
367
- if (key.meta && (key.backspace || key.delete) || key.ctrl && input === "w") {
368
- setQuery(deleteWordBack);
369
- setSelected(0);
370
- return;
371
- }
372
- if (key.ctrl && input === "u") {
373
- setQuery("");
374
- setSelected(0);
375
- return;
376
- }
377
- if (key.backspace || key.delete) {
378
- setQuery((q) => q.slice(0, -1));
379
- setSelected(0);
380
- return;
381
- }
382
- if (input && !key.ctrl && !key.meta) {
383
- setQuery((q) => q + input);
384
- setSelected(0);
385
- }
386
- });
387
- return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
388
- /* @__PURE__ */ jsx5(SearchInput, { query }),
389
- /* @__PURE__ */ jsxs6(Box6, { children: [
390
- /* @__PURE__ */ jsx5(
391
- ResultList,
392
- {
393
- results,
394
- selected: sel,
395
- query,
396
- height: listHeight,
397
- width: left
398
- }
399
- ),
400
- /* @__PURE__ */ jsx5(PreviewPane, { entry: current, width: right })
401
- ] }),
402
- /* @__PURE__ */ jsx5(Footer, { flash, errorCount, resultCount: results.length })
403
- ] });
404
- }
405
-
406
- // src/cli.ts
407
- var pkgPath = join3(dirname2(fileURLToPath2(import.meta.url)), "..", "package.json");
408
- var pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
409
- var program = new Command();
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();
410
1066
  program.name("keybook").description("Search keyboard shortcuts & recipes for your apps").version(pkg.version);
411
1067
  program.command("path").description("Print the data directory").action(() => {
412
- console.log(runPath());
1068
+ console.log(runPath());
413
1069
  });
414
1070
  program.command("edit").description("Open the data directory in $EDITOR").action(() => {
415
- const { dir } = resolveDataDir();
416
- ensureDataDir(dir);
417
- const editor = process.env.EDITOR || "code";
418
- const child = spawn(editor, [dir], { stdio: "inherit" });
419
- 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" }));
420
1074
  });
421
1075
  program.command("check").description("Validate all data files").action(() => {
422
- const { dir } = resolveDataDir();
423
- ensureDataDir(dir);
424
- const result = runCheck(dir);
425
- for (const line of result.lines) (result.ok ? console.log : console.error)(line);
426
- 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));
427
1134
  });
428
1135
  program.action(() => {
429
- const { dir } = resolveDataDir();
430
- const init = ensureDataDir(dir);
431
- if (init.initialized) console.log(`Initialized ${dir} from seed (${init.fileCount} files).`);
432
- const { entries, errors } = loadEntries(dir);
433
- for (const e of errors) console.error(`\u26A0 ${e.file}: ${e.message}`);
434
- 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
+ }));
435
1146
  });
436
1147
  program.parse();
1148
+ //#endregion
1149
+ export {};