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.
@@ -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
+ }
@@ -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;