@arthony/keybook 0.3.2 → 0.4.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.
package/README.md CHANGED
@@ -42,8 +42,9 @@ keybook check # validate your data files
42
42
  In the TUI: type to fuzzy-search, ↑/↓ to move, ⏎ to copy the shortcut (or a
43
43
  recipe's command) to the clipboard, ⎋ to quit.
44
44
 
45
- Press `⌃O` in the TUI to add an entry without leaving the search screen. Or
46
- script it: `keybook add --app Fork --action 'Push' --keys 'shift cmd p' --tags push`
45
+ Manage entries without leaving the search screen: `⌃O` to add, `⌃E` to edit the
46
+ selected entry (pre-filled form), `⌃X` to delete it (with a `y/n` confirm). Or
47
+ script an add: `keybook add --app Fork --action 'Push' --keys 'shift cmd p' --tags push`
47
48
  (`--keys` accepts glyphs or words; recipes use repeatable `--step`).
48
49
 
49
50
  ## Your data
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { Command } from "commander";
7
7
  import { Box, Text, render, useApp, useInput, useStdout } from "ink";
8
8
  import { createElement, useCallback, useMemo, useState } from "react";
9
9
  import { homedir } from "node:os";
10
- import { Document, parse, parseDocument } from "yaml";
10
+ import { Document, isMap, isSeq, parse, parseDocument } from "yaml";
11
11
  import { z } from "zod";
12
12
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
13
13
  import { Fzf, extendedMatch } from "fzf";
@@ -177,6 +177,8 @@ function loadEntries(dataDir) {
177
177
  }
178
178
  entries.push({
179
179
  app,
180
+ file,
181
+ index: i,
180
182
  ...parsed.data
181
183
  });
182
184
  });
@@ -310,6 +312,73 @@ function addEntry(dir, app, entry) {
310
312
  lines: [`✓ ${created ? "created" : "added to"} ${basename(file)}`]
311
313
  };
312
314
  }
315
+ function deleteEntry(dir, file, index, expectedAction) {
316
+ const path = join(dir, file);
317
+ let original;
318
+ try {
319
+ original = readFileSync(path, "utf8");
320
+ } catch (e) {
321
+ return err(path, [e.message]);
322
+ }
323
+ const doc = parseDocument(original);
324
+ const node = doc.getIn(["entries", index]);
325
+ if ((isMap(node) ? String(node.get("action") ?? "").trim() : void 0) !== expectedAction.trim()) return err(path, ["✗ entry changed on disk — reload and retry"]);
326
+ doc.deleteIn(["entries", index]);
327
+ const seq = doc.getIn(["entries"]);
328
+ const emptied = isSeq(seq) ? seq.items.length === 0 : true;
329
+ try {
330
+ if (emptied) unlinkSync(path);
331
+ else writeFileSync(path, doc.toString());
332
+ } catch (e) {
333
+ return err(path, [e.message]);
334
+ }
335
+ if (!emptied) {
336
+ const fileErr = loadEntries(dir).errors.find((e) => e.file === basename(file));
337
+ if (fileErr) {
338
+ writeFileSync(path, original);
339
+ return err(path, [`✗ ${fileErr.message}`]);
340
+ }
341
+ }
342
+ return {
343
+ ok: true,
344
+ file: path,
345
+ created: false,
346
+ lines: [emptied ? `✗ deleted '${expectedAction}' (removed empty ${basename(file)})` : `✗ deleted '${expectedAction}'`]
347
+ };
348
+ }
349
+ function editEntry(dir, file, index, entry, expectedAction) {
350
+ if ("app" in entry) return err("", ["Error: entry must not have an app field; use --app instead"]);
351
+ const parsed = entrySchema.safeParse(entry);
352
+ if (!parsed.success) return err("", parsed.error.issues.map((i) => i.message));
353
+ const clean = buildClean(parsed.data);
354
+ const path = join(dir, file);
355
+ let original;
356
+ try {
357
+ original = readFileSync(path, "utf8");
358
+ } catch (e) {
359
+ return err(path, [e.message]);
360
+ }
361
+ const doc = parseDocument(original);
362
+ const node = doc.getIn(["entries", index]);
363
+ if ((isMap(node) ? String(node.get("action") ?? "").trim() : void 0) !== expectedAction.trim()) return err(path, ["✗ entry changed on disk — reload and retry"]);
364
+ doc.setIn(["entries", index], doc.createNode(clean));
365
+ try {
366
+ writeFileSync(path, doc.toString());
367
+ } catch (e) {
368
+ return err(path, [e.message]);
369
+ }
370
+ const fileErr = loadEntries(dir).errors.find((e) => e.file === basename(file));
371
+ if (fileErr) {
372
+ writeFileSync(path, original);
373
+ return err(path, [`✗ ${fileErr.message}`]);
374
+ }
375
+ return {
376
+ ok: true,
377
+ file: path,
378
+ created: false,
379
+ lines: [`✓ updated '${clean.action}'`]
380
+ };
381
+ }
313
382
  //#endregion
