@scrylog/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +46 -7
  2. package/package.json +13 -10
  3. package/src/api/docs/sessions.ts +3 -2
  4. package/src/api/docs/system.ts +3 -0
  5. package/src/api/docs/usage.ts +2 -2
  6. package/src/api/routes/sessions.ts +6 -2
  7. package/src/api/routes/system.ts +4 -1
  8. package/src/api/routes/webhooks/claude.ts +8 -0
  9. package/src/api/routes/webhooks/opencode.ts +1 -0
  10. package/src/api/schemas/common.ts +8 -0
  11. package/src/api/schemas/sessions.ts +15 -1
  12. package/src/api/schemas/usage.ts +1 -0
  13. package/src/cli/commands.ts +85 -3
  14. package/src/cli/index.ts +18 -0
  15. package/src/cli/program.ts +175 -0
  16. package/src/daemon/collectors/claude/collector.ts +1 -1
  17. package/src/daemon/collectors/claude/discovery.ts +2 -23
  18. package/src/daemon/collectors/codex/collector.ts +3 -3
  19. package/src/daemon/collectors/git.ts +60 -2
  20. package/src/daemon/collectors/herdr/collector.ts +128 -0
  21. package/src/daemon/collectors/herdr/index.ts +2 -0
  22. package/src/daemon/collectors/herdr/normalization.ts +111 -0
  23. package/src/daemon/collectors/opencode-db.ts +17 -5
  24. package/src/daemon/collectors/tmux/collector.ts +21 -1
  25. package/src/daemon/collectors/tmux/enrichment.ts +1 -0
  26. package/src/daemon/collectors/usage.ts +115 -49
  27. package/src/daemon/index.ts +2 -0
  28. package/src/daemon/store.ts +44 -2
  29. package/src/postinstall.cjs +11 -0
  30. package/src/shared/config.ts +33 -0
  31. package/src/shared/session-exclusions.ts +5 -0
  32. package/src/shared/session-view.ts +67 -6
  33. package/src/shared/terminal-capabilities.ts +130 -0
  34. package/src/shared/types.ts +33 -2
  35. package/src/shared/version.ts +44 -0
  36. package/src/tui/App.tsx +240 -0
  37. package/src/tui/assets/providers/SOURCES.md +21 -0
  38. package/src/tui/assets/providers/claude-terminal.png +0 -0
  39. package/src/tui/assets/providers/claude-terminal.svg +62 -0
  40. package/src/tui/assets/providers/claude.png +0 -0
  41. package/src/tui/assets/providers/copilot.png +0 -0
  42. package/src/tui/assets/providers/copilot.svg +44 -0
  43. package/src/tui/assets/providers/openai-terminal.png +0 -0
  44. package/src/tui/assets/providers/openai-terminal.svg +62 -0
  45. package/src/tui/assets/providers/openai.png +0 -0
  46. package/src/tui/assets/providers/opencode-terminal.png +0 -0
  47. package/src/tui/assets/providers/opencode-terminal.svg +62 -0
  48. package/src/tui/assets/providers/opencode.png +0 -0
  49. package/src/tui/assets/providers/shell.png +0 -0
  50. package/src/tui/assets/providers/shell.svg +23 -0
  51. package/src/tui/assets/providers/unknown.png +0 -0
  52. package/src/tui/assets/providers/unknown.svg +23 -0
  53. package/src/tui/client.ts +22 -8
  54. package/src/tui/components/BindingsModal.tsx +82 -0
  55. package/src/tui/components/DashboardHeader.tsx +256 -0
  56. package/src/tui/components/KeyBar.tsx +67 -0
  57. package/src/tui/components/SessionDetailModal.tsx +169 -0
  58. package/src/tui/components/SessionsView.tsx +184 -0
  59. package/src/tui/components/TabBar.tsx +40 -0
  60. package/src/tui/components/UsageSummaryPane.tsx +272 -0
  61. package/src/tui/components/UsageView.tsx +242 -0
  62. package/src/tui/components/tool-icons.ts +24 -0
  63. package/src/tui/index.tsx +106 -0
  64. package/src/tui/keybindings.ts +49 -0
  65. package/src/tui/model.ts +295 -0
  66. package/src/tui/state.ts +77 -0
  67. package/src/tui/theme.ts +110 -0
  68. package/src/usage/README.md +3 -3
  69. package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
  70. package/src/usage/clients/anthropic-web-usage.ts +133 -49
  71. package/src/usage/clients/claude-cli-probe.ts +458 -132
  72. package/src/usage/clients/codex-app-server.ts +354 -0
  73. package/src/usage/clients/codex-oauth-usage.ts +143 -16
  74. package/src/usage/clients/opencode-go-api.ts +100 -0
  75. package/src/usage/clients/opencode-go-usage.ts +163 -27
  76. package/src/usage/errors.ts +33 -0
  77. package/src/usage/local/opencode-go-local.ts +194 -0
  78. package/src/usage/mappers.ts +24 -15
  79. package/src/usage/planner.ts +89 -25
  80. package/src/usage/providers/claude.ts +203 -83
  81. package/src/usage/providers/codex.ts +92 -11
  82. package/src/usage/providers/opencode-go.ts +94 -16
  83. package/src/usage/resolvers/browser-cookies.ts +407 -69
  84. package/src/usage/resolvers/claude-auth.ts +140 -40
  85. package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
  86. package/src/usage/resolvers/claude-web.ts +72 -6
  87. package/src/usage/resolvers/codex-auth.ts +74 -17
  88. package/src/usage/resolvers/keychain-coordinator.ts +397 -0
  89. package/src/usage/resolvers/opencode-go-web.ts +47 -6
  90. package/src/usage/types.ts +37 -2
  91. package/src/usage/web-session-cache.ts +37 -0
  92. package/src/neo-blessed.d.ts +0 -4
  93. package/src/tui/app.ts +0 -920
  94. package/src/tui/index.ts +0 -217
