routstrd 0.4.10 → 0.4.12

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.
@@ -585,14 +585,111 @@ function truncateNpub(npub: string): string {
585
585
  return npub.slice(0, 10) + "…" + npub.slice(-6);
586
586
  }
587
587
 
588
+ /**
589
+ * Token breakdown of a single request, split the way the TUI colours it:
590
+ * input read from the prompt cache (green) vs input that was not (red,
591
+ * i.e. cache writes plus uncached input), plus output tokens.
592
+ */
593
+ export interface TokenSegments {
594
+ /** Input tokens served from the prompt cache. */
595
+ cacheRead: number;
596
+ /** Input tokens that were not served from the cache. */
597
+ notCached: number;
598
+ /** Total input tokens, cached and not. */
599
+ input: number;
600
+ output: number;
601
+ /** Input + output. */
602
+ total: number;
603
+ }
604
+
605
+ /**
606
+ * Split a usage entry into cache read / not-cached input and output tokens.
607
+ *
608
+ * Providers report prompt tokens inconsistently: OpenAI-style `prompt_tokens`
609
+ * already includes the cached subsets, while Anthropic-style `input_tokens`
610
+ * reports them alongside. If the prompt is at least as large as the cached
611
+ * counts it is treated as containing them (OpenAI); otherwise the cached
612
+ * counts are extra (Anthropic). `input` is the sum of what is drawn rather
613
+ * than a possibly under-counted `totalTokens`.
614
+ */
615
+ export function tokenSegments(entry: {
616
+ promptTokens?: number;
617
+ completionTokens?: number;
618
+ cacheReadInputTokens?: number;
619
+ cacheCreationInputTokens?: number;
620
+ }): TokenSegments {
621
+ const cacheRead = Math.max(0, entry.cacheReadInputTokens || 0);
622
+ const cacheWrite = Math.max(0, entry.cacheCreationInputTokens || 0);
623
+ const prompt = Math.max(0, entry.promptTokens || 0);
624
+ const output = Math.max(0, entry.completionTokens || 0);
625
+ const cached = cacheRead + cacheWrite;
626
+ const uncachedInput = prompt >= cached ? prompt - cached : prompt;
627
+ const notCached = cacheWrite + uncachedInput;
628
+ const input = cacheRead + notCached;
629
+ return { cacheRead, notCached, input, output, total: input + output };
630
+ }
631
+
632
+ /** One coloured slice of a {@link renderStackedBar}. */
633
+ export interface BarSegment {
634
+ /** Segment magnitude; negatives are treated as zero. */
635
+ value: number;
636
+ /** ANSI escape applied to this segment's cells. */
637
+ color: string;
638
+ }
639
+
640
+ /**
641
+ * Render a fixed-width stacked bar: `trackWidth` cells split between the
642
+ * segments in proportion to their values, so the bar conveys *composition*
643
+ * (magnitude is reported separately, e.g. as the token total beside it).
644
+ *
645
+ * Cell counts use largest-remainder rounding, so the segments always fill
646
+ * exactly `trackWidth` cells. A row with no tokens renders as an empty track.
647
+ */
648
+ export function renderStackedBar(segments: BarSegment[], trackWidth: number): string {
649
+ const track = Math.max(0, Math.floor(trackWidth));
650
+ if (track === 0) return "";
651
+
652
+ const values = segments.map((segment) => Math.max(0, segment.value));
653
+ const total = values.reduce((sum, value) => sum + value, 0);
654
+ if (total <= 0) return " ".repeat(track);
655
+
656
+ const exact = values.map((value) => (value / total) * track);
657
+ const cells = exact.map((value) => Math.floor(value));
658
+ let remaining = track - cells.reduce((sum, value) => sum + value, 0);
659
+
660
+ // Largest-remainder: leftover cells go to the biggest fractional parts.
661
+ const remainders = exact
662
+ .map((value, index) => ({ index, frac: value - Math.floor(value) }))
663
+ .sort((a, b) => b.frac - a.frac);
664
+ for (let i = 0; i < remainders.length && remaining > 0; i++, remaining--) {
665
+ cells[remainders[i]!.index]! += 1;
666
+ }
667
+
668
+ const bar = segments
669
+ .map((segment, i) => (cells[i]! > 0 ? `${segment.color}${"█".repeat(cells[i]!)}` : ""))
670
+ .join("");
671
+ return bar.length > 0 ? bar + COLORS.reset : " ".repeat(track);
672
+ }
673
+
674
+ /** Colour coding for the Recent tab's cache-hit bars. */
675
+ const TOKEN_BAR_COLORS = {
676
+ cacheRead: COLORS.green,
677
+ notCached: COLORS.red,
678
+ };
679
+
588
680
  export function renderRecent(stats: UsageStats, width: number, naming: ClientNaming): string {
589
681
  const recentEntries = stats.entries.slice(0, 50);
590
682
  if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
591
683
 
592
- const timeCol = 10;
593
- const modelCol = 18;
594
- const tokensCol = 18;
595
- const costCol = 18;
684
+ const timeCol = 8;
685
+ const costCol = 12;
686
+ // Width reserved left of the token counts for the cache-hit bar. `CACHE HIT`
687
+ // is the header drawn over the bar, so the bar can never be narrower.
688
+ const minBarWidth = "CACHE HIT".length;
689
+ const maxBarWidth = 20;
690
+ const minProviderCol = 12;
691
+ const minModelCol = 10;
692
+ const minClientCol = 6;
596
693
 
597
694
  // Remote mode: `Alice (claude-code)` — owner display name + the client id
598
695
  // with the `-<npub tail>` suffix stripped. Local mode (no owner/name data)
@@ -602,39 +699,81 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam
602
699
  hasOwnerInfo ? resolveClientLabel(entry.client, naming) : entry.client || "unknown"
603
700
  );
