routstrd 0.3.3 → 0.3.4

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/bun.lock CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "routstrd",
6
6
  "dependencies": {
7
7
  "@cashu/cashu-ts": "^4.3.0",
8
- "@routstr/sdk": "^0.3.11",
8
+ "@routstr/sdk": "^0.3.12",
9
9
  "applesauce-core": "^5.1.0",
10
10
  "applesauce-relay": "^5.1.0",
11
11
  "applesauce-wallet-connect": "^6.0.0",
@@ -71,7 +71,7 @@
71
71
 
72
72
  "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
73
73
 
74
- "@routstr/sdk": ["@routstr/sdk@0.3.11", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "rxjs": "^7.8.1", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-RecciPpG9jIeoH3R89fe9Dw7eQewbJei1i5IsC/tFCsmMY62I6d0KHctYEdyV+n3WzYZo1l3nBZLUhUO8WHJXA=="],
74
+ "@routstr/sdk": ["@routstr/sdk@0.3.12", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "rxjs": "^7.8.1", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-S/z52pPYZRnWOeD/Vgmk4xx4h6KHDcjkk0eb/vUPnpTEN9+pZ2widG9P3Bz37GWvCFsaGmTO3rWX1bhn34YkDA=="],
75
75
 
76
76
  "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="],
77
77
 
@@ -47047,6 +47047,8 @@ async function createBunSqliteDriver(dbPath, options) {
47047
47047
  const logger6 = (options?.logger ?? consoleLogger3).child("BunSqliteDriver");
47048
47048
  const SQLite = (await import("bun:sqlite")).default;
47049
47049
  const db = new SQLite(dbPath);
47050
+ db.run("PRAGMA journal_mode = WAL");
47051
+ db.run("PRAGMA busy_timeout = 5000");
47050
47052
  db.run(`
47051
47053
  CREATE TABLE IF NOT EXISTS sdk_storage (
47052
47054
  key TEXT PRIMARY KEY,
@@ -47154,6 +47156,8 @@ var createBunSqliteUsageTrackingDriver = (options = {}) => {
47154
47156
  throw new Error("Bun SQLite Database constructor is required. Pass { sqlite: { Database } } when creating the driver.");
47155
47157
  }
47156
47158
  const db = new SQLiteDatabase(dbPath);
47159
+ db.run("PRAGMA journal_mode = WAL");
47160
+ db.run("PRAGMA busy_timeout = 5000");
47157
47161
  db.run(`
47158
47162
  CREATE TABLE IF NOT EXISTS ${tableName} (
47159
47163
  id TEXT PRIMARY KEY,
package/dist/index.js CHANGED
@@ -13876,9 +13876,6 @@ var init_constants = __esm(() => {
13876
13876
  // src/tui/usage/data.ts
13877
13877
  async function fetchStatus() {
13878
13878
  try {
13879
- const running = await isDaemonRunning();
13880
- if (!running)
13881
- return null;
13882
13879
  const result = await callDaemon("/status");
13883
13880
  if (result.error)
13884
13881
  return null;
@@ -13895,9 +13892,6 @@ async function fetchStatus() {
13895
13892
  }
13896
13893
  async function fetchBalance() {
13897
13894
  try {
13898
- const running = await isDaemonRunning();
13899
- if (!running)
13900
- return null;
13901
13895
  const result = await callDaemon("/keys/balance");
13902
13896
  if (result.error)
13903
13897
  return null;
@@ -13914,9 +13908,6 @@ async function fetchBalance() {
13914
13908
  }
13915
13909
  async function fetchUsageSummary() {
13916
13910
  try {
13917
- const running = await isDaemonRunning();
13918
- if (!running)
13919
- return null;
13920
13911
  const tz = new Date().getTimezoneOffset();
13921
13912
  const result = await callDaemon(`/usage/summary?tz=${tz}`);
13922
13913
  if (result.error)
@@ -13950,9 +13941,6 @@ function formatNumber(n) {
13950
13941
  }
13951
13942
  async function fetchClients() {
13952
13943
  try {
13953
- const running = await isDaemonRunning();
13954
- if (!running)
13955
- return [];
13956
13944
  const result = await callDaemon("/clients");
13957
13945
  if (result.error)
13958
13946
  return [];
@@ -14606,10 +14594,9 @@ async function runUsageTui() {
14606
14594
  let clients = [];
14607
14595
  let visibleTabs = getVisibleTabs(false);
14608
14596
  let refreshInterval = null;
14609
- let shouldUpdate = true;
14610
14597
  let autoRefresh = true;
14611
14598
  let cleanedUp = false;
14612
- let rendering = false;
14599
+ let fetching = false;
14613
14600
  if (isInteractive) {
14614
14601
  stdout.write(enterAlternateScreen() + hideCursor());
14615
14602
  stdin.setRawMode?.(true);
@@ -14633,55 +14620,70 @@ async function runUsageTui() {
14633
14620
  }
14634
14621
  process.on("SIGINT", () => cleanup(0));
14635
14622
  process.on("SIGTERM", () => cleanup(0));
14636
- async function render2(forceFetch = false) {
14637
- if (rendering)
14623
+ async function fetchData() {
14624
+ if (fetching)
14638
14625
  return;
14639
- rendering = true;
14626
+ fetching = true;
14640
14627
  try {
14641
- const width = getWidth();
14642
- const height = getHeight();
14643
- if (forceFetch || shouldUpdate) {
14644
- stats = await fetchUsageSummary();
14645
- balance = await fetchBalance();
14646
- status = await fetchStatus();
14647
- clients = await fetchClients();
14628
+ const running2 = await isDaemonRunning();
14629
+ if (!running2) {
14630
+ stats = null;
14631
+ } else {
14632
+ const [newStats, newBalance, newStatus, newClients] = await Promise.all([
14633
+ fetchUsageSummary(),
14634
+ fetchBalance(),
14635
+ fetchStatus(),
14636
+ fetchClients()
14637
+ ]);
14638
+ if (newStats)
14639
+ stats = newStats;
14640
+ if (newBalance)
14641
+ balance = newBalance;
14642
+ if (newStatus)
14643
+ status = newStatus;
14644
+ if (newClients && newClients.length > 0)
14645
+ clients = newClients;
14648
14646
  const npubsVisible = hasAnyNpubs(clients);
14649
14647
  visibleTabs = getVisibleTabs(npubsVisible);
14650
14648
  if (currentTab === "npubs" && !npubsVisible) {
14651
14649
  currentTab = "clients";
14652
14650
  vimState.scrollPos = 0;
14653
14651
  }
14654
- shouldUpdate = false;
14655
14652
  }
14656
- if (!stats) {
14657
- stdout.write(moveCursor(1, 1) + eraseDown() + `${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}
14653
+ render2();
14654
+ } finally {
14655
+ fetching = false;
14656
+ }
14657
+ }
14658
+ function render2() {
14659
+ const width = getWidth();
14660
+ const height = getHeight();
14661
+ if (!stats) {
14662
+ stdout.write(moveCursor(1, 1) + eraseDown() + `${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}
14658
14663
  ` + `Make sure routstrd is running.
14659
14664
  ` + `
14660
14665
  Press Q to quit.`);
14661
- return;
14662
- }
14663
- const content = renderTabContent(currentTab, stats, balance, status, width, clients);
14664
- const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
14665
- const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
14666
- const chromeLines = chrome.split(`
14666
+ return;
14667
+ }
14668
+ const content = renderTabContent(currentTab, stats, balance, status, width, clients);
14669
+ const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
14670
+ const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
14671
+ const chromeLines = chrome.split(`
14667
14672
  `).length - 1;
14668
- const footerSeparator = renderSeparator(width);
14669
- const footerLines = footerSeparator.split(`
14673
+ const footerSeparator = renderSeparator(width);
14674
+ const footerLines = footerSeparator.split(`
14670
14675
  `).length - 1;
14671
- const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
14672
- const visibleContent = applyScrollToContent(content, contentViewportHeight);
14673
- const footerBlock = (visibleContent ? `
14676
+ const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
14677
+ const visibleContent = applyScrollToContent(content, contentViewportHeight);
14678
+ const footerBlock = (visibleContent ? `
14674
14679
  ` : "") + footerSeparator + footer;
14675
- stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
14676
- } finally {
14677
- rendering = false;
14678
- }
14680
+ stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
14679
14681
  }
14680
14682
  const handleKey = (key) => {
14681
14683
  if (vimState.isSearching) {
14682
14684
  if (key === "\x1B" || key === "\x1B[3~") {
14683
14685
  exitSearchMode();
14684
- render2(false);
14686
+ render2();
14685
14687
  return;
14686
14688
  }
14687
14689
  if (key === "\r" || key === `
@@ -14689,117 +14691,115 @@ Press Q to quit.`);
14689
14691
  if (stats?.entries)
14690
14692
  performSearch(vimState.searchQuery, stats.entries);
14691
14693
  exitSearchMode();
14692
- render2(false);
14694
+ render2();
14693
14695
  return;
14694
14696
  }
14695
14697
  if (key === "\x7F" || key === "\b") {
14696
14698
  vimState.searchQuery = vimState.searchQuery.slice(0, -1);
14697
14699
  if (stats?.entries)
14698
14700
  performSearch(vimState.searchQuery, stats.entries);
14699
- render2(false);
14701
+ render2();
14700
14702
  return;
14701
14703
  }
14702
14704
  if (key === "\x03") {
14703
14705
  exitSearchMode();
14704
- render2(false);
14706
+ render2();
14705
14707
  return;
14706
14708
  }
14707
14709
  if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
14708
14710
  vimState.searchQuery += key;
14709
14711
  if (stats?.entries)
14710
14712
  performSearch(vimState.searchQuery, stats.entries);
14711
- render2(false);
14713
+ render2();
14712
14714
  }
14713
14715
  return;
14714
14716
  }
14715
14717
  if (key === "q" || key === "Q" || key === "\x03")
14716
14718
  return cleanup(0);
14717
14719
  if (key === "r" || key === "R") {
14718
- shouldUpdate = true;
14719
- render2(true);
14720
+ fetchData();
14720
14721
  return;
14721
14722
  }
14722
14723
  if (key === "a" || key === "A") {
14723
14724
  autoRefresh = !autoRefresh;
14724
- shouldUpdate = true;
14725
- render2(false);
14725
+ render2();
14726
14726
  return;
14727
14727
  }
14728
14728
  if (key === "j" || key === "\x1B[B" || key === "\x1BOB") {
14729
14729
  scrollDown();
14730
- render2(false);
14730
+ render2();
14731
14731
  return;
14732
14732
  }
14733
14733
  if (key === "k" || key === "\x1B[A" || key === "\x1BOA") {
14734
14734
  scrollUp();
14735
- render2(false);
14735
+ render2();
14736
14736
  return;
14737
14737
  }
14738
14738
  if (key === "l" || key === "\x1B[C" || key === "\x1BOC") {
14739
14739
  const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
14740
14740
  currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length].id;
14741
14741
  vimState.scrollPos = 0;
14742
- render2(false);
14742
+ render2();
14743
14743
  return;
14744
14744
  }
14745
14745
  if (key === "h" || key === "\x1B[D" || key === "\x1BOD") {
14746
14746
  const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
14747
14747
  currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length].id;
14748
14748
  vimState.scrollPos = 0;
14749
- render2(false);
14749
+ render2();
14750
14750
  return;
14751
14751
  }
14752
14752
  if (key === "g") {
14753
14753
  if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) {
14754
14754
  scrollToBottom();
14755
14755
  vimState.lastKey = "";
14756
- render2(false);
14756
+ render2();
14757
14757
  return;
14758
14758
  }
14759
14759
  vimState.lastKey = "g";
14760
14760
  vimState.lastKeyTime = Date.now();
14761
14761
  scrollToTop();
14762
- render2(false);
14762
+ render2();
14763
14763
  return;
14764
14764
  }
14765
14765
  if (key === "\x02") {
14766
14766
  pageUp();
14767
- render2(false);
14767
+ render2();
14768
14768
  return;
14769
14769
  }
14770
14770
  if (key === "\x06") {
14771
14771
  pageDown();
14772
- render2(false);
14772
+ render2();
14773
14773
  return;
14774
14774
  }
14775
14775
  if (key === "\x15") {
14776
14776
  scrollUp(10);
14777
- render2(false);
14777
+ render2();
14778
14778
  return;
14779
14779
  }
14780
14780
  if (key === "\x04") {
14781
14781
  scrollDown(10);
14782
- render2(false);
14782
+ render2();
14783
14783
  return;
14784
14784
  }
14785
14785
  if (key === "\x1B[H" || key === "\x1B[1~" || key === "\x1BOH") {
14786
14786
  scrollToTop();
14787
- render2(false);
14787
+ render2();
14788
14788
  return;
14789
14789
  }
14790
14790
  if (key === "\x1B[F" || key === "\x1B[4~" || key === "\x1BOF") {
14791
14791
  scrollToBottom();
14792
- render2(false);
14792
+ render2();
14793
14793
  return;
14794
14794
  }
14795
14795
  if (key === "/") {
14796
14796
  startSearch(false);
14797
- render2(false);
14797
+ render2();
14798
14798
  return;
14799
14799
  }
14800
14800
  if (key === "?") {
14801
14801
  startSearch(true);
14802
- render2(false);
14802
+ render2();
14803
14803
  return;
14804
14804
  }
14805
14805
  if (key === "n") {
@@ -14807,7 +14807,7 @@ Press Q to quit.`);
14807
14807
  prevSearchResult(stats?.entries.length || 0);
14808
14808
  else
14809
14809
  nextSearchResult(stats?.entries.length || 0);
14810
- render2(false);
14810
+ render2();
14811
14811
  return;
14812
14812
  }
14813
14813
  if (key === "N") {
@@ -14815,28 +14815,27 @@ Press Q to quit.`);
14815
14815
  nextSearchResult(stats?.entries.length || 0);
14816
14816
  else
14817
14817
  prevSearchResult(stats?.entries.length || 0);
14818
- render2(false);
14818
+ render2();
14819
14819
  return;
14820
14820
  }
14821
14821
  if (key === "\x1B") {
14822
14822
  scrollToTop();
14823
- render2(false);
14823
+ render2();
14824
14824
  return;
14825
14825
  }
14826
14826
  const tab = visibleTabs.find((t) => t.key === key);
14827
14827
  if (tab) {
14828
14828
  currentTab = tab.id;
14829
14829
  vimState.scrollPos = 0;
14830
- render2(false);
14830
+ render2();
14831
14831
  }
14832
14832
  };
14833
14833
  if (isInteractive)
14834
14834
  stdin.on("data", handleKey);
14835
- await render2(true);
14835
+ await fetchData();
14836
14836
  refreshInterval = setInterval(() => {
14837
14837
  if (autoRefresh) {
14838
- shouldUpdate = true;
14839
- render2(true);
14838
+ fetchData();
14840
14839
  }
14841
14840
  }, 2000);
14842
14841
  }
@@ -15588,7 +15587,7 @@ async function isCocodInstalled(cocodPath) {
15588
15587
  // package.json
15589
15588
  var package_default = {
15590
15589
  name: "routstrd",
15591
- version: "0.3.3",
15590
+ version: "0.3.4",
15592
15591
  module: "src/index.ts",
15593
15592
  type: "module",
15594
15593
  private: false,
@@ -15612,7 +15611,7 @@ var package_default = {
15612
15611
  },
15613
15612
  dependencies: {
15614
15613
  "@cashu/cashu-ts": "^4.3.0",
15615
- "@routstr/sdk": "^0.3.11",
15614
+ "@routstr/sdk": "^0.3.12",
15616
15615
  "applesauce-core": "^5.1.0",
15617
15616
  "applesauce-relay": "^5.1.0",
15618
15617
  "applesauce-wallet-connect": "^6.0.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routstrd",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@cashu/cashu-ts": "^4.3.0",
27
- "@routstr/sdk": "^0.3.11",
27
+ "@routstr/sdk": "^0.3.12",
28
28
  "applesauce-core": "^5.1.0",
29
29
  "applesauce-relay": "^5.1.0",
30
30
  "applesauce-wallet-connect": "^6.0.0",
@@ -49,10 +49,9 @@ export async function runUsageTui(): Promise<void> {
49
49
  let clients: ClientInfo[] = [];
50
50
  let visibleTabs: Tab[] = getVisibleTabs(false);
51
51
  let refreshInterval: ReturnType<typeof setInterval> | null = null;
52
- let shouldUpdate = true;
53
52
  let autoRefresh = true;
54
53
  let cleanedUp = false;
55
- let rendering = false;
54
+ let fetching = false;
56
55
 
57
56
  if (isInteractive) {
58
57
  stdout.write(enterAlternateScreen() + hideCursor());
@@ -80,123 +79,141 @@ export async function runUsageTui(): Promise<void> {
80
79
  process.on("SIGINT", () => cleanup(0));
81
80
  process.on("SIGTERM", () => cleanup(0));
82
81
 
83
- async function render(forceFetch = false) {
84
- if (rendering) return;
85
- rendering = true;
86
-
82
+ /**
83
+ * Background fetch — async, never blocks rendering. Updates the state
84
+ * variables only on success, then triggers a repaint. Overlapping calls
85
+ * are skipped via the `fetching` guard so we don't race stale results.
86
+ */
87
+ async function fetchData(): Promise<void> {
88
+ if (fetching) return;
89
+ fetching = true;
87
90
  try {
88
- const width = getWidth();
89
- const height = getHeight();
91
+ const running = await isDaemonRunning();
92
+ if (!running) {
93
+ stats = null;
94
+ } else {
95
+ // Fire all 4 fetches concurrently — cuts the blocked window significantly.
96
+ const [newStats, newBalance, newStatus, newClients] = await Promise.all([
97
+ fetchUsageSummary(),
98
+ fetchBalance(),
99
+ fetchStatus(),
100
+ fetchClients(),
101
+ ]);
102
+ if (newStats) stats = newStats;
103
+ if (newBalance) balance = newBalance;
104
+ if (newStatus) status = newStatus;
105
+ if (newClients && newClients.length > 0) clients = newClients;
90
106
 
91
- if (forceFetch || shouldUpdate) {
92
- stats = await fetchUsageSummary();
93
- balance = await fetchBalance();
94
- status = await fetchStatus();
95
- clients = await fetchClients();
96
107
  const npubsVisible = hasAnyNpubs(clients);
97
108
  visibleTabs = getVisibleTabs(npubsVisible);
98
- // If current tab is npubs but it's no longer visible, fall back to clients
99
109
  if (currentTab === "npubs" && !npubsVisible) {
100
110
  currentTab = "clients";
101
111
  vimState.scrollPos = 0;
102
112
  }
103
- shouldUpdate = false;
104
- }
105
-
106
- if (!stats) {
107
- stdout.write(
108
- moveCursor(1, 1) +
109
- eraseDown() +
110
- `${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
111
- `Make sure routstrd is running.\n` +
112
- `\nPress Q to quit.`
113
- );
114
- return;
115
113
  }
114
+ render();
115
+ } finally {
116
+ fetching = false;
117
+ }
118
+ }
116
119
 
117
- const content = renderTabContent(currentTab, stats, balance, status, width, clients);
118
- const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
119
- const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
120
- const chromeLines = chrome.split("\n").length - 1;
121
- const footerSeparator = renderSeparator(width);
122
- const footerLines = footerSeparator.split("\n").length - 1;
123
- const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
124
- const visibleContent = applyScrollToContent(content, contentViewportHeight);
125
- const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
120
+ /**
121
+ * Synchronous paint — never awaits I/O. Reads the current state variables
122
+ * and writes to stdout. Safe to call from key handlers without blocking.
123
+ */
124
+ function render(): void {
125
+ const width = getWidth();
126
+ const height = getHeight();
126
127
 
127
- stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
128
- } finally {
129
- rendering = false;
128
+ if (!stats) {
129
+ stdout.write(
130
+ moveCursor(1, 1) +
131
+ eraseDown() +
132
+ `${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
133
+ `Make sure routstrd is running.\n` +
134
+ `\nPress Q to quit.`
135
+ );
136
+ return;
130
137
  }
138
+
139
+ const content = renderTabContent(currentTab, stats, balance, status, width, clients);
140
+ const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
141
+ const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
142
+ const chromeLines = chrome.split("\n").length - 1;
143
+ const footerSeparator = renderSeparator(width);
144
+ const footerLines = footerSeparator.split("\n").length - 1;
145
+ const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
146
+ const visibleContent = applyScrollToContent(content, contentViewportHeight);
147
+ const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
148
+
149
+ stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
131
150
  }
132
151
 
133
152
  const handleKey = (key: string) => {
134
153
  if (vimState.isSearching) {
135
154
  if (key === "\x1b" || key === "\x1b[3~") {
136
155
  exitSearchMode();
137
- void render(false);
156
+ render();
138
157
  return;
139
158
  }
140
159
  if (key === "\r" || key === "\n") {
141
160
  if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
142
161
  exitSearchMode();
143
- void render(false);
162
+ render();
144
163
  return;
145
164
  }
146
165
  if (key === "\x7f" || key === "\x08") {
147
166
  vimState.searchQuery = vimState.searchQuery.slice(0, -1);
148
167
  if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
149
- void render(false);
168
+ render();
150
169
  return;
151
170
  }
152
171
  if (key === "\x03") {
153
172
  exitSearchMode();
154
- void render(false);
173
+ render();
155
174
  return;
156
175
  }
157
176
  if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
158
177
  vimState.searchQuery += key;
159
178
  if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
160
- void render(false);
179
+ render();
161
180
  }
162
181
  return;
163
182
  }
164
183
 
165
184
  if (key === "q" || key === "Q" || key === "\u0003") return cleanup(0);
166
185
  if (key === "r" || key === "R") {
167
- shouldUpdate = true;
168
- void render(true);
186
+ void fetchData();
169
187
  return;
170
188
  }
171
189
  if (key === "a" || key === "A") {
172
190
  autoRefresh = !autoRefresh;
173
- shouldUpdate = true;
174
- void render(false);
191
+ render();
175
192
  return;
176
193
  }
177
194
 
178
195
  if (key === "j" || key === "\x1b[B" || key === "\x1bOB") {
179
196
  scrollDown();
180
- void render(false);
197
+ render();
181
198
  return;
182
199
  }
183
200
  if (key === "k" || key === "\x1b[A" || key === "\x1bOA") {
184
201
  scrollUp();
185
- void render(false);
202
+ render();
186
203
  return;
187
204
  }
188
205
  if (key === "l" || key === "\x1b[C" || key === "\x1bOC") {
189
206
  const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
190
207
  currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length]!.id;
191
208
  vimState.scrollPos = 0;
192
- void render(false);
209
+ render();
193
210
  return;
194
211
  }
195
212
  if (key === "h" || key === "\x1b[D" || key === "\x1bOD") {
196
213
  const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
197
214
  currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length]!.id;
198
215
  vimState.scrollPos = 0;
199
- void render(false);
216
+ render();
200
217
  return;
201
218
  }
202
219
 
@@ -204,54 +221,53 @@ export async function runUsageTui(): Promise<void> {
204
221
  if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) {
205
222
  scrollToBottom();
206
223
  vimState.lastKey = "";
207
- void render(false);
224
+ render();
208
225
  return;
209
226
  }
210
227
  vimState.lastKey = "g";
211
228
  vimState.lastKeyTime = Date.now();
212
229
  scrollToTop();
213
- void render(false);
230
+ render();
214
231
  return;
215
232
  }
216
233
 
217
- if (key === "\x02") { pageUp(); void render(false); return; }
218
- if (key === "\x06") { pageDown(); void render(false); return; }
219
- if (key === "\x15") { scrollUp(10); void render(false); return; }
220
- if (key === "\x04") { scrollDown(10); void render(false); return; }
221
- if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); void render(false); return; }
222
- if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); void render(false); return; }
223
- if (key === "/") { startSearch(false); void render(false); return; }
224
- if (key === "?") { startSearch(true); void render(false); return; }
234
+ if (key === "\x02") { pageUp(); render(); return; }
235
+ if (key === "\x06") { pageDown(); render(); return; }
236
+ if (key === "\x15") { scrollUp(10); render(); return; }
237
+ if (key === "\x04") { scrollDown(10); render(); return; }
238
+ if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); render(); return; }
239
+ if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); render(); return; }
240
+ if (key === "/") { startSearch(false); render(); return; }
241
+ if (key === "?") { startSearch(true); render(); return; }
225
242
  if (key === "n") {
226
243
  if (vimState.searchReverse) prevSearchResult(stats?.entries.length || 0);
227
244
  else nextSearchResult(stats?.entries.length || 0);
228
- void render(false);
245
+ render();
229
246
  return;
230
247
  }
231
248
  if (key === "N") {
232
249
  if (vimState.searchReverse) nextSearchResult(stats?.entries.length || 0);
233
250
  else prevSearchResult(stats?.entries.length || 0);
234
- void render(false);
251
+ render();
235
252
  return;
236
253
  }
237
- if (key === "\x1b") { scrollToTop(); void render(false); return; }
254
+ if (key === "\x1b") { scrollToTop(); render(); return; }
238
255
 
239
256
  const tab = visibleTabs.find((t) => t.key === key);
240
257
  if (tab) {
241
258
  currentTab = tab.id;
242
259
  vimState.scrollPos = 0;
243
- void render(false);
260
+ render();
244
261
  }
245
262
  };
246
263
 
247
264
  if (isInteractive) stdin.on("data", handleKey);
248
265
 
249
- await render(true);
266
+ await fetchData();
250
267
 
251
268
  refreshInterval = setInterval(() => {
252
269
  if (autoRefresh) {
253
- shouldUpdate = true;
254
- void render(true);
270
+ void fetchData();
255
271
  }
256
272
  }, 2000);
257
273
  }
@@ -26,9 +26,6 @@ export interface StatusInfo {
26
26
 
27
27
  export async function fetchStatus(): Promise<StatusInfo | null> {
28
28
  try {
29
- const running = await isDaemonRunning();
30
- if (!running) return null;
31
-
32
29
  const result = await callDaemon("/status");
33
30
  if (result.error) return null;
34
31
 
@@ -52,9 +49,6 @@ export async function fetchStatus(): Promise<StatusInfo | null> {
52
49
 
53
50
  export async function fetchBalance(): Promise<BalanceInfo | null> {
54
51
  try {
55
- const running = await isDaemonRunning();
56
- if (!running) return null;
57
-
58
52
  const result = await callDaemon("/keys/balance");
59
53
  if (result.error) return null;
60
54
 
@@ -78,9 +72,6 @@ export async function fetchBalance(): Promise<BalanceInfo | null> {
78
72
 
79
73
  export async function fetchUsageSummary(): Promise<UsageStats | null> {
80
74
  try {
81
- const running = await isDaemonRunning();
82
- if (!running) return null;
83
-
84
75
  const tz = new Date().getTimezoneOffset();
85
76
  const result = await callDaemon(`/usage/summary?tz=${tz}`);
86
77
  if (result.error) return null;
@@ -123,9 +114,6 @@ export interface ClientInfo {
123
114
 
124
115
  export async function fetchClients(): Promise<ClientInfo[]> {
125
116
  try {
126
- const running = await isDaemonRunning();
127
- if (!running) return [];
128
-
129
117
  const result = await callDaemon("/clients");
130
118
  if (result.error) return [];
131
119