bitty-tui 0.0.16 → 0.0.18

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/cli.js CHANGED
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { render } from "ink";
4
4
  import App from "./app.js";
5
5
  import { StatusMessageProvider } from "./hooks/status-message.js";
6
+ import { MouseProvider } from "./hooks/use-mouse.js";
6
7
  import { readPackageUpSync } from "read-package-up";
7
8
  import { art } from "./theme/art.js";
8
9
  import path from "node:path";
@@ -26,4 +27,4 @@ if (args.includes("--help") || args.includes("-h")) {
26
27
  `);
27
28
  process.exit(0);
28
29
  }
29
- render(_jsx(StatusMessageProvider, { children: _jsx(App, {}) }));
30
+ render(_jsx(StatusMessageProvider, { children: _jsx(MouseProvider, { children: _jsx(App, {}) }) }));
@@ -17,7 +17,6 @@
17
17
  * - Cipher-specific key if available
18
18
  * 4. Decrypt cipher fields using the chosen key
19
19
  */
20
- import https from "node:https";
21
20
  import crypto from "node:crypto";
22
21
  import * as argon2 from "argon2";
23
22
  export class FetchError extends Error {
@@ -32,54 +31,14 @@ export class FetchError extends Error {
32
31
  return JSON.parse(this.data);
33
32
  }
34
33
  }
35
- function fetch(url, options = {}, body = null) {
36
- return new Promise((resolve, reject) => {
37
- const urlObj = new URL(url);
38
- const requestOptions = {
39
- hostname: urlObj.hostname,
40
- path: urlObj.pathname + urlObj.search,
41
- method: options.method || "GET",
42
- headers: options.headers || {},
43
- };
44
- const req = https.request(requestOptions, (res) => {
45
- let data = "";
46
- const onData = (chunk) => {
47
- data += chunk;
48
- };
49
- const onEnd = () => {
50
- cleanup();
51
- if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
52
- reject(new FetchError(res.statusCode, data, `HTTP error: ${res.statusCode} ${res.statusMessage}`));
53
- return;
54
- }
55
- resolve({
56
- status: res.statusCode,
57
- json: () => Promise.resolve(JSON.parse(data)),
58
- text: () => Promise.resolve(data),
59
- });
60
- };
61
- const onError = (error) => {
62
- cleanup();
63
- reject(error);
64
- };
65
- const cleanup = () => {
66
- res.removeListener("data", onData);
67
- res.removeListener("end", onEnd);
68
- res.removeListener("error", onError);
69
- };
70
- res.on("data", onData);
71
- res.on("end", onEnd);
72
- res.on("error", onError);
73
- });
74
- req.on("error", (error) => {
75
- reject(error);
76
- });
77
- if (body) {
78
- req.write(typeof body === "string" ? body : JSON.stringify(body));
79
- }
80
- req.end();
81
- });
82
- }
34
+ const fetchApi = async (...args) => {
35
+ const response = await fetch(...args);
36
+ if (!response.ok) {
37
+ const data = await response.text();
38
+ throw new FetchError(response.status, data);
39
+ }
40
+ return response;
41
+ };
83
42
  export var CipherType;
84
43
  (function (CipherType) {
85
44
  CipherType[CipherType["Login"] = 1] = "Login";
@@ -409,13 +368,12 @@ export class Client {
409
368
  async login(email, password, skipPrelogin = false, opts) {
410
369
  let keys = this.keys;
411
370
  if (!skipPrelogin) {
412
- const prelogin = await fetch(`${this.identityUrl}/accounts/prelogin`, {
371
+ const prelogin = await fetchApi(`${this.identityUrl}/accounts/prelogin`, {
413
372
  method: "POST",
414
373
  headers: {
415
374
  "Content-Type": "application/json",
416
375
  },
417
- }, {
418
- email,
376
+ body: JSON.stringify({ email }),
419
377
  }).then((r) => r.json());
420
378
  keys = await mcbw.deriveMasterKey(email, password, prelogin);
421
379
  this.keys = keys;
@@ -432,12 +390,17 @@ export class Client {
432
390
  for (const [key, value] of Object.entries(opts || {})) {
433
391
  bodyParams.append(key, value);
434
392
  }
435
- const identityReq = await fetch(`${this.identityUrl}/connect/token`, {
393
+ const identityReq = await fetchApi(`${this.identityUrl}/connect/token`, {
436
394
  method: "POST",
437
395
  headers: {
438
- "Content-Type": "application/x-www-form-urlencoded",
396
+ accept: "*/*",
397
+ "accept-language": "en-US",
398
+ "bitwarden-client-name": "web",
399
+ "bitwarden-client-version": "2025.9.0",
400
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
439
401
  },
440
- }, bodyParams.toString()).then((r) => r.json());
402
+ body: bodyParams.toString(),
403
+ }).then((r) => r.json());
441
404
  this.token = identityReq.access_token;
442
405
  this.refreshToken = identityReq.refresh_token;
443
406
  this.tokenExpiration = Date.now() + identityReq.expires_in * 1000;
@@ -453,19 +416,20 @@ export class Client {
453
416
  this.orgKeys = {};
454
417
  }
455
418
  async sendEmailMfaCode(email) {
456
- fetch(`${this.apiUrl}/two-factor/send-email-login`, {
419
+ fetchApi(`${this.apiUrl}/two-factor/send-email-login`, {
457
420
  method: "POST",
458
421
  headers: {
459
422
  "Content-Type": "application/json",
460
423
  },
461
- }, JSON.stringify({
462
- email: email,
463
- masterPasswordHash: this.keys.masterPasswordHash,
464
- ssoEmail2FaSessionToken: "",
465
- deviceIdentifier: DEVICE_IDENTIFIER,
466
- authRequestAccessCode: "",
467
- authRequestId: "",
468
- }));
424
+ body: JSON.stringify({
425
+ email: email,
426
+ masterPasswordHash: this.keys.masterPasswordHash,
427
+ ssoEmail2FaSessionToken: "",
428
+ deviceIdentifier: DEVICE_IDENTIFIER,
429
+ authRequestAccessCode: "",
430
+ authRequestId: "",
431
+ }),
432
+ });
469
433
  }
470
434
  // Check and refresh token if needed
471
435
  async checkToken() {
@@ -473,12 +437,13 @@ export class Client {
473
437
  if (!this.refreshToken) {
474
438
  throw new Error("No refresh token available. Please login first.");
475
439
  }
476
- const identityReq = await fetch(`${this.identityUrl}/connect/token`, {
440
+ const identityReq = await fetchApi(`${this.identityUrl}/connect/token`, {
477
441
  method: "POST",
478
442
  headers: {
479
443
  "Content-Type": "application/x-www-form-urlencoded",
480
444
  },
481
- }, `refresh_token=${this.refreshToken}&grant_type=refresh_token&client_id=web&scope=api%20offline_access`).then((r) => r.json());
445
+ body: `refresh_token=${this.refreshToken}&grant_type=refresh_token&client_id=web&scope=api%20offline_access`,
446
+ }).then((r) => r.json());
482
447
  this.token = identityReq.access_token;
483
448
  this.refreshToken = identityReq.refresh_token;
484
449
  this.tokenExpiration = Date.now() + identityReq.expires_in * 1000;
@@ -491,7 +456,7 @@ export class Client {
491
456
  */
492
457
  async syncRefresh() {
493
458
  await this.checkToken();
494
- this.syncCache = await fetch(`${this.apiUrl}/sync?excludeDomains=true`, {
459
+ this.syncCache = await fetchApi(`${this.apiUrl}/sync?excludeDomains=true`, {
495
460
  method: "GET",
496
461
  headers: {
497
462
  Authorization: `Bearer ${this.token}`,
@@ -637,13 +602,14 @@ export class Client {
637
602
  }
638
603
  async createSecret(obj) {
639
604
  const key = this.getDecryptionKey(obj);
640
- const s = await fetch(`${this.apiUrl}/ciphers`, {
605
+ const s = await fetchApi(`${this.apiUrl}/ciphers`, {
641
606
  method: "POST",
642
607
  headers: {
643
608
  Authorization: `Bearer ${this.token}`,
644
609
  "Content-Type": "application/json",
645
610
  },
646
- }, this.encryptCipher(obj, key));
611
+ body: JSON.stringify(this.encryptCipher(obj, key)),
612
+ });
647
613
  return s.json();
648
614
  }
649
615
  objectDiff(obj1, obj2) {
@@ -683,13 +649,14 @@ export class Client {
683
649
  const key = this.getDecryptionKey(patch);
684
650
  const data = this.patchObject(original, this.encryptCipher(obj, key));
685
651
  data.data = undefined;
686
- const s = await fetch(`${this.apiUrl}/ciphers/${id}`, {
652
+ const s = await fetchApi(`${this.apiUrl}/ciphers/${id}`, {
687
653
  method: "PUT",
688
654
  headers: {
689
655
  Authorization: `Bearer ${this.token}`,
690
656
  "Content-Type": "application/json",
691
657
  },
692
- }, data);
658
+ body: JSON.stringify(data),
659
+ });
693
660
  this.decryptedSyncCache = null;
694
661
  this.syncCache = null;
695
662
  return s.json();
@@ -1,24 +1,30 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Text, Box, useFocus, useInput } from "ink";
3
- import { useRef, useState } from "react";
3
+ import { useId, useRef, useState } from "react";
4
4
  import { primary } from "../theme/style.js";
5
+ import { useMouseTarget } from "../hooks/use-mouse.js";
5
6
  export const Button = ({ isActive = true, doubleConfirm, onClick, children, autoFocus = false, ...props }) => {
6
- const { isFocused } = useFocus({ autoFocus: autoFocus });
7
+ const generatedId = useId();
8
+ const { isFocused } = useFocus({ id: generatedId, autoFocus: autoFocus });
7
9
  const [askConfirm, setAskConfirm] = useState(false);
8
10
  const timeoutRef = useRef(null);
9
- useInput((input, key) => {
10
- if (key.return) {
11
- if (timeoutRef.current)
12
- clearTimeout(timeoutRef.current);
13
- if (doubleConfirm && !askConfirm) {
14
- setAskConfirm(true);
15
- timeoutRef.current = setTimeout(() => setAskConfirm(false), 1000);
16
- return;
17
- }
18
- if (askConfirm)
19
- setAskConfirm(false);
20
- onClick();
11
+ const boxRef = useRef(null);
12
+ const handlePress = () => {
13
+ if (timeoutRef.current)
14
+ clearTimeout(timeoutRef.current);
15
+ if (doubleConfirm && !askConfirm) {
16
+ setAskConfirm(true);
17
+ timeoutRef.current = setTimeout(() => setAskConfirm(false), 1000);
18
+ return;
21
19
  }
20
+ if (askConfirm)
21
+ setAskConfirm(false);
22
+ onClick();
23
+ };
24
+ useMouseTarget(generatedId, boxRef, { onClick: handlePress });
25
+ useInput((input, key) => {
26
+ if (key.return)
27
+ handlePress();
22
28
  }, { isActive: isFocused && isActive });
23
- return (_jsx(Box, { borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", alignItems: "center", justifyContent: "center", ...props, children: _jsx(Text, { color: isFocused && isActive ? (askConfirm ? "yellow" : "white") : "gray", children: askConfirm ? "Confirm?" : children }) }));
29
+ return (_jsx(Box, { ref: boxRef, borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", alignItems: "center", justifyContent: "center", ...props, children: _jsx(Text, { color: isFocused && isActive ? (askConfirm ? "yellow" : "white") : "gray", children: askConfirm ? "Confirm?" : children }) }));
24
30
  };
@@ -1,12 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text, Box, useFocus, useInput } from "ink";
3
+ import { useId, useRef } from "react";
3
4
  import { primary } from "../theme/style.js";
5
+ import { useMouseTarget } from "../hooks/use-mouse.js";
4
6
  export const Checkbox = ({ isActive = true, value, label, onToggle, ...props }) => {
5
- const { isFocused } = useFocus();
7
+ const generatedId = useId();
8
+ const { isFocused } = useFocus({ id: generatedId });
9
+ const boxRef = useRef(null);
10
+ useMouseTarget(generatedId, boxRef, {
11
+ onClick: () => onToggle(!value),
12
+ });
6
13
  useInput((input, key) => {
7
14
  if (input === " ") {
8
15
  onToggle(!value);
9
16
  }
10
17
  }, { isActive: isFocused && isActive });
11
- return (_jsxs(Box, { ...props, children: [_jsx(Box, { width: 5, height: 3, flexShrink: 0, borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", children: value && (_jsx(Box, { width: 1, height: 1, marginLeft: 1, children: _jsx(Text, { color: isFocused && isActive ? primary : "gray", children: "X" }) })) }), _jsx(Box, { marginTop: 1, marginLeft: 1, children: _jsx(Text, { children: label }) })] }));
18
+ return (_jsxs(Box, { ref: boxRef, ...props, children: [_jsx(Box, { width: 5, height: 3, flexShrink: 0, borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", children: value && (_jsx(Box, { width: 1, height: 1, marginLeft: 1, children: _jsx(Text, { color: isFocused && isActive ? primary : "gray", children: "X" }) })) }), _jsx(Box, { marginTop: 1, marginLeft: 1, children: _jsx(Text, { children: label }) })] }));
12
19
  };
@@ -1,11 +1,12 @@
1
1
  import { ReactNode } from "react";
2
- export declare const ScrollView: <T>({ count, list, isActive, selectedIndex, onSelect, onSubmit, children, }: {
2
+ export declare const ScrollView: <T>({ count, list, isActive, selectedIndex, onSelect, onSubmit, offsetRef, children, }: {
3
3
  count: number;
4
4
  list: T[];
5
5
  isActive: boolean;
6
6
  selectedIndex: number;
7
7
  onSelect?: (position: number) => void;
8
8
  onSubmit?: (position: number) => void;
9
+ offsetRef?: React.MutableRefObject<number>;
9
10
  children: (arg: {
10
11
  el: T;
11
12
  index: number;
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, useInput } from "ink";
3
3
  import { useEffect, useState } from "react";
4
- export const ScrollView = ({ count, list, isActive, selectedIndex, onSelect, onSubmit, children, }) => {
4
+ export const ScrollView = ({ count, list, isActive, selectedIndex, onSelect, onSubmit, offsetRef, children, }) => {
5
5
  const [offset, setOffset] = useState(0);
6
+ if (offsetRef)
7
+ offsetRef.current = offset;
6
8
  useInput((input, key) => {
7
9
  if (key.upArrow) {
8
10
  if (selectedIndex === offset && offset > 0) {
@@ -1,16 +1,21 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Text, Box, useFocus, useInput, useFocusManager } from "ink";
2
+ import { Text, Box, useFocus, useInput, useFocusManager, } from "ink";
3
3
  import { primary } from "../theme/style.js";
4
- import { useEffect, useMemo, useState } from "react";
4
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
5
5
  import clipboard from "clipboardy";
6
6
  import chalk from "chalk";
7
7
  import { useStatusMessage } from "../hooks/status-message.js";
8
+ import { useMouseTarget } from "../hooks/use-mouse.js";
8
9
  export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFocus, isActive, autoFocus, inline, multiline, maxLines = 1, onChange, onSubmit, onCopy, ...props }) => {
9
10
  const [cursor, setCursor] = useState(onChange ? value.length : 0);
10
11
  const [scrollOffset, setScrollOffset] = useState(0);
11
- const { isFocused } = useFocus({ id, isActive, autoFocus });
12
+ const generatedId = useId();
13
+ const effectiveId = id ?? generatedId;
14
+ const { isFocused } = useFocus({ id: effectiveId, isActive, autoFocus });
12
15
  const { showStatusMessage } = useStatusMessage();
13
16
  const { focusNext } = useFocusManager();
17
+ const boxRef = useRef(null);
18
+ useMouseTarget(effectiveId, boxRef);
14
19
  const displayValue = useMemo(() => {
15
20
  let displayValue = value;
16
21
  if (isPassword && (showPasswordOnFocus ? !isFocused : true)) {
@@ -189,5 +194,5 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
189
194
  }
190
195
  }
191
196
  }, { isActive: isFocused });
192
- return (_jsx(Box, { borderStyle: "round", borderColor: isFocused ? primary : "gray", borderBottom: !inline, borderTop: !inline, borderLeft: !inline, borderRight: !inline, flexGrow: 1, flexShrink: 0, paddingX: inline ? 0 : 1, overflow: "hidden", minHeight: inline ? 1 : 3, ...props, children: _jsx(Text, { color: value ? "white" : "gray", children: displayValue }) }));
197
+ return (_jsx(Box, { ref: boxRef, borderStyle: "round", borderColor: isFocused ? primary : "gray", borderBottom: !inline, borderTop: !inline, borderLeft: !inline, borderRight: !inline, flexGrow: 1, flexShrink: 0, paddingX: inline ? 0 : 1, overflow: "hidden", minHeight: inline ? 1 : 3, ...props, children: _jsx(Text, { color: value ? "white" : "gray", children: displayValue }) }));
193
198
  };
@@ -8,6 +8,7 @@ import { HelpBar } from "./components/HelpBar.js";
8
8
  import { primary } from "../theme/style.js";
9
9
  import { bwClient, clearConfig, emptyCipher, useBwSync } from "../hooks/bw.js";
10
10
  import { useStatusMessage } from "../hooks/status-message.js";
11
+ import { useMouseSubscribe } from "../hooks/use-mouse.js";
11
12
  export function DashboardView({ onLogout }) {
12
13
  const { sync, error, fetchSync } = useBwSync();
13
14
  const [syncState, setSyncState] = useState(sync);
@@ -46,13 +47,20 @@ export function DashboardView({ onLogout }) {
46
47
  await clearConfig();
47
48
  onLogout();
48
49
  };
50
+ useMouseSubscribe((targetId) => {
51
+ if (targetId === "search") {
52
+ setFocusedComponent("search");
53
+ }
54
+ else if (targetId === "list") {
55
+ setFocusedComponent("list");
56
+ }
57
+ else {
58
+ setFocusedComponent("detail");
59
+ }
60
+ });
49
61
  useEffect(() => {
50
62
  setSyncState(sync);
51
63
  }, [sync]);
52
- useEffect(() => {
53
- if (focusedComponent === "detail")
54
- focusNext();
55
- }, [focusedComponent]);
56
64
  useEffect(() => {
57
65
  if (error)
58
66
  showStatusMessage(error, "error");
@@ -5,6 +5,8 @@ import { CipherType } from "../../clients/bw.js";
5
5
  import { ScrollView } from "../../components/ScrollView.js";
6
6
  import clipboard from "clipboardy";
7
7
  import { useStatusMessage } from "../../hooks/status-message.js";
8
+ import { useRef } from "react";
9
+ import { useMouseTarget } from "../../hooks/use-mouse.js";
8
10
  const getTypeIcon = (type) => {
9
11
  switch (type) {
10
12
  case CipherType.Login:
@@ -22,6 +24,18 @@ const getTypeIcon = (type) => {
22
24
  export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
23
25
  const { stdout } = useStdout();
24
26
  const { showStatusMessage } = useStatusMessage();
27
+ const boxRef = useRef(null);
28
+ const scrollOffsetRef = useRef(0);
29
+ useMouseTarget("list", boxRef, {
30
+ noFocus: true,
31
+ onClick: (_relX, relY) => {
32
+ const visibleIndex = relY - 1; // -1 for border
33
+ const actualIndex = scrollOffsetRef.current + visibleIndex;
34
+ if (actualIndex >= 0 && actualIndex < filteredCiphers.length) {
35
+ onSelect(actualIndex);
36
+ }
37
+ },
38
+ });
25
39
  useInput((input, key) => {
26
40
  const cipher = selected !== null ? filteredCiphers[selected] : null;
27
41
  let field, fldName;
@@ -64,5 +78,5 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
64
78
  showStatusMessage(`📋 Copied ${fldName} to clipboard!`, "success");
65
79
  }
66
80
  }, { isActive: isFocused });
67
- return (_jsx(Box, { flexDirection: "column", width: "40%", borderStyle: "round", borderColor: isFocused ? primaryLight : "gray", borderRightColor: "gray", paddingX: 1, overflow: "hidden", children: _jsx(ScrollView, { isActive: isFocused, count: Math.max(stdout.rows - 14, 20), list: filteredCiphers, selectedIndex: selected ?? 0, onSelect: onSelect, children: ({ el: cipher, selected }) => (_jsxs(Box, { justifyContent: "space-between", backgroundColor: selected ? (isFocused ? primary : primaryDark) : "", children: [_jsxs(Box, { children: [_jsxs(Text, { children: [getTypeIcon(cipher.type), " "] }), _jsx(Text, { color: selected && isFocused ? "white" : "default", wrap: "truncate", children: cipher.name })] }), cipher.favorite && _jsx(Text, { color: "yellow", children: "\u2605" })] }, cipher.id)) }) }));
81
+ return (_jsx(Box, { ref: boxRef, flexDirection: "column", width: "40%", borderStyle: "round", borderColor: isFocused ? primaryLight : "gray", borderRightColor: "gray", paddingX: 1, overflow: "hidden", children: _jsx(ScrollView, { isActive: isFocused, count: Math.max(stdout.rows - 14, 20), list: filteredCiphers, selectedIndex: selected ?? 0, onSelect: onSelect, offsetRef: scrollOffsetRef, children: ({ el: cipher, selected }) => (_jsxs(Box, { justifyContent: "space-between", backgroundColor: selected ? (isFocused ? primary : primaryDark) : "", children: [_jsxs(Box, { children: [_jsxs(Text, { children: [getTypeIcon(cipher.type), " "] }), _jsx(Text, { color: selected && isFocused ? "white" : "default", wrap: "truncate", children: cipher.name })] }), cipher.favorite && _jsx(Text, { color: "yellow", children: "\u2605" })] }, cipher.id)) }) }));
68
82
  }
@@ -5,6 +5,12 @@ interface BwConfig {
5
5
  refreshToken: string;
6
6
  }
7
7
  export declare const bwClient: Client;
8
+ export interface LoginHints {
9
+ email?: string;
10
+ baseUrl?: string;
11
+ }
12
+ export declare function loadLoginHints(): Promise<LoginHints>;
13
+ export declare function saveLoginHints(hints: LoginHints): Promise<void>;
8
14
  export declare function loadConfig(): Promise<boolean>;
9
15
  export declare function saveConfig(config: BwConfig): Promise<void>;
10
16
  export declare function clearConfig(): Promise<void>;
package/dist/hooks/bw.js CHANGED
@@ -4,7 +4,34 @@ import path from "path";
4
4
  import { CipherType, Client } from "../clients/bw.js";
5
5
  import { useCallback, useEffect, useState } from "react";
6
6
  export const bwClient = new Client();
7
- const configPath = path.join(os.homedir(), ".config", "bitty", "config.json");
7
+ const configDir = path.join(os.homedir(), ".config", "bitty");
8
+ const configPath = path.join(configDir, "config.json");
9
+ export async function loadLoginHints() {
10
+ try {
11
+ if (fs.existsSync(configPath)) {
12
+ const content = await fs.promises.readFile(configPath, "utf-8");
13
+ const config = JSON.parse(Buffer.from(content, "base64").toString("utf-8"));
14
+ return { email: config.email, baseUrl: config.baseUrl };
15
+ }
16
+ }
17
+ catch { }
18
+ return {};
19
+ }
20
+ export async function saveLoginHints(hints) {
21
+ let config = {};
22
+ try {
23
+ if (fs.existsSync(configPath)) {
24
+ const content = await fs.promises.readFile(configPath, "utf-8");
25
+ config = JSON.parse(Buffer.from(content, "base64").toString("utf-8"));
26
+ }
27
+ }
28
+ catch { }
29
+ config.email = hints.email;
30
+ config.baseUrl = hints.baseUrl;
31
+ const encoded = Buffer.from(JSON.stringify(config)).toString("base64");
32
+ await fs.promises.mkdir(configDir, { recursive: true });
33
+ await fs.promises.writeFile(configPath, encoded);
34
+ }
8
35
  export async function loadConfig() {
9
36
  try {
10
37
  if (fs.existsSync(configPath)) {
@@ -69,7 +96,7 @@ export async function saveConfig(config) {
69
96
  ...config,
70
97
  keys,
71
98
  })).toString("base64");
72
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
99
+ await fs.promises.mkdir(configDir, { recursive: true });
73
100
  await fs.promises.writeFile(configPath, encConfig);
74
101
  }
75
102
  export async function clearConfig() {
@@ -0,0 +1,11 @@
1
+ import { type DOMElement } from "ink";
2
+ type TargetOptions = {
3
+ noFocus?: boolean;
4
+ onClick?: (relX: number, relY: number) => void;
5
+ };
6
+ export declare const MouseProvider: ({ children, }: {
7
+ children: React.ReactNode;
8
+ }) => import("react/jsx-runtime").JSX.Element;
9
+ export declare const useMouseTarget: (id: string, ref: React.RefObject<DOMElement | null>, options?: TargetOptions) => void;
10
+ export declare const useMouseSubscribe: (listener: (targetId: string) => void) => void;
11
+ export {};
@@ -0,0 +1,124 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useCallback, useEffect, useRef, } from "react";
3
+ import { useStdin, useFocusManager, measureElement, } from "ink";
4
+ const MouseContext = createContext({
5
+ registerTarget: () => { },
6
+ unregisterTarget: () => { },
7
+ subscribe: () => () => { },
8
+ });
9
+ const mouseSequenceRe = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
10
+ function getAbsolutePosition(node) {
11
+ let currentNode = node;
12
+ let x = 0;
13
+ let y = 0;
14
+ while (currentNode?.parentNode) {
15
+ if (!currentNode.yogaNode)
16
+ return undefined;
17
+ x += currentNode.yogaNode.getComputedLeft();
18
+ y += currentNode.yogaNode.getComputedTop();
19
+ currentNode = currentNode.parentNode;
20
+ }
21
+ return { x, y };
22
+ }
23
+ export const MouseProvider = ({ children, }) => {
24
+ const { internal_eventEmitter } = useStdin();
25
+ const { focus } = useFocusManager();
26
+ const targetsRef = useRef(new Map());
27
+ const listenersRef = useRef(new Set());
28
+ const focusRef = useRef(focus);
29
+ focusRef.current = focus;
30
+ const registerTarget = useCallback((id, ref, options) => {
31
+ targetsRef.current.set(id, { ref, ...options });
32
+ }, []);
33
+ const unregisterTarget = useCallback((id) => {
34
+ targetsRef.current.delete(id);
35
+ }, []);
36
+ const subscribe = useCallback((listener) => {
37
+ listenersRef.current.add(listener);
38
+ return () => {
39
+ listenersRef.current.delete(listener);
40
+ };
41
+ }, []);
42
+ const handleClick = useCallback((x, y) => {
43
+ for (const [id, target] of targetsRef.current) {
44
+ const node = target.ref.current;
45
+ if (!node)
46
+ continue;
47
+ const pos = getAbsolutePosition(node);
48
+ if (!pos)
49
+ continue;
50
+ const { width, height } = measureElement(node);
51
+ if (x >= pos.x &&
52
+ x < pos.x + width &&
53
+ y >= pos.y &&
54
+ y < pos.y + height) {
55
+ for (const listener of listenersRef.current) {
56
+ listener(id);
57
+ }
58
+ if (!target.noFocus) {
59
+ focusRef.current(id);
60
+ }
61
+ target.onClick?.(x - pos.x, y - pos.y);
62
+ return;
63
+ }
64
+ }
65
+ }, []);
66
+ const handleClickRef = useRef(handleClick);
67
+ handleClickRef.current = handleClick;
68
+ useEffect(() => {
69
+ if (!internal_eventEmitter)
70
+ return;
71
+ // Enable SGR mouse tracking mode
72
+ process.stdout.write("\x1b[?1000h\x1b[?1006h");
73
+ // Patch event emitter to intercept mouse sequences
74
+ const originalEmit = internal_eventEmitter.emit.bind(internal_eventEmitter);
75
+ internal_eventEmitter.emit = (event, ...args) => {
76
+ if (event === "input") {
77
+ const data = args[0];
78
+ const match = mouseSequenceRe.exec(data);
79
+ if (match) {
80
+ const button = parseInt(match[1], 10);
81
+ const x = parseInt(match[2], 10) - 1;
82
+ const y = parseInt(match[3], 10) - 1;
83
+ const isPress = match[4] === "M";
84
+ if (isPress && (button & 3) === 0) {
85
+ handleClickRef.current(x, y);
86
+ }
87
+ return true;
88
+ }
89
+ }
90
+ return originalEmit(event, ...args);
91
+ };
92
+ const disableMouse = () => {
93
+ process.stdout.write("\x1b[?1006l\x1b[?1000l");
94
+ };
95
+ process.on("exit", disableMouse);
96
+ return () => {
97
+ internal_eventEmitter.emit = originalEmit;
98
+ disableMouse();
99
+ process.removeListener("exit", disableMouse);
100
+ };
101
+ }, [internal_eventEmitter]);
102
+ return (_jsx(MouseContext.Provider, { value: { registerTarget, unregisterTarget, subscribe }, children: children }));
103
+ };
104
+ export const useMouseTarget = (id, ref, options) => {
105
+ const { registerTarget, unregisterTarget } = useContext(MouseContext);
106
+ const onClickRef = useRef(options?.onClick);
107
+ onClickRef.current = options?.onClick;
108
+ useEffect(() => {
109
+ registerTarget(id, ref, {
110
+ noFocus: options?.noFocus,
111
+ onClick: (x, y) => onClickRef.current?.(x, y),
112
+ });
113
+ return () => unregisterTarget(id);
114
+ }, [id, ref, registerTarget, unregisterTarget, options?.noFocus]);
115
+ };
116
+ export const useMouseSubscribe = (listener) => {
117
+ const { subscribe } = useContext(MouseContext);
118
+ const listenerRef = useRef(listener);
119
+ listenerRef.current = listener;
120
+ useEffect(() => {
121
+ const stableListener = (id) => listenerRef.current(id);
122
+ return subscribe(stableListener);
123
+ }, [subscribe]);
124
+ };
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
4
4
  import { TextInput } from "../components/TextInput.js";
5
5
  import { Button } from "../components/Button.js";
6
6
  import { primary } from "../theme/style.js";
7
- import { bwClient, loadConfig, saveConfig } from "../hooks/bw.js";
7
+ import { bwClient, loadConfig, loadLoginHints, saveConfig, saveLoginHints } from "../hooks/bw.js";
8
8
  import { useStatusMessage } from "../hooks/status-message.js";
9
9
  import { Checkbox } from "../components/Checkbox.js";
10
10
  import { FetchError, TwoFactorProvider } from "../clients/bw.js";
@@ -94,6 +94,12 @@ export function LoginView({ onLogin }) {
94
94
  refreshToken: bwClient.refreshToken,
95
95
  });
96
96
  }
97
+ else {
98
+ saveLoginHints({
99
+ email: email?.trim() || undefined,
100
+ baseUrl: url?.trim() || undefined,
101
+ });
102
+ }
97
103
  }
98
104
  catch (e) {
99
105
  showStatusMessage("Login failed, please check your credentials.", "error");
@@ -105,7 +111,13 @@ export function LoginView({ onLogin }) {
105
111
  const loggedIn = await loadConfig();
106
112
  if (loggedIn) {
107
113
  onLogin();
114
+ return;
108
115
  }
116
+ const hints = await loadLoginHints();
117
+ if (hints.baseUrl)
118
+ setUrl(hints.baseUrl);
119
+ if (hints.email)
120
+ setEmail(hints.email);
109
121
  }
110
122
  catch (e) {
111
123
  showStatusMessage("Failed to load config file", "error");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bitty-tui",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "license": "MIT",
5
5
  "repository": "https://github.com/mceck/bitty",
6
6
  "keywords": [