@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.
- package/README.md +46 -7
- package/package.json +13 -10
- package/src/api/docs/sessions.ts +3 -2
- package/src/api/docs/system.ts +3 -0
- package/src/api/docs/usage.ts +2 -2
- package/src/api/routes/sessions.ts +6 -2
- package/src/api/routes/system.ts +4 -1
- package/src/api/routes/webhooks/claude.ts +8 -0
- package/src/api/routes/webhooks/opencode.ts +1 -0
- package/src/api/schemas/common.ts +8 -0
- package/src/api/schemas/sessions.ts +15 -1
- package/src/api/schemas/usage.ts +1 -0
- package/src/cli/commands.ts +85 -3
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +1 -1
- package/src/daemon/collectors/claude/discovery.ts +2 -23
- package/src/daemon/collectors/codex/collector.ts +3 -3
- package/src/daemon/collectors/git.ts +60 -2
- package/src/daemon/collectors/herdr/collector.ts +128 -0
- package/src/daemon/collectors/herdr/index.ts +2 -0
- package/src/daemon/collectors/herdr/normalization.ts +111 -0
- package/src/daemon/collectors/opencode-db.ts +17 -5
- package/src/daemon/collectors/tmux/collector.ts +21 -1
- package/src/daemon/collectors/tmux/enrichment.ts +1 -0
- package/src/daemon/collectors/usage.ts +115 -49
- package/src/daemon/index.ts +2 -0
- package/src/daemon/store.ts +44 -2
- package/src/postinstall.cjs +11 -0
- package/src/shared/config.ts +33 -0
- package/src/shared/session-exclusions.ts +5 -0
- package/src/shared/session-view.ts +67 -6
- package/src/shared/terminal-capabilities.ts +130 -0
- package/src/shared/types.ts +33 -2
- package/src/shared/version.ts +44 -0
- package/src/tui/App.tsx +240 -0
- package/src/tui/assets/providers/SOURCES.md +21 -0
- package/src/tui/assets/providers/claude-terminal.png +0 -0
- package/src/tui/assets/providers/claude-terminal.svg +62 -0
- package/src/tui/assets/providers/claude.png +0 -0
- package/src/tui/assets/providers/copilot.png +0 -0
- package/src/tui/assets/providers/copilot.svg +44 -0
- package/src/tui/assets/providers/openai-terminal.png +0 -0
- package/src/tui/assets/providers/openai-terminal.svg +62 -0
- package/src/tui/assets/providers/openai.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.svg +62 -0
- package/src/tui/assets/providers/opencode.png +0 -0
- package/src/tui/assets/providers/shell.png +0 -0
- package/src/tui/assets/providers/shell.svg +23 -0
- package/src/tui/assets/providers/unknown.png +0 -0
- package/src/tui/assets/providers/unknown.svg +23 -0
- package/src/tui/client.ts +22 -8
- package/src/tui/components/BindingsModal.tsx +82 -0
- package/src/tui/components/DashboardHeader.tsx +256 -0
- package/src/tui/components/KeyBar.tsx +67 -0
- package/src/tui/components/SessionDetailModal.tsx +169 -0
- package/src/tui/components/SessionsView.tsx +184 -0
- package/src/tui/components/TabBar.tsx +40 -0
- package/src/tui/components/UsageSummaryPane.tsx +272 -0
- package/src/tui/components/UsageView.tsx +242 -0
- package/src/tui/components/tool-icons.ts +24 -0
- package/src/tui/index.tsx +106 -0
- package/src/tui/keybindings.ts +49 -0
- package/src/tui/model.ts +295 -0
- package/src/tui/state.ts +77 -0
- package/src/tui/theme.ts +110 -0
- package/src/usage/README.md +3 -3
- package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
- package/src/usage/clients/anthropic-web-usage.ts +133 -49
- package/src/usage/clients/claude-cli-probe.ts +458 -132
- package/src/usage/clients/codex-app-server.ts +354 -0
- package/src/usage/clients/codex-oauth-usage.ts +143 -16
- package/src/usage/clients/opencode-go-api.ts +100 -0
- package/src/usage/clients/opencode-go-usage.ts +163 -27
- package/src/usage/errors.ts +33 -0
- package/src/usage/local/opencode-go-local.ts +194 -0
- package/src/usage/mappers.ts +24 -15
- package/src/usage/planner.ts +89 -25
- package/src/usage/providers/claude.ts +203 -83
- package/src/usage/providers/codex.ts +92 -11
- package/src/usage/providers/opencode-go.ts +94 -16
- package/src/usage/resolvers/browser-cookies.ts +407 -69
- package/src/usage/resolvers/claude-auth.ts +140 -40
- package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
- package/src/usage/resolvers/claude-web.ts +72 -6
- package/src/usage/resolvers/codex-auth.ts +74 -17
- package/src/usage/resolvers/keychain-coordinator.ts +397 -0
- package/src/usage/resolvers/opencode-go-web.ts +47 -6
- package/src/usage/types.ts +37 -2
- package/src/usage/web-session-cache.ts +37 -0
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.ts +0 -920
- 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
|
+
}
|