@arthony/keybook 0.7.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 +624 -187
  3. package/package.json +1 -1
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,23 @@ 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
+ }
763
1032
  //#endregion
764
1033
  //#region src/tui/AddEntryForm.tsx
765
1034
  const TYPES = [
@@ -770,9 +1039,13 @@ const TYPES = [
770
1039
  const LAST_FIELD = 5;
771
1040
  function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, initialFocus, title }) {
772
1041
  const { draft, update, setDraft } = useAddForm(initial ?? { app: apps[0] ?? "" });
1042
+ const { stdout } = useStdout();
773
1043
  const [focused, setFocused] = useState(initialFocus ?? 0);
1044
+ const [pos, setPos] = useState(() => [...fieldValue(draft, initialFocus ?? 0)].length);
774
1045
  const [stepCursor, setStepCursor] = useState(initial?.steps?.length ?? 0);
775
1046
  const [grabbed, setGrabbed] = useState(false);
1047
+ const [editingStep, setEditingStep] = useState(null);
1048
+ const [editBuffer, setEditBuffer] = useState("");
776
1049
  const [appIndex, setAppIndex] = useState(() => {
777
1050
  const i = apps.indexOf(initial?.app ?? apps[0] ?? "");
778
1051
  return i >= 0 ? i : 0;
@@ -780,10 +1053,73 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
780
1053
  const [screen, setScreen] = useState("form");
781
1054
  const [hint, setHint] = useState("");
782
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
+ };
783
1113
  useEffect(() => {
784
1114
  if (!(focused === 3 && draft.type === "recipe")) {
1115
+ grabbedRef.current = false;
785
1116
  setGrabbed(false);
1117
+ stepCursorRef.current = draft.steps.length;
786
1118
  setStepCursor(draft.steps.length);
1119
+ editingStepRef.current = null;
1120
+ setEditingStep(null);
1121
+ editBufferRef.current = "";
1122
+ setEditBuffer("");
787
1123
  }
788
1124
  }, [
789
1125
  focused,
@@ -792,20 +1128,34 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
792
1128
  ]);
793
1129
  const appChoices = [...apps, "Create new app…"];
794
1130
  function commitAppSelection(index) {
795
- if (index === apps.length) update({ creatingApp: true });
796
- else update({
797
- creatingApp: false,
798
- app: apps[index] ?? ""
799
- });
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
+ }
800
1149
  }
801
1150
  function goReview() {
802
- const d = flushStep(draft);
1151
+ const d = flushStep(draftRef.current);
803
1152
  const v = validateDraft(d);
804
1153
  if (v) {
805
1154
  setHint(v);
806
1155
  return;
807
1156
  }
808
1157
  setHint("");
1158
+ draftRef.current = d;
809
1159
  setDraft(d);
810
1160
  setScreen("review");
811
1161
  }
@@ -823,11 +1173,20 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
823
1173
  }
824
1174
  return;
825
1175
  }
826
- if (grabbed && key.escape) return setGrabbed(false);
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
+ }
827
1186
  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) {
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) {
831
1190
  if (key.upArrow) {
832
1191
  const next = Math.max(appIndex - 1, 0);
833
1192
  setAppIndex(next);
@@ -838,75 +1197,187 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
838
1197
  setAppIndex(next);
839
1198
  return commitAppSelection(next);
840
1199
  }
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 });
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
+ }
845
1215
  return;
846
1216
  }
847
- if (key.return) return setFocused(1);
1217
+ if (key.return) return focusField(1);
848
1218
  return;
849
1219
  }
850
- if (focused === 1) {
1220
+ if (focusedRef.current === 1) {
851
1221
  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] });
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 });
854
1229
  }
855
- if (key.return) return setFocused(2);
1230
+ if (key.return) return focusField(2);
856
1231
  return;
857
1232
  }
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);
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);
865
1245
  }
866
- if (key.downArrow && stepCursor < steps.length - 1) {
867
- update({ steps: moveStep(steps, stepCursor, stepCursor + 1) });
868
- return setStepCursor(stepCursor + 1);
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;
869
1290
  }
870
- if (key.return || input === " " || key.escape) return setGrabbed(false);
871
1291
  return;
872
1292
  }
873
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
+ }
874
1302
  if (key.return) {
875
- if (draft.stepLine.trim()) {
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
+ };
876
1311
  update({
877
- steps: [...steps, draft.stepLine.trim()],
1312
+ steps: nextSteps,
878
1313
  stepLine: ""
879
1314
  });
880
- setStepCursor(steps.length + 1);
1315
+ setStep(steps.length + 1);
1316
+ posRef.current = 0;
1317
+ setPos(0);
881
1318
  }
