@arthony/keybook 0.8.0 → 0.9.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 +3 -2
  2. package/dist/cli.js +118 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -44,8 +44,9 @@ In the TUI: type to fuzzy-search, ↑/↓ to move, ⏎ to copy the shortcut (or
44
44
  recipe's command) to the clipboard, ⎋ to quit.
45
45
 
46
46
  Manage entries without leaving the search screen: `⌃O` to add, `⌃E` to edit the
47
- selected entry (pre-filled form), `⌃X` to delete it (with a `y/n` confirm). Or
48
- script an add: `keybook add --app Fork --action 'Push' --keys 'shift cmd p' --tags push`
47
+ selected entry (pre-filled form), `⌃X` to delete it (with a `y/n` confirm). In
48
+ the form, the App picker jumps as you type (`vs` lands on VS Code; `↑`/`↓` still
49
+ step through it). Or script an add: `keybook add --app Fork --action 'Push' --keys 'shift cmd p' --tags push`
49
50
  (`--keys` accepts glyphs or words; recipes use repeatable `--step`).
50
51
 
51
52
  ### Text editing
package/dist/cli.js CHANGED
@@ -553,7 +553,7 @@ function Field({ label, value, focused, pos, width }) {
553
553
  focused
554
554
  })] });
555
555
  }
556
- function FormFields({ draft, apps, appIndex, focused, pos, width, existingTags, stepCursor, grabbed, editingStep, editBuffer }) {
556
+ function FormFields({ draft, apps, appIndex, appPrefix = "", focused, pos, width, existingTags, stepCursor, grabbed, editingStep, editBuffer }) {
557
557
  const appChoices = [...apps, "Create new app…"];
558
558
  const keysPreview = draft.keys.trim() ? ` → ${normalizeKeys(draft.keys)}` : "";
559
559
  return /* @__PURE__ */ jsxs(Box, {
@@ -567,7 +567,7 @@ function FormFields({ draft, apps, appIndex, focused, pos, width, existingTags,
567
567
  pos,
568
568
  width,
569
569
  focused: focused === 0
570
- }) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? " (↑/↓)" : ""] })] }),
570
+ }) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? appPrefix ? ` (↑/↓ · "${appPrefix}")` : " (↑/↓ or type)" : ""] })] }),
571
571
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
572
572
  color: focused === 1 ? "cyan" : "gray",
573
573
  children: "Type".padEnd(8)
@@ -1030,6 +1030,35 @@ function fieldValue(d, focused) {
1030
1030
  return "";
1031
1031
  }
1032
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
+ }
1061
+ //#endregion
1033
1062
  //#region src/tui/AddEntryForm.tsx
1034
1063
  const TYPES = [
1035
1064
  "shortcut",
@@ -1039,7 +1068,7 @@ const TYPES = [
1039
1068
  const LAST_FIELD = 5;
1040
1069
  function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, initialFocus, title }) {
1041
1070
  const { draft, update, setDraft } = useAddForm(initial ?? { app: apps[0] ?? "" });
1042
- const { stdout } = useStdout();
1071
+ const { columns } = useTerminalSize();
1043
1072
  const [focused, setFocused] = useState(initialFocus ?? 0);
1044
1073
  const [pos, setPos] = useState(() => [...fieldValue(draft, initialFocus ?? 0)].length);
1045
1074
  const [stepCursor, setStepCursor] = useState(initial?.steps?.length ?? 0);
@@ -1050,6 +1079,7 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
1050
1079
  const i = apps.indexOf(initial?.app ?? apps[0] ?? "");
1051
1080
  return i >= 0 ? i : 0;
1052
1081
  });
1082
+ const [appPrefix, setAppPrefix] = useState("");
1053
1083
  const [screen, setScreen] = useState("form");
1054
1084
  const [hint, setHint] = useState("");
1055
1085
  const [writeError, setWriteError] = useState("");
@@ -1059,6 +1089,8 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
1059
1089
  posRef.current = pos;
1060
1090
  const focusedRef = useRef(focused);
1061
1091
  focusedRef.current = focused;
1092
+ const appPrefixRef = useRef(appPrefix);
1093
+ appPrefixRef.current = appPrefix;
1062
1094
  const editingStepRef = useRef(editingStep);
