@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.
- package/README.md +22 -3
- package/dist/cli.js +656 -190
- 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
|
|
29
|
-
enable *Terminal → Settings → Profiles → Keyboard → "Use Option as
|
|
30
|
-
key"
|
|
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 {
|
|
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
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
445
|
-
]
|
|
446
|
-
}),
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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__ */
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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(
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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:
|
|
595
|
+
children: keysPreview
|
|
524
596
|
}) : null
|
|
525
|
-
] }) : draft.type === "command" ? /* @__PURE__ */
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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)
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
829
|
-
if (key.ctrl && input === "p") return
|
|
830
|
-
if (
|
|
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 (
|
|
842
|
-
if (key.return) return
|
|
843
|
-
|
|
844
|
-
|
|
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
|
|
1246
|
+
if (key.return) return focusField(1);
|
|
848
1247
|
return;
|
|
849
1248
|
}
|
|
850
|
-
if (
|
|
1249
|
+
if (focusedRef.current === 1) {
|
|
851
1250
|
if (key.leftArrow || key.rightArrow || input === " ") {
|
|
852
|
-
const idx = TYPES.indexOf(
|
|
853
|
-
|
|
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
|
|
1259
|
+
if (key.return) return focusField(2);
|
|
856
1260
|
return;
|
|
857
1261
|
}
|
|
858
|
-
if (
|
|
859
|
-
const steps =
|
|
860
|
-
const onAppendLine =
|
|
861
|
-
if (
|
|
862
|
-
if (key.upArrow &&
|
|
863
|
-
|
|
864
|
-
|
|
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 &&
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
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:
|
|
1341
|
+
steps: nextSteps,
|
|
878
1342
|
stepLine: ""
|
|
879
1343
|
});
|
|
880
|
-
|
|
1344
|
+
setStep(steps.length + 1);
|
|
1345
|
+
posRef.current = 0;
|
|
1346
|
+
setPos(0);
|
|
881
1347
|
}
|
|
882
1348
|
return;
|
|
883
1349
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
|
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.
|
|
897
|
-
|
|
898
|
-
|
|
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,
|
|
1385
|
+
const next = deleteStep(steps, stepCursorRef.current);
|
|
1386
|
+
draftRef.current = {
|
|
1387
|
+
...draftRef.current,
|
|
1388
|
+
steps: next
|
|
1389
|
+
};
|
|
901
1390
|
update({ steps: next });
|
|
902
|
-
return
|
|
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 =
|
|
908
|
-
|
|
909
|
-
|
|
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:
|
|
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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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 {
|
|
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
|
-
|
|
1346
|
-
const
|
|
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 &&
|
|
1379
|
-
setEditTarget(
|
|
1852
|
+
if (dataDir && curEntry && key.ctrl && input === "e") {
|
|
1853
|
+
setEditTarget(curEntry);
|
|
1380
1854
|
setMode("edit");
|
|
1381
1855
|
return;
|
|
1382
1856
|
}
|
|
1383
|
-
if (dataDir &&
|
|
1384
|
-
setPendingDelete(
|
|
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 &&
|
|
1392
|
-
const next = toggleFavorite(dataDir,
|
|
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(
|
|
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 (
|
|
1880
|
+
if (curEntry) setFlash(onCopy(curEntry.keys ?? curEntry.command ?? `${curEntry.app}: ${curEntry.action}`) ? "✓ copied!" : "✗ copy failed");
|
|
1407
1881
|
return;
|
|
1408
1882
|
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
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
|
-
"
|
|
62
|
-
|
|
63
|
-
"
|
|
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
|
+
}
|