routstrd 0.4.9 → 0.4.11

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.
@@ -1,5 +1,6 @@
1
1
  import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
2
  import {
3
+ callAuth,
3
4
  callDaemon,
4
5
  getDaemonBaseUrl,
5
6
  isDaemonRunning,
@@ -145,3 +146,112 @@ export async function fetchClients(): Promise<ClientInfo[]> {
145
146
  export function hasAnyNpubs(clients: ClientInfo[]): boolean {
146
147
  return clients.some((c) => !!c.ownerNpub);
147
148
  }
149
+
150
+ /** A configured npub as returned by the auth proxy (`/npubs`). */
151
+ export interface NpubEntry {
152
+ npub: string;
153
+ name: string | null;
154
+ role: string;
155
+ }
156
+
157
+ /**
158
+ * Fetch the configured npubs (with their display names and roles) from the
159
+ * auth proxy. Returns an empty list when the endpoint is unavailable — e.g. a
160
+ * local daemon that doesn't route `/npubs` — so the TUI degrades gracefully.
161
+ */
162
+ export async function fetchNpubs(): Promise<NpubEntry[]> {
163
+ try {
164
+ const result = await callAuth("/npubs");
165
+ if (result.error) return [];
166
+
167
+ // Handle both wrapped { output: { npubs } } and direct { npubs } responses.
168
+ const direct = (result as { npubs?: NpubEntry[] }).npubs;
169
+ const wrapped = (result.output as { npubs?: NpubEntry[] } | undefined)?.npubs;
170
+ return direct ?? wrapped ?? [];
171
+ } catch {
172
+ return [];
173
+ }
174
+ }
175
+
176
+ /** Number of trailing npub characters the auth proxy folds into a client id. */
177
+ export const NPUB_SUFFIX_LENGTH = 7;
178
+
179
+ /**
180
+ * Lookup tables for turning a raw usage `client` id (e.g.
181
+ * `claude-code-a1b2c3d`) back into a human label (e.g. `Alice (claude-code)`).
182
+ *
183
+ * All maps are empty in local mode, so {@link resolveClientLabel} falls back to
184
+ * the raw id with no behaviour change there.
185
+ */
186
+ export interface ClientNaming {
187
+ /** Configured npubs (names + roles) from the auth proxy. */
188
+ npubs: NpubEntry[];
189
+ /** bare clientId -> owner npub, from `GET /clients`. */
190
+ ownersByClientId: Map<string, string>;
191
+ /** npub -> display name (null when unset). */
192
+ npubNames: Map<string, string | null>;
193
+ /** trailing npub suffix -> npub. */
194
+ npubsBySuffix: Map<string, string>;
195
+ }
196
+
197
+ export function buildClientNaming(clients: ClientInfo[], npubs: NpubEntry[]): ClientNaming {
198
+ const ownersByClientId = new Map<string, string>();
199
+ for (const c of clients) {
200
+ if (c.ownerNpub) ownersByClientId.set(c.clientId, c.ownerNpub);
201
+ }
202
+
203
+ const npubNames = new Map<string, string | null>();
204
+ const npubsBySuffix = new Map<string, string>();
205
+ const remember = (npub: string) => npubsBySuffix.set(npub.slice(-NPUB_SUFFIX_LENGTH), npub);
206
+
207
+ for (const n of npubs) {
208
+ npubNames.set(n.npub, n.name ?? null);
209
+ remember(n.npub);
210
+ }
211
+ // `GET /clients` also carries owner npubs, which covers entries whose owner
212
+ // isn't present in the (possibly filtered) `/npubs` response.
213
+ for (const owner of ownersByClientId.values()) remember(owner);
214
+
215
+ return { npubs, ownersByClientId, npubNames, npubsBySuffix };
216
+ }
217
+
218
+ /** Configured display name for an npub, or null when unset/blank. */
219
+ function npubDisplayName(naming: ClientNaming, npub: string): string | null {
220
+ const name = naming.npubNames.get(npub)?.trim();
221
+ return name && name.length > 0 ? name : null;
222
+ }
223
+
224
+ /** `Alice (claude-code)` when the owner has a name, else the bare client id. */
225
+ function formatClientLabel(naming: ClientNaming, bareId: string, ownerNpub: string): string {
226
+ const name = npubDisplayName(naming, ownerNpub);
227
+ return name ? `${name} (${bareId})` : bareId;
228
+ }
229
+
230
+ /**
231
+ * Render a usage entry's `client` value as `Name (client-id)`, where `Name` is
232
+ * the owner npub's display name and the `-<npub tail>` suffix is stripped.
233
+ * Falls back to the raw id when no owner/name can be resolved (local mode).
234
+ */
235
+ export function resolveClientLabel(clientId: string | undefined, naming: ClientNaming): string {
236
+ const raw = clientId && clientId.length > 0 ? clientId : "unknown";
237
+
238
+ // Exact match: the raw id is a known bare clientId or its suffixed form.
239
+ for (const [bareId, ownerNpub] of naming.ownersByClientId) {
240
+ const suffixed = `${bareId}-${ownerNpub.slice(-NPUB_SUFFIX_LENGTH)}`;
241
+ if (raw !== bareId && raw !== suffixed) continue;
242
+ return formatClientLabel(naming, bareId, ownerNpub);
243
+ }
244
+
245
+ // Fallback: strip a trailing `-<npub tail>` that matches a known npub.
246
+ for (const [suffix, npub] of naming.npubsBySuffix) {
247
+ const marker = `-${suffix}`;
248
+ if (raw.length <= marker.length || !raw.endsWith(marker)) continue;
249
+ return formatClientLabel(naming, raw.slice(0, -marker.length), npub);
250
+ }
251
+
252
+ return raw;
253
+ }
254
+
255
+ export function emptyClientNaming(): ClientNaming {
256
+ return buildClientNaming([], []);
257
+ }
@@ -3,7 +3,8 @@ import type { Tab } from "./types.ts";
3
3
  import {
4
4
  formatNumber,
5
5
  formatTime,
6
- type ClientInfo,
6
+ resolveClientLabel,
7
+ type ClientNaming,
7
8
  } from "./data.ts";
8
9
  import { vimState } from "./state.ts";
9
10
  import { stripAnsi } from "./terminal.ts";
@@ -503,20 +504,23 @@ export function renderClients(stats: UsageStats, width: number): string {
503
504
  return output;
504
505
  }
505
506
 
506
- export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string {
507
+ export function renderNpubs(stats: UsageStats, naming: ClientNaming, width: number): string {
507
508
  const npubStats = stats.summary.npubs;
508
509
  if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown");
509
510
 
511
+ // Index configured npubs by their npub so usage rows can show names/roles.
512
+ const configured = new Map(naming.npubs.map((entry) => [entry.npub, entry]));
513
+
510
514
  const totalCost = stats.totalSatsCost;
511
515
  const maxCost = npubStats[0]!.satsCost;
512
516
  const lines: string[] = [];
513
517
 
514
- const col1 = 24; // Npub (truncated)
518
+ const col1 = 24; // Name (or truncated npub)
515
519
  const col2 = 12; // Requests
516
520
  const col3 = 24; // Cost
517
521
  const col4 = 12; // Tokens
518
522
 
519
- const hNpub = "Npub".padEnd(col1);
523
+ const hNpub = "Name".padEnd(col1);
520
524
  const hReqs = "Requests".padEnd(col2);
521
525
  const hCost = "Cost".padEnd(col3);
522
526
  const hTok = "Tokens".padEnd(col4);
@@ -527,9 +531,11 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
527
531
  for (const npub of npubStats) {
528
532
  const pct = totalCost > 0 ? ((npub.satsCost / totalCost) * 100).toFixed(1) : "0.0";
529
533
  const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 0);
530
- const shortNpub = truncateNpub(npub.npub);
534
+ const entry = configured.get(npub.npub);
535
+ const name = entry?.name?.trim();
536
+ const label = name || truncateNpub(npub.npub);
531
537
 
532
- const dNpub = shortNpub.padEnd(col1);
538
+ const dNpub = label.slice(0, col1 - 1).padEnd(col1);
533
539
  const dReqs = formatReqs(npub.requests).padEnd(col2);
534
540
  const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
535
541
  const dTok = formatNumber(npub.totalTokens).padEnd(col4);
@@ -541,8 +547,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
541
547
  `${COLORS.green}${dCost}${COLORS.reset}` +
542
548
  `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`
543
549
  );
544
- // Full npub on its own line for copy-ability
545
- lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`);
550
+ // Full npub (and role when known) on its own line for copy-ability.
551
+ const roleSuffix = entry?.role ? ` [${entry.role}]` : "";
552
+ lines.push(` ${COLORS.dim}${npub.npub}${roleSuffix}${COLORS.reset}`);
546
553
  lines.push(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`);
547
554
  lines.push("");
548
555
  }
@@ -555,7 +562,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
555
562
  // Use pre-aggregated topModels from summary
556
563
  for (const topNpub of stats.summary.npubs.slice(0, 5)) {
557
564
  if (topNpub.topModels.length === 0) continue;
558
- npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
565
+ const name = configured.get(topNpub.npub)?.name?.trim();
566
+ const label = name || truncateNpub(topNpub.npub);
567
+ npubModelLines.push(`${COLORS.bold}${label}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
559
568
  for (const m of topNpub.topModels) {
560
569
  npubModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
561
570
  }
@@ -576,46 +585,201 @@ function truncateNpub(npub: string): string {
576
585
  return npub.slice(0, 10) + "…" + npub.slice(-6);
577
586
  }
578
587
 
579
- export function renderRecent(stats: UsageStats, width: number): string {
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
+
680
+ export function renderRecent(stats: UsageStats, width: number, naming: ClientNaming): string {
580
681
  const recentEntries = stats.entries.slice(0, 50);
581
682
  if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
582
683
 
583
- const clientCol = 14;
584
- const tokensCol = 18;
585
- const costCol = 18;
586
- const providerCol = Math.max(16, width - 4 - 10 - 18 - tokensCol - costCol - clientCol - 5);
587
- const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0;
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;
693
+
694
+ // Remote mode: `Alice (claude-code)` — owner display name + the client id
695
+ // with the `-<npub tail>` suffix stripped. Local mode (no owner/name data)
696
+ // keeps the previous behaviour: the raw id in a fixed 14-col cell.
697
+ const hasOwnerInfo = naming.npubs.length > 0 || naming.ownersByClientId.size > 0;
698
+ const clientLabels = recentEntries.map((entry) =>
699
+ hasOwnerInfo ? resolveClientLabel(entry.client, naming) : entry.client || "unknown"
700
+ );
701
+ const maxLabelLen = clientLabels.reduce((max, label) => Math.max(max, label.length), 6);
702
+
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.
715
+ const innerWidth = Math.max(0, width - 4);
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;
736
+
588
737
  const lines: string[] = [];
589
- lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"I/CR/CW/O".padEnd(tokensCol)} ${"I/O/T in sats".padEnd(costCol)} ${"BASE:PROVIDER".padEnd(providerCol)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
590
- 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);
591
750
 
592
- for (const entry of recentEntries) {
751
+ for (let i = 0; i < recentEntries.length; i++) {
752
+ const entry = recentEntries[i]!;
593
753
  const time = formatTime(entry.timestamp).slice(0, 8);
594
- const model = entry.modelId.slice(0, 18).padEnd(18);
595
- const tokens = [
596
- entry.promptTokens,
597
- entry.cacheReadInputTokens || 0,
598
- entry.cacheCreationInputTokens || 0,
599
- entry.completionTokens,
600
- ].map(formatNumber).join("/");
754
+ const model = entry.modelId.slice(0, modelCol).padEnd(modelCol);
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)}`;
601
761
  const totalSats = typeof entry.totalMsats === "number" ? entry.totalMsats / 1000 : entry.satsCost;
602
- const cost = [
603
- formatCost(msatsToSats(entry.inputMsats)),
604
- formatCost(msatsToSats(entry.outputMsats)),
605
- formatCost(totalSats),
606
- ].join("/");
762
+ // Right-aligned so the `sats` unit ends at the same column on every row.
763
+ const cost = `${formatCost(totalSats)} sats`.padStart(costCol);
607
764
  const baseUrl = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "");
608
765
  const provider = `${baseUrl}:${entry.provider || "unknown"}`.slice(0, providerCol).padEnd(providerCol);
609
- const clientName = (entry.client || "unknown").slice(0, clientCol - 1);
766
+ const clientLabel = clientLabels[i]!.slice(0, clientCol).padEnd(clientCol);
610
767
  const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
611
768
  const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
612
- 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}${clientName}${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(" "));
613
777
  }
614
778
 
615
779
  return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
616
780
  }
617
781
 
618
- export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, clients: ClientInfo[] = []): string {
782
+ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, naming: ClientNaming): string {
619
783
  switch (activeTab) {
620
784
  case "overview": return renderOverview(stats, balance, status, width);
621
785
  case "today": return renderToday(stats, width);
@@ -623,8 +787,8 @@ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: B
623
787
  case "providers": return renderProviders(stats, width);
624
788
  case "tokens": return renderTokens(stats, width);
625
789
  case "clients": return renderClients(stats, width);
626
- case "npubs": return renderNpubs(stats, clients, width);
627
- case "recent": return renderRecent(stats, width);
790
+ case "npubs": return renderNpubs(stats, naming, width);
791
+ case "recent": return renderRecent(stats, width, naming);
628
792
  default: return "Unknown tab";
629
793
  }
630
794
  }
@@ -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