@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
package/src/tui/model.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type { DetectionReason, ProcessInfo, SessionModel, SessionState, SessionTool } from "../shared/types";
|
|
2
|
+
import type { SessionListItem } from "../shared/session-view";
|
|
3
|
+
import { formatDuration, truncate } from "../shared/utils";
|
|
4
|
+
import { BRAND } from "./theme";
|
|
5
|
+
|
|
6
|
+
export type DashboardView = "sessions" | "usage";
|
|
7
|
+
export type DashboardMode = "list" | "search" | "detail" | "message" | "bindings";
|
|
8
|
+
export type SessionToolFilter = SessionTool | "all";
|
|
9
|
+
export type SessionStateFilter = SessionState | "all";
|
|
10
|
+
|
|
11
|
+
export interface DashboardFilters {
|
|
12
|
+
query: string;
|
|
13
|
+
actionableOnly: boolean;
|
|
14
|
+
tool: SessionToolFilter;
|
|
15
|
+
state: SessionStateFilter;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type DashboardRow = ProjectHeaderRow | SessionRow | GapRow;
|
|
19
|
+
|
|
20
|
+
export interface ProjectHeaderRow {
|
|
21
|
+
kind: "project-header";
|
|
22
|
+
projectKey: string;
|
|
23
|
+
projectName: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SessionRow {
|
|
27
|
+
kind: "session";
|
|
28
|
+
projectKey: string;
|
|
29
|
+
session: SessionListItem;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GapRow {
|
|
33
|
+
kind: "gap";
|
|
34
|
+
projectKey: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SessionColumnWidths {
|
|
38
|
+
session: number;
|
|
39
|
+
state: number;
|
|
40
|
+
tool: number;
|
|
41
|
+
summary: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const TOOL_FILTERS: SessionToolFilter[] = ["all", "opencode", "claude", "codex", "copilotcli", "shell", "unknown"];
|
|
45
|
+
export const STATE_FILTERS: SessionStateFilter[] = ["all", "working", "waiting-input", "idle", "shell", "unknown"];
|
|
46
|
+
export const WORKING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
47
|
+
|
|
48
|
+
export const STATE_COLORS: Record<string, string> = {
|
|
49
|
+
working: BRAND.stateWorking,
|
|
50
|
+
idle: BRAND.stateIdle,
|
|
51
|
+
"waiting-input": BRAND.violet,
|
|
52
|
+
shell: BRAND.accentBlue,
|
|
53
|
+
error: BRAND.danger,
|
|
54
|
+
unknown: BRAND.grey,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function applySessionFilters(
|
|
58
|
+
sessions: SessionListItem[],
|
|
59
|
+
filters: DashboardFilters,
|
|
60
|
+
standaloneMaxAgeHours: number,
|
|
61
|
+
): SessionListItem[] {
|
|
62
|
+
const query = filters.query.toLowerCase();
|
|
63
|
+
const cutoff = standaloneMaxAgeHours > 0 ? Date.now() - standaloneMaxAgeHours * 3_600_000 : 0;
|
|
64
|
+
return sessions.filter((session) => {
|
|
65
|
+
if (session.parentSessionId) {
|
|
66
|
+
if (session.state !== "working" && session.state !== "waiting-input") return false;
|
|
67
|
+
} else if (cutoff > 0 && session.source === "standalone" && session.lastActivity < cutoff) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (filters.actionableOnly && !(session.hasAttention ?? session.state === "waiting-input")) return false;
|
|
71
|
+
if (filters.tool !== "all" && session.tool !== filters.tool) return false;
|
|
72
|
+
if (filters.state !== "all" && session.state !== filters.state) return false;
|
|
73
|
+
if (!query) return true;
|
|
74
|
+
const searchable = [
|
|
75
|
+
session.id,
|
|
76
|
+
session.tool,
|
|
77
|
+
session.state,
|
|
78
|
+
session.summary,
|
|
79
|
+
session.project?.displayName,
|
|
80
|
+
session.project?.compactLabel,
|
|
81
|
+
session.project?.branch,
|
|
82
|
+
session.displayName,
|
|
83
|
+
session.tmux?.session,
|
|
84
|
+
session.herdr?.workspace,
|
|
85
|
+
session.herdr?.name,
|
|
86
|
+
session.herdr?.tab,
|
|
87
|
+
session.herdr?.agent,
|
|
88
|
+
session.currentPath,
|
|
89
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
90
|
+
return searchable.includes(query);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function cycleFilters(filters: DashboardFilters): DashboardFilters {
|
|
95
|
+
const nextTool = TOOL_FILTERS[(TOOL_FILTERS.indexOf(filters.tool) + 1) % TOOL_FILTERS.length] ?? "all";
|
|
96
|
+
if (nextTool !== "all") return { ...filters, tool: nextTool, state: "all" };
|
|
97
|
+
const nextState = STATE_FILTERS[(STATE_FILTERS.indexOf(filters.state) + 1) % STATE_FILTERS.length] ?? "all";
|
|
98
|
+
return { ...filters, tool: "all", state: nextState };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sessionSortWeight(session: SessionListItem): number {
|
|
102
|
+
// Active work first (working/waiting), then idle workloads.
|
|
103
|
+
const statePenalty = session.state === "working" ? 0 : session.state === "waiting-input" ? 20 : 60;
|
|
104
|
+
const toolPenalty = session.tool === "unknown" ? 30 : session.tool === "shell" ? 20 : 0;
|
|
105
|
+
return statePenalty + toolPenalty;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function groupSortWeight(group: { sessions: SessionListItem[] }): number {
|
|
109
|
+
const minWeight = Math.min(...group.sessions.map(sessionSortWeight));
|
|
110
|
+
return group.sessions.every((session) => !session.project) ? 1000 + minWeight : minWeight;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function buildRows(sessions: SessionListItem[], narrow = false): DashboardRow[] {
|
|
114
|
+
const roots = sessions.filter((session) => !session.parentSessionId);
|
|
115
|
+
const childrenByParent = new Map<string, SessionListItem[]>();
|
|
116
|
+
for (const session of sessions) {
|
|
117
|
+
if (!session.parentSessionId) continue;
|
|
118
|
+
const children = childrenByParent.get(session.parentSessionId) ?? [];
|
|
119
|
+
children.push(session);
|
|
120
|
+
childrenByParent.set(session.parentSessionId, children);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const groups = new Map<string, { projectName: string; sessions: SessionListItem[] }>();
|
|
124
|
+
for (const session of roots) {
|
|
125
|
+
const key = getProjectGroupKey(session);
|
|
126
|
+
const group = groups.get(key);
|
|
127
|
+
if (group) {
|
|
128
|
+
group.sessions.push(session);
|
|
129
|
+
} else {
|
|
130
|
+
groups.set(key, {
|
|
131
|
+
projectName: session.project?.displayName ?? (session.source === "standalone"
|
|
132
|
+
? (session.tool !== "unknown" ? session.tool : "standalone")
|
|
133
|
+
: session.source === "herdr"
|
|
134
|
+
? session.herdr?.workspace ?? "herdr"
|
|
135
|
+
: session.tmux?.session ?? key),
|
|
136
|
+
sessions: [session],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const recency = (group: { sessions: SessionListItem[] }) => Math.max(...group.sessions.map((s) => s.lastActivity));
|
|
142
|
+
const sortedGroups = [...groups.entries()].sort(
|
|
143
|
+
([, left], [, right]) => groupSortWeight(left) - groupSortWeight(right) || recency(right) - recency(left),
|
|
144
|
+
);
|
|
145
|
+
const rows: DashboardRow[] = sortedGroups.length > 0 ? [{ kind: "gap", projectKey: "__initial-gap__" }] : [];
|
|
146
|
+
sortedGroups.forEach(([projectKey, group], groupIndex) => {
|
|
147
|
+
if (groupIndex > 0) rows.push({ kind: "gap", projectKey });
|
|
148
|
+
group.sessions.sort((left, right) => sessionSortWeight(left) - sessionSortWeight(right) || right.lastActivity - left.lastActivity);
|
|
149
|
+
rows.push({ kind: "project-header", projectKey, projectName: group.projectName });
|
|
150
|
+
for (const session of group.sessions) {
|
|
151
|
+
rows.push({ kind: "session", projectKey, session });
|
|
152
|
+
if (!narrow) {
|
|
153
|
+
const children = (childrenByParent.get(session.id) ?? []).sort(
|
|
154
|
+
(left, right) => sessionSortWeight(left) - sessionSortWeight(right) || right.lastActivity - left.lastActivity,
|
|
155
|
+
);
|
|
156
|
+
for (const child of children) rows.push({ kind: "session", projectKey, session: child });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
return rows;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getProjectGroupKey(session: SessionListItem): string {
|
|
164
|
+
return session.project?.repositoryIdentityPath
|
|
165
|
+
?? session.project?.gitRootPath
|
|
166
|
+
?? session.project?.sourcePath
|
|
167
|
+
?? session.project?.displayName
|
|
168
|
+
?? (session.source === "standalone"
|
|
169
|
+
? `standalone:${session.tool}`
|
|
170
|
+
: session.source === "herdr"
|
|
171
|
+
? `herdr:${session.herdr?.workspaceId ?? session.herdr?.workspace ?? session.herdr?.paneId ?? session.id}`
|
|
172
|
+
: session.tmux?.session ?? session.id);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function moveSelection(rows: DashboardRow[], selectedId: string | undefined, direction: 1 | -1): string | undefined {
|
|
176
|
+
if (rows.length === 0) return selectedId;
|
|
177
|
+
if (!selectedId) {
|
|
178
|
+
const candidates = direction > 0 ? rows : [...rows].reverse();
|
|
179
|
+
return candidates.find((row): row is SessionRow => row.kind === "session")?.session.id;
|
|
180
|
+
}
|
|
181
|
+
const current = rows.findIndex((row) => row.kind === "session" && row.session.id === selectedId);
|
|
182
|
+
if (current < 0) return undefined;
|
|
183
|
+
for (let index = current + direction; index >= 0 && index < rows.length; index += direction) {
|
|
184
|
+
const row = rows[index];
|
|
185
|
+
if (row?.kind === "session") return row.session.id;
|
|
186
|
+
}
|
|
187
|
+
return selectedId;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function visibleRowWindow(rows: DashboardRow[], selectedId: string | undefined, limit: number): DashboardRow[] {
|
|
191
|
+
if (rows.length <= limit) return rows;
|
|
192
|
+
const selected = rows.findIndex((row) => row.kind === "session" && row.session.id === selectedId);
|
|
193
|
+
const start = Math.max(0, Math.min(selected - Math.floor(limit / 2), rows.length - limit));
|
|
194
|
+
return rows.slice(start, start + limit);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function stateGlyph(state: string, spinnerFrame = 0): string {
|
|
198
|
+
if (state === "working") return WORKING_FRAMES[spinnerFrame % WORKING_FRAMES.length] ?? WORKING_FRAMES[0]!;
|
|
199
|
+
if (state === "idle") return "○";
|
|
200
|
+
if (state === "waiting-input") return "?";
|
|
201
|
+
if (state === "shell") return ">";
|
|
202
|
+
if (state === "error") return "✗";
|
|
203
|
+
return "?";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function formatBranchLabel(session: SessionListItem): string {
|
|
207
|
+
if (!session.project?.isGitRepository) return "";
|
|
208
|
+
return session.project.branch || "detached";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function formatTargetLabel(session: SessionListItem, narrow = false): string {
|
|
212
|
+
let base: string;
|
|
213
|
+
if (session.parentSessionId) {
|
|
214
|
+
base = ` ↳ ${truncate(session.summary || session.id, narrow ? 22 : 26)}`;
|
|
215
|
+
} else if (session.tmux) {
|
|
216
|
+
const label = session.displayName?.trim()
|
|
217
|
+
|| session.tmux.session?.trim()
|
|
218
|
+
|| session.id;
|
|
219
|
+
base = ` ▸ ${label}`;
|
|
220
|
+
} else if (session.herdr) {
|
|
221
|
+
const label = session.displayName?.trim()
|
|
222
|
+
|| session.herdr.tab?.trim()
|
|
223
|
+
|| session.herdr.agent?.trim()
|
|
224
|
+
|| session.herdr.paneId;
|
|
225
|
+
base = ` ▹ ${label}`;
|
|
226
|
+
} else {
|
|
227
|
+
const placeholder = session.activeTool
|
|
228
|
+
? `[${session.activeTool}]`
|
|
229
|
+
: (session.id.includes(":") ? session.id.split(":")[1]!.slice(0, 8) : session.id.slice(0, 8));
|
|
230
|
+
base = ` ◇ ${placeholder}`;
|
|
231
|
+
}
|
|
232
|
+
const branch = formatBranchLabel(session);
|
|
233
|
+
return branch ? `${base}@${branch}` : base;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export type SessionToolMode = "text" | "monogram" | "icons";
|
|
237
|
+
|
|
238
|
+
export function sessionColumnWidths(width: number, toolMode: SessionToolMode = "text", toolContentWidth = 0): SessionColumnWidths {
|
|
239
|
+
const narrow = width < 110;
|
|
240
|
+
const available = Math.max(36, width - 9);
|
|
241
|
+
if (narrow) {
|
|
242
|
+
const enhanced = toolMode !== "text";
|
|
243
|
+
const state = 11;
|
|
244
|
+
// On very narrow widths the summary column is a few truncated characters;
|
|
245
|
+
// drop it entirely and hand its space to the session column. The budget
|
|
246
|
+
// matches the pane's real content width (borders + padding + gutter) so
|
|
247
|
+
// rows fill from border to border.
|
|
248
|
+
if (width < 100) {
|
|
249
|
+
const filledAvailable = Math.max(36, width - 6);
|
|
250
|
+
const tool = Math.max(5, (toolContentWidth || (toolMode === "text" ? 10 : 7)) + 1);
|
|
251
|
+
return { session: Math.max(14, filledAvailable - state - tool), state, tool, summary: 0 };
|
|
252
|
+
}
|
|
253
|
+
const tool = toolMode === "text"
|
|
254
|
+
? Math.min(12, Math.max(9, Math.floor(available * 0.18)))
|
|
255
|
+
: toolMode === "icons" ? 7 : 8;
|
|
256
|
+
const session = Math.min(enhanced ? 32 : 28, Math.max(14, Math.floor(available * (enhanced ? 0.4 : 0.34))));
|
|
257
|
+
return { session, state, tool, summary: Math.max(8, available - session - state - tool) };
|
|
258
|
+
}
|
|
259
|
+
const state = 11;
|
|
260
|
+
const tool = toolMode === "text" ? 13 : toolMode === "icons" ? 3 : 5;
|
|
261
|
+
if (toolMode !== "text") {
|
|
262
|
+
const remaining = available - state - tool;
|
|
263
|
+
const session = Math.min(72, Math.max(30, Math.floor(remaining * 0.55)));
|
|
264
|
+
return { session, state, tool, summary: Math.max(8, available - session - state - tool) };
|
|
265
|
+
}
|
|
266
|
+
const session = Math.min(40, Math.max(30, available - state - tool - 39));
|
|
267
|
+
return { session, state, tool, summary: Math.max(8, available - session - state - tool) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function formatStatusCell(session: SessionListItem, spinnerFrame: number): string {
|
|
271
|
+
const glyph = stateGlyph(session.state, spinnerFrame);
|
|
272
|
+
return `${glyph} (${formatDuration(session.stableForMs ?? 0)})`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function activeChildCounts(sessions: SessionListItem[]): Map<string, number> {
|
|
276
|
+
const counts = new Map<string, number>();
|
|
277
|
+
for (const session of sessions) {
|
|
278
|
+
if (!session.parentSessionId || (session.state !== "working" && session.state !== "waiting-input")) continue;
|
|
279
|
+
counts.set(session.parentSessionId, (counts.get(session.parentSessionId) ?? 0) + 1);
|
|
280
|
+
}
|
|
281
|
+
return counts;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function sessionAttachCommand(session: SessionListItem, insideTmux: boolean): string[] | null {
|
|
285
|
+
if (session.source === "tmux" && session.tmux) {
|
|
286
|
+
return insideTmux
|
|
287
|
+
? ["tmux", "switch-client", "-t", session.tmux.session]
|
|
288
|
+
: ["tmux", "attach-session", "-t", session.tmux.session];
|
|
289
|
+
}
|
|
290
|
+
if (session.source === "herdr" && session.herdr?.terminalId) {
|
|
291
|
+
return ["herdr", "terminal", "attach", session.herdr.terminalId];
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
package/src/tui/state.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { SessionModel } from "../shared/types";
|
|
2
|
+
import type { SessionListItem, SessionViewCounts } from "../shared/session-view";
|
|
3
|
+
import type { UsageState } from "../usage/types";
|
|
4
|
+
import { cycleFilters, type DashboardFilters, type DashboardMode, type DashboardView } from "./model";
|
|
5
|
+
|
|
6
|
+
export interface DashboardState {
|
|
7
|
+
view: DashboardView;
|
|
8
|
+
mode: DashboardMode;
|
|
9
|
+
sessions: SessionListItem[];
|
|
10
|
+
counts: SessionViewCounts | null;
|
|
11
|
+
usage: UsageState;
|
|
12
|
+
selectedSessionId?: string;
|
|
13
|
+
detail?: SessionModel;
|
|
14
|
+
message?: string;
|
|
15
|
+
daemonConnected: boolean;
|
|
16
|
+
filters: DashboardFilters;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const INITIAL_DASHBOARD_STATE: DashboardState = {
|
|
20
|
+
view: "sessions",
|
|
21
|
+
mode: "list",
|
|
22
|
+
sessions: [],
|
|
23
|
+
counts: null,
|
|
24
|
+
usage: { providers: [], lastRefreshedAt: 0 },
|
|
25
|
+
daemonConnected: true,
|
|
26
|
+
filters: { query: "", actionableOnly: false, tool: "all", state: "all" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type DashboardAction =
|
|
30
|
+
| { type: "sessions-loaded"; sessions: SessionListItem[]; counts: SessionViewCounts }
|
|
31
|
+
| { type: "usage-loaded"; usage: UsageState }
|
|
32
|
+
| { type: "connection"; connected: boolean }
|
|
33
|
+
| { type: "select"; id?: string }
|
|
34
|
+
| { type: "view"; view: DashboardView }
|
|
35
|
+
| { type: "search" }
|
|
36
|
+
| { type: "query"; query: string }
|
|
37
|
+
| { type: "toggle-actionable" }
|
|
38
|
+
| { type: "cycle-filters" }
|
|
39
|
+
| { type: "detail"; session: SessionModel }
|
|
40
|
+
| { type: "message"; message: string }
|
|
41
|
+
| { type: "bindings" }
|
|
42
|
+
| { type: "close-overlay" };
|
|
43
|
+
|
|
44
|
+
export function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState {
|
|
45
|
+
switch (action.type) {
|
|
46
|
+
case "sessions-loaded": {
|
|
47
|
+
const selectedSessionId = action.sessions.some((session) => session.id === state.selectedSessionId)
|
|
48
|
+
? state.selectedSessionId
|
|
49
|
+
: undefined;
|
|
50
|
+
return { ...state, sessions: action.sessions, counts: action.counts, selectedSessionId, daemonConnected: true };
|
|
51
|
+
}
|
|
52
|
+
case "usage-loaded":
|
|
53
|
+
return { ...state, usage: action.usage };
|
|
54
|
+
case "connection":
|
|
55
|
+
return { ...state, daemonConnected: action.connected };
|
|
56
|
+
case "select":
|
|
57
|
+
return { ...state, selectedSessionId: action.id };
|
|
58
|
+
case "view":
|
|
59
|
+
return { ...state, view: action.view, mode: "list", detail: undefined, message: undefined };
|
|
60
|
+
case "search":
|
|
61
|
+
return { ...state, mode: "search" };
|
|
62
|
+
case "query":
|
|
63
|
+
return { ...state, filters: { ...state.filters, query: action.query } };
|
|
64
|
+
case "toggle-actionable":
|
|
65
|
+
return { ...state, filters: { ...state.filters, actionableOnly: !state.filters.actionableOnly } };
|
|
66
|
+
case "cycle-filters":
|
|
67
|
+
return { ...state, filters: cycleFilters(state.filters) };
|
|
68
|
+
case "detail":
|
|
69
|
+
return { ...state, mode: "detail", detail: action.session, message: undefined };
|
|
70
|
+
case "message":
|
|
71
|
+
return { ...state, mode: "message", message: action.message, detail: undefined };
|
|
72
|
+
case "bindings":
|
|
73
|
+
return { ...state, mode: "bindings", detail: undefined, message: undefined };
|
|
74
|
+
case "close-overlay":
|
|
75
|
+
return { ...state, mode: "list", detail: undefined, message: undefined };
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/tui/theme.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrylog brand palette (source: brand guide, OpenTUI-compatible ANSI sheet).
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for every color used by the TUI: change a value here
|
|
5
|
+
* (or swap the whole object for an alternate theme) and the whole UI follows.
|
|
6
|
+
*/
|
|
7
|
+
export const BRAND = {
|
|
8
|
+
/** Main Teal/Cyan — primary brand color (xterm 80). */
|
|
9
|
+
primary: "#5CCCCC",
|
|
10
|
+
/** Accent Light Blue (xterm 159). */
|
|
11
|
+
accentBlue: "#A1E6FF",
|
|
12
|
+
/** Highlight Violet (xterm 177). */
|
|
13
|
+
violet: "#CFA6FF",
|
|
14
|
+
/** Detail Grey — secondary text (xterm 246). */
|
|
15
|
+
grey: "#A0A0A0",
|
|
16
|
+
/** Background Dark (xterm 234). */
|
|
17
|
+
bgDark: "#1A1A1A",
|
|
18
|
+
/** Modal / floating panel surface (near-black, kept from legacy scheme). */
|
|
19
|
+
surface: "#11151c",
|
|
20
|
+
/** Functional colors kept unchanged (no palette equivalent). */
|
|
21
|
+
danger: "#f05252",
|
|
22
|
+
warning: "#f5a524",
|
|
23
|
+
|
|
24
|
+
/** Legacy chrome visuals preserved by design: pane borders and separators. */
|
|
25
|
+
border: "#495365",
|
|
26
|
+
/** Selected session row background (legacy slate). */
|
|
27
|
+
selection: "#263244",
|
|
28
|
+
/** Fallback foreground for rows whose state has no mapping. */
|
|
29
|
+
fallback: "#d8dee9",
|
|
30
|
+
|
|
31
|
+
/** Session state colors (palette-family tints). */
|
|
32
|
+
stateWorking: "#E8BC5D",
|
|
33
|
+
stateIdle: "#6BD9A4",
|
|
34
|
+
/** Usage semaphore ≥90% (soft coral, danger family). */
|
|
35
|
+
usageHigh: "#E28686",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export interface ProviderVisual {
|
|
39
|
+
name: string;
|
|
40
|
+
short: string;
|
|
41
|
+
brandColor: string;
|
|
42
|
+
borderColor: string;
|
|
43
|
+
/** Alternative brand color readable on dark terminal backgrounds. */
|
|
44
|
+
onDarkColor?: string;
|
|
45
|
+
tileColor?: string;
|
|
46
|
+
terminalAsset?: URL;
|
|
47
|
+
graphicsAsset?: URL;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Third-party provider brand colors: data, not brand palette. */
|
|
51
|
+
export const PROVIDER_BRANDS: Record<string, ProviderVisual> = {
|
|
52
|
+
claude: {
|
|
53
|
+
name: "Claude",
|
|
54
|
+
short: "CL",
|
|
55
|
+
brandColor: "#d97757",
|
|
56
|
+
borderColor: "#d97757",
|
|
57
|
+
terminalAsset: new URL("./assets/providers/claude-terminal.png", import.meta.url),
|
|
58
|
+
graphicsAsset: new URL("./assets/providers/claude.png", import.meta.url),
|
|
59
|
+
},
|
|
60
|
+
codex: {
|
|
61
|
+
name: "Codex",
|
|
62
|
+
short: "CX",
|
|
63
|
+
brandColor: "#539af8",
|
|
64
|
+
borderColor: "#539af8",
|
|
65
|
+
terminalAsset: new URL("./assets/providers/openai-terminal.png", import.meta.url),
|
|
66
|
+
graphicsAsset: new URL("./assets/providers/openai.png", import.meta.url),
|
|
67
|
+
},
|
|
68
|
+
"opencode-go": {
|
|
69
|
+
name: "OpenCode Go",
|
|
70
|
+
short: "OC",
|
|
71
|
+
brandColor: "#111111",
|
|
72
|
+
onDarkColor: "#c8cdd8",
|
|
73
|
+
borderColor: BRAND.grey,
|
|
74
|
+
tileColor: "#f3f3f3",
|
|
75
|
+
terminalAsset: new URL("./assets/providers/opencode-terminal.png", import.meta.url),
|
|
76
|
+
graphicsAsset: new URL("./assets/providers/opencode.png", import.meta.url),
|
|
77
|
+
},
|
|
78
|
+
copilot: {
|
|
79
|
+
name: "Copilot",
|
|
80
|
+
short: "CP",
|
|
81
|
+
brandColor: "#c8cdd8",
|
|
82
|
+
borderColor: BRAND.grey,
|
|
83
|
+
graphicsAsset: new URL("./assets/providers/copilot.png", import.meta.url),
|
|
84
|
+
},
|
|
85
|
+
shell: {
|
|
86
|
+
name: "Shell",
|
|
87
|
+
short: "SH",
|
|
88
|
+
brandColor: "#c8cdd8",
|
|
89
|
+
borderColor: BRAND.grey,
|
|
90
|
+
graphicsAsset: new URL("./assets/providers/shell.png", import.meta.url),
|
|
91
|
+
},
|
|
92
|
+
unknown: {
|
|
93
|
+
name: "Unknown",
|
|
94
|
+
short: "?",
|
|
95
|
+
brandColor: BRAND.grey,
|
|
96
|
+
borderColor: BRAND.grey,
|
|
97
|
+
graphicsAsset: new URL("./assets/providers/unknown.png", import.meta.url),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const BRAND_MARK = {
|
|
102
|
+
/** Blocks: front-view crystal ball + prompt + circuit pedestal, brand palette. */
|
|
103
|
+
art: [
|
|
104
|
+
{ fg: BRAND.accentBlue, content: " ▗▄▄▄▄▄▄▖" },
|
|
105
|
+
{ fg: BRAND.primary, content: " ▐▓▒░░░░▒▓▌" },
|
|
106
|
+
{ fg: BRAND.primary, content: " ░ ◉◈◉ ›_ ░" },
|
|
107
|
+
{ fg: BRAND.violet, content: " ▜▓▒░░░░▒▓▛" },
|
|
108
|
+
{ fg: BRAND.violet, content: " ▐█╿██╵█▌" },
|
|
109
|
+
],
|
|
110
|
+
} as const;
|
package/src/usage/README.md
CHANGED
|
@@ -297,7 +297,7 @@ These clients use the global `fetch` and have no Node.js or platform dependencie
|
|
|
297
297
|
|---|---|---|
|
|
298
298
|
| `enabled` | `true` | Enable usage collection |
|
|
299
299
|
| `pollIntervalMs` | `300000` | Polling interval (5 minutes) |
|
|
300
|
-
| `providers` | `["claude", "opencode-go"]` | Which providers to poll |
|
|
300
|
+
| `providers` | `["claude", "opencode-go", "codex"]` | Which providers to poll |
|
|
301
301
|
| `claude.dataSource` | `"auto"` | `"auto"`, `"oauth"`, `"web"`, or `"cli"` |
|
|
302
302
|
| `claude.webExtrasEnabled` | `true` | Enrich OAuth/CLI with Web API extras |
|
|
303
303
|
| `codex.dataSource` | `"auto"` | `"auto"` or `"oauth"` |
|
|
@@ -311,7 +311,7 @@ These clients use the global `fetch` and have no Node.js or platform dependencie
|
|
|
311
311
|
| Claude OAuth (`~/.claude/.credentials.json`) | ⚠️ (fallback only) | ✅ | ✅ | ✅ |
|
|
312
312
|
| Claude Web (browser cookies) | ✅ (Chrome + Safari) | ✅ (Chrome/Chromium) | ✅ (Chrome/Chromium) | ❌ (Chrome: DPAPI not implemented) |
|
|
313
313
|
| Claude CLI (PTY probe) | ✅ (`script` wrapper) | ⚠️ (no PTY wrapper) | ⚠️ (no PTY wrapper) | ⚠️ (no PTY wrapper) |
|
|
314
|
-
| Codex OAuth
|
|
314
|
+
| Codex OAuth + owner CLI recovery | ✅ | ✅ | ✅ | ⚠️ (CLI launch is experimental) |
|
|
315
315
|
| OpenCode Go (browser cookies) | ✅ | ✅ | ✅ | ❌ (Chrome: DPAPI not implemented) |
|
|
316
316
|
|
|
317
317
|
## Adding a new provider
|
|
@@ -319,5 +319,5 @@ These clients use the global `fetch` and have no Node.js or platform dependencie
|
|
|
319
319
|
1. Create resolver + client in `src/usage/resolvers/` and `src/usage/clients/`
|
|
320
320
|
2. Create provider in `src/usage/providers/<name>.ts` that returns `{ entry: UsageProviderEntry, snapshot: any }`
|
|
321
321
|
3. Add mapper in `src/usage/mappers.ts`: `map<Name>ToEntry(snapshot, errors, available)` — include `accountId` computation
|
|
322
|
-
4. Register in `UsageCollector.poll()
|
|
322
|
+
4. Register in `UsageCollector.poll()`; the provider returns an unavailable entry when credentials are absent
|
|
323
323
|
5. No changes needed to `UsageState`, Zod schemas, or API route
|
|
@@ -1,27 +1,91 @@
|
|
|
1
1
|
import type { UsageClient } from "../client";
|
|
2
2
|
import type { ClaudeOAuthCredentials, ClaudeUsageSnapshot, NamedRateWindowLegacy, ProviderCostSnapshotLegacy, RateWindowLegacy } from "../types";
|
|
3
|
+
import { classifyUsageError, UsageError } from "../errors";
|
|
3
4
|
|
|
4
|
-
export class AuthenticationError extends
|
|
5
|
+
export class AuthenticationError extends UsageError {
|
|
5
6
|
constructor(message: string) {
|
|
6
|
-
super(message);
|
|
7
|
+
super("invalid-credentials", message);
|
|
7
8
|
this.name = "AuthenticationError";
|
|
8
9
|
}
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export class ScopeError extends
|
|
12
|
+
export class ScopeError extends UsageError {
|
|
12
13
|
constructor(message: string) {
|
|
13
|
-
super(message);
|
|
14
|
+
super("forbidden", message);
|
|
14
15
|
this.name = "ScopeError";
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export class ParseError extends
|
|
19
|
+
export class ParseError extends UsageError {
|
|
19
20
|
constructor(message: string) {
|
|
20
|
-
super(message);
|
|
21
|
+
super("invalid-response", message);
|
|
21
22
|
this.name = "ParseError";
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
export interface OAuthProfileIdentity {
|
|
27
|
+
email: string | null;
|
|
28
|
+
organizationId: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readTrimmedString(record: Record<string, unknown> | undefined | null, keys: string[]): string | null {
|
|
32
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
const value = record[key];
|
|
35
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseOAuthProfile(data: unknown): OAuthProfileIdentity {
|
|
41
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
42
|
+
return { email: null, organizationId: null };
|
|
43
|
+
}
|
|
44
|
+
const root = data as Record<string, unknown>;
|
|
45
|
+
const account = root["account"] as Record<string, unknown> | null | undefined;
|
|
46
|
+
const organization = root["organization"] as Record<string, unknown> | null | undefined;
|
|
47
|
+
const email = readTrimmedString(account, ["emailAddress", "email_address", "email"])
|
|
48
|
+
?? readTrimmedString(root, ["emailAddress", "email_address", "email"]);
|
|
49
|
+
const organizationId = readTrimmedString(organization, ["uuid"])
|
|
50
|
+
?? readTrimmedString(root, ["organizationUuid", "organization_uuid"]);
|
|
51
|
+
return { email, organizationId };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function fetchOAuthProfile(
|
|
55
|
+
accessToken: string,
|
|
56
|
+
options?: { signal?: AbortSignal },
|
|
57
|
+
): Promise<OAuthProfileIdentity> {
|
|
58
|
+
let res: Response;
|
|
59
|
+
try {
|
|
60
|
+
res = await fetch("https://api.anthropic.com/api/oauth/profile", {
|
|
61
|
+
signal: options?.signal,
|
|
62
|
+
headers: {
|
|
63
|
+
Authorization: `Bearer ${accessToken}`,
|
|
64
|
+
Accept: "application/json",
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw classifyUsageError(error);
|
|
70
|
+
}
|
|
71
|
+
if (res.status === 401) {
|
|
72
|
+
throw new AuthenticationError("OAuth profile unauthorized");
|
|
73
|
+
}
|
|
74
|
+
if (res.status >= 500) {
|
|
75
|
+
throw new UsageError("transient", `OAuth profile server error: ${res.status}`);
|
|
76
|
+
}
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
throw new UsageError("invalid-response", `Unexpected OAuth profile response: ${res.status}`);
|
|
79
|
+
}
|
|
80
|
+
let data: unknown;
|
|
81
|
+
try {
|
|
82
|
+
data = await res.json();
|
|
83
|
+
} catch {
|
|
84
|
+
throw new ParseError("OAuth profile response was not valid JSON");
|
|
85
|
+
}
|
|
86
|
+
return parseOAuthProfile(data);
|
|
87
|
+
}
|
|
88
|
+
|
|
25
89
|
interface OAuthUsageResponse {
|
|
26
90
|
five_hour?: { utilization?: number | null; resets_at?: string | null } | null;
|
|
27
91
|
seven_day?: { utilization?: number | null; resets_at?: string | null } | null;
|
|
@@ -89,15 +153,20 @@ function toRateWindow(
|
|
|
89
153
|
|
|
90
154
|
export class AnthropicOAuthUsageClient implements UsageClient<ClaudeOAuthCredentials, ClaudeUsageSnapshot> {
|
|
91
155
|
async fetch(creds: ClaudeOAuthCredentials, options?: { signal?: AbortSignal }): Promise<ClaudeUsageSnapshot> {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
156
|
+
let res: Response;
|
|
157
|
+
try {
|
|
158
|
+
res = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
159
|
+
signal: options?.signal,
|
|
160
|
+
headers: {
|
|
161
|
+
Authorization: `Bearer ${creds.accessToken}`,
|
|
162
|
+
Accept: "application/json",
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw classifyUsageError(error);
|
|
169
|
+
}
|
|
101
170
|
|
|
102
171
|
if (res.status === 401) {
|
|
103
172
|
throw new AuthenticationError("OAuth token expired or invalid");
|
|
@@ -107,13 +176,23 @@ export class AnthropicOAuthUsageClient implements UsageClient<ClaudeOAuthCredent
|
|
|
107
176
|
if (body.includes("user:profile")) {
|
|
108
177
|
throw new ScopeError("Token missing required scope user:profile");
|
|
109
178
|
}
|
|
110
|
-
throw new
|
|
179
|
+
throw new ScopeError(`OAuth access forbidden (${res.status})`);
|
|
111
180
|
}
|
|
181
|
+
if (res.status === 429) {
|
|
182
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
183
|
+
throw new UsageError("rate-limited", "OAuth usage request rate limited", Number.isFinite(retryAfter) ? retryAfter * 1000 : undefined);
|
|
184
|
+
}
|
|
185
|
+
if (res.status >= 500) throw new UsageError("transient", `OAuth usage server error: ${res.status}`);
|
|
112
186
|
if (!res.ok) {
|
|
113
|
-
throw new
|
|
187
|
+
throw new UsageError("invalid-response", `Unexpected OAuth usage response: ${res.status}`);
|
|
114
188
|
}
|
|
115
189
|
|
|
116
|
-
|
|
190
|
+
let data: OAuthUsageResponse;
|
|
191
|
+
try {
|
|
192
|
+
data = (await res.json()) as OAuthUsageResponse;
|
|
193
|
+
} catch {
|
|
194
|
+
throw new ParseError("OAuth usage response was not valid JSON");
|
|
195
|
+
}
|
|
117
196
|
|
|
118
197
|
if (!data.five_hour || typeof data.five_hour.utilization !== "number") {
|
|
119
198
|
throw new ParseError("Missing five_hour.utilization");
|
|
@@ -170,9 +249,10 @@ export class AnthropicOAuthUsageClient implements UsageClient<ClaudeOAuthCredent
|
|
|
170
249
|
updatedAt: new Date(),
|
|
171
250
|
accountEmail: null,
|
|
172
251
|
accountOrganization: null,
|
|
252
|
+
accountOrganizationId: null,
|
|
173
253
|
loginMethod: null,
|
|
174
|
-
subscriptionType: null,
|
|
175
|
-
rateLimitTier: null,
|
|
254
|
+
subscriptionType: creds.subscriptionType ?? null,
|
|
255
|
+
rateLimitTier: creds.rateLimitTier ?? null,
|
|
176
256
|
};
|
|
177
257
|
}
|
|
178
258
|
}
|