@arthony/keybook 0.6.0 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +22 -3
  2. package/dist/cli.js +703 -174
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -25,9 +25,10 @@ Requires macOS. The Homebrew tap installs Node 22 automatically; the npm
25
25
  route assumes you already have Node 22+. Both install the `keybook`
26
26
  command and the `kb` alias.
27
27
 
28
- **Terminal.app users:** if `⌥⌫` doesn't delete a word in the search input,
29
- enable *Terminal → Settings → Profiles → Keyboard → "Use Option as Meta
30
- key"*, or use `⌃W` (works in any terminal).
28
+ **Terminal.app users:** if `⌥⌫`, `⌥←` or `⌥→` don't work in the search
29
+ input, enable *Terminal → Settings → Profiles → Keyboard → "Use Option as
30
+ Meta key"*. `⌃W` (delete word back) works in any terminal; there is no
31
+ Option-free equivalent for word-wise cursor movement.
31
32
 
32
33
  ## Usage
33
34
 
@@ -47,6 +48,24 @@ selected entry (pre-filled form), `⌃X` to delete it (with a `y/n` confirm). Or
47
48
  script an add: `keybook add --app Fork --action 'Push' --keys 'shift cmd p' --tags push`
48
49
  (`--keys` accepts glyphs or words; recipes use repeatable `--step`).
49
50
 
51
+ ### Text editing
52
+
53
+ Every text input — the search box and each field of the add/edit form — takes
54
+ the same readline-style bindings:
55
+
56
+ | Key | Does |
57
+ |---|---|
58
+ | `←` / `→` | move one character |
59
+ | `⌥←` / `⌥→` | move one word |
60
+ | `⌃A` / `⌃E` | jump to start / end of the line |
61
+ | `⌫` / `⌃D` | delete before / at the cursor |
62
+ | `⌥⌫` / `⌃W` | delete the word before the cursor |
63
+ | `⌃U` / `⌃K` | delete to the start / end of the line |
64
+
65
+ `⌃E` is the one exception: in the **search box** it opens the edit form for the
66
+ selected entry (there is no conflict for `⌃A`, which works everywhere), and on
67
+ a **selected recipe step** it opens that step for in-place editing.
68
+
50
69
  ## Your data
51
70
 
52
71
  Shortcuts live in plain YAML files you own — by default `~/.config/keybook/`
package/dist/cli.js CHANGED
@@ -5,11 +5,11 @@ import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Command } from "commander";
7
7
  import { Box, Text, render, useApp, useInput, useStdout } from "ink";
8
- import { createElement, useCallback, useMemo, useState } from "react";
8
+ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
9
9
  import { homedir } from "node:os";
10
10
  import { Document, isMap, isSeq, parse, parseDocument } from "yaml";
11
11
  import { z } from "zod";
12
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
+ import { jsx, jsxs } from "react/jsx-runtime";
13
13
  import { Fzf, extendedMatch } from "fzf";
14
14
  //#region src/config.ts
15
15
  const YAML_RE$2 = /\.ya?ml$/;
@@ -379,6 +379,23 @@ function editEntry(dir, file, index, entry, expectedAction) {
379
379
  lines: [`✓ updated '${clean.action}'`]
380
380
  };
381
381
  }
382
+ function moveEntry(dir, sourceFile, index, expectedAction, targetApp, entry) {
383
+ if (resolveTargetFile(dir, targetApp).file === join(dir, sourceFile)) return editEntry(dir, sourceFile, index, entry, expectedAction);
384
+ const addRes = addEntry(dir, targetApp, entry);
385
+ if (!addRes.ok) return addRes;
386
+ if (!deleteEntry(dir, sourceFile, index, expectedAction).ok) return {
387
+ ok: true,
388
+ file: addRes.file,
389
+ created: addRes.created,
390
+ lines: [`⚠ moved to ${targetApp}; original still in ${sourceFile} — remove it manually`]
391
+ };
392
+ return {
393
+ ok: true,
394
+ file: addRes.file,
395
+ created: addRes.created,
396
+ lines: [`✓ moved '${entry.action}' → ${targetApp}`]
397
+ };
398
+ }
382
399
  //#endregion
383
400
  //#region src/commands.ts
384
401
  function runPath(env = process.env) {
@@ -411,30 +428,109 @@ function runAdd(dir, draft) {
411
428
  };
412
429
  }
413
430
  //#endregion
431
+ //#region src/tui/CursorText.tsx
432
+ /**
433
+ * Renders a value with a block cursor at `pos`, windowed horizontally so the
434
+ * cursor is always visible and the row NEVER wraps.
435
+ *
436
+ * Window rule (stateless): the view stays anchored at the left until the
437
+ * cursor would fall off the right edge, then scrolls one character at a time.
438
+ * A pure function of (pos, width) — no stored scroll offset to keep in sync.
439
+ *
440
+ * There is deliberately no clipped-content indicator: rendering a marker plus
441
+ * `width` cells is width+1 columns, which wraps — the exact failure this
442
+ * component exists to prevent. See the design spec §7.
443
+ *
444
+ * `key={chars.length}` is load-bearing, not decorative. Ink's Yoga layout
445
+ * measures a `wrap="truncate-end"` Text node's width incrementally, and that
446
+ * measurement is one render stale relative to a code-point-count change: the
447
+ * FIRST render after this Text's content grows (e.g. typing a character into
448
+ * an empty focused field) uses the PRIOR frame's — too-narrow — measured
449
+ * width, so it clips to a bare "…" and, since nothing re-renders without
450
+ * another keystroke, a real terminal would leave that "…" on screen
451
+ * indefinitely. Keying on the code-point count forces React (and Ink's host
452
+ * reconciler) to unmount the old node and mount a fresh one whenever the
453
+ * length changes, so there is no stale prior measurement to be wrong: a
454
+ * fresh node's first layout pass measures the current content correctly.
455
+ * `wrap="truncate-end"` is kept as the defensive backstop (matches the
456
+ * "windowed content never exceeds `width` cells" invariant above); without
457
+ * the key, it is the thing that makes the staleness visible as clipping
458
+ * instead of silently wrapping onto a second row and corrupting whatever
459
+ * renders below.
460
+ */
461
+ function CursorText({ value, pos, width, focused }) {
462
+ const chars = [...value];
463
+ const w = Math.max(1, width);
464
+ if (!focused) return /* @__PURE__ */ jsx(Text, {
465
+ wrap: "truncate-end",
466
+ children: chars.slice(0, w).join("")
467
+ }, chars.length);
468
+ const p = Math.max(0, Math.min(pos, chars.length));
469
+ const start = p < w ? 0 : p - w + 1;
470
+ return /* @__PURE__ */ jsxs(Text, {
471
+ wrap: "truncate-end",
472
+ children: [
473
+ chars.slice(start, p).join(""),
474
+ /* @__PURE__ */ jsx(Text, {
475
+ inverse: true,
476
+ children: chars[p] ?? " "
477
+ }),
478
+ chars.slice(p + 1, start + w).join("")
479
+ ]
480
+ }, chars.length);
481
+ }
482
+ //#endregion
414
483
  //#region src/tui/StepsBuilder.tsx
