bitty-tui 0.0.22 → 0.1.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/dist/app.js CHANGED
@@ -1,10 +1,15 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
+ import { useInput } from "ink";
3
4
  import { LoginView } from "./login/LoginView.js";
4
5
  import { DashboardView } from "./dashboard/DashboardView.js";
5
6
  import { useRefreshResize } from "./hooks/refresh-resize.js";
6
7
  export default function App() {
7
8
  const [view, setView] = useState("login");
8
9
  const r = useRefreshResize();
10
+ useInput((input, key) => {
11
+ if (input === "c" && key.ctrl)
12
+ process.exit(0);
13
+ });
9
14
  return (_jsxs(_Fragment, { children: [view === "login" ? (_jsx(LoginView, { onLogin: () => setView("dashboard") })) : (_jsx(DashboardView, { onLogout: () => setView("login") })), r] }));
10
15
  }
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { render } from "ink";
4
4
  import App from "./app.js";
5
5
  import { StatusMessageProvider } from "./hooks/status-message.js";
6
6
  import { MouseProvider } from "./hooks/use-mouse.js";
7
+ import { KeybindingsProvider } from "./hooks/keybindings.js";
7
8
  import { readPackageUpSync } from "read-package-up";
8
9
  import { art } from "./theme/art.js";
9
10
  import path from "node:path";