604
701
  const maxLabelLen = clientLabels.reduce((max, label) => Math.max(max, label.length), 6);
605
- const clientCol = hasOwnerInfo ? Math.min(32, Math.max(14, maxLabelLen)) : 14;
606
702
 
607
- // Budget: time + model + tokens + cost + provider + client + 5 separators.
703
+ // Token counts are drawn as `IN - OUT` with IN pinned to the left edge of the
704
+ // column and OUT to the right, so both halves need the widest value on show.
705
+ const tokenRows = recentEntries.map((entry) => tokenSegments(entry));
706
+ const inputTexts = tokenRows.map((row) => formatNumber(row.input));
707
+ const outputTexts = tokenRows.map((row) => formatNumber(row.output));
708
+ const inputCol = Math.max(...inputTexts.map((text) => text.length));
709
+ const outputCol = Math.max(...outputTexts.map((text) => text.length));
710
+ const inOutCol = inputCol + outputCol + " - ".length;
711
+
712
+ // Lay out the columns against the box's inner width: start from the widest
713
+ // layout, hand the slack to the provider column, then give space back in
714
+ // priority order (bar, provider, model, client) until everything fits.
608
715
  const innerWidth = Math.max(0, width - 4);
609
- const providerCol = Math.max(12, innerWidth - timeCol - modelCol - tokensCol - costCol - 5 - clientCol);
716
+ let modelCol = 18;
717
+ let clientCol = hasOwnerInfo ? Math.min(32, Math.max(14, maxLabelLen)) : 14;
718
+ let barWidth = maxBarWidth;
719
+ let showProvider = false;
720
+ let providerCol = 0;
721
+ // Column widths plus one separator between each visible column.
722
+ const usedWidth = () =>
723
+ timeCol + modelCol + (barWidth + 1 + inOutCol) + costCol + clientCol +
724
+ (showProvider ? providerCol + 1 : 0) + 4;
725
+
726
+ if (innerWidth - usedWidth() > minProviderCol + 1) {
727
+ showProvider = true;
728
+ providerCol = innerWidth - usedWidth();
729
+ }
730
+ while (usedWidth() > innerWidth && barWidth > minBarWidth) barWidth -= 1;
731
+ while (usedWidth() > innerWidth && providerCol > minProviderCol) providerCol -= 1;
732
+ while (usedWidth() > innerWidth && modelCol > minModelCol) modelCol -= 1;
733
+ while (usedWidth() > innerWidth && clientCol > minClientCol) clientCol -= 1;
734
+
735
+ const tokensCol = barWidth + 1 + inOutCol;
610
736
 
611
- const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0;
612
737
  const lines: string[] = [];
