@arthony/keybook 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +22 -3
  2. package/dist/cli.js +656 -190
  3. package/package.json +21 -33
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, useEffect, 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$/;
@@ -428,38 +428,106 @@ function runAdd(dir, draft) {
428
428
  };
429
429
  }
430
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
431
483
  //#region src/tui/StepsBuilder.tsx
432
- function StepsBuilder({ steps, line, active, cursor, grabbed = false }) {
484
+ function StepsBuilder({ steps, line, active, cursor, grabbed = false, pos, width, editingIndex, editBuffer }) {
433
485
  const cur = cursor ?? steps.length;
434
486
  const onAppendLine = cur >= steps.length;
435
- const hint = 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";
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";
436
490
  return /* @__PURE__ */ jsxs(Box, {
437
491
  flexDirection: "column",
438
492
  children: [
439
- steps.map((s, i) => {
440
- return /* @__PURE__ */ jsxs(Text, { children: [
441
- active && cur === i ? grabbed ? "⇅ " : "> " : " ",
493
+ steps.map((s, i) => i === editingIndex ? /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, {
494
+ color: "cyan",
495
+ children: [
496
+ " ",
442
497
  i + 1,
443
- ". ",
444
- s
445
- ] }, i);
446
- }),
447
- /* @__PURE__ */ jsxs(Box, { children: [
448
- /* @__PURE__ */ jsxs(Text, {
449
- color: active && onAppendLine ? "cyan" : "gray",
450
- children: [
451
- " ",
452
- steps.length + 1,
453
- ".",
454
- " "
455
- ]
456
- }),
457
- /* @__PURE__ */ jsx(Text, { children: line }),
458
- active && onAppendLine ? /* @__PURE__ */ jsx(Text, {
459
- inverse: true,
460
- children: " "
461
- }) : null
462
- ] }),
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 ? "⇅ " : "> " : " ",
508
+ i + 1,
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
+ })] }),
463
531
  active ? /* @__PURE__ */ jsx(Text, {
464
532
  color: "gray",
465
533
  children: hint
@@ -474,31 +542,32 @@ const TYPES$1 = [
474
542
  "command",
475
543
  "recipe"
476
544
  ];
477
- function Field({ label, value, focused }) {
478
- return /* @__PURE__ */ jsxs(Box, { children: [
479
- /* @__PURE__ */ jsx(Text, {
480
- color: focused ? "cyan" : "gray",
481
- children: label.padEnd(8)
482
- }),
483
- /* @__PURE__ */ jsx(Text, { children: value }),
484
- focused ? /* @__PURE__ */ jsx(Text, {
485
- inverse: true,
486
- children: " "
487
- }) : null
488
- ] });
489
- }
490
- function FormFields({ draft, apps, appIndex, focused, existingTags, stepCursor, grabbed }) {
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 }) {
491
557
  const appChoices = [...apps, "Create new app…"];
558
+ const keysPreview = draft.keys.trim() ? ` → ${normalizeKeys(draft.keys)}` : "";
492
559
  return /* @__PURE__ */ jsxs(Box, {
493
560
  flexDirection: "column",
494
561
  children: [
495
562
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
496
563
  color: focused === 0 ? "cyan" : "gray",
497
564
  children: "App".padEnd(8)
498
- }), draft.creatingApp ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: draft.newApp }), focused === 0 ? /* @__PURE__ */ jsx(Text, {
499
- inverse: true,
500
- children: " "
501
- }) : 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 ? " (↑/↓)" : ""] })] }),
502
571
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
503
572
  color: focused === 1 ? "cyan" : "gray",
504
573
  children: "Type".padEnd(8)
@@ -506,27 +575,38 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, stepCursor,
506
575
  /* @__PURE__ */ jsx(Field, {
507
576
  label: "Action",
508
577
  value: draft.action,
509
- focused: focused === 2
578
+ focused: focused === 2,
579
+ pos,
580
+ width
510
581
  }),
511
582
  draft.type === "shortcut" ? /* @__PURE__ */ jsxs(Box, { children: [
512
583
  /* @__PURE__ */ jsx(Text, {
513
584
  color: focused === 3 ? "cyan" : "gray",
514
585
  children: "Keys".padEnd(8)
515
586
  }),
516
- /* @__PURE__ */ jsx(Text, { children: draft.keys }),
517
- focused === 3 ? /* @__PURE__ */ jsx(Text, {
518
- inverse: true,
519
- children: " "
520
- }) : null,
521
- 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, {
522
594
  color: "gray",
523
- children: [" → ", normalizeKeys(draft.keys)]
595
+ children: keysPreview
524
596
  }) : null
525
- ] }) : draft.type === "command" ? /* @__PURE__ */ jsx(Field, {
526
- label: "Command",
527
- value: `$ ${draft.command}`,
528
- focused: focused === 3
529
- }) : /* @__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, {
530
610
  flexDirection: "column",
531
611
  children: [/* @__PURE__ */ jsx(Text, {
532
612
  color: focused === 3 ? "cyan" : "gray",
@@ -536,13 +616,19 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, stepCursor,
536
616
  line: draft.stepLine,
537
617
  active: focused === 3,
538
618
  cursor: stepCursor,
539
- grabbed
619
+ grabbed,
620
+ pos,
621
+ width,
622
+ editingIndex: editingStep ?? void 0,
623
+ editBuffer
540
624
  })]