1063
1095
  editingStepRef.current = editingStep;
1064
1096
  const editBufferRef = useRef(editBuffer);
@@ -1080,9 +1112,15 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
1080
1112
  * after the batch is committed and rendered, i.e. too late for that second
1081
1113
  * keystroke).
1082
1114
  */
1115
+ /** Set the type-to-jump prefix, keeping the ref in sync for same-batch reads. */
1116
+ const setPrefix = (p) => {
1117
+ appPrefixRef.current = p;
1118
+ setAppPrefix(p);
1119
+ };
1083
1120
  const focusField = (f) => {
1084
1121
  focusedRef.current = f;
1085
1122
  setFocused(f);
1123
+ setPrefix("");
1086
1124
  const next = [...fieldValue(draftRef.current, f)].length;
1087
1125
  posRef.current = next;
1088
1126
  setPos(next);
@@ -1147,6 +1185,15 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
1147
1185
  });
1148
1186
  }
1149
1187
  }
1188
+ /** Highlight the first app whose name starts with `prefix`; false if none does. */
1189
+ function jumpToApp(prefix) {
1190
+ const needle = prefix.toLowerCase();
1191
+ const i = apps.findIndex((a) => a.toLowerCase().startsWith(needle));
1192
+ if (i < 0) return false;
1193
+ setAppIndex(i);
1194
+ commitAppSelection(i);
1195
+ return true;
1196
+ }
1150
1197
  function goReview() {
1151
1198
  const d = flushStep(draftRef.current);
1152
1199
  const v = validateDraft(d);
@@ -1188,11 +1235,13 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
1188
1235
  if (key.ctrl && input === "p") return focusField(Math.max(focusedRef.current - 1, 0));
1189
1236
  if (focusedRef.current === 0) {
1190
1237
  if (key.upArrow) {
1238
+ setPrefix("");
1191
1239
  const next = Math.max(appIndex - 1, 0);
1192
1240
  setAppIndex(next);
1193
1241
  return commitAppSelection(next);
1194
1242
  }
1195
1243
  if (key.downArrow) {
1244
+ setPrefix("");
1196
1245
  const next = Math.min(appIndex + 1, appChoices.length - 1);
1197
1246
  setAppIndex(next);
1198
1247
  return commitAppSelection(next);
@@ -1215,6 +1264,16 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
1215
1264
  return;
1216
1265
  }
1217
1266
  if (key.return) return focusField(1);
1267
+ if (key.backspace || key.delete) {
1268
+ const shorter = [...appPrefixRef.current].slice(0, -1).join("");
1269
+ setPrefix(shorter);
1270
+ if (shorter) jumpToApp(shorter);
1271
+ return;
1272
+ }
1273
+ if (input && !key.ctrl && !key.meta) {
1274
+ const longer = appPrefixRef.current + input;
1275
+ if (jumpToApp(longer)) setPrefix(longer);
1276
+ }
1218
1277
  return;
1219
1278
  }
1220
1279
  if (focusedRef.current === 1) {
@@ -1399,9 +1458,10 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
1399
1458
  draft,
1400
1459
  apps,
1401
1460
  appIndex,
1461
+ appPrefix,
1402
1462
  focused,
1403
1463
  pos,
1404
- width: formFieldWidth(stdout?.columns),
1464
+ width: formFieldWidth(columns),
1405
1465
  existingTags,
1406
1466
  stepCursor,
1407
1467
  grabbed,
@@ -1600,12 +1660,55 @@ function FilterPicker({ apps, onSelect, onCancel, height = 16, width = 40 }) {
1600
1660
  ]
1601
1661
  });
1602
1662
  }
