@scrylog/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +46 -7
  2. package/package.json +13 -10
  3. package/src/api/docs/sessions.ts +3 -2
  4. package/src/api/docs/system.ts +3 -0
  5. package/src/api/docs/usage.ts +2 -2
  6. package/src/api/routes/sessions.ts +6 -2
  7. package/src/api/routes/system.ts +4 -1
  8. package/src/api/routes/webhooks/claude.ts +8 -0
  9. package/src/api/routes/webhooks/opencode.ts +1 -0
  10. package/src/api/schemas/common.ts +8 -0
  11. package/src/api/schemas/sessions.ts +15 -1
  12. package/src/api/schemas/usage.ts +1 -0
  13. package/src/cli/commands.ts +85 -3
  14. package/src/cli/index.ts +18 -0
  15. package/src/cli/program.ts +175 -0
  16. package/src/daemon/collectors/claude/collector.ts +1 -1
  17. package/src/daemon/collectors/claude/discovery.ts +2 -23
  18. package/src/daemon/collectors/codex/collector.ts +3 -3
  19. package/src/daemon/collectors/git.ts +60 -2
  20. package/src/daemon/collectors/herdr/collector.ts +128 -0
  21. package/src/daemon/collectors/herdr/index.ts +2 -0
  22. package/src/daemon/collectors/herdr/normalization.ts +111 -0
  23. package/src/daemon/collectors/opencode-db.ts +17 -5
  24. package/src/daemon/collectors/tmux/collector.ts +21 -1
  25. package/src/daemon/collectors/tmux/enrichment.ts +1 -0
  26. package/src/daemon/collectors/usage.ts +115 -49
  27. package/src/daemon/index.ts +2 -0
  28. package/src/daemon/store.ts +44 -2
  29. package/src/postinstall.cjs +11 -0
  30. package/src/shared/config.ts +33 -0
  31. package/src/shared/session-exclusions.ts +5 -0
  32. package/src/shared/session-view.ts +67 -6
  33. package/src/shared/terminal-capabilities.ts +130 -0
  34. package/src/shared/types.ts +33 -2
  35. package/src/shared/version.ts +44 -0
  36. package/src/tui/App.tsx +240 -0
  37. package/src/tui/assets/providers/SOURCES.md +21 -0
  38. package/src/tui/assets/providers/claude-terminal.png +0 -0
  39. package/src/tui/assets/providers/claude-terminal.svg +62 -0
  40. package/src/tui/assets/providers/claude.png +0 -0
  41. package/src/tui/assets/providers/copilot.png +0 -0
  42. package/src/tui/assets/providers/copilot.svg +44 -0
  43. package/src/tui/assets/providers/openai-terminal.png +0 -0
  44. package/src/tui/assets/providers/openai-terminal.svg +62 -0
  45. package/src/tui/assets/providers/openai.png +0 -0
  46. package/src/tui/assets/providers/opencode-terminal.png +0 -0
  47. package/src/tui/assets/providers/opencode-terminal.svg +62 -0
  48. package/src/tui/assets/providers/opencode.png +0 -0
  49. package/src/tui/assets/providers/shell.png +0 -0
  50. package/src/tui/assets/providers/shell.svg +23 -0
  51. package/src/tui/assets/providers/unknown.png +0 -0
  52. package/src/tui/assets/providers/unknown.svg +23 -0
  53. package/src/tui/client.ts +22 -8
  54. package/src/tui/components/BindingsModal.tsx +82 -0
  55. package/src/tui/components/DashboardHeader.tsx +256 -0
  56. package/src/tui/components/KeyBar.tsx +67 -0
  57. package/src/tui/components/SessionDetailModal.tsx +169 -0
  58. package/src/tui/components/SessionsView.tsx +184 -0
  59. package/src/tui/components/TabBar.tsx +40 -0
  60. package/src/tui/components/UsageSummaryPane.tsx +272 -0
  61. package/src/tui/components/UsageView.tsx +242 -0
  62. package/src/tui/components/tool-icons.ts +24 -0
  63. package/src/tui/index.tsx +106 -0
  64. package/src/tui/keybindings.ts +49 -0
  65. package/src/tui/model.ts +295 -0
  66. package/src/tui/state.ts +77 -0
  67. package/src/tui/theme.ts +110 -0
  68. package/src/usage/README.md +3 -3
  69. package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
  70. package/src/usage/clients/anthropic-web-usage.ts +133 -49
  71. package/src/usage/clients/claude-cli-probe.ts +458 -132
  72. package/src/usage/clients/codex-app-server.ts +354 -0
  73. package/src/usage/clients/codex-oauth-usage.ts +143 -16
  74. package/src/usage/clients/opencode-go-api.ts +100 -0
  75. package/src/usage/clients/opencode-go-usage.ts +163 -27
  76. package/src/usage/errors.ts +33 -0
  77. package/src/usage/local/opencode-go-local.ts +194 -0
  78. package/src/usage/mappers.ts +24 -15
  79. package/src/usage/planner.ts +89 -25
  80. package/src/usage/providers/claude.ts +203 -83
  81. package/src/usage/providers/codex.ts +92 -11
  82. package/src/usage/providers/opencode-go.ts +94 -16
  83. package/src/usage/resolvers/browser-cookies.ts +407 -69
  84. package/src/usage/resolvers/claude-auth.ts +140 -40
  85. package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
  86. package/src/usage/resolvers/claude-web.ts +72 -6
  87. package/src/usage/resolvers/codex-auth.ts +74 -17
  88. package/src/usage/resolvers/keychain-coordinator.ts +397 -0
  89. package/src/usage/resolvers/opencode-go-web.ts +47 -6
  90. package/src/usage/types.ts +37 -2
  91. package/src/usage/web-session-cache.ts +37 -0
  92. package/src/neo-blessed.d.ts +0 -4
  93. package/src/tui/app.ts +0 -920
  94. package/src/tui/index.ts +0 -217
@@ -0,0 +1,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
+ }
@@ -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
+
@@ -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
+ }
@@ -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;
@@ -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 (`~/.codex/auth.json`) | ✅ | ✅ | ✅ | ✅ (filepath) |
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()` with availability pre-check
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