882
1319
  return;
883
1320
  }
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);
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);
887
1333
  return;
888
1334
  }
889
1335
  if (key.upArrow) {
890
- if (steps.length) return setStepCursor(steps.length - 1);
1336
+ if (steps.length) return setStep(steps.length - 1);
891
1337
  return;
892
1338
  }
893
- if (input && !key.ctrl && !key.meta) return update({ stepLine: draft.stepLine + input });
894
1339
  return;
895
1340
  }
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);
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);
1350
+ return;
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);
899
1355
  if (key.backspace || key.delete) {
900
- const next = deleteStep(steps, stepCursor);
1356
+ const next = deleteStep(steps, stepCursorRef.current);
1357
+ draftRef.current = {
1358
+ ...draftRef.current,
1359
+ steps: next
1360
+ };
901
1361
  update({ steps: next });
902
- return setStepCursor(Math.min(stepCursor, next.length));
1362
+ return setStep(Math.min(stepCursorRef.current, next.length));
903
1363
  }
904
1364
  return;
905
1365
  }
906
1366
  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 });
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
+ }
910
1381
  });
911
1382
  const enterHint = focused <= 1 ? "next" : focused === 3 && draft.type === "recipe" ? "add step" : "review";
912
1383
  if (screen === "review") return /* @__PURE__ */ jsx(ReviewScreen, {
@@ -929,9 +1400,13 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
929
1400
  apps,
930
1401
  appIndex,
931
1402
  focused,
1403
+ pos,
1404
+ width: formFieldWidth(stdout?.columns),
932
1405
  existingTags,
933
1406
  stepCursor,
934
- grabbed
1407
+ grabbed,
1408
+ editingStep,
1409
+ editBuffer
935
1410
  })
936
1411
  }),
