@tokentop/ttop 0.2.2 → 0.3.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 +32 -32
- 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 +29 -23
- package/src/plugins/notifications/visual-flash.ts +21 -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 +66 -57
- package/src/plugins/registry.ts +47 -43
- 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/repos/agentSessions.ts +33 -23
- package/src/storage/repos/providerSnapshots.ts +5 -5
- package/src/storage/repos/usageEvents.ts +40 -28
- 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 +295 -177
- 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 +313 -166
- package/src/tui/components/SettingsModal.tsx +231 -214
- 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 +193 -144
- 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 +232 -183
- package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
- package/src/tui/contexts/StorageContext.tsx +151 -117
- 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 +3 -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 +127 -102
- package/src/tui/views/HistoricalTrendsView.tsx +579 -250
- package/src/tui/views/ProjectsView.tsx +466 -237
- package/src/tui/views/RealTimeDashboard.tsx +150 -111
- package/src/tui/views/SettingsView.tsx +222 -191
- 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,13 @@ 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);
|
|
1023
1205
|
|
|
1024
1206
|
// Aggregate projects
|
|
1025
1207
|
const projectStats = useMemo(
|
|
@@ -1041,22 +1223,23 @@ export function ProjectsView() {
|
|
|
1041
1223
|
if (!filterQuery) return projectStats;
|
|
1042
1224
|
const q = filterQuery.toLowerCase();
|
|
1043
1225
|
return projectStats.filter(
|
|
1044
|
-
(p: ProjectStats) =>
|
|
1045
|
-
p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
|
|
1226
|
+
(p: ProjectStats) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
|
|
1046
1227
|
);
|
|
1047
1228
|
}, [projectStats, filterQuery]);
|
|
1048
1229
|
|
|
1049
1230
|
const visibleCost = useMemo(
|
|
1050
|
-
() =>
|
|
1051
|
-
|
|
1052
|
-
|
|
1231
|
+
() =>
|
|
1232
|
+
filterQuery
|
|
1233
|
+
? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.cost, 0)
|
|
1234
|
+
: totalCost,
|
|
1053
1235
|
[filterQuery, filteredProjects, totalCost],
|
|
1054
1236
|
);
|
|
1055
1237
|
|
|
1056
1238
|
const visibleSessions = useMemo(
|
|
1057
|
-
() =>
|
|
1058
|
-
|
|
1059
|
-
|
|
1239
|
+
() =>
|
|
1240
|
+
filterQuery
|
|
1241
|
+
? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.sessionCount, 0)
|
|
1242
|
+
: totalSessions,
|
|
1060
1243
|
[filterQuery, filteredProjects, totalSessions],
|
|
1061
1244
|
);
|
|
1062
1245
|
|
|
@@ -1073,11 +1256,53 @@ export function ProjectsView() {
|
|
|
1073
1256
|
useEffect(() => {
|
|
1074
1257
|
if (sortedProjects.length === 0) {
|
|
1075
1258
|
if (selectedIndex !== 0) setSelectedIndex(0);
|
|
1259
|
+
scrollOffsetRef.current = 0;
|
|
1260
|
+
scrollboxRef.current?.scrollTo(0);
|
|
1076
1261
|
} else if (selectedIndex >= sortedProjects.length) {
|
|
1077
1262
|
setSelectedIndex(sortedProjects.length - 1);
|
|
1078
1263
|
}
|
|
1079
1264
|
}, [sortedProjects.length, selectedIndex]);
|
|
1080
1265
|
|
|
1266
|
+
// Scroll to keep selected row visible
|
|
1267
|
+
const visibleRows = useMemo(() => {
|
|
1268
|
+
const appChrome = (termHeight >= 35 ? 7 : 1) + 1; // App.tsx: Header (large/small) + StatusBar
|
|
1269
|
+
const outerPadding = 2;
|
|
1270
|
+
const aggregateHeader = 1;
|
|
1271
|
+
const columnHeaders = 1;
|
|
1272
|
+
const tableBorder = 2;
|
|
1273
|
+
const summaryFooter = 1;
|
|
1274
|
+
const footerHints = 1;
|
|
1275
|
+
const filterBar = isFiltering ? 1 : 0;
|
|
1276
|
+
const insightsHeight = showInsights && termHeight >= 26 ? 9 : 0;
|
|
1277
|
+
return Math.max(
|
|
1278
|
+
1,
|
|
1279
|
+
termHeight -
|
|
1280
|
+
appChrome -
|
|
1281
|
+
outerPadding -
|
|
1282
|
+
aggregateHeader -
|
|
1283
|
+
columnHeaders -
|
|
1284
|
+
tableBorder -
|
|
1285
|
+
summaryFooter -
|
|
1286
|
+
footerHints -
|
|
1287
|
+
filterBar -
|
|
1288
|
+
insightsHeight,
|
|
1289
|
+
);
|
|
1290
|
+
}, [termHeight, isFiltering, showInsights]);
|
|
1291
|
+
|
|
1292
|
+
useLayoutEffect(() => {
|
|
1293
|
+
if (!scrollboxRef.current || sortedProjects.length === 0) return;
|
|
1294
|
+
|
|
1295
|
+
let newOffset = scrollOffsetRef.current;
|
|
1296
|
+
if (selectedIndex < newOffset) {
|
|
1297
|
+
newOffset = selectedIndex;
|
|
1298
|
+
} else if (selectedIndex >= newOffset + visibleRows) {
|
|
1299
|
+
newOffset = selectedIndex - visibleRows + 1;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
scrollOffsetRef.current = newOffset;
|
|
1303
|
+
scrollboxRef.current.scrollTo(newOffset);
|
|
1304
|
+
}, [selectedIndex, visibleRows, sortedProjects.length]);
|
|
1305
|
+
|
|
1081
1306
|
// Input focus sync
|
|
1082
1307
|
useEffect(() => {
|
|
1083
1308
|
setInputFocused(isFiltering);
|
|
@@ -1090,7 +1315,7 @@ export function ProjectsView() {
|
|
|
1090
1315
|
(key: { name: string; shift?: boolean; ctrl?: boolean }) => {
|
|
1091
1316
|
// Drawer open: only Esc closes
|
|
1092
1317
|
if (drawerProject) {
|
|
1093
|
-
if (key.name ===
|
|
1318
|
+
if (key.name === "escape") {
|
|
1094
1319
|
setDrawerProject(null);
|
|
1095
1320
|
}
|
|
1096
1321
|
return;
|
|
@@ -1098,59 +1323,59 @@ export function ProjectsView() {
|
|
|
1098
1323
|
|
|
1099
1324
|
// Filter mode
|
|
1100
1325
|
if (isFiltering) {
|
|
1101
|
-
if (key.name ===
|
|
1326
|
+
if (key.name === "escape") {
|
|
1102
1327
|
setIsFiltering(false);
|
|
1103
|
-
setFilterQuery(
|
|
1104
|
-
} else if (key.name ===
|
|
1328
|
+
setFilterQuery("");
|
|
1329
|
+
} else if (key.name === "enter" || key.name === "return") {
|
|
1105
1330
|
setIsFiltering(false);
|
|
1106
1331
|
}
|
|
1107
1332
|
return;
|
|
1108
1333
|
}
|
|
1109
1334
|
|
|
1110
1335
|
// Navigation
|
|
1111
|
-
if (key.name ===
|
|
1336
|
+
if (key.name === "down" || key.name === "j") {
|
|
1112
1337
|
setSelectedIndex((prev: number) => Math.min(prev + 1, sortedProjects.length - 1));
|
|
1113
|
-
} else if (key.name ===
|
|
1338
|
+
} else if (key.name === "up" || key.name === "k") {
|
|
1114
1339
|
setSelectedIndex((prev: number) => Math.max(prev - 1, 0));
|
|
1115
1340
|
}
|
|
1116
1341
|
// Sort cycling
|
|
1117
|
-
else if (key.name ===
|
|
1342
|
+
else if (key.name === "s") {
|
|
1118
1343
|
setSortField((prev: SortField) => {
|
|
1119
1344
|
const idx = SORT_FIELDS.indexOf(prev);
|
|
1120
1345
|
return SORT_FIELDS[(idx + 1) % SORT_FIELDS.length]!;
|
|
1121
1346
|
});
|
|
1122
1347
|
}
|
|
1123
1348
|
// Time window
|
|
1124
|
-
else if (key.name ===
|
|
1349
|
+
else if (key.name === "t") {
|
|
1125
1350
|
cycleWindow();
|
|
1126
1351
|
}
|
|
1127
1352
|
// Toggle insights panel
|
|
1128
|
-
else if (key.name ===
|
|
1353
|
+
else if (key.name === "v") {
|
|
1129
1354
|
setShowInsights((prev: boolean) => !prev);
|
|
1130
1355
|
}
|
|
1131
1356
|
// Open detail drawer
|
|
1132
|
-
else if (key.name ===
|
|
1357
|
+
else if (key.name === "enter" || key.name === "return") {
|
|
1133
1358
|
const project = sortedProjects[selectedIndex];
|
|
1134
1359
|
if (project) {
|
|
1135
1360
|
setDrawerProject(project);
|
|
1136
1361
|
}
|
|
1137
1362
|
}
|
|
1138
1363
|
// Filter
|
|
1139
|
-
else if (key.name ===
|
|
1364
|
+
else if (key.name === "/" || key.name === "f") {
|
|
1140
1365
|
setIsFiltering(true);
|
|
1141
1366
|
}
|
|
1142
1367
|
// Clear filter
|
|
1143
|
-
else if (key.name ===
|
|
1368
|
+
else if (key.name === "escape") {
|
|
1144
1369
|
if (filterQuery) {
|
|
1145
|
-
setFilterQuery(
|
|
1370
|
+
setFilterQuery("");
|
|
1146
1371
|
}
|
|
1147
1372
|
}
|
|
1148
|
-
// Jump to top
|
|
1149
|
-
else if (key.name ===
|
|
1150
|
-
setSelectedIndex(0);
|
|
1151
|
-
}
|
|
1152
|
-
else if (key.name === 'G' || (key.name === 'g' && key.shift)) {
|
|
1373
|
+
// Jump to bottom (Shift+G) / top (g)
|
|
1374
|
+
else if (key.shift && key.name === "g") {
|
|
1153
1375
|
setSelectedIndex(Math.max(0, sortedProjects.length - 1));
|
|
1376
|
+
} else if (key.name === "g") {
|
|
1377
|
+
setSelectedIndex(0);
|
|
1378
|
+
scrollOffsetRef.current = 0;
|
|
1154
1379
|
}
|
|
1155
1380
|
},
|
|
1156
1381
|
[drawerProject, isFiltering, sortedProjects, selectedIndex, cycleWindow, filterQuery],
|
|
@@ -1173,7 +1398,9 @@ export function ProjectsView() {
|
|
|
1173
1398
|
<EmptyState hasFilter={filterQuery.length > 0} />
|
|
1174
1399
|
<box flexDirection="row" height={1} paddingLeft={1}>
|
|
1175
1400
|
<text fg={colors.textSubtle}>
|
|
1176
|
-
{filterQuery
|
|
1401
|
+
{filterQuery
|
|
1402
|
+
? "Esc clear filter / edit filter t time window"
|
|
1403
|
+
: "t time window / filter"}
|
|
1177
1404
|
</text>
|
|
1178
1405
|
</box>
|
|
1179
1406
|
</box>
|
|
@@ -1203,7 +1430,9 @@ export function ProjectsView() {
|
|
|
1203
1430
|
|
|
1204
1431
|
{/* Column headers */}
|
|
1205
1432
|
<box flexDirection="row" height={1} marginTop={0}>
|
|
1206
|
-
<text width={2} height={1} fg={colors.textMuted}>
|
|
1433
|
+
<text width={2} height={1} fg={colors.textMuted}>
|
|
1434
|
+
{" "}
|
|
1435
|
+
</text>
|
|
1207
1436
|
{visibleColumns.map((col: ColumnSpec) => (
|
|
1208
1437
|
<box key={col.key} marginRight={1}>
|
|
1209
1438
|
<text width={col.width} height={1} fg={colors.textMuted}>
|
|
@@ -1226,7 +1455,7 @@ export function ProjectsView() {
|
|
|
1226
1455
|
textColor={colors.text}
|
|
1227
1456
|
cursorColor={colors.primary}
|
|
1228
1457
|
/>
|
|
1229
|
-
<text fg={colors.textSubtle}> (esc clear
|
|
1458
|
+
<text fg={colors.textSubtle}> (esc clear enter apply)</text>
|
|
1230
1459
|
</box>
|
|
1231
1460
|
)}
|
|
1232
1461
|
|
|
@@ -1239,7 +1468,7 @@ export function ProjectsView() {
|
|
|
1239
1468
|
borderColor={colors.border}
|
|
1240
1469
|
overflow="hidden"
|
|
1241
1470
|
>
|
|
1242
|
-
<scrollbox flexGrow={1}>
|
|
1471
|
+
<scrollbox ref={scrollboxRef} flexGrow={1}>
|
|
1243
1472
|
<box flexDirection="column">
|
|
1244
1473
|
{sortedProjects.map((project: ProjectStats, index: number) => (
|
|
1245
1474
|
<ProjectRow
|
|
@@ -1266,10 +1495,10 @@ export function ProjectsView() {
|
|
|
1266
1495
|
<box flexDirection="row" height={1} paddingLeft={1} flexShrink={0}>
|
|
1267
1496
|
<text fg={colors.textSubtle} height={1}>
|
|
1268
1497
|
{isFiltering
|
|
1269
|
-
?
|
|
1498
|
+
? "Type to filter Esc cancel Enter apply"
|
|
1270
1499
|
: filterQuery
|
|
1271
1500
|
? `Filter: "${filterQuery}" Esc clear / edit`
|
|
1272
|
-
:
|
|
1501
|
+
: "\u2191\u2193 navigate Enter details s sort t time v insights / filter"}
|
|
1273
1502
|
</text>
|
|
1274
1503
|
</box>
|
|
1275
1504
|
</box>
|