@scrylog/cli 0.1.4 → 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 +9 -21
- package/src/api/routes/webhooks/opencode.ts +6 -1
- package/src/api/routes.ts +1 -1
- 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 +170 -26
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +11 -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/collector.ts +4 -2
- 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 +109 -14
- 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/index.ts +2 -1
- 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 +679 -69
- package/src/usage/resolvers/claude-auth.ts +150 -41
- package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
- 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 -193
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { TextAttributes, type MouseEvent } from "@opentui/core";
|
|
2
|
+
import { useRef } from "react";
|
|
3
|
+
import type { TuiConfig } from "../../shared/config";
|
|
4
|
+
import type { SessionModel } from "../../shared/types";
|
|
5
|
+
import type { SessionListItem } from "../../shared/session-view";
|
|
6
|
+
import type { UsageState } from "../../usage/types";
|
|
7
|
+
import { UsageSummaryPane } from "./UsageSummaryPane";
|
|
8
|
+
import { providerAccentColor, providerVisual, ProviderIcon } from "./UsageView";
|
|
9
|
+
import { SessionDetailModal } from "./SessionDetailModal";
|
|
10
|
+
import { MINI_ICON_SIZE, isImageIconMode, toolProviderKey } from "./tool-icons";
|
|
11
|
+
import { truncate } from "../../shared/utils";
|
|
12
|
+
import { BRAND } from "../theme";
|
|
13
|
+
import {
|
|
14
|
+
STATE_COLORS,
|
|
15
|
+
activeChildCounts,
|
|
16
|
+
formatStatusCell,
|
|
17
|
+
formatTargetLabel,
|
|
18
|
+
sessionColumnWidths,
|
|
19
|
+
type DashboardFilters,
|
|
20
|
+
type DashboardMode,
|
|
21
|
+
type DashboardRow,
|
|
22
|
+
type SessionToolMode,
|
|
23
|
+
} from "../model";
|
|
24
|
+
|
|
25
|
+
interface SessionsViewProps {
|
|
26
|
+
rows: DashboardRow[];
|
|
27
|
+
sessions: SessionListItem[];
|
|
28
|
+
selectedSessionId?: string;
|
|
29
|
+
filters: DashboardFilters;
|
|
30
|
+
mode: DashboardMode;
|
|
31
|
+
detail?: SessionModel;
|
|
32
|
+
message?: string;
|
|
33
|
+
usage?: UsageState;
|
|
34
|
+
iconMode: TuiConfig["providerIcons"];
|
|
35
|
+
spinnerFrame: number;
|
|
36
|
+
width: number;
|
|
37
|
+
interactive?: boolean;
|
|
38
|
+
onQueryChange(query: string): void;
|
|
39
|
+
onSearchSubmit(): void;
|
|
40
|
+
onSelectSession?(sessionId: string): void;
|
|
41
|
+
onOpenSessionDetail?(session: SessionListItem): void;
|
|
42
|
+
onScrollSelection?(direction: 1 | -1): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SessionsView(props: SessionsViewProps) {
|
|
46
|
+
const { width } = props;
|
|
47
|
+
const narrow = width < 110;
|
|
48
|
+
const toolMode: SessionToolMode = isImageIconMode(props.iconMode) ? "icons" : props.iconMode === "text" ? "text" : "monogram";
|
|
49
|
+
const childCounts = activeChildCounts(props.sessions);
|
|
50
|
+
// Smallest tool column that fits the widest rendered content: only relevant
|
|
51
|
+
// when the summary column is gone (width < 100).
|
|
52
|
+
let toolContentWidth = 0;
|
|
53
|
+
if (narrow && width < 100) {
|
|
54
|
+
for (const row of props.rows) {
|
|
55
|
+
if (row.kind !== "session") continue;
|
|
56
|
+
const badgeValue = childCounts.get(row.session.id) ?? 0;
|
|
57
|
+
const badgeText = badgeValue > 0 ? `[+${badgeValue}]` : "";
|
|
58
|
+
let len: number;
|
|
59
|
+
if (toolMode === "monogram") {
|
|
60
|
+
const key = toolProviderKey(row.session.tool);
|
|
61
|
+
const short = key ? providerVisual(key).short.length : row.session.tool.length;
|
|
62
|
+
len = short + (badgeText ? badgeText.length + 1 : 0);
|
|
63
|
+
} else if (toolMode === "icons") {
|
|
64
|
+
len = MINI_ICON_SIZE.width + (badgeText ? badgeText.length + 1 : 0);
|
|
65
|
+
} else {
|
|
66
|
+
len = row.session.tool.length + (badgeText ? badgeText.length + 1 : 0);
|
|
67
|
+
}
|
|
68
|
+
toolContentWidth = Math.max(toolContentWidth, len);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const columns = sessionColumnWidths(width, toolMode, toolContentWidth);
|
|
72
|
+
const contentWidth = Math.max(20, width - 4);
|
|
73
|
+
const lastClickRef = useRef<{ id: string; time: number } | undefined>(undefined);
|
|
74
|
+
|
|
75
|
+
const handleRowMouseUp = props.interactive && props.onSelectSession
|
|
76
|
+
? (session: SessionListItem) => (event: MouseEvent) => {
|
|
77
|
+
if (event.button !== 0) return;
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const last = lastClickRef.current;
|
|
80
|
+
if (last && last.id === session.id && now - last.time < 500) {
|
|
81
|
+
lastClickRef.current = undefined;
|
|
82
|
+
props.onOpenSessionDetail?.(session);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
lastClickRef.current = { id: session.id, time: now };
|
|
86
|
+
props.onSelectSession!(session.id);
|
|
87
|
+
}
|
|
88
|
+
: undefined;
|
|
89
|
+
|
|
90
|
+
const handleMouseScroll = props.interactive && props.onScrollSelection
|
|
91
|
+
? (event: MouseEvent) => {
|
|
92
|
+
props.onScrollSelection!(event.scroll?.direction === "down" ? 1 : -1);
|
|
93
|
+
}
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<box position="relative" flexDirection="column" flexGrow={1}>
|
|
98
|
+
{props.mode === "search" && (
|
|
99
|
+
<box height={3} border borderColor={BRAND.primary} title="Search" paddingX={1}>
|
|
100
|
+
<input
|
|
101
|
+
focused
|
|
102
|
+
value={props.filters.query}
|
|
103
|
+
placeholder="session, project, tool..."
|
|
104
|
+
onInput={props.onQueryChange}
|
|
105
|
+
onSubmit={props.onSearchSubmit}
|
|
106
|
+
/>
|
|
107
|
+
</box>
|
|
108
|
+
)}
|
|
109
|
+
<box
|
|
110
|
+
border
|
|
111
|
+
borderColor={BRAND.border}
|
|
112
|
+
flexDirection="column"
|
|
113
|
+
flexGrow={1}
|
|
114
|
+
paddingX={1}
|
|
115
|
+
onMouseScroll={handleMouseScroll}
|
|
116
|
+
>
|
|
117
|
+
<box flexDirection="row" height={1}>
|
|
118
|
+
<text width={2} content="" />
|
|
119
|
+
<text width={columns.session} attributes={TextAttributes.BOLD} content="Session" />
|
|
120
|
+
<text width={columns.state} attributes={TextAttributes.BOLD} content="St" />
|
|
121
|
+
<text width={columns.tool} attributes={TextAttributes.BOLD} content={isImageIconMode(props.iconMode) ? "" : "Tool"} truncate />
|
|
122
|
+
{columns.summary > 0 && <text width={columns.summary} attributes={TextAttributes.BOLD} content="Summary" />}
|
|
123
|
+
</box>
|
|
124
|
+
{props.rows.length === 0 && <text fg={BRAND.grey} content="No sessions detected" />}
|
|
125
|
+
{props.rows.map((row) => {
|
|
126
|
+
if (row.kind === "gap") return <text key={row.projectKey} content="" />;
|
|
127
|
+
if (row.kind === "project-header") {
|
|
128
|
+
const label = truncate(row.projectName, Math.max(8, columns.session - 6));
|
|
129
|
+
return <text key={`header:${row.projectKey}`} fg={BRAND.grey} attributes={TextAttributes.BOLD} content={` == ${label.padEnd(Math.max(8, columns.session - 6))} ==`} />;
|
|
130
|
+
}
|
|
131
|
+
const selected = row.session.id === props.selectedSessionId;
|
|
132
|
+
const status = formatStatusCell(row.session, props.spinnerFrame);
|
|
133
|
+
const childCount = narrow ? childCounts.get(row.session.id) ?? 0 : 0;
|
|
134
|
+
const badge = childCount > 0 ? `[+${childCount}]` : "";
|
|
135
|
+
const toolKey = toolProviderKey(row.session.tool);
|
|
136
|
+
const toolContent = badge
|
|
137
|
+
? `${row.session.tool.slice(0, Math.max(1, columns.tool - badge.length - 1))} ${badge}`
|
|
138
|
+
: row.session.tool;
|
|
139
|
+
return (
|
|
140
|
+
<box
|
|
141
|
+
key={row.session.id}
|
|
142
|
+
backgroundColor={selected ? BRAND.selection : undefined}
|
|
143
|
+
flexDirection="row"
|
|
144
|
+
height={1}
|
|
145
|
+
onMouseUp={handleRowMouseUp?.(row.session)}
|
|
146
|
+
>
|
|
147
|
+
<text width={2} attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE} content={selected ? "> " : " "} />
|
|
148
|
+
<text width={columns.session} fg={STATE_COLORS[row.session.state] ?? BRAND.fallback} content={truncate(formatTargetLabel(row.session, narrow), columns.session)} truncate />
|
|
149
|
+
<text width={columns.state} fg={STATE_COLORS[row.session.state] ?? BRAND.fallback} attributes={TextAttributes.BOLD} content={truncate(status, columns.state)} truncate />
|
|
150
|
+
{toolMode === "icons" && toolKey ? (
|
|
151
|
+
<>
|
|
152
|
+
<ProviderIcon provider={toolKey} mode={props.iconMode} size={MINI_ICON_SIZE} />
|
|
153
|
+
<text width={columns.tool - MINI_ICON_SIZE.width} fg={STATE_COLORS[row.session.state] ?? BRAND.fallback} content={truncate(badge, columns.tool - MINI_ICON_SIZE.width)} truncate />
|
|
154
|
+
</>
|
|
155
|
+
) : toolMode === "monogram" && toolKey ? (
|
|
156
|
+
<text
|
|
157
|
+
width={columns.tool}
|
|
158
|
+
fg={providerAccentColor(toolKey)}
|
|
159
|
+
attributes={TextAttributes.BOLD}
|
|
160
|
+
content={truncate(badge ? `${providerVisual(toolKey).short} ${badge}` : providerVisual(toolKey).short, columns.tool)}
|
|
161
|
+
truncate
|
|
162
|
+
/>
|
|
163
|
+
) : (
|
|
164
|
+
<text width={columns.tool} fg={STATE_COLORS[row.session.state] ?? BRAND.fallback} content={truncate(toolContent, columns.tool)} truncate />
|
|
165
|
+
)}
|
|
166
|
+
{columns.summary > 0 && (
|
|
167
|
+
<text width={columns.summary} fg={STATE_COLORS[row.session.state] ?? BRAND.fallback} content={truncate(row.session.summary || "-", columns.summary)} truncate />
|
|
168
|
+
)}
|
|
169
|
+
</box>
|
|
170
|
+
);
|
|
171
|
+
})}
|
|
172
|
+
</box>
|
|
173
|
+
{props.usage && <UsageSummaryPane usage={props.usage} width={width} iconMode={props.iconMode} />}
|
|
174
|
+
{props.mode === "detail" && props.detail && (
|
|
175
|
+
<SessionDetailModal detail={props.detail} iconMode={props.iconMode} width={width} />
|
|
176
|
+
)}
|
|
177
|
+
{props.mode === "message" && props.message && (
|
|
178
|
+
<box position="absolute" top="35%" left="15%" width="70%" height={7} title="scrylog" border borderColor={BRAND.warning} backgroundColor={BRAND.surface} padding={1} alignItems="center" justifyContent="center" zIndex={10}>
|
|
179
|
+
<text fg={BRAND.warning} content={props.message} wrapMode="word" />
|
|
180
|
+
</box>
|
|
181
|
+
)}
|
|
182
|
+
</box>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { TextAttributes, type MouseEvent } from "@opentui/core";
|
|
2
|
+
import type { DashboardView } from "../model";
|
|
3
|
+
import { BRAND } from "../theme";
|
|
4
|
+
|
|
5
|
+
const ACTIVE_BACKGROUND = BRAND.primary;
|
|
6
|
+
const ACTIVE_FOREGROUND = BRAND.bgDark;
|
|
7
|
+
const INACTIVE_FOREGROUND = BRAND.grey;
|
|
8
|
+
|
|
9
|
+
const TABS: { view: DashboardView; label: string }[] = [
|
|
10
|
+
{ view: "sessions", label: " Sessions " },
|
|
11
|
+
{ view: "usage", label: " Usage " },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function TabBar({ view, onSelect }: { view: DashboardView; onSelect?(next: DashboardView): void }) {
|
|
15
|
+
return (
|
|
16
|
+
<box height={1} flexDirection="row">
|
|
17
|
+
{TABS.map((tab) => {
|
|
18
|
+
const active = tab.view === view;
|
|
19
|
+
return (
|
|
20
|
+
<box
|
|
21
|
+
key={tab.view}
|
|
22
|
+
backgroundColor={active ? ACTIVE_BACKGROUND : undefined}
|
|
23
|
+
flexDirection="row"
|
|
24
|
+
height={1}
|
|
25
|
+
onMouseUp={onSelect ? (event: MouseEvent) => {
|
|
26
|
+
if (event.button !== 0) return;
|
|
27
|
+
if (!active) onSelect(tab.view);
|
|
28
|
+
} : undefined}
|
|
29
|
+
>
|
|
30
|
+
<text
|
|
31
|
+
fg={active ? ACTIVE_FOREGROUND : INACTIVE_FOREGROUND}
|
|
32
|
+
attributes={active ? TextAttributes.BOLD : TextAttributes.NONE}
|
|
33
|
+
content={tab.label}
|
|
34
|
+
/>
|
|
35
|
+
</box>
|
|
36
|
+
);
|
|
37
|
+
})}
|
|
38
|
+
</box>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -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
|
+
}
|