541
625
  }),
542
626
  /* @__PURE__ */ jsx(Field, {
543
627
  label: "Tags",
544
628
  value: draft.tags,
545
- focused: focused === 4
629
+ focused: focused === 4,
630
+ pos,
631
+ width
546
632
  }),
547
633
  focused === 4 && existingTags && existingTags.length > 0 ? /* @__PURE__ */ jsx(Text, {
548
634
  color: "gray",
@@ -551,7 +637,9 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, stepCursor,
551
637
  /* @__PURE__ */ jsx(Field, {
552
638
  label: "Notes",
553
639
  value: draft.notes,
554
- focused: focused === 5
640
+ focused: focused === 5,
641
+ pos,
642
+ width
555
643
  })
556
644
  ]
557
645
  });
@@ -666,6 +754,170 @@ function ReviewScreen({ app, entry, targetPath, error }) {
666
754
  });
667
755
  }
668
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
669
921
  //#region src/tui/useAddForm.ts
670
922
  const emptyDraft = {
671
923
  app: "",
@@ -760,6 +1012,52 @@ function useAddForm(initial = {}) {
760
1012
  setDraft
761
1013
  };
762
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
+ }
1032
+ //#endregion
1033
+ //#region src/tui/useTerminalSize.ts
1034
+ /**
1035
+ * The terminal's current size, re-read whenever stdout fires `resize`.
1036
+ *
1037
+ * Ink reacts to a resize by re-running Yoga layout and repainting, but it
1038
+ * never re-renders React, so any width or height computed from
1039
+ * `stdout.columns`/`stdout.rows` during render would stay frozen at the last
1040
+ * keystroke. Holding the size in state turns a resize into a render.
1041
+ */
1042
+ function useTerminalSize() {
1043
+ const { stdout } = useStdout();
1044
+ const [size, setSize] = useState(() => ({
1045
+ columns: stdout?.columns,
1046
+ rows: stdout?.rows
1047
+ }));
1048
+ useEffect(() => {
1049
+ if (!stdout) return;
1050
+ const onResize = () => setSize({
1051
+ columns: stdout.columns,
1052
+ rows: stdout.rows
1053
+ });
1054
+ stdout.on("resize", onResize);
1055
+ return () => {
1056
+ stdout.off("resize", onResize);
1057
+ };
1058
+ }, [stdout]);
1059
+ return size;
1060
+ }
763
1061
  //#endregion
764
1062
  //#region src/tui/AddEntryForm.tsx