937
1412
  /* @__PURE__ */ jsxs(Box, {
@@ -1127,6 +1602,9 @@ function FilterPicker({ apps, onSelect, onCancel, height = 16, width = 40 }) {
1127
1602
  }
1128
1603
  //#endregion
1129
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
+ }
1130
1608
  function Footer({ flash, errorCount, resultCount, confirm, filterActive = false }) {
1131
1609
  if (confirm) return /* @__PURE__ */ jsxs(Box, {
1132
1610
  marginTop: 1,
@@ -1147,7 +1625,7 @@ function Footer({ flash, errorCount, resultCount, confirm, filterActive = false
1147
1625
  children: [/* @__PURE__ */ jsx(Text, {
1148
1626
  color: "gray",
1149
1627
  wrap: "truncate-end",
1150
- 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)
1151
1629
  }), flash ? /* @__PURE__ */ jsx(Text, {
1152
1630
  color: "green",
1153
1631
  wrap: "truncate-end",
@@ -1253,22 +1731,20 @@ function ResultList({ results, selected, query, height = 12, width = "50%", favo
1253
1731
  }
1254
1732
  //#endregion
1255
1733
  //#region src/tui/SearchInput.tsx
1256
- function SearchInput({ query, filterLabel }) {
1734
+ function SearchInput({ query, pos, width, filterLabel }) {
1257
1735
  return /* @__PURE__ */ jsxs(Box, {
1258
1736
  justifyContent: "space-between",
1259
1737
  children: [/* @__PURE__ */ jsxs(Text, {
1260
1738
  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
- ]
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
+ })]
1272
1748
  }), filterLabel ? /* @__PURE__ */ jsx(Text, {
1273
1749
  color: "gray",
1274
1750
  wrap: "truncate-end",
@@ -1277,42 +1753,6 @@ function SearchInput({ query, filterLabel }) {
1277
1753
  });
1278
1754
  }
1279
1755
  //#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
1756
  //#region src/tui/App.tsx
1317
1757
  const sameApp = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
1318
1758
  function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboard }) {
@@ -1324,9 +1764,12 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1324
1764
  const [favorites, setFavorites] = useState(() => dataDir ? loadFavorites(dataDir) : /* @__PURE__ */ new Set());
1325
1765
  const [editTarget, setEditTarget] = useState(null);
1326
1766
  const [pendingDelete, setPendingDelete] = useState(null);
1327
- const [query, setQuery] = useState("");
1767
+ const [query, setQuery] = useState(() => atEnd(""));
1328
1768
  const [selected, setSelected] = useState(0);
1329
1769
  const [flash, setFlash] = useState("");
1770
+ const queryRef = useRef(query);
1771
+ queryRef.current = query;
1772
+ const currentRef = useRef(void 0);
1330
1773
  const reload = useCallback(() => {
1331
1774
  if (dataDir) setEntries(loadEntries(dataDir).entries);
1332
1775
  }, [dataDir]);
@@ -1339,9 +1782,10 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1339
1782
  filter,
1340
1783
  favorites
1341
1784
  ]);
1342
- const results = useMemo(() => search(scoped, query), [scoped, query]);
1785
+ const results = useMemo(() => search(scoped, query.text), [scoped, query.text]);
1343
1786
  const sel = results.length ? Math.min(selected, results.length - 1) : 0;
1344
1787
  const current = results[sel];
1788
+ currentRef.current = current;
1345
1789
  const listHeight = visibleListHeight(stdout?.rows);
1346
1790
  const { left, right } = columnWidths(stdout?.columns);
1347
1791
  useInput((input, key) => {
@@ -1362,6 +1806,7 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1362
1806
  return;
1363
1807
  }
1364
1808
  if (!key.return && flash) setFlash("");
1809
+ const curEntry = currentRef.current;
1365
1810
  if (key.escape) {
1366
1811
  if (filter.type !== "all") {
1367
1812
  setFilter({ type: "all" });
@@ -1375,23 +1820,23 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1375
1820
  setMode("add");
1376
1821
  return;
1377
1822
  }
1378
- if (dataDir && current && key.ctrl && input === "e") {
1379
- setEditTarget(current);
1823
+ if (dataDir && curEntry && key.ctrl && input === "e") {
1824
+ setEditTarget(curEntry);
1380
1825
  setMode("edit");
1381
1826
  return;
1382
1827
  }
1383
- if (dataDir && current && key.ctrl && input === "x") {
1384
- setPendingDelete(current);
1828
+ if (dataDir && curEntry && key.ctrl && input === "x") {
1829
+ setPendingDelete(curEntry);
1385
1830
  return;
1386
1831
  }
1387
1832
  if (key.ctrl && input === "f") {
1388
1833
  setMode("filter");
1389
1834
  return;
1390
1835
  }
1391
- if (dataDir && current && key.ctrl && input === "s") {
1392
- 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);
1393
1838
  setFavorites(next);
1394
- setFlash(next.has(favKey(current.app, current.action)) ? "★ starred" : "☆ unstarred");
1839
+ setFlash(next.has(favKey(curEntry.app, curEntry.action)) ? "★ starred" : "☆ unstarred");
1395
1840
  return;
1396
1841
  }
1397
1842
  if (key.downArrow || key.ctrl && input === "n") {
@@ -1403,28 +1848,17 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1403
1848
  return;
1404
1849
  }
1405
1850
  if (key.return) {
1406
- if (current) setFlash(onCopy(current.keys ?? current.command ?? `${current.app}: ${current.action}`) ? "✓ copied!" : "✗ copy failed");
1407
- return;
1408
- }
1409
- if (key.meta && (key.backspace || key.delete) || key.ctrl && input === "w") {
1410
- setQuery(deleteWordBack);
1411
- setSelected(0);
1851
+ if (curEntry) setFlash(onCopy(curEntry.keys ?? curEntry.command ?? `${curEntry.app}: ${curEntry.action}`) ? "✓ copied!" : "✗ copy failed");
1412
1852
  return;
1413
1853
  }
1414
- if (key.ctrl && input === "u") {
1415
- setQuery("");
1416
- 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);
1417
1860
  return;
1418
1861
  }
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
1862
  }, { isActive: mode === "search" });
1429
1863
  const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1430
1864
  if (mode === "filter") return /* @__PURE__ */ jsx(FilterPicker, {
@@ -1478,17 +1912,20 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1478
1912
  setEditTarget(null);
1479
1913
  }
1480
1914
  });
1915
+ const filterLabelText = filter.type === "app" ? `(filter: ${filter.app})` : filter.type === "favorites" ? "(★ Favorites)" : void 0;
1481
1916
  return /* @__PURE__ */ jsxs(Box, {
1482
1917
  flexDirection: "column",
1483
1918
  children: [
1484
1919
  /* @__PURE__ */ jsx(SearchInput, {
1485
- query,
1486
- 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
1487
1924
  }),
1488
1925
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(ResultList, {
1489
1926
  results,
1490
1927
  selected: sel,
1491
- query,
1928
+ query: query.text,
1492
1929
  height: listHeight,
1493
1930
  width: left,
1494
1931
  favorites,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arthony/keybook",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },