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/LICENSE +21 -0
- package/README.md +35 -0
- package/bin/octop.js +13 -0
- package/bin/octop.mjs +13 -0
- package/package.json +48 -0
- package/src/cli.ts +60 -0
- package/src/core/agents.ts +78 -0
- package/src/core/session.ts +315 -0
- package/src/core/types.ts +156 -0
- package/src/data/pricing.ts +82 -0
- package/src/data/sqlite.ts +347 -0
- package/src/index.ts +6 -0
- package/src/ui/App.tsx +141 -0
- package/src/ui/components/AgentChainGraph.tsx +95 -0
- package/src/ui/components/AgentTree.tsx +98 -0
- package/src/ui/components/DetailsPanel.tsx +210 -0
- package/src/ui/components/MessagesPanel.tsx +188 -0
- package/src/ui/components/SparkLine.tsx +18 -0
- package/src/ui/components/StatusBar.tsx +24 -0
- package/src/ui/components/TabBar.tsx +42 -0
- package/src/ui/screens/OverviewScreen.tsx +327 -0
- package/src/ui/screens/SessionsScreen.tsx +191 -0
- package/src/ui/screens/TimelineScreen.tsx +222 -0
- package/src/ui/screens/ToolsScreen.tsx +260 -0
- package/src/ui/theme.ts +21 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React, { useState, useMemo, useCallback, memo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { colors } from "../theme";
|
|
4
|
+
import { StatusBar } from "../components/StatusBar";
|
|
5
|
+
import { AgentTree } from "../components/AgentTree";
|
|
6
|
+
import { DetailsPanel } from "../components/DetailsPanel";
|
|
7
|
+
import { MessagesPanel } from "../components/MessagesPanel";
|
|
8
|
+
import type { Workflow, Session, AgentNode, FlatNode } from "../../core/types";
|
|
9
|
+
|
|
10
|
+
interface SessionsScreenProps {
|
|
11
|
+
workflows: Workflow[];
|
|
12
|
+
isActive: boolean;
|
|
13
|
+
contentHeight: number;
|
|
14
|
+
terminalWidth: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type RightMode = "stats" | "messages";
|
|
18
|
+
|
|
19
|
+
function flattenWorkflow(workflow: Workflow, workflowIndex: number): FlatNode[] {
|
|
20
|
+
const nodes: FlatNode[] = [];
|
|
21
|
+
|
|
22
|
+
function walk(node: AgentNode) {
|
|
23
|
+
nodes.push({
|
|
24
|
+
id: node.session.id,
|
|
25
|
+
session: node.session,
|
|
26
|
+
workflowIndex,
|
|
27
|
+
depth: node.depth,
|
|
28
|
+
hasChildren: node.children.length > 0,
|
|
29
|
+
agentNode: node,
|
|
30
|
+
});
|
|
31
|
+
for (const child of node.children) {
|
|
32
|
+
walk(child);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
walk(workflow.agentTree);
|
|
37
|
+
return nodes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function SessionsScreenInner({
|
|
41
|
+
workflows,
|
|
42
|
+
isActive,
|
|
43
|
+
contentHeight,
|
|
44
|
+
terminalWidth,
|
|
45
|
+
}: SessionsScreenProps) {
|
|
46
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
47
|
+
const [rightMode, setRightMode] = useState<RightMode>("stats");
|
|
48
|
+
const [msgScrollOffset, setMsgScrollOffset] = useState(0);
|
|
49
|
+
|
|
50
|
+
// Flat list of all nodes (root workflows + sub-agents in tree order)
|
|
51
|
+
const flatNodes = useMemo(() => {
|
|
52
|
+
return workflows.flatMap((w, i) => flattenWorkflow(w, i));
|
|
53
|
+
}, [workflows]);
|
|
54
|
+
|
|
55
|
+
const clampedIndex = Math.min(selectedIndex, Math.max(0, flatNodes.length - 1));
|
|
56
|
+
const selectedNode = flatNodes[clampedIndex] ?? null;
|
|
57
|
+
|
|
58
|
+
// For DetailsPanel we need the full Workflow — use the root workflow of the selected node
|
|
59
|
+
const selectedWorkflow = useMemo(() => {
|
|
60
|
+
if (!selectedNode) return null;
|
|
61
|
+
const w = workflows[selectedNode.workflowIndex];
|
|
62
|
+
if (!w) return null;
|
|
63
|
+
// If we selected a sub-agent, wrap it as a single-session workflow for DetailsPanel
|
|
64
|
+
if (selectedNode.session.id !== w.mainSession.id) {
|
|
65
|
+
return {
|
|
66
|
+
id: selectedNode.session.id,
|
|
67
|
+
mainSession: selectedNode.session,
|
|
68
|
+
subAgentSessions: selectedNode.agentNode.children.map((c) => c.session),
|
|
69
|
+
agentTree: selectedNode.agentNode,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return w;
|
|
73
|
+
}, [selectedNode, workflows]);
|
|
74
|
+
|
|
75
|
+
const leftWidth = Math.floor(terminalWidth * 0.35);
|
|
76
|
+
const rightWidth = terminalWidth - leftWidth - 2; // 2 for borders
|
|
77
|
+
|
|
78
|
+
// Message scroll: reset when selection changes
|
|
79
|
+
const handleSelect = useCallback((index: number) => {
|
|
80
|
+
setSelectedIndex(index);
|
|
81
|
+
setMsgScrollOffset(0);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
useInput(
|
|
85
|
+
(input, key) => {
|
|
86
|
+
if (key.upArrow || input === "k") {
|
|
87
|
+
handleSelect(Math.max(0, clampedIndex - 1));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (key.downArrow || input === "j") {
|
|
91
|
+
handleSelect(Math.min(flatNodes.length - 1, clampedIndex + 1));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (key.tab) {
|
|
95
|
+
setRightMode((m) => (m === "stats" ? "messages" : "stats"));
|
|
96
|
+
setMsgScrollOffset(0);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Message scroll (only when in messages mode)
|
|
100
|
+
if (rightMode === "messages") {
|
|
101
|
+
if (input === "u" || key.pageUp) {
|
|
102
|
+
setMsgScrollOffset((o) => Math.max(0, o - 10));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (input === "d" || key.pageDown) {
|
|
106
|
+
setMsgScrollOffset((o) => o + 10);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
{ isActive }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const panelHeight = contentHeight - 2; // leave room for status bar
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Box flexDirection="column" width={terminalWidth} height={contentHeight}>
|
|
118
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
119
|
+
{/* Left: agent/session tree */}
|
|
120
|
+
<Box
|
|
121
|
+
width={leftWidth}
|
|
122
|
+
borderStyle="single"
|
|
123
|
+
borderColor={colors.border}
|
|
124
|
+
flexDirection="column"
|
|
125
|
+
overflow="hidden"
|
|
126
|
+
>
|
|
127
|
+
<AgentTree
|
|
128
|
+
workflows={workflows}
|
|
129
|
+
selectedId={selectedNode?.id ?? null}
|
|
130
|
+
flatNodes={flatNodes}
|
|
131
|
+
onSelect={(id) => {
|
|
132
|
+
const idx = flatNodes.findIndex((n) => n.id === id);
|
|
133
|
+
if (idx >= 0) handleSelect(idx);
|
|
134
|
+
}}
|
|
135
|
+
maxHeight={panelHeight}
|
|
136
|
+
/>
|
|
137
|
+
</Box>
|
|
138
|
+
|
|
139
|
+
{/* Right: details or messages */}
|
|
140
|
+
<Box
|
|
141
|
+
flexGrow={1}
|
|
142
|
+
width={rightWidth}
|
|
143
|
+
borderStyle="single"
|
|
144
|
+
borderColor={colors.border}
|
|
145
|
+
flexDirection="column"
|
|
146
|
+
overflow="hidden"
|
|
147
|
+
>
|
|
148
|
+
{/* Mode tab header */}
|
|
149
|
+
<Box paddingX={1} flexDirection="row">
|
|
150
|
+
<Text
|
|
151
|
+
color={rightMode === "stats" ? colors.accent : colors.textDim}
|
|
152
|
+
bold={rightMode === "stats"}
|
|
153
|
+
>
|
|
154
|
+
[Stats]
|
|
155
|
+
</Text>
|
|
156
|
+
<Text color={colors.textDim}> </Text>
|
|
157
|
+
<Text
|
|
158
|
+
color={rightMode === "messages" ? colors.accent : colors.textDim}
|
|
159
|
+
bold={rightMode === "messages"}
|
|
160
|
+
>
|
|
161
|
+
[Messages]
|
|
162
|
+
</Text>
|
|
163
|
+
<Box flexGrow={1} />
|
|
164
|
+
<Text color={colors.textDim}>Tab:switch</Text>
|
|
165
|
+
</Box>
|
|
166
|
+
|
|
167
|
+
{rightMode === "stats" ? (
|
|
168
|
+
<DetailsPanel workflow={selectedWorkflow} />
|
|
169
|
+
) : (
|
|
170
|
+
<MessagesPanel
|
|
171
|
+
session={selectedNode?.session ?? null}
|
|
172
|
+
scrollOffset={msgScrollOffset}
|
|
173
|
+
onScrollOffsetChange={setMsgScrollOffset}
|
|
174
|
+
maxHeight={panelHeight - 2}
|
|
175
|
+
/>
|
|
176
|
+
)}
|
|
177
|
+
</Box>
|
|
178
|
+
</Box>
|
|
179
|
+
|
|
180
|
+
<StatusBar
|
|
181
|
+
hints={
|
|
182
|
+
rightMode === "messages"
|
|
183
|
+
? "j/k:nav Tab:stats u/d:scroll 2:tools 3:overview r:refresh q:quit"
|
|
184
|
+
: "j/k:nav Tab:messages 2:tools 3:overview r:refresh q:quit"
|
|
185
|
+
}
|
|
186
|
+
/>
|
|
187
|
+
</Box>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const SessionsScreen = memo(SessionsScreenInner);
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import React, { useState, useMemo, useCallback, memo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { colors } from "../theme";
|
|
4
|
+
import { StatusBar } from "../components/StatusBar";
|
|
5
|
+
import type { Workflow, Interaction, MessagePart } from "../../core/types";
|
|
6
|
+
|
|
7
|
+
interface TimelineScreenProps {
|
|
8
|
+
workflows: Workflow[];
|
|
9
|
+
isActive: boolean;
|
|
10
|
+
contentHeight: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type TimelineLine =
|
|
14
|
+
| { kind: "session-header"; sessionId: string; title: string; agent: string | null; time: number | null }
|
|
15
|
+
| { kind: "interaction-header"; interactionId: string; modelId: string; time: number | null; agent: string | null }
|
|
16
|
+
| { kind: "tool-call"; part: MessagePart & { type: "tool" }; interactionId: string }
|
|
17
|
+
| { kind: "text-snippet"; text: string; interactionId: string }
|
|
18
|
+
| { kind: "reasoning-snippet"; text: string; interactionId: string }
|
|
19
|
+
| { kind: "spacer" };
|
|
20
|
+
|
|
21
|
+
function formatTime(ts: number | null): string {
|
|
22
|
+
if (!ts) return "??:??";
|
|
23
|
+
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildTimeline(workflows: Workflow[]): TimelineLine[] {
|
|
27
|
+
const lines: TimelineLine[] = [];
|
|
28
|
+
|
|
29
|
+
const allSessions = workflows.flatMap((w) => [
|
|
30
|
+
w.mainSession,
|
|
31
|
+
...w.subAgentSessions,
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
// Sort by timeCreated
|
|
35
|
+
allSessions.sort((a, b) => (a.timeCreated ?? 0) - (b.timeCreated ?? 0));
|
|
36
|
+
|
|
37
|
+
for (const session of allSessions) {
|
|
38
|
+
const agentName = session.interactions[0]?.agent ?? null;
|
|
39
|
+
lines.push({
|
|
40
|
+
kind: "session-header",
|
|
41
|
+
sessionId: session.id,
|
|
42
|
+
title: session.title ?? session.id.slice(0, 12),
|
|
43
|
+
agent: agentName,
|
|
44
|
+
time: session.timeCreated,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
for (const interaction of session.interactions) {
|
|
48
|
+
if (interaction.role !== "assistant") continue;
|
|
49
|
+
|
|
50
|
+
lines.push({
|
|
51
|
+
kind: "interaction-header",
|
|
52
|
+
interactionId: interaction.id,
|
|
53
|
+
modelId: interaction.modelId,
|
|
54
|
+
time: interaction.time.created,
|
|
55
|
+
agent: interaction.agent,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
for (const part of interaction.parts) {
|
|
59
|
+
if (part.type === "tool") {
|
|
60
|
+
lines.push({
|
|
61
|
+
kind: "tool-call",
|
|
62
|
+
part: part as MessagePart & { type: "tool" },
|
|
63
|
+
interactionId: interaction.id,
|
|
64
|
+
});
|
|
65
|
+
} else if (part.type === "text" && part.text.trim().length > 0) {
|
|
66
|
+
lines.push({
|
|
67
|
+
kind: "text-snippet",
|
|
68
|
+
text: part.text.slice(0, 120).replace(/\n/g, " "),
|
|
69
|
+
interactionId: interaction.id,
|
|
70
|
+
});
|
|
71
|
+
} else if (part.type === "reasoning" && part.text.trim().length > 0) {
|
|
72
|
+
lines.push({
|
|
73
|
+
kind: "reasoning-snippet",
|
|
74
|
+
text: part.text.slice(0, 100).replace(/\n/g, " "),
|
|
75
|
+
interactionId: interaction.id,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
lines.push({ kind: "spacer" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderLine(line: TimelineLine, idx: number): React.ReactElement {
|
|
88
|
+
switch (line.kind) {
|
|
89
|
+
case "session-header":
|
|
90
|
+
return (
|
|
91
|
+
<Box key={idx} flexDirection="row">
|
|
92
|
+
<Text color={colors.accent} bold>
|
|
93
|
+
▶ {truncate(line.title, 40)}
|
|
94
|
+
</Text>
|
|
95
|
+
{line.agent && <Text color={colors.cyan}> [{line.agent}]</Text>}
|
|
96
|
+
<Box flexGrow={1} />
|
|
97
|
+
<Text color={colors.textDim}>{formatTime(line.time)}</Text>
|
|
98
|
+
</Box>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
case "interaction-header":
|
|
102
|
+
return (
|
|
103
|
+
<Box key={idx} flexDirection="row">
|
|
104
|
+
<Text color={colors.textDim}> </Text>
|
|
105
|
+
<Text color={colors.purple}>◆ </Text>
|
|
106
|
+
<Text color={colors.info}>{truncate(line.modelId, 30)}</Text>
|
|
107
|
+
{line.agent && <Text color={colors.cyan}> [{line.agent}]</Text>}
|
|
108
|
+
<Box flexGrow={1} />
|
|
109
|
+
<Text color={colors.textDim}>{formatTime(line.time)}</Text>
|
|
110
|
+
</Box>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
case "tool-call": {
|
|
114
|
+
const p = line.part;
|
|
115
|
+
const statusIcon = p.status === "completed" ? "✓" : p.status === "error" ? "✗" : "◌";
|
|
116
|
+
const statusColor =
|
|
117
|
+
p.status === "completed" ? colors.success : p.status === "error" ? colors.error : colors.warning;
|
|
118
|
+
const durationMs = p.timeEnd > 0 && p.timeStart > 0 ? p.timeEnd - p.timeStart : 0;
|
|
119
|
+
const durationStr = durationMs > 0 ? ` ${(durationMs / 1000).toFixed(1)}s` : "";
|
|
120
|
+
return (
|
|
121
|
+
<Box key={idx} flexDirection="row">
|
|
122
|
+
<Text color={colors.textDim}> </Text>
|
|
123
|
+
<Text color={statusColor}>{statusIcon} </Text>
|
|
124
|
+
<Text color={colors.text}>{truncate(p.toolName, 20)}</Text>
|
|
125
|
+
{p.title && <Text color={colors.textDim}> {truncate(p.title, 25)}</Text>}
|
|
126
|
+
<Box flexGrow={1} />
|
|
127
|
+
<Text color={colors.textDim}>{durationStr}</Text>
|
|
128
|
+
</Box>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case "text-snippet":
|
|
133
|
+
return (
|
|
134
|
+
<Box key={idx} flexDirection="row">
|
|
135
|
+
<Text color={colors.textDim}> │ </Text>
|
|
136
|
+
<Text color={colors.text}>{truncate(line.text, 80)}</Text>
|
|
137
|
+
</Box>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
case "reasoning-snippet":
|
|
141
|
+
return (
|
|
142
|
+
<Box key={idx} flexDirection="row">
|
|
143
|
+
<Text color={colors.textDim}> ⚡ </Text>
|
|
144
|
+
<Text color={colors.accentDim}>{truncate(line.text, 80)}</Text>
|
|
145
|
+
</Box>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
case "spacer":
|
|
149
|
+
return <Box key={idx} />;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function TimelineScreenInner({ workflows, isActive, contentHeight }: TimelineScreenProps) {
|
|
154
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
155
|
+
|
|
156
|
+
const allLines = useMemo(() => buildTimeline(workflows), [workflows]);
|
|
157
|
+
|
|
158
|
+
const visibleHeight = contentHeight - 4; // header row + status bar + borders
|
|
159
|
+
|
|
160
|
+
const clampOffset = useCallback(
|
|
161
|
+
(offset: number) => Math.max(0, Math.min(offset, Math.max(0, allLines.length - visibleHeight))),
|
|
162
|
+
[allLines.length, visibleHeight]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
useInput(
|
|
166
|
+
(input, key) => {
|
|
167
|
+
if (key.upArrow || input === "k") {
|
|
168
|
+
setScrollOffset((o) => clampOffset(o - 1));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (key.downArrow || input === "j") {
|
|
172
|
+
setScrollOffset((o) => clampOffset(o + 1));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (input === "g") {
|
|
176
|
+
setScrollOffset(0);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (input === "G") {
|
|
180
|
+
setScrollOffset(clampOffset(allLines.length));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (key.pageUp) {
|
|
184
|
+
setScrollOffset((o) => clampOffset(o - visibleHeight));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (key.pageDown) {
|
|
188
|
+
setScrollOffset((o) => clampOffset(o + visibleHeight));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
{ isActive }
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const visibleLines = allLines.slice(scrollOffset, scrollOffset + visibleHeight);
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<Box flexDirection="column" height={contentHeight}>
|
|
199
|
+
<Box paddingX={1} flexDirection="row">
|
|
200
|
+
<Text color={colors.accent} bold>
|
|
201
|
+
Timeline
|
|
202
|
+
</Text>
|
|
203
|
+
<Box flexGrow={1} />
|
|
204
|
+
<Text color={colors.textDim}>
|
|
205
|
+
{scrollOffset + 1}-{Math.min(scrollOffset + visibleHeight, allLines.length)}/
|
|
206
|
+
{allLines.length}
|
|
207
|
+
</Text>
|
|
208
|
+
</Box>
|
|
209
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
210
|
+
{visibleLines.map((line, i) => renderLine(line, scrollOffset + i))}
|
|
211
|
+
</Box>
|
|
212
|
+
<StatusBar hints="j/k:scroll g/G:top/bottom PgUp/PgDn:page 1:sessions 3:tools 4:overview q:quit" />
|
|
213
|
+
</Box>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const TimelineScreen = memo(TimelineScreenInner);
|
|
218
|
+
|
|
219
|
+
function truncate(s: string, max: number): string {
|
|
220
|
+
if (s.length <= max) return s;
|
|
221
|
+
return s.slice(0, max - 1) + "…";
|
|
222
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import React, { useState, useMemo, memo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { colors } from "../theme";
|
|
4
|
+
import { StatusBar } from "../components/StatusBar";
|
|
5
|
+
import type { Workflow, ToolUsage } from "../../core/types";
|
|
6
|
+
import { getToolUsage } from "../../core/session";
|
|
7
|
+
|
|
8
|
+
interface ToolsScreenProps {
|
|
9
|
+
workflows: Workflow[];
|
|
10
|
+
isActive: boolean;
|
|
11
|
+
contentHeight: number;
|
|
12
|
+
terminalWidth: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type SortKey = "calls" | "failures" | "avgTime";
|
|
16
|
+
|
|
17
|
+
function aggregateToolUsage(workflows: Workflow[]): ToolUsage[] {
|
|
18
|
+
const merged = new Map<
|
|
19
|
+
string,
|
|
20
|
+
{ calls: number; successes: number; failures: number; totalDurationMs: number; recentErrors: string[] }
|
|
21
|
+
>();
|
|
22
|
+
|
|
23
|
+
for (const workflow of workflows) {
|
|
24
|
+
const allSessions = [workflow.mainSession, ...workflow.subAgentSessions];
|
|
25
|
+
for (const session of allSessions) {
|
|
26
|
+
for (const tool of getToolUsage(session)) {
|
|
27
|
+
const existing = merged.get(tool.name) ?? {
|
|
28
|
+
calls: 0,
|
|
29
|
+
successes: 0,
|
|
30
|
+
failures: 0,
|
|
31
|
+
totalDurationMs: 0,
|
|
32
|
+
recentErrors: [],
|
|
33
|
+
};
|
|
34
|
+
existing.calls += tool.calls;
|
|
35
|
+
existing.successes += tool.successes;
|
|
36
|
+
existing.failures += tool.failures;
|
|
37
|
+
existing.totalDurationMs += tool.totalDurationMs;
|
|
38
|
+
for (const err of tool.recentErrors) {
|
|
39
|
+
if (existing.recentErrors.length < 3) existing.recentErrors.push(err);
|
|
40
|
+
}
|
|
41
|
+
merged.set(tool.name, existing);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return Array.from(merged.entries()).map(([name, s]) => ({
|
|
47
|
+
name,
|
|
48
|
+
calls: s.calls,
|
|
49
|
+
successes: s.successes,
|
|
50
|
+
failures: s.failures,
|
|
51
|
+
totalDurationMs: s.totalDurationMs,
|
|
52
|
+
avgDurationMs: s.calls > 0 ? s.totalDurationMs / s.calls : 0,
|
|
53
|
+
recentErrors: s.recentErrors,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function SuccessBar({ successes, calls, width = 12 }: { successes: number; calls: number; width?: number }) {
|
|
58
|
+
const pct = calls > 0 ? successes / calls : 0;
|
|
59
|
+
const filled = Math.round(pct * width);
|
|
60
|
+
const empty = width - filled;
|
|
61
|
+
const color = pct >= 0.9 ? colors.success : pct >= 0.7 ? colors.warning : colors.error;
|
|
62
|
+
return (
|
|
63
|
+
<Text>
|
|
64
|
+
<Text color={color}>{"█".repeat(filled)}</Text>
|
|
65
|
+
<Text color={colors.border}>{"░".repeat(empty)}</Text>
|
|
66
|
+
<Text color={colors.textDim}> {Math.round(pct * 100)}%</Text>
|
|
67
|
+
</Text>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatDuration(ms: number): string {
|
|
72
|
+
if (ms === 0) return "—";
|
|
73
|
+
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
|
74
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ToolsScreenInner({ workflows, isActive, contentHeight, terminalWidth }: ToolsScreenProps) {
|
|
78
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
79
|
+
const [sortKey, setSortKey] = useState<SortKey>("calls");
|
|
80
|
+
|
|
81
|
+
const allTools = useMemo(() => aggregateToolUsage(workflows), [workflows]);
|
|
82
|
+
|
|
83
|
+
const sortedTools = useMemo(() => {
|
|
84
|
+
const copy = [...allTools];
|
|
85
|
+
switch (sortKey) {
|
|
86
|
+
case "calls":
|
|
87
|
+
return copy.sort((a, b) => b.calls - a.calls);
|
|
88
|
+
case "failures":
|
|
89
|
+
return copy.sort((a, b) => b.failures - a.failures);
|
|
90
|
+
case "avgTime":
|
|
91
|
+
return copy.sort((a, b) => b.avgDurationMs - a.avgDurationMs);
|
|
92
|
+
}
|
|
93
|
+
}, [allTools, sortKey]);
|
|
94
|
+
|
|
95
|
+
const listHeight = contentHeight - 4; // header + status bar + padding
|
|
96
|
+
const clampedIndex = Math.min(selectedIndex, Math.max(0, sortedTools.length - 1));
|
|
97
|
+
const selectedTool = sortedTools[clampedIndex] ?? null;
|
|
98
|
+
|
|
99
|
+
// Pagination
|
|
100
|
+
const startIndex = clampedIndex >= listHeight ? clampedIndex - listHeight + 1 : 0;
|
|
101
|
+
const visibleTools = sortedTools.slice(startIndex, startIndex + listHeight);
|
|
102
|
+
|
|
103
|
+
useInput(
|
|
104
|
+
(input, key) => {
|
|
105
|
+
if (key.upArrow || input === "k") {
|
|
106
|
+
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (key.downArrow || input === "j") {
|
|
110
|
+
setSelectedIndex((i) => Math.min(sortedTools.length - 1, i + 1));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key.tab) {
|
|
114
|
+
setSortKey((k) => {
|
|
115
|
+
if (k === "calls") return "failures";
|
|
116
|
+
if (k === "failures") return "avgTime";
|
|
117
|
+
return "calls";
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{ isActive }
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const sortLabels: Record<SortKey, string> = {
|
|
126
|
+
calls: "Calls",
|
|
127
|
+
failures: "Failures",
|
|
128
|
+
avgTime: "Avg Time",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Box flexDirection="column" width={terminalWidth} height={contentHeight}>
|
|
133
|
+
<Box paddingX={1} flexDirection="row">
|
|
134
|
+
<Text color={colors.accent} bold>
|
|
135
|
+
Tools
|
|
136
|
+
</Text>
|
|
137
|
+
<Box flexGrow={1} />
|
|
138
|
+
<Text color={colors.textDim}>
|
|
139
|
+
sort:{" "}
|
|
140
|
+
{(["calls", "failures", "avgTime"] as SortKey[]).map((k) => (
|
|
141
|
+
<Text key={k} color={sortKey === k ? colors.accent : colors.textDim}>
|
|
142
|
+
[{k === sortKey ? sortLabels[k] : k}]{" "}
|
|
143
|
+
</Text>
|
|
144
|
+
))}
|
|
145
|
+
Tab:cycle
|
|
146
|
+
</Text>
|
|
147
|
+
</Box>
|
|
148
|
+
|
|
149
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
150
|
+
{/* Left: tool list */}
|
|
151
|
+
<Box width={36} flexDirection="column" borderStyle="single" borderColor={colors.border}>
|
|
152
|
+
<Box paddingX={1} flexDirection="row">
|
|
153
|
+
<Text color={colors.textDim} bold>
|
|
154
|
+
Tool
|
|
155
|
+
</Text>
|
|
156
|
+
<Box flexGrow={1} />
|
|
157
|
+
<Text color={colors.textDim}>calls </Text>
|
|
158
|
+
<Text color={colors.textDim}>err</Text>
|
|
159
|
+
</Box>
|
|
160
|
+
{visibleTools.map((tool, i) => {
|
|
161
|
+
const isSelected = tool.name === selectedTool?.name;
|
|
162
|
+
return (
|
|
163
|
+
<Box key={tool.name} flexDirection="row" paddingX={1}>
|
|
164
|
+
<Text
|
|
165
|
+
color={isSelected ? colors.accent : colors.textDim}
|
|
166
|
+
bold={isSelected}
|
|
167
|
+
>
|
|
168
|
+
{isSelected ? "▶ " : " "}{truncate(tool.name, 20)}
|
|
169
|
+
</Text>
|
|
170
|
+
<Box flexGrow={1} />
|
|
171
|
+
<Text color={colors.info}>{tool.calls}</Text>
|
|
172
|
+
<Text color={colors.textDim}> </Text>
|
|
173
|
+
<Text color={tool.failures > 0 ? colors.error : colors.textDim}>{tool.failures}</Text>
|
|
174
|
+
</Box>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
{allTools.length === 0 && (
|
|
178
|
+
<Box paddingX={1}>
|
|
179
|
+
<Text color={colors.textDim}>No tool data yet</Text>
|
|
180
|
+
</Box>
|
|
181
|
+
)}
|
|
182
|
+
</Box>
|
|
183
|
+
|
|
184
|
+
{/* Right: detail panel */}
|
|
185
|
+
<Box flexGrow={1} flexDirection="column" borderStyle="single" borderColor={colors.border} paddingX={1}>
|
|
186
|
+
{selectedTool ? (
|
|
187
|
+
<>
|
|
188
|
+
<Text color={colors.cyan} bold>
|
|
189
|
+
{selectedTool.name}
|
|
190
|
+
</Text>
|
|
191
|
+
<Box marginTop={1} flexDirection="column">
|
|
192
|
+
<Box flexDirection="row">
|
|
193
|
+
<Box width={14}>
|
|
194
|
+
<Text color={colors.textDim}>Total calls</Text>
|
|
195
|
+
</Box>
|
|
196
|
+
<Text color={colors.text}>{selectedTool.calls}</Text>
|
|
197
|
+
</Box>
|
|
198
|
+
<Box flexDirection="row">
|
|
199
|
+
<Box width={14}>
|
|
200
|
+
<Text color={colors.textDim}>Successes</Text>
|
|
201
|
+
</Box>
|
|
202
|
+
<Text color={colors.success}>{selectedTool.successes}</Text>
|
|
203
|
+
</Box>
|
|
204
|
+
<Box flexDirection="row">
|
|
205
|
+
<Box width={14}>
|
|
206
|
+
<Text color={colors.textDim}>Failures</Text>
|
|
207
|
+
</Box>
|
|
208
|
+
<Text color={selectedTool.failures > 0 ? colors.error : colors.textDim}>
|
|
209
|
+
{selectedTool.failures}
|
|
210
|
+
</Text>
|
|
211
|
+
</Box>
|
|
212
|
+
<Box flexDirection="row">
|
|
213
|
+
<Box width={14}>
|
|
214
|
+
<Text color={colors.textDim}>Avg time</Text>
|
|
215
|
+
</Box>
|
|
216
|
+
<Text color={colors.info}>{formatDuration(selectedTool.avgDurationMs)}</Text>
|
|
217
|
+
</Box>
|
|
218
|
+
</Box>
|
|
219
|
+
|
|
220
|
+
<Box marginTop={1}>
|
|
221
|
+
<Text color={colors.textDim}>Success rate </Text>
|
|
222
|
+
<SuccessBar successes={selectedTool.successes} calls={selectedTool.calls} />
|
|
223
|
+
</Box>
|
|
224
|
+
|
|
225
|
+
{selectedTool.recentErrors.length > 0 && (
|
|
226
|
+
<>
|
|
227
|
+
<Box marginTop={1}>
|
|
228
|
+
<Text color={colors.warning} bold>
|
|
229
|
+
Recent Errors
|
|
230
|
+
</Text>
|
|
231
|
+
</Box>
|
|
232
|
+
{selectedTool.recentErrors.map((err, i) => (
|
|
233
|
+
<Box key={i} flexDirection="row">
|
|
234
|
+
<Text color={colors.error}>• </Text>
|
|
235
|
+
<Text color={colors.text}>{truncate(err, 60)}</Text>
|
|
236
|
+
</Box>
|
|
237
|
+
))}
|
|
238
|
+
</>
|
|
239
|
+
)}
|
|
240
|
+
</>
|
|
241
|
+
) : (
|
|
242
|
+
<Text color={colors.textDim}>No tools found. Run some OpenCode sessions first.</Text>
|
|
243
|
+
)}
|
|
244
|
+
</Box>
|
|
245
|
+
</Box>
|
|
246
|
+
|
|
247
|
+
<StatusBar
|
|
248
|
+
hints="j/k:nav Tab:cycle-sort 1:sessions 3:overview q:quit"
|
|
249
|
+
info={`${allTools.length} tools`}
|
|
250
|
+
/>
|
|
251
|
+
</Box>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const ToolsScreen = memo(ToolsScreenInner);
|
|
256
|
+
|
|
257
|
+
function truncate(s: string, max: number): string {
|
|
258
|
+
if (s.length <= max) return s;
|
|
259
|
+
return s.slice(0, max - 1) + "…";
|
|
260
|
+
}
|
package/src/ui/theme.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const colors = {
|
|
2
|
+
bg: "#1a1a2e",
|
|
3
|
+
bgSecondary: "#16213e",
|
|
4
|
+
border: "#0f3460",
|
|
5
|
+
accent: "#e94560",
|
|
6
|
+
accentDim: "#533483",
|
|
7
|
+
text: "#eaeaea",
|
|
8
|
+
textDim: "#888",
|
|
9
|
+
success: "#4ade80",
|
|
10
|
+
warning: "#fbbf24",
|
|
11
|
+
error: "#f87171",
|
|
12
|
+
info: "#60a5fa",
|
|
13
|
+
purple: "#a855f7",
|
|
14
|
+
cyan: "#22d3ee",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export const tokens = {
|
|
18
|
+
radius: 1,
|
|
19
|
+
paddingX: 1,
|
|
20
|
+
paddingY: 0,
|
|
21
|
+
} as const;
|