bitty-tui 0.0.21 → 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,10 +4,12 @@ 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";
10
11
  import { fileURLToPath } from "node:url";
12
+ import { debugLogPath } from "./debug.js";
11
13
  const args = process.argv.slice(2);
12
14
  if (args.includes("--version") || args.includes("-v")) {
13
15
  const __filename = fileURLToPath(import.meta.url);
@@ -24,7 +26,14 @@ if (args.includes("--help") || args.includes("-h")) {
24
26
  Options
25
27
  --help Show help
26
28
  --version Show version
29
+ --debug Write API debug logs to ${debugLogPath}
27
30
  `);
28
31
  process.exit(0);
29
32
  }
30
- 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
+ });
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import crypto from "node:crypto";
21
21
  import * as argon2 from "argon2";
22
+ import { debugEnabled, debugLog } from "../debug.js";
22
23
  export class FetchError extends Error {
23
24
  status;
24
25
  data;
@@ -32,7 +33,26 @@ export class FetchError extends Error {
32
33
  }
33
34
  }
34
35
  const fetchApi = async (...args) => {
36
+ if (debugEnabled) {
37
+ const [input, init] = args;
38
+ const method = (init?.method ?? "GET").toUpperCase();
39
+ const url = input instanceof Request ? input.url : String(input);
40
+ const body = init?.body != null ? String(init.body) : "(none)";
41
+ const headers = new Headers(init?.headers);
42
+ const headersStr = [...headers.entries()]
43
+ .map(([k, v]) => ` ${k}: ${v}`)
44
+ .join("\n");
45
+ debugLog(`→ ${method} ${url}`);
46
+ debugLog(` headers:\n${headersStr || " (none)"}`);
47
+ debugLog(` body: ${body}`);
48
+ }
35
49
  const response = await fetch(...args);
50
+ if (debugEnabled) {
51
+ const clone = response.clone();
52
+ const responseBody = await clone.text();
53
+ debugLog(`← ${response.status} ${response.statusText}`);
54
+ debugLog(` response: ${responseBody}`);
55
+ }
36
56
  if (!response.ok) {
37
57
  const data = await response.text();
38
58
  throw new FetchError(response.status, data);
@@ -73,6 +93,17 @@ export var KdfType;
73
93
  KdfType[KdfType["Argon2id"] = 1] = "Argon2id";
74
94
  })(KdfType || (KdfType = {}));
75
95
  const DEVICE_IDENTIFIER = "928f9664-5559-4a7b-9853-caf5bfa5dd57";
96
+ const DEVICE_TYPE = "9"; // ChromeBrowser → ClientName::Web in the SDK
97
+ const CLIENT_NAME = "web";
98
+ const CLIENT_VERSION = "2025.9.0";
99
+ const USER_AGENT = "Bitwarden_Bitty";
100
+ const defaultHeaders = () => ({
101
+ "Device-Identifier": DEVICE_IDENTIFIER,
102
+ "Bitwarden-Client-Name": CLIENT_NAME,
103
+ "Bitwarden-Client-Version": CLIENT_VERSION,
104
+ "Device-Type": DEVICE_TYPE,
105
+ "User-Agent": USER_AGENT,
106
+ });
76
107
  class Bw {
77
108
  /**
78
109
  * Derives the master key and related keys from the user's email and password.
@@ -373,6 +404,8 @@ export class Client {
373
404
  const prelogin = await fetchApi(`${this.identityUrl}/accounts/prelogin`, {
374
405
  method: "POST",
375
406
  headers: {
407
+ ...defaultHeaders(),
408
+ Accept: "application/json",
376
409
  "Content-Type": "application/json",
377
410
  },
378
411
  body: JSON.stringify({ email }),
@@ -381,24 +414,24 @@ export class Client {
381
414
  this.keys = keys;
382
415
  }
383
416
  const bodyParams = new URLSearchParams();
384
- bodyParams.append("username", email);
385
- bodyParams.append("password", keys.masterPasswordHash);
386
- bodyParams.append("grant_type", "password");
387
- bodyParams.append("deviceName", "chrome");
417
+ bodyParams.append("scope", "api offline_access");
418
+ bodyParams.append("client_id", "web");
388
419
  bodyParams.append("deviceType", "9");
389
420
  bodyParams.append("deviceIdentifier", DEVICE_IDENTIFIER);
390
- bodyParams.append("client_id", "web");
391
- bodyParams.append("scope", "api offline_access");
421
+ bodyParams.append("deviceName", "firefox");
422
+ bodyParams.append("grant_type", "password");
423
+ bodyParams.append("username", email);
424
+ bodyParams.append("password", keys.masterPasswordHash);
392
425
  for (const [key, value] of Object.entries(opts || {})) {
393
426
  bodyParams.append(key, value);
394
427
  }
395
428
  const identityReq = await fetchApi(`${this.identityUrl}/connect/token`, {
396
429
  method: "POST",
397
430
  headers: {
398
- accept: "*/*",
399
- "accept-language": "en-US",
400
- "bitwarden-client-name": "web",
401
- "bitwarden-client-version": "2025.9.0",
431
+ ...defaultHeaders(),
432
+ Accept: "application/json",
433
+ "Cache-Control": "no-store",
434
+ Pragma: "no-cache",
402
435
  "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
403
436
  },
404
437
  body: bodyParams.toString(),
@@ -421,6 +454,8 @@ export class Client {
421
454
  return fetchApi(`${this.apiUrl}/two-factor/send-email-login`, {
422
455
  method: "POST",
423
456
  headers: {
457
+ ...defaultHeaders(),
458
+ Accept: "application/json",
424
459
  "Content-Type": "application/json",
425
460
  },
426
461
  body: JSON.stringify({
@@ -440,14 +475,17 @@ export class Client {
440
475
  throw new Error("No refresh token available. Please login first.");
441
476
  }
442
477
  const bodyParams = new URLSearchParams();
443
- bodyParams.append("refresh_token", this.refreshToken);
444
478
  bodyParams.append("grant_type", "refresh_token");
479
+ bodyParams.append("refresh_token", this.refreshToken);
445
480
  bodyParams.append("client_id", "web");
446
- bodyParams.append("scope", "api offline_access");
447
481
  const identityReq = await fetchApi(`${this.identityUrl}/connect/token`, {
448
482
  method: "POST",
449
483
  headers: {
450
- "Content-Type": "application/x-www-form-urlencoded",
484
+ ...defaultHeaders(),
485
+ Accept: "application/json",
486
+ "Cache-Control": "no-store",
487
+ Pragma: "no-cache",
488
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
451
489
  },
452
490
  body: bodyParams.toString(),
453
491
  }).then((r) => r.json());
@@ -466,8 +504,9 @@ export class Client {
466
504
  this.syncCache = await fetchApi(`${this.apiUrl}/sync?excludeDomains=true`, {
467
505
  method: "GET",
468
506
  headers: {
507
+ ...defaultHeaders(),
508
+ Accept: "application/json",
469
509
  Authorization: `Bearer ${this.token}`,
470
- "bitwarden-client-version": "2025.9.0",
471
510
  },
472
511
  }).then((r) => r.json());
473
512
  this.decryptOrgKeys();
@@ -638,6 +677,8 @@ export class Client {
638
677
  const s = await fetchApi(url, {
639
678
  method: "POST",
640
679
  headers: {
680
+ // ...defaultHeaders(),
681
+ // Accept: "application/json",
641
682
  Authorization: `Bearer ${this.token}`,
642
683
  "Content-Type": "application/json",
643
684
  },
@@ -685,6 +726,8 @@ export class Client {
685
726
  const s = await fetchApi(`${this.apiUrl}/ciphers/${id}`, {
686
727
  method: "PUT",
687
728
  headers: {
729
+ // ...defaultHeaders(),
730
+ // Accept: "application/json",
688
731
  Authorization: `Bearer ${this.token}`,
689
732
  "Content-Type": "application/json",
690
733
  },
@@ -699,6 +742,8 @@ export class Client {
699
742
  await fetchApi(`${this.apiUrl}/ciphers/${id}`, {
700
743
  method: "DELETE",
701
744
  headers: {
745
+ // ...defaultHeaders(),
746
+ // Accept: "application/json",
702
747
  Authorization: `Bearer ${this.token}`,
703
748
  },
704
749
  });
@@ -722,6 +767,8 @@ export class Client {
722
767
  const s = await fetchApi(`${this.apiUrl}/ciphers/${id}/share`, {
723
768
  method: "PUT",
724
769
  headers: {
770
+ // ...defaultHeaders(),
771
+ // Accept: "application/json",
725
772
  Authorization: `Bearer ${this.token}`,
726
773
  "Content-Type": "application/json",
727
774
  },
@@ -739,6 +786,8 @@ export class Client {
739
786
  const s = await fetchApi(`${this.apiUrl}/ciphers/${id}/collections_v2`, {
740
787
  method: "PUT",
741
788
  headers: {
789
+ // ...defaultHeaders(),
790
+ // Accept: "application/json",
742
791
  Authorization: `Bearer ${this.token}`,
743
792
  "Content-Type": "application/json",
744
793
  },
@@ -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,12 +84,12 @@ 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
  }
89
91
  else {
90
- clipboard.writeSync(value);
92
+ clipboard.write(value);
91
93
  showStatusMessage("📋 Copied to clipboard!", "success");
92
94
  }
93
95
  }
@@ -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, { 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, {
@@ -38,8 +40,9 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
38
40
  });
39
41
  useInput((input, key) => {
40
42
  const cipher = selected !== null ? filteredCiphers[selected] : null;
41
- let field, fldName;
42
- if (key.ctrl && input === "y") {
43
+ let field;
44
+ let fldName;
45
+ if (matchBinding(input, key, keybindings.copyPrimary)) {
43
46
  switch (cipher?.type) {
44
47
  case CipherType.Login:
45
48
  field = cipher.login?.password;
@@ -55,7 +58,7 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
55
58
  break;
56
59
  }
57
60
  }
58
- else if (key.ctrl && input === "u") {
61
+ else if (matchBinding(input, key, keybindings.copySecondary)) {
59
62
  switch (cipher?.type) {
60
63
  case CipherType.Login:
61
64
  field = cipher.login?.username;
@@ -67,14 +70,14 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
67
70
  break;
68
71
  }
69
72
  }
70
- else if (key.ctrl && input === "t") {
73
+ else if (matchBinding(input, key, keybindings.copyTotp)) {
71
74
  if (cipher?.type === CipherType.Login) {
72
75
  field = cipher.login?.currentTotp;
73
76
  fldName = "TOTP";
74
77
  }
75
78
  }
76
79
  if (field) {
77
- clipboard.writeSync(field);
80
+ clipboard.write(field);
78
81
  showStatusMessage(`📋 Copied ${fldName} to clipboard!`, "success");
79
82
  }
80
83
  }, { isActive: isFocused });
@@ -0,0 +1,3 @@
1
+ export declare const debugEnabled: boolean;
2
+ export declare const debugLogPath: string;
3
+ export declare function debugLog(message: string): void;
package/dist/debug.js ADDED
@@ -0,0 +1,15 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ export const debugEnabled = process.argv.includes("--debug");
5
+ const logDir = path.join(os.homedir(), ".config", "bitty");
6
+ export const debugLogPath = path.join(logDir, "debug.log");
7
+ if (debugEnabled) {
8
+ fs.mkdirSync(logDir, { recursive: true });
9
+ fs.writeFileSync(debugLogPath, `--- debug session started ${new Date().toISOString()} ---\n`);
10
+ }
11
+ export function debugLog(message) {
12
+ if (!debugEnabled)
13
+ return;
14
+ fs.appendFileSync(debugLogPath, `[${new Date().toISOString()}] ${message}\n`);
15
+ }
@@ -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.21",
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.3.0",
36
- "otplib": "^13.3.0",
37
- "react": "^19.1.0",
35
+ "ink": "^7.0.1",
36
+ "otplib": "^13.4.0",
37
+ "react": "^19.2.5",
38
38
  "read-package-up": "^12.0.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@sindresorhus/tsconfig": "^8.0.1",
42
- "@types/node": "^24.5.2",
43
- "@types/react": "^19.1.13",
44
- "eslint-plugin-react": "^7.32.2",
45
- "eslint-plugin-react-hooks": "^5.2.0",
41
+ "@sindresorhus/tsconfig": "^8.1.0",
42
+ "@types/node": "^25.6.0",
43
+ "@types/react": "^19.2.14",
44
+ "eslint-plugin-react": "^7.37.5",
45
+ "eslint-plugin-react-hooks": "^7.1.1",
46
46
  "ink-testing-library": "^4.0.0",
47
- "typescript": "^5.0.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)