opencode-top 3.0.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/src/ui/App.tsx ADDED
@@ -0,0 +1,141 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import { colors } from "./theme";
4
+ import { TabBar } from "./components/TabBar";
5
+ import { SessionsScreen } from "./screens/SessionsScreen";
6
+ import { ToolsScreen } from "./screens/ToolsScreen";
7
+ import { OverviewScreen } from "./screens/OverviewScreen";
8
+ import type { Workflow, ScreenId } from "../core/types";
9
+ import { loadSessions, sessionExists } from "../data/sqlite";
10
+ import { groupSessionsToWorkflows } from "../core/agents";
11
+
12
+ interface AppProps {
13
+ refreshInterval?: number;
14
+ }
15
+
16
+ function workflowsEqual(a: Workflow[], b: Workflow[]): boolean {
17
+ if (a.length !== b.length) return false;
18
+ for (let i = 0; i < a.length; i++) {
19
+ if (a[i].id !== b[i].id) return false;
20
+ if (a[i].mainSession.interactions.length !== b[i].mainSession.interactions.length) return false;
21
+ if (a[i].subAgentSessions.length !== b[i].subAgentSessions.length) return false;
22
+ }
23
+ return true;
24
+ }
25
+
26
+ export function App({ refreshInterval = 2000 }: AppProps) {
27
+ const { exit } = useApp();
28
+ const { stdout } = useStdout();
29
+ const terminalHeight = stdout?.rows ?? 24;
30
+ const terminalWidth = stdout?.columns ?? 80;
31
+
32
+ const [workflows, setWorkflows] = useState<Workflow[]>([]);
33
+ const [screen, setScreen] = useState<ScreenId>("sessions");
34
+ const [error, setError] = useState<string | null>(null);
35
+ const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
36
+
37
+ const workflowsRef = useRef<Workflow[]>([]);
38
+ const mountedRef = useRef(false);
39
+
40
+ const loadData = useCallback(() => {
41
+ if (!sessionExists()) {
42
+ if (mountedRef.current) {
43
+ setError("No OpenCode database found. Run OpenCode first.");
44
+ }
45
+ return;
46
+ }
47
+
48
+ try {
49
+ const sessions = loadSessions();
50
+ const grouped = groupSessionsToWorkflows(sessions);
51
+
52
+ if (!workflowsEqual(workflowsRef.current, grouped)) {
53
+ workflowsRef.current = grouped;
54
+ if (mountedRef.current) {
55
+ setWorkflows(grouped);
56
+ setLastRefresh(new Date());
57
+ }
58
+ }
59
+
60
+ if (mountedRef.current) {
61
+ setError(null);
62
+ }
63
+ } catch (e) {
64
+ if (mountedRef.current) {
65
+ setError(e instanceof Error ? e.message : "Failed to load sessions");
66
+ }
67
+ }
68
+ }, []);
69
+
70
+ useEffect(() => {
71
+ mountedRef.current = true;
72
+ loadData();
73
+ const interval = setInterval(loadData, refreshInterval);
74
+ return () => {
75
+ mountedRef.current = false;
76
+ clearInterval(interval);
77
+ };
78
+ }, [loadData, refreshInterval]);
79
+
80
+ // Global: q quit, r refresh, 1-3 tabs
81
+ useInput((input, key) => {
82
+ if (input === "q" || key.escape) {
83
+ exit();
84
+ return;
85
+ }
86
+ if (input === "r") {
87
+ loadData();
88
+ return;
89
+ }
90
+ if (input === "1") { setScreen("sessions"); return; }
91
+ if (input === "2") { setScreen("tools"); return; }
92
+ if (input === "3") { setScreen("overview"); return; }
93
+ });
94
+
95
+ if (error) {
96
+ return (
97
+ <Box flexDirection="column" padding={2}>
98
+ <Text color={colors.error} bold>Error</Text>
99
+ <Text color={colors.text}>{error}</Text>
100
+ <Box marginTop={1}>
101
+ <Text color={colors.textDim}>Press q to quit</Text>
102
+ </Box>
103
+ </Box>
104
+ );
105
+ }
106
+
107
+ // Tab bar is 3 rows (border top + content + border bottom)
108
+ const contentHeight = terminalHeight - 3;
109
+
110
+ return (
111
+ <Box flexDirection="column" width={terminalWidth}>
112
+ <TabBar activeScreen={screen} lastRefresh={lastRefresh} />
113
+ <Box width={terminalWidth} height={contentHeight}>
114
+ {screen === "sessions" && (
115
+ <SessionsScreen
116
+ workflows={workflows}
117
+ isActive={true}
118
+ contentHeight={contentHeight}
119
+ terminalWidth={terminalWidth}
120
+ />
121
+ )}
122
+ {screen === "tools" && (
123
+ <ToolsScreen
124
+ workflows={workflows}
125
+ isActive={true}
126
+ contentHeight={contentHeight}
127
+ terminalWidth={terminalWidth}
128
+ />
129
+ )}
130
+ {screen === "overview" && (
131
+ <OverviewScreen
132
+ workflows={workflows}
133
+ isActive={true}
134
+ contentHeight={contentHeight}
135
+ terminalWidth={terminalWidth}
136
+ />
137
+ )}
138
+ </Box>
139
+ </Box>
140
+ );
141
+ }
@@ -0,0 +1,95 @@
1
+ import React, { memo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { colors } from "../theme";
4
+ import type { AgentNode } from "../../core/types";
5
+ import { getSessionTokens, getSessionCostSingle } from "../../core/session";
6
+ import { getPricing } from "../../data/pricing";
7
+
8
+ function formatTokens(n: number): string {
9
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
10
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
11
+ return n.toString();
12
+ }
13
+
14
+ interface AgentNodeRowProps {
15
+ node: AgentNode;
16
+ isLast: boolean;
17
+ prefix: string;
18
+ }
19
+
20
+ function AgentNodeRow({ node, isLast, prefix }: AgentNodeRowProps) {
21
+ const { session } = node;
22
+ const tokens = getSessionTokens(session);
23
+ const pricing = getPricing(session.interactions[0]?.modelId ?? "");
24
+ const cost = getSessionCostSingle(session, pricing);
25
+ const agentName = session.interactions[0]?.agent ?? session.interactions[0]?.role ?? "main";
26
+
27
+ const connector = isLast ? "└─ " : "├─ ";
28
+ const childPrefix = prefix + (isLast ? " " : "│ ");
29
+
30
+ return (
31
+ <>
32
+ <Box flexDirection="row">
33
+ <Text color={colors.textDim}>{prefix}{connector}</Text>
34
+ <Text color={colors.cyan}>[{agentName}]</Text>
35
+ <Text color={colors.text}> {truncate(session.title ?? session.id.slice(0, 8), 20)}</Text>
36
+ <Box flexGrow={1} />
37
+ <Text color={colors.textDim}>{formatTokens(tokens.total)}</Text>
38
+ <Text color={colors.textDim}> </Text>
39
+ <Text color={colors.success}>${cost.toFixed(3)}</Text>
40
+ </Box>
41
+ {node.children.map((child, i) => (
42
+ <AgentNodeRow
43
+ key={child.session.id}
44
+ node={child}
45
+ isLast={i === node.children.length - 1}
46
+ prefix={childPrefix}
47
+ />
48
+ ))}
49
+ </>
50
+ );
51
+ }
52
+
53
+ interface AgentChainGraphProps {
54
+ agentTree: AgentNode;
55
+ }
56
+
57
+ function AgentChainGraphInner({ agentTree }: AgentChainGraphProps) {
58
+ const { session } = agentTree;
59
+ const tokens = getSessionTokens(session);
60
+ const pricing = getPricing(session.interactions[0]?.modelId ?? "");
61
+ const cost = getSessionCostSingle(session, pricing);
62
+ const agentName = session.interactions[0]?.agent ?? "main";
63
+
64
+ if (agentTree.children.length === 0) {
65
+ return null;
66
+ }
67
+
68
+ return (
69
+ <Box flexDirection="column">
70
+ <Box flexDirection="row">
71
+ <Text color={colors.cyan} bold>[{agentName}]</Text>
72
+ <Text color={colors.text}> {truncate(session.title ?? "root", 20)}</Text>
73
+ <Box flexGrow={1} />
74
+ <Text color={colors.textDim}>{formatTokens(tokens.total)}</Text>
75
+ <Text color={colors.textDim}> </Text>
76
+ <Text color={colors.success}>${cost.toFixed(3)}</Text>
77
+ </Box>
78
+ {agentTree.children.map((child, i) => (
79
+ <AgentNodeRow
80
+ key={child.session.id}
81
+ node={child}
82
+ isLast={i === agentTree.children.length - 1}
83
+ prefix=""
84
+ />
85
+ ))}
86
+ </Box>
87
+ );
88
+ }
89
+
90
+ export const AgentChainGraph = memo(AgentChainGraphInner);
91
+
92
+ function truncate(s: string, max: number): string {
93
+ if (s.length <= max) return s;
94
+ return s.slice(0, max - 1) + "…";
95
+ }
@@ -0,0 +1,98 @@
1
+ import React, { memo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { colors } from "../theme";
4
+ import type { Workflow, FlatNode } from "../../core/types";
5
+ import { getSessionTokens, getSessionCostSingle } from "../../core/session";
6
+ import { getPricing } from "../../data/pricing";
7
+ import Decimal from "decimal.js";
8
+
9
+ interface AgentTreeProps {
10
+ workflows: Workflow[];
11
+ selectedId: string | null;
12
+ flatNodes: FlatNode[];
13
+ onSelect: (id: string) => void;
14
+ maxHeight?: number;
15
+ }
16
+
17
+ function formatTokens(n: number): string {
18
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
19
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
20
+ return n.toString();
21
+ }
22
+
23
+ function formatCost(d: Decimal): string {
24
+ if (d.lessThan(0.01)) return `$${d.toFixed(4)}`;
25
+ return `$${d.toFixed(2)}`;
26
+ }
27
+
28
+ function AgentTreeInner({ workflows, selectedId, flatNodes, maxHeight = 20 }: AgentTreeProps) {
29
+ const headerHeight = 2;
30
+ const visibleCount = Math.max(1, maxHeight - headerHeight);
31
+ const selectedIndex = flatNodes.findIndex((n) => n.id === selectedId);
32
+
33
+ let startIndex = 0;
34
+ if (selectedIndex >= visibleCount - 1) {
35
+ startIndex = Math.max(0, selectedIndex - visibleCount + 2);
36
+ }
37
+ const visibleNodes = flatNodes.slice(startIndex, startIndex + visibleCount);
38
+
39
+ return (
40
+ <Box flexDirection="column" paddingX={1}>
41
+ <Box marginBottom={1} flexDirection="row">
42
+ <Text color={colors.accent} bold>Sessions</Text>
43
+ <Box flexGrow={1} />
44
+ <Text color={colors.textDim}>{workflows.length}</Text>
45
+ </Box>
46
+
47
+ {visibleNodes.map((node) => {
48
+ const isSelected = node.id === selectedId;
49
+ const tokens = getSessionTokens(node.session);
50
+ const pricing = getPricing(node.session.interactions[0]?.modelId ?? "");
51
+ const cost = getSessionCostSingle(node.session, pricing);
52
+ const agentName = node.session.interactions[0]?.agent ?? null;
53
+
54
+ const indent = " ".repeat(node.depth);
55
+ const prefix = node.depth === 0
56
+ ? (node.hasChildren ? "▶ " : "● ")
57
+ : (node.hasChildren ? "├▶ " : "├─ ");
58
+
59
+ const label = node.depth === 0
60
+ ? truncate(node.session.title ?? node.session.projectName ?? "Untitled", 22)
61
+ : truncate(`[${agentName ?? "?"}] ${node.session.title ?? ""}`, 20);
62
+
63
+ return (
64
+ <Box key={node.id} flexDirection="row">
65
+ <Box width={1} />
66
+ <Text
67
+ color={isSelected ? undefined : colors.text}
68
+ backgroundColor={isSelected ? colors.accent : undefined}
69
+ bold={isSelected}
70
+ >
71
+ {indent}{prefix}{label}
72
+ </Text>
73
+ <Box flexGrow={1} />
74
+ <Text color={colors.textDim} dimColor>{formatTokens(tokens.total)}</Text>
75
+ <Box width={1} />
76
+ <Text color={colors.success}>{formatCost(cost)}</Text>
77
+ <Box width={1} />
78
+ </Box>
79
+ );
80
+ })}
81
+
82
+ {flatNodes.length > visibleCount && (
83
+ <Text color={colors.textDim}>
84
+ {startIndex > 0 ? "↑ " : " "}
85
+ {startIndex + visibleCount}/{flatNodes.length}
86
+ {startIndex + visibleCount < flatNodes.length ? " ↓" : " "}
87
+ </Text>
88
+ )}
89
+ </Box>
90
+ );
91
+ }
92
+
93
+ export const AgentTree = memo(AgentTreeInner);
94
+
95
+ function truncate(label: string, maxLen: number): string {
96
+ if (label.length <= maxLen) return label;
97
+ return label.slice(0, maxLen - 1) + "…";
98
+ }
@@ -0,0 +1,210 @@
1
+ import React, { memo, useMemo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { colors } from "../theme";
4
+ import type { Workflow } from "../../core/types";
5
+ import {
6
+ getSessionTokens,
7
+ getSessionCostSingle,
8
+ getSessionDuration,
9
+ getOutputRate,
10
+ getToolUsage,
11
+ } from "../../core/session";
12
+ import { getPricing } from "../../data/pricing";
13
+ import { AgentChainGraph } from "./AgentChainGraph";
14
+
15
+ interface DetailsPanelProps {
16
+ workflow: Workflow | null;
17
+ }
18
+
19
+ function StatRow({
20
+ label,
21
+ value,
22
+ color = colors.text,
23
+ }: {
24
+ label: string;
25
+ value: string;
26
+ color?: string;
27
+ }) {
28
+ return (
29
+ <Box flexDirection="row">
30
+ <Box width={12}>
31
+ <Text color={colors.textDim}>{label}</Text>
32
+ </Box>
33
+ <Text color={color}>{value}</Text>
34
+ </Box>
35
+ );
36
+ }
37
+
38
+ function formatDuration(ms: number): string {
39
+ if (ms === 0) return "—";
40
+ const mins = Math.floor(ms / 60000);
41
+ const secs = Math.floor((ms % 60000) / 1000);
42
+ if (mins === 0) return `${secs}s`;
43
+ return `${mins}m ${secs}s`;
44
+ }
45
+
46
+ function ProgressBar({
47
+ value,
48
+ max,
49
+ width = 16,
50
+ color = colors.accent,
51
+ }: {
52
+ value: number;
53
+ max: number;
54
+ width?: number;
55
+ color?: string;
56
+ }) {
57
+ const pct = max > 0 ? Math.min(value / max, 1) : 0;
58
+ const filled = Math.round(pct * width);
59
+ const empty = width - filled;
60
+
61
+ return (
62
+ <Text>
63
+ <Text color={color}>{"█".repeat(filled)}</Text>
64
+ <Text color={colors.border}>{"░".repeat(empty)}</Text>
65
+ <Text color={colors.textDim}> {Math.round(pct * 100)}%</Text>
66
+ </Text>
67
+ );
68
+ }
69
+
70
+ function formatTokens(n: number): string {
71
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
72
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
73
+ return n.toString();
74
+ }
75
+
76
+ function DetailsPanelInner({ workflow }: DetailsPanelProps) {
77
+ const data = useMemo(() => {
78
+ if (!workflow) return null;
79
+
80
+ const session = workflow.mainSession;
81
+ const tokens = getSessionTokens(session);
82
+ const modelId = session.interactions[0]?.modelId ?? "";
83
+ const pricing = getPricing(modelId);
84
+ const cost = getSessionCostSingle(session, pricing);
85
+ const duration = getSessionDuration(session);
86
+ const outputRate = getOutputRate(session);
87
+
88
+ const contextUsage = tokens.input + tokens.cacheRead + tokens.cacheWrite;
89
+ const contextPct = pricing.contextWindow > 0 ? contextUsage / pricing.contextWindow : 0;
90
+
91
+ const modelBreakdown = new Map<string, { count: number; tokens: number }>();
92
+ for (const i of session.interactions) {
93
+ const existing = modelBreakdown.get(i.modelId) ?? { count: 0, tokens: 0 };
94
+ existing.count++;
95
+ existing.tokens += i.tokens.total;
96
+ modelBreakdown.set(i.modelId, existing);
97
+ }
98
+
99
+ const toolUsage = getToolUsage(session);
100
+ const topTools = toolUsage
101
+ .sort((a, b) => b.calls - a.calls)
102
+ .slice(0, 3);
103
+
104
+ return {
105
+ title: session.title ?? "Untitled",
106
+ project: session.projectName ?? "—",
107
+ tokens: tokens.total,
108
+ cost,
109
+ duration,
110
+ outputRate,
111
+ calls: session.interactions.length,
112
+ contextUsage,
113
+ contextWindow: pricing.contextWindow,
114
+ contextPct,
115
+ modelBreakdown,
116
+ topTools,
117
+ agentTree: workflow.agentTree,
118
+ hasSubAgents: workflow.subAgentSessions.length > 0,
119
+ };
120
+ }, [workflow]);
121
+
122
+ if (!data) {
123
+ return (
124
+ <Box flexDirection="column" paddingX={1}>
125
+ <Text color={colors.textDim}>Select a session</Text>
126
+ </Box>
127
+ );
128
+ }
129
+
130
+ return (
131
+ <Box flexDirection="column" paddingX={1}>
132
+ <Box marginBottom={1}>
133
+ <Text color={colors.accent} bold>
134
+ Details
135
+ </Text>
136
+ </Box>
137
+
138
+ <Box flexDirection="column">
139
+ <Text color={colors.cyan} bold>
140
+ {data.title}
141
+ </Text>
142
+ <Text color={colors.textDim}>{data.project}</Text>
143
+
144
+ <StatRow label="Tokens" value={formatTokens(data.tokens)} />
145
+ <StatRow label="Cost" value={`$${data.cost.toFixed(4)}`} color={colors.success} />
146
+ <StatRow label="Duration" value={formatDuration(data.duration)} />
147
+ <StatRow label="Rate" value={`${data.outputRate.toFixed(0)} tok/s`} />
148
+ <StatRow label="Calls" value={data.calls.toString()} />
149
+
150
+ <Box marginTop={1}>
151
+ <Text color={colors.textDim}>Context</Text>
152
+ </Box>
153
+ <ProgressBar
154
+ value={data.contextUsage}
155
+ max={data.contextWindow}
156
+ color={data.contextPct > 0.8 ? colors.warning : colors.accent}
157
+ />
158
+ </Box>
159
+
160
+ <Box marginTop={1}>
161
+ <Text color={colors.purple} bold>
162
+ Models
163
+ </Text>
164
+ </Box>
165
+ {Array.from(data.modelBreakdown.entries())
166
+ .slice(0, 3)
167
+ .map(([model, stats]) => (
168
+ <Box key={model} flexDirection="row">
169
+ <Text color={colors.text}>{model.slice(0, 25)}</Text>
170
+ <Box flexGrow={1} />
171
+ <Text color={colors.textDim}>{stats.count}</Text>
172
+ <Box width={1} />
173
+ <Text color={colors.info}>{formatTokens(stats.tokens)}</Text>
174
+ </Box>
175
+ ))}
176
+
177
+ {data.topTools.length > 0 && (
178
+ <>
179
+ <Box marginTop={1}>
180
+ <Text color={colors.purple} bold>
181
+ Top Tools
182
+ </Text>
183
+ </Box>
184
+ {data.topTools.map((tool) => (
185
+ <Box key={tool.name} flexDirection="row">
186
+ <Text color={colors.text}>{tool.name.slice(0, 20)}</Text>
187
+ <Box flexGrow={1} />
188
+ <Text color={tool.failures > 0 ? colors.warning : colors.success}>
189
+ {tool.successes}/{tool.calls}
190
+ </Text>
191
+ </Box>
192
+ ))}
193
+ </>
194
+ )}
195
+
196
+ {data.hasSubAgents && (
197
+ <>
198
+ <Box marginTop={1}>
199
+ <Text color={colors.purple} bold>
200
+ Agent Chain
201
+ </Text>
202
+ </Box>
203
+ <AgentChainGraph agentTree={data.agentTree} />
204
+ </>
205
+ )}
206
+ </Box>
207
+ );
208
+ }
209
+
210
+ export const DetailsPanel = memo(DetailsPanelInner);