@@ -29,4 +30,10 @@ if (args.includes("--help") || args.includes("-h")) {
29
30
  `);
30
31
  process.exit(0);
31
32
  }
32
- render(_jsx(StatusMessageProvider, { children: _jsx(MouseProvider, { children: _jsx(App, {}) }) }));
33
+ render(_jsx(KeybindingsProvider, { children: _jsx(StatusMessageProvider, { children: _jsx(MouseProvider, { children: _jsx(App, {}) }) }) }), {
34
+ exitOnCtrlC: false,
35
+ kittyKeyboard: {
36
+ mode: "enabled",
37
+ flags: ["disambiguateEscapeCodes"],
38
+ },
39
+ });
@@ -6,6 +6,7 @@ import clipboard from "clipboardy";
6
6
  import chalk from "chalk";
7
7
  import { useStatusMessage } from "../hooks/status-message.js";
8
8
  import { useMouseTarget } from "../hooks/use-mouse.js";
9
+ import { matchBinding, useKeybindings } from "../hooks/keybindings.js";
9
10
  export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFocus, isActive, autoFocus, inline, multiline, maxLines = 1, onChange, onSubmit, onCopy, ...props }) => {
10
11
  const [cursor, setCursor] = useState(onChange ? value.length : 0);
11
12
  const [scrollOffset, setScrollOffset] = useState(0);
@@ -14,6 +15,7 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
14
15
  const { isFocused } = useFocus({ id: effectiveId, isActive, autoFocus });
15
16
  const { showStatusMessage } = useStatusMessage();
16
17
  const { focusNext } = useFocusManager();
18
+ const { keybindings } = useKeybindings();
17
19
  const boxRef = useRef(null);
18
20
  useMouseTarget(effectiveId, boxRef);
19
21
  const displayValue = useMemo(() => {
@@ -82,7 +84,7 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
82
84
  }
83
85
  }, [value, cursor, multiline, maxLines]);
84
86
  useInput((input, key) => {
85
- if (key.ctrl && input === "y") {
87
+ if (matchBinding(input, key, keybindings.copyField)) {
86
88
  if (onCopy) {
87
89
  onCopy(value);
88
90
  }
@@ -95,7 +97,7 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
95
97
  onChange?.(value.slice(0, Math.max(0, cursor - 1)) + value.slice(cursor));
96
98
  setCursor(cursor - 1);
97
99
  }
98
- else if (key.ctrl && input === "e") {
100
+ else if (matchBinding(input, key, keybindings.cursorEnd)) {
99
101
  if (multiline) {
100
102
  const nextNewline = value.indexOf("\n", cursor);
101
103
  if (nextNewline !== -1) {
@@ -105,7 +107,7 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
105
107
  }
106
108
  setCursor(value.length);
107
109
  }
108
- else if (key.ctrl && input === "a") {
110
+ else if (matchBinding(input, key, keybindings.cursorStart)) {
109
111
  if (multiline) {
110
112
  const prevNewline = value.lastIndexOf("\n", Math.max(0, cursor - 1));
111
113
  if (prevNewline !== -1) {
@@ -166,7 +168,17 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
166
168
  }
167
169
  }
168
170
  else if (key.return) {
169
- if (multiline && cursor > 0 && value[cursor - 1] === "\\") {
171
+ if (multiline && (key.shift || key.meta)) {
172
+ const newValue = value.slice(0, cursor) + "\n" + value.slice(cursor);
173
+ const newCursor = cursor + 1;
174
+ const newCurrentLine = newValue.substring(0, newCursor).split("\n").length - 1;
175
+ if (newCurrentLine >= scrollOffset + maxLines) {
176
+ setScrollOffset(newCurrentLine - maxLines + 1);
177
+ }
178
+ onChange?.(newValue);
179
+ setCursor(newCursor);
180
+ }
181
+ else if (multiline && cursor > 0 && value[cursor - 1] === "\\") {
170
182
  const newValue = value.slice(0, cursor - 1) + "\n" + value.slice(cursor);
171
183
  const newCurrentLine = newValue.substring(0, cursor).split("\n").length - 1;
172
184
  if (newCurrentLine >= scrollOffset + maxLines) {
@@ -1,15 +1,17 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useMemo } from "react";
3
3
  import { Box, Text, useFocusManager, useInput, useStdout } from "ink";
4
4
  import { TextInput } from "../components/TextInput.js";
5
5
  import { VaultList } from "./components/VaultList.js";
6
6
  import { CipherDetail } from "./components/CipherDetail.js";
7
7
  import { HelpBar } from "./components/HelpBar.js";
8
+ import { KeybindingsView } from "./KeybindingsView.js";
8
9
  import { primary } from "../theme/style.js";
9
10
  import { bwClient, clearConfig, createEmptyCipher, emptyCipher, useBwSync } from "../hooks/bw.js";
10
11
  import { useStatusMessage } from "../hooks/status-message.js";
11
12
  import { useMouseSubscribe } from "../hooks/use-mouse.js";
12
13
  import { TabButton } from "../components/TabButton.js";
14
+ import { matchBinding, useKeybindings } from "../hooks/keybindings.js";
13
15
  export function DashboardView({ onLogout }) {
14
16
  const { sync, error, fetchSync } = useBwSync();
15
17
  const [syncState, setSyncState] = useState(sync);
@@ -20,9 +22,11 @@ export function DashboardView({ onLogout }) {
20
22
  const [detailMode, setDetailMode] = useState("view");
21
23
  const [editedCipher, setEditedCipher] = useState(null);
22
24
  const [activeTab, setActiveTab] = useState("main");
25
+ const [showKeybindings, setShowKeybindings] = useState(false);
23
26
  const { focus, focusNext } = useFocusManager();
24
27
  const { stdout } = useStdout();
25
28
  const { statusMessage, statusMessageColor, showStatusMessage } = useStatusMessage();
29
+ const { keybindings } = useKeybindings();
26
30
  const filteredCiphers = useMemo(() => {
27
31
  return (syncState?.ciphers.filter((c) => {
28
32
  if (c.deletedDate)
@@ -73,11 +77,20 @@ export function DashboardView({ onLogout }) {
73
77
  showStatusMessage(error, "error");
74
78
  }, [error]);
75
79
  useInput(async (input, key) => {
76
- if (key.ctrl && input === "w") {
80
+ if (matchBinding(input, key, keybindings.logout)) {
77
81
  await logout();
78
82
  return;
79
83
  }
80
- if (key.shift && key.rightArrow) {
84
+ if (matchBinding(input, key, keybindings.openKeybindings)) {
85
+ setShowKeybindings(true);
86
+ return;
87
+ }
88
+ if (matchBinding(input, key, keybindings.refresh)) {
89
+ await fetchSync();
90
+ showStatusMessage("Refreshed!", "success");
91
+ return;
92
+ }
93
+ if (matchBinding(input, key, keybindings.nextTab)) {
81
94
  setActiveTab((prev) => {
82
95
  if (prev === "main")
83
96
  return "more";
@@ -87,7 +100,7 @@ export function DashboardView({ onLogout }) {
87
100
  });
88
101
  return;
89
102
  }
90
- if (key.shift && key.leftArrow) {
103
+ if (matchBinding(input, key, keybindings.prevTab)) {
91
104
  setActiveTab((prev) => {
92
105
  if (prev === "main")
93
106
  return syncState?.collections?.length ? "collections" : "more";
@@ -97,12 +110,12 @@ export function DashboardView({ onLogout }) {
97
110
  });
98
111
  return;
99
112
  }
100
- if (input === "/" && focusedComponent === "list") {
113
+ if (matchBinding(input, key, keybindings.focusSearch) && focusedComponent === "list") {
101
114
  setFocusedComponent("search");
102
115
  focus("search");
103
116
  return;
104
117
  }
105
- if (key.ctrl && input === "n") {
118
+ if (matchBinding(input, key, keybindings.newCipher)) {
106
119
  setDetailMode("new");
107
120
  setEditedCipher(emptyCipher);
108
121
  setFocusedComponent("detail");
@@ -131,105 +144,105 @@ export function DashboardView({ onLogout }) {
131
144
  setShowDetails(false);
132
145
  }
133
146
  }
134
- });
147
+ }, { isActive: !showKeybindings });
135
148
  useEffect(() => {
136
149
  if (showDetails)
137
150
  return;
138
151
  setShowDetails(true);
139
152
  setTimeout(focusNext, 50);
140
153
  }, [showDetails]);
141
- return (_jsxs(Box, { flexDirection: "column", width: "100%", height: stdout.rows - 2, children: [_jsx(Box, { borderStyle: "double", borderColor: primary, paddingX: 2, justifyContent: "center", flexShrink: 0, children: _jsx(Text, { bold: true, color: primary, children: "BiTTY" }) }), _jsxs(Box, { flexShrink: 0, children: [_jsx(Box, { width: "40%", children: _jsx(TextInput, { id: "search", placeholder: focusedComponent === "search" ? "" : "[/] Search in vault", value: searchQuery, isActive: false, onChange: setSearchQuery, onSubmit: () => {
142
- setFocusedComponent("list");
143
- focusNext();
144
- } }) }), _jsxs(Box, { width: "60%", paddingX: 1, justifyContent: "space-between", children: [statusMessage ? (_jsx(Box, { padding: 1, flexShrink: 1, children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) })) : (_jsx(Box, {})), selectedCipher && (_jsxs(Box, { gap: 1, flexShrink: 0, children: [_jsx(TabButton, { active: false, onClick: async () => {
145
- await fetchSync();
146
- showStatusMessage("Refreshed!", "success");
147
- }, children: "\uD83D\uDD04" }), _jsx(TabButton, { active: activeTab === "main", onClick: () => setActiveTab("main"), children: "Main" }), _jsx(TabButton, { active: activeTab === "more", onClick: () => setActiveTab("more"), children: "More" }), !!syncState?.collections?.length && (_jsx(TabButton, { active: activeTab === "collections", onClick: () => setActiveTab("collections"), children: "Collections" }))] }))] })] }), _jsxs(Box, { minHeight: 20, flexGrow: 1, children: [_jsx(VaultList, { filteredCiphers: filteredCiphers, isFocused: ["list", "search"].includes(focusedComponent), selected: listIndex, onSelect: (index) => setListSelected(filteredCiphers[index] || null) }), _jsx(CipherDetail, { selectedCipher: showDetails ? selectedCipher : null, mode: detailMode, activeTab: activeTab, collections: writableCollections, organizations: organizations, isFocused: focusedComponent === "detail", onTypeChange: (type) => {
148
- const fresh = createEmptyCipher(type);
149
- fresh.name = editedCipher?.name ?? "";
150
- fresh.notes = editedCipher?.notes ?? null;
151
- fresh.collectionIds = editedCipher?.collectionIds ?? [];
152
- fresh.organizationId = editedCipher?.organizationId ?? null;
153
- setEditedCipher(fresh);
154
- }, onReset: async () => {
155
- bwClient.decryptedSyncCache = null;
156
- await fetchSync(false);
157
- showStatusMessage("Resetted!", "success");
158
- }, onChange: (cipher) => {
159
- if (detailMode === "new") {
160
- setEditedCipher(cipher);
161
- return;
162
- }
163
- const updatedCiphers = syncState?.ciphers.map((c) => c.id === cipher.id ? cipher : c);
164
- setSyncState((prev) => ({ ...prev, ciphers: updatedCiphers }));
165
- }, onDelete: async (cipher) => {
166
- showStatusMessage("Deleting...");
167
- try {
168
- await bwClient.deleteSecret(cipher.id);
169
- fetchSync();
170
- showStatusMessage("Deleted!", "success");
171
- setFocusedComponent("list");
172
- setActiveTab("main");
173
- }
174
- catch (e) {
175
- showStatusMessage("Delete error", "error");
176
- }
177
- }, onSave: async (cipher) => {
178
- showStatusMessage("Saving...");
179
- if (detailMode === "new") {
180
- try {
181
- if (cipher.organizationId && !cipher.collectionIds?.length) {
182
- showStatusMessage("Select at least one collection", "error");
154
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: stdout.rows - 2, children: [_jsx(Box, { borderStyle: "double", borderColor: primary, paddingX: 2, justifyContent: "center", flexShrink: 0, children: _jsx(Text, { bold: true, color: primary, children: "BiTTY" }) }), showKeybindings ? (_jsx(KeybindingsView, { onClose: () => setShowKeybindings(false) })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexShrink: 0, children: [_jsx(Box, { width: "40%", children: _jsx(TextInput, { id: "search", placeholder: focusedComponent === "search" ? "" : "[/] Search in vault", value: searchQuery, isActive: false, onChange: setSearchQuery, onSubmit: () => {
155
+ setFocusedComponent("list");
156
+ focusNext();
157
+ } }) }), _jsxs(Box, { width: "60%", paddingX: 1, justifyContent: "space-between", children: [statusMessage ? (_jsx(Box, { padding: 1, flexShrink: 1, children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) })) : (_jsx(Box, {})), _jsxs(Box, { gap: 1, flexShrink: 0, children: [_jsx(TabButton, { active: false, onClick: () => setShowKeybindings(true), children: "\u2699" }), _jsx(TabButton, { active: false, onClick: async () => {
158
+ await fetchSync();
159
+ showStatusMessage("Refreshed!", "success");
160
+ }, children: "\u27F3" }), selectedCipher && (_jsxs(_Fragment, { children: [_jsx(TabButton, { active: activeTab === "main", onClick: () => setActiveTab("main"), children: "Main" }), _jsx(TabButton, { active: activeTab === "more", onClick: () => setActiveTab("more"), children: "More" }), !!syncState?.collections?.length && (_jsx(TabButton, { active: activeTab === "collections", onClick: () => setActiveTab("collections"), children: "Collections" }))] }))] })] })] }), _jsxs(Box, { minHeight: 20, flexGrow: 1, children: [_jsx(VaultList, { filteredCiphers: filteredCiphers, isFocused: ["list", "search"].includes(focusedComponent), selected: listIndex, onSelect: (index) => setListSelected(filteredCiphers[index] || null) }), _jsx(CipherDetail, { selectedCipher: showDetails ? selectedCipher : null, mode: detailMode, activeTab: activeTab, collections: writableCollections, organizations: organizations, isFocused: focusedComponent === "detail", onTypeChange: (type) => {
161
+ const fresh = createEmptyCipher(type);
162
+ fresh.name = editedCipher?.name ?? "";
163
+ fresh.notes = editedCipher?.notes ?? null;
164
+ fresh.collectionIds = editedCipher?.collectionIds ?? [];
165
+ fresh.organizationId = editedCipher?.organizationId ?? null;
166
+ setEditedCipher(fresh);
167
+ }, onReset: async () => {
168
+ bwClient.decryptedSyncCache = null;
169
+ await fetchSync(false);
170
+ showStatusMessage("Resetted!", "success");
171
+ }, onChange: (cipher) => {
172
+ if (detailMode === "new") {
173
+ setEditedCipher(cipher);
183
174
  return;
184
175
  }
185
- await bwClient.createSecret(cipher);
186
- fetchSync();
187
- showStatusMessage("Saved!", "success");
188
- setDetailMode("view");
189
- setFocusedComponent("list");
190
- setActiveTab("main");
191
- }
192
- catch (e) {
193
- showStatusMessage("Synchronization error", "error");
194
- }
195
- }
196
- else {
197
- try {
198
- const original = sync?.ciphers.find((c) => c.id === cipher.id);
199
- const originalOrgId = original?.organizationId ?? null;
200
- const originalCollections = original?.collectionIds ?? [];
201
- const newCollections = [...cipher.collectionIds ?? []];
202
- // Sharing: personal cipher being assigned to an org
203
- if (!originalOrgId && cipher.organizationId) {
204
- if (!newCollections.length) {
205
- showStatusMessage("Select at least one collection to share", "error");
206
- return;
207
- }
208
- await bwClient.shareCipher(cipher.id, cipher, newCollections);
176
+ const updatedCiphers = syncState?.ciphers.map((c) => c.id === cipher.id ? cipher : c);
177
+ setSyncState((prev) => ({ ...prev, ciphers: updatedCiphers }));
178
+ }, onDelete: async (cipher) => {
179
+ showStatusMessage("Deleting...");
180
+ try {
181
+ await bwClient.deleteSecret(cipher.id);
209
182
  fetchSync();
210
- showStatusMessage("Shared!", "success");
183
+ showStatusMessage("Deleted!", "success");
211
184
  setFocusedComponent("list");
212
185
  setActiveTab("main");
213
- return;
214
186
  }
215
- const updated = await bwClient.updateSecret(cipher.id, cipher);
216
- const collectionsChanged = originalCollections.length !== newCollections.length ||
217
- originalCollections.some((id) => !newCollections.includes(id));
218
- if (collectionsChanged) {
219
- await bwClient.updateCollections(cipher.id, newCollections);
187
+ catch (e) {
188
+ showStatusMessage("Delete error", "error");
220
189
  }
221
- if (!updated && !collectionsChanged) {
222
- showStatusMessage("Nothing to save");
223
- return;
190
+ }, onSave: async (cipher) => {
191
+ showStatusMessage("Saving...");
192
+ if (detailMode === "new") {
193
+ try {
194
+ if (cipher.organizationId && !cipher.collectionIds?.length) {
195
+ showStatusMessage("Select at least one collection", "error");
196
+ return;
197
+ }
198
+ await bwClient.createSecret(cipher);
199
+ fetchSync();
200
+ showStatusMessage("Saved!", "success");
201
+ setDetailMode("view");
202
+ setFocusedComponent("list");
203
+ setActiveTab("main");
204
+ }
205
+ catch (e) {
206
+ showStatusMessage("Synchronization error", "error");
207
+ }
208
+ }
209
+ else {
210
+ try {
211
+ const original = sync?.ciphers.find((c) => c.id === cipher.id);
212
+ const originalOrgId = original?.organizationId ?? null;
213
+ const originalCollections = original?.collectionIds ?? [];
214
+ const newCollections = [...cipher.collectionIds ?? []];
215
+ // Sharing: personal cipher being assigned to an org
216
+ if (!originalOrgId && cipher.organizationId) {
217
+ if (!newCollections.length) {
218
+ showStatusMessage("Select at least one collection to share", "error");
219
+ return;
220
+ }
221
+ await bwClient.shareCipher(cipher.id, cipher, newCollections);
222
+ fetchSync();
223
+ showStatusMessage("Shared!", "success");
224
+ setFocusedComponent("list");
225
+ setActiveTab("main");
226
+ return;
227
+ }
228
+ const updated = await bwClient.updateSecret(cipher.id, cipher);
229
+ const collectionsChanged = originalCollections.length !== newCollections.length ||
230
+ originalCollections.some((id) => !newCollections.includes(id));
231
+ if (collectionsChanged) {
232
+ await bwClient.updateCollections(cipher.id, newCollections);
233
+ }
234
+ if (!updated && !collectionsChanged) {
235
+ showStatusMessage("Nothing to save");
236
+ return;
237
+ }
238
+ fetchSync();
239
+ showStatusMessage("Saved!", "success");
240
+ setFocusedComponent("list");
241
+ setActiveTab("main");
242
+ }
243
+ catch (e) {
244
+ showStatusMessage("Synchronization error", "error");
245
+ }
224
246
  }
225
- fetchSync();
226
- showStatusMessage("Saved!", "success");
227
- setFocusedComponent("list");
228
- setActiveTab("main");
229
- }
230
- catch (e) {
231
- showStatusMessage("Synchronization error", "error");
232
- }
233
- }
234
- } })] }), _jsx(HelpBar, { focus: focusedComponent, cipher: selectedCipher, mode: detailMode })] }));
247
+ } })] }), _jsx(HelpBar, { focus: focusedComponent, cipher: selectedCipher, mode: detailMode })] }))] }));
235
248
  }
@@ -0,0 +1,5 @@
1
+ type Props = {
2
+ onClose: () => void;
3
+ };
4
+ export declare function KeybindingsView({ onClose }: Props): import("react/jsx-runtime").JSX.Element;
5
+ export {};
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput, useStdout } from "ink";
3
+ import { useState } from "react";
4
+ import { primary } from "../theme/style.js";
5
+ import { DEFAULT_KEYBINDINGS, KEYBINDING_LABELS, KEYBINDING_ORDER, captureBinding, displayBinding, useKeybindings, } from "../hooks/keybindings.js";
6
+ export function KeybindingsView({ onClose }) {
7
+ const { keybindings, updateKeybindings } = useKeybindings();
8
+ const { stdout } = useStdout();
9
+ const [selectedIndex, setSelectedIndex] = useState(0);
10
+ const [capturing, setCapturing] = useState(false);
11
+ const [capturingId, setCapturingId] = useState(null);
12
+ const [justReset, setJustReset] = useState(false);
13
+ // Total items: all bindings + "Reset to defaults" row
14
+ const totalItems = KEYBINDING_ORDER.length + 1;
15
+ const isResetSelected = selectedIndex === KEYBINDING_ORDER.length;
16
+ // How many list rows can fit on screen (leave room for header, hints, reset row)
17
+ const maxVisible = Math.max(stdout.rows - 10, 5);
18
+ const [offset, setOffset] = useState(0);
19
+ useInput(async (input, key) => {
20
+ if (capturing) {
21
+ // Bare Escape cancels capture without saving
22
+ if (key.escape && !key.ctrl && !key.shift && !key.meta) {
23
+ setCapturing(false);
24
+ setCapturingId(null);
25
+ return;
26
+ }
27
+ const binding = captureBinding(input, key);
28
+ if (binding && capturingId) {
29
+ await updateKeybindings({ ...keybindings, [capturingId]: binding });
30
+ setCapturing(false);
31
+ setCapturingId(null);
32
+ }
33
+ return;
34
+ }
35
+ if (key.upArrow) {
36
+ const newIndex = Math.max(0, selectedIndex - 1);
37
+ setSelectedIndex(newIndex);
38
+ if (newIndex < offset)
39
+ setOffset(newIndex);
40
+ return;
41
+ }
42
+ if (key.downArrow) {
43
+ const newIndex = Math.min(totalItems - 1, selectedIndex + 1);
44
+ setSelectedIndex(newIndex);
45
+ if (newIndex >= offset + maxVisible)
46
+ setOffset(newIndex - maxVisible + 1);
47
+ return;
48
+ }
49
+ if (key.escape) {
50
+ onClose();
51
+ return;
52
+ }
53
+ if (key.return) {
54
+ if (isResetSelected) {
55
+ await updateKeybindings({ ...DEFAULT_KEYBINDINGS });
56
+ setJustReset(true);
57
+ setTimeout(() => setJustReset(false), 1500);
58
+ return;
59
+ }
60
+ const id = KEYBINDING_ORDER[selectedIndex];
61
+ if (id) {
62
+ setCapturingId(id);
63
+ setCapturing(true);
64
+ }
65
+ }
66
+ });
67
+ const visibleBindings = KEYBINDING_ORDER.slice(offset, offset + maxVisible);
68
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: primary, paddingX: 2, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: primary, children: "Keybindings" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "\u2191/\u2193" }), " Navigate ", _jsx(Text, { bold: true, children: "Enter" }), " Edit ", _jsx(Text, { bold: true, children: "Esc" }), " Close"] }) }), _jsxs(Box, { paddingX: 1, marginBottom: 1, children: [_jsx(Box, { width: 36, children: _jsx(Text, { color: "#9f9f9f", bold: true, children: "Action" }) }), _jsx(Text, { color: "#9f9f9f", bold: true, children: "Binding" })] }), visibleBindings.map((id, visibleIdx) => {
69
+ const actualIndex = visibleIdx + offset;
70
+ const isSelected = selectedIndex === actualIndex;
71
+ const isEditing = capturing && capturingId === id;
72
+ const binding = keybindings[id];
73
+ return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: isSelected ? primary : "#9f9f9f", children: isSelected ? "▶" : " " }) }), _jsx(Box, { width: 34, flexShrink: 0, children: _jsx(Text, { color: isSelected ? "white" : "default", wrap: "truncate", children: KEYBINDING_LABELS[id] }) }), _jsx(Text, { color: isEditing ? "yellow" : isSelected ? primary : "#9f9f9f", children: isEditing ? "Press new key..." : displayBinding(binding) })] }, id));
74
+ }), KEYBINDING_ORDER.length > maxVisible && (_jsx(Box, { paddingX: 3, children: _jsx(Text, { color: "#9f9f9f", children: offset + maxVisible < KEYBINDING_ORDER.length ? "▼ more" : "" }) })), selectedIndex >= offset + maxVisible - 1 ||
75
+ isResetSelected ||
76
+ KEYBINDING_ORDER.length <= maxVisible ? (_jsxs(Box, { paddingX: 1, marginTop: 1, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: isResetSelected ? primary : "#9f9f9f", children: isResetSelected ? "▶" : " " }) }), _jsx(Text, { color: isResetSelected ? "white" : "#9f9f9f", children: justReset ? "Reset done!" : "Reset to defaults" })] })) : null] }));
77
+ }
@@ -1,34 +1,37 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { CipherType } from "../../clients/bw.js";
4
+ import { displayBinding, useKeybindings } from "../../hooks/keybindings.js";
4
5
  export function HelpBar({ focus, cipher, mode, }) {
5
- return (_jsxs(Box, { borderStyle: "single", borderColor: "#9f9f9f", marginTop: 1, paddingX: 1, flexShrink: 0, justifyContent: "space-around", children: [_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "/ " }), "Search"] }), focus === "list" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "\u2191/\u2193 " }), "Navigate"] })) : focus === "detail" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Tab/Enter " }), "Next Field"] })) : (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Esc " }), "Clear Search"] })), focus === "list" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Tab/Enter " }), "Select"] })) : focus === "detail" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Esc " }), "Focus List"] })) : (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Tab/Enter " }), "Focus List"] })), mode !== "new" && (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Ctrl+n " }), "New"] })), ...copyButtons(focus, cipher), _jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Ctrl+w " }), "Logout"] })] }));
6
+ const { keybindings } = useKeybindings();
7
+ const kb = keybindings;
8
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "#9f9f9f", marginTop: 1, paddingX: 1, flexShrink: 0, justifyContent: "space-around", children: [_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.focusSearch), " "] }), "Search"] }), focus === "list" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "\u2191/\u2193 " }), "Navigate"] })) : focus === "detail" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Tab/Enter " }), "Next Field"] })) : (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Esc " }), "Clear Search"] })), focus === "list" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Tab/Enter " }), "Select"] })) : focus === "detail" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Esc " }), "Focus List"] })) : (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Tab/Enter " }), "Focus List"] })), mode !== "new" && (_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.newCipher), " "] }), "New"] })), ...copyButtons(focus, cipher, kb), _jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.logout), " "] }), "Logout"] })] }));
6
9
  }
7
- const copyButtons = (focus, cipher) => {
10
+ const copyButtons = (focus, cipher, kb) => {
8
11
  if (focus === "detail") {
9
12
  return [
10
- _jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Ctrl+y " }), "Copy Field"] }),
13
+ _jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copyField), " "] }), "Copy Field"] }),
11
14
  ];
12
15
  }
13
16
  switch (cipher?.type) {
14
17
  case CipherType.Login:
15
18
  return [
16
- _jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Ctrl+y " }), "Copy Password"] }, "copy-password"),
19
+ _jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copyPrimary), " "] }), "Copy Password"] }, "copy-password"),
17
20
  ...(cipher.login?.totp
18
21
  ? [
19
- _jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Ctrl+t " }), "Copy TOTP"] }, "copy-totp"),
22
+ _jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copyTotp), " "] }), "Copy TOTP"] }, "copy-totp"),
20
23
  ]
21
24
  : []),
22
- _jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Ctrl+u " }), "Copy Username"] }, "copy-username"),
25
+ _jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copySecondary), " "] }), "Copy Username"] }, "copy-username"),
23
26
  ];
24
27
  case CipherType.SecureNote:
25
28
  return [
26
- _jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Ctrl+y " }), "Copy Note"] }, "copy-note"),
29
+ _jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copyPrimary), " "] }), "Copy Note"] }, "copy-note"),
27
30
  ];
28
31
  case CipherType.SSHKey:
29
32
  return [
30
- _jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Ctrl+y " }), "Copy Private Key"] }, "copy-private-key"),
31
- _jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Ctrl+u " }), "Copy Public Key"] }, "copy-public-key"),
33
+ _jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copyPrimary), " "] }), "Copy Private Key"] }, "copy-private-key"),
34
+ _jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copySecondary), " "] }), "Copy Public Key"] }, "copy-public-key"),
32
35
  ];
33
36
  default:
34
37
  return [];
@@ -7,6 +7,7 @@ import clipboard from "clipboardy";
7
7
  import { useStatusMessage } from "../../hooks/status-message.js";
8
8
  import { useRef } from "react";
9
9
  import { useMouseTarget } from "../../hooks/use-mouse.js";
10
+ import { matchBinding, useKeybindings } from "../../hooks/keybindings.js";
10
11
  const getTypeIcon = (type) => {
11
12
  switch (type) {
12
13
  case CipherType.Login:
@@ -24,6 +25,7 @@ const getTypeIcon = (type) => {
24
25
  export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
25
26
  const { stdout } = useStdout();
26
27
  const { showStatusMessage } = useStatusMessage();
28
+ const { keybindings } = useKeybindings();
27
29
  const boxRef = useRef(null);
28
30
  const scrollOffsetRef = useRef(0);
29
31
  useMouseTarget("list", boxRef, {
@@ -40,7 +42,7 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
40
42
  const cipher = selected !== null ? filteredCiphers[selected] : null;
41
43
  let field;
42
44
  let fldName;
43
- if (key.ctrl && input === "y") {
45
+ if (matchBinding(input, key, keybindings.copyPrimary)) {
44
46
  switch (cipher?.type) {
45
47
  case CipherType.Login:
46
48
  field = cipher.login?.password;
@@ -56,7 +58,7 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
56
58
  break;
57
59
  }
58
60
  }
59
- else if (key.ctrl && input === "u") {
61
+ else if (matchBinding(input, key, keybindings.copySecondary)) {
60
62
  switch (cipher?.type) {
61
63
  case CipherType.Login:
62
64
  field = cipher.login?.username;
@@ -68,7 +70,7 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
68
70
  break;
69
71
  }
70
72
  }
71
- else if (key.ctrl && input === "t") {
73
+ else if (matchBinding(input, key, keybindings.copyTotp)) {
72
74
  if (cipher?.type === CipherType.Login) {
73
75
  field = cipher.login?.currentTotp;
74
76
  fldName = "TOTP";
@@ -0,0 +1,34 @@
1
+ import { type ReactNode } from "react";
2
+ import type { Key } from "ink";
3
+ export type KeybindingId = "logout" | "nextTab" | "prevTab" | "focusSearch" | "newCipher" | "copyPrimary" | "copySecondary" | "copyTotp" | "copyField" | "cursorEnd" | "cursorStart" | "openKeybindings" | "refresh";
4
+ export interface KeyBinding {
5
+ ctrl?: boolean;
6
+ shift?: boolean;
7
+ meta?: boolean;
8
+ key: string;
9
+ }
10
+ export type KeybindingsMap = Record<KeybindingId, KeyBinding>;
11
+ export declare const KEYBINDING_LABELS: Record<KeybindingId, string>;
12
+ export declare const KEYBINDING_ORDER: KeybindingId[];
13
+ export declare const DEFAULT_KEYBINDINGS: KeybindingsMap;
14
+ export declare function loadKeybindings(): Promise<KeybindingsMap>;
15
+ export declare function saveKeybindings(keybindings: KeybindingsMap): Promise<void>;
16
+ export declare function displayBinding(binding: KeyBinding): string;
17
+ /**
18
+ * Capture a key combination from raw Ink input.
19
+ * Returns null if the combination should not be captured (Ctrl+C, Ctrl+Z, bare Escape).
20
+ */
21
+ export declare function captureBinding(input: string, key: Key): KeyBinding | null;
22
+ /**
23
+ * Returns true if the given (input, key) pair matches the binding.
24
+ */
25
+ export declare function matchBinding(input: string, key: Key, binding: KeyBinding): boolean;
26
+ interface KeybindingsContextValue {
27
+ keybindings: KeybindingsMap;
28
+ updateKeybindings: (newBindings: KeybindingsMap) => Promise<void>;
29
+ }
30
+ export declare function KeybindingsProvider({ children }: {
31
+ children: ReactNode;
32
+ }): import("react/jsx-runtime").JSX.Element;
33
+ export declare function useKeybindings(): KeybindingsContextValue;
34
+ export {};
@@ -0,0 +1,202 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import os from "os";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { createContext, useContext, useState, useEffect, } from "react";
6
+ export const KEYBINDING_LABELS = {
7
+ logout: "Logout",
8
+ nextTab: "Next Tab",
9
+ prevTab: "Previous Tab",
10
+ focusSearch: "Focus Search",
11
+ newCipher: "New Cipher",
12
+ copyPrimary: "Copy Primary (Password/Note/Key)",
13
+ copySecondary: "Copy Secondary (Username/Pub Key)",
14
+ copyTotp: "Copy TOTP",
15
+ copyField: "Copy Field (in editor)",
16
+ cursorEnd: "Cursor to End",
17
+ cursorStart: "Cursor to Start",
18
+ openKeybindings: "Open Keybindings",
19
+ refresh: "Refresh Vault",
20
+ };
21
+ export const KEYBINDING_ORDER = [
22
+ "logout",
23
+ "nextTab",
24
+ "prevTab",
25
+ "focusSearch",
26
+ "newCipher",
27
+ "copyPrimary",
28
+ "copySecondary",
29
+ "copyTotp",
30
+ "copyField",
31
+ "cursorEnd",
32
+ "cursorStart",
33
+ "openKeybindings",
34
+ "refresh",
35
+ ];
36
+ export const DEFAULT_KEYBINDINGS = {
37
+ logout: { ctrl: true, key: "w" },
38
+ nextTab: { shift: true, key: "rightArrow" },
39
+ prevTab: { shift: true, key: "leftArrow" },
40
+ focusSearch: { key: "/" },
41
+ newCipher: { ctrl: true, key: "n" },
42
+ copyPrimary: { ctrl: true, key: "y" },
43
+ copySecondary: { ctrl: true, key: "u" },
44
+ copyTotp: { ctrl: true, key: "t" },
45
+ copyField: { ctrl: true, key: "y" },
46
+ cursorEnd: { ctrl: true, key: "e" },
47
+ cursorStart: { ctrl: true, key: "a" },
48
+ openKeybindings: { ctrl: true, key: "k" },
49
+ refresh: { ctrl: true, key: "r" },
50
+ };
51
+ const configDir = path.join(os.homedir(), ".config", "bitty");
52
+ const keybindsPath = path.join(configDir, "keybinds.json");
53
+ export async function loadKeybindings() {
54
+ try {
55
+ if (fs.existsSync(keybindsPath)) {
56
+ const content = await fs.promises.readFile(keybindsPath, "utf-8");
57
+ const overrides = JSON.parse(content);
58
+ return { ...DEFAULT_KEYBINDINGS, ...overrides };
59
+ }
60
+ }
61
+ catch { }
62
+ return { ...DEFAULT_KEYBINDINGS };
63
+ }
64
+ export async function saveKeybindings(keybindings) {
65
+ const overrides = {};
66
+ for (const [id, binding] of Object.entries(keybindings)) {
67
+ const def = DEFAULT_KEYBINDINGS[id];
68
+ if (JSON.stringify(binding) !== JSON.stringify(def)) {
69
+ overrides[id] = binding;
70
+ }
71
+ }
72
+ await fs.promises.mkdir(configDir, { recursive: true });
73
+ await fs.promises.writeFile(keybindsPath, JSON.stringify(overrides, null, 2));
74
+ }
75
+ const SPECIAL_KEY_DISPLAY = {
76
+ upArrow: "↑",
77
+ downArrow: "↓",
78
+ leftArrow: "←",
79
+ rightArrow: "→",
80
+ escape: "Esc",
81
+ return: "Enter",
82
+ tab: "Tab",
83
+ backspace: "Backspace",
84
+ delete: "Delete",
85
+ pageUp: "PgUp",
86
+ pageDown: "PgDn",
87
+ space: "Space",
88
+ };
89
+ export function displayBinding(binding) {
90
+ const parts = [];
91
+ if (binding.ctrl)
92
+ parts.push("Ctrl");
93
+ if (binding.shift)
94
+ parts.push("Shift");
95
+ if (binding.meta)
96
+ parts.push("Meta");
97
+ const keyDisplay = SPECIAL_KEY_DISPLAY[binding.key] ?? binding.key.toUpperCase();
98
+ parts.push(keyDisplay);
99
+ return parts.join("+");
100
+ }
101
+ /**
102
+ * Capture a key combination from raw Ink input.
103
+ * Returns null if the combination should not be captured (Ctrl+C, Ctrl+Z, bare Escape).
104
+ */
105
+ export function captureBinding(input, key) {
106
+ // Never capture Ctrl+C or Ctrl+Z (terminal signals)
107
+ if (key.ctrl && (input === "c" || input === "z"))
108
+ return null;
109
+ // Bare Escape is used to cancel capture mode
110
+ if (key.escape && !key.ctrl && !key.shift && !key.meta)
111
+ return null;
112
+ let keyName;
113
+ if (key.upArrow)
114
+ keyName = "upArrow";
115
+ else if (key.downArrow)
116
+ keyName = "downArrow";
117
+ else if (key.leftArrow)
118
+ keyName = "leftArrow";
119
+ else if (key.rightArrow)
120
+ keyName = "rightArrow";
121
+ else if (key.return)
122
+ keyName = "return";
123
+ else if (key.tab)
124
+ keyName = "tab";
125
+ else if (key.backspace)
126
+ keyName = "backspace";
127
+ else if (key.delete)
128
+ keyName = "delete";
129
+ else if (key.pageUp)
130
+ keyName = "pageUp";
131
+ else if (key.pageDown)
132
+ keyName = "pageDown";
133
+ else if (input === " ")
134
+ keyName = "space";
135
+ else if (input)
136
+ keyName = input.toLowerCase();
137
+ else
138
+ return null;
139
+ return {
140
+ ctrl: key.ctrl || undefined,
141
+ shift: key.shift || undefined,
142
+ meta: key.meta || undefined,
143
+ key: keyName,
144
+ };
145
+ }
146
+ /**
147
+ * Returns true if the given (input, key) pair matches the binding.
148
+ */
149
+ export function matchBinding(input, key, binding) {
150
+ if (!!binding.ctrl !== !!key.ctrl)
151
+ return false;
152
+ if (!!binding.shift !== !!key.shift)
153
+ return false;
154
+ if (!!binding.meta !== !!key.meta)
155
+ return false;
156
+ switch (binding.key) {
157
+ case "upArrow":
158
+ return !!key.upArrow;
159
+ case "downArrow":
160
+ return !!key.downArrow;
161
+ case "leftArrow":
162
+ return !!key.leftArrow;
163
+ case "rightArrow":
164
+ return !!key.rightArrow;
165
+ case "escape":
166
+ return !!key.escape;
167
+ case "return":
168
+ return !!key.return;
169
+ case "tab":
170
+ return !!key.tab;
171
+ case "backspace":
172
+ return !!key.backspace;
173
+ case "delete":
174
+ return !!key.delete;
175
+ case "pageUp":
176
+ return !!key.pageUp;
177
+ case "pageDown":
178
+ return !!key.pageDown;
179
+ case "space":
180
+ return input === " ";
181
+ default:
182
+ return input === binding.key;
183
+ }
184
+ }
185
+ const KeybindingsContext = createContext({
186
+ keybindings: DEFAULT_KEYBINDINGS,
187
+ updateKeybindings: async () => { },
188
+ });
189
+ export function KeybindingsProvider({ children }) {
190
+ const [keybindings, setKeybindings] = useState(DEFAULT_KEYBINDINGS);
191
+ useEffect(() => {
192
+ loadKeybindings().then(setKeybindings);
193
+ }, []);
194
+ const updateKeybindings = async (newBindings) => {
195
+ await saveKeybindings(newBindings);
196
+ setKeybindings(newBindings);
197
+ };
198
+ return (_jsx(KeybindingsContext.Provider, { value: { keybindings, updateKeybindings }, children: children }));
199
+ }
200
+ export function useKeybindings() {
201
+ return useContext(KeybindingsContext);
202
+ }
@@ -21,6 +21,8 @@ function getAbsolutePosition(node) {
21
21
  return { x, y };
22
22
  }
23
23
  export const MouseProvider = ({ children, }) => {
24
+ // ink 7 removed internal_eventEmitter from PublicProps type, but it is still
25
+ // present at runtime inside the StdinContext. Cast through unknown to access it.
24
26
  const { internal_eventEmitter } = useStdin();
25
27
  const { focus } = useFocusManager();
26
28
  const targetsRef = useRef(new Map());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bitty-tui",
3
- "version": "0.0.22",
3
+ "version": "0.1.0",
4
4
  "license": "MIT",
5
5
  "repository": "https://github.com/mceck/bitty",
6
6
  "keywords": [
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "type": "module",
19
19
  "engines": {
20
- "node": ">=16"
20
+ "node": ">=22"
21
21
  },
22
22
  "scripts": {
23
23
  "build": "tsc",
@@ -32,18 +32,18 @@
32
32
  "argon2": "^0.44.0",
33
33
  "chalk": "^5.6.2",
34
34
  "clipboardy": "^5.3.1",
35
- "ink": "^6.8.0",
35
+ "ink": "^7.0.1",
36
36
  "otplib": "^13.4.0",
37
37
  "react": "^19.2.5",
38
38
  "read-package-up": "^12.0.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@sindresorhus/tsconfig": "^8.1.0",
42
- "@types/node": "^24.12.2",
42
+ "@types/node": "^25.6.0",
43
43
  "@types/react": "^19.2.14",
44
44
  "eslint-plugin-react": "^7.37.5",
45
- "eslint-plugin-react-hooks": "^5.2.0",
45
+ "eslint-plugin-react-hooks": "^7.1.1",
46
46
  "ink-testing-library": "^4.0.0",
47
- "typescript": "^5.9.3"
47
+ "typescript": "^6.0.3"
48
48
  }
49
49
  }
package/readme.md CHANGED
@@ -23,6 +23,12 @@ Works also with Vaultwarden.
23
23
 
24
24
  If you check "Remember me" during login, your vault encryption keys will be stored in plain text in your home folder (`$HOME/.config/bitty/config.json`). Use this option only if you are the only user of your machine.
25
25
 
26
+ ## Custom keybindings
27
+
28
+ Click the **⚙** button in the toolbar, or press `Ctrl+K`, to open the keybindings editor. Use `↑`/`↓` to select an action, `Enter` to capture a new key combination, and `Esc` to close.
29
+
30
+ Overrides are saved to `~/.config/bitty/keybinds.json` and merged with the defaults on startup. Delete the file (or use "Reset to defaults" inside the editor) to go back to the defaults.
31
+
26
32
 
27
33
  ## Acknowledgments
28
34
  - [Bitwarden whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper)