@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,204 @@
1
+ export interface ViewVisit {
2
+ view: string;
3
+ visitedAt: number;
4
+ duration?: number;
5
+ }
6
+
7
+ export interface CoverageReport {
8
+ sessionStart: number;
9
+ sessionEnd?: number | undefined;
10
+ viewsVisited: ViewVisit[];
11
+ uniqueViews: string[];
12
+ viewCounts: Record<string, number>;
13
+ totalViewChanges: number;
14
+ coveragePercentage?: number | undefined;
15
+ }
16
+
17
+ export interface CoverageTracker {
18
+ start(): void;
19
+ stop(): CoverageReport;
20
+ recordView(view: string): void;
21
+ getCurrentView(): string | null;
22
+ getReport(): CoverageReport;
23
+ isTracking(): boolean;
24
+ }
25
+
26
+ const KNOWN_VIEWS = [
27
+ 'Dashboard',
28
+ 'Providers',
29
+ 'Trends',
30
+ 'Projects',
31
+ 'Settings',
32
+ ];
33
+
34
+ export function createCoverageTracker(
35
+ knownViews: string[] = KNOWN_VIEWS
36
+ ): CoverageTracker {
37
+ let tracking = false;
38
+ let sessionStart = 0;
39
+ let currentView: string | null = null;
40
+ let currentViewStart = 0;
41
+ const viewsVisited: ViewVisit[] = [];
42
+ const viewCounts: Record<string, number> = {};
43
+
44
+ function buildReport(): CoverageReport {
45
+ const uniqueViews = [...new Set(viewsVisited.map((v) => v.view))];
46
+ const coveragePercentage =
47
+ knownViews.length > 0
48
+ ? (uniqueViews.filter((v) => knownViews.includes(v)).length /
49
+ knownViews.length) *
50
+ 100
51
+ : undefined;
52
+
53
+ return {
54
+ sessionStart,
55
+ sessionEnd: tracking ? undefined : Date.now(),
56
+ viewsVisited: [...viewsVisited],
57
+ uniqueViews,
58
+ viewCounts: { ...viewCounts },
59
+ totalViewChanges: viewsVisited.length,
60
+ coveragePercentage,
61
+ };
62
+ }
63
+
64
+ return {
65
+ start() {
66
+ tracking = true;
67
+ sessionStart = Date.now();
68
+ currentView = null;
69
+ currentViewStart = 0;
70
+ viewsVisited.length = 0;
71
+ Object.keys(viewCounts).forEach((k) => delete viewCounts[k]);
72
+ },
73
+
74
+ stop() {
75
+ if (currentView && currentViewStart > 0) {
76
+ const lastVisit = viewsVisited[viewsVisited.length - 1];
77
+ if (lastVisit && lastVisit.view === currentView) {
78
+ lastVisit.duration = Date.now() - currentViewStart;
79
+ }
80
+ }
81
+ tracking = false;
82
+ return buildReport();
83
+ },
84
+
85
+ recordView(view: string) {
86
+ if (!tracking) return;
87
+ if (view === currentView) return;
88
+
89
+ const now = Date.now();
90
+
91
+ if (currentView && currentViewStart > 0) {
92
+ const lastVisit = viewsVisited[viewsVisited.length - 1];
93
+ if (lastVisit && lastVisit.view === currentView) {
94
+ lastVisit.duration = now - currentViewStart;
95
+ }
96
+ }
97
+
98
+ currentView = view;
99
+ currentViewStart = now;
100
+ viewsVisited.push({ view, visitedAt: now });
101
+ viewCounts[view] = (viewCounts[view] ?? 0) + 1;
102
+ },
103
+
104
+ getCurrentView() {
105
+ return currentView;
106
+ },
107
+
108
+ getReport() {
109
+ return buildReport();
110
+ },
111
+
112
+ isTracking() {
113
+ return tracking;
114
+ },
115
+ };
116
+ }
117
+
118
+ export function detectViewFromFrame(frame: string): string | null {
119
+ const lines = frame.split('\n').slice(0, 5);
120
+ const headerArea = lines.join('\n');
121
+
122
+ if (headerArea.includes('[1] Dashboard') && headerArea.includes('│')) {
123
+ const match = headerArea.match(/\[(\d)\] (\w+)/g);
124
+ if (match) {
125
+ for (const m of match) {
126
+ if (!m.includes('[')) continue;
127
+ const viewMatch = m.match(/\[(\d)\] (\w+)/);
128
+ if (viewMatch) {
129
+ const num = viewMatch[1];
130
+ const name = viewMatch[2];
131
+ if (
132
+ headerArea.includes(`[${num}]`) &&
133
+ frame.toLowerCase().includes(name?.toLowerCase() ?? '')
134
+ ) {
135
+ if (
136
+ headerArea.includes(`[${num}] ${name}`) &&
137
+ !headerArea.includes(`│ ${name}`)
138
+ ) {
139
+ return name ?? null;
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ for (const view of KNOWN_VIEWS) {
148
+ if (frame.includes(`${view} `) || frame.includes(` ${view}`)) {
149
+ const viewLower = view.toLowerCase();
150
+ const frameLower = frame.toLowerCase();
151
+ if (
152
+ frameLower.includes(`${viewLower} view`) ||
153
+ frameLower.includes(`${viewLower}:`) ||
154
+ (frameLower.includes(viewLower) && lines.some((l) => l.includes(view)))
155
+ ) {
156
+ return view;
157
+ }
158
+ }
159
+ }
160
+
161
+ return null;
162
+ }
163
+
164
+ export function formatCoverageReport(report: CoverageReport): string {
165
+ const lines: string[] = [
166
+ '╔══════════════════════════════════════════════════════════════════╗',
167
+ '║ COVERAGE REPORT ║',
168
+ '╠══════════════════════════════════════════════════════════════════╣',
169
+ ];
170
+
171
+ const duration = (report.sessionEnd ?? Date.now()) - report.sessionStart;
172
+ const durationSec = (duration / 1000).toFixed(1);
173
+
174
+ lines.push(
175
+ `║ Session Duration: ${durationSec}s`.padEnd(67) + '║'
176
+ );
177
+ lines.push(
178
+ `║ Views Visited: ${report.uniqueViews.length} unique, ${report.totalViewChanges} total`.padEnd(
179
+ 67
180
+ ) + '║'
181
+ );
182
+
183
+ if (report.coveragePercentage !== undefined) {
184
+ lines.push(
185
+ `║ Coverage: ${report.coveragePercentage.toFixed(0)}%`.padEnd(67) + '║'
186
+ );
187
+ }
188
+
189
+ lines.push('╠══════════════════════════════════════════════════════════════════╣');
190
+ lines.push('║ View Breakdown: ║');
191
+
192
+ for (const [view, count] of Object.entries(report.viewCounts).sort(
193
+ (a, b) => b[1] - a[1]
194
+ )) {
195
+ const visits = report.viewsVisited.filter((v) => v.view === view);
196
+ const totalTime = visits.reduce((sum, v) => sum + (v.duration ?? 0), 0);
197
+ const timeStr = totalTime > 0 ? ` (${(totalTime / 1000).toFixed(1)}s)` : '';
198
+ lines.push(`║ ${view}: ${count}x${timeStr}`.padEnd(67) + '║');
199
+ }
200
+
201
+ lines.push('╚══════════════════════════════════════════════════════════════════╝');
202
+
203
+ return lines.join('\n');
204
+ }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bun
2
+ import { createDriver } from './driver.ts';
3
+ import * as fs from 'fs/promises';
4
+ import * as path from 'path';
5
+
6
+ const SNAPSHOTS_DIR = './snapshots';
7
+
8
+ async function saveSnapshot(frame: string, name: string): Promise<string> {
9
+ await fs.mkdir(SNAPSHOTS_DIR, { recursive: true });
10
+ const filePath = path.join(SNAPSHOTS_DIR, `${name}.txt`);
11
+ await fs.writeFile(filePath, frame, 'utf-8');
12
+ return path.resolve(filePath);
13
+ }
14
+
15
+ async function main() {
16
+ console.log('Creating driver...');
17
+ const driver = await createDriver({ width: 100, height: 30 });
18
+
19
+ console.log('Launching app...');
20
+ await driver.launch();
21
+ await driver.waitForStable();
22
+
23
+ console.log('Capturing dashboard...');
24
+ const dashboardFrame = await driver.capture();
25
+ const dashboardPath = await saveSnapshot(dashboardFrame, '01-dashboard');
26
+ console.log(`Saved: ${dashboardPath}`);
27
+
28
+ console.log('Switching to Providers view...');
29
+ await driver.pressKey('2');
30
+ await driver.waitForStable();
31
+ const providersFrame = await driver.capture();
32
+ const providersPath = await saveSnapshot(providersFrame, '02-providers');
33
+ console.log(`Saved: ${providersPath}`);
34
+
35
+ console.log('Switching to Trends view...');
36
+ await driver.pressKey('3');
37
+ await driver.waitForStable();
38
+ const trendsFrame = await driver.capture();
39
+ const trendsPath = await saveSnapshot(trendsFrame, '03-trends');
40
+ console.log(`Saved: ${trendsPath}`);
41
+
42
+ console.log('Switching to Projects view...');
43
+ await driver.pressKey('4');
44
+ await driver.waitForStable();
45
+ const projectsFrame = await driver.capture();
46
+ const projectsPath = await saveSnapshot(projectsFrame, '04-projects');
47
+ console.log(`Saved: ${projectsPath}`);
48
+
49
+ console.log('Switching to Settings view...');
50
+ await driver.pressKey('5');
51
+ await driver.waitForStable();
52
+ const settingsFrame = await driver.capture();
53
+ const settingsPath = await saveSnapshot(settingsFrame, '05-settings');
54
+ console.log(`Saved: ${settingsPath}`);
55
+
56
+ console.log('Closing driver...');
57
+ await driver.close();
58
+
59
+ console.log('\n✓ All snapshots saved to ./snapshots/');
60
+ console.log(' You can view them with: cat snapshots/01-dashboard.txt');
61
+ }
62
+
63
+ main().catch((err) => {
64
+ console.error('Demo failed:', err);
65
+ process.exit(1);
66
+ });
@@ -0,0 +1,166 @@
1
+ export interface DiffResult {
2
+ identical: boolean;
3
+ changedLines: number;
4
+ totalLines: number;
5
+ changePercentage: number;
6
+ additions: LineChange[];
7
+ deletions: LineChange[];
8
+ modifications: LineChange[];
9
+ visualDiff: string;
10
+ }
11
+
12
+ export interface LineChange {
13
+ lineNumber: number;
14
+ content: string;
15
+ expected?: string;
16
+ }
17
+
18
+ export interface DiffOptions {
19
+ ignoreWhitespace?: boolean;
20
+ contextLines?: number;
21
+ }
22
+
23
+ export function diffFrames(
24
+ expected: string,
25
+ actual: string,
26
+ options: DiffOptions = {}
27
+ ): DiffResult {
28
+ const { ignoreWhitespace = false, contextLines = 2 } = options;
29
+
30
+ const normalize = (s: string) =>
31
+ ignoreWhitespace ? s.replace(/\s+/g, ' ').trim() : s;
32
+
33
+ const expectedLines = expected.split('\n');
34
+ const actualLines = actual.split('\n');
35
+
36
+ const additions: LineChange[] = [];
37
+ const deletions: LineChange[] = [];
38
+ const modifications: LineChange[] = [];
39
+ const diffLines: string[] = [];
40
+
41
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
42
+ const changedLineNumbers = new Set<number>();
43
+
44
+ for (let i = 0; i < maxLines; i++) {
45
+ const expLine = expectedLines[i] ?? '';
46
+ const actLine = actualLines[i] ?? '';
47
+ const expNorm = normalize(expLine);
48
+ const actNorm = normalize(actLine);
49
+
50
+ if (expNorm !== actNorm) {
51
+ changedLineNumbers.add(i);
52
+
53
+ if (i >= expectedLines.length) {
54
+ additions.push({ lineNumber: i + 1, content: actLine });
55
+ } else if (i >= actualLines.length) {
56
+ deletions.push({ lineNumber: i + 1, content: expLine });
57
+ } else {
58
+ modifications.push({
59
+ lineNumber: i + 1,
60
+ content: actLine,
61
+ expected: expLine,
62
+ });
63
+ }
64
+ }
65
+ }
66
+
67
+ for (let i = 0; i < maxLines; i++) {
68
+ const expLine = expectedLines[i] ?? '';
69
+ const actLine = actualLines[i] ?? '';
70
+
71
+ const isInContext = Array.from(changedLineNumbers).some(
72
+ (changed) => Math.abs(changed - i) <= contextLines
73
+ );
74
+
75
+ if (changedLineNumbers.has(i)) {
76
+ if (i >= expectedLines.length) {
77
+ diffLines.push(`+ ${String(i + 1).padStart(3)} │ ${actLine}`);
78
+ } else if (i >= actualLines.length) {
79
+ diffLines.push(`- ${String(i + 1).padStart(3)} │ ${expLine}`);
80
+ } else {
81
+ diffLines.push(`- ${String(i + 1).padStart(3)} │ ${expLine}`);
82
+ diffLines.push(`+ ${String(i + 1).padStart(3)} │ ${actLine}`);
83
+ }
84
+ } else if (isInContext) {
85
+ diffLines.push(` ${String(i + 1).padStart(3)} │ ${actLine}`);
86
+ }
87
+ }
88
+
89
+ const identical = changedLineNumbers.size === 0;
90
+ const changedLines = changedLineNumbers.size;
91
+ const totalLines = maxLines;
92
+ const changePercentage =
93
+ totalLines > 0 ? (changedLines / totalLines) * 100 : 0;
94
+
95
+ return {
96
+ identical,
97
+ changedLines,
98
+ totalLines,
99
+ changePercentage,
100
+ additions,
101
+ deletions,
102
+ modifications,
103
+ visualDiff: diffLines.join('\n'),
104
+ };
105
+ }
106
+
107
+ export function highlightDiff(
108
+ expected: string,
109
+ actual: string,
110
+ options: DiffOptions = {}
111
+ ): string {
112
+ const result = diffFrames(expected, actual, options);
113
+
114
+ if (result.identical) {
115
+ return '✓ Frames are identical';
116
+ }
117
+
118
+ const lines: string[] = [
119
+ `╔══════════════════════════════════════════════════════════════════╗`,
120
+ `║ FRAME DIFF ║`,
121
+ `║ Changed: ${result.changedLines}/${result.totalLines} lines (${result.changePercentage.toFixed(1)}%)${' '.repeat(Math.max(0, 24 - result.changePercentage.toFixed(1).length))}║`,
122
+ `╠══════════════════════════════════════════════════════════════════╣`,
123
+ `║ - = removed + = added ║`,
124
+ `╚══════════════════════════════════════════════════════════════════╝`,
125
+ '',
126
+ result.visualDiff,
127
+ ];
128
+
129
+ return lines.join('\n');
130
+ }
131
+
132
+ export function createCharDiff(expected: string, actual: string): string {
133
+ const expectedLines = expected.split('\n');
134
+ const actualLines = actual.split('\n');
135
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
136
+
137
+ const diffLines: string[] = [];
138
+
139
+ for (let i = 0; i < maxLines; i++) {
140
+ const expLine = expectedLines[i] ?? '';
141
+ const actLine = actualLines[i] ?? '';
142
+
143
+ if (expLine === actLine) {
144
+ continue;
145
+ }
146
+
147
+ const maxLen = Math.max(expLine.length, actLine.length);
148
+ let markers = '';
149
+
150
+ for (let j = 0; j < maxLen; j++) {
151
+ const expChar = expLine[j] ?? ' ';
152
+ const actChar = actLine[j] ?? ' ';
153
+ markers += expChar === actChar ? ' ' : '^';
154
+ }
155
+
156
+ if (markers.trim()) {
157
+ diffLines.push(`Line ${i + 1}:`);
158
+ diffLines.push(` exp: ${expLine}`);
159
+ diffLines.push(` act: ${actLine}`);
160
+ diffLines.push(` ${markers}`);
161
+ diffLines.push('');
162
+ }
163
+ }
164
+
165
+ return diffLines.join('\n');
166
+ }
@@ -0,0 +1,270 @@
1
+ import { createTestRenderer } from '@opentui/core/testing';
2
+ import { AppContext, createRoot } from '@opentui/react';
3
+ import React from 'react';
4
+ import { act } from 'react';
5
+ import { createAppElement, type CreateAppOptions } from '../createApp.tsx';
6
+ import { TestModeContext } from '../hooks/useSafeRenderer.ts';
7
+
8
+ export interface DriverOptions {
9
+ width?: number;
10
+ height?: number;
11
+ appOptions?: CreateAppOptions;
12
+ }
13
+
14
+ export interface CaptureResult {
15
+ frame: string;
16
+ width: number;
17
+ height: number;
18
+ timestamp: number;
19
+ }
20
+
21
+ export interface WaitOptions {
22
+ timeout?: number;
23
+ interval?: number;
24
+ }
25
+
26
+ export interface StableOptions {
27
+ maxIterations?: number;
28
+ intervalMs?: number;
29
+ stableFrames?: number;
30
+ }
31
+
32
+ export interface KeyModifiers {
33
+ ctrl?: boolean;
34
+ shift?: boolean;
35
+ meta?: boolean;
36
+ option?: boolean;
37
+ }
38
+
39
+ type TestSetup = Awaited<ReturnType<typeof createTestRenderer>> & {
40
+ root: ReturnType<typeof createRoot> | null;
41
+ };
42
+
43
+ export interface Driver {
44
+ launch(): Promise<void>;
45
+ close(): Promise<void>;
46
+ isRunning(): boolean;
47
+
48
+ sendKeys(keys: string): Promise<void>;
49
+ pressKey(key: string, modifiers?: KeyModifiers): Promise<void>;
50
+ typeText(text: string, delay?: number): Promise<void>;
51
+ pressEnter(): Promise<void>;
52
+ pressEscape(): Promise<void>;
53
+ pressTab(): Promise<void>;
54
+ pressArrow(direction: 'up' | 'down' | 'left' | 'right'): Promise<void>;
55
+
56
+ capture(): Promise<string>;
57
+ captureWithMeta(): Promise<CaptureResult>;
58
+
59
+ waitForText(text: string, options?: WaitOptions): Promise<boolean>;
60
+ waitForStable(options?: StableOptions): Promise<void>;
61
+ settle(): Promise<void>;
62
+
63
+ resize(cols: number, rows: number): Promise<void>;
64
+ getSize(): { width: number; height: number };
65
+ }
66
+
67
+ export async function createDriver(options: DriverOptions = {}): Promise<Driver> {
68
+ const width = options.width ?? 100;
69
+ const height = options.height ?? 30;
70
+ const appOptions = {
71
+ ...options.appOptions,
72
+ debug: options.appOptions?.debug ?? false,
73
+ testMode: true,
74
+ };
75
+
76
+ let testSetup: TestSetup | null = null;
77
+ let currentWidth = width;
78
+ let currentHeight = height;
79
+
80
+ const assertRunning = (): TestSetup => {
81
+ if (!testSetup) {
82
+ throw new Error('Driver not launched. Call launch() first.');
83
+ }
84
+ return testSetup;
85
+ };
86
+
87
+ const driver: Driver = {
88
+ async launch() {
89
+ if (testSetup) {
90
+ throw new Error('Driver already launched. Call close() first.');
91
+ }
92
+
93
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
94
+
95
+ const coreTestSetup = await createTestRenderer({
96
+ width: currentWidth,
97
+ height: currentHeight,
98
+ });
99
+
100
+ const root = createRoot(coreTestSetup.renderer);
101
+
102
+ const appElement = createAppElement(appOptions);
103
+
104
+ const wrappedApp = React.createElement(
105
+ AppContext.Provider,
106
+ {
107
+ value: {
108
+ keyHandler: coreTestSetup.renderer.keyInput,
109
+ renderer: coreTestSetup.renderer
110
+ }
111
+ },
112
+ React.createElement(
113
+ TestModeContext.Provider,
114
+ { value: true },
115
+ appElement
116
+ )
117
+ );
118
+
119
+ act(() => {
120
+ root.render(wrappedApp);
121
+ });
122
+
123
+ testSetup = { ...coreTestSetup, root };
124
+ await testSetup.renderOnce();
125
+ },
126
+
127
+ async close() {
128
+ if (testSetup) {
129
+ if (testSetup.root) {
130
+ act(() => {
131
+ testSetup!.root!.unmount();
132
+ });
133
+ }
134
+ testSetup.renderer.destroy();
135
+ testSetup = null;
136
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = false;
137
+ }
138
+ },
139
+
140
+ isRunning() {
141
+ return testSetup !== null;
142
+ },
143
+
144
+ async sendKeys(keys: string) {
145
+ const setup = assertRunning();
146
+
147
+ for (const char of keys) {
148
+ if (char === '\r' || char === '\n') {
149
+ setup.mockInput.pressEnter();
150
+ } else if (char === '\t') {
151
+ setup.mockInput.pressTab();
152
+ } else if (char === '\x1b') {
153
+ setup.mockInput.pressEscape();
154
+ } else if (char === '\x7f') {
155
+ setup.mockInput.pressBackspace();
156
+ } else {
157
+ setup.mockInput.pressKey(char);
158
+ }
159
+ }
160
+
161
+ await this.settle();
162
+ },
163
+
164
+ async pressKey(key: string, modifiers: KeyModifiers = {}) {
165
+ const setup = assertRunning();
166
+ setup.mockInput.pressKey(key, modifiers);
167
+ await this.settle();
168
+ },
169
+
170
+ async typeText(text: string, delay = 0) {
171
+ const setup = assertRunning();
172
+ await setup.mockInput.typeText(text, delay);
173
+ await this.settle();
174
+ },
175
+
176
+ async pressEnter() {
177
+ const setup = assertRunning();
178
+ setup.mockInput.pressEnter();
179
+ await this.settle();
180
+ },
181
+
182
+ async pressEscape() {
183
+ const setup = assertRunning();
184
+ setup.mockInput.pressEscape();
185
+ await this.settle();
186
+ },
187
+
188
+ async pressTab() {
189
+ const setup = assertRunning();
190
+ setup.mockInput.pressTab();
191
+ await this.settle();
192
+ },
193
+
194
+ async pressArrow(direction: 'up' | 'down' | 'left' | 'right') {
195
+ const setup = assertRunning();
196
+ setup.mockInput.pressArrow(direction);
197
+ await this.settle();
198
+ },
199
+
200
+ async capture(): Promise<string> {
201
+ const setup = assertRunning();
202
+ await setup.renderOnce();
203
+ return setup.captureCharFrame();
204
+ },
205
+
206
+ async captureWithMeta(): Promise<CaptureResult> {
207
+ const frame = await this.capture();
208
+ return {
209
+ frame,
210
+ width: currentWidth,
211
+ height: currentHeight,
212
+ timestamp: Date.now(),
213
+ };
214
+ },
215
+
216
+ async waitForText(text: string, options: WaitOptions = {}): Promise<boolean> {
217
+ const { timeout = 5000, interval = 50 } = options;
218
+ const start = Date.now();
219
+
220
+ while (Date.now() - start < timeout) {
221
+ const frame = await this.capture();
222
+ if (frame.includes(text)) {
223
+ return true;
224
+ }
225
+ await new Promise(resolve => setTimeout(resolve, interval));
226
+ }
227
+
228
+ return false;
229
+ },
230
+
231
+ async waitForStable(options: StableOptions = {}): Promise<void> {
232
+ const { maxIterations = 10, intervalMs = 50, stableFrames = 2 } = options;
233
+ let lastFrame = '';
234
+ let stableCount = 0;
235
+
236
+ for (let i = 0; i < maxIterations; i++) {
237
+ const frame = await this.capture();
238
+ if (frame === lastFrame) {
239
+ stableCount++;
240
+ if (stableCount >= stableFrames) {
241
+ return;
242
+ }
243
+ } else {
244
+ stableCount = 0;
245
+ }
246
+ lastFrame = frame;
247
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
248
+ }
249
+ },
250
+
251
+ async settle(): Promise<void> {
252
+ const setup = assertRunning();
253
+ await setup.renderOnce();
254
+ },
255
+
256
+ async resize(cols: number, rows: number): Promise<void> {
257
+ const setup = assertRunning();
258
+ currentWidth = cols;
259
+ currentHeight = rows;
260
+ setup.resize(cols, rows);
261
+ await this.settle();
262
+ },
263
+
264
+ getSize() {
265
+ return { width: currentWidth, height: currentHeight };
266
+ },
267
+ };
268
+
269
+ return driver;
270
+ }