415
- function StepsBuilder({ steps, line, active }) {
484
+ function StepsBuilder({ steps, line, active, cursor, grabbed = false, pos, width, editingIndex, editBuffer }) {
485
+ const cur = cursor ?? steps.length;
486
+ const onAppendLine = cur >= steps.length;
487
+ const isEditing = editingIndex !== void 0;
488
+ const w = width ?? 71;
489
+ const hint = isEditing ? "⏎ save · esc cancel" : grabbed ? "↑↓ move · Space/⏎ drop" : steps.length === 0 ? "⏎ adds a step · ⌫ on an empty line removes the last" : onAppendLine ? "⏎ adds a step · ↑ select a step to reorder" : "↑↓ select · Space grab · ⌫ delete · ⌃E edit";
416
490
  return /* @__PURE__ */ jsxs(Box, {
417
491
  flexDirection: "column",
418
492
  children: [
419
- steps.map((s, i) => /* @__PURE__ */ jsxs(Text, { children: [
493
+ steps.map((s, i) => i === editingIndex ? /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, {
494
+ color: "cyan",
495
+ children: [
496
+ " ",
497
+ i + 1,
498
+ ".",
499
+ " "
500
+ ]
501
+ }), /* @__PURE__ */ jsx(CursorText, {
502
+ value: editBuffer ?? "",
503
+ pos: pos ?? [...editBuffer ?? ""].length,
504
+ width: w,
505
+ focused: true
506
+ })] }, `${i}-edit`) : /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, { children: [
507
+ active && cur === i ? grabbed ? "⇅ " : "> " : " ",
420
508
  i + 1,
421
- ". ",
422
- s
423
- ] }, i)),
424
- /* @__PURE__ */ jsxs(Box, { children: [
425
- /* @__PURE__ */ jsxs(Text, {
426
- color: active ? "cyan" : "gray",
427
- children: [steps.length + 1, ". "]
428
- }),
429
- /* @__PURE__ */ jsx(Text, { children: line }),
430
- active ? /* @__PURE__ */ jsx(Text, {
431
- inverse: true,
432
- children: " "
433
- }) : null
434
- ] }),
435
- steps.length === 0 ? /* @__PURE__ */ jsx(Text, {
509
+ ".",
510
+ " "
511
+ ] }), /* @__PURE__ */ jsx(CursorText, {
512
+ value: s,
513
+ pos: 0,
514
+ width: w,
515
+ focused: false
516
+ })] }, i)),
517
+ /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, {
518
+ color: active && onAppendLine ? "cyan" : "gray",
519
+ children: [
520
+ " ",
521
+ steps.length + 1,
522
+ ".",
523
+ " "
524
+ ]
525
+ }), /* @__PURE__ */ jsx(CursorText, {
526
+ value: line,
527
+ pos: pos ?? [...line].length,
528
+ width: w,
529
+ focused: active && onAppendLine
530
+ })] }),
531
+ active ? /* @__PURE__ */ jsx(Text, {
436
532
  color: "gray",
437
- children: "⏎ adds a step · ⌫ on an empty line removes the last"
533
+ children: hint
438
534
  }) : null
439
535
  ]
440
536
  });
@@ -446,34 +542,32 @@ const TYPES$1 = [
446
542
  "command",
447
543
  "recipe"
448
544
  ];
449
- function Field({ label, value, focused }) {
450
- return /* @__PURE__ */ jsxs(Box, { children: [
451
- /* @__PURE__ */ jsx(Text, {
452
- color: focused ? "cyan" : "gray",
453
- children: label.padEnd(8)
454
- }),
455
- /* @__PURE__ */ jsx(Text, { children: value }),
456
- focused ? /* @__PURE__ */ jsx(Text, {
457
- inverse: true,
458
- children: " "
459
- }) : null
460
- ] });
461
- }
462
- function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp }) {
545
+ function Field({ label, value, focused, pos, width }) {
546
+ return /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
547
+ color: focused ? "cyan" : "gray",
548
+ children: label.padEnd(8)
549
+ }), /* @__PURE__ */ jsx(CursorText, {
550
+ value,
551
+ pos,
552
+ width,
553
+ focused
554
+ })] });
555
+ }
556
+ function FormFields({ draft, apps, appIndex, focused, pos, width, existingTags, stepCursor, grabbed, editingStep, editBuffer }) {
463
557
  const appChoices = [...apps, "Create new app…"];
558
+ const keysPreview = draft.keys.trim() ? ` → ${normalizeKeys(draft.keys)}` : "";
464
559
  return /* @__PURE__ */ jsxs(Box, {
465
560
  flexDirection: "column",
466
561
  children: [
467
562
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
468
563
  color: focused === 0 ? "cyan" : "gray",
469
564
  children: "App".padEnd(8)
470
- }), lockedApp ? /* @__PURE__ */ jsx(Text, {
471
- color: "gray",
472
- children: `${lockedApp} (locked)`
473
- }) : draft.creatingApp ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: draft.newApp }), focused === 0 ? /* @__PURE__ */ jsx(Text, {
474
- inverse: true,
475
- children: " "
476
- }) : null] }) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? " (↑/↓)" : ""] })] }),
565
+ }), draft.creatingApp ? /* @__PURE__ */ jsx(CursorText, {
566
+ value: draft.newApp,
567
+ pos,
568
+ width,
569
+ focused: focused === 0
570
+ }) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? " (↑/↓)" : ""] })] }),
477
571
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
478
572
  color: focused === 1 ? "cyan" : "gray",
479
573
  children: "Type".padEnd(8)
@@ -481,27 +575,38 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp })
481
575
  /* @__PURE__ */ jsx(Field, {
482
576
  label: "Action",
483
577
  value: draft.action,
484
- focused: focused === 2
578
+ focused: focused === 2,
579
+ pos,
580
+ width
485
581
  }),
