@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.
Files changed (44) hide show
  1. package/dist/capture/jsonl-rewriter.js +72 -7
  2. package/dist/commands/dashboard.js +186 -557
  3. package/dist/commands/init.js +3 -15
  4. package/dist/commands/run.js +222 -256
  5. package/dist/commands/setup.js +0 -47
  6. package/dist/commands/swarm-dashboard.js +4 -13
  7. package/dist/deploy/instructions.d.ts +2 -5
  8. package/dist/deploy/instructions.js +8 -11
  9. package/dist/deploy/settings.js +21 -15
  10. package/dist/deploy/skills.d.ts +0 -8
  11. package/dist/deploy/skills.js +0 -26
  12. package/dist/index.js +2 -2
  13. package/dist/lib/usage-parser.js +1 -2
  14. package/dist/utils/platform.d.ts +0 -3
  15. package/dist/utils/platform.js +1 -4
  16. package/dist/utils/session-binding.d.ts +1 -1
  17. package/dist/utils/session-binding.js +2 -3
  18. package/package.json +1 -1
  19. package/templates/agents/README.md +182 -0
  20. package/templates/agents/code-reviewer.md +166 -0
  21. package/templates/agents/debug-detective.md +169 -0
  22. package/templates/agents/ekkOS_Vercel.md +99 -0
  23. package/templates/agents/extension-manager.md +229 -0
  24. package/templates/agents/git-companion.md +185 -0
  25. package/templates/agents/github-test-agent.md +321 -0
  26. package/templates/agents/railway-manager.md +179 -0
  27. package/templates/hooks/assistant-response.ps1 +26 -94
  28. package/templates/hooks/lib/count-tokens.cjs +0 -0
  29. package/templates/hooks/lib/ekkos-reminders.sh +0 -0
  30. package/templates/hooks/session-start.ps1 +224 -61
  31. package/templates/hooks/session-start.sh +1 -1
  32. package/templates/hooks/stop.ps1 +249 -103
  33. package/templates/hooks/stop.sh +1 -1
  34. package/templates/hooks/user-prompt-submit.ps1 +519 -129
  35. package/templates/hooks/user-prompt-submit.sh +2 -2
  36. package/templates/plan-template.md +0 -0
  37. package/templates/spec-template.md +0 -0
  38. package/templates/windsurf-hooks/before-submit-prompt.sh +238 -0
  39. package/templates/windsurf-hooks/install.sh +0 -0
  40. package/templates/windsurf-hooks/lib/contract.sh +0 -0
  41. package/templates/windsurf-hooks/post-cascade-response.sh +0 -0
  42. package/templates/windsurf-hooks/pre-user-prompt.sh +0 -0
  43. package/templates/windsurf-skills/ekkos-memory/SKILL.md +219 -0
  44. 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
