@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,180 @@
1
+ import { useColors } from '../contexts/ThemeContext.tsx';
2
+ import type { SparklineStyle, SparklineOrientation } from '@/config/schema.ts';
3
+
4
+ export interface SparklineProps {
5
+ data: number[];
6
+ width?: number;
7
+ label?: string;
8
+ fixedMax?: number;
9
+ thresholds?: { warning: number; error: number };
10
+ style?: SparklineStyle;
11
+ orientation?: SparklineOrientation;
12
+ showBaseline?: boolean;
13
+ }
14
+
15
+ const BLOCK_CHARS_UP = ['▁', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
16
+ const BLOCK_CHARS_DOWN = ['▔', '▔', '🬂', '🬎', '▀', '🬩', '🬷', '🮂', '█'];
17
+
18
+ function valueToBraille(
19
+ left: number,
20
+ right: number,
21
+ orientation: SparklineOrientation,
22
+ includeBaseline: boolean
23
+ ): string {
24
+ const leftDots = Math.min(4, Math.max(0, Math.floor(left)));
25
+ const rightDots = Math.min(4, Math.max(0, Math.floor(right)));
26
+
27
+ let code = 0x2800;
28
+
29
+ if (orientation === 'up') {
30
+ // Fill from bottom to top: dot7, dot3, dot2, dot1 (left) / dot8, dot6, dot5, dot4 (right)
31
+ const leftBitsUp = [0x40, 0x04, 0x02, 0x01];
32
+ const rightBitsUp = [0x80, 0x20, 0x10, 0x08];
33
+
34
+ if (includeBaseline) {
35
+ code |= 0x40 | 0x80; // Always include bottom row when baseline is on
36
+ }
37
+
38
+ for (let i = 0; i < leftDots; i++) {
39
+ code |= leftBitsUp[i]!;
40
+ }
41
+ for (let i = 0; i < rightDots; i++) {
42
+ code |= rightBitsUp[i]!;
43
+ }
44
+ } else {
45
+ // Fill from top to bottom: dot1, dot2, dot3, dot7 (left) / dot4, dot5, dot6, dot8 (right)
46
+ const leftBitsDown = [0x01, 0x02, 0x04, 0x40];
47
+ const rightBitsDown = [0x08, 0x10, 0x20, 0x80];
48
+
49
+ if (includeBaseline) {
50
+ code |= 0x01 | 0x08; // Always include top row when baseline is on
51
+ }
52
+
53
+ for (let i = 0; i < leftDots; i++) {
54
+ code |= leftBitsDown[i]!;
55
+ }
56
+ for (let i = 0; i < rightDots; i++) {
57
+ code |= rightBitsDown[i]!;
58
+ }
59
+ }
60
+
61
+ return String.fromCharCode(code);
62
+ }
63
+
64
+ function getBaselineBraille(orientation: SparklineOrientation): string {
65
+ return orientation === 'up' ? '⣀' : '⠉';
66
+ }
67
+
68
+ function getValueColor(
69
+ rawValue: number,
70
+ thresholds: { warning: number; error: number },
71
+ colors: { success: string; warning: string; error: string; textSubtle: string }
72
+ ): string {
73
+ if (rawValue <= 0) return colors.textSubtle;
74
+ if (rawValue >= thresholds.error) return colors.error;
75
+ if (rawValue >= thresholds.warning) return colors.warning;
76
+ return colors.success;
77
+ }
78
+
79
+ export function Sparkline({
80
+ data,
81
+ width = 60,
82
+ label,
83
+ fixedMax = 2000,
84
+ thresholds = { warning: 800, error: 2000 },
85
+ style = 'braille',
86
+ orientation = 'up',
87
+ showBaseline = true,
88
+ }: SparklineProps) {
89
+ const colors = useColors();
90
+
91
+ const peak = Math.max(...data, 0);
92
+ const sqrtMax = Math.sqrt(fixedMax);
93
+
94
+ const normalizeValue = (v: number): number => {
95
+ if (v <= 0) return 0;
96
+ const sqrtValue = Math.sqrt(Math.min(v, fixedMax));
97
+ return (sqrtValue / sqrtMax) * (style === 'braille' ? 4 : 8);
98
+ };
99
+
100
+ const peakLabel = peak >= 1000 ? `${(peak/1000).toFixed(1)}k` : `${Math.round(peak)}`;
101
+
102
+ if (style === 'braille') {
103
+ const effectiveWidth = width * 2;
104
+ const displayData = data.slice(-effectiveWidth);
105
+ const charCount = Math.ceil(displayData.length / 2);
106
+ const padding = width - charCount;
107
+
108
+ const groups: { color: string; chars: string }[] = [];
109
+ const baselineChar = getBaselineBraille(orientation);
110
+
111
+ for (let i = 0; i < displayData.length; i += 2) {
112
+ const leftRaw = displayData[i] ?? 0;
113
+ const rightRaw = displayData[i + 1] ?? leftRaw;
114
+
115
+ const leftNorm = normalizeValue(leftRaw);
116
+ const rightNorm = normalizeValue(rightRaw);
117
+
118
+ const dominantRaw = Math.max(leftRaw, rightRaw);
119
+ const color = getValueColor(dominantRaw, thresholds, colors);
120
+
121
+ let char: string;
122
+ if (dominantRaw <= 0) {
123
+ char = showBaseline ? baselineChar : ' ';
124
+ } else {
125
+ char = valueToBraille(leftNorm, rightNorm, orientation, showBaseline);
126
+ }
127
+
128
+ if (groups.length > 0 && groups[groups.length - 1]!.color === color) {
129
+ groups[groups.length - 1]!.chars += char;
130
+ } else {
131
+ groups.push({ color, chars: char });
132
+ }
133
+ }
134
+
135
+ return (
136
+ <box flexDirection="column">
137
+ <text>
138
+ {padding > 0 && <span>{' '.repeat(padding)}</span>}
139
+ {groups.map((group, i) => (
140
+ <span key={i} fg={group.color}>{group.chars}</span>
141
+ ))}
142
+ </text>
143
+ {label && <text fg={colors.textMuted}>{label} peak:{peakLabel}</text>}
144
+ </box>
145
+ );
146
+ }
147
+
148
+ const blockChars = orientation === 'up' ? BLOCK_CHARS_UP : BLOCK_CHARS_DOWN;
149
+ const baselineChar = showBaseline ? (orientation === 'up' ? '▁' : '▔') : '·';
150
+
151
+ const displayData = data.slice(-width);
152
+ const padding = width - displayData.length;
153
+
154
+ const groups: { color: string; chars: string }[] = [];
155
+ for (let i = 0; i < displayData.length; i++) {
156
+ const rawValue = displayData[i] ?? 0;
157
+ const v = Math.round(normalizeValue(rawValue));
158
+
159
+ const color = getValueColor(rawValue, thresholds, colors);
160
+ const char = rawValue <= 0 ? baselineChar : (blockChars[v] ?? blockChars[1] ?? '▁');
161
+
162
+ if (groups.length > 0 && groups[groups.length - 1]!.color === color) {
163
+ groups[groups.length - 1]!.chars += char;
164
+ } else {
165
+ groups.push({ color, chars: char });
166
+ }
167
+ }
168
+
169
+ return (
170
+ <box flexDirection="column">
171
+ <text>
172
+ {padding > 0 && <span>{' '.repeat(padding)}</span>}
173
+ {groups.map((group, i) => (
174
+ <span key={i} fg={group.color}>{group.chars}</span>
175
+ ))}
176
+ </text>
177
+ {label && <text fg={colors.textMuted}>{label} peak:{peakLabel}</text>}
178
+ </box>
179
+ );
180
+ }
@@ -0,0 +1,36 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4
+ const FRAME_INTERVAL = 80;
5
+
6
+ interface SpinnerProps {
7
+ color?: string;
8
+ }
9
+
10
+ export function Spinner({ color }: SpinnerProps) {
11
+ const [frameIndex, setFrameIndex] = useState(0);
12
+
13
+ useEffect(() => {
14
+ const interval = setInterval(() => {
15
+ setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
16
+ }, FRAME_INTERVAL);
17
+
18
+ return () => clearInterval(interval);
19
+ }, []);
20
+
21
+ return <text {...(color ? { fg: color } : {})}>{SPINNER_FRAMES[frameIndex]}</text>;
22
+ }
23
+
24
+ export function useSpinner(): string {
25
+ const [frameIndex, setFrameIndex] = useState(0);
26
+
27
+ useEffect(() => {
28
+ const interval = setInterval(() => {
29
+ setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
30
+ }, FRAME_INTERVAL);
31
+
32
+ return () => clearInterval(interval);
33
+ }, []);
34
+
35
+ return SPINNER_FRAMES[frameIndex] ?? '⠋';
36
+ }
@@ -0,0 +1,96 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useTerminalDimensions } from '@opentui/react';
3
+ import { useColors } from '../contexts/ThemeContext.tsx';
4
+ import { useTimeWindow } from '../contexts/TimeWindowContext.tsx';
5
+
6
+ interface StatusBarProps {
7
+ lastRefresh?: number;
8
+ nextRefresh?: number;
9
+ demoMode?: boolean;
10
+ }
11
+
12
+ export function StatusBar({ lastRefresh, nextRefresh, demoMode = false }: StatusBarProps) {
13
+ const colors = useColors();
14
+ const { width: termWidth } = useTerminalDimensions();
15
+ const { window: timeWindow } = useTimeWindow();
16
+ const [, setTick] = useState(0);
17
+
18
+ useEffect(() => {
19
+ const interval = setInterval(() => {
20
+ setTick((t) => t + 1);
21
+ }, 1000);
22
+
23
+ return () => clearInterval(interval);
24
+ }, []);
25
+
26
+ const isNarrow = termWidth < 100;
27
+
28
+ const lastRefreshText = lastRefresh
29
+ ? (isNarrow ? formatTimeShort(lastRefresh) : formatTime(lastRefresh))
30
+ : '';
31
+
32
+ const nextRefreshText = nextRefresh
33
+ ? `${formatCountdown(nextRefresh)}`
34
+ : '';
35
+
36
+ const timeLabel = timeWindow.toUpperCase();
37
+
38
+ return (
39
+ <box
40
+ flexDirection="row"
41
+ justifyContent="space-between"
42
+ paddingX={1}
43
+ backgroundColor={colors.foreground}
44
+ flexShrink={0}
45
+ height={1}
46
+ overflow="hidden"
47
+ >
48
+ <box flexDirection="row" gap={2} overflow="hidden">
49
+ <text fg={colors.textSubtle}>1-4 views</text>
50
+ <text fg={colors.text}>
51
+ <span fg={colors.textSubtle}>t</span> <span fg={colors.info}>{timeLabel}</span>
52
+ </text>
53
+ <text fg={colors.textSubtle}>, settings</text>
54
+ <text fg={colors.textSubtle}>: cmd</text>
55
+ <text fg={colors.textSubtle}>? help</text>
56
+ </box>
57
+ <box flexDirection="row" gap={2} overflow="hidden">
58
+ {demoMode && <text fg={colors.warning}>{isNarrow ? 'DEMO' : 'DEMO MODE'}</text>}
59
+ {lastRefreshText && <text fg={colors.textSubtle}>{lastRefreshText}</text>}
60
+ {nextRefreshText && <text fg={colors.textSubtle}>{nextRefreshText}</text>}
61
+ </box>
62
+ </box>
63
+ );
64
+ }
65
+
66
+ function formatTimeShort(timestamp: number): string {
67
+ const date = new Date(timestamp);
68
+ return date.toLocaleTimeString('en-US', {
69
+ hour: '2-digit',
70
+ minute: '2-digit',
71
+ hour12: false,
72
+ });
73
+ }
74
+
75
+ function formatTime(timestamp: number): string {
76
+ const date = new Date(timestamp);
77
+ return date.toLocaleTimeString('en-US', {
78
+ hour: '2-digit',
79
+ minute: '2-digit',
80
+ second: '2-digit',
81
+ hour12: false,
82
+ });
83
+ }
84
+
85
+ function formatCountdown(timestamp: number): string {
86
+ const now = Date.now();
87
+ const diff = timestamp - now;
88
+
89
+ if (diff <= 0) return 'now';
90
+
91
+ const seconds = Math.floor(diff / 1000);
92
+ if (seconds < 60) return `${seconds}s`;
93
+
94
+ const minutes = Math.floor(seconds / 60);
95
+ return `${minutes}m ${seconds % 60}s`;
96
+ }
@@ -0,0 +1,234 @@
1
+ import { useState, useMemo, useEffect } from 'react';
2
+ import { useKeyboard } from '@opentui/react';
3
+ import { useColors } from '../contexts/ThemeContext.tsx';
4
+ import type { ThemePlugin, ColorSchemePreference } from '@/plugins/types/theme.ts';
5
+
6
+ interface ThemePickerProps {
7
+ themes: ThemePlugin[];
8
+ currentThemeId: string;
9
+ currentScheme: ColorSchemePreference;
10
+ onSelect: (themeId: string, scheme: ColorSchemePreference) => void;
11
+ onPreview: (theme: ThemePlugin | null) => void;
12
+ onCancel: () => void;
13
+ }
14
+
15
+ type ThemeItem =
16
+ | { type: 'header'; label: string }
17
+ | { type: 'theme'; theme: ThemePlugin };
18
+
19
+ export function ThemePicker({
20
+ themes,
21
+ currentThemeId,
22
+ currentScheme,
23
+ onSelect,
24
+ onPreview,
25
+ onCancel,
26
+ }: ThemePickerProps) {
27
+ const colors = useColors();
28
+ const [scheme, setScheme] = useState<ColorSchemePreference>(currentScheme);
29
+ const [focusPane, setFocusPane] = useState<'scheme' | 'list'>('list');
30
+ const [focusedIndex, setFocusedIndex] = useState(0);
31
+
32
+ // Filter and group themes based on selected scheme
33
+ const listItems = useMemo<ThemeItem[]>(() => {
34
+ const darkThemes = themes.filter(t => t.colorScheme === 'dark');
35
+ const lightThemes = themes.filter(t => t.colorScheme === 'light');
36
+
37
+ if (scheme === 'dark') {
38
+ return darkThemes.map(t => ({ type: 'theme', theme: t }));
39
+ }
40
+ if (scheme === 'light') {
41
+ return lightThemes.map(t => ({ type: 'theme', theme: t }));
42
+ }
43
+ // Auto: Show both with headers
44
+ return [
45
+ { type: 'header', label: 'DARK' },
46
+ ...darkThemes.map(t => ({ type: 'theme', theme: t } as const)),
47
+ { type: 'header', label: 'LIGHT' },
48
+ ...lightThemes.map(t => ({ type: 'theme', theme: t } as const)),
49
+ ];
50
+ }, [themes, scheme]);
51
+
52
+ // Find initial index for current theme
53
+ useEffect(() => {
54
+ const idx = listItems.findIndex(item => item.type === 'theme' && item.theme.id === currentThemeId);
55
+ if (idx >= 0) {
56
+ setFocusedIndex(idx);
57
+ } else {
58
+ // If not found (e.g. switched scheme and current theme is hidden), select first theme
59
+ const firstThemeIdx = listItems.findIndex(item => item.type === 'theme');
60
+ if (firstThemeIdx >= 0) setFocusedIndex(firstThemeIdx);
61
+ }
62
+ }, [currentThemeId, listItems]);
63
+
64
+ // Scroll calculation
65
+ const visibleCount = 10;
66
+ const scrollOffset = useMemo(() => {
67
+ if (listItems.length <= visibleCount) return 0;
68
+ if (focusedIndex < visibleCount - 1) return 0;
69
+ const maxOffset = listItems.length - visibleCount;
70
+ return Math.min(focusedIndex - visibleCount + 2, maxOffset);
71
+ }, [focusedIndex, listItems.length, visibleCount]);
72
+
73
+ const visibleItems = listItems.slice(scrollOffset, scrollOffset + visibleCount);
74
+ const hasMoreAbove = scrollOffset > 0;
75
+ const hasMoreBelow = scrollOffset + visibleCount < listItems.length;
76
+
77
+ useKeyboard((key) => {
78
+ if (key.name === 'escape') {
79
+ onCancel();
80
+ return;
81
+ }
82
+
83
+ if (key.name === 'tab') {
84
+ setFocusPane(p => p === 'scheme' ? 'list' : 'scheme');
85
+ return;
86
+ }
87
+
88
+ if (focusPane === 'scheme') {
89
+ if (key.name === 'left' || key.name === 'h' || key.name === 'right' || key.name === 'l') {
90
+ const schemes: ColorSchemePreference[] = ['auto', 'dark', 'light'];
91
+ const currentIdx = schemes.indexOf(scheme);
92
+ const direction = (key.name === 'left' || key.name === 'h') ? -1 : 1;
93
+ const nextIdx = (currentIdx + direction + schemes.length) % schemes.length;
94
+ const newScheme = schemes[nextIdx]!;
95
+ setScheme(newScheme);
96
+
97
+ // Preview first theme of new scheme
98
+ const darkThemes = themes.filter(t => t.colorScheme === 'dark');
99
+ const lightThemes = themes.filter(t => t.colorScheme === 'light');
100
+ let nextTheme: ThemePlugin | undefined;
101
+
102
+ if (newScheme === 'dark') nextTheme = darkThemes[0];
103
+ else if (newScheme === 'light') nextTheme = lightThemes[0];
104
+ else nextTheme = darkThemes[0]; // Auto defaults to dark usually
105
+
106
+ if (nextTheme) {
107
+ onPreview(nextTheme);
108
+ }
109
+ } else if (key.name === 'down' || key.name === 'j') {
110
+ setFocusPane('list');
111
+ }
112
+ return;
113
+ }
114
+
115
+ if (focusPane === 'list') {
116
+ if (key.name === 'up' || key.name === 'k') {
117
+ let nextIdx = focusedIndex - 1;
118
+ // Skip headers going up
119
+ while (nextIdx >= 0 && listItems[nextIdx]?.type === 'header') {
120
+ nextIdx--;
121
+ }
122
+ if (nextIdx >= 0) {
123
+ setFocusedIndex(nextIdx);
124
+ const item = listItems[nextIdx];
125
+ if (item?.type === 'theme') onPreview(item.theme);
126
+ } else {
127
+ setFocusPane('scheme');
128
+ }
129
+ } else if (key.name === 'down' || key.name === 'j') {
130
+ let nextIdx = focusedIndex + 1;
131
+ // Skip headers going down
132
+ while (nextIdx < listItems.length && listItems[nextIdx]?.type === 'header') {
133
+ nextIdx++;
134
+ }
135
+ if (nextIdx < listItems.length) {
136
+ setFocusedIndex(nextIdx);
137
+ const item = listItems[nextIdx];
138
+ if (item?.type === 'theme') onPreview(item.theme);
139
+ }
140
+ } else if (key.name === 'return') {
141
+ const item = listItems[focusedIndex];
142
+ if (item?.type === 'theme') {
143
+ onSelect(item.theme.id, scheme);
144
+ }
145
+ }
146
+ }
147
+ });
148
+
149
+ return (
150
+ <box flexDirection="column" flexGrow={1} padding={1}>
151
+ {/* Section 1: Scheme Selector */}
152
+ <box flexDirection="row" justifyContent="center" marginBottom={1} height={1}>
153
+ {(['auto', 'dark', 'light'] as const).map((s) => {
154
+ const isActive = scheme === s;
155
+ const isFocused = focusPane === 'scheme';
156
+ return (
157
+ <box key={s} marginRight={1}>
158
+ <text
159
+ {...(isActive ? { bg: colors.primary } : {})}
160
+ fg={isActive ? colors.background : isFocused ? colors.text : colors.textMuted}
161
+ >
162
+ {isFocused && isActive ? '▸ ' : ' '}
163
+ {s.charAt(0).toUpperCase() + s.slice(1)}
164
+ {isFocused && isActive ? ' ◂' : ' '}
165
+ </text>
166
+ </box>
167
+ );
168
+ })}
169
+ </box>
170
+
171
+ {/* Section 2: Theme List */}
172
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
173
+ <box flexDirection="row" justifyContent="space-between" marginBottom={0} height={1}>
174
+ <text fg={colors.textMuted}>THEMES</text>
175
+ {(hasMoreAbove || hasMoreBelow) && (
176
+ <text fg={colors.textSubtle}>
177
+ {hasMoreAbove ? '▲' : ' '}{hasMoreBelow ? '▼' : ' '}
178
+ </text>
179
+ )}
180
+ </box>
181
+
182
+ {visibleItems.map((item, idx) => {
183
+ const realIndex = scrollOffset + idx;
184
+ const isFocused = realIndex === focusedIndex && focusPane === 'list';
185
+
186
+ if (item.type === 'header') {
187
+ return (
188
+ <box key={`header-${realIndex}`} height={1} marginTop={1} marginBottom={0}>
189
+ <text fg={colors.textSubtle}><strong>{item.label}</strong></text>
190
+ </box>
191
+ );
192
+ }
193
+
194
+ const theme = item.theme;
195
+ const isActive = theme.id === currentThemeId;
196
+
197
+ return (
198
+ <box
199
+ key={theme.id}
200
+ flexDirection="row"
201
+ height={1}
202
+ paddingX={1}
203
+ {...(isFocused ? { backgroundColor: colors.primary } : {})}
204
+ >
205
+ <text fg={isFocused ? colors.background : colors.primary}>
206
+ {isFocused ? '▸ ' : ' '}
207
+ </text>
208
+
209
+ <box width={20}>
210
+ <text fg={isFocused ? colors.background : colors.text}>
211
+ {theme.name}
212
+ </text>
213
+ </box>
214
+
215
+ <box flexDirection="row" gap={1}>
216
+ <text bg={theme.colors.background}> </text>
217
+ <text bg={theme.colors.primary}> </text>
218
+ <text bg={theme.colors.secondary}> </text>
219
+ <text bg={theme.colors.success}> </text>
220
+ <text bg={theme.colors.error}> </text>
221
+ </box>
222
+
223
+ {isActive && (
224
+ <box marginLeft={2}>
225
+ <text fg={isFocused ? colors.background : colors.textMuted}>(active)</text>
226
+ </box>
227
+ )}
228
+ </box>
229
+ );
230
+ })}
231
+ </box>
232
+ </box>
233
+ );
234
+ }
@@ -0,0 +1,61 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useColors } from '../contexts/ThemeContext.tsx';
3
+
4
+ interface ToastProps {
5
+ message: string;
6
+ duration?: number;
7
+ onDismiss: () => void;
8
+ type?: 'info' | 'success' | 'warning' | 'error';
9
+ }
10
+
11
+ export function Toast({ message, duration = 2000, onDismiss, type = 'success' }: ToastProps) {
12
+ const colors = useColors();
13
+
14
+ useEffect(() => {
15
+ const timer = setTimeout(onDismiss, duration);
16
+ return () => clearTimeout(timer);
17
+ }, [duration, onDismiss]);
18
+
19
+ const accentColor = {
20
+ info: colors.info,
21
+ success: colors.success,
22
+ warning: colors.warning,
23
+ error: colors.error,
24
+ }[type];
25
+
26
+ return (
27
+ <box
28
+ position="absolute"
29
+ top={3}
30
+ right={2}
31
+ flexDirection="row"
32
+ backgroundColor={colors.foreground}
33
+ borderStyle="rounded"
34
+ borderColor={colors.border}
35
+ >
36
+ <box width={1} backgroundColor={accentColor} />
37
+ <box paddingLeft={1} paddingRight={2}>
38
+ <text fg={colors.text}>{message}</text>
39
+ </box>
40
+ </box>
41
+ );
42
+ }
43
+
44
+ interface ToastState {
45
+ message: string;
46
+ type: 'info' | 'success' | 'warning' | 'error';
47
+ }
48
+
49
+ export function useToast() {
50
+ const [toast, setToast] = useState<ToastState | null>(null);
51
+
52
+ const showToast = (message: string, type: 'info' | 'success' | 'warning' | 'error' = 'success') => {
53
+ setToast({ message, type });
54
+ };
55
+
56
+ const dismissToast = () => {
57
+ setToast(null);
58
+ };
59
+
60
+ return { toast, showToast, dismissToast };
61
+ }
@@ -0,0 +1,80 @@
1
+ import { useColors } from '../contexts/ThemeContext.tsx';
2
+
3
+ interface UsageGaugeProps {
4
+ label: string;
5
+ usedPercent: number | null;
6
+ windowLabel?: string;
7
+ resetsAt?: number;
8
+ width?: number;
9
+ color?: string;
10
+ }
11
+
12
+ export function UsageGauge({
13
+ label,
14
+ usedPercent,
15
+ windowLabel,
16
+ resetsAt,
17
+ width = 40,
18
+ color,
19
+ }: UsageGaugeProps) {
20
+ const colors = useColors();
21
+ const providerColor = color ?? colors.primary;
22
+
23
+ const percent = usedPercent ?? 0;
24
+ const barWidth = Math.max(0, width - 10);
25
+ const filledWidth = Math.round((percent / 100) * barWidth);
26
+ const emptyWidth = barWidth - filledWidth;
27
+
28
+ const fillColor = percent >= 90 ? colors.gaugeDanger :
29
+ percent >= 70 ? colors.gaugeWarning :
30
+ providerColor;
31
+
32
+ const filledBar = '█'.repeat(filledWidth);
33
+ const emptyBar = '·'.repeat(emptyWidth);
34
+
35
+ const resetText = resetsAt ? formatResetTime(resetsAt) : '';
36
+ const showWindowLabel = windowLabel && !label.toLowerCase().includes(windowLabel.toLowerCase());
37
+
38
+ return (
39
+ <box flexDirection="column" width={width}>
40
+ <box flexDirection="row" justifyContent="space-between">
41
+ <text fg={colors.textMuted}>{label}</text>
42
+ <text fg={colors.text}>
43
+ {usedPercent !== null ? `${Math.round(percent)}%` : '--'}
44
+ </text>
45
+ </box>
46
+ <box flexDirection="row">
47
+ <text>
48
+ <span fg={fillColor}>{filledBar}</span>
49
+ <span fg={colors.gaugeBackground}>{emptyBar}</span>
50
+ </text>
51
+ </box>
52
+ {(showWindowLabel || resetText) && (
53
+ <text fg={colors.textMuted}>
54
+ {showWindowLabel ? windowLabel : ''}
55
+ {showWindowLabel && resetText ? ' · ' : ''}
56
+ {resetText}
57
+ </text>
58
+ )}
59
+ </box>
60
+ );
61
+ }
62
+
63
+ function formatResetTime(timestamp: number): string {
64
+ const now = Date.now();
65
+ const diff = timestamp - now;
66
+
67
+ if (diff <= 0) return 'resets soon';
68
+
69
+ const minutes = Math.floor(diff / 60000);
70
+ const hours = Math.floor(minutes / 60);
71
+ const days = Math.floor(hours / 24);
72
+
73
+ if (days > 0) {
74
+ return `resets in ${days}d ${hours % 24}h`;
75
+ }
76
+ if (hours > 0) {
77
+ return `resets in ${hours}h ${minutes % 60}m`;
78
+ }
79
+ return `resets in ${minutes}m`;
80
+ }
@@ -0,0 +1,5 @@
1
+ export { Header } from './Header.tsx';
2
+ export { StatusBar } from './StatusBar.tsx';
3
+ export { UsageGauge } from './UsageGauge.tsx';
4
+ export { ProviderCard } from './ProviderCard.tsx';
5
+ export { DebugConsole } from './DebugConsole.tsx';