@tokentop/ttop 0.2.2 → 0.4.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/README.md +2 -5
- package/package.json +8 -5
- package/src/agents/aggregator.ts +39 -19
- package/src/agents/costing.ts +11 -9
- package/src/agents/index.ts +7 -9
- package/src/agents/types.ts +2 -2
- package/src/cli.ts +28 -23
- package/src/config/schema.test.ts +42 -40
- package/src/config/schema.ts +26 -26
- package/src/demo/simulator.ts +161 -122
- package/src/plugins/agents/index.ts +2 -2
- package/src/plugins/auth-sources.ts +42 -38
- package/src/plugins/lifecycle.ts +42 -38
- package/src/plugins/loader.test.ts +49 -49
- package/src/plugins/loader.ts +67 -63
- package/src/plugins/notification-bus.ts +21 -16
- package/src/plugins/notifications/index.ts +2 -2
- package/src/plugins/notifications/terminal-bell.ts +36 -23
- package/src/plugins/notifications/visual-flash.ts +31 -19
- package/src/plugins/npm-installer.ts +23 -25
- package/src/plugins/plugin-context-factory.ts +10 -10
- package/src/plugins/plugin-host.ts +12 -5
- package/src/plugins/providers/anthropic.ts +64 -57
- package/src/plugins/providers/antigravity.ts +81 -69
- package/src/plugins/providers/codex.ts +42 -42
- package/src/plugins/providers/gemini.ts +106 -87
- package/src/plugins/providers/github-copilot.ts +49 -45
- package/src/plugins/providers/index.ts +10 -12
- package/src/plugins/providers/minimax.ts +50 -44
- package/src/plugins/providers/openai-api.ts +82 -62
- package/src/plugins/providers/opencode-zen.ts +46 -39
- package/src/plugins/providers/perplexity.ts +55 -46
- package/src/plugins/providers/zai.ts +68 -57
- package/src/plugins/registry.ts +80 -71
- package/src/plugins/sandbox-guard.test.ts +45 -48
- package/src/plugins/sandbox-guard.ts +20 -25
- package/src/plugins/sandbox.ts +26 -48
- package/src/plugins/themes/catppuccin-latte.ts +29 -29
- package/src/plugins/themes/catppuccin-mocha.ts +29 -29
- package/src/plugins/themes/claude-code.ts +29 -29
- package/src/plugins/themes/dracula.ts +29 -29
- package/src/plugins/themes/github-light.ts +29 -29
- package/src/plugins/themes/gruvbox-dark.ts +29 -29
- package/src/plugins/themes/gruvbox-light.ts +29 -29
- package/src/plugins/themes/index.ts +15 -15
- package/src/plugins/themes/kanagawa.ts +29 -29
- package/src/plugins/themes/nord.ts +29 -29
- package/src/plugins/themes/one-dark.ts +29 -29
- package/src/plugins/themes/opencode.ts +31 -31
- package/src/plugins/themes/rose-pine-dawn.ts +29 -29
- package/src/plugins/themes/rose-pine.ts +29 -29
- package/src/plugins/themes/solarized-light.ts +29 -29
- package/src/plugins/themes/tokyo-night.ts +29 -29
- package/src/plugins/types/agent.ts +5 -5
- package/src/plugins/types/base.ts +35 -24
- package/src/plugins/types/index.ts +9 -9
- package/src/plugins/types/notification.ts +12 -12
- package/src/plugins/types/provider.ts +12 -8
- package/src/plugins/types/theme.ts +31 -23
- package/src/plugins/update-checker.ts +6 -6
- package/src/pricing/estimator.test.ts +39 -44
- package/src/pricing/estimator.ts +7 -13
- package/src/pricing/fallback.test.ts +30 -34
- package/src/pricing/fallback.ts +54 -27
- package/src/pricing/index.ts +18 -12
- package/src/pricing/models-dev.ts +6 -6
- package/src/storage/database.ts +56 -46
- package/src/storage/db.ts +12 -12
- package/src/storage/index.ts +44 -45
- package/src/storage/migrations/index.ts +2 -2
- package/src/storage/paths.ts +13 -13
- package/src/storage/persistence-service.ts +206 -0
- package/src/storage/repos/agentSessions.ts +33 -23
- package/src/storage/repos/providerSnapshots.ts +5 -5
- package/src/storage/repos/usageEvents.ts +40 -28
- package/src/storage/retention.ts +146 -0
- package/src/storage/types.ts +5 -5
- package/src/tui/App.tsx +180 -137
- package/src/tui/components/CommandPalette.tsx +46 -34
- package/src/tui/components/DebugConsole.tsx +39 -33
- package/src/tui/components/DebugPanel.tsx +314 -183
- package/src/tui/components/FooterHints.tsx +2 -2
- package/src/tui/components/GhostProviderCard.tsx +37 -35
- package/src/tui/components/Header.tsx +68 -35
- package/src/tui/components/HelpOverlay.tsx +99 -29
- package/src/tui/components/InlineGauge.tsx +10 -7
- package/src/tui/components/InlineSparkline.tsx +17 -11
- package/src/tui/components/KpiStrip.tsx +128 -98
- package/src/tui/components/LimitGauge.tsx +96 -59
- package/src/tui/components/ModalBackdrop.tsx +4 -4
- package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
- package/src/tui/components/ProviderCard.tsx +232 -226
- package/src/tui/components/ProviderDetailPanel.tsx +133 -58
- package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
- package/src/tui/components/ProvidersList.tsx +215 -126
- package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
- package/src/tui/components/SessionsTable.tsx +320 -169
- package/src/tui/components/SettingsModal.tsx +593 -269
- package/src/tui/components/SidebarBreakdown.tsx +30 -16
- package/src/tui/components/Skeleton.tsx +24 -17
- package/src/tui/components/SmartSidebar.tsx +360 -258
- package/src/tui/components/Sparkline.tsx +64 -52
- package/src/tui/components/Spinner.tsx +3 -3
- package/src/tui/components/StatusBar.tsx +27 -27
- package/src/tui/components/ThemePicker.tsx +67 -66
- package/src/tui/components/Toast.tsx +9 -6
- package/src/tui/components/UsageGauge.tsx +10 -13
- package/src/tui/components/index.ts +5 -5
- package/src/tui/contexts/AgentSessionContext.tsx +164 -151
- package/src/tui/contexts/ConfigContext.tsx +15 -13
- package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
- package/src/tui/contexts/DemoModeContext.tsx +22 -15
- package/src/tui/contexts/DrawerContext.tsx +11 -9
- package/src/tui/contexts/InputContext.tsx +2 -2
- package/src/tui/contexts/LogContext.tsx +62 -47
- package/src/tui/contexts/PluginContext.tsx +238 -183
- package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
- package/src/tui/contexts/StorageContext.tsx +95 -149
- package/src/tui/contexts/ThemeContext.tsx +21 -16
- package/src/tui/contexts/TimeWindowContext.tsx +72 -61
- package/src/tui/contexts/ToastContext.tsx +10 -7
- package/src/tui/contexts/index.ts +9 -5
- package/src/tui/createApp.tsx +8 -12
- package/src/tui/debug/captureFrame.ts +25 -28
- package/src/tui/debug/snapshot.tsx +179 -162
- package/src/tui/driver/assertions.ts +30 -22
- package/src/tui/driver/cli.ts +231 -221
- package/src/tui/driver/coverage.ts +27 -44
- package/src/tui/driver/demo-snapshot.ts +33 -33
- package/src/tui/driver/diff.ts +26 -32
- package/src/tui/driver/driver.ts +43 -48
- package/src/tui/driver/index.ts +36 -40
- package/src/tui/driver/recorder.ts +37 -48
- package/src/tui/driver/test-workflow.ts +51 -51
- package/src/tui/driver/test.ts +25 -25
- package/src/tui/hooks/index.ts +24 -20
- package/src/tui/hooks/useAnimatedValue.ts +13 -18
- package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
- package/src/tui/hooks/useDashboardState.ts +26 -24
- package/src/tui/hooks/useEmaActivity.ts +82 -74
- package/src/tui/hooks/useEntranceAnimation.ts +11 -11
- package/src/tui/hooks/useExitAnimation.ts +7 -3
- package/src/tui/hooks/usePulse.ts +4 -4
- package/src/tui/hooks/useSafeRenderer.ts +5 -5
- package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
- package/src/tui/hooks/useValueFlash.ts +10 -14
- package/src/tui/index.tsx +29 -6
- package/src/tui/shutdown.ts +19 -0
- package/src/tui/utils/providerColor.ts +1 -1
- package/src/tui/views/Dashboard.tsx +164 -102
- package/src/tui/views/HistoricalTrendsView.tsx +579 -250
- package/src/tui/views/ProjectsView.tsx +495 -243
- package/src/tui/views/RealTimeDashboard.tsx +176 -116
- package/src/tui/views/SettingsView.tsx +225 -189
- package/src/tui/views/index.ts +2 -2
- package/src/types/opentui-test-utils.d.ts +7 -7
- package/src/utils/clipboard.ts +29 -29
- package/src/utils/currency.test.ts +53 -53
- package/src/utils/currency.ts +35 -32
- package/src/version.ts +1 -1
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
2
|
+
import { RGBA } from "@opentui/core";
|
|
3
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
4
|
+
import type React from "react";
|
|
5
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import type { AgentSessionAggregate } from "../../agents/types.ts";
|
|
7
|
+
import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
|
|
8
|
+
import { useInputFocus } from "../contexts/InputContext.tsx";
|
|
9
|
+
import { useColors } from "../contexts/ThemeContext.tsx";
|
|
10
|
+
import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
|
|
9
11
|
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
13
|
// Types
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
13
15
|
|
|
14
|
-
type SortField =
|
|
16
|
+
type SortField = "cost" | "tokens" | "sessions" | "cache" | "cpr" | "activity";
|
|
15
17
|
|
|
16
18
|
interface ModelStats {
|
|
17
19
|
modelId: string;
|
|
@@ -54,17 +56,17 @@ const OVERLAY_BG = RGBA.fromValues(0.0, 0.0, 0.0, 0.5);
|
|
|
54
56
|
|
|
55
57
|
function padRight(str: string, len: number): string {
|
|
56
58
|
if (str.length >= len) return str.slice(0, len);
|
|
57
|
-
return str +
|
|
59
|
+
return str + " ".repeat(len - str.length);
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
function padLeft(str: string, len: number): string {
|
|
61
63
|
if (str.length >= len) return str.slice(0, len);
|
|
62
|
-
return
|
|
64
|
+
return " ".repeat(len - str.length) + str;
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
function formatCost(num: number): string {
|
|
66
|
-
if (num === 0) return
|
|
67
|
-
if (num < 0.01) return
|
|
68
|
+
if (num === 0) return "$0";
|
|
69
|
+
if (num < 0.01) return "<$0.01";
|
|
68
70
|
if (num < 10) return `$${num.toFixed(2)}`;
|
|
69
71
|
if (num < 100) return `$${num.toFixed(1)}`;
|
|
70
72
|
if (num < 1000) return `$${Math.round(num)}`;
|
|
@@ -78,7 +80,7 @@ function formatTokensCompact(num: number): string {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
function formatPercent(val: number): string {
|
|
81
|
-
if (val < 1 && val > 0) return
|
|
83
|
+
if (val < 1 && val > 0) return "<1%";
|
|
82
84
|
return `${Math.round(val)}%`;
|
|
83
85
|
}
|
|
84
86
|
|
|
@@ -96,14 +98,23 @@ function formatRelativeTime(timestamp: number): string {
|
|
|
96
98
|
|
|
97
99
|
function truncate(str: string, maxLen: number): string {
|
|
98
100
|
if (str.length <= maxLen) return str;
|
|
99
|
-
return str.slice(0, maxLen - 1) +
|
|
101
|
+
return str.slice(0, maxLen - 1) + "\u2026";
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
// Sparkline characters for 5-char mini trend
|
|
103
|
-
const SPARK_CHARS = [
|
|
105
|
+
const SPARK_CHARS = [
|
|
106
|
+
"\u2581",
|
|
107
|
+
"\u2582",
|
|
108
|
+
"\u2583",
|
|
109
|
+
"\u2584",
|
|
110
|
+
"\u2585",
|
|
111
|
+
"\u2586",
|
|
112
|
+
"\u2587",
|
|
113
|
+
"\u2588",
|
|
114
|
+
];
|
|
104
115
|
|
|
105
116
|
function miniSparkline(values: number[], width: number): string {
|
|
106
|
-
if (values.length === 0) return
|
|
117
|
+
if (values.length === 0) return "\u00B7".repeat(width);
|
|
107
118
|
// Bucket into `width` slots
|
|
108
119
|
const bucketSize = Math.max(1, Math.ceil(values.length / width));
|
|
109
120
|
const buckets: number[] = [];
|
|
@@ -116,17 +127,17 @@ function miniSparkline(values: number[], width: number): string {
|
|
|
116
127
|
const maxVal = Math.max(...buckets, 1);
|
|
117
128
|
return buckets
|
|
118
129
|
.map((v) => {
|
|
119
|
-
if (v === 0) return
|
|
130
|
+
if (v === 0) return "\u00B7";
|
|
120
131
|
const idx = Math.min(Math.floor((v / maxVal) * SPARK_CHARS.length), SPARK_CHARS.length - 1);
|
|
121
132
|
return SPARK_CHARS[idx]!;
|
|
122
133
|
})
|
|
123
|
-
.join(
|
|
134
|
+
.join("");
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
function makeBar(percent: number, width: number): string {
|
|
127
138
|
const clamped = Math.max(0, Math.min(100, percent));
|
|
128
139
|
const filled = Math.round((clamped / 100) * width);
|
|
129
|
-
return
|
|
140
|
+
return "\u2588".repeat(filled) + "\u00B7".repeat(width - filled);
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
// ---------------------------------------------------------------------------
|
|
@@ -139,15 +150,14 @@ function aggregateProjects(
|
|
|
139
150
|
): ProjectStats[] {
|
|
140
151
|
const statsMap = new Map<string, ProjectStats>();
|
|
141
152
|
|
|
142
|
-
const filtered =
|
|
143
|
-
? sessions.filter((s) => s.lastActivityAt >= windowStart)
|
|
144
|
-
: sessions;
|
|
153
|
+
const filtered =
|
|
154
|
+
windowStart !== null ? sessions.filter((s) => s.lastActivityAt >= windowStart) : sessions;
|
|
145
155
|
|
|
146
156
|
for (const session of filtered) {
|
|
147
|
-
const path = session.projectPath ||
|
|
157
|
+
const path = session.projectPath || "Unknown";
|
|
148
158
|
const name = session.projectPath
|
|
149
|
-
? session.projectPath.split(
|
|
150
|
-
:
|
|
159
|
+
? session.projectPath.split("/").pop() || session.projectPath
|
|
160
|
+
: "Unknown";
|
|
151
161
|
|
|
152
162
|
let project = statsMap.get(path);
|
|
153
163
|
if (!project) {
|
|
@@ -172,7 +182,7 @@ function aggregateProjects(
|
|
|
172
182
|
}
|
|
173
183
|
|
|
174
184
|
project.sessionCount += 1;
|
|
175
|
-
if (session.status ===
|
|
185
|
+
if (session.status === "active") project.activeSessions += 1;
|
|
176
186
|
project.inputTokens += session.totals.input;
|
|
177
187
|
project.outputTokens += session.totals.output;
|
|
178
188
|
project.cacheRead += session.totals.cacheRead ?? 0;
|
|
@@ -257,37 +267,43 @@ function aggregateProjects(
|
|
|
257
267
|
// Sort fields cycle
|
|
258
268
|
// ---------------------------------------------------------------------------
|
|
259
269
|
|
|
260
|
-
const SORT_FIELDS: SortField[] = [
|
|
270
|
+
const SORT_FIELDS: SortField[] = ["cost", "tokens", "sessions", "cache", "cpr", "activity"];
|
|
261
271
|
|
|
262
272
|
const SORT_LABELS: Record<SortField, string> = {
|
|
263
|
-
cost:
|
|
264
|
-
tokens:
|
|
265
|
-
sessions:
|
|
266
|
-
cache:
|
|
267
|
-
cpr:
|
|
268
|
-
activity:
|
|
273
|
+
cost: "COST",
|
|
274
|
+
tokens: "TOKENS",
|
|
275
|
+
sessions: "SESSIONS",
|
|
276
|
+
cache: "CACHE",
|
|
277
|
+
cpr: "$/REQ",
|
|
278
|
+
activity: "ACTIVITY",
|
|
269
279
|
};
|
|
270
280
|
|
|
271
|
-
function sortProjects(
|
|
281
|
+
function sortProjects(
|
|
282
|
+
projects: ProjectStats[],
|
|
283
|
+
field: SortField,
|
|
284
|
+
_totalCost?: number,
|
|
285
|
+
): ProjectStats[] {
|
|
272
286
|
return [...projects].sort((a, b) => {
|
|
273
287
|
switch (field) {
|
|
274
|
-
case
|
|
288
|
+
case "cost":
|
|
275
289
|
return b.cost - a.cost;
|
|
276
|
-
case
|
|
290
|
+
case "tokens":
|
|
277
291
|
return b.tokens - a.tokens;
|
|
278
|
-
case
|
|
292
|
+
case "sessions":
|
|
279
293
|
return b.sessionCount - a.sessionCount;
|
|
280
|
-
case
|
|
281
|
-
const cacheA =
|
|
282
|
-
|
|
294
|
+
case "cache": {
|
|
295
|
+
const cacheA =
|
|
296
|
+
a.inputTokens + a.cacheRead > 0 ? a.cacheRead / (a.inputTokens + a.cacheRead) : 0;
|
|
297
|
+
const cacheB =
|
|
298
|
+
b.inputTokens + b.cacheRead > 0 ? b.cacheRead / (b.inputTokens + b.cacheRead) : 0;
|
|
283
299
|
return cacheB - cacheA;
|
|
284
300
|
}
|
|
285
|
-
case
|
|
301
|
+
case "cpr": {
|
|
286
302
|
const cprA = a.requestCount > 0 ? a.cost / a.requestCount : 0;
|
|
287
303
|
const cprB = b.requestCount > 0 ? b.cost / b.requestCount : 0;
|
|
288
304
|
return cprB - cprA;
|
|
289
305
|
}
|
|
290
|
-
case
|
|
306
|
+
case "activity":
|
|
291
307
|
return b.activeSessions - a.activeSessions;
|
|
292
308
|
default:
|
|
293
309
|
return 0;
|
|
@@ -307,27 +323,27 @@ interface ColumnSpec {
|
|
|
307
323
|
}
|
|
308
324
|
|
|
309
325
|
const COLUMNS: ColumnSpec[] = [
|
|
310
|
-
{ key:
|
|
311
|
-
{ key:
|
|
312
|
-
{ key:
|
|
313
|
-
{ key:
|
|
314
|
-
{ key:
|
|
315
|
-
{ key:
|
|
316
|
-
{ key:
|
|
317
|
-
{ key:
|
|
318
|
-
{ key:
|
|
326
|
+
{ key: "project", label: "PROJECT", width: 20, minTermWidth: 0 },
|
|
327
|
+
{ key: "cost", label: "COST", width: 8, minTermWidth: 0 },
|
|
328
|
+
{ key: "share", label: "SHARE", width: 6, minTermWidth: 80 },
|
|
329
|
+
{ key: "trend", label: "TREND", width: 7, minTermWidth: 90 },
|
|
330
|
+
{ key: "sess", label: "SESS", width: 7, minTermWidth: 0 },
|
|
331
|
+
{ key: "cache", label: "CACHE", width: 7, minTermWidth: 100 },
|
|
332
|
+
{ key: "cpr", label: "$/REQ", width: 8, minTermWidth: 110 },
|
|
333
|
+
{ key: "topmodel", label: "TOP MODEL", width: 18, minTermWidth: 120 },
|
|
334
|
+
{ key: "activity", label: "ACTIVITY", width: 8, minTermWidth: 70 },
|
|
319
335
|
];
|
|
320
336
|
|
|
321
337
|
function getVisibleColumns(termWidth: number): ColumnSpec[] {
|
|
322
338
|
return COLUMNS.filter((c) => termWidth >= c.minTermWidth).map((c) => {
|
|
323
|
-
if (c.key ===
|
|
339
|
+
if (c.key === "project") {
|
|
324
340
|
let w = 20;
|
|
325
341
|
if (termWidth >= 160) w = 40;
|
|
326
342
|
else if (termWidth >= 120) w = 30;
|
|
327
343
|
else if (termWidth >= 100) w = 25;
|
|
328
344
|
return { ...c, width: w };
|
|
329
345
|
}
|
|
330
|
-
if (c.key ===
|
|
346
|
+
if (c.key === "topmodel" && termWidth >= 160) {
|
|
331
347
|
return { ...c, width: 24 };
|
|
332
348
|
}
|
|
333
349
|
return c;
|
|
@@ -347,7 +363,14 @@ interface AggregateHeaderProps {
|
|
|
347
363
|
sortField: SortField;
|
|
348
364
|
}
|
|
349
365
|
|
|
350
|
-
function AggregateHeader({
|
|
366
|
+
function AggregateHeader({
|
|
367
|
+
totalCost,
|
|
368
|
+
globalCost,
|
|
369
|
+
projectCount,
|
|
370
|
+
sessionCount,
|
|
371
|
+
windowLabel,
|
|
372
|
+
sortField,
|
|
373
|
+
}: AggregateHeaderProps) {
|
|
351
374
|
const colors = useColors();
|
|
352
375
|
const avgPerProject = projectCount > 0 ? totalCost / projectCount : 0;
|
|
353
376
|
const isFiltered = globalCost !== undefined && globalCost !== totalCost;
|
|
@@ -355,14 +378,18 @@ function AggregateHeader({ totalCost, globalCost, projectCount, sessionCount, wi
|
|
|
355
378
|
return (
|
|
356
379
|
<box flexDirection="row" height={1} justifyContent="space-between" paddingX={1}>
|
|
357
380
|
<text height={1}>
|
|
358
|
-
<span fg={colors.success}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
381
|
+
<span fg={colors.success}>
|
|
382
|
+
<strong>{formatCost(totalCost)}</strong>
|
|
383
|
+
</span>
|
|
384
|
+
{isFiltered ? (
|
|
385
|
+
<span fg={colors.textSubtle}> of {formatCost(globalCost)} </span>
|
|
386
|
+
) : (
|
|
387
|
+
<span fg={colors.textMuted}> total </span>
|
|
388
|
+
)}
|
|
362
389
|
<span fg={colors.text}>{projectCount}</span>
|
|
363
|
-
<span fg={colors.textMuted}> projects
|
|
390
|
+
<span fg={colors.textMuted}> projects </span>
|
|
364
391
|
<span fg={colors.text}>{sessionCount}</span>
|
|
365
|
-
<span fg={colors.textMuted}> sessions
|
|
392
|
+
<span fg={colors.textMuted}> sessions Avg $/proj: </span>
|
|
366
393
|
<span fg={colors.text}>{formatCost(avgPerProject)}</span>
|
|
367
394
|
</text>
|
|
368
395
|
<text height={1} fg={colors.textMuted}>
|
|
@@ -384,23 +411,23 @@ function ProjectRow({ project, isSelected, totalCost, visibleColumns }: ProjectR
|
|
|
384
411
|
const fg = isSelected ? colors.primary : colors.text;
|
|
385
412
|
const mutedFg = isSelected ? colors.primary : colors.textMuted;
|
|
386
413
|
const subtleFg = isSelected ? colors.primary : colors.textSubtle;
|
|
387
|
-
const railChar = isSelected ?
|
|
414
|
+
const railChar = isSelected ? "\u258C" : " ";
|
|
388
415
|
|
|
389
416
|
const share = totalCost > 0 ? (project.cost / totalCost) * 100 : 0;
|
|
390
417
|
const cacheTotal = project.inputTokens + project.cacheRead;
|
|
391
418
|
const cacheRate = cacheTotal > 0 ? (project.cacheRead / cacheTotal) * 100 : 0;
|
|
392
419
|
const costPerReq = project.requestCount > 0 ? project.cost / project.requestCount : 0;
|
|
393
420
|
const topModel = project.models[0];
|
|
394
|
-
const topModelName = topModel ? (topModel.modelId.split(
|
|
395
|
-
const topModelShare =
|
|
396
|
-
? Math.round((topModel.cost / project.cost) * 100)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const colRenderers: Record<string, (w: number) => React.
|
|
421
|
+
const topModelName = topModel ? (topModel.modelId.split("/").pop() ?? topModel.modelId) : "-";
|
|
422
|
+
const topModelShare =
|
|
423
|
+
topModel && project.cost > 0 ? Math.round((topModel.cost / project.cost) * 100) : 0;
|
|
424
|
+
const activityDots =
|
|
425
|
+
project.activeSessions > 0
|
|
426
|
+
? "\u25CF".repeat(Math.min(project.activeSessions, 5)) +
|
|
427
|
+
(project.activeSessions > 5 ? `+${project.activeSessions - 5}` : "")
|
|
428
|
+
: "\u25CB";
|
|
429
|
+
|
|
430
|
+
const colRenderers: Record<string, (w: number) => React.ReactNode> = {
|
|
404
431
|
project: (w) => (
|
|
405
432
|
<text width={w} height={1} fg={fg}>
|
|
406
433
|
{padRight(truncate(project.name, w - 1), w)}
|
|
@@ -418,7 +445,8 @@ function ProjectRow({ project, isSelected, totalCost, visibleColumns }: ProjectR
|
|
|
418
445
|
),
|
|
419
446
|
trend: (w) => (
|
|
420
447
|
<text width={w} height={1} fg={subtleFg}>
|
|
421
|
-
{
|
|
448
|
+
{" "}
|
|
449
|
+
{miniSparkline(project.trend, 5)}{" "}
|
|
422
450
|
</text>
|
|
423
451
|
),
|
|
424
452
|
sess: (w) => (
|
|
@@ -427,41 +455,54 @@ function ProjectRow({ project, isSelected, totalCost, visibleColumns }: ProjectR
|
|
|
427
455
|
</text>
|
|
428
456
|
),
|
|
429
457
|
cache: (w) => (
|
|
430
|
-
<text
|
|
431
|
-
{
|
|
458
|
+
<text
|
|
459
|
+
width={w}
|
|
460
|
+
height={1}
|
|
461
|
+
fg={cacheRate >= 50 ? (isSelected ? colors.primary : colors.success) : mutedFg}
|
|
462
|
+
>
|
|
463
|
+
{padLeft(cacheRate > 0 ? formatPercent(cacheRate) : "-", w)}
|
|
432
464
|
</text>
|
|
433
465
|
),
|
|
434
466
|
cpr: (w) => (
|
|
435
467
|
<text width={w} height={1} fg={mutedFg}>
|
|
436
|
-
{padLeft(costPerReq > 0 ? formatCost(costPerReq) :
|
|
468
|
+
{padLeft(costPerReq > 0 ? formatCost(costPerReq) : "-", w)}
|
|
437
469
|
</text>
|
|
438
470
|
),
|
|
439
471
|
topmodel: (w) => {
|
|
440
472
|
const nameW = w - 5;
|
|
441
473
|
return (
|
|
442
474
|
<text width={w} height={1} fg={subtleFg}>
|
|
443
|
-
{padRight(
|
|
444
|
-
topModel
|
|
445
|
-
? `${truncate(topModelName, nameW)} ${topModelShare}%`
|
|
446
|
-
: '-',
|
|
447
|
-
w,
|
|
448
|
-
)}
|
|
475
|
+
{padRight(topModel ? `${truncate(topModelName, nameW)} ${topModelShare}%` : "-", w)}
|
|
449
476
|
</text>
|
|
450
477
|
);
|
|
451
478
|
},
|
|
452
479
|
activity: (w) => (
|
|
453
|
-
<text
|
|
480
|
+
<text
|
|
481
|
+
width={w}
|
|
482
|
+
height={1}
|
|
483
|
+
fg={project.activeSessions > 0 ? (isSelected ? colors.primary : colors.success) : mutedFg}
|
|
484
|
+
>
|
|
454
485
|
{padRight(activityDots, w)}
|
|
455
486
|
</text>
|
|
456
487
|
),
|
|
457
488
|
};
|
|
458
489
|
|
|
459
490
|
return (
|
|
460
|
-
<box
|
|
461
|
-
|
|
491
|
+
<box
|
|
492
|
+
flexDirection="row"
|
|
493
|
+
height={1}
|
|
494
|
+
{...(isSelected ? { backgroundColor: colors.borderMuted } : {})}
|
|
495
|
+
>
|
|
496
|
+
<text width={2} height={1} fg={isSelected ? colors.primary : colors.textSubtle}>
|
|
497
|
+
{railChar}{" "}
|
|
498
|
+
</text>
|
|
462
499
|
{visibleColumns.map((col) => {
|
|
463
500
|
const renderer = colRenderers[col.key];
|
|
464
|
-
return renderer ?
|
|
501
|
+
return renderer ? (
|
|
502
|
+
<box key={col.key} marginRight={1}>
|
|
503
|
+
{renderer(col.width)}
|
|
504
|
+
</box>
|
|
505
|
+
) : null;
|
|
465
506
|
})}
|
|
466
507
|
</box>
|
|
467
508
|
);
|
|
@@ -484,10 +525,10 @@ function SummaryFooter({ projects, visibleColumns }: SummaryFooterProps) {
|
|
|
484
525
|
const cacheRate = cacheTotal > 0 ? (totalCacheRead / cacheTotal) * 100 : 0;
|
|
485
526
|
const avgCpr = totalRequests > 0 ? totalCost / totalRequests : 0;
|
|
486
527
|
|
|
487
|
-
const colRenderers: Record<string, (w: number) => React.
|
|
528
|
+
const colRenderers: Record<string, (w: number) => React.ReactNode> = {
|
|
488
529
|
project: (w) => (
|
|
489
530
|
<text width={w} height={1} fg={colors.textMuted}>
|
|
490
|
-
{padRight(
|
|
531
|
+
{padRight("TOTAL", w)}
|
|
491
532
|
</text>
|
|
492
533
|
),
|
|
493
534
|
cost: (w) => (
|
|
@@ -497,12 +538,12 @@ function SummaryFooter({ projects, visibleColumns }: SummaryFooterProps) {
|
|
|
497
538
|
),
|
|
498
539
|
share: (w) => (
|
|
499
540
|
<text width={w} height={1} fg={colors.textMuted}>
|
|
500
|
-
{padLeft(
|
|
541
|
+
{padLeft("100%", w)}
|
|
501
542
|
</text>
|
|
502
543
|
),
|
|
503
544
|
trend: (w) => (
|
|
504
545
|
<text width={w} height={1} fg={colors.textSubtle}>
|
|
505
|
-
{
|
|
546
|
+
{" ".repeat(w)}
|
|
506
547
|
</text>
|
|
507
548
|
),
|
|
508
549
|
sess: (w) => (
|
|
@@ -522,22 +563,28 @@ function SummaryFooter({ projects, visibleColumns }: SummaryFooterProps) {
|
|
|
522
563
|
),
|
|
523
564
|
topmodel: (w) => (
|
|
524
565
|
<text width={w} height={1} fg={colors.textSubtle}>
|
|
525
|
-
{
|
|
566
|
+
{" ".repeat(w)}
|
|
526
567
|
</text>
|
|
527
568
|
),
|
|
528
569
|
activity: (w) => (
|
|
529
570
|
<text width={w} height={1} fg={totalActive > 0 ? colors.success : colors.textMuted}>
|
|
530
|
-
{padRight(totalActive > 0 ? `${totalActive} live` :
|
|
571
|
+
{padRight(totalActive > 0 ? `${totalActive} live` : "idle", w)}
|
|
531
572
|
</text>
|
|
532
573
|
),
|
|
533
574
|
};
|
|
534
575
|
|
|
535
576
|
return (
|
|
536
577
|
<box flexDirection="row" height={1}>
|
|
537
|
-
<text width={2} height={1} fg={colors.border}>
|
|
578
|
+
<text width={2} height={1} fg={colors.border}>
|
|
579
|
+
{"\u2500 "}
|
|
580
|
+
</text>
|
|
538
581
|
{visibleColumns.map((col) => {
|
|
539
582
|
const renderer = colRenderers[col.key];
|
|
540
|
-
return renderer ?
|
|
583
|
+
return renderer ? (
|
|
584
|
+
<box key={col.key} marginRight={1}>
|
|
585
|
+
{renderer(col.width)}
|
|
586
|
+
</box>
|
|
587
|
+
) : null;
|
|
541
588
|
})}
|
|
542
589
|
</box>
|
|
543
590
|
);
|
|
@@ -560,7 +607,7 @@ function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
|
|
|
560
607
|
const map = new Map<string, { name: string; cost: number }>();
|
|
561
608
|
for (const p of projects) {
|
|
562
609
|
for (const m of p.models) {
|
|
563
|
-
const name = m.modelId.split(
|
|
610
|
+
const name = m.modelId.split("/").pop() ?? m.modelId;
|
|
564
611
|
const existing = map.get(name);
|
|
565
612
|
if (existing) {
|
|
566
613
|
existing.cost += m.cost;
|
|
@@ -597,9 +644,9 @@ function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
|
|
|
597
644
|
if (projects.length === 0) return null;
|
|
598
645
|
const bestCpr = [...projects]
|
|
599
646
|
.filter((p) => p.requestCount > 0)
|
|
600
|
-
.sort((a, b) =>
|
|
647
|
+
.sort((a, b) => a.cost / a.requestCount - b.cost / b.requestCount)[0];
|
|
601
648
|
const bestCache = [...projects]
|
|
602
|
-
.filter((p) =>
|
|
649
|
+
.filter((p) => p.inputTokens + p.cacheRead > 0)
|
|
603
650
|
.sort((a, b) => {
|
|
604
651
|
const rateB = b.cacheRead / (b.inputTokens + b.cacheRead);
|
|
605
652
|
const rateA = a.cacheRead / (a.inputTokens + a.cacheRead);
|
|
@@ -642,15 +689,23 @@ function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
|
|
|
642
689
|
flexShrink={0}
|
|
643
690
|
>
|
|
644
691
|
<box flexDirection="row" paddingX={1} height={1}>
|
|
645
|
-
<text height={1} fg={colors.textMuted}
|
|
646
|
-
|
|
647
|
-
|
|
692
|
+
<text height={1} fg={colors.textMuted}>
|
|
693
|
+
<strong>CROSS-PROJECT INSIGHTS</strong>
|
|
694
|
+
</text>
|
|
695
|
+
<text flexGrow={1} height={1}>
|
|
696
|
+
{" "}
|
|
697
|
+
</text>
|
|
698
|
+
<text height={1} fg={colors.textSubtle}>
|
|
699
|
+
[v] toggle
|
|
700
|
+
</text>
|
|
648
701
|
</box>
|
|
649
702
|
|
|
650
703
|
<box flexDirection="row" paddingX={1} gap={2} overflow="hidden">
|
|
651
704
|
{/* COST BY MODEL */}
|
|
652
705
|
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
653
|
-
<text height={1} fg={colors.textMuted}
|
|
706
|
+
<text height={1} fg={colors.textMuted}>
|
|
707
|
+
<strong>COST BY MODEL</strong>
|
|
708
|
+
</text>
|
|
654
709
|
{modelCosts.map((m: { name: string; cost: number }) => (
|
|
655
710
|
<box key={m.name} flexDirection="row" height={1}>
|
|
656
711
|
<text width={nameWidth} height={1} fg={colors.text}>
|
|
@@ -665,13 +720,17 @@ function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
|
|
|
665
720
|
</box>
|
|
666
721
|
))}
|
|
667
722
|
{modelCosts.length === 0 && (
|
|
668
|
-
<text height={1} fg={colors.textSubtle}>
|
|
723
|
+
<text height={1} fg={colors.textSubtle}>
|
|
724
|
+
No model data
|
|
725
|
+
</text>
|
|
669
726
|
)}
|
|
670
727
|
</box>
|
|
671
728
|
|
|
672
729
|
{/* COST BY AGENT */}
|
|
673
730
|
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
674
|
-
<text height={1} fg={colors.textMuted}
|
|
731
|
+
<text height={1} fg={colors.textMuted}>
|
|
732
|
+
<strong>COST BY AGENT</strong>
|
|
733
|
+
</text>
|
|
675
734
|
{agentCosts.map((a: { name: string; cost: number }) => (
|
|
676
735
|
<box key={a.name} flexDirection="row" height={1}>
|
|
677
736
|
<text width={nameWidth} height={1} fg={colors.text}>
|
|
@@ -686,38 +745,49 @@ function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
|
|
|
686
745
|
</box>
|
|
687
746
|
))}
|
|
688
747
|
{agentCosts.length === 0 && (
|
|
689
|
-
<text height={1} fg={colors.textSubtle}>
|
|
748
|
+
<text height={1} fg={colors.textSubtle}>
|
|
749
|
+
No agent data
|
|
750
|
+
</text>
|
|
690
751
|
)}
|
|
691
752
|
</box>
|
|
692
753
|
|
|
693
754
|
{/* EFFICIENCY + BIGGEST MOVER */}
|
|
694
755
|
{isWide && rankings && (
|
|
695
756
|
<box flexDirection="column" width={30} overflow="hidden">
|
|
696
|
-
<text height={1} fg={colors.textMuted}
|
|
757
|
+
<text height={1} fg={colors.textMuted}>
|
|
758
|
+
<strong>RANKINGS</strong>
|
|
759
|
+
</text>
|
|
697
760
|
{rankings.bestCpr && (
|
|
698
761
|
<text height={1} fg={colors.textSubtle}>
|
|
699
|
-
Best $/req: <span fg={colors.success}>{truncate(rankings.bestCpr.name, 10)}</span>
|
|
700
|
-
{
|
|
762
|
+
Best $/req: <span fg={colors.success}>{truncate(rankings.bestCpr.name, 10)}</span>{" "}
|
|
763
|
+
{formatCost(rankings.bestCpr.cost / rankings.bestCpr.requestCount)}
|
|
701
764
|
</text>
|
|
702
765
|
)}
|
|
703
766
|
{rankings.bestCache && (
|
|
704
767
|
<text height={1} fg={colors.textSubtle}>
|
|
705
|
-
Best cache: <span fg={colors.success}>{truncate(rankings.bestCache.name, 10)}</span>
|
|
706
|
-
{
|
|
768
|
+
Best cache: <span fg={colors.success}>{truncate(rankings.bestCache.name, 10)}</span>{" "}
|
|
769
|
+
{formatPercent(
|
|
770
|
+
(rankings.bestCache.cacheRead /
|
|
771
|
+
(rankings.bestCache.inputTokens + rankings.bestCache.cacheRead)) *
|
|
772
|
+
100,
|
|
773
|
+
)}
|
|
707
774
|
</text>
|
|
708
775
|
)}
|
|
709
776
|
{rankings.mostActive && rankings.mostActive.activeSessions > 0 && (
|
|
710
777
|
<text height={1} fg={colors.textSubtle}>
|
|
711
|
-
Most active:
|
|
712
|
-
{
|
|
778
|
+
Most active:{" "}
|
|
779
|
+
<span fg={colors.success}>{truncate(rankings.mostActive.name, 10)}</span>{" "}
|
|
780
|
+
{rankings.mostActive.activeSessions} live
|
|
713
781
|
</text>
|
|
714
782
|
)}
|
|
715
783
|
{rankings.biggestMover && (
|
|
716
784
|
<text height={1} fg={colors.textSubtle}>
|
|
717
|
-
Biggest mover:
|
|
785
|
+
Biggest mover:{" "}
|
|
786
|
+
<span fg={rankings.biggestMoverDelta >= 0 ? colors.warning : colors.info}>
|
|
718
787
|
{truncate(rankings.biggestMover.name, 8)}
|
|
719
|
-
</span>
|
|
720
|
-
{
|
|
788
|
+
</span>{" "}
|
|
789
|
+
{rankings.biggestMoverDelta >= 0 ? "\u25B2" : "\u25BC"}
|
|
790
|
+
{formatPercent(Math.min(Math.abs(rankings.biggestMoverDelta) * 100, 200))}
|
|
721
791
|
</text>
|
|
722
792
|
)}
|
|
723
793
|
</box>
|
|
@@ -749,12 +819,14 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
|
|
|
749
819
|
const share = totalCost > 0 ? (project.cost / totalCost) * 100 : 0;
|
|
750
820
|
const cacheTotal = project.inputTokens + project.cacheRead;
|
|
751
821
|
const cacheRate = cacheTotal > 0 ? (project.cacheRead / cacheTotal) * 100 : 0;
|
|
752
|
-
const outputShare =
|
|
753
|
-
|
|
754
|
-
|
|
822
|
+
const outputShare =
|
|
823
|
+
project.inputTokens + project.outputTokens > 0
|
|
824
|
+
? (project.outputTokens / (project.inputTokens + project.outputTokens)) * 100
|
|
825
|
+
: 0;
|
|
755
826
|
const costPerReq = project.requestCount > 0 ? project.cost / project.requestCount : 0;
|
|
756
827
|
const avgPerSession = project.sessionCount > 0 ? project.cost / project.sessionCount : 0;
|
|
757
|
-
const allTokens =
|
|
828
|
+
const allTokens =
|
|
829
|
+
project.inputTokens + project.outputTokens + project.cacheRead + project.cacheWrite;
|
|
758
830
|
|
|
759
831
|
// Token composition percentages
|
|
760
832
|
const inputPct = allTokens > 0 ? (project.inputTokens / allTokens) * 100 : 0;
|
|
@@ -792,10 +864,14 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
|
|
|
792
864
|
{/* Header */}
|
|
793
865
|
<box flexDirection="row" justifyContent="space-between" height={1} marginBottom={1}>
|
|
794
866
|
<text height={1}>
|
|
795
|
-
<span fg={colors.primary}
|
|
867
|
+
<span fg={colors.primary}>
|
|
868
|
+
<strong>PROJECT: {truncate(project.name, 30)}</strong>
|
|
869
|
+
</span>
|
|
796
870
|
<span fg={colors.textMuted}> ({formatPercent(share)} of total)</span>
|
|
797
871
|
</text>
|
|
798
|
-
<text height={1} fg={colors.textMuted}>
|
|
872
|
+
<text height={1} fg={colors.textMuted}>
|
|
873
|
+
[Esc] Close
|
|
874
|
+
</text>
|
|
799
875
|
</box>
|
|
800
876
|
|
|
801
877
|
{/* Overview stats */}
|
|
@@ -811,13 +887,17 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
|
|
|
811
887
|
<box flexDirection="row" height={1} justifyContent="space-between">
|
|
812
888
|
<text height={1}>
|
|
813
889
|
<span fg={colors.textMuted}>Cost: </span>
|
|
814
|
-
<span fg={colors.success}
|
|
890
|
+
<span fg={colors.success}>
|
|
891
|
+
<strong>{formatCost(project.cost)}</strong>
|
|
892
|
+
</span>
|
|
815
893
|
</text>
|
|
816
894
|
<text height={1}>
|
|
817
895
|
<span fg={colors.textMuted}>Sessions: </span>
|
|
818
896
|
<span fg={colors.text}>{project.sessionCount}</span>
|
|
819
897
|
<span fg={colors.textMuted}> (</span>
|
|
820
|
-
<span fg={project.activeSessions > 0 ? colors.success : colors.textMuted}>
|
|
898
|
+
<span fg={project.activeSessions > 0 ? colors.success : colors.textMuted}>
|
|
899
|
+
{project.activeSessions} active
|
|
900
|
+
</span>
|
|
821
901
|
<span fg={colors.textMuted}>)</span>
|
|
822
902
|
</text>
|
|
823
903
|
</box>
|
|
@@ -825,7 +905,12 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
|
|
|
825
905
|
<text height={1}>
|
|
826
906
|
<span fg={colors.textMuted}>Tokens: </span>
|
|
827
907
|
<span fg={colors.text}>{formatTokensCompact(allTokens)}</span>
|
|
828
|
-
<span fg={colors.textMuted}>
|
|
908
|
+
<span fg={colors.textMuted}>
|
|
909
|
+
{" "}
|
|
910
|
+
(In: {formatTokensCompact(project.inputTokens)}
|
|
911
|
+
{project.cacheRead > 0 ? ` + ${formatTokensCompact(project.cacheRead)} cached` : ""}{" "}
|
|
912
|
+
/ Out: {formatTokensCompact(project.outputTokens)})
|
|
913
|
+
</span>
|
|
829
914
|
</text>
|
|
830
915
|
<text height={1}>
|
|
831
916
|
<span fg={colors.textMuted}>Requests: </span>
|
|
@@ -836,10 +921,24 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
|
|
|
836
921
|
|
|
837
922
|
{/* MODEL BREAKDOWN */}
|
|
838
923
|
<box flexDirection="row" height={1} paddingX={1} marginTop={0}>
|
|
839
|
-
<text flexGrow={1} height={1} fg={colors.primary}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
<box width={8} justifyContent="flex-end"
|
|
924
|
+
<text flexGrow={1} height={1} fg={colors.primary}>
|
|
925
|
+
<strong>MODEL</strong>
|
|
926
|
+
</text>
|
|
927
|
+
<box width={8} justifyContent="flex-end">
|
|
928
|
+
<text fg={colors.primary}>
|
|
929
|
+
<strong>COST</strong>
|
|
930
|
+
</text>
|
|
931
|
+
</box>
|
|
932
|
+
<box width={6} justifyContent="flex-end">
|
|
933
|
+
<text fg={colors.primary}>
|
|
934
|
+
<strong>SHARE</strong>
|
|
935
|
+
</text>
|
|
936
|
+
</box>
|
|
937
|
+
<box width={8} justifyContent="flex-end">
|
|
938
|
+
<text fg={colors.primary}>
|
|
939
|
+
<strong>REQS</strong>
|
|
940
|
+
</text>
|
|
941
|
+
</box>
|
|
843
942
|
</box>
|
|
844
943
|
<box
|
|
845
944
|
flexDirection="column"
|
|
@@ -850,46 +949,73 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
|
|
|
850
949
|
flexShrink={0}
|
|
851
950
|
>
|
|
852
951
|
<scrollbox>
|
|
853
|
-
{project.models.length > 0 ?
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
<
|
|
859
|
-
{
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
<
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
<
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
<
|
|
952
|
+
{project.models.length > 0 ? (
|
|
953
|
+
project.models.slice(0, 8).map((m, idx) => {
|
|
954
|
+
const modelName = m.modelId.split("/").pop() ?? m.modelId;
|
|
955
|
+
const modelShare = project.cost > 0 ? (m.cost / project.cost) * 100 : 0;
|
|
956
|
+
return (
|
|
957
|
+
<box key={`${m.modelId}-${idx}`} flexDirection="row" height={1} paddingX={1}>
|
|
958
|
+
<text flexGrow={1} height={1} fg={colors.text} overflow="hidden">
|
|
959
|
+
{truncate(modelName, contentWidth - 28)}
|
|
960
|
+
</text>
|
|
961
|
+
<box width={8} height={1} justifyContent="flex-end">
|
|
962
|
+
<text fg={colors.success}>{formatCost(m.cost)}</text>
|
|
963
|
+
</box>
|
|
964
|
+
<box width={6} height={1} justifyContent="flex-end">
|
|
965
|
+
<text fg={colors.textMuted}>{formatPercent(modelShare)}</text>
|
|
966
|
+
</box>
|
|
967
|
+
<box width={8} height={1} justifyContent="flex-end">
|
|
968
|
+
<text fg={colors.textMuted}>{m.requests}</text>
|
|
969
|
+
</box>
|
|
869
970
|
</box>
|
|
870
|
-
|
|
871
|
-
)
|
|
872
|
-
|
|
873
|
-
<text height={1} paddingX={1} fg={colors.textSubtle}>
|
|
971
|
+
);
|
|
972
|
+
})
|
|
973
|
+
) : (
|
|
974
|
+
<text height={1} paddingX={1} fg={colors.textSubtle}>
|
|
975
|
+
No model data
|
|
976
|
+
</text>
|
|
874
977
|
)}
|
|
875
978
|
</scrollbox>
|
|
876
979
|
</box>
|
|
877
980
|
|
|
878
981
|
{/* SESSIONS */}
|
|
879
982
|
<box flexDirection="row" height={1} paddingX={1} marginTop={0}>
|
|
880
|
-
<text flexGrow={1} height={1} fg={colors.primary}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
<box width={
|
|
884
|
-
|
|
983
|
+
<text flexGrow={1} height={1} fg={colors.primary}>
|
|
984
|
+
<strong>SESSIONS</strong>
|
|
985
|
+
</text>
|
|
986
|
+
<box width={8} justifyContent="flex-end">
|
|
987
|
+
<text fg={colors.primary}>
|
|
988
|
+
<strong>COST</strong>
|
|
989
|
+
</text>
|
|
990
|
+
</box>
|
|
991
|
+
<box width={8} justifyContent="flex-end">
|
|
992
|
+
<text fg={colors.primary}>
|
|
993
|
+
<strong>TOKENS</strong>
|
|
994
|
+
</text>
|
|
995
|
+
</box>
|
|
996
|
+
<box width={6} justifyContent="flex-end">
|
|
997
|
+
<text fg={colors.primary}>
|
|
998
|
+
<strong>AGO</strong>
|
|
999
|
+
</text>
|
|
1000
|
+
</box>
|
|
1001
|
+
<box width={2} justifyContent="flex-end">
|
|
1002
|
+
<text fg={colors.primary}> </text>
|
|
1003
|
+
</box>
|
|
885
1004
|
</box>
|
|
886
|
-
<box
|
|
1005
|
+
<box
|
|
1006
|
+
flexDirection="column"
|
|
1007
|
+
border
|
|
1008
|
+
borderColor={colors.border}
|
|
1009
|
+
overflow="hidden"
|
|
1010
|
+
flexGrow={1}
|
|
1011
|
+
minHeight={3}
|
|
1012
|
+
>
|
|
887
1013
|
<scrollbox flexGrow={1}>
|
|
888
1014
|
{project.sessions
|
|
889
1015
|
.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
|
890
1016
|
.slice(0, 15)
|
|
891
1017
|
.map((s) => {
|
|
892
|
-
const modelName = s.streams[0]?.modelId.split(
|
|
1018
|
+
const modelName = s.streams[0]?.modelId.split("/").pop() ?? "unknown";
|
|
893
1019
|
return (
|
|
894
1020
|
<box key={s.sessionId} flexDirection="row" height={1} paddingX={1}>
|
|
895
1021
|
<text width={10} height={1} fg={colors.textSubtle} overflow="hidden">
|
|
@@ -902,76 +1028,130 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
|
|
|
902
1028
|
<text fg={colors.success}>{formatCost(s.totalCostUsd ?? 0)}</text>
|
|
903
1029
|
</box>
|
|
904
1030
|
<box width={8} height={1} justifyContent="flex-end">
|
|
905
|
-
<text fg={colors.textMuted}>
|
|
1031
|
+
<text fg={colors.textMuted}>
|
|
1032
|
+
{formatTokensCompact(s.totals.input + s.totals.output)}
|
|
1033
|
+
</text>
|
|
906
1034
|
</box>
|
|
907
1035
|
<box width={6} height={1} justifyContent="flex-end">
|
|
908
1036
|
<text fg={colors.textMuted}>{formatRelativeTime(s.lastActivityAt)}</text>
|
|
909
1037
|
</box>
|
|
910
1038
|
<box width={2} height={1} justifyContent="flex-end">
|
|
911
|
-
<text fg={s.status ===
|
|
912
|
-
{s.status ===
|
|
1039
|
+
<text fg={s.status === "active" ? colors.success : colors.textMuted}>
|
|
1040
|
+
{s.status === "active" ? "\u25CF" : "\u25CB"}
|
|
913
1041
|
</text>
|
|
914
1042
|
</box>
|
|
915
1043
|
</box>
|
|
916
1044
|
);
|
|
917
1045
|
})}
|
|
918
1046
|
{project.sessions.length === 0 && (
|
|
919
|
-
<text height={1} paddingX={1} fg={colors.textSubtle}>
|
|
1047
|
+
<text height={1} paddingX={1} fg={colors.textSubtle}>
|
|
1048
|
+
No sessions
|
|
1049
|
+
</text>
|
|
920
1050
|
)}
|
|
921
1051
|
</scrollbox>
|
|
922
1052
|
</box>
|
|
923
1053
|
|
|
924
1054
|
{/* EFFICIENCY + TOKEN COMPOSITION */}
|
|
925
1055
|
{height >= 30 && (
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
<
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
<
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
<text height={1} fg={colors.text}> {formatPercent(outputPct)}</text>
|
|
960
|
-
</box>
|
|
961
|
-
<box flexDirection="row" height={1}>
|
|
962
|
-
<text width={12} height={1} fg={colors.textMuted}>{'Cache read'}</text>
|
|
963
|
-
<text width={compBarW} height={1} fg={colors.success}>{makeBar(cacheReadPct, compBarW)}</text>
|
|
964
|
-
<text height={1} fg={colors.text}> {formatPercent(cacheReadPct)}</text>
|
|
1056
|
+
<box flexDirection="row" gap={1} flexShrink={0} marginTop={0}>
|
|
1057
|
+
{/* Efficiency */}
|
|
1058
|
+
<box
|
|
1059
|
+
flexDirection="column"
|
|
1060
|
+
flexGrow={1}
|
|
1061
|
+
border
|
|
1062
|
+
borderColor={colors.border}
|
|
1063
|
+
paddingX={1}
|
|
1064
|
+
overflow="hidden"
|
|
1065
|
+
>
|
|
1066
|
+
<text height={1} fg={colors.primary}>
|
|
1067
|
+
<strong>EFFICIENCY</strong>
|
|
1068
|
+
</text>
|
|
1069
|
+
<text height={1}>
|
|
1070
|
+
<span fg={colors.textMuted}>Cache leverage: </span>
|
|
1071
|
+
<span fg={cacheRate >= 50 ? colors.success : colors.warning}>
|
|
1072
|
+
{formatPercent(cacheRate)}
|
|
1073
|
+
</span>
|
|
1074
|
+
</text>
|
|
1075
|
+
<text height={1}>
|
|
1076
|
+
<span fg={colors.textMuted}>Output share: </span>
|
|
1077
|
+
<span fg={outputShare > 65 ? colors.warning : colors.text}>
|
|
1078
|
+
{formatPercent(outputShare)}
|
|
1079
|
+
</span>
|
|
1080
|
+
</text>
|
|
1081
|
+
<text height={1}>
|
|
1082
|
+
<span fg={colors.textMuted}>$/request: </span>
|
|
1083
|
+
<span fg={colors.text}>{formatCost(costPerReq)}</span>
|
|
1084
|
+
</text>
|
|
1085
|
+
<text height={1}>
|
|
1086
|
+
<span fg={colors.textMuted}>Avg/session: </span>
|
|
1087
|
+
<span fg={colors.text}>{formatCost(avgPerSession)}</span>
|
|
1088
|
+
</text>
|
|
965
1089
|
</box>
|
|
966
|
-
|
|
1090
|
+
|
|
1091
|
+
{/* Token composition */}
|
|
1092
|
+
<box
|
|
1093
|
+
flexDirection="column"
|
|
1094
|
+
flexGrow={1}
|
|
1095
|
+
border
|
|
1096
|
+
borderColor={colors.border}
|
|
1097
|
+
paddingX={1}
|
|
1098
|
+
overflow="hidden"
|
|
1099
|
+
>
|
|
1100
|
+
<text height={1} fg={colors.primary}>
|
|
1101
|
+
<strong>TOKEN COMPOSITION</strong>
|
|
1102
|
+
</text>
|
|
967
1103
|
<box flexDirection="row" height={1}>
|
|
968
|
-
<text width={12} height={1} fg={colors.textMuted}>
|
|
969
|
-
|
|
970
|
-
|
|
1104
|
+
<text width={12} height={1} fg={colors.textMuted}>
|
|
1105
|
+
{"Input "}
|
|
1106
|
+
</text>
|
|
1107
|
+
<text width={compBarW} height={1} fg={colors.info}>
|
|
1108
|
+
{makeBar(inputPct, compBarW)}
|
|
1109
|
+
</text>
|
|
1110
|
+
<text height={1} fg={colors.text}>
|
|
1111
|
+
{" "}
|
|
1112
|
+
{formatPercent(inputPct)}
|
|
1113
|
+
</text>
|
|
971
1114
|
</box>
|
|
972
|
-
|
|
1115
|
+
<box flexDirection="row" height={1}>
|
|
1116
|
+
<text width={12} height={1} fg={colors.textMuted}>
|
|
1117
|
+
{"Output "}
|
|
1118
|
+
</text>
|
|
1119
|
+
<text width={compBarW} height={1} fg={colors.warning}>
|
|
1120
|
+
{makeBar(outputPct, compBarW)}
|
|
1121
|
+
</text>
|
|
1122
|
+
<text height={1} fg={colors.text}>
|
|
1123
|
+
{" "}
|
|
1124
|
+
{formatPercent(outputPct)}
|
|
1125
|
+
</text>
|
|
1126
|
+
</box>
|
|
1127
|
+
<box flexDirection="row" height={1}>
|
|
1128
|
+
<text width={12} height={1} fg={colors.textMuted}>
|
|
1129
|
+
{"Cache read"}
|
|
1130
|
+
</text>
|
|
1131
|
+
<text width={compBarW} height={1} fg={colors.success}>
|
|
1132
|
+
{makeBar(cacheReadPct, compBarW)}
|
|
1133
|
+
</text>
|
|
1134
|
+
<text height={1} fg={colors.text}>
|
|
1135
|
+
{" "}
|
|
1136
|
+
{formatPercent(cacheReadPct)}
|
|
1137
|
+
</text>
|
|
1138
|
+
</box>
|
|
1139
|
+
{cacheWritePct > 0 && (
|
|
1140
|
+
<box flexDirection="row" height={1}>
|
|
1141
|
+
<text width={12} height={1} fg={colors.textMuted}>
|
|
1142
|
+
{"Cache writ"}
|
|
1143
|
+
</text>
|
|
1144
|
+
<text width={compBarW} height={1} fg={colors.accent}>
|
|
1145
|
+
{makeBar(cacheWritePct, compBarW)}
|
|
1146
|
+
</text>
|
|
1147
|
+
<text height={1} fg={colors.text}>
|
|
1148
|
+
{" "}
|
|
1149
|
+
{formatPercent(cacheWritePct)}
|
|
1150
|
+
</text>
|
|
1151
|
+
</box>
|
|
1152
|
+
)}
|
|
1153
|
+
</box>
|
|
973
1154
|
</box>
|
|
974
|
-
</box>
|
|
975
1155
|
)}
|
|
976
1156
|
</box>
|
|
977
1157
|
</box>
|
|
@@ -1015,11 +1195,14 @@ export function ProjectsView() {
|
|
|
1015
1195
|
const { setInputFocused } = useInputFocus();
|
|
1016
1196
|
|
|
1017
1197
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
1018
|
-
const [sortField, setSortField] = useState<SortField>(
|
|
1198
|
+
const [sortField, setSortField] = useState<SortField>("cost");
|
|
1019
1199
|
const [showInsights, setShowInsights] = useState(termHeight >= 35);
|
|
1020
1200
|
const [drawerProject, setDrawerProject] = useState<ProjectStats | null>(null);
|
|
1021
|
-
const [filterQuery, setFilterQuery] = useState(
|
|
1201
|
+
const [filterQuery, setFilterQuery] = useState("");
|
|
1022
1202
|
const [isFiltering, setIsFiltering] = useState(false);
|
|
1203
|
+
const scrollOffsetRef = useRef(0);
|
|
1204
|
+
const scrollboxRef = useRef<ScrollBoxRenderable>(null);
|
|
1205
|
+
const selectedProjectPathRef = useRef<string | null>(null);
|
|
1023
1206
|
|
|
1024
1207
|
// Aggregate projects
|
|
1025
1208
|
const projectStats = useMemo(
|
|
@@ -1041,22 +1224,23 @@ export function ProjectsView() {
|
|
|
1041
1224
|
if (!filterQuery) return projectStats;
|
|
1042
1225
|
const q = filterQuery.toLowerCase();
|
|
1043
1226
|
return projectStats.filter(
|
|
1044
|
-
(p: ProjectStats) =>
|
|
1045
|
-
p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
|
|
1227
|
+
(p: ProjectStats) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
|
|
1046
1228
|
);
|
|
1047
1229
|
}, [projectStats, filterQuery]);
|
|
1048
1230
|
|
|
1049
1231
|
const visibleCost = useMemo(
|
|
1050
|
-
() =>
|
|
1051
|
-
|
|
1052
|
-
|
|
1232
|
+
() =>
|
|
1233
|
+
filterQuery
|
|
1234
|
+
? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.cost, 0)
|
|
1235
|
+
: totalCost,
|
|
1053
1236
|
[filterQuery, filteredProjects, totalCost],
|
|
1054
1237
|
);
|
|
1055
1238
|
|
|
1056
1239
|
const visibleSessions = useMemo(
|
|
1057
|
-
() =>
|
|
1058
|
-
|
|
1059
|
-
|
|
1240
|
+
() =>
|
|
1241
|
+
filterQuery
|
|
1242
|
+
? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.sessionCount, 0)
|
|
1243
|
+
: totalSessions,
|
|
1060
1244
|
[filterQuery, filteredProjects, totalSessions],
|
|
1061
1245
|
);
|
|
1062
1246
|
|
|
@@ -1069,14 +1253,78 @@ export function ProjectsView() {
|
|
|
1069
1253
|
// Visible columns
|
|
1070
1254
|
const visibleColumns: ColumnSpec[] = useMemo(() => getVisibleColumns(termWidth), [termWidth]);
|
|
1071
1255
|
|
|
1072
|
-
//
|
|
1073
|
-
|
|
1256
|
+
// Stabilize selection across re-sorts: track selected project by path
|
|
1257
|
+
// so the highlight follows the same project when live data causes reordering.
|
|
1258
|
+
useLayoutEffect(() => {
|
|
1074
1259
|
if (sortedProjects.length === 0) {
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1260
|
+
setSelectedIndex(0);
|
|
1261
|
+
scrollOffsetRef.current = 0;
|
|
1262
|
+
scrollboxRef.current?.scrollTo(0);
|
|
1263
|
+
selectedProjectPathRef.current = null;
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (selectedProjectPathRef.current) {
|
|
1268
|
+
const newIndex = sortedProjects.findIndex((p) => p.path === selectedProjectPathRef.current);
|
|
1269
|
+
if (newIndex !== -1) {
|
|
1270
|
+
setSelectedIndex(newIndex);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
// Tracked project was filtered out — fall through to clamp
|
|
1274
|
+
selectedProjectPathRef.current = null;
|
|
1078
1275
|
}
|
|
1079
|
-
|
|
1276
|
+
|
|
1277
|
+
// Clamp to valid range (functional update avoids selectedIndex in deps)
|
|
1278
|
+
setSelectedIndex((prev) => Math.min(prev, sortedProjects.length - 1));
|
|
1279
|
+
}, [sortedProjects]);
|
|
1280
|
+
|
|
1281
|
+
// Record which project the user selected
|
|
1282
|
+
useEffect(() => {
|
|
1283
|
+
const project = sortedProjects[selectedIndex];
|
|
1284
|
+
if (project) {
|
|
1285
|
+
selectedProjectPathRef.current = project.path;
|
|
1286
|
+
}
|
|
1287
|
+
}, [selectedIndex, sortedProjects]);
|
|
1288
|
+
|
|
1289
|
+
// Scroll to keep selected row visible
|
|
1290
|
+
const visibleRows = useMemo(() => {
|
|
1291
|
+
const appChrome = (termHeight >= 35 ? 7 : 1) + 1; // App.tsx: Header (large/small) + StatusBar
|
|
1292
|
+
const outerPadding = 2;
|
|
1293
|
+
const aggregateHeader = 1;
|
|
1294
|
+
const columnHeaders = 1;
|
|
1295
|
+
const tableBorder = 2;
|
|
1296
|
+
const summaryFooter = 1;
|
|
1297
|
+
const footerHints = 1;
|
|
1298
|
+
const filterBar = isFiltering ? 1 : 0;
|
|
1299
|
+
const insightsHeight = showInsights && termHeight >= 26 ? 9 : 0;
|
|
1300
|
+
return Math.max(
|
|
1301
|
+
1,
|
|
1302
|
+
termHeight -
|
|
1303
|
+
appChrome -
|
|
1304
|
+
outerPadding -
|
|
1305
|
+
aggregateHeader -
|
|
1306
|
+
columnHeaders -
|
|
1307
|
+
tableBorder -
|
|
1308
|
+
summaryFooter -
|
|
1309
|
+
footerHints -
|
|
1310
|
+
filterBar -
|
|
1311
|
+
insightsHeight,
|
|
1312
|
+
);
|
|
1313
|
+
}, [termHeight, isFiltering, showInsights]);
|
|
1314
|
+
|
|
1315
|
+
useLayoutEffect(() => {
|
|
1316
|
+
if (!scrollboxRef.current || sortedProjects.length === 0) return;
|
|
1317
|
+
|
|
1318
|
+
let newOffset = scrollOffsetRef.current;
|
|
1319
|
+
if (selectedIndex < newOffset) {
|
|
1320
|
+
newOffset = selectedIndex;
|
|
1321
|
+
} else if (selectedIndex >= newOffset + visibleRows) {
|
|
1322
|
+
newOffset = selectedIndex - visibleRows + 1;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
scrollOffsetRef.current = newOffset;
|
|
1326
|
+
scrollboxRef.current.scrollTo(newOffset);
|
|
1327
|
+
}, [selectedIndex, visibleRows, sortedProjects.length]);
|
|
1080
1328
|
|
|
1081
1329
|
// Input focus sync
|
|
1082
1330
|
useEffect(() => {
|
|
@@ -1090,7 +1338,7 @@ export function ProjectsView() {
|
|
|
1090
1338
|
(key: { name: string; shift?: boolean; ctrl?: boolean }) => {
|
|
1091
1339
|
// Drawer open: only Esc closes
|
|
1092
1340
|
if (drawerProject) {
|
|
1093
|
-
if (key.name ===
|
|
1341
|
+
if (key.name === "escape") {
|
|
1094
1342
|
setDrawerProject(null);
|
|
1095
1343
|
}
|
|
1096
1344
|
return;
|
|
@@ -1098,59 +1346,59 @@ export function ProjectsView() {
|
|
|
1098
1346
|
|
|
1099
1347
|
// Filter mode
|
|
1100
1348
|
if (isFiltering) {
|
|
1101
|
-
if (key.name ===
|
|
1349
|
+
if (key.name === "escape") {
|
|
1102
1350
|
setIsFiltering(false);
|
|
1103
|
-
setFilterQuery(
|
|
1104
|
-
} else if (key.name ===
|
|
1351
|
+
setFilterQuery("");
|
|
1352
|
+
} else if (key.name === "enter" || key.name === "return") {
|
|
1105
1353
|
setIsFiltering(false);
|
|
1106
1354
|
}
|
|
1107
1355
|
return;
|
|
1108
1356
|
}
|
|
1109
1357
|
|
|
1110
1358
|
// Navigation
|
|
1111
|
-
if (key.name ===
|
|
1359
|
+
if (key.name === "down" || key.name === "j") {
|
|
1112
1360
|
setSelectedIndex((prev: number) => Math.min(prev + 1, sortedProjects.length - 1));
|
|
1113
|
-
} else if (key.name ===
|
|
1361
|
+
} else if (key.name === "up" || key.name === "k") {
|
|
1114
1362
|
setSelectedIndex((prev: number) => Math.max(prev - 1, 0));
|
|
1115
1363
|
}
|
|
1116
1364
|
// Sort cycling
|
|
1117
|
-
else if (key.name ===
|
|
1365
|
+
else if (key.name === "s") {
|
|
1118
1366
|
setSortField((prev: SortField) => {
|
|
1119
1367
|
const idx = SORT_FIELDS.indexOf(prev);
|
|
1120
1368
|
return SORT_FIELDS[(idx + 1) % SORT_FIELDS.length]!;
|
|
1121
1369
|
});
|
|
1122
1370
|
}
|
|
1123
1371
|
// Time window
|
|
1124
|
-
else if (key.name ===
|
|
1372
|
+
else if (key.name === "t") {
|
|
1125
1373
|
cycleWindow();
|
|
1126
1374
|
}
|
|
1127
1375
|
// Toggle insights panel
|
|
1128
|
-
else if (key.name ===
|
|
1376
|
+
else if (key.name === "v") {
|
|
1129
1377
|
setShowInsights((prev: boolean) => !prev);
|
|
1130
1378
|
}
|
|
1131
1379
|
// Open detail drawer
|
|
1132
|
-
else if (key.name ===
|
|
1380
|
+
else if (key.name === "enter" || key.name === "return") {
|
|
1133
1381
|
const project = sortedProjects[selectedIndex];
|
|
1134
1382
|
if (project) {
|
|
1135
1383
|
setDrawerProject(project);
|
|
1136
1384
|
}
|
|
1137
1385
|
}
|
|
1138
1386
|
// Filter
|
|
1139
|
-
else if (key.name ===
|
|
1387
|
+
else if (key.name === "/" || key.name === "f") {
|
|
1140
1388
|
setIsFiltering(true);
|
|
1141
1389
|
}
|
|
1142
1390
|
// Clear filter
|
|
1143
|
-
else if (key.name ===
|
|
1391
|
+
else if (key.name === "escape") {
|
|
1144
1392
|
if (filterQuery) {
|
|
1145
|
-
setFilterQuery(
|
|
1393
|
+
setFilterQuery("");
|
|
1146
1394
|
}
|
|
1147
1395
|
}
|
|
1148
|
-
// Jump to top
|
|
1149
|
-
else if (key.name ===
|
|
1150
|
-
setSelectedIndex(0);
|
|
1151
|
-
}
|
|
1152
|
-
else if (key.name === 'G' || (key.name === 'g' && key.shift)) {
|
|
1396
|
+
// Jump to bottom (Shift+G) / top (g)
|
|
1397
|
+
else if (key.shift && key.name === "g") {
|
|
1153
1398
|
setSelectedIndex(Math.max(0, sortedProjects.length - 1));
|
|
1399
|
+
} else if (key.name === "g") {
|
|
1400
|
+
setSelectedIndex(0);
|
|
1401
|
+
scrollOffsetRef.current = 0;
|
|
1154
1402
|
}
|
|
1155
1403
|
},
|
|
1156
1404
|
[drawerProject, isFiltering, sortedProjects, selectedIndex, cycleWindow, filterQuery],
|
|
@@ -1173,7 +1421,9 @@ export function ProjectsView() {
|
|
|
1173
1421
|
<EmptyState hasFilter={filterQuery.length > 0} />
|
|
1174
1422
|
<box flexDirection="row" height={1} paddingLeft={1}>
|
|
1175
1423
|
<text fg={colors.textSubtle}>
|
|
1176
|
-
{filterQuery
|
|
1424
|
+
{filterQuery
|
|
1425
|
+
? "Esc clear filter / edit filter t time window"
|
|
1426
|
+
: "t time window / filter"}
|
|
1177
1427
|
</text>
|
|
1178
1428
|
</box>
|
|
1179
1429
|
</box>
|
|
@@ -1203,7 +1453,9 @@ export function ProjectsView() {
|
|
|
1203
1453
|
|
|
1204
1454
|
{/* Column headers */}
|
|
1205
1455
|
<box flexDirection="row" height={1} marginTop={0}>
|
|
1206
|
-
<text width={2} height={1} fg={colors.textMuted}>
|
|
1456
|
+
<text width={2} height={1} fg={colors.textMuted}>
|
|
1457
|
+
{" "}
|
|
1458
|
+
</text>
|
|
1207
1459
|
{visibleColumns.map((col: ColumnSpec) => (
|
|
1208
1460
|
<box key={col.key} marginRight={1}>
|
|
1209
1461
|
<text width={col.width} height={1} fg={colors.textMuted}>
|
|
@@ -1226,7 +1478,7 @@ export function ProjectsView() {
|
|
|
1226
1478
|
textColor={colors.text}
|
|
1227
1479
|
cursorColor={colors.primary}
|
|
1228
1480
|
/>
|
|
1229
|
-
<text fg={colors.textSubtle}> (esc clear
|
|
1481
|
+
<text fg={colors.textSubtle}> (esc clear enter apply)</text>
|
|
1230
1482
|
</box>
|
|
1231
1483
|
)}
|
|
1232
1484
|
|
|
@@ -1239,7 +1491,7 @@ export function ProjectsView() {
|
|
|
1239
1491
|
borderColor={colors.border}
|
|
1240
1492
|
overflow="hidden"
|
|
1241
1493
|
>
|
|
1242
|
-
<scrollbox flexGrow={1}>
|
|
1494
|
+
<scrollbox ref={scrollboxRef} flexGrow={1}>
|
|
1243
1495
|
<box flexDirection="column">
|
|
1244
1496
|
{sortedProjects.map((project: ProjectStats, index: number) => (
|
|
1245
1497
|
<ProjectRow
|
|
@@ -1266,10 +1518,10 @@ export function ProjectsView() {
|
|
|
1266
1518
|
<box flexDirection="row" height={1} paddingLeft={1} flexShrink={0}>
|
|
1267
1519
|
<text fg={colors.textSubtle} height={1}>
|
|
1268
1520
|
{isFiltering
|
|
1269
|
-
?
|
|
1521
|
+
? "Type to filter Esc cancel Enter apply"
|
|
1270
1522
|
: filterQuery
|
|
1271
1523
|
? `Filter: "${filterQuery}" Esc clear / edit`
|
|
1272
|
-
:
|
|
1524
|
+
: "\u2191\u2193 navigate Enter details s sort t time v insights / filter"}
|
|
1273
1525
|
</text>
|
|
1274
1526
|
</box>
|
|
1275
1527
|
</box>
|