@@ -0,0 +1,272 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import type { TuiConfig } from "../../shared/config";
3
+ import type { UsageProviderEntry, UsageState, UsageWindow } from "../../usage/types";
4
+ import { clampPercent, compactCostLabel, providerAccentColor, providerVisual, renderUsageBar, usageColor, ProviderIcon } from "./UsageView";
5
+ import { MINI_ICON_SIZE, isImageIconMode } from "./tool-icons";
6
+ import { BRAND } from "../theme";
7
+
8
+ export interface ProviderUsageSummary {
9
+ provider: string;
10
+ short: string;
11
+ name: string;
12
+ percent: number;
13
+ windowLabel: string | null;
14
+ costLabel: string | null;
15
+ color: string;
16
+ hasWindows: boolean;
17
+ status: UsageProviderEntry["status"];
18
+ }
19
+
20
+ export function usageWindowLabel(window: UsageWindow): string {
21
+ const haystack = `${window.id} ${window.title}`.toLowerCase();
22
+ if (/\bsession\b/.test(haystack)) return "S";
23
+ if (/week/.test(haystack)) return "W";
24
+ if (/month/.test(haystack)) return "M";
25
+ if (/\bday\b|\bdaily\b/.test(haystack)) return "D";
26
+ if (/hour/.test(haystack)) return "H";
27
+ const first = window.title.trim()[0] ?? window.id.trim()[0];
28
+ return first ? first.toUpperCase() : "?";
29
+ }
30
+
31
+ export function summarizeProvider(entry: UsageProviderEntry): ProviderUsageSummary {
32
+ const worst = (entry.windows ?? [])
33
+ .filter((window) => Number.isFinite(window.usedPercent))
34
+ .reduce<UsageWindow | null>((acc, window) =>
35
+ !acc || clampPercent(window.usedPercent) > clampPercent(acc.usedPercent) ? window : acc, null);
36
+ const visual = providerVisual(entry.provider);
37
+ return {
38
+ provider: entry.provider,
39
+ short: visual.short,
40
+ name: visual.name,
41
+ percent: clampPercent(worst?.usedPercent ?? 0),
42
+ windowLabel: worst ? usageWindowLabel(worst) : null,
43
+ costLabel: compactCostLabel(entry.cost),
44
+ color: usageColor(worst?.usedPercent ?? 0, entry.provider),
45
+ hasWindows: Boolean(worst),
46
+ status: entry.status,
47
+ };
48
+ }
49
+
50
+ const NARROW_WIDTH = 110;
51
+ /** border (2) + paddingX (2) — chrome the pane consumes from the width prop. */
52
+ const PANE_CHROME = 4;
53
+ const CELL_GAP = 2;
54
+ const INNER_GAP = 1;
55
+ const MIN_BAR_WIDTH = 4;
56
+ const MAX_COST_WIDTH = 6;
57
+ const MIN_STATUS_WIDTH = 6;
58
+ /** outer gap (1) + separator (1) + gap to next cell (1) in compact mode. */
59
+ const COMPACT_JUNCTION = INNER_GAP + 1 + INNER_GAP;
60
+
61
+ export type SummaryLayoutMode = "bar" | "compact";
62
+
63
+ export interface SummaryLayout {
64
+ mode: SummaryLayoutMode;
65
+ /** Shared bar width in bar mode; 0 in compact mode. */
66
+ barWidth: number;
67
+ visible: ProviderUsageSummary[];
68
+ hiddenCount: number;
69
+ }
70
+
71
+ /** Highest-usage providers first, but only on narrow terminals. */
72
+ export function orderSummaries(summaries: ProviderUsageSummary[], width: number): ProviderUsageSummary[] {
73
+ if (width >= NARROW_WIDTH) return summaries;
74
+ return [...summaries].sort((a, b) => b.percent - a.percent);
75
+ }
76
+
77
+ interface CellSegment {
78
+ content: string;
79
+ width: number;
80
+ }
81
+
82
+ function pctText(summary: ProviderUsageSummary): string {
83
+ return `${Math.round(summary.percent)}%`;
84
+ }
85
+
86
+ function statusText(summary: ProviderUsageSummary): string {
87
+ return summary.status.slice(0, MIN_STATUS_WIDTH);
88
+ }
89
+
90
+ function trimCost(label: string | null | undefined): string | null {
91
+ if (!label || label.length <= MAX_COST_WIDTH) return label ?? null;
92
+ return `${label.slice(0, MAX_COST_WIDTH - 1)}…`;
93
+ }
94
+
95
+ function summarySegments(summary: ProviderUsageSummary, costLabel: string | null, barWidth: number): CellSegment[] {
96
+ const segments: CellSegment[] = [
97
+ { content: summary.short.slice(0, 2), width: 2 },
98
+ { content: renderUsageBar(summary.percent, barWidth), width: barWidth },
99
+ ];
100
+ if (summary.windowLabel) {
101
+ segments.push({ content: ` ${summary.windowLabel}`, width: summary.windowLabel.length + 1 });
102
+ }
103
+ const pct = pctText(summary);
104
+ segments.push({ content: pct, width: pct.length });
105
+ if (costLabel) {
106
+ segments.push({ content: costLabel, width: costLabel.length });
107
+ }
108
+ return segments;
109
+ }
110
+
111
+ function segmentsWidth(segments: CellSegment[]): number {
112
+ const widths = segments.reduce((acc, segment) => acc + segment.width, 0);
113
+ return widths + (segments.length - 1) * INNER_GAP;
114
+ }
115
+
116
+ /** Zero-bar cell width, honored by the budget solver for ok summaries. */
117
+ function nonBarWidth(summary: ProviderUsageSummary, costLabel: string | null): number {
118
+ return cellWidth({ ...summary, costLabel }, 0);
119
+ }
120
+
121
+ function cellWidth(summary: ProviderUsageSummary, barWidth: number): number {
122
+ if (!summary.hasWindows || summary.status !== "ok") {
123
+ return 2 + INNER_GAP + statusText(summary).length;
124
+ }
125
+ return segmentsWidth(summarySegments(summary, summary.costLabel, barWidth));
126
+ }
127
+
128
+ /**
129
+ * Fit preference: maximize visible providers first (compact separators fit
130
+ * more providers than bars), falling back to bar rows when both show the same
131
+ * count. Bars share one width; costs are truncated before rows are dropped.
132
+ */
133
+ export function layoutSummaries(summaries: ProviderUsageSummary[], width: number): SummaryLayout {
134
+ const ordered = orderSummaries(summaries, width);
135
+ const available = Math.max(0, width - PANE_CHROME);
136
+ if (ordered.length === 0) {
137
+ return { mode: "bar", barWidth: MIN_BAR_WIDTH, visible: [], hiddenCount: 0 };
138
+ }
139
+
140
+ const trimmed = ordered.map((s) => ({ ...s, costLabel: trimCost(s.costLabel) }));
141
+ let barLayout: SummaryLayout | null = null;
142
+ {
143
+ const allBudget = Math.floor(
144
+ (available - trimmed.reduce((acc, s) => acc + nonBarWidth(s, s.costLabel), 0) - (trimmed.length - 1) * CELL_GAP) / trimmed.length,
145
+ );
146
+ if (allBudget >= MIN_BAR_WIDTH) {
147
+ barLayout = { mode: "bar", barWidth: allBudget, visible: trimmed, hiddenCount: 0 };
148
+ } else {
149
+ for (let count = trimmed.length - 1; count >= 1; count--) {
150
+ const cells = trimmed.slice(0, count);
151
+ const gaps = (count - 1) * CELL_GAP;
152
+ const budget = Math.floor((available - cells.reduce((acc, s) => acc + nonBarWidth(s, s.costLabel), 0) - gaps) / count);
153
+ if (budget >= MIN_BAR_WIDTH) {
154
+ barLayout = { mode: "bar", barWidth: budget, visible: cells, hiddenCount: trimmed.length - count };
155
+ break;
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ // Compact: without bars, separators fit the most providers.
162
+ const widths = ordered.map(compactSegmentWidth);
163
+ let visibleCount = 1;
164
+ let total = widths[0];
165
+ for (let i = 1; i < widths.length; i++) {
166
+ const next = total + COMPACT_JUNCTION + widths[i];
167
+ if (next > available) break;
168
+ total = next;
169
+ visibleCount++;
170
+ }
171
+ const compactLayout: SummaryLayout = {
172
+ mode: "compact",
173
+ barWidth: 0,
174
+ visible: ordered.slice(0, visibleCount),
175
+ hiddenCount: ordered.length - visibleCount,
176
+ };
177
+
178
+ if (!barLayout) {
179
+ return compactLayout;
180
+ }
181
+ return barLayout.visible.length >= compactLayout.visible.length ? barLayout : compactLayout;
182
+ }
183
+
184
+ function compactSegmentWidth(summary: ProviderUsageSummary): number {
185
+ if (!summary.hasWindows && summary.status !== "ok") {
186
+ return 2 + INNER_GAP + statusText(summary).length;
187
+ }
188
+ return 2 + INNER_GAP + pctText(summary).length;
189
+ }
190
+
191
+ function BarCell({ summary, width, barWidth, iconMode }: { summary: ProviderUsageSummary; width: number; barWidth: number; iconMode: TuiConfig["providerIcons"] }) {
192
+ const shortCell = isImageIconMode(iconMode)
193
+ ? <ProviderIcon provider={summary.provider} mode={iconMode} size={MINI_ICON_SIZE} />
194
+ : <text width={2} fg={providerAccentColor(summary.provider)} attributes={TextAttributes.BOLD} content={summary.short.slice(0, 2)} />;
195
+ if (!summary.hasWindows || summary.status !== "ok") {
196
+ return (
197
+ <box width={width} height={1} flexDirection="row" gap={INNER_GAP}>
198
+ {shortCell}
199
+ <text fg={BRAND.grey} content={statusText(summary)} />
200
+ </box>
201
+ );
202
+ }
203
+ const segments = summarySegments(summary, summary.costLabel, barWidth);
204
+ const accent = providerAccentColor(summary.provider);
205
+ return (
206
+ <box width={width} height={1} flexDirection="row" gap={INNER_GAP}>
207
+ {shortCell}
208
+ <text width={barWidth} fg={accent} content={renderUsageBar(summary.percent, barWidth)} />
209
+ {summary.windowLabel && (
210
+ <text width={summary.windowLabel.length + 1} fg={BRAND.grey} attributes={TextAttributes.BOLD} content={` ${summary.windowLabel}`} />
211
+ )}
212
+ <text width={pctText(summary).length} fg={usageColor(summary.percent, summary.provider)} attributes={TextAttributes.BOLD} content={pctText(summary)} />
213
+ {summary.costLabel && <text width={summary.costLabel.length} fg={BRAND.grey} content={summary.costLabel} />}
214
+ </box>
215
+ );
216
+ }
217
+
218
+ function CompactCell({ summary, leadingSeparator }: { summary: ProviderUsageSummary; leadingSeparator: boolean }) {
219
+ const shortCell = (
220
+ <text width={2} fg={providerAccentColor(summary.provider)} attributes={TextAttributes.BOLD} content={summary.short.slice(0, 2)} />
221
+ );
222
+ const dim = summary.hasWindows && summary.status === "ok" ? usageColor(summary.percent, summary.provider) : BRAND.grey;
223
+ const label = !summary.hasWindows && summary.status !== "ok"
224
+ ? statusText(summary)
225
+ : pctText(summary);
226
+ return (
227
+ <box height={1} flexDirection="row" gap={INNER_GAP}>
228
+ {leadingSeparator && <text fg={BRAND.border} content="│" />}
229
+ {shortCell}
230
+ <text fg={dim} attributes={TextAttributes.BOLD} content={label} />
231
+ </box>
232
+ );
233
+ }
234
+
235
+ export function UsageSummaryPane({ usage, width, iconMode }: { usage: UsageState; width: number; iconMode: TuiConfig["providerIcons"] }) {
236
+ const summaries = usage.providers.filter((entry) => entry.available)
237
+ .map((entry) => summarizeProvider(entry));
238
+ const layout = layoutSummaries(summaries, width);
239
+ return (
240
+ <box
241
+ height={3}
242
+ border
243
+ borderColor={BRAND.border}
244
+ titleColor={BRAND.grey}
245
+ title="Usage"
246
+ paddingX={1}
247
+ flexDirection="row"
248
+ gap={layout.mode === "bar" ? CELL_GAP : INNER_GAP}
249
+ >
250
+ {summaries.length === 0 ? (
251
+ <text fg={BRAND.grey} content="no usage data" />
252
+ ) : layout.mode === "bar" ? (
253
+ layout.visible.map((summary) => (
254
+ <BarCell
255
+ key={summary.provider}
256
+ summary={summary}
257
+ width={cellWidth(summary, layout.barWidth)}
258
+ barWidth={layout.barWidth}
259
+ iconMode={iconMode}
260
+ />
261
+ ))
262
+ ) : (
263
+ layout.visible.map((summary, index) => (
264
+ <CompactCell key={summary.provider} summary={summary} leadingSeparator={index > 0} />
265
+ ))
266
+ )}
267
+ {layout.mode === "bar" && layout.hiddenCount > 0 && (
268
+ <text fg={BRAND.grey} content={`+${layout.hiddenCount}`} />
269
+ )}
270
+ </box>
271
+ );
272
+ }
@@ -0,0 +1,242 @@
1
+ import { NativeImage, TextAttributes } from "@opentui/core";
2
+ import { useEffect, useState } from "react";
3
+ import type { TuiConfig } from "../../shared/config";
4
+ import type { ProviderCostSnapshot, UsageProviderEntry, UsageState, UsageWindow } from "../../usage/types";
5
+ import { BRAND, PROVIDER_BRANDS, type ProviderVisual } from "../theme";
6
+
7
+ export type { ProviderVisual };
8
+
9
+ export const PROVIDER_ICON_SIZES = {
10
+ blocks: { width: 8, height: 4 },
11
+ graphics: { width: 6, height: 3 },
12
+ kitty: { width: 6, height: 3 },
13
+ sixel: { width: 6, height: 3 },
14
+ text: { width: 4, height: 3 },
15
+ mini: { width: 2, height: 1 },
16
+ } as const;
17
+
18
+ export function iconImageProtocol(mode: TuiConfig["providerIcons"]): "blocks" | "auto" | "kitty" | "sixel" {
19
+ if (mode === "blocks") return "blocks";
20
+ if (mode === "kitty" || mode === "sixel") return mode;
21
+ return "auto";
22
+ }
23
+
24
+ const PROVIDERS = PROVIDER_BRANDS;
25
+
26
+ export function providerVisual(provider: string): ProviderVisual {
27
+ return PROVIDERS[provider] ?? { name: provider, short: "AI", brandColor: BRAND.grey, borderColor: BRAND.grey };
28
+ }
29
+
30
+ export function providerAccentColor(provider: string): string {
31
+ return providerVisual(provider).onDarkColor ?? providerVisual(provider).brandColor;
32
+ }
33
+
34
+ export function clampPercent(value: number): number {
35
+ return Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
36
+ }
37
+
38
+ export function usageColor(value: number, provider?: string): string {
39
+ const percent = clampPercent(value);
40
+ if (percent >= 90) return BRAND.usageHigh;
41
+ if (percent >= 70) return BRAND.stateWorking;
42
+ if (provider) return providerAccentColor(provider);
43
+ return BRAND.stateIdle;
44
+ }
45
+
46
+ export function renderUsageBar(value: number, width: number): string {
47
+ const size = Math.max(4, width);
48
+ const filled = Math.round((clampPercent(value) / 100) * size);
49
+ return `${"█".repeat(filled)}${"░".repeat(size - filled)}`;
50
+ }
51
+
52
+ function formatUsageNumber(value: number, compact: boolean): string {
53
+ if (compact && Math.abs(value) >= 1_000) {
54
+ return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1).replace(/\.0$/, "")}k`;
55
+ }
56
+ return value.toLocaleString("en-US", { maximumFractionDigits: 2 });
57
+ }
58
+
59
+ export function formatCostAmount(amount: number, currency: string, compact = false): string {
60
+ const value = formatUsageNumber(amount, compact);
61
+ const normalized = currency.trim().toUpperCase();
62
+ if (normalized === "USD") return `$${value}`;
63
+ if (normalized === "EUR") return `€${value}`;
64
+ if (normalized === "GBP") return `£${value}`;
65
+ if (normalized === "CREDITS") return compact ? `${value}cr` : `${value} credits`;
66
+ return compact ? `${value} ${currency}` : `${currency} ${value}`;
67
+ }
68
+
69
+ export function compactCostLabel(cost: ProviderCostSnapshot | null | undefined): string | null {
70
+ if (!cost) return null;
71
+ if (cost.limit > 0) return formatCostAmount(cost.used, cost.currency, true);
72
+ if (cost.balance !== undefined) return `${formatCostAmount(cost.balance, cost.currency, true)} left`;
73
+ return null;
74
+ }
75
+
76
+ export function formatCostRange(cost: ProviderCostSnapshot): string {
77
+ const normalized = cost.currency.trim().toUpperCase();
78
+ if (normalized === "CREDITS") {
79
+ return `${formatUsageNumber(cost.used, false)} / ${formatUsageNumber(cost.limit, false)} credits`;
80
+ }
81
+ return `${formatCostAmount(cost.used, cost.currency)} / ${formatCostAmount(cost.limit, cost.currency)}`;
82
+ }
83
+
84
+ export function formatRelativeReset(seconds: number): string {
85
+ const days = Math.floor(seconds / 86_400);
86
+ const hours = Math.floor((seconds % 86_400) / 3_600);
87
+ const minutes = Math.floor((seconds % 3_600) / 60);
88
+ if (seconds <= 0) return "now";
89
+ if (days > 0) return `${days}d ${hours}h`;
90
+ if (hours > 0) return `${hours}h ${minutes}m`;
91
+ return `${minutes}m`;
92
+ }
93
+
94
+ export function formatResetTime(timestamp: number): string {
95
+ return new Date(timestamp).toLocaleString("en-US", {
96
+ month: "short",
97
+ day: "numeric",
98
+ hour: "numeric",
99
+ minute: "2-digit",
100
+ hour12: true,
101
+ }).replace(":00 ", " ").replace(" AM", "am").replace(" PM", "pm");
102
+ }
103
+
104
+ export function formatReset(window: UsageWindow, now = Date.now()): string {
105
+ const resetsAt = window.resetsAt ? Date.parse(window.resetsAt) : undefined;
106
+ const seconds = resetsAt !== undefined
107
+ ? Math.max(0, Math.round((resetsAt - now) / 1000))
108
+ : Math.max(0, window.resetInSec ?? 0);
109
+ if (resetsAt === undefined && window.resetInSec === undefined) return "reset unknown";
110
+ const timestamp = resetsAt ?? now + seconds * 1000;
111
+ return `${formatResetTime(timestamp)} (${formatRelativeReset(seconds)})`;
112
+ }
113
+
114
+ export function ProviderIcon({ provider, mode, size }: { provider: string; mode: TuiConfig["providerIcons"]; size?: { width: number; height: number } }) {
115
+ const visual = providerVisual(provider);
116
+ const iconSize = size ?? PROVIDER_ICON_SIZES[mode];
117
+ const [image, setImage] = useState<NativeImage | null>(null);
118
+
119
+ useEffect(() => {
120
+ const asset = mode === "blocks" ? visual.terminalAsset : visual.graphicsAsset;
121
+ if (mode === "text" || !asset) return;
122
+ let active = true;
123
+ let loaded: NativeImage | null = null;
124
+ void NativeImage.load(asset).then((next) => {
125
+ loaded = next;
126
+ if (active) setImage(next);
127
+ else next.dispose();
128
+ }).catch(() => setImage(null));
129
+ return () => {
130
+ active = false;
131
+ loaded?.dispose();
132
+ };
133
+ }, [mode, provider]);
134
+
135
+ if (image) {
136
+ return <image source={image} protocol={iconImageProtocol(mode)} fit="fit" {...iconSize} />;
137
+ }
138
+ return (
139
+ <box {...iconSize} backgroundColor={visual.tileColor} alignItems="center" justifyContent="center">
140
+ <text fg={visual.brandColor} attributes={TextAttributes.BOLD} content={visual.short} />
141
+ </box>
142
+ );
143
+ }
144
+
145
+ function UsageLine({ provider, window, width }: { provider: string; window: UsageWindow; width: number }) {
146
+ const percent = clampPercent(window.usedPercent);
147
+ return (
148
+ <box flexDirection="column">
149
+ <box flexDirection="row" justifyContent="space-between">
150
+ <text content={window.title} />
151
+ <text fg={usageColor(percent, provider)} attributes={TextAttributes.BOLD} content={`${Math.round(percent)}%`} />
152
+ </box>
153
+ <text fg={usageColor(percent, provider)} content={renderUsageBar(percent, width)} />
154
+ <text fg={BRAND.grey} content={formatReset(window)} />
155
+ </box>
156
+ );
157
+ }
158
+
159
+ function ProviderCard({ provider, width, iconMode }: {
160
+ provider: UsageProviderEntry;
161
+ width: number;
162
+ iconMode: TuiConfig["providerIcons"];
163
+ }) {
164
+ const visual = providerVisual(provider.provider);
165
+ const windows = provider.windows ?? [];
166
+ const subtitle = provider.metadata?.subscriptionType
167
+ ?? provider.metadata?.workspaceName
168
+ ?? provider.metadata?.accountEmail
169
+ ?? provider.status;
170
+ return (
171
+ <box title={visual.name} border borderColor={visual.borderColor} width={width} flexShrink={0} flexDirection="column" paddingX={1} paddingBottom={1} paddingTop={1}>
172
+ <box height={PROVIDER_ICON_SIZES.blocks.height} flexDirection="row" gap={1}>
173
+ <ProviderIcon provider={provider.provider} mode={iconMode} />
174
+ <box flexDirection="column" justifyContent="center">
175
+ <text attributes={TextAttributes.BOLD} content={visual.name} />
176
+ <text fg={BRAND.grey} content={subtitle ?? provider.status} />
177
+ </box>
178
+ </box>
179
+ <box height={1} />
180
+ {provider.status !== "ok" && (
181
+ <text
182
+ fg={provider.status === "error" ? BRAND.danger : BRAND.warning}
183
+ content={provider.errors?.[0] ?? (provider.status === "pending" ? "Waiting for first refresh" : "Credentials not available")}
184
+ wrapMode="word"
185
+ />
186
+ )}
187
+ {provider.status === "ok" && windows.map((window) => (
188
+ <UsageLine key={window.id} provider={provider.provider} window={window} width={width - 4} />
189
+ ))}
190
+ {provider.cost && (
191
+ <box flexDirection="column">
192
+ {provider.cost.limit > 0 && (
193
+ <text
194
+ fg={BRAND.grey}
195
+ content={`${provider.cost.period}: ${formatCostRange(provider.cost)}`}
196
+ />
197
+ )}
198
+ {provider.cost.balance !== undefined && (
199
+ <text fg={BRAND.grey} content={`Balance: ${formatCostAmount(provider.cost.balance, provider.cost.currency)}`} />
200
+ )}
201
+ </box>
202
+ )}
203
+ </box>
204
+ );
205
+ }
206
+
207
+ export function UsageView({ usage, width, iconMode, hidden = false }: { usage: UsageState; width: number; iconMode: TuiConfig["providerIcons"]; hidden?: boolean }) {
208
+ const rich = width >= 110;
209
+ const columns = rich ? Math.min(3, Math.max(1, usage.providers.length)) : 1;
210
+ // Narrow single-column cards reserve extra width for border + padding so the
211
+ // outer pane border is never painted over by scrollbox content.
212
+ const cardWidth = rich
213
+ ? Math.max(32, Math.floor((width - 4 - (columns - 1)) / columns))
214
+ : Math.max(20, width - 6);
215
+ return (
216
+ <box
217
+ border
218
+ borderColor={BRAND.border}
219
+ flexDirection="column"
220
+ flexGrow={1}
221
+ paddingX={1}
222
+ >
223
+ {usage.providers.length === 0 ? (
224
+ <box flexGrow={1} alignItems="center" justifyContent="center">
225
+ <text fg={BRAND.grey} content="No usage providers configured" />
226
+ </box>
227
+ ) : (
228
+ <scrollbox
229
+ focused={!hidden}
230
+ scrollX={false}
231
+ scrollY
232
+ flexGrow={1}
233
+ contentOptions={{ flexDirection: rich ? "row" : "column", flexWrap: rich ? "wrap" : "no-wrap", gap: 1 }}
234
+ >
235
+ {usage.providers.map((provider) => (
236
+ <ProviderCard key={provider.provider} provider={provider} width={cardWidth} iconMode={iconMode} />
237
+ ))}
238
+ </scrollbox>
239
+ )}
240
+ </box>
241
+ );
242
+ }
@@ -0,0 +1,24 @@
1
+ import type { SessionTool } from "../../shared/types";
2
+ import type { TuiConfig } from "../../shared/config";
3
+ import { PROVIDER_ICON_SIZES } from "./UsageView";
4
+
5
+ type ToolProviderKey = "claude" | "codex" | "opencode-go" | "copilot" | "shell" | "unknown";
6
+
7
+ const TOOL_PROVIDER_KEYS: Record<SessionTool, ToolProviderKey> = {
8
+ claude: "claude",
9
+ codex: "codex",
10
+ opencode: "opencode-go",
11
+ copilotcli: "copilot",
12
+ shell: "shell",
13
+ unknown: "unknown",
14
+ };
15
+
16
+ export function toolProviderKey(tool: string): ToolProviderKey | null {
17
+ return (TOOL_PROVIDER_KEYS as Record<string, ToolProviderKey>)[tool] ?? null;
18
+ }
19
+
20
+ export function isImageIconMode(mode: TuiConfig["providerIcons"]): boolean {
21
+ return mode === "graphics" || mode === "kitty" || mode === "sixel";
22
+ }
23
+
24
+ export const MINI_ICON_SIZE = PROVIDER_ICON_SIZES.mini;
@@ -0,0 +1,106 @@
1
+ import { createCliRenderer } from "@opentui/core";
2
+ import type { CliRenderer } from "@opentui/core";
3
+ import { createRoot } from "@opentui/react";
4
+ import type { ReactNode } from "react";
5
+ import { loadConfig } from "../shared/config";
6
+ import type { ScrylogConfig } from "../shared/config";
7
+ import { getServiceManagerOrThrow } from "../service/manager";
8
+ import type { ServiceStatus } from "../service/manager";
9
+ import { getDaemonVersionMismatch, getInstalledVersion } from "../shared/version";
10
+ import type { DaemonVersionMismatch } from "../shared/version";
11
+ import { App } from "./App";
12
+ import { DaemonClient } from "./client";
13
+
14
+ export async function launchTui(configPath?: string, includeUnknown = false): Promise<void> {
15
+ const config = loadConfig(configPath);
16
+ const client = new DaemonClient(config);
17
+ const health = await client.getHealth();
18
+ if (!health) {
19
+ printDaemonNotRunning(config, getServiceManagerOrThrow().platform);
20
+ process.exitCode = 1;
21
+ return;
22
+ }
23
+
24
+ const versionMismatch = getDaemonVersionMismatch(getInstalledVersion(), health.version);
25
+ if (versionMismatch) printDaemonVersionMismatch(versionMismatch);
26
+
27
+ const renderer = await createCliRenderer({ exitOnCtrlC: false });
28
+ await mountDashboard(renderer, (close) => {
29
+ const attach = (command: string[], sessionId: string) => {
30
+ close();
31
+ const result = Bun.spawnSync(command, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
32
+ if (result.exitCode !== 0) {
33
+ process.stderr.write(`Could not enter session ${sessionId}\n`);
34
+ process.exitCode = 1;
35
+ }
36
+ };
37
+
38
+ return (
39
+ <App
40
+ client={client}
41
+ options={{ refreshMs: config.tui.refreshMs, includeUnknown }}
42
+ config={config}
43
+ versionMismatch={versionMismatch}
44
+ onExit={close}
45
+ onAttach={attach}
46
+ />
47
+ );
48
+ });
49
+ }
50
+
51
+ export function printDaemonVersionMismatch(mismatch: DaemonVersionMismatch): void {
52
+ if (mismatch.daemonVersion) {
53
+ process.stderr.write(
54
+ `scrylog: daemon version mismatch (CLI ${mismatch.cliVersion}, daemon ${mismatch.daemonVersion}).\n` +
55
+ "Restart it to activate the installed version: scrylog daemon restart\n" +
56
+ "For a foreground or unsupported service, stop it and run: scrylogd\n" +
57
+ `Release notes: https://github.com/scrylog/scrylog/releases/tag/v${mismatch.cliVersion}\n\n`,
58
+ );
59
+ return;
60
+ }
61
+
62
+ process.stderr.write(
63
+ "scrylog: the running daemon is from an older version.\n" +
64
+ "Restart it to activate the installed version: scrylog daemon restart\n" +
65
+ "For a foreground or unsupported service, stop it and run: scrylogd\n" +
66
+ "If integrations misbehave after updating, run: scrylog setup\n\n",
67
+ );
68
+ }
69
+
70
+ export async function mountDashboard(renderer: CliRenderer, render: (close: () => void) => ReactNode): Promise<void> {
71
+ const root = createRoot(renderer);
72
+ await new Promise<void>((resolve) => {
73
+ let closed = false;
74
+ const close = () => {
75
+ if (closed) return;
76
+ closed = true;
77
+ root.unmount();
78
+ renderer.destroy();
79
+ resolve();
80
+ };
81
+ root.render(render(close));
82
+ });
83
+ }
84
+
85
+ export function printDaemonNotRunning(config: ScrylogConfig, platform: ServiceStatus["platform"]): void {
86
+ const logPath = config.daemon.logFilePath;
87
+ const hasService = platform === "macos" || platform === "linux";
88
+
89
+ process.stderr.write("scrylog: daemon is not running.\n\n");
90
+ process.stderr.write("Verify:\n scrylog daemon status\n scrylog doctor\n\n");
91
+ process.stderr.write("Start it (pick one):\n");
92
+ if (hasService) {
93
+ process.stderr.write(" scrylog daemon start # user service\n");
94
+ process.stderr.write(" scrylog daemon install # if the service is not installed yet\n");
95
+ }
96
+ process.stderr.write(` scrylogd # foreground\n\nLogs:\n scrylog logs\n ${logPath}\n`);
97
+
98
+ if (!hasService) {
99
+ const note = platform === "wsl"
100
+ ? "Service management is unavailable (WSL without systemd). Run scrylogd directly or enable systemd in /etc/wsl.conf."
101
+ : platform === "linux-no-systemd"
102
+ ? "Service management is unavailable (no systemd user session). Run scrylogd directly."
103
+ : "Service management is unavailable on this platform. Run scrylogd directly or use Task Scheduler.";
104
+ process.stderr.write(`\n${note}\n`);
105
+ }
106
+ }