@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.
Files changed (31) hide show
  1. package/README.md +57 -0
  2. package/dist/commands/dashboard.js +561 -186
  3. package/dist/commands/run.js +1 -1
  4. package/dist/deploy/settings.js +13 -26
  5. package/package.json +1 -1
  6. package/templates/hooks/assistant-response.ps1 +94 -26
  7. package/templates/hooks/hooks.json +24 -12
  8. package/templates/hooks/lib/count-tokens.cjs +0 -0
  9. package/templates/hooks/lib/ekkos-reminders.sh +0 -0
  10. package/templates/hooks/session-start.ps1 +61 -224
  11. package/templates/hooks/session-start.sh +1 -1
  12. package/templates/hooks/stop.ps1 +103 -249
  13. package/templates/hooks/stop.sh +1 -1
  14. package/templates/hooks/user-prompt-submit.ps1 +129 -519
  15. package/templates/hooks/user-prompt-submit.sh +2 -2
  16. package/templates/plan-template.md +0 -0
  17. package/templates/spec-template.md +0 -0
  18. package/templates/windsurf-hooks/install.sh +0 -0
  19. package/templates/windsurf-hooks/lib/contract.sh +0 -0
  20. package/templates/windsurf-hooks/post-cascade-response.sh +0 -0
  21. package/templates/windsurf-hooks/pre-user-prompt.sh +0 -0
  22. package/templates/agents/README.md +0 -182
  23. package/templates/agents/code-reviewer.md +0 -166
  24. package/templates/agents/debug-detective.md +0 -169
  25. package/templates/agents/ekkOS_Vercel.md +0 -99
  26. package/templates/agents/extension-manager.md +0 -229
  27. package/templates/agents/git-companion.md +0 -185
  28. package/templates/agents/github-test-agent.md +0 -321
  29. package/templates/agents/railway-manager.md +0 -179
  30. package/templates/windsurf-hooks/before-submit-prompt.sh +0 -238
  31. 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
- const encoded = projectPath.replace(/\//g, '-');
271
- const projectDir = path.join(os.homedir(), '.claude', 'projects', encoded);
272
- if (!fs.existsSync(projectDir))
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 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;
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 || 'session';
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(4, Math.floor(remaining * 0.30));
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 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
- });
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: true, // Enable keyboard scrolling
531
- vi: true, // Enable vi-style keys (j/k for scroll)
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: true,
534
- interactive: true, // Make box interactive for scrolling
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
- tokenChart.top = layout.chart.top;
579
- tokenChart.left = H_PAD;
580
- tokenChart.width = contentWidth;
581
- tokenChart.height = layout.chart.height;
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
- // ── Logo color wave animation ──
894
+ // ── Header render (animated logo in border label + compact stats content) ──
612
895
  let waveOffset = 0;
613
- let sparkleTimer;
614
- function renderLogoWave() {
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 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}`);
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 = (0, state_js_1.uuidToWords)(basename);
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 — wave animation handles rendering, just trigger a frame ──
1012
+ // ── Header ──
699
1013
  try {
700
- renderLogoWave();
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
- tokenChart.setData([
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 + 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
+ // 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 >= 44) {
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 = 6;
780
- const colCtx = 6;
781
- const colCost = 6;
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(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}`;
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}${pad(mTag, colM)}{/${mColor}-fg}` + div +
797
- pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
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 = `{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}`;
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}${pad(mTag, colM)}{/${mColor}-fg}` + div +
821
- pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
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 = `{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}`;
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
- pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
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
- ` {gray-fg}? help q quit r refresh{/gray-fg}`);
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
- 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;
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
- else
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(['q', 'C-c'], () => {
1324
+ screen.key(['C-c'], () => {
975
1325
  clearInterval(pollInterval);
976
1326
  clearInterval(windowPollInterval);
977
- clearTimeout(sparkleTimer);
1327
+ clearInterval(headerAnimInterval);
1328
+ clearInterval(fortuneInterval);
978
1329
  screen.destroy();
979
1330
  process.exit(0);
980
1331
  });
981
- screen.key(['r'], () => {
982
- lastFileSize = 0;
983
- updateDashboard();
984
- updateWindowBox();
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
- 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,
1357
+ if (!inTmux) {
1358
+ screen.key(['up', 'k'], () => {
1359
+ turnBox.scroll(-1);
1360
+ screen.render();
1045
1361
  });
1046
- screen.append(help);
1047
- screen.render();
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
- screen.removeListener('key', closeHelp);
1053
- };
1054
- screen.on('key', closeHelp);
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 ──