@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,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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DashboardView } from "./model";
|
|
2
|
+
|
|
3
|
+
export type BindingScope = "global" | DashboardView;
|
|
4
|
+
|
|
5
|
+
export interface KeyBinding {
|
|
6
|
+
key: string;
|
|
7
|
+
description: string;
|
|
8
|
+
scope: BindingScope;
|
|
9
|
+
essential?: boolean;
|
|
10
|
+
footer?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const KEYBINDINGS: KeyBinding[] = [
|
|
14
|
+
{ key: "Tab", description: "Switch tab", scope: "global", essential: true },
|
|
15
|
+
{ key: "q", description: "Quit", scope: "global", essential: true },
|
|
16
|
+
{ key: "r", description: "Refresh", scope: "global" },
|
|
17
|
+
{ key: "s", description: "Open the sessions tab", scope: "global", essential: true, footer: false },
|
|
18
|
+
{ key: "u", description: "Open the usage tab", scope: "global", essential: true, footer: false },
|
|
19
|
+
{ key: "?", description: "Keybindings", scope: "global", essential: true },
|
|
20
|
+
|
|
21
|
+
{ key: "j / Down", description: "Next session", scope: "sessions" },
|
|
22
|
+
{ key: "k / Up", description: "Prev session", scope: "sessions" },
|
|
23
|
+
{ key: "Enter", description: "Detail", scope: "sessions" },
|
|
24
|
+
{ key: "t", description: "Attach", scope: "sessions" },
|
|
25
|
+
{ key: "/", description: "Search", scope: "sessions" },
|
|
26
|
+
{ key: "a", description: "Actionable filter", scope: "sessions" },
|
|
27
|
+
{ key: "f", description: "Cycle filters", scope: "sessions" },
|
|
28
|
+
{ key: "Esc", description: "Clear selection or close overlay", scope: "sessions" },
|
|
29
|
+
|
|
30
|
+
{ key: "Click", description: "Select a session", scope: "sessions", footer: false },
|
|
31
|
+
{ key: "2x Click", description: "Open session detail", scope: "sessions", footer: false },
|
|
32
|
+
{ key: "Scroll", description: "Select next or previous session", scope: "sessions", footer: false },
|
|
33
|
+
{ key: "Click tab", description: "Switch tabs", scope: "global", footer: false },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export function contextBindings(view: DashboardView): KeyBinding[] {
|
|
37
|
+
return KEYBINDINGS.filter((binding) => binding.scope === "global" || binding.scope === view);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function essentialBindings(view: DashboardView): KeyBinding[] {
|
|
41
|
+
return contextBindings(view).filter((binding) => binding.essential);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function bindingsForModal(): { scope: BindingScope; bindings: KeyBinding[] }[] {
|
|
45
|
+
const scopes: BindingScope[] = ["global", "sessions", "usage"];
|
|
46
|
+
return scopes
|
|
47
|
+
.map((scope) => ({ scope, bindings: KEYBINDINGS.filter((binding) => binding.scope === scope) }))
|
|
48
|
+
.filter((group) => group.bindings.length > 0);
|
|
49
|
+
}
|