- // 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)
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 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;
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 || path.basename(latestJsonl, '.jsonl');
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 = displaySessionName(initialSessionName);
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(8, Math.floor(remaining * 0.40));
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 40% of remaining)
752
- function createTokenChart(top, left, width, height) {
753
- return contrib.line({
754
- top, left, width, height,
755
- label: ' Tokens/Turn (K) ',
756
- showLegend: true,
757
- legend: { width: 8 },
758
- style: {
759
- line: 'green',
760
- text: 'white',
761
- baseline: 'white',
762
- border: { fg: 'cyan' },
763
- },
764
- border: { type: 'line', fg: 'cyan' },
765
- xLabelPadding: 0,
766
- xPadding: 1,
767
- wholeNumbersOnly: false,
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: !inTmux, // In tmux split mode keep dashboard passive
782
- vi: !inTmux, // Avoid single-key handlers interfering with paste
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: !inTmux,
785
- interactive: !inTmux, // Standalone only; passive in tmux split
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
- // blessed-contrib line can keep a stale tiny canvas when terminals report
830
- // initial dimensions incorrectly (observed in Windows Terminal). Rebuild
831
- // the chart widget whenever dimensions change so the plot fills the panel.
832
- if (chartLayoutW !== contentWidth || chartLayoutH !== layout.chart.height) {
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
- // ── Header render (animated logo in border label + compact stats content) ──
611
+ // ── Logo color wave animation ──
891
612
  let waveOffset = 0;
892
- function buildAnimatedLogo() {
893
- const raw = LOGO_CHARS.join('');
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 logoPlain = ` ${LOGO_CHARS.join('')} `;
920
- const animatedLogo = buildAnimatedLogo();
921
- const logoTagged = ` ${animatedLogo.tagged} `;
922
- const maxSessionLen = Math.max(6, boxW - logoPlain.length - 4);
923
- const sessionLabel = sessionName.length > maxSessionLen
924
- ? `${sessionName.slice(0, Math.max(0, maxSessionLen - 1))}…`
925
- : sessionName;
926
- const sessionPlain = ` ${sessionLabel} `;
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 = displaySessionName(basename);
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
- renderHeader();
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
- lastChartSeries = [
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. Windows terminal rendering can
1071
- // wrap by one char if this is too tight, which pushes Cost to next line.
1072
- const w = Math.max(18, turnBox.width - 4); // usable content width
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 >= 60) {
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 = 7;
1103
- const colCtx = 7;
1104
- const colCost = 8;
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(10, Math.floor(flexTotal * 0.35));
1109
- const wrW = Math.max(11, Math.floor(flexTotal * 0.30));
1110
- const outW = Math.max(6, flexTotal - rdW - wrW);
1111
- 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)}`;
1112
- separator = `${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(rdW)}┼${'─'.repeat(wrW)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}`;
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}${cpad(mTag, colM)}{/${mColor}-fg}` + div +
1120
- rpad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
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 = `${pad('Turn', colNum)}${div}${rpad('Model', colM)}${div}${rpad('Context', colCtx)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}`;
1136
- separator = `${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}`;
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}${cpad(mTag, colM)}{/${mColor}-fg}` + div +
1144
- rpad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
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 = `${pad('Turn', colNum)}${div}${rpad('Context', colCtx)}${div}${rpad('Cost', colCost)}`;
1155
- separator = `${'─'.repeat(colNum)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(colCost)}`;
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
- rpad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
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
- (inTmux
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
- let token = null;
1220
- if (process.platform === 'darwin') {
1221
- const { execSync } = require('child_process');
1222
- const credsJson = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf-8', timeout: 5000 }).trim();
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
- clearInterval(headerAnimInterval);
1324
- clearInterval(fortuneInterval);
977
+ clearTimeout(sparkleTimer);
1325
978
  screen.destroy();
1326
979
  process.exit(0);
1327
980
  });
1328
- if (!inTmux) {
1329
- screen.key(['q'], () => {
1330
- clearInterval(pollInterval);
1331
- clearInterval(windowPollInterval);
1332
- clearInterval(headerAnimInterval);
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
- if (!inTmux) {
1354
- screen.key(['up', 'k'], () => {
1355
- turnBox.scroll(-1);
1356
- screen.render();
1357
- });
1358
- screen.key(['down', 'j'], () => {
1359
- turnBox.scroll(1);
1360
- screen.render();
1361
- });
1362
- screen.key(['pageup', 'u'], () => {
1363
- turnBox.scroll(-(turnBox.height - 2));
1364
- screen.render();
1365
- });
1366
- screen.key(['pagedown', 'd'], () => {
1367
- turnBox.scroll((turnBox.height - 2));
1368
- screen.render();
1369
- });
1370
- screen.key(['home', 'g'], () => {
1371
- turnBox.setScrollPerc(0);
1372
- screen.render();
1373
- });
1374
- screen.key(['end', 'G'], () => {
1375
- turnBox.setScrollPerc(100);
1376
- screen.render();
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.key(['?', 'h'], () => {
1379
- // Quick help overlay
1380
- const help = blessed.box({
1381
- top: 'center',
1382
- left: 'center',
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
- // Defer listener so the '?' keypress that opened help doesn't immediately close it
1405
- setImmediate(() => {
1406
- const closeHelp = () => {
1407
- help.destroy();
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 ──