765
1063
  const TYPES = [
@@ -770,9 +1068,13 @@ const TYPES = [
770
1068
  const LAST_FIELD = 5;
771
1069
  function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, initialFocus, title }) {
772
1070
  const { draft, update, setDraft } = useAddForm(initial ?? { app: apps[0] ?? "" });
1071
+ const { columns } = useTerminalSize();
773
1072
  const [focused, setFocused] = useState(initialFocus ?? 0);
1073
+ const [pos, setPos] = useState(() => [...fieldValue(draft, initialFocus ?? 0)].length);
774
1074
  const [stepCursor, setStepCursor] = useState(initial?.steps?.length ?? 0);
775
1075
  const [grabbed, setGrabbed] = useState(false);
1076
+ const [editingStep, setEditingStep] = useState(null);
1077
+ const [editBuffer, setEditBuffer] = useState("");
776
1078
  const [appIndex, setAppIndex] = useState(() => {
777
1079
  const i = apps.indexOf(initial?.app ?? apps[0] ?? "");
778
1080
  return i >= 0 ? i : 0;
@@ -780,10 +1082,73 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
780
1082
  const [screen, setScreen] = useState("form");
781
1083
  const [hint, setHint] = useState("");
782
1084
  const [writeError, setWriteError] = useState("");
1085
+ const draftRef = useRef(draft);
1086
+ draftRef.current = draft;
1087
+ const posRef = useRef(pos);
1088
+ posRef.current = pos;
1089
+ const focusedRef = useRef(focused);
1090
+ focusedRef.current = focused;
1091
+ const editingStepRef = useRef(editingStep);
1092
+ editingStepRef.current = editingStep;
1093
+ const editBufferRef = useRef(editBuffer);
1094
+ editBufferRef.current = editBuffer;
1095
+ const stepCursorRef = useRef(stepCursor);
1096
+ stepCursorRef.current = stepCursor;
1097
+ const grabbedRef = useRef(grabbed);
1098
+ grabbedRef.current = grabbed;
1099
+ /**
1100
+ * Change focus AND place the cursor at the end of the newly-focused field.
1101
+ * Done here rather than in a useEffect: an effect runs after render, leaving
1102
+ * one frame drawn with the previous field's pos.
1103
+ *
1104
+ * Also synchronously clears any open step edit OR grab. ⌃N/⌃P are handled
1105
+ * above the field-3 block, so they fire even while `editingStepRef.current`
1106
+ * is set or `grabbedRef.current` is true — if a SECOND keystroke (e.g. esc)
1107
+ * arrives in the same batch right after, it must see the edit/grab as
1108
+ * already closed, not wait for the cleanup effect below (which only runs
1109
+ * after the batch is committed and rendered, i.e. too late for that second
1110
+ * keystroke).
1111
+ */
1112
+ const focusField = (f) => {
1113
+ focusedRef.current = f;
1114
+ setFocused(f);
1115
+ const next = [...fieldValue(draftRef.current, f)].length;
1116
+ posRef.current = next;
1117
+ setPos(next);
1118
+ editingStepRef.current = null;
1119
+ setEditingStep(null);
1120
+ editBufferRef.current = "";
1121
+ setEditBuffer("");
1122
+ grabbedRef.current = false;
1123
+ setGrabbed(false);
1124
+ stepCursorRef.current = draftRef.current.steps.length;
1125
+ setStepCursor(draftRef.current.steps.length);
1126
+ };
1127
+ /** Enter/exit step-edit mode, keeping the ref in sync for same-batch reads. */
1128
+ const setEditing = (step) => {
1129
+ editingStepRef.current = step;
1130
+ setEditingStep(step);
1131
+ };
1132
+ /** Move the step cursor, keeping the ref in sync for same-batch reads. */
1133
+ const setStep = (cursor) => {
1134
+ stepCursorRef.current = cursor;
1135
+ setStepCursor(cursor);
1136
+ };
1137
+ /** Toggle grab, keeping the ref in sync for same-batch reads. */
1138
+ const setGrab = (g) => {
1139
+ grabbedRef.current = g;
1140
+ setGrabbed(g);
1141
+ };
783
1142
  useEffect(() => {
784
1143
  if (!(focused === 3 && draft.type === "recipe")) {
1144
+ grabbedRef.current = false;
785
1145
  setGrabbed(false);
1146
+ stepCursorRef.current = draft.steps.length;
786
1147
  setStepCursor(draft.steps.length);
1148
+ editingStepRef.current = null;
1149
+ setEditingStep(null);
1150
+ editBufferRef.current = "";
1151
+ setEditBuffer("");
787
1152
  }
788
1153
  }, [
789
1154
  focused,
@@ -792,20 +1157,34 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
792
1157
  ]);
793
1158
  const appChoices = [...apps, "Create new app…"];
794
1159
  function commitAppSelection(index) {
795
- if (index === apps.length) update({ creatingApp: true });
796
- else update({
797
- creatingApp: false,
798
- app: apps[index] ?? ""
799
- });
1160
+ if (index === apps.length) {
1161
+ draftRef.current = {
1162
+ ...draftRef.current,
1163
+ creatingApp: true
1164
+ };
1165
+ update({ creatingApp: true });
1166
+ } else {
1167
+ const app = apps[index] ?? "";
1168
+ draftRef.current = {
1169
+ ...draftRef.current,
1170
+ creatingApp: false,
1171
+ app
1172
+ };
1173
+ update({
1174
+ creatingApp: false,
1175
+ app
1176
+ });
1177
+ }
800
1178
  }
801
1179
  function goReview() {
802
- const d = flushStep(draft);
1180
+ const d = flushStep(draftRef.current);
803
1181
  const v = validateDraft(d);
804
1182
  if (v) {
805
1183
  setHint(v);
806
1184
  return;
807
1185
  }
808
1186
  setHint("");
1187
+ draftRef.current = d;
809
1188
  setDraft(d);
810
1189
  setScreen("review");
811
1190
  }
@@ -823,11 +1202,20 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
823
1202
  }
824
1203
  return;
825
1204
  }
826
- if (grabbed && key.escape) return setGrabbed(false);
1205
+ if (grabbedRef.current && key.escape) return setGrab(false);
1206
+ if (editingStepRef.current !== null && key.escape) {
1207
+ setEditing(null);
1208
+ editBufferRef.current = "";
1209
+ setEditBuffer("");
1210
+ const appendEndPos = [...draftRef.current.stepLine].length;
1211
+ posRef.current = appendEndPos;
1212
+ setPos(appendEndPos);
1213
+ return;
1214
+ }
827
1215
  if (key.escape) return onCancel();
828
- if (key.ctrl && input === "n") return setFocused((f) => Math.min(f + 1, LAST_FIELD));
829
- if (key.ctrl && input === "p") return setFocused((f) => Math.max(f - 1, 0));
830
- if (focused === 0) {
1216
+ if (key.ctrl && input === "n") return focusField(Math.min(focusedRef.current + 1, LAST_FIELD));
1217
+ if (key.ctrl && input === "p") return focusField(Math.max(focusedRef.current - 1, 0));
1218
+ if (focusedRef.current === 0) {
831
1219
  if (key.upArrow) {
832
1220
  const next = Math.max(appIndex - 1, 0);
833
1221
  setAppIndex(next);
@@ -838,75 +1226,187 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
838
1226
  setAppIndex(next);
839
1227
  return commitAppSelection(next);
840
1228
  }
841
- if (draft.creatingApp) {
842
- if (key.return) return setFocused(1);
843
- if (key.backspace || key.delete) return update({ newApp: draft.newApp.slice(0, -1) });
844
- if (input && !key.ctrl && !key.meta) return update({ newApp: draft.newApp + input });
1229
+ if (draftRef.current.creatingApp) {
1230
+ if (key.return) return focusField(1);
1231
+ const nextApp = applyTextKey({
1232
+ text: draftRef.current.newApp,
1233
+ pos: posRef.current
1234
+ }, input, key, { lineKeys: true });
1235
+ if (nextApp) {
1236
+ draftRef.current = {
1237
+ ...draftRef.current,
1238
+ newApp: nextApp.text
1239
+ };
1240
+ update({ newApp: nextApp.text });
1241
+ posRef.current = nextApp.pos;
1242
+ setPos(nextApp.pos);
1243
+ }
845
1244
  return;
846
1245
  }
847
- if (key.return) return setFocused(1);
1246
+ if (key.return) return focusField(1);
848
1247
  return;
849
1248
  }
850
- if (focused === 1) {
1249
+ if (focusedRef.current === 1) {
851
1250
  if (key.leftArrow || key.rightArrow || input === " ") {
852
- const idx = TYPES.indexOf(draft.type);
853
- return update({ type: TYPES[key.leftArrow ? (idx + TYPES.length - 1) % TYPES.length : (idx + 1) % TYPES.length] });
1251
+ const idx = TYPES.indexOf(draftRef.current.type);
1252
+ const nextType = TYPES[key.leftArrow ? (idx + TYPES.length - 1) % TYPES.length : (idx + 1) % TYPES.length] ?? "shortcut";
1253
+ draftRef.current = {
1254
+ ...draftRef.current,
1255
+ type: nextType
1256
+ };
1257
+ return update({ type: nextType });
854
1258
  }
855
- if (key.return) return setFocused(2);
1259
+ if (key.return) return focusField(2);
856
1260
  return;
857
1261
  }
858
- if (focused === 3 && draft.type === "recipe") {
859
- const steps = draft.steps;
860
- const onAppendLine = stepCursor >= steps.length;
861
- if (grabbed) {
862
- if (key.upArrow && stepCursor > 0) {
863
- update({ steps: moveStep(steps, stepCursor, stepCursor - 1) });
864
- return setStepCursor(stepCursor - 1);
1262
+ if (focusedRef.current === 3 && draftRef.current.type === "recipe") {
1263
+ const steps = draftRef.current.steps;
1264
+ const onAppendLine = stepCursorRef.current >= steps.length;
1265
+ if (grabbedRef.current) {
1266
+ if (key.upArrow && stepCursorRef.current > 0) {
1267
+ const nextSteps = moveStep(steps, stepCursorRef.current, stepCursorRef.current - 1);
1268
+ draftRef.current = {
1269
+ ...draftRef.current,
1270
+ steps: nextSteps
1271
+ };
1272
+ update({ steps: nextSteps });
1273
+ return setStep(stepCursorRef.current - 1);
865
1274
  }
866
- if (key.downArrow && stepCursor < steps.length - 1) {
867
- update({ steps: moveStep(steps, stepCursor, stepCursor + 1) });
868
- return setStepCursor(stepCursor + 1);
1275
+ if (key.downArrow && stepCursorRef.current < steps.length - 1) {
1276
+ const nextSteps = moveStep(steps, stepCursorRef.current, stepCursorRef.current + 1);
1277
+ draftRef.current = {
1278
+ ...draftRef.current,
1279
+ steps: nextSteps
1280
+ };
1281
+ update({ steps: nextSteps });
1282
+ return setStep(stepCursorRef.current + 1);
1283
+ }
1284
+ if (key.return || input === " " || key.escape) return setGrab(false);
1285
+ return;
1286
+ }
1287
+ if (editingStepRef.current !== null) {
1288
+ const idx = editingStepRef.current;
1289
+ if (key.return) {
1290
+ const trimmed = editBufferRef.current.trim();
1291
+ if (trimmed) {
1292
+ const next = [...steps];
1293
+ next[idx] = trimmed;
1294
+ draftRef.current = {
1295
+ ...draftRef.current,
1296
+ steps: next
1297
+ };
1298
+ update({ steps: next });
1299
+ }
1300
+ setEditing(null);
1301
+ editBufferRef.current = "";
1302
+ setEditBuffer("");
1303
+ const appendEndPos = [...draftRef.current.stepLine].length;
1304
+ posRef.current = appendEndPos;
1305
+ setPos(appendEndPos);
1306
+ setStep(steps.length);
1307
+ return;
1308
+ }
1309
+ const nextEdit = applyTextKey({
1310
+ text: editBufferRef.current,
1311
+ pos: posRef.current
1312
+ }, input, key, { lineKeys: true });
1313
+ if (nextEdit) {
1314
+ editBufferRef.current = nextEdit.text;
1315
+ setEditBuffer(nextEdit.text);
1316
+ posRef.current = nextEdit.pos;
1317
+ setPos(nextEdit.pos);
1318
+ return;
869
1319
  }
870
- if (key.return || input === " " || key.escape) return setGrabbed(false);
871
1320
  return;
872
1321
  }
873
1322
  if (onAppendLine) {
1323
+ if ((key.backspace || key.delete) && !draftRef.current.stepLine) {
1324
+ if (steps.length) return setStep(steps.length - 1);
1325
+ return;
1326
+ }
1327
+ if (key.upArrow && !draftRef.current.stepLine) {
1328
+ if (steps.length) return setStep(steps.length - 1);
1329
+ return;
1330
+ }
874
1331
  if (key.return) {
875
- if (draft.stepLine.trim()) {
1332
+ const trimmed = draftRef.current.stepLine.trim();
1333
+ if (trimmed) {
1334
+ const nextSteps = [...steps, trimmed];
1335
+ draftRef.current = {
1336
+ ...draftRef.current,
1337
+ steps: nextSteps,
1338
+ stepLine: ""
1339
+ };
876
1340
  update({
877
- steps: [...steps, draft.stepLine.trim()],
1341
+ steps: nextSteps,
878
1342
  stepLine: ""
879
1343
  });
880
- setStepCursor(steps.length + 1);
1344
+ setStep(steps.length + 1);
1345
+ posRef.current = 0;
1346
+ setPos(0);
881
1347
  }
882
1348
  return;
883
1349
  }
884
- if (key.backspace || key.delete) {
885
- if (draft.stepLine) return update({ stepLine: draft.stepLine.slice(0, -1) });
886
- if (steps.length) return setStepCursor(steps.length - 1);
1350
+ const nextLine = applyTextKey({
1351
+ text: draftRef.current.stepLine,
1352
+ pos: posRef.current
1353
+ }, input, key, { lineKeys: true });
1354
+ if (nextLine) {
1355
+ draftRef.current = {
1356
+ ...draftRef.current,
1357
+ stepLine: nextLine.text
1358
+ };
1359
+ update({ stepLine: nextLine.text });
1360
+ posRef.current = nextLine.pos;
1361
+ setPos(nextLine.pos);
887
1362
  return;
888
1363
  }
889
1364
  if (key.upArrow) {
890
- if (steps.length) return setStepCursor(steps.length - 1);
1365
+ if (steps.length) return setStep(steps.length - 1);
891
1366
  return;
892
1367
  }
893
- if (input && !key.ctrl && !key.meta) return update({ stepLine: draft.stepLine + input });
894
1368
  return;
895
1369
  }
896
- if (key.upArrow) return setStepCursor(Math.max(0, stepCursor - 1));
897
- if (key.downArrow) return setStepCursor(stepCursor + 1);
898
- if (key.return || input === " ") return setGrabbed(true);
1370
+ if (key.ctrl && input === "e") {
1371
+ const target = steps[stepCursorRef.current];
1372
+ if (target === void 0) return;
1373
+ setEditing(stepCursorRef.current);
1374
+ editBufferRef.current = target;
1375
+ setEditBuffer(target);
1376
+ const endPos = [...target].length;
1377
+ posRef.current = endPos;
1378
+ setPos(endPos);
1379
+ return;
1380
+ }
1381
+ if (key.upArrow) return setStep(Math.max(0, stepCursorRef.current - 1));
1382
+ if (key.downArrow) return setStep(stepCursorRef.current + 1);
1383
+ if (key.return || input === " ") return setGrab(true);
899
1384
  if (key.backspace || key.delete) {
900
- const next = deleteStep(steps, stepCursor);
1385
+ const next = deleteStep(steps, stepCursorRef.current);
1386
+ draftRef.current = {
1387
+ ...draftRef.current,
1388
+ steps: next
1389
+ };
901
1390
  update({ steps: next });
902
- return setStepCursor(Math.min(stepCursor, next.length));
1391
+ return setStep(Math.min(stepCursorRef.current, next.length));
903
1392
  }
904
1393
  return;
905
1394
  }
906
1395
  if (key.return) return goReview();
907
- const fieldKey = focused === 2 ? "action" : focused === 3 ? draft.type === "command" ? "command" : "keys" : focused === 4 ? "tags" : "notes";
908
- if (key.backspace || key.delete) return update({ [fieldKey]: draft[fieldKey].slice(0, -1) });
909
- if (input && !key.ctrl && !key.meta) return update({ [fieldKey]: draft[fieldKey] + input });
1396
+ const fieldKey = focusedRef.current === 2 ? "action" : focusedRef.current === 3 ? draftRef.current.type === "command" ? "command" : "keys" : focusedRef.current === 4 ? "tags" : "notes";
1397
+ const nextText = applyTextKey({
1398
+ text: draftRef.current[fieldKey],
1399
+ pos: posRef.current
1400
+ }, input, key, { lineKeys: true });
1401
+ if (nextText) {
1402
+ draftRef.current = {
1403
+ ...draftRef.current,
1404
+ [fieldKey]: nextText.text
1405
+ };
1406
+ update({ [fieldKey]: nextText.text });
1407
+ posRef.current = nextText.pos;
1408
+ setPos(nextText.pos);
1409
+ }
910
1410
  });
911
1411
  const enterHint = focused <= 1 ? "next" : focused === 3 && draft.type === "recipe" ? "add step" : "review";
912
1412
  if (screen === "review") return /* @__PURE__ */ jsx(ReviewScreen, {
@@ -929,9 +1429,13 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
929
1429
  apps,
930
1430
  appIndex,
931
1431
  focused,
1432
+ pos,
1433
+ width: formFieldWidth(columns),
932
1434
  existingTags,
933
1435
  stepCursor,
934
- grabbed
1436
+ grabbed,
1437
+ editingStep,
1438
+ editBuffer
935
1439
  })
936
1440
  }),
937
1441
  /* @__PURE__ */ jsxs(Box, {
@@ -1127,6 +1631,9 @@ function FilterPicker({ apps, onSelect, onCancel, height = 16, width = 40 }) {
1127
1631
  }
1128
1632
  //#endregion
1129
1633
  //#region src/tui/Footer.tsx
1634
+ function hintLine(filterActive, resultCount) {
1635
+ return `↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⌃F filter ⌃S star ${filterActive ? "⎋ clear filter" : "⎋ quit"} (${resultCount})`;
1636
+ }
1130
1637
  function Footer({ flash, errorCount, resultCount, confirm, filterActive = false }) {
1131
1638
  if (confirm) return /* @__PURE__ */ jsxs(Box, {
1132
1639
  marginTop: 1,
@@ -1147,7 +1654,7 @@ function Footer({ flash, errorCount, resultCount, confirm, filterActive = false
1147
1654
  children: [/* @__PURE__ */ jsx(Text, {
1148
1655
  color: "gray",
1149
1656
  wrap: "truncate-end",
1150
- children: `↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⌃F filter ⌃S star ${filterActive ? "⎋ clear filter" : "⎋ quit"} ⌃U clear (${resultCount})`
1657
+ children: hintLine(filterActive, resultCount)
1151
1658
  }), flash ? /* @__PURE__ */ jsx(Text, {
1152
1659
  color: "green",
1153
1660
  wrap: "truncate-end",
@@ -1253,22 +1760,20 @@ function ResultList({ results, selected, query, height = 12, width = "50%", favo
1253
1760
  }
1254
1761
  //#endregion
1255
1762
  //#region src/tui/SearchInput.tsx
1256
- function SearchInput({ query, filterLabel }) {
1763
+ function SearchInput({ query, pos, width, filterLabel }) {
1257
1764
  return /* @__PURE__ */ jsxs(Box, {
1258
1765
  justifyContent: "space-between",
1259
1766
  children: [/* @__PURE__ */ jsxs(Text, {
1260
1767
  wrap: "truncate-end",
1261
- children: [
1262
- /* @__PURE__ */ jsx(Text, {
1263
- color: "cyan",
1264
- children: "search: "
1265
- }),
1266
- /* @__PURE__ */ jsx(Text, { children: query }),
1267
- /* @__PURE__ */ jsx(Text, {
1268
- inverse: true,
1269
- children: " "
1270
- })
1271
- ]
1768
+ children: [/* @__PURE__ */ jsx(Text, {
1769
+ color: "cyan",
1770
+ children: "search: "
1771
+ }), /* @__PURE__ */ jsx(CursorText, {
1772
+ value: query,
1773
+ pos,
1774
+ width,
1775
+ focused: true
1776
+ })]
1272
1777
  }), filterLabel ? /* @__PURE__ */ jsx(Text, {
1273
1778
  color: "gray",
1274
1779
  wrap: "truncate-end",
@@ -1277,56 +1782,23 @@ function SearchInput({ query, filterLabel }) {
1277
1782
  });
1278
1783
  }
1279
1784
  //#endregion
1280
- //#region src/tui/input.ts
1281
- /**
1282
- * Drop the run of trailing whitespace, then the run of trailing non-whitespace.
1283
- * Mirrors readline's `unix-word-rubout` / `backward-kill-word` behavior so a
1284
- * single press eats both the spaces and the word immediately before them.
1285
- */
1286
- function deleteWordBack(query) {
1287
- const trimmed = query.replace(/\s+$/, "");
1288
- const lastSpace = trimmed.lastIndexOf(" ");
1289
- if (lastSpace === -1) return "";
1290
- return trimmed.slice(0, lastSpace + 1);
1291
- }
1292
- /**
1293
- * Rows to give the windowed result list, sized to the terminal height so the
1294
- * whole frame fits the viewport. Ink can only redraw in place while the frame
1295
- * stays within the terminal; an oversized frame duplicates on every keypress.
1296
- */
1297
- function visibleListHeight(terminalRows) {
1298
- return Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 4);
1299
- }
1300
- /**
1301
- * Integer widths for the two side-by-side panes. They MUST sum to the exact
1302
- * terminal width: two `width="50%"` siblings each round up on an odd-width
1303
- * terminal (58 + 58 = 116 at 115 cols), overflowing by a column. The terminal
1304
- * then soft-wraps the full-width rows, Ink miscounts the frame height, and its
1305
- * redraw leaves stale lines stacked on every keystroke.
1306
- */
1307
- function columnWidths(terminalCols) {
1308
- const cols = terminalCols && terminalCols > 0 ? terminalCols : 80;
1309
- const left = Math.floor(cols / 2);
1310
- return {
1311
- left,
1312
- right: cols - left
1313
- };
1314
- }
1315
- //#endregion
1316
1785
  //#region src/tui/App.tsx
1317
1786
  const sameApp = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
1318
1787
  function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboard }) {
1319
1788
  const { exit } = useApp();
1320
- const { stdout } = useStdout();
1789
+ const { columns, rows } = useTerminalSize();
1321
1790
  const [entries, setEntries] = useState(initial);
1322
1791
  const [mode, setMode] = useState("search");
1323
1792
  const [filter, setFilter] = useState({ type: "all" });
1324
1793
  const [favorites, setFavorites] = useState(() => dataDir ? loadFavorites(dataDir) : /* @__PURE__ */ new Set());
1325
1794
  const [editTarget, setEditTarget] = useState(null);
1326
1795
  const [pendingDelete, setPendingDelete] = useState(null);
1327
- const [query, setQuery] = useState("");
1796
+ const [query, setQuery] = useState(() => atEnd(""));
1328
1797
  const [selected, setSelected] = useState(0);
1329
1798
  const [flash, setFlash] = useState("");
1799
+ const queryRef = useRef(query);
1800
+ queryRef.current = query;
1801
+ const currentRef = useRef(void 0);
1330
1802
  const reload = useCallback(() => {
1331
1803
  if (dataDir) setEntries(loadEntries(dataDir).entries);
1332
1804
  }, [dataDir]);
@@ -1339,11 +1811,12 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1339
1811
  filter,
1340
1812
  favorites
1341
1813
  ]);
1342
- const results = useMemo(() => search(scoped, query), [scoped, query]);
1814
+ const results = useMemo(() => search(scoped, query.text), [scoped, query.text]);
1343
1815
  const sel = results.length ? Math.min(selected, results.length - 1) : 0;
1344
1816
  const current = results[sel];
1345
- const listHeight = visibleListHeight(stdout?.rows);
1346
- const { left, right } = columnWidths(stdout?.columns);
1817
+ currentRef.current = current;
1818
+ const listHeight = visibleListHeight(rows);
1819
+ const { left, right } = columnWidths(columns);
1347
1820
  useInput((input, key) => {
1348
1821
  if (key.ctrl && input === "c") {
1349
1822
  exit();
@@ -1362,6 +1835,7 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1362
1835
  return;
1363
1836
  }
1364
1837
  if (!key.return && flash) setFlash("");
1838
+ const curEntry = currentRef.current;
1365
1839
  if (key.escape) {
1366
1840
  if (filter.type !== "all") {
1367
1841
  setFilter({ type: "all" });
@@ -1375,23 +1849,23 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1375
1849
  setMode("add");
1376
1850
  return;
1377
1851
  }
1378
- if (dataDir && current && key.ctrl && input === "e") {
1379
- setEditTarget(current);
1852
+ if (dataDir && curEntry && key.ctrl && input === "e") {
1853
+ setEditTarget(curEntry);
1380
1854
  setMode("edit");
1381
1855
  return;
1382
1856
  }
1383
- if (dataDir && current && key.ctrl && input === "x") {
1384
- setPendingDelete(current);
1857
+ if (dataDir && curEntry && key.ctrl && input === "x") {
1858
+ setPendingDelete(curEntry);
1385
1859
  return;
1386
1860
  }
1387
1861
  if (key.ctrl && input === "f") {
1388
1862
  setMode("filter");
1389
1863
  return;
1390
1864
  }
1391
- if (dataDir && current && key.ctrl && input === "s") {
1392
- const next = toggleFavorite(dataDir, current.app, current.action);
1865
+ if (dataDir && curEntry && key.ctrl && input === "s") {
1866
+ const next = toggleFavorite(dataDir, curEntry.app, curEntry.action);
1393
1867
  setFavorites(next);
1394
- setFlash(next.has(favKey(current.app, current.action)) ? "★ starred" : "☆ unstarred");
1868
+ setFlash(next.has(favKey(curEntry.app, curEntry.action)) ? "★ starred" : "☆ unstarred");
1395
1869
  return;
1396
1870
  }
1397
1871
  if (key.downArrow || key.ctrl && input === "n") {
@@ -1403,28 +1877,17 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1403
1877
  return;
1404
1878
  }
1405
1879
  if (key.return) {
1406
- if (current) setFlash(onCopy(current.keys ?? current.command ?? `${current.app}: ${current.action}`) ? "✓ copied!" : "✗ copy failed");
1880
+ if (curEntry) setFlash(onCopy(curEntry.keys ?? curEntry.command ?? `${curEntry.app}: ${curEntry.action}`) ? "✓ copied!" : "✗ copy failed");
1407
1881
  return;
1408
1882
  }
1409
- if (key.meta && (key.backspace || key.delete) || key.ctrl && input === "w") {
1410
- setQuery(deleteWordBack);
1411
- setSelected(0);
1883
+ const cur = queryRef.current;
1884
+ const next = applyTextKey(cur, input, key, { lineKeys: false });
1885
+ if (next) {
1886
+ if (next.text !== cur.text) setSelected(0);
1887
+ queryRef.current = next;
1888
+ setQuery(next);
1412
1889
  return;
1413
1890
  }
1414
- if (key.ctrl && input === "u") {
1415
- setQuery("");
1416
- setSelected(0);
1417
- return;
1418
- }
1419
- if (key.backspace || key.delete) {
1420
- setQuery((q) => q.slice(0, -1));
1421
- setSelected(0);
1422
- return;
1423
- }
1424
- if (input && !key.ctrl && !key.meta) {
1425
- setQuery((q) => q + input);
1426
- setSelected(0);
1427
- }
1428
1891
  }, { isActive: mode === "search" });
1429
1892
  const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1430
1893
  if (mode === "filter") return /* @__PURE__ */ jsx(FilterPicker, {
@@ -1478,17 +1941,20 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1478
1941
  setEditTarget(null);
1479
1942
  }
1480
1943
  });
1944
+ const filterLabelText = filter.type === "app" ? `(filter: ${filter.app})` : filter.type === "favorites" ? "(★ Favorites)" : void 0;
1481
1945
  return /* @__PURE__ */ jsxs(Box, {
1482
1946
  flexDirection: "column",
1483
1947
  children: [
1484
1948
  /* @__PURE__ */ jsx(SearchInput, {
1485
- query,
1486
- filterLabel: filter.type === "app" ? `(filter: ${filter.app})` : filter.type === "favorites" ? "(★ Favorites)" : void 0
1949
+ query: query.text,
1950
+ pos: query.pos,
1951
+ width: searchFieldWidth(columns, filterLabelText?.length ?? 0),
1952
+ filterLabel: filterLabelText
1487
1953
  }),
1488
1954
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(ResultList, {
1489
1955
  results,
1490
1956
  selected: sel,
1491
- query,
1957
+ query: query.text,
1492
1958
  height: listHeight,
1493
1959
  width: left,
1494
1960
  favorites,
package/package.json CHANGED
@@ -1,9 +1,7 @@
1
1
  {
2
2
  "name": "@arthony/keybook",
3
- "version": "0.7.0",
4
- "publishConfig": {
5
- "access": "public"
6
- },
3
+ "version": "0.8.1",
4
+ "publishConfig": { "access": "public" },
7
5
  "description": "A macOS TUI for searching keyboard shortcuts and recipes of your favorite apps",
8
6
  "keywords": [
9
7
  "cli",
@@ -17,27 +15,23 @@
17
15
  "productivity"
18
16
  ],
19
17
  "homepage": "https://github.com/Ariyapong/keybook#readme",
20
- "repository": {
21
- "type": "git",
22
- "url": "git+https://github.com/Ariyapong/keybook.git"
23
- },
24
- "bugs": {
25
- "url": "https://github.com/Ariyapong/keybook/issues"
26
- },
18
+ "repository": { "type": "git", "url": "git+https://github.com/Ariyapong/keybook.git" },
19
+ "bugs": { "url": "https://github.com/Ariyapong/keybook/issues" },
27
20
  "author": "Ariyapong Wimolnoch",
28
21
  "type": "module",
29
- "bin": {
30
- "keybook": "dist/cli.js",
31
- "kb": "dist/cli.js"
32
- },
33
- "files": [
34
- "dist",
35
- "seed",
36
- "README.md",
37
- "LICENSE"
38
- ],
39
- "engines": {
40
- "node": ">=22"
22
+ "bin": { "keybook": "dist/cli.js", "kb": "dist/cli.js" },
23
+ "files": ["dist", "seed", "README.md", "LICENSE"],
24
+ "engines": { "node": ">=22" },
25
+ "scripts": {
26
+ "build": "tsdown",
27
+ "dev": "tsx src/cli.ts",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "typecheck": "tsc --noEmit",
31
+ "lint": "biome check .",
32
+ "format": "biome format --write .",
33
+ "prepublishOnly": "pnpm build",
34
+ "bump-tap": "./scripts/bump-tap.sh"
41
35
  },
42
36
  "dependencies": {
43
37
  "commander": "^12.1.0",
@@ -58,14 +52,8 @@
58
52
  "vitest": "^2.1.0"
59
53
  },
60
54
  "license": "MIT",
61
- "scripts": {
62
- "build": "tsdown",
63
- "dev": "tsx src/cli.ts",
64
- "test": "vitest run",
65
- "test:watch": "vitest",
66
- "typecheck": "tsc --noEmit",
67
- "lint": "biome check .",
68
- "format": "biome format --write .",
69
- "bump-tap": "./scripts/bump-tap.sh"
55
+ "packageManager": "pnpm@10.25.0",
56
+ "pnpm": {
57
+ "onlyBuiltDependencies": ["@biomejs/biome", "esbuild"]
70
58
  }
71
- }
59
+ }