314
383
  //#region src/commands.ts
315
384
  function runPath(env = process.env) {
@@ -390,7 +459,7 @@ function Field({ label, value, focused }) {
390
459
  }) : null
391
460
  ] });
392
461
  }
393
- function FormFields({ draft, apps, appIndex, focused, existingTags }) {
462
+ function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp }) {
394
463
  const appChoices = [...apps, "Create new app…"];
395
464
  return /* @__PURE__ */ jsxs(Box, {
396
465
  flexDirection: "column",
@@ -398,7 +467,10 @@ function FormFields({ draft, apps, appIndex, focused, existingTags }) {
398
467
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
399
468
  color: focused === 0 ? "cyan" : "gray",
400
469
  children: "App".padEnd(8)
401
- }), draft.creatingApp ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: draft.newApp }), focused === 0 ? /* @__PURE__ */ jsx(Text, {
470
+ }), lockedApp ? /* @__PURE__ */ jsx(Text, {
471
+ color: "gray",
472
+ children: `${lockedApp} (locked)`
473
+ }) : draft.creatingApp ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: draft.newApp }), focused === 0 ? /* @__PURE__ */ jsx(Text, {
402
474
  inverse: true,
403
475
  children: " "
404
476
  }) : null] }) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? " (↑/↓)" : ""] })] }),
@@ -617,10 +689,25 @@ function draftToEntryInput(d) {
617
689
  if (d.notes.trim()) e.notes = d.notes.trim();
618
690
  return e;
619
691
  }