1603
- //#endregion
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})`;
1663
+ /**
1664
+ * Hints in display order. Terminals narrower than the budget cannot show them
1665
+ * all, so `hintLine` drops them by `priority` (highest number first) until
1666
+ * the row fits. The quit / clear-filter hint and the result count are never
1667
+ * dropped: the count is what a narrow terminal used to lose, and ⎋ is the only
1668
+ * way out.
1669
+ */
1670
+ const HINTS = [
1671
+ {
1672
+ text: "↑↓ move",
1673
+ priority: 7
1674
+ },
1675
+ {
1676
+ text: "⏎ copy",
1677
+ priority: 1
1678
+ },
1679
+ {
1680
+ text: "⌃O add",
1681
+ priority: 2
1682
+ },
1683
+ {
1684
+ text: "⌃E edit",
1685
+ priority: 4
1686
+ },
1687
+ {
1688
+ text: "⌃X del",
1689
+ priority: 3
1690
+ },
1691
+ {
1692
+ text: "⌃F filter",
1693
+ priority: 5
1694
+ },
1695
+ {
1696
+ text: "⌃S star",
1697
+ priority: 6
1698
+ }
1699
+ ];
1700
+ function hintLine(filterActive, resultCount, columns = 80) {
1701
+ const tail = `${filterActive ? "⎋ clear filter" : "⎋ quit"} (${resultCount})`;
1702
+ const fit = Math.min(columns, 80);
1703
+ const hints = [...HINTS];
1704
+ for (;;) {
1705
+ const line = [...hints.map((h) => h.text), tail].join(" ");
1706
+ if ([...line].length <= fit || hints.length === 0) return line;
1707
+ const drop = hints.reduce((a, b) => b.priority > a.priority ? b : a);
1708
+ hints.splice(hints.indexOf(drop), 1);
1709
+ }
1607
1710
  }
1608
- function Footer({ flash, errorCount, resultCount, confirm, filterActive = false }) {
1711
+ function Footer({ flash, errorCount, resultCount, confirm, filterActive = false, columns }) {
1609
1712
  if (confirm) return /* @__PURE__ */ jsxs(Box, {
1610
1713
  marginTop: 1,
1611
1714
  justifyContent: "space-between",
@@ -1625,7 +1728,7 @@ function Footer({ flash, errorCount, resultCount, confirm, filterActive = false
1625
1728
  children: [/* @__PURE__ */ jsx(Text, {
1626
1729
  color: "gray",
1627
1730
  wrap: "truncate-end",
1628
- children: hintLine(filterActive, resultCount)
1731
+ children: hintLine(filterActive, resultCount, columns)
1629
1732
  }), flash ? /* @__PURE__ */ jsx(Text, {
1630
1733
  color: "green",
1631
1734
  wrap: "truncate-end",
@@ -1757,7 +1860,7 @@ function SearchInput({ query, pos, width, filterLabel }) {
1757
1860
  const sameApp = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
1758
1861
  function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboard }) {
1759
1862
  const { exit } = useApp();
1760
- const { stdout } = useStdout();
1863
+ const { columns, rows } = useTerminalSize();
1761
1864
  const [entries, setEntries] = useState(initial);
1762
1865
  const [mode, setMode] = useState("search");
1763
1866
  const [filter, setFilter] = useState({ type: "all" });
@@ -1786,8 +1889,8 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1786
1889
  const sel = results.length ? Math.min(selected, results.length - 1) : 0;
1787
1890
  const current = results[sel];
1788
1891
  currentRef.current = current;
1789
- const listHeight = visibleListHeight(stdout?.rows);
1790
- const { left, right } = columnWidths(stdout?.columns);
1892
+ const listHeight = visibleListHeight(rows);
1893
+ const { left, right } = columnWidths(columns);
1791
1894
  useInput((input, key) => {
1792
1895
  if (key.ctrl && input === "c") {
1793
1896
  exit();
@@ -1919,7 +2022,7 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1919
2022
  /* @__PURE__ */ jsx(SearchInput, {
1920
2023
  query: query.text,
1921
2024
  pos: query.pos,
1922
- width: searchFieldWidth(stdout?.columns, filterLabelText?.length ?? 0),
2025
+ width: searchFieldWidth(columns, filterLabelText?.length ?? 0),
1923
2026
  filterLabel: filterLabelText
1924
2027
  }),
1925
2028
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(ResultList, {
@@ -1939,6 +2042,7 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1939
2042
  errorCount,
1940
2043
  resultCount: results.length,
1941
2044
  filterActive: filter.type !== "all",
2045
+ columns,
1942
2046
  confirm: pendingDelete ? `Delete '${pendingDelete.app}: ${pendingDelete.action}'?` : void 0
1943
2047
  })
1944
2048
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arthony/keybook",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },