@ekkos/cli 1.0.36 → 1.1.1
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/README.md +57 -0
- package/dist/commands/dashboard.js +561 -186
- package/dist/commands/run.js +1 -1
- package/dist/deploy/settings.js +13 -26
- package/package.json +1 -1
- package/templates/hooks/assistant-response.ps1 +94 -26
- package/templates/hooks/hooks.json +24 -12
- package/templates/hooks/lib/count-tokens.cjs +0 -0
- package/templates/hooks/lib/ekkos-reminders.sh +0 -0
- package/templates/hooks/session-start.ps1 +61 -224
- package/templates/hooks/session-start.sh +1 -1
- package/templates/hooks/stop.ps1 +103 -249
- package/templates/hooks/stop.sh +1 -1
- package/templates/hooks/user-prompt-submit.ps1 +129 -519
- 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/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/agents/README.md +0 -182
- package/templates/agents/code-reviewer.md +0 -166
- package/templates/agents/debug-detective.md +0 -169
- package/templates/agents/ekkOS_Vercel.md +0 -99
- package/templates/agents/extension-manager.md +0 -229
- package/templates/agents/git-companion.md +0 -185
- package/templates/agents/github-test-agent.md +0 -321
- package/templates/agents/railway-manager.md +0 -179
- package/templates/windsurf-hooks/before-submit-prompt.sh +0 -238
- package/templates/windsurf-skills/ekkos-memory/SKILL.md +0 -219
|
@@ -67,6 +67,7 @@ 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;
|
|
70
71
|
// ── Pricing ──
|
|
71
72
|
// Pricing per MTok from https://platform.claude.com/docs/en/about-claude/pricing
|
|
72
73
|
const MODEL_PRICING = {
|
|
@@ -113,6 +114,19 @@ function modelTag(model) {
|
|
|
113
114
|
return 'Haiku';
|
|
114
115
|
return '?';
|
|
115
116
|
}
|
|
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
|
+
}
|
|
116
130
|
function parseJsonlFile(jsonlPath, sessionName) {
|
|
117
131
|
const content = fs.readFileSync(jsonlPath, 'utf-8');
|
|
118
132
|
const lines = content.trim().split('\n');
|
|
@@ -141,6 +155,18 @@ function parseJsonlFile(jsonlPath, sessionName) {
|
|
|
141
155
|
model = entry.message.model || model;
|
|
142
156
|
// Smart routing: _ekkos_routed_model contains the actual model used
|
|
143
157
|
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');
|
|
144
170
|
const inputTokens = usage.input_tokens || 0;
|
|
145
171
|
const outputTokens = usage.output_tokens || 0;
|
|
146
172
|
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
@@ -182,6 +208,9 @@ function parseJsonlFile(jsonlPath, sessionName) {
|
|
|
182
208
|
tools: toolStr,
|
|
183
209
|
model,
|
|
184
210
|
routedModel,
|
|
211
|
+
replayState,
|
|
212
|
+
replayStore,
|
|
213
|
+
evictionState,
|
|
185
214
|
timestamp: ts,
|
|
186
215
|
};
|
|
187
216
|
if (msgId) {
|
|
@@ -205,6 +234,9 @@ function parseJsonlFile(jsonlPath, sessionName) {
|
|
|
205
234
|
const cacheHitRate = (totalCacheRead + totalCacheCreate) > 0
|
|
206
235
|
? (totalCacheRead / (totalCacheRead + totalCacheCreate)) * 100
|
|
207
236
|
: 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);
|
|
208
240
|
let duration = '0m';
|
|
209
241
|
if (startedAt && turns.length > 0) {
|
|
210
242
|
const start = new Date(startedAt).getTime();
|
|
@@ -233,11 +265,63 @@ function parseJsonlFile(jsonlPath, sessionName) {
|
|
|
233
265
|
currentContextTokens,
|
|
234
266
|
modelContextSize,
|
|
235
267
|
cacheHitRate,
|
|
268
|
+
replayAppliedCount,
|
|
269
|
+
replaySkippedSizeCount,
|
|
270
|
+
replaySkipStoreCount,
|
|
236
271
|
startedAt,
|
|
237
272
|
duration,
|
|
238
273
|
turns,
|
|
239
274
|
};
|
|
240
275
|
}
|
|
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
|
+
}
|
|
241
325
|
// ── Resolve session to JSONL path ──
|
|
242
326
|
function resolveJsonlPath(sessionName, createdAfterMs) {
|
|
243
327
|
// 1) Try standard resolution (works when sessionId is a real UUID)
|
|
@@ -267,23 +351,53 @@ function resolveJsonlPath(sessionName, createdAfterMs) {
|
|
|
267
351
|
* This prevents picking up old sessions that are still being modified.
|
|
268
352
|
*/
|
|
269
353
|
function findLatestJsonl(projectPath, createdAfterMs) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
354
|
+
// Claude encodes project paths by replacing separators with '-'.
|
|
355
|
+
// On Windows, ':' is also illegal in directory names so it gets replaced too.
|
|
356
|
+
// Try all plausible encodings since Claude's exact scheme varies by platform.
|
|
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)
|
|
273
387
|
return null;
|
|
274
|
-
const
|
|
275
|
-
.
|
|
276
|
-
.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return
|
|
388
|
+
const candidateEncodings = new Set([
|
|
389
|
+
projectPath.replace(/[\\/]/g, '-'),
|
|
390
|
+
projectPath.replace(/[:\\/]/g, '-'),
|
|
391
|
+
'-' + projectPath.replace(/[:\\/]/g, '-'),
|
|
392
|
+
projectPath.replace(/\//g, '-'),
|
|
393
|
+
]);
|
|
394
|
+
const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
|
|
395
|
+
for (const encoded of candidateEncodings) {
|
|
396
|
+
const exactPath = path.join(projectsRoot, encoded, `${sessionId}.jsonl`);
|
|
397
|
+
if (fs.existsSync(exactPath))
|
|
398
|
+
return exactPath;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
287
401
|
}
|
|
288
402
|
function getLatestSession() {
|
|
289
403
|
const sessions = (0, state_js_1.getActiveSessions)();
|
|
@@ -315,6 +429,29 @@ async function waitForNewSession() {
|
|
|
315
429
|
const startWait = Date.now();
|
|
316
430
|
let candidateName = null;
|
|
317
431
|
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) ────────────────
|
|
318
455
|
const sessions = (0, state_js_1.getActiveSessions)();
|
|
319
456
|
// Find sessions that started after our launch
|
|
320
457
|
for (const s of sessions) {
|
|
@@ -343,11 +480,49 @@ async function waitForNewSession() {
|
|
|
343
480
|
if (launchCwd) {
|
|
344
481
|
const latestJsonl = findLatestJsonl(launchCwd, launchTs);
|
|
345
482
|
if (latestJsonl) {
|
|
346
|
-
const name = candidateName || '
|
|
483
|
+
const name = candidateName || path.basename(latestJsonl, '.jsonl');
|
|
347
484
|
console.log(chalk_1.default.green(` Found session via CWD: ${name}`));
|
|
348
485
|
return { sessionName: name, jsonlPath: latestJsonl };
|
|
349
486
|
}
|
|
350
487
|
}
|
|
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
|
+
}
|
|
351
526
|
await sleep(pollMs);
|
|
352
527
|
process.stdout.write(chalk_1.default.gray('.'));
|
|
353
528
|
}
|
|
@@ -374,9 +549,10 @@ function sleep(ms) {
|
|
|
374
549
|
}
|
|
375
550
|
// ── TUI Dashboard ──
|
|
376
551
|
async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
377
|
-
let sessionName = initialSessionName;
|
|
552
|
+
let sessionName = displaySessionName(initialSessionName);
|
|
378
553
|
const blessed = require('blessed');
|
|
379
554
|
const contrib = require('blessed-contrib');
|
|
555
|
+
const inTmux = process.env.TMUX !== undefined;
|
|
380
556
|
// ══════════════════════════════════════════════════════════════════════════
|
|
381
557
|
// TMUX SPLIT PANE ISOLATION
|
|
382
558
|
// When dashboard runs in a separate tmux pane from `ekkos run`, blessed must
|
|
@@ -451,6 +627,62 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
451
627
|
// footer: 3 rows (totals + routing + keybindings)
|
|
452
628
|
const LOGO_CHARS = ['e', 'k', 'k', 'O', 'S', '_'];
|
|
453
629
|
const WAVE_COLORS = ['cyan', 'blue', 'magenta', 'yellow', 'green', 'white'];
|
|
630
|
+
const GOOD_LUCK_FORTUNES = [
|
|
631
|
+
// AI / LLM era humor
|
|
632
|
+
'The AI was confident. It was also wrong.',
|
|
633
|
+
'Vibe coded it. Ship it. Pray.',
|
|
634
|
+
'Your model hallucinated. Your memory did not.',
|
|
635
|
+
'Claude said "I cannot assist with that." ekkOS remembered anyway.',
|
|
636
|
+
'The context window closed. The lesson did not.',
|
|
637
|
+
'Cursor wrote it. You own it. Good luck.',
|
|
638
|
+
'It works. Nobody knows why. Memory saved the why.',
|
|
639
|
+
'LLM said "as of my knowledge cutoff." ekkOS said hold my cache.',
|
|
640
|
+
'Your agent forgot. Classic agent behavior.',
|
|
641
|
+
'GPT-5 dropped. Your memory still works.',
|
|
642
|
+
'Trained on the internet. Trusted by no one.',
|
|
643
|
+
'Fine-tuned on vibes. Running in production.',
|
|
644
|
+
'Prompt engineering is just yelling more politely.',
|
|
645
|
+
'The AI is confident 97% of the time. The other 3% is your bug.',
|
|
646
|
+
// Friday deploys / prod pain
|
|
647
|
+
'Pushed to prod on a Friday. Memory captured the regret.',
|
|
648
|
+
'It was working this morning. The morning remembers.',
|
|
649
|
+
'The bug was in prod for 3 months. The fix took 4 minutes.',
|
|
650
|
+
'Hotfix on a hotfix. Classic.',
|
|
651
|
+
'Rollback complete. Dignity: partial.',
|
|
652
|
+
'"It works on my machine." Ship the machine.',
|
|
653
|
+
'The incident was resolved. The root cause was vibes.',
|
|
654
|
+
'Post-mortem written. Lessons immediately forgotten. Not anymore.',
|
|
655
|
+
// Context / memory specific
|
|
656
|
+
'Cold start problem? Never met her.',
|
|
657
|
+
'94% cache hit rate. The other 6% are trust issues.',
|
|
658
|
+
'107 turns. Zero compaction. One very tired server.',
|
|
659
|
+
'Flat cost curve. Exponential confidence.',
|
|
660
|
+
'Your session ended. Your mistakes did not.',
|
|
661
|
+
'The context limit hit. The memory did not care.',
|
|
662
|
+
'Compaction is a skill issue.',
|
|
663
|
+
// General dev pain
|
|
664
|
+
'The ticket said "small change." It was not small.',
|
|
665
|
+
'Story points are astrology for engineers.',
|
|
666
|
+
'The meeting could have been a memory.',
|
|
667
|
+
'"Just a quick question." — 45 minutes ago.',
|
|
668
|
+
'Senior dev. 8 years experience. Still googles how to center a div.',
|
|
669
|
+
'Code review: where confidence goes to die.',
|
|
670
|
+
'The PR sat for 11 days. You merged it anyway.',
|
|
671
|
+
'Works fine until it\'s demoed. Classic.',
|
|
672
|
+
'Two spaces or four? Choose your enemies carefully.',
|
|
673
|
+
'Tech debt is just regular debt with better excuses.',
|
|
674
|
+
'The documentation was last updated in 2019. Press F.',
|
|
675
|
+
'Legacy code: someone\'s proudest moment, your worst nightmare.',
|
|
676
|
+
'Tabs vs spaces is still unresolved. The war continues.',
|
|
677
|
+
'LGTM. (I did not look at this.)',
|
|
678
|
+
'The standup is 15 minutes. It is never 15 minutes.',
|
|
679
|
+
'Agile: deadline anxiety, but make it a ceremony.',
|
|
680
|
+
'"No breaking changes." — Famous last words.',
|
|
681
|
+
'Your regex is beautiful. Your regex is unmaintainable.',
|
|
682
|
+
'undefined is just the universe saying try again.',
|
|
683
|
+
'It\'s not a bug. It\'s a negotiated feature.',
|
|
684
|
+
'Closed one ticket. Jira opened three.',
|
|
685
|
+
];
|
|
454
686
|
const W = '100%';
|
|
455
687
|
const HEADER_H = 3;
|
|
456
688
|
const CONTEXT_H = 5;
|
|
@@ -461,7 +693,7 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
461
693
|
function calcLayout() {
|
|
462
694
|
const H = screen.height;
|
|
463
695
|
const remaining = Math.max(6, H - FIXED_H);
|
|
464
|
-
const chartH = Math.max(
|
|
696
|
+
const chartH = Math.max(8, Math.floor(remaining * 0.40));
|
|
465
697
|
const tableH = Math.max(4, remaining - chartH);
|
|
466
698
|
return {
|
|
467
699
|
header: { top: 0, height: HEADER_H },
|
|
@@ -473,6 +705,12 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
473
705
|
};
|
|
474
706
|
}
|
|
475
707
|
let layout = calcLayout();
|
|
708
|
+
let lastFileSize = 0;
|
|
709
|
+
let lastData = null;
|
|
710
|
+
let lastChartSeries = null;
|
|
711
|
+
let lastScrollPerc = 0; // Preserve scroll position across updates
|
|
712
|
+
let fortuneIdx = Math.floor(Math.random() * GOOD_LUCK_FORTUNES.length);
|
|
713
|
+
let fortuneText = GOOD_LUCK_FORTUNES[fortuneIdx];
|
|
476
714
|
// Header: session stats (3 lines)
|
|
477
715
|
const headerBox = blessed.box({
|
|
478
716
|
top: layout.header.top, left: 0, width: W, height: layout.header.height,
|
|
@@ -482,6 +720,18 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
482
720
|
border: { type: 'line' },
|
|
483
721
|
label: ' ekkOS_ ',
|
|
484
722
|
});
|
|
723
|
+
// Explicit header message row: with HEADER_H=3 and a border, this is the
|
|
724
|
+
// single inner content row (visual line 2 of the widget).
|
|
725
|
+
const headerMessageRow = blessed.box({
|
|
726
|
+
parent: headerBox,
|
|
727
|
+
top: 0,
|
|
728
|
+
left: 0,
|
|
729
|
+
width: '100%-2',
|
|
730
|
+
height: 1,
|
|
731
|
+
tags: false,
|
|
732
|
+
style: { fg: 'green', bold: true },
|
|
733
|
+
content: '',
|
|
734
|
+
});
|
|
485
735
|
// Context: progress bar + costs + cache (5 lines)
|
|
486
736
|
const contextBox = blessed.box({
|
|
487
737
|
top: layout.context.top, left: 0, width: W, height: layout.context.height,
|
|
@@ -502,23 +752,28 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
502
752
|
tags: false,
|
|
503
753
|
style: { fg: 'red', bold: true }, // ansi redBright = official Clawd orange
|
|
504
754
|
});
|
|
505
|
-
// Token chart (fills
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
755
|
+
// Token chart (fills 40% of remaining)
|
|
756
|
+
function createTokenChart(top, left, width, height) {
|
|
757
|
+
return contrib.line({
|
|
758
|
+
top, left, width, height,
|
|
759
|
+
label: ' Tokens/Turn (K) ',
|
|
760
|
+
showLegend: true,
|
|
761
|
+
legend: { width: 8 },
|
|
762
|
+
style: {
|
|
763
|
+
line: 'green',
|
|
764
|
+
text: 'white',
|
|
765
|
+
baseline: 'white',
|
|
766
|
+
border: { fg: 'cyan' },
|
|
767
|
+
},
|
|
768
|
+
border: { type: 'line', fg: 'cyan' },
|
|
769
|
+
xLabelPadding: 0,
|
|
770
|
+
xPadding: 1,
|
|
771
|
+
wholeNumbersOnly: false,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
let tokenChart = createTokenChart(layout.chart.top, 0, W, layout.chart.height);
|
|
775
|
+
let chartLayoutW = 0;
|
|
776
|
+
let chartLayoutH = 0;
|
|
522
777
|
// Turn table — manual rendering for full-width columns + dim dividers
|
|
523
778
|
const turnBox = blessed.box({
|
|
524
779
|
top: layout.table.top, left: 0, width: W, height: layout.table.height,
|
|
@@ -527,11 +782,11 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
527
782
|
scrollable: true,
|
|
528
783
|
alwaysScroll: true,
|
|
529
784
|
scrollbar: { ch: '│', style: { fg: 'cyan' } },
|
|
530
|
-
keys:
|
|
531
|
-
vi:
|
|
785
|
+
keys: !inTmux, // In tmux split mode keep dashboard passive
|
|
786
|
+
vi: !inTmux, // Avoid single-key handlers interfering with paste
|
|
532
787
|
mouse: false, // Mouse disabled (use keyboard for scrolling, allows text selection)
|
|
533
|
-
input:
|
|
534
|
-
interactive:
|
|
788
|
+
input: !inTmux,
|
|
789
|
+
interactive: !inTmux, // Standalone only; passive in tmux split
|
|
535
790
|
label: ' Turns (scroll: ↑↓/k/j, page: PgUp/u, home/end: g/G) ',
|
|
536
791
|
border: { type: 'line', fg: 'cyan' },
|
|
537
792
|
style: { fg: 'white', border: { fg: 'cyan' } },
|
|
@@ -575,10 +830,29 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
575
830
|
contextBox.left = H_PAD;
|
|
576
831
|
contextBox.width = contentWidth;
|
|
577
832
|
contextBox.height = layout.context.height;
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
833
|
+
// blessed-contrib line can keep a stale tiny canvas when terminals report
|
|
834
|
+
// initial dimensions incorrectly (observed in Windows Terminal). Rebuild
|
|
835
|
+
// the chart widget whenever dimensions change so the plot fills the panel.
|
|
836
|
+
if (chartLayoutW !== contentWidth || chartLayoutH !== layout.chart.height) {
|
|
837
|
+
try {
|
|
838
|
+
screen.remove(tokenChart);
|
|
839
|
+
}
|
|
840
|
+
catch { }
|
|
841
|
+
try {
|
|
842
|
+
tokenChart.destroy?.();
|
|
843
|
+
}
|
|
844
|
+
catch { }
|
|
845
|
+
tokenChart = createTokenChart(layout.chart.top, H_PAD, contentWidth, layout.chart.height);
|
|
846
|
+
chartLayoutW = contentWidth;
|
|
847
|
+
chartLayoutH = layout.chart.height;
|
|
848
|
+
screen.append(tokenChart);
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
tokenChart.top = layout.chart.top;
|
|
852
|
+
tokenChart.left = H_PAD;
|
|
853
|
+
tokenChart.width = contentWidth;
|
|
854
|
+
tokenChart.height = layout.chart.height;
|
|
855
|
+
}
|
|
582
856
|
turnBox.top = layout.table.top;
|
|
583
857
|
turnBox.left = H_PAD;
|
|
584
858
|
turnBox.width = contentWidth;
|
|
@@ -591,7 +865,16 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
591
865
|
footerBox.left = H_PAD;
|
|
592
866
|
footerBox.width = contentWidth;
|
|
593
867
|
footerBox.height = layout.footer.height;
|
|
868
|
+
// Force blessed-contrib to re-render the chart canvas at the new dimensions
|
|
869
|
+
if (lastChartSeries) {
|
|
870
|
+
try {
|
|
871
|
+
tokenChart.setData(lastChartSeries);
|
|
872
|
+
}
|
|
873
|
+
catch { }
|
|
874
|
+
}
|
|
594
875
|
}
|
|
876
|
+
// Apply once at startup so chart/table geometry is correct even before any resize event.
|
|
877
|
+
applyLayout();
|
|
595
878
|
// Track geometry so we can re-anchor widgets even if tmux resize events are flaky
|
|
596
879
|
let lastLayoutW = screen.width || 0;
|
|
597
880
|
let lastLayoutH = screen.height || 0;
|
|
@@ -608,59 +891,82 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
608
891
|
catch { }
|
|
609
892
|
applyLayout();
|
|
610
893
|
}
|
|
611
|
-
// ──
|
|
894
|
+
// ── Header render (animated logo in border label + compact stats content) ──
|
|
612
895
|
let waveOffset = 0;
|
|
613
|
-
|
|
614
|
-
|
|
896
|
+
function buildAnimatedLogo() {
|
|
897
|
+
const raw = LOGO_CHARS.join('');
|
|
898
|
+
const tagged = LOGO_CHARS.map((ch, i) => {
|
|
899
|
+
const colorIdx = Math.abs(i + waveOffset) % WAVE_COLORS.length;
|
|
900
|
+
const color = WAVE_COLORS[colorIdx];
|
|
901
|
+
return `{${color}-fg}${ch}{/${color}-fg}`;
|
|
902
|
+
}).join('');
|
|
903
|
+
return { raw, tagged };
|
|
904
|
+
}
|
|
905
|
+
function buildShinySessionName(name) {
|
|
906
|
+
if (!name)
|
|
907
|
+
return '';
|
|
908
|
+
const chars = name.split('');
|
|
909
|
+
const shineIdx = Math.abs(waveOffset) % chars.length;
|
|
910
|
+
return chars.map((ch, i) => {
|
|
911
|
+
if (i === shineIdx)
|
|
912
|
+
return `{white-fg}{bold}${ch}{/bold}{/white-fg}`;
|
|
913
|
+
if (i === (shineIdx + chars.length - 1) % chars.length || i === (shineIdx + 1) % chars.length) {
|
|
914
|
+
return `{cyan-fg}${ch}{/cyan-fg}`;
|
|
915
|
+
}
|
|
916
|
+
return `{magenta-fg}${ch}{/magenta-fg}`;
|
|
917
|
+
}).join('');
|
|
918
|
+
}
|
|
919
|
+
function renderHeader() {
|
|
615
920
|
try {
|
|
616
921
|
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 ┐
|
|
638
922
|
const boxW = Math.max(10, headerBox.width - 2); // minus border chars
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
923
|
+
const logoPlain = ` ${LOGO_CHARS.join('')} `;
|
|
924
|
+
const animatedLogo = buildAnimatedLogo();
|
|
925
|
+
const logoTagged = ` ${animatedLogo.tagged} `;
|
|
926
|
+
const maxSessionLen = Math.max(6, boxW - logoPlain.length - 4);
|
|
927
|
+
const sessionLabel = sessionName.length > maxSessionLen
|
|
928
|
+
? `${sessionName.slice(0, Math.max(0, maxSessionLen - 1))}…`
|
|
929
|
+
: sessionName;
|
|
930
|
+
const sessionPlain = ` ${sessionLabel} `;
|
|
931
|
+
const sessionTagged = ` ${buildShinySessionName(sessionLabel)} `;
|
|
932
|
+
const rightGap = ' ';
|
|
933
|
+
const pad = Math.max(1, boxW - logoPlain.length - sessionPlain.length - rightGap.length);
|
|
934
|
+
const divider = '─'.repeat(pad);
|
|
935
|
+
// Keep a raw fallback for extremely narrow panes, but prefer animated label.
|
|
936
|
+
const fallbackLabel = (logoPlain + divider + sessionPlain + rightGap).slice(0, boxW);
|
|
937
|
+
if (fallbackLabel.length < logoPlain.length + 2) {
|
|
938
|
+
headerBox.setLabel(fallbackLabel);
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
headerBox.setLabel(logoTagged + divider + sessionTagged + rightGap);
|
|
647
942
|
}
|
|
943
|
+
// Message line inside the box (centered)
|
|
944
|
+
const fortuneRaw = (fortuneText && fortuneText.trim().length > 0)
|
|
945
|
+
? fortuneText.trim()
|
|
946
|
+
: 'Good luck.';
|
|
947
|
+
const truncateForWidth = (text, maxWidth) => {
|
|
948
|
+
if (maxWidth <= 0)
|
|
949
|
+
return '';
|
|
950
|
+
if (text.length <= maxWidth)
|
|
951
|
+
return text;
|
|
952
|
+
if (maxWidth <= 3)
|
|
953
|
+
return '.'.repeat(maxWidth);
|
|
954
|
+
return `${text.slice(0, maxWidth - 3)}...`;
|
|
955
|
+
};
|
|
956
|
+
const centerForWidth = (text, maxWidth) => {
|
|
957
|
+
const clipped = truncateForWidth(text, maxWidth);
|
|
958
|
+
const leftPad = Math.max(0, Math.floor((maxWidth - clipped.length) / 2));
|
|
959
|
+
return `${' '.repeat(leftPad)}${clipped}`;
|
|
960
|
+
};
|
|
961
|
+
const maxInner = Math.max(8, boxW - 1);
|
|
962
|
+
// Render through dedicated line-2 row, not generic box content flow.
|
|
963
|
+
headerBox.setContent('');
|
|
964
|
+
headerMessageRow.setContent(centerForWidth(fortuneRaw, maxInner));
|
|
648
965
|
screen.render();
|
|
649
966
|
}
|
|
650
967
|
catch { }
|
|
651
968
|
}
|
|
652
|
-
// Wave cycles every 200ms for smooth color sweep
|
|
653
|
-
function scheduleWave() {
|
|
654
|
-
sparkleTimer = setTimeout(() => {
|
|
655
|
-
renderLogoWave();
|
|
656
|
-
scheduleWave();
|
|
657
|
-
}, 200);
|
|
658
|
-
}
|
|
659
|
-
scheduleWave();
|
|
660
969
|
// ── Update function ──
|
|
661
|
-
let lastFileSize = 0;
|
|
662
|
-
let lastData = null;
|
|
663
|
-
let lastScrollPerc = 0; // Preserve scroll position across updates
|
|
664
970
|
const debugLog = path.join(os.homedir(), '.ekkos', 'dashboard.log');
|
|
665
971
|
function dlog(msg) {
|
|
666
972
|
try {
|
|
@@ -676,12 +982,20 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
676
982
|
const basename = path.basename(jsonlPath, '.jsonl');
|
|
677
983
|
// JSONL filename is the session UUID (e.g., 607bd8e4-0a04-4db2-acf5-3f794be0f956.jsonl)
|
|
678
984
|
if (/^[0-9a-f]{8}-/.test(basename)) {
|
|
679
|
-
sessionName = (
|
|
985
|
+
sessionName = displaySessionName(basename);
|
|
680
986
|
screen.title = `ekkOS - ${sessionName}`;
|
|
681
987
|
}
|
|
682
988
|
}
|
|
683
989
|
catch { }
|
|
684
990
|
}
|
|
991
|
+
// If we started with a UUID fallback, keep trying to resolve to the bound word session.
|
|
992
|
+
if (UUID_REGEX.test(sessionName)) {
|
|
993
|
+
const resolvedName = displaySessionName(sessionName);
|
|
994
|
+
if (resolvedName !== sessionName) {
|
|
995
|
+
sessionName = resolvedName;
|
|
996
|
+
screen.title = `ekkOS - ${sessionName}`;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
685
999
|
let data;
|
|
686
1000
|
try {
|
|
687
1001
|
const stat = fs.statSync(jsonlPath);
|
|
@@ -695,9 +1009,9 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
695
1009
|
dlog(`Parse error: ${err.message}`);
|
|
696
1010
|
return;
|
|
697
1011
|
}
|
|
698
|
-
// ── Header
|
|
1012
|
+
// ── Header ──
|
|
699
1013
|
try {
|
|
700
|
-
|
|
1014
|
+
renderHeader();
|
|
701
1015
|
}
|
|
702
1016
|
catch (err) {
|
|
703
1017
|
dlog(`Header: ${err.message}`);
|
|
@@ -729,7 +1043,10 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
729
1043
|
` {cyan-fg}Output{/cyan-fg} $${ou.toFixed(2)}\n` +
|
|
730
1044
|
` {${hitColor}-fg}${data.cacheHitRate.toFixed(0)}% cache{/${hitColor}-fg}` +
|
|
731
1045
|
` peak:${cappedMax.toFixed(0)}%` +
|
|
732
|
-
` avg:$${data.avgCostPerTurn.toFixed(2)}/t`
|
|
1046
|
+
` avg:$${data.avgCostPerTurn.toFixed(2)}/t` +
|
|
1047
|
+
` replay A:${data.replayAppliedCount}` +
|
|
1048
|
+
` SZ:${data.replaySkippedSizeCount}` +
|
|
1049
|
+
` ST:${data.replaySkipStoreCount}`);
|
|
733
1050
|
}
|
|
734
1051
|
catch (err) {
|
|
735
1052
|
dlog(`Context: ${err.message}`);
|
|
@@ -739,11 +1056,12 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
739
1056
|
const recent = data.turns.slice(-30);
|
|
740
1057
|
if (recent.length >= 2) {
|
|
741
1058
|
const x = recent.map(t => String(t.turn));
|
|
742
|
-
|
|
1059
|
+
lastChartSeries = [
|
|
743
1060
|
{ title: 'Rd', x, y: recent.map(t => Math.round(t.cacheRead / 1000)), style: { line: 'green' } },
|
|
744
1061
|
{ title: 'Wr', x, y: recent.map(t => Math.round(t.cacheCreate / 1000)), style: { line: 'yellow' } },
|
|
745
1062
|
{ title: 'Out', x, y: recent.map(t => Math.round(t.output / 1000)), style: { line: 'cyan' } },
|
|
746
|
-
]
|
|
1063
|
+
];
|
|
1064
|
+
tokenChart.setData(lastChartSeries);
|
|
747
1065
|
}
|
|
748
1066
|
}
|
|
749
1067
|
catch (err) {
|
|
@@ -753,9 +1071,10 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
753
1071
|
try {
|
|
754
1072
|
// Preserve scroll position BEFORE updating content
|
|
755
1073
|
lastScrollPerc = turnBox.getScrollPerc();
|
|
756
|
-
// Account for borders + scrollbar gutter
|
|
757
|
-
|
|
758
|
-
const
|
|
1074
|
+
// Account for borders + scrollbar gutter. Windows terminal rendering can
|
|
1075
|
+
// wrap by one char if this is too tight, which pushes Cost to next line.
|
|
1076
|
+
const w = Math.max(18, turnBox.width - 4); // usable content width
|
|
1077
|
+
const div = '│';
|
|
759
1078
|
function pad(s, width) {
|
|
760
1079
|
if (s.length >= width)
|
|
761
1080
|
return s.slice(0, width);
|
|
@@ -766,6 +1085,14 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
766
1085
|
return s.slice(0, width);
|
|
767
1086
|
return ' '.repeat(width - s.length) + s;
|
|
768
1087
|
}
|
|
1088
|
+
function cpad(s, width) {
|
|
1089
|
+
if (s.length >= width)
|
|
1090
|
+
return s.slice(0, width);
|
|
1091
|
+
const total = width - s.length;
|
|
1092
|
+
const left = Math.floor(total / 2);
|
|
1093
|
+
const right = total - left;
|
|
1094
|
+
return ' '.repeat(left) + s + ' '.repeat(right);
|
|
1095
|
+
}
|
|
769
1096
|
// Data rows — RENDER ALL TURNS for full scrollback history
|
|
770
1097
|
// Don't slice to visibleRows only — let user scroll through entire session
|
|
771
1098
|
const turns = data.turns.slice().reverse();
|
|
@@ -773,28 +1100,28 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
773
1100
|
let separator = '';
|
|
774
1101
|
let rows = [];
|
|
775
1102
|
// Responsive table layouts keep headers visible even in very narrow panes.
|
|
776
|
-
if (w >=
|
|
1103
|
+
if (w >= 60) {
|
|
777
1104
|
// Full mode: Turn, Model, Context, Cache Rd, Cache Wr, Output, Cost
|
|
778
1105
|
const colNum = 4;
|
|
779
|
-
const colM =
|
|
780
|
-
const colCtx =
|
|
781
|
-
const colCost =
|
|
1106
|
+
const colM = 7;
|
|
1107
|
+
const colCtx = 7;
|
|
1108
|
+
const colCost = 8;
|
|
782
1109
|
const nDividers = 6;
|
|
783
1110
|
const fixedW = colNum + colM + colCtx + colCost;
|
|
784
1111
|
const flexTotal = w - fixedW - nDividers;
|
|
785
|
-
const rdW = Math.max(
|
|
786
|
-
const wrW = Math.max(
|
|
787
|
-
const outW = Math.max(
|
|
788
|
-
header =
|
|
789
|
-
separator =
|
|
1112
|
+
const rdW = Math.max(10, Math.floor(flexTotal * 0.35));
|
|
1113
|
+
const wrW = Math.max(11, Math.floor(flexTotal * 0.30));
|
|
1114
|
+
const outW = Math.max(6, flexTotal - rdW - wrW);
|
|
1115
|
+
header = `${pad('Turn', colNum)}${div}${rpad('Model', colM)}${div}${rpad('Contex', colCtx)}${div}${rpad('Cache Read', rdW)}${div}${rpad('Cache Write', wrW)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}`;
|
|
1116
|
+
separator = `${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(rdW)}┼${'─'.repeat(wrW)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}`;
|
|
790
1117
|
rows = turns.map(t => {
|
|
791
1118
|
const mTag = modelTag(t.routedModel);
|
|
792
1119
|
const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
|
|
793
1120
|
const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
|
|
794
1121
|
const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
|
|
795
1122
|
return (pad(String(t.turn), colNum) + div +
|
|
796
|
-
`{${mColor}-fg}${
|
|
797
|
-
|
|
1123
|
+
`{${mColor}-fg}${cpad(mTag, colM)}{/${mColor}-fg}` + div +
|
|
1124
|
+
rpad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
|
|
798
1125
|
`{green-fg}${rpad(fmtK(t.cacheRead), rdW)}{/green-fg}` + div +
|
|
799
1126
|
`{yellow-fg}${rpad(fmtK(t.cacheCreate), wrW)}{/yellow-fg}` + div +
|
|
800
1127
|
`{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
|
|
@@ -809,16 +1136,16 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
809
1136
|
const colCost = 6;
|
|
810
1137
|
const nDividers = 4;
|
|
811
1138
|
const outW = Math.max(4, w - (colNum + colM + colCtx + colCost + nDividers));
|
|
812
|
-
header =
|
|
813
|
-
separator =
|
|
1139
|
+
header = `${pad('Turn', colNum)}${div}${rpad('Model', colM)}${div}${rpad('Context', colCtx)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}`;
|
|
1140
|
+
separator = `${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}`;
|
|
814
1141
|
rows = turns.map(t => {
|
|
815
1142
|
const mTag = modelTag(t.routedModel);
|
|
816
1143
|
const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
|
|
817
1144
|
const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
|
|
818
1145
|
const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
|
|
819
1146
|
return (pad(String(t.turn), colNum) + div +
|
|
820
|
-
`{${mColor}-fg}${
|
|
821
|
-
|
|
1147
|
+
`{${mColor}-fg}${cpad(mTag, colM)}{/${mColor}-fg}` + div +
|
|
1148
|
+
rpad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
|
|
822
1149
|
`{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
|
|
823
1150
|
costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
|
|
824
1151
|
});
|
|
@@ -828,13 +1155,13 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
828
1155
|
const colNum = 4;
|
|
829
1156
|
const colCtx = 6;
|
|
830
1157
|
const colCost = 6;
|
|
831
|
-
header =
|
|
832
|
-
separator =
|
|
1158
|
+
header = `${pad('Turn', colNum)}${div}${rpad('Context', colCtx)}${div}${rpad('Cost', colCost)}`;
|
|
1159
|
+
separator = `${'─'.repeat(colNum)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(colCost)}`;
|
|
833
1160
|
rows = turns.map(t => {
|
|
834
1161
|
const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
|
|
835
1162
|
const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
|
|
836
1163
|
return (pad(String(t.turn), colNum) + div +
|
|
837
|
-
|
|
1164
|
+
rpad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
|
|
838
1165
|
costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
|
|
839
1166
|
});
|
|
840
1167
|
}
|
|
@@ -865,11 +1192,15 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
865
1192
|
const savingsStr = totalSavings > 0
|
|
866
1193
|
? ` {green-fg}saved $${totalSavings.toFixed(2)}{/green-fg}`
|
|
867
1194
|
: '';
|
|
1195
|
+
footerBox.setLabel(` ${sessionName} `);
|
|
868
1196
|
footerBox.setContent(` {green-fg}$${data.totalCost.toFixed(2)}{/green-fg}` +
|
|
869
1197
|
` ${totalTokensM}M` +
|
|
870
1198
|
` ${routingStr}` +
|
|
1199
|
+
` R[A:${data.replayAppliedCount} SZ:${data.replaySkippedSizeCount} ST:${data.replaySkipStoreCount}]` +
|
|
871
1200
|
savingsStr +
|
|
872
|
-
|
|
1201
|
+
(inTmux
|
|
1202
|
+
? ` {gray-fg}Ctrl+C quit{/gray-fg}`
|
|
1203
|
+
: ` {gray-fg}? help q quit r refresh{/gray-fg}`));
|
|
873
1204
|
}
|
|
874
1205
|
catch (err) {
|
|
875
1206
|
dlog(`Footer: ${err.message}`);
|
|
@@ -889,10 +1220,20 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
889
1220
|
*/
|
|
890
1221
|
async function fetchAnthropicUsage() {
|
|
891
1222
|
try {
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1223
|
+
let token = null;
|
|
1224
|
+
if (process.platform === 'darwin') {
|
|
1225
|
+
const { execSync } = require('child_process');
|
|
1226
|
+
const credsJson = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
1227
|
+
token = JSON.parse(credsJson)?.claudeAiOauth?.accessToken ?? null;
|
|
1228
|
+
}
|
|
1229
|
+
else if (process.platform === 'win32') {
|
|
1230
|
+
// Windows: Claude Code stores credentials in ~/.claude/.credentials.json
|
|
1231
|
+
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
1232
|
+
if (fs.existsSync(credsPath)) {
|
|
1233
|
+
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
|
|
1234
|
+
token = creds?.claudeAiOauth?.accessToken ?? null;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
896
1237
|
if (!token)
|
|
897
1238
|
return null;
|
|
898
1239
|
const resp = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
|
@@ -958,10 +1299,19 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
958
1299
|
screen.on('resize', () => {
|
|
959
1300
|
try {
|
|
960
1301
|
ensureLayoutSynced();
|
|
961
|
-
if (lastData)
|
|
1302
|
+
if (lastData) {
|
|
962
1303
|
updateDashboard();
|
|
963
|
-
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
// Even without data, re-apply chart series so the canvas redraws at new size
|
|
1307
|
+
if (lastChartSeries) {
|
|
1308
|
+
try {
|
|
1309
|
+
tokenChart.setData(lastChartSeries);
|
|
1310
|
+
}
|
|
1311
|
+
catch { }
|
|
1312
|
+
}
|
|
964
1313
|
screen.render();
|
|
1314
|
+
}
|
|
965
1315
|
}
|
|
966
1316
|
catch (err) {
|
|
967
1317
|
dlog(`Resize: ${err.message}`);
|
|
@@ -971,88 +1321,101 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
971
1321
|
// KEYBOARD SHORTCUTS - Only capture when dashboard pane has focus
|
|
972
1322
|
// In tmux split mode, this prevents capturing keys from Claude Code pane
|
|
973
1323
|
// ══════════════════════════════════════════════════════════════════════════
|
|
974
|
-
screen.key(['
|
|
1324
|
+
screen.key(['C-c'], () => {
|
|
975
1325
|
clearInterval(pollInterval);
|
|
976
1326
|
clearInterval(windowPollInterval);
|
|
977
|
-
|
|
1327
|
+
clearInterval(headerAnimInterval);
|
|
1328
|
+
clearInterval(fortuneInterval);
|
|
978
1329
|
screen.destroy();
|
|
979
1330
|
process.exit(0);
|
|
980
1331
|
});
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1332
|
+
if (!inTmux) {
|
|
1333
|
+
screen.key(['q'], () => {
|
|
1334
|
+
clearInterval(pollInterval);
|
|
1335
|
+
clearInterval(windowPollInterval);
|
|
1336
|
+
clearInterval(headerAnimInterval);
|
|
1337
|
+
clearInterval(fortuneInterval);
|
|
1338
|
+
screen.destroy();
|
|
1339
|
+
process.exit(0);
|
|
1340
|
+
});
|
|
1341
|
+
screen.key(['r'], () => {
|
|
1342
|
+
lastFileSize = 0;
|
|
1343
|
+
updateDashboard();
|
|
1344
|
+
updateWindowBox();
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
986
1347
|
// ══════════════════════════════════════════════════════════════════════════
|
|
987
1348
|
// FOCUS MANAGEMENT: In tmux split mode, DON'T auto-focus the turnBox
|
|
988
1349
|
// This prevents the dashboard from stealing focus from Claude Code on startup
|
|
989
1350
|
// User can manually focus by clicking into the dashboard pane
|
|
990
1351
|
// ══════════════════════════════════════════════════════════════════════════
|
|
991
|
-
// Check if we're in a tmux session
|
|
992
|
-
const inTmux = process.env.TMUX !== undefined;
|
|
993
1352
|
if (!inTmux) {
|
|
994
1353
|
// Only auto-focus when running standalone (not in tmux split)
|
|
995
1354
|
turnBox.focus();
|
|
996
1355
|
}
|
|
997
1356
|
// Scroll controls for turn table
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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,
|
|
1357
|
+
if (!inTmux) {
|
|
1358
|
+
screen.key(['up', 'k'], () => {
|
|
1359
|
+
turnBox.scroll(-1);
|
|
1360
|
+
screen.render();
|
|
1045
1361
|
});
|
|
1046
|
-
screen.
|
|
1047
|
-
|
|
1048
|
-
// Close on any key press
|
|
1049
|
-
const closeHelp = () => {
|
|
1050
|
-
help.destroy();
|
|
1362
|
+
screen.key(['down', 'j'], () => {
|
|
1363
|
+
turnBox.scroll(1);
|
|
1051
1364
|
screen.render();
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1365
|
+
});
|
|
1366
|
+
screen.key(['pageup', 'u'], () => {
|
|
1367
|
+
turnBox.scroll(-(turnBox.height - 2));
|
|
1368
|
+
screen.render();
|
|
1369
|
+
});
|
|
1370
|
+
screen.key(['pagedown', 'd'], () => {
|
|
1371
|
+
turnBox.scroll((turnBox.height - 2));
|
|
1372
|
+
screen.render();
|
|
1373
|
+
});
|
|
1374
|
+
screen.key(['home', 'g'], () => {
|
|
1375
|
+
turnBox.setScrollPerc(0);
|
|
1376
|
+
screen.render();
|
|
1377
|
+
});
|
|
1378
|
+
screen.key(['end', 'G'], () => {
|
|
1379
|
+
turnBox.setScrollPerc(100);
|
|
1380
|
+
screen.render();
|
|
1381
|
+
});
|
|
1382
|
+
screen.key(['?', 'h'], () => {
|
|
1383
|
+
// Quick help overlay
|
|
1384
|
+
const help = blessed.box({
|
|
1385
|
+
top: 'center',
|
|
1386
|
+
left: 'center',
|
|
1387
|
+
width: 50,
|
|
1388
|
+
height: 16,
|
|
1389
|
+
content: ('{bold}Navigation{/bold}\n' +
|
|
1390
|
+
' ↑/k/j/↓ Scroll line\n' +
|
|
1391
|
+
' PgUp/u Scroll page up\n' +
|
|
1392
|
+
' PgDn/d Scroll page down\n' +
|
|
1393
|
+
' g/Home Scroll to top\n' +
|
|
1394
|
+
' G/End Scroll to bottom\n' +
|
|
1395
|
+
'\n' +
|
|
1396
|
+
'{bold}Controls{/bold}\n' +
|
|
1397
|
+
' r Refresh now\n' +
|
|
1398
|
+
' q/Ctrl+C Quit\n' +
|
|
1399
|
+
'\n' +
|
|
1400
|
+
'{gray-fg}Press any key to close{/gray-fg}'),
|
|
1401
|
+
tags: true,
|
|
1402
|
+
border: 'line',
|
|
1403
|
+
style: { border: { fg: 'cyan' } },
|
|
1404
|
+
padding: 1,
|
|
1405
|
+
});
|
|
1406
|
+
screen.append(help);
|
|
1407
|
+
screen.render();
|
|
1408
|
+
// Defer listener so the '?' keypress that opened help doesn't immediately close it
|
|
1409
|
+
setImmediate(() => {
|
|
1410
|
+
const closeHelp = () => {
|
|
1411
|
+
help.destroy();
|
|
1412
|
+
screen.render();
|
|
1413
|
+
screen.removeListener('key', closeHelp);
|
|
1414
|
+
};
|
|
1415
|
+
screen.once('key', closeHelp);
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1056
1419
|
// Clear terminal buffer — prevents garbage text from previous commands
|
|
1057
1420
|
screen.program.clear();
|
|
1058
1421
|
// Dashboard is fully passive — no widget captures keyboard input
|
|
@@ -1061,6 +1424,18 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
|
|
|
1061
1424
|
// Delay first ccusage call — let blessed render first, then load heavy data
|
|
1062
1425
|
setTimeout(() => updateWindowBox(), 2000);
|
|
1063
1426
|
const pollInterval = setInterval(updateDashboard, refreshMs);
|
|
1427
|
+
const headerAnimInterval = setInterval(() => {
|
|
1428
|
+
// Keep advancing across the full session label; wrap at a large value.
|
|
1429
|
+
waveOffset = (waveOffset + 1) % 1000000;
|
|
1430
|
+
renderHeader();
|
|
1431
|
+
}, 500);
|
|
1432
|
+
const fortuneInterval = setInterval(() => {
|
|
1433
|
+
if (GOOD_LUCK_FORTUNES.length === 0)
|
|
1434
|
+
return;
|
|
1435
|
+
fortuneIdx = (fortuneIdx + 1) % GOOD_LUCK_FORTUNES.length;
|
|
1436
|
+
fortuneText = GOOD_LUCK_FORTUNES[fortuneIdx];
|
|
1437
|
+
renderHeader();
|
|
1438
|
+
}, 30000);
|
|
1064
1439
|
const windowPollInterval = setInterval(updateWindowBox, 15000); // every 15s
|
|
1065
1440
|
}
|
|
1066
1441
|
// ── Helpers ──
|