@tokentop/ttop 0.2.2

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.
Files changed (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +273 -0
  3. package/bin/ttop.js +49 -0
  4. package/package.json +68 -0
  5. package/src/agents/aggregator.ts +176 -0
  6. package/src/agents/costing.ts +74 -0
  7. package/src/agents/index.ts +12 -0
  8. package/src/agents/types.ts +71 -0
  9. package/src/cli.ts +167 -0
  10. package/src/config/schema.test.ts +166 -0
  11. package/src/config/schema.ts +165 -0
  12. package/src/demo/simulator.ts +745 -0
  13. package/src/plugins/agents/index.ts +2 -0
  14. package/src/plugins/auth-sources.ts +186 -0
  15. package/src/plugins/lifecycle.ts +177 -0
  16. package/src/plugins/loader.test.ts +195 -0
  17. package/src/plugins/loader.ts +282 -0
  18. package/src/plugins/notification-bus.ts +153 -0
  19. package/src/plugins/notifications/index.ts +2 -0
  20. package/src/plugins/notifications/terminal-bell.ts +66 -0
  21. package/src/plugins/notifications/visual-flash.ts +57 -0
  22. package/src/plugins/npm-installer.ts +164 -0
  23. package/src/plugins/plugin-context-factory.ts +67 -0
  24. package/src/plugins/plugin-host.ts +205 -0
  25. package/src/plugins/providers/anthropic.ts +325 -0
  26. package/src/plugins/providers/antigravity.ts +309 -0
  27. package/src/plugins/providers/codex.ts +250 -0
  28. package/src/plugins/providers/gemini.ts +391 -0
  29. package/src/plugins/providers/github-copilot.ts +194 -0
  30. package/src/plugins/providers/index.ts +12 -0
  31. package/src/plugins/providers/minimax.ts +223 -0
  32. package/src/plugins/providers/openai-api.ts +250 -0
  33. package/src/plugins/providers/opencode-zen.ts +125 -0
  34. package/src/plugins/providers/perplexity.ts +177 -0
  35. package/src/plugins/providers/zai.ts +225 -0
  36. package/src/plugins/registry.ts +251 -0
  37. package/src/plugins/sandbox-guard.test.ts +191 -0
  38. package/src/plugins/sandbox-guard.ts +155 -0
  39. package/src/plugins/sandbox.ts +138 -0
  40. package/src/plugins/themes/catppuccin-latte.ts +45 -0
  41. package/src/plugins/themes/catppuccin-mocha.ts +45 -0
  42. package/src/plugins/themes/claude-code.ts +45 -0
  43. package/src/plugins/themes/dracula.ts +45 -0
  44. package/src/plugins/themes/github-light.ts +45 -0
  45. package/src/plugins/themes/gruvbox-dark.ts +45 -0
  46. package/src/plugins/themes/gruvbox-light.ts +45 -0
  47. package/src/plugins/themes/index.ts +15 -0
  48. package/src/plugins/themes/kanagawa.ts +45 -0
  49. package/src/plugins/themes/nord.ts +45 -0
  50. package/src/plugins/themes/one-dark.ts +45 -0
  51. package/src/plugins/themes/opencode.ts +47 -0
  52. package/src/plugins/themes/rose-pine-dawn.ts +45 -0
  53. package/src/plugins/themes/rose-pine.ts +45 -0
  54. package/src/plugins/themes/solarized-light.ts +45 -0
  55. package/src/plugins/themes/tokyo-night.ts +49 -0
  56. package/src/plugins/types/agent.ts +108 -0
  57. package/src/plugins/types/base.ts +267 -0
  58. package/src/plugins/types/index.ts +19 -0
  59. package/src/plugins/types/notification.ts +51 -0
  60. package/src/plugins/types/provider.ts +233 -0
  61. package/src/plugins/types/theme.ts +70 -0
  62. package/src/plugins/update-checker.ts +177 -0
  63. package/src/pricing/estimator.test.ts +181 -0
  64. package/src/pricing/estimator.ts +80 -0
  65. package/src/pricing/fallback.test.ts +71 -0
  66. package/src/pricing/fallback.ts +62 -0
  67. package/src/pricing/index.ts +66 -0
  68. package/src/pricing/models-dev.ts +130 -0
  69. package/src/storage/database.ts +613 -0
  70. package/src/storage/db.ts +64 -0
  71. package/src/storage/index.ts +66 -0
  72. package/src/storage/migrations/index.ts +189 -0
  73. package/src/storage/paths.ts +24 -0
  74. package/src/storage/repos/agentSessions.ts +210 -0
  75. package/src/storage/repos/providerSnapshots.ts +116 -0
  76. package/src/storage/repos/usageEvents.ts +334 -0
  77. package/src/storage/types.ts +288 -0
  78. package/src/tui/App.tsx +381 -0
  79. package/src/tui/components/CommandPalette.tsx +166 -0
  80. package/src/tui/components/DebugConsole.tsx +161 -0
  81. package/src/tui/components/DebugPanel.tsx +660 -0
  82. package/src/tui/components/FooterHints.tsx +22 -0
  83. package/src/tui/components/GhostProviderCard.tsx +45 -0
  84. package/src/tui/components/Header.tsx +125 -0
  85. package/src/tui/components/HelpOverlay.tsx +45 -0
  86. package/src/tui/components/InlineGauge.tsx +34 -0
  87. package/src/tui/components/InlineSparkline.tsx +78 -0
  88. package/src/tui/components/KpiStrip.tsx +246 -0
  89. package/src/tui/components/LimitGauge.tsx +186 -0
  90. package/src/tui/components/ModalBackdrop.tsx +42 -0
  91. package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
  92. package/src/tui/components/ProviderCard.tsx +309 -0
  93. package/src/tui/components/ProviderDetailPanel.tsx +237 -0
  94. package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
  95. package/src/tui/components/ProvidersList.tsx +380 -0
  96. package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
  97. package/src/tui/components/SessionsTable.tsx +401 -0
  98. package/src/tui/components/SettingsModal.tsx +587 -0
  99. package/src/tui/components/SidebarBreakdown.tsx +87 -0
  100. package/src/tui/components/Skeleton.tsx +102 -0
  101. package/src/tui/components/SmartSidebar.tsx +644 -0
  102. package/src/tui/components/Sparkline.tsx +180 -0
  103. package/src/tui/components/Spinner.tsx +36 -0
  104. package/src/tui/components/StatusBar.tsx +96 -0
  105. package/src/tui/components/ThemePicker.tsx +234 -0
  106. package/src/tui/components/Toast.tsx +61 -0
  107. package/src/tui/components/UsageGauge.tsx +80 -0
  108. package/src/tui/components/index.ts +5 -0
  109. package/src/tui/contexts/AgentSessionContext.tsx +307 -0
  110. package/src/tui/contexts/ConfigContext.tsx +141 -0
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
  112. package/src/tui/contexts/DemoModeContext.tsx +60 -0
  113. package/src/tui/contexts/DrawerContext.tsx +42 -0
  114. package/src/tui/contexts/InputContext.tsx +30 -0
  115. package/src/tui/contexts/LogContext.tsx +150 -0
  116. package/src/tui/contexts/PluginContext.tsx +424 -0
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
  118. package/src/tui/contexts/StorageContext.tsx +233 -0
  119. package/src/tui/contexts/ThemeContext.tsx +95 -0
  120. package/src/tui/contexts/TimeWindowContext.tsx +158 -0
  121. package/src/tui/contexts/ToastContext.tsx +40 -0
  122. package/src/tui/contexts/index.ts +5 -0
  123. package/src/tui/createApp.tsx +47 -0
  124. package/src/tui/debug/captureFrame.ts +262 -0
  125. package/src/tui/debug/snapshot.tsx +644 -0
  126. package/src/tui/driver/assertions.ts +279 -0
  127. package/src/tui/driver/cli.ts +717 -0
  128. package/src/tui/driver/coverage.ts +204 -0
  129. package/src/tui/driver/demo-snapshot.ts +66 -0
  130. package/src/tui/driver/diff.ts +166 -0
  131. package/src/tui/driver/driver.ts +270 -0
  132. package/src/tui/driver/index.ts +53 -0
  133. package/src/tui/driver/recorder.ts +303 -0
  134. package/src/tui/driver/test-workflow.ts +115 -0
  135. package/src/tui/driver/test.ts +47 -0
  136. package/src/tui/hooks/index.ts +20 -0
  137. package/src/tui/hooks/useAnimatedValue.ts +113 -0
  138. package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
  139. package/src/tui/hooks/useDashboardState.ts +116 -0
  140. package/src/tui/hooks/useEmaActivity.ts +223 -0
  141. package/src/tui/hooks/useEntranceAnimation.ts +62 -0
  142. package/src/tui/hooks/useExitAnimation.ts +137 -0
  143. package/src/tui/hooks/usePulse.ts +138 -0
  144. package/src/tui/hooks/useSafeRenderer.ts +24 -0
  145. package/src/tui/hooks/useValueFlash.ts +111 -0
  146. package/src/tui/index.tsx +50 -0
  147. package/src/tui/utils/providerColor.ts +32 -0
  148. package/src/tui/views/Dashboard.tsx +340 -0
  149. package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
  150. package/src/tui/views/ProjectsView.tsx +1277 -0
  151. package/src/tui/views/RealTimeDashboard.tsx +393 -0
  152. package/src/tui/views/SettingsView.tsx +465 -0
  153. package/src/tui/views/index.ts +2 -0
  154. package/src/types/global.d.ts +5 -0
  155. package/src/types/opentui-test-utils.d.ts +18 -0
  156. package/src/utils/clipboard.ts +118 -0
  157. package/src/utils/currency.test.ts +106 -0
  158. package/src/utils/currency.ts +79 -0
  159. package/src/version.ts +1 -0
@@ -0,0 +1,45 @@
1
+ import { forwardRef } from 'react';
2
+ import type { BoxRenderable } from '@opentui/core';
3
+ import { useColors } from '../contexts/ThemeContext.tsx';
4
+
5
+ interface GhostProviderCardProps {
6
+ name: string;
7
+ focused?: boolean;
8
+ onFocus?: () => void;
9
+ }
10
+
11
+ export const GhostProviderCard = forwardRef<BoxRenderable, GhostProviderCardProps>(({
12
+ name,
13
+ focused = false,
14
+ onFocus,
15
+ }, ref) => {
16
+ const colors = useColors();
17
+
18
+ return (
19
+ <box
20
+ ref={ref}
21
+ border
22
+ borderStyle="rounded"
23
+ borderColor={focused ? colors.textSubtle : colors.textSubtle}
24
+ padding={1}
25
+ flexDirection="column"
26
+ gap={1}
27
+ width={44}
28
+ minHeight={8}
29
+ {...(onFocus ? { onMouseDown: onFocus } : {})}
30
+ >
31
+ <box flexDirection="row" justifyContent="space-between" alignItems="center">
32
+ <text fg={colors.textSubtle}>
33
+ {name}
34
+ </text>
35
+ <text fg={colors.textSubtle}>○</text>
36
+ </box>
37
+
38
+ <box flexDirection="column" flexGrow={1} justifyContent="center" alignItems="center" gap={1}>
39
+ <text fg={colors.textSubtle}>Not configured</text>
40
+ <text fg={colors.textMuted}>Set API key to enable</text>
41
+ </box>
42
+ </box>
43
+ );
44
+ });
45
+ GhostProviderCard.displayName = 'GhostProviderCard';
@@ -0,0 +1,125 @@
1
+ import { useTerminalDimensions } from '@opentui/react';
2
+ import { useColors, useTheme } from '../contexts/ThemeContext.tsx';
3
+ import { VERSION } from '../../version.ts';
4
+
5
+ interface HeaderProps {
6
+ title?: string;
7
+ subtitle?: string;
8
+ activeView?: 'dashboard' | 'providers' | 'trends' | 'projects';
9
+ demoMode?: boolean;
10
+ }
11
+
12
+ const ASCII_LOGO = [
13
+ '████████╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗████████╗ ██████╗ ██████╗ ',
14
+ '╚══██╔══╝██╔═══██╗██║ ██╔╝██╔════╝████╗ ██║╚══██╔══╝██╔═══██╗██╔══██╗',
15
+ ' ██║ ██║ ██║█████╔╝ █████╗ ██╔██╗ ██║ ██║ ██║ ██║██████╔╝',
16
+ ' ██║ ██║ ██║██╔═██╗ ██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██╔═══╝ ',
17
+ ' ██║ ╚██████╔╝██║ ██╗███████╗██║ ╚████║ ██║ ╚██████╔╝██║ ',
18
+ ' ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ ',
19
+ ];
20
+
21
+ const MIN_HEIGHT_FOR_LARGE_LOGO = 35;
22
+
23
+ export function Header({ title = 'tokentop', subtitle, activeView, demoMode = false }: HeaderProps) {
24
+ const colors = useColors();
25
+ const { components } = useTheme();
26
+ const { height } = useTerminalDimensions();
27
+
28
+ const isDashboard = activeView === 'dashboard';
29
+ const isProviders = activeView === 'providers';
30
+ const isTrends = activeView === 'trends';
31
+ const isProjects = activeView === 'projects';
32
+ const useLargeLogo = height >= MIN_HEIGHT_FOR_LARGE_LOGO;
33
+
34
+ const titleColor = components.header?.titleColor;
35
+ const titleAccentColor = components.header?.titleAccentColor;
36
+ const hasSplitTitle = titleColor && titleAccentColor && title === 'tokentop';
37
+
38
+ const headerHeight = useLargeLogo ? 7 : 1;
39
+
40
+ return (
41
+ <box
42
+ flexDirection="column"
43
+ backgroundColor={colors.foreground}
44
+ flexShrink={0}
45
+ height={headerHeight}
46
+ paddingX={1}
47
+ overflow="hidden"
48
+ >
49
+ {useLargeLogo && (
50
+ <box flexDirection="column" alignItems="center" height={6}>
51
+ {ASCII_LOGO.map((line, idx) => {
52
+ if (hasSplitTitle) {
53
+ const splitAt = 44;
54
+ return (
55
+ <text key={idx} height={1}>
56
+ <span fg={titleColor}>{line.slice(0, splitAt)}</span>
57
+ <span fg={titleAccentColor}>{line.slice(splitAt)}</span>
58
+ </text>
59
+ );
60
+ }
61
+ return <text key={idx} height={1} fg={colors.primary}>{line}</text>;
62
+ })}
63
+ </box>
64
+ )}
65
+ <box flexDirection="row" justifyContent="space-between" alignItems="center" height={1}>
66
+ {!useLargeLogo && (
67
+ <box flexDirection="row" gap={1} alignItems="center" height={1}>
68
+ {hasSplitTitle ? (
69
+ <text height={1}>
70
+ <span fg={titleColor}><strong>token</strong></span>
71
+ <span fg={titleAccentColor}><strong>top</strong></span>
72
+ </text>
73
+ ) : (
74
+ <text height={1}>
75
+ <span fg={colors.primary}>
76
+ <strong>{title}</strong>
77
+ </span>
78
+ </text>
79
+ )}
80
+ {demoMode && (
81
+ <text height={1} fg={colors.warning}><strong>DEMO</strong></text>
82
+ )}
83
+ {subtitle && (
84
+ <text height={1} fg={colors.textMuted}>{subtitle}</text>
85
+ )}
86
+ </box>
87
+ )}
88
+ {useLargeLogo && <box width={10} />}
89
+ {activeView && (
90
+ <box flexDirection="row" gap={useLargeLogo ? 1 : 0} marginLeft={useLargeLogo ? 0 : 2} alignItems="center" height={1}>
91
+ <text height={1}>
92
+ {isDashboard ? (
93
+ <span bg={colors.primary} fg={colors.background}><strong> 1 DASHBOARD </strong></span>
94
+ ) : (
95
+ <span fg={colors.textMuted}> 1 DASHBOARD </span>
96
+ )}
97
+ </text>
98
+ <text height={1}>
99
+ {isProviders ? (
100
+ <span bg={colors.primary} fg={colors.background}><strong> 2 PROVIDERS </strong></span>
101
+ ) : (
102
+ <span fg={colors.textMuted}> 2 PROVIDERS </span>
103
+ )}
104
+ </text>
105
+ <text height={1}>
106
+ {isTrends ? (
107
+ <span bg={colors.primary} fg={colors.background}><strong> 3 TRENDS </strong></span>
108
+ ) : (
109
+ <span fg={colors.textMuted}> 3 TRENDS </span>
110
+ )}
111
+ </text>
112
+ <text height={1}>
113
+ {isProjects ? (
114
+ <span bg={colors.primary} fg={colors.background}><strong> 4 PROJECTS </strong></span>
115
+ ) : (
116
+ <span fg={colors.textMuted}> 4 PROJECTS </span>
117
+ )}
118
+ </text>
119
+ </box>
120
+ )}
121
+ <text height={1} fg={colors.textMuted}>v{VERSION}</text>
122
+ </box>
123
+ </box>
124
+ );
125
+ }
@@ -0,0 +1,45 @@
1
+ import { useTerminalDimensions } from '@opentui/react';
2
+ import { useColors } from '../contexts/ThemeContext.tsx';
3
+ import { ModalBackdrop, Z_INDEX } from './ModalBackdrop.tsx';
4
+
5
+ export function HelpOverlay() {
6
+ const colors = useColors();
7
+ const { height: termHeight } = useTerminalDimensions();
8
+ const isCompact = termHeight < 28;
9
+
10
+ return (
11
+ <ModalBackdrop zIndex={Z_INDEX.MODAL}>
12
+ <box
13
+ width={46}
14
+ height={isCompact ? 18 : 20}
15
+ border
16
+ borderStyle="double"
17
+ borderColor={colors.primary}
18
+ flexDirection="column"
19
+ padding={1}
20
+ backgroundColor={colors.background}
21
+ overflow="hidden"
22
+ >
23
+ <box justifyContent="center" height={1}><text height={1}><strong>Help</strong></text></box>
24
+
25
+ <box height={1} marginTop={1}><text height={1} fg={colors.primary}><strong>Navigation</strong></text></box>
26
+ <box flexDirection="row" height={1}><text width={10} height={1} fg={colors.textMuted}>↑↓ j/k</text><text height={1}>Navigate</text></box>
27
+ <box flexDirection="row" height={1}><text width={10} height={1} fg={colors.textMuted}>Enter</text><text height={1}>View details</text></box>
28
+
29
+ <box height={1} marginTop={1}><text height={1} fg={colors.primary}><strong>Actions</strong></text></box>
30
+ <box flexDirection="row" height={1}><text width={10} height={1} fg={colors.textMuted}>/</text><text height={1}>Filter</text></box>
31
+ <box flexDirection="row" height={1}><text width={10} height={1} fg={colors.textMuted}>s</text><text height={1}>Sort</text></box>
32
+ <box flexDirection="row" height={1}><text width={10} height={1} fg={colors.textMuted}>t</text><text height={1}>Time window</text></box>
33
+
34
+ <box height={1} marginTop={1}><text height={1} fg={colors.primary}><strong>Global</strong></text></box>
35
+ <box flexDirection="row" height={1}><text width={10} height={1} fg={colors.textMuted}>1-4</text><text height={1}>Switch views</text></box>
36
+ <box flexDirection="row" height={1}><text width={10} height={1} fg={colors.textMuted}>,</text><text height={1}>Settings</text></box>
37
+ <box flexDirection="row" height={1}><text width={10} height={1} fg={colors.textMuted}>:</text><text height={1}>Command palette</text></box>
38
+ <box flexDirection="row" height={1}><text width={10} height={1} fg={colors.textMuted}>~</text><text height={1}>Debug panel</text></box>
39
+ <box flexDirection="row" height={1}><text width={10} height={1} fg={colors.textMuted}>q</text><text height={1}>Quit</text></box>
40
+
41
+ <box justifyContent="center" height={1} marginTop={1}><text height={1} fg={colors.textMuted}>? or Esc to close</text></box>
42
+ </box>
43
+ </ModalBackdrop>
44
+ );
45
+ }
@@ -0,0 +1,34 @@
1
+ import { useColors } from '../contexts/ThemeContext.tsx';
2
+
3
+ interface InlineGaugeProps {
4
+ percent: number | null;
5
+ width?: number;
6
+ color?: string;
7
+ }
8
+
9
+ export function InlineGauge({ percent, width = 20, color }: InlineGaugeProps) {
10
+ const colors = useColors();
11
+
12
+ if (percent === null || percent === undefined) {
13
+ const emptyBar = '·'.repeat(width);
14
+ return <text fg={colors.textSubtle}>{emptyBar}</text>;
15
+ }
16
+
17
+ const clamped = Math.max(0, Math.min(100, percent));
18
+ const filledWidth = Math.round((clamped / 100) * width);
19
+ const emptyWidth = width - filledWidth;
20
+
21
+ const fillColor = clamped >= 90 ? colors.gaugeDanger :
22
+ clamped >= 70 ? colors.gaugeWarning :
23
+ color ?? colors.gaugeFill;
24
+
25
+ const filledBar = '█'.repeat(filledWidth);
26
+ const emptyBar = '·'.repeat(emptyWidth);
27
+
28
+ return (
29
+ <text>
30
+ <span fg={fillColor}>{filledBar}</span>
31
+ <span fg={colors.gaugeBackground}>{emptyBar}</span>
32
+ </text>
33
+ );
34
+ }
@@ -0,0 +1,78 @@
1
+ import { useColors } from '../contexts/ThemeContext.tsx';
2
+ import type { UsageSnapshot } from '../contexts/PluginContext.tsx';
3
+
4
+ interface InlineSparklineProps {
5
+ history: UsageSnapshot[];
6
+ width?: number;
7
+ }
8
+
9
+ function valueToBraille(left: number, right: number): string {
10
+ const leftDots = Math.min(4, Math.max(0, Math.floor(left)));
11
+ const rightDots = Math.min(4, Math.max(0, Math.floor(right)));
12
+
13
+ let code = 0x2800;
14
+ const leftBits = [0x40, 0x04, 0x02, 0x01];
15
+ const rightBits = [0x80, 0x20, 0x10, 0x08];
16
+
17
+ code |= 0x40 | 0x80;
18
+
19
+ for (let i = 0; i < leftDots; i++) {
20
+ code |= leftBits[i]!;
21
+ }
22
+ for (let i = 0; i < rightDots; i++) {
23
+ code |= rightBits[i]!;
24
+ }
25
+
26
+ return String.fromCharCode(code);
27
+ }
28
+
29
+ export function InlineSparkline({ history, width = 8 }: InlineSparklineProps) {
30
+ const colors = useColors();
31
+
32
+ if (history.length < 2) {
33
+ return <text fg={colors.textSubtle}>{'⣀'.repeat(width)}</text>;
34
+ }
35
+
36
+ const values = history.map(s => s.usedPercent ?? 0);
37
+ const effectiveWidth = width * 2;
38
+ const displayData = values.slice(-effectiveWidth);
39
+
40
+ const max = Math.max(...displayData, 1);
41
+ const normalize = (v: number): number => (v / max) * 4;
42
+
43
+ const groups: { color: string; chars: string }[] = [];
44
+
45
+ for (let i = 0; i < displayData.length; i += 2) {
46
+ const leftRaw = displayData[i] ?? 0;
47
+ const rightRaw = displayData[i + 1] ?? leftRaw;
48
+
49
+ const leftNorm = normalize(leftRaw);
50
+ const rightNorm = normalize(rightRaw);
51
+
52
+ const dominantRaw = Math.max(leftRaw, rightRaw);
53
+ const color = dominantRaw >= 90 ? colors.error :
54
+ dominantRaw >= 70 ? colors.warning :
55
+ dominantRaw > 0 ? colors.success :
56
+ colors.textSubtle;
57
+
58
+ const char = dominantRaw <= 0 ? '⣀' : valueToBraille(leftNorm, rightNorm);
59
+
60
+ if (groups.length > 0 && groups[groups.length - 1]!.color === color) {
61
+ groups[groups.length - 1]!.chars += char;
62
+ } else {
63
+ groups.push({ color, chars: char });
64
+ }
65
+ }
66
+
67
+ const charCount = Math.ceil(displayData.length / 2);
68
+ const padding = width - charCount;
69
+
70
+ return (
71
+ <text>
72
+ {padding > 0 && <span fg={colors.textSubtle}>{'⣀'.repeat(padding)}</span>}
73
+ {groups.map((group, i) => (
74
+ <span key={i} fg={group.color}>{group.chars}</span>
75
+ ))}
76
+ </text>
77
+ );
78
+ }
@@ -0,0 +1,246 @@
1
+ import { useTerminalDimensions } from '@opentui/react';
2
+ import { useColors } from '../contexts/ThemeContext.tsx';
3
+ import { useTimeWindow, type TimeWindow } from '../contexts/TimeWindowContext.tsx';
4
+ import { useConfig } from '../contexts/ConfigContext.tsx';
5
+ import { Sparkline } from './Sparkline.tsx';
6
+ import { useValueFlash, interpolateColor } from '../hooks/useValueFlash.ts';
7
+ import { useAnimatedValue } from '../hooks/useAnimatedValue.ts';
8
+
9
+ type MetricType = 'cost' | 'tokens' | 'requests' | 'rate' | 'default';
10
+
11
+ interface KPICardProps {
12
+ title: string;
13
+ value: string;
14
+ delta?: string;
15
+ subValue?: string;
16
+ metric?: MetricType;
17
+ flashIntensity?: number;
18
+ }
19
+
20
+ const TIME_WINDOW_EXPANDED: Record<TimeWindow, string> = {
21
+ '5m': 'Last 5 Minutes',
22
+ '15m': 'Last 15 Minutes',
23
+ '1h': 'Last Hour',
24
+ '24h': 'Last 24 Hours',
25
+ '7d': 'Last 7 Days',
26
+ '30d': 'Last 30 Days',
27
+ 'all': 'All Time',
28
+ };
29
+
30
+ interface TimeWindowCardProps {
31
+ isCompact: boolean;
32
+ }
33
+
34
+ function TimeWindowCard({ isCompact }: TimeWindowCardProps) {
35
+ const colors = useColors();
36
+ const { window: timeWindow } = useTimeWindow();
37
+
38
+ const title = isCompact ? 'WINDOW' : 'TIME WINDOW (t)';
39
+ const value = isCompact ? timeWindow.toUpperCase() : TIME_WINDOW_EXPANDED[timeWindow];
40
+ const hint = isCompact ? '(t)' : "Press 't' to change";
41
+
42
+ return (
43
+ <box
44
+ flexDirection="column"
45
+ paddingX={1}
46
+ flexShrink={0}
47
+ width={isCompact ? 9 : 22}
48
+ >
49
+ <text fg={colors.textMuted} height={1}>{title}</text>
50
+ <text fg={colors.info} height={1}><strong>{value}</strong></text>
51
+ <text fg={colors.textSubtle} height={1}>{hint}</text>
52
+ </box>
53
+ );
54
+ }
55
+
56
+ function KPICard({ title, value, delta, subValue, metric = 'default', flashIntensity = 0 }: KPICardProps) {
57
+ const colors = useColors();
58
+
59
+ const getBaseMetricColor = (m: MetricType): string => {
60
+ switch (m) {
61
+ case 'cost': return colors.success;
62
+ case 'tokens': return colors.primary;
63
+ case 'requests': return colors.secondary;
64
+ case 'rate': return colors.warning;
65
+ default: return colors.text;
66
+ }
67
+ };
68
+
69
+ const baseColor = getBaseMetricColor(metric);
70
+ const flashHighlight = '#ffffff';
71
+ const valueColor = flashIntensity > 0
72
+ ? interpolateColor(flashIntensity, baseColor, flashHighlight)
73
+ : baseColor;
74
+
75
+ const deltaColor = colors.success;
76
+
77
+ return (
78
+ <box
79
+ flexDirection="column"
80
+ paddingLeft={1}
81
+ paddingRight={2}
82
+ flexGrow={1}
83
+ >
84
+ <text fg={colors.textMuted} height={1}>{title}</text>
85
+ <text fg={valueColor} height={1}><strong>{value}</strong></text>
86
+ {delta && <text fg={deltaColor} height={1}>{delta}</text>}
87
+ {subValue && <text fg={colors.textMuted} height={1}>{subValue}</text>}
88
+ </box>
89
+ );
90
+ }
91
+
92
+ export interface ActivityStatus {
93
+ label: string;
94
+ color: string;
95
+ }
96
+
97
+ export interface BudgetInfo {
98
+ limit: number | null;
99
+ budgetCost: number;
100
+ budgetType: 'daily' | 'weekly' | 'monthly' | 'none';
101
+ budgetTypeLabel: string;
102
+ warningPercent: number;
103
+ criticalPercent: number;
104
+ }
105
+
106
+ export interface KpiStripProps {
107
+ totalCost: number;
108
+ totalTokens: number;
109
+ totalRequests: number;
110
+ activeCount: number;
111
+ deltaCost: number;
112
+ deltaTokens: number;
113
+ windowSec: number;
114
+ activity: { instantRate: number; avgRate: number; isSpike: boolean };
115
+ sparkData: number[];
116
+ budget?: BudgetInfo;
117
+ }
118
+
119
+ export function KpiStrip({
120
+ totalCost,
121
+ totalTokens,
122
+ totalRequests,
123
+ activeCount,
124
+ deltaCost,
125
+ deltaTokens,
126
+ windowSec,
127
+ activity,
128
+ sparkData,
129
+ }: KpiStripProps) {
130
+ const colors = useColors();
131
+ const { width: terminalWidth } = useTerminalDimensions();
132
+ const { config } = useConfig();
133
+ const { sparkline: sparklineConfig } = config.display;
134
+
135
+ const animatedCost = useAnimatedValue(totalCost, { durationMs: 400, precision: 2 });
136
+ const animatedTokens = useAnimatedValue(totalTokens, { durationMs: 400, precision: 0 });
137
+ const animatedRequests = useAnimatedValue(totalRequests, { durationMs: 300, precision: 0 });
138
+
139
+ const { intensity: costFlashIntensity } = useValueFlash(totalCost, {
140
+ durationMs: 500,
141
+ increaseOnly: true,
142
+ threshold: 0.001
143
+ });
144
+ const { intensity: tokenFlashIntensity } = useValueFlash(totalTokens, {
145
+ durationMs: 400,
146
+ increaseOnly: true,
147
+ threshold: 10
148
+ });
149
+
150
+ const formatCurrency = (val: number) => `$${val.toFixed(2)}`;
151
+ const formatTokens = (val: number) => val > 1000000 ? `${(val/1000000).toFixed(1)}M` : `${(val/1000).toFixed(1)}K`;
152
+ const formatRate = (val: number) => val >= 1000 ? `${(val/1000).toFixed(1)}k` : `${Math.round(val)}`;
153
+ const formatBurnTokens = (val: number) => val >= 1000 ? `${(val/1000).toFixed(1)}k` : `${Math.round(val)}`;
154
+
155
+ const formatWindowLabel = (sec: number): string => {
156
+ if (sec < 60) return `${Math.round(sec)}s`;
157
+ if (sec < 3600) return `${Math.round(sec / 60)}m`;
158
+ return `${(sec / 3600).toFixed(1)}h`;
159
+ };
160
+
161
+ const hasEnoughHistory = windowSec >= 30;
162
+ const burnRateCostPerHour = hasEnoughHistory ? deltaCost * (3600 / windowSec) : 0;
163
+ const burnRateTokensPerMin = hasEnoughHistory ? deltaTokens * (60 / windowSec) : 0;
164
+ const windowLabel = formatWindowLabel(windowSec);
165
+
166
+ const getActivityStatus = (): ActivityStatus => {
167
+ const { instantRate, avgRate, isSpike } = activity;
168
+ if (isSpike || instantRate >= 500) return { label: 'SPIKE', color: colors.error };
169
+ if (avgRate >= 150) return { label: 'HOT', color: colors.warning };
170
+ if (avgRate >= 50) return { label: 'BUSY', color: colors.success };
171
+ if (avgRate >= 10) return { label: 'LOW', color: colors.textMuted };
172
+ return { label: 'IDLE', color: colors.textSubtle };
173
+ };
174
+
175
+ const activityStatus = getActivityStatus();
176
+
177
+ const isCompact = terminalWidth < 100;
178
+ const sparklineWidth = isCompact
179
+ ? Math.min(25, Math.max(15, Math.floor(terminalWidth * 0.2)))
180
+ : Math.min(50, Math.max(20, Math.floor(terminalWidth * 0.25)));
181
+
182
+ // Fixed max based on thresholds so bar HEIGHT reflects actual activity level
183
+ // This ensures green (low), yellow (medium), red (high) show distinct bar sizes
184
+ // 600 = 1.2x the SPIKE threshold (500), giving headroom for extreme spikes
185
+ const sparkMax = 600;
186
+
187
+ return (
188
+ <>
189
+ <box flexDirection="row" gap={0} height={4} flexShrink={0}>
190
+ <TimeWindowCard isCompact={isCompact} />
191
+ <KPICard
192
+ title="COST"
193
+ value={formatCurrency(animatedCost)}
194
+ delta={hasEnoughHistory ? `+${formatCurrency(deltaCost)} (${windowLabel})` : 'gathering...'}
195
+ metric="cost"
196
+ flashIntensity={costFlashIntensity}
197
+ />
198
+ <KPICard
199
+ title="TOKENS"
200
+ value={formatTokens(animatedTokens)}
201
+ delta={hasEnoughHistory ? `+${formatTokens(deltaTokens)} (${windowLabel})` : 'gathering...'}
202
+ metric="tokens"
203
+ flashIntensity={tokenFlashIntensity}
204
+ />
205
+ <KPICard
206
+ title="REQUESTS"
207
+ value={Math.round(animatedRequests).toLocaleString()}
208
+ subValue={`${activeCount} active`}
209
+ metric="requests"
210
+ />
211
+ {!isCompact && (
212
+ <KPICard
213
+ title="BURN RATE"
214
+ value={hasEnoughHistory ? `${formatCurrency(burnRateCostPerHour)}/hr` : '--'}
215
+ subValue={hasEnoughHistory ? `${formatBurnTokens(burnRateTokensPerMin)} tok/min` : 'gathering...'}
216
+ metric="rate"
217
+ />
218
+ )}
219
+
220
+ <box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
221
+ <box flexDirection="row" justifyContent="space-between">
222
+ <text fg={colors.textMuted}>ACTIVITY</text>
223
+ <text>
224
+ <span fg={activityStatus.color}>{activityStatus.label}</span>
225
+ <span fg={colors.textMuted}> {formatRate(activity.instantRate)}/s</span>
226
+ </text>
227
+ </box>
228
+ <Sparkline
229
+ data={sparkData}
230
+ width={sparklineWidth}
231
+ label="tok/s"
232
+ fixedMax={sparkMax}
233
+ thresholds={{ warning: 150, error: 500 }}
234
+ style={sparklineConfig.style}
235
+ orientation={sparklineConfig.orientation}
236
+ showBaseline={sparklineConfig.showBaseline}
237
+ />
238
+ </box>
239
+ </box>
240
+
241
+ <box height={1} overflow="hidden">
242
+ <text fg={colors.border}>{'─'.repeat(300)}</text>
243
+ </box>
244
+ </>
245
+ );
246
+ }