486
582
  draft.type === "shortcut" ? /* @__PURE__ */ jsxs(Box, { children: [
487
583
  /* @__PURE__ */ jsx(Text, {
488
584
  color: focused === 3 ? "cyan" : "gray",
489
585
  children: "Keys".padEnd(8)
490
586
  }),
491
- /* @__PURE__ */ jsx(Text, { children: draft.keys }),
492
- focused === 3 ? /* @__PURE__ */ jsx(Text, {
493
- inverse: true,
494
- children: " "
495
- }) : null,
496
- draft.keys.trim() ? /* @__PURE__ */ jsxs(Text, {
587
+ /* @__PURE__ */ jsx(CursorText, {
588
+ value: draft.keys,
589
+ pos,
590
+ width: Math.max(8, width - keysPreview.length),
591
+ focused: focused === 3
592
+ }),
593
+ keysPreview ? /* @__PURE__ */ jsx(Text, {
497
594
  color: "gray",
498
- children: [" → ", normalizeKeys(draft.keys)]
595
+ children: keysPreview
499
596
  }) : null
500
- ] }) : draft.type === "command" ? /* @__PURE__ */ jsx(Field, {
501
- label: "Command",
502
- value: `$ ${draft.command}`,
503
- focused: focused === 3
504
- }) : /* @__PURE__ */ jsxs(Box, {
597
+ ] }) : draft.type === "command" ? /* @__PURE__ */ jsxs(Box, { children: [
598
+ /* @__PURE__ */ jsx(Text, {
599
+ color: focused === 3 ? "cyan" : "gray",
600
+ children: "Command".padEnd(8)
601
+ }),
602
+ /* @__PURE__ */ jsx(Text, { children: "$ " }),
603
+ /* @__PURE__ */ jsx(CursorText, {
604
+ value: draft.command,
605
+ pos,
606
+ width: width - 2,
607
+ focused: focused === 3
608
+ })
609
+ ] }) : /* @__PURE__ */ jsxs(Box, {
505
610
  flexDirection: "column",
506
611
  children: [/* @__PURE__ */ jsx(Text, {
507
612
  color: focused === 3 ? "cyan" : "gray",
@@ -509,13 +614,21 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp })
509
614
  }), /* @__PURE__ */ jsx(StepsBuilder, {
510
615
  steps: draft.steps,
511
616
  line: draft.stepLine,
512
- active: focused === 3
617
+ active: focused === 3,
618
+ cursor: stepCursor,
619
+ grabbed,
620
+ pos,
621
+ width,
622
+ editingIndex: editingStep ?? void 0,
623
+ editBuffer
513
624
  })]
514
625
  }),
515
626
  /* @__PURE__ */ jsx(Field, {
516
627
  label: "Tags",
517
628
  value: draft.tags,
518
- focused: focused === 4
629
+ focused: focused === 4,
630
+ pos,
631
+ width
519
632
  }),
520
633
  focused === 4 && existingTags && existingTags.length > 0 ? /* @__PURE__ */ jsx(Text, {
521
634
  color: "gray",
@@ -524,7 +637,9 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp })
524
637
  /* @__PURE__ */ jsx(Field, {
525
638
  label: "Notes",
526
639
  value: draft.notes,
527
- focused: focused === 5
640
+ focused: focused === 5,
641
+ pos,
642
+ width
528
643
  })
529
644
  ]
530
645
  });
@@ -639,6 +754,170 @@ function ReviewScreen({ app, entry, targetPath, error }) {
639
754
  });
640
755
  }
641
756
  //#endregion
757
+ //#region src/tui/cursor.ts
758
+ const cp = (s) => [...s];
759
+ const isSpace = (c) => c !== void 0 && /\s/.test(c);
760
+ function build(chars, pos) {
761
+ return {
762
+ text: chars.join(""),
763
+ pos: Math.max(0, Math.min(pos, chars.length))
764
+ };
765
+ }
766
+ /** Seed a state with the cursor after the last character — the focus convention. */
767
+ function atEnd(text) {
768
+ return {
769
+ text,
770
+ pos: cp(text).length
771
+ };
772
+ }
773
+ function insert(s, chars) {
774
+ const a = cp(s.text);
775
+ const ins = cp(chars);
776
+ a.splice(s.pos, 0, ...ins);
777
+ return build(a, s.pos + ins.length);
778
+ }
779
+ function backspace(s) {
780
+ if (s.pos <= 0) return s;
781
+ const a = cp(s.text);
782
+ a.splice(s.pos - 1, 1);
783
+ return build(a, s.pos - 1);
784
+ }
785
+ function forwardDelete(s) {
786
+ const a = cp(s.text);
787
+ if (s.pos >= a.length) return s;
788
+ a.splice(s.pos, 1);
789
+ return build(a, s.pos);
790
+ }
791
+ function left(s) {
792
+ return s.pos <= 0 ? s : {
793
+ text: s.text,
794
+ pos: s.pos - 1
795
+ };
796
+ }
797
+ function right(s) {
798
+ return s.pos >= cp(s.text).length ? s : {
799
+ text: s.text,
800
+ pos: s.pos + 1
801
+ };
802
+ }
803
+ function home(s) {
804
+ return s.pos === 0 ? s : {
805
+ text: s.text,
806
+ pos: 0
807
+ };
808
+ }
809
+ function end(s) {
810
+ const len = cp(s.text).length;
811
+ return s.pos === len ? s : {
812
+ text: s.text,
813
+ pos: len
814
+ };
815
+ }
816
+ /**
817
+ * Start of the word before `pos`: skip the run of whitespace, then the run of
818
+ * non-whitespace. Mirrors readline's `backward-kill-word`, and reproduces the
819
+ * v0.2 Phase A `deleteWordBack` semantics when pos is at end-of-string.
820
+ */
821
+ function wordStartBefore(a, pos) {
822
+ let i = pos;
823
+ while (i > 0 && isSpace(a[i - 1])) i--;
824
+ while (i > 0 && !isSpace(a[i - 1])) i--;
825
+ return i;
826
+ }
827
+ /** End of the word after `pos`: skip whitespace, then non-whitespace. */
828
+ function wordEndAfter(a, pos) {
829
+ let i = pos;
830
+ while (i < a.length && isSpace(a[i])) i++;
831
+ while (i < a.length && !isSpace(a[i])) i++;
832
+ return i;
833
+ }
834
+ function wordLeft(s) {
835
+ const start = wordStartBefore(cp(s.text), s.pos);
836
+ return start === s.pos ? s : {
837
+ text: s.text,
838
+ pos: start
839
+ };
840
+ }
841
+ function wordRight(s) {
842
+ const stop = wordEndAfter(cp(s.text), s.pos);
843
+ return stop === s.pos ? s : {
844
+ text: s.text,
845
+ pos: stop
846
+ };
847
+ }
848
+ function deleteWordBack(s) {
849
+ const a = cp(s.text);
850
+ const start = wordStartBefore(a, s.pos);
851
+ if (start === s.pos) return s;
852
+ a.splice(start, s.pos - start);
853
+ return build(a, start);
854
+ }
855
+ function killToStart(s) {
856
+ if (s.pos <= 0) return s;
857
+ return build(cp(s.text).slice(s.pos), 0);
858
+ }
859
+ function killToEnd(s) {
860
+ const a = cp(s.text);
861
+ if (s.pos >= a.length) return s;
862
+ return build(a.slice(0, s.pos), s.pos);
863
+ }
864
+ const same = (a, b) => a.text === b.text && a.pos === b.pos;
865
+ function route(s, input, key, opts) {
866
+ if (key.leftArrow) return key.meta ? wordLeft(s) : left(s);
867
+ if (key.rightArrow) return key.meta ? wordRight(s) : right(s);
868
+ if (key.meta && (key.backspace || key.delete) || key.ctrl && input === "w") return deleteWordBack(s);
869
+ if (key.backspace || key.delete) return backspace(s);
870
+ if (key.ctrl && input === "u") return killToStart(s);
871
+ if (key.ctrl && input === "k") return killToEnd(s);
872
+ if (key.ctrl && input === "d") return forwardDelete(s);
873
+ if (key.ctrl && input === "a") return home(s);
874
+ if (opts.lineKeys && key.ctrl && input === "e") return end(s);
875
+ if (input && !key.ctrl && !key.meta) return insert(s, input);
876
+ return null;
877
+ }
878
+ /**
879
+ * Route a keypress to an op. Returns null when the key is not a text-editing
880
+ * key, OR when the op would leave the state unchanged — callers rely on that
881
+ * to keep their own fallback branches reachable (see AddEntryForm's
882
+ * empty-step-line backspace).
883
+ */
884
+ function applyTextKey(s, input, key, opts) {
885
+ const next = route(s, input, key, opts);
886
+ if (next === null) return null;
887
+ return same(next, s) ? null : next;
888
+ }
889
+ /**
890
+ * Rows to give the windowed result list, sized to the terminal height so the
891
+ * whole frame fits the viewport. Ink can only redraw in place while the frame
892
+ * stays within the terminal; an oversized frame duplicates on every keypress.
893
+ */
894
+ function visibleListHeight(terminalRows) {
895
+ return Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 4);
896
+ }
897
+ /**
898
+ * Integer widths for the two side-by-side panes. They MUST sum to the exact
899
+ * terminal width: two `width="50%"` siblings each round up on an odd-width
900
+ * terminal (58 + 58 = 116 at 115 cols), overflowing by a column. The terminal
901
+ * then soft-wraps the full-width rows, Ink miscounts the frame height, and its
902
+ * redraw leaves stale lines stacked on every keystroke.
903
+ */
904
+ function columnWidths(terminalCols) {
905
+ const cols = terminalCols && terminalCols > 0 ? terminalCols : 80;
906
+ const left = Math.floor(cols / 2);
907
+ return {
908
+ left,
909
+ right: cols - left
910
+ };
911
+ }
912
+ /** Columns available to the search query, after "search: " and the filter label. */
913
+ function searchFieldWidth(terminalCols, filterLabelLength) {
914
+ return Math.max(8, (terminalCols && terminalCols > 0 ? terminalCols : 80) - 8 - filterLabelLength - 1);
915
+ }
916
+ /** Columns available to a form field, after its 8-column label plus one safety column. */
917
+ function formFieldWidth(terminalCols) {
918
+ return Math.max(8, (terminalCols && terminalCols > 0 ? terminalCols : 80) - 8 - 1);
919
+ }
920
+ //#endregion
642
921
  //#region src/tui/useAddForm.ts