613
- lines.push(`${COLORS.bold}${["TIME".padEnd(timeCol), "MODEL".padEnd(modelCol), "I/CR/CW/O".padEnd(tokensCol), "I/O/T in sats".padEnd(costCol), "BASE:PROVIDER".padEnd(providerCol), "CLIENT".padEnd(clientCol)].join(" ")}${COLORS.reset}`);
614
- lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
738
+ const header = [
739
+ "TIME".padEnd(timeCol),
740
+ "MODEL".padEnd(modelCol),
741
+ "CACHE HIT".padEnd(tokensCol - inOutCol) + "IN".padEnd(inputCol) + " - " + "OUT".padStart(outputCol),
742
+ // Right-aligned like the values below it, so the column's right edge is
743
+ // shared by the header and every `n sats` cell.
744
+ "COST".padStart(costCol),
745
+ ...(showProvider ? ["BASE:PROVIDER".padEnd(providerCol)] : []),
746
+ "CLIENT".padEnd(clientCol),
747
+ ];
748
+ lines.push(`${COLORS.bold}${header.join(" ")}${COLORS.reset}`);
749
+ lines.push(COLORS.dim + "─".repeat(innerWidth) + COLORS.reset);
615
750
 
616
751
  for (let i = 0; i < recentEntries.length; i++) {
617
752
  const entry = recentEntries[i]!;
618
753
  const time = formatTime(entry.timestamp).slice(0, 8);
619
754
  const model = entry.modelId.slice(0, modelCol).padEnd(modelCol);
620
- const tokens = [
621
- entry.promptTokens,
622
- entry.cacheReadInputTokens || 0,
623
- entry.cacheCreationInputTokens || 0,
624
- entry.completionTokens,
625
- ].map(formatNumber).join("/");
755
+ const segments = tokenRows[i]!;
756
+ const bar = renderStackedBar([
757
+ { value: segments.cacheRead, color: TOKEN_BAR_COLORS.cacheRead },
758
+ { value: segments.notCached, color: TOKEN_BAR_COLORS.notCached },
759
+ ], barWidth);
760
+ const tokens = `${bar} ${inputTexts[i]!.padEnd(inputCol)} - ${outputTexts[i]!.padStart(outputCol)}`;
626
761
  const totalSats = typeof entry.totalMsats === "number" ? entry.totalMsats / 1000 : entry.satsCost;
627
- const cost = [
628
- formatCost(msatsToSats(entry.inputMsats)),
629
- formatCost(msatsToSats(entry.outputMsats)),
630
- formatCost(totalSats),
631
- ].join("/");
762
+ // Right-aligned so the `sats` unit ends at the same column on every row.
763
+ const cost = `${formatCost(totalSats)} sats`.padStart(costCol);
632
764
  const baseUrl = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "");
633
765
  const provider = `${baseUrl}:${entry.provider || "unknown"}`.slice(0, providerCol).padEnd(providerCol);
634
766
  const clientLabel = clientLabels[i]!.slice(0, clientCol).padEnd(clientCol);
635
767
  const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
636
768
  const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
