@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.
Files changed (158) hide show
  1. package/README.md +2 -5
  2. package/package.json +8 -5
  3. package/src/agents/aggregator.ts +39 -19
  4. package/src/agents/costing.ts +11 -9
  5. package/src/agents/index.ts +7 -9
  6. package/src/agents/types.ts +2 -2
  7. package/src/cli.ts +28 -23
  8. package/src/config/schema.test.ts +42 -40
  9. package/src/config/schema.ts +26 -26
  10. package/src/demo/simulator.ts +161 -122
  11. package/src/plugins/agents/index.ts +2 -2
  12. package/src/plugins/auth-sources.ts +42 -38
  13. package/src/plugins/lifecycle.ts +32 -32
  14. package/src/plugins/loader.test.ts +49 -49
  15. package/src/plugins/loader.ts +67 -63
  16. package/src/plugins/notification-bus.ts +21 -16
  17. package/src/plugins/notifications/index.ts +2 -2
  18. package/src/plugins/notifications/terminal-bell.ts +29 -23
  19. package/src/plugins/notifications/visual-flash.ts +21 -19
  20. package/src/plugins/npm-installer.ts +23 -25
  21. package/src/plugins/plugin-context-factory.ts +10 -10
  22. package/src/plugins/plugin-host.ts +12 -5
  23. package/src/plugins/providers/anthropic.ts +64 -57
  24. package/src/plugins/providers/antigravity.ts +81 -69
  25. package/src/plugins/providers/codex.ts +42 -42
  26. package/src/plugins/providers/gemini.ts +106 -87
  27. package/src/plugins/providers/github-copilot.ts +49 -45
  28. package/src/plugins/providers/index.ts +10 -12
  29. package/src/plugins/providers/minimax.ts +50 -44
  30. package/src/plugins/providers/openai-api.ts +82 -62
  31. package/src/plugins/providers/opencode-zen.ts +46 -39
  32. package/src/plugins/providers/perplexity.ts +55 -46
  33. package/src/plugins/providers/zai.ts +66 -57
  34. package/src/plugins/registry.ts +47 -43
  35. package/src/plugins/sandbox-guard.test.ts +45 -48
  36. package/src/plugins/sandbox-guard.ts +20 -25
  37. package/src/plugins/sandbox.ts +26 -48
  38. package/src/plugins/themes/catppuccin-latte.ts +29 -29
  39. package/src/plugins/themes/catppuccin-mocha.ts +29 -29
  40. package/src/plugins/themes/claude-code.ts +29 -29
  41. package/src/plugins/themes/dracula.ts +29 -29
  42. package/src/plugins/themes/github-light.ts +29 -29
  43. package/src/plugins/themes/gruvbox-dark.ts +29 -29
  44. package/src/plugins/themes/gruvbox-light.ts +29 -29
  45. package/src/plugins/themes/index.ts +15 -15
  46. package/src/plugins/themes/kanagawa.ts +29 -29
  47. package/src/plugins/themes/nord.ts +29 -29
  48. package/src/plugins/themes/one-dark.ts +29 -29
  49. package/src/plugins/themes/opencode.ts +31 -31
  50. package/src/plugins/themes/rose-pine-dawn.ts +29 -29
  51. package/src/plugins/themes/rose-pine.ts +29 -29
  52. package/src/plugins/themes/solarized-light.ts +29 -29
  53. package/src/plugins/themes/tokyo-night.ts +29 -29
  54. package/src/plugins/types/agent.ts +5 -5
  55. package/src/plugins/types/base.ts +35 -24
  56. package/src/plugins/types/index.ts +9 -9
  57. package/src/plugins/types/notification.ts +12 -12
  58. package/src/plugins/types/provider.ts +12 -8
  59. package/src/plugins/types/theme.ts +31 -23
  60. package/src/plugins/update-checker.ts +6 -6
  61. package/src/pricing/estimator.test.ts +39 -44
  62. package/src/pricing/estimator.ts +7 -13
  63. package/src/pricing/fallback.test.ts +30 -34
  64. package/src/pricing/fallback.ts +54 -27
  65. package/src/pricing/index.ts +18 -12
  66. package/src/pricing/models-dev.ts +6 -6
  67. package/src/storage/database.ts +56 -46
  68. package/src/storage/db.ts +12 -12
  69. package/src/storage/index.ts +44 -45
  70. package/src/storage/migrations/index.ts +2 -2
  71. package/src/storage/paths.ts +13 -13
  72. package/src/storage/repos/agentSessions.ts +33 -23
  73. package/src/storage/repos/providerSnapshots.ts +5 -5
  74. package/src/storage/repos/usageEvents.ts +40 -28
  75. package/src/storage/types.ts +5 -5
  76. package/src/tui/App.tsx +180 -137
  77. package/src/tui/components/CommandPalette.tsx +46 -34
  78. package/src/tui/components/DebugConsole.tsx +39 -33
  79. package/src/tui/components/DebugPanel.tsx +295 -177
  80. package/src/tui/components/FooterHints.tsx +2 -2
  81. package/src/tui/components/GhostProviderCard.tsx +37 -35
  82. package/src/tui/components/Header.tsx +68 -35
  83. package/src/tui/components/HelpOverlay.tsx +99 -29
  84. package/src/tui/components/InlineGauge.tsx +10 -7
  85. package/src/tui/components/InlineSparkline.tsx +17 -11
  86. package/src/tui/components/KpiStrip.tsx +128 -98
  87. package/src/tui/components/LimitGauge.tsx +96 -59
  88. package/src/tui/components/ModalBackdrop.tsx +4 -4
  89. package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
  90. package/src/tui/components/ProviderCard.tsx +232 -226
  91. package/src/tui/components/ProviderDetailPanel.tsx +133 -58
  92. package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
  93. package/src/tui/components/ProvidersList.tsx +215 -126
  94. package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
  95. package/src/tui/components/SessionsTable.tsx +313 -166
  96. package/src/tui/components/SettingsModal.tsx +231 -214
  97. package/src/tui/components/SidebarBreakdown.tsx +30 -16
  98. package/src/tui/components/Skeleton.tsx +24 -17
  99. package/src/tui/components/SmartSidebar.tsx +360 -258
  100. package/src/tui/components/Sparkline.tsx +64 -52
  101. package/src/tui/components/Spinner.tsx +3 -3
  102. package/src/tui/components/StatusBar.tsx +27 -27
  103. package/src/tui/components/ThemePicker.tsx +67 -66
  104. package/src/tui/components/Toast.tsx +9 -6
  105. package/src/tui/components/UsageGauge.tsx +10 -13
  106. package/src/tui/components/index.ts +5 -5
  107. package/src/tui/contexts/AgentSessionContext.tsx +193 -144
  108. package/src/tui/contexts/ConfigContext.tsx +15 -13
  109. package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
  110. package/src/tui/contexts/DemoModeContext.tsx +22 -15
  111. package/src/tui/contexts/DrawerContext.tsx +11 -9
  112. package/src/tui/contexts/InputContext.tsx +2 -2
  113. package/src/tui/contexts/LogContext.tsx +62 -47
  114. package/src/tui/contexts/PluginContext.tsx +232 -183
  115. package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
  116. package/src/tui/contexts/StorageContext.tsx +151 -117
  117. package/src/tui/contexts/ThemeContext.tsx +21 -16
  118. package/src/tui/contexts/TimeWindowContext.tsx +72 -61
  119. package/src/tui/contexts/ToastContext.tsx +10 -7
  120. package/src/tui/contexts/index.ts +9 -5
  121. package/src/tui/createApp.tsx +8 -12
  122. package/src/tui/debug/captureFrame.ts +25 -28
  123. package/src/tui/debug/snapshot.tsx +179 -162
  124. package/src/tui/driver/assertions.ts +30 -22
  125. package/src/tui/driver/cli.ts +231 -221
  126. package/src/tui/driver/coverage.ts +27 -44
  127. package/src/tui/driver/demo-snapshot.ts +33 -33
  128. package/src/tui/driver/diff.ts +26 -32
  129. package/src/tui/driver/driver.ts +43 -48
  130. package/src/tui/driver/index.ts +36 -40
  131. package/src/tui/driver/recorder.ts +37 -48
  132. package/src/tui/driver/test-workflow.ts +51 -51
  133. package/src/tui/driver/test.ts +25 -25
  134. package/src/tui/hooks/index.ts +24 -20
  135. package/src/tui/hooks/useAnimatedValue.ts +13 -18
  136. package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
  137. package/src/tui/hooks/useDashboardState.ts +26 -24
  138. package/src/tui/hooks/useEmaActivity.ts +82 -74
  139. package/src/tui/hooks/useEntranceAnimation.ts +11 -11
  140. package/src/tui/hooks/useExitAnimation.ts +3 -3
  141. package/src/tui/hooks/usePulse.ts +4 -4
  142. package/src/tui/hooks/useSafeRenderer.ts +5 -5
  143. package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
  144. package/src/tui/hooks/useValueFlash.ts +10 -14
  145. package/src/tui/index.tsx +29 -6
  146. package/src/tui/shutdown.ts +19 -0
  147. package/src/tui/utils/providerColor.ts +1 -1
  148. package/src/tui/views/Dashboard.tsx +127 -102
  149. package/src/tui/views/HistoricalTrendsView.tsx +579 -250
  150. package/src/tui/views/ProjectsView.tsx +466 -237
  151. package/src/tui/views/RealTimeDashboard.tsx +150 -111
  152. package/src/tui/views/SettingsView.tsx +222 -191
  153. package/src/tui/views/index.ts +2 -2
  154. package/src/types/opentui-test-utils.d.ts +7 -7
  155. package/src/utils/clipboard.ts +29 -29
  156. package/src/utils/currency.test.ts +53 -53
  157. package/src/utils/currency.ts +35 -32
  158. package/src/version.ts +1 -1