643
922
  const emptyDraft = {
644
923
  app: "",
@@ -656,6 +935,20 @@ const emptyDraft = {
656
935
  function parseTags(raw) {
657
936
  return raw.split(",").map((t) => t.trim()).filter(Boolean);
658
937
  }
938
+ /** Move the step at `from` to position `to`, returning a NEW array. No-op if out of range or from===to. */
939
+ function moveStep(steps, from, to) {
940
+ if (from < 0 || from >= steps.length || to < 0 || to >= steps.length || from === to) return steps;
941
+ const next = [...steps];
942
+ const [moved] = next.splice(from, 1);
943
+ if (moved === void 0) return steps;
944
+ next.splice(to, 0, moved);
945
+ return next;
946
+ }
947
+ /** Remove the step at `i`, returning a NEW array. No-op if out of range. */
948
+ function deleteStep(steps, i) {
949
+ if (i < 0 || i >= steps.length) return steps;
950
+ return steps.filter((_, idx) => idx !== i);
951
+ }
659
952
  function resolvedApp(d) {
660
953
  return (d.creatingApp ? d.newApp : d.app).trim();
661
954
  }
@@ -719,6 +1012,23 @@ function useAddForm(initial = {}) {
719
1012
  setDraft
720
1013
  };
721
1014
  }
1015
+ /**
1016
+ * The text currently being edited for a given focus index. Chooser fields
1017
+ * (0 when not creating an app, 1) have no text and return "".
1018
+ * Used to place the cursor at end-of-value whenever focus changes.
1019
+ */
1020
+ function fieldValue(d, focused) {
1021
+ if (focused === 0) return d.creatingApp ? d.newApp : "";
1022
+ if (focused === 2) return d.action;
1023
+ if (focused === 3) {
1024
+ if (d.type === "command") return d.command;
1025
+ if (d.type === "shortcut") return d.keys;
1026
+ return d.stepLine;
1027
+ }
1028
+ if (focused === 4) return d.tags;
1029
+ if (focused === 5) return d.notes;
1030
+ return "";
1031
+ }
722
1032
  //#endregion
723
1033
  //#region src/tui/AddEntryForm.tsx
724
1034
  const TYPES = [
@@ -727,29 +1037,125 @@ const TYPES = [
727
1037
  "recipe"
728
1038
  ];
729
1039
  const LAST_FIELD = 5;
730
- function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, lockedApp, title }) {
1040
+ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, initialFocus, title }) {
731
1041
  const { draft, update, setDraft } = useAddForm(initial ?? { app: apps[0] ?? "" });
732
- const [focused, setFocused] = useState(lockedApp ? 1 : 0);
733
- const [appIndex, setAppIndex] = useState(0);
1042
+ const { stdout } = useStdout();
1043
+ const [focused, setFocused] = useState(initialFocus ?? 0);
1044
+ const [pos, setPos] = useState(() => [...fieldValue(draft, initialFocus ?? 0)].length);
1045
+ const [stepCursor, setStepCursor] = useState(initial?.steps?.length ?? 0);
1046
+ const [grabbed, setGrabbed] = useState(false);
1047
+ const [editingStep, setEditingStep] = useState(null);
1048
+ const [editBuffer, setEditBuffer] = useState("");
1049
+ const [appIndex, setAppIndex] = useState(() => {
1050
+ const i = apps.indexOf(initial?.app ?? apps[0] ?? "");
1051
+ return i >= 0 ? i : 0;
1052
+ });
734
1053
  const [screen, setScreen] = useState("form");
735
1054
  const [hint, setHint] = useState("");
736
1055
  const [writeError, setWriteError] = useState("");
1056
+ const draftRef = useRef(draft);
1057
+ draftRef.current = draft;
1058
+ const posRef = useRef(pos);
1059
+ posRef.current = pos;
1060
+ const focusedRef = useRef(focused);
1061
+ focusedRef.current = focused;
1062
+ const editingStepRef = useRef(editingStep);
1063
+ editingStepRef.current = editingStep;
1064
+ const editBufferRef = useRef(editBuffer);
1065
+ editBufferRef.current = editBuffer;
1066
+ const stepCursorRef = useRef(stepCursor);
1067
+ stepCursorRef.current = stepCursor;
1068
+ const grabbedRef = useRef(grabbed);
1069
+ grabbedRef.current = grabbed;
1070
+ /**
1071
+ * Change focus AND place the cursor at the end of the newly-focused field.
1072
+ * Done here rather than in a useEffect: an effect runs after render, leaving
1073
+ * one frame drawn with the previous field's pos.
1074
+ *
1075
+ * Also synchronously clears any open step edit OR grab. ⌃N/⌃P are handled
1076
+ * above the field-3 block, so they fire even while `editingStepRef.current`
1077
+ * is set or `grabbedRef.current` is true — if a SECOND keystroke (e.g. esc)
1078
+ * arrives in the same batch right after, it must see the edit/grab as
1079
+ * already closed, not wait for the cleanup effect below (which only runs
1080
+ * after the batch is committed and rendered, i.e. too late for that second
1081
+ * keystroke).
1082
+ */
1083
+ const focusField = (f) => {
1084
+ focusedRef.current = f;
1085
+ setFocused(f);
1086
+ const next = [...fieldValue(draftRef.current, f)].length;
1087
+ posRef.current = next;
1088
+ setPos(next);
1089
+ editingStepRef.current = null;
1090
+ setEditingStep(null);
1091
+ editBufferRef.current = "";
1092
+ setEditBuffer("");
1093
+ grabbedRef.current = false;
1094
+ setGrabbed(false);
1095
+ stepCursorRef.current = draftRef.current.steps.length;
1096
+ setStepCursor(draftRef.current.steps.length);
1097
+ };
1098
+ /** Enter/exit step-edit mode, keeping the ref in sync for same-batch reads. */
1099
+ const setEditing = (step) => {
1100
+ editingStepRef.current = step;
1101
+ setEditingStep(step);
1102
+ };
1103
+ /** Move the step cursor, keeping the ref in sync for same-batch reads. */
1104
+ const setStep = (cursor) => {
1105
+ stepCursorRef.current = cursor;
1106
+ setStepCursor(cursor);
1107
+ };
1108
+ /** Toggle grab, keeping the ref in sync for same-batch reads. */
1109
+ const setGrab = (g) => {
1110
+ grabbedRef.current = g;
1111
+ setGrabbed(g);
1112
+ };
1113
+ useEffect(() => {
1114
+ if (!(focused === 3 && draft.type === "recipe")) {
1115
+ grabbedRef.current = false;
1116
+ setGrabbed(false);
1117
+ stepCursorRef.current = draft.steps.length;
1118
+ setStepCursor(draft.steps.length);
1119
+ editingStepRef.current = null;
1120
+ setEditingStep(null);
1121
+ editBufferRef.current = "";
1122
+ setEditBuffer("");
1123
+ }
1124
+ }, [
1125
+ focused,
1126
+ draft.type,
1127
+ draft.steps.length
1128
+ ]);
737
1129
  const appChoices = [...apps, "Create new app…"];
738
1130
  function commitAppSelection(index) {
739
- if (index === apps.length) update({ creatingApp: true });
740
- else update({
741
- creatingApp: false,
742
- app: apps[index] ?? ""
743
- });
1131
+ if (index === apps.length) {
1132
+ draftRef.current = {
1133
+ ...draftRef.current,
1134
+ creatingApp: true
1135
+ };
1136
+ update({ creatingApp: true });
1137
+ } else {
1138
+ const app = apps[index] ?? "";
1139
+ draftRef.current = {
1140
+ ...draftRef.current,
1141
+ creatingApp: false,
1142
+ app
1143
+ };
1144
+ update({
1145
+ creatingApp: false,
1146
+ app
1147
+ });
1148
+ }
744
1149
  }
745
1150
  function goReview() {
746
- const d = flushStep(draft);
1151
+ const d = flushStep(draftRef.current);
747
1152
  const v = validateDraft(d);
748
1153
  if (v) {
749
1154
  setHint(v);
750
1155
  return;
751
1156
  }
752
1157
  setHint("");
1158
+ draftRef.current = d;
753
1159
  setDraft(d);
754
1160
  setScreen("review");
755
1161
  }
@@ -767,10 +1173,20 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
767
1173
  }
