@ekkos/cli 1.0.34 → 1.0.36
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/dist/capture/jsonl-rewriter.js +72 -7
- package/dist/commands/dashboard.js +186 -557
- package/dist/commands/init.js +3 -15
- package/dist/commands/run.js +222 -256
- package/dist/commands/setup.js +0 -47
- package/dist/commands/swarm-dashboard.js +4 -13
- package/dist/deploy/instructions.d.ts +2 -5
- package/dist/deploy/instructions.js +8 -11
- package/dist/deploy/settings.js +21 -15
- package/dist/deploy/skills.d.ts +0 -8
- package/dist/deploy/skills.js +0 -26
- package/dist/index.js +2 -2
- package/dist/lib/usage-parser.js +1 -2
- package/dist/utils/platform.d.ts +0 -3
- package/dist/utils/platform.js +1 -4
- package/dist/utils/session-binding.d.ts +1 -1
- package/dist/utils/session-binding.js +2 -3
- package/package.json +1 -1
- package/templates/agents/README.md +182 -0
- package/templates/agents/code-reviewer.md +166 -0
- package/templates/agents/debug-detective.md +169 -0
- package/templates/agents/ekkOS_Vercel.md +99 -0
- package/templates/agents/extension-manager.md +229 -0
- package/templates/agents/git-companion.md +185 -0
- package/templates/agents/github-test-agent.md +321 -0
- package/templates/agents/railway-manager.md +179 -0
- package/templates/hooks/assistant-response.ps1 +26 -94
- package/templates/hooks/lib/count-tokens.cjs +0 -0
- package/templates/hooks/lib/ekkos-reminders.sh +0 -0
- package/templates/hooks/session-start.ps1 +224 -61
- package/templates/hooks/session-start.sh +1 -1
- package/templates/hooks/stop.ps1 +249 -103
- package/templates/hooks/stop.sh +1 -1
- package/templates/hooks/user-prompt-submit.ps1 +519 -129
- package/templates/hooks/user-prompt-submit.sh +2 -2
- package/templates/plan-template.md +0 -0
- package/templates/spec-template.md +0 -0
- package/templates/windsurf-hooks/before-submit-prompt.sh +238 -0
- package/templates/windsurf-hooks/install.sh +0 -0
- package/templates/windsurf-hooks/lib/contract.sh +0 -0
- package/templates/windsurf-hooks/post-cascade-response.sh +0 -0
- package/templates/windsurf-hooks/pre-user-prompt.sh +0 -0
- package/templates/windsurf-skills/ekkos-memory/SKILL.md +219 -0
- package/README.md +0 -57
|
@@ -67,7 +67,6 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
67
67
|
const commander_1 = require("commander");
|
|
68
68
|
const usage_parser_js_1 = require("../lib/usage-parser.js");
|
|
69
69
|
const state_js_1 = require("../utils/state.js");
|
|
70
|
-
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
71
70
|
// ── Pricing ──
|
|
72
71
|
// Pricing per MTok from https://platform.claude.com/docs/en/about-claude/pricing
|
|
73
72
|
const MODEL_PRICING = {
|
|
@@ -114,19 +113,6 @@ function modelTag(model) {
|
|
|
114
113
|
return 'Haiku';
|
|
115
114
|
return '?';
|
|
116
115
|
}
|
|
117
|
-
function parseCacheHintValue(cacheHint, key) {
|
|
118
|
-
if (!cacheHint || typeof cacheHint !== 'string')
|
|
119
|
-
return null;
|
|
120
|
-
const parts = cacheHint.split(';');
|
|
121
|
-
for (const part of parts) {
|
|
122
|
-
const [k, ...rest] = part.split('=');
|
|
123
|
-
if (k?.trim() === key) {
|
|
124
|
-
const value = rest.join('=').trim();
|
|
125
|
-
return value || null;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
116
|
function parseJsonlFile(jsonlPath, sessionName) {
|
|
131
117
|
const content = fs.readFileSync(jsonlPath, 'utf-8');
|
|
132
118
|
const lines = content.trim().split('\n');
|
|
@@ -155,18 +141,6 @@ function parseJsonlFile(jsonlPath, sessionName) {
|
|
|
155
141
|
model = entry.message.model || model;
|
|
156
142
|
// Smart routing: _ekkos_routed_model contains the actual model used
|
|
157
143
|
const routedModel = entry.message._ekkos_routed_model || entry.message.model || model;
|
|
158
|
-
const cacheHint = typeof entry.message._ekkos_cache_hint === 'string'
|
|
159
|
-
? entry.message._ekkos_cache_hint
|
|
160
|
-
: undefined;
|
|
161
|
-
const replayState = typeof entry.message._ekkos_replay_state === 'string'
|
|
162
|
-
? entry.message._ekkos_replay_state
|
|
163
|
-
: (parseCacheHintValue(cacheHint, 'replay') || 'unknown');
|
|
164
|
-
const replayStore = typeof entry.message._ekkos_replay_store === 'string'
|
|
165
|
-
? entry.message._ekkos_replay_store
|
|
166
|
-
: 'none';
|
|
167
|
-
const evictionState = typeof entry.message._ekkos_eviction_state === 'string'
|
|
168
|
-
? entry.message._ekkos_eviction_state
|
|
169
|
-
: (parseCacheHintValue(cacheHint, 'eviction') || 'unknown');
|
|
170
144
|
const inputTokens = usage.input_tokens || 0;
|
|
171
145
|
const outputTokens = usage.output_tokens || 0;
|
|
172
146
|
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
@@ -208,9 +182,6 @@ function parseJsonlFile(jsonlPath, sessionName) {
|
|
|
208
182
|
tools: toolStr,
|
|
209
183
|
model,
|
|
210
184
|
routedModel,
|
|
211
|
-
replayState,
|
|
212
|
-
replayStore,
|
|
213
|
-
evictionState,
|
|
214
185
|
timestamp: ts,
|
|
215
186
|
};
|
|
216
187
|
if (msgId) {
|
|
@@ -234,9 +205,6 @@ function parseJsonlFile(jsonlPath, sessionName) {
|
|
|
234
205
|
const cacheHitRate = (totalCacheRead + totalCacheCreate) > 0
|
|
235
206
|
? (totalCacheRead / (totalCacheRead + totalCacheCreate)) * 100
|
|
236
207
|
: 0;
|
|
237
|
-
const replayAppliedCount = turns.reduce((sum, t) => sum + (t.replayState === 'applied' ? 1 : 0), 0);
|
|
238
|
-
const replaySkippedSizeCount = turns.reduce((sum, t) => sum + (t.replayState === 'skipped-size' ? 1 : 0), 0);
|
|
239
|
-
const replaySkipStoreCount = turns.reduce((sum, t) => sum + (t.replayStore === 'skip-size' ? 1 : 0), 0);
|
|
240
208
|
let duration = '0m';
|
|
241
209
|
if (startedAt && turns.length > 0) {
|
|
242
210
|
const start = new Date(startedAt).getTime();
|
|
@@ -265,63 +233,11 @@ function parseJsonlFile(jsonlPath, sessionName) {
|
|
|
265
233
|
currentContextTokens,
|
|
266
234
|
modelContextSize,
|
|
267
235
|
cacheHitRate,
|
|
268
|
-
replayAppliedCount,
|
|
269
|
-
replaySkippedSizeCount,
|
|
270
|
-
replaySkipStoreCount,
|
|
271
236
|
startedAt,
|
|
272
237
|
duration,
|
|
273
238
|
turns,
|
|
274
239
|
};
|
|
275
240
|
}
|
|
276
|
-
function readJsonFile(filePath) {
|
|
277
|
-
try {
|
|
278
|
-
if (!fs.existsSync(filePath))
|
|
279
|
-
return null;
|
|
280
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
return null;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
function resolveSessionAlias(sessionId) {
|
|
287
|
-
const normalized = sessionId.toLowerCase();
|
|
288
|
-
// Project-local hook state (most reliable for the active session)
|
|
289
|
-
const projectState = readJsonFile(path.join(process.cwd(), '.claude', 'state', 'current-session.json'));
|
|
290
|
-
if (projectState?.session_id?.toLowerCase() === normalized &&
|
|
291
|
-
projectState.session_name &&
|
|
292
|
-
!UUID_REGEX.test(projectState.session_name)) {
|
|
293
|
-
return projectState.session_name;
|
|
294
|
-
}
|
|
295
|
-
// Global Claude hook state
|
|
296
|
-
const claudeState = readJsonFile(path.join(os.homedir(), '.claude', 'state', 'current-session.json'));
|
|
297
|
-
if (claudeState?.session_id?.toLowerCase() === normalized &&
|
|
298
|
-
claudeState.session_name &&
|
|
299
|
-
!UUID_REGEX.test(claudeState.session_name)) {
|
|
300
|
-
return claudeState.session_name;
|
|
301
|
-
}
|
|
302
|
-
// ekkOS global state
|
|
303
|
-
const ekkosState = readJsonFile(path.join(os.homedir(), '.ekkos', 'current-session.json'));
|
|
304
|
-
if (ekkosState?.session_id?.toLowerCase() === normalized &&
|
|
305
|
-
ekkosState.session_name &&
|
|
306
|
-
!UUID_REGEX.test(ekkosState.session_name)) {
|
|
307
|
-
return ekkosState.session_name;
|
|
308
|
-
}
|
|
309
|
-
// Multi-session index fallback
|
|
310
|
-
const activeSessions = readJsonFile(path.join(os.homedir(), '.ekkos', 'active-sessions.json')) || [];
|
|
311
|
-
const bySessionId = activeSessions
|
|
312
|
-
.filter(s => s.sessionId?.toLowerCase() === normalized && s.sessionName && !UUID_REGEX.test(s.sessionName))
|
|
313
|
-
.sort((a, b) => (b.lastHeartbeat || '').localeCompare(a.lastHeartbeat || ''));
|
|
314
|
-
if (bySessionId.length > 0)
|
|
315
|
-
return bySessionId[0].sessionName || null;
|
|
316
|
-
return null;
|
|
317
|
-
}
|
|
318
|
-
function displaySessionName(rawName) {
|
|
319
|
-
if (!rawName)
|
|
320
|
-
return 'session';
|
|
321
|
-
if (!UUID_REGEX.test(rawName))
|
|
322
|
-
return rawName;
|
|
323
|
-
return resolveSessionAlias(rawName) || (0, state_js_1.uuidToWords)(rawName);
|
|
324
|
-
}
|
|
325
241
|
// ── Resolve session to JSONL path ──
|
|
326
242
|
function resolveJsonlPath(sessionName, createdAfterMs) {
|
|
327
243
|
// 1) Try standard resolution (works when sessionId is a real UUID)
|
|
@@ -351,53 +267,23 @@ function resolveJsonlPath(sessionName, createdAfterMs) {
|
|
|
351
267
|
* This prevents picking up old sessions that are still being modified.
|
|
352
268
|
*/
|
|
353
269
|
function findLatestJsonl(projectPath, createdAfterMs) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const candidateEncodings = new Set([
|
|
358
|
-
projectPath.replace(/[\\/]/g, '-'), // C:-Users-name (backslash only)
|
|
359
|
-
projectPath.replace(/[:\\/]/g, '-'), // C--Users-name (colon + backslash)
|
|
360
|
-
'-' + projectPath.replace(/[:\\/]/g, '-'), // -C--Users-name (leading separator)
|
|
361
|
-
projectPath.replace(/\//g, '-'), // macOS: /Users/name → -Users-name
|
|
362
|
-
]);
|
|
363
|
-
const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
|
|
364
|
-
for (const encoded of candidateEncodings) {
|
|
365
|
-
const projectDir = path.join(projectsRoot, encoded);
|
|
366
|
-
if (!fs.existsSync(projectDir))
|
|
367
|
-
continue;
|
|
368
|
-
const jsonlFiles = fs.readdirSync(projectDir)
|
|
369
|
-
.filter(f => f.endsWith('.jsonl'))
|
|
370
|
-
.map(f => {
|
|
371
|
-
const stat = fs.statSync(path.join(projectDir, f));
|
|
372
|
-
return {
|
|
373
|
-
path: path.join(projectDir, f),
|
|
374
|
-
mtime: stat.mtimeMs,
|
|
375
|
-
birthtime: stat.birthtimeMs,
|
|
376
|
-
};
|
|
377
|
-
})
|
|
378
|
-
.filter(f => !createdAfterMs || f.birthtime > createdAfterMs)
|
|
379
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
380
|
-
if (jsonlFiles.length > 0)
|
|
381
|
-
return jsonlFiles[0].path;
|
|
382
|
-
}
|
|
383
|
-
return null;
|
|
384
|
-
}
|
|
385
|
-
function findJsonlBySessionId(projectPath, sessionId) {
|
|
386
|
-
if (!sessionId)
|
|
270
|
+
const encoded = projectPath.replace(/\//g, '-');
|
|
271
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', encoded);
|
|
272
|
+
if (!fs.existsSync(projectDir))
|
|
387
273
|
return null;
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
return null;
|
|
274
|
+
const jsonlFiles = fs.readdirSync(projectDir)
|
|
275
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
276
|
+
.map(f => {
|
|
277
|
+
const stat = fs.statSync(path.join(projectDir, f));
|
|
278
|
+
return {
|
|
279
|
+
path: path.join(projectDir, f),
|
|
280
|
+
mtime: stat.mtimeMs,
|
|
281
|
+
birthtime: stat.birthtimeMs,
|
|
282
|
+
};
|
|
283
|
+
})
|
|
284
|
+
.filter(f => !createdAfterMs || f.birthtime > createdAfterMs)
|
|
285
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
286
|
+
return jsonlFiles.length > 0 ? jsonlFiles[0].path : null;
|
|
401
287
|
}
|
|
402
288
|
function getLatestSession() {
|
|
403
289
|
const sessions = (0, state_js_1.getActiveSessions)();
|
|
@@ -429,29 +315,6 @@ async function waitForNewSession() {
|
|
|
429
315
|
const startWait = Date.now();
|
|
430
316
|
let candidateName = null;
|
|
431
317
|
while (Date.now() - startWait < maxWaitMs) {
|
|
432
|
-
// ── Windows hook hint file ──────────────────────────────────────────────
|
|
433
|
-
// On Windows, active-sessions.json is never populated because hook processes
|
|
434
|
-
// are short-lived and their PIDs are dead by the time we poll. Instead, the
|
|
435
|
-
// user-prompt-submit.ps1 hook writes ~/.ekkos/hook-session-hint.json with
|
|
436
|
-
// { sessionName, sessionId, projectPath, ts } on every turn. Read it here.
|
|
437
|
-
const hintPath = path.join(state_js_1.EKKOS_DIR, 'hook-session-hint.json');
|
|
438
|
-
try {
|
|
439
|
-
if (fs.existsSync(hintPath)) {
|
|
440
|
-
const hint = JSON.parse(fs.readFileSync(hintPath, 'utf-8'));
|
|
441
|
-
if (hint.ts >= launchTs - 5000 && hint.sessionName && hint.projectPath) {
|
|
442
|
-
candidateName = hint.sessionName;
|
|
443
|
-
const jsonlPath = findJsonlBySessionId(hint.projectPath, hint.sessionId || '')
|
|
444
|
-
|| findLatestJsonl(hint.projectPath, launchTs)
|
|
445
|
-
|| resolveJsonlPath(hint.sessionName, launchTs);
|
|
446
|
-
if (jsonlPath) {
|
|
447
|
-
console.log(chalk_1.default.green(` Found session (hook hint): ${hint.sessionName}`));
|
|
448
|
-
return { sessionName: hint.sessionName, jsonlPath };
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
catch { /* ignore */ }
|
|
454
|
-
// ── Standard: active-sessions.json (works on Mac/Linux) ────────────────
|
|
455
318
|
const sessions = (0, state_js_1.getActiveSessions)();
|
|
456
319
|
// Find sessions that started after our launch
|
|
457
320
|
for (const s of sessions) {
|
|
@@ -480,49 +343,11 @@ async function waitForNewSession() {
|
|
|
480
343
|
if (launchCwd) {
|
|
481
344
|
const latestJsonl = findLatestJsonl(launchCwd, launchTs);
|
|
482
345
|
if (latestJsonl) {
|
|
483
|
-
const name = candidateName ||
|
|
346
|
+
const name = candidateName || 'session';
|
|
484
347
|
console.log(chalk_1.default.green(` Found session via CWD: ${name}`));
|
|
485
348
|
return { sessionName: name, jsonlPath: latestJsonl };
|
|
486
349
|
}
|
|
487
350
|
}
|
|
488
|
-
// Broad fallback: scan ALL project directories for any new JSONL (Windows safety net).
|
|
489
|
-
// Claude creates the JSONL immediately when a session starts, before the first message.
|
|
490
|
-
// This catches cases where path encoding doesn't match.
|
|
491
|
-
{
|
|
492
|
-
const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
|
|
493
|
-
try {
|
|
494
|
-
if (fs.existsSync(projectsRoot)) {
|
|
495
|
-
const allNewJsonl = fs.readdirSync(projectsRoot)
|
|
496
|
-
.flatMap(dir => {
|
|
497
|
-
const dirPath = path.join(projectsRoot, dir);
|
|
498
|
-
try {
|
|
499
|
-
return fs.readdirSync(dirPath)
|
|
500
|
-
.filter(f => f.endsWith('.jsonl'))
|
|
501
|
-
.map(f => {
|
|
502
|
-
const fp = path.join(dirPath, f);
|
|
503
|
-
const stat = fs.statSync(fp);
|
|
504
|
-
return { path: fp, birthtime: stat.birthtimeMs, mtime: stat.mtimeMs };
|
|
505
|
-
})
|
|
506
|
-
.filter(f => f.birthtime > launchTs);
|
|
507
|
-
}
|
|
508
|
-
catch {
|
|
509
|
-
return [];
|
|
510
|
-
}
|
|
511
|
-
})
|
|
512
|
-
.sort((a, b) => b.birthtime - a.birthtime);
|
|
513
|
-
if (allNewJsonl.length > 0) {
|
|
514
|
-
const jsonlPath = allNewJsonl[0].path;
|
|
515
|
-
// Derive name from filename (e.g. abc123.jsonl → use candidateName if set, else basename)
|
|
516
|
-
const baseName = path.basename(jsonlPath, '.jsonl');
|
|
517
|
-
const derivedName = /^[0-9a-f]{8}-/.test(baseName) ? (0, state_js_1.uuidToWords)(baseName) : baseName;
|
|
518
|
-
const name = candidateName || derivedName;
|
|
519
|
-
console.log(chalk_1.default.green(` Found session via scan: ${name}`));
|
|
520
|
-
return { sessionName: name, jsonlPath };
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
catch { /* ignore */ }
|
|
525
|
-
}
|
|
526
351
|
await sleep(pollMs);
|
|
527
352
|
process.stdout.write(chalk_1.default.gray('.'));
|
|
528
353
|
}
|
|
@@ -549,10 +374,9 @@ function sleep(ms) {
|
|
|
549
374
|
}
|
|
550
375
|
// ── TUI Dashboard ──
|
|
551
376
|
async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
552
|
-
let sessionName =
|
|
377
|
+
let sessionName = initialSessionName;
|
|
553
378
|
const blessed = require('blessed');
|
|
554
379
|
const contrib = require('blessed-contrib');
|
|
555
|
-
const inTmux = process.env.TMUX !== undefined;
|
|
556
380
|
// ══════════════════════════════════════════════════════════════════════════
|
|
557
381
|
// TMUX SPLIT PANE ISOLATION
|
|
558
382
|
// When dashboard runs in a separate tmux pane from `ekkos run`, blessed must
|
|
@@ -627,58 +451,6 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
627
451
|
// footer: 3 rows (totals + routing + keybindings)
|
|
628
452
|
const LOGO_CHARS = ['e', 'k', 'k', 'O', 'S', '_'];
|
|
629
453
|
const WAVE_COLORS = ['cyan', 'blue', 'magenta', 'yellow', 'green', 'white'];
|
|
630
|
-
const GOOD_LUCK_FORTUNES = [
|
|
631
|
-
'Memory is just RAM with commitment issues.',
|
|
632
|
-
'Your context window is not a trash can.',
|
|
633
|
-
'Ship it. Memory will remember the rest.',
|
|
634
|
-
'Segfault: a love letter from the past.',
|
|
635
|
-
'undefined is not a personality.',
|
|
636
|
-
'The AI forgot. ekkOS did not.',
|
|
637
|
-
'Cold start is just a fancy word for amnesia.',
|
|
638
|
-
'Cache hit. Dopamine unlocked.',
|
|
639
|
-
'94% hit rate. The other 6% are learning.',
|
|
640
|
-
'Fewer tokens, bigger thoughts.',
|
|
641
|
-
'Your last session called. It left context.',
|
|
642
|
-
'NaN is just a number in denial.',
|
|
643
|
-
'Memory leak: when code has attachment issues.',
|
|
644
|
-
'The bug was in the chair the whole time.',
|
|
645
|
-
'Rebase early, rebase often, rebase bravely.',
|
|
646
|
-
'It works on my machine. Ship the machine.',
|
|
647
|
-
'Latency is just suspense with worse UX.',
|
|
648
|
-
'A good prompt is worth 1000 retries.',
|
|
649
|
-
'The model hallucinates. Your memory does not.',
|
|
650
|
-
'Every great system was once a bad YAML file.',
|
|
651
|
-
'async/await: optimism compiled.',
|
|
652
|
-
'Your future self will read this code.',
|
|
653
|
-
'The diff is the truth.',
|
|
654
|
-
'Type safety is love made explicit.',
|
|
655
|
-
'Tokens are money. Spend them wisely.',
|
|
656
|
-
'Context is king. Memory is the kingdom.',
|
|
657
|
-
'The LLM forgot. You did not have to.',
|
|
658
|
-
'Green CI: the only morning green flag.',
|
|
659
|
-
'Throwaway sessions are so 2023.',
|
|
660
|
-
'Your AI just learned from last time.',
|
|
661
|
-
'One prompt to rule them all. One memory to find them.',
|
|
662
|
-
'Always learning. Getting faster. Still caffeinated.',
|
|
663
|
-
'The cold start problem is someone else\'s problem now.',
|
|
664
|
-
'Trust the cache. Fear the cache miss.',
|
|
665
|
-
'Technical debt: code with feelings.',
|
|
666
|
-
'The logs never lie. Developers sometimes do.',
|
|
667
|
-
'404: motivation not found. Memory restored.',
|
|
668
|
-
'Embeddings: vibes but make them math.',
|
|
669
|
-
'null is just the universe saying try again.',
|
|
670
|
-
'Ship small, remember everything.',
|
|
671
|
-
'Your session ended. Your memory did not.',
|
|
672
|
-
'Hallucination-free since last deployment.',
|
|
673
|
-
'ekkOS remembers so you do not have to.',
|
|
674
|
-
'The best refactor is the one that ships.',
|
|
675
|
-
'Rate limited? The system is just thinking.',
|
|
676
|
-
'Context window: full. ekkOS: still going.',
|
|
677
|
-
'Good memory compounds like good interest.',
|
|
678
|
-
'The AI got smarter. You did not change a line.',
|
|
679
|
-
'Compaction is optional. Excellence is not.',
|
|
680
|
-
'Build like the memory persists. It does.',
|
|
681
|
-
];
|
|
682
454
|
const W = '100%';
|
|
683
455
|
const HEADER_H = 3;
|
|
684
456
|
const CONTEXT_H = 5;
|
|
@@ -689,7 +461,7 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
689
461
|
function calcLayout() {
|
|
690
462
|
const H = screen.height;
|
|
691
463
|
const remaining = Math.max(6, H - FIXED_H);
|
|
692
|
-
const chartH = Math.max(
|
|
464
|
+
const chartH = Math.max(4, Math.floor(remaining * 0.30));
|
|
693
465
|
const tableH = Math.max(4, remaining - chartH);
|
|
694
466
|
return {
|
|
695
467
|
header: { top: 0, height: HEADER_H },
|
|
@@ -701,12 +473,6 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
701
473
|
};
|
|
702
474
|
}
|
|
703
475
|
let layout = calcLayout();
|
|
704
|
-
let lastFileSize = 0;
|
|
705
|
-
let lastData = null;
|
|
706
|
-
let lastChartSeries = null;
|
|
707
|
-
let lastScrollPerc = 0; // Preserve scroll position across updates
|
|
708
|
-
let fortuneIdx = Math.floor(Math.random() * GOOD_LUCK_FORTUNES.length);
|
|
709
|
-
let fortuneText = GOOD_LUCK_FORTUNES[fortuneIdx];
|
|
710
476
|
// Header: session stats (3 lines)
|
|
711
477
|
const headerBox = blessed.box({
|
|
712
478
|
top: layout.header.top, left: 0, width: W, height: layout.header.height,
|
|
@@ -716,18 +482,6 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
716
482
|
border: { type: 'line' },
|
|
717
483
|
label: ' ekkOS_ ',
|
|
718
484
|
});
|
|
719
|
-
// Explicit header message row: with HEADER_H=3 and a border, this is the
|
|
720
|
-
// single inner content row (visual line 2 of the widget).
|
|
721
|
-
const headerMessageRow = blessed.box({
|
|
722
|
-
parent: headerBox,
|
|
723
|
-
top: 0,
|
|
724
|
-
left: 0,
|
|
725
|
-
width: '100%-2',
|
|
726
|
-
height: 1,
|
|
727
|
-
tags: false,
|
|
728
|
-
style: { fg: 'green', bold: true },
|
|
729
|
-
content: '',
|
|
730
|
-
});
|
|
731
485
|
// Context: progress bar + costs + cache (5 lines)
|
|
732
486
|
const contextBox = blessed.box({
|
|
733
487
|
top: layout.context.top, left: 0, width: W, height: layout.context.height,
|
|
@@ -748,28 +502,23 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
748
502
|
tags: false,
|
|
749
503
|
style: { fg: 'red', bold: true }, // ansi redBright = official Clawd orange
|
|
750
504
|
});
|
|
751
|
-
// Token chart (fills
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
let tokenChart = createTokenChart(layout.chart.top, 0, W, layout.chart.height);
|
|
771
|
-
let chartLayoutW = 0;
|
|
772
|
-
let chartLayoutH = 0;
|
|
505
|
+
// Token chart (fills 30% of remaining)
|
|
506
|
+
const tokenChart = contrib.line({
|
|
507
|
+
top: layout.chart.top, left: 0, width: W, height: layout.chart.height,
|
|
508
|
+
label: ' Tokens/Turn (K) ',
|
|
509
|
+
showLegend: true,
|
|
510
|
+
legend: { width: 8 },
|
|
511
|
+
style: {
|
|
512
|
+
line: 'green',
|
|
513
|
+
text: 'white',
|
|
514
|
+
baseline: 'white',
|
|
515
|
+
border: { fg: 'cyan' },
|
|
516
|
+
},
|
|
517
|
+
border: { type: 'line', fg: 'cyan' },
|
|
518
|
+
xLabelPadding: 0,
|
|
519
|
+
xPadding: 1,
|
|
520
|
+
wholeNumbersOnly: false,
|
|
521
|
+
});
|
|
773
522
|
// Turn table — manual rendering for full-width columns + dim dividers
|
|
774
523
|
const turnBox = blessed.box({
|
|
775
524
|
top: layout.table.top, left: 0, width: W, height: layout.table.height,
|
|
@@ -778,11 +527,11 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
778
527
|
scrollable: true,
|
|
779
528
|
alwaysScroll: true,
|
|
780
529
|
scrollbar: { ch: '│', style: { fg: 'cyan' } },
|
|
781
|
-
keys:
|
|
782
|
-
vi:
|
|
530
|
+
keys: true, // Enable keyboard scrolling
|
|
531
|
+
vi: true, // Enable vi-style keys (j/k for scroll)
|
|
783
532
|
mouse: false, // Mouse disabled (use keyboard for scrolling, allows text selection)
|
|
784
|
-
input:
|
|
785
|
-
interactive:
|
|
533
|
+
input: true,
|
|
534
|
+
interactive: true, // Make box interactive for scrolling
|
|
786
535
|
label: ' Turns (scroll: ↑↓/k/j, page: PgUp/u, home/end: g/G) ',
|
|
787
536
|
border: { type: 'line', fg: 'cyan' },
|
|
788
537
|
style: { fg: 'white', border: { fg: 'cyan' } },
|
|
@@ -826,29 +575,10 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
826
575
|
contextBox.left = H_PAD;
|
|
827
576
|
contextBox.width = contentWidth;
|
|
828
577
|
contextBox.height = layout.context.height;
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
try {
|
|
834
|
-
screen.remove(tokenChart);
|
|
835
|
-
}
|
|
836
|
-
catch { }
|
|
837
|
-
try {
|
|
838
|
-
tokenChart.destroy?.();
|
|
839
|
-
}
|
|
840
|
-
catch { }
|
|
841
|
-
tokenChart = createTokenChart(layout.chart.top, H_PAD, contentWidth, layout.chart.height);
|
|
842
|
-
chartLayoutW = contentWidth;
|
|
843
|
-
chartLayoutH = layout.chart.height;
|
|
844
|
-
screen.append(tokenChart);
|
|
845
|
-
}
|
|
846
|
-
else {
|
|
847
|
-
tokenChart.top = layout.chart.top;
|
|
848
|
-
tokenChart.left = H_PAD;
|
|
849
|
-
tokenChart.width = contentWidth;
|
|
850
|
-
tokenChart.height = layout.chart.height;
|
|
851
|
-
}
|
|
578
|
+
tokenChart.top = layout.chart.top;
|
|
579
|
+
tokenChart.left = H_PAD;
|
|
580
|
+
tokenChart.width = contentWidth;
|
|
581
|
+
tokenChart.height = layout.chart.height;
|
|
852
582
|
turnBox.top = layout.table.top;
|
|
853
583
|
turnBox.left = H_PAD;
|
|
854
584
|
turnBox.width = contentWidth;
|
|
@@ -861,16 +591,7 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
861
591
|
footerBox.left = H_PAD;
|
|
862
592
|
footerBox.width = contentWidth;
|
|
863
593
|
footerBox.height = layout.footer.height;
|
|
864
|
-
// Force blessed-contrib to re-render the chart canvas at the new dimensions
|
|
865
|
-
if (lastChartSeries) {
|
|
866
|
-
try {
|
|
867
|
-
tokenChart.setData(lastChartSeries);
|
|
868
|
-
}
|
|
869
|
-
catch { }
|
|
870
|
-
}
|
|
871
594
|
}
|
|
872
|
-
// Apply once at startup so chart/table geometry is correct even before any resize event.
|
|
873
|
-
applyLayout();
|
|
874
595
|
// Track geometry so we can re-anchor widgets even if tmux resize events are flaky
|
|
875
596
|
let lastLayoutW = screen.width || 0;
|
|
876
597
|
let lastLayoutH = screen.height || 0;
|
|
@@ -887,82 +608,59 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
887
608
|
catch { }
|
|
888
609
|
applyLayout();
|
|
889
610
|
}
|
|
890
|
-
// ──
|
|
611
|
+
// ── Logo color wave animation ──
|
|
891
612
|
let waveOffset = 0;
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
const tagged = LOGO_CHARS.map((ch, i) => {
|
|
895
|
-
const colorIdx = Math.abs(i + waveOffset) % WAVE_COLORS.length;
|
|
896
|
-
const color = WAVE_COLORS[colorIdx];
|
|
897
|
-
return `{${color}-fg}${ch}{/${color}-fg}`;
|
|
898
|
-
}).join('');
|
|
899
|
-
return { raw, tagged };
|
|
900
|
-
}
|
|
901
|
-
function buildShinySessionName(name) {
|
|
902
|
-
if (!name)
|
|
903
|
-
return '';
|
|
904
|
-
const chars = name.split('');
|
|
905
|
-
const shineIdx = Math.abs(waveOffset) % chars.length;
|
|
906
|
-
return chars.map((ch, i) => {
|
|
907
|
-
if (i === shineIdx)
|
|
908
|
-
return `{white-fg}{bold}${ch}{/bold}{/white-fg}`;
|
|
909
|
-
if (i === (shineIdx + chars.length - 1) % chars.length || i === (shineIdx + 1) % chars.length) {
|
|
910
|
-
return `{cyan-fg}${ch}{/cyan-fg}`;
|
|
911
|
-
}
|
|
912
|
-
return `{magenta-fg}${ch}{/magenta-fg}`;
|
|
913
|
-
}).join('');
|
|
914
|
-
}
|
|
915
|
-
function renderHeader() {
|
|
613
|
+
let sparkleTimer;
|
|
614
|
+
function renderLogoWave() {
|
|
916
615
|
try {
|
|
917
616
|
ensureLayoutSynced();
|
|
617
|
+
// Color wave in the border label
|
|
618
|
+
const coloredChars = LOGO_CHARS.map((ch, i) => {
|
|
619
|
+
const colorIdx = (i + waveOffset) % WAVE_COLORS.length;
|
|
620
|
+
return `{${WAVE_COLORS[colorIdx]}-fg}${ch}{/${WAVE_COLORS[colorIdx]}-fg}`;
|
|
621
|
+
});
|
|
622
|
+
// Logo left + session name right in border label
|
|
623
|
+
const logoStr = ` ${coloredChars.join('')} `;
|
|
624
|
+
// Session name with traveling shimmer across ALL characters
|
|
625
|
+
const SESSION_GLOW = ['white', 'cyan', 'magenta'];
|
|
626
|
+
const glowPos = (waveOffset * 2) % sessionName.length; // 2x speed for snappier travel
|
|
627
|
+
const nameChars = sessionName.split('').map((ch, i) => {
|
|
628
|
+
const dist = Math.min(Math.abs(i - glowPos), sessionName.length - Math.abs(i - glowPos)); // wrapping distance
|
|
629
|
+
if (dist === 0)
|
|
630
|
+
return `{${SESSION_GLOW[0]}-fg}${ch}{/${SESSION_GLOW[0]}-fg}`;
|
|
631
|
+
if (dist <= 2)
|
|
632
|
+
return `{${SESSION_GLOW[1]}-fg}${ch}{/${SESSION_GLOW[1]}-fg}`;
|
|
633
|
+
return `{${SESSION_GLOW[2]}-fg}${ch}{/${SESSION_GLOW[2]}-fg}`;
|
|
634
|
+
});
|
|
635
|
+
const sessionStr = ` ${nameChars.join('')} `;
|
|
636
|
+
const rawLogoLen = LOGO_CHARS.length + 2; // " ekkOS_ " = 8
|
|
637
|
+
const rawSessionLen = sessionName.length + 3; // " name " + 1 extra space before ┐
|
|
918
638
|
const boxW = Math.max(10, headerBox.width - 2); // minus border chars
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
const sessionTagged = ` ${buildShinySessionName(sessionLabel)} `;
|
|
928
|
-
const rightGap = ' ';
|
|
929
|
-
const pad = Math.max(1, boxW - logoPlain.length - sessionPlain.length - rightGap.length);
|
|
930
|
-
const divider = '─'.repeat(pad);
|
|
931
|
-
// Keep a raw fallback for extremely narrow panes, but prefer animated label.
|
|
932
|
-
const fallbackLabel = (logoPlain + divider + sessionPlain + rightGap).slice(0, boxW);
|
|
933
|
-
if (fallbackLabel.length < logoPlain.length + 2) {
|
|
934
|
-
headerBox.setLabel(fallbackLabel);
|
|
935
|
-
}
|
|
936
|
-
else {
|
|
937
|
-
headerBox.setLabel(logoTagged + divider + sessionTagged + rightGap);
|
|
639
|
+
const pad = Math.max(1, boxW - rawLogoLen - rawSessionLen);
|
|
640
|
+
headerBox.setLabel(logoStr + '─'.repeat(pad) + sessionStr);
|
|
641
|
+
waveOffset = (waveOffset + 1) % WAVE_COLORS.length;
|
|
642
|
+
// Stats go inside the box
|
|
643
|
+
const data = lastData;
|
|
644
|
+
if (data) {
|
|
645
|
+
const m = data.model.replace('claude-', '').replace(/-\d{8}$/, '');
|
|
646
|
+
headerBox.setContent(` {green-fg}$${data.totalCost.toFixed(2)}{/green-fg} T${data.turnCount} ${data.duration} $${data.avgCostPerTurn.toFixed(2)}/t {cyan-fg}${m}{/cyan-fg}`);
|
|
938
647
|
}
|
|
939
|
-
// Message line inside the box (centered)
|
|
940
|
-
const fortuneRaw = (fortuneText && fortuneText.trim().length > 0)
|
|
941
|
-
? fortuneText.trim()
|
|
942
|
-
: 'Good luck.';
|
|
943
|
-
const truncateForWidth = (text, maxWidth) => {
|
|
944
|
-
if (maxWidth <= 0)
|
|
945
|
-
return '';
|
|
946
|
-
if (text.length <= maxWidth)
|
|
947
|
-
return text;
|
|
948
|
-
if (maxWidth <= 3)
|
|
949
|
-
return '.'.repeat(maxWidth);
|
|
950
|
-
return `${text.slice(0, maxWidth - 3)}...`;
|
|
951
|
-
};
|
|
952
|
-
const centerForWidth = (text, maxWidth) => {
|
|
953
|
-
const clipped = truncateForWidth(text, maxWidth);
|
|
954
|
-
const leftPad = Math.max(0, Math.floor((maxWidth - clipped.length) / 2));
|
|
955
|
-
return `${' '.repeat(leftPad)}${clipped}`;
|
|
956
|
-
};
|
|
957
|
-
const maxInner = Math.max(8, boxW - 1);
|
|
958
|
-
// Render through dedicated line-2 row, not generic box content flow.
|
|
959
|
-
headerBox.setContent('');
|
|
960
|
-
headerMessageRow.setContent(centerForWidth(fortuneRaw, maxInner));
|
|
961
648
|
screen.render();
|
|
962
649
|
}
|
|
963
650
|
catch { }
|
|
964
651
|
}
|
|
652
|
+
// Wave cycles every 200ms for smooth color sweep
|
|
653
|
+
function scheduleWave() {
|
|
654
|
+
sparkleTimer = setTimeout(() => {
|
|
655
|
+
renderLogoWave();
|
|
656
|
+
scheduleWave();
|
|
657
|
+
}, 200);
|
|
658
|
+
}
|
|
659
|
+
scheduleWave();
|
|
965
660
|
// ── Update function ──
|
|
661
|
+
let lastFileSize = 0;
|
|
662
|
+
let lastData = null;
|
|
663
|
+
let lastScrollPerc = 0; // Preserve scroll position across updates
|
|
966
664
|
const debugLog = path.join(os.homedir(), '.ekkos', 'dashboard.log');
|
|
967
665
|
function dlog(msg) {
|
|
968
666
|
try {
|
|
@@ -978,20 +676,12 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
978
676
|
const basename = path.basename(jsonlPath, '.jsonl');
|
|
979
677
|
// JSONL filename is the session UUID (e.g., 607bd8e4-0a04-4db2-acf5-3f794be0f956.jsonl)
|
|
980
678
|
if (/^[0-9a-f]{8}-/.test(basename)) {
|
|
981
|
-
sessionName =
|
|
679
|
+
sessionName = (0, state_js_1.uuidToWords)(basename);
|
|
982
680
|
screen.title = `ekkOS - ${sessionName}`;
|
|
983
681
|
}
|
|
984
682
|
}
|
|
985
683
|
catch { }
|
|
986
684
|
}
|
|
987
|
-
// If we started with a UUID fallback, keep trying to resolve to the bound word session.
|
|
988
|
-
if (UUID_REGEX.test(sessionName)) {
|
|
989
|
-
const resolvedName = displaySessionName(sessionName);
|
|
990
|
-
if (resolvedName !== sessionName) {
|
|
991
|
-
sessionName = resolvedName;
|
|
992
|
-
screen.title = `ekkOS - ${sessionName}`;
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
685
|
let data;
|
|
996
686
|
try {
|
|
997
687
|
const stat = fs.statSync(jsonlPath);
|
|
@@ -1005,9 +695,9 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1005
695
|
dlog(`Parse error: ${err.message}`);
|
|
1006
696
|
return;
|
|
1007
697
|
}
|
|
1008
|
-
// ── Header ──
|
|
698
|
+
// ── Header — wave animation handles rendering, just trigger a frame ──
|
|
1009
699
|
try {
|
|
1010
|
-
|
|
700
|
+
renderLogoWave();
|
|
1011
701
|
}
|
|
1012
702
|
catch (err) {
|
|
1013
703
|
dlog(`Header: ${err.message}`);
|
|
@@ -1039,10 +729,7 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1039
729
|
` {cyan-fg}Output{/cyan-fg} $${ou.toFixed(2)}\n` +
|
|
1040
730
|
` {${hitColor}-fg}${data.cacheHitRate.toFixed(0)}% cache{/${hitColor}-fg}` +
|
|
1041
731
|
` peak:${cappedMax.toFixed(0)}%` +
|
|
1042
|
-
` avg:$${data.avgCostPerTurn.toFixed(2)}/t`
|
|
1043
|
-
` replay A:${data.replayAppliedCount}` +
|
|
1044
|
-
` SZ:${data.replaySkippedSizeCount}` +
|
|
1045
|
-
` ST:${data.replaySkipStoreCount}`);
|
|
732
|
+
` avg:$${data.avgCostPerTurn.toFixed(2)}/t`);
|
|
1046
733
|
}
|
|
1047
734
|
catch (err) {
|
|
1048
735
|
dlog(`Context: ${err.message}`);
|
|
@@ -1052,12 +739,11 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1052
739
|
const recent = data.turns.slice(-30);
|
|
1053
740
|
if (recent.length >= 2) {
|
|
1054
741
|
const x = recent.map(t => String(t.turn));
|
|
1055
|
-
|
|
742
|
+
tokenChart.setData([
|
|
1056
743
|
{ title: 'Rd', x, y: recent.map(t => Math.round(t.cacheRead / 1000)), style: { line: 'green' } },
|
|
1057
744
|
{ title: 'Wr', x, y: recent.map(t => Math.round(t.cacheCreate / 1000)), style: { line: 'yellow' } },
|
|
1058
745
|
{ title: 'Out', x, y: recent.map(t => Math.round(t.output / 1000)), style: { line: 'cyan' } },
|
|
1059
|
-
];
|
|
1060
|
-
tokenChart.setData(lastChartSeries);
|
|
746
|
+
]);
|
|
1061
747
|
}
|
|
1062
748
|
}
|
|
1063
749
|
catch (err) {
|
|
@@ -1067,10 +753,9 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1067
753
|
try {
|
|
1068
754
|
// Preserve scroll position BEFORE updating content
|
|
1069
755
|
lastScrollPerc = turnBox.getScrollPerc();
|
|
1070
|
-
// Account for borders + scrollbar gutter
|
|
1071
|
-
|
|
1072
|
-
const
|
|
1073
|
-
const div = '│';
|
|
756
|
+
// Account for borders + scrollbar gutter + padding so last column never wraps
|
|
757
|
+
const w = Math.max(18, turnBox.width - 5); // usable content width
|
|
758
|
+
const div = '{gray-fg}│{/gray-fg}';
|
|
1074
759
|
function pad(s, width) {
|
|
1075
760
|
if (s.length >= width)
|
|
1076
761
|
return s.slice(0, width);
|
|
@@ -1081,14 +766,6 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1081
766
|
return s.slice(0, width);
|
|
1082
767
|
return ' '.repeat(width - s.length) + s;
|
|
1083
768
|
}
|
|
1084
|
-
function cpad(s, width) {
|
|
1085
|
-
if (s.length >= width)
|
|
1086
|
-
return s.slice(0, width);
|
|
1087
|
-
const total = width - s.length;
|
|
1088
|
-
const left = Math.floor(total / 2);
|
|
1089
|
-
const right = total - left;
|
|
1090
|
-
return ' '.repeat(left) + s + ' '.repeat(right);
|
|
1091
|
-
}
|
|
1092
769
|
// Data rows — RENDER ALL TURNS for full scrollback history
|
|
1093
770
|
// Don't slice to visibleRows only — let user scroll through entire session
|
|
1094
771
|
const turns = data.turns.slice().reverse();
|
|
@@ -1096,28 +773,28 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1096
773
|
let separator = '';
|
|
1097
774
|
let rows = [];
|
|
1098
775
|
// Responsive table layouts keep headers visible even in very narrow panes.
|
|
1099
|
-
if (w >=
|
|
776
|
+
if (w >= 44) {
|
|
1100
777
|
// Full mode: Turn, Model, Context, Cache Rd, Cache Wr, Output, Cost
|
|
1101
778
|
const colNum = 4;
|
|
1102
|
-
const colM =
|
|
1103
|
-
const colCtx =
|
|
1104
|
-
const colCost =
|
|
779
|
+
const colM = 6;
|
|
780
|
+
const colCtx = 6;
|
|
781
|
+
const colCost = 6;
|
|
1105
782
|
const nDividers = 6;
|
|
1106
783
|
const fixedW = colNum + colM + colCtx + colCost;
|
|
1107
784
|
const flexTotal = w - fixedW - nDividers;
|
|
1108
|
-
const rdW = Math.max(
|
|
1109
|
-
const wrW = Math.max(
|
|
1110
|
-
const outW = Math.max(
|
|
1111
|
-
header =
|
|
1112
|
-
separator =
|
|
785
|
+
const rdW = Math.max(4, Math.floor(flexTotal * 0.35));
|
|
786
|
+
const wrW = Math.max(4, Math.floor(flexTotal * 0.30));
|
|
787
|
+
const outW = Math.max(4, flexTotal - rdW - wrW);
|
|
788
|
+
header = `{bold}${pad('Turn', colNum)}${div}${pad('Model', colM)}${div}${pad('Context', colCtx)}${div}${rpad('Cache Rd', rdW)}${div}${rpad('Cache Wr', wrW)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}{/bold}`;
|
|
789
|
+
separator = `{gray-fg}${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(rdW)}┼${'─'.repeat(wrW)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}{/gray-fg}`;
|
|
1113
790
|
rows = turns.map(t => {
|
|
1114
791
|
const mTag = modelTag(t.routedModel);
|
|
1115
792
|
const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
|
|
1116
793
|
const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
|
|
1117
794
|
const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
|
|
1118
795
|
return (pad(String(t.turn), colNum) + div +
|
|
1119
|
-
`{${mColor}-fg}${
|
|
1120
|
-
|
|
796
|
+
`{${mColor}-fg}${pad(mTag, colM)}{/${mColor}-fg}` + div +
|
|
797
|
+
pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
|
|
1121
798
|
`{green-fg}${rpad(fmtK(t.cacheRead), rdW)}{/green-fg}` + div +
|
|
1122
799
|
`{yellow-fg}${rpad(fmtK(t.cacheCreate), wrW)}{/yellow-fg}` + div +
|
|
1123
800
|
`{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
|
|
@@ -1132,16 +809,16 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1132
809
|
const colCost = 6;
|
|
1133
810
|
const nDividers = 4;
|
|
1134
811
|
const outW = Math.max(4, w - (colNum + colM + colCtx + colCost + nDividers));
|
|
1135
|
-
header =
|
|
1136
|
-
separator =
|
|
812
|
+
header = `{bold}${pad('Turn', colNum)}${div}${pad('Model', colM)}${div}${pad('Context', colCtx)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}{/bold}`;
|
|
813
|
+
separator = `{gray-fg}${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}{/gray-fg}`;
|
|
1137
814
|
rows = turns.map(t => {
|
|
1138
815
|
const mTag = modelTag(t.routedModel);
|
|
1139
816
|
const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
|
|
1140
817
|
const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
|
|
1141
818
|
const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
|
|
1142
819
|
return (pad(String(t.turn), colNum) + div +
|
|
1143
|
-
`{${mColor}-fg}${
|
|
1144
|
-
|
|
820
|
+
`{${mColor}-fg}${pad(mTag, colM)}{/${mColor}-fg}` + div +
|
|
821
|
+
pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
|
|
1145
822
|
`{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
|
|
1146
823
|
costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
|
|
1147
824
|
});
|
|
@@ -1151,13 +828,13 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1151
828
|
const colNum = 4;
|
|
1152
829
|
const colCtx = 6;
|
|
1153
830
|
const colCost = 6;
|
|
1154
|
-
header =
|
|
1155
|
-
separator =
|
|
831
|
+
header = `{bold}${pad('Turn', colNum)}${div}${pad('Context', colCtx)}${div}${rpad('Cost', colCost)}{/bold}`;
|
|
832
|
+
separator = `{gray-fg}${'─'.repeat(colNum)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(colCost)}{/gray-fg}`;
|
|
1156
833
|
rows = turns.map(t => {
|
|
1157
834
|
const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
|
|
1158
835
|
const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
|
|
1159
836
|
return (pad(String(t.turn), colNum) + div +
|
|
1160
|
-
|
|
837
|
+
pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
|
|
1161
838
|
costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
|
|
1162
839
|
});
|
|
1163
840
|
}
|
|
@@ -1188,15 +865,11 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1188
865
|
const savingsStr = totalSavings > 0
|
|
1189
866
|
? ` {green-fg}saved $${totalSavings.toFixed(2)}{/green-fg}`
|
|
1190
867
|
: '';
|
|
1191
|
-
footerBox.setLabel(` ${sessionName} `);
|
|
1192
868
|
footerBox.setContent(` {green-fg}$${data.totalCost.toFixed(2)}{/green-fg}` +
|
|
1193
869
|
` ${totalTokensM}M` +
|
|
1194
870
|
` ${routingStr}` +
|
|
1195
|
-
` R[A:${data.replayAppliedCount} SZ:${data.replaySkippedSizeCount} ST:${data.replaySkipStoreCount}]` +
|
|
1196
871
|
savingsStr +
|
|
1197
|
-
|
|
1198
|
-
? ` {gray-fg}Ctrl+C quit{/gray-fg}`
|
|
1199
|
-
: ` {gray-fg}? help q quit r refresh{/gray-fg}`));
|
|
872
|
+
` {gray-fg}? help q quit r refresh{/gray-fg}`);
|
|
1200
873
|
}
|
|
1201
874
|
catch (err) {
|
|
1202
875
|
dlog(`Footer: ${err.message}`);
|
|
@@ -1216,20 +889,10 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1216
889
|
*/
|
|
1217
890
|
async function fetchAnthropicUsage() {
|
|
1218
891
|
try {
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
token = JSON.parse(credsJson)?.claudeAiOauth?.accessToken ?? null;
|
|
1224
|
-
}
|
|
1225
|
-
else if (process.platform === 'win32') {
|
|
1226
|
-
// Windows: Claude Code stores credentials in ~/.claude/.credentials.json
|
|
1227
|
-
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
1228
|
-
if (fs.existsSync(credsPath)) {
|
|
1229
|
-
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
|
|
1230
|
-
token = creds?.claudeAiOauth?.accessToken ?? null;
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
892
|
+
const { execSync } = require('child_process');
|
|
893
|
+
const credsJson = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
894
|
+
const creds = JSON.parse(credsJson);
|
|
895
|
+
const token = creds?.claudeAiOauth?.accessToken;
|
|
1233
896
|
if (!token)
|
|
1234
897
|
return null;
|
|
1235
898
|
const resp = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
|
@@ -1295,19 +958,10 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1295
958
|
screen.on('resize', () => {
|
|
1296
959
|
try {
|
|
1297
960
|
ensureLayoutSynced();
|
|
1298
|
-
if (lastData)
|
|
961
|
+
if (lastData)
|
|
1299
962
|
updateDashboard();
|
|
1300
|
-
|
|
1301
|
-
else {
|
|
1302
|
-
// Even without data, re-apply chart series so the canvas redraws at new size
|
|
1303
|
-
if (lastChartSeries) {
|
|
1304
|
-
try {
|
|
1305
|
-
tokenChart.setData(lastChartSeries);
|
|
1306
|
-
}
|
|
1307
|
-
catch { }
|
|
1308
|
-
}
|
|
963
|
+
else
|
|
1309
964
|
screen.render();
|
|
1310
|
-
}
|
|
1311
965
|
}
|
|
1312
966
|
catch (err) {
|
|
1313
967
|
dlog(`Resize: ${err.message}`);
|
|
@@ -1317,101 +971,88 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1317
971
|
// KEYBOARD SHORTCUTS - Only capture when dashboard pane has focus
|
|
1318
972
|
// In tmux split mode, this prevents capturing keys from Claude Code pane
|
|
1319
973
|
// ══════════════════════════════════════════════════════════════════════════
|
|
1320
|
-
screen.key(['C-c'], () => {
|
|
974
|
+
screen.key(['q', 'C-c'], () => {
|
|
1321
975
|
clearInterval(pollInterval);
|
|
1322
976
|
clearInterval(windowPollInterval);
|
|
1323
|
-
|
|
1324
|
-
clearInterval(fortuneInterval);
|
|
977
|
+
clearTimeout(sparkleTimer);
|
|
1325
978
|
screen.destroy();
|
|
1326
979
|
process.exit(0);
|
|
1327
980
|
});
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
clearInterval(fortuneInterval);
|
|
1334
|
-
screen.destroy();
|
|
1335
|
-
process.exit(0);
|
|
1336
|
-
});
|
|
1337
|
-
screen.key(['r'], () => {
|
|
1338
|
-
lastFileSize = 0;
|
|
1339
|
-
updateDashboard();
|
|
1340
|
-
updateWindowBox();
|
|
1341
|
-
});
|
|
1342
|
-
}
|
|
981
|
+
screen.key(['r'], () => {
|
|
982
|
+
lastFileSize = 0;
|
|
983
|
+
updateDashboard();
|
|
984
|
+
updateWindowBox();
|
|
985
|
+
});
|
|
1343
986
|
// ══════════════════════════════════════════════════════════════════════════
|
|
1344
987
|
// FOCUS MANAGEMENT: In tmux split mode, DON'T auto-focus the turnBox
|
|
1345
988
|
// This prevents the dashboard from stealing focus from Claude Code on startup
|
|
1346
989
|
// User can manually focus by clicking into the dashboard pane
|
|
1347
990
|
// ══════════════════════════════════════════════════════════════════════════
|
|
991
|
+
// Check if we're in a tmux session
|
|
992
|
+
const inTmux = process.env.TMUX !== undefined;
|
|
1348
993
|
if (!inTmux) {
|
|
1349
994
|
// Only auto-focus when running standalone (not in tmux split)
|
|
1350
995
|
turnBox.focus();
|
|
1351
996
|
}
|
|
1352
997
|
// Scroll controls for turn table
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
998
|
+
screen.key(['up', 'k'], () => {
|
|
999
|
+
turnBox.scroll(-1);
|
|
1000
|
+
screen.render();
|
|
1001
|
+
});
|
|
1002
|
+
screen.key(['down', 'j'], () => {
|
|
1003
|
+
turnBox.scroll(1);
|
|
1004
|
+
screen.render();
|
|
1005
|
+
});
|
|
1006
|
+
screen.key(['pageup', 'u'], () => {
|
|
1007
|
+
turnBox.scroll(-(turnBox.height - 2));
|
|
1008
|
+
screen.render();
|
|
1009
|
+
});
|
|
1010
|
+
screen.key(['pagedown', 'd'], () => {
|
|
1011
|
+
turnBox.scroll((turnBox.height - 2));
|
|
1012
|
+
screen.render();
|
|
1013
|
+
});
|
|
1014
|
+
screen.key(['home', 'g'], () => {
|
|
1015
|
+
turnBox.setScrollPerc(0);
|
|
1016
|
+
screen.render();
|
|
1017
|
+
});
|
|
1018
|
+
screen.key(['end', 'G'], () => {
|
|
1019
|
+
turnBox.setScrollPerc(100);
|
|
1020
|
+
screen.render();
|
|
1021
|
+
});
|
|
1022
|
+
screen.key(['?', 'h'], () => {
|
|
1023
|
+
// Quick help overlay
|
|
1024
|
+
const help = blessed.box({
|
|
1025
|
+
top: 'center',
|
|
1026
|
+
left: 'center',
|
|
1027
|
+
width: 50,
|
|
1028
|
+
height: 16,
|
|
1029
|
+
content: ('{bold}Navigation{/bold}\n' +
|
|
1030
|
+
' ↑/k/j/↓ Scroll line\n' +
|
|
1031
|
+
' PgUp/u Scroll page up\n' +
|
|
1032
|
+
' PgDn/d Scroll page down\n' +
|
|
1033
|
+
' g/Home Scroll to top\n' +
|
|
1034
|
+
' G/End Scroll to bottom\n' +
|
|
1035
|
+
'\n' +
|
|
1036
|
+
'{bold}Controls{/bold}\n' +
|
|
1037
|
+
' r Refresh now\n' +
|
|
1038
|
+
' q/Ctrl+C Quit\n' +
|
|
1039
|
+
'\n' +
|
|
1040
|
+
'{gray-fg}Press any key to close{/gray-fg}'),
|
|
1041
|
+
tags: true,
|
|
1042
|
+
border: 'line',
|
|
1043
|
+
style: { border: { fg: 'cyan' } },
|
|
1044
|
+
padding: 1,
|
|
1377
1045
|
});
|
|
1378
|
-
screen.
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
width: 50,
|
|
1384
|
-
height: 16,
|
|
1385
|
-
content: ('{bold}Navigation{/bold}\n' +
|
|
1386
|
-
' ↑/k/j/↓ Scroll line\n' +
|
|
1387
|
-
' PgUp/u Scroll page up\n' +
|
|
1388
|
-
' PgDn/d Scroll page down\n' +
|
|
1389
|
-
' g/Home Scroll to top\n' +
|
|
1390
|
-
' G/End Scroll to bottom\n' +
|
|
1391
|
-
'\n' +
|
|
1392
|
-
'{bold}Controls{/bold}\n' +
|
|
1393
|
-
' r Refresh now\n' +
|
|
1394
|
-
' q/Ctrl+C Quit\n' +
|
|
1395
|
-
'\n' +
|
|
1396
|
-
'{gray-fg}Press any key to close{/gray-fg}'),
|
|
1397
|
-
tags: true,
|
|
1398
|
-
border: 'line',
|
|
1399
|
-
style: { border: { fg: 'cyan' } },
|
|
1400
|
-
padding: 1,
|
|
1401
|
-
});
|
|
1402
|
-
screen.append(help);
|
|
1046
|
+
screen.append(help);
|
|
1047
|
+
screen.render();
|
|
1048
|
+
// Close on any key press
|
|
1049
|
+
const closeHelp = () => {
|
|
1050
|
+
help.destroy();
|
|
1403
1051
|
screen.render();
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
screen.render();
|
|
1409
|
-
screen.removeListener('key', closeHelp);
|
|
1410
|
-
};
|
|
1411
|
-
screen.once('key', closeHelp);
|
|
1412
|
-
});
|
|
1413
|
-
});
|
|
1414
|
-
}
|
|
1052
|
+
screen.removeListener('key', closeHelp);
|
|
1053
|
+
};
|
|
1054
|
+
screen.on('key', closeHelp);
|
|
1055
|
+
});
|
|
1415
1056
|
// Clear terminal buffer — prevents garbage text from previous commands
|
|
1416
1057
|
screen.program.clear();
|
|
1417
1058
|
// Dashboard is fully passive — no widget captures keyboard input
|
|
@@ -1420,18 +1061,6 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1420
1061
|
// Delay first ccusage call — let blessed render first, then load heavy data
|
|
1421
1062
|
setTimeout(() => updateWindowBox(), 2000);
|
|
1422
1063
|
const pollInterval = setInterval(updateDashboard, refreshMs);
|
|
1423
|
-
const headerAnimInterval = setInterval(() => {
|
|
1424
|
-
// Keep advancing across the full session label; wrap at a large value.
|
|
1425
|
-
waveOffset = (waveOffset + 1) % 1000000;
|
|
1426
|
-
renderHeader();
|
|
1427
|
-
}, 500);
|
|
1428
|
-
const fortuneInterval = setInterval(() => {
|
|
1429
|
-
if (GOOD_LUCK_FORTUNES.length === 0)
|
|
1430
|
-
return;
|
|
1431
|
-
fortuneIdx = (fortuneIdx + 1) % GOOD_LUCK_FORTUNES.length;
|
|
1432
|
-
fortuneText = GOOD_LUCK_FORTUNES[fortuneIdx];
|
|
1433
|
-
renderHeader();
|
|
1434
|
-
}, 30000);
|
|
1435
1064
|
const windowPollInterval = setInterval(updateWindowBox, 15000); // every 15s
|
|
1436
1065
|
}
|
|
1437
1066
|
// ── Helpers ──
|