620
- function useAddForm(initialApp = "") {
692
+ /** Inverse of draftToEntryInput: seed a Draft from an existing entry for editing. */
693
+ function entryToDraft(app, e) {
694
+ const type = e.keys ? "shortcut" : e.command ? "command" : "recipe";
695
+ return {
696
+ ...emptyDraft,
697
+ app,
698
+ type,
699
+ action: e.action,
700
+ keys: e.keys ?? "",
701
+ command: e.command ?? "",
702
+ steps: e.steps ? [...e.steps] : [],
703
+ tags: (e.tags ?? []).join(", "),
704
+ notes: e.notes ?? ""
705
+ };
706
+ }
707
+ function useAddForm(initial = {}) {
621
708
  const [draft, setDraft] = useState(() => ({
622
709
  ...emptyDraft,
623
- app: initialApp
710
+ ...initial
624
711
  }));
625
712
  const update = (patch) => setDraft((d) => ({
626
713
  ...d,
@@ -640,9 +727,9 @@ const TYPES = [
640
727
  "recipe"
641
728
  ];
642
729
  const LAST_FIELD = 5;
643
- function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget }) {
644
- const { draft, update, setDraft } = useAddForm(apps[0] ?? "");
645
- const [focused, setFocused] = useState(0);
730
+ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, lockedApp, title }) {
731
+ const { draft, update, setDraft } = useAddForm(initial ?? { app: apps[0] ?? "" });
732
+ const [focused, setFocused] = useState(lockedApp ? 1 : 0);
646
733
  const [appIndex, setAppIndex] = useState(0);
647
734
  const [screen, setScreen] = useState("form");
648
735
  const [hint, setHint] = useState("");
@@ -682,7 +769,7 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
682
769
  }
683
770
  if (key.escape) return onCancel();
684
771
  if (key.ctrl && input === "n") return setFocused((f) => Math.min(f + 1, LAST_FIELD));
685
- if (key.ctrl && input === "p") return setFocused((f) => Math.max(f - 1, 0));
772
+ if (key.ctrl && input === "p") return setFocused((f) => Math.max(f - 1, lockedApp ? 1 : 0));
686
773
  if (focused === 0) {
687
774
  if (key.upArrow) {
688
775
  const next = Math.max(appIndex - 1, 0);
@@ -742,7 +829,7 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
742
829
  children: [
743
830
  /* @__PURE__ */ jsx(Text, {
744
831
  color: "cyan",
745
- children: "keybook add"
832
+ children: title ?? "keybook add"
746
833
  }),
747
834
  /* @__PURE__ */ jsx(Box, {
748
835
  marginTop: 1,
@@ -751,7 +838,8 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
751
838
  apps,
752
839
  appIndex,
753
840
  focused,
754
- existingTags
841
+ existingTags,
842
+ lockedApp
755
843
  })
756
844
  }),
757
845
  /* @__PURE__ */ jsx(Box, {
@@ -796,7 +884,20 @@ function search(entries, query) {
796
884
  }
797
885
  //#endregion
798
886
  //#region src/tui/Footer.tsx
799
- function Footer({ flash, errorCount, resultCount }) {
887
+ function Footer({ flash, errorCount, resultCount, confirm }) {
888
+ if (confirm) return /* @__PURE__ */ jsxs(Box, {
889
+ marginTop: 1,
890
+ justifyContent: "space-between",
891
+ children: [/* @__PURE__ */ jsx(Text, {
892
+ color: "yellow",
893
+ wrap: "truncate-end",
894
+ children: confirm
895
+ }), /* @__PURE__ */ jsx(Text, {
896
+ color: "yellow",
897
+ bold: true,
898
+ children: " y / n"
899
+ })]
900
+ });
800
901
  return /* @__PURE__ */ jsxs(Box, {
801
902
  marginTop: 1,
802
903
  justifyContent: "space-between",
@@ -804,7 +905,7 @@ function Footer({ flash, errorCount, resultCount }) {
804
905
  color: "gray",
805
906
  wrap: "truncate-end",
806
907
  children: [
807
- "↑↓ move ⏎ copy ⌃O add quit ⌥⌫/⌃W del ⌃U clear (",
908
+ "↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⎋ quit ⌃U clear (",
808
909
  resultCount,
809
910
  ")"
810
911
  ]
@@ -972,6 +1073,8 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
972
1073
  const { stdout } = useStdout();
973
1074
  const [entries, setEntries] = useState(initial);
974
1075
  const [mode, setMode] = useState("search");
1076
+ const [editTarget, setEditTarget] = useState(null);
1077
+ const [pendingDelete, setPendingDelete] = useState(null);
975
1078
  const [query, setQuery] = useState("");
976
1079
  const [selected, setSelected] = useState(0);
977
1080
  const [flash, setFlash] = useState("");
@@ -984,8 +1087,24 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
984
1087
  const listHeight = visibleListHeight(stdout?.rows);
985
1088
  const { left, right } = columnWidths(stdout?.columns);
986
1089
  useInput((input, key) => {
1090
+ if (key.ctrl && input === "c") {
1091
+ exit();
1092
+ return;
1093
+ }
1094
+ if (pendingDelete) {
1095
+ if ((key.return || input === "y" || input === "Y") && dataDir) {
1096
+ const r = deleteEntry(dataDir, pendingDelete.file, pendingDelete.index, pendingDelete.action.trim());
1097
+ if (r.ok) {
1098
+ reload();
1099
+ setSelected(0);
1100
+ }
1101
+ setFlash(r.lines[0] ?? (r.ok ? "✗ deleted" : "✗ delete failed"));
1102
+ } else setFlash("");
1103
+ setPendingDelete(null);
1104
+ return;
1105
+ }
987
1106
  if (!key.return && flash) setFlash("");
988
- if (key.escape || key.ctrl && input === "c") {
1107
+ if (key.escape) {
989
1108
  exit();
990
1109
  return;
991
1110
  }
@@ -993,6 +1112,15 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
993
1112
  setMode("add");
994
1113
  return;
995
1114
  }
1115
+ if (dataDir && current && key.ctrl && input === "e") {
1116
+ setEditTarget(current);
1117
+ setMode("edit");
1118
+ return;
1119
+ }
1120
+ if (dataDir && current && key.ctrl && input === "x") {
1121
+ setPendingDelete(current);
1122
+ return;
1123
+ }
996
1124
  if (key.downArrow || key.ctrl && input === "n") {
997
1125
  setSelected((s) => Math.min(s + 1, results.length - 1));
998
1126
  return;
@@ -1025,24 +1153,47 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1025
1153
  setSelected(0);
1026
1154
  }
1027
1155
  }, { isActive: mode === "search" });
1028
- if (mode === "add" && dataDir) {
1029
- const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1030
- return /* @__PURE__ */ jsx(AddEntryForm, {
1031
- apps: listApps(dataDir),
1032
- existingTags,
1033
- resolveTarget: (a) => resolveTargetFile(dataDir, a),
1034
- onSubmit: (app, entry) => addEntry(dataDir, app, entry),
1035
- onComplete: (result) => {
1036
- if (result.ok) {
1037
- reload();
1038
- setSelected(0);
1039
- setFlash(result.lines[0] ?? "✓ added");
1040
- }
1041
- setMode("search");
1042
- },
1043
- onCancel: () => setMode("search")
1044
- });
1045
- }
1156
+ const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1157
+ if (mode === "add" && dataDir) return /* @__PURE__ */ jsx(AddEntryForm, {
1158
+ apps: listApps(dataDir),
1159
+ existingTags,
1160
+ resolveTarget: (a) => resolveTargetFile(dataDir, a),
1161
+ onSubmit: (app, entry) => addEntry(dataDir, app, entry),
1162
+ onComplete: (result) => {
1163
+ if (result.ok) {
1164
+ reload();
1165
+ setSelected(0);
1166
+ setFlash(result.lines[0] ?? "✓ added");
1167
+ }
1168
+ setMode("search");
1169
+ },
1170
+ onCancel: () => setMode("search")
1171
+ });
1172
+ if (mode === "edit" && dataDir && editTarget) return /* @__PURE__ */ jsx(AddEntryForm, {
1173
+ apps: listApps(dataDir),
1174
+ existingTags,
1175
+ lockedApp: editTarget.app,
1176
+ initial: entryToDraft(editTarget.app, editTarget),
1177
+ title: `Edit entry — ${editTarget.app}`,
1178
+ resolveTarget: () => ({
1179
+ file: editTarget.file,
1180
+ created: false
1181
+ }),
1182
+ onSubmit: (_app, entry) => editEntry(dataDir, editTarget.file, editTarget.index, entry, editTarget.action),
1183
+ onComplete: (result) => {
1184
+ if (result.ok) {
1185
+ reload();
1186
+ setSelected(0);
1187
+ setFlash(result.lines[0] ?? "✓ updated");
1188
+ }
1189
+ setMode("search");
1190
+ setEditTarget(null);
1191
+ },
1192
+ onCancel: () => {
1193
+ setMode("search");
1194
+ setEditTarget(null);
1195
+ }
1196
+ });
1046
1197
  return /* @__PURE__ */ jsxs(Box, {
1047
1198
  flexDirection: "column",
1048
1199
  children: [
@@ -1060,7 +1211,8 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1060
1211
  /* @__PURE__ */ jsx(Footer, {
1061
1212
  flash,
1062
1213
  errorCount,
1063
- resultCount: results.length
1214
+ resultCount: results.length,
1215
+ confirm: pendingDelete ? `Delete '${pendingDelete.app}: ${pendingDelete.action}'?` : void 0
1064
1216
  })
1065
1217
  ]
1066
1218
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arthony/keybook",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/seed/vim.yaml CHANGED
@@ -64,3 +64,115 @@ entries:
64
64
  - action: Indent / outdent the line or selection
65
65
  keys: ">>, <<"
66
66
  tags: [indent]
67
+ - action: Move to end of word / end of WORD
68
+ keys: "e, E"
69
+ notes: e treats punctuation as a word boundary; E spans punctuation (whitespace-separated WORD).
70
+ tags: [word, motion, end]
71
+ - action: Move forward / back by WORD (whitespace-separated)
72
+ keys: "W, B"
73
+ notes: Capital variants of w / b — jump past punctuation in one step.
74
+ tags: [word, motion, big]
75
+ - action: Find a character on the current line
76
+ keys: "f, F, t, T"
77
+ notes: >-
78
+ After pressing, type the target character. f / F land ON it (forward / back);
79
+ t / T land just before it. ; repeats the same find, , reverses direction.
80
+ Searches like "fa" (find 'a') are great when paired with d / c (e.g. dt, deletes to before next ',').
81
+ tags: [find, char, line, motion, tip]
82
+ - action: Search forward / back for the word under the cursor
83
+ keys: "*, #"
84
+ notes: Use n / N afterwards to step through matches. Great for tracing usages.
85
+ tags: [search, word, find, navigate, tip]
86
+ - action: Jump to top / middle / bottom of the screen
87
+ keys: "H, M, L"
88
+ notes: Cursor only — doesn't scroll the viewport. Combine with zz to recenter after.
89
+ tags: [screen, viewport, motion]
90
+ - action: Scroll half / full page down / up
91
+ keys: "⌃D, ⌃U, ⌃F, ⌃B"
92
+ notes: Half-page (⌃D / ⌃U) is usually less jarring than full-page (⌃F / ⌃B).
93
+ tags: [scroll, page, motion]
94
+ - action: Recenter / top / bottom the current line in the viewport
95
+ keys: "zz, zt, zb"
96
+ notes: zz centers the current line, zt puts it at the top of the window, zb at the bottom.
97
+ tags: [scroll, center, recenter, viewport]
98
+ - action: Jump back / forward in the cursor history
99
+ keys: "⌃O, ⌃I"
100
+ notes: >-
101
+ Vim records every jump >1 line. ⌃O retraces backwards through your trail, ⌃I goes forward.
102
+ Indispensable for diving into code and bouncing back where you were.
103
+ tags: [jump, history, navigate, tip, productivity]
104
+ - action: Jump to local declaration / global definition under cursor
105
+ keys: "gd, gD"
106
+ notes: gd searches the current function/scope for the identifier under the cursor; gD searches the whole file.
107
+ tags: [definition, declaration, goto, tip]
108
+ - action: Open a new line below / above the current
109
+ keys: "o, O"
110
+ notes: Both enter Insert mode immediately on the new line.
111
+ tags: [insert, new line, edit]
112
+ - action: Insert at start / append at end of line
113
+ keys: "I, A"
114
+ notes: I jumps to the first non-blank character and enters Insert; A appends after the last character.
115
+ tags: [insert, append, line, edit]
116
+ - action: Change / delete to end of line
117
+ keys: "C, D"
118
+ notes: C is shorthand for c$ (change to end of line); D is d$ (delete to end). Quick rewrites.
119
+ tags: [change, delete, line, edit]
120
+ - action: Replace a single character / enter Replace mode
121
+ keys: "r, R"
122
+ notes: After r, type the replacement char (one key, doesn't enter Insert). R overwrites characters as you type until ⎋.
123
+ tags: [replace, overwrite, edit]
124
+ - action: Join the next line onto the current one
125
+ keys: "J"
126
+ notes: Adds a space between them. gJ joins without inserting a space.
127
+ tags: [join, line, edit]
128
+ - action: Toggle case of character / inner word
129
+ keys: "~, g~iw"
130
+ notes: ~ flips one char's case; g~iw flips the word under the cursor. gu / gU make a motion lower / upper-case.
131
+ tags: [case, toggle, edit]
132
+ - action: Split the window horizontally / vertically
133
+ keys: ":split⏎, :vsplit⏎"
134
+ notes: Short forms :sp and :vsp. After splitting, :e <file> opens a file in the focused split.
135
+ tags: [split, window, layout]
136
+ - action: Move focus between splits
137
+ keys: "⌃Wh, ⌃Wj, ⌃Wk, ⌃Wl"
138
+ notes: ⌃W followed by an hjkl direction. ⌃W⌃W cycles forward through splits.
139
+ tags: [split, window, navigate]
140
+ - action: List and switch buffers
141
+ keys: ":ls⏎, :b <n>⏎, :bn, :bp"
142
+ notes: :ls lists all open buffers with their numbers. :bn / :bp cycle next / previous.
143
+ tags: [buffer, switch, list, tip]
144
+ - action: Close the current buffer (keep the split layout)
145
+ keys: ":bd⏎"
146
+ notes: Drops the buffer but keeps the window. Add ! to discard unsaved changes.
147
+ tags: [buffer, close]
148
+ - action: Set a mark / jump back to it
149
+ keys: "m, ', `"
150
+ notes: >-
151
+ m{letter} sets a mark at the cursor; '{letter} jumps to its line, `{letter} (backtick) to the exact column.
152
+ Lowercase marks a–z are file-local; capital A–Z are global across files.
153
+ tags: [mark, jump, bookmark, "ma", "'a", tip]
154
+ - action: Record / play back a macro
155
+ keys: "q...q, @, @@"
156
+ notes: >-
157
+ q{letter} starts recording into a register; q stops. @{letter} replays. @@ re-runs the last macro.
158
+ Workflow — record one edit on the first item, then @a (or whatever register) on each subsequent occurrence.
159
+ tags: [macro, record, replay, automation, tip, productivity]
160
+ - action: Repeat the last ex (:) command
161
+ keys: "@:"
162
+ notes: "After running any :command once, @: replays it; @@ then keeps replaying."
163
+ tags: [repeat, command, tip]
164
+ - action: Toggle relative / absolute line numbers
165
+ keys: ":set rnu⏎, :set nornu⏎, :set nu⏎"
166
+ notes: >-
167
+ Relative numbers (rnu) show each line's distance from the cursor — "5j" or "12dd" become trivial to count.
168
+ Combine `set nu` + `set rnu` for a hybrid view. Put both in your vimrc to make it permanent.
169
+ tags: [option, line numbers, relative, productivity, tip]
170
+ - action: Insert the same text on every line of a column selection
171
+ steps:
172
+ - "⌃V to enter Visual-Block mode"
173
+ - Use j / k to extend the block over the lines you want
174
+ - Press I (capital) to insert at the LEFT edge of the block
175
+ - Type the text you want repeated on every line
176
+ - "Press ⎋ — wait a moment — the text duplicates onto every selected line"
177
+ notes: A (capital) does the same at the RIGHT edge. Game-changer for adding comment markers, trailing commas, or any column prefix/suffix.
178
+ tags: [visual block, multi line, insert, comment, prefix, tip, productivity]