768
1174
  return;
769
1175
  }
1176
+ if (grabbedRef.current && key.escape) return setGrab(false);
1177
+ if (editingStepRef.current !== null && key.escape) {
1178
+ setEditing(null);
1179
+ editBufferRef.current = "";
1180
+ setEditBuffer("");
1181
+ const appendEndPos = [...draftRef.current.stepLine].length;
1182
+ posRef.current = appendEndPos;
1183
+ setPos(appendEndPos);
1184
+ return;
1185
+ }
770
1186
  if (key.escape) return onCancel();
771
- if (key.ctrl && input === "n") return setFocused((f) => Math.min(f + 1, LAST_FIELD));
772
- if (key.ctrl && input === "p") return setFocused((f) => Math.max(f - 1, lockedApp ? 1 : 0));
773
- if (focused === 0) {
1187
+ if (key.ctrl && input === "n") return focusField(Math.min(focusedRef.current + 1, LAST_FIELD));
1188
+ if (key.ctrl && input === "p") return focusField(Math.max(focusedRef.current - 1, 0));
1189
+ if (focusedRef.current === 0) {
774
1190
  if (key.upArrow) {
775
1191
  const next = Math.max(appIndex - 1, 0);
776
1192
  setAppIndex(next);
@@ -781,42 +1197,187 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
781
1197
  setAppIndex(next);
782
1198
  return commitAppSelection(next);
783
1199
  }
784
- if (draft.creatingApp) {
785
- if (key.return) return setFocused(1);
786
- if (key.backspace || key.delete) return update({ newApp: draft.newApp.slice(0, -1) });
787
- if (input && !key.ctrl && !key.meta) return update({ newApp: draft.newApp + input });
1200
+ if (draftRef.current.creatingApp) {
1201
+ if (key.return) return focusField(1);
1202
+ const nextApp = applyTextKey({
1203
+ text: draftRef.current.newApp,
1204
+ pos: posRef.current
1205
+ }, input, key, { lineKeys: true });
1206
+ if (nextApp) {
1207
+ draftRef.current = {
1208
+ ...draftRef.current,
1209
+ newApp: nextApp.text
1210
+ };
1211
+ update({ newApp: nextApp.text });
1212
+ posRef.current = nextApp.pos;
1213
+ setPos(nextApp.pos);
1214
+ }
788
1215
  return;
789
1216
  }
790
- if (key.return) return setFocused(1);
1217
+ if (key.return) return focusField(1);
791
1218
  return;
792
1219
  }
793
- if (focused === 1) {
1220
+ if (focusedRef.current === 1) {
794
1221
  if (key.leftArrow || key.rightArrow || input === " ") {
795
- const idx = TYPES.indexOf(draft.type);
796
- return update({ type: TYPES[key.leftArrow ? (idx + TYPES.length - 1) % TYPES.length : (idx + 1) % TYPES.length] });
1222
+ const idx = TYPES.indexOf(draftRef.current.type);
1223
+ const nextType = TYPES[key.leftArrow ? (idx + TYPES.length - 1) % TYPES.length : (idx + 1) % TYPES.length] ?? "shortcut";
1224
+ draftRef.current = {
1225
+ ...draftRef.current,
1226
+ type: nextType
1227
+ };
1228
+ return update({ type: nextType });
797
1229
  }
798
- if (key.return) return setFocused(2);
1230
+ if (key.return) return focusField(2);
799
1231
  return;
800
1232
  }
801
- if (focused === 3 && draft.type === "recipe") {
802
- if (key.return) {
803
- if (draft.stepLine.trim()) update({
804
- steps: [...draft.steps, draft.stepLine.trim()],
805
- stepLine: ""
806
- });
1233
+ if (focusedRef.current === 3 && draftRef.current.type === "recipe") {
1234
+ const steps = draftRef.current.steps;
1235
+ const onAppendLine = stepCursorRef.current >= steps.length;
1236
+ if (grabbedRef.current) {
1237
+ if (key.upArrow && stepCursorRef.current > 0) {
1238
+ const nextSteps = moveStep(steps, stepCursorRef.current, stepCursorRef.current - 1);
1239
+ draftRef.current = {
1240
+ ...draftRef.current,
1241
+ steps: nextSteps
1242
+ };
1243
+ update({ steps: nextSteps });
1244
+ return setStep(stepCursorRef.current - 1);
1245
+ }
1246
+ if (key.downArrow && stepCursorRef.current < steps.length - 1) {
1247
+ const nextSteps = moveStep(steps, stepCursorRef.current, stepCursorRef.current + 1);
1248
+ draftRef.current = {
1249
+ ...draftRef.current,
1250
+ steps: nextSteps
1251
+ };
1252
+ update({ steps: nextSteps });
1253
+ return setStep(stepCursorRef.current + 1);
1254
+ }
1255
+ if (key.return || input === " " || key.escape) return setGrab(false);
1256
+ return;
1257
+ }
1258
+ if (editingStepRef.current !== null) {
1259
+ const idx = editingStepRef.current;
1260
+ if (key.return) {
1261
+ const trimmed = editBufferRef.current.trim();
1262
+ if (trimmed) {
1263
+ const next = [...steps];
1264
+ next[idx] = trimmed;
1265
+ draftRef.current = {
1266
+ ...draftRef.current,
1267
+ steps: next
1268
+ };
1269
+ update({ steps: next });
1270
+ }
1271
+ setEditing(null);
1272
+ editBufferRef.current = "";
1273
+ setEditBuffer("");
1274
+ const appendEndPos = [...draftRef.current.stepLine].length;
1275
+ posRef.current = appendEndPos;
1276
+ setPos(appendEndPos);
1277
+ setStep(steps.length);
1278
+ return;
1279
+ }
1280
+ const nextEdit = applyTextKey({
1281
+ text: editBufferRef.current,
1282
+ pos: posRef.current
1283
+ }, input, key, { lineKeys: true });
1284
+ if (nextEdit) {
1285
+ editBufferRef.current = nextEdit.text;
1286
+ setEditBuffer(nextEdit.text);
1287
+ posRef.current = nextEdit.pos;
1288
+ setPos(nextEdit.pos);
1289
+ return;
1290
+ }
1291
+ return;
1292
+ }
1293
+ if (onAppendLine) {
1294
+ if ((key.backspace || key.delete) && !draftRef.current.stepLine) {
1295
+ if (steps.length) return setStep(steps.length - 1);
1296
+ return;
1297
+ }
1298
+ if (key.upArrow && !draftRef.current.stepLine) {
1299
+ if (steps.length) return setStep(steps.length - 1);
1300
+ return;
1301
+ }
1302
+ if (key.return) {
1303
+ const trimmed = draftRef.current.stepLine.trim();
1304
+ if (trimmed) {
1305
+ const nextSteps = [...steps, trimmed];
1306
+ draftRef.current = {
1307
+ ...draftRef.current,
1308
+ steps: nextSteps,
1309
+ stepLine: ""
1310
+ };
1311
+ update({
1312
+ steps: nextSteps,
1313
+ stepLine: ""
1314
+ });
1315
+ setStep(steps.length + 1);
1316
+ posRef.current = 0;
1317
+ setPos(0);
1318
+ }
1319
+ return;
1320
+ }
1321
+ const nextLine = applyTextKey({
1322
+ text: draftRef.current.stepLine,
1323
+ pos: posRef.current
1324
+ }, input, key, { lineKeys: true });
1325
+ if (nextLine) {
1326
+ draftRef.current = {
1327
+ ...draftRef.current,
1328
+ stepLine: nextLine.text
1329
+ };
1330
+ update({ stepLine: nextLine.text });
1331
+ posRef.current = nextLine.pos;
1332
+ setPos(nextLine.pos);
1333
+ return;
1334
+ }
1335
+ if (key.upArrow) {
1336
+ if (steps.length) return setStep(steps.length - 1);
1337
+ return;
1338
+ }
1339
+ return;
1340
+ }
1341
+ if (key.ctrl && input === "e") {
1342
+ const target = steps[stepCursorRef.current];
1343
+ if (target === void 0) return;
1344
+ setEditing(stepCursorRef.current);
1345
+ editBufferRef.current = target;
1346
+ setEditBuffer(target);
1347
+ const endPos = [...target].length;
1348
+ posRef.current = endPos;
1349
+ setPos(endPos);
807
1350
  return;
808
1351
  }
1352
+ if (key.upArrow) return setStep(Math.max(0, stepCursorRef.current - 1));
1353
+ if (key.downArrow) return setStep(stepCursorRef.current + 1);
1354
+ if (key.return || input === " ") return setGrab(true);
809
1355
  if (key.backspace || key.delete) {
810
- if (draft.stepLine) return update({ stepLine: draft.stepLine.slice(0, -1) });
811
- return update({ steps: draft.steps.slice(0, -1) });
1356
+ const next = deleteStep(steps, stepCursorRef.current);
1357
+ draftRef.current = {
1358
+ ...draftRef.current,
1359
+ steps: next
1360
+ };
1361
+ update({ steps: next });
1362
+ return setStep(Math.min(stepCursorRef.current, next.length));
812
1363
  }
813
- if (input && !key.ctrl && !key.meta) return update({ stepLine: draft.stepLine + input });
814
1364
  return;
815
1365
  }
816
1366
  if (key.return) return goReview();
817
- const fieldKey = focused === 2 ? "action" : focused === 3 ? draft.type === "command" ? "command" : "keys" : focused === 4 ? "tags" : "notes";
818
- if (key.backspace || key.delete) return update({ [fieldKey]: draft[fieldKey].slice(0, -1) });
819
- if (input && !key.ctrl && !key.meta) return update({ [fieldKey]: draft[fieldKey] + input });
1367
+ const fieldKey = focusedRef.current === 2 ? "action" : focusedRef.current === 3 ? draftRef.current.type === "command" ? "command" : "keys" : focusedRef.current === 4 ? "tags" : "notes";
1368
+ const nextText = applyTextKey({
1369
+ text: draftRef.current[fieldKey],
1370
+ pos: posRef.current
1371
+ }, input, key, { lineKeys: true });
1372
+ if (nextText) {
1373
+ draftRef.current = {
1374
+ ...draftRef.current,
1375
+ [fieldKey]: nextText.text
1376
+ };
1377
+ update({ [fieldKey]: nextText.text });
1378
+ posRef.current = nextText.pos;
1379
+ setPos(nextText.pos);
1380
+ }
820
1381
  });
821
1382
  const enterHint = focused <= 1 ? "next" : focused === 3 && draft.type === "recipe" ? "add step" : "review";
822
1383
  if (screen === "review") return /* @__PURE__ */ jsx(ReviewScreen, {
@@ -839,8 +1400,13 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
839
1400
  apps,
840
1401
  appIndex,
841
1402
  focused,
1403
+ pos,
1404
+ width: formFieldWidth(stdout?.columns),
842
1405
  existingTags,
843
- lockedApp
1406
+ stepCursor,
1407
+ grabbed,
1408
+ editingStep,
1409
+ editBuffer
844
1410
  })
845
1411
  }),
846
1412
  /* @__PURE__ */ jsxs(Box, {
@@ -1036,6 +1602,9 @@ function FilterPicker({ apps, onSelect, onCancel, height = 16, width = 40 }) {
1036
1602
  }
1037
1603
  //#endregion
1038
1604
  //#region src/tui/Footer.tsx
1605
+ function hintLine(filterActive, resultCount) {
1606
+ return `↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⌃F filter ⌃S star ${filterActive ? "⎋ clear filter" : "⎋ quit"} (${resultCount})`;
1607
+ }
1039
1608
  function Footer({ flash, errorCount, resultCount, confirm, filterActive = false }) {
1040
1609
  if (confirm) return /* @__PURE__ */ jsxs(Box, {
1041
1610
  marginTop: 1,
@@ -1056,7 +1625,7 @@ function Footer({ flash, errorCount, resultCount, confirm, filterActive = false
1056
1625
  children: [/* @__PURE__ */ jsx(Text, {
1057
1626
  color: "gray",
1058
1627
  wrap: "truncate-end",
1059
- children: `↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⌃F filter ⌃S star ${filterActive ? "⎋ clear filter" : "⎋ quit"} ⌃U clear (${resultCount})`
1628
+ children: hintLine(filterActive, resultCount)
1060
1629
  }), flash ? /* @__PURE__ */ jsx(Text, {
1061
1630
  color: "green",
1062
1631
  wrap: "truncate-end",
@@ -1162,22 +1731,20 @@ function ResultList({ results, selected, query, height = 12, width = "50%", favo
1162
1731
  }
1163
1732
  //#endregion
1164
1733
  //#region src/tui/SearchInput.tsx
1165
- function SearchInput({ query, filterLabel }) {
1734
+ function SearchInput({ query, pos, width, filterLabel }) {
1166
1735
  return /* @__PURE__ */ jsxs(Box, {
1167
1736
  justifyContent: "space-between",
1168
1737
  children: [/* @__PURE__ */ jsxs(Text, {
1169
1738
  wrap: "truncate-end",
1170
- children: [
1171
- /* @__PURE__ */ jsx(Text, {
1172
- color: "cyan",
1173
- children: "search: "
1174
- }),
1175
- /* @__PURE__ */ jsx(Text, { children: query }),
1176
- /* @__PURE__ */ jsx(Text, {
1177
- inverse: true,
1178
- children: " "
1179
- })
1180
- ]
1739
+ children: [/* @__PURE__ */ jsx(Text, {
1740
+ color: "cyan",
1741
+ children: "search: "
1742
+ }), /* @__PURE__ */ jsx(CursorText, {
1743
+ value: query,
1744
+ pos,
1745
+ width,
1746
+ focused: true
1747
+ })]
1181
1748
  }), filterLabel ? /* @__PURE__ */ jsx(Text, {
1182
1749
  color: "gray",
1183
1750
  wrap: "truncate-end",
@@ -1186,43 +1753,8 @@ function SearchInput({ query, filterLabel }) {
1186
1753
  });
1187
1754
  }
1188
1755
  //#endregion
1189
- //#region src/tui/input.ts
1190
- /**
1191
- * Drop the run of trailing whitespace, then the run of trailing non-whitespace.
1192
- * Mirrors readline's `unix-word-rubout` / `backward-kill-word` behavior so a
1193
- * single press eats both the spaces and the word immediately before them.
1194
- */
1195
- function deleteWordBack(query) {
1196
- const trimmed = query.replace(/\s+$/, "");
1197
- const lastSpace = trimmed.lastIndexOf(" ");
1198
- if (lastSpace === -1) return "";
1199
- return trimmed.slice(0, lastSpace + 1);
1200
- }
1201
- /**
1202
- * Rows to give the windowed result list, sized to the terminal height so the
1203
- * whole frame fits the viewport. Ink can only redraw in place while the frame
1204
- * stays within the terminal; an oversized frame duplicates on every keypress.
1205
- */
1206
- function visibleListHeight(terminalRows) {
1207
- return Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 4);
1208
- }
1209
- /**
1210
- * Integer widths for the two side-by-side panes. They MUST sum to the exact
1211
- * terminal width: two `width="50%"` siblings each round up on an odd-width
1212
- * terminal (58 + 58 = 116 at 115 cols), overflowing by a column. The terminal
1213
- * then soft-wraps the full-width rows, Ink miscounts the frame height, and its
1214
- * redraw leaves stale lines stacked on every keystroke.
1215
- */
1216
- function columnWidths(terminalCols) {
1217
- const cols = terminalCols && terminalCols > 0 ? terminalCols : 80;
1218
- const left = Math.floor(cols / 2);
1219
- return {
1220
- left,
1221
- right: cols - left
1222
- };
1223
- }
1224
- //#endregion
1225
1756
  //#region src/tui/App.tsx
1757
+ const sameApp = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
1226
1758
  function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboard }) {
1227
1759
  const { exit } = useApp();
1228
1760
  const { stdout } = useStdout();
@@ -1232,9 +1764,12 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1232
1764
  const [favorites, setFavorites] = useState(() => dataDir ? loadFavorites(dataDir) : /* @__PURE__ */ new Set());
1233
1765
  const [editTarget, setEditTarget] = useState(null);
1234
1766
  const [pendingDelete, setPendingDelete] = useState(null);
1235
- const [query, setQuery] = useState("");
1767
+ const [query, setQuery] = useState(() => atEnd(""));
1236
1768
  const [selected, setSelected] = useState(0);
1237
1769
  const [flash, setFlash] = useState("");
1770
+ const queryRef = useRef(query);
1771
+ queryRef.current = query;
1772
+ const currentRef = useRef(void 0);
1238
1773
  const reload = useCallback(() => {
1239
1774
  if (dataDir) setEntries(loadEntries(dataDir).entries);
1240
1775
  }, [dataDir]);
@@ -1247,9 +1782,10 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1247
1782
  filter,
1248
1783
  favorites
1249
1784
  ]);
1250
- const results = useMemo(() => search(scoped, query), [scoped, query]);
1785
+ const results = useMemo(() => search(scoped, query.text), [scoped, query.text]);
1251
1786
  const sel = results.length ? Math.min(selected, results.length - 1) : 0;
1252
1787
  const current = results[sel];
1788
+ currentRef.current = current;
1253
1789
  const listHeight = visibleListHeight(stdout?.rows);
1254
1790
  const { left, right } = columnWidths(stdout?.columns);
1255
1791
  useInput((input, key) => {
@@ -1270,6 +1806,7 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1270
1806
  return;
1271
1807
  }
1272
1808
  if (!key.return && flash) setFlash("");
1809
+ const curEntry = currentRef.current;
1273
1810
  if (key.escape) {
1274
1811
  if (filter.type !== "all") {
1275
1812
  setFilter({ type: "all" });
@@ -1283,23 +1820,23 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1283
1820
  setMode("add");
1284
1821
  return;
1285
1822
  }
1286
- if (dataDir && current && key.ctrl && input === "e") {
1287
- setEditTarget(current);
1823
+ if (dataDir && curEntry && key.ctrl && input === "e") {
1824
+ setEditTarget(curEntry);
1288
1825
  setMode("edit");
1289
1826
  return;
1290
1827
  }
1291
- if (dataDir && current && key.ctrl && input === "x") {
1292
- setPendingDelete(current);
1828
+ if (dataDir && curEntry && key.ctrl && input === "x") {
1829
+ setPendingDelete(curEntry);
1293
1830
  return;
1294
1831
  }
1295
1832
  if (key.ctrl && input === "f") {
1296
1833
  setMode("filter");
1297
1834
  return;
1298
1835
  }
1299
- if (dataDir && current && key.ctrl && input === "s") {
1300
- const next = toggleFavorite(dataDir, current.app, current.action);
1836
+ if (dataDir && curEntry && key.ctrl && input === "s") {
1837
+ const next = toggleFavorite(dataDir, curEntry.app, curEntry.action);
1301
1838
  setFavorites(next);
1302
- setFlash(next.has(favKey(current.app, current.action)) ? "★ starred" : "☆ unstarred");
1839
+ setFlash(next.has(favKey(curEntry.app, curEntry.action)) ? "★ starred" : "☆ unstarred");
1303
1840
  return;
1304
1841
  }
1305
1842
  if (key.downArrow || key.ctrl && input === "n") {
@@ -1311,28 +1848,17 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1311
1848
  return;
1312
1849
  }
1313
1850
  if (key.return) {
1314
- if (current) setFlash(onCopy(current.keys ?? current.command ?? `${current.app}: ${current.action}`) ? "✓ copied!" : "✗ copy failed");
1315
- return;
1316
- }
1317
- if (key.meta && (key.backspace || key.delete) || key.ctrl && input === "w") {
1318
- setQuery(deleteWordBack);
1319
- setSelected(0);
1320
- return;
1321
- }
1322
- if (key.ctrl && input === "u") {
1323
- setQuery("");
1324
- setSelected(0);
1851
+ if (curEntry) setFlash(onCopy(curEntry.keys ?? curEntry.command ?? `${curEntry.app}: ${curEntry.action}`) ? "✓ copied!" : "✗ copy failed");
1325
1852
  return;
1326
1853
  }
1327
- if (key.backspace || key.delete) {
1328
- setQuery((q) => q.slice(0, -1));
1329
- setSelected(0);
1854
+ const cur = queryRef.current;
1855
+ const next = applyTextKey(cur, input, key, { lineKeys: false });
1856
+ if (next) {
1857
+ if (next.text !== cur.text) setSelected(0);
1858
+ queryRef.current = next;
1859
+ setQuery(next);
1330
1860
  return;
1331
1861
  }
1332
- if (input && !key.ctrl && !key.meta) {
1333
- setQuery((q) => q + input);
1334
- setSelected(0);
1335
- }
1336
1862
  }, { isActive: mode === "search" });
1337
1863
  const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1338
1864
  if (mode === "filter") return /* @__PURE__ */ jsx(FilterPicker, {
@@ -1364,14 +1890,14 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1364
1890
  if (mode === "edit" && dataDir && editTarget) return /* @__PURE__ */ jsx(AddEntryForm, {
1365
1891
  apps: listApps(dataDir),
1366
1892
  existingTags,
1367
- lockedApp: editTarget.app,
1893
+ initialFocus: 1,
1368
1894
  initial: entryToDraft(editTarget.app, editTarget),
1369
1895
  title: `Edit entry — ${editTarget.app}`,
1370
- resolveTarget: () => ({
1896
+ resolveTarget: (app) => sameApp(app, editTarget.app) ? {
1371
1897
  file: editTarget.file,
1372
1898
  created: false
1373
- }),
1374
- onSubmit: (_app, entry) => editEntry(dataDir, editTarget.file, editTarget.index, entry, editTarget.action),
1899
+ } : resolveTargetFile(dataDir, app),
1900
+ onSubmit: (app, entry) => sameApp(app, editTarget.app) ? editEntry(dataDir, editTarget.file, editTarget.index, entry, editTarget.action) : moveEntry(dataDir, editTarget.file, editTarget.index, editTarget.action, app, entry),
1375
1901
  onComplete: (result) => {
1376
1902
  if (result.ok) {
1377
1903
  reload();
@@ -1386,17 +1912,20 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1386
1912
  setEditTarget(null);
1387
1913
  }
1388
1914
  });
1915
+ const filterLabelText = filter.type === "app" ? `(filter: ${filter.app})` : filter.type === "favorites" ? "(★ Favorites)" : void 0;
1389
1916
  return /* @__PURE__ */ jsxs(Box, {
1390
1917
  flexDirection: "column",
1391
1918
  children: [
1392
1919
  /* @__PURE__ */ jsx(SearchInput, {
1393
- query,
1394
- filterLabel: filter.type === "app" ? `(filter: ${filter.app})` : filter.type === "favorites" ? "(★ Favorites)" : void 0
1920
+ query: query.text,
1921
+ pos: query.pos,
1922
+ width: searchFieldWidth(stdout?.columns, filterLabelText?.length ?? 0),
1923
+ filterLabel: filterLabelText
1395
1924
  }),
1396
1925
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(ResultList, {
1397
1926
  results,
1398
1927
  selected: sel,
1399
- query,
1928
+ query: query.text,
1400
1929
  height: listHeight,
1401
1930
  width: left,
1402
1931
  favorites,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arthony/keybook",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -65,6 +65,7 @@
65
65
  "test:watch": "vitest",
66
66
  "typecheck": "tsc --noEmit",
67
67
  "lint": "biome check .",
68
- "format": "biome format --write ."
68
+ "format": "biome format --write .",
69
+ "bump-tap": "./scripts/bump-tap.sh"
69
70
  }
70
71
  }