@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,67 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import { truncate } from "../../shared/utils";
3
+ import type { DashboardView } from "../model";
4
+ import { contextBindings, essentialBindings } from "../keybindings";
5
+ import { BRAND } from "../theme";
6
+
7
+ const KEY_COLOR = BRAND.primary;
8
+ const DESCRIPTION_COLOR = BRAND.grey;
9
+ const SEPARATOR = " · ";
10
+
11
+ function KeyBarItem({ binding, separator }: { binding: { key: string; description: string }; separator?: string }) {
12
+ return (
13
+ <box flexDirection="row" height={1} flexShrink={0} gap={1}>
14
+ <text fg={KEY_COLOR} attributes={TextAttributes.BOLD} content={binding.key} />
15
+ <text fg={DESCRIPTION_COLOR} content={binding.description} />
16
+ {separator && <text fg={DESCRIPTION_COLOR} content={separator} />}
17
+ </box>
18
+ );
19
+ }
20
+
21
+ export function KeyBar({
22
+ view,
23
+ width,
24
+ context,
25
+ }: {
26
+ view: DashboardView;
27
+ width: number;
28
+ context?: string;
29
+ }) {
30
+ const bindings = contextBindings(view).filter((binding) => binding.footer !== false);
31
+ const essentials = essentialBindings(view).filter((binding) => binding.footer !== false);
32
+ const narrow = width < 110;
33
+ const candidates = narrow ? essentials : bindings;
34
+ const contextSuffix = context ? `${SEPARATOR}${context}` : "";
35
+ const maxHidden = Math.max(0, bindings.length - essentials.length);
36
+ const indicatorWidth = narrow && maxHidden > 0 ? SEPARATOR.length + `+${maxHidden} more (?)`.length : 0;
37
+ const budget = Math.max(0, width - 2 - contextSuffix.length - indicatorWidth);
38
+
39
+ const items: { key: string; description: string }[] = [];
40
+ let used = 0;
41
+ for (const binding of candidates) {
42
+ const itemWidth = binding.key.length + 1 + binding.description.length + (items.length > 0 ? SEPARATOR.length : 0);
43
+ if (items.length > 0 && used + itemWidth > budget) break;
44
+ items.push(binding);
45
+ used += itemWidth;
46
+ }
47
+
48
+ const hidden = bindings.length - items.length;
49
+ const indicator = hidden > 0 ? `+${hidden} more (?)` : null;
50
+
51
+ return (
52
+ <box height={1} flexDirection="row" width={Math.max(1, width - 1)} paddingX={1} flexShrink={0}>
53
+ {items.map((binding, index) => (
54
+ <KeyBarItem key={binding.key} binding={binding} separator={index < items.length - 1 ? SEPARATOR : undefined} />
55
+ ))}
56
+ {indicator && <text fg={DESCRIPTION_COLOR} content={`${SEPARATOR}${indicator}`} />}
57
+ {context && (
58
+ <text
59
+ flexGrow={1}
60
+ fg={DESCRIPTION_COLOR}
61
+ content={truncate(contextSuffix, Math.max(1, width - 2 - used))}
62
+ truncate
63
+ />
64
+ )}
65
+ </box>
66
+ );
67
+ }
@@ -0,0 +1,169 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import type { SessionModel } from "../../shared/types";
3
+ import type { TuiConfig } from "../../shared/config";
4
+ import { formatDuration, truncate } from "../../shared/utils";
5
+ import { BRAND } from "../theme";
6
+ import { STATE_COLORS } from "../model";
7
+ import { providerAccentColor, providerVisual, ProviderIcon } from "./UsageView";
8
+ import { MINI_ICON_SIZE, isImageIconMode, toolProviderKey } from "./tool-icons";
9
+
10
+ const LABEL_WIDTH = 12;
11
+
12
+ interface DetailRow {
13
+ label: string;
14
+ value: string;
15
+ color?: string;
16
+ }
17
+
18
+ function formatRelativeTime(timestamp: number, now = Date.now()): string {
19
+ const seconds = Math.max(1, Math.round((now - timestamp) / 1000));
20
+ if (seconds > 86400) return `${Math.floor(seconds / 86400)}d ago`;
21
+ if (seconds > 3600) return `${Math.floor(seconds / 3600)}h ago`;
22
+ if (seconds > 60) return `${Math.floor(seconds / 60)}m ago`;
23
+ return "just now";
24
+ }
25
+
26
+ function buildInfoRows(session: SessionModel): DetailRow[] {
27
+ const rows: DetailRow[] = [];
28
+ rows.push({ label: "id", value: session.id });
29
+ rows.push({ label: "state", value: `${session.state} (${formatDuration(session.stableForMs ?? 0)})`, color: STATE_COLORS[session.state] ?? BRAND.fallback });
30
+ rows.push({ label: "tool", value: session.tool });
31
+ rows.push({ label: "source", value: `${session.source}${session.detectionSource ? ` / ${session.detectionSource}` : ""}` });
32
+ if (session.project?.displayName) rows.push({ label: "project", value: session.project.displayName });
33
+ if (session.project?.isGitRepository && session.project?.branch) {
34
+ rows.push({ label: "git", value: session.project.branch, color: BRAND.accentBlue });
35
+ }
36
+ if (session.currentPath) rows.push({ label: "path", value: session.currentPath });
37
+ if (session.summary) rows.push({ label: "summary", value: session.summary });
38
+ rows.push({ label: "activity", value: formatRelativeTime(session.lastActivity) });
39
+ if (session.processChain?.length) {
40
+ rows.push({ label: "command", value: session.processChain.join(" → "), color: BRAND.grey });
41
+ }
42
+ if (session.tmux) {
43
+ rows.push({ label: "tmux", value: `${session.tmux.session}:${session.tmux.window}.${session.tmux.pane}`, color: BRAND.accentBlue });
44
+ if (session.tmux.windowName) rows.push({ label: "window", value: session.tmux.windowName });
45
+ }
46
+ if (session.herdr) {
47
+ const herdr = session.herdr;
48
+ if (herdr.workspace) rows.push({ label: "workspace", value: herdr.workspace, color: BRAND.accentBlue });
49
+ if (herdr.tab) rows.push({ label: "tab", value: herdr.tab });
50
+ if (herdr.name) rows.push({ label: "pane", value: herdr.name });
51
+ if (herdr.agent) {
52
+ rows.push({ label: "agent", value: herdr.agent + (herdr.agentStatus ? ` (${herdr.agentStatus})` : ""), color: BRAND.accentBlue });
53
+ }
54
+ }
55
+ if (session.opencode) {
56
+ rows.push({ label: "instance", value: session.opencode.instanceUrl, color: BRAND.grey });
57
+ }
58
+ if (session.claude?.permissionMode) rows.push({ label: "perm mode", value: session.claude.permissionMode });
59
+ if (session.codex?.model) rows.push({ label: "model", value: session.codex.model });
60
+ if (session.codex?.permissionMode) rows.push({ label: "perm mode", value: session.codex.permissionMode });
61
+ if (session.parentSessionId) rows.push({ label: "parent", value: session.parentSessionId });
62
+ if (session.childSessionIds?.length) rows.push({ label: "children", value: String(session.childSessionIds.length) });
63
+ return rows;
64
+ }
65
+
66
+ export function SessionDetailModal(props: {
67
+ detail: SessionModel;
68
+ iconMode: TuiConfig["providerIcons"];
69
+ width: number;
70
+ }) {
71
+ const { detail } = props;
72
+ const toolMode = isImageIconMode(props.iconMode) ? "icons" : props.iconMode === "text" ? "text" : "monogram";
73
+ const toolKey = toolProviderKey(detail.tool);
74
+ const stateColor = STATE_COLORS[detail.state] ?? BRAND.fallback;
75
+ const modalWidth = Math.max(40, Math.floor(props.width * 0.9));
76
+ const innerWidth = Math.max(20, modalWidth - 4);
77
+ const titleWidth = Math.max(10, innerWidth - 12);
78
+
79
+ const infoRows = buildInfoRows(detail);
80
+ const reasonRows = [
81
+ ...(detail.toolReasons ?? []).map((reason) => ({ kind: "tool" as const, value: `${reason.label} — ${reason.detail}` })),
82
+ ...(detail.stateReasons ?? []).map((reason) => ({ kind: "state" as const, value: `${reason.label} — ${reason.detail}` })),
83
+ ];
84
+ const longestValue = Math.max(
85
+ 0,
86
+ ...infoRows.map((row) => row.value.length),
87
+ ...reasonRows.map((reason) => reason.value.length),
88
+ );
89
+ const tableWidth = Math.max(innerWidth, LABEL_WIDTH + 2 + longestValue);
90
+ const tableRule = `${"─".repeat(LABEL_WIDTH)}┼${"─".repeat(tableWidth - LABEL_WIDTH - 1)}`;
91
+
92
+ return (
93
+ <box
94
+ position="absolute"
95
+ top="5%"
96
+ left="5%"
97
+ width="90%"
98
+ height="90%"
99
+ title="Session detail"
100
+ border
101
+ borderColor={BRAND.primary}
102
+ backgroundColor={BRAND.surface}
103
+ flexDirection="column"
104
+ padding={1}
105
+ zIndex={10}
106
+ >
107
+ <box flexGrow={1} overflow="hidden" flexDirection="column">
108
+ <scrollbox flexGrow={1} height="100%" scrollY scrollX focused>
109
+ <box flexDirection="column">
110
+ <box flexDirection="row" height={1}>
111
+ {toolMode === "icons" && toolKey ? (
112
+ <>
113
+ <ProviderIcon provider={toolKey} mode={props.iconMode} size={MINI_ICON_SIZE} />
114
+ <text width={1} content=" " />
115
+ <text
116
+ fg={providerAccentColor(toolKey)}
117
+ attributes={TextAttributes.BOLD}
118
+ content={providerVisual(toolKey).short}
119
+ />
120
+ </>
121
+ ) : (
122
+ <text
123
+ fg={toolKey ? providerAccentColor(toolKey) : BRAND.fallback}
124
+ attributes={TextAttributes.BOLD}
125
+ content={toolKey ? providerVisual(toolKey).short : truncate(detail.tool, 8)}
126
+ />
127
+ )}
128
+ <text width={1} content=" " />
129
+ <text
130
+ fg={BRAND.fallback}
131
+ attributes={TextAttributes.BOLD}
132
+ content={truncate(detail.project?.displayName ?? detail.id, titleWidth)}
133
+ truncate
134
+ />
135
+ <text fg={BRAND.grey} content=" · " />
136
+ <text fg={stateColor} attributes={TextAttributes.BOLD} content={detail.state} />
137
+ </box>
138
+ <box height={1} flexDirection="row"><text fg={BRAND.border} content={tableRule} /></box>
139
+ {infoRows.map((row, index) => (
140
+ <box key={row.label} flexDirection="column">
141
+ <box flexDirection="row" height={1}>
142
+ <text width={LABEL_WIDTH} fg={BRAND.grey} content={row.label} />
143
+ <text width={1} fg={BRAND.border} content="│" />
144
+ <text width={1} content=" " />
145
+ <text width={row.value.length} fg={row.color ?? BRAND.fallback} content={row.value} />
146
+ </box>
147
+ <box height={1} flexDirection="row"><text fg={BRAND.border} content={tableRule} /></box>
148
+ {index === infoRows.length - 1 && reasonRows.length > 0 && <text content="" height={1} />}
149
+ </box>
150
+ ))}
151
+ {reasonRows.length > 0 && <text fg={BRAND.grey} attributes={TextAttributes.BOLD} content="Classification" />}
152
+ {reasonRows.map((reason, index) => (
153
+ <box key={`${reason.kind}:${reason.value}:${index}`} flexDirection="column">
154
+ <box flexDirection="row" height={1}>
155
+ <text width={LABEL_WIDTH} fg={BRAND.grey} content={reason.kind} />
156
+ <text width={1} fg={BRAND.border} content="│" />
157
+ <text width={1} content=" " />
158
+ <text width={reason.value.length} fg={BRAND.fallback} content={reason.value} />
159
+ </box>
160
+ <box height={1} flexDirection="row"><text fg={BRAND.border} content={tableRule} /></box>
161
+ </box>
162
+ ))}
163
+ </box>
164
+ </scrollbox>
165
+ </box>
166
+ <text height={1} flexShrink={0} fg={BRAND.grey} content="←→ scroll · q/Esc close · t attach" />
167
+ </box>
168
+ );
169
+ }
@@ -0,0 +1,184 @@
1
+ import { TextAttributes, type MouseEvent } from "@opentui/core";
2
+ import { useRef } from "react";
3
+ import type { TuiConfig } from "../../shared/config";
4
+ import type { SessionModel } from "../../shared/types";
5
+ import type { SessionListItem } from "../../shared/session-view";
6
+ import type { UsageState } from "../../usage/types";
7
+ import { UsageSummaryPane } from "./UsageSummaryPane";
8
+ import { providerAccentColor, providerVisual, ProviderIcon } from "./UsageView";
9
+ import { SessionDetailModal } from "./SessionDetailModal";
10
+ import { MINI_ICON_SIZE, isImageIconMode, toolProviderKey } from "./tool-icons";
11
+ import { truncate } from "../../shared/utils";
12
+ import { BRAND } from "../theme";
13
+ import {
14
+ STATE_COLORS,
15
+ activeChildCounts,
16
+ formatStatusCell,
17
+ formatTargetLabel,
18
+ sessionColumnWidths,
19
+ type DashboardFilters,
20
+ type DashboardMode,
21
+ type DashboardRow,
22
+ type SessionToolMode,
23
+ } from "../model";
24
+
25
+ interface SessionsViewProps {
26
+ rows: DashboardRow[];
27
+ sessions: SessionListItem[];
28
+ selectedSessionId?: string;
29
+ filters: DashboardFilters;
30
+ mode: DashboardMode;
31
+ detail?: SessionModel;
32
+ message?: string;
33
+ usage?: UsageState;
34
+ iconMode: TuiConfig["providerIcons"];
35
+ spinnerFrame: number;
36
+ width: number;
37
+ interactive?: boolean;
38
+ onQueryChange(query: string): void;
39
+ onSearchSubmit(): void;
40
+ onSelectSession?(sessionId: string): void;
41
+ onOpenSessionDetail?(session: SessionListItem): void;
42
+ onScrollSelection?(direction: 1 | -1): void;
43
+ }
44
+
45
+ export function SessionsView(props: SessionsViewProps) {
46
+ const { width } = props;
47
+ const narrow = width < 110;
48
+ const toolMode: SessionToolMode = isImageIconMode(props.iconMode) ? "icons" : props.iconMode === "text" ? "text" : "monogram";
49
+ const childCounts = activeChildCounts(props.sessions);
50
+ // Smallest tool column that fits the widest rendered content: only relevant
51
+ // when the summary column is gone (width < 100).
52
+ let toolContentWidth = 0;
53
+ if (narrow && width < 100) {
54
+ for (const row of props.rows) {
55
+ if (row.kind !== "session") continue;
56
+ const badgeValue = childCounts.get(row.session.id) ?? 0;
57
+ const badgeText = badgeValue > 0 ? `[+${badgeValue}]` : "";
58
+ let len: number;
59
+ if (toolMode === "monogram") {
60
+ const key = toolProviderKey(row.session.tool);
61
+ const short = key ? providerVisual(key).short.length : row.session.tool.length;
62
+ len = short + (badgeText ? badgeText.length + 1 : 0);
63
+ } else if (toolMode === "icons") {
64
+ len = MINI_ICON_SIZE.width + (badgeText ? badgeText.length + 1 : 0);
65
+ } else {
66
+ len = row.session.tool.length + (badgeText ? badgeText.length + 1 : 0);
67
+ }
68
+ toolContentWidth = Math.max(toolContentWidth, len);
69
+ }
70
+ }
71
+ const columns = sessionColumnWidths(width, toolMode, toolContentWidth);
72
+ const contentWidth = Math.max(20, width - 4);
73
+ const lastClickRef = useRef<{ id: string; time: number } | undefined>(undefined);
74
+
75
+ const handleRowMouseUp = props.interactive && props.onSelectSession
76
+ ? (session: SessionListItem) => (event: MouseEvent) => {
77
+ if (event.button !== 0) return;
78
+ const now = Date.now();
79
+ const last = lastClickRef.current;
80
+ if (last && last.id === session.id && now - last.time < 500) {
81
+ lastClickRef.current = undefined;
82
+ props.onOpenSessionDetail?.(session);
83
+ return;
84
+ }
85
+ lastClickRef.current = { id: session.id, time: now };
86
+ props.onSelectSession!(session.id);
87
+ }
88
+ : undefined;
89
+
90
+ const handleMouseScroll = props.interactive && props.onScrollSelection
91
+ ? (event: MouseEvent) => {
92
+ props.onScrollSelection!(event.scroll?.direction === "down" ? 1 : -1);
93
+ }
94
+ : undefined;
95
+
96
+ return (
97
+ <box position="relative" flexDirection="column" flexGrow={1}>
98
+ {props.mode === "search" && (
99
+ <box height={3} border borderColor={BRAND.primary} title="Search" paddingX={1}>
100
+ <input
101
+ focused
102
+ value={props.filters.query}
103
+ placeholder="session, project, tool..."
104
+ onInput={props.onQueryChange}
105
+ onSubmit={props.onSearchSubmit}
106
+ />
107
+ </box>
108
+ )}
109
+ <box
110
+ border
111
+ borderColor={BRAND.border}
112
+ flexDirection="column"
113
+ flexGrow={1}
114
+ paddingX={1}
115
+ onMouseScroll={handleMouseScroll}
116
+ >
117
+ <box flexDirection="row" height={1}>
118
+ <text width={2} content="" />
119
+ <text width={columns.session} attributes={TextAttributes.BOLD} content="Session" />
120
+ <text width={columns.state} attributes={TextAttributes.BOLD} content="St" />
121
+ <text width={columns.tool} attributes={TextAttributes.BOLD} content={isImageIconMode(props.iconMode) ? "" : "Tool"} truncate />
122
+ {columns.summary > 0 && <text width={columns.summary} attributes={TextAttributes.BOLD} content="Summary" />}
123
+ </box>
124
+ {props.rows.length === 0 && <text fg={BRAND.grey} content="No sessions detected" />}
125
+ {props.rows.map((row) => {
126
+ if (row.kind === "gap") return <text key={row.projectKey} content="" />;
127
+ if (row.kind === "project-header") {
128
+ const label = truncate(row.projectName, Math.max(8, columns.session - 6));
129
+ return <text key={`header:${row.projectKey}`} fg={BRAND.grey} attributes={TextAttributes.BOLD} content={` == ${label.padEnd(Math.max(8, columns.session - 6))} ==`} />;
130
+ }
131
+ const selected = row.session.id === props.selectedSessionId;
132
+ const status = formatStatusCell(row.session, props.spinnerFrame);
133
+ const childCount = narrow ? childCounts.get(row.session.id) ?? 0 : 0;
134
+ const badge = childCount > 0 ? `[+${childCount}]` : "";
135
+ const toolKey = toolProviderKey(row.session.tool);
136
+ const toolContent = badge
137
+ ? `${row.session.tool.slice(0, Math.max(1, columns.tool - badge.length - 1))} ${badge}`
138
+ : row.session.tool;
139
+ return (
140
+ <box
141
+ key={row.session.id}
142
+ backgroundColor={selected ? BRAND.selection : undefined}
143
+ flexDirection="row"
144
+ height={1}
145
+ onMouseUp={handleRowMouseUp?.(row.session)}
146
+ >
147
+ <text width={2} attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE} content={selected ? "> " : " "} />
148
+ <text width={columns.session} fg={STATE_COLORS[row.session.state] ?? BRAND.fallback} content={truncate(formatTargetLabel(row.session, narrow), columns.session)} truncate />
149
+ <text width={columns.state} fg={STATE_COLORS[row.session.state] ?? BRAND.fallback} attributes={TextAttributes.BOLD} content={truncate(status, columns.state)} truncate />
150
+ {toolMode === "icons" && toolKey ? (
151
+ <>
152
+ <ProviderIcon provider={toolKey} mode={props.iconMode} size={MINI_ICON_SIZE} />
153
+ <text width={columns.tool - MINI_ICON_SIZE.width} fg={STATE_COLORS[row.session.state] ?? BRAND.fallback} content={truncate(badge, columns.tool - MINI_ICON_SIZE.width)} truncate />
154
+ </>
155
+ ) : toolMode === "monogram" && toolKey ? (
156
+ <text
157
+ width={columns.tool}
158
+ fg={providerAccentColor(toolKey)}
159
+ attributes={TextAttributes.BOLD}
160
+ content={truncate(badge ? `${providerVisual(toolKey).short} ${badge}` : providerVisual(toolKey).short, columns.tool)}
161
+ truncate
162
+ />
163
+ ) : (
164
+ <text width={columns.tool} fg={STATE_COLORS[row.session.state] ?? BRAND.fallback} content={truncate(toolContent, columns.tool)} truncate />
165
+ )}
166
+ {columns.summary > 0 && (
167
+ <text width={columns.summary} fg={STATE_COLORS[row.session.state] ?? BRAND.fallback} content={truncate(row.session.summary || "-", columns.summary)} truncate />
168
+ )}
169
+ </box>
170
+ );
171
+ })}
172
+ </box>
173
+ {props.usage && <UsageSummaryPane usage={props.usage} width={width} iconMode={props.iconMode} />}
174
+ {props.mode === "detail" && props.detail && (
175
+ <SessionDetailModal detail={props.detail} iconMode={props.iconMode} width={width} />
176
+ )}
177
+ {props.mode === "message" && props.message && (
178
+ <box position="absolute" top="35%" left="15%" width="70%" height={7} title="scrylog" border borderColor={BRAND.warning} backgroundColor={BRAND.surface} padding={1} alignItems="center" justifyContent="center" zIndex={10}>
179
+ <text fg={BRAND.warning} content={props.message} wrapMode="word" />
180
+ </box>
181
+ )}
182
+ </box>
183
+ );
184
+ }
@@ -0,0 +1,40 @@
1
+ import { TextAttributes, type MouseEvent } from "@opentui/core";
2
+ import type { DashboardView } from "../model";
3
+ import { BRAND } from "../theme";
4
+
5
+ const ACTIVE_BACKGROUND = BRAND.primary;
6
+ const ACTIVE_FOREGROUND = BRAND.bgDark;
7
+ const INACTIVE_FOREGROUND = BRAND.grey;
8
+
9
+ const TABS: { view: DashboardView; label: string }[] = [
10
+ { view: "sessions", label: " Sessions " },
11
+ { view: "usage", label: " Usage " },
12
+ ];
13
+
14
+ export function TabBar({ view, onSelect }: { view: DashboardView; onSelect?(next: DashboardView): void }) {
15
+ return (
16
+ <box height={1} flexDirection="row">
17
+ {TABS.map((tab) => {
18
+ const active = tab.view === view;
19
+ return (
20
+ <box
21
+ key={tab.view}
22
+ backgroundColor={active ? ACTIVE_BACKGROUND : undefined}
23
+ flexDirection="row"
24
+ height={1}
25
+ onMouseUp={onSelect ? (event: MouseEvent) => {
26
+ if (event.button !== 0) return;
27
+ if (!active) onSelect(tab.view);
28
+ } : undefined}
29
+ >
30
+ <text
31
+ fg={active ? ACTIVE_FOREGROUND : INACTIVE_FOREGROUND}
32
+ attributes={active ? TextAttributes.BOLD : TextAttributes.NONE}
33
+ content={tab.label}
34
+ />
35
+ </box>
36
+ );
37
+ })}
38
+ </box>
39
+ );
40
+ }