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,188 @@
1
+ import React, { useMemo, memo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { colors } from "../theme";
4
+ import type { Session, Interaction, MessagePart } from "../../core/types";
5
+
6
+ interface MessagesPanelProps {
7
+ session: Session | null;
8
+ scrollOffset: number;
9
+ onScrollOffsetChange: (offset: number) => void;
10
+ maxHeight: number;
11
+ }
12
+
13
+ type MsgLine =
14
+ | { kind: "interaction-header"; interaction: Interaction }
15
+ | { kind: "tool-call"; part: MessagePart & { type: "tool" } }
16
+ | { kind: "text"; text: string }
17
+ | { kind: "reasoning"; text: string }
18
+ | { kind: "spacer" };
19
+
20
+ function buildLines(session: Session): MsgLine[] {
21
+ const lines: MsgLine[] = [];
22
+ for (const interaction of session.interactions) {
23
+ if (interaction.role !== "assistant") continue;
24
+ lines.push({ kind: "interaction-header", interaction });
25
+ for (const part of interaction.parts) {
26
+ if (part.type === "tool") {
27
+ lines.push({ kind: "tool-call", part: part as MessagePart & { type: "tool" } });
28
+ } else if (part.type === "text" && part.text.trim()) {
29
+ // Split long text into display lines of ~wrap length
30
+ const wrapped = wrapText(part.text.trim(), 200);
31
+ for (const line of wrapped) {
32
+ lines.push({ kind: "text", text: line });
33
+ }
34
+ } else if (part.type === "reasoning" && part.text.trim()) {
35
+ const wrapped = wrapText(part.text.trim(), 200);
36
+ for (const line of wrapped) {
37
+ lines.push({ kind: "reasoning", text: line });
38
+ }
39
+ }
40
+ }
41
+ lines.push({ kind: "spacer" });
42
+ }
43
+ return lines;
44
+ }
45
+
46
+ function wrapText(text: string, maxLen: number): string[] {
47
+ const rawLines = text.split("\n");
48
+ const out: string[] = [];
49
+ for (const raw of rawLines) {
50
+ if (raw.length <= maxLen) {
51
+ out.push(raw);
52
+ } else {
53
+ for (let i = 0; i < raw.length; i += maxLen) {
54
+ out.push(raw.slice(i, i + maxLen));
55
+ }
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function formatTime(ts: number | null): string {
62
+ if (!ts) return "";
63
+ return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
64
+ }
65
+
66
+ function formatDuration(ms: number): string {
67
+ if (ms <= 0) return "";
68
+ if (ms < 1000) return `${ms}ms`;
69
+ return `${(ms / 1000).toFixed(1)}s`;
70
+ }
71
+
72
+ function renderLine(line: MsgLine, idx: number): React.ReactElement {
73
+ switch (line.kind) {
74
+ case "interaction-header": {
75
+ const { interaction: i } = line;
76
+ const dur =
77
+ i.time.completed && i.time.created
78
+ ? i.time.completed - i.time.created
79
+ : 0;
80
+ return (
81
+ <Box key={idx} flexDirection="row" marginTop={1}>
82
+ <Text color={colors.purple} bold>◆ </Text>
83
+ <Text color={colors.info}>{truncate(i.modelId, 28)}</Text>
84
+ {i.agent && <Text color={colors.cyan}> [{i.agent}]</Text>}
85
+ <Box flexGrow={1} />
86
+ {dur > 0 && <Text color={colors.textDim}>{formatDuration(dur)} </Text>}
87
+ <Text color={colors.textDim}>{formatTime(i.time.created)}</Text>
88
+ </Box>
89
+ );
90
+ }
91
+
92
+ case "tool-call": {
93
+ const p = line.part;
94
+ const statusIcon = p.status === "completed" ? "✓" : p.status === "error" ? "✗" : "◌";
95
+ const statusColor =
96
+ p.status === "completed" ? colors.success : p.status === "error" ? colors.error : colors.warning;
97
+ const dur = p.timeEnd > 0 && p.timeStart > 0 ? p.timeEnd - p.timeStart : 0;
98
+ const exitStr = p.exitCode !== null && p.exitCode !== 0 ? ` exit:${p.exitCode}` : "";
99
+ return (
100
+ <Box key={idx} flexDirection="column" paddingLeft={2}>
101
+ <Box flexDirection="row">
102
+ <Text color={statusColor}>{statusIcon} </Text>
103
+ <Text color={colors.text} bold>{truncate(p.toolName, 20)}</Text>
104
+ {p.title && <Text color={colors.textDim}> {truncate(p.title, 30)}</Text>}
105
+ <Box flexGrow={1} />
106
+ {exitStr && <Text color={colors.error}>{exitStr} </Text>}
107
+ {dur > 0 && <Text color={colors.textDim}>{formatDuration(dur)}</Text>}
108
+ </Box>
109
+ {/* Show output preview for errors or short outputs */}
110
+ {(p.status === "error" || (p.output && p.output.length < 200 && p.output.trim())) && (
111
+ <Box paddingLeft={2}>
112
+ <Text color={p.status === "error" ? colors.error : colors.textDim}>
113
+ {truncate(p.output.trim(), 120)}
114
+ </Text>
115
+ </Box>
116
+ )}
117
+ </Box>
118
+ );
119
+ }
120
+
121
+ case "text":
122
+ return (
123
+ <Box key={idx} paddingLeft={2}>
124
+ <Text color={colors.text}>{line.text}</Text>
125
+ </Box>
126
+ );
127
+
128
+ case "reasoning":
129
+ return (
130
+ <Box key={idx} paddingLeft={2}>
131
+ <Text color={colors.accentDim}>⚡ {line.text}</Text>
132
+ </Box>
133
+ );
134
+
135
+ case "spacer":
136
+ return <Box key={idx} />;
137
+ }
138
+ }
139
+
140
+ function MessagesPanelInner({
141
+ session,
142
+ scrollOffset,
143
+ maxHeight,
144
+ }: MessagesPanelProps) {
145
+ const allLines = useMemo(() => {
146
+ if (!session) return [];
147
+ return buildLines(session);
148
+ }, [session]);
149
+
150
+ if (!session) {
151
+ return (
152
+ <Box paddingX={1}>
153
+ <Text color={colors.textDim}>Select a session</Text>
154
+ </Box>
155
+ );
156
+ }
157
+
158
+ if (allLines.length === 0) {
159
+ return (
160
+ <Box paddingX={1}>
161
+ <Text color={colors.textDim}>No messages</Text>
162
+ </Box>
163
+ );
164
+ }
165
+
166
+ const clampedOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, allLines.length - maxHeight)));
167
+ const visibleLines = allLines.slice(clampedOffset, clampedOffset + maxHeight);
168
+
169
+ return (
170
+ <Box flexDirection="column" paddingX={1} overflow="hidden">
171
+ <Box flexDirection="row">
172
+ <Text color={colors.textDim}>
173
+ {clampedOffset + 1}–{Math.min(clampedOffset + maxHeight, allLines.length)}/{allLines.length}
174
+ </Text>
175
+ <Box flexGrow={1} />
176
+ <Text color={colors.textDim}>{session.interactions.length} turns</Text>
177
+ </Box>
178
+ {visibleLines.map((line, i) => renderLine(line, clampedOffset + i))}
179
+ </Box>
180
+ );
181
+ }
182
+
183
+ export const MessagesPanel = memo(MessagesPanelInner);
184
+
185
+ function truncate(s: string, max: number): string {
186
+ if (s.length <= max) return s;
187
+ return s.slice(0, max - 1) + "…";
188
+ }
@@ -0,0 +1,18 @@
1
+ import React, { memo } from "react";
2
+ import { Text } from "ink";
3
+ import { colors } from "../theme";
4
+ import { buildSparkSeries } from "../../core/session";
5
+
6
+ interface SparkLineProps {
7
+ values: number[];
8
+ color?: string;
9
+ width?: number;
10
+ }
11
+
12
+ function SparkLineInner({ values, color = colors.info, width }: SparkLineProps) {
13
+ const data = width && values.length > width ? values.slice(-width) : values;
14
+ const spark = buildSparkSeries(data);
15
+ return <Text color={color}>{spark}</Text>;
16
+ }
17
+
18
+ export const SparkLine = memo(SparkLineInner);
@@ -0,0 +1,24 @@
1
+ import React, { memo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { colors } from "../theme";
4
+
5
+ interface StatusBarProps {
6
+ hints: string;
7
+ info?: string;
8
+ }
9
+
10
+ function StatusBarInner({ hints, info }: StatusBarProps) {
11
+ return (
12
+ <Box paddingX={1} borderStyle="single" borderColor={colors.border} flexDirection="row">
13
+ <Text color={colors.textDim}>{hints}</Text>
14
+ {info && (
15
+ <>
16
+ <Box flexGrow={1} />
17
+ <Text color={colors.info}>{info}</Text>
18
+ </>
19
+ )}
20
+ </Box>
21
+ );
22
+ }
23
+
24
+ export const StatusBar = memo(StatusBarInner);
@@ -0,0 +1,42 @@
1
+ import React, { memo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { colors } from "../theme";
4
+ import type { ScreenId } from "../../core/types";
5
+
6
+ interface TabBarProps {
7
+ activeScreen: ScreenId;
8
+ lastRefresh: Date;
9
+ }
10
+
11
+ const TABS: { id: ScreenId; label: string; key: string }[] = [
12
+ { id: "sessions", label: "Sessions", key: "1" },
13
+ { id: "tools", label: "Tools", key: "2" },
14
+ { id: "overview", label: "Overview", key: "3" },
15
+ ];
16
+
17
+ function TabBarInner({ activeScreen, lastRefresh }: TabBarProps) {
18
+ return (
19
+ <Box paddingX={1} borderStyle="single" borderColor={colors.border} flexDirection="row">
20
+ <Text color={colors.accent} bold>
21
+ OCMonitor{" "}
22
+ </Text>
23
+ {TABS.map((tab) => {
24
+ const isActive = tab.id === activeScreen;
25
+ return (
26
+ <Box key={tab.id} marginRight={1}>
27
+ <Text
28
+ color={isActive ? colors.accent : colors.textDim}
29
+ bold={isActive}
30
+ >
31
+ [{tab.key}]{isActive ? <Text color={colors.text}> {tab.label}</Text> : ` ${tab.label}`}
32
+ </Text>
33
+ </Box>
34
+ );
35
+ })}
36
+ <Box flexGrow={1} />
37
+ <Text color={colors.textDim}>{lastRefresh.toLocaleTimeString()}</Text>
38
+ </Box>
39
+ );
40
+ }
41
+
42
+ export const TabBar = memo(TabBarInner);
@@ -0,0 +1,327 @@
1
+ import React, { useMemo, memo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { colors } from "../theme";
4
+ import { StatusBar } from "../components/StatusBar";
5
+ import { SparkLine } from "../components/SparkLine";
6
+ import type { Workflow } from "../../core/types";
7
+ import { computeOverviewStats, buildSparkSeries } from "../../core/session";
8
+ import { getAllPricing } from "../../data/pricing";
9
+
10
+ interface OverviewScreenProps {
11
+ workflows: Workflow[];
12
+ isActive: boolean;
13
+ contentHeight: number;
14
+ terminalWidth: 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 StatRow({ label, value, color = colors.text }: { label: string; value: string; color?: string }) {
24
+ return (
25
+ <Box flexDirection="row">
26
+ <Box width={18}>
27
+ <Text color={colors.textDim}>{label}</Text>
28
+ </Box>
29
+ <Text color={color}>{value}</Text>
30
+ </Box>
31
+ );
32
+ }
33
+
34
+ function SectionHeader({ title }: { title: string }) {
35
+ return (
36
+ <Box marginTop={1}>
37
+ <Text color={colors.purple} bold>{title}</Text>
38
+ </Box>
39
+ );
40
+ }
41
+
42
+ /** Horizontal bar: filled █ proportional to value/max, with label and count */
43
+ function HBar({
44
+ label,
45
+ value,
46
+ max,
47
+ total,
48
+ width = 16,
49
+ barColor = colors.info,
50
+ labelWidth = 10,
51
+ showPct = false,
52
+ }: {
53
+ label: string;
54
+ value: number;
55
+ max: number;
56
+ total?: number;
57
+ width?: number;
58
+ barColor?: string;
59
+ labelWidth?: number;
60
+ showPct?: boolean;
61
+ }) {
62
+ const pct = max > 0 ? value / max : 0;
63
+ const filled = Math.max(0, Math.round(pct * width));
64
+ const empty = width - filled;
65
+ const pctStr = showPct && total && total > 0 ? ` ${Math.round((value / total) * 100)}%` : "";
66
+ return (
67
+ <Box flexDirection="row">
68
+ <Box width={labelWidth}>
69
+ <Text color={colors.text}>{truncate(label, labelWidth - 1)}</Text>
70
+ </Box>
71
+ <Text color={barColor}>{"█".repeat(filled)}</Text>
72
+ <Text color={colors.border}>{"░".repeat(empty)}</Text>
73
+ <Text color={colors.textDim}> {value}{pctStr}</Text>
74
+ </Box>
75
+ );
76
+ }
77
+
78
+ /** Error rate bar: shows ok + error segments */
79
+ function ErrorBar({
80
+ label,
81
+ calls,
82
+ errors,
83
+ width = 16,
84
+ labelWidth = 8,
85
+ }: {
86
+ label: string;
87
+ calls: number;
88
+ errors: number;
89
+ width: number;
90
+ labelWidth: number;
91
+ }) {
92
+ const okCount = calls - errors;
93
+ const okFilled = calls > 0 ? Math.round((okCount / calls) * width) : width;
94
+ const errFilled = width - okFilled;
95
+ const errPct = calls > 0 ? Math.round((errors / calls) * 100) : 0;
96
+ return (
97
+ <Box flexDirection="row">
98
+ <Box width={labelWidth}>
99
+ <Text color={colors.text}>{truncate(label, labelWidth - 1)}</Text>
100
+ </Box>
101
+ <Text color={colors.success}>{"█".repeat(okFilled)}</Text>
102
+ <Text color={errors > 0 ? colors.error : colors.border}>{"█".repeat(errFilled)}</Text>
103
+ <Text color={colors.textDim}> {calls} calls</Text>
104
+ {errors > 0 && <Text color={colors.error}> {errors} err ({errPct}%)</Text>}
105
+ </Box>
106
+ );
107
+ }
108
+
109
+ function OverviewScreenInner({ workflows, isActive, contentHeight, terminalWidth }: OverviewScreenProps) {
110
+ const pricing = useMemo(() => getAllPricing(), []);
111
+ const stats = useMemo(() => computeOverviewStats(workflows, pricing), [workflows, pricing]);
112
+
113
+ const topModels = useMemo(() =>
114
+ Array.from(stats.modelBreakdown.entries())
115
+ .sort((a, b) => b[1].calls - a[1].calls)
116
+ .slice(0, 4),
117
+ [stats]
118
+ );
119
+
120
+ const topProjects = useMemo(() =>
121
+ Array.from(stats.projectBreakdown.entries())
122
+ .sort((a, b) => b[1].sessions - a[1].sessions)
123
+ .slice(0, 4),
124
+ [stats]
125
+ );
126
+
127
+ const agentToolErrorList = useMemo(() =>
128
+ Array.from(stats.agentToolErrors.entries())
129
+ .sort((a, b) => b[1].calls - a[1].calls),
130
+ [stats]
131
+ );
132
+
133
+ const topTools = useMemo(() =>
134
+ Array.from(stats.toolCallCounts.entries())
135
+ .sort((a, b) => b[1].calls - a[1].calls)
136
+ .slice(0, 8),
137
+ [stats]
138
+ );
139
+ const maxToolCalls = topTools[0]?.[1].calls ?? 1;
140
+
141
+ // Weekly sparklines
142
+ const weeklyTokenValues = stats.weeklyTokens.map((d) => d.tokens);
143
+ const weeklySessionValues = stats.weeklySessions.map((d) => d.sessions);
144
+ const maxWeeklyTokens = Math.max(...weeklyTokenValues, 1);
145
+ const maxWeeklySessions = Math.max(...weeklySessionValues, 1);
146
+
147
+ // Hourly heatmap — group into 4-hour buckets for compactness: 0-3,4-7,8-11,12-15,16-19,20-23
148
+ const hourlyBuckets = Array.from({ length: 6 }, (_, i) =>
149
+ stats.hourlyActivity.slice(i * 4, i * 4 + 4).reduce((a, b) => a + b, 0)
150
+ );
151
+ const hourSpark = buildSparkSeries(stats.hourlyActivity);
152
+
153
+ // Column widths based on terminal
154
+ const leftW = Math.max(32, Math.floor(terminalWidth * 0.38));
155
+ const midW = Math.max(26, Math.floor(terminalWidth * 0.30));
156
+ // right gets remaining
157
+
158
+ return (
159
+ <Box flexDirection="column" width={terminalWidth} height={contentHeight}>
160
+ <Box paddingX={1} flexDirection="row">
161
+ <Text color={colors.accent} bold>Overview</Text>
162
+ <Box flexGrow={1} />
163
+ <Text color={colors.textDim}>{workflows.length} workflows {stats.totalTokens.total > 0 ? formatTokens(stats.totalTokens.total) + " tokens" : ""}</Text>
164
+ </Box>
165
+
166
+ <Box flexDirection="row" flexGrow={1} paddingX={1}>
167
+
168
+ {/* ── LEFT COLUMN ─────────────────────────────────────── */}
169
+ <Box flexDirection="column" width={leftW}>
170
+
171
+ <SectionHeader title="Totals" />
172
+ <StatRow label="Total tokens" value={formatTokens(stats.totalTokens.total)} />
173
+ <StatRow label=" input" value={formatTokens(stats.totalTokens.input)} color={colors.info} />
174
+ <StatRow label=" output" value={formatTokens(stats.totalTokens.output)} color={colors.cyan} />
175
+ <StatRow label=" cache r/w" value={`${formatTokens(stats.totalTokens.cacheRead)} / ${formatTokens(stats.totalTokens.cacheWrite)}`} color={colors.textDim} />
176
+ <StatRow label="Total cost" value={`$${stats.totalCost.toFixed(4)}`} color={colors.success} />
177
+
178
+ <SectionHeader title="Token trend (7d)" />
179
+ <Box flexDirection="row">
180
+ <SparkLine values={weeklyTokenValues} color={colors.cyan} />
181
+ </Box>
182
+ <Box flexDirection="row">
183
+ <Text color={colors.textDim}>{stats.weeklyTokens[0]?.date.slice(3) ?? ""}</Text>
184
+ <Box flexGrow={1} />
185
+ <Text color={colors.textDim}>peak {formatTokens(maxWeeklyTokens)}</Text>
186
+ <Box flexGrow={1} />
187
+ <Text color={colors.textDim}>{stats.weeklyTokens[6]?.date.slice(3) ?? ""}</Text>
188
+ </Box>
189
+
190
+ <SectionHeader title="Sessions (7d)" />
191
+ <Box flexDirection="row">
192
+ <SparkLine values={weeklySessionValues} color={colors.accent} />
193
+ </Box>
194
+ <Box flexDirection="row">
195
+ <Text color={colors.textDim}>{stats.weeklySessions[0]?.date.slice(3) ?? ""}</Text>
196
+ <Box flexGrow={1} />
197
+ <Text color={colors.textDim}>peak {maxWeeklySessions}/day</Text>
198
+ <Box flexGrow={1} />
199
+ <Text color={colors.textDim}>{stats.weeklySessions[6]?.date.slice(3) ?? ""}</Text>
200
+ </Box>
201
+
202
+ <SectionHeader title="Hourly activity" />
203
+ <Text color={colors.warning}>{hourSpark}</Text>
204
+ <Box flexDirection="row">
205
+ <Text color={colors.textDim}>00</Text>
206
+ <Box flexGrow={1} />
207
+ <Text color={colors.textDim}>06</Text>
208
+ <Box flexGrow={1} />
209
+ <Text color={colors.textDim}>12</Text>
210
+ <Box flexGrow={1} />
211
+ <Text color={colors.textDim}>18</Text>
212
+ <Box flexGrow={1} />
213
+ <Text color={colors.textDim}>23</Text>
214
+ </Box>
215
+
216
+ </Box>
217
+
218
+ {/* ── MIDDLE COLUMN ───────────────────────────────────── */}
219
+ <Box flexDirection="column" width={midW} paddingLeft={2}>
220
+
221
+ <SectionHeader title="Tool errors by agent" />
222
+ {agentToolErrorList.length === 0
223
+ ? <Text color={colors.textDim}>No tool data</Text>
224
+ : agentToolErrorList.map(([agent, data]) => (
225
+ <ErrorBar
226
+ key={agent}
227
+ label={agent}
228
+ calls={data.calls}
229
+ errors={data.errors}
230
+ width={14}
231
+ labelWidth={9}
232
+ />
233
+ ))
234
+ }
235
+
236
+ <SectionHeader title="Top tools (calls / errors)" />
237
+ {topTools.length === 0
238
+ ? <Text color={colors.textDim}>No tool data</Text>
239
+ : topTools.map(([name, data]) => (
240
+ <Box key={name} flexDirection="row">
241
+ <Box width={10}>
242
+ <Text color={colors.text}>{truncate(name, 9)}</Text>
243
+ </Box>
244
+ <Text color={data.errors > 0 ? colors.warning : colors.info}>
245
+ {"█".repeat(Math.max(1, Math.round((data.calls / maxToolCalls) * 12)))}
246
+ </Text>
247
+ <Text color={colors.border}>
248
+ {"░".repeat(Math.max(0, 12 - Math.max(1, Math.round((data.calls / maxToolCalls) * 12))))}
249
+ </Text>
250
+ <Text color={colors.textDim}> {data.calls}</Text>
251
+ {data.errors > 0 && <Text color={colors.error}> ✗{data.errors}</Text>}
252
+ </Box>
253
+ ))
254
+ }
255
+
256
+ </Box>
257
+
258
+ {/* ── RIGHT COLUMN ────────────────────────────────────── */}
259
+ <Box flexDirection="column" flexGrow={1} paddingLeft={2}>
260
+
261
+ <SectionHeader title="Projects" />
262
+ {topProjects.length === 0
263
+ ? <Text color={colors.textDim}>No project data</Text>
264
+ : topProjects.map(([project, data]) => (
265
+ <Box key={project} flexDirection="row">
266
+ <Box width={22}>
267
+ <Text color={colors.text}>{truncate(project, 20)}</Text>
268
+ </Box>
269
+ <Text color={colors.info}>{data.sessions} sess</Text>
270
+ <Text color={colors.success}> ${data.cost.toFixed(3)}</Text>
271
+ </Box>
272
+ ))
273
+ }
274
+
275
+ <SectionHeader title="Tool avg duration (top 5)" />
276
+ {(() => {
277
+ const durList = Array.from(stats.toolCallCounts.entries())
278
+ .filter(([, d]) => d.calls > 0 && d.totalDurationMs > 0)
279
+ .map(([name, d]) => ({ name, avg: d.totalDurationMs / d.calls }))
280
+ .sort((a, b) => b.avg - a.avg)
281
+ .slice(0, 5);
282
+ const maxAvg = durList[0]?.avg ?? 1;
283
+ const barW = 10;
284
+ return durList.map(({ name, avg }) => {
285
+ const filled = Math.max(1, Math.round((avg / maxAvg) * barW));
286
+ const durStr = avg < 1000 ? `${avg.toFixed(0)}ms` : `${(avg / 1000).toFixed(1)}s`;
287
+ return (
288
+ <Box key={name} flexDirection="row">
289
+ <Box width={12}>
290
+ <Text color={colors.text}>{truncate(name, 10)}</Text>
291
+ </Box>
292
+ <Text color={colors.warning}>{"█".repeat(filled)}</Text>
293
+ <Text color={colors.border}>{"░".repeat(barW - filled)}</Text>
294
+ <Text color={colors.textDim}> {durStr}</Text>
295
+ </Box>
296
+ );
297
+ });
298
+ })()}
299
+
300
+ <SectionHeader title="Models" />
301
+ {topModels.length === 0
302
+ ? <Text color={colors.textDim}>No model data</Text>
303
+ : topModels.map(([model, data]) => (
304
+ <Box key={model} flexDirection="row">
305
+ <Box width={22}>
306
+ <Text color={colors.text}>{truncate(model, 20)}</Text>
307
+ </Box>
308
+ <Text color={colors.info}>{data.calls}</Text>
309
+ <Text color={colors.textDim}> {formatTokens(data.tokens)}</Text>
310
+ </Box>
311
+ ))
312
+ }
313
+
314
+ </Box>
315
+ </Box>
316
+
317
+ <StatusBar hints="1:sessions 2:tools r:refresh q:quit" />
318
+ </Box>
319
+ );
320
+ }
321
+
322
+ export const OverviewScreen = memo(OverviewScreenInner);
323
+
324
+ function truncate(s: string, max: number): string {
325
+ if (s.length <= max) return s;
326
+ return s.slice(0, max - 1) + "…";
327
+ }