@@ -1,5 +1,5 @@
1
- import { useColors } from '../contexts/ThemeContext.tsx';
2
- import type { SparklineStyle, SparklineOrientation } from '@/config/schema.ts';
1
+ import type { SparklineOrientation, SparklineStyle } from "@/config/schema.ts";
2
+ import { useColors } from "../contexts/ThemeContext.tsx";
3
3
 
4
4
  export interface SparklineProps {
5
5
  data: number[];
@@ -12,29 +12,29 @@ export interface SparklineProps {
12
12
  showBaseline?: boolean;
13
13
  }
14
14
 
15
- const BLOCK_CHARS_UP = ['▁', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
16
- const BLOCK_CHARS_DOWN = ['▔', '▔', '🬂', '🬎', '▀', '🬩', '🬷', '🮂', '█'];
15
+ const BLOCK_CHARS_UP = ["▁", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
16
+ const BLOCK_CHARS_DOWN = ["▔", "▔", "🬂", "🬎", "▀", "🬩", "🬷", "🮂", "█"];
17
17
 
18
18
  function valueToBraille(
19
- left: number,
20
- right: number,
19
+ left: number,
20
+ right: number,
21
21
  orientation: SparklineOrientation,
22
- includeBaseline: boolean
22
+ includeBaseline: boolean,
23
23
  ): string {
24
24
  const leftDots = Math.min(4, Math.max(0, Math.floor(left)));
25
25
  const rightDots = Math.min(4, Math.max(0, Math.floor(right)));
26
-
26
+
27
27
  let code = 0x2800;
28
-
29
- if (orientation === 'up') {
28
+
29
+ if (orientation === "up") {
30
30
  // Fill from bottom to top: dot7, dot3, dot2, dot1 (left) / dot8, dot6, dot5, dot4 (right)
31
31
  const leftBitsUp = [0x40, 0x04, 0x02, 0x01];
32
32
  const rightBitsUp = [0x80, 0x20, 0x10, 0x08];
33
-
33
+
34
34
  if (includeBaseline) {
35
35
  code |= 0x40 | 0x80; // Always include bottom row when baseline is on
36
36
  }
37
-
37
+
38
38
  for (let i = 0; i < leftDots; i++) {
39
39
  code |= leftBitsUp[i]!;
40
40
  }
@@ -45,11 +45,11 @@ function valueToBraille(
45
45
  // Fill from top to bottom: dot1, dot2, dot3, dot7 (left) / dot4, dot5, dot6, dot8 (right)
46
46
  const leftBitsDown = [0x01, 0x02, 0x04, 0x40];
47
47
  const rightBitsDown = [0x08, 0x10, 0x20, 0x80];
48
-
48
+
49
49
  if (includeBaseline) {
50
50
  code |= 0x01 | 0x08; // Always include top row when baseline is on
51
51
  }
52
-
52
+
53
53
  for (let i = 0; i < leftDots; i++) {
54
54
  code |= leftBitsDown[i]!;
55
55
  }
@@ -57,18 +57,18 @@ function valueToBraille(
57
57
  code |= rightBitsDown[i]!;
58
58
  }
59
59
  }
60
-
60
+
61
61
  return String.fromCharCode(code);
62
62
  }
63
63
 
64
64
  function getBaselineBraille(orientation: SparklineOrientation): string {
65
- return orientation === 'up' ? '⣀' : '⠉';
65
+ return orientation === "up" ? "⣀" : "⠉";
66
66
  }
67
67
 
68
68
  function getValueColor(
69
69
  rawValue: number,
70
70
  thresholds: { warning: number; error: number },
71
- colors: { success: string; warning: string; error: string; textSubtle: string }
71
+ colors: { success: string; warning: string; error: string; textSubtle: string },
72
72
  ): string {
73
73
  if (rawValue <= 0) return colors.textSubtle;
74
74
  if (rawValue >= thresholds.error) return colors.error;
@@ -76,105 +76,117 @@ function getValueColor(
76
76
  return colors.success;
77
77
  }
78
78
 
79
- export function Sparkline({
80
- data,
81
- width = 60,
82
- label,
79
+ export function Sparkline({
80
+ data,
81
+ width = 60,
82
+ label,
83
83
  fixedMax = 2000,
84
84
  thresholds = { warning: 800, error: 2000 },
85
- style = 'braille',
86
- orientation = 'up',
85
+ style = "braille",
86
+ orientation = "up",
87
87
  showBaseline = true,
88
88
  }: SparklineProps) {
89
89
  const colors = useColors();
90
-
90
+
91
91
  const peak = Math.max(...data, 0);
92
92
  const sqrtMax = Math.sqrt(fixedMax);
93
-
93
+
94
94
  const normalizeValue = (v: number): number => {
95
95
  if (v <= 0) return 0;
96
96
  const sqrtValue = Math.sqrt(Math.min(v, fixedMax));
97
- return (sqrtValue / sqrtMax) * (style === 'braille' ? 4 : 8);
97
+ return (sqrtValue / sqrtMax) * (style === "braille" ? 4 : 8);
98
98
  };
99
-
100
- const peakLabel = peak >= 1000 ? `${(peak/1000).toFixed(1)}k` : `${Math.round(peak)}`;
101
99
 
102
- if (style === 'braille') {
100
+ const peakLabel = peak >= 1000 ? `${(peak / 1000).toFixed(1)}k` : `${Math.round(peak)}`;
101
+
102
+ if (style === "braille") {
103
103
  const effectiveWidth = width * 2;
104
104
  const displayData = data.slice(-effectiveWidth);
105
105
  const charCount = Math.ceil(displayData.length / 2);
106
106
  const padding = width - charCount;
107
-
107
+
108
108
  const groups: { color: string; chars: string }[] = [];
109
109
  const baselineChar = getBaselineBraille(orientation);
110
-
110
+
111
111
  for (let i = 0; i < displayData.length; i += 2) {
112
112
  const leftRaw = displayData[i] ?? 0;
113
113
  const rightRaw = displayData[i + 1] ?? leftRaw;
114
-
114
+
115
115
  const leftNorm = normalizeValue(leftRaw);
116
116
  const rightNorm = normalizeValue(rightRaw);
117
-
117
+
118
118
  const dominantRaw = Math.max(leftRaw, rightRaw);
119
119
  const color = getValueColor(dominantRaw, thresholds, colors);
120
-
120
+
121
121
  let char: string;
122
122
  if (dominantRaw <= 0) {
123
- char = showBaseline ? baselineChar : ' ';
123
+ char = showBaseline ? baselineChar : " ";
124
124
  } else {
125
125
  char = valueToBraille(leftNorm, rightNorm, orientation, showBaseline);
126
126
  }
127
-
127
+
128
128
  if (groups.length > 0 && groups[groups.length - 1]!.color === color) {
129
129
  groups[groups.length - 1]!.chars += char;
130
130
  } else {
131
131
  groups.push({ color, chars: char });
132
132
  }
133
133
  }
134
-
134
+
135
135
  return (
136
136
  <box flexDirection="column">
137
137
  <text>
138
- {padding > 0 && <span>{' '.repeat(padding)}</span>}
138
+ {padding > 0 && <span>{" ".repeat(padding)}</span>}
139
139
  {groups.map((group, i) => (
140
- <span key={i} fg={group.color}>{group.chars}</span>
140
+ <span key={i} fg={group.color}>
141
+ {group.chars}
142
+ </span>
141
143
  ))}
142
144
  </text>
143
- {label && <text fg={colors.textMuted}>{label} peak:{peakLabel}</text>}
145
+ {label && (
146
+ <text fg={colors.textMuted}>
147
+ {label} peak:{peakLabel}
148
+ </text>
149
+ )}
144
150
  </box>
145
151
  );
146
152
  }
147
-
148
- const blockChars = orientation === 'up' ? BLOCK_CHARS_UP : BLOCK_CHARS_DOWN;
149
- const baselineChar = showBaseline ? (orientation === 'up' ? '▁' : '▔') : '·';
150
-
153
+
154
+ const blockChars = orientation === "up" ? BLOCK_CHARS_UP : BLOCK_CHARS_DOWN;
155
+ const baselineChar = showBaseline ? (orientation === "up" ? "▁" : "▔") : "·";
156
+
151
157
  const displayData = data.slice(-width);
152
158
  const padding = width - displayData.length;
153
-
159
+
154
160
  const groups: { color: string; chars: string }[] = [];
155
161
  for (let i = 0; i < displayData.length; i++) {
156
162
  const rawValue = displayData[i] ?? 0;
157
163
  const v = Math.round(normalizeValue(rawValue));
158
-
164
+
159
165
  const color = getValueColor(rawValue, thresholds, colors);
160
- const char = rawValue <= 0 ? baselineChar : (blockChars[v] ?? blockChars[1] ?? '▁');
161
-
166
+ const char = rawValue <= 0 ? baselineChar : (blockChars[v] ?? blockChars[1] ?? "▁");
167
+
162
168
  if (groups.length > 0 && groups[groups.length - 1]!.color === color) {
163
169
  groups[groups.length - 1]!.chars += char;
164
170
  } else {
165
171
  groups.push({ color, chars: char });
166
172
  }
167
173
  }
168
-
174
+
169
175
  return (
170
176
  <box flexDirection="column">
171
177
  <text>
172
- {padding > 0 && <span>{' '.repeat(padding)}</span>}
178
+ {padding > 0 && <span>{" ".repeat(padding)}</span>}
173
179
  {groups.map((group, i) => (
174
- <span key={i} fg={group.color}>{group.chars}</span>
180
+ <span key={i} fg={group.color}>
181
+ {group.chars}
182
+ </span>
175
183
  ))}
176
184
  </text>
177
- {label && <text fg={colors.textMuted}>{label} peak:{peakLabel}</text>}
185
+ {label && (
186
+ <text fg={colors.textMuted}>
187
+ {label} peak:{peakLabel}
188
+ </text>
189
+ )}
178
190
  </box>
179
191
  );
180
192
  }
@@ -1,6 +1,6 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useEffect, useState } from "react";
2
2
 
3
- const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
3
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4
4
  const FRAME_INTERVAL = 80;
5
5
 
6
6
  interface SpinnerProps {
@@ -32,5 +32,5 @@ export function useSpinner(): string {
32
32
  return () => clearInterval(interval);
33
33
  }, []);
34
34
 
35
- return SPINNER_FRAMES[frameIndex] ?? '⠋';
35
+ return SPINNER_FRAMES[frameIndex] ?? "⠋";
36
36
  }
@@ -1,7 +1,7 @@
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';
1
+ import { useTerminalDimensions } from "@opentui/react";
2
+ import { useEffect, useState } from "react";
3
+ import { useColors } from "../contexts/ThemeContext.tsx";
4
+ import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
5
5
 
6
6
  interface StatusBarProps {
7
7
  lastRefresh?: number;
@@ -26,25 +26,25 @@ export function StatusBar({ lastRefresh, nextRefresh, demoMode = false }: Status
26
26
  const isNarrow = termWidth < 100;
27
27
 
28
28
  const lastRefreshText = lastRefresh
29
- ? (isNarrow ? formatTimeShort(lastRefresh) : formatTime(lastRefresh))
30
- : '';
29
+ ? isNarrow
30
+ ? formatTimeShort(lastRefresh)
31
+ : formatTime(lastRefresh)
32
+ : "";
31
33
 
32
- const nextRefreshText = nextRefresh
33
- ? `${formatCountdown(nextRefresh)}`
34
- : '';
34
+ const nextRefreshText = nextRefresh ? `${formatCountdown(nextRefresh)}` : "";
35
35
 
36
36
  const timeLabel = timeWindow.toUpperCase();
37
37
 
38
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
- >
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
48
  <box flexDirection="row" gap={2} overflow="hidden">
49
49
  <text fg={colors.textSubtle}>1-4 views</text>
50
50
  <text fg={colors.text}>
@@ -55,7 +55,7 @@ export function StatusBar({ lastRefresh, nextRefresh, demoMode = false }: Status
55
55
  <text fg={colors.textSubtle}>? help</text>
56
56
  </box>
57
57
  <box flexDirection="row" gap={2} overflow="hidden">
58
- {demoMode && <text fg={colors.warning}>{isNarrow ? 'DEMO' : 'DEMO MODE'}</text>}
58
+ {demoMode && <text fg={colors.warning}>{isNarrow ? "DEMO" : "DEMO MODE"}</text>}
59
59
  {lastRefreshText && <text fg={colors.textSubtle}>{lastRefreshText}</text>}
60
60
  {nextRefreshText && <text fg={colors.textSubtle}>{nextRefreshText}</text>}
61
61
  </box>
@@ -65,19 +65,19 @@ export function StatusBar({ lastRefresh, nextRefresh, demoMode = false }: Status
65
65
 
66
66
  function formatTimeShort(timestamp: number): string {
67
67
  const date = new Date(timestamp);
68
- return date.toLocaleTimeString('en-US', {
69
- hour: '2-digit',
70
- minute: '2-digit',
68
+ return date.toLocaleTimeString("en-US", {
69
+ hour: "2-digit",
70
+ minute: "2-digit",
71
71
  hour12: false,
72
72
  });
73
73
  }
74
74
 
75
75
  function formatTime(timestamp: number): string {
76
76
  const date = new Date(timestamp);
77
- return date.toLocaleTimeString('en-US', {
78
- hour: '2-digit',
79
- minute: '2-digit',
80
- second: '2-digit',
77
+ return date.toLocaleTimeString("en-US", {
78
+ hour: "2-digit",
79
+ minute: "2-digit",
80
+ second: "2-digit",
81
81
  hour12: false,
82
82
  });
83
83
  }
@@ -86,7 +86,7 @@ function formatCountdown(timestamp: number): string {
86
86
  const now = Date.now();
87
87
  const diff = timestamp - now;
88
88
 
89
- if (diff <= 0) return 'now';
89
+ if (diff <= 0) return "now";
90
90
 
91
91
  const seconds = Math.floor(diff / 1000);
92
92
  if (seconds < 60) return `${seconds}s`;
@@ -1,7 +1,7 @@
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';
1
+ import { useKeyboard } from "@opentui/react";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import type { ColorSchemePreference, ThemePlugin } from "@/plugins/types/theme.ts";
4
+ import { useColors } from "../contexts/ThemeContext.tsx";
5
5
 
6
6
  interface ThemePickerProps {
7
7
  themes: ThemePlugin[];
@@ -12,9 +12,7 @@ interface ThemePickerProps {
12
12
  onCancel: () => void;
13
13
  }
14
14
 
15
- type ThemeItem =
16
- | { type: 'header'; label: string }
17
- | { type: 'theme'; theme: ThemePlugin };
15
+ type ThemeItem = { type: "header"; label: string } | { type: "theme"; theme: ThemePlugin };
18
16
 
19
17
  export function ThemePicker({
20
18
  themes,
@@ -26,37 +24,39 @@ export function ThemePicker({
26
24
  }: ThemePickerProps) {
27
25
  const colors = useColors();
28
26
  const [scheme, setScheme] = useState<ColorSchemePreference>(currentScheme);
29
- const [focusPane, setFocusPane] = useState<'scheme' | 'list'>('list');
27
+ const [focusPane, setFocusPane] = useState<"scheme" | "list">("list");
30
28
  const [focusedIndex, setFocusedIndex] = useState(0);
31
29
 
32
30
  // Filter and group themes based on selected scheme
33
31
  const listItems = useMemo<ThemeItem[]>(() => {
34
- const darkThemes = themes.filter(t => t.colorScheme === 'dark');
35
- const lightThemes = themes.filter(t => t.colorScheme === 'light');
32
+ const darkThemes = themes.filter((t) => t.colorScheme === "dark");
33
+ const lightThemes = themes.filter((t) => t.colorScheme === "light");
36
34
 
37
- if (scheme === 'dark') {
38
- return darkThemes.map(t => ({ type: 'theme', theme: t }));
35
+ if (scheme === "dark") {
36
+ return darkThemes.map((t) => ({ type: "theme", theme: t }));
39
37
  }
40
- if (scheme === 'light') {
41
- return lightThemes.map(t => ({ type: 'theme', theme: t }));
38
+ if (scheme === "light") {
39
+ return lightThemes.map((t) => ({ type: "theme", theme: t }));
42
40
  }
43
41
  // Auto: Show both with headers
44
42
  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)),
43
+ { type: "header", label: "DARK" },
44
+ ...darkThemes.map((t) => ({ type: "theme", theme: t }) as const),
45
+ { type: "header", label: "LIGHT" },
46
+ ...lightThemes.map((t) => ({ type: "theme", theme: t }) as const),
49
47
  ];
50
48
  }, [themes, scheme]);
51
49
 
52
50
  // Find initial index for current theme
53
51
  useEffect(() => {
54
- const idx = listItems.findIndex(item => item.type === 'theme' && item.theme.id === currentThemeId);
52
+ const idx = listItems.findIndex(
53
+ (item) => item.type === "theme" && item.theme.id === currentThemeId,
54
+ );
55
55
  if (idx >= 0) {
56
56
  setFocusedIndex(idx);
57
57
  } else {
58
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');
59
+ const firstThemeIdx = listItems.findIndex((item) => item.type === "theme");
60
60
  if (firstThemeIdx >= 0) setFocusedIndex(firstThemeIdx);
61
61
  }
62
62
  }, [currentThemeId, listItems]);
@@ -75,71 +75,71 @@ export function ThemePicker({
75
75
  const hasMoreBelow = scrollOffset + visibleCount < listItems.length;
76
76
 
77
77
  useKeyboard((key) => {
78
- if (key.name === 'escape') {
78
+ if (key.name === "escape") {
79
79
  onCancel();
80
80
  return;
81
81
  }
82
82
 
83
- if (key.name === 'tab') {
84
- setFocusPane(p => p === 'scheme' ? 'list' : 'scheme');
83
+ if (key.name === "tab") {
84
+ setFocusPane((p) => (p === "scheme" ? "list" : "scheme"));
85
85
  return;
86
86
  }
87
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'];
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
91
  const currentIdx = schemes.indexOf(scheme);
92
- const direction = (key.name === 'left' || key.name === 'h') ? -1 : 1;
92
+ const direction = key.name === "left" || key.name === "h" ? -1 : 1;
93
93
  const nextIdx = (currentIdx + direction + schemes.length) % schemes.length;
94
94
  const newScheme = schemes[nextIdx]!;
95
95
  setScheme(newScheme);
96
-
96
+
97
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');
98
+ const darkThemes = themes.filter((t) => t.colorScheme === "dark");
99
+ const lightThemes = themes.filter((t) => t.colorScheme === "light");
100
100
  let nextTheme: ThemePlugin | undefined;
101
-
102
- if (newScheme === 'dark') nextTheme = darkThemes[0];
103
- else if (newScheme === 'light') nextTheme = lightThemes[0];
101
+
102
+ if (newScheme === "dark") nextTheme = darkThemes[0];
103
+ else if (newScheme === "light") nextTheme = lightThemes[0];
104
104
  else nextTheme = darkThemes[0]; // Auto defaults to dark usually
105
-
105
+
106
106
  if (nextTheme) {
107
- onPreview(nextTheme);
107
+ onPreview(nextTheme);
108
108
  }
109
- } else if (key.name === 'down' || key.name === 'j') {
110
- setFocusPane('list');
109
+ } else if (key.name === "down" || key.name === "j") {
110
+ setFocusPane("list");
111
111
  }
112
112
  return;
113
113
  }
114
114
 
115
- if (focusPane === 'list') {
116
- if (key.name === 'up' || key.name === 'k') {
115
+ if (focusPane === "list") {
116
+ if (key.name === "up" || key.name === "k") {
117
117
  let nextIdx = focusedIndex - 1;
118
118
  // Skip headers going up
119
- while (nextIdx >= 0 && listItems[nextIdx]?.type === 'header') {
119
+ while (nextIdx >= 0 && listItems[nextIdx]?.type === "header") {
120
120
  nextIdx--;
121
121
  }
122
122
  if (nextIdx >= 0) {
123
123
  setFocusedIndex(nextIdx);
124
124
  const item = listItems[nextIdx];
125
- if (item?.type === 'theme') onPreview(item.theme);
125
+ if (item?.type === "theme") onPreview(item.theme);
126
126
  } else {
127
- setFocusPane('scheme');
127
+ setFocusPane("scheme");
128
128
  }
129
- } else if (key.name === 'down' || key.name === 'j') {
129
+ } else if (key.name === "down" || key.name === "j") {
130
130
  let nextIdx = focusedIndex + 1;
131
131
  // Skip headers going down
132
- while (nextIdx < listItems.length && listItems[nextIdx]?.type === 'header') {
132
+ while (nextIdx < listItems.length && listItems[nextIdx]?.type === "header") {
133
133
  nextIdx++;
134
134
  }
135
135
  if (nextIdx < listItems.length) {
136
136
  setFocusedIndex(nextIdx);
137
137
  const item = listItems[nextIdx];
138
- if (item?.type === 'theme') onPreview(item.theme);
138
+ if (item?.type === "theme") onPreview(item.theme);
139
139
  }
140
- } else if (key.name === 'return') {
140
+ } else if (key.name === "return") {
141
141
  const item = listItems[focusedIndex];
142
- if (item?.type === 'theme') {
142
+ if (item?.type === "theme") {
143
143
  onSelect(item.theme.id, scheme);
144
144
  }
145
145
  }
@@ -150,18 +150,18 @@ export function ThemePicker({
150
150
  <box flexDirection="column" flexGrow={1} padding={1}>
151
151
  {/* Section 1: Scheme Selector */}
152
152
  <box flexDirection="row" justifyContent="center" marginBottom={1} height={1}>
153
- {(['auto', 'dark', 'light'] as const).map((s) => {
153
+ {(["auto", "dark", "light"] as const).map((s) => {
154
154
  const isActive = scheme === s;
155
- const isFocused = focusPane === 'scheme';
155
+ const isFocused = focusPane === "scheme";
156
156
  return (
157
157
  <box key={s} marginRight={1}>
158
158
  <text
159
159
  {...(isActive ? { bg: colors.primary } : {})}
160
160
  fg={isActive ? colors.background : isFocused ? colors.text : colors.textMuted}
161
161
  >
162
- {isFocused && isActive ? '▸ ' : ' '}
162
+ {isFocused && isActive ? "▸ " : " "}
163
163
  {s.charAt(0).toUpperCase() + s.slice(1)}
164
- {isFocused && isActive ? ' ◂' : ' '}
164
+ {isFocused && isActive ? " ◂" : " "}
165
165
  </text>
166
166
  </box>
167
167
  );
@@ -174,19 +174,22 @@ export function ThemePicker({
174
174
  <text fg={colors.textMuted}>THEMES</text>
175
175
  {(hasMoreAbove || hasMoreBelow) && (
176
176
  <text fg={colors.textSubtle}>
177
- {hasMoreAbove ? '▲' : ' '}{hasMoreBelow ? '▼' : ' '}
177
+ {hasMoreAbove ? "▲" : " "}
178
+ {hasMoreBelow ? "▼" : " "}
178
179
  </text>
179
180
  )}
180
181
  </box>
181
182
 
182
183
  {visibleItems.map((item, idx) => {
183
184
  const realIndex = scrollOffset + idx;
184
- const isFocused = realIndex === focusedIndex && focusPane === 'list';
185
-
186
- if (item.type === 'header') {
185
+ const isFocused = realIndex === focusedIndex && focusPane === "list";
186
+
187
+ if (item.type === "header") {
187
188
  return (
188
189
  <box key={`header-${realIndex}`} height={1} marginTop={1} marginBottom={0}>
189
- <text fg={colors.textSubtle}><strong>{item.label}</strong></text>
190
+ <text fg={colors.textSubtle}>
191
+ <strong>{item.label}</strong>
192
+ </text>
190
193
  </box>
191
194
  );
192
195
  }
@@ -203,21 +206,19 @@ export function ThemePicker({
203
206
  {...(isFocused ? { backgroundColor: colors.primary } : {})}
204
207
  >
205
208
  <text fg={isFocused ? colors.background : colors.primary}>
206
- {isFocused ? '▸ ' : ' '}
209
+ {isFocused ? "▸ " : " "}
207
210
  </text>
208
-
211
+
209
212
  <box width={20}>
210
- <text fg={isFocused ? colors.background : colors.text}>
211
- {theme.name}
212
- </text>
213
+ <text fg={isFocused ? colors.background : colors.text}>{theme.name}</text>
213
214
  </box>
214
215
 
215
216
  <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>
217
+ <text bg={theme.colors.background}> </text>
218
+ <text bg={theme.colors.primary}> </text>
219
+ <text bg={theme.colors.secondary}> </text>
220
+ <text bg={theme.colors.success}> </text>
221
+ <text bg={theme.colors.error}> </text>
221
222
  </box>
222
223
 
223
224
  {isActive && (
@@ -1,14 +1,14 @@
1
- import { useState, useEffect } from 'react';
2
- import { useColors } from '../contexts/ThemeContext.tsx';
1
+ import { useEffect, useState } from "react";
2
+ import { useColors } from "../contexts/ThemeContext.tsx";
3
3
 
4
4
  interface ToastProps {
5
5
  message: string;
6
6
  duration?: number;
7
7
  onDismiss: () => void;
8
- type?: 'info' | 'success' | 'warning' | 'error';
8
+ type?: "info" | "success" | "warning" | "error";
9
9
  }
10
10
 
11
- export function Toast({ message, duration = 2000, onDismiss, type = 'success' }: ToastProps) {
11
+ export function Toast({ message, duration = 2000, onDismiss, type = "success" }: ToastProps) {
12
12
  const colors = useColors();
13
13
 
14
14
  useEffect(() => {
@@ -43,13 +43,16 @@ export function Toast({ message, duration = 2000, onDismiss, type = 'success' }:
43
43
 
44
44
  interface ToastState {
45
45
  message: string;
46
- type: 'info' | 'success' | 'warning' | 'error';
46
+ type: "info" | "success" | "warning" | "error";
47
47
  }
48
48
 
49
49
  export function useToast() {
50
50
  const [toast, setToast] = useState<ToastState | null>(null);
51
51
 
52
- const showToast = (message: string, type: 'info' | 'success' | 'warning' | 'error' = 'success') => {
52
+ const showToast = (
53
+ message: string,
54
+ type: "info" | "success" | "warning" | "error" = "success",
55
+ ) => {
53
56
  setToast({ message, type });
54
57
  };
55
58