637
- lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(tokensCol)} ${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientLabel}${COLORS.reset}`);
769
+ lines.push([
770
+ `${COLORS.dim}${time}${COLORS.reset}`,
771
+ `${modelColor}${model}${COLORS.reset}`,
772
+ tokens,
773
+ `${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset}`,
774
+ ...(showProvider ? [`${COLORS.dim}${provider}${COLORS.reset}`] : []),
775
+ `${clientColor}${clientLabel}${COLORS.reset}`,
776
+ ].join(" "));
638
777
  }
639
778
 
640
779
  return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
@@ -46,8 +46,9 @@ export interface RoutstrdConfig {
46
46
  port: number;
47
47
  host: string;
48
48
  provider: string | null;
49
- cocodPath: string | null;
50
49
  mode?: "xcashu" | "apikeys";
50
+ /** Opt into SDK automatic model-path selection for DeepSeek V4.1 Flash. Disabled by default; requires daemon restart after changing. */
51
+ autoModelPath?: boolean;
51
52
  /** Raw upstream request/response logging. Disabled by default because logs can contain sensitive prompts, outputs, and auth/payment headers. */
52
53
  requestResponseLogging?: {
53
54
  /** Enable raw request/response file logging. */
@@ -85,7 +86,7 @@ export const DEFAULT_CONFIG: RoutstrdConfig = {
85
86
  port: 8008,
86
87
  host: "127.0.0.1",
87
88
  provider: null,
88
- cocodPath: null,
89
89
  mode: "apikeys",
90
+ autoModelPath: false,
90
91
  maxTokens: 64000,
91
92
  };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Cooldown reporting, shared by the daemon (`GET /cooldowns`) and the
3
+ * `routstrd cooldowns` CLI command.
4
+ *
5
+ * Cooldown state is owned by the SDK: its `ProviderManager` writes
6
+ * `providersOnCooldown` into the SdkStore, and every entry blocks routing for
7
+ * the length of the cooldown window (`getCooldownDurationMs()`, 210s today).
8
+ * An entry with a `modelId` cools down only that model on the provider; an
9
+ * entry without one cools down every model on the provider.
10
+ *
11
+ * The SDK prunes expired entries lazily (on the next routing decision), so a
12
+ * read of the persisted list can contain entries that already timed out.
13
+ * Reporting therefore filters by age and never mutates the store.
14
+ */
15
+
16
+ /** One persisted cooldown entry, as stored by the SDK. */
17
+ export interface StoredCooldownEntry {
18
+ baseUrl: string;
19
+ /** Present for model-scoped entries; absent for provider-wide ones. */
20
+ modelId?: string;
21
+ /** When the cooldown started (ms since epoch). */
22
+ timestamp: number;
23
+ }
24
+
25
+ export interface CooldownSummary {
26
+ baseUrl: string;
27
+ /** Model id for model-scoped cooldowns, `null` for provider-wide ones. */
28
+ modelId: string | null;
29
+ scope: "provider" | "model";
30
+ startedAt: number;
31
+ expiresAt: number;
32
+ remainingMs: number;
33
+ }
34
+
35
+ export interface CooldownsOutput {
36
+ /** Server time the payload was built at (ms since epoch). */
37
+ now: number;
38
+ cooldownDurationMs: number;
39
+ /** Number of active cooldown entries (provider- and model-scoped). */
40
+ count: number;
41
+ /** Number of distinct providers with at least one active entry. */
42
+ providerCount: number;
43
+ cooldowns: CooldownSummary[];
44
+ }
45
+
46
+ /**
47
+ * Build the `/cooldowns` payload: drop expired entries, tag each entry's
48
+ * scope, and compute when it lifts. Longest remaining cooldown comes first.
49
+ */
50
+ export function buildCooldownsOutput(
51
+ entries: StoredCooldownEntry[],
52
+ cooldownDurationMs: number,
53
+ now: number = Date.now(),
54
+ ): CooldownsOutput {
55
+ const cooldowns = entries
56
+ .filter((entry) => now - entry.timestamp < cooldownDurationMs)
57
+ .map((entry): CooldownSummary => {
58
+ const modelId = entry.modelId ?? null;
59
+ const expiresAt = entry.timestamp + cooldownDurationMs;
60
+ return {
61
+ baseUrl: entry.baseUrl,
62
+ modelId,
63
+ scope: modelId === null ? "provider" : "model",
64
+ startedAt: entry.timestamp,
65
+ expiresAt,
66
+ remainingMs: Math.max(0, expiresAt - now),
67
+ };
68
+ })
69
+ .sort(
70
+ (a, b) =>
71
+ b.remainingMs - a.remainingMs ||
72
+ a.baseUrl.localeCompare(b.baseUrl) ||
73
+ (a.modelId ?? "").localeCompare(b.modelId ?? ""),
74
+ );
75
+
76
+ return {
77
+ now,
78
+ cooldownDurationMs,
79
+ count: cooldowns.length,
80
+ providerCount: new Set(cooldowns.map((entry) => entry.baseUrl)).size,
81
+ cooldowns,
82
+ };
83
+ }
84
+
85
+ /** Format a remaining-time value for the terminal, e.g. `2m 05s` or `42s`. */
86
+ export function formatCooldownRemaining(ms: number): string {
87
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
88
+ const minutes = Math.floor(totalSeconds / 60);
89
+ const seconds = totalSeconds % 60;
90
+ return minutes > 0
91
+ ? `${minutes}m ${String(seconds).padStart(2, "0")}s`
92
+ : `${seconds}s`;
93
+ }
94
+
95
+ /** Render a `/cooldowns` payload for `routstrd cooldowns`. */
96
+ export function formatCooldowns(output: CooldownsOutput): string {
97
+ const windowSeconds = Math.round(output.cooldownDurationMs / 1000);
98
+ const heading = `Cooldowns (${windowSeconds}s window)`;
99
+
100
+ if (output.cooldowns.length === 0) {
101
+ return `${heading}\n\n Nothing is on cooldown right now.`;
102
+ }
103
+
104
+ const scopeCell = (entry: CooldownSummary) =>
105
+ entry.scope === "provider" ? "PROVIDER" : "MODEL";
106
+ const urlWidth = Math.max(
107
+ ...output.cooldowns.map((entry) => entry.baseUrl.length),
108
+ );
109
+ const modelWidth = Math.max(
110
+ 0,
111
+ ...output.cooldowns.map((entry) => (entry.modelId ?? "").length),
112
+ );
113
+
114
+ const lines = [
115
+ heading,
116
+ "",
117
+ ` ${output.count} active across ${output.providerCount} ${
118
+ output.providerCount === 1 ? "provider" : "providers"
119
+ }:`,
120
+ "",
121
+ ];
122
+
123
+ for (const entry of output.cooldowns) {
124
+ const cells = [scopeCell(entry).padEnd("PROVIDER".length), entry.baseUrl.padEnd(urlWidth)];
125
+ if (entry.scope === "model") {
126
+ cells.push((entry.modelId ?? "").padEnd(modelWidth));
127
+ }
128
+ lines.push(
129
+ ` ${cells.join(" ")} expires in ${formatCooldownRemaining(entry.remainingMs)}`,
130
+ );
131
+ }
132
+
133
+ return lines.join("\n");
134
+ }
@@ -56,6 +56,32 @@ class DaemonConnectionError extends Error {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * Upper bound for a single daemon request, including response-body
61
+ * consumption. The daemon bounds its own NWC operations, so this only guards
62
+ * against a wedged server; without it a hung request would block the CLI
63
+ * forever.
64
+ */
65
+ export const DAEMON_REQUEST_TIMEOUT_MS = 120_000;
66
+
67
+ /**
68
+ * Upper bound for value-moving wallet routes. A cashu melt/swap can
69
+ * legitimately run longer than {@link DAEMON_REQUEST_TIMEOUT_MS} (the mint has
70
+ * no request timeout in routstrd), so these get a more generous bound that
71
+ * still prevents an indefinite CLI hang.
72
+ */
73
+ export const DAEMON_LONG_REQUEST_TIMEOUT_MS = 600_000;
74
+
75
+ /** Routes that may legitimately outlive the default request timeout. */
76
+ const LONG_RUNNING_ROUTES = ["/wallet/send/", "/wallet/receive/"];
77
+
78
+ function requestTimeoutMs(path: string): number {
79
+ const pathname = path.split("?")[0] ?? path;
80
+ return LONG_RUNNING_ROUTES.some((route) => pathname.startsWith(route))
81
+ ? DAEMON_LONG_REQUEST_TIMEOUT_MS
82
+ : DAEMON_REQUEST_TIMEOUT_MS;
83
+ }
84
+
59
85
  export function getDaemonBaseUrl(config: RoutstrdConfig): string {
60
86
  if (config.daemonUrl) {
61
87
  return config.daemonUrl.replace(/\/$/, "");
@@ -70,7 +96,7 @@ export function getAuthBaseUrl(config: RoutstrdConfig): string {
70
96
  return getDaemonBaseUrl(config);
71
97
  }
72
98
 
73
- async function _callUrl(
99
+ export async function callDaemonUrl(
74
100
  baseUrl: string,
75
101
  path: string,
76
102
  options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
@@ -99,23 +125,41 @@ async function _callUrl(
99
125
  if (authorization) headers.set("Authorization", authorization);
100
126
  if (bodyString) headers.set("Content-Type", "application/json");
101
127
 
128
+ const timeoutMs = requestTimeoutMs(path);
129
+ const timeoutError = () =>
130
+ new Error(
131
+ `Daemon request timed out after ${timeoutMs / 1000}s; ` +
132
+ "any payment outcome is unknown — check before retrying",
133
+ );
134
+ // The signal stays armed while the body is read, so a daemon that sends
135
+ // headers and then stalls the body cannot hang the CLI either. Aborting
136
+ // here never cancels the daemon's operation — see timeoutError's warning.
137
+ const signal = AbortSignal.timeout(timeoutMs);
138
+
102
139
  let response: Response;
103
140
  try {
104
141
  response = await fetch(url, {
105
142
  method,
106
143
  headers,
107
144
  body: bodyString,
145
+ signal,
108
146
  });
109
147
  } catch (error) {
148
+ if (signal.aborted) throw timeoutError();
149
+ // Only connection failures qualify for alternate-host retries.
110
150
  throw new DaemonConnectionError(error);
111
151
  }
112
152
 
113
- if (!response.ok) {
114
- const errorData = (await response.json()) as { error?: string };
115
- throw new Error(errorData.error || `HTTP ${response.status}`);
153
+ try {
154
+ if (!response.ok) {
155
+ const errorData = (await response.json()) as { error?: string };
156
+ throw new Error(errorData.error || `HTTP ${response.status}`);
157
+ }
158
+ return (await response.json()) as CommandResponse;
159
+ } catch (error) {
160
+ if (signal.aborted) throw timeoutError();
161
+ throw error;
116
162
  }
117
-
118
- return response.json() as Promise<CommandResponse>;
119
163
  }
120
164
 
121
165
  async function callLocalDaemon(
@@ -127,7 +171,7 @@ async function callLocalDaemon(
127
171
  let connectionError: DaemonConnectionError | undefined;
128
172
  for (const baseUrl of localDaemonBaseUrls(config)) {
129
173
  try {
130
- return await _callUrl(baseUrl, path, options, config);
174
+ return await callDaemonUrl(baseUrl, path, options, config);
131
175
  } catch (error) {
132
176
  if (!(error instanceof DaemonConnectionError)) throw error;
133
177
  connectionError = error;
@@ -142,7 +186,7 @@ export async function callDaemon(
142
186
  ): Promise<CommandResponse> {
143
187
  const config = await loadConfig();
144
188
  if (config.daemonUrl) {
145
- return _callUrl(getDaemonBaseUrl(config), path, options, config);
189
+ return callDaemonUrl(getDaemonBaseUrl(config), path, options, config);
146
190
  }
147
191
  return callLocalDaemon(path, options, config);
148
192
  }
@@ -157,7 +201,7 @@ export async function callAuth(
157
201
  if (!config.authUrl && !config.daemonUrl) {
158
202
  return callLocalDaemon(path, options, config);
159
203
  }
160
- return _callUrl(getAuthBaseUrl(config), path, options, config);
204
+ return callDaemonUrl(getAuthBaseUrl(config), path, options, config);
161
205
  }
162
206
 
163
207
  export async function isDaemonRunning(): Promise<boolean> {
@@ -0,0 +1,9 @@
1
+ /** Transaction types understood by the wallet history type filter. */
2
+ export const HISTORY_ENTRY_TYPES = ["mint", "melt", "send", "receive"] as const;
3
+
4
+ export type HistoryEntryType = (typeof HISTORY_ENTRY_TYPES)[number];
5
+
6
+ /** True when `value` is a recognized history transaction type. */
7
+ export function isHistoryEntryType(value: string): value is HistoryEntryType {
8
+ return (HISTORY_ENTRY_TYPES as readonly string[]).includes(value);
9
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Rejects when `timeoutMs` elapses before `promise` settles.
3
+ * This bounds the caller's wait; it does not cancel the underlying operation.
4
+ *
5
+ * Used to bound requests that may otherwise wait forever — notably NWC calls
6
+ * whose underlying library applies its own timeout only after a support/encryption
7
+ * handshake that can itself hang on a stale relay subscription.
8
+ */
9
+ export function withTimeout<T>(
10
+ promise: Promise<T>,
11
+ timeoutMs: number,
12
+ message = "Operation timed out",
13
+ ): Promise<T> {
14
+ let timer: ReturnType<typeof setTimeout> | undefined;
15
+ const timeout = new Promise<never>((_resolve, reject) => {
16
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
17
+ });
18
+ return Promise.race([promise, timeout]).finally(() => {
19
+ if (timer !== undefined) clearTimeout(timer);
20
+ });
21
+ }
@@ -1,113 +0,0 @@
1
- # TUI refactor plan
2
-
3
- ## Goals
4
- - Move the usage TUI implementation out of `src/cli/usage-tui.ts` into a dedicated `src/tui/` folder.
5
- - Reduce the size and responsibility of the current monolithic file.
6
- - Keep the existing CLI entrypoint stable so current usage does not break.
7
- - Preserve behavior while making future TUI work easier.
8
-
9
- ## Current state
10
- `src/cli/usage-tui.ts` currently mixes several concerns in one file:
11
- - TUI-specific types and constants
12
- - ANSI/terminal helpers
13
- - scroll/search/vim navigation state
14
- - data fetching from the daemon
15
- - usage aggregation/stat helpers
16
- - rendering for all tabs
17
- - app lifecycle and keyboard event handling
18
-
19
- This makes the file hard to extend safely.
20
-
21
- ## Refactor strategy
22
- Do this incrementally and keep a thin compatibility wrapper in `src/cli/usage-tui.ts`.
23
-
24
- ### Target structure
25
- - `src/tui/usage/index.ts`
26
- - public entrypoint: `runUsageTui()`
27
- - `src/tui/usage/types.ts`
28
- - `UsageStats`, tab ids, tab metadata, derived stat types
29
- - `src/tui/usage/constants.ts`
30
- - tabs, colors, model/client color maps
31
- - `src/tui/usage/terminal.ts`
32
- - ANSI helpers, width/height helpers, `stripAnsi`
33
- - `src/tui/usage/state.ts`
34
- - vim/search/scroll state and state mutation helpers
35
- - `src/tui/usage/data.ts`
36
- - `fetchUsage()` and usage aggregation helpers
37
- - `src/tui/usage/render.ts`
38
- - shared render helpers and tab renderers
39
- - `src/tui/usage/app.ts`
40
- - main loop, render orchestration, input handling, cleanup
41
- - `src/cli/usage-tui.ts`
42
- - compatibility wrapper that re-exports or calls `runUsageTui()` from `src/tui/usage`
43
-
44
- ## Design choices
45
- ### 1. Keep CLI path compatibility
46
- Do not delete the CLI file outright. Turn it into a tiny wrapper:
47
- - minimal import from `../tui/usage/index.ts`
48
- - export `runUsageTui()`
49
-
50
- This avoids breaking any existing imports or scripts.
51
-
52
- ### 2. Separate pure logic from side effects
53
- Keep these pure where possible:
54
- - aggregation helpers
55
- - formatting helpers
56
- - render helpers that return strings
57
- - scroll clamping logic
58
-
59
- Keep side effects isolated in the app layer:
60
- - reading terminal size
61
- - writing to stdout
62
- - raw mode setup
63
- - signal handling
64
- - interval scheduling
65
-
66
- ### 3. Avoid over-engineering
67
- This should be a pragmatic refactor, not a framework:
68
- - no unnecessary classes
69
- - keep function-based design
70
- - only extract modules around clear responsibility boundaries
71
-
72
- ### 4. Preserve behavior first
73
- No UX changes unless needed to support the extraction.
74
- That means:
75
- - same tabs
76
- - same keybindings
77
- - same output format
78
- - same fetch cadence
79
- - same search/scroll behavior
80
-
81
- ## Implementation steps
82
- 1. Create `src/tui/usage/`.
83
- 2. Extract types/constants first.
84
- 3. Extract terminal helpers.
85
- 4. Extract data fetching + aggregation helpers.
86
- 5. Extract state/search/scroll logic.
87
- 6. Extract rendering helpers + tab renderers.
88
- 7. Build `app.ts` using the extracted modules.
89
- 8. Replace `src/cli/usage-tui.ts` with a thin wrapper.
90
- 9. Run a TypeScript/bun check and fix imports.
91
- 10. Smoke-test keyboard handling and rendering behavior.
92
-
93
- ## Risks
94
- - circular imports between render/state/constants
95
- - broken relative import paths during extraction
96
- - subtle behavior regressions in scroll/search state
97
- - terminal escape handling differences if helpers are split carelessly
98
-
99
- ## Validation checklist
100
- - `src/cli/usage-tui.ts` still exposes `runUsageTui()`
101
- - TUI starts from the same CLI path
102
- - scroll still works for long content
103
- - vim keys still work
104
- - arrow keys still work
105
- - tab switching still resets scroll
106
- - search mode still works
107
- - cleanup still restores cursor and alternate screen
108
-
109
- ## Non-goals
110
- - redesigning the UI
111
- - changing tab contents
112
- - introducing tests unless needed for safety
113
- - adding new features unrelated to the refactor