@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.
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/bin/ttop.js +49 -0
- package/package.json +68 -0
- package/src/agents/aggregator.ts +176 -0
- package/src/agents/costing.ts +74 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/types.ts +71 -0
- package/src/cli.ts +167 -0
- package/src/config/schema.test.ts +166 -0
- package/src/config/schema.ts +165 -0
- package/src/demo/simulator.ts +745 -0
- package/src/plugins/agents/index.ts +2 -0
- package/src/plugins/auth-sources.ts +186 -0
- package/src/plugins/lifecycle.ts +177 -0
- package/src/plugins/loader.test.ts +195 -0
- package/src/plugins/loader.ts +282 -0
- package/src/plugins/notification-bus.ts +153 -0
- package/src/plugins/notifications/index.ts +2 -0
- package/src/plugins/notifications/terminal-bell.ts +66 -0
- package/src/plugins/notifications/visual-flash.ts +57 -0
- package/src/plugins/npm-installer.ts +164 -0
- package/src/plugins/plugin-context-factory.ts +67 -0
- package/src/plugins/plugin-host.ts +205 -0
- package/src/plugins/providers/anthropic.ts +325 -0
- package/src/plugins/providers/antigravity.ts +309 -0
- package/src/plugins/providers/codex.ts +250 -0
- package/src/plugins/providers/gemini.ts +391 -0
- package/src/plugins/providers/github-copilot.ts +194 -0
- package/src/plugins/providers/index.ts +12 -0
- package/src/plugins/providers/minimax.ts +223 -0
- package/src/plugins/providers/openai-api.ts +250 -0
- package/src/plugins/providers/opencode-zen.ts +125 -0
- package/src/plugins/providers/perplexity.ts +177 -0
- package/src/plugins/providers/zai.ts +225 -0
- package/src/plugins/registry.ts +251 -0
- package/src/plugins/sandbox-guard.test.ts +191 -0
- package/src/plugins/sandbox-guard.ts +155 -0
- package/src/plugins/sandbox.ts +138 -0
- package/src/plugins/themes/catppuccin-latte.ts +45 -0
- package/src/plugins/themes/catppuccin-mocha.ts +45 -0
- package/src/plugins/themes/claude-code.ts +45 -0
- package/src/plugins/themes/dracula.ts +45 -0
- package/src/plugins/themes/github-light.ts +45 -0
- package/src/plugins/themes/gruvbox-dark.ts +45 -0
- package/src/plugins/themes/gruvbox-light.ts +45 -0
- package/src/plugins/themes/index.ts +15 -0
- package/src/plugins/themes/kanagawa.ts +45 -0
- package/src/plugins/themes/nord.ts +45 -0
- package/src/plugins/themes/one-dark.ts +45 -0
- package/src/plugins/themes/opencode.ts +47 -0
- package/src/plugins/themes/rose-pine-dawn.ts +45 -0
- package/src/plugins/themes/rose-pine.ts +45 -0
- package/src/plugins/themes/solarized-light.ts +45 -0
- package/src/plugins/themes/tokyo-night.ts +49 -0
- package/src/plugins/types/agent.ts +108 -0
- package/src/plugins/types/base.ts +267 -0
- package/src/plugins/types/index.ts +19 -0
- package/src/plugins/types/notification.ts +51 -0
- package/src/plugins/types/provider.ts +233 -0
- package/src/plugins/types/theme.ts +70 -0
- package/src/plugins/update-checker.ts +177 -0
- package/src/pricing/estimator.test.ts +181 -0
- package/src/pricing/estimator.ts +80 -0
- package/src/pricing/fallback.test.ts +71 -0
- package/src/pricing/fallback.ts +62 -0
- package/src/pricing/index.ts +66 -0
- package/src/pricing/models-dev.ts +130 -0
- package/src/storage/database.ts +613 -0
- package/src/storage/db.ts +64 -0
- package/src/storage/index.ts +66 -0
- package/src/storage/migrations/index.ts +189 -0
- package/src/storage/paths.ts +24 -0
- package/src/storage/repos/agentSessions.ts +210 -0
- package/src/storage/repos/providerSnapshots.ts +116 -0
- package/src/storage/repos/usageEvents.ts +334 -0
- package/src/storage/types.ts +288 -0
- package/src/tui/App.tsx +381 -0
- package/src/tui/components/CommandPalette.tsx +166 -0
- package/src/tui/components/DebugConsole.tsx +161 -0
- package/src/tui/components/DebugPanel.tsx +660 -0
- package/src/tui/components/FooterHints.tsx +22 -0
- package/src/tui/components/GhostProviderCard.tsx +45 -0
- package/src/tui/components/Header.tsx +125 -0
- package/src/tui/components/HelpOverlay.tsx +45 -0
- package/src/tui/components/InlineGauge.tsx +34 -0
- package/src/tui/components/InlineSparkline.tsx +78 -0
- package/src/tui/components/KpiStrip.tsx +246 -0
- package/src/tui/components/LimitGauge.tsx +186 -0
- package/src/tui/components/ModalBackdrop.tsx +42 -0
- package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
- package/src/tui/components/ProviderCard.tsx +309 -0
- package/src/tui/components/ProviderDetailPanel.tsx +237 -0
- package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
- package/src/tui/components/ProvidersList.tsx +380 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
- package/src/tui/components/SessionsTable.tsx +401 -0
- package/src/tui/components/SettingsModal.tsx +587 -0
- package/src/tui/components/SidebarBreakdown.tsx +87 -0
- package/src/tui/components/Skeleton.tsx +102 -0
- package/src/tui/components/SmartSidebar.tsx +644 -0
- package/src/tui/components/Sparkline.tsx +180 -0
- package/src/tui/components/Spinner.tsx +36 -0
- package/src/tui/components/StatusBar.tsx +96 -0
- package/src/tui/components/ThemePicker.tsx +234 -0
- package/src/tui/components/Toast.tsx +61 -0
- package/src/tui/components/UsageGauge.tsx +80 -0
- package/src/tui/components/index.ts +5 -0
- package/src/tui/contexts/AgentSessionContext.tsx +307 -0
- package/src/tui/contexts/ConfigContext.tsx +141 -0
- package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
- package/src/tui/contexts/DemoModeContext.tsx +60 -0
- package/src/tui/contexts/DrawerContext.tsx +42 -0
- package/src/tui/contexts/InputContext.tsx +30 -0
- package/src/tui/contexts/LogContext.tsx +150 -0
- package/src/tui/contexts/PluginContext.tsx +424 -0
- package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
- package/src/tui/contexts/StorageContext.tsx +233 -0
- package/src/tui/contexts/ThemeContext.tsx +95 -0
- package/src/tui/contexts/TimeWindowContext.tsx +158 -0
- package/src/tui/contexts/ToastContext.tsx +40 -0
- package/src/tui/contexts/index.ts +5 -0
- package/src/tui/createApp.tsx +47 -0
- package/src/tui/debug/captureFrame.ts +262 -0
- package/src/tui/debug/snapshot.tsx +644 -0
- package/src/tui/driver/assertions.ts +279 -0
- package/src/tui/driver/cli.ts +717 -0
- package/src/tui/driver/coverage.ts +204 -0
- package/src/tui/driver/demo-snapshot.ts +66 -0
- package/src/tui/driver/diff.ts +166 -0
- package/src/tui/driver/driver.ts +270 -0
- package/src/tui/driver/index.ts +53 -0
- package/src/tui/driver/recorder.ts +303 -0
- package/src/tui/driver/test-workflow.ts +115 -0
- package/src/tui/driver/test.ts +47 -0
- package/src/tui/hooks/index.ts +20 -0
- package/src/tui/hooks/useAnimatedValue.ts +113 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
- package/src/tui/hooks/useDashboardState.ts +116 -0
- package/src/tui/hooks/useEmaActivity.ts +223 -0
- package/src/tui/hooks/useEntranceAnimation.ts +62 -0
- package/src/tui/hooks/useExitAnimation.ts +137 -0
- package/src/tui/hooks/usePulse.ts +138 -0
- package/src/tui/hooks/useSafeRenderer.ts +24 -0
- package/src/tui/hooks/useValueFlash.ts +111 -0
- package/src/tui/index.tsx +50 -0
- package/src/tui/utils/providerColor.ts +32 -0
- package/src/tui/views/Dashboard.tsx +340 -0
- package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
- package/src/tui/views/ProjectsView.tsx +1277 -0
- package/src/tui/views/RealTimeDashboard.tsx +393 -0
- package/src/tui/views/SettingsView.tsx +465 -0
- package/src/tui/views/index.ts +2 -0
- package/src/types/global.d.ts +5 -0
- package/src/types/opentui-test-utils.d.ts +18 -0
- package/src/utils/clipboard.ts +118 -0
- package/src/utils/currency.test.ts +106 -0
- package/src/utils/currency.ts +79 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import * as readline from 'readline';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { diffFrames, highlightDiff } from './diff.ts';
|
|
6
|
+
import { assertSnapshot, listGoldenFiles, deleteGoldenFile, getGoldenFile } from './assertions.ts';
|
|
7
|
+
import { createDriver, type Driver, type DriverOptions } from './driver.ts';
|
|
8
|
+
import {
|
|
9
|
+
createRecorder,
|
|
10
|
+
replayRecording,
|
|
11
|
+
saveRecording,
|
|
12
|
+
loadRecording,
|
|
13
|
+
listRecordings,
|
|
14
|
+
deleteRecording,
|
|
15
|
+
type Recorder,
|
|
16
|
+
type Recording,
|
|
17
|
+
} from './recorder.ts';
|
|
18
|
+
import {
|
|
19
|
+
createCoverageTracker,
|
|
20
|
+
detectViewFromFrame,
|
|
21
|
+
formatCoverageReport,
|
|
22
|
+
type CoverageTracker,
|
|
23
|
+
} from './coverage.ts';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_SNAPSHOTS_DIR = './snapshots';
|
|
26
|
+
|
|
27
|
+
interface Command {
|
|
28
|
+
action: string;
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Response {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
error?: string;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let driver: Driver | null = null;
|
|
39
|
+
let frameCounter = 0;
|
|
40
|
+
let recorder: Recorder | null = null;
|
|
41
|
+
let coverageTracker: CoverageTracker | null = null;
|
|
42
|
+
let currentWidth = 100;
|
|
43
|
+
let currentHeight = 30;
|
|
44
|
+
|
|
45
|
+
async function ensureDir(dir: string): Promise<void> {
|
|
46
|
+
await fs.mkdir(dir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function saveFrameToFile(frame: string, filePath: string): Promise<string> {
|
|
50
|
+
const dir = path.dirname(filePath);
|
|
51
|
+
await ensureDir(dir);
|
|
52
|
+
await fs.writeFile(filePath, frame, 'utf-8');
|
|
53
|
+
return path.resolve(filePath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function handleCommand(cmd: Command): Promise<Response> {
|
|
57
|
+
try {
|
|
58
|
+
switch (cmd.action) {
|
|
59
|
+
case 'launch': {
|
|
60
|
+
if (driver?.isRunning()) {
|
|
61
|
+
return { ok: false, error: 'Driver already running' };
|
|
62
|
+
}
|
|
63
|
+
currentWidth = typeof cmd.width === 'number' ? cmd.width : 100;
|
|
64
|
+
currentHeight = typeof cmd.height === 'number' ? cmd.height : 30;
|
|
65
|
+
const options: DriverOptions = {
|
|
66
|
+
width: currentWidth,
|
|
67
|
+
height: currentHeight,
|
|
68
|
+
appOptions: {
|
|
69
|
+
debug: cmd.debug === true,
|
|
70
|
+
demoMode: cmd.demo === true,
|
|
71
|
+
...(typeof cmd.demoSeed === 'number' ? { demoSeed: cmd.demoSeed, demoMode: true } : {}),
|
|
72
|
+
...(typeof cmd.demoPreset === 'string' && ['light', 'normal', 'heavy'].includes(cmd.demoPreset)
|
|
73
|
+
? { demoPreset: cmd.demoPreset as 'light' | 'normal' | 'heavy', demoMode: true }
|
|
74
|
+
: {}),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
driver = await createDriver(options);
|
|
78
|
+
await driver.launch();
|
|
79
|
+
return { ok: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'close': {
|
|
83
|
+
if (!driver) {
|
|
84
|
+
return { ok: false, error: 'Driver not running' };
|
|
85
|
+
}
|
|
86
|
+
await driver.close();
|
|
87
|
+
driver = null;
|
|
88
|
+
return { ok: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case 'sendKeys': {
|
|
92
|
+
if (!driver?.isRunning()) {
|
|
93
|
+
return { ok: false, error: 'Driver not running' };
|
|
94
|
+
}
|
|
95
|
+
const keys = typeof cmd.keys === 'string' ? cmd.keys : '';
|
|
96
|
+
await driver.sendKeys(keys);
|
|
97
|
+
recorder?.addCommand('sendKeys', { keys });
|
|
98
|
+
if (recorder?.isRecording()) {
|
|
99
|
+
recorder.captureFrame(await driver.capture());
|
|
100
|
+
}
|
|
101
|
+
return { ok: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case 'pressKey': {
|
|
105
|
+
if (!driver?.isRunning()) {
|
|
106
|
+
return { ok: false, error: 'Driver not running' };
|
|
107
|
+
}
|
|
108
|
+
const key = typeof cmd.key === 'string' ? cmd.key : '';
|
|
109
|
+
const modifiers = typeof cmd.modifiers === 'object' && cmd.modifiers !== null
|
|
110
|
+
? cmd.modifiers as Record<string, boolean>
|
|
111
|
+
: {};
|
|
112
|
+
await driver.pressKey(key, modifiers);
|
|
113
|
+
recorder?.addCommand('pressKey', { key, ...(Object.keys(modifiers).length > 0 ? { modifiers } : {}) });
|
|
114
|
+
if (recorder?.isRecording()) {
|
|
115
|
+
recorder.captureFrame(await driver.capture());
|
|
116
|
+
}
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case 'typeText': {
|
|
121
|
+
if (!driver?.isRunning()) {
|
|
122
|
+
return { ok: false, error: 'Driver not running' };
|
|
123
|
+
}
|
|
124
|
+
const text = typeof cmd.text === 'string' ? cmd.text : '';
|
|
125
|
+
const delay = typeof cmd.delay === 'number' ? cmd.delay : 0;
|
|
126
|
+
await driver.typeText(text, delay);
|
|
127
|
+
recorder?.addCommand('typeText', { text, ...(delay > 0 ? { delay } : {}) });
|
|
128
|
+
if (recorder?.isRecording()) {
|
|
129
|
+
recorder.captureFrame(await driver.capture());
|
|
130
|
+
}
|
|
131
|
+
return { ok: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case 'pressTab': {
|
|
135
|
+
if (!driver?.isRunning()) {
|
|
136
|
+
return { ok: false, error: 'Driver not running' };
|
|
137
|
+
}
|
|
138
|
+
await driver.pressTab();
|
|
139
|
+
recorder?.addCommand('pressTab');
|
|
140
|
+
if (recorder?.isRecording()) {
|
|
141
|
+
recorder.captureFrame(await driver.capture());
|
|
142
|
+
}
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case 'pressEnter': {
|
|
147
|
+
if (!driver?.isRunning()) {
|
|
148
|
+
return { ok: false, error: 'Driver not running' };
|
|
149
|
+
}
|
|
150
|
+
await driver.pressEnter();
|
|
151
|
+
recorder?.addCommand('pressEnter');
|
|
152
|
+
if (recorder?.isRecording()) {
|
|
153
|
+
recorder.captureFrame(await driver.capture());
|
|
154
|
+
}
|
|
155
|
+
return { ok: true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case 'pressEscape': {
|
|
159
|
+
if (!driver?.isRunning()) {
|
|
160
|
+
return { ok: false, error: 'Driver not running' };
|
|
161
|
+
}
|
|
162
|
+
await driver.pressEscape();
|
|
163
|
+
recorder?.addCommand('pressEscape');
|
|
164
|
+
if (recorder?.isRecording()) {
|
|
165
|
+
recorder.captureFrame(await driver.capture());
|
|
166
|
+
}
|
|
167
|
+
return { ok: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case 'pressArrow': {
|
|
171
|
+
if (!driver?.isRunning()) {
|
|
172
|
+
return { ok: false, error: 'Driver not running' };
|
|
173
|
+
}
|
|
174
|
+
const direction = cmd.direction as 'up' | 'down' | 'left' | 'right';
|
|
175
|
+
if (!['up', 'down', 'left', 'right'].includes(direction)) {
|
|
176
|
+
return { ok: false, error: 'Invalid direction. Use: up, down, left, right' };
|
|
177
|
+
}
|
|
178
|
+
await driver.pressArrow(direction);
|
|
179
|
+
recorder?.addCommand('pressArrow', { direction });
|
|
180
|
+
if (recorder?.isRecording()) {
|
|
181
|
+
recorder.captureFrame(await driver.capture());
|
|
182
|
+
}
|
|
183
|
+
return { ok: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'capture': {
|
|
187
|
+
if (!driver?.isRunning()) {
|
|
188
|
+
return { ok: false, error: 'Driver not running' };
|
|
189
|
+
}
|
|
190
|
+
const result = cmd.meta === true
|
|
191
|
+
? await driver.captureWithMeta()
|
|
192
|
+
: { frame: await driver.capture() };
|
|
193
|
+
|
|
194
|
+
if (typeof cmd.save === 'string') {
|
|
195
|
+
const savedPath = await saveFrameToFile(result.frame, cmd.save);
|
|
196
|
+
return { ok: true, ...result, savedTo: savedPath };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { ok: true, ...result };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case 'snapshot': {
|
|
203
|
+
if (!driver?.isRunning()) {
|
|
204
|
+
return { ok: false, error: 'Driver not running' };
|
|
205
|
+
}
|
|
206
|
+
const result = await driver.captureWithMeta();
|
|
207
|
+
const dir = typeof cmd.dir === 'string' ? cmd.dir : DEFAULT_SNAPSHOTS_DIR;
|
|
208
|
+
const name = typeof cmd.name === 'string'
|
|
209
|
+
? cmd.name
|
|
210
|
+
: `frame-${String(++frameCounter).padStart(4, '0')}`;
|
|
211
|
+
|
|
212
|
+
const framePath = path.join(dir, `${name}.txt`);
|
|
213
|
+
const metadataPath = path.join(dir, `${name}.json`);
|
|
214
|
+
|
|
215
|
+
const metadata = {
|
|
216
|
+
timestamp: new Date(result.timestamp).toISOString(),
|
|
217
|
+
width: result.width,
|
|
218
|
+
height: result.height,
|
|
219
|
+
name,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
await ensureDir(dir);
|
|
223
|
+
await Promise.all([
|
|
224
|
+
fs.writeFile(framePath, result.frame, 'utf-8'),
|
|
225
|
+
fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'),
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
ok: true,
|
|
230
|
+
savedTo: path.resolve(framePath),
|
|
231
|
+
metadataPath: path.resolve(metadataPath),
|
|
232
|
+
name,
|
|
233
|
+
metadata,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case 'waitForText': {
|
|
238
|
+
if (!driver?.isRunning()) {
|
|
239
|
+
return { ok: false, error: 'Driver not running' };
|
|
240
|
+
}
|
|
241
|
+
const text = typeof cmd.text === 'string' ? cmd.text : '';
|
|
242
|
+
const timeout = typeof cmd.timeout === 'number' ? cmd.timeout : 5000;
|
|
243
|
+
const found = await driver.waitForText(text, { timeout });
|
|
244
|
+
return { ok: true, found };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case 'waitForStable': {
|
|
248
|
+
if (!driver?.isRunning()) {
|
|
249
|
+
return { ok: false, error: 'Driver not running' };
|
|
250
|
+
}
|
|
251
|
+
const maxIterations = typeof cmd.maxIterations === 'number' ? cmd.maxIterations : 10;
|
|
252
|
+
const intervalMs = typeof cmd.intervalMs === 'number' ? cmd.intervalMs : 50;
|
|
253
|
+
await driver.waitForStable({ maxIterations, intervalMs });
|
|
254
|
+
recorder?.addCommand('waitForStable', { maxIterations, intervalMs });
|
|
255
|
+
return { ok: true };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'sleep': {
|
|
259
|
+
const ms = typeof cmd.ms === 'number' ? cmd.ms : 1000;
|
|
260
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
261
|
+
recorder?.addCommand('sleep', { ms });
|
|
262
|
+
return { ok: true, sleptMs: ms };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case 'resize': {
|
|
266
|
+
if (!driver?.isRunning()) {
|
|
267
|
+
return { ok: false, error: 'Driver not running' };
|
|
268
|
+
}
|
|
269
|
+
const cols = typeof cmd.cols === 'number' ? cmd.cols : 100;
|
|
270
|
+
const rows = typeof cmd.rows === 'number' ? cmd.rows : 30;
|
|
271
|
+
await driver.resize(cols, rows);
|
|
272
|
+
currentWidth = cols;
|
|
273
|
+
currentHeight = rows;
|
|
274
|
+
recorder?.addCommand('resize', { cols, rows });
|
|
275
|
+
return { ok: true };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
case 'status': {
|
|
279
|
+
return {
|
|
280
|
+
ok: true,
|
|
281
|
+
running: driver?.isRunning() ?? false,
|
|
282
|
+
size: driver?.getSize() ?? null,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case 'diff': {
|
|
287
|
+
const file1 = typeof cmd.file1 === 'string' ? cmd.file1 : null;
|
|
288
|
+
const file2 = typeof cmd.file2 === 'string' ? cmd.file2 : null;
|
|
289
|
+
const frame1 = typeof cmd.frame1 === 'string' ? cmd.frame1 : null;
|
|
290
|
+
const frame2 = typeof cmd.frame2 === 'string' ? cmd.frame2 : null;
|
|
291
|
+
const ignoreWhitespace = cmd.ignoreWhitespace === true;
|
|
292
|
+
|
|
293
|
+
let expected: string;
|
|
294
|
+
let actual: string;
|
|
295
|
+
|
|
296
|
+
if (file1 && file2) {
|
|
297
|
+
expected = await fs.readFile(file1, 'utf-8');
|
|
298
|
+
actual = await fs.readFile(file2, 'utf-8');
|
|
299
|
+
} else if (frame1 && frame2) {
|
|
300
|
+
expected = frame1;
|
|
301
|
+
actual = frame2;
|
|
302
|
+
} else {
|
|
303
|
+
return { ok: false, error: 'Provide file1/file2 or frame1/frame2' };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const result = diffFrames(expected, actual, { ignoreWhitespace });
|
|
307
|
+
const visual = cmd.visual === true ? highlightDiff(expected, actual, { ignoreWhitespace }) : undefined;
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
identical: result.identical,
|
|
312
|
+
changedLines: result.changedLines,
|
|
313
|
+
totalLines: result.totalLines,
|
|
314
|
+
changePercentage: result.changePercentage,
|
|
315
|
+
additions: result.additions.length,
|
|
316
|
+
deletions: result.deletions.length,
|
|
317
|
+
modifications: result.modifications.length,
|
|
318
|
+
...(visual ? { visual } : {}),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case 'assert': {
|
|
323
|
+
if (!driver?.isRunning()) {
|
|
324
|
+
return { ok: false, error: 'Driver not running' };
|
|
325
|
+
}
|
|
326
|
+
const name = typeof cmd.name === 'string' ? cmd.name : null;
|
|
327
|
+
if (!name) {
|
|
328
|
+
return { ok: false, error: 'Name is required for assertion' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const frame = await driver.capture();
|
|
332
|
+
const { width, height } = driver.getSize();
|
|
333
|
+
const goldenDir = typeof cmd.goldenDir === 'string' ? cmd.goldenDir : './golden';
|
|
334
|
+
const updateGolden = cmd.update === true;
|
|
335
|
+
const ignoreWhitespace = cmd.ignoreWhitespace === true;
|
|
336
|
+
|
|
337
|
+
const result = await assertSnapshot(name, frame, {
|
|
338
|
+
goldenDir,
|
|
339
|
+
updateGolden,
|
|
340
|
+
ignoreWhitespace,
|
|
341
|
+
width,
|
|
342
|
+
height,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
passed: result.passed,
|
|
348
|
+
goldenExists: result.goldenExists,
|
|
349
|
+
goldenPath: result.goldenPath,
|
|
350
|
+
message: result.message,
|
|
351
|
+
width,
|
|
352
|
+
height,
|
|
353
|
+
...(result.dimensionMismatch ? { dimensionMismatch: result.dimensionMismatch } : {}),
|
|
354
|
+
...(result.diff ? {
|
|
355
|
+
changedLines: result.diff.changedLines,
|
|
356
|
+
totalLines: result.diff.totalLines,
|
|
357
|
+
changePercentage: result.diff.changePercentage,
|
|
358
|
+
} : {}),
|
|
359
|
+
...(result.visual && cmd.visual === true ? { visual: result.visual } : {}),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
case 'listGolden': {
|
|
364
|
+
const goldenDir = typeof cmd.goldenDir === 'string' ? cmd.goldenDir : './golden';
|
|
365
|
+
const files = await listGoldenFiles(goldenDir);
|
|
366
|
+
return { ok: true, files, count: files.length };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case 'getGolden': {
|
|
370
|
+
const name = typeof cmd.name === 'string' ? cmd.name : null;
|
|
371
|
+
if (!name) {
|
|
372
|
+
return { ok: false, error: 'Name is required' };
|
|
373
|
+
}
|
|
374
|
+
const goldenDir = typeof cmd.goldenDir === 'string' ? cmd.goldenDir : './golden';
|
|
375
|
+
const info = await getGoldenFile(name, goldenDir);
|
|
376
|
+
if (info === null) {
|
|
377
|
+
return { ok: false, error: `Golden file not found: ${name}` };
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
ok: true,
|
|
381
|
+
name: info.name,
|
|
382
|
+
content: info.content,
|
|
383
|
+
width: info.width,
|
|
384
|
+
height: info.height,
|
|
385
|
+
createdAt: info.createdAt,
|
|
386
|
+
updatedAt: info.updatedAt,
|
|
387
|
+
isLegacy: info.isLegacy,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
case 'deleteGolden': {
|
|
392
|
+
const name = typeof cmd.name === 'string' ? cmd.name : null;
|
|
393
|
+
if (!name) {
|
|
394
|
+
return { ok: false, error: 'Name is required' };
|
|
395
|
+
}
|
|
396
|
+
const goldenDir = typeof cmd.goldenDir === 'string' ? cmd.goldenDir : './golden';
|
|
397
|
+
const deleted = await deleteGoldenFile(name, goldenDir);
|
|
398
|
+
return { ok: true, deleted, name };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
case 'startRecording': {
|
|
402
|
+
if (!driver?.isRunning()) {
|
|
403
|
+
return { ok: false, error: 'Driver not running' };
|
|
404
|
+
}
|
|
405
|
+
if (recorder?.isRecording()) {
|
|
406
|
+
return { ok: false, error: 'Already recording' };
|
|
407
|
+
}
|
|
408
|
+
const name = typeof cmd.name === 'string' ? cmd.name : `recording-${Date.now()}`;
|
|
409
|
+
const captureFrames = cmd.captureFrames === true;
|
|
410
|
+
recorder = createRecorder(currentWidth, currentHeight, { name, captureFrames });
|
|
411
|
+
recorder.start();
|
|
412
|
+
return { ok: true, name, captureFrames };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
case 'stopRecording': {
|
|
416
|
+
if (!recorder?.isRecording()) {
|
|
417
|
+
return { ok: false, error: 'Not recording' };
|
|
418
|
+
}
|
|
419
|
+
const recording = recorder.stop();
|
|
420
|
+
const recordingsDir = typeof cmd.dir === 'string' ? cmd.dir : './recordings';
|
|
421
|
+
const savedPath = await saveRecording(recording, recordingsDir);
|
|
422
|
+
recorder = null;
|
|
423
|
+
return {
|
|
424
|
+
ok: true,
|
|
425
|
+
name: recording.name,
|
|
426
|
+
commandCount: recording.commands.length,
|
|
427
|
+
savedTo: savedPath,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
case 'cancelRecording': {
|
|
432
|
+
if (!recorder?.isRecording()) {
|
|
433
|
+
return { ok: false, error: 'Not recording' };
|
|
434
|
+
}
|
|
435
|
+
recorder = null;
|
|
436
|
+
return { ok: true };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
case 'recordingStatus': {
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
recording: recorder?.isRecording() ?? false,
|
|
443
|
+
commandCount: recorder?.getRecording()?.commands.length ?? 0,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
case 'replay': {
|
|
448
|
+
if (!driver?.isRunning()) {
|
|
449
|
+
return { ok: false, error: 'Driver not running' };
|
|
450
|
+
}
|
|
451
|
+
const name = typeof cmd.name === 'string' ? cmd.name : null;
|
|
452
|
+
let recording: Recording | null = null;
|
|
453
|
+
|
|
454
|
+
if (name) {
|
|
455
|
+
const recordingsDir = typeof cmd.dir === 'string' ? cmd.dir : './recordings';
|
|
456
|
+
recording = await loadRecording(name, recordingsDir);
|
|
457
|
+
if (!recording) {
|
|
458
|
+
return { ok: false, error: `Recording not found: ${name}` };
|
|
459
|
+
}
|
|
460
|
+
} else if (typeof cmd.recording === 'object' && cmd.recording !== null) {
|
|
461
|
+
recording = cmd.recording as Recording;
|
|
462
|
+
} else {
|
|
463
|
+
return { ok: false, error: 'Provide name or recording object' };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const speed = typeof cmd.speed === 'number' ? cmd.speed : Infinity;
|
|
467
|
+
const result = await replayRecording(driver, recording, { speed });
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
ok: true,
|
|
471
|
+
success: result.success,
|
|
472
|
+
commandsExecuted: result.commandsExecuted,
|
|
473
|
+
totalCommands: result.totalCommands,
|
|
474
|
+
errors: result.errors,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
case 'listRecordings': {
|
|
479
|
+
const recordingsDir = typeof cmd.dir === 'string' ? cmd.dir : './recordings';
|
|
480
|
+
const recordings = await listRecordings(recordingsDir);
|
|
481
|
+
return { ok: true, recordings, count: recordings.length };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
case 'getRecording': {
|
|
485
|
+
const name = typeof cmd.name === 'string' ? cmd.name : null;
|
|
486
|
+
if (!name) {
|
|
487
|
+
return { ok: false, error: 'Name is required' };
|
|
488
|
+
}
|
|
489
|
+
const recordingsDir = typeof cmd.dir === 'string' ? cmd.dir : './recordings';
|
|
490
|
+
const recording = await loadRecording(name, recordingsDir);
|
|
491
|
+
if (!recording) {
|
|
492
|
+
return { ok: false, error: `Recording not found: ${name}` };
|
|
493
|
+
}
|
|
494
|
+
return { ok: true, recording };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
case 'deleteRecording': {
|
|
498
|
+
const name = typeof cmd.name === 'string' ? cmd.name : null;
|
|
499
|
+
if (!name) {
|
|
500
|
+
return { ok: false, error: 'Name is required' };
|
|
501
|
+
}
|
|
502
|
+
const recordingsDir = typeof cmd.dir === 'string' ? cmd.dir : './recordings';
|
|
503
|
+
const deleted = await deleteRecording(name, recordingsDir);
|
|
504
|
+
return { ok: true, deleted, name };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
case 'startCoverage': {
|
|
508
|
+
if (!driver?.isRunning()) {
|
|
509
|
+
return { ok: false, error: 'Driver not running' };
|
|
510
|
+
}
|
|
511
|
+
if (coverageTracker?.isTracking()) {
|
|
512
|
+
return { ok: false, error: 'Already tracking coverage' };
|
|
513
|
+
}
|
|
514
|
+
const knownViews =
|
|
515
|
+
Array.isArray(cmd.knownViews) &&
|
|
516
|
+
cmd.knownViews.every((v): v is string => typeof v === 'string')
|
|
517
|
+
? cmd.knownViews
|
|
518
|
+
: undefined;
|
|
519
|
+
coverageTracker = createCoverageTracker(knownViews);
|
|
520
|
+
coverageTracker.start();
|
|
521
|
+
return { ok: true };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
case 'stopCoverage': {
|
|
525
|
+
if (!coverageTracker?.isTracking()) {
|
|
526
|
+
return { ok: false, error: 'Not tracking coverage' };
|
|
527
|
+
}
|
|
528
|
+
const report = coverageTracker.stop();
|
|
529
|
+
const visual = cmd.visual === true ? formatCoverageReport(report) : undefined;
|
|
530
|
+
coverageTracker = null;
|
|
531
|
+
return {
|
|
532
|
+
ok: true,
|
|
533
|
+
...report,
|
|
534
|
+
...(visual ? { visual } : {}),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
case 'recordViewFromFrame': {
|
|
539
|
+
if (!driver?.isRunning()) {
|
|
540
|
+
return { ok: false, error: 'Driver not running' };
|
|
541
|
+
}
|
|
542
|
+
if (!coverageTracker?.isTracking()) {
|
|
543
|
+
return { ok: false, error: 'Not tracking coverage' };
|
|
544
|
+
}
|
|
545
|
+
const frame = await driver.capture();
|
|
546
|
+
const view = detectViewFromFrame(frame);
|
|
547
|
+
if (view) {
|
|
548
|
+
coverageTracker.recordView(view);
|
|
549
|
+
}
|
|
550
|
+
return { ok: true, detectedView: view };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
case 'getCoverage': {
|
|
554
|
+
if (!coverageTracker) {
|
|
555
|
+
return { ok: false, error: 'No coverage tracker' };
|
|
556
|
+
}
|
|
557
|
+
const report = coverageTracker.getReport();
|
|
558
|
+
const visual = cmd.visual === true ? formatCoverageReport(report) : undefined;
|
|
559
|
+
return {
|
|
560
|
+
ok: true,
|
|
561
|
+
...report,
|
|
562
|
+
...(visual ? { visual } : {}),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
case 'help': {
|
|
567
|
+
return {
|
|
568
|
+
ok: true,
|
|
569
|
+
commands: [
|
|
570
|
+
'launch - Start the app (options: width, height, debug; demo mode: demo, demoSeed, demoPreset)',
|
|
571
|
+
'close - Stop the app',
|
|
572
|
+
'sendKeys - Send key sequence (options: keys)',
|
|
573
|
+
'pressKey - Press single key (options: key, modifiers)',
|
|
574
|
+
'pressTab - Press Tab key',
|
|
575
|
+
'pressEnter - Press Enter key',
|
|
576
|
+
'pressEscape - Press Escape key',
|
|
577
|
+
'pressArrow - Press arrow key (options: direction)',
|
|
578
|
+
'typeText - Type text (options: text, delay)',
|
|
579
|
+
'capture - Get current frame (options: meta, save)',
|
|
580
|
+
'snapshot - Save frame to file (options: dir, name)',
|
|
581
|
+
'waitForText - Wait for text (options: text, timeout)',
|
|
582
|
+
'waitForStable - Wait for stable frame (options: maxIterations, intervalMs)',
|
|
583
|
+
'sleep - Pause for duration (options: ms)',
|
|
584
|
+
'resize - Resize terminal (options: cols, rows)',
|
|
585
|
+
'status - Get driver status',
|
|
586
|
+
'diff - Compare two frames (options: file1, file2 OR frame1, frame2, ignoreWhitespace, visual)',
|
|
587
|
+
'assert - Assert snapshot matches golden file (options: name, goldenDir, update, ignoreWhitespace, visual)',
|
|
588
|
+
'listGolden - List all golden files (options: goldenDir)',
|
|
589
|
+
'getGolden - Get content of golden file (options: name, goldenDir)',
|
|
590
|
+
'deleteGolden - Delete a golden file (options: name, goldenDir)',
|
|
591
|
+
'startRecording - Start recording commands (options: name, captureFrames)',
|
|
592
|
+
'stopRecording - Stop recording and save (options: dir)',
|
|
593
|
+
'cancelRecording - Cancel recording without saving',
|
|
594
|
+
'recordingStatus - Get current recording status',
|
|
595
|
+
'replay - Replay a recording (options: name, dir, speed)',
|
|
596
|
+
'listRecordings - List all recordings (options: dir)',
|
|
597
|
+
'getRecording - Get a recording (options: name, dir)',
|
|
598
|
+
'deleteRecording - Delete a recording (options: name, dir)',
|
|
599
|
+
'startCoverage - Start tracking view coverage (options: knownViews)',
|
|
600
|
+
'stopCoverage - Stop tracking and get report (options: visual)',
|
|
601
|
+
'recordViewFromFrame - Detect and record current view',
|
|
602
|
+
'getCoverage - Get current coverage report (options: visual)',
|
|
603
|
+
],
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
default:
|
|
608
|
+
return { ok: false, error: `Unknown action: ${cmd.action}` };
|
|
609
|
+
}
|
|
610
|
+
} catch (err) {
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
error: err instanceof Error ? err.message : String(err),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function main() {
|
|
619
|
+
const args = process.argv.slice(2);
|
|
620
|
+
|
|
621
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
622
|
+
console.log(`
|
|
623
|
+
Tokentop Driver CLI
|
|
624
|
+
|
|
625
|
+
Headless driver for testing and automating the tokentop TUI.
|
|
626
|
+
|
|
627
|
+
Usage:
|
|
628
|
+
bun src/tui/driver/cli.ts # Interactive JSON-line mode
|
|
629
|
+
bun src/tui/driver/cli.ts --help # Show this help
|
|
630
|
+
|
|
631
|
+
JSON-line Protocol:
|
|
632
|
+
Send JSON commands to stdin, receive JSON responses on stdout.
|
|
633
|
+
One command per line.
|
|
634
|
+
|
|
635
|
+
Commands:
|
|
636
|
+
{"action":"launch","width":100,"height":30} Launch app
|
|
637
|
+
{"action":"launch","demo":true} Launch in demo mode
|
|
638
|
+
{"action":"launch","demo":true,"demoSeed":42} Launch demo with seed
|
|
639
|
+
{"action":"launch","demo":true,"demoPreset":"heavy"} Launch demo with preset
|
|
640
|
+
{"action":"capture"} Get current frame
|
|
641
|
+
{"action":"capture","save":"./frame.txt"} Capture and save to file
|
|
642
|
+
{"action":"snapshot","name":"dashboard"} Save snapshot to ./snapshots/
|
|
643
|
+
{"action":"sendKeys","keys":"jj"} Send keystrokes
|
|
644
|
+
{"action":"pressKey","key":"1"} Switch to view 1
|
|
645
|
+
{"action":"waitForText","text":"Dashboard"} Wait for text
|
|
646
|
+
{"action":"resize","cols":120,"rows":40} Resize terminal
|
|
647
|
+
{"action":"close"} Stop app
|
|
648
|
+
{"action":"help"} List all commands
|
|
649
|
+
|
|
650
|
+
Example:
|
|
651
|
+
echo '{"action":"launch"}' | bun src/tui/driver/cli.ts
|
|
652
|
+
echo '{"action":"capture"}' | bun src/tui/driver/cli.ts
|
|
653
|
+
`);
|
|
654
|
+
process.exit(0);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const rl = readline.createInterface({
|
|
658
|
+
input: process.stdin,
|
|
659
|
+
output: process.stdout,
|
|
660
|
+
terminal: false,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
const commandQueue: string[] = [];
|
|
664
|
+
let processing = false;
|
|
665
|
+
let closed = false;
|
|
666
|
+
|
|
667
|
+
async function processQueue(): Promise<void> {
|
|
668
|
+
if (processing) return;
|
|
669
|
+
processing = true;
|
|
670
|
+
|
|
671
|
+
while (commandQueue.length > 0) {
|
|
672
|
+
const line = commandQueue.shift()!;
|
|
673
|
+
try {
|
|
674
|
+
const cmd = JSON.parse(line) as Command;
|
|
675
|
+
const response = await handleCommand(cmd);
|
|
676
|
+
console.log(JSON.stringify(response));
|
|
677
|
+
} catch (err) {
|
|
678
|
+
console.log(JSON.stringify({
|
|
679
|
+
ok: false,
|
|
680
|
+
error: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
processing = false;
|
|
686
|
+
|
|
687
|
+
if (closed && commandQueue.length === 0) {
|
|
688
|
+
if (driver?.isRunning()) {
|
|
689
|
+
await driver.close();
|
|
690
|
+
}
|
|
691
|
+
process.exit(0);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
rl.on('line', (line) => {
|
|
696
|
+
const trimmed = line.trim();
|
|
697
|
+
if (!trimmed) return;
|
|
698
|
+
commandQueue.push(trimmed);
|
|
699
|
+
processQueue();
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
rl.on('close', () => {
|
|
703
|
+
closed = true;
|
|
704
|
+
if (!processing && commandQueue.length === 0) {
|
|
705
|
+
if (driver?.isRunning()) {
|
|
706
|
+
driver.close().then(() => process.exit(0));
|
|
707
|
+
} else {
|
|
708
|
+
process.exit(0);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
main().catch((err) => {
|
|
715
|
+
console.error('Driver CLI failed:', err);
|
|
716
|
+
process.exit(1);
